From 9c9648656da313a3cb67fa82538aec6297c1257c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Fri, 8 Aug 2025 18:59:25 +0800 Subject: [PATCH] fix: macOS ssh term unusable (#838) --- README.md | 2 + README_zh.md | 2 + lib/data/model/app/scripts/cmd_types.dart | 249 ++++++++++++++++++ .../app/{ => scripts}/script_builders.dart | 155 ++++++----- lib/data/model/app/scripts/script_consts.dart | 100 +++++++ lib/data/model/app/scripts/shell_func.dart | 102 +++++++ lib/data/model/app/shell_func.dart | 242 ----------------- lib/data/model/server/server.dart | 2 +- .../server/server_status_update_req.dart | 5 +- lib/data/model/server/system.dart | 2 +- lib/data/provider/container.dart | 13 +- lib/data/provider/server.dart | 11 +- lib/data/provider/systemd.dart | 6 +- lib/data/res/github_id.dart | 5 + lib/view/page/process.dart | 2 +- lib/view/page/server/detail/view.dart | 3 +- lib/view/page/server/logo.dart | 2 +- lib/view/page/server/tab/tab.dart | 3 +- pubspec.lock | 4 +- pubspec.yaml | 2 +- test/script_builder_test.dart | 186 +++++++++++++ test/windows_test.dart | 9 +- 22 files changed, 768 insertions(+), 339 deletions(-) create mode 100644 lib/data/model/app/scripts/cmd_types.dart rename lib/data/model/app/{ => scripts}/script_builders.dart (63%) create mode 100644 lib/data/model/app/scripts/script_consts.dart create mode 100644 lib/data/model/app/scripts/shell_func.dart delete mode 100644 lib/data/model/app/shell_func.dart create mode 100644 test/script_builder_test.dart diff --git a/README.md b/README.md index f0edbc03..ae3a4fc3 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ After you read the above, you can open an [issue](https://github.com/lollipopkit Any positive contribution is welcome. +If I forgot to add your name to the contributors list, please add a comment in the issue or PR you opened to let me know, I will add it as soon as possible. + ### Development 1. Setup [Flutter](https://flutter.dev/docs/get-started/install) environment. diff --git a/README_zh.md b/README_zh.md index fa887ea1..cbf09205 100644 --- a/README_zh.md +++ b/README_zh.md @@ -67,6 +67,8 @@ Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/rel 任何正面的贡献都欢迎。 +如果我忘记在贡献者列表中添加你的名字,请在你打开的 issue 或 PR 中添加评论让我知道,我会尽快添加。 + ### 开发 1. 安装 [Flutter](https://flutter.dev/docs/get-started/install) diff --git a/lib/data/model/app/scripts/cmd_types.dart b/lib/data/model/app/scripts/cmd_types.dart new file mode 100644 index 00000000..6217df9e --- /dev/null +++ b/lib/data/model/app/scripts/cmd_types.dart @@ -0,0 +1,249 @@ +import 'package:server_box/core/extension/context/locale.dart'; +import 'package:server_box/data/model/server/system.dart'; + +/// Base class for all command type enums +abstract class CommandType { + String get cmd; +} + +/// Linux/Unix status commands +enum StatusCmdType implements CommandType { + echo('echo ${SystemType.linuxSign}'), + time('date +%s'), + net('cat /proc/net/dev'), + sys('cat /etc/*-release | grep ^PRETTY_NAME'), + cpu('cat /proc/stat | grep cpu'), + uptime('uptime'), + conn('cat /proc/net/snmp'), + disk( + 'lsblk --bytes --json --output ' + 'FSTYPE,PATH,NAME,KNAME,MOUNTPOINT,FSSIZE,FSUSED,FSAVAIL,FSUSE%,UUID', + ), + mem("cat /proc/meminfo | grep -E 'Mem|Swap'"), + tempType('cat /sys/class/thermal/thermal_zone*/type'), + tempVal('cat /sys/class/thermal/thermal_zone*/temp'), + host('cat /etc/hostname'), + diskio('cat /proc/diskstats'), + + /// Get battery information from Linux power supply subsystem + /// + /// Reads battery data from sysfs power supply interface: + /// - Iterates through all power supply devices in /sys/class/power_supply/ + /// - Each device has a uevent file with key-value pairs of power supply properties + /// - Includes battery level, status, technology type, and other attributes + /// - Works with laptops, UPS devices, and other power supplies + /// - Adds echo after each file to separate multiple power supplies + /// - Returns empty if no power supplies are detected (e.g., desktop systems) + battery('for f in /sys/class/power_supply/*/uevent; do cat "\$f"; echo; done'), + + /// Get NVIDIA GPU information using nvidia-smi in XML format + /// Requires NVIDIA drivers and nvidia-smi utility to be installed + nvidia('nvidia-smi -q -x'), + + /// Get AMD GPU information using multiple fallback methods + /// + /// This command tries three different AMD monitoring tools in order of preference: + /// 1. amd-smi: Modern AMD System Management Interface (ROCm 5.0+) + /// - Uses 'amd-smi list --json' to get GPU list + /// - Uses 'amd-smi metric --json' to get performance metrics + /// 2. rocm-smi: ROCm System Management Interface (older versions) + /// - First tries '--json' output format if supported + /// - Falls back to human-readable format with comprehensive metrics + /// 3. radeontop: Real-time GPU usage monitor for older AMD cards + /// - Uses 2-second timeout to avoid hanging + /// - Skips header line with 'tail -n +2' + /// - Outputs single line of usage data + /// + /// If none of these tools are available, outputs error message + amd( + 'if command -v amd-smi >/dev/null 2>&1; then ' + 'amd-smi list --json && amd-smi metric --json; ' + 'elif command -v rocm-smi >/dev/null 2>&1; then ' + 'rocm-smi --json || rocm-smi --showunique --showuse --showtemp ' + '--showfan --showclocks --showmemuse --showpower; ' + 'elif command -v radeontop >/dev/null 2>&1; then ' + 'timeout 2s radeontop -d - -l 1 | tail -n +2; ' + 'else echo "No AMD GPU monitoring tools found"; fi', + ), + sensors('sensors'), + + /// Get SMART disk health information for all storage devices + /// + /// Uses a combination of lsblk and smartctl to collect disk health data: + /// - lsblk -dn -o KNAME lists all block devices (kernel names only, no dependencies) + /// - For each device, runs smartctl with -a (all info) and -j (JSON output) + /// - Targets raw device nodes in /dev/ (e.g., /dev/sda, /dev/nvme0n1) + /// - Adds echo after each device to separate output blocks + /// - May require elevated privileges for some drives + /// - smartctl must be installed (part of smartmontools package) + diskSmart('for d in \$(lsblk -dn -o KNAME); do smartctl -a -j /dev/\$d; echo; done'), + cpuBrand('cat /proc/cpuinfo | grep "model name"'); + + @override + final String cmd; + + const StatusCmdType(this.cmd); +} + +/// BSD/macOS status commands +enum BSDStatusCmdType implements CommandType { + echo('echo ${SystemType.bsdSign}'), + time('date +%s'), + net('netstat -ibn'), + sys('uname -or'), + cpu('top -l 1 | grep "CPU usage"'), + uptime('uptime'), + disk('df -k'), // Keep df -k for BSD systems as lsblk is not available on macOS/BSD + mem('top -l 1 | grep PhysMem'), + host('hostname'), + cpuBrand('sysctl -n machdep.cpu.brand_string'); + + @override + final String cmd; + + const BSDStatusCmdType(this.cmd); +} + +/// Windows PowerShell status commands +enum WindowsStatusCmdType implements CommandType { + echo('echo ${SystemType.windowsSign}'), + time('[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()'), + + /// Get network interface statistics using Windows Performance Counters + /// + /// Uses Get-Counter to collect network I/O metrics from all network interfaces: + /// - Collects bytes received and sent per second for all network interfaces + /// - Takes 2 samples with 1 second interval to calculate rates + /// - Outputs results in JSON format for easy parsing + /// - Counter paths use double backslashes to escape PowerShell string literals + net( + r'Get-Counter -Counter ' + r'"\\NetworkInterface(*)\\Bytes Received/sec", ' + r'"\\NetworkInterface(*)\\Bytes Sent/sec" ' + r'-SampleInterval 1 -MaxSamples 2 | ConvertTo-Json', + ), + sys('(Get-ComputerInfo).OsName'), + cpu( + 'Get-WmiObject -Class Win32_Processor | ' + 'Select-Object Name, LoadPercentage | ConvertTo-Json', + ), + uptime('(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime'), + conn('(netstat -an | findstr ESTABLISHED | Measure-Object -Line).Count'), + disk( + 'Get-WmiObject -Class Win32_LogicalDisk | ' + 'Select-Object DeviceID, Size, FreeSpace, FileSystem | ConvertTo-Json', + ), + mem( + 'Get-WmiObject -Class Win32_OperatingSystem | ' + 'Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json', + ), + + /// Get system temperature using Windows Management Instrumentation (WMI) + /// + /// Queries the MSAcpi_ThermalZoneTemperature class from the WMI root/wmi namespace: + /// - Uses Get-CimInstance to access ACPI thermal zone data + /// - ErrorAction SilentlyContinue prevents errors on systems without thermal sensors + /// - Converts temperature from 10ths of Kelvin to Celsius: (temp - 2732) / 10 + /// - Uses calculated property to perform the temperature conversion + /// - Returns JSON with InstanceName and converted Temperature values + /// - May return empty result on systems without ACPI thermal sensor support + temp( + 'Get-CimInstance -ClassName MSAcpi_ThermalZoneTemperature ' + '-Namespace root/wmi -ErrorAction SilentlyContinue | ' + 'Select-Object InstanceName, @{Name=\'Temperature\';' + 'Expression={[math]::Round((\$_.CurrentTemperature - 2732) / 10, 1)}} | ' + 'ConvertTo-Json', + ), + host(r'Write-Output $env:COMPUTERNAME'), + + /// Get disk I/O statistics using Windows Performance Counters + /// + /// Uses Get-Counter to collect disk I/O metrics from all physical disks: + /// - Monitors read and write bytes per second for all physical disks + /// - Takes 2 samples with 1 second interval to calculate I/O rates + /// - Physical disk counters provide hardware-level I/O statistics + /// - Outputs results in JSON format for parsing + /// - Counter names use wildcard (*) to capture all disk instances + diskio( + r'Get-Counter -Counter ' + r'"\\PhysicalDisk(*)\\Disk Read Bytes/sec", ' + r'"\\PhysicalDisk(*)\\Disk Write Bytes/sec" ' + r'-SampleInterval 1 -MaxSamples 2 | ConvertTo-Json', + ), + battery( + 'Get-WmiObject -Class Win32_Battery | ' + 'Select-Object EstimatedChargeRemaining, BatteryStatus | ConvertTo-Json', + ), + + /// Get NVIDIA GPU information on Windows + /// + /// Checks if nvidia-smi is available before attempting to use it: + /// - Uses Get-Command to test if nvidia-smi.exe exists in PATH + /// - ErrorAction SilentlyContinue prevents PowerShell errors if not found + /// - If available, runs nvidia-smi with -q (query) and -x (XML output) flags + /// - If not available, outputs standard error message for consistent handling + nvidia( + 'if (Get-Command nvidia-smi -ErrorAction SilentlyContinue) { ' + 'nvidia-smi -q -x } else { echo "NVIDIA driver not found" }', + ), + + /// Get AMD GPU information on Windows + /// + /// Checks for AMD monitoring tools using similar pattern to Linux version: + /// - Uses Get-Command to test if amd-smi.exe exists in PATH + /// - ErrorAction SilentlyContinue prevents PowerShell errors if not found + /// - If available, runs amd-smi list command with JSON output + /// - If not available, outputs standard error message for consistent handling + /// - Windows version is simpler than Linux due to fewer AMD tool variations + amd( + 'if (Get-Command amd-smi -ErrorAction SilentlyContinue) { ' + 'amd-smi list --json } else { echo "AMD driver not found" }', + ), + sensors( + 'Get-CimInstance -ClassName Win32_TemperatureProbe ' + '-ErrorAction SilentlyContinue | ' + 'Select-Object Name, CurrentReading | ConvertTo-Json', + ), + + /// Get SMART disk health information on Windows using Storage cmdlets + /// + /// Uses Windows PowerShell storage management cmdlets: + /// - Get-PhysicalDisk retrieves all physical storage devices + /// - Get-StorageReliabilityCounter gets SMART health data via pipeline + /// - Selects key health metrics: DeviceId, Temperature, TemperatureMax, Wear, PowerOnHours + /// - Outputs results in JSON format for consistent parsing + /// - Works with NVMe, SATA, and other storage interfaces supported by Windows + /// - May require elevated privileges on some systems + diskSmart( + 'Get-PhysicalDisk | Get-StorageReliabilityCounter | ' + 'Select-Object DeviceId, Temperature, TemperatureMax, Wear, PowerOnHours | ' + 'ConvertTo-Json', + ), + cpuBrand('(Get-WmiObject -Class Win32_Processor).Name'); + + @override + final String cmd; + + const WindowsStatusCmdType(this.cmd); +} + +/// Extensions for StatusCmdType +extension StatusCmdTypeX on StatusCmdType { + String get i18n => switch (this) { + StatusCmdType.sys => l10n.system, + StatusCmdType.host => l10n.host, + StatusCmdType.uptime => l10n.uptime, + StatusCmdType.battery => l10n.battery, + StatusCmdType.sensors => l10n.sensors, + StatusCmdType.disk => l10n.disk, + final val => val.name, + }; +} + +/// Generic extension for Enum types +extension EnumX on Enum { + /// Find out the required segment from [segments] + String find(List segments) { + return segments[index]; + } +} diff --git a/lib/data/model/app/script_builders.dart b/lib/data/model/app/scripts/script_builders.dart similarity index 63% rename from lib/data/model/app/script_builders.dart rename to lib/data/model/app/scripts/script_builders.dart index 3946eb48..0cddef67 100644 --- a/lib/data/model/app/script_builders.dart +++ b/lib/data/model/app/scripts/script_builders.dart @@ -1,5 +1,6 @@ -import 'package:server_box/data/model/app/shell_func.dart'; -import 'package:server_box/data/res/build_data.dart'; +import 'package:server_box/data/model/app/scripts/cmd_types.dart'; +import 'package:server_box/data/model/app/scripts/script_consts.dart'; +import 'package:server_box/data/model/app/scripts/shell_func.dart'; /// Abstract base class for platform-specific script builders abstract class ScriptBuilder { @@ -7,21 +8,24 @@ abstract class ScriptBuilder { /// Generate a complete script for all shell functions String buildScript(Map? customCmds); - + /// Get the script file name for this platform String get scriptFileName; - + /// Get the command to install the script String getInstallCommand(String scriptDir, String scriptPath); - + /// Get the execution command for a specific function String getExecCommand(String scriptPath, ShellFunc func); - + /// Get custom commands string for this platform - String getCustomCmdsString( - ShellFunc func, - Map? customCmds, - ); + String getCustomCmdsString(ShellFunc func, Map? customCmds); + + /// Get the script header for this platform + String get scriptHeader; + + /// Get the command divider for this platform + String get cmdDivider => ScriptConstants.cmdDivider; } /// Windows PowerShell script builder @@ -29,13 +33,16 @@ class WindowsScriptBuilder extends ScriptBuilder { const WindowsScriptBuilder(); @override - String get scriptFileName => 'srvboxm_v${BuildData.script}.ps1'; + String get scriptFileName => ScriptConstants.scriptFileWindows; + + @override + String get scriptHeader => ScriptConstants.windowsScriptHeader; @override String getInstallCommand(String scriptDir, String scriptPath) { return 'New-Item -ItemType Directory -Force -Path \'$scriptDir\' | Out-Null; ' - '\$content = [System.Console]::In.ReadToEnd(); ' - 'Set-Content -Path \'$scriptPath\' -Value \$content -Encoding UTF8'; + '\$content = [System.Console]::In.ReadToEnd(); ' + 'Set-Content -Path \'$scriptPath\' -Value \$content -Encoding UTF8'; } @override @@ -44,10 +51,7 @@ class WindowsScriptBuilder extends ScriptBuilder { } @override - String getCustomCmdsString( - ShellFunc func, - Map? customCmds, - ) { + String getCustomCmdsString(ShellFunc func, Map? customCmds) { if (func == ShellFunc.status && customCmds != null && customCmds.isNotEmpty) { return '\n${customCmds.values.map((cmd) => '\t$cmd').join('\n')}'; } @@ -57,13 +61,7 @@ class WindowsScriptBuilder extends ScriptBuilder { @override String buildScript(Map? customCmds) { final sb = StringBuffer(); - sb.write(''' -# PowerShell script for ServerBox app v1.0.${BuildData.build} -# DO NOT delete this file while app is running - -\$ErrorActionPreference = "SilentlyContinue" - -'''); + sb.write(scriptHeader); // Write each function for (final func in ShellFunc.values) { @@ -93,22 +91,26 @@ switch (\$args[0]) { return sb.toString(); } + /// Get Windows-specific command for a shell function String _getWindowsCommand(ShellFunc func) => switch (func) { - ShellFunc.status => WindowsStatusCmdType.values.map((e) => e.cmd).join(ShellFunc.cmdDivider), + ShellFunc.status => WindowsStatusCmdType.values.map((e) => e.cmd).join(cmdDivider), ShellFunc.process => 'Get-Process | Select-Object ProcessName, Id, CPU, WorkingSet | ConvertTo-Json', ShellFunc.shutdown => 'Stop-Computer -Force', ShellFunc.reboot => 'Restart-Computer -Force', - ShellFunc.suspend => + ShellFunc.suspend => 'Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Application]::SetSuspendState(\'Suspend\', \$false, \$false)', }; } -/// Unix shell script builder +/// Unix shell script builder class UnixScriptBuilder extends ScriptBuilder { const UnixScriptBuilder(); @override - String get scriptFileName => 'srvboxm_v${BuildData.script}.sh'; + String get scriptFileName => ScriptConstants.scriptFile; + + @override + String get scriptHeader => ScriptConstants.unixScriptHeader; @override String getInstallCommand(String scriptDir, String scriptPath) { @@ -125,12 +127,9 @@ chmod 755 $scriptPath } @override - String getCustomCmdsString( - ShellFunc func, - Map? customCmds, - ) { + String getCustomCmdsString(ShellFunc func, Map? customCmds) { if (func == ShellFunc.status && customCmds != null && customCmds.isNotEmpty) { - return '${ShellFunc.cmdDivider}\n\t${customCmds.values.join(ShellFunc.cmdDivider)}'; + return '$cmdDivider\n\t${customCmds.values.join(cmdDivider)}'; } return ''; } @@ -138,25 +137,7 @@ chmod 755 $scriptPath @override String buildScript(Map? customCmds) { final sb = StringBuffer(); - sb.write(''' -#!/bin/sh -# Script for ServerBox app v1.0.${BuildData.build} -# DO NOT delete this file while app is running - -export LANG=en_US.UTF-8 - -# If macSign & bsdSign are both empty, then it's linux -macSign=\$(uname -a 2>&1 | grep "Darwin") -bsdSign=\$(uname -a 2>&1 | grep "BSD") - -# Link /bin/sh to busybox? -isBusybox=\$(ls -l /bin/sh | grep "busybox") - -userId=\$(id -u) - -exec 2>/dev/null - -'''); + sb.write(scriptHeader); // Write each function for (final func in ShellFunc.values) { final customCmdsStr = getCustomCmdsString(func, customCmds); @@ -186,17 +167,40 @@ esac'''); return sb.toString(); } + /// Get Unix-specific command for a shell function String _getUnixCommand(ShellFunc func) { switch (func) { case ShellFunc.status: - return ''' -if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then -\t${StatusCmdType.values.map((e) => e.cmd).join(ShellFunc.cmdDivider)} -else -\t${BSDStatusCmdType.values.map((e) => e.cmd).join(ShellFunc.cmdDivider)} -fi'''; + return _getUnixStatusCommand(); case ShellFunc.process: - return ''' + return _getUnixProcessCommand(); + case ShellFunc.shutdown: + return _getUnixShutdownCommand(); + case ShellFunc.reboot: + return _getUnixRebootCommand(); + case ShellFunc.suspend: + return _getUnixSuspendCommand(); + } + } + + /// Get Unix status command with OS detection + String _getUnixStatusCommand() { + // Generate command lists for better readability + final linuxCommands = StatusCmdType.values.map((e) => e.cmd).join(cmdDivider); + + final bsdCommands = BSDStatusCmdType.values.map((e) => e.cmd).join(cmdDivider); + + return ''' +if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then +\t$linuxCommands +else +\t$bsdCommands +fi'''; + } + + /// Get Unix process command with busybox detection + String _getUnixProcessCommand() { + return ''' if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then \tif [ "\$isBusybox" != "" ]; then \t\tps w @@ -205,30 +209,37 @@ if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then \tfi else \tps -ax -fi -'''; - case ShellFunc.shutdown: - return ''' +fi'''; + } + + /// Get Unix shutdown command with privilege detection + String _getUnixShutdownCommand() { + return ''' if [ "\$userId" = "0" ]; then \tshutdown -h now else \tsudo -S shutdown -h now fi'''; - case ShellFunc.reboot: - return ''' + } + + /// Get Unix reboot command with privilege detection + String _getUnixRebootCommand() { + return ''' if [ "\$userId" = "0" ]; then \treboot else \tsudo -S reboot fi'''; - case ShellFunc.suspend: - return ''' + } + + /// Get Unix suspend command with privilege detection + String _getUnixSuspendCommand() { + return ''' if [ "\$userId" = "0" ]; then \tsystemctl suspend else \tsudo -S systemctl suspend fi'''; - } } } @@ -236,7 +247,13 @@ fi'''; class ScriptBuilderFactory { const ScriptBuilderFactory._(); + /// Get the appropriate script builder based on platform static ScriptBuilder getBuilder(bool isWindows) { return isWindows ? const WindowsScriptBuilder() : const UnixScriptBuilder(); } -} \ No newline at end of file + + /// Get all available builders (useful for testing) + static List getAllBuilders() { + return const [WindowsScriptBuilder(), UnixScriptBuilder()]; + } +} diff --git a/lib/data/model/app/scripts/script_consts.dart b/lib/data/model/app/scripts/script_consts.dart new file mode 100644 index 00000000..6c952ffe --- /dev/null +++ b/lib/data/model/app/scripts/script_consts.dart @@ -0,0 +1,100 @@ +import 'package:server_box/data/res/build_data.dart'; + +/// Constants used throughout the script system +class ScriptConstants { + const ScriptConstants._(); + + // Script file names + static const String scriptFile = 'srvboxm_v${BuildData.script}.sh'; + static const String scriptFileWindows = 'srvboxm_v${BuildData.script}.ps1'; + + // Script directories + static const String scriptDirHome = '~/.config/server_box'; + static const String scriptDirTmp = '/tmp/server_box'; + static const String scriptDirHomeWindows = '%USERPROFILE%/.config/server_box'; + static const String scriptDirTmpWindows = '%TEMP%/server_box'; + + // Command separators and dividers + static const String separator = 'SrvBoxSep'; + + /// The suffix `\t` is for formatting + static const String cmdDivider = '\necho $separator\n\t'; + + // Path separators + static const String unixPathSeparator = '/'; + static const String windowsPathSeparator = '\\'; + + // Script headers + static const String unixScriptHeader = + ''' +#!/bin/sh +# Script for ServerBox app v1.0.${BuildData.build} +# DO NOT delete this file while app is running + +export LANG=en_US.UTF-8 + +# If macSign & bsdSign are both empty, then it's linux +macSign=\$(uname -a 2>&1 | grep "Darwin") +bsdSign=\$(uname -a 2>&1 | grep "BSD") + +# Link /bin/sh to busybox? +isBusybox=\$(ls -l /bin/sh | grep "busybox") + +userId=\$(id -u) + +exec 2>/dev/null + +'''; + + static const String windowsScriptHeader = + ''' +# PowerShell script for ServerBox app v1.0.${BuildData.build} +# DO NOT delete this file while app is running + +\$ErrorActionPreference = "SilentlyContinue" + +'''; +} + +/// Script path configuration and management +class ScriptPaths { + ScriptPaths._(); + + static final Map _scriptDirMap = {}; + + /// Get the script directory for the given [id]. + /// + /// Default is [ScriptConstants.scriptDirTmp]/[ScriptConstants.scriptFile], + /// if this path is not accessible, it will be changed to + /// [ScriptConstants.scriptDirHome]/[ScriptConstants.scriptFile]. + static String getScriptDir(String id, {bool isWindows = false}) { + final defaultTmpDir = isWindows ? ScriptConstants.scriptDirTmpWindows : ScriptConstants.scriptDirTmp; + _scriptDirMap[id] ??= defaultTmpDir; + return _scriptDirMap[id]!; + } + + /// Switch between tmp and home directories for script storage + static String switchScriptDir(String id, {bool isWindows = false}) { + return switch (_scriptDirMap[id]) { + ScriptConstants.scriptDirTmp => _scriptDirMap[id] = ScriptConstants.scriptDirHome, + ScriptConstants.scriptDirTmpWindows => _scriptDirMap[id] = ScriptConstants.scriptDirHomeWindows, + ScriptConstants.scriptDirHome => _scriptDirMap[id] = ScriptConstants.scriptDirTmp, + ScriptConstants.scriptDirHomeWindows => _scriptDirMap[id] = ScriptConstants.scriptDirTmpWindows, + _ => + _scriptDirMap[id] = isWindows ? ScriptConstants.scriptDirHomeWindows : ScriptConstants.scriptDirHome, + }; + } + + /// Get the full script path for the given [id] + static String getScriptPath(String id, {bool isWindows = false}) { + final dir = getScriptDir(id, isWindows: isWindows); + final fileName = isWindows ? ScriptConstants.scriptFileWindows : ScriptConstants.scriptFile; + final separator = isWindows ? ScriptConstants.windowsPathSeparator : ScriptConstants.unixPathSeparator; + return '$dir$separator$fileName'; + } + + /// Clear cached script directories (useful for testing) + static void clearCache() { + _scriptDirMap.clear(); + } +} diff --git a/lib/data/model/app/scripts/shell_func.dart b/lib/data/model/app/scripts/shell_func.dart new file mode 100644 index 00000000..71326a70 --- /dev/null +++ b/lib/data/model/app/scripts/shell_func.dart @@ -0,0 +1,102 @@ +import 'package:server_box/data/model/app/scripts/script_builders.dart'; +import 'package:server_box/data/model/app/scripts/script_consts.dart'; +import 'package:server_box/data/model/server/system.dart'; +import 'package:server_box/data/provider/server.dart'; + +/// Shell functions available in the ServerBox application +enum ShellFunc { + status('SbStatus'), + process('SbProcess'), + shutdown('SbShutdown'), + reboot('SbReboot'), + suspend('SbSuspend'); + + /// The function name used in scripts + final String name; + + const ShellFunc(this.name); + + /// Get the command line flag for this function + String get flag => switch (this) { + ShellFunc.process => 'p', + ShellFunc.shutdown => 'sd', + ShellFunc.reboot => 'r', + ShellFunc.suspend => 'sp', + ShellFunc.status => 's', + }; + + /// Execute this shell function on the specified server + String exec(String id, {SystemType? systemType}) { + final scriptPath = ShellFuncManager.getScriptPath(id, systemType: systemType); + final isWindows = systemType == SystemType.windows; + final builder = ScriptBuilderFactory.getBuilder(isWindows); + + return builder.getExecCommand(scriptPath, this); + } +} + +/// Manager class for shell function operations +class ShellFuncManager { + const ShellFuncManager._(); + + /// Normalize a directory path to ensure it doesn't end with trailing separators + static String _normalizeDir(String dir, bool isWindows) { + final separator = isWindows ? ScriptConstants.windowsPathSeparator : ScriptConstants.unixPathSeparator; + + // Remove all trailing separators + final pattern = RegExp('${RegExp.escape(separator)}+\$'); + return dir.replaceAll(pattern, ''); + } + + /// Get the script directory for the given [id]. + /// + /// Checks for custom script directory first, then falls back to default. + static String getScriptDir(String id, {SystemType? systemType}) { + final customScriptDir = ServerProvider.pick(id: id)?.value.spi.custom?.scriptDir; + final isWindows = systemType == SystemType.windows; + + if (customScriptDir != null) return _normalizeDir(customScriptDir, isWindows); + return ScriptPaths.getScriptDir(id, isWindows: isWindows); + } + + /// Switch between tmp and home directories for script storage + static void switchScriptDir(String id, {SystemType? systemType}) { + final isWindows = systemType == SystemType.windows; + ScriptPaths.switchScriptDir(id, isWindows: isWindows); + } + + /// Get the full script path for the given [id] + static String getScriptPath(String id, {SystemType? systemType}) { + final customScriptDir = ServerProvider.pick(id: id)?.value.spi.custom?.scriptDir; + if (customScriptDir != null) { + final isWindows = systemType == SystemType.windows; + final normalizedDir = _normalizeDir(customScriptDir, isWindows); + final fileName = isWindows ? ScriptConstants.scriptFileWindows : ScriptConstants.scriptFile; + final separator = isWindows ? ScriptConstants.windowsPathSeparator : ScriptConstants.unixPathSeparator; + return '$normalizedDir$separator$fileName'; + } + + final isWindows = systemType == SystemType.windows; + return ScriptPaths.getScriptPath(id, isWindows: isWindows); + } + + /// Get the installation shell command for the script + static String getInstallShellCmd(String id, {SystemType? systemType}) { + final scriptDir = getScriptDir(id, systemType: systemType); + final isWindows = systemType == SystemType.windows; + final normalizedDir = _normalizeDir(scriptDir, isWindows); + final builder = ScriptBuilderFactory.getBuilder(isWindows); + final separator = isWindows ? ScriptConstants.windowsPathSeparator : ScriptConstants.unixPathSeparator; + final scriptPath = '$normalizedDir$separator${builder.scriptFileName}'; + + return builder.getInstallCommand(normalizedDir, scriptPath); + } + + /// Generate complete script based on system type + static String allScript(Map? customCmds, {SystemType? systemType}) { + final isWindows = systemType == SystemType.windows; + final builder = ScriptBuilderFactory.getBuilder(isWindows); + + return builder.buildScript(customCmds); + } +} diff --git a/lib/data/model/app/shell_func.dart b/lib/data/model/app/shell_func.dart deleted file mode 100644 index cb042f47..00000000 --- a/lib/data/model/app/shell_func.dart +++ /dev/null @@ -1,242 +0,0 @@ -import 'package:server_box/core/extension/context/locale.dart'; -import 'package:server_box/data/model/app/script_builders.dart'; -import 'package:server_box/data/model/server/system.dart'; -import 'package:server_box/data/provider/server.dart'; -import 'package:server_box/data/res/build_data.dart'; - -enum ShellFunc { - status('SbStatus'), - //docker, - process('SbProcess'), - shutdown('SbShutdown'), - reboot('SbReboot'), - suspend('SbSuspend'); - - final String name; - - const ShellFunc(this.name); - - static const seperator = 'SrvBoxSep'; - - /// The suffix `\t` is for formatting - static const cmdDivider = '\necho $seperator\n\t'; - - /// srvboxm -> ServerBox Mobile - static const scriptFile = 'srvboxm_v${BuildData.script}.sh'; - static const scriptFileWindows = 'srvboxm_v${BuildData.script}.ps1'; - static const scriptDirHome = '~/.config/server_box'; - static const scriptDirTmp = '/tmp/server_box'; - static const scriptDirHomeWindows = '%USERPROFILE%/.config/server_box'; - static const scriptDirTmpWindows = '%TEMP%/server_box'; - - static final _scriptDirMap = {}; - - /// Get the script directory for the given [id]. - /// - /// Default is [scriptDirTmp]/[scriptFile], if this path is not accessible, - /// it will be changed to [scriptDirHome]/[scriptFile]. - static String getScriptDir(String id, {SystemType? systemType}) { - final customScriptDir = ServerProvider.pick(id: id)?.value.spi.custom?.scriptDir; - if (customScriptDir != null) return customScriptDir; - - final defaultTmpDir = systemType == SystemType.windows ? scriptDirTmpWindows : scriptDirTmp; - _scriptDirMap[id] ??= defaultTmpDir; - return _scriptDirMap[id]!; - } - - static void switchScriptDir(String id, {SystemType? systemType}) => switch (_scriptDirMap[id]) { - scriptDirTmp => _scriptDirMap[id] = scriptDirHome, - scriptDirTmpWindows => _scriptDirMap[id] = scriptDirHomeWindows, - scriptDirHome => _scriptDirMap[id] = scriptDirTmp, - scriptDirHomeWindows => _scriptDirMap[id] = scriptDirTmpWindows, - _ => _scriptDirMap[id] = systemType == SystemType.windows ? scriptDirHomeWindows : scriptDirHome, - }; - - static String getScriptPath(String id, {SystemType? systemType}) { - final dir = getScriptDir(id, systemType: systemType); - final fileName = systemType == SystemType.windows ? scriptFileWindows : scriptFile; - final separator = systemType == SystemType.windows ? '\\' : '/'; - return '$dir$separator$fileName'; - } - - static String getInstallShellCmd(String id, {SystemType? systemType}) { - final scriptDir = getScriptDir(id, systemType: systemType); - final isWindows = systemType == SystemType.windows; - final builder = ScriptBuilderFactory.getBuilder(isWindows); - final separator = isWindows ? '\\' : '/'; - final scriptPath = '$scriptDir$separator${builder.scriptFileName}'; - - return builder.getInstallCommand(scriptDir, scriptPath); - } - - String get flag => switch (this) { - ShellFunc.process => 'p', - ShellFunc.shutdown => 'sd', - ShellFunc.reboot => 'r', - ShellFunc.suspend => 'sp', - ShellFunc.status => 's', - // ShellFunc.docker=> 'd', - }; - - String exec(String id, {SystemType? systemType}) { - final scriptPath = getScriptPath(id, systemType: systemType); - final isWindows = systemType == SystemType.windows; - final builder = ScriptBuilderFactory.getBuilder(isWindows); - - return builder.getExecCommand(scriptPath, this); - } - - - - /// Generate script based on system type - static String allScript(Map? customCmds, {SystemType? systemType}) { - final isWindows = systemType == SystemType.windows; - final builder = ScriptBuilderFactory.getBuilder(isWindows); - - return builder.buildScript(customCmds); - } - -} - -enum StatusCmdType { - echo._('echo ${SystemType.linuxSign}'), - time._('date +%s'), - net._('cat /proc/net/dev'), - sys._('cat /etc/*-release | grep ^PRETTY_NAME'), - cpu._('cat /proc/stat | grep cpu'), - uptime._('uptime'), - conn._('cat /proc/net/snmp'), - disk._( - 'lsblk --bytes --json --output ' - 'FSTYPE,PATH,NAME,KNAME,MOUNTPOINT,FSSIZE,FSUSED,FSAVAIL,FSUSE%,UUID', - ), - mem._("cat /proc/meminfo | grep -E 'Mem|Swap'"), - tempType._('cat /sys/class/thermal/thermal_zone*/type'), - tempVal._('cat /sys/class/thermal/thermal_zone*/temp'), - host._('cat /etc/hostname'), - diskio._('cat /proc/diskstats'), - battery._('for f in /sys/class/power_supply/*/uevent; do cat "\$f"; echo; done'), - nvidia._('nvidia-smi -q -x'), - amd._( - 'if command -v amd-smi >/dev/null 2>&1; then ' - 'amd-smi list --json && amd-smi metric --json; ' - 'elif command -v rocm-smi >/dev/null 2>&1; then ' - 'rocm-smi --json || rocm-smi --showunique --showuse --showtemp ' - '--showfan --showclocks --showmemuse --showpower; ' - 'elif command -v radeontop >/dev/null 2>&1; then ' - 'timeout 2s radeontop -d - -l 1 | tail -n +2; ' - 'else echo "No AMD GPU monitoring tools found"; fi', - ), - sensors._('sensors'), - diskSmart._('for d in \$(lsblk -dn -o KNAME); do smartctl -a -j /dev/\$d; echo; done'), - cpuBrand._('cat /proc/cpuinfo | grep "model name"'); - - final String cmd; - - const StatusCmdType._(this.cmd); -} - -enum BSDStatusCmdType { - echo._('echo ${SystemType.bsdSign}'), - time._('date +%s'), - net._('netstat -ibn'), - sys._('uname -or'), - cpu._('top -l 1 | grep "CPU usage"'), - uptime._('uptime'), - // Keep df -k for BSD systems as lsblk is not available on macOS/BSD - disk._('df -k'), - mem._('top -l 1 | grep PhysMem'), - //temp, - host._('hostname'), - cpuBrand._('sysctl -n machdep.cpu.brand_string'); - - final String cmd; - - const BSDStatusCmdType._(this.cmd); -} - -extension StatusCmdTypeX on StatusCmdType { - String get i18n => switch (this) { - StatusCmdType.sys => l10n.system, - StatusCmdType.host => l10n.host, - StatusCmdType.uptime => l10n.uptime, - StatusCmdType.battery => l10n.battery, - StatusCmdType.sensors => l10n.sensors, - StatusCmdType.disk => l10n.disk, - final val => val.name, - }; -} - -enum WindowsStatusCmdType { - echo._('echo ${SystemType.windowsSign}'), - time._('[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()'), - net._( - r'Get-Counter -Counter ' - r'"\\NetworkInterface(*)\\Bytes Received/sec", ' - r'"\\NetworkInterface(*)\\Bytes Sent/sec" ' - r'-SampleInterval 1 -MaxSamples 2 | ConvertTo-Json', - ), - sys._('(Get-ComputerInfo).OsName'), - cpu._( - 'Get-WmiObject -Class Win32_Processor | ' - 'Select-Object Name, LoadPercentage | ConvertTo-Json', - ), - uptime._('(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime'), - conn._('(netstat -an | findstr ESTABLISHED | Measure-Object -Line).Count'), - disk._( - 'Get-WmiObject -Class Win32_LogicalDisk | ' - 'Select-Object DeviceID, Size, FreeSpace, FileSystem | ConvertTo-Json', - ), - mem._( - 'Get-WmiObject -Class Win32_OperatingSystem | ' - 'Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json', - ), - temp._( - 'Get-CimInstance -ClassName MSAcpi_ThermalZoneTemperature ' - '-Namespace root/wmi -ErrorAction SilentlyContinue | ' - 'Select-Object InstanceName, @{Name=\'Temperature\';' - 'Expression={[math]::Round((\$_.CurrentTemperature - 2732) / 10, 1)}} | ' - 'ConvertTo-Json', - ), - host._(r'Write-Output $env:COMPUTERNAME'), - diskio._( - r'Get-Counter -Counter ' - r'"\\PhysicalDisk(*)\\Disk Read Bytes/sec", ' - r'"\\PhysicalDisk(*)\\Disk Write Bytes/sec" ' - r'-SampleInterval 1 -MaxSamples 2 | ConvertTo-Json', - ), - battery._( - 'Get-WmiObject -Class Win32_Battery | ' - 'Select-Object EstimatedChargeRemaining, BatteryStatus | ConvertTo-Json', - ), - nvidia._( - 'if (Get-Command nvidia-smi -ErrorAction SilentlyContinue) { ' - 'nvidia-smi -q -x } else { echo "NVIDIA driver not found" }', - ), - amd._( - 'if (Get-Command amd-smi -ErrorAction SilentlyContinue) { ' - 'amd-smi list --json } else { echo "AMD driver not found" }', - ), - sensors._( - 'Get-CimInstance -ClassName Win32_TemperatureProbe ' - '-ErrorAction SilentlyContinue | ' - 'Select-Object Name, CurrentReading | ConvertTo-Json', - ), - diskSmart._( - 'Get-PhysicalDisk | Get-StorageReliabilityCounter | ' - 'Select-Object DeviceId, Temperature, TemperatureMax, Wear, PowerOnHours | ' - 'ConvertTo-Json', - ), - cpuBrand._('(Get-WmiObject -Class Win32_Processor).Name'); - - final String cmd; - - const WindowsStatusCmdType._(this.cmd); -} - -extension EnumX on Enum { - /// Find out the required segment from [segments] - String find(List segments) { - return segments[index]; - } -} diff --git a/lib/data/model/server/server.dart b/lib/data/model/server/server.dart index 1bc5fd22..4489dfa4 100644 --- a/lib/data/model/server/server.dart +++ b/lib/data/model/server/server.dart @@ -1,6 +1,6 @@ import 'package:dartssh2/dartssh2.dart'; import 'package:fl_lib/fl_lib.dart'; -import 'package:server_box/data/model/app/shell_func.dart'; +import 'package:server_box/data/model/app/scripts/cmd_types.dart'; import 'package:server_box/data/model/server/amd.dart'; import 'package:server_box/data/model/server/battery.dart'; import 'package:server_box/data/model/server/conn.dart'; diff --git a/lib/data/model/server/server_status_update_req.dart b/lib/data/model/server/server_status_update_req.dart index 7d160b02..d251133e 100644 --- a/lib/data/model/server/server_status_update_req.dart +++ b/lib/data/model/server/server_status_update_req.dart @@ -1,7 +1,8 @@ import 'dart:convert'; import 'package:fl_lib/fl_lib.dart'; -import 'package:server_box/data/model/app/shell_func.dart'; +import 'package:server_box/data/model/app/scripts/cmd_types.dart'; +import 'package:server_box/data/model/app/scripts/script_consts.dart'; import 'package:server_box/data/model/server/amd.dart'; import 'package:server_box/data/model/server/battery.dart'; import 'package:server_box/data/model/server/conn.dart'; @@ -295,7 +296,7 @@ String? _parseSysVer(String raw) { String? _parseHostName(String raw) { if (raw.isEmpty) return null; - if (raw.contains(ShellFunc.scriptFile)) return null; + if (raw.contains(ScriptConstants.scriptFile)) return null; return raw; } diff --git a/lib/data/model/server/system.dart b/lib/data/model/server/system.dart index 73cea8bf..8eacfd3e 100644 --- a/lib/data/model/server/system.dart +++ b/lib/data/model/server/system.dart @@ -1,5 +1,5 @@ import 'package:fl_lib/fl_lib.dart'; -import 'package:server_box/data/model/app/shell_func.dart'; +import 'package:server_box/data/model/app/scripts/cmd_types.dart'; enum SystemType { linux(linuxSign), diff --git a/lib/data/provider/container.dart b/lib/data/provider/container.dart index d642dfbb..e4ead47c 100644 --- a/lib/data/provider/container.dart +++ b/lib/data/provider/container.dart @@ -6,7 +6,7 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:server_box/core/extension/ssh_client.dart'; import 'package:server_box/data/model/app/error.dart'; -import 'package:server_box/data/model/app/shell_func.dart'; +import 'package:server_box/data/model/app/scripts/script_consts.dart'; import 'package:server_box/data/model/container/image.dart'; import 'package:server_box/data/model/container/ps.dart'; import 'package:server_box/data/model/container/type.dart'; @@ -109,7 +109,7 @@ class ContainerProvider extends ChangeNotifier { } // Check result segments count - final segments = raw.split(ShellFunc.seperator); + final segments = raw.split(ScriptConstants.separator); if (segments.length != ContainerCmdType.values.length) { error = ContainerErr( type: ContainerErrType.segmentsNotMatch, @@ -270,7 +270,7 @@ enum ContainerCmdType { stats, images // No specific commands needed for prune actions as they are simple - // and don't require splitting output with ShellFunc.seperator + // and don't require splitting output with ScriptConstants.separator ; String exec(ContainerType type, {bool sudo = false, bool includeStats = false}) { @@ -296,6 +296,11 @@ enum ContainerCmdType { static String execAll(ContainerType type, {bool sudo = false, bool includeStats = false}) { return ContainerCmdType.values .map((e) => e.exec(type, sudo: sudo, includeStats: includeStats)) - .join('\necho ${ShellFunc.seperator}\n'); + .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 ca404fb1..ef8597e7 100644 --- a/lib/data/provider/server.dart +++ b/lib/data/provider/server.dart @@ -11,7 +11,8 @@ import 'package:server_box/core/utils/server.dart'; import 'package:server_box/core/utils/ssh_auth.dart'; import 'package:server_box/data/helper/system_detector.dart'; import 'package:server_box/data/model/app/error.dart'; -import 'package:server_box/data/model/app/shell_func.dart'; +import 'package:server_box/data/model/app/scripts/script_consts.dart'; +import 'package:server_box/data/model/app/scripts/shell_func.dart'; import 'package:server_box/data/model/server/server.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/server_status_update_req.dart'; @@ -337,12 +338,12 @@ class ServerProvider extends Provider { sv.status.system = detectedSystemType; final (_, writeScriptResult) = await sv.client!.exec((session) async { - final scriptRaw = ShellFunc.allScript(spi.custom?.cmds, systemType: detectedSystemType).uint8List; + final scriptRaw = ShellFuncManager.allScript(spi.custom?.cmds, systemType: detectedSystemType).uint8List; session.stdin.add(scriptRaw); session.stdin.close(); - }, entry: ShellFunc.getInstallShellCmd(spi.id, systemType: detectedSystemType)); + }, entry: ShellFuncManager.getInstallShellCmd(spi.id, systemType: detectedSystemType)); if (writeScriptResult.isNotEmpty && detectedSystemType != SystemType.windows) { - ShellFunc.switchScriptDir(spi.id, systemType: detectedSystemType); + ShellFuncManager.switchScriptDir(spi.id, systemType: detectedSystemType); throw writeScriptResult; } } on SSHAuthAbortError catch (e) { @@ -384,7 +385,7 @@ class ServerProvider extends Provider { try { raw = await sv.client?.run(ShellFunc.status.exec(spi.id, systemType: sv.status.system)).string; dprint('Get status from ${spi.name}:\n$raw'); - segments = raw?.split(ShellFunc.seperator).map((e) => e.trim()).toList(); + segments = raw?.split(ScriptConstants.separator).map((e) => e.trim()).toList(); if (raw == null || raw.isEmpty || segments == null || segments.isEmpty) { if (Stores.setting.keepStatusWhenErr.fetch()) { // Keep previous server status when err occurs diff --git a/lib/data/provider/systemd.dart b/lib/data/provider/systemd.dart index 131c5245..e634c23c 100644 --- a/lib/data/provider/systemd.dart +++ b/lib/data/provider/systemd.dart @@ -1,6 +1,6 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:server_box/core/extension/ssh_client.dart'; -import 'package:server_box/data/model/app/shell_func.dart'; +import 'package:server_box/data/model/app/scripts/script_consts.dart'; import 'package:server_box/data/model/server/server.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/systemd.dart'; @@ -60,12 +60,12 @@ final class SystemdProvider { ''' for unit in ${unitNames_.join(' ')}; do state=\$(systemctl show --no-pager \$unit) - echo -n "${ShellFunc.seperator}\n\$state" + echo -n "${ScriptConstants.separator}\n\$state" done '''; final client = _si.value.client!; final result = await client.execForOutput(script); - final units = result.split(ShellFunc.seperator); + final units = result.split(ScriptConstants.separator); final parsedUnits = []; for (final unit in units) { diff --git a/lib/data/res/github_id.dart b/lib/data/res/github_id.dart index 647b7890..ed42a9f4 100644 --- a/lib/data/res/github_id.dart +++ b/lib/data/res/github_id.dart @@ -119,6 +119,11 @@ abstract final class GithubIds { 'AstroEngineeer', 'mochasweet', 'back-lacking', + 'cainiaojr', + 'MisterMunkerz', + 'CreeperKong', + 'zxf945', + 'cnen2018', }; } diff --git a/lib/view/page/process.dart b/lib/view/page/process.dart index d761807b..d4cbd4a9 100644 --- a/lib/view/page/process.dart +++ b/lib/view/page/process.dart @@ -5,7 +5,7 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/route.dart'; -import 'package:server_box/data/model/app/shell_func.dart'; +import 'package:server_box/data/model/app/scripts/shell_func.dart'; import 'package:server_box/data/model/server/proc.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/res/store.dart'; diff --git a/lib/view/page/server/detail/view.dart b/lib/view/page/server/detail/view.dart index 6856a750..1e9786da 100644 --- a/lib/view/page/server/detail/view.dart +++ b/lib/view/page/server/detail/view.dart @@ -6,8 +6,8 @@ import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:icons_plus/icons_plus.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/route.dart'; +import 'package:server_box/data/model/app/scripts/cmd_types.dart'; import 'package:server_box/data/model/app/server_detail_card.dart'; -import 'package:server_box/data/model/app/shell_func.dart'; import 'package:server_box/data/model/server/amd.dart'; import 'package:server_box/data/model/server/battery.dart'; import 'package:server_box/data/model/server/cpu.dart'; @@ -23,7 +23,6 @@ import 'package:server_box/data/res/store.dart'; import 'package:server_box/view/page/pve.dart'; import 'package:server_box/view/page/server/edit.dart'; import 'package:server_box/view/page/server/logo.dart'; - import 'package:server_box/view/widget/server_func_btns.dart'; part 'misc.dart'; diff --git a/lib/view/page/server/logo.dart b/lib/view/page/server/logo.dart index f8e5d947..fecf30e7 100644 --- a/lib/view/page/server/logo.dart +++ b/lib/view/page/server/logo.dart @@ -1,6 +1,6 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; -import 'package:server_box/data/model/app/shell_func.dart'; +import 'package:server_box/data/model/app/scripts/cmd_types.dart'; import 'package:server_box/data/model/server/dist.dart'; import 'package:server_box/data/model/server/server.dart'; import 'package:server_box/data/res/store.dart'; diff --git a/lib/view/page/server/tab/tab.dart b/lib/view/page/server/tab/tab.dart index a527ae04..53b680c2 100644 --- a/lib/view/page/server/tab/tab.dart +++ b/lib/view/page/server/tab/tab.dart @@ -11,7 +11,8 @@ import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/extension/ssh_client.dart'; import 'package:server_box/core/route.dart'; import 'package:server_box/data/model/app/net_view.dart'; -import 'package:server_box/data/model/app/shell_func.dart'; +import 'package:server_box/data/model/app/scripts/cmd_types.dart'; +import 'package:server_box/data/model/app/scripts/shell_func.dart'; import 'package:server_box/data/model/server/server.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/try_limiter.dart'; diff --git a/pubspec.lock b/pubspec.lock index e9f0015a..670e467d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1821,8 +1821,8 @@ packages: dependency: "direct main" description: path: "." - ref: "v1.0.588" - resolved-ref: d28207b988b5bed38c799618b9c412486592c689 + ref: "v4.0.3" + resolved-ref: c64183346b924173eb7251800001a64771911185 url: "https://github.com/lollipopkit/xterm.dart" source: git version: "4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 7f37b2a0..d440aaef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,7 +47,7 @@ dependencies: xterm: git: url: https://github.com/lollipopkit/xterm.dart - ref: v1.0.588 + ref: v4.0.3 computer: git: url: https://github.com/lollipopkit/dart_computer diff --git a/test/script_builder_test.dart b/test/script_builder_test.dart new file mode 100644 index 00000000..b612a6d6 --- /dev/null +++ b/test/script_builder_test.dart @@ -0,0 +1,186 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/data/model/app/scripts/script_builders.dart'; +import 'package:server_box/data/model/app/scripts/script_consts.dart'; +import 'package:server_box/data/model/app/scripts/shell_func.dart'; +import 'package:server_box/data/model/server/system.dart'; + +void main() { + group('Script Builder Integration Tests', () { + test('script generation produces valid output for all platforms', () { + for (final builder in ScriptBuilderFactory.getAllBuilders()) { + final script = builder.buildScript(null); + + // Basic validation + expect(script, isNotEmpty, reason: 'Script should not be empty for ${builder.runtimeType}'); + + // Should contain all required functions + for (final func in ShellFunc.values) { + expect(script, contains(func.name), reason: 'Script should contain function ${func.name}'); + } + + // Should contain proper headers + expect(script, contains(builder.scriptHeader), reason: 'Script should contain proper header'); + + // Should be well-formed + if (builder is WindowsScriptBuilder) { + expect( + script, + contains('switch (\$args[0])'), + reason: 'Windows script should contain switch statement', + ); + expect( + script, + contains('PowerShell script for ServerBox'), + reason: 'Windows script should have PowerShell header', + ); + } else if (builder is UnixScriptBuilder) { + expect(script, contains('#!/bin/sh'), reason: 'Unix script should have shebang'); + expect(script, contains('case \$1 in'), reason: 'Unix script should contain case statement'); + } + } + }); + + test('script generation with custom commands works correctly', () { + final customCmds = {'custom_test': 'echo "Custom test command"', 'another_cmd': 'whoami'}; + + for (final builder in ScriptBuilderFactory.getAllBuilders()) { + final script = builder.buildScript(customCmds); + + expect(script, isNotEmpty); + + // Custom commands should only be included in status function for both platforms + if (builder is UnixScriptBuilder) { + expect(script, contains('echo "Custom test command"')); + expect(script, contains('whoami')); + } + + // Windows builder should include custom commands in SbStatus function + if (builder is WindowsScriptBuilder) { + expect(script, contains('echo "Custom test command"')); + expect(script, contains('whoami')); + } + } + }); + + test('script file names are correct for each platform', () { + final windowsBuilder = ScriptBuilderFactory.getBuilder(true); + final unixBuilder = ScriptBuilderFactory.getBuilder(false); + + expect(windowsBuilder.scriptFileName, equals(ScriptConstants.scriptFileWindows)); + expect(windowsBuilder.scriptFileName, endsWith('.ps1')); + + expect(unixBuilder.scriptFileName, equals(ScriptConstants.scriptFile)); + expect(unixBuilder.scriptFileName, endsWith('.sh')); + }); + + test('install commands are generated correctly', () { + const testDir = '/tmp/test'; + const testPath = '/tmp/test/script.sh'; + + final unixBuilder = ScriptBuilderFactory.getBuilder(false); + final installCmd = unixBuilder.getInstallCommand(testDir, testPath); + + expect(installCmd, contains('mkdir')); + expect(installCmd, contains('chmod 755')); + expect(installCmd, contains(testPath)); + + const testDirWindows = 'C:\\temp\\test'; + const testPathWindows = 'C:\\temp\\test\\script.ps1'; + + final windowsBuilder = ScriptBuilderFactory.getBuilder(true); + final installCmdWindows = windowsBuilder.getInstallCommand(testDirWindows, testPathWindows); + + expect(installCmdWindows, contains('New-Item')); + expect(installCmdWindows, contains('Set-Content')); + expect(installCmdWindows, contains(testPathWindows)); + }); + + test('exec commands are generated correctly for all platforms', () { + const testPath = '/tmp/test/script.sh'; + const testPathWindows = 'C:\\temp\\test\\script.ps1'; + + final unixBuilder = ScriptBuilderFactory.getBuilder(false); + final windowsBuilder = ScriptBuilderFactory.getBuilder(true); + + for (final func in ShellFunc.values) { + final unixExec = unixBuilder.getExecCommand(testPath, func); + expect(unixExec, contains(testPath)); + expect(unixExec, contains(func.flag)); + + final windowsExec = windowsBuilder.getExecCommand(testPathWindows, func); + expect(windowsExec, contains('powershell')); + expect(windowsExec, contains('-ExecutionPolicy Bypass')); + expect(windowsExec, contains(func.flag)); + } + }); + + test('script headers contain proper metadata', () { + final windowsBuilder = ScriptBuilderFactory.getBuilder(true); + final unixBuilder = ScriptBuilderFactory.getBuilder(false); + + expect(windowsBuilder.scriptHeader, contains('PowerShell script for ServerBox')); + expect(windowsBuilder.scriptHeader, contains('DO NOT delete this file')); + expect(windowsBuilder.scriptHeader, contains('\$ErrorActionPreference = "SilentlyContinue"')); + + expect(unixBuilder.scriptHeader, contains('#!/bin/sh')); + expect(unixBuilder.scriptHeader, contains('Script for ServerBox')); + expect(unixBuilder.scriptHeader, contains('DO NOT delete this file')); + expect(unixBuilder.scriptHeader, contains('export LANG=en_US.UTF-8')); + }); + + test('command dividers are consistent', () { + final windowsBuilder = ScriptBuilderFactory.getBuilder(true); + final unixBuilder = ScriptBuilderFactory.getBuilder(false); + + expect(windowsBuilder.cmdDivider, equals(ScriptConstants.cmdDivider)); + expect(unixBuilder.cmdDivider, equals(ScriptConstants.cmdDivider)); + expect(ScriptConstants.cmdDivider, contains(ScriptConstants.separator)); + }); + + test('scripts handle all system types properly', () { + // Test that system type detection is properly handled + final unixScript = ShellFuncManager.allScript(null, systemType: SystemType.linux); + final bsdScript = ShellFuncManager.allScript(null, systemType: SystemType.bsd); + final windowsScript = ShellFuncManager.allScript(null, systemType: SystemType.windows); + + expect(unixScript, contains('#!/bin/sh')); + expect(bsdScript, contains('#!/bin/sh')); // BSD uses same script as Linux + expect(windowsScript, contains('PowerShell script')); + + // Verify OS detection logic in Unix script + expect(unixScript, contains('macSign=')); + expect(unixScript, contains('bsdSign=')); + expect(unixScript, contains('isBusybox=')); + }); + + test('error handling in script generation', () { + // Test that script generation doesn't throw with edge cases + expect(() => ShellFuncManager.allScript({}, systemType: SystemType.linux), returnsNormally); + expect(() => ShellFuncManager.allScript(null, systemType: SystemType.windows), returnsNormally); + + // Test with empty custom commands + expect(() => ShellFuncManager.allScript({}, systemType: SystemType.bsd), returnsNormally); + + // Test with null system type (should default to something) + expect(() => ShellFuncManager.allScript(null), returnsNormally); + }); + }); + + group('ScriptBuilderFactory Tests', () { + test('factory returns correct builder types', () { + final windowsBuilder = ScriptBuilderFactory.getBuilder(true); + final unixBuilder = ScriptBuilderFactory.getBuilder(false); + + expect(windowsBuilder, isA()); + expect(unixBuilder, isA()); + }); + + test('getAllBuilders returns all available builders', () { + final builders = ScriptBuilderFactory.getAllBuilders(); + + expect(builders, hasLength(2)); + expect(builders.any((b) => b is WindowsScriptBuilder), isTrue); + expect(builders.any((b) => b is UnixScriptBuilder), isTrue); + }); + }); +} diff --git a/test/windows_test.dart b/test/windows_test.dart index cffa7051..ea8a473b 100644 --- a/test/windows_test.dart +++ b/test/windows_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:server_box/data/model/app/shell_func.dart'; +import 'package:server_box/data/model/app/scripts/cmd_types.dart'; +import 'package:server_box/data/model/app/scripts/shell_func.dart'; import 'package:server_box/data/model/server/server_status_update_req.dart'; import 'package:server_box/data/model/server/system.dart'; import 'package:server_box/data/res/status.dart'; @@ -14,7 +15,7 @@ void main() { }); test('should generate Windows PowerShell script correctly', () { - final script = ShellFunc.allScript({'custom_cmd': 'echo "test"'}, systemType: SystemType.windows); + final script = ShellFuncManager.allScript({'custom_cmd': 'echo "test"'}, systemType: SystemType.windows); expect(script, contains('PowerShell script for ServerBox')); expect(script, contains('function SbStatus')); @@ -225,11 +226,11 @@ void main() { test('should handle Windows script path generation', () { const serverId = 'test-server'; - final scriptPath = ShellFunc.getScriptPath(serverId, systemType: SystemType.windows); + final scriptPath = ShellFuncManager.getScriptPath(serverId, systemType: SystemType.windows); expect(scriptPath, contains('.ps1')); expect(scriptPath, contains('\\')); - final installCmd = ShellFunc.getInstallShellCmd(serverId, systemType: SystemType.windows); + final installCmd = ShellFuncManager.getInstallShellCmd(serverId, systemType: SystemType.windows); expect(installCmd, contains('New-Item')); expect(installCmd, contains('Set-Content')); // No longer contains 'powershell' prefix as commands now run in PowerShell session