diff --git a/devtools_options.yaml b/devtools_options.yaml deleted file mode 100644 index 7e7e7f67..00000000 --- a/devtools_options.yaml +++ /dev/null @@ -1 +0,0 @@ -extensions: diff --git a/distribute_options.yaml b/distribute_options.yaml deleted file mode 100644 index 0f5d29ab..00000000 --- a/distribute_options.yaml +++ /dev/null @@ -1,13 +0,0 @@ -variables: -output: dist/ -releases: - - name: linux - jobs: - - name: release-linux-deb - package: - platform: linux - target: deb - - name: release-linux-rpm - package: - platform: linux - target: rpm diff --git a/lib/app.dart b/lib/app.dart index 4591692b..141bc57a 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -96,20 +96,25 @@ class MyApp extends StatelessWidget { themeMode: themeMode, theme: light.fixWindowsFont, darkTheme: (tMode < 3 ? dark : dark.toAmoled).fixWindowsFont, - home: Builder( - builder: (context) { + home: FutureBuilder>( + future: _IntroPage.builders, + builder: (context, snapshot) { context.setLibL10n(); final appL10n = AppLocalizations.of(context); if (appL10n != null) l10n = appL10n; Widget child; - final intros = _IntroPage.builders; - if (intros.isNotEmpty) { - child = _IntroPage(intros); + if (snapshot.connectionState == ConnectionState.waiting) { + child = const Scaffold(body: Center(child: CircularProgressIndicator())); + } else { + final intros = snapshot.data ?? []; + if (intros.isNotEmpty) { + child = _IntroPage(intros); + } else { + child = const HomePage(); + } } - child = const HomePage(); - return VirtualWindowFrame(title: BuildData.name, child: child); }, ), diff --git a/lib/core/sync.dart b/lib/core/sync.dart index c717b76b..1b0fc694 100644 --- a/lib/core/sync.dart +++ b/lib/core/sync.dart @@ -12,12 +12,28 @@ final class BakSyncer extends SyncIface { const BakSyncer._() : super(); @override - Future saveToFile() => BackupV2.backup(); + Future saveToFile() async { + final pwd = await SecureStoreProps.bakPwd.read(); + if (pwd == null || pwd.isEmpty) { + // Enforce password for non-clipboard backups + throw Exception('Backup password not set'); + } + await BackupV2.backup(null, pwd); + } @override Future fromFile(String path) async { final content = await File(path).readAsString(); - return MergeableUtils.fromJsonString(content).$1; + final pwd = await SecureStoreProps.bakPwd.read(); + try { + if (Cryptor.isEncrypted(content)) { + return MergeableUtils.fromJsonString(content, pwd).$1; + } + return MergeableUtils.fromJsonString(content).$1; + } catch (_) { + // Fallback: try without password if detection failed + return MergeableUtils.fromJsonString(content).$1; + } } @override @@ -28,6 +44,9 @@ final class BakSyncer extends SyncIface { final webdavEnabled = PrefProps.webdavSync.get(); if (webdavEnabled) return Webdav.shared; + final gistEnabled = PrefProps.gistSync.get(); + if (gistEnabled) return GistRs.shared; + return null; } } diff --git a/lib/data/helper/system_detector.dart b/lib/data/helper/system_detector.dart index 4f9d0d9e..ffd0fccc 100644 --- a/lib/data/helper/system_detector.dart +++ b/lib/data/helper/system_detector.dart @@ -6,17 +6,14 @@ import 'package:server_box/data/model/server/system.dart'; /// Helper class for detecting remote system types class SystemDetector { /// Detects the system type of a remote server - /// + /// /// First checks if a custom system type is configured in [spi]. /// If not, attempts to detect the system by running commands: /// 1. 'ver' command to detect Windows /// 2. 'uname -a' command to detect Linux/BSD/Darwin - /// + /// /// Returns [SystemType.linux] as default if detection fails. - static Future detect( - SSHClient client, - Spi spi, - ) async { + static Future detect(SSHClient client, Spi spi) async { // First, check if custom system type is defined SystemType? detectedSystemType = spi.customSystemType; if (detectedSystemType != null) { @@ -54,4 +51,4 @@ class SystemDetector { dprint('Defaulting to Linux system type for ${spi.oldId}'); return detectedSystemType; } -} \ No newline at end of file +} diff --git a/lib/data/model/app/bak/backup_service.dart b/lib/data/model/app/bak/backup_service.dart index fd31bcc8..1a6e0bac 100644 --- a/lib/data/model/app/bak/backup_service.dart +++ b/lib/data/model/app/bak/backup_service.dart @@ -5,20 +5,35 @@ import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/data/model/app/bak/backup2.dart'; import 'package:server_box/data/model/app/bak/backup_source.dart'; import 'package:server_box/data/model/app/bak/utils.dart'; -import 'package:server_box/data/res/store.dart'; /// Service class for handling backup operations class BackupService { /// Perform backup operation with the given source static Future backup(BuildContext context, BackupSource source) async { - final password = await _getBackupPassword(context); - if (password == null) return; - try { + String? password; + + if (source is ClipboardBackupSource) { + // Clipboard backup: allow optional password + password = await _getClipboardPassword(context); + if (password == null) return; // canceled + } else { + // All other backups require pre-set bakPwd (SecureStore) + final saved = await SecureStoreProps.bakPwd.read(); + if (saved == null || saved.isEmpty) { + // Prompt to set before proceeding + password = await _showPasswordDialog(context, hint: l10n.backupPasswordTip); + if (password == null || password.isEmpty) return; // Not set + await SecureStoreProps.bakPwd.write(password); + context.showSnackBar(l10n.backupPasswordSet); + } else { + password = saved; + } + } + final path = await BackupV2.backup(null, password.isEmpty ? null : password); await source.saveContent(path); - // Show success message for clipboard source if (source is ClipboardBackupSource) { context.showSnackBar(libL10n.success); } @@ -42,12 +57,12 @@ class BackupService { } /// Handle password dialog for backup operations - static Future _getBackupPassword(BuildContext context) async { - final savedPassword = await Stores.setting.backupasswd.read(); + static Future _getClipboardPassword(BuildContext context) async { + // Use saved bakPwd as default for clipboard flow if exists, but allow empty/custom + final savedPassword = await SecureStoreProps.bakPwd.read(); String? password; if (savedPassword != null && savedPassword.isNotEmpty) { - // Use saved password or ask for custom password final useCustom = await context.showRoundDialog( title: l10n.backupPassword, child: Text(l10n.backupPasswordTip), @@ -57,19 +72,15 @@ class BackupService { TextButton(onPressed: () => context.pop(true), child: Text(libL10n.custom)), ], ); - if (useCustom == null) return null; - if (useCustom) { password = await _showPasswordDialog(context, initial: savedPassword); } else { password = savedPassword; } } else { - // No saved password, ask if user wants to set one password = await _showPasswordDialog(context); } - return password; } @@ -95,7 +106,7 @@ class BackupService { } // Try with saved password first - final savedPassword = await Stores.setting.backupasswd.read(); + final savedPassword = await SecureStoreProps.bakPwd.read(); if (savedPassword != null && savedPassword.isNotEmpty) { try { final (backup, err) = await context.showLoadingDialog( diff --git a/lib/data/model/app/scripts/script_builders.dart b/lib/data/model/app/scripts/script_builders.dart index 4271f7b7..6a3ca55b 100644 --- a/lib/data/model/app/scripts/script_builders.dart +++ b/lib/data/model/app/scripts/script_builders.dart @@ -196,10 +196,14 @@ esac'''); /// Get Unix status command with OS detection String _getUnixStatusCommand({required List disabledCmdTypes}) { // Generate command lists with command-specific separators, filtering disabled commands - final filteredLinuxCmdTypes = StatusCmdType.values.where((e) => !disabledCmdTypes.contains(e.displayName)); + final filteredLinuxCmdTypes = StatusCmdType.values.where( + (e) => !disabledCmdTypes.contains(e.displayName), + ); final linuxCommands = filteredLinuxCmdTypes.map((e) => '${e.divider}${e.cmd}').join('').trimRight(); - final filteredBsdCmdTypes = BSDStatusCmdType.values.where((e) => !disabledCmdTypes.contains(e.displayName)); + final filteredBsdCmdTypes = BSDStatusCmdType.values.where( + (e) => !disabledCmdTypes.contains(e.displayName), + ); final bsdCommands = filteredBsdCmdTypes.map((e) => '${e.divider}${e.cmd}').join('').trimRight(); return ''' diff --git a/lib/data/model/app/scripts/script_consts.dart b/lib/data/model/app/scripts/script_consts.dart index a193f5db..a8f920f8 100644 --- a/lib/data/model/app/scripts/script_consts.dart +++ b/lib/data/model/app/scripts/script_consts.dart @@ -32,14 +32,14 @@ class ScriptConstants { /// Parse script output into command-specific map static Map parseScriptOutput(String raw) { final result = {}; - + if (raw.isEmpty) return result; - + // Parse line by line to properly handle command-specific separators final lines = raw.split('\n'); String? currentCmd; final buffer = StringBuffer(); - + for (final line in lines) { if (line.startsWith('$separator.')) { // Save previous command content @@ -61,12 +61,12 @@ class ScriptConstants { buffer.writeln(line); } } - + // Don't forget the last command if (currentCmd != null) { result[currentCmd] = buffer.toString().trim(); } - + return result; } diff --git a/lib/data/model/app/scripts/shell_func.dart b/lib/data/model/app/scripts/shell_func.dart index a3f72660..b61ab803 100644 --- a/lib/data/model/app/scripts/shell_func.dart +++ b/lib/data/model/app/scripts/shell_func.dart @@ -93,7 +93,11 @@ class ShellFuncManager { } /// Generate complete script based on system type - static String allScript(Map? customCmds, {SystemType? systemType, List? disabledCmdTypes}) { + static String allScript( + Map? customCmds, { + SystemType? systemType, + List? disabledCmdTypes, + }) { final isWindows = systemType == SystemType.windows; final builder = ScriptBuilderFactory.getBuilder(isWindows); diff --git a/lib/data/model/server/cpu.dart b/lib/data/model/server/cpu.dart index 684e0854..ce48381b 100644 --- a/lib/data/model/server/cpu.dart +++ b/lib/data/model/server/cpu.dart @@ -142,16 +142,7 @@ class SingleCpuCore extends TimeSeqIface { final int irq; final int softirq; - SingleCpuCore( - this.id, - this.user, - this.sys, - this.nice, - this.idle, - this.iowait, - this.irq, - this.softirq, - ); + SingleCpuCore(this.id, this.user, this.sys, this.nice, this.idle, this.iowait, this.irq, this.softirq); int get total => user + sys + nice + idle + iowait + irq + softirq; @@ -200,11 +191,11 @@ final class CpuBrand { } final _bsdCpuPercentReg = RegExp(r'(\d+\.\d+)%'); -final _macCpuPercentReg = RegExp( - r'CPU usage: ([\d.]+)% user, ([\d.]+)% sys, ([\d.]+)% idle'); +final _macCpuPercentReg = RegExp(r'CPU usage: ([\d.]+)% user, ([\d.]+)% sys, ([\d.]+)% idle'); final _freebsdCpuPercentReg = RegExp( - r'CPU: ([\d.]+)% user, ([\d.]+)% nice, ([\d.]+)% system, ' - r'([\d.]+)% interrupt, ([\d.]+)% idle'); + r'CPU: ([\d.]+)% user, ([\d.]+)% nice, ([\d.]+)% system, ' + r'([\d.]+)% interrupt, ([\d.]+)% idle', +); /// Parse CPU status on BSD system with support for different BSD variants /// @@ -214,14 +205,14 @@ final _freebsdCpuPercentReg = RegExp( /// - Generic BSD: fallback to percentage extraction Cpus parseBsdCpu(String raw) { final init = InitStatus.cpus; - + // Try macOS format first final macMatch = _macCpuPercentReg.firstMatch(raw); if (macMatch != null) { final userPercent = double.parse(macMatch.group(1)!).toInt(); final sysPercent = double.parse(macMatch.group(2)!).toInt(); final idlePercent = double.parse(macMatch.group(3)!).toInt(); - + init.add([ SingleCpuCore( 'cpu0', @@ -236,7 +227,7 @@ Cpus parseBsdCpu(String raw) { ]); return init; } - + // Try FreeBSD format final freebsdMatch = _freebsdCpuPercentReg.firstMatch(raw); if (freebsdMatch != null) { @@ -245,7 +236,7 @@ Cpus parseBsdCpu(String raw) { final sysPercent = double.parse(freebsdMatch.group(3)!).toInt(); final irqPercent = double.parse(freebsdMatch.group(4)!).toInt(); final idlePercent = double.parse(freebsdMatch.group(5)!).toInt(); - + init.add([ SingleCpuCore( 'cpu0', @@ -260,20 +251,28 @@ Cpus parseBsdCpu(String raw) { ]); return init; } - + // Fallback to generic percentage extraction final percents = _bsdCpuPercentReg .allMatches(raw) - .map((e) => double.parse(e.group(1) ?? '0')) + .map((e) { + final valueStr = e.group(1) ?? '0'; + final value = double.tryParse(valueStr); + if (value == null) { + dprint('Warning: Failed to parse CPU percentage from "$valueStr"'); + return 0.0; + } + return value; + }) .toList(); - + if (percents.length >= 3) { // Validate that percentages are reasonable (0-100 range) final validPercents = percents.where((p) => p >= 0 && p <= 100).toList(); if (validPercents.length != percents.length) { Loggers.app.warning('BSD CPU fallback parsing found invalid percentages in: $raw'); } - + init.add([ SingleCpuCore( 'cpu0', @@ -288,10 +287,12 @@ Cpus parseBsdCpu(String raw) { ]); return init; } else if (percents.isNotEmpty) { - Loggers.app.warning('BSD CPU fallback parsing found ${percents.length} percentages (expected at least 3) in: $raw'); + Loggers.app.warning( + 'BSD CPU fallback parsing found ${percents.length} percentages (expected at least 3) in: $raw', + ); } else { Loggers.app.warning('BSD CPU fallback parsing found no percentages in: $raw'); } - + return init; } diff --git a/lib/data/model/server/memory.dart b/lib/data/model/server/memory.dart index ccbfb3cb..d9de061e 100644 --- a/lib/data/model/server/memory.dart +++ b/lib/data/model/server/memory.dart @@ -19,15 +19,11 @@ class Memory { static Memory parse(String raw) { final items = raw.split('\n').map((e) => memItemReg.firstMatch(e)).toList(); - final total = int.tryParse( - items.firstWhereOrNull((e) => e?.group(1) == 'MemTotal:') - ?.group(2) ?? '1') ?? 1; - final free = int.tryParse( - items.firstWhereOrNull((e) => e?.group(1) == 'MemFree:') - ?.group(2) ?? '0') ?? 0; - final available = int.tryParse( - items.firstWhereOrNull((e) => e?.group(1) == 'MemAvailable:') - ?.group(2) ?? '0') ?? 0; + final total = + int.tryParse(items.firstWhereOrNull((e) => e?.group(1) == 'MemTotal:')?.group(2) ?? '1') ?? 1; + final free = int.tryParse(items.firstWhereOrNull((e) => e?.group(1) == 'MemFree:')?.group(2) ?? '0') ?? 0; + final available = + int.tryParse(items.firstWhereOrNull((e) => e?.group(1) == 'MemAvailable:')?.group(2) ?? '0') ?? 0; return Memory(total: total, free: free, avail: available); } @@ -36,14 +32,13 @@ class Memory { final memItemReg = RegExp(r'([A-Z].+:)\s+([0-9]+) kB'); /// Parse BSD/macOS memory from top output -/// +/// /// Supports formats like: /// - macOS: "PhysMem: 32G used (1536M wired), 64G unused." /// - FreeBSD: "Mem: 456M Active, 2918M Inact, 1127M Wired, 187M Cache, 829M Buf, 3535M Free" Memory parseBsdMemory(String raw) { // Try macOS format first: "PhysMem: 32G used (1536M wired), 64G unused." - final macMemReg = RegExp( - r'PhysMem:\s*([\d.]+)([KMGT])\s*used.*?,\s*([\d.]+)([KMGT])\s*unused'); + final macMemReg = RegExp(r'PhysMem:\s*([\d.]+)([KMGT])\s*used.*?,\s*([\d.]+)([KMGT])\s*unused'); final macMatch = macMemReg.firstMatch(raw); if (macMatch != null) { @@ -58,8 +53,7 @@ Memory parseBsdMemory(String raw) { } // Try FreeBSD format: "Mem: 456M Active, 2918M Inact, 1127M Wired, 187M Cache, 829M Buf, 3535M Free" - final freeBsdReg = RegExp( - r'(\d+)([KMGT])\s+(Active|Inact|Wired|Cache|Buf|Free)', caseSensitive: false); + final freeBsdReg = RegExp(r'(\d+)([KMGT])\s+(Active|Inact|Wired|Cache|Buf|Free)', caseSensitive: false); final matches = freeBsdReg.allMatches(raw); if (matches.isNotEmpty) { @@ -72,7 +66,11 @@ Memory parseBsdMemory(String raw) { final kb = _convertToKB(amount, unit); // Only sum known keywords - if (keyword == 'active' || keyword == 'inact' || keyword == 'wired' || keyword == 'cache' || keyword == 'buf') { + if (keyword == 'active' || + keyword == 'inact' || + keyword == 'wired' || + keyword == 'cache' || + keyword == 'buf') { usedKB += kb; } else if (keyword == 'free') { freeKB += kb; @@ -121,15 +119,12 @@ class Swap { static Swap parse(String raw) { final items = raw.split('\n').map((e) => memItemReg.firstMatch(e)).toList(); - final total = int.tryParse( - items.firstWhereOrNull((e) => e?.group(1) == 'SwapTotal:') - ?.group(2) ?? '1') ?? 0; - final free = int.tryParse( - items.firstWhereOrNull((e) => e?.group(1) == 'SwapFree:') - ?.group(2) ?? '1') ?? 0; - final cached = int.tryParse( - items.firstWhereOrNull((e) => e?.group(1) == 'SwapCached:') - ?.group(2) ?? '0') ?? 0; + final total = + int.tryParse(items.firstWhereOrNull((e) => e?.group(1) == 'SwapTotal:')?.group(2) ?? '1') ?? 0; + final free = + int.tryParse(items.firstWhereOrNull((e) => e?.group(1) == 'SwapFree:')?.group(2) ?? '1') ?? 0; + final cached = + int.tryParse(items.firstWhereOrNull((e) => e?.group(1) == 'SwapCached:')?.group(2) ?? '0') ?? 0; return Swap(total: total, free: free, cached: cached); } diff --git a/lib/data/model/server/server_status_update_req.dart b/lib/data/model/server/server_status_update_req.dart index 3499884c..8f9021d3 100644 --- a/lib/data/model/server/server_status_update_req.dart +++ b/lib/data/model/server/server_status_update_req.dart @@ -45,7 +45,8 @@ Future getStatus(ServerStatusUpdateReq req) async { Future _getLinuxStatus(ServerStatusUpdateReq req) async { final parsedOutput = req.parsedOutput; - final time = int.tryParse(StatusCmdType.time.findInMap(parsedOutput)) ?? + final time = + int.tryParse(StatusCmdType.time.findInMap(parsedOutput)) ?? DateTime.now().millisecondsSinceEpoch ~/ 1000; try { @@ -259,9 +260,7 @@ String? _parseUpTime(String raw) { if (splitedComma.length >= 2) { final timePart = splitedComma[1].trim(); // Check if it's in HH:MM format - if (timePart.contains(':') && - !timePart.contains('user') && - !timePart.contains('load')) { + if (timePart.contains(':') && !timePart.contains('user') && !timePart.contains('load')) { return '$firstPart, $timePart'; } } @@ -269,9 +268,7 @@ String? _parseUpTime(String raw) { } // Case 2: "2:34" (hours:minutes) - already in good format - if (firstPart.contains(':') && - !firstPart.contains('user') && - !firstPart.contains('load')) { + if (firstPart.contains(':') && !firstPart.contains('user') && !firstPart.contains('load')) { return firstPart; } @@ -303,7 +300,8 @@ String? _parseHostName(String raw) { // Windows status parsing implementation Future _getWindowsStatus(ServerStatusUpdateReq req) async { final parsedOutput = req.parsedOutput; - final time = int.tryParse(WindowsStatusCmdType.time.findInMap(parsedOutput)) ?? + final time = + int.tryParse(WindowsStatusCmdType.time.findInMap(parsedOutput)) ?? DateTime.now().millisecondsSinceEpoch ~/ 1000; // Parse all different resource types using helper methods @@ -372,10 +370,7 @@ void _parseWindowsCpuData(ServerStatusUpdateReq req, Map parsedO try { // Windows CPU parsing - JSON format from PowerShell final cpuRaw = WindowsStatusCmdType.cpu.findInMap(parsedOutput); - if (cpuRaw.isNotEmpty && - cpuRaw != 'null' && - !cpuRaw.contains('error') && - !cpuRaw.contains('Exception')) { + if (cpuRaw.isNotEmpty && cpuRaw != 'null' && !cpuRaw.contains('error') && !cpuRaw.contains('Exception')) { final cpus = WindowsParser.parseCpu(cpuRaw, req.ss); if (cpus.isNotEmpty) { req.ss.cpu.update(cpus); @@ -397,10 +392,7 @@ void _parseWindowsCpuData(ServerStatusUpdateReq req, Map parsedO void _parseWindowsMemoryData(ServerStatusUpdateReq req, Map parsedOutput) { try { final memRaw = WindowsStatusCmdType.mem.findInMap(parsedOutput); - if (memRaw.isNotEmpty && - memRaw != 'null' && - !memRaw.contains('error') && - !memRaw.contains('Exception')) { + if (memRaw.isNotEmpty && memRaw != 'null' && !memRaw.contains('error') && !memRaw.contains('Exception')) { final memory = WindowsParser.parseMemory(memRaw); if (memory != null) { req.ss.mem = memory; @@ -506,7 +498,6 @@ void _parseWindowsGpuData(ServerStatusUpdateReq req, Map parsedO } } - List _parseWindowsBatteries(String raw) { try { final dynamic jsonData = json.decode(raw); @@ -515,24 +506,19 @@ List _parseWindowsBatteries(String raw) { final batteryList = jsonData is List ? jsonData : [jsonData]; for (final batteryData in batteryList) { - final chargeRemaining = - batteryData['EstimatedChargeRemaining'] as int? ?? 0; + final chargeRemaining = batteryData['EstimatedChargeRemaining'] as int? ?? 0; final batteryStatus = batteryData['BatteryStatus'] as int? ?? 0; - // Windows battery status: 1=Other, 2=Unknown, 3=Full, 4=Low, - // 5=Critical, 6=Charging, 7=ChargingAndLow, 8=ChargingAndCritical, + // Windows battery status: 1=Other, 2=Unknown, 3=Full, 4=Low, + // 5=Critical, 6=Charging, 7=ChargingAndLow, 8=ChargingAndCritical, // 9=Undefined, 10=PartiallyCharged - final isCharging = batteryStatus == 6 || - batteryStatus == 7 || - batteryStatus == 8; + final isCharging = batteryStatus == 6 || batteryStatus == 7 || batteryStatus == 8; batteries.add( Battery( name: 'Battery', percent: chargeRemaining, - status: isCharging - ? BatteryStatus.charging - : BatteryStatus.discharging, + status: isCharging ? BatteryStatus.charging : BatteryStatus.discharging, ), ); } @@ -579,12 +565,7 @@ List _parseWindowsNetwork(String raw, int currentTime) { final tx = interfaceTx[interfaceName] ?? 0; netParts.add( - NetSpeedPart( - interfaceName, - BigInt.from(rx.toInt()), - BigInt.from(tx.toInt()), - currentTime, - ), + NetSpeedPart(interfaceName, BigInt.from(rx.toInt()), BigInt.from(tx.toInt()), currentTime), ); } } @@ -597,7 +578,7 @@ List _parseWindowsNetwork(String raw, int currentTime) { } String _extractInterfaceName(String path) { - // Extract interface name from path like + // Extract interface name from path like // "\\Computer\\NetworkInterface(Interface Name)\\..." final match = RegExp(r'\\NetworkInterface\(([^)]+)\)\\').firstMatch(path); return match?.group(1) ?? ''; @@ -632,7 +613,7 @@ List _parseWindowsDiskIO(String raw, int currentTime) { } } - // Create DiskIOPiece for each disk - convert bytes to sectors + // Create DiskIOPiece for each disk - convert bytes to sectors // (assuming 512 bytes per sector) for (final diskName in diskReads.keys) { final readBytes = diskReads[diskName] ?? 0; @@ -659,7 +640,7 @@ List _parseWindowsDiskIO(String raw, int currentTime) { } String _extractDiskName(String path) { - // Extract disk name from path like + // Extract disk name from path like // "\\Computer\\PhysicalDisk(Disk Name)\\..." final match = RegExp(r'\\PhysicalDisk\(([^)]+)\)\\').firstMatch(path); return match?.group(1) ?? ''; @@ -668,9 +649,7 @@ String _extractDiskName(String path) { void _parseWindowsTemperatures(Temperatures temps, String raw) { try { // Handle error output - if (raw.contains('Error') || - raw.contains('Exception') || - raw.contains('The term')) { + if (raw.contains('Error') || raw.contains('Exception') || raw.contains('The term')) { return; } @@ -689,7 +668,7 @@ void _parseWindowsTemperatures(Temperatures temps, String raw) { if (temperature != null) { // Convert to the format expected by the existing parse method typeLines.add('/sys/class/thermal/thermal_zone$i/$typeName'); - // Convert to millicelsius (multiply by 1000) + // Convert to millicelsius (multiply by 1000) // as expected by Linux parsing valueLines.add((temperature * 1000).round().toString()); } diff --git a/lib/data/model/server/system.dart b/lib/data/model/server/system.dart index b095dc60..56b5809b 100644 --- a/lib/data/model/server/system.dart +++ b/lib/data/model/server/system.dart @@ -14,16 +14,14 @@ enum SystemType { static const windowsSign = '__windows'; /// Used for parsing system types from shell output. - /// + /// /// This method looks for specific system signatures in the shell output /// and returns the corresponding SystemType. If no signature is found, /// it defaults to Linux but logs the detection failure for debugging. static SystemType parse(String value) { // Log the raw value for debugging purposes (truncated to avoid spam) - final truncatedValue = value.length > 100 - ? '${value.substring(0, 100)}...' - : value; - + final truncatedValue = value.length > 100 ? '${value.substring(0, 100)}...' : value; + if (value.contains(windowsSign)) { Loggers.app.info('System detected as Windows from signature in: $truncatedValue'); return SystemType.windows; @@ -32,24 +30,23 @@ enum SystemType { Loggers.app.info('System detected as BSD from signature in: $truncatedValue'); return SystemType.bsd; } - + // Log when falling back to Linux detection if (value.trim().isEmpty) { Loggers.app.warning( 'System detection received empty input, defaulting to Linux. ' - 'This may indicate a script execution issue.' + 'This may indicate a script execution issue.', ); } else if (!value.contains(linuxSign)) { Loggers.app.warning( 'System detection could not find any known signatures (Windows: $windowsSign, ' 'BSD: $bsdSign, Linux: $linuxSign) in output: "$truncatedValue". ' - 'Defaulting to Linux, but this may cause incorrect parsing.' + 'Defaulting to Linux, but this may cause incorrect parsing.', ); } else { Loggers.app.info('System detected as Linux from signature in: $truncatedValue'); } - + return SystemType.linux; } - } diff --git a/lib/data/model/server/windows_parser.dart b/lib/data/model/server/windows_parser.dart index 582d9f1b..379d9b57 100644 --- a/lib/data/model/server/windows_parser.dart +++ b/lib/data/model/server/windows_parser.dart @@ -8,7 +8,7 @@ import 'package:server_box/data/model/server/memory.dart'; import 'package:server_box/data/model/server/server.dart'; /// Windows-specific status parsing utilities -/// +/// /// This module handles parsing of Windows PowerShell command outputs /// for server monitoring. It extracts the Windows parsing logic /// to improve maintainability and readability. @@ -36,15 +36,13 @@ class WindowsParser { static String? parseUpTime(String raw) { try { // Clean the input - trim whitespace and get the first non-empty line - final cleanedInput = raw.trim().split('\n') - .where((line) => line.trim().isNotEmpty) - .firstOrNull; - + final cleanedInput = raw.trim().split('\n').where((line) => line.trim().isNotEmpty).firstOrNull; + if (cleanedInput == null || cleanedInput.isEmpty) { Loggers.app.warning('Windows uptime parsing: empty or null input'); return null; } - + // Try multiple date formats to handle different Windows locale/version outputs final formatters = [ DateFormat('EEEE, MMMM d, yyyy h:mm:ss a', 'en_US'), // Original format @@ -56,24 +54,27 @@ class WindowsParser { DateFormat('d/M/yyyy h:mm:ss a', 'en_US'), // Short European format DateFormat('dd/MM/yyyy h:mm:ss a', 'en_US'), // Short European format with zero padding ]; - + DateTime? dateTime; for (final formatter in formatters) { dateTime = formatter.tryParseLoose(cleanedInput); if (dateTime != null) break; } - + if (dateTime == null) { Loggers.app.warning('Windows uptime parsing: could not parse date format for: $cleanedInput'); return null; } - + final now = DateTime.now(); final uptime = now.difference(dateTime); - + // Validate that the uptime is reasonable (not negative, not too far in the future) - if (uptime.isNegative || uptime.inDays > 3650) { // More than 10 years seems unreasonable - Loggers.app.warning('Windows uptime parsing: unreasonable uptime calculated: ${uptime.inDays} days for date: $cleanedInput'); + if (uptime.isNegative || uptime.inDays > 3650) { + // More than 10 years seems unreasonable + Loggers.app.warning( + 'Windows uptime parsing: unreasonable uptime calculated: ${uptime.inDays} days for date: $cleanedInput', + ); return null; } @@ -168,8 +169,8 @@ class WindowsParser { } /// Parse Windows memory information from PowerShell output - /// - /// NOTE: Windows Win32_OperatingSystem properties TotalVisibleMemorySize + /// + /// NOTE: Windows Win32_OperatingSystem properties TotalVisibleMemorySize /// and FreePhysicalMemory are returned in KB units. static Memory? parseMemory(String raw) { try { @@ -200,22 +201,19 @@ class WindowsParser { for (final diskData in diskList) { final deviceId = diskData['DeviceID']?.toString() ?? ''; - final size = - BigInt.tryParse(diskData['Size']?.toString() ?? '0') ?? BigInt.zero; - final freeSpace = - BigInt.tryParse(diskData['FreeSpace']?.toString() ?? '0') ?? - BigInt.zero; + final size = BigInt.tryParse(diskData['Size']?.toString() ?? '0') ?? BigInt.zero; + final freeSpace = BigInt.tryParse(diskData['FreeSpace']?.toString() ?? '0') ?? BigInt.zero; final fileSystem = diskData['FileSystem']?.toString() ?? ''; // Validate all required fields - final hasRequiredFields = deviceId.isNotEmpty && - size != BigInt.zero && - freeSpace != BigInt.zero && - fileSystem.isNotEmpty; + final hasRequiredFields = + deviceId.isNotEmpty && size != BigInt.zero && freeSpace != BigInt.zero && fileSystem.isNotEmpty; if (!hasRequiredFields) { - Loggers.app.warning('Windows disk parsing: skipping disk with missing required fields. ' - 'DeviceID: $deviceId, Size: $size, FreeSpace: $freeSpace, FileSystem: $fileSystem'); + Loggers.app.warning( + 'Windows disk parsing: skipping disk with missing required fields. ' + 'DeviceID: $deviceId, Size: $size, FreeSpace: $freeSpace, FileSystem: $fileSystem', + ); continue; } @@ -223,7 +221,7 @@ class WindowsParser { final freeKB = freeSpace ~/ BigInt.from(1024); final usedKB = sizeKB - freeKB; final usedPercent = sizeKB > BigInt.zero - ? ((usedKB * BigInt.from(100)) ~/ sizeKB).toInt() + ? ((usedKB * BigInt.from(100)) ~/ sizeKB).toInt().clamp(0, 100) : 0; disks.add( @@ -245,4 +243,4 @@ class WindowsParser { return []; } } -} \ No newline at end of file +} diff --git a/lib/data/provider/container.dart b/lib/data/provider/container.dart index e4ead47c..42e15342 100644 --- a/lib/data/provider/container.dart +++ b/lib/data/provider/container.dart @@ -298,7 +298,7 @@ enum ContainerCmdType { .map((e) => e.exec(type, sudo: sudo, includeStats: includeStats)) .join('\necho ${ScriptConstants.separator}\n'); } - + /// Find out the required segment from [segments] String find(List segments) { return segments[index]; diff --git a/lib/data/provider/server.dart b/lib/data/provider/server.dart index 371f8225..c27e851f 100644 --- a/lib/data/provider/server.dart +++ b/lib/data/provider/server.dart @@ -144,7 +144,7 @@ class ServerProvider extends Provider { // Start a new update operation final updateFuture = _updateServer(s.spi); _serverIdsUpdating[s.spi.id] = updateFuture; - + try { await updateFuture; } finally { @@ -382,7 +382,11 @@ class ServerProvider extends Provider { sv.status.system = detectedSystemType; final (_, writeScriptResult) = await sv.client!.exec((session) async { - final scriptRaw = ShellFuncManager.allScript(spi.custom?.cmds, systemType: detectedSystemType, disabledCmdTypes: spi.disabledCmdTypes).uint8List; + final scriptRaw = ShellFuncManager.allScript( + spi.custom?.cmds, + systemType: detectedSystemType, + disabledCmdTypes: spi.disabledCmdTypes, + ).uint8List; session.stdin.add(scriptRaw); session.stdin.close(); }, entry: ShellFuncManager.getInstallShellCmd(spi.id, systemType: detectedSystemType)); @@ -396,7 +400,7 @@ class ServerProvider extends Provider { sv.status.err = err; Loggers.app.warning(err); _setServerState(s, ServerConn.failed); - + // Update SSH session status to disconnected final sessionId = 'ssh_${spi.id}'; TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); @@ -407,7 +411,7 @@ class ServerProvider extends Provider { sv.status.err = err; Loggers.app.warning(err); _setServerState(s, ServerConn.failed); - + // Update SSH session status to disconnected final sessionId = 'ssh_${spi.id}'; TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); @@ -419,7 +423,7 @@ class ServerProvider extends Provider { sv.status.err = err; Loggers.app.warning(err); _setServerState(s, ServerConn.failed); - + // Update SSH session status to disconnected final sessionId = 'ssh_${spi.id}'; TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); @@ -452,7 +456,7 @@ class ServerProvider extends Provider { TryLimiter.inc(sid); sv.status.err = SSHErr(type: SSHErrType.segements, message: 'Seperate segments failed, raw:\n$raw'); _setServerState(s, ServerConn.failed); - + // Update SSH session status to disconnected on segments error final sessionId = 'ssh_${spi.id}'; TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); @@ -463,7 +467,7 @@ class ServerProvider extends Provider { sv.status.err = SSHErr(type: SSHErrType.getStatus, message: e.toString()); _setServerState(s, ServerConn.failed); Loggers.app.warning('Get status from ${spi.name} failed', e); - + // Update SSH session status to disconnected on status error final sessionId = 'ssh_${spi.id}'; TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); @@ -473,7 +477,7 @@ class ServerProvider extends Provider { try { // Parse script output into command-specific map final parsedOutput = ScriptConstants.parseScriptOutput(raw); - + final req = ServerStatusUpdateReq( ss: sv.status, parsedOutput: parsedOutput, @@ -486,7 +490,7 @@ class ServerProvider extends Provider { sv.status.err = SSHErr(type: SSHErrType.getStatus, message: 'Parse failed: $e\n\n$raw'); _setServerState(s, ServerConn.failed); Loggers.app.warning('Server status', e, trace); - + // Update SSH session status to disconnected on parse error final sessionId = 'ssh_${spi.id}'; TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); diff --git a/lib/data/provider/systemd.dart b/lib/data/provider/systemd.dart index da5f435a..71bdc46f 100644 --- a/lib/data/provider/systemd.dart +++ b/lib/data/provider/systemd.dart @@ -81,10 +81,7 @@ done final parsedUnits = []; for (final unit in units.where((e) => e.trim().isNotEmpty)) { - final parts = unit - .split('\n') - .where((e) => e.trim().isNotEmpty) - .toList(); + final parts = unit.split('\n').where((e) => e.trim().isNotEmpty).toList(); if (parts.isEmpty) continue; var name = ''; var type = ''; diff --git a/lib/intro.dart b/lib/intro.dart index 5e8b5c85..b6d6cbde 100644 --- a/lib/intro.dart +++ b/lib/intro.dart @@ -5,7 +5,7 @@ final class _IntroPage extends StatelessWidget { const _IntroPage(this.pages); - static const _builders = {1: _buildAppSettings}; + static const _builders = {1: _buildAppSettings, 2: _buildBackupPasswordMigration}; @override Widget build(BuildContext context) { @@ -33,6 +33,43 @@ final class _IntroPage extends StatelessWidget { SizedBox(height: padTop), IntroPage.title(text: l10n.init, big: true), SizedBox(height: padTop), + // Prompt to set backup password after migration or on first launch + ListTile( + leading: const Icon(Icons.lock), + title: Text(l10n.backupPassword), + subtitle: Text(l10n.backupPasswordTip, style: UIs.textGrey), + trailing: const Icon(Icons.keyboard_arrow_right), + onTap: () async { + final currentPwd = await SecureStoreProps.bakPwd.read(); + final controller = TextEditingController(text: currentPwd ?? ''); + final result = await ctx.showRoundDialog( + title: l10n.backupPassword, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.backupPasswordTip, style: UIs.textGrey), + UIs.height13, + Input( + label: l10n.backupPassword, + controller: controller, + obscureText: true, + onSubmitted: (_) => ctx.pop(true), + ), + ], + ), + actions: Btnx.oks, + ); + if (result == true) { + final pwd = controller.text.trim(); + if (pwd.isEmpty) { + ctx.showSnackBar(libL10n.empty); + return; + } + await SecureStoreProps.bakPwd.write(pwd); + ctx.showSnackBar(l10n.backupPasswordSet); + } + }, + ).cardx, ListTile( leading: const Icon(IonIcons.language), title: Text(libL10n.language), @@ -76,9 +113,86 @@ final class _IntroPage extends StatelessWidget { ); } - static List get builders { + static Widget _buildBackupPasswordMigration(BuildContext ctx, double padTop) { + return ListView( + padding: _introListPad, + children: [ + SizedBox(height: padTop), + IntroPage.title(text: l10n.backupPassword, big: true), + SizedBox(height: padTop * 0.5), + Text( + '${l10n.backupTip}\n\n${l10n.backupPasswordTip}', + style: const TextStyle(fontSize: 16), + textAlign: TextAlign.center, + ), + SizedBox(height: padTop * 0.5), + ListTile( + leading: const Icon(Icons.lock, color: Colors.orange), + title: Text(l10n.backupPassword), + subtitle: Text(l10n.backupPasswordTip, style: UIs.textGrey), + trailing: const Icon(Icons.keyboard_arrow_right), + onTap: () async { + final controller = TextEditingController(); + final result = await ctx.showRoundDialog( + title: l10n.backupPassword, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.backupPasswordTip, style: UIs.textGrey), + UIs.height13, + Input( + label: l10n.backupPassword, + controller: controller, + obscureText: true, + onSubmitted: (_) => ctx.pop(true), + ), + ], + ), + actions: [ + TextButton(onPressed: () => ctx.pop(false), child: Text(libL10n.cancel)), + TextButton(onPressed: () => ctx.pop(true), child: Text(libL10n.ok)), + ], + ); + if (result == true) { + final pwd = controller.text.trim(); + if (pwd.isNotEmpty) { + await SecureStoreProps.bakPwd.write(pwd); + ctx.showSnackBar(l10n.backupPasswordSet); + } + } + }, + ).cardx, + SizedBox(height: padTop), + Text( + 'This step is recommended for secure backup functionality.', + style: UIs.textGrey, + textAlign: TextAlign.center, + ), + UIs.height77, + ], + ); + } + + static Future> get builders async { final storedVer = _setting.introVer.fetch(); - return _builders.entries.where((e) => e.key > storedVer).map((e) => e.value).toList(); + final lastVer = _setting.lastVer.fetch(); + + // If user is upgrading from older version and doesn't have backup password set, + // show the backup password migration page + final hasBackupPwd = (await SecureStoreProps.bakPwd.read())?.isNotEmpty == true; + final isUpgrading = lastVer > 0 && storedVer < 2; // lastVer > 0 means not first install + + final builders = _builders.entries + .where((e) { + if (e.key == 2 && (!isUpgrading || hasBackupPwd)) { + return false; // Skip backup password migration if not upgrading or already has password + } + return e.key > storedVer; + }) + .map((e) => e.value) + .toList(); + + return builders; } static final _setting = Stores.setting; diff --git a/lib/view/page/backup.dart b/lib/view/page/backup.dart index 16de25b4..ed8becef 100644 --- a/lib/view/page/backup.dart +++ b/lib/view/page/backup.dart @@ -28,10 +28,12 @@ class BackupPage extends StatefulWidget { final class _BackupPageState extends State with AutomaticKeepAliveClientMixin { final webdavLoading = false.vn; + final gistLoading = false.vn; @override void dispose() { webdavLoading.dispose(); + gistLoading.dispose(); super.dispose(); } @@ -48,8 +50,10 @@ final class _BackupPageState extends State with AutomaticKeepAliveCl [ CenterGreyTitle(libL10n.sync), _buildTip, + _buildBakPwd, if (isMacOS || isIOS) _buildIcloud, _buildWebdav, + _buildGist, _buildFile, _buildClipboard, ], @@ -58,6 +62,82 @@ final class _BackupPageState extends State with AutomaticKeepAliveCl ); } + Widget get _buildBakPwd { + return FutureBuilder( + future: SecureStoreProps.bakPwd.read(), + builder: (context, snapshot) { + final hasPwd = snapshot.data?.isNotEmpty == true; + return CardX( + child: ListTile( + leading: const Icon(Icons.lock), + title: Text(l10n.backupPassword), + subtitle: Text(hasPwd ? l10n.backupEncrypted : l10n.backupNotEncrypted, style: UIs.textGrey), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton(onPressed: () async => _onTapSetBakPwd(context), child: Text(libL10n.setting)), + if (hasPwd) ...[ + UIs.width7, + TextButton( + onPressed: () async { + await SecureStoreProps.bakPwd.write(null); + context.showSnackBar(l10n.backupPasswordRemoved); + setState(() {}); + }, + child: Text(libL10n.delete), + ), + ], + ], + ), + onTap: () async => _onTapSetBakPwd(context), + ), + ); + }, + ); + } + + Future _onTapSetBakPwd(BuildContext context) async { + final currentPwd = await SecureStoreProps.bakPwd.read(); + final controller = TextEditingController(text: currentPwd ?? ''); + final node = FocusNode(); + final result = await context.showRoundDialog( + title: l10n.backupPassword, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.backupPasswordTip, style: UIs.textGrey), + UIs.height13, + Input( + label: l10n.backupPassword, + controller: controller, + node: node, + obscureText: true, + onSubmitted: (_) => context.pop(true), + ), + ], + ), + actions: Btnx.oks, + ); + if (result == true) { + final pwd = controller.text.trim(); + if (pwd.isEmpty) { + context.showSnackBar(libL10n.empty); + return; + } + await SecureStoreProps.bakPwd.write(pwd); + context.showSnackBar(l10n.backupPasswordSet); + setState(() {}); + } + } + + Future _ensureBakPwd(BuildContext context) async { + final saved = await SecureStoreProps.bakPwd.read(); + if (saved != null && saved.isNotEmpty) return true; + await _onTapSetBakPwd(context); + final after = await SecureStoreProps.bakPwd.read(); + return after != null && after.isNotEmpty; + } + Widget get _buildTip { return CardX( child: ListTile( @@ -102,6 +182,10 @@ final class _BackupPageState extends State with AutomaticKeepAliveCl context.showSnackBar(l10n.autoBackupConflict); return false; } + if (p0) { + final ok = await _ensureBakPwd(context); + if (!ok) return false; + } if (p0) { await bakSync.sync(rs: icloud); } @@ -133,6 +217,10 @@ final class _BackupPageState extends State with AutomaticKeepAliveCl context.showSnackBar(l10n.autoBackupConflict); return false; } + if (p0) { + final ok = await _ensureBakPwd(context); + if (!ok) return false; + } if (p0) { final url = PrefProps.webdavUrl.get(); final user = PrefProps.webdavUser.get(); @@ -178,6 +266,67 @@ final class _BackupPageState extends State with AutomaticKeepAliveCl ); } + Widget get _buildGist { + return CardX( + child: ExpandTile( + leading: const Icon(Icons.code), + title: const Text('GitHub Gist'), + initiallyExpanded: false, + children: [ + ListTile( + title: Text(libL10n.setting), + trailing: const Icon(Icons.settings), + onTap: () async => _onTapGistSetting(context), + ), + ListTile( + title: Text(libL10n.auto), + trailing: StoreSwitch( + prop: PrefProps.gistSync, + validator: (p0) async { + if (p0 && (PrefProps.icloudSync.get() || PrefProps.webdavSync.get())) { + context.showSnackBar(l10n.autoBackupConflict); + return false; + } + if (p0) { + final ok = await _ensureBakPwd(context); + if (!ok) return false; + } + if (p0) { + final token = PrefProps.githubToken.get(); + // Allow empty gistId (will create one on first upload) + final hasToken = token != null && token.isNotEmpty; + if (!hasToken) { + context.showSnackBar('Token or Gist ID is empty'); + return false; + } + gistLoading.value = true; + await bakSync.sync(rs: GistRs.shared); + gistLoading.value = false; + } + return true; + }, + ), + ), + ListTile( + title: Text(l10n.manual), + trailing: gistLoading.listenVal((loading) { + if (loading) return SizedLoading.small; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton(onPressed: () async => _onTapGistDl(context), child: Text(libL10n.restore)), + UIs.width7, + TextButton(onPressed: () async => _onTapGistUp(context), child: Text(libL10n.backup)), + ], + ); + }), + ), + ], + ), + ); + } + Widget get _buildClipboard { return CardX( child: ExpandTile( @@ -289,7 +438,9 @@ final class _BackupPageState extends State with AutomaticKeepAliveCl final date = DateTime.now().ymdhms(ymdSep: '-', hmsSep: '-', sep: '-'); final bakName = '$date-${Miscs.bakFileName}'; try { - final savedPassword = await Stores.setting.backupasswd.read(); + final ok = await _ensureBakPwd(context); + if (!ok) return; + final savedPassword = await SecureStoreProps.bakPwd.read(); await BackupV2.backup(bakName, savedPassword); await Webdav.shared.upload(relativePath: bakName); Loggers.app.info('Upload webdav backup success'); @@ -301,6 +452,85 @@ final class _BackupPageState extends State with AutomaticKeepAliveCl } } + Future _onTapGistDl(BuildContext context) async { + gistLoading.value = true; + try { + final files = await GistRs.shared.list(); + if (files.isEmpty) return context.showSnackBar(l10n.dirEmpty); + + final fileName = await context.showPickSingleDialog(title: libL10n.restore, items: files); + if (fileName == null) return; + + await GistRs.shared.download(relativePath: fileName); + final dlFile = await File('${Paths.doc}/$fileName').readAsString(); + await BackupService.restoreFromText(context, dlFile); + } catch (e, s) { + context.showErrDialog(e, s, libL10n.restore); + Loggers.app.warning('Download gist backup failed', e, s); + } finally { + gistLoading.value = false; + } + } + + Future _onTapGistUp(BuildContext context) async { + gistLoading.value = true; + final date = DateTime.now().ymdhms(ymdSep: '-', hmsSep: '-', sep: '-'); + final bakName = '$date-${Miscs.bakFileName}'; + try { + final ok = await _ensureBakPwd(context); + if (!ok) return; + final savedPassword = await SecureStoreProps.bakPwd.read(); + await BackupV2.backup(bakName, savedPassword); + await GistRs.shared.upload(relativePath: bakName); + Loggers.app.info('Upload gist backup success'); + } catch (e, s) { + context.showErrDialog(e, s, l10n.upload); + Loggers.app.warning('Upload gist backup failed', e, s); + } finally { + gistLoading.value = false; + } + } + + Future _onTapGistSetting(BuildContext context) async { + final tokenCtrl = TextEditingController(text: PrefProps.githubToken.get()); + final gistIdCtrl = TextEditingController(text: PrefProps.gistId.get()); + final nodeToken = FocusNode(); + final result = await context.showRoundDialog( + title: 'GitHub Gist', + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Input(label: 'Token', controller: tokenCtrl, suggestion: false, node: nodeToken), + Input( + label: 'Gist ID (optional)', + controller: gistIdCtrl, + suggestion: false, + onSubmitted: (_) => context.pop(true), + ), + ], + ), + actions: Btnx.oks, + ); + if (result == true) { + try { + final token_ = tokenCtrl.text.trim(); + final gistId_ = gistIdCtrl.text.trim(); + + await GistRs.test(token: token_, gistId: gistId_.isEmpty ? null : gistId_); + context.showSnackBar(libL10n.success); + + await PrefProps.githubToken.set(token_); + if (gistId_.isEmpty) { + await PrefProps.gistId.remove(); + } else { + await PrefProps.gistId.set(gistId_); + } + } catch (e, s) { + context.showErrDialog(e, s, 'Gist'); + } + } + } + Future _onTapWebdavSetting(BuildContext context) async { final url = TextEditingController(text: PrefProps.webdavUrl.get()); final user = TextEditingController(text: PrefProps.webdavUser.get()); diff --git a/pubspec.lock b/pubspec.lock index 1dc80906..da9db2b9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -497,8 +497,8 @@ packages: dependency: "direct main" description: path: "." - ref: "v1.0.329" - resolved-ref: "1620fb055986dfcf7587d7a16eb994a39c0d5772" + ref: "v1.0.333" + resolved-ref: d73f37c7e7232847d29507ac0df24d38db64a9dc url: "https://github.com/lppcg/fl_lib" source: git version: "0.0.1" diff --git a/pubspec.yaml b/pubspec.yaml index 00cceb2a..921db8a4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,7 +63,7 @@ dependencies: fl_lib: git: url: https://github.com/lppcg/fl_lib - ref: v1.0.329 + ref: v1.0.333 dependency_overrides: # webdav_client_plus: