fix: macOS ssh term unusable (#838)

This commit is contained in:
lollipopkit🏳️‍⚧️
2025-08-08 18:59:25 +08:00
committed by GitHub
parent 6880bcc192
commit 9c9648656d
22 changed files with 768 additions and 339 deletions

View File

@@ -65,6 +65,8 @@ After you read the above, you can open an [issue](https://github.com/lollipopkit
Any positive contribution is welcome. 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 ### Development
1. Setup [Flutter](https://flutter.dev/docs/get-started/install) environment. 1. Setup [Flutter](https://flutter.dev/docs/get-started/install) environment.

View File

@@ -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) 1. 安装 [Flutter](https://flutter.dev/docs/get-started/install)

View File

@@ -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<String> segments) {
return segments[index];
}
}

View File

@@ -1,5 +1,6 @@
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/res/build_data.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 base class for platform-specific script builders
abstract class ScriptBuilder { abstract class ScriptBuilder {
@@ -18,10 +19,13 @@ abstract class ScriptBuilder {
String getExecCommand(String scriptPath, ShellFunc func); String getExecCommand(String scriptPath, ShellFunc func);
/// Get custom commands string for this platform /// Get custom commands string for this platform
String getCustomCmdsString( String getCustomCmdsString(ShellFunc func, Map<String, String>? customCmds);
ShellFunc func,
Map<String, String>? 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 /// Windows PowerShell script builder
@@ -29,13 +33,16 @@ class WindowsScriptBuilder extends ScriptBuilder {
const WindowsScriptBuilder(); const WindowsScriptBuilder();
@override @override
String get scriptFileName => 'srvboxm_v${BuildData.script}.ps1'; String get scriptFileName => ScriptConstants.scriptFileWindows;
@override
String get scriptHeader => ScriptConstants.windowsScriptHeader;
@override @override
String getInstallCommand(String scriptDir, String scriptPath) { String getInstallCommand(String scriptDir, String scriptPath) {
return 'New-Item -ItemType Directory -Force -Path \'$scriptDir\' | Out-Null; ' return 'New-Item -ItemType Directory -Force -Path \'$scriptDir\' | Out-Null; '
'\$content = [System.Console]::In.ReadToEnd(); ' '\$content = [System.Console]::In.ReadToEnd(); '
'Set-Content -Path \'$scriptPath\' -Value \$content -Encoding UTF8'; 'Set-Content -Path \'$scriptPath\' -Value \$content -Encoding UTF8';
} }
@override @override
@@ -44,10 +51,7 @@ class WindowsScriptBuilder extends ScriptBuilder {
} }
@override @override
String getCustomCmdsString( String getCustomCmdsString(ShellFunc func, Map<String, String>? customCmds) {
ShellFunc func,
Map<String, String>? customCmds,
) {
if (func == ShellFunc.status && customCmds != null && customCmds.isNotEmpty) { if (func == ShellFunc.status && customCmds != null && customCmds.isNotEmpty) {
return '\n${customCmds.values.map((cmd) => '\t$cmd').join('\n')}'; return '\n${customCmds.values.map((cmd) => '\t$cmd').join('\n')}';
} }
@@ -57,13 +61,7 @@ class WindowsScriptBuilder extends ScriptBuilder {
@override @override
String buildScript(Map<String, String>? customCmds) { String buildScript(Map<String, String>? customCmds) {
final sb = StringBuffer(); final sb = StringBuffer();
sb.write(''' sb.write(scriptHeader);
# PowerShell script for ServerBox app v1.0.${BuildData.build}
# DO NOT delete this file while app is running
\$ErrorActionPreference = "SilentlyContinue"
''');
// Write each function // Write each function
for (final func in ShellFunc.values) { for (final func in ShellFunc.values) {
@@ -93,8 +91,9 @@ switch (\$args[0]) {
return sb.toString(); return sb.toString();
} }
/// Get Windows-specific command for a shell function
String _getWindowsCommand(ShellFunc func) => switch (func) { 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.process => 'Get-Process | Select-Object ProcessName, Id, CPU, WorkingSet | ConvertTo-Json',
ShellFunc.shutdown => 'Stop-Computer -Force', ShellFunc.shutdown => 'Stop-Computer -Force',
ShellFunc.reboot => 'Restart-Computer -Force', ShellFunc.reboot => 'Restart-Computer -Force',
@@ -108,7 +107,10 @@ class UnixScriptBuilder extends ScriptBuilder {
const UnixScriptBuilder(); const UnixScriptBuilder();
@override @override
String get scriptFileName => 'srvboxm_v${BuildData.script}.sh'; String get scriptFileName => ScriptConstants.scriptFile;
@override
String get scriptHeader => ScriptConstants.unixScriptHeader;
@override @override
String getInstallCommand(String scriptDir, String scriptPath) { String getInstallCommand(String scriptDir, String scriptPath) {
@@ -125,12 +127,9 @@ chmod 755 $scriptPath
} }
@override @override
String getCustomCmdsString( String getCustomCmdsString(ShellFunc func, Map<String, String>? customCmds) {
ShellFunc func,
Map<String, String>? customCmds,
) {
if (func == ShellFunc.status && customCmds != null && customCmds.isNotEmpty) { 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 ''; return '';
} }
@@ -138,25 +137,7 @@ chmod 755 $scriptPath
@override @override
String buildScript(Map<String, String>? customCmds) { String buildScript(Map<String, String>? customCmds) {
final sb = StringBuffer(); final sb = StringBuffer();
sb.write(''' sb.write(scriptHeader);
#!/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
''');
// Write each function // Write each function
for (final func in ShellFunc.values) { for (final func in ShellFunc.values) {
final customCmdsStr = getCustomCmdsString(func, customCmds); final customCmdsStr = getCustomCmdsString(func, customCmds);
@@ -186,17 +167,40 @@ esac''');
return sb.toString(); return sb.toString();
} }
/// Get Unix-specific command for a shell function
String _getUnixCommand(ShellFunc func) { String _getUnixCommand(ShellFunc func) {
switch (func) { switch (func) {
case ShellFunc.status: case ShellFunc.status:
return ''' return _getUnixStatusCommand();
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''';
case ShellFunc.process: 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 if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then
\tif [ "\$isBusybox" != "" ]; then \tif [ "\$isBusybox" != "" ]; then
\t\tps w \t\tps w
@@ -205,30 +209,37 @@ if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then
\tfi \tfi
else else
\tps -ax \tps -ax
fi fi''';
'''; }
case ShellFunc.shutdown:
return ''' /// Get Unix shutdown command with privilege detection
String _getUnixShutdownCommand() {
return '''
if [ "\$userId" = "0" ]; then if [ "\$userId" = "0" ]; then
\tshutdown -h now \tshutdown -h now
else else
\tsudo -S shutdown -h now \tsudo -S shutdown -h now
fi'''; fi''';
case ShellFunc.reboot: }
return '''
/// Get Unix reboot command with privilege detection
String _getUnixRebootCommand() {
return '''
if [ "\$userId" = "0" ]; then if [ "\$userId" = "0" ]; then
\treboot \treboot
else else
\tsudo -S reboot \tsudo -S reboot
fi'''; fi''';
case ShellFunc.suspend: }
return '''
/// Get Unix suspend command with privilege detection
String _getUnixSuspendCommand() {
return '''
if [ "\$userId" = "0" ]; then if [ "\$userId" = "0" ]; then
\tsystemctl suspend \tsystemctl suspend
else else
\tsudo -S systemctl suspend \tsudo -S systemctl suspend
fi'''; fi''';
}
} }
} }
@@ -236,7 +247,13 @@ fi''';
class ScriptBuilderFactory { class ScriptBuilderFactory {
const ScriptBuilderFactory._(); const ScriptBuilderFactory._();
/// Get the appropriate script builder based on platform
static ScriptBuilder getBuilder(bool isWindows) { static ScriptBuilder getBuilder(bool isWindows) {
return isWindows ? const WindowsScriptBuilder() : const UnixScriptBuilder(); return isWindows ? const WindowsScriptBuilder() : const UnixScriptBuilder();
} }
/// Get all available builders (useful for testing)
static List<ScriptBuilder> getAllBuilders() {
return const [WindowsScriptBuilder(), UnixScriptBuilder()];
}
} }

View File

@@ -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<String, String> _scriptDirMap = <String, String>{};
/// 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();
}
}

View File

@@ -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<String, String>? customCmds, {SystemType? systemType}) {
final isWindows = systemType == SystemType.windows;
final builder = ScriptBuilderFactory.getBuilder(isWindows);
return builder.buildScript(customCmds);
}
}

View File

@@ -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 = <String, String>{};
/// 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<String, String>? 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<String> segments) {
return segments[index];
}
}

View File

@@ -1,6 +1,6 @@
import 'package:dartssh2/dartssh2.dart'; import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.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/amd.dart';
import 'package:server_box/data/model/server/battery.dart'; import 'package:server_box/data/model/server/battery.dart';
import 'package:server_box/data/model/server/conn.dart'; import 'package:server_box/data/model/server/conn.dart';

View File

@@ -1,7 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'package:fl_lib/fl_lib.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/app/scripts/script_consts.dart';
import 'package:server_box/data/model/server/amd.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/battery.dart';
import 'package:server_box/data/model/server/conn.dart'; import 'package:server_box/data/model/server/conn.dart';
@@ -295,7 +296,7 @@ String? _parseSysVer(String raw) {
String? _parseHostName(String raw) { String? _parseHostName(String raw) {
if (raw.isEmpty) return null; if (raw.isEmpty) return null;
if (raw.contains(ShellFunc.scriptFile)) return null; if (raw.contains(ScriptConstants.scriptFile)) return null;
return raw; return raw;
} }

View File

@@ -1,5 +1,5 @@
import 'package:fl_lib/fl_lib.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';
enum SystemType { enum SystemType {
linux(linuxSign), linux(linuxSign),

View File

@@ -6,7 +6,7 @@ import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:server_box/core/extension/ssh_client.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/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/image.dart';
import 'package:server_box/data/model/container/ps.dart'; import 'package:server_box/data/model/container/ps.dart';
import 'package:server_box/data/model/container/type.dart'; import 'package:server_box/data/model/container/type.dart';
@@ -109,7 +109,7 @@ class ContainerProvider extends ChangeNotifier {
} }
// Check result segments count // Check result segments count
final segments = raw.split(ShellFunc.seperator); final segments = raw.split(ScriptConstants.separator);
if (segments.length != ContainerCmdType.values.length) { if (segments.length != ContainerCmdType.values.length) {
error = ContainerErr( error = ContainerErr(
type: ContainerErrType.segmentsNotMatch, type: ContainerErrType.segmentsNotMatch,
@@ -270,7 +270,7 @@ enum ContainerCmdType {
stats, stats,
images images
// No specific commands needed for prune actions as they are simple // 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}) { 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}) { static String execAll(ContainerType type, {bool sudo = false, bool includeStats = false}) {
return ContainerCmdType.values return ContainerCmdType.values
.map((e) => e.exec(type, sudo: sudo, includeStats: includeStats)) .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<String> segments) {
return segments[index];
} }
} }

View File

@@ -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/core/utils/ssh_auth.dart';
import 'package:server_box/data/helper/system_detector.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/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.dart';
import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/server_status_update_req.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; sv.status.system = detectedSystemType;
final (_, writeScriptResult) = await sv.client!.exec((session) async { 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.add(scriptRaw);
session.stdin.close(); session.stdin.close();
}, entry: ShellFunc.getInstallShellCmd(spi.id, systemType: detectedSystemType)); }, entry: ShellFuncManager.getInstallShellCmd(spi.id, systemType: detectedSystemType));
if (writeScriptResult.isNotEmpty && detectedSystemType != SystemType.windows) { if (writeScriptResult.isNotEmpty && detectedSystemType != SystemType.windows) {
ShellFunc.switchScriptDir(spi.id, systemType: detectedSystemType); ShellFuncManager.switchScriptDir(spi.id, systemType: detectedSystemType);
throw writeScriptResult; throw writeScriptResult;
} }
} on SSHAuthAbortError catch (e) { } on SSHAuthAbortError catch (e) {
@@ -384,7 +385,7 @@ class ServerProvider extends Provider {
try { try {
raw = await sv.client?.run(ShellFunc.status.exec(spi.id, systemType: sv.status.system)).string; raw = await sv.client?.run(ShellFunc.status.exec(spi.id, systemType: sv.status.system)).string;
dprint('Get status from ${spi.name}:\n$raw'); 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 (raw == null || raw.isEmpty || segments == null || segments.isEmpty) {
if (Stores.setting.keepStatusWhenErr.fetch()) { if (Stores.setting.keepStatusWhenErr.fetch()) {
// Keep previous server status when err occurs // Keep previous server status when err occurs

View File

@@ -1,6 +1,6 @@
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/core/extension/ssh_client.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.dart';
import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/systemd.dart'; import 'package:server_box/data/model/server/systemd.dart';
@@ -60,12 +60,12 @@ final class SystemdProvider {
''' '''
for unit in ${unitNames_.join(' ')}; do for unit in ${unitNames_.join(' ')}; do
state=\$(systemctl show --no-pager \$unit) state=\$(systemctl show --no-pager \$unit)
echo -n "${ShellFunc.seperator}\n\$state" echo -n "${ScriptConstants.separator}\n\$state"
done done
'''; ''';
final client = _si.value.client!; final client = _si.value.client!;
final result = await client.execForOutput(script); final result = await client.execForOutput(script);
final units = result.split(ShellFunc.seperator); final units = result.split(ScriptConstants.separator);
final parsedUnits = <SystemdUnit>[]; final parsedUnits = <SystemdUnit>[];
for (final unit in units) { for (final unit in units) {

View File

@@ -119,6 +119,11 @@ abstract final class GithubIds {
'AstroEngineeer', 'AstroEngineeer',
'mochasweet', 'mochasweet',
'back-lacking', 'back-lacking',
'cainiaojr',
'MisterMunkerz',
'CreeperKong',
'zxf945',
'cnen2018',
}; };
} }

View File

@@ -5,7 +5,7 @@ import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/route.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/proc.dart';
import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/store.dart';

View File

@@ -6,8 +6,8 @@ import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:icons_plus/icons_plus.dart'; import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/route.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/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/amd.dart';
import 'package:server_box/data/model/server/battery.dart'; import 'package:server_box/data/model/server/battery.dart';
import 'package:server_box/data/model/server/cpu.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/pve.dart';
import 'package:server_box/view/page/server/edit.dart'; import 'package:server_box/view/page/server/edit.dart';
import 'package:server_box/view/page/server/logo.dart'; import 'package:server_box/view/page/server/logo.dart';
import 'package:server_box/view/widget/server_func_btns.dart'; import 'package:server_box/view/widget/server_func_btns.dart';
part 'misc.dart'; part 'misc.dart';

View File

@@ -1,6 +1,6 @@
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.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/dist.dart';
import 'package:server_box/data/model/server/server.dart'; import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/store.dart';

View File

@@ -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/extension/ssh_client.dart';
import 'package:server_box/core/route.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/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.dart';
import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/try_limiter.dart'; import 'package:server_box/data/model/server/try_limiter.dart';

View File

@@ -1821,8 +1821,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: "v1.0.588" ref: "v4.0.3"
resolved-ref: d28207b988b5bed38c799618b9c412486592c689 resolved-ref: c64183346b924173eb7251800001a64771911185
url: "https://github.com/lollipopkit/xterm.dart" url: "https://github.com/lollipopkit/xterm.dart"
source: git source: git
version: "4.0.0" version: "4.0.0"

View File

@@ -47,7 +47,7 @@ dependencies:
xterm: xterm:
git: git:
url: https://github.com/lollipopkit/xterm.dart url: https://github.com/lollipopkit/xterm.dart
ref: v1.0.588 ref: v4.0.3
computer: computer:
git: git:
url: https://github.com/lollipopkit/dart_computer url: https://github.com/lollipopkit/dart_computer

View File

@@ -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<WindowsScriptBuilder>());
expect(unixBuilder, isA<UnixScriptBuilder>());
});
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);
});
});
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; 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/server_status_update_req.dart';
import 'package:server_box/data/model/server/system.dart'; import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/res/status.dart'; import 'package:server_box/data/res/status.dart';
@@ -14,7 +15,7 @@ void main() {
}); });
test('should generate Windows PowerShell script correctly', () { 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('PowerShell script for ServerBox'));
expect(script, contains('function SbStatus')); expect(script, contains('function SbStatus'));
@@ -225,11 +226,11 @@ void main() {
test('should handle Windows script path generation', () { test('should handle Windows script path generation', () {
const serverId = 'test-server'; 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('.ps1'));
expect(scriptPath, contains('\\')); 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('New-Item'));
expect(installCmd, contains('Set-Content')); expect(installCmd, contains('Set-Content'));
// No longer contains 'powershell' prefix as commands now run in PowerShell session // No longer contains 'powershell' prefix as commands now run in PowerShell session