diff --git a/desktop/GSM3/.gitignore b/desktop/GSM3/.gitignore
new file mode 100644
index 0000000..dd39356
--- /dev/null
+++ b/desktop/GSM3/.gitignore
@@ -0,0 +1,54 @@
+## .NET / Visual Studio
+[Bb]in/
+[Oo]bj/
+[Dd]ebug/
+[Rr]elease/
+.vs/
+*.user
+*.suo
+*.userosscache
+*.sln.docstates
+artifacts/
+
+# Build logs
+[Ll]og/
+[Ll]ogs/
+*.log
+*.binlog
+
+# Test results
+[Tt]est[Rr]esult*/
+*.trx
+*.coverage
+*.coveragexml
+
+# NuGet
+*.nupkg
+*.snupkg
+*.nuget.props
+*.nuget.targets
+project.lock.json
+
+# MSIX packaging output
+AppPackages/
+BundleArtifacts/
+*.msix
+*.msixupload
+*.appx
+*.appxbundle
+*.appxupload
+
+# Publish output
+publish/
+*.pubxml
+PublishScripts/
+
+# Code analysis and tooling
+_ReSharper*/
+*.DotSettings.user
+*.dotCover
+.idea/
+
+# Generated files
+Generated\ Files/
+*_wpftmp.csproj
diff --git a/desktop/GSM3/App.xaml b/desktop/GSM3/App.xaml
new file mode 100644
index 0000000..62bd118
--- /dev/null
+++ b/desktop/GSM3/App.xaml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/GSM3/App.xaml.cs b/desktop/GSM3/App.xaml.cs
new file mode 100644
index 0000000..fa0cc98
--- /dev/null
+++ b/desktop/GSM3/App.xaml.cs
@@ -0,0 +1,43 @@
+using Microsoft.UI.Xaml;
+using Microsoft.Extensions.DependencyInjection;
+using GSM3.Services;
+
+namespace GSM3;
+
+public partial class App : Application
+{
+ private Window? _window;
+
+ public App()
+ {
+ InitializeComponent();
+ ConfigureServices();
+ }
+
+ private void ConfigureServices()
+ {
+ var services = new ServiceCollection();
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ var provider = services.BuildServiceProvider();
+ ServiceLocator.Initialize(provider);
+ }
+
+ protected override void OnLaunched(LaunchActivatedEventArgs args)
+ {
+ _window = new MainWindow();
+ _window.Activate();
+ }
+
+ public static Window? MainAppWindow => (Current as App)?._window;
+}
diff --git a/desktop/GSM3/Assets/AppIcon.ico b/desktop/GSM3/Assets/AppIcon.ico
new file mode 100644
index 0000000..235dad2
Binary files /dev/null and b/desktop/GSM3/Assets/AppIcon.ico differ
diff --git a/desktop/GSM3/Assets/LockScreenLogo.scale-200.png b/desktop/GSM3/Assets/LockScreenLogo.scale-200.png
new file mode 100644
index 0000000..33f889e
Binary files /dev/null and b/desktop/GSM3/Assets/LockScreenLogo.scale-200.png differ
diff --git a/desktop/GSM3/Assets/SplashScreen.scale-200.png b/desktop/GSM3/Assets/SplashScreen.scale-200.png
new file mode 100644
index 0000000..802c79d
Binary files /dev/null and b/desktop/GSM3/Assets/SplashScreen.scale-200.png differ
diff --git a/desktop/GSM3/Assets/Square150x150Logo.scale-200.png b/desktop/GSM3/Assets/Square150x150Logo.scale-200.png
new file mode 100644
index 0000000..ddba42a
Binary files /dev/null and b/desktop/GSM3/Assets/Square150x150Logo.scale-200.png differ
diff --git a/desktop/GSM3/Assets/Square44x44Logo.scale-200.png b/desktop/GSM3/Assets/Square44x44Logo.scale-200.png
new file mode 100644
index 0000000..9327dd7
Binary files /dev/null and b/desktop/GSM3/Assets/Square44x44Logo.scale-200.png differ
diff --git a/desktop/GSM3/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/desktop/GSM3/Assets/Square44x44Logo.targetsize-24_altform-unplated.png
new file mode 100644
index 0000000..e51416a
Binary files /dev/null and b/desktop/GSM3/Assets/Square44x44Logo.targetsize-24_altform-unplated.png differ
diff --git a/desktop/GSM3/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png b/desktop/GSM3/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png
new file mode 100644
index 0000000..bf063eb
Binary files /dev/null and b/desktop/GSM3/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png differ
diff --git a/desktop/GSM3/Assets/StoreLogo.png b/desktop/GSM3/Assets/StoreLogo.png
new file mode 100644
index 0000000..be865f0
Binary files /dev/null and b/desktop/GSM3/Assets/StoreLogo.png differ
diff --git a/desktop/GSM3/Assets/Wide310x150Logo.scale-200.png b/desktop/GSM3/Assets/Wide310x150Logo.scale-200.png
new file mode 100644
index 0000000..314d297
Binary files /dev/null and b/desktop/GSM3/Assets/Wide310x150Logo.scale-200.png differ
diff --git a/desktop/GSM3/Converters/Converters.cs b/desktop/GSM3/Converters/Converters.cs
new file mode 100644
index 0000000..f84dabe
--- /dev/null
+++ b/desktop/GSM3/Converters/Converters.cs
@@ -0,0 +1,71 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Data;
+
+namespace GSM3.Converters;
+
+public class BoolToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ if (value is bool b)
+ return b ? Visibility.Visible : Visibility.Collapsed;
+ return Visibility.Collapsed;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ {
+ return value is Visibility v && v == Visibility.Visible;
+ }
+}
+
+public class FileSizeConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ if (value is long bytes)
+ return FormatSize(bytes);
+ return "0 B";
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ => throw new NotImplementedException();
+
+ private static string FormatSize(long bytes)
+ {
+ string[] sizes = ["B", "KB", "MB", "GB", "TB"];
+ double len = bytes;
+ int order = 0;
+ while (len >= 1024 && order < sizes.Length - 1)
+ {
+ order++;
+ len /= 1024;
+ }
+ return $"{len:0.##} {sizes[order]}";
+ }
+}
+
+public class DateTimeConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ if (value is DateTime dt)
+ return dt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss");
+ return "-";
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ => throw new NotImplementedException();
+}
+
+public class PercentConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ if (value is double d)
+ return $"{d:F1}%";
+ return "0%";
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ => throw new NotImplementedException();
+}
diff --git a/desktop/GSM3/GSM3.csproj b/desktop/GSM3/GSM3.csproj
new file mode 100644
index 0000000..085e6ff
--- /dev/null
+++ b/desktop/GSM3/GSM3.csproj
@@ -0,0 +1,80 @@
+
+
+ WinExe
+ net8.0-windows10.0.26100.0
+ 10.0.17763.0
+ GSM3
+ app.manifest
+ x86;x64;ARM64
+ win-$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant())
+ win-$(Platform).pubxml
+ true
+ false
+ true
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
+
+
+
+
+
+ False
+ True
+ False
+ True
+
+
diff --git a/desktop/GSM3/GameConfigs/minecraft.yml b/desktop/GSM3/GameConfigs/minecraft.yml
new file mode 100644
index 0000000..c431772
--- /dev/null
+++ b/desktop/GSM3/GameConfigs/minecraft.yml
@@ -0,0 +1,141 @@
+meta:
+ game_name: "Minecraft Java"
+ config_file: "server.properties"
+ parser: "properties"
+
+sections:
+ - name: "服务器设置"
+ fields:
+ - key: "server-name"
+ label: "服务器名称"
+ type: "string"
+ default: "Dedicated Server"
+ - key: "server-port"
+ label: "服务器端口"
+ type: "number"
+ default: 25565
+ - key: "max-players"
+ label: "最大玩家数"
+ type: "number"
+ default: 20
+ min: 1
+ max: 1000
+ - key: "motd"
+ label: "服务器描述(MOTD)"
+ type: "string"
+ default: "A Minecraft Server"
+ - key: "level-name"
+ label: "世界名称"
+ type: "string"
+ default: "world"
+ - key: "level-seed"
+ label: "世界种子"
+ type: "string"
+ default: ""
+ - key: "gamemode"
+ label: "游戏模式"
+ type: "select"
+ default: "survival"
+ options:
+ - value: "survival"
+ label: "生存"
+ - value: "creative"
+ label: "创造"
+ - value: "adventure"
+ label: "冒险"
+ - value: "spectator"
+ label: "旁观"
+ - key: "difficulty"
+ label: "难度"
+ type: "select"
+ default: "easy"
+ options:
+ - value: "peaceful"
+ label: "和平"
+ - value: "easy"
+ label: "简单"
+ - value: "normal"
+ label: "普通"
+ - value: "hard"
+ label: "困难"
+ - key: "online-mode"
+ label: "正版验证"
+ type: "boolean"
+ default: true
+ - key: "pvp"
+ label: "允许PVP"
+ type: "boolean"
+ default: true
+ - key: "enable-command-block"
+ label: "命令方块"
+ type: "boolean"
+ default: false
+ - key: "allow-flight"
+ label: "允许飞行"
+ type: "boolean"
+ default: false
+ - key: "spawn-animals"
+ label: "生成动物"
+ type: "boolean"
+ default: true
+ - key: "spawn-monsters"
+ label: "生成怪物"
+ type: "boolean"
+ default: true
+ - key: "spawn-npcs"
+ label: "生成NPC"
+ type: "boolean"
+ default: true
+ - key: "generate-structures"
+ label: "生成建筑"
+ type: "boolean"
+ default: true
+ - key: "view-distance"
+ label: "视距"
+ type: "number"
+ default: 10
+ min: 3
+ max: 32
+ - key: "simulation-distance"
+ label: "模拟距离"
+ type: "number"
+ default: 10
+ min: 3
+ max: 32
+ - key: "white-list"
+ label: "白名单"
+ type: "boolean"
+ default: false
+ - key: "enable-rcon"
+ label: "启用RCON"
+ type: "boolean"
+ default: false
+ - key: "rcon.port"
+ label: "RCON端口"
+ type: "number"
+ default: 25575
+ - key: "rcon.password"
+ label: "RCON密码"
+ type: "string"
+ default: ""
+
+ - name: "性能设置"
+ fields:
+ - key: "max-tick-time"
+ label: "最大Tick时间(ms)"
+ type: "number"
+ default: 60000
+ - key: "network-compression-threshold"
+ label: "网络压缩阈值"
+ type: "number"
+ default: 256
+ - key: "max-world-size"
+ label: "最大世界大小"
+ type: "number"
+ default: 29999984
+ - key: "entity-broadcast-range-percentage"
+ label: "实体广播范围(%)"
+ type: "number"
+ default: 100
+ min: 10
+ max: 1000
diff --git a/desktop/GSM3/GameConfigs/palworld.yml b/desktop/GSM3/GameConfigs/palworld.yml
new file mode 100644
index 0000000..d489f1e
--- /dev/null
+++ b/desktop/GSM3/GameConfigs/palworld.yml
@@ -0,0 +1,132 @@
+meta:
+ game_name: "PalWorld"
+ config_file: "PalWorldSettings.ini"
+ parser: "ini"
+ section: "/Script/Pal.PalGameWorldSettings"
+
+sections:
+ - name: "服务器基础设置"
+ fields:
+ - key: "ServerName"
+ label: "服务器名称"
+ type: "string"
+ default: "Default Palworld Server"
+ - key: "ServerDescription"
+ label: "服务器描述"
+ type: "string"
+ default: ""
+ - key: "AdminPassword"
+ label: "管理员密码"
+ type: "string"
+ default: ""
+ - key: "ServerPassword"
+ label: "服务器密码"
+ type: "string"
+ default: ""
+ - key: "ServerPlayerMaxNum"
+ label: "最大玩家数"
+ type: "number"
+ default: 32
+ min: 1
+ max: 64
+ - key: "PublicPort"
+ label: "公开端口"
+ type: "number"
+ default: 8211
+ - key: "RCONPort"
+ label: "RCON端口"
+ type: "number"
+ default: 25575
+ - key: "RCONEnabled"
+ label: "启用RCON"
+ type: "boolean"
+ default: true
+
+ - name: "游戏难度设置"
+ fields:
+ - key: "DayTimeSpeedRate"
+ label: "白天速度倍率"
+ type: "number"
+ default: 1.0
+ min: 0.1
+ max: 5.0
+ step: 0.1
+ - key: "NightTimeSpeedRate"
+ label: "夜晚速度倍率"
+ type: "number"
+ default: 1.0
+ min: 0.1
+ max: 5.0
+ step: 0.1
+ - key: "ExpRate"
+ label: "经验倍率"
+ type: "number"
+ default: 1.0
+ min: 0.1
+ max: 20.0
+ step: 0.1
+ - key: "PalCaptureRate"
+ label: "帕鲁捕获倍率"
+ type: "number"
+ default: 1.0
+ min: 0.1
+ max: 10.0
+ step: 0.1
+ - key: "PalSpawnNumRate"
+ label: "帕鲁生成倍率"
+ type: "number"
+ default: 1.0
+ min: 0.1
+ max: 5.0
+ step: 0.1
+ - key: "PalDamageRateAttack"
+ label: "帕鲁攻击伤害倍率"
+ type: "number"
+ default: 1.0
+ min: 0.1
+ max: 5.0
+ step: 0.1
+ - key: "PalDamageRateDefense"
+ label: "帕鲁防御倍率"
+ type: "number"
+ default: 1.0
+ - key: "PlayerDamageRateAttack"
+ label: "玩家攻击伤害倍率"
+ type: "number"
+ default: 1.0
+ - key: "PlayerDamageRateDefense"
+ label: "玩家防御倍率"
+ type: "number"
+ default: 1.0
+ - key: "DeathPenalty"
+ label: "死亡惩罚"
+ type: "select"
+ default: "All"
+ options:
+ - value: "None"
+ label: "无惩罚"
+ - value: "Item"
+ label: "掉落物品"
+ - value: "ItemAndEquipment"
+ label: "掉落物品和装备"
+ - value: "All"
+ label: "全部掉落"
+
+ - name: "资源设置"
+ fields:
+ - key: "CollectionDropRate"
+ label: "采集物掉落倍率"
+ type: "number"
+ default: 1.0
+ - key: "CollectionObjectHpRate"
+ label: "采集物血量倍率"
+ type: "number"
+ default: 1.0
+ - key: "CollectionObjectRespawnSpeedRate"
+ label: "采集物刷新速度"
+ type: "number"
+ default: 1.0
+ - key: "EnemyDropItemRate"
+ label: "敌人掉落倍率"
+ type: "number"
+ default: 1.0
diff --git a/desktop/GSM3/GameConfigs/terraria.yml b/desktop/GSM3/GameConfigs/terraria.yml
new file mode 100644
index 0000000..efe2ba2
--- /dev/null
+++ b/desktop/GSM3/GameConfigs/terraria.yml
@@ -0,0 +1,71 @@
+meta:
+ game_name: "Terraria"
+ config_file: "serverconfig.txt"
+ parser: "properties"
+
+sections:
+ - name: "基础设置"
+ fields:
+ - key: "world"
+ label: "世界文件路径"
+ type: "string"
+ default: ""
+ - key: "autocreate"
+ label: "自动创建世界大小"
+ type: "select"
+ default: "2"
+ options:
+ - value: "1"
+ label: "小"
+ - value: "2"
+ label: "中"
+ - value: "3"
+ label: "大"
+ - key: "worldname"
+ label: "世界名称"
+ type: "string"
+ default: "TerrariaWorld"
+ - key: "maxplayers"
+ label: "最大玩家数"
+ type: "number"
+ default: 8
+ min: 1
+ max: 255
+ - key: "port"
+ label: "端口"
+ type: "number"
+ default: 7777
+ - key: "password"
+ label: "密码"
+ type: "string"
+ default: ""
+ - key: "motd"
+ label: "服务器描述"
+ type: "string"
+ default: "Welcome to Terraria!"
+ - key: "difficulty"
+ label: "难度"
+ type: "select"
+ default: "0"
+ options:
+ - value: "0"
+ label: "普通"
+ - value: "1"
+ label: "专家"
+ - value: "2"
+ label: "大师"
+ - value: "3"
+ label: "旅途"
+ - key: "secure"
+ label: "反作弊"
+ type: "boolean"
+ default: true
+ - key: "language"
+ label: "语言"
+ type: "select"
+ default: "zh-Hans"
+ options:
+ - value: "en-US"
+ label: "英语"
+ - value: "zh-Hans"
+ label: "简体中文"
diff --git a/desktop/GSM3/Helpers/Helpers.cs b/desktop/GSM3/Helpers/Helpers.cs
new file mode 100644
index 0000000..6aaab45
--- /dev/null
+++ b/desktop/GSM3/Helpers/Helpers.cs
@@ -0,0 +1,67 @@
+using Microsoft.UI;
+using Microsoft.UI.Xaml.Media;
+using GSM3.Models;
+
+namespace GSM3.Helpers;
+
+public static class StatusHelper
+{
+ public static string GetStatusText(InstanceStatus status) => status switch
+ {
+ InstanceStatus.Stopped => "已停止",
+ InstanceStatus.Starting => "启动中",
+ InstanceStatus.Running => "运行中",
+ InstanceStatus.Stopping => "停止中",
+ InstanceStatus.Crashed => "已崩溃",
+ _ => "未知"
+ };
+
+ public static SolidColorBrush GetStatusBrush(InstanceStatus status) => status switch
+ {
+ InstanceStatus.Running => new SolidColorBrush(Colors.Green),
+ InstanceStatus.Starting or InstanceStatus.Stopping => new SolidColorBrush(Colors.Orange),
+ InstanceStatus.Crashed => new SolidColorBrush(Colors.Red),
+ _ => new SolidColorBrush(Colors.Gray)
+ };
+
+ public static string GetInstanceTypeText(InstanceType type) => type switch
+ {
+ InstanceType.Generic => "通用",
+ InstanceType.MinecraftJava => "Minecraft Java",
+ InstanceType.MinecraftBedrock => "Minecraft 基岩版",
+ _ => "未知"
+ };
+
+ public static string GetTaskTypeText(TaskType type) => type switch
+ {
+ TaskType.Power => "电源操作",
+ TaskType.Command => "命令执行",
+ TaskType.Backup => "备份",
+ TaskType.System => "系统",
+ _ => "未知"
+ };
+
+ public static string GetUserRoleText(UserRole role) => role switch
+ {
+ UserRole.Admin => "管理员",
+ UserRole.User => "普通用户",
+ _ => "未知"
+ };
+}
+
+public static class PathHelper
+{
+ public static string GetDataPath()
+ {
+ var path = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "GSM3");
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ public static string GetDataFilePath(string fileName)
+ {
+ return Path.Combine(GetDataPath(), fileName);
+ }
+}
diff --git a/desktop/GSM3/MainWindow.xaml b/desktop/GSM3/MainWindow.xaml
new file mode 100644
index 0000000..343d2d0
--- /dev/null
+++ b/desktop/GSM3/MainWindow.xaml
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/GSM3/MainWindow.xaml.cs b/desktop/GSM3/MainWindow.xaml.cs
new file mode 100644
index 0000000..43a3f5f
--- /dev/null
+++ b/desktop/GSM3/MainWindow.xaml.cs
@@ -0,0 +1,189 @@
+using Microsoft.UI.Windowing;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using GSM3.Pages;
+using GSM3.Services;
+using GSM3.Models;
+
+namespace GSM3;
+
+public sealed partial class MainWindow : Window
+{
+ private readonly UserManager _userManager;
+ private User? _currentUser;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ ExtendsContentIntoTitleBar = true;
+ SetTitleBar(AppTitleBar);
+ AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Tall;
+ AppWindow.SetIcon("Assets/AppIcon.ico");
+
+ _userManager = ServiceLocator.GetService();
+ Activated += MainWindow_Activated;
+ }
+
+ private async void MainWindow_Activated(object sender, WindowActivatedEventArgs args)
+ {
+ Activated -= MainWindow_Activated;
+ await CheckLoginStateAsync();
+ }
+
+ private async Task CheckLoginStateAsync()
+ {
+ await _userManager.InitializeAsync();
+
+ if (!_userManager.HasUsers())
+ {
+ ShowRegisterFirst();
+ }
+ else
+ {
+ ShowLogin();
+ }
+ }
+
+ private void ShowLogin()
+ {
+ LoginPanel.Visibility = Visibility.Visible;
+ NavView.Visibility = Visibility.Collapsed;
+ RegisterLink.Content = "没有账号?点击注册";
+ LoginButton.Content = "登录";
+ LoginButton.Tag = "login";
+ LoginError.IsOpen = false;
+ }
+
+ private void ShowRegisterFirst()
+ {
+ LoginPanel.Visibility = Visibility.Visible;
+ NavView.Visibility = Visibility.Collapsed;
+ LoginButton.Content = "注册管理员账号";
+ LoginButton.Tag = "register";
+ RegisterLink.Visibility = Visibility.Collapsed;
+ LoginError.IsOpen = false;
+ }
+
+ private void ShowMainUI()
+ {
+ LoginPanel.Visibility = Visibility.Collapsed;
+ NavView.Visibility = Visibility.Visible;
+ AppTitleBar.Title = $"GSM3 - {_currentUser?.Username ?? ""}";
+ }
+
+ private async void LoginButton_Click(object sender, RoutedEventArgs e)
+ {
+ var username = LoginUsername.Text.Trim();
+ var password = LoginPassword.Password;
+
+ if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
+ {
+ LoginError.Message = "请输入用户名和密码";
+ LoginError.IsOpen = true;
+ return;
+ }
+
+ if (LoginButton.Tag as string == "register")
+ {
+ var (success, error, user) = await _userManager.RegisterAsync(username, password, UserRole.Admin);
+ if (success)
+ {
+ _currentUser = user;
+ ShowMainUI();
+ }
+ else
+ {
+ LoginError.Message = error ?? "注册失败";
+ LoginError.IsOpen = true;
+ }
+ }
+ else
+ {
+ var (success, error, user) = await _userManager.LoginAsync(username, password);
+ if (success)
+ {
+ _currentUser = user;
+ ShowMainUI();
+ }
+ else
+ {
+ LoginError.Message = error ?? "登录失败";
+ LoginError.IsOpen = true;
+ }
+ }
+ }
+
+ private void LoginPassword_KeyDown(object sender, KeyRoutedEventArgs e)
+ {
+ if (e.Key == Windows.System.VirtualKey.Enter)
+ LoginButton_Click(sender, e);
+ }
+
+ private void RegisterLink_Click(object sender, RoutedEventArgs e)
+ {
+ if (LoginButton.Tag as string == "login")
+ {
+ LoginButton.Content = "注册";
+ LoginButton.Tag = "register";
+ RegisterLink.Content = "已有账号?点击登录";
+ }
+ else
+ {
+ LoginButton.Content = "登录";
+ LoginButton.Tag = "login";
+ RegisterLink.Content = "没有账号?点击注册";
+ }
+ }
+
+ private void TitleBar_PaneToggleRequested(TitleBar sender, object args)
+ {
+ NavView.IsPaneOpen = !NavView.IsPaneOpen;
+ }
+
+ private void TitleBar_BackRequested(TitleBar sender, object args)
+ {
+ NavFrame.GoBack();
+ }
+
+ private void NavView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
+ {
+ if (args.IsSettingsSelected)
+ {
+ NavFrame.Navigate(typeof(SettingsPage));
+ return;
+ }
+
+ if (args.SelectedItem is NavigationViewItem item)
+ {
+ var tag = item.Tag as string;
+ var pageType = tag switch
+ {
+ "dashboard" => typeof(DashboardPage),
+ "instances" => typeof(InstancesPage),
+ "terminal" => typeof(TerminalPage),
+ "files" => typeof(FilesPage),
+ "deploy" => typeof(GameDeployPage),
+ "scheduler" => typeof(SchedulerPage),
+ "backup" => typeof(BackupPage),
+ "system" => typeof(SystemPage),
+ "environment" => typeof(EnvironmentPage),
+ "plugins" => typeof(PluginsPage),
+ "users" => typeof(UsersPage),
+ "about" => typeof(AboutPage),
+ _ => typeof(DashboardPage)
+ };
+ NavFrame.Navigate(pageType);
+ }
+ }
+
+ public User? CurrentUser => _currentUser;
+
+ public void Logout()
+ {
+ _currentUser = null;
+ ShowLogin();
+ LoginUsername.Text = "";
+ LoginPassword.Password = "";
+ }
+}
diff --git a/desktop/GSM3/Models/AppConfig.cs b/desktop/GSM3/Models/AppConfig.cs
new file mode 100644
index 0000000..af5aa9e
--- /dev/null
+++ b/desktop/GSM3/Models/AppConfig.cs
@@ -0,0 +1,42 @@
+namespace GSM3.Models;
+
+public class AppConfig
+{
+ public ServerConfig Server { get; set; } = new();
+ public AuthConfig Auth { get; set; } = new();
+ public SteamCMDConfig SteamCMD { get; set; } = new();
+ public TerminalConfig Terminal { get; set; } = new();
+ public GameConfig Game { get; set; } = new();
+}
+
+public class ServerConfig
+{
+ public int Port { get; set; } = 3000;
+ public string Host { get; set; } = "0.0.0.0";
+}
+
+public class AuthConfig
+{
+ public int MaxLoginAttempts { get; set; } = 5;
+ public int LockoutDurationMinutes { get; set; } = 30;
+ public int SessionTimeoutMinutes { get; set; } = 1440;
+}
+
+public class SteamCMDConfig
+{
+ public string InstallPath { get; set; } = "";
+ public bool IsInstalled { get; set; }
+ public string Version { get; set; } = "";
+}
+
+public class TerminalConfig
+{
+ public string DefaultUser { get; set; } = "";
+ public int MaxSessions { get; set; } = 10;
+ public int TimeoutMinutes { get; set; } = 30;
+}
+
+public class GameConfig
+{
+ public string DefaultInstallPath { get; set; } = "";
+}
diff --git a/desktop/GSM3/Models/BackupInfo.cs b/desktop/GSM3/Models/BackupInfo.cs
new file mode 100644
index 0000000..6d82486
--- /dev/null
+++ b/desktop/GSM3/Models/BackupInfo.cs
@@ -0,0 +1,12 @@
+namespace GSM3.Models;
+
+public class BackupInfo
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+ public string InstanceId { get; set; } = "";
+ public string InstanceName { get; set; } = "";
+ public string FileName { get; set; } = "";
+ public string FilePath { get; set; } = "";
+ public long FileSize { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/desktop/GSM3/Models/FileItem.cs b/desktop/GSM3/Models/FileItem.cs
new file mode 100644
index 0000000..7e45ae3
--- /dev/null
+++ b/desktop/GSM3/Models/FileItem.cs
@@ -0,0 +1,27 @@
+namespace GSM3.Models;
+
+public enum FileItemType { File, Directory }
+
+public class FileItem
+{
+ public string Name { get; set; } = "";
+ public string Path { get; set; } = "";
+ public FileItemType Type { get; set; }
+ public long Size { get; set; }
+ public DateTime ModifiedAt { get; set; }
+ public DateTime CreatedAt { get; set; }
+ public string Extension { get; set; } = "";
+ public bool IsReadOnly { get; set; }
+ public bool IsHidden { get; set; }
+}
+
+public class DriveEntry
+{
+ public string Name { get; set; } = "";
+ public string Label { get; set; } = "";
+ public string DriveType { get; set; } = "";
+ public string Format { get; set; } = "";
+ public long TotalBytes { get; set; }
+ public long FreeBytes { get; set; }
+ public bool IsReady { get; set; }
+}
diff --git a/desktop/GSM3/Models/Instance.cs b/desktop/GSM3/Models/Instance.cs
new file mode 100644
index 0000000..e1f3fbd
--- /dev/null
+++ b/desktop/GSM3/Models/Instance.cs
@@ -0,0 +1,25 @@
+namespace GSM3.Models;
+
+public enum InstanceStatus { Stopped, Starting, Running, Stopping, Crashed }
+public enum InstanceType { Generic, MinecraftJava, MinecraftBedrock }
+public enum StopCommand { CtrlC, Stop, Exit, Quit }
+
+public class Instance
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+ public string Name { get; set; } = "";
+ public string Description { get; set; } = "";
+ public string WorkingDirectory { get; set; } = "";
+ public string StartCommand { get; set; } = "";
+ public string ProgramPath { get; set; } = "";
+ public StopCommand StopCommandType { get; set; } = StopCommand.CtrlC;
+ public bool AutoStart { get; set; }
+ public bool EnableStreamForward { get; set; }
+ public InstanceType Type { get; set; } = InstanceType.Generic;
+ public InstanceStatus Status { get; set; } = InstanceStatus.Stopped;
+ public string TerminalUser { get; set; } = "";
+ public string TerminalSessionId { get; set; } = "";
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public DateTime? LastStarted { get; set; }
+ public DateTime? LastStopped { get; set; }
+}
diff --git a/desktop/GSM3/Models/RconConfig.cs b/desktop/GSM3/Models/RconConfig.cs
new file mode 100644
index 0000000..d7df17b
--- /dev/null
+++ b/desktop/GSM3/Models/RconConfig.cs
@@ -0,0 +1,17 @@
+namespace GSM3.Models;
+
+public enum RconStatus { Disconnected, Connecting, Connected, Authenticated, Error }
+
+public class RconConfig
+{
+ public string Host { get; set; } = "127.0.0.1";
+ public int Port { get; set; } = 25575;
+ public string Password { get; set; } = "";
+}
+
+public class RconCommand
+{
+ public string Command { get; set; } = "";
+ public string Response { get; set; } = "";
+ public DateTime Timestamp { get; set; } = DateTime.UtcNow;
+}
diff --git a/desktop/GSM3/Models/ScheduledTask.cs b/desktop/GSM3/Models/ScheduledTask.cs
new file mode 100644
index 0000000..0ea14cb
--- /dev/null
+++ b/desktop/GSM3/Models/ScheduledTask.cs
@@ -0,0 +1,20 @@
+namespace GSM3.Models;
+
+public enum TaskType { Power, Command, Backup, System }
+public enum PowerAction { Start, Stop, Restart }
+
+public class ScheduledTask
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+ public string Name { get; set; } = "";
+ public string InstanceId { get; set; } = "";
+ public TaskType Type { get; set; }
+ public string CronExpression { get; set; } = "";
+ public bool Enabled { get; set; } = true;
+ public PowerAction? PowerActionType { get; set; }
+ public string? Command { get; set; }
+ public string? SystemAction { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public DateTime? LastRun { get; set; }
+ public DateTime? NextRun { get; set; }
+}
diff --git a/desktop/GSM3/Models/SystemInfo.cs b/desktop/GSM3/Models/SystemInfo.cs
new file mode 100644
index 0000000..be8c882
--- /dev/null
+++ b/desktop/GSM3/Models/SystemInfo.cs
@@ -0,0 +1,64 @@
+namespace GSM3.Models;
+
+public class SystemStats
+{
+ public CpuStats Cpu { get; set; } = new();
+ public MemoryStats Memory { get; set; } = new();
+ public DiskStats Disk { get; set; } = new();
+ public NetworkStats Network { get; set; } = new();
+ public DateTime Timestamp { get; set; } = DateTime.UtcNow;
+}
+
+public class CpuStats
+{
+ public double UsagePercent { get; set; }
+ public int CoreCount { get; set; }
+ public string ModelName { get; set; } = "";
+ public double[] CoreUsages { get; set; } = Array.Empty();
+}
+
+public class MemoryStats
+{
+ public long TotalBytes { get; set; }
+ public long UsedBytes { get; set; }
+ public long AvailableBytes { get; set; }
+ public double UsagePercent { get; set; }
+}
+
+public class DiskStats
+{
+ public string DriveName { get; set; } = "";
+ public long TotalBytes { get; set; }
+ public long UsedBytes { get; set; }
+ public long FreeBytes { get; set; }
+ public double UsagePercent { get; set; }
+}
+
+public class NetworkStats
+{
+ public string InterfaceName { get; set; } = "";
+ public long BytesSent { get; set; }
+ public long BytesReceived { get; set; }
+ public long SendSpeed { get; set; }
+ public long ReceiveSpeed { get; set; }
+}
+
+public class ProcessInfo
+{
+ public int Pid { get; set; }
+ public string Name { get; set; } = "";
+ public double CpuPercent { get; set; }
+ public long MemoryBytes { get; set; }
+ public string User { get; set; } = "";
+ public DateTime StartTime { get; set; }
+}
+
+public class ActivePort
+{
+ public string Protocol { get; set; } = "";
+ public string LocalAddress { get; set; } = "";
+ public int LocalPort { get; set; }
+ public string State { get; set; } = "";
+ public int Pid { get; set; }
+ public string ProcessName { get; set; } = "";
+}
diff --git a/desktop/GSM3/Models/TerminalSession.cs b/desktop/GSM3/Models/TerminalSession.cs
new file mode 100644
index 0000000..406d28a
--- /dev/null
+++ b/desktop/GSM3/Models/TerminalSession.cs
@@ -0,0 +1,11 @@
+namespace GSM3.Models;
+
+public class TerminalSession
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+ public string Name { get; set; } = "";
+ public string WorkingDirectory { get; set; } = "";
+ public int? ProcessId { get; set; }
+ public bool IsActive { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/desktop/GSM3/Models/User.cs b/desktop/GSM3/Models/User.cs
new file mode 100644
index 0000000..53b4c11
--- /dev/null
+++ b/desktop/GSM3/Models/User.cs
@@ -0,0 +1,16 @@
+namespace GSM3.Models;
+
+public enum UserRole { Admin, User }
+
+public class User
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+ public string Username { get; set; } = "";
+ public string PasswordHash { get; set; } = "";
+ public string Salt { get; set; } = "";
+ public UserRole Role { get; set; } = UserRole.User;
+ public int LoginAttempts { get; set; }
+ public DateTime? LockedUntil { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public DateTime? LastLogin { get; set; }
+}
diff --git a/desktop/GSM3/Package.appxmanifest b/desktop/GSM3/Package.appxmanifest
new file mode 100644
index 0000000..a0c8fcf
--- /dev/null
+++ b/desktop/GSM3/Package.appxmanifest
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+ GSM3
+ AppPublisher
+ Assets\StoreLogo.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/GSM3/Pages/AboutPage.xaml b/desktop/GSM3/Pages/AboutPage.xaml
new file mode 100644
index 0000000..60706e6
--- /dev/null
+++ b/desktop/GSM3/Pages/AboutPage.xaml
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/GSM3/Pages/AboutPage.xaml.cs b/desktop/GSM3/Pages/AboutPage.xaml.cs
new file mode 100644
index 0000000..8642523
--- /dev/null
+++ b/desktop/GSM3/Pages/AboutPage.xaml.cs
@@ -0,0 +1,18 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace GSM3.Pages;
+
+public sealed partial class AboutPage : Page
+{
+ public AboutPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void CheckUpdateButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Implement update check logic
+ await System.Threading.Tasks.Task.CompletedTask;
+ }
+}
diff --git a/desktop/GSM3/Pages/BackupPage.xaml b/desktop/GSM3/Pages/BackupPage.xaml
new file mode 100644
index 0000000..665eb5a
--- /dev/null
+++ b/desktop/GSM3/Pages/BackupPage.xaml
@@ -0,0 +1,172 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/GSM3/Pages/BackupPage.xaml.cs b/desktop/GSM3/Pages/BackupPage.xaml.cs
new file mode 100644
index 0000000..d0aefc2
--- /dev/null
+++ b/desktop/GSM3/Pages/BackupPage.xaml.cs
@@ -0,0 +1,37 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace GSM3.Pages;
+
+public sealed partial class BackupPage : Page
+{
+ public BackupPage()
+ {
+ InitializeComponent();
+ }
+
+ private void InstanceSelector_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ // TODO: Filter backup list by selected instance
+ }
+
+ private void CreateBackupButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Create a new backup for the selected instance
+ }
+
+ private void RestoreButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Restore the selected backup
+ }
+
+ private void DeleteButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Delete the selected backup
+ }
+
+ private void RefreshButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Refresh the backup list
+ }
+}
diff --git a/desktop/GSM3/Pages/DashboardPage.xaml b/desktop/GSM3/Pages/DashboardPage.xaml
new file mode 100644
index 0000000..918b3a0
--- /dev/null
+++ b/desktop/GSM3/Pages/DashboardPage.xaml
@@ -0,0 +1,252 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/GSM3/Pages/DashboardPage.xaml.cs b/desktop/GSM3/Pages/DashboardPage.xaml.cs
new file mode 100644
index 0000000..ea0011f
--- /dev/null
+++ b/desktop/GSM3/Pages/DashboardPage.xaml.cs
@@ -0,0 +1,30 @@
+using Microsoft.UI.Xaml.Controls;
+
+namespace GSM3.Pages;
+
+public sealed partial class DashboardPage : Page
+{
+ public DashboardPage()
+ {
+ InitializeComponent();
+ Loaded += DashboardPage_Loaded;
+ }
+
+ private void DashboardPage_Loaded(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
+ {
+ // Sample placeholder data
+ TotalCountText.Text = "3";
+ RunningCountText.Text = "2";
+ StoppedCountText.Text = "1";
+ ErrorCountText.Text = "0";
+
+ CpuProgressBar.Value = 45;
+ CpuPercentText.Text = "45%";
+
+ MemoryProgressBar.Value = 62;
+ MemoryPercentText.Text = "62%";
+
+ DiskProgressBar.Value = 38;
+ DiskPercentText.Text = "38%";
+ }
+}
diff --git a/desktop/GSM3/Pages/EnvironmentPage.xaml b/desktop/GSM3/Pages/EnvironmentPage.xaml
new file mode 100644
index 0000000..e5f7231
--- /dev/null
+++ b/desktop/GSM3/Pages/EnvironmentPage.xaml
@@ -0,0 +1,234 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/GSM3/Pages/EnvironmentPage.xaml.cs b/desktop/GSM3/Pages/EnvironmentPage.xaml.cs
new file mode 100644
index 0000000..6461e68
--- /dev/null
+++ b/desktop/GSM3/Pages/EnvironmentPage.xaml.cs
@@ -0,0 +1,139 @@
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media;
+using System.Collections.Generic;
+
+namespace GSM3.Pages;
+
+public sealed partial class EnvironmentPage : Page
+{
+ public EnvironmentPage()
+ {
+ InitializeComponent();
+ Loaded += EnvironmentPage_Loaded;
+ }
+
+ private void EnvironmentPage_Loaded(object sender, RoutedEventArgs e)
+ {
+ LoadJavaRuntimes();
+ LoadVcRedistributables();
+ LoadDirectXStatus();
+ LoadDotNetRuntimes();
+ }
+
+ private void LoadJavaRuntimes()
+ {
+ var javaItems = new List
+ {
+ new()
+ {
+ Name = "Java 21 (Eclipse Temurin)",
+ Path = @"C:\Program Files\Eclipse Adoptium\jdk-21\bin\java.exe",
+ Status = "已安装",
+ StatusColor = new SolidColorBrush(Colors.Green)
+ },
+ new()
+ {
+ Name = "Java 17 (Eclipse Temurin)",
+ Path = @"C:\Program Files\Eclipse Adoptium\jdk-17\bin\java.exe",
+ Status = "已安装",
+ StatusColor = new SolidColorBrush(Colors.Green)
+ },
+ new()
+ {
+ Name = "Java 8 (Oracle)",
+ Path = @"C:\Program Files\Java\jre1.8.0\bin\java.exe",
+ Status = "已安装",
+ StatusColor = new SolidColorBrush(Colors.Green)
+ }
+ };
+
+ JavaListView.ItemsSource = javaItems;
+ }
+
+ private void LoadVcRedistributables()
+ {
+ var vcItems = new List
+ {
+ new()
+ {
+ Name = "Visual C++ 2015-2022 (x64)",
+ Status = "已安装",
+ StatusColor = new SolidColorBrush(Colors.Green)
+ },
+ new()
+ {
+ Name = "Visual C++ 2015-2022 (x86)",
+ Status = "已安装",
+ StatusColor = new SolidColorBrush(Colors.Green)
+ },
+ new()
+ {
+ Name = "Visual C++ 2013 (x64)",
+ Status = "未安装",
+ StatusColor = new SolidColorBrush(Colors.Gray)
+ }
+ };
+
+ VcRedistListView.ItemsSource = vcItems;
+ }
+
+ private void LoadDirectXStatus()
+ {
+ DirectXStatus.Text = "DirectX 12 — 已安装";
+ }
+
+ private void LoadDotNetRuntimes()
+ {
+ var dotnetItems = new List
+ {
+ new()
+ {
+ Name = ".NET 8.0.x",
+ Status = "已安装",
+ StatusColor = new SolidColorBrush(Colors.Green)
+ },
+ new()
+ {
+ Name = ".NET 7.0.x",
+ Status = "已安装",
+ StatusColor = new SolidColorBrush(Colors.Green)
+ },
+ new()
+ {
+ Name = ".NET 6.0.x",
+ Status = "已安装",
+ StatusColor = new SolidColorBrush(Colors.Green)
+ }
+ };
+
+ DotNetListView.ItemsSource = dotnetItems;
+ }
+
+ private void RefreshButton_Click(object sender, RoutedEventArgs e)
+ {
+ LoadJavaRuntimes();
+ LoadVcRedistributables();
+ LoadDirectXStatus();
+ LoadDotNetRuntimes();
+ }
+
+ private void InstallJava_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Open Java installation dialog
+ }
+
+ private void UninstallJava_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Uninstall selected Java runtime
+ }
+}
+
+public class EnvironmentItem
+{
+ public string Name { get; set; } = string.Empty;
+ public string Path { get; set; } = string.Empty;
+ public string Status { get; set; } = string.Empty;
+ public SolidColorBrush StatusColor { get; set; } = new(Colors.Gray);
+}
diff --git a/desktop/GSM3/Pages/FilesPage.xaml b/desktop/GSM3/Pages/FilesPage.xaml
new file mode 100644
index 0000000..72dcd2c
--- /dev/null
+++ b/desktop/GSM3/Pages/FilesPage.xaml
@@ -0,0 +1,225 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/GSM3/Pages/FilesPage.xaml.cs b/desktop/GSM3/Pages/FilesPage.xaml.cs
new file mode 100644
index 0000000..80c3ce3
--- /dev/null
+++ b/desktop/GSM3/Pages/FilesPage.xaml.cs
@@ -0,0 +1,104 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using GSM3.Models;
+
+namespace GSM3.Pages;
+
+public sealed partial class FilesPage : Page
+{
+ public FilesPage()
+ {
+ InitializeComponent();
+ }
+
+ // ---------- Static helpers used by x:Bind in the DataTemplate ----------
+
+ public static string GetFileIcon(FileItemType type)
+ {
+ return type == FileItemType.Directory ? "" : "";
+ }
+
+ public static string FormatFileSize(long size, FileItemType type)
+ {
+ if (type == FileItemType.Directory)
+ return "";
+
+ if (size < 1024)
+ return $"{size} B";
+ if (size < 1024 * 1024)
+ return $"{size / 1024.0:F1} KB";
+ if (size < 1024 * 1024 * 1024)
+ return $"{size / (1024.0 * 1024.0):F1} MB";
+ return $"{size / (1024.0 * 1024.0 * 1024.0):F2} GB";
+ }
+
+ public static string FormatDateTime(DateTime dateTime)
+ {
+ return dateTime.ToString("yyyy-MM-dd HH:mm");
+ }
+
+ // ---------- Drive selector ----------
+
+ private void DriveSelector_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ }
+
+ // ---------- Navigation ----------
+
+ private void UpButton_Click(object sender, RoutedEventArgs e)
+ {
+ }
+
+ private void GoButton_Click(object sender, RoutedEventArgs e)
+ {
+ }
+
+ private void PathTextBox_KeyDown(object sender, KeyRoutedEventArgs e)
+ {
+ }
+
+ // ---------- Toolbar actions ----------
+
+ private void NewFileButton_Click(object sender, RoutedEventArgs e)
+ {
+ }
+
+ private void NewFolderButton_Click(object sender, RoutedEventArgs e)
+ {
+ }
+
+ private void UploadButton_Click(object sender, RoutedEventArgs e)
+ {
+ }
+
+ private void DownloadButton_Click(object sender, RoutedEventArgs e)
+ {
+ }
+
+ private void DeleteButton_Click(object sender, RoutedEventArgs e)
+ {
+ }
+
+ private void CopyButton_Click(object sender, RoutedEventArgs e)
+ {
+ }
+
+ private void MoveButton_Click(object sender, RoutedEventArgs e)
+ {
+ }
+
+ private void CompressButton_Click(object sender, RoutedEventArgs e)
+ {
+ }
+
+ // ---------- File list interaction ----------
+
+ private void FileListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ }
+
+ private void FileListView_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
+ {
+ }
+}
diff --git a/desktop/GSM3/Pages/GameDeployPage.xaml b/desktop/GSM3/Pages/GameDeployPage.xaml
new file mode 100644
index 0000000..11ed3bc
--- /dev/null
+++ b/desktop/GSM3/Pages/GameDeployPage.xaml
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/GSM3/Pages/GameDeployPage.xaml.cs b/desktop/GSM3/Pages/GameDeployPage.xaml.cs
new file mode 100644
index 0000000..c0f0921
--- /dev/null
+++ b/desktop/GSM3/Pages/GameDeployPage.xaml.cs
@@ -0,0 +1,80 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using System.Collections.ObjectModel;
+
+namespace GSM3.Pages;
+
+public sealed partial class GameDeployPage : Page
+{
+ public class GameItem
+ {
+ public string Name { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public string AppId { get; set; } = string.Empty;
+ public Visibility AppIdVisibility => string.IsNullOrEmpty(AppId) ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ private readonly ObservableCollection _steamGames = new()
+ {
+ new GameItem { Name = "Counter-Strike 2", Description = "经典竞技射击游戏服务器", AppId = "App ID: 730" },
+ new GameItem { Name = "Valheim", Description = "北欧神话生存探索游戏服务器", AppId = "App ID: 896660" },
+ new GameItem { Name = "ARK: Survival Evolved", Description = "恐龙生存冒险游戏服务器", AppId = "App ID: 376030" },
+ };
+
+ private readonly ObservableCollection _minecraftGames = new()
+ {
+ new GameItem { Name = "Minecraft Java Edition", Description = "Java 版 Minecraft 服务器" },
+ new GameItem { Name = "Minecraft Bedrock Edition", Description = "基岩版 Minecraft 服务器" },
+ };
+
+ private readonly ObservableCollection _otherGames = new()
+ {
+ new GameItem { Name = "自定义服务器", Description = "手动配置自定义游戏服务器" },
+ };
+
+ public GameDeployPage()
+ {
+ InitializeComponent();
+ Loaded += GameDeployPage_Loaded;
+ }
+
+ private void GameDeployPage_Loaded(object sender, RoutedEventArgs e)
+ {
+ // Default to Steam category
+ GameListView.ItemsSource = _steamGames;
+ }
+
+ private void Category_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is RadioButton radio)
+ {
+ if (radio == CategorySteam)
+ {
+ GameListView.ItemsSource = _steamGames;
+ }
+ else if (radio == CategoryMinecraft)
+ {
+ GameListView.ItemsSource = _minecraftGames;
+ }
+ else if (radio == CategoryOther)
+ {
+ GameListView.ItemsSource = _otherGames;
+ }
+ }
+ }
+
+ private void InstallSteamCmdButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Implement SteamCMD installation/update logic
+ }
+
+ private void DeployButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Implement game server deployment logic
+ }
+
+ private void BrowsePathButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Implement folder picker for install path
+ }
+}
diff --git a/desktop/GSM3/Pages/InstancesPage.xaml b/desktop/GSM3/Pages/InstancesPage.xaml
new file mode 100644
index 0000000..e1dddc6
--- /dev/null
+++ b/desktop/GSM3/Pages/InstancesPage.xaml
@@ -0,0 +1,192 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/GSM3/Pages/InstancesPage.xaml.cs b/desktop/GSM3/Pages/InstancesPage.xaml.cs
new file mode 100644
index 0000000..4fd24ae
--- /dev/null
+++ b/desktop/GSM3/Pages/InstancesPage.xaml.cs
@@ -0,0 +1,38 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace GSM3.Pages;
+
+public sealed partial class InstancesPage : Page
+{
+ public InstancesPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void CreateInstanceButton_Click(object sender, RoutedEventArgs e)
+ {
+ CreateInstanceDialog.XamlRoot = XamlRoot;
+ await CreateInstanceDialog.ShowAsync();
+ }
+
+ private void StartButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Start selected instance
+ }
+
+ private void StopButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Stop selected instance
+ }
+
+ private void RestartButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Restart selected instance
+ }
+
+ private void DeleteButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Delete selected instance
+ }
+}
diff --git a/desktop/GSM3/Pages/PluginsPage.xaml b/desktop/GSM3/Pages/PluginsPage.xaml
new file mode 100644
index 0000000..4a9f90b
--- /dev/null
+++ b/desktop/GSM3/Pages/PluginsPage.xaml
@@ -0,0 +1,238 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/GSM3/Pages/PluginsPage.xaml.cs b/desktop/GSM3/Pages/PluginsPage.xaml.cs
new file mode 100644
index 0000000..90598ec
--- /dev/null
+++ b/desktop/GSM3/Pages/PluginsPage.xaml.cs
@@ -0,0 +1,60 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace GSM3.Pages;
+
+public sealed partial class PluginsPage : Page
+{
+ public PluginsPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void CreatePluginButton_Click(object sender, RoutedEventArgs e)
+ {
+ // Clear dialog fields
+ PluginNameBox.Text = string.Empty;
+ PluginVersionBox.Text = string.Empty;
+ PluginAuthorBox.Text = string.Empty;
+ PluginDescriptionBox.Text = string.Empty;
+
+ CreatePluginDialog.XamlRoot = this.XamlRoot;
+ await CreatePluginDialog.ShowAsync();
+ }
+
+ private void CreatePluginDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
+ {
+ // TODO: Create plugin with values from dialog fields
+ }
+
+ private void DeletePluginButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Delete the selected plugin
+ }
+
+ private void RefreshButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Refresh the plugin list
+ }
+
+ private void PluginListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ // TODO: Update detail panel with selected plugin info
+ if (PluginListView.SelectedItem != null)
+ {
+ DetailPlaceholderText.Visibility = Visibility.Collapsed;
+ DetailContentPanel.Visibility = Visibility.Visible;
+ // TODO: Populate detail fields from selected item
+ }
+ else
+ {
+ DetailPlaceholderText.Visibility = Visibility.Visible;
+ DetailContentPanel.Visibility = Visibility.Collapsed;
+ }
+ }
+
+ private void PluginToggle_Toggled(object sender, RoutedEventArgs e)
+ {
+ // TODO: Handle plugin enable/disable toggle
+ }
+}
diff --git a/desktop/GSM3/Pages/SchedulerPage.xaml b/desktop/GSM3/Pages/SchedulerPage.xaml
new file mode 100644
index 0000000..849031f
--- /dev/null
+++ b/desktop/GSM3/Pages/SchedulerPage.xaml
@@ -0,0 +1,181 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/GSM3/Pages/SchedulerPage.xaml.cs b/desktop/GSM3/Pages/SchedulerPage.xaml.cs
new file mode 100644
index 0000000..fb31f36
--- /dev/null
+++ b/desktop/GSM3/Pages/SchedulerPage.xaml.cs
@@ -0,0 +1,104 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace GSM3.Pages;
+
+public sealed partial class SchedulerPage : Page
+{
+ public SchedulerPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void CreateTaskButton_Click(object sender, RoutedEventArgs e)
+ {
+ CreateTaskDialog.Title = "创建定时任务";
+ CreateTaskDialog.PrimaryButtonText = "创建";
+ ClearDialogFields();
+ CreateTaskDialog.XamlRoot = XamlRoot;
+ await CreateTaskDialog.ShowAsync();
+ }
+
+ private async void EditTaskButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (TaskListView.SelectedItem == null)
+ {
+ ShowStatus("请先选择要编辑的任务", InfoBarSeverity.Warning);
+ return;
+ }
+
+ CreateTaskDialog.Title = "编辑定时任务";
+ CreateTaskDialog.PrimaryButtonText = "保存";
+ CreateTaskDialog.XamlRoot = XamlRoot;
+ await CreateTaskDialog.ShowAsync();
+ }
+
+ private void DeleteTaskButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (TaskListView.SelectedItem == null)
+ {
+ ShowStatus("请先选择要删除的任务", InfoBarSeverity.Warning);
+ return;
+ }
+
+ // TODO: Confirm and delete selected task
+ }
+
+ private void RunNowButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (TaskListView.SelectedItem == null)
+ {
+ ShowStatus("请先选择要执行的任务", InfoBarSeverity.Warning);
+ return;
+ }
+
+ // TODO: Execute selected task immediately
+ }
+
+ private void CreateTaskDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
+ {
+ if (string.IsNullOrWhiteSpace(TaskNameTextBox.Text))
+ {
+ args.Cancel = true;
+ ShowStatus("请输入任务名称", InfoBarSeverity.Error);
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(CronExpressionTextBox.Text))
+ {
+ args.Cancel = true;
+ ShowStatus("请输入 Cron 表达式", InfoBarSeverity.Error);
+ return;
+ }
+
+ // TODO: Save the task
+ }
+
+ private void ActionComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (CustomCommandTextBox == null) return;
+
+ var selectedItem = ActionComboBox.SelectedItem as ComboBoxItem;
+ CustomCommandTextBox.Visibility =
+ selectedItem?.Content?.ToString() == "自定义命令"
+ ? Visibility.Visible
+ : Visibility.Collapsed;
+ }
+
+ private void ClearDialogFields()
+ {
+ TaskNameTextBox.Text = string.Empty;
+ InstanceComboBox.SelectedIndex = -1;
+ ActionComboBox.SelectedIndex = -1;
+ CronExpressionTextBox.Text = string.Empty;
+ CustomCommandTextBox.Text = string.Empty;
+ CustomCommandTextBox.Visibility = Visibility.Collapsed;
+ }
+
+ private void ShowStatus(string message, InfoBarSeverity severity)
+ {
+ StatusInfoBar.Message = message;
+ StatusInfoBar.Severity = severity;
+ StatusInfoBar.IsOpen = true;
+ }
+}
diff --git a/desktop/GSM3/Pages/SettingsPage.xaml b/desktop/GSM3/Pages/SettingsPage.xaml
new file mode 100644
index 0000000..f069c02
--- /dev/null
+++ b/desktop/GSM3/Pages/SettingsPage.xaml
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/GSM3/Pages/SettingsPage.xaml.cs b/desktop/GSM3/Pages/SettingsPage.xaml.cs
new file mode 100644
index 0000000..127294b
--- /dev/null
+++ b/desktop/GSM3/Pages/SettingsPage.xaml.cs
@@ -0,0 +1,29 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace GSM3.Pages;
+
+public sealed partial class SettingsPage : Page
+{
+ public SettingsPage()
+ {
+ InitializeComponent();
+ }
+
+ private void ThemeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ // TODO: Apply selected theme
+ }
+
+ private async void BrowseSteamCmdPath_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Show FolderPicker and set SteamCmdPathTextBox.Text
+ await System.Threading.Tasks.Task.CompletedTask;
+ }
+
+ private async void BrowseGameInstallPath_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Show FolderPicker and set GameInstallPathTextBox.Text
+ await System.Threading.Tasks.Task.CompletedTask;
+ }
+}
diff --git a/desktop/GSM3/Pages/SystemPage.xaml b/desktop/GSM3/Pages/SystemPage.xaml
new file mode 100644
index 0000000..6c8e6ec
--- /dev/null
+++ b/desktop/GSM3/Pages/SystemPage.xaml
@@ -0,0 +1,379 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/GSM3/Pages/SystemPage.xaml.cs b/desktop/GSM3/Pages/SystemPage.xaml.cs
new file mode 100644
index 0000000..6651c97
--- /dev/null
+++ b/desktop/GSM3/Pages/SystemPage.xaml.cs
@@ -0,0 +1,96 @@
+using System.Collections.ObjectModel;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace GSM3.Pages;
+
+public sealed partial class SystemPage : Page
+{
+ public SystemPage()
+ {
+ InitializeComponent();
+ Loaded += SystemPage_Loaded;
+ }
+
+ private void SystemPage_Loaded(object sender, RoutedEventArgs e)
+ {
+ LoadSampleData();
+ }
+
+ private void LoadSampleData()
+ {
+ // CPU
+ CpuBar.Value = 35;
+ CpuText.Text = "35%";
+
+ // Memory
+ MemoryBar.Value = 68;
+ MemoryText.Text = "68%";
+ MemoryDetailText.Text = "10.9 GB / 16.0 GB";
+
+ // Disk
+ DiskBar.Value = 45;
+ DiskText.Text = "45%";
+
+ // Network
+ UploadText.Text = "1.2 MB/s";
+ DownloadText.Text = "5.8 MB/s";
+
+ // Process list sample data
+ var processes = new ObservableCollection
+ {
+ new() { Pid = 1024, Name = "java.exe", CpuPercent = 12.5, MemoryDisplay = "1.2 GB" },
+ new() { Pid = 2048, Name = "steamcmd.exe", CpuPercent = 5.3, MemoryDisplay = "512 MB" },
+ new() { Pid = 3072, Name = "minecraft_server", CpuPercent = 8.7, MemoryDisplay = "2.4 GB" },
+ new() { Pid = 4096, Name = "nginx.exe", CpuPercent = 0.8, MemoryDisplay = "64 MB" },
+ new() { Pid = 5120, Name = "node.exe", CpuPercent = 3.2, MemoryDisplay = "256 MB" },
+ };
+ ProcessListView.ItemsSource = processes;
+
+ // Active ports sample data
+ var ports = new ObservableCollection
+ {
+ new() { Protocol = "TCP", LocalAddress = "0.0.0.0", LocalPort = 25565, RemoteAddress = "*", State = "LISTEN", ProcessName = "java.exe" },
+ new() { Protocol = "TCP", LocalAddress = "0.0.0.0", LocalPort = 80, RemoteAddress = "*", State = "LISTEN", ProcessName = "nginx.exe" },
+ new() { Protocol = "TCP", LocalAddress = "192.168.1.10", LocalPort = 25565, RemoteAddress = "192.168.1.50", State = "ESTABLISHED", ProcessName = "java.exe" },
+ new() { Protocol = "UDP", LocalAddress = "0.0.0.0", LocalPort = 27015, RemoteAddress = "*", State = "LISTEN", ProcessName = "srcds.exe" },
+ new() { Protocol = "TCP", LocalAddress = "0.0.0.0", LocalPort = 3000, RemoteAddress = "*", State = "LISTEN", ProcessName = "node.exe" },
+ };
+ PortListView.ItemsSource = ports;
+ }
+
+ private void RefreshButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Refresh system monitoring data
+ LoadSampleData();
+ }
+
+ private void KillProcessButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Kill selected process
+ }
+}
+
+///
+/// Display item for the process list view.
+///
+public class ProcessDisplayItem
+{
+ public int Pid { get; set; }
+ public string Name { get; set; } = "";
+ public double CpuPercent { get; set; }
+ public string MemoryDisplay { get; set; } = "";
+}
+
+///
+/// Display item for the port list view.
+///
+public class PortDisplayItem
+{
+ public string Protocol { get; set; } = "";
+ public string LocalAddress { get; set; } = "";
+ public int LocalPort { get; set; }
+ public string RemoteAddress { get; set; } = "";
+ public string State { get; set; } = "";
+ public string ProcessName { get; set; } = "";
+}
diff --git a/desktop/GSM3/Pages/TerminalPage.xaml b/desktop/GSM3/Pages/TerminalPage.xaml
new file mode 100644
index 0000000..822e5f4
--- /dev/null
+++ b/desktop/GSM3/Pages/TerminalPage.xaml
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/GSM3/Pages/TerminalPage.xaml.cs b/desktop/GSM3/Pages/TerminalPage.xaml.cs
new file mode 100644
index 0000000..2e21832
--- /dev/null
+++ b/desktop/GSM3/Pages/TerminalPage.xaml.cs
@@ -0,0 +1,58 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+
+namespace GSM3.Pages;
+
+public sealed partial class TerminalPage : Page
+{
+ public TerminalPage()
+ {
+ InitializeComponent();
+ TerminalOutput.Text = "GSM3 终端已就绪...\n> ";
+ }
+
+ private void SendCommand()
+ {
+ var command = CommandInput.Text;
+ if (string.IsNullOrWhiteSpace(command))
+ return;
+
+ TerminalOutput.Text += command + "\n";
+ TerminalOutput.Text += "> ";
+ CommandInput.Text = string.Empty;
+
+ // Scroll to bottom
+ TerminalScrollViewer.UpdateLayout();
+ TerminalScrollViewer.ChangeView(null, TerminalScrollViewer.ScrollableHeight, null);
+ }
+
+ private void CommandInput_KeyDown(object sender, KeyRoutedEventArgs e)
+ {
+ if (e.Key == Windows.System.VirtualKey.Enter)
+ {
+ SendCommand();
+ e.Handled = true;
+ }
+ }
+
+ private void SendButton_Click(object sender, RoutedEventArgs e)
+ {
+ SendCommand();
+ }
+
+ private void NewSessionButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Implement new session creation
+ }
+
+ private void CloseSessionButton_Click(object sender, RoutedEventArgs e)
+ {
+ // TODO: Implement session closing
+ }
+
+ private void ClearScreenButton_Click(object sender, RoutedEventArgs e)
+ {
+ TerminalOutput.Text = "> ";
+ }
+}
diff --git a/desktop/GSM3/Pages/UsersPage.xaml b/desktop/GSM3/Pages/UsersPage.xaml
new file mode 100644
index 0000000..7a1ab3c
--- /dev/null
+++ b/desktop/GSM3/Pages/UsersPage.xaml
@@ -0,0 +1,211 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/GSM3/Pages/UsersPage.xaml.cs b/desktop/GSM3/Pages/UsersPage.xaml.cs
new file mode 100644
index 0000000..eef0336
--- /dev/null
+++ b/desktop/GSM3/Pages/UsersPage.xaml.cs
@@ -0,0 +1,133 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace GSM3.Pages;
+
+public sealed partial class UsersPage : Page
+{
+ public UsersPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void AddUserButton_Click(object sender, RoutedEventArgs e)
+ {
+ ClearAddUserDialogFields();
+ AddUserDialog.XamlRoot = XamlRoot;
+ await AddUserDialog.ShowAsync();
+ }
+
+ private void DeleteUserButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (UserListView.SelectedItem == null)
+ {
+ ShowStatus("请先选择要删除的用户", InfoBarSeverity.Warning);
+ return;
+ }
+
+ // TODO: Confirm and delete selected user
+ }
+
+ private async void ChangeRoleButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (UserListView.SelectedItem == null)
+ {
+ ShowStatus("请先选择要修改角色的用户", InfoBarSeverity.Warning);
+ return;
+ }
+
+ ChangeRoleDialog.XamlRoot = XamlRoot;
+ await ChangeRoleDialog.ShowAsync();
+ }
+
+ private async void ChangePasswordButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (UserListView.SelectedItem == null)
+ {
+ ShowStatus("请先选择要修改密码的用户", InfoBarSeverity.Warning);
+ return;
+ }
+
+ ClearChangePasswordDialogFields();
+ ChangePasswordDialog.XamlRoot = XamlRoot;
+ await ChangePasswordDialog.ShowAsync();
+ }
+
+ private void AddUserDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
+ {
+ if (string.IsNullOrWhiteSpace(NewUsernameTextBox.Text))
+ {
+ args.Cancel = true;
+ ShowStatus("请输入用户名", InfoBarSeverity.Error);
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(NewPasswordBox.Password))
+ {
+ args.Cancel = true;
+ ShowStatus("请输入密码", InfoBarSeverity.Error);
+ return;
+ }
+
+ if (NewPasswordBox.Password != ConfirmPasswordBox.Password)
+ {
+ args.Cancel = true;
+ ShowStatus("两次输入的密码不一致", InfoBarSeverity.Error);
+ return;
+ }
+
+ // TODO: Create the new user
+ }
+
+ private void ChangePasswordDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
+ {
+ if (string.IsNullOrWhiteSpace(ChangeNewPasswordBox.Password))
+ {
+ args.Cancel = true;
+ ShowStatus("请输入新密码", InfoBarSeverity.Error);
+ return;
+ }
+
+ if (ChangeNewPasswordBox.Password != ChangeConfirmPasswordBox.Password)
+ {
+ args.Cancel = true;
+ ShowStatus("两次输入的密码不一致", InfoBarSeverity.Error);
+ return;
+ }
+
+ // TODO: Update user password
+ }
+
+ private void ChangeRoleDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
+ {
+ if (ChangeRoleComboBox.SelectedItem == null)
+ {
+ args.Cancel = true;
+ ShowStatus("请选择角色", InfoBarSeverity.Error);
+ return;
+ }
+
+ // TODO: Update user role
+ }
+
+ private void ClearAddUserDialogFields()
+ {
+ NewUsernameTextBox.Text = string.Empty;
+ NewPasswordBox.Password = string.Empty;
+ ConfirmPasswordBox.Password = string.Empty;
+ NewRoleComboBox.SelectedIndex = -1;
+ }
+
+ private void ClearChangePasswordDialogFields()
+ {
+ ChangeNewPasswordBox.Password = string.Empty;
+ ChangeConfirmPasswordBox.Password = string.Empty;
+ }
+
+ private void ShowStatus(string message, InfoBarSeverity severity)
+ {
+ StatusInfoBar.Message = message;
+ StatusInfoBar.Severity = severity;
+ StatusInfoBar.IsOpen = true;
+ }
+}
diff --git a/desktop/GSM3/Properties/launchSettings.json b/desktop/GSM3/Properties/launchSettings.json
new file mode 100644
index 0000000..2e5c6c1
--- /dev/null
+++ b/desktop/GSM3/Properties/launchSettings.json
@@ -0,0 +1,10 @@
+{
+ "profiles": {
+ "GSM3 (Package)": {
+ "commandName": "MsixPackage"
+ },
+ "GSM3 (Unpackaged)": {
+ "commandName": "Project"
+ }
+ }
+}
\ No newline at end of file
diff --git a/desktop/GSM3/Services/BackupManager.cs b/desktop/GSM3/Services/BackupManager.cs
new file mode 100644
index 0000000..700413c
--- /dev/null
+++ b/desktop/GSM3/Services/BackupManager.cs
@@ -0,0 +1,220 @@
+namespace GSM3.Services;
+
+using System.Diagnostics;
+using System.IO.Compression;
+using System.Text.Json;
+using GSM3.Models;
+
+public class BackupManager
+{
+ private static readonly string DataDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GSM3");
+ private static readonly string BackupsDir = Path.Combine(DataDir, "backups");
+ private static readonly string IndexPath = Path.Combine(BackupsDir, "index.json");
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ private readonly InstanceManager _instanceManager;
+ private readonly SemaphoreSlim _lock = new(1, 1);
+ private List _backups = new();
+
+ public int MaxBackupsPerInstance { get; set; } = 5;
+
+ public BackupManager(InstanceManager instanceManager)
+ {
+ _instanceManager = instanceManager;
+ }
+
+ public async Task InitializeAsync()
+ {
+ Directory.CreateDirectory(BackupsDir);
+
+ if (File.Exists(IndexPath))
+ {
+ try
+ {
+ var json = await File.ReadAllTextAsync(IndexPath);
+ _backups = JsonSerializer.Deserialize>(json, JsonOptions) ?? new();
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to load backup index: {ex.Message}");
+ _backups = new();
+ }
+ }
+ }
+
+ public async Task<(bool Success, string? Error, BackupInfo? Backup)> CreateBackupAsync(
+ string instanceId, string? notes = null)
+ {
+ var instance = _instanceManager.GetInstance(instanceId);
+ if (instance == null)
+ return (false, "Instance not found.", null);
+
+ var sourceDir = instance.WorkingDirectory;
+ if (string.IsNullOrEmpty(sourceDir) || !Directory.Exists(sourceDir))
+ return (false, "Instance working directory does not exist.", null);
+
+ await _lock.WaitAsync();
+ try
+ {
+ var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
+ var safeName = SanitizeFileName(instance.Name);
+ var fileName = $"{safeName}_{timestamp}.zip";
+ var instanceBackupDir = Path.Combine(BackupsDir, instanceId);
+ Directory.CreateDirectory(instanceBackupDir);
+ var filePath = Path.Combine(instanceBackupDir, fileName);
+
+ ZipFile.CreateFromDirectory(sourceDir, filePath, CompressionLevel.Optimal, false);
+
+ var fileInfo = new FileInfo(filePath);
+ var backup = new BackupInfo
+ {
+ InstanceId = instanceId,
+ InstanceName = instance.Name,
+ FileName = fileName,
+ FilePath = filePath,
+ FileSize = fileInfo.Length,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ _backups.Add(backup);
+
+ // Enforce retention policy
+ EnforceRetention(instanceId);
+
+ await SaveIndexAsync();
+ return (true, null, backup);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to create backup: {ex.Message}");
+ return (false, $"Backup failed: {ex.Message}", null);
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ public Task<(bool Success, string? Error)> RestoreBackupAsync(string backupId, string? targetPath = null)
+ {
+ var backup = _backups.FirstOrDefault(b => b.Id == backupId);
+ if (backup == null)
+ return Task.FromResult<(bool, string?)>((false, "Backup not found."));
+
+ if (!File.Exists(backup.FilePath))
+ return Task.FromResult<(bool, string?)>((false, "Backup file is missing."));
+
+ var destination = targetPath;
+ if (string.IsNullOrEmpty(destination))
+ {
+ var instance = _instanceManager.GetInstance(backup.InstanceId);
+ if (instance == null)
+ return Task.FromResult<(bool, string?)>((false, "Instance not found and no target path specified."));
+ destination = instance.WorkingDirectory;
+ }
+
+ try
+ {
+ if (Directory.Exists(destination))
+ {
+ Directory.Delete(destination, recursive: true);
+ }
+ Directory.CreateDirectory(destination);
+ ZipFile.ExtractToDirectory(backup.FilePath, destination, overwriteFiles: true);
+ return Task.FromResult<(bool, string?)>((true, null));
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult<(bool, string?)>((false, $"Restore failed: {ex.Message}"));
+ }
+ }
+
+ public List ListBackups(string? instanceId = null)
+ {
+ if (instanceId != null)
+ return _backups.Where(b => b.InstanceId == instanceId)
+ .OrderByDescending(b => b.CreatedAt)
+ .ToList();
+
+ return _backups.OrderByDescending(b => b.CreatedAt).ToList();
+ }
+
+ public async Task<(bool Success, string? Error)> DeleteBackupAsync(string backupId)
+ {
+ await _lock.WaitAsync();
+ try
+ {
+ var backup = _backups.FirstOrDefault(b => b.Id == backupId);
+ if (backup == null)
+ return (false, "Backup not found.");
+
+ if (File.Exists(backup.FilePath))
+ {
+ File.Delete(backup.FilePath);
+ }
+
+ _backups.Remove(backup);
+ await SaveIndexAsync();
+ return (true, null);
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ // ── Retention Policy ───────────────────────────────────────
+
+ private void EnforceRetention(string instanceId)
+ {
+ if (MaxBackupsPerInstance <= 0) return;
+
+ var instanceBackups = _backups
+ .Where(b => b.InstanceId == instanceId)
+ .OrderByDescending(b => b.CreatedAt)
+ .ToList();
+
+ while (instanceBackups.Count > MaxBackupsPerInstance)
+ {
+ var oldest = instanceBackups.Last();
+ if (File.Exists(oldest.FilePath))
+ {
+ File.Delete(oldest.FilePath);
+ }
+ _backups.Remove(oldest);
+ instanceBackups.Remove(oldest);
+ }
+ }
+
+ // ── Helpers ────────────────────────────────────────────────
+
+ private async Task SaveIndexAsync()
+ {
+ try
+ {
+ var json = JsonSerializer.Serialize(_backups, JsonOptions);
+ await File.WriteAllTextAsync(IndexPath, json);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to save backup index: {ex.Message}");
+ }
+ }
+
+ private static string SanitizeFileName(string name)
+ {
+ var invalid = Path.GetInvalidFileNameChars();
+ var sb = new System.Text.StringBuilder(name.Length);
+ foreach (var c in name)
+ {
+ sb.Append(Array.IndexOf(invalid, c) >= 0 ? '_' : c);
+ }
+ return sb.ToString();
+ }
+}
diff --git a/desktop/GSM3/Services/ConfigManager.cs b/desktop/GSM3/Services/ConfigManager.cs
new file mode 100644
index 0000000..9786558
--- /dev/null
+++ b/desktop/GSM3/Services/ConfigManager.cs
@@ -0,0 +1,100 @@
+namespace GSM3.Services;
+
+using System.Text.Json;
+using GSM3.Models;
+
+public class ConfigManager
+{
+ private static readonly string DataDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GSM3");
+ private static readonly string ConfigPath = Path.Combine(DataDir, "config.json");
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ private readonly object _lock = new();
+ private AppConfig _config = new();
+ private bool _initialized;
+
+ public AppConfig Config
+ {
+ get { lock (_lock) { return _config; } }
+ }
+
+ public async Task InitializeAsync()
+ {
+ if (_initialized) return;
+
+ Directory.CreateDirectory(DataDir);
+
+ if (File.Exists(ConfigPath))
+ {
+ await LoadConfigAsync();
+ }
+ else
+ {
+ await SaveConfigAsync();
+ }
+
+ _initialized = true;
+ }
+
+ public async Task LoadConfigAsync()
+ {
+ try
+ {
+ var json = await File.ReadAllTextAsync(ConfigPath);
+ var config = JsonSerializer.Deserialize(json, JsonOptions);
+ if (config != null)
+ {
+ lock (_lock)
+ {
+ _config = config;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Failed to load config: {ex.Message}");
+ _config = new AppConfig();
+ }
+ }
+
+ public async Task SaveConfigAsync()
+ {
+ try
+ {
+ Directory.CreateDirectory(DataDir);
+ string json;
+ lock (_lock)
+ {
+ json = JsonSerializer.Serialize(_config, JsonOptions);
+ }
+ await File.WriteAllTextAsync(ConfigPath, json);
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Failed to save config: {ex.Message}");
+ }
+ }
+
+ public AppConfig GetConfig()
+ {
+ lock (_lock)
+ {
+ return _config;
+ }
+ }
+
+ public async Task UpdateConfigAsync(Action updater)
+ {
+ lock (_lock)
+ {
+ updater(_config);
+ }
+ await SaveConfigAsync();
+ }
+}
diff --git a/desktop/GSM3/Services/CronParser.cs b/desktop/GSM3/Services/CronParser.cs
new file mode 100644
index 0000000..10033a0
--- /dev/null
+++ b/desktop/GSM3/Services/CronParser.cs
@@ -0,0 +1,245 @@
+namespace GSM3.Services;
+
+///
+/// Simple 5-field cron expression parser (minute hour day-of-month month day-of-week).
+/// Supports: * (any), specific values, ranges (1-5), steps (*/5, 1-30/5), lists (1,3,5).
+/// Day-of-week: 0=Sunday .. 6=Saturday (7 is also accepted as Sunday).
+///
+public static class CronParser
+{
+ ///
+ /// Returns the next occurrence of the cron schedule strictly after .
+ /// Scans up to 4 years ahead; throws if no match is found (e.g. impossible expression).
+ ///
+ public static DateTime GetNextOccurrence(string cronExpression, DateTime from)
+ {
+ var fields = Parse(cronExpression);
+ // Start from the next minute boundary
+ var dt = new DateTime(from.Year, from.Month, from.Day, from.Hour, from.Minute, 0).AddMinutes(1);
+ var limit = from.AddYears(4);
+
+ while (dt <= limit)
+ {
+ if (!fields.Months.Contains(dt.Month))
+ {
+ // Jump to first day of next month
+ dt = new DateTime(dt.Year, dt.Month, 1).AddMonths(1);
+ continue;
+ }
+
+ if (!fields.DaysOfMonth.Contains(dt.Day) || !fields.DaysOfWeek.Contains((int)dt.DayOfWeek))
+ {
+ dt = dt.Date.AddDays(1);
+ continue;
+ }
+
+ if (!fields.Hours.Contains(dt.Hour))
+ {
+ dt = new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 0, 0).AddHours(1);
+ continue;
+ }
+
+ if (!fields.Minutes.Contains(dt.Minute))
+ {
+ dt = dt.AddMinutes(1);
+ continue;
+ }
+
+ return dt;
+ }
+
+ throw new InvalidOperationException(
+ $"No matching occurrence found within 4 years for cron expression: {cronExpression}");
+ }
+
+ ///
+ /// Tests whether matches the cron expression (ignoring seconds).
+ ///
+ public static bool IsMatch(string cronExpression, DateTime time)
+ {
+ var fields = Parse(cronExpression);
+ return fields.Minutes.Contains(time.Minute)
+ && fields.Hours.Contains(time.Hour)
+ && fields.DaysOfMonth.Contains(time.Day)
+ && fields.Months.Contains(time.Month)
+ && fields.DaysOfWeek.Contains((int)time.DayOfWeek);
+ }
+
+ ///
+ /// Returns a human-readable Chinese description of the cron expression.
+ /// Examples: "每5分钟", "每天 09:00", "每周一 08:30", "每月1日 00:00".
+ ///
+ public static string GetDescription(string cronExpression)
+ {
+ var parts = cronExpression.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 5)
+ return cronExpression;
+
+ var minute = parts[0];
+ var hour = parts[1];
+ var dom = parts[2];
+ var month = parts[3];
+ var dow = parts[4];
+
+ // Every N minutes: */N * * * *
+ if (minute.StartsWith("*/") && hour == "*" && dom == "*" && month == "*" && dow == "*")
+ return $"每{minute[2..]}分钟";
+
+ // Every N hours: 0 */N * * *
+ if (minute == "0" && hour.StartsWith("*/") && dom == "*" && month == "*" && dow == "*")
+ return $"每{hour[2..]}小时";
+
+ // Build time string
+ string timeStr = FormatTime(minute, hour);
+
+ // Specific month + day: M D H M *
+ if (month != "*" && dom != "*" && dow == "*")
+ return $"每年{month}月{dom}日 {timeStr}";
+
+ // Specific day of month: M * DOM * *
+ if (dom != "*" && month == "*" && dow == "*")
+ return $"每月{dom}日 {timeStr}";
+
+ // Specific day of week: M H * * DOW
+ if (dow != "*" && dom == "*" && month == "*")
+ {
+ var dowDesc = FormatDayOfWeek(dow);
+ return $"每周{dowDesc} {timeStr}";
+ }
+
+ // Every day at specific time: M H * * *
+ if (dom == "*" && month == "*" && dow == "*")
+ {
+ if (minute == "*" && hour == "*")
+ return "每分钟";
+ if (minute == "*")
+ return $"每小时({hour}时)";
+ return $"每天 {timeStr}";
+ }
+
+ // Fallback: return the raw expression
+ return cronExpression;
+ }
+
+ // ── Internal parsing ────────────────────────────────────────────────
+
+ private static CronFields Parse(string cronExpression)
+ {
+ var parts = cronExpression.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 5)
+ throw new FormatException($"Cron expression must have exactly 5 fields, got {parts.Length}: {cronExpression}");
+
+ return new CronFields
+ {
+ Minutes = ParseField(parts[0], 0, 59),
+ Hours = ParseField(parts[1], 0, 23),
+ DaysOfMonth = ParseField(parts[2], 1, 31),
+ Months = ParseField(parts[3], 1, 12),
+ DaysOfWeek = NormalizeDow(ParseField(parts[4], 0, 7)),
+ };
+ }
+
+ ///
+ /// Parses a single cron field (supports *, values, ranges, steps, lists).
+ ///
+ private static HashSet ParseField(string field, int min, int max)
+ {
+ var result = new HashSet();
+
+ foreach (var item in field.Split(','))
+ {
+ var token = item.Trim();
+ if (string.IsNullOrEmpty(token))
+ throw new FormatException($"Empty token in cron field: {field}");
+
+ // Check for step: */N or A-B/N or A/N
+ int step = 1;
+ var slashIdx = token.IndexOf('/');
+ if (slashIdx >= 0)
+ {
+ step = int.Parse(token[(slashIdx + 1)..]);
+ if (step <= 0) throw new FormatException($"Step must be positive: {token}");
+ token = token[..slashIdx];
+ }
+
+ if (token == "*")
+ {
+ for (int i = min; i <= max; i += step)
+ result.Add(i);
+ }
+ else if (token.Contains('-'))
+ {
+ var rangeParts = token.Split('-');
+ int rangeStart = int.Parse(rangeParts[0]);
+ int rangeEnd = int.Parse(rangeParts[1]);
+ for (int i = rangeStart; i <= rangeEnd; i += step)
+ result.Add(i);
+ }
+ else
+ {
+ int val = int.Parse(token);
+ if (slashIdx >= 0)
+ {
+ // e.g. 5/10 means starting at 5, every 10
+ for (int i = val; i <= max; i += step)
+ result.Add(i);
+ }
+ else
+ {
+ result.Add(val);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Normalize day-of-week: treat 7 as 0 (both mean Sunday).
+ ///
+ private static HashSet NormalizeDow(HashSet dow)
+ {
+ if (dow.Remove(7))
+ dow.Add(0);
+ return dow;
+ }
+
+ private static string FormatTime(string minuteField, string hourField)
+ {
+ // Attempt to produce HH:mm; fall back to raw tokens.
+ if (int.TryParse(hourField, out var h) && int.TryParse(minuteField, out var m))
+ return $"{h:D2}:{m:D2}";
+ return $"{hourField}:{minuteField}";
+ }
+
+ private static string FormatDayOfWeek(string dowField)
+ {
+ // Map numeric day-of-week to Chinese name
+ string[] names = ["日", "一", "二", "三", "四", "五", "六"];
+
+ if (int.TryParse(dowField, out var d))
+ {
+ if (d == 7) d = 0;
+ return d >= 0 && d <= 6 ? names[d] : dowField;
+ }
+
+ // Handle lists: 1,3,5 -> 一、三、五
+ if (dowField.Contains(','))
+ {
+ var parts = dowField.Split(',')
+ .Select(p => int.TryParse(p.Trim(), out var v) && v >= 0 && v <= 6 ? names[v] : p.Trim());
+ return string.Join("、", parts);
+ }
+
+ return dowField;
+ }
+
+ private struct CronFields
+ {
+ public HashSet Minutes;
+ public HashSet Hours;
+ public HashSet DaysOfMonth;
+ public HashSet Months;
+ public HashSet DaysOfWeek;
+ }
+}
diff --git a/desktop/GSM3/Services/FileManager.cs b/desktop/GSM3/Services/FileManager.cs
new file mode 100644
index 0000000..896c823
--- /dev/null
+++ b/desktop/GSM3/Services/FileManager.cs
@@ -0,0 +1,267 @@
+namespace GSM3.Services;
+
+using System.Diagnostics;
+using GSM3.Models;
+
+public class FileManager
+{
+ public List ListDirectory(string path)
+ {
+ var entries = new List();
+
+ if (!Directory.Exists(path))
+ return entries;
+
+ try
+ {
+ var dirInfo = new DirectoryInfo(path);
+
+ foreach (var dir in dirInfo.GetDirectories())
+ {
+ try
+ {
+ entries.Add(new FileItem
+ {
+ Name = dir.Name,
+ Path = dir.FullName,
+ Type = FileItemType.Directory,
+ CreatedAt = dir.CreationTimeUtc,
+ ModifiedAt = dir.LastWriteTimeUtc,
+ IsReadOnly = dir.Attributes.HasFlag(FileAttributes.ReadOnly),
+ IsHidden = dir.Attributes.HasFlag(FileAttributes.Hidden)
+ });
+ }
+ catch { /* skip inaccessible dirs */ }
+ }
+
+ foreach (var file in dirInfo.GetFiles())
+ {
+ try
+ {
+ entries.Add(new FileItem
+ {
+ Name = file.Name,
+ Path = file.FullName,
+ Type = FileItemType.File,
+ Size = file.Length,
+ CreatedAt = file.CreationTimeUtc,
+ ModifiedAt = file.LastWriteTimeUtc,
+ Extension = file.Extension,
+ IsReadOnly = file.IsReadOnly,
+ IsHidden = file.Attributes.HasFlag(FileAttributes.Hidden)
+ });
+ }
+ catch { /* skip inaccessible files */ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to list directory {path}: {ex.Message}");
+ }
+
+ return entries;
+ }
+
+ public async Task<(bool Success, string? Content, string? Error)> ReadFileAsync(string path)
+ {
+ try
+ {
+ if (!File.Exists(path))
+ return (false, null, "File not found.");
+
+ var content = await File.ReadAllTextAsync(path);
+ return (true, content, null);
+ }
+ catch (Exception ex)
+ {
+ return (false, null, ex.Message);
+ }
+ }
+
+ public async Task<(bool Success, string? Error)> WriteFileAsync(string path, string content)
+ {
+ try
+ {
+ var dir = System.IO.Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
+
+ await File.WriteAllTextAsync(path, content);
+ return (true, null);
+ }
+ catch (Exception ex)
+ {
+ return (false, ex.Message);
+ }
+ }
+
+ public (bool Success, string? Error) CreateFile(string path)
+ {
+ try
+ {
+ var dir = System.IO.Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
+
+ if (File.Exists(path))
+ return (false, "File already exists.");
+
+ File.Create(path).Dispose();
+ return (true, null);
+ }
+ catch (Exception ex)
+ {
+ return (false, ex.Message);
+ }
+ }
+
+ public (bool Success, string? Error) CreateDirectory(string path)
+ {
+ try
+ {
+ if (Directory.Exists(path))
+ return (false, "Directory already exists.");
+
+ Directory.CreateDirectory(path);
+ return (true, null);
+ }
+ catch (Exception ex)
+ {
+ return (false, ex.Message);
+ }
+ }
+
+ public (bool Success, string? Error) DeleteFile(string path)
+ {
+ try
+ {
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ return (true, null);
+ }
+
+ if (Directory.Exists(path))
+ {
+ Directory.Delete(path, recursive: true);
+ return (true, null);
+ }
+
+ return (false, "Path not found.");
+ }
+ catch (Exception ex)
+ {
+ return (false, ex.Message);
+ }
+ }
+
+ public (bool Success, string? Error) CopyFile(string source, string destination)
+ {
+ try
+ {
+ if (!File.Exists(source))
+ return (false, "Source file not found.");
+
+ var dir = System.IO.Path.GetDirectoryName(destination);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
+
+ File.Copy(source, destination, overwrite: true);
+ return (true, null);
+ }
+ catch (Exception ex)
+ {
+ return (false, ex.Message);
+ }
+ }
+
+ public (bool Success, string? Error) MoveFile(string source, string destination)
+ {
+ try
+ {
+ var dir = System.IO.Path.GetDirectoryName(destination);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
+
+ if (File.Exists(source))
+ {
+ File.Move(source, destination, overwrite: true);
+ return (true, null);
+ }
+
+ if (Directory.Exists(source))
+ {
+ Directory.Move(source, destination);
+ return (true, null);
+ }
+
+ return (false, "Source not found.");
+ }
+ catch (Exception ex)
+ {
+ return (false, ex.Message);
+ }
+ }
+
+ public List SearchFiles(string rootPath, string pattern, bool recursive = true)
+ {
+ var results = new List();
+
+ if (!Directory.Exists(rootPath))
+ return results;
+
+ try
+ {
+ var option = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
+ foreach (var filePath in Directory.EnumerateFiles(rootPath, pattern, option))
+ {
+ try
+ {
+ var fi = new FileInfo(filePath);
+ results.Add(new FileItem
+ {
+ Name = fi.Name,
+ Path = fi.FullName,
+ Type = FileItemType.File,
+ Size = fi.Length,
+ CreatedAt = fi.CreationTimeUtc,
+ ModifiedAt = fi.LastWriteTimeUtc,
+ Extension = fi.Extension,
+ IsReadOnly = fi.IsReadOnly,
+ IsHidden = fi.Attributes.HasFlag(FileAttributes.Hidden)
+ });
+ }
+ catch { /* skip inaccessible */ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Search failed in {rootPath}: {ex.Message}");
+ }
+
+ return results;
+ }
+
+ public List GetDrives()
+ {
+ var drives = new List();
+ foreach (var drive in DriveInfo.GetDrives())
+ {
+ try
+ {
+ drives.Add(new DriveEntry
+ {
+ Name = drive.Name,
+ Label = drive.IsReady ? drive.VolumeLabel : "",
+ DriveType = drive.DriveType.ToString(),
+ Format = drive.IsReady ? drive.DriveFormat : "",
+ TotalBytes = drive.IsReady ? drive.TotalSize : 0,
+ FreeBytes = drive.IsReady ? drive.AvailableFreeSpace : 0,
+ IsReady = drive.IsReady
+ });
+ }
+ catch { /* skip inaccessible drives */ }
+ }
+ return drives;
+ }
+}
diff --git a/desktop/GSM3/Services/GameConfigService.cs b/desktop/GSM3/Services/GameConfigService.cs
new file mode 100644
index 0000000..3ce5dda
--- /dev/null
+++ b/desktop/GSM3/Services/GameConfigService.cs
@@ -0,0 +1,675 @@
+namespace GSM3.Services;
+
+using System.Text;
+using System.Text.RegularExpressions;
+
+///
+/// Represents the metadata section of a game config schema.
+///
+public class GameConfigMeta
+{
+ public string GameName { get; set; } = "";
+ public string ConfigFile { get; set; } = "";
+ public string Parser { get; set; } = "properties";
+ public string? Section { get; set; }
+}
+
+///
+/// Represents a select option in a field definition.
+///
+public class GameConfigOption
+{
+ public string Value { get; set; } = "";
+ public string Label { get; set; } = "";
+}
+
+///
+/// Represents a single config field definition from the schema.
+///
+public class GameConfigField
+{
+ public string Key { get; set; } = "";
+ public string Label { get; set; } = "";
+ public string Type { get; set; } = "string";
+ public string Default { get; set; } = "";
+ public double? Min { get; set; }
+ public double? Max { get; set; }
+ public double? Step { get; set; }
+ public List Options { get; set; } = new();
+}
+
+///
+/// Represents a section of config fields.
+///
+public class GameConfigSection
+{
+ public string Name { get; set; } = "";
+ public List Fields { get; set; } = new();
+}
+
+///
+/// Represents a full game config schema loaded from a YAML file.
+///
+public class GameConfigSchema
+{
+ public GameConfigMeta Meta { get; set; } = new();
+ public List Sections { get; set; } = new();
+}
+
+///
+/// Service for loading game config YAML schemas and reading/writing actual game config files.
+/// Uses a simple hand-rolled YAML parser (no NuGet dependency).
+///
+public class GameConfigService
+{
+ private readonly string _gameConfigsDir;
+
+ public GameConfigService()
+ {
+ // Look for GameConfigs directory relative to the app's base directory
+ _gameConfigsDir = Path.Combine(AppContext.BaseDirectory, "GameConfigs");
+
+ // Fallback: check if we're running from the project directory (dev scenario)
+ if (!Directory.Exists(_gameConfigsDir))
+ {
+ var projectDir = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
+ var devPath = Path.Combine(projectDir, "GameConfigs");
+ if (Directory.Exists(devPath))
+ {
+ _gameConfigsDir = devPath;
+ }
+ }
+ }
+
+ public GameConfigService(string gameConfigsDir)
+ {
+ _gameConfigsDir = gameConfigsDir;
+ }
+
+ ///
+ /// Lists all available YAML config schema files in the GameConfigs directory.
+ ///
+ public List GetAvailableConfigs()
+ {
+ if (!Directory.Exists(_gameConfigsDir))
+ return new List();
+
+ return Directory.GetFiles(_gameConfigsDir, "*.yml")
+ .Concat(Directory.GetFiles(_gameConfigsDir, "*.yaml"))
+ .OrderBy(f => f)
+ .ToList();
+ }
+
+ ///
+ /// Loads and parses a YAML schema file into a GameConfigSchema object.
+ ///
+ public GameConfigSchema LoadSchema(string yamlPath)
+ {
+ if (!File.Exists(yamlPath))
+ throw new FileNotFoundException($"Schema file not found: {yamlPath}");
+
+ var lines = File.ReadAllLines(yamlPath, Encoding.UTF8);
+ return ParseYaml(lines);
+ }
+
+ ///
+ /// Loads a schema by game name (searches available configs).
+ ///
+ public GameConfigSchema? LoadSchemaByGameName(string gameName)
+ {
+ foreach (var path in GetAvailableConfigs())
+ {
+ try
+ {
+ var schema = LoadSchema(path);
+ if (schema.Meta.GameName.Equals(gameName, StringComparison.OrdinalIgnoreCase))
+ return schema;
+ }
+ catch
+ {
+ // Skip invalid schemas
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Reads an actual game config file using the schema to understand its format.
+ /// Returns a dictionary of key -> value for all recognized fields.
+ ///
+ public Dictionary ReadConfig(string configFilePath, GameConfigSchema schema)
+ {
+ var values = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ // Initialize with defaults from schema
+ foreach (var section in schema.Sections)
+ {
+ foreach (var field in section.Fields)
+ {
+ values[field.Key] = field.Default;
+ }
+ }
+
+ if (!File.Exists(configFilePath))
+ return values;
+
+ var content = File.ReadAllText(configFilePath, Encoding.UTF8);
+
+ switch (schema.Meta.Parser.ToLowerInvariant())
+ {
+ case "properties":
+ ParsePropertiesFile(content, values);
+ break;
+ case "ini":
+ ParseIniFile(content, schema.Meta.Section, values);
+ break;
+ default:
+ throw new NotSupportedException($"Unsupported parser type: {schema.Meta.Parser}");
+ }
+
+ return values;
+ }
+
+ ///
+ /// Writes config values to a game config file using the schema format.
+ ///
+ public void SaveConfig(string configFilePath, GameConfigSchema schema, Dictionary values)
+ {
+ // Ensure directory exists
+ var dir = Path.GetDirectoryName(configFilePath);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
+
+ switch (schema.Meta.Parser.ToLowerInvariant())
+ {
+ case "properties":
+ WritePropertiesFile(configFilePath, schema, values);
+ break;
+ case "ini":
+ WriteIniFile(configFilePath, schema, values);
+ break;
+ default:
+ throw new NotSupportedException($"Unsupported parser type: {schema.Meta.Parser}");
+ }
+ }
+
+ #region Properties file parser
+
+ private void ParsePropertiesFile(string content, Dictionary values)
+ {
+ var lines = content.Split('\n');
+ foreach (var rawLine in lines)
+ {
+ var line = rawLine.TrimEnd('\r').Trim();
+
+ // Skip comments and empty lines
+ if (string.IsNullOrEmpty(line) || line.StartsWith('#') || line.StartsWith("//"))
+ continue;
+
+ var eqIndex = line.IndexOf('=');
+ if (eqIndex < 0)
+ continue;
+
+ var key = line[..eqIndex].Trim();
+ var value = line[(eqIndex + 1)..].Trim();
+
+ if (values.ContainsKey(key))
+ {
+ values[key] = value;
+ }
+ }
+ }
+
+ private void WritePropertiesFile(string filePath, GameConfigSchema schema, Dictionary values)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine($"# {schema.Meta.GameName} Server Configuration");
+ sb.AppendLine($"# Generated by GSM3");
+ sb.AppendLine();
+
+ // If the file already exists, try to preserve comments and ordering
+ if (File.Exists(filePath))
+ {
+ var existingLines = File.ReadAllLines(filePath, Encoding.UTF8);
+ var writtenKeys = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var rawLine in existingLines)
+ {
+ var line = rawLine.TrimEnd('\r');
+ var trimmed = line.Trim();
+
+ if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#') || trimmed.StartsWith("//"))
+ {
+ sb.AppendLine(line);
+ continue;
+ }
+
+ var eqIndex = trimmed.IndexOf('=');
+ if (eqIndex < 0)
+ {
+ sb.AppendLine(line);
+ continue;
+ }
+
+ var key = trimmed[..eqIndex].Trim();
+ if (values.TryGetValue(key, out var val))
+ {
+ sb.AppendLine($"{key}={val}");
+ writtenKeys.Add(key);
+ }
+ else
+ {
+ sb.AppendLine(line);
+ writtenKeys.Add(key);
+ }
+ }
+
+ // Append any new keys not in the existing file
+ foreach (var kvp in values)
+ {
+ if (!writtenKeys.Contains(kvp.Key))
+ {
+ sb.AppendLine($"{kvp.Key}={kvp.Value}");
+ }
+ }
+ }
+ else
+ {
+ // Write fresh file organized by schema sections
+ foreach (var section in schema.Sections)
+ {
+ sb.AppendLine($"# {section.Name}");
+ foreach (var field in section.Fields)
+ {
+ var val = values.TryGetValue(field.Key, out var v) ? v : field.Default;
+ sb.AppendLine($"{field.Key}={val}");
+ }
+ sb.AppendLine();
+ }
+ }
+
+ File.WriteAllText(filePath, sb.ToString(), Encoding.UTF8);
+ }
+
+ #endregion
+
+ #region INI file parser
+
+ private void ParseIniFile(string content, string? targetSection, Dictionary values)
+ {
+ var lines = content.Split('\n');
+ string? currentSection = null;
+
+ foreach (var rawLine in lines)
+ {
+ var line = rawLine.TrimEnd('\r').Trim();
+
+ if (string.IsNullOrEmpty(line) || line.StartsWith(';') || line.StartsWith('#'))
+ continue;
+
+ // Section header: [SectionName]
+ if (line.StartsWith('[') && line.EndsWith(']'))
+ {
+ currentSection = line[1..^1].Trim();
+ continue;
+ }
+
+ // Only parse keys from the target section (or all if no target)
+ if (targetSection != null && !string.Equals(currentSection, targetSection, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ var eqIndex = line.IndexOf('=');
+ if (eqIndex < 0)
+ continue;
+
+ var key = line[..eqIndex].Trim();
+ var value = line[(eqIndex + 1)..].Trim();
+
+ // PalWorld wraps the entire value set in OptionSettings=(...)
+ // Handle the special case where the value contains nested key=value pairs
+ if (key == "OptionSettings" && value.StartsWith('(') && value.EndsWith(')'))
+ {
+ ParsePalWorldOptionSettings(value[1..^1], values);
+ continue;
+ }
+
+ // Remove surrounding quotes if present
+ if (value.Length >= 2 &&
+ ((value.StartsWith('"') && value.EndsWith('"')) ||
+ (value.StartsWith('\'') && value.EndsWith('\''))))
+ {
+ value = value[1..^1];
+ }
+
+ if (values.ContainsKey(key))
+ {
+ values[key] = value;
+ }
+ }
+ }
+
+ ///
+ /// PalWorld stores all settings in a single line like:
+ /// OptionSettings=(ServerName="...",ServerPlayerMaxNum=32,ExpRate=1.000000,...)
+ ///
+ private void ParsePalWorldOptionSettings(string optionString, Dictionary values)
+ {
+ // Split by commas, but respect quoted strings
+ var pairs = SplitRespectingQuotes(optionString, ',');
+
+ foreach (var pair in pairs)
+ {
+ var trimmed = pair.Trim();
+ var eqIndex = trimmed.IndexOf('=');
+ if (eqIndex < 0)
+ continue;
+
+ var key = trimmed[..eqIndex].Trim();
+ var value = trimmed[(eqIndex + 1)..].Trim();
+
+ // Remove surrounding quotes
+ if (value.Length >= 2 && value.StartsWith('"') && value.EndsWith('"'))
+ {
+ value = value[1..^1];
+ }
+
+ if (values.ContainsKey(key))
+ {
+ values[key] = value;
+ }
+ }
+ }
+
+ private static List SplitRespectingQuotes(string input, char delimiter)
+ {
+ var result = new List();
+ var current = new StringBuilder();
+ bool inQuotes = false;
+
+ foreach (char c in input)
+ {
+ if (c == '"')
+ {
+ inQuotes = !inQuotes;
+ current.Append(c);
+ }
+ else if (c == delimiter && !inQuotes)
+ {
+ result.Add(current.ToString());
+ current.Clear();
+ }
+ else
+ {
+ current.Append(c);
+ }
+ }
+
+ if (current.Length > 0)
+ result.Add(current.ToString());
+
+ return result;
+ }
+
+ private void WriteIniFile(string filePath, GameConfigSchema schema, Dictionary values)
+ {
+ var sb = new StringBuilder();
+
+ // Special handling for PalWorld-style INI files where all settings go in OptionSettings=(...)
+ if (schema.Meta.Section != null && schema.Meta.ConfigFile.Contains("PalWorld", StringComparison.OrdinalIgnoreCase))
+ {
+ WritePalWorldIni(sb, schema, values);
+ }
+ else
+ {
+ WriteStandardIni(sb, schema, values);
+ }
+
+ File.WriteAllText(filePath, sb.ToString(), Encoding.UTF8);
+ }
+
+ private void WritePalWorldIni(StringBuilder sb, GameConfigSchema schema, Dictionary values)
+ {
+ sb.AppendLine("; PalWorld Server Configuration");
+ sb.AppendLine("; Generated by GSM3");
+ sb.AppendLine();
+ sb.AppendLine($"[{schema.Meta.Section}]");
+
+ var pairs = new List();
+ foreach (var section in schema.Sections)
+ {
+ foreach (var field in section.Fields)
+ {
+ var val = values.TryGetValue(field.Key, out var v) ? v : field.Default;
+
+ // Wrap string values in quotes for PalWorld format
+ if (field.Type == "string")
+ {
+ pairs.Add($"{field.Key}=\"{val}\"");
+ }
+ else if (field.Type == "boolean")
+ {
+ // PalWorld uses True/False
+ var boolVal = val.Equals("true", StringComparison.OrdinalIgnoreCase) ? "True" : "False";
+ pairs.Add($"{field.Key}={boolVal}");
+ }
+ else if (field.Type == "number" && val.Contains('.'))
+ {
+ // PalWorld uses 6-decimal float format
+ if (double.TryParse(val, System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var numVal))
+ {
+ pairs.Add($"{field.Key}={numVal.ToString("F6", System.Globalization.CultureInfo.InvariantCulture)}");
+ }
+ else
+ {
+ pairs.Add($"{field.Key}={val}");
+ }
+ }
+ else
+ {
+ pairs.Add($"{field.Key}={val}");
+ }
+ }
+ }
+
+ sb.AppendLine($"OptionSettings=({string.Join(",", pairs)})");
+ }
+
+ private void WriteStandardIni(StringBuilder sb, GameConfigSchema schema, Dictionary values)
+ {
+ sb.AppendLine($"; {schema.Meta.GameName} Server Configuration");
+ sb.AppendLine("; Generated by GSM3");
+ sb.AppendLine();
+
+ if (schema.Meta.Section != null)
+ {
+ sb.AppendLine($"[{schema.Meta.Section}]");
+ }
+
+ foreach (var section in schema.Sections)
+ {
+ sb.AppendLine($"; --- {section.Name} ---");
+ foreach (var field in section.Fields)
+ {
+ var val = values.TryGetValue(field.Key, out var v) ? v : field.Default;
+ sb.AppendLine($"{field.Key}={val}");
+ }
+ sb.AppendLine();
+ }
+ }
+
+ #endregion
+
+ #region Simple YAML parser
+
+ ///
+ /// Parses a YAML schema file into a GameConfigSchema.
+ /// This is a minimal YAML parser that handles the specific structure of our schema files.
+ /// It does NOT handle arbitrary YAML -- only the flat key-value and list structures used here.
+ ///
+ private GameConfigSchema ParseYaml(string[] lines)
+ {
+ var schema = new GameConfigSchema();
+ var context = new Stack();
+ GameConfigSection? currentSection = null;
+ GameConfigField? currentField = null;
+ GameConfigOption? currentOption = null;
+
+ for (int i = 0; i < lines.Length; i++)
+ {
+ var rawLine = lines[i].TrimEnd('\r');
+
+ // Skip empty lines and comments
+ if (string.IsNullOrWhiteSpace(rawLine) || rawLine.TrimStart().StartsWith('#'))
+ continue;
+
+ int indent = GetIndent(rawLine);
+ var trimmed = rawLine.Trim();
+
+ // List item indicator
+ bool isList = trimmed.StartsWith("- ");
+ if (isList)
+ trimmed = trimmed[2..].Trim();
+
+ // Parse key: value
+ var colonIndex = trimmed.IndexOf(':');
+ if (colonIndex < 0)
+ continue;
+
+ var key = trimmed[..colonIndex].Trim();
+ var value = trimmed[(colonIndex + 1)..].Trim();
+
+ // Remove surrounding quotes from value
+ value = UnquoteYaml(value);
+
+ // Determine context based on indentation level
+ if (indent == 0)
+ {
+ // Top-level: meta or sections
+ if (key == "meta")
+ {
+ context.Clear();
+ context.Push("meta");
+ }
+ else if (key == "sections")
+ {
+ context.Clear();
+ context.Push("sections");
+ }
+ }
+ else if (context.Count > 0 && context.Peek() == "meta" && indent == 2)
+ {
+ // meta fields
+ switch (key)
+ {
+ case "game_name": schema.Meta.GameName = value; break;
+ case "config_file": schema.Meta.ConfigFile = value; break;
+ case "parser": schema.Meta.Parser = value; break;
+ case "section": schema.Meta.Section = value; break;
+ }
+ }
+ else if (context.Count > 0 && context.Peek() == "sections" && isList && indent == 2)
+ {
+ // New section
+ if (key == "name")
+ {
+ currentSection = new GameConfigSection { Name = value };
+ schema.Sections.Add(currentSection);
+ currentField = null;
+ currentOption = null;
+ }
+ }
+ else if (currentSection != null && indent == 4 && key == "fields")
+ {
+ // fields: list start -- nothing to do, children will be parsed
+ }
+ else if (currentSection != null && isList && indent == 6)
+ {
+ // New field in the current section
+ if (key == "key")
+ {
+ currentField = new GameConfigField { Key = value };
+ currentSection.Fields.Add(currentField);
+ currentOption = null;
+ }
+ }
+ else if (currentField != null && indent == 8 && !isList)
+ {
+ // Field properties
+ switch (key)
+ {
+ case "label": currentField.Label = value; break;
+ case "type": currentField.Type = value; break;
+ case "default":
+ currentField.Default = value;
+ break;
+ case "min":
+ if (double.TryParse(value, System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var min))
+ currentField.Min = min;
+ break;
+ case "max":
+ if (double.TryParse(value, System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var max))
+ currentField.Max = max;
+ break;
+ case "step":
+ if (double.TryParse(value, System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var step))
+ currentField.Step = step;
+ break;
+ case "options":
+ // options: list start
+ break;
+ }
+ }
+ else if (currentField != null && isList && indent == 10)
+ {
+ // New option in the current field
+ if (key == "value")
+ {
+ currentOption = new GameConfigOption { Value = value };
+ currentField.Options.Add(currentOption);
+ }
+ }
+ else if (currentOption != null && indent == 12)
+ {
+ // Option property
+ if (key == "label")
+ {
+ currentOption.Label = value;
+ }
+ }
+ }
+
+ return schema;
+ }
+
+ private static int GetIndent(string line)
+ {
+ int count = 0;
+ foreach (char c in line)
+ {
+ if (c == ' ') count++;
+ else break;
+ }
+ return count;
+ }
+
+ private static string UnquoteYaml(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ return value;
+
+ if (value.Length >= 2 &&
+ ((value.StartsWith('"') && value.EndsWith('"')) ||
+ (value.StartsWith('\'') && value.EndsWith('\''))))
+ {
+ return value[1..^1];
+ }
+
+ return value;
+ }
+
+ #endregion
+}
diff --git a/desktop/GSM3/Services/InstanceManager.cs b/desktop/GSM3/Services/InstanceManager.cs
new file mode 100644
index 0000000..bcd838a
--- /dev/null
+++ b/desktop/GSM3/Services/InstanceManager.cs
@@ -0,0 +1,316 @@
+namespace GSM3.Services;
+
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Text.Json;
+using GSM3.Models;
+
+public class InstanceManager
+{
+ private static readonly string DataDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GSM3");
+ private static readonly string InstancesPath = Path.Combine(DataDir, "instances.json");
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ private readonly ConcurrentDictionary _instances = new();
+ private readonly ConcurrentDictionary _processes = new();
+ private readonly SemaphoreSlim _saveLock = new(1, 1);
+
+ public event EventHandler? OnInstanceStatusChanged;
+ public event EventHandler? OnInstanceOutput;
+
+ public async Task InitializeAsync()
+ {
+ Directory.CreateDirectory(DataDir);
+
+ if (File.Exists(InstancesPath))
+ {
+ try
+ {
+ var json = await File.ReadAllTextAsync(InstancesPath);
+ var instances = JsonSerializer.Deserialize>(json, JsonOptions);
+ if (instances != null)
+ {
+ foreach (var inst in instances)
+ {
+ // Reset runtime state on load
+ inst.Status = InstanceStatus.Stopped;
+ inst.TerminalSessionId = "";
+ _instances[inst.Id] = inst;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to load instances: {ex.Message}");
+ }
+ }
+ }
+
+ public async Task CreateInstanceAsync(Instance instance)
+ {
+ instance.Id = Guid.NewGuid().ToString();
+ instance.CreatedAt = DateTime.UtcNow;
+ instance.Status = InstanceStatus.Stopped;
+
+ _instances[instance.Id] = instance;
+ await SaveAsync();
+ return instance;
+ }
+
+ public async Task UpdateInstanceAsync(Instance updated)
+ {
+ if (!_instances.TryGetValue(updated.Id, out var existing))
+ return null;
+
+ // Preserve runtime state
+ updated.Status = existing.Status;
+ updated.TerminalSessionId = existing.TerminalSessionId;
+ _instances[updated.Id] = updated;
+ await SaveAsync();
+ return updated;
+ }
+
+ public async Task DeleteInstanceAsync(string instanceId)
+ {
+ if (!_instances.TryGetValue(instanceId, out var instance))
+ return false;
+
+ if (instance.Status == InstanceStatus.Running)
+ await StopInstanceAsync(instanceId);
+
+ _instances.TryRemove(instanceId, out _);
+ await SaveAsync();
+ return true;
+ }
+
+ public async Task StartInstanceAsync(string instanceId)
+ {
+ if (!_instances.TryGetValue(instanceId, out var instance))
+ return false;
+
+ if (instance.Status == InstanceStatus.Running)
+ return true;
+
+ try
+ {
+ SetStatus(instance, InstanceStatus.Starting);
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = instance.ProgramPath,
+ Arguments = instance.StartCommand,
+ WorkingDirectory = string.IsNullOrEmpty(instance.WorkingDirectory)
+ ? Path.GetDirectoryName(instance.ProgramPath) ?? ""
+ : instance.WorkingDirectory,
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ RedirectStandardInput = true,
+ CreateNoWindow = true
+ };
+
+ var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
+
+ process.OutputDataReceived += (_, e) =>
+ {
+ if (e.Data != null)
+ OnInstanceOutput?.Invoke(this, new InstanceOutputEventArgs(instanceId, e.Data, false));
+ };
+
+ process.ErrorDataReceived += (_, e) =>
+ {
+ if (e.Data != null)
+ OnInstanceOutput?.Invoke(this, new InstanceOutputEventArgs(instanceId, e.Data, true));
+ };
+
+ process.Exited += (_, _) =>
+ {
+ _processes.TryRemove(instanceId, out _);
+ instance.LastStopped = DateTime.UtcNow;
+ SetStatus(instance, process.ExitCode != 0 ? InstanceStatus.Crashed : InstanceStatus.Stopped);
+ _ = SaveAsync();
+ };
+
+ if (!process.Start())
+ {
+ SetStatus(instance, InstanceStatus.Crashed);
+ return false;
+ }
+
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+
+ _processes[instanceId] = process;
+ instance.LastStarted = DateTime.UtcNow;
+ SetStatus(instance, InstanceStatus.Running);
+ await SaveAsync();
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to start instance {instanceId}: {ex.Message}");
+ SetStatus(instance, InstanceStatus.Crashed);
+ return false;
+ }
+ }
+
+ public async Task StopInstanceAsync(string instanceId)
+ {
+ if (!_instances.TryGetValue(instanceId, out var instance))
+ return false;
+
+ if (instance.Status != InstanceStatus.Running)
+ return true;
+
+ SetStatus(instance, InstanceStatus.Stopping);
+
+ if (_processes.TryRemove(instanceId, out var process))
+ {
+ try
+ {
+ if (!process.HasExited)
+ {
+ // Try graceful shutdown based on StopCommandType
+ try
+ {
+ switch (instance.StopCommandType)
+ {
+ case StopCommand.Stop:
+ await process.StandardInput.WriteLineAsync("stop");
+ break;
+ case StopCommand.Exit:
+ await process.StandardInput.WriteLineAsync("exit");
+ break;
+ case StopCommand.Quit:
+ await process.StandardInput.WriteLineAsync("quit");
+ break;
+ case StopCommand.CtrlC:
+ default:
+ // Send Ctrl+C is not reliably possible via StandardInput,
+ // fall through to Kill
+ break;
+ }
+
+ // Wait briefly for graceful shutdown
+ if (!process.WaitForExit(5000))
+ {
+ process.Kill(entireProcessTree: true);
+ await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5));
+ }
+ }
+ catch
+ {
+ process.Kill(entireProcessTree: true);
+ }
+ }
+ process.Dispose();
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to stop instance {instanceId}: {ex.Message}");
+ }
+ }
+
+ instance.LastStopped = DateTime.UtcNow;
+ SetStatus(instance, InstanceStatus.Stopped);
+ await SaveAsync();
+ return true;
+ }
+
+ public async Task RestartInstanceAsync(string instanceId)
+ {
+ await StopInstanceAsync(instanceId);
+ await Task.Delay(1000); // brief pause between stop and start
+ return await StartInstanceAsync(instanceId);
+ }
+
+ public List GetInstances() => _instances.Values.ToList();
+
+ public Instance? GetInstance(string instanceId) =>
+ _instances.TryGetValue(instanceId, out var inst) ? inst : null;
+
+ ///
+ /// Send input to a running instance's stdin.
+ ///
+ public bool SendInput(string instanceId, string input)
+ {
+ if (!_processes.TryGetValue(instanceId, out var process))
+ return false;
+
+ try
+ {
+ if (process.HasExited) return false;
+ process.StandardInput.WriteLine(input);
+ process.StandardInput.Flush();
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ // ── Helpers ────────────────────────────────────────────────
+
+ private void SetStatus(Instance instance, InstanceStatus status)
+ {
+ var previous = instance.Status;
+ instance.Status = status;
+ OnInstanceStatusChanged?.Invoke(this,
+ new InstanceStatusEventArgs(instance.Id, previous, status));
+ }
+
+ private async Task SaveAsync()
+ {
+ await _saveLock.WaitAsync();
+ try
+ {
+ var json = JsonSerializer.Serialize(_instances.Values.ToList(), JsonOptions);
+ await File.WriteAllTextAsync(InstancesPath, json);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to save instances: {ex.Message}");
+ }
+ finally
+ {
+ _saveLock.Release();
+ }
+ }
+}
+
+// ── Event Args ─────────────────────────────────────────────────
+
+public class InstanceStatusEventArgs : EventArgs
+{
+ public string InstanceId { get; }
+ public InstanceStatus PreviousStatus { get; }
+ public InstanceStatus NewStatus { get; }
+
+ public InstanceStatusEventArgs(string instanceId, InstanceStatus previous, InstanceStatus next)
+ {
+ InstanceId = instanceId;
+ PreviousStatus = previous;
+ NewStatus = next;
+ }
+}
+
+public class InstanceOutputEventArgs : EventArgs
+{
+ public string InstanceId { get; }
+ public string Data { get; }
+ public bool IsError { get; }
+
+ public InstanceOutputEventArgs(string instanceId, string data, bool isError)
+ {
+ InstanceId = instanceId;
+ Data = data;
+ IsError = isError;
+ }
+}
diff --git a/desktop/GSM3/Services/ProcessRunner.cs b/desktop/GSM3/Services/ProcessRunner.cs
new file mode 100644
index 0000000..e328a77
--- /dev/null
+++ b/desktop/GSM3/Services/ProcessRunner.cs
@@ -0,0 +1,88 @@
+namespace GSM3.Services;
+
+using System.Diagnostics;
+using System.Text;
+
+///
+/// Utility for running external processes (e.g. SteamCMD, game servers) with
+/// async output capture and process lifecycle management.
+///
+public class ProcessRunner : IDisposable
+{
+ private Process? _process;
+
+ public event EventHandler? OutputReceived;
+ public event EventHandler? ErrorReceived;
+ public event EventHandler? Exited;
+
+ public int? ProcessId => _process?.Id;
+ public bool IsRunning => _process != null && !_process.HasExited;
+
+ public void Start(string fileName, string arguments = "", string workingDirectory = "",
+ Dictionary? envVars = null, bool redirectInput = true)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ WorkingDirectory = workingDirectory,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ RedirectStandardInput = redirectInput,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8,
+ };
+
+ if (envVars != null)
+ {
+ foreach (var (key, value) in envVars)
+ psi.Environment[key] = value;
+ }
+
+ _process = new Process { StartInfo = psi, EnableRaisingEvents = true };
+ _process.OutputDataReceived += (_, e) => { if (e.Data != null) OutputReceived?.Invoke(this, e.Data); };
+ _process.ErrorDataReceived += (_, e) => { if (e.Data != null) ErrorReceived?.Invoke(this, e.Data); };
+ _process.Exited += (_, _) => Exited?.Invoke(this, _process.ExitCode);
+
+ _process.Start();
+ _process.BeginOutputReadLine();
+ _process.BeginErrorReadLine();
+ }
+
+ public void SendInput(string input)
+ {
+ _process?.StandardInput.WriteLine(input);
+ }
+
+ public void SendCtrlC()
+ {
+ if (_process != null && !_process.HasExited)
+ {
+ // On Windows, use GenerateConsoleCtrlEvent via P/Invoke
+ // Fallback: kill the process
+ try { _process.Kill(entireProcessTree: true); }
+ catch { }
+ }
+ }
+
+ public void Kill()
+ {
+ try { _process?.Kill(entireProcessTree: true); }
+ catch { }
+ }
+
+ public async Task WaitForExitAsync(CancellationToken ct = default)
+ {
+ if (_process == null) return -1;
+ await _process.WaitForExitAsync(ct);
+ return _process.ExitCode;
+ }
+
+ public void Dispose()
+ {
+ _process?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/desktop/GSM3/Services/RconManager.cs b/desktop/GSM3/Services/RconManager.cs
new file mode 100644
index 0000000..867dea2
--- /dev/null
+++ b/desktop/GSM3/Services/RconManager.cs
@@ -0,0 +1,306 @@
+namespace GSM3.Services;
+
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using GSM3.Models;
+
+public class RconManager
+{
+ // Source RCON packet types
+ private const int SERVERDATA_AUTH = 3;
+ private const int SERVERDATA_AUTH_RESPONSE = 2;
+ private const int SERVERDATA_EXECCOMMAND = 2;
+ private const int SERVERDATA_RESPONSE_VALUE = 0;
+
+ private readonly ConcurrentDictionary _connections = new();
+
+ public event EventHandler? OnStatusChanged;
+ public event EventHandler? OnResponseReceived;
+
+ public async Task<(bool Success, string? Error)> ConnectAsync(
+ string connectionId, RconConfig config)
+ {
+ return await ConnectAsync(connectionId, config.Host, config.Port, config.Password);
+ }
+
+ public async Task<(bool Success, string? Error)> ConnectAsync(
+ string connectionId, string host, int port, string password)
+ {
+ // Close existing connection with this ID if any
+ await DisconnectAsync(connectionId);
+
+ try
+ {
+ var client = new TcpClient();
+ RaiseStatus(connectionId, RconStatus.Connecting);
+
+ await client.ConnectAsync(IPAddress.Parse(host), port);
+
+ var connection = new RconConnection
+ {
+ Id = connectionId,
+ Host = host,
+ Port = port,
+ Client = client,
+ Stream = client.GetStream()
+ };
+
+ _connections[connectionId] = connection;
+ RaiseStatus(connectionId, RconStatus.Connected);
+
+ // Authenticate
+ var authResult = await AuthenticateInternalAsync(connection, password);
+ if (!authResult.Success)
+ {
+ await DisconnectAsync(connectionId);
+ RaiseStatus(connectionId, RconStatus.Error);
+ return authResult;
+ }
+
+ connection.IsAuthenticated = true;
+ RaiseStatus(connectionId, RconStatus.Authenticated);
+ return (true, null);
+ }
+ catch (Exception ex)
+ {
+ RaiseStatus(connectionId, RconStatus.Error);
+ return (false, $"Connection failed: {ex.Message}");
+ }
+ }
+
+ public Task DisconnectAsync(string connectionId)
+ {
+ if (_connections.TryRemove(connectionId, out var conn))
+ {
+ try
+ {
+ conn.Stream?.Dispose();
+ conn.Client?.Dispose();
+ }
+ catch { /* ignore cleanup errors */ }
+
+ RaiseStatus(connectionId, RconStatus.Disconnected);
+ }
+ return Task.CompletedTask;
+ }
+
+ public async Task<(bool Success, string? Error)> AuthenticateAsync(string connectionId, string password)
+ {
+ if (!_connections.TryGetValue(connectionId, out var conn))
+ return (false, "Not connected.");
+
+ return await AuthenticateInternalAsync(conn, password);
+ }
+
+ public async Task<(bool Success, string? Response, string? Error)> ExecuteCommandAsync(
+ string connectionId, string command)
+ {
+ if (!_connections.TryGetValue(connectionId, out var conn))
+ return (false, null, "Not connected.");
+
+ if (!conn.IsAuthenticated)
+ return (false, null, "Not authenticated.");
+
+ try
+ {
+ var requestId = conn.NextId();
+ var packet = BuildPacket(requestId, SERVERDATA_EXECCOMMAND, command);
+ await conn.Stream!.WriteAsync(packet);
+
+ // Read response(s) - may come in multiple packets
+ var responseBody = new StringBuilder();
+ var response = await ReadPacketAsync(conn.Stream);
+ if (response == null)
+ return (false, null, "No response from server.");
+
+ responseBody.Append(response.Value.Body);
+
+ // Try reading additional packets with a short timeout (multi-packet responses)
+ conn.Client!.ReceiveTimeout = 200;
+ try
+ {
+ while (conn.Stream.DataAvailable)
+ {
+ var extra = await ReadPacketAsync(conn.Stream);
+ if (extra == null) break;
+ responseBody.Append(extra.Value.Body);
+ }
+ }
+ catch { /* timeout is expected */ }
+ finally
+ {
+ conn.Client.ReceiveTimeout = 0;
+ }
+
+ var result = responseBody.ToString();
+ OnResponseReceived?.Invoke(this,
+ new RconResponseEventArgs(connectionId, command, result));
+
+ return (true, result, null);
+ }
+ catch (Exception ex)
+ {
+ return (false, null, $"Command failed: {ex.Message}");
+ }
+ }
+
+ public bool IsConnected(string connectionId) =>
+ _connections.TryGetValue(connectionId, out var conn) &&
+ conn.Client?.Connected == true;
+
+ public void DisconnectAll()
+ {
+ foreach (var id in _connections.Keys.ToList())
+ {
+ _ = DisconnectAsync(id);
+ }
+ }
+
+ // ── Packet Format ──────────────────────────────────────────
+ // 4 bytes: packet size (int32 LE) = id(4) + type(4) + body + null(1) + null(1)
+ // 4 bytes: request id (int32 LE)
+ // 4 bytes: type (int32 LE)
+ // N bytes: body (null-terminated ASCII)
+ // 1 byte: empty string terminator (0x00)
+
+ private static byte[] BuildPacket(int id, int type, string body)
+ {
+ var bodyBytes = Encoding.ASCII.GetBytes(body);
+ var packetSize = 4 + 4 + bodyBytes.Length + 1 + 1; // id + type + body + null + null
+
+ var packet = new byte[4 + packetSize]; // size prefix + packet
+ var offset = 0;
+
+ // Size
+ BitConverter.GetBytes(packetSize).CopyTo(packet, offset); offset += 4;
+ // ID
+ BitConverter.GetBytes(id).CopyTo(packet, offset); offset += 4;
+ // Type
+ BitConverter.GetBytes(type).CopyTo(packet, offset); offset += 4;
+ // Body
+ bodyBytes.CopyTo(packet, offset); offset += bodyBytes.Length;
+ // Two null terminators
+ packet[offset] = 0; offset++;
+ packet[offset] = 0;
+
+ return packet;
+ }
+
+ private static async Task ReadPacketAsync(NetworkStream stream)
+ {
+ // Read size (4 bytes)
+ var sizeBuffer = new byte[4];
+ var bytesRead = await ReadExactAsync(stream, sizeBuffer, 4);
+ if (bytesRead < 4) return null;
+
+ var packetSize = BitConverter.ToInt32(sizeBuffer, 0);
+ if (packetSize < 10 || packetSize > 4096) return null;
+
+ // Read rest of packet
+ var body = new byte[packetSize];
+ bytesRead = await ReadExactAsync(stream, body, packetSize);
+ if (bytesRead < packetSize) return null;
+
+ return new RconPacket
+ {
+ Size = packetSize,
+ Id = BitConverter.ToInt32(body, 0),
+ Type = BitConverter.ToInt32(body, 4),
+ Body = Encoding.ASCII.GetString(body, 8, packetSize - 10) // minus id(4)+type(4)+null(1)+null(1)
+ };
+ }
+
+ private static async Task ReadExactAsync(NetworkStream stream, byte[] buffer, int count)
+ {
+ int totalRead = 0;
+ while (totalRead < count)
+ {
+ var read = await stream.ReadAsync(buffer.AsMemory(totalRead, count - totalRead));
+ if (read == 0) break;
+ totalRead += read;
+ }
+ return totalRead;
+ }
+
+ private async Task<(bool Success, string? Error)> AuthenticateInternalAsync(
+ RconConnection conn, string password)
+ {
+ try
+ {
+ var requestId = conn.NextId();
+ var packet = BuildPacket(requestId, SERVERDATA_AUTH, password);
+ await conn.Stream!.WriteAsync(packet);
+
+ var response = await ReadPacketAsync(conn.Stream);
+ if (response == null)
+ return (false, "No response from server.");
+
+ // Auth response: id matches = success, id == -1 = failure
+ if (response.Value.Id == -1)
+ return (false, "Authentication failed. Check password.");
+
+ return (true, null);
+ }
+ catch (Exception ex)
+ {
+ return (false, $"Authentication error: {ex.Message}");
+ }
+ }
+
+ private void RaiseStatus(string connectionId, RconStatus status)
+ {
+ OnStatusChanged?.Invoke(this, new RconStatusEventArgs(connectionId, status));
+ }
+
+ // ── Internal Types ─────────────────────────────────────────
+
+ private class RconConnection
+ {
+ public string Id { get; set; } = string.Empty;
+ public string Host { get; set; } = string.Empty;
+ public int Port { get; set; }
+ public TcpClient? Client { get; set; }
+ public NetworkStream? Stream { get; set; }
+ public bool IsAuthenticated { get; set; }
+ private int _requestId;
+
+ public int NextId() => Interlocked.Increment(ref _requestId);
+ }
+
+ private struct RconPacket
+ {
+ public int Size;
+ public int Id;
+ public int Type;
+ public string Body;
+ }
+}
+
+public class RconStatusEventArgs : EventArgs
+{
+ public string ConnectionId { get; }
+ public RconStatus Status { get; }
+
+ public RconStatusEventArgs(string connectionId, RconStatus status)
+ {
+ ConnectionId = connectionId;
+ Status = status;
+ }
+}
+
+public class RconResponseEventArgs : EventArgs
+{
+ public string ConnectionId { get; }
+ public string Command { get; }
+ public string Response { get; }
+
+ public RconResponseEventArgs(string connectionId, string command, string response)
+ {
+ ConnectionId = connectionId;
+ Command = command;
+ Response = response;
+ }
+}
diff --git a/desktop/GSM3/Services/RconProtocol.cs b/desktop/GSM3/Services/RconProtocol.cs
new file mode 100644
index 0000000..b637d9e
--- /dev/null
+++ b/desktop/GSM3/Services/RconProtocol.cs
@@ -0,0 +1,113 @@
+namespace GSM3.Services;
+
+using System.Net.Sockets;
+using System.Text;
+
+///
+/// Source RCON protocol implementation.
+/// Packet format: [4-byte size LE][4-byte id LE][4-byte type LE][body ASCII][null][null]
+/// Reference: https://developer.valvesoftware.com/wiki/Source_RCON_Protocol
+///
+public class RconProtocol : IDisposable
+{
+ private TcpClient? _client;
+ private NetworkStream? _stream;
+ private int _packetId;
+ private readonly object _lock = new();
+
+ public bool IsConnected => _client?.Connected ?? false;
+
+ public async Task ConnectAsync(string host, int port, int timeoutMs = 5000)
+ {
+ _client = new TcpClient();
+ using var cts = new CancellationTokenSource(timeoutMs);
+ await _client.ConnectAsync(host, port, cts.Token);
+ _stream = _client.GetStream();
+ }
+
+ public async Task AuthenticateAsync(string password)
+ {
+ var response = await SendPacketAsync(3, password); // SERVERDATA_AUTH = 3
+ return response.Type == 2; // SERVERDATA_AUTH_RESPONSE = 2
+ }
+
+ public async Task ExecuteCommandAsync(string command)
+ {
+ var response = await SendPacketAsync(2, command); // SERVERDATA_EXECCOMMAND = 2
+ return response.Body;
+ }
+
+ private async Task SendPacketAsync(int type, string body)
+ {
+ if (_stream == null) throw new InvalidOperationException("Not connected");
+
+ var id = Interlocked.Increment(ref _packetId);
+ var bodyBytes = Encoding.UTF8.GetBytes(body);
+ var size = 4 + 4 + bodyBytes.Length + 2; // id + type + body + 2 nulls
+
+ var packet = new byte[4 + size];
+ BitConverter.GetBytes(size).CopyTo(packet, 0);
+ BitConverter.GetBytes(id).CopyTo(packet, 4);
+ BitConverter.GetBytes(type).CopyTo(packet, 8);
+ bodyBytes.CopyTo(packet, 12);
+ // last 2 bytes are already 0 (null terminators)
+
+ lock (_lock)
+ {
+ _stream.Write(packet, 0, packet.Length);
+ }
+
+ return await ReadPacketAsync();
+ }
+
+ private async Task ReadPacketAsync()
+ {
+ if (_stream == null) throw new InvalidOperationException("Not connected");
+
+ var sizeBuffer = new byte[4];
+ await ReadExactAsync(_stream, sizeBuffer, 4);
+ var size = BitConverter.ToInt32(sizeBuffer, 0);
+
+ var payloadBuffer = new byte[size];
+ await ReadExactAsync(_stream, payloadBuffer, size);
+
+ return new RconPacket
+ {
+ Id = BitConverter.ToInt32(payloadBuffer, 0),
+ Type = BitConverter.ToInt32(payloadBuffer, 4),
+ Body = Encoding.UTF8.GetString(payloadBuffer, 8, size - 10) // minus id(4) + type(4) + 2 nulls
+ };
+ }
+
+ private static async Task ReadExactAsync(NetworkStream stream, byte[] buffer, int count)
+ {
+ int offset = 0;
+ while (offset < count)
+ {
+ int read = await stream.ReadAsync(buffer.AsMemory(offset, count - offset));
+ if (read == 0) throw new IOException("Connection closed");
+ offset += read;
+ }
+ }
+
+ public void Disconnect()
+ {
+ _stream?.Dispose();
+ _client?.Dispose();
+ _stream = null;
+ _client = null;
+ }
+
+ public void Dispose()
+ {
+ Disconnect();
+ GC.SuppressFinalize(this);
+ }
+}
+
+public struct RconPacket
+{
+ public int Id;
+ public int Type;
+ public string Body;
+}
diff --git a/desktop/GSM3/Services/SchedulerManager.cs b/desktop/GSM3/Services/SchedulerManager.cs
new file mode 100644
index 0000000..1e66f1f
--- /dev/null
+++ b/desktop/GSM3/Services/SchedulerManager.cs
@@ -0,0 +1,303 @@
+namespace GSM3.Services;
+
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Text.Json;
+using GSM3.Models;
+
+public class SchedulerManager : IDisposable
+{
+ private static readonly string DataDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GSM3");
+ private static readonly string TasksPath = Path.Combine(DataDir, "tasks.json");
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ private readonly ConcurrentDictionary _tasks = new();
+ private readonly InstanceManager _instanceManager;
+ private readonly BackupManager _backupManager;
+ private readonly SemaphoreSlim _saveLock = new(1, 1);
+ private Timer? _checkTimer;
+
+ public event EventHandler? OnTaskExecuted;
+
+ public SchedulerManager(InstanceManager instanceManager, BackupManager backupManager)
+ {
+ _instanceManager = instanceManager;
+ _backupManager = backupManager;
+ }
+
+ public async Task InitializeAsync()
+ {
+ Directory.CreateDirectory(DataDir);
+
+ if (File.Exists(TasksPath))
+ {
+ try
+ {
+ var json = await File.ReadAllTextAsync(TasksPath);
+ var tasks = JsonSerializer.Deserialize>(json, JsonOptions);
+ if (tasks != null)
+ {
+ foreach (var task in tasks)
+ {
+ task.NextRun = GetNextRun(task.CronExpression);
+ _tasks[task.Id] = task;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to load tasks: {ex.Message}");
+ }
+ }
+
+ // Check every 30 seconds for tasks due
+ _checkTimer = new Timer(_ => _ = CheckAndExecuteAsync(), null, TimeSpan.Zero, TimeSpan.FromSeconds(30));
+ }
+
+ public async Task CreateTaskAsync(ScheduledTask task)
+ {
+ task.Id = Guid.NewGuid().ToString();
+ task.CreatedAt = DateTime.UtcNow;
+ task.NextRun = GetNextRun(task.CronExpression);
+
+ _tasks[task.Id] = task;
+ await SaveAsync();
+ return task;
+ }
+
+ public async Task UpdateTaskAsync(ScheduledTask updated)
+ {
+ if (!_tasks.ContainsKey(updated.Id))
+ return null;
+
+ updated.NextRun = GetNextRun(updated.CronExpression);
+ _tasks[updated.Id] = updated;
+ await SaveAsync();
+ return updated;
+ }
+
+ public async Task DeleteTaskAsync(string taskId)
+ {
+ if (!_tasks.TryRemove(taskId, out _))
+ return false;
+
+ await SaveAsync();
+ return true;
+ }
+
+ public async Task ToggleTaskAsync(string taskId)
+ {
+ if (!_tasks.TryGetValue(taskId, out var task))
+ return false;
+
+ task.Enabled = !task.Enabled;
+ if (task.Enabled)
+ task.NextRun = GetNextRun(task.CronExpression);
+
+ await SaveAsync();
+ return true;
+ }
+
+ public List GetTasks() => _tasks.Values.ToList();
+
+ public async Task ExecuteNowAsync(string taskId)
+ {
+ if (!_tasks.TryGetValue(taskId, out var task))
+ return false;
+
+ await ExecuteTaskAsync(task);
+ return true;
+ }
+
+ // ── Execution ──────────────────────────────────────────────
+
+ private async Task CheckAndExecuteAsync()
+ {
+ var now = DateTime.UtcNow;
+ foreach (var task in _tasks.Values.Where(t => t.Enabled && t.NextRun.HasValue && t.NextRun <= now))
+ {
+ await ExecuteTaskAsync(task);
+ }
+ }
+
+ private async Task ExecuteTaskAsync(ScheduledTask task)
+ {
+ string? error = null;
+ try
+ {
+ switch (task.Type)
+ {
+ case TaskType.Power:
+ if (task.PowerActionType.HasValue)
+ {
+ switch (task.PowerActionType.Value)
+ {
+ case PowerAction.Start:
+ await _instanceManager.StartInstanceAsync(task.InstanceId);
+ break;
+ case PowerAction.Stop:
+ await _instanceManager.StopInstanceAsync(task.InstanceId);
+ break;
+ case PowerAction.Restart:
+ await _instanceManager.RestartInstanceAsync(task.InstanceId);
+ break;
+ }
+ }
+ break;
+
+ case TaskType.Command:
+ if (!string.IsNullOrEmpty(task.Command))
+ {
+ _instanceManager.SendInput(task.InstanceId, task.Command);
+ }
+ break;
+
+ case TaskType.Backup:
+ var result = await _backupManager.CreateBackupAsync(task.InstanceId, $"Scheduled: {task.Name}");
+ if (!result.Success)
+ error = result.Error;
+ break;
+
+ case TaskType.System:
+ // System actions (e.g., update) handled externally
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ error = ex.Message;
+ Debug.WriteLine($"Task {task.Id} execution failed: {ex.Message}");
+ }
+
+ task.LastRun = DateTime.UtcNow;
+ task.NextRun = GetNextRun(task.CronExpression);
+ await SaveAsync();
+
+ OnTaskExecuted?.Invoke(this, new ScheduledTaskEventArgs(task.Id, task.Name, task.Type, error));
+ }
+
+ // ── Cron Parsing ───────────────────────────────────────────
+ // Format: minute hour day month dayOfWeek
+ // Supports: *, specific values, ranges (1-5), steps (*/5), lists (1,3,5)
+
+ private static DateTime? GetNextRun(string cronExpression)
+ {
+ try
+ {
+ var parts = cronExpression.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 5) return null;
+
+ var now = DateTime.UtcNow;
+ var candidate = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc)
+ .AddMinutes(1);
+
+ // Search up to 1 year ahead
+ var limit = candidate.AddYears(1);
+ while (candidate < limit)
+ {
+ if (MatchesCron(candidate, parts))
+ return candidate;
+ candidate = candidate.AddMinutes(1);
+ }
+
+ return null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static bool MatchesCron(DateTime dt, string[] parts)
+ {
+ return MatchesField(parts[0], dt.Minute, 0, 59) &&
+ MatchesField(parts[1], dt.Hour, 0, 23) &&
+ MatchesField(parts[2], dt.Day, 1, 31) &&
+ MatchesField(parts[3], dt.Month, 1, 12) &&
+ MatchesField(parts[4], (int)dt.DayOfWeek, 0, 6);
+ }
+
+ private static bool MatchesField(string field, int value, int min, int max)
+ {
+ if (field == "*") return true;
+
+ foreach (var part in field.Split(','))
+ {
+ if (part.Contains('/'))
+ {
+ var stepParts = part.Split('/');
+ var baseRange = stepParts[0];
+ if (!int.TryParse(stepParts[1], out var step) || step <= 0) continue;
+
+ int start = baseRange == "*" ? min : int.Parse(baseRange);
+ for (var i = start; i <= max; i += step)
+ {
+ if (i == value) return true;
+ }
+ }
+ else if (part.Contains('-'))
+ {
+ var rangeParts = part.Split('-');
+ var low = int.Parse(rangeParts[0]);
+ var high = int.Parse(rangeParts[1]);
+ if (value >= low && value <= high) return true;
+ }
+ else
+ {
+ if (int.TryParse(part, out var exact) && exact == value)
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // ── Persistence ────────────────────────────────────────────
+
+ private async Task SaveAsync()
+ {
+ await _saveLock.WaitAsync();
+ try
+ {
+ var json = JsonSerializer.Serialize(_tasks.Values.ToList(), JsonOptions);
+ await File.WriteAllTextAsync(TasksPath, json);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to save tasks: {ex.Message}");
+ }
+ finally
+ {
+ _saveLock.Release();
+ }
+ }
+
+ public void Dispose()
+ {
+ _checkTimer?.Dispose();
+ _checkTimer = null;
+ GC.SuppressFinalize(this);
+ }
+}
+
+public class ScheduledTaskEventArgs : EventArgs
+{
+ public string TaskId { get; }
+ public string TaskName { get; }
+ public TaskType TaskType { get; }
+ public string? Error { get; }
+
+ public ScheduledTaskEventArgs(string taskId, string taskName, TaskType taskType, string? error)
+ {
+ TaskId = taskId;
+ TaskName = taskName;
+ TaskType = taskType;
+ Error = error;
+ }
+}
diff --git a/desktop/GSM3/Services/ServiceLocator.cs b/desktop/GSM3/Services/ServiceLocator.cs
new file mode 100644
index 0000000..3a8cff8
--- /dev/null
+++ b/desktop/GSM3/Services/ServiceLocator.cs
@@ -0,0 +1,15 @@
+namespace GSM3.Services;
+
+using Microsoft.Extensions.DependencyInjection;
+
+public static class ServiceLocator
+{
+ private static IServiceProvider? _provider;
+
+ public static void Initialize(IServiceProvider provider) => _provider = provider;
+
+ public static T GetService() where T : class =>
+ _provider?.GetRequiredService() ?? throw new InvalidOperationException("ServiceLocator not initialized");
+
+ public static T? TryGetService() where T : class => _provider?.GetService();
+}
diff --git a/desktop/GSM3/Services/SteamCMDManager.cs b/desktop/GSM3/Services/SteamCMDManager.cs
new file mode 100644
index 0000000..57c9e29
--- /dev/null
+++ b/desktop/GSM3/Services/SteamCMDManager.cs
@@ -0,0 +1,214 @@
+namespace GSM3.Services;
+
+using System.Diagnostics;
+using System.IO.Compression;
+using System.Net.Http;
+using GSM3.Models;
+
+public class SteamCMDManager
+{
+ private const string SteamCMDUrl = "https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip";
+
+ private static readonly string DataDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GSM3");
+ private static readonly string DefaultSteamCMDDir = Path.Combine(DataDir, "steamcmd");
+
+ private readonly ConfigManager _configManager;
+ private static readonly HttpClient HttpClient = new();
+
+ public event EventHandler? OnOutput;
+
+ public SteamCMDManager(ConfigManager configManager)
+ {
+ _configManager = configManager;
+ }
+
+ public string GetExecutablePath()
+ {
+ var config = _configManager.GetConfig();
+ var dir = string.IsNullOrEmpty(config.SteamCMD.InstallPath)
+ ? DefaultSteamCMDDir
+ : config.SteamCMD.InstallPath;
+ return Path.Combine(dir, "steamcmd.exe");
+ }
+
+ public bool CheckInstalled()
+ {
+ var exePath = GetExecutablePath();
+ var installed = File.Exists(exePath);
+
+ // Sync config state
+ var config = _configManager.GetConfig();
+ if (config.SteamCMD.IsInstalled != installed)
+ {
+ _ = _configManager.UpdateConfigAsync(c => c.SteamCMD.IsInstalled = installed);
+ }
+
+ return installed;
+ }
+
+ public async Task SetPathAsync(string path)
+ {
+ await _configManager.UpdateConfigAsync(c => c.SteamCMD.InstallPath = path);
+ }
+
+ public async Task<(bool Success, string? Error)> InstallAsync(IProgress? progress = null)
+ {
+ try
+ {
+ var installDir = Path.GetDirectoryName(GetExecutablePath()) ?? DefaultSteamCMDDir;
+ Directory.CreateDirectory(installDir);
+
+ var zipPath = Path.Combine(installDir, "steamcmd.zip");
+
+ Log("Downloading SteamCMD...");
+
+ // Download with progress
+ using (var response = await HttpClient.GetAsync(SteamCMDUrl, HttpCompletionOption.ResponseHeadersRead))
+ {
+ response.EnsureSuccessStatusCode();
+ var totalBytes = response.Content.Headers.ContentLength ?? -1;
+
+ using var contentStream = await response.Content.ReadAsStreamAsync();
+ using var fileStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write, FileShare.None);
+
+ var buffer = new byte[81920];
+ long totalRead = 0;
+ int bytesRead;
+
+ while ((bytesRead = await contentStream.ReadAsync(buffer)) > 0)
+ {
+ await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead));
+ totalRead += bytesRead;
+
+ if (totalBytes > 0)
+ progress?.Report((double)totalRead / totalBytes);
+ }
+ }
+
+ Log("Extracting SteamCMD...");
+ ZipFile.ExtractToDirectory(zipPath, installDir, overwriteFiles: true);
+
+ // Cleanup zip
+ if (File.Exists(zipPath))
+ File.Delete(zipPath);
+
+ // Run initial update (SteamCMD needs to update itself on first run)
+ Log("Running initial SteamCMD update...");
+ var updateResult = await RunSteamCMDAsync("+quit");
+ if (!updateResult.Success)
+ return (false, $"SteamCMD initial update failed: {updateResult.Error}");
+
+ // Update config
+ await _configManager.UpdateConfigAsync(c =>
+ {
+ c.SteamCMD.IsInstalled = true;
+ c.SteamCMD.InstallPath = installDir;
+ });
+
+ Log("SteamCMD installed successfully.");
+ return (true, null);
+ }
+ catch (Exception ex)
+ {
+ return (false, $"Installation failed: {ex.Message}");
+ }
+ }
+
+ public async Task<(bool Success, string? Error)> UpdateGameAsync(
+ int appId,
+ string installDir,
+ string? username = null,
+ string? password = null,
+ string? betaBranch = null,
+ bool validate = true)
+ {
+ if (!CheckInstalled())
+ return (false, "SteamCMD is not installed.");
+
+ Directory.CreateDirectory(installDir);
+
+ // Build command arguments
+ var args = new List();
+
+ if (!string.IsNullOrEmpty(username))
+ {
+ args.Add($"+login {username} {password ?? ""}");
+ }
+ else
+ {
+ args.Add("+login anonymous");
+ }
+
+ args.Add($"+force_install_dir \"{installDir}\"");
+
+ var appUpdateCmd = $"+app_update {appId}";
+ if (!string.IsNullOrEmpty(betaBranch))
+ appUpdateCmd += $" -beta {betaBranch}";
+ if (validate)
+ appUpdateCmd += " validate";
+ args.Add(appUpdateCmd);
+
+ args.Add("+quit");
+
+ var commandLine = string.Join(" ", args);
+ Log($"Updating app {appId}...");
+
+ return await RunSteamCMDAsync(commandLine);
+ }
+
+ // ── Internal ───────────────────────────────────────────────
+
+ private async Task<(bool Success, string? Error)> RunSteamCMDAsync(string arguments)
+ {
+ try
+ {
+ var exePath = GetExecutablePath();
+ var psi = new ProcessStartInfo
+ {
+ FileName = exePath,
+ Arguments = arguments,
+ WorkingDirectory = Path.GetDirectoryName(exePath) ?? "",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
+
+ using var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
+
+ process.OutputDataReceived += (_, e) =>
+ {
+ if (!string.IsNullOrEmpty(e.Data))
+ Log(e.Data);
+ };
+
+ process.ErrorDataReceived += (_, e) =>
+ {
+ if (!string.IsNullOrEmpty(e.Data))
+ Log($"[ERR] {e.Data}");
+ };
+
+ process.Start();
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+
+ await process.WaitForExitAsync();
+
+ if (process.ExitCode != 0)
+ return (false, $"SteamCMD exited with code {process.ExitCode}");
+
+ return (true, null);
+ }
+ catch (Exception ex)
+ {
+ return (false, ex.Message);
+ }
+ }
+
+ private void Log(string message)
+ {
+ Debug.WriteLine($"[SteamCMD] {message}");
+ OnOutput?.Invoke(this, message);
+ }
+}
diff --git a/desktop/GSM3/Services/SystemMonitor.cs b/desktop/GSM3/Services/SystemMonitor.cs
new file mode 100644
index 0000000..a8ce238
--- /dev/null
+++ b/desktop/GSM3/Services/SystemMonitor.cs
@@ -0,0 +1,361 @@
+namespace GSM3.Services;
+
+using System.Diagnostics;
+using System.Management;
+using System.Net.NetworkInformation;
+using GSM3.Models;
+
+public class SystemMonitor : IDisposable
+{
+ private Timer? _timer;
+ private readonly object _lock = new();
+ private SystemStats _lastStats = new();
+ private long _prevBytesSent;
+ private long _prevBytesRecv;
+ private DateTime _prevNetTime = DateTime.UtcNow;
+
+ public event EventHandler? OnStatsUpdated;
+
+ public int IntervalMs { get; set; } = 5000;
+
+ public void Start(int? intervalMs = null)
+ {
+ if (intervalMs.HasValue)
+ IntervalMs = intervalMs.Value;
+
+ _timer?.Dispose();
+ _timer = new Timer(_ => CollectStats(), null, 0, IntervalMs);
+ }
+
+ public void Stop()
+ {
+ _timer?.Dispose();
+ _timer = null;
+ }
+
+ public SystemStats GetLastStats()
+ {
+ lock (_lock) { return _lastStats; }
+ }
+
+ // ── CPU ────────────────────────────────────────────────────
+
+ public async Task GetCpuUsageAsync()
+ {
+ var cpuStats = new CpuStats
+ {
+ CoreCount = Environment.ProcessorCount
+ };
+
+ try
+ {
+ // Get CPU model name via WMI
+ using var searcher = new ManagementObjectSearcher("SELECT Name FROM Win32_Processor");
+ foreach (ManagementObject obj in searcher.Get())
+ {
+ cpuStats.ModelName = obj["Name"]?.ToString() ?? "Unknown";
+ break;
+ }
+ }
+ catch { /* WMI may not be available */ }
+
+ try
+ {
+ // Two-sample CPU measurement
+ var startTime = DateTime.UtcNow;
+ var startCpuUsage = Process.GetProcesses().Sum(p =>
+ {
+ try { return p.TotalProcessorTime.TotalMilliseconds; }
+ catch { return 0; }
+ });
+
+ await Task.Delay(500);
+
+ var endTime = DateTime.UtcNow;
+ var endCpuUsage = Process.GetProcesses().Sum(p =>
+ {
+ try { return p.TotalProcessorTime.TotalMilliseconds; }
+ catch { return 0; }
+ });
+
+ var cpuUsedMs = endCpuUsage - startCpuUsage;
+ var totalMs = (endTime - startTime).TotalMilliseconds * Environment.ProcessorCount;
+ cpuStats.UsagePercent = Math.Round(Math.Min(cpuUsedMs / totalMs * 100, 100), 1);
+ }
+ catch
+ {
+ cpuStats.UsagePercent = 0;
+ }
+
+ return cpuStats;
+ }
+
+ // ── Memory ─────────────────────────────────────────────────
+
+ public MemoryStats GetMemoryInfo()
+ {
+ try
+ {
+ using var searcher = new ManagementObjectSearcher(
+ "SELECT TotalVisibleMemorySize, FreePhysicalMemory FROM Win32_OperatingSystem");
+ foreach (ManagementObject obj in searcher.Get())
+ {
+ var totalKb = Convert.ToInt64(obj["TotalVisibleMemorySize"]);
+ var freeKb = Convert.ToInt64(obj["FreePhysicalMemory"]);
+ var totalBytes = totalKb * 1024;
+ var freeBytes = freeKb * 1024;
+ var usedBytes = totalBytes - freeBytes;
+
+ return new MemoryStats
+ {
+ TotalBytes = totalBytes,
+ UsedBytes = usedBytes,
+ AvailableBytes = freeBytes,
+ UsagePercent = Math.Round((double)usedBytes / totalBytes * 100, 1)
+ };
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to get memory info: {ex.Message}");
+ }
+
+ return new MemoryStats();
+ }
+
+ // ── Disk ───────────────────────────────────────────────────
+
+ public DiskStats GetDiskInfo()
+ {
+ try
+ {
+ // Return info for the system drive
+ var systemDrive = Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\";
+ var drive = new DriveInfo(systemDrive);
+ if (drive.IsReady)
+ {
+ return new DiskStats
+ {
+ DriveName = drive.Name,
+ TotalBytes = drive.TotalSize,
+ FreeBytes = drive.AvailableFreeSpace,
+ UsedBytes = drive.TotalSize - drive.AvailableFreeSpace,
+ UsagePercent = Math.Round(
+ (double)(drive.TotalSize - drive.AvailableFreeSpace) / drive.TotalSize * 100, 1)
+ };
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to get disk info: {ex.Message}");
+ }
+ return new DiskStats();
+ }
+
+ // ── Network ────────────────────────────────────────────────
+
+ public NetworkStats GetNetworkInfo()
+ {
+ try
+ {
+ long totalSent = 0, totalRecv = 0;
+ string interfaceName = "";
+ foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
+ {
+ if (nic.OperationalStatus != OperationalStatus.Up) continue;
+ var stats = nic.GetIPStatistics();
+ totalSent += stats.BytesSent;
+ totalRecv += stats.BytesReceived;
+ if (string.IsNullOrEmpty(interfaceName))
+ interfaceName = nic.Name;
+ }
+
+ var now = DateTime.UtcNow;
+ var elapsed = (now - _prevNetTime).TotalSeconds;
+ var sentPerSec = elapsed > 0 ? (long)((totalSent - _prevBytesSent) / elapsed) : 0;
+ var recvPerSec = elapsed > 0 ? (long)((totalRecv - _prevBytesRecv) / elapsed) : 0;
+
+ _prevBytesSent = totalSent;
+ _prevBytesRecv = totalRecv;
+ _prevNetTime = now;
+
+ return new NetworkStats
+ {
+ InterfaceName = interfaceName,
+ BytesSent = totalSent,
+ BytesReceived = totalRecv,
+ SendSpeed = Math.Max(0, sentPerSec),
+ ReceiveSpeed = Math.Max(0, recvPerSec)
+ };
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to get network info: {ex.Message}");
+ return new NetworkStats();
+ }
+ }
+
+ // ── Processes ──────────────────────────────────────────────
+
+ public List GetProcessList()
+ {
+ var result = new List();
+ try
+ {
+ foreach (var p in Process.GetProcesses())
+ {
+ try
+ {
+ result.Add(new ProcessInfo
+ {
+ Pid = p.Id,
+ Name = p.ProcessName,
+ MemoryBytes = p.WorkingSet64,
+ StartTime = p.StartTime.ToUniversalTime()
+ });
+ }
+ catch { /* access denied for some system processes */ }
+ finally { p.Dispose(); }
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to get process list: {ex.Message}");
+ }
+ return result;
+ }
+
+ // ── Ports ──────────────────────────────────────────────────
+
+ public List GetActivePorts()
+ {
+ var ports = new List();
+ try
+ {
+ var properties = IPGlobalProperties.GetIPGlobalProperties();
+
+ foreach (var tcp in properties.GetActiveTcpListeners())
+ {
+ ports.Add(new ActivePort
+ {
+ Protocol = "TCP",
+ LocalAddress = tcp.Address.ToString(),
+ LocalPort = tcp.Port,
+ State = "LISTENING"
+ });
+ }
+
+ foreach (var conn in properties.GetActiveTcpConnections())
+ {
+ ports.Add(new ActivePort
+ {
+ Protocol = "TCP",
+ LocalAddress = conn.LocalEndPoint.Address.ToString(),
+ LocalPort = conn.LocalEndPoint.Port,
+ State = conn.State.ToString()
+ });
+ }
+
+ foreach (var udp in properties.GetActiveUdpListeners())
+ {
+ ports.Add(new ActivePort
+ {
+ Protocol = "UDP",
+ LocalAddress = udp.Address.ToString(),
+ LocalPort = udp.Port
+ });
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to get active ports: {ex.Message}");
+ }
+ return ports;
+ }
+
+ // ── Kill Process ───────────────────────────────────────────
+
+ public (bool Success, string? Error) KillProcess(int pid)
+ {
+ try
+ {
+ var process = Process.GetProcessById(pid);
+ process.Kill(entireProcessTree: true);
+ process.Dispose();
+ return (true, null);
+ }
+ catch (ArgumentException)
+ {
+ return (false, $"Process {pid} not found.");
+ }
+ catch (Exception ex)
+ {
+ return (false, ex.Message);
+ }
+ }
+
+ // ── System Info ────────────────────────────────────────────
+
+ public Dictionary GetSystemInfo()
+ {
+ var info = new Dictionary
+ {
+ ["MachineName"] = Environment.MachineName,
+ ["OSVersion"] = Environment.OSVersion.ToString(),
+ ["ProcessorCount"] = Environment.ProcessorCount.ToString(),
+ ["Is64BitOS"] = Environment.Is64BitOperatingSystem.ToString(),
+ ["UserName"] = Environment.UserName,
+ ["SystemDirectory"] = Environment.SystemDirectory,
+ ["CLRVersion"] = Environment.Version.ToString()
+ };
+
+ try
+ {
+ using var searcher = new ManagementObjectSearcher("SELECT Name FROM Win32_Processor");
+ foreach (ManagementObject obj in searcher.Get())
+ {
+ info["Processor"] = obj["Name"]?.ToString() ?? "Unknown";
+ break;
+ }
+ }
+ catch { /* WMI may not be available */ }
+
+ return info;
+ }
+
+ // ── Periodic Collection ────────────────────────────────────
+
+ private async void CollectStats()
+ {
+ try
+ {
+ var cpu = await GetCpuUsageAsync();
+ var memory = GetMemoryInfo();
+ var disk = GetDiskInfo();
+ var network = GetNetworkInfo();
+
+ var stats = new SystemStats
+ {
+ Cpu = cpu,
+ Memory = memory,
+ Disk = disk,
+ Network = network,
+ Timestamp = DateTime.UtcNow
+ };
+
+ lock (_lock) { _lastStats = stats; }
+ OnStatsUpdated?.Invoke(this, stats);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to collect stats: {ex.Message}");
+ }
+ }
+
+ public void Dispose()
+ {
+ _timer?.Dispose();
+ _timer = null;
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/desktop/GSM3/Services/TerminalManager.cs b/desktop/GSM3/Services/TerminalManager.cs
new file mode 100644
index 0000000..6ad4339
--- /dev/null
+++ b/desktop/GSM3/Services/TerminalManager.cs
@@ -0,0 +1,185 @@
+namespace GSM3.Services;
+
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Text;
+using GSM3.Models;
+
+public class TerminalManager
+{
+ private readonly ConcurrentDictionary _sessions = new();
+
+ public event EventHandler? OnOutput;
+ public event EventHandler? OnSessionClosed;
+
+ public TerminalSession CreateSession(string? name = null, string? workingDirectory = null)
+ {
+ var session = new TerminalSession
+ {
+ Name = name ?? $"Terminal {_sessions.Count + 1}",
+ WorkingDirectory = workingDirectory ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ IsActive = true
+ };
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = "powershell.exe",
+ Arguments = "-NoLogo -NoProfile",
+ WorkingDirectory = session.WorkingDirectory,
+ UseShellExecute = false,
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8
+ };
+
+ var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
+
+ process.OutputDataReceived += (_, e) =>
+ {
+ if (e.Data != null)
+ OnOutput?.Invoke(this, new TerminalOutputEventArgs(session.Id, e.Data, false));
+ };
+
+ process.ErrorDataReceived += (_, e) =>
+ {
+ if (e.Data != null)
+ OnOutput?.Invoke(this, new TerminalOutputEventArgs(session.Id, e.Data, true));
+ };
+
+ process.Exited += (_, _) =>
+ {
+ if (_sessions.TryRemove(session.Id, out var state))
+ {
+ state.Session.IsActive = false;
+ state.Session.ProcessId = null;
+ state.Process.Dispose();
+ OnSessionClosed?.Invoke(this, session.Id);
+ }
+ };
+
+ process.Start();
+ session.ProcessId = process.Id;
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+
+ var sessionState = new TerminalSessionState(session, process);
+ _sessions[session.Id] = sessionState;
+
+ return session;
+ }
+
+ public bool SendInput(string sessionId, string input)
+ {
+ if (!_sessions.TryGetValue(sessionId, out var state))
+ return false;
+
+ if (state.Process.HasExited)
+ return false;
+
+ try
+ {
+ state.Process.StandardInput.WriteLine(input);
+ state.Process.StandardInput.Flush();
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to send input to session {sessionId}: {ex.Message}");
+ return false;
+ }
+ }
+
+ public void CloseSession(string sessionId)
+ {
+ if (!_sessions.TryRemove(sessionId, out var state))
+ return;
+
+ state.Session.IsActive = false;
+ state.Session.ProcessId = null;
+
+ try
+ {
+ if (!state.Process.HasExited)
+ {
+ state.Process.StandardInput.WriteLine("exit");
+ if (!state.Process.WaitForExit(3000))
+ {
+ state.Process.Kill(entireProcessTree: true);
+ }
+ }
+ state.Process.Dispose();
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to close session {sessionId}: {ex.Message}");
+ }
+
+ OnSessionClosed?.Invoke(this, sessionId);
+ }
+
+ public bool ResizeTerminal(string sessionId, int columns, int rows)
+ {
+ if (!_sessions.TryGetValue(sessionId, out var state))
+ return false;
+
+ // PowerShell resize via command
+ try
+ {
+ if (!state.Process.HasExited)
+ {
+ state.Process.StandardInput.WriteLine(
+ $"[Console]::WindowWidth = {columns}; [Console]::WindowHeight = {rows}");
+ state.Process.StandardInput.Flush();
+ }
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public List GetSessions()
+ {
+ return _sessions.Values.Select(s => s.Session).ToList();
+ }
+
+ public void CloseAll()
+ {
+ foreach (var id in _sessions.Keys.ToList())
+ {
+ CloseSession(id);
+ }
+ }
+
+ // ── Internal State ─────────────────────────────────────────
+
+ private sealed class TerminalSessionState
+ {
+ public TerminalSession Session { get; }
+ public Process Process { get; }
+
+ public TerminalSessionState(TerminalSession session, Process process)
+ {
+ Session = session;
+ Process = process;
+ }
+ }
+}
+
+public class TerminalOutputEventArgs : EventArgs
+{
+ public string SessionId { get; }
+ public string Data { get; }
+ public bool IsError { get; }
+
+ public TerminalOutputEventArgs(string sessionId, string data, bool isError)
+ {
+ SessionId = sessionId;
+ Data = data;
+ IsError = isError;
+ }
+}
diff --git a/desktop/GSM3/Services/UserManager.cs b/desktop/GSM3/Services/UserManager.cs
new file mode 100644
index 0000000..baa3d7d
--- /dev/null
+++ b/desktop/GSM3/Services/UserManager.cs
@@ -0,0 +1,213 @@
+namespace GSM3.Services;
+
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using GSM3.Models;
+
+public class UserManager
+{
+ private static readonly string DataDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GSM3");
+ private static readonly string UsersPath = Path.Combine(DataDir, "users.json");
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ private readonly ConcurrentDictionary _users = new();
+ private readonly ConfigManager _configManager;
+ private readonly SemaphoreSlim _saveLock = new(1, 1);
+
+ public UserManager(ConfigManager configManager)
+ {
+ _configManager = configManager;
+ }
+
+ public async Task InitializeAsync()
+ {
+ Directory.CreateDirectory(DataDir);
+
+ if (File.Exists(UsersPath))
+ {
+ try
+ {
+ var json = await File.ReadAllTextAsync(UsersPath);
+ var users = JsonSerializer.Deserialize>(json, JsonOptions);
+ if (users != null)
+ {
+ foreach (var user in users)
+ {
+ _users[user.Id] = user;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Failed to load users: {ex.Message}");
+ }
+ }
+ }
+
+ public bool HasUsers() => !_users.IsEmpty;
+
+ public async Task<(bool Success, string? Error, User? User)> LoginAsync(string username, string password)
+ {
+ var user = _users.Values.FirstOrDefault(u =>
+ u.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
+
+ if (user == null)
+ return (false, "Invalid username or password.", null);
+
+ // Check lockout
+ if (user.LockedUntil.HasValue && user.LockedUntil.Value > DateTime.UtcNow)
+ {
+ var remaining = (user.LockedUntil.Value - DateTime.UtcNow).Minutes + 1;
+ return (false, $"Account locked. Try again in {remaining} minute(s).", null);
+ }
+
+ // Reset lockout if expired
+ if (user.LockedUntil.HasValue && user.LockedUntil.Value <= DateTime.UtcNow)
+ {
+ user.LoginAttempts = 0;
+ user.LockedUntil = null;
+ }
+
+ var hash = HashPassword(password, user.Salt);
+ if (hash != user.PasswordHash)
+ {
+ user.LoginAttempts++;
+ var config = _configManager.GetConfig();
+
+ if (user.LoginAttempts >= config.Auth.MaxLoginAttempts)
+ {
+ user.LockedUntil = DateTime.UtcNow.AddMinutes(config.Auth.LockoutDurationMinutes);
+ await SaveAsync();
+ return (false, $"Account locked after {config.Auth.MaxLoginAttempts} failed attempts.", null);
+ }
+
+ await SaveAsync();
+ return (false, "Invalid username or password.", null);
+ }
+
+ // Successful login
+ user.LoginAttempts = 0;
+ user.LockedUntil = null;
+ user.LastLogin = DateTime.UtcNow;
+ await SaveAsync();
+
+ return (true, null, user);
+ }
+
+ public async Task<(bool Success, string? Error, User? User)> RegisterAsync(
+ string username, string password, UserRole role = UserRole.User)
+ {
+ if (string.IsNullOrWhiteSpace(username) || username.Length < 3)
+ return (false, "Username must be at least 3 characters.", null);
+
+ if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
+ return (false, "Password must be at least 6 characters.", null);
+
+ if (_users.Values.Any(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase)))
+ return (false, "Username already exists.", null);
+
+ var salt = GenerateSalt();
+ var hash = HashPassword(password, salt);
+
+ var user = new User
+ {
+ Username = username,
+ PasswordHash = hash,
+ Salt = salt,
+ Role = role,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ if (!_users.TryAdd(user.Id, user))
+ return (false, "Failed to create user.", null);
+
+ await SaveAsync();
+ return (true, null, user);
+ }
+
+ public async Task<(bool Success, string? Error)> ChangePasswordAsync(
+ string userId, string currentPassword, string newPassword)
+ {
+ if (!_users.TryGetValue(userId, out var user))
+ return (false, "User not found.");
+
+ var hash = HashPassword(currentPassword, user.Salt);
+ if (hash != user.PasswordHash)
+ return (false, "Current password is incorrect.");
+
+ if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 6)
+ return (false, "New password must be at least 6 characters.");
+
+ user.Salt = GenerateSalt();
+ user.PasswordHash = HashPassword(newPassword, user.Salt);
+ await SaveAsync();
+
+ return (true, null);
+ }
+
+ public List GetUsers()
+ {
+ return _users.Values.Select(u => new User
+ {
+ Id = u.Id,
+ Username = u.Username,
+ Role = u.Role,
+ CreatedAt = u.CreatedAt,
+ LastLogin = u.LastLogin,
+ LoginAttempts = u.LoginAttempts,
+ LockedUntil = u.LockedUntil
+ // PasswordHash and Salt intentionally omitted
+ }).ToList();
+ }
+
+ public async Task<(bool Success, string? Error)> DeleteUserAsync(string userId)
+ {
+ if (!_users.TryRemove(userId, out _))
+ return (false, "User not found.");
+
+ await SaveAsync();
+ return (true, null);
+ }
+
+ // ── Helpers ────────────────────────────────────────────────
+
+ private static string GenerateSalt()
+ {
+ var bytes = RandomNumberGenerator.GetBytes(32);
+ return Convert.ToBase64String(bytes);
+ }
+
+ private static string HashPassword(string password, string salt)
+ {
+ var combined = Encoding.UTF8.GetBytes(password + salt);
+ var hash = SHA256.HashData(combined);
+ return Convert.ToBase64String(hash);
+ }
+
+ private async Task SaveAsync()
+ {
+ await _saveLock.WaitAsync();
+ try
+ {
+ Directory.CreateDirectory(DataDir);
+ var json = JsonSerializer.Serialize(_users.Values.ToList(), JsonOptions);
+ await File.WriteAllTextAsync(UsersPath, json);
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Failed to save users: {ex.Message}");
+ }
+ finally
+ {
+ _saveLock.Release();
+ }
+ }
+}
diff --git a/desktop/GSM3/ViewModels/BackupViewModel.cs b/desktop/GSM3/ViewModels/BackupViewModel.cs
new file mode 100644
index 0000000..be38391
--- /dev/null
+++ b/desktop/GSM3/ViewModels/BackupViewModel.cs
@@ -0,0 +1,206 @@
+namespace GSM3.ViewModels;
+
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GSM3.Models;
+using GSM3.Services;
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+
+public partial class BackupViewModel : ObservableObject
+{
+ private readonly BackupManager _backupManager;
+ private readonly InstanceManager _instanceManager;
+
+ public ObservableCollection Backups { get; } = new();
+ public ObservableCollection Instances { get; } = new();
+
+ [ObservableProperty] private Instance? selectedInstance;
+ [ObservableProperty] private BackupInfo? selectedBackup;
+ [ObservableProperty] private string statusMessage = "";
+ [ObservableProperty] private bool isLoading;
+ [ObservableProperty] private string backupNotes = "";
+
+ public BackupViewModel()
+ {
+ _backupManager = ServiceLocator.GetService();
+ _instanceManager = ServiceLocator.GetService();
+ }
+
+ public async Task LoadAsync()
+ {
+ IsLoading = true;
+ try
+ {
+ await _backupManager.InitializeAsync();
+ await _instanceManager.InitializeAsync();
+
+ // Load instance list for filtering
+ var instances = _instanceManager.GetInstances();
+ Instances.Clear();
+ foreach (var inst in instances)
+ Instances.Add(inst);
+
+ await RefreshAsync();
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ partial void OnSelectedInstanceChanged(Instance? value)
+ {
+ // Re-filter backups when instance selection changes
+ _ = RefreshAsync();
+ }
+
+ [RelayCommand]
+ private async Task RefreshAsync()
+ {
+ IsLoading = true;
+ try
+ {
+ var backups = _backupManager.ListBackups(SelectedInstance?.Id);
+ Backups.Clear();
+ foreach (var backup in backups)
+ Backups.Add(backup);
+
+ StatusMessage = $"{Backups.Count} backup(s) found.";
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Failed to load backups: {ex.Message}";
+ Debug.WriteLine($"BackupRefresh error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task CreateBackupAsync()
+ {
+ if (SelectedInstance == null)
+ {
+ StatusMessage = "Please select an instance to back up.";
+ return;
+ }
+
+ IsLoading = true;
+ StatusMessage = $"Creating backup for '{SelectedInstance.Name}'...";
+ try
+ {
+ var notes = string.IsNullOrWhiteSpace(BackupNotes) ? null : BackupNotes.Trim();
+ var result = await _backupManager.CreateBackupAsync(SelectedInstance.Id, notes);
+
+ if (result.Success && result.Backup != null)
+ {
+ BackupNotes = "";
+ await RefreshAsync();
+ StatusMessage = $"Backup created: {result.Backup.FileName}";
+ }
+ else
+ {
+ StatusMessage = $"Backup failed: {result.Error}";
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Backup failed: {ex.Message}";
+ Debug.WriteLine($"CreateBackup error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task RestoreBackupAsync()
+ {
+ if (SelectedBackup == null)
+ {
+ StatusMessage = "No backup selected.";
+ return;
+ }
+
+ IsLoading = true;
+ StatusMessage = $"Restoring backup '{SelectedBackup.FileName}'...";
+ try
+ {
+ var result = await _backupManager.RestoreBackupAsync(SelectedBackup.Id);
+
+ if (result.Success)
+ {
+ StatusMessage = $"Backup '{SelectedBackup.FileName}' restored successfully.";
+ }
+ else
+ {
+ StatusMessage = $"Restore failed: {result.Error}";
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Restore failed: {ex.Message}";
+ Debug.WriteLine($"RestoreBackup error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task DeleteBackupAsync()
+ {
+ if (SelectedBackup == null)
+ {
+ StatusMessage = "No backup selected.";
+ return;
+ }
+
+ IsLoading = true;
+ try
+ {
+ var fileName = SelectedBackup.FileName;
+ var result = await _backupManager.DeleteBackupAsync(SelectedBackup.Id);
+
+ if (result.Success)
+ {
+ SelectedBackup = null;
+ await RefreshAsync();
+ StatusMessage = $"Backup '{fileName}' deleted.";
+ }
+ else
+ {
+ StatusMessage = $"Delete failed: {result.Error}";
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Delete failed: {ex.Message}";
+ Debug.WriteLine($"DeleteBackup error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private void ClearFilter()
+ {
+ SelectedInstance = null;
+ // OnSelectedInstanceChanged will trigger refresh
+ }
+
+ public string FormatFileSize(long bytes)
+ {
+ if (bytes < 1024) return $"{bytes} B";
+ if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB";
+ if (bytes < 1024 * 1024 * 1024) return $"{bytes / (1024.0 * 1024):F1} MB";
+ return $"{bytes / (1024.0 * 1024 * 1024):F2} GB";
+ }
+}
diff --git a/desktop/GSM3/ViewModels/DashboardViewModel.cs b/desktop/GSM3/ViewModels/DashboardViewModel.cs
new file mode 100644
index 0000000..a206d80
--- /dev/null
+++ b/desktop/GSM3/ViewModels/DashboardViewModel.cs
@@ -0,0 +1,84 @@
+namespace GSM3.ViewModels;
+
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GSM3.Models;
+using GSM3.Services;
+using System.Collections.ObjectModel;
+
+public partial class DashboardViewModel : ObservableObject
+{
+ private readonly InstanceManager _instanceManager;
+ private readonly SystemMonitor _systemMonitor;
+
+ [ObservableProperty] private int totalInstances;
+ [ObservableProperty] private int runningInstances;
+ [ObservableProperty] private int stoppedInstances;
+ [ObservableProperty] private double cpuUsage;
+ [ObservableProperty] private double memoryUsage;
+ [ObservableProperty] private double diskUsage;
+ [ObservableProperty] private string cpuModel = "";
+ [ObservableProperty] private long totalMemory;
+ [ObservableProperty] private long usedMemory;
+
+ public ObservableCollection RecentInstances { get; } = new();
+
+ public DashboardViewModel()
+ {
+ _instanceManager = ServiceLocator.GetService();
+ _systemMonitor = ServiceLocator.GetService();
+ }
+
+ public async Task LoadAsync()
+ {
+ await _instanceManager.InitializeAsync();
+ var instances = _instanceManager.GetInstances();
+ TotalInstances = instances.Count;
+ RunningInstances = instances.Count(i => i.Status == InstanceStatus.Running);
+ StoppedInstances = instances.Count(i => i.Status == InstanceStatus.Stopped);
+
+ RecentInstances.Clear();
+ foreach (var inst in instances.Take(5))
+ RecentInstances.Add(inst);
+
+ await RefreshSystemStatsAsync();
+ }
+
+ [RelayCommand]
+ private async Task RefreshSystemStatsAsync()
+ {
+ // Gather CPU usage asynchronously
+ var cpu = await _systemMonitor.GetCpuUsageAsync();
+ CpuUsage = cpu.UsagePercent;
+
+ // Memory info
+ var memory = _systemMonitor.GetMemoryInfo();
+ MemoryUsage = memory.UsagePercent;
+ TotalMemory = memory.TotalBytes;
+ UsedMemory = memory.UsedBytes;
+
+ // Disk info - primary drive
+ var disk = _systemMonitor.GetDiskInfo();
+ DiskUsage = disk.UsagePercent;
+
+ // CPU model from system info
+ var sysInfo = _systemMonitor.GetSystemInfo();
+ if (sysInfo.TryGetValue("Processor", out var processor))
+ CpuModel = processor;
+ }
+
+ [RelayCommand]
+ private async Task RefreshInstancesAsync()
+ {
+ var instances = _instanceManager.GetInstances();
+ TotalInstances = instances.Count;
+ RunningInstances = instances.Count(i => i.Status == InstanceStatus.Running);
+ StoppedInstances = instances.Count(i => i.Status == InstanceStatus.Stopped);
+
+ RecentInstances.Clear();
+ foreach (var inst in instances.Take(5))
+ RecentInstances.Add(inst);
+
+ await Task.CompletedTask;
+ }
+}
diff --git a/desktop/GSM3/ViewModels/FilesViewModel.cs b/desktop/GSM3/ViewModels/FilesViewModel.cs
new file mode 100644
index 0000000..ee55673
--- /dev/null
+++ b/desktop/GSM3/ViewModels/FilesViewModel.cs
@@ -0,0 +1,314 @@
+namespace GSM3.ViewModels;
+
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GSM3.Models;
+using GSM3.Services;
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+
+public partial class FilesViewModel : ObservableObject
+{
+ private readonly FileManager _fileManager;
+ private readonly Stack _navigationHistory = new();
+
+ public ObservableCollection Files { get; } = new();
+ public ObservableCollection PathSegments { get; } = new();
+ public ObservableCollection DriveList { get; } = new();
+ public ObservableCollection SelectedItems { get; } = new();
+
+ [ObservableProperty] private string currentPath = "";
+ [ObservableProperty] private string statusMessage = "";
+ [ObservableProperty] private bool isLoading;
+ [ObservableProperty] private string newFileName = "";
+ [ObservableProperty] private string newFolderName = "";
+ [ObservableProperty] private string searchPattern = "";
+ [ObservableProperty] private int fileCount;
+ [ObservableProperty] private int folderCount;
+
+ public FilesViewModel()
+ {
+ _fileManager = ServiceLocator.GetService();
+ }
+
+ public void Load()
+ {
+ LoadDrives();
+ // Start at the first available drive
+ if (DriveList.Count > 0)
+ {
+ var readyDrive = DriveList.FirstOrDefault(d => d.IsReady);
+ if (readyDrive != null)
+ NavigateTo(readyDrive.Name);
+ }
+ }
+
+ private void LoadDrives()
+ {
+ DriveList.Clear();
+ var drives = _fileManager.GetDrives();
+ foreach (var drive in drives)
+ DriveList.Add(drive);
+ }
+
+ [RelayCommand]
+ private void NavigateTo(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path)) return;
+
+ IsLoading = true;
+ try
+ {
+ // Push current path to history before navigating
+ if (!string.IsNullOrEmpty(CurrentPath))
+ _navigationHistory.Push(CurrentPath);
+
+ CurrentPath = path;
+ RefreshFiles();
+ UpdatePathSegments();
+ StatusMessage = $"{FileCount} file(s), {FolderCount} folder(s)";
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Navigation failed: {ex.Message}";
+ Debug.WriteLine($"NavigateTo error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private void GoUp()
+ {
+ if (string.IsNullOrEmpty(CurrentPath)) return;
+
+ var parent = Path.GetDirectoryName(CurrentPath);
+ if (!string.IsNullOrEmpty(parent))
+ {
+ NavigateTo(parent);
+ }
+ else
+ {
+ // At root of a drive, stay put
+ StatusMessage = "Already at root.";
+ }
+ }
+
+ [RelayCommand]
+ private void GoBack()
+ {
+ if (_navigationHistory.Count == 0)
+ {
+ StatusMessage = "No navigation history.";
+ return;
+ }
+
+ var previousPath = _navigationHistory.Pop();
+ CurrentPath = previousPath;
+
+ IsLoading = true;
+ try
+ {
+ RefreshFiles();
+ UpdatePathSegments();
+ StatusMessage = $"{FileCount} file(s), {FolderCount} folder(s)";
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Navigation failed: {ex.Message}";
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private void Refresh()
+ {
+ if (string.IsNullOrEmpty(CurrentPath)) return;
+
+ IsLoading = true;
+ try
+ {
+ RefreshFiles();
+ StatusMessage = $"{FileCount} file(s), {FolderCount} folder(s)";
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private void CreateFile()
+ {
+ if (string.IsNullOrEmpty(CurrentPath) || string.IsNullOrWhiteSpace(NewFileName))
+ {
+ StatusMessage = "Please enter a file name.";
+ return;
+ }
+
+ var filePath = Path.Combine(CurrentPath, NewFileName.Trim());
+ var result = _fileManager.CreateFile(filePath);
+
+ if (result.Success)
+ {
+ NewFileName = "";
+ RefreshFiles();
+ StatusMessage = $"File '{Path.GetFileName(filePath)}' created.";
+ }
+ else
+ {
+ StatusMessage = $"Failed to create file: {result.Error}";
+ }
+ }
+
+ [RelayCommand]
+ private void CreateFolder()
+ {
+ if (string.IsNullOrEmpty(CurrentPath) || string.IsNullOrWhiteSpace(NewFolderName))
+ {
+ StatusMessage = "Please enter a folder name.";
+ return;
+ }
+
+ var folderPath = Path.Combine(CurrentPath, NewFolderName.Trim());
+ var result = _fileManager.CreateDirectory(folderPath);
+
+ if (result.Success)
+ {
+ NewFolderName = "";
+ RefreshFiles();
+ StatusMessage = $"Folder '{Path.GetFileName(folderPath)}' created.";
+ }
+ else
+ {
+ StatusMessage = $"Failed to create folder: {result.Error}";
+ }
+ }
+
+ [RelayCommand]
+ private void DeleteSelected()
+ {
+ if (SelectedItems.Count == 0)
+ {
+ StatusMessage = "No items selected.";
+ return;
+ }
+
+ int deleted = 0;
+ int failed = 0;
+
+ foreach (var item in SelectedItems.ToList())
+ {
+ var result = _fileManager.DeleteFile(item.Path);
+ if (result.Success)
+ deleted++;
+ else
+ failed++;
+ }
+
+ SelectedItems.Clear();
+ RefreshFiles();
+
+ if (failed == 0)
+ StatusMessage = $"{deleted} item(s) deleted.";
+ else
+ StatusMessage = $"{deleted} deleted, {failed} failed.";
+ }
+
+ [RelayCommand]
+ private void SearchFiles()
+ {
+ if (string.IsNullOrEmpty(CurrentPath) || string.IsNullOrWhiteSpace(SearchPattern))
+ {
+ StatusMessage = "Enter a search pattern (e.g., *.txt).";
+ return;
+ }
+
+ IsLoading = true;
+ try
+ {
+ var results = _fileManager.SearchFiles(CurrentPath, SearchPattern.Trim());
+
+ Files.Clear();
+ foreach (var entry in results)
+ {
+ Files.Add(entry);
+ }
+
+ FileCount = Files.Count(f => f.Type == FileItemType.File);
+ FolderCount = Files.Count(f => f.Type == FileItemType.Directory);
+ StatusMessage = $"Search found {Files.Count} result(s).";
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Search failed: {ex.Message}";
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private void NavigateToDrive(DriveEntry drive)
+ {
+ if (drive.IsReady)
+ NavigateTo(drive.Name);
+ else
+ StatusMessage = $"Drive {drive.Name} is not ready.";
+ }
+
+ public void OpenItem(FileItem item)
+ {
+ if (item.Type == FileItemType.Directory)
+ {
+ NavigateTo(item.Path);
+ }
+ }
+
+ private void RefreshFiles()
+ {
+ Files.Clear();
+ SelectedItems.Clear();
+
+ var entries = _fileManager.ListDirectory(CurrentPath);
+
+ // Directories first, then files, each sorted by name
+ var sorted = entries
+ .OrderByDescending(e => e.Type == FileItemType.Directory)
+ .ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var entry in sorted)
+ {
+ Files.Add(entry);
+ }
+
+ FileCount = Files.Count(f => f.Type == FileItemType.File);
+ FolderCount = Files.Count(f => f.Type == FileItemType.Directory);
+ }
+
+ private void UpdatePathSegments()
+ {
+ PathSegments.Clear();
+ if (string.IsNullOrEmpty(CurrentPath)) return;
+
+ // Build breadcrumb segments
+ var parts = CurrentPath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
+ var accumulated = "";
+
+ foreach (var part in parts)
+ {
+ if (string.IsNullOrEmpty(accumulated))
+ accumulated = part + Path.DirectorySeparatorChar;
+ else
+ accumulated = Path.Combine(accumulated, part);
+
+ PathSegments.Add(accumulated);
+ }
+ }
+}
diff --git a/desktop/GSM3/ViewModels/InstancesViewModel.cs b/desktop/GSM3/ViewModels/InstancesViewModel.cs
new file mode 100644
index 0000000..0639fcb
--- /dev/null
+++ b/desktop/GSM3/ViewModels/InstancesViewModel.cs
@@ -0,0 +1,306 @@
+namespace GSM3.ViewModels;
+
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GSM3.Models;
+using GSM3.Services;
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+
+public partial class InstancesViewModel : ObservableObject
+{
+ private readonly InstanceManager _instanceManager;
+
+ public ObservableCollection Instances { get; } = new();
+
+ [ObservableProperty] private Instance? selectedInstance;
+ [ObservableProperty] private bool isLoading;
+ [ObservableProperty] private string statusMessage = "";
+
+ // Create/Edit form fields
+ [ObservableProperty] private string editName = "";
+ [ObservableProperty] private string editDescription = "";
+ [ObservableProperty] private string editWorkingDirectory = "";
+ [ObservableProperty] private string editStartCommand = "";
+ [ObservableProperty] private string editProgramPath = "";
+ [ObservableProperty] private InstanceType editType = InstanceType.Generic;
+ [ObservableProperty] private StopCommand editStopCommandType = StopCommand.CtrlC;
+ [ObservableProperty] private bool editAutoStart;
+ [ObservableProperty] private bool editEnableStreamForward;
+ [ObservableProperty] private bool isEditing;
+
+ public InstancesViewModel()
+ {
+ _instanceManager = ServiceLocator.GetService();
+ _instanceManager.OnInstanceStatusChanged += OnInstanceStatusChanged;
+ }
+
+ public async Task LoadAsync()
+ {
+ IsLoading = true;
+ try
+ {
+ await _instanceManager.InitializeAsync();
+ await RefreshInstancesAsync();
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task RefreshInstancesAsync()
+ {
+ var instances = _instanceManager.GetInstances();
+ Instances.Clear();
+ foreach (var inst in instances)
+ Instances.Add(inst);
+ StatusMessage = $"{instances.Count} instance(s) loaded";
+ }
+
+ [RelayCommand]
+ private async Task CreateInstanceAsync()
+ {
+ if (string.IsNullOrWhiteSpace(EditName))
+ {
+ StatusMessage = "Instance name is required.";
+ return;
+ }
+
+ IsLoading = true;
+ try
+ {
+ var instance = new Instance
+ {
+ Name = EditName.Trim(),
+ Description = EditDescription.Trim(),
+ WorkingDirectory = EditWorkingDirectory.Trim(),
+ StartCommand = EditStartCommand.Trim(),
+ ProgramPath = EditProgramPath.Trim(),
+ Type = EditType,
+ StopCommandType = EditStopCommandType,
+ AutoStart = EditAutoStart,
+ EnableStreamForward = EditEnableStreamForward
+ };
+
+ await _instanceManager.CreateInstanceAsync(instance);
+ ClearEditFields();
+ await RefreshInstancesAsync();
+ StatusMessage = $"Instance '{instance.Name}' created.";
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Failed to create instance: {ex.Message}";
+ Debug.WriteLine($"CreateInstance error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task DeleteInstanceAsync()
+ {
+ if (SelectedInstance == null)
+ {
+ StatusMessage = "No instance selected.";
+ return;
+ }
+
+ IsLoading = true;
+ try
+ {
+ var name = SelectedInstance.Name;
+ var success = await _instanceManager.DeleteInstanceAsync(SelectedInstance.Id);
+ if (success)
+ {
+ SelectedInstance = null;
+ await RefreshInstancesAsync();
+ StatusMessage = $"Instance '{name}' deleted.";
+ }
+ else
+ {
+ StatusMessage = $"Failed to delete instance '{name}'.";
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Delete failed: {ex.Message}";
+ Debug.WriteLine($"DeleteInstance error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task StartInstanceAsync()
+ {
+ if (SelectedInstance == null)
+ {
+ StatusMessage = "No instance selected.";
+ return;
+ }
+
+ StatusMessage = $"Starting '{SelectedInstance.Name}'...";
+ try
+ {
+ var success = await _instanceManager.StartInstanceAsync(SelectedInstance.Id);
+ StatusMessage = success
+ ? $"Instance '{SelectedInstance.Name}' started."
+ : $"Failed to start '{SelectedInstance.Name}'.";
+ await RefreshInstancesAsync();
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Start failed: {ex.Message}";
+ Debug.WriteLine($"StartInstance error: {ex}");
+ }
+ }
+
+ [RelayCommand]
+ private async Task StopInstanceAsync()
+ {
+ if (SelectedInstance == null)
+ {
+ StatusMessage = "No instance selected.";
+ return;
+ }
+
+ StatusMessage = $"Stopping '{SelectedInstance.Name}'...";
+ try
+ {
+ var success = await _instanceManager.StopInstanceAsync(SelectedInstance.Id);
+ StatusMessage = success
+ ? $"Instance '{SelectedInstance.Name}' stopped."
+ : $"Failed to stop '{SelectedInstance.Name}'.";
+ await RefreshInstancesAsync();
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Stop failed: {ex.Message}";
+ Debug.WriteLine($"StopInstance error: {ex}");
+ }
+ }
+
+ [RelayCommand]
+ private async Task RestartInstanceAsync()
+ {
+ if (SelectedInstance == null)
+ {
+ StatusMessage = "No instance selected.";
+ return;
+ }
+
+ StatusMessage = $"Restarting '{SelectedInstance.Name}'...";
+ try
+ {
+ var success = await _instanceManager.RestartInstanceAsync(SelectedInstance.Id);
+ StatusMessage = success
+ ? $"Instance '{SelectedInstance.Name}' restarted."
+ : $"Failed to restart '{SelectedInstance.Name}'.";
+ await RefreshInstancesAsync();
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Restart failed: {ex.Message}";
+ Debug.WriteLine($"RestartInstance error: {ex}");
+ }
+ }
+
+ [RelayCommand]
+ private async Task SaveInstanceAsync()
+ {
+ if (SelectedInstance == null || string.IsNullOrWhiteSpace(EditName))
+ {
+ StatusMessage = "No instance selected or name is empty.";
+ return;
+ }
+
+ IsLoading = true;
+ try
+ {
+ SelectedInstance.Name = EditName.Trim();
+ SelectedInstance.Description = EditDescription.Trim();
+ SelectedInstance.WorkingDirectory = EditWorkingDirectory.Trim();
+ SelectedInstance.StartCommand = EditStartCommand.Trim();
+ SelectedInstance.ProgramPath = EditProgramPath.Trim();
+ SelectedInstance.Type = EditType;
+ SelectedInstance.StopCommandType = EditStopCommandType;
+ SelectedInstance.AutoStart = EditAutoStart;
+ SelectedInstance.EnableStreamForward = EditEnableStreamForward;
+
+ var updated = await _instanceManager.UpdateInstanceAsync(SelectedInstance);
+ if (updated != null)
+ {
+ await RefreshInstancesAsync();
+ StatusMessage = $"Instance '{updated.Name}' updated.";
+ }
+ else
+ {
+ StatusMessage = "Failed to update instance.";
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Save failed: {ex.Message}";
+ Debug.WriteLine($"SaveInstance error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ IsEditing = false;
+ }
+ }
+
+ public void BeginEdit()
+ {
+ if (SelectedInstance == null) return;
+
+ EditName = SelectedInstance.Name;
+ EditDescription = SelectedInstance.Description;
+ EditWorkingDirectory = SelectedInstance.WorkingDirectory;
+ EditStartCommand = SelectedInstance.StartCommand;
+ EditProgramPath = SelectedInstance.ProgramPath;
+ EditType = SelectedInstance.Type;
+ EditStopCommandType = SelectedInstance.StopCommandType;
+ EditAutoStart = SelectedInstance.AutoStart;
+ EditEnableStreamForward = SelectedInstance.EnableStreamForward;
+ IsEditing = true;
+ }
+
+ public void CancelEdit()
+ {
+ ClearEditFields();
+ IsEditing = false;
+ }
+
+ private void ClearEditFields()
+ {
+ EditName = "";
+ EditDescription = "";
+ EditWorkingDirectory = "";
+ EditStartCommand = "";
+ EditProgramPath = "";
+ EditType = InstanceType.Generic;
+ EditStopCommandType = StopCommand.CtrlC;
+ EditAutoStart = false;
+ EditEnableStreamForward = false;
+ IsEditing = false;
+ }
+
+ private void OnInstanceStatusChanged(object? sender, InstanceStatusEventArgs e)
+ {
+ // Update the instance status in the collection
+ var instance = Instances.FirstOrDefault(i => i.Id == e.InstanceId);
+ if (instance != null)
+ {
+ instance.Status = e.NewStatus;
+ // Trigger UI refresh by re-reading from service
+ _ = RefreshInstancesAsync();
+ }
+ }
+}
diff --git a/desktop/GSM3/ViewModels/SchedulerViewModel.cs b/desktop/GSM3/ViewModels/SchedulerViewModel.cs
new file mode 100644
index 0000000..c351b03
--- /dev/null
+++ b/desktop/GSM3/ViewModels/SchedulerViewModel.cs
@@ -0,0 +1,305 @@
+namespace GSM3.ViewModels;
+
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GSM3.Models;
+using GSM3.Services;
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+
+public partial class SchedulerViewModel : ObservableObject
+{
+ private readonly SchedulerManager _schedulerManager;
+ private readonly InstanceManager _instanceManager;
+
+ public ObservableCollection Tasks { get; } = new();
+ public ObservableCollection Instances { get; } = new();
+
+ [ObservableProperty] private ScheduledTask? selectedTask;
+ [ObservableProperty] private string statusMessage = "";
+ [ObservableProperty] private bool isLoading;
+
+ // Create/Edit form fields
+ [ObservableProperty] private string editName = "";
+ [ObservableProperty] private string editInstanceId = "";
+ [ObservableProperty] private TaskType editTaskType = TaskType.Power;
+ [ObservableProperty] private string editCronExpression = "0 * * * *";
+ [ObservableProperty] private bool editEnabled = true;
+ [ObservableProperty] private PowerAction editPowerAction = PowerAction.Restart;
+ [ObservableProperty] private string editCommand = "";
+ [ObservableProperty] private string editSystemAction = "";
+ [ObservableProperty] private bool isEditing;
+
+ public SchedulerViewModel()
+ {
+ _schedulerManager = ServiceLocator.GetService();
+ _instanceManager = ServiceLocator.GetService();
+ }
+
+ public async Task LoadAsync()
+ {
+ IsLoading = true;
+ try
+ {
+ await _schedulerManager.InitializeAsync();
+ await _instanceManager.InitializeAsync();
+
+ var instances = _instanceManager.GetInstances();
+ Instances.Clear();
+ foreach (var inst in instances)
+ Instances.Add(inst);
+
+ await RefreshTasksAsync();
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task RefreshTasksAsync()
+ {
+ var tasks = _schedulerManager.GetTasks();
+ Tasks.Clear();
+ foreach (var task in tasks.OrderBy(t => t.Name))
+ Tasks.Add(task);
+
+ StatusMessage = $"{Tasks.Count} scheduled task(s).";
+ }
+
+ [RelayCommand]
+ private async Task CreateTaskAsync()
+ {
+ if (string.IsNullOrWhiteSpace(EditName))
+ {
+ StatusMessage = "Task name is required.";
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(EditCronExpression))
+ {
+ StatusMessage = "Cron expression is required.";
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(EditInstanceId))
+ {
+ StatusMessage = "Please select an instance.";
+ return;
+ }
+
+ IsLoading = true;
+ try
+ {
+ var task = new ScheduledTask
+ {
+ Name = EditName.Trim(),
+ InstanceId = EditInstanceId,
+ Type = EditTaskType,
+ CronExpression = EditCronExpression.Trim(),
+ Enabled = EditEnabled,
+ PowerActionType = EditTaskType == TaskType.Power ? EditPowerAction : null,
+ Command = EditTaskType == TaskType.Command ? EditCommand.Trim() : null,
+ SystemAction = EditTaskType == TaskType.System ? EditSystemAction.Trim() : null
+ };
+
+ await _schedulerManager.CreateTaskAsync(task);
+ ClearEditFields();
+ await RefreshTasksAsync();
+ StatusMessage = $"Task '{task.Name}' created.";
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Failed to create task: {ex.Message}";
+ Debug.WriteLine($"CreateTask error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task EditTaskAsync()
+ {
+ if (SelectedTask == null || string.IsNullOrWhiteSpace(EditName))
+ {
+ StatusMessage = "No task selected or name is empty.";
+ return;
+ }
+
+ IsLoading = true;
+ try
+ {
+ SelectedTask.Name = EditName.Trim();
+ SelectedTask.InstanceId = EditInstanceId;
+ SelectedTask.Type = EditTaskType;
+ SelectedTask.CronExpression = EditCronExpression.Trim();
+ SelectedTask.Enabled = EditEnabled;
+ SelectedTask.PowerActionType = EditTaskType == TaskType.Power ? EditPowerAction : null;
+ SelectedTask.Command = EditTaskType == TaskType.Command ? EditCommand.Trim() : null;
+ SelectedTask.SystemAction = EditTaskType == TaskType.System ? EditSystemAction.Trim() : null;
+
+ var updated = await _schedulerManager.UpdateTaskAsync(SelectedTask);
+ if (updated != null)
+ {
+ await RefreshTasksAsync();
+ StatusMessage = $"Task '{updated.Name}' updated.";
+ }
+ else
+ {
+ StatusMessage = "Failed to update task.";
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Update failed: {ex.Message}";
+ Debug.WriteLine($"EditTask error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ IsEditing = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task DeleteTaskAsync()
+ {
+ if (SelectedTask == null)
+ {
+ StatusMessage = "No task selected.";
+ return;
+ }
+
+ IsLoading = true;
+ try
+ {
+ var name = SelectedTask.Name;
+ var success = await _schedulerManager.DeleteTaskAsync(SelectedTask.Id);
+ if (success)
+ {
+ SelectedTask = null;
+ await RefreshTasksAsync();
+ StatusMessage = $"Task '{name}' deleted.";
+ }
+ else
+ {
+ StatusMessage = $"Failed to delete task '{name}'.";
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Delete failed: {ex.Message}";
+ Debug.WriteLine($"DeleteTask error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task ToggleTaskAsync()
+ {
+ if (SelectedTask == null)
+ {
+ StatusMessage = "No task selected.";
+ return;
+ }
+
+ try
+ {
+ var success = await _schedulerManager.ToggleTaskAsync(SelectedTask.Id);
+ if (success)
+ {
+ await RefreshTasksAsync();
+ // Find the updated task to check its state
+ var updatedTask = Tasks.FirstOrDefault(t => t.Id == SelectedTask.Id);
+ var state = updatedTask?.Enabled == true ? "enabled" : "disabled";
+ StatusMessage = $"Task '{SelectedTask.Name}' {state}.";
+ }
+ else
+ {
+ StatusMessage = "Failed to toggle task.";
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Toggle failed: {ex.Message}";
+ Debug.WriteLine($"ToggleTask error: {ex}");
+ }
+ }
+
+ [RelayCommand]
+ private async Task ExecuteNowAsync()
+ {
+ if (SelectedTask == null)
+ {
+ StatusMessage = "No task selected.";
+ return;
+ }
+
+ StatusMessage = $"Executing '{SelectedTask.Name}'...";
+ try
+ {
+ var success = await _schedulerManager.ExecuteNowAsync(SelectedTask.Id);
+ if (success)
+ {
+ await RefreshTasksAsync();
+ StatusMessage = $"Task '{SelectedTask.Name}' executed.";
+ }
+ else
+ {
+ StatusMessage = $"Failed to execute task '{SelectedTask.Name}'.";
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Execution failed: {ex.Message}";
+ Debug.WriteLine($"ExecuteNow error: {ex}");
+ }
+ }
+
+ public void BeginEdit()
+ {
+ if (SelectedTask == null) return;
+
+ EditName = SelectedTask.Name;
+ EditInstanceId = SelectedTask.InstanceId;
+ EditTaskType = SelectedTask.Type;
+ EditCronExpression = SelectedTask.CronExpression;
+ EditEnabled = SelectedTask.Enabled;
+ EditPowerAction = SelectedTask.PowerActionType ?? PowerAction.Restart;
+ EditCommand = SelectedTask.Command ?? "";
+ EditSystemAction = SelectedTask.SystemAction ?? "";
+ IsEditing = true;
+ }
+
+ public void BeginCreate()
+ {
+ ClearEditFields();
+ SelectedTask = null;
+ IsEditing = true;
+ }
+
+ public void CancelEdit()
+ {
+ ClearEditFields();
+ IsEditing = false;
+ }
+
+ private void ClearEditFields()
+ {
+ EditName = "";
+ EditInstanceId = "";
+ EditTaskType = TaskType.Power;
+ EditCronExpression = "0 * * * *";
+ EditEnabled = true;
+ EditPowerAction = PowerAction.Restart;
+ EditCommand = "";
+ EditSystemAction = "";
+ IsEditing = false;
+ }
+}
diff --git a/desktop/GSM3/ViewModels/SettingsViewModel.cs b/desktop/GSM3/ViewModels/SettingsViewModel.cs
new file mode 100644
index 0000000..bb9bb92
--- /dev/null
+++ b/desktop/GSM3/ViewModels/SettingsViewModel.cs
@@ -0,0 +1,216 @@
+namespace GSM3.ViewModels;
+
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GSM3.Models;
+using GSM3.Services;
+using System.Diagnostics;
+
+public partial class SettingsViewModel : ObservableObject
+{
+ private readonly ConfigManager _configManager;
+
+ [ObservableProperty] private AppConfig config = new();
+ [ObservableProperty] private string statusMessage = "";
+ [ObservableProperty] private bool isLoading;
+ [ObservableProperty] private bool hasUnsavedChanges;
+
+ // Flattened config properties for easier binding
+ [ObservableProperty] private int serverPort;
+ [ObservableProperty] private string serverHost = "0.0.0.0";
+ [ObservableProperty] private int maxLoginAttempts;
+ [ObservableProperty] private int lockoutDurationMinutes;
+ [ObservableProperty] private int sessionTimeoutMinutes;
+ [ObservableProperty] private string steamCmdInstallPath = "";
+ [ObservableProperty] private string terminalDefaultUser = "";
+ [ObservableProperty] private int terminalMaxSessions;
+ [ObservableProperty] private int terminalTimeoutMinutes;
+ [ObservableProperty] private string gameDefaultInstallPath = "";
+
+ // Theme
+ [ObservableProperty] private int selectedThemeIndex;
+
+ public SettingsViewModel()
+ {
+ _configManager = ServiceLocator.GetService();
+ }
+
+ public async Task LoadAsync()
+ {
+ IsLoading = true;
+ try
+ {
+ await _configManager.InitializeAsync();
+ LoadConfigValues();
+ StatusMessage = "Settings loaded.";
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Failed to load settings: {ex.Message}";
+ Debug.WriteLine($"SettingsLoad error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ private void LoadConfigValues()
+ {
+ var cfg = _configManager.GetConfig();
+ Config = cfg;
+
+ // Server
+ ServerPort = cfg.Server.Port;
+ ServerHost = cfg.Server.Host;
+
+ // Auth
+ MaxLoginAttempts = cfg.Auth.MaxLoginAttempts;
+ LockoutDurationMinutes = cfg.Auth.LockoutDurationMinutes;
+ SessionTimeoutMinutes = cfg.Auth.SessionTimeoutMinutes;
+
+ // SteamCMD
+ SteamCmdInstallPath = cfg.SteamCMD.InstallPath;
+
+ // Terminal
+ TerminalDefaultUser = cfg.Terminal.DefaultUser;
+ TerminalMaxSessions = cfg.Terminal.MaxSessions;
+ TerminalTimeoutMinutes = cfg.Terminal.TimeoutMinutes;
+
+ // Game
+ GameDefaultInstallPath = cfg.Game.DefaultInstallPath;
+
+ HasUnsavedChanges = false;
+ }
+
+ [RelayCommand]
+ private async Task SaveSettingsAsync()
+ {
+ IsLoading = true;
+ try
+ {
+ await _configManager.UpdateConfigAsync(cfg =>
+ {
+ // Server
+ cfg.Server.Port = ServerPort;
+ cfg.Server.Host = ServerHost;
+
+ // Auth
+ cfg.Auth.MaxLoginAttempts = MaxLoginAttempts;
+ cfg.Auth.LockoutDurationMinutes = LockoutDurationMinutes;
+ cfg.Auth.SessionTimeoutMinutes = SessionTimeoutMinutes;
+
+ // SteamCMD
+ cfg.SteamCMD.InstallPath = SteamCmdInstallPath;
+
+ // Terminal
+ cfg.Terminal.DefaultUser = TerminalDefaultUser;
+ cfg.Terminal.MaxSessions = TerminalMaxSessions;
+ cfg.Terminal.TimeoutMinutes = TerminalTimeoutMinutes;
+
+ // Game
+ cfg.Game.DefaultInstallPath = GameDefaultInstallPath;
+ });
+
+ HasUnsavedChanges = false;
+ StatusMessage = "Settings saved.";
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Failed to save settings: {ex.Message}";
+ Debug.WriteLine($"SaveSettings error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task ResetSettingsAsync()
+ {
+ IsLoading = true;
+ try
+ {
+ // Reset to defaults by creating a new AppConfig
+ await _configManager.UpdateConfigAsync(cfg =>
+ {
+ var defaults = new AppConfig();
+
+ cfg.Server.Port = defaults.Server.Port;
+ cfg.Server.Host = defaults.Server.Host;
+
+ cfg.Auth.MaxLoginAttempts = defaults.Auth.MaxLoginAttempts;
+ cfg.Auth.LockoutDurationMinutes = defaults.Auth.LockoutDurationMinutes;
+ cfg.Auth.SessionTimeoutMinutes = defaults.Auth.SessionTimeoutMinutes;
+
+ cfg.SteamCMD.InstallPath = defaults.SteamCMD.InstallPath;
+
+ cfg.Terminal.DefaultUser = defaults.Terminal.DefaultUser;
+ cfg.Terminal.MaxSessions = defaults.Terminal.MaxSessions;
+ cfg.Terminal.TimeoutMinutes = defaults.Terminal.TimeoutMinutes;
+
+ cfg.Game.DefaultInstallPath = defaults.Game.DefaultInstallPath;
+ });
+
+ LoadConfigValues();
+ StatusMessage = "Settings reset to defaults.";
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Failed to reset settings: {ex.Message}";
+ Debug.WriteLine($"ResetSettings error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private void BrowseSteamCMDPath()
+ {
+ // In WinUI 3, folder picking must be initiated from the View layer
+ // (using Windows.Storage.Pickers.FolderPicker) and the result passed here.
+ // This command signals intent; the View handles the picker and calls SetSteamCMDPath.
+ StatusMessage = "Use the browse button to select a SteamCMD folder.";
+ }
+
+ [RelayCommand]
+ private void BrowseGamePath()
+ {
+ // Same pattern as SteamCMD path - the View handles the picker dialog.
+ StatusMessage = "Use the browse button to select a game install folder.";
+ }
+
+ ///
+ /// Called from the View after a FolderPicker dialog completes for SteamCMD path.
+ ///
+ public void SetSteamCMDPath(string path)
+ {
+ SteamCmdInstallPath = path;
+ HasUnsavedChanges = true;
+ }
+
+ ///
+ /// Called from the View after a FolderPicker dialog completes for game install path.
+ ///
+ public void SetGamePath(string path)
+ {
+ GameDefaultInstallPath = path;
+ HasUnsavedChanges = true;
+ }
+
+ // Track changes on any property update
+ partial void OnServerPortChanged(int value) => HasUnsavedChanges = true;
+ partial void OnServerHostChanged(string value) => HasUnsavedChanges = true;
+ partial void OnMaxLoginAttemptsChanged(int value) => HasUnsavedChanges = true;
+ partial void OnLockoutDurationMinutesChanged(int value) => HasUnsavedChanges = true;
+ partial void OnSessionTimeoutMinutesChanged(int value) => HasUnsavedChanges = true;
+ partial void OnSteamCmdInstallPathChanged(string value) => HasUnsavedChanges = true;
+ partial void OnTerminalDefaultUserChanged(string value) => HasUnsavedChanges = true;
+ partial void OnTerminalMaxSessionsChanged(int value) => HasUnsavedChanges = true;
+ partial void OnTerminalTimeoutMinutesChanged(int value) => HasUnsavedChanges = true;
+ partial void OnGameDefaultInstallPathChanged(string value) => HasUnsavedChanges = true;
+ partial void OnSelectedThemeIndexChanged(int value) => HasUnsavedChanges = true;
+}
diff --git a/desktop/GSM3/ViewModels/SystemViewModel.cs b/desktop/GSM3/ViewModels/SystemViewModel.cs
new file mode 100644
index 0000000..8e7b10f
--- /dev/null
+++ b/desktop/GSM3/ViewModels/SystemViewModel.cs
@@ -0,0 +1,212 @@
+namespace GSM3.ViewModels;
+
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GSM3.Models;
+using GSM3.Services;
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+
+public partial class SystemViewModel : ObservableObject, IDisposable
+{
+ private readonly SystemMonitor _systemMonitor;
+ private Timer? _refreshTimer;
+
+ [ObservableProperty] private double cpuUsage;
+ [ObservableProperty] private double memoryUsage;
+ [ObservableProperty] private long totalMemory;
+ [ObservableProperty] private long usedMemory;
+ [ObservableProperty] private long availableMemory;
+ [ObservableProperty] private double diskUsage;
+ [ObservableProperty] private long diskTotal;
+ [ObservableProperty] private long diskFree;
+ [ObservableProperty] private string cpuModel = "";
+ [ObservableProperty] private int cpuCores;
+ [ObservableProperty] private long networkBytesSent;
+ [ObservableProperty] private long networkBytesReceived;
+ [ObservableProperty] private long networkSendSpeed;
+ [ObservableProperty] private long networkReceiveSpeed;
+ [ObservableProperty] private string machineName = "";
+ [ObservableProperty] private string osVersion = "";
+ [ObservableProperty] private bool isMonitoring;
+ [ObservableProperty] private int refreshIntervalSeconds = 5;
+ [ObservableProperty] private string statusMessage = "";
+ [ObservableProperty] private bool isLoading;
+
+ public ObservableCollection Processes { get; } = new();
+ public ObservableCollection Ports { get; } = new();
+
+ public SystemViewModel()
+ {
+ _systemMonitor = ServiceLocator.GetService();
+ }
+
+ public async Task LoadAsync()
+ {
+ IsLoading = true;
+ try
+ {
+ LoadSystemInfo();
+ await RefreshAsync();
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ private void LoadSystemInfo()
+ {
+ var info = _systemMonitor.GetSystemInfo();
+
+ if (info.TryGetValue("MachineName", out var machine))
+ MachineName = machine;
+ if (info.TryGetValue("OSVersion", out var os))
+ OsVersion = os;
+ if (info.TryGetValue("Processor", out var proc))
+ CpuModel = proc;
+ if (info.TryGetValue("ProcessorCount", out var cores) && int.TryParse(cores, out var coreCount))
+ CpuCores = coreCount;
+ }
+
+ [RelayCommand]
+ private async Task RefreshAsync()
+ {
+ IsLoading = true;
+ try
+ {
+ // CPU usage
+ var cpu = await _systemMonitor.GetCpuUsageAsync();
+ CpuUsage = cpu.UsagePercent;
+
+ // Memory
+ var memory = _systemMonitor.GetMemoryInfo();
+ MemoryUsage = memory.UsagePercent;
+ TotalMemory = memory.TotalBytes;
+ UsedMemory = memory.UsedBytes;
+ AvailableMemory = memory.AvailableBytes;
+
+ // Disk
+ var disk = _systemMonitor.GetDiskInfo();
+ DiskUsage = disk.UsagePercent;
+ DiskTotal = disk.TotalBytes;
+ DiskFree = disk.FreeBytes;
+
+ // Network
+ var network = _systemMonitor.GetNetworkInfo();
+ NetworkBytesSent = network.BytesSent;
+ NetworkBytesReceived = network.BytesReceived;
+ NetworkSendSpeed = network.SendSpeed;
+ NetworkReceiveSpeed = network.ReceiveSpeed;
+
+ // Processes
+ RefreshProcesses();
+
+ // Ports
+ RefreshPorts();
+
+ StatusMessage = $"Refreshed at {DateTime.Now:HH:mm:ss}";
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Refresh failed: {ex.Message}";
+ Debug.WriteLine($"SystemRefresh error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ private void RefreshProcesses()
+ {
+ var processes = _systemMonitor.GetProcessList();
+ Processes.Clear();
+
+ // Sort by memory usage descending, take top 100
+ foreach (var proc in processes.OrderByDescending(p => p.MemoryBytes).Take(100))
+ Processes.Add(proc);
+ }
+
+ private void RefreshPorts()
+ {
+ var ports = _systemMonitor.GetActivePorts();
+ Ports.Clear();
+
+ foreach (var port in ports.OrderBy(p => p.LocalPort))
+ Ports.Add(port);
+ }
+
+ [RelayCommand]
+ private void KillProcess(int pid)
+ {
+ var result = _systemMonitor.KillProcess(pid);
+ if (result.Success)
+ {
+ var killed = Processes.FirstOrDefault(p => p.Pid == pid);
+ if (killed != null)
+ Processes.Remove(killed);
+ StatusMessage = $"Process {pid} terminated.";
+ }
+ else
+ {
+ StatusMessage = $"Failed to kill process {pid}: {result.Error}";
+ }
+ }
+
+ partial void OnIsMonitoringChanged(bool value)
+ {
+ if (value)
+ {
+ StartMonitoring();
+ }
+ else
+ {
+ StopMonitoring();
+ }
+ }
+
+ partial void OnRefreshIntervalSecondsChanged(int value)
+ {
+ // Restart monitoring with new interval if currently active
+ if (IsMonitoring)
+ {
+ StopMonitoring();
+ StartMonitoring();
+ }
+ }
+
+ private void StartMonitoring()
+ {
+ _refreshTimer?.Dispose();
+
+ var intervalMs = Math.Max(1000, RefreshIntervalSeconds * 1000);
+ _refreshTimer = new Timer(async _ =>
+ {
+ try
+ {
+ await RefreshAsync();
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Auto-refresh error: {ex.Message}");
+ }
+ }, null, 0, intervalMs);
+
+ StatusMessage = $"Monitoring started (every {RefreshIntervalSeconds}s).";
+ }
+
+ private void StopMonitoring()
+ {
+ _refreshTimer?.Dispose();
+ _refreshTimer = null;
+ StatusMessage = "Monitoring stopped.";
+ }
+
+ public void Dispose()
+ {
+ _refreshTimer?.Dispose();
+ _refreshTimer = null;
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/desktop/GSM3/ViewModels/TerminalViewModel.cs b/desktop/GSM3/ViewModels/TerminalViewModel.cs
new file mode 100644
index 0000000..94c48a0
--- /dev/null
+++ b/desktop/GSM3/ViewModels/TerminalViewModel.cs
@@ -0,0 +1,248 @@
+namespace GSM3.ViewModels;
+
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GSM3.Models;
+using GSM3.Services;
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Text;
+
+public partial class TerminalViewModel : ObservableObject
+{
+ private readonly TerminalManager _terminalManager;
+ private readonly object _outputLock = new();
+ private readonly Dictionary _sessionOutputs = new();
+
+ public ObservableCollection Sessions { get; } = new();
+
+ [ObservableProperty] private TerminalSession? selectedSession;
+ [ObservableProperty] private string outputText = "";
+ [ObservableProperty] private string inputText = "";
+ [ObservableProperty] private string statusMessage = "";
+ [ObservableProperty] private int maxOutputLines = 5000;
+
+ public TerminalViewModel()
+ {
+ _terminalManager = ServiceLocator.GetService();
+ _terminalManager.OnOutput += OnTerminalOutput;
+ _terminalManager.OnSessionClosed += OnSessionClosed;
+ }
+
+ public void Load()
+ {
+ RefreshSessions();
+ }
+
+ partial void OnSelectedSessionChanged(TerminalSession? value)
+ {
+ if (value == null)
+ {
+ OutputText = "";
+ return;
+ }
+
+ lock (_outputLock)
+ {
+ if (_sessionOutputs.TryGetValue(value.Id, out var sb))
+ OutputText = sb.ToString();
+ else
+ OutputText = "";
+ }
+ }
+
+ [RelayCommand]
+ private void CreateSession()
+ {
+ try
+ {
+ var sessionName = $"Terminal {Sessions.Count + 1}";
+ var session = _terminalManager.CreateSession(sessionName);
+
+ lock (_outputLock)
+ {
+ _sessionOutputs[session.Id] = new StringBuilder();
+ }
+
+ Sessions.Add(session);
+ SelectedSession = session;
+ StatusMessage = $"Session '{session.Name}' created.";
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Failed to create session: {ex.Message}";
+ Debug.WriteLine($"CreateSession error: {ex}");
+ }
+ }
+
+ [RelayCommand]
+ private void CloseSession()
+ {
+ if (SelectedSession == null)
+ {
+ StatusMessage = "No session selected.";
+ return;
+ }
+
+ try
+ {
+ var name = SelectedSession.Name;
+ var id = SelectedSession.Id;
+
+ _terminalManager.CloseSession(id);
+
+ lock (_outputLock)
+ {
+ _sessionOutputs.Remove(id);
+ }
+
+ Sessions.Remove(SelectedSession);
+ SelectedSession = Sessions.LastOrDefault();
+ StatusMessage = $"Session '{name}' closed.";
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Failed to close session: {ex.Message}";
+ Debug.WriteLine($"CloseSession error: {ex}");
+ }
+ }
+
+ [RelayCommand]
+ private void SendInput()
+ {
+ if (SelectedSession == null)
+ {
+ StatusMessage = "No session selected.";
+ return;
+ }
+
+ if (string.IsNullOrEmpty(InputText))
+ return;
+
+ try
+ {
+ var success = _terminalManager.SendInput(SelectedSession.Id, InputText);
+ if (success)
+ {
+ // Echo the input in the output buffer
+ AppendOutput(SelectedSession.Id, $"> {InputText}");
+ InputText = "";
+ }
+ else
+ {
+ StatusMessage = "Failed to send input. Session may be closed.";
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Send failed: {ex.Message}";
+ Debug.WriteLine($"SendInput error: {ex}");
+ }
+ }
+
+ [RelayCommand]
+ private void ClearOutput()
+ {
+ if (SelectedSession == null) return;
+
+ lock (_outputLock)
+ {
+ if (_sessionOutputs.TryGetValue(SelectedSession.Id, out var sb))
+ sb.Clear();
+ }
+
+ OutputText = "";
+ }
+
+ [RelayCommand]
+ private void CloseAllSessions()
+ {
+ _terminalManager.CloseAll();
+ Sessions.Clear();
+
+ lock (_outputLock)
+ {
+ _sessionOutputs.Clear();
+ }
+
+ SelectedSession = null;
+ OutputText = "";
+ StatusMessage = "All sessions closed.";
+ }
+
+ private void RefreshSessions()
+ {
+ var sessions = _terminalManager.GetSessions();
+ Sessions.Clear();
+ foreach (var session in sessions)
+ {
+ Sessions.Add(session);
+ lock (_outputLock)
+ {
+ if (!_sessionOutputs.ContainsKey(session.Id))
+ _sessionOutputs[session.Id] = new StringBuilder();
+ }
+ }
+
+ if (Sessions.Count > 0 && SelectedSession == null)
+ SelectedSession = Sessions[0];
+ }
+
+ private void AppendOutput(string sessionId, string data)
+ {
+ lock (_outputLock)
+ {
+ if (!_sessionOutputs.TryGetValue(sessionId, out var sb))
+ {
+ sb = new StringBuilder();
+ _sessionOutputs[sessionId] = sb;
+ }
+
+ sb.AppendLine(data);
+
+ // Trim output if it exceeds max lines
+ if (sb.Length > MaxOutputLines * 120)
+ {
+ var text = sb.ToString();
+ var lines = text.Split('\n');
+ if (lines.Length > MaxOutputLines)
+ {
+ var trimmed = string.Join('\n', lines.Skip(lines.Length - MaxOutputLines));
+ sb.Clear();
+ sb.Append(trimmed);
+ }
+ }
+ }
+
+ // Update display if this is the selected session
+ if (SelectedSession?.Id == sessionId)
+ {
+ lock (_outputLock)
+ {
+ OutputText = _sessionOutputs[sessionId].ToString();
+ }
+ }
+ }
+
+ private void OnTerminalOutput(object? sender, TerminalOutputEventArgs e)
+ {
+ var prefix = e.IsError ? "[ERR] " : "";
+ AppendOutput(e.SessionId, $"{prefix}{e.Data}");
+ }
+
+ private void OnSessionClosed(object? sender, string sessionId)
+ {
+ var session = Sessions.FirstOrDefault(s => s.Id == sessionId);
+ if (session != null)
+ {
+ Sessions.Remove(session);
+ if (SelectedSession?.Id == sessionId)
+ SelectedSession = Sessions.LastOrDefault();
+ }
+
+ lock (_outputLock)
+ {
+ _sessionOutputs.Remove(sessionId);
+ }
+ }
+}
diff --git a/desktop/GSM3/ViewModels/UsersViewModel.cs b/desktop/GSM3/ViewModels/UsersViewModel.cs
new file mode 100644
index 0000000..f4c327f
--- /dev/null
+++ b/desktop/GSM3/ViewModels/UsersViewModel.cs
@@ -0,0 +1,254 @@
+namespace GSM3.ViewModels;
+
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GSM3.Models;
+using GSM3.Services;
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+
+public partial class UsersViewModel : ObservableObject
+{
+ private readonly UserManager _userManager;
+
+ public ObservableCollection Users { get; } = new();
+
+ [ObservableProperty] private User? selectedUser;
+ [ObservableProperty] private string statusMessage = "";
+ [ObservableProperty] private bool isLoading;
+ [ObservableProperty] private bool isAdmin;
+
+ // Add user form fields
+ [ObservableProperty] private string newUsername = "";
+ [ObservableProperty] private string newPassword = "";
+ [ObservableProperty] private string newRole = "user";
+
+ // Change password fields
+ [ObservableProperty] private string currentPassword = "";
+ [ObservableProperty] private string changeNewPassword = "";
+ [ObservableProperty] private string confirmPassword = "";
+ [ObservableProperty] private bool isChangingPassword;
+
+ // Change role field
+ [ObservableProperty] private string changeRoleTo = "user";
+
+ public UsersViewModel()
+ {
+ _userManager = ServiceLocator.GetService();
+ }
+
+ public async Task LoadAsync()
+ {
+ IsLoading = true;
+ try
+ {
+ await _userManager.InitializeAsync();
+ await RefreshAsync();
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task RefreshAsync()
+ {
+ var users = _userManager.GetUsers();
+ Users.Clear();
+ foreach (var user in users.OrderBy(u => u.Username))
+ Users.Add(user);
+
+ StatusMessage = $"{Users.Count} user(s) loaded.";
+ }
+
+ [RelayCommand]
+ private async Task AddUserAsync()
+ {
+ if (string.IsNullOrWhiteSpace(NewUsername))
+ {
+ StatusMessage = "Username is required.";
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(NewPassword))
+ {
+ StatusMessage = "Password is required.";
+ return;
+ }
+
+ if (NewPassword.Length < 6)
+ {
+ StatusMessage = "Password must be at least 6 characters.";
+ return;
+ }
+
+ IsLoading = true;
+ try
+ {
+ var role = Enum.TryParse(NewRole, ignoreCase: true, out var parsedRole)
+ ? parsedRole : UserRole.User;
+ var result = await _userManager.RegisterAsync(NewUsername.Trim(), NewPassword, role);
+
+ if (result.Success)
+ {
+ NewUsername = "";
+ NewPassword = "";
+ NewRole = "user";
+ await RefreshAsync();
+ StatusMessage = $"User '{result.User?.Username}' created.";
+ }
+ else
+ {
+ StatusMessage = $"Failed to create user: {result.Error}";
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Add user failed: {ex.Message}";
+ Debug.WriteLine($"AddUser error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task DeleteUserAsync()
+ {
+ if (SelectedUser == null)
+ {
+ StatusMessage = "No user selected.";
+ return;
+ }
+
+ IsLoading = true;
+ try
+ {
+ var username = SelectedUser.Username;
+ var result = await _userManager.DeleteUserAsync(SelectedUser.Id);
+
+ if (result.Success)
+ {
+ SelectedUser = null;
+ await RefreshAsync();
+ StatusMessage = $"User '{username}' deleted.";
+ }
+ else
+ {
+ StatusMessage = $"Failed to delete user: {result.Error}";
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Delete failed: {ex.Message}";
+ Debug.WriteLine($"DeleteUser error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task ChangePasswordAsync()
+ {
+ if (SelectedUser == null)
+ {
+ StatusMessage = "No user selected.";
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(CurrentPassword))
+ {
+ StatusMessage = "Current password is required.";
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(ChangeNewPassword))
+ {
+ StatusMessage = "New password is required.";
+ return;
+ }
+
+ if (ChangeNewPassword != ConfirmPassword)
+ {
+ StatusMessage = "New passwords do not match.";
+ return;
+ }
+
+ if (ChangeNewPassword.Length < 6)
+ {
+ StatusMessage = "New password must be at least 6 characters.";
+ return;
+ }
+
+ IsLoading = true;
+ try
+ {
+ var result = await _userManager.ChangePasswordAsync(
+ SelectedUser.Id, CurrentPassword, ChangeNewPassword);
+
+ if (result.Success)
+ {
+ CurrentPassword = "";
+ ChangeNewPassword = "";
+ ConfirmPassword = "";
+ IsChangingPassword = false;
+ StatusMessage = $"Password changed for '{SelectedUser.Username}'.";
+ }
+ else
+ {
+ StatusMessage = $"Password change failed: {result.Error}";
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Password change failed: {ex.Message}";
+ Debug.WriteLine($"ChangePassword error: {ex}");
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task ChangeRoleAsync()
+ {
+ if (SelectedUser == null)
+ {
+ StatusMessage = "No user selected.";
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(ChangeRoleTo))
+ {
+ StatusMessage = "Role is required.";
+ return;
+ }
+
+ // Role changes require re-registering or a dedicated endpoint.
+ // Since UserManager doesn't expose a ChangeRole method, we note the
+ // limitation and provide the UI hook for when one is added.
+ StatusMessage = $"Role change to '{ChangeRoleTo}' for '{SelectedUser.Username}' - feature pending service support.";
+ await Task.CompletedTask;
+ }
+
+ public void BeginChangePassword()
+ {
+ CurrentPassword = "";
+ ChangeNewPassword = "";
+ ConfirmPassword = "";
+ IsChangingPassword = true;
+ }
+
+ public void CancelChangePassword()
+ {
+ CurrentPassword = "";
+ ChangeNewPassword = "";
+ ConfirmPassword = "";
+ IsChangingPassword = false;
+ }
+}
diff --git a/desktop/GSM3/app.manifest b/desktop/GSM3/app.manifest
new file mode 100644
index 0000000..ac72ede
--- /dev/null
+++ b/desktop/GSM3/app.manifest
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PerMonitorV2
+
+
+
\ No newline at end of file