diff --git a/README.md b/README.md
index ca320a62..4efea80b 100644
--- a/README.md
+++ b/README.md
@@ -44,10 +44,68 @@ The app ships as a single installer (bundling .NET, Node.js, WebView2 runtime, R
*(I publish the signed/packaged installer as a GitHub Release asset for clean versioning and smaller repositories.)*
+[](https://www.youtube.com/watch?v=wrqGoTCtAjs)
[](https://youtu.be/tmbAn3lIKmM)
*(click the image to watch on YouTube)*
+## π οΈ v5.1.0 π οΈ
+
+The "Game Changer" just got a lot more polish. This beta is all about workflow and stability.
+
+Whatβs new in this version?
+
+- **π¦ Shop Integration:** No more popups! Arbitrage and "Buy X" are now built directly into the Shop Detail Panel.
+- **β³ Precise Timers:** Live countdowns for all events in the dock + chat commands to query event status.
+- **πΊοΈ Minimap Upgrade:** Added Circle/Square/16:9 layouts, opacity slider, and a real-time server clock!
+- **ποΈ Builder Heaven:** Added !upkeepdetail for granular 24h upkeep reports from your Storage Monitors.
+- **π Smart Alerts:** New In-App popup alerts and "Alarm to Team Chat" options.
+- **π‘οΈ Lag Protection:** Increased handshake timeout (15s) and added a chat-alert delay to prevent spam on laggy API servers.
+- **β οΈ Player Tracking Overhaul:** The player Tracking System has been changed completely. Please review the changes in Patch Notes in-app to understand what's happening.
+
+## π Rust+ Desk v5.0 β Game Changer Update!
+This release is packed with architectural hardening by @JawadYzbk & significant QoL upgrades.
+
+**π οΈ Core & System**
+- Auto-Update System: No more manual downloads! The app now features a background update checker with detailed progress reporting (speed, size, percentage).
+- Centralized Runtime Management: More reliable detection and management of Node.js and background CLI processes.
+- FCM Hardening: A complete architectural rewrite of background communication to eliminate UI freezes and crashes during connection transitions.
+
+**πΊοΈ Interface & Map**
+Modern Edge-to-Edge Design: A sleek, borderless layout for a truly modern look.
+Interpolated Map Movement: Silky-smooth real-time tracking for players and map events.
+BattleMetrics Refactor: The BM button has been upgraded to a clean icon and moved directly onto the server connection card.
+
+**π€ Player & Team Tracking**
+- Advanced Player List: Rebuilt from the ground up for better performance and styling.
+- Custom Grouping: Organize your tracked players with custom Group Names and Colors.
+- Live Indicators: Added "Is Online" status and Play Time counters directly to the player cards.
+
+**π Smart Home & Automation**
+- Storage Monitor Support: Automatic recognition of Storage Monitors for chat commands.
+- Enhanced !Upkeep: Now supports an arbitrary number of connected Tool Cupboards simultaneously.
+- Smart Alarm Fix: Audio alerts for Smart Alarms are now working reliably again.
+
+**π‘οΈ Security & Identity**
+- FCM Token Expiry Tracking: The app now proactively warns you via a new sidebar InfoBar before your pairing expires.
+- Steam Identity (Preview): New Steam profile integration in the sidebar, laying the groundwork for future account-linked features.
+
+**π οΈ Bug Fixes:** We fixed a LOT of smaller bugs, issues, improved the UI dramatically, the Battlemetrics integration etc.
+
+## Update 4.5.1 β The Intelligence Update (May 12th) + Shop Polling Hotfix
+
+
+**π Smart Map Follow**
+- **Player Tracking**: Lock the camera onto any teammate or yourself. The map smoothly centers on the target, making it ideal for tracking raids or roams in real-time.
+
+**π¬ Chat Command Automation**
+- **Switch Control**: Control your base from anywhere by assigning aliases to Smart Switches. Use !toggle, !on, or !off in team chat.
+- **Direct Setup**: Manage all your chat command bindings directly within the Team Chat Overlay.
+
+**π‘οΈ Stability Overhaul**
+- **Reliable Sync**: Fixed "ghost" devices and stale data by implementing a complete session reset on disconnect.
+- **Fast Probing**: Optimized device checks to prevent UI hangs during server lag or when devices are missing.
+
## Update 4.2 β Cargo Ship Overhaul (May 5th)
diff --git a/RustPlusDesktop/MainWindow.xaml.cs b/RustPlusDesktop/MainWindow.xaml.cs
index 203ef3ec..3d72bdee 100644
--- a/RustPlusDesktop/MainWindow.xaml.cs
+++ b/RustPlusDesktop/MainWindow.xaml.cs
@@ -542,6 +542,7 @@ await Dispatcher.InvokeAsync(() =>
_monumentWatcher.OnDebug += (s, msg) => Dispatcher.BeginInvoke(new Action(() => AppendLog(msg)));
+ InitializeModifications();
}
private void OnTrackingNotification(string msg, string serverName)
diff --git a/RustPlusDesktop/RustPlusDesk.csproj b/RustPlusDesktop/RustPlusDesk.csproj
index 5143557d..88241bf1 100644
--- a/RustPlusDesktop/RustPlusDesk.csproj
+++ b/RustPlusDesktop/RustPlusDesk.csproj
@@ -359,7 +359,9 @@
-
+
+
+
60
diff --git a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Grid.cs b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Grid.cs
index 901079ed..cc6c8171 100644
--- a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Grid.cs
+++ b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Grid.cs
@@ -25,12 +25,20 @@ private void RedrawGrid()
double ow = _worldRectPx.Width, oh = _worldRectPx.Height;
double step = ow / cells;
+ double shiftPxX = GridShiftX * (ow / _worldSizeS);
+ double shiftPxY = GridShiftY * (oh / _worldSizeS);
+
var stroke = new SolidColorBrush(Color.FromArgb(120, 255, 255, 255));
double thin = 1.0, thick = 2.0;
- for (int i = 0; i <= cells; i++)
+ int extra = 3;
+
+ // Draw vertical lines
+ for (int i = -extra; i <= cells + extra; i++)
{
- double x = ox + i * step;
+ double x = ox + i * step + shiftPxX;
+ if (x < ox || x > ox + ow) continue;
+
var line = new System.Windows.Shapes.Line
{
X1 = x,
@@ -38,14 +46,17 @@ private void RedrawGrid()
X2 = x,
Y2 = oy + oh,
Stroke = stroke,
- StrokeThickness = (i % 5 == 0) ? thick : thin
+ StrokeThickness = (Math.Abs(i) % 5 == 0) ? thick : thin
};
GridLayer.Children.Add(line);
}
- for (int j = 0; j <= cells; j++)
+ // Draw horizontal lines
+ for (int j = -extra; j <= cells + extra; j++)
{
- double y = oy + j * step;
+ double y = oy + j * step - shiftPxY;
+ if (y < oy || y > oy + oh) continue;
+
var line = new System.Windows.Shapes.Line
{
X1 = ox,
@@ -53,19 +64,28 @@ private void RedrawGrid()
X2 = ox + ow,
Y2 = y,
Stroke = stroke,
- StrokeThickness = (j % 5 == 0) ? thick : thin
+ StrokeThickness = (Math.Abs(j) % 5 == 0) ? thick : thin
};
GridLayer.Children.Add(line);
}
- for (int i = 0; i < cells; i++)
+ // Draw labels
+ for (int i = -extra; i < cells + extra; i++)
{
- string col = ColumnLabel(i);
- for (int j = 0; j < cells; j++)
+ for (int j = -extra; j < cells + extra; j++)
{
+ double x = ox + i * step + 1 + shiftPxX;
+ double y = oy + j * step + 1 - shiftPxY;
+
+ if (x < ox || x >= ox + ow || y < oy || y >= oy + oh) continue;
+
+ int colIdx = Math.Clamp(i, 0, cells - 1);
+ int rowIdx = Math.Clamp(j, 0, cells - 1);
+ string col = ColumnLabel(colIdx);
+
var tb = new TextBlock
{
- Text = $"{col}{j}",
+ Text = $"{col}{rowIdx}",
Foreground = Brushes.White,
FontSize = 10,
Margin = new Thickness(2, 2, 0, 0),
@@ -73,9 +93,6 @@ private void RedrawGrid()
Padding = new Thickness(2, 0, 2, 0)
};
- double x = ox + i * step + 1;
- double y = oy + j * step + 1;
-
GridLayer.Children.Add(tb);
Canvas.SetLeft(tb, x);
Canvas.SetTop(tb, y);
@@ -101,11 +118,14 @@ private bool TryGetGridRef(double x, double y, out string label)
label = "";
if (_worldSizeS <= 0) return false;
+ double shiftedX = x - GridShiftX;
+ double shiftedY = y - GridShiftY;
+
int cells = Math.Max(1, (int)Math.Round(_worldSizeS / 150.0));
double cell = _worldSizeS / (double)cells;
- int col = Math.Clamp((int)Math.Floor(x / cell), 0, cells - 1);
- int row = Math.Clamp((int)Math.Floor((_worldSizeS - y) / cell), 0, cells - 1);
+ int col = Math.Clamp((int)Math.Floor(shiftedX / cell), 0, cells - 1);
+ int row = Math.Clamp((int)Math.Floor((_worldSizeS - shiftedY) / cell), 0, cells - 1);
label = $"{ColumnLabel(col)}{row}";
return true;
diff --git a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Interaction.cs b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Interaction.cs
index 150d274e..166c1553 100644
--- a/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Interaction.cs
+++ b/RustPlusDesktop/Views/MainWindow/Map/MainWindow.Map.Interaction.cs
@@ -167,6 +167,17 @@ private void WebViewHost_MouseDown(object? sender, MouseButtonEventArgs e)
var hostPos = e.GetPosition(WebViewHost);
var mapPos = HostToScenePreTransform(hostPos);
+ if (IsDragGridMode && e.ChangedButton == MouseButton.Left)
+ {
+ _isDraggingGrid = true;
+ _dragGridStartMapPos = mapPos;
+ _dragGridStartShiftX = GridShiftX;
+ _dragGridStartShiftY = GridShiftY;
+ WebViewHost.CaptureMouse();
+ e.Handled = true;
+ return;
+ }
+
if (_overlayToolsVisible && _currentTool != OverlayToolMode.None)
{
HandleOverlayMouseDown(e, mapPos);
@@ -211,6 +222,31 @@ private void WebViewHost_MouseMove(object? sender, MouseEventArgs e)
var hostPos = e.GetPosition(WebViewHost);
var mapPos = HostToScenePreTransform(hostPos);
+ if (IsDragGridMode && _isDraggingGrid)
+ {
+ double deltaX = mapPos.X - _dragGridStartMapPos.X;
+ double deltaY = mapPos.Y - _dragGridStartMapPos.Y;
+ if (_worldSizeS > 0 && _worldRectPx.Width > 0 && _worldRectPx.Height > 0)
+ {
+ double newShiftX = _dragGridStartShiftX + deltaX * (_worldSizeS / _worldRectPx.Width);
+ double newShiftY = _dragGridStartShiftY - deltaY * (_worldSizeS / _worldRectPx.Height);
+
+ newShiftX = Math.Clamp(newShiftX, -300.0, 300.0);
+ newShiftY = Math.Clamp(newShiftY, -300.0, 300.0);
+
+ newShiftX = Math.Round(newShiftX);
+ newShiftY = Math.Round(newShiftY);
+
+ GridShiftX = newShiftX;
+ GridShiftY = newShiftY;
+ ModRedrawGrid();
+
+ OnGridOffsetsDragged?.Invoke(newShiftX, newShiftY);
+ }
+ e.Handled = true;
+ return;
+ }
+
if (_overlayToolsVisible && _currentTool != OverlayToolMode.None)
{
HandleOverlayMouseMove(e, mapPos);
@@ -237,6 +273,14 @@ private void WebViewHost_MouseUp(object? sender, MouseButtonEventArgs e)
var hostPos = e.GetPosition(WebViewHost);
var mapPos = HostToScenePreTransform(hostPos);
+ if (IsDragGridMode && _isDraggingGrid && e.ChangedButton == MouseButton.Left)
+ {
+ _isDraggingGrid = false;
+ WebViewHost.ReleaseMouseCapture();
+ e.Handled = true;
+ return;
+ }
+
if (_overlayToolsVisible && _currentTool != OverlayToolMode.None)
{
HandleOverlayMouseUp(e, mapPos);
diff --git a/build.bat b/build.bat
new file mode 100644
index 00000000..9c57eb8c
--- /dev/null
+++ b/build.bat
@@ -0,0 +1,17 @@
+@echo off
+title Build Rust+ Desktop
+echo ==============================================
+echo Building Rust+ Desktop...
+echo ==============================================
+dotnet build
+if %errorlevel% neq 0 (
+ echo.
+ echo [ERROR] Build failed!
+ echo ==============================================
+ pause
+ exit /b %errorlevel%
+)
+echo.
+echo [SUCCESS] Build completed successfully!
+echo ==============================================
+pause
diff --git a/build_setup.bat b/build_setup.bat
new file mode 100644
index 00000000..c959e2ce
--- /dev/null
+++ b/build_setup.bat
@@ -0,0 +1,79 @@
+@echo off
+title Build Rust+ Desktop Installer
+cd /d "%~dp0"
+
+echo ==============================================
+echo 1. Publishing application in Release configuration...
+echo ==============================================
+dotnet publish "%~dp0RustPlusDesktop\RustPlusDesk.csproj" -c Release -r win-x64 --self-contained true -o "%~dp0RustPlusDesktop\bin\Installer\publish"
+if %errorlevel% neq 0 (
+ echo.
+ echo [ERROR] Dotnet publish failed!
+ echo ==============================================
+ pause
+ exit /b %errorlevel%
+)
+
+echo.
+echo ==============================================
+echo 2. Locating Inno Setup Compiler (ISCC)...
+echo ==============================================
+
+set "ISCC_PATH="
+
+:: Check common installation paths
+if exist "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" (
+ set "ISCC_PATH=C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
+) else if exist "C:\Program Files\Inno Setup 6\ISCC.exe" (
+ set "ISCC_PATH=C:\Program Files\Inno Setup 6\ISCC.exe"
+) else if exist "C:\Program Files (x86)\Inno Setup 5\ISCC.exe" (
+ set "ISCC_PATH=C:\Program Files (x86)\Inno Setup 5\ISCC.exe"
+) else if exist "C:\Program Files\Inno Setup 5\ISCC.exe" (
+ set "ISCC_PATH=C:\Program Files\Inno Setup 5\ISCC.exe"
+)
+
+:: If not found in standard paths, try checking PATH
+if "%ISCC_PATH%"=="" (
+ where iscc >nul 2>nul
+ if %errorlevel% eq 0 (
+ set "ISCC_PATH=iscc"
+ )
+)
+
+if "%ISCC_PATH%"=="" (
+ echo.
+ echo [WARNING] Inno Setup Compiler (ISCC.exe) was not found on your system.
+ echo.
+ echo To build the setup installer (RustPlusDesk-Setup.exe):
+ echo 1. Download and install Inno Setup 6 from:
+ echo https://jrsoftware.org/isdl.php
+ echo 2. Once installed, run this build_setup.bat script again, OR:
+ echo 3. Open "%~dp0RustPlusDesktop\Installer\Setup.iss" using the Inno Setup GUI and press F9.
+ echo.
+ echo NOTE: The compiled files are already ready in:
+ echo "%~dp0RustPlusDesktop\bin\Installer\publish\"
+ echo ==============================================
+ pause
+ exit /b 0
+)
+
+echo Found ISCC at: "%ISCC_PATH%"
+echo.
+echo ==============================================
+echo 3. Compiling Installer...
+echo ==============================================
+"%ISCC_PATH%" "%~dp0RustPlusDesktop\Installer\Setup.iss"
+if %errorlevel% neq 0 (
+ echo.
+ echo [ERROR] Inno Setup compilation failed!
+ echo ==============================================
+ pause
+ exit /b %errorlevel%
+)
+
+echo.
+echo ==============================================
+echo [SUCCESS] Installer generated successfully!
+echo Location: "%~dp0RustPlusDesktop\bin\Installer\RustPlusDesk-Setup.exe"
+echo ==============================================
+pause
diff --git a/modification/GridCustomizationMod.cs b/modification/GridCustomizationMod.cs
new file mode 100644
index 00000000..1b964df0
--- /dev/null
+++ b/modification/GridCustomizationMod.cs
@@ -0,0 +1,459 @@
+using System;
+using System.IO;
+using System.Text.Json;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using RustPlusDesk.Views;
+
+namespace RustPlusDesk.Modification
+{
+ public class GridCustomizationMod : IMod
+ {
+ public string Id => "GridCustomization";
+ public string Name => "Grid Customization";
+ public string Description => "Allows manual shifting of the coordinate grid horizontally (Left/Right) and vertically (Down/Up). " +
+ "The shift instantly affects the visual map grid lines and coordinate log/chat calculations.";
+
+ private bool _isEnabled = false;
+ public bool IsEnabled
+ {
+ get => _isEnabled;
+ set
+ {
+ _isEnabled = value;
+ ApplyOrResetGridShift();
+ }
+ }
+
+ private MainWindow? _mainWindow;
+ private double _shiftX = 0.0;
+ private double _shiftY = 0.0;
+
+ private Slider? _sliderX;
+ private Slider? _sliderY;
+ private TextBox? _txtXValue;
+ private TextBox? _txtYValue;
+ private bool _isUpdatingUIFromDrag = false;
+
+ private readonly string _settingsPath;
+
+ public GridCustomizationMod()
+ {
+ var appDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "RustPlusDesk");
+ _settingsPath = Path.Combine(appDir, "grid_settings.json");
+ LoadSettings();
+ }
+
+ public void Initialize(MainWindow mainWindow)
+ {
+ _mainWindow = mainWindow;
+ if (_isEnabled)
+ {
+ ApplyGridShift();
+ }
+ }
+
+ public void OnChatReceived(string text, string author, ulong steamId) { }
+ public void OnDeviceStateChanged(uint entityId, bool isOn, string kind) { }
+
+ private class GridSettings
+ {
+ public double ShiftX { get; set; }
+ public double ShiftY { get; set; }
+ }
+
+ private void LoadSettings()
+ {
+ try
+ {
+ if (File.Exists(_settingsPath))
+ {
+ var json = File.ReadAllText(_settingsPath);
+ var settings = JsonSerializer.Deserialize(json);
+ if (settings != null)
+ {
+ _shiftX = settings.ShiftX;
+ _shiftY = settings.ShiftY;
+ }
+ }
+ }
+ catch
+ {
+ // Ignore load failures
+ }
+ }
+
+ private void SaveSettings()
+ {
+ try
+ {
+ var settings = new GridSettings { ShiftX = _shiftX, ShiftY = _shiftY };
+ var json = JsonSerializer.Serialize(settings);
+ Directory.CreateDirectory(Path.GetDirectoryName(_settingsPath)!);
+ File.WriteAllText(_settingsPath, json);
+ }
+ catch
+ {
+ // Ignore save failures
+ }
+ }
+
+ private void ApplyGridShift()
+ {
+ if (_mainWindow != null)
+ {
+ _mainWindow.GridShiftX = _shiftX;
+ _mainWindow.GridShiftY = _shiftY;
+ _mainWindow.ModRedrawGrid();
+ }
+ }
+
+ private void ApplyOrResetGridShift()
+ {
+ if (_mainWindow == null) return;
+
+ if (_isEnabled)
+ {
+ _mainWindow.GridShiftX = _shiftX;
+ _mainWindow.GridShiftY = _shiftY;
+ }
+ else
+ {
+ _mainWindow.GridShiftX = 0.0;
+ _mainWindow.GridShiftY = 0.0;
+ _mainWindow.IsDragGridMode = false;
+ }
+ _mainWindow.ModRedrawGrid();
+ }
+
+ private void HandleGridOffsetsDragged(double newX, double newY)
+ {
+ if (_isUpdatingUIFromDrag) return;
+ _isUpdatingUIFromDrag = true;
+ try
+ {
+ _shiftX = newX;
+ _shiftY = newY;
+ if (_sliderX != null) _sliderX.Value = newX;
+ if (_sliderY != null) _sliderY.Value = newY;
+ if (_txtXValue != null) _txtXValue.Text = newX.ToString("F0");
+ if (_txtYValue != null) _txtYValue.Text = newY.ToString("F0");
+ SaveSettings();
+ }
+ finally
+ {
+ _isUpdatingUIFromDrag = false;
+ }
+ }
+
+ public FrameworkElement? GetConfigUI()
+ {
+ var root = new StackPanel { Margin = new Thickness(0, 10, 0, 0) };
+
+ // Direct Map Calibration (Drag Grid) Toggle
+ var toggleDragGrid = new Wpf.Ui.Controls.ToggleSwitch
+ {
+ Content = "Drag Grid on Map (Left Click & Drag)",
+ IsChecked = _mainWindow?.IsDragGridMode == true,
+ Margin = new Thickness(0, 0, 0, 20),
+ Foreground = Brushes.White
+ };
+ toggleDragGrid.Checked += (s, e) =>
+ {
+ if (_mainWindow != null)
+ {
+ _mainWindow.IsDragGridMode = true;
+ _mainWindow.ModLog("Direct Map Calibration enabled. Hold left mouse button on the map and drag the grid lines.");
+ }
+ };
+ toggleDragGrid.Unchecked += (s, e) =>
+ {
+ if (_mainWindow != null)
+ {
+ _mainWindow.IsDragGridMode = false;
+ _mainWindow.ModLog("Direct Map Calibration disabled.");
+ }
+ };
+ root.Children.Add(toggleDragGrid);
+
+ // Horizontal Shift Slider (Left/Right)
+ var lblX = new TextBlock
+ {
+ Text = "Horizontal Shift (meters)",
+ Foreground = Brushes.White,
+ FontWeight = FontWeights.SemiBold,
+ Margin = new Thickness(0, 0, 0, 5)
+ };
+ root.Children.Add(lblX);
+
+ var gridX = new Grid { Margin = new Thickness(0, 0, 0, 15) };
+ gridX.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ gridX.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(10) }); // space
+ gridX.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(80) }); // TextBox
+
+ _sliderX = new Slider
+ {
+ Minimum = -300,
+ Maximum = 300,
+ Value = _shiftX,
+ TickFrequency = 1,
+ IsSnapToTickEnabled = true,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ _sliderX.ValueChanged += (s, e) =>
+ {
+ if (_isUpdatingUIFromDrag) return;
+ _shiftX = Math.Round(e.NewValue);
+ if (_txtXValue != null)
+ {
+ _txtXValue.Text = _shiftX.ToString("F0");
+ }
+ if (_isEnabled)
+ {
+ SaveSettings();
+ ApplyGridShift();
+ }
+ };
+ Grid.SetColumn(_sliderX, 0);
+ gridX.Children.Add(_sliderX);
+
+ _txtXValue = new TextBox
+ {
+ Text = _shiftX.ToString("F0"),
+ Width = 70,
+ Height = 26,
+ VerticalAlignment = VerticalAlignment.Center,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ TextAlignment = TextAlignment.Center,
+ ToolTip = "Enter offset in meters manually. Use Up/Down arrows to step by 1.",
+ Background = new SolidColorBrush(Color.FromRgb(40, 40, 40)),
+ Foreground = Brushes.White,
+ BorderBrush = Brushes.Gray,
+ Padding = new Thickness(2)
+ };
+ _txtXValue.TextChanged += (s, e) =>
+ {
+ if (_isUpdatingUIFromDrag) return;
+ if (double.TryParse(_txtXValue.Text, out double val))
+ {
+ double clamped = Math.Clamp(val, -300.0, 300.0);
+ if (Math.Abs(_shiftX - clamped) > 0.001)
+ {
+ _shiftX = clamped;
+ if (_sliderX != null && Math.Abs(_sliderX.Value - clamped) > 0.001)
+ {
+ _sliderX.Value = clamped;
+ }
+ if (_isEnabled)
+ {
+ SaveSettings();
+ ApplyGridShift();
+ }
+ }
+ }
+ };
+ _txtXValue.LostFocus += (s, e) =>
+ {
+ _txtXValue.Text = _shiftX.ToString("F0");
+ };
+ _txtXValue.KeyDown += (s, e) =>
+ {
+ if (e.Key == Key.Enter)
+ {
+ UIElement element = (UIElement)s;
+ element.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
+ }
+ };
+ _txtXValue.PreviewKeyDown += (s, e) =>
+ {
+ if (e.Key == Key.Up)
+ {
+ double currentVal = _shiftX;
+ double newVal = Math.Clamp(currentVal + 1, -300.0, 300.0);
+ _txtXValue.Text = newVal.ToString("F0");
+ _txtXValue.CaretIndex = _txtXValue.Text.Length;
+ e.Handled = true;
+ }
+ else if (e.Key == Key.Down)
+ {
+ double currentVal = _shiftX;
+ double newVal = Math.Clamp(currentVal - 1, -300.0, 300.0);
+ _txtXValue.Text = newVal.ToString("F0");
+ _txtXValue.CaretIndex = _txtXValue.Text.Length;
+ e.Handled = true;
+ }
+ };
+ Grid.SetColumn(_txtXValue, 2);
+ gridX.Children.Add(_txtXValue);
+
+ root.Children.Add(gridX);
+
+ // Vertical Shift Slider (Down/Up)
+ var lblY = new TextBlock
+ {
+ Text = "Vertical Shift (meters)",
+ Foreground = Brushes.White,
+ FontWeight = FontWeights.SemiBold,
+ Margin = new Thickness(0, 0, 0, 5)
+ };
+ root.Children.Add(lblY);
+
+ var gridY = new Grid { Margin = new Thickness(0, 0, 0, 20) };
+ gridY.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ gridY.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(10) }); // space
+ gridY.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(80) }); // TextBox
+
+ _sliderY = new Slider
+ {
+ Minimum = -300,
+ Maximum = 300,
+ Value = _shiftY,
+ TickFrequency = 1,
+ IsSnapToTickEnabled = true,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ _sliderY.ValueChanged += (s, e) =>
+ {
+ if (_isUpdatingUIFromDrag) return;
+ _shiftY = Math.Round(e.NewValue);
+ if (_txtYValue != null)
+ {
+ _txtYValue.Text = _shiftY.ToString("F0");
+ }
+ if (_isEnabled)
+ {
+ SaveSettings();
+ ApplyGridShift();
+ }
+ };
+ Grid.SetColumn(_sliderY, 0);
+ gridY.Children.Add(_sliderY);
+
+ _txtYValue = new TextBox
+ {
+ Text = _shiftY.ToString("F0"),
+ Width = 70,
+ Height = 26,
+ VerticalAlignment = VerticalAlignment.Center,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ TextAlignment = TextAlignment.Center,
+ ToolTip = "Enter offset in meters manually. Use Up/Down arrows to step by 1.",
+ Background = new SolidColorBrush(Color.FromRgb(40, 40, 40)),
+ Foreground = Brushes.White,
+ BorderBrush = Brushes.Gray,
+ Padding = new Thickness(2)
+ };
+ _txtYValue.TextChanged += (s, e) =>
+ {
+ if (_isUpdatingUIFromDrag) return;
+ if (double.TryParse(_txtYValue.Text, out double val))
+ {
+ double clamped = Math.Clamp(val, -300.0, 300.0);
+ if (Math.Abs(_shiftY - clamped) > 0.001)
+ {
+ _shiftY = clamped;
+ if (_sliderY != null && Math.Abs(_sliderY.Value - clamped) > 0.001)
+ {
+ _sliderY.Value = clamped;
+ }
+ if (_isEnabled)
+ {
+ SaveSettings();
+ ApplyGridShift();
+ }
+ }
+ }
+ };
+ _txtYValue.LostFocus += (s, e) =>
+ {
+ _txtYValue.Text = _shiftY.ToString("F0");
+ };
+ _txtYValue.KeyDown += (s, e) =>
+ {
+ if (e.Key == Key.Enter)
+ {
+ UIElement element = (UIElement)s;
+ element.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
+ }
+ };
+ _txtYValue.PreviewKeyDown += (s, e) =>
+ {
+ if (e.Key == Key.Up)
+ {
+ double currentVal = _shiftY;
+ double newVal = Math.Clamp(currentVal + 1, -300.0, 300.0);
+ _txtYValue.Text = newVal.ToString("F0");
+ _txtYValue.CaretIndex = _txtYValue.Text.Length;
+ e.Handled = true;
+ }
+ else if (e.Key == Key.Down)
+ {
+ double currentVal = _shiftY;
+ double newVal = Math.Clamp(currentVal - 1, -300.0, 300.0);
+ _txtYValue.Text = newVal.ToString("F0");
+ _txtYValue.CaretIndex = _txtYValue.Text.Length;
+ e.Handled = true;
+ }
+ };
+ Grid.SetColumn(_txtYValue, 2);
+ gridY.Children.Add(_txtYValue);
+
+ root.Children.Add(gridY);
+
+ // Reset Button
+ var btnReset = new Button
+ {
+ Content = "Reset Offsets",
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Padding = new Thickness(15, 8, 15, 8)
+ };
+ btnReset.Click += (s, e) =>
+ {
+ _isUpdatingUIFromDrag = true;
+ try
+ {
+ _shiftX = 0.0;
+ _shiftY = 0.0;
+ if (_sliderX != null) _sliderX.Value = 0.0;
+ if (_sliderY != null) _sliderY.Value = 0.0;
+ if (_txtXValue != null) _txtXValue.Text = "0";
+ if (_txtYValue != null) _txtYValue.Text = "0";
+ if (_isEnabled)
+ {
+ SaveSettings();
+ ApplyGridShift();
+ }
+ }
+ finally
+ {
+ _isUpdatingUIFromDrag = false;
+ }
+ };
+ root.Children.Add(btnReset);
+
+ // Loaded & Unloaded Lifecycle Subscriptions
+ root.Loaded += (s, e) =>
+ {
+ if (_mainWindow != null)
+ {
+ _mainWindow.OnGridOffsetsDragged -= HandleGridOffsetsDragged;
+ _mainWindow.OnGridOffsetsDragged += HandleGridOffsetsDragged;
+ }
+ };
+ root.Unloaded += (s, e) =>
+ {
+ if (_mainWindow != null)
+ {
+ _mainWindow.IsDragGridMode = false;
+ _mainWindow.OnGridOffsetsDragged -= HandleGridOffsetsDragged;
+ }
+ };
+
+ return root;
+ }
+ }
+}
diff --git a/modification/IMod.cs b/modification/IMod.cs
new file mode 100644
index 00000000..32462a65
--- /dev/null
+++ b/modification/IMod.cs
@@ -0,0 +1,18 @@
+using System;
+using RustPlusDesk.Views;
+
+namespace RustPlusDesk.Modification
+{
+ public interface IMod
+ {
+ string Id { get; }
+ string Name { get; }
+ string Description { get; }
+ bool IsEnabled { get; set; }
+
+ void Initialize(MainWindow mainWindow);
+ void OnChatReceived(string text, string author, ulong steamId);
+ void OnDeviceStateChanged(uint entityId, bool isOn, string kind);
+ System.Windows.FrameworkElement? GetConfigUI();
+ }
+}
diff --git a/modification/MainWindow.Modifications.cs b/modification/MainWindow.Modifications.cs
new file mode 100644
index 00000000..2d0c284c
--- /dev/null
+++ b/modification/MainWindow.Modifications.cs
@@ -0,0 +1,123 @@
+using System;
+using System.Windows;
+using System.Windows.Controls;
+using System.Threading.Tasks;
+using RustPlusDesk.Models;
+using RustPlusDesk.Services;
+using RustPlusDesk.Modification;
+
+namespace RustPlusDesk.Views
+{
+ public partial class MainWindow
+ {
+ private ModManager? _modManager;
+
+ public void InitializeModifications()
+ {
+ _modManager = new ModManager(this, AppendLog);
+ _modManager.Initialize();
+
+ // Add the Mods tab to the main TabControl programmatically
+ Dispatcher.Invoke(() =>
+ {
+ try
+ {
+ var tabItem = new TabItem
+ {
+ Header = "π Mods",
+ Style = (Style)FindResource("PrettyTabItem")
+ };
+
+ // Create the UI content for the modifications tab
+ var grid = _modManager.CreateModsTabUI();
+ tabItem.Content = grid;
+
+ MainTabs.Items.Add(tabItem);
+ AppendLog("[ModSystem] Added 'Mods' tab to MainTabs.");
+ }
+ catch (Exception ex)
+ {
+ AppendLog($"[ModSystem] Error creating Mods UI tab: {ex.Message}");
+ }
+ });
+
+ // Register event handlers on the WebSocket client
+ if (_rust is RustPlusClientReal real)
+ {
+ real.DeviceStateEvent += async (id, isOn, kind) =>
+ {
+ await Dispatcher.InvokeAsync(() =>
+ {
+ _modManager.OnDeviceStateChanged(id, isOn, kind ?? "");
+ });
+ };
+
+ real.TeamChatReceived += async (sender, m) =>
+ {
+ await Dispatcher.InvokeAsync(() =>
+ {
+ _modManager.OnChatReceived(m.Text, m.Author, m.SteamId);
+ });
+ };
+ }
+ }
+
+ // Helper methods for modifications
+
+ public void ModLog(string message)
+ {
+ AppendLog($"[Mod] {message}");
+ }
+
+ public async Task ModToggleSwitchAsync(uint entityId, bool isOn)
+ {
+ try
+ {
+ if (!await EnsureConnectedAsync())
+ {
+ AppendLog($"[Mod] Cannot toggle switch #{entityId}: not connected.");
+ return;
+ }
+
+ AppendLog($"[Mod] Toggling switch #{entityId} to {(isOn ? "ON" : "OFF")}...");
+ await _rust.ToggleSmartSwitchAsync(entityId, isOn);
+
+ // Also update local device state in UI if it exists
+ var dev = FindDeviceById(_vm.Selected?.Devices, entityId);
+ if (dev != null)
+ {
+ _suppressToggleHandler = true;
+ dev.IsOn = isOn;
+ _suppressToggleHandler = false;
+ }
+ }
+ catch (Exception ex)
+ {
+ AppendLog($"[Mod] Failed to toggle switch #{entityId}: {ex.Message}");
+ }
+ }
+
+ public System.Collections.ObjectModel.ObservableCollection? ModGetDevices()
+ {
+ return _vm.Selected?.Devices;
+ }
+
+ public double GridShiftX { get; set; } = 0.0;
+ public double GridShiftY { get; set; } = 0.0;
+
+ public bool IsDragGridMode { get; set; } = false;
+ private bool _isDraggingGrid = false;
+ private Point _dragGridStartMapPos;
+ private double _dragGridStartShiftX;
+ private double _dragGridStartShiftY;
+ public event Action? OnGridOffsetsDragged;
+
+ public void ModRedrawGrid()
+ {
+ Dispatcher.Invoke(() =>
+ {
+ RedrawGrid();
+ });
+ }
+ }
+}
diff --git a/modification/ModManager.cs b/modification/ModManager.cs
new file mode 100644
index 00000000..398255b0
--- /dev/null
+++ b/modification/ModManager.cs
@@ -0,0 +1,276 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using RustPlusDesk.Views;
+
+namespace RustPlusDesk.Modification
+{
+ public class ModManager
+ {
+ private readonly MainWindow _mainWindow;
+ private readonly Action _logAction;
+ private readonly List _mods = new();
+ private readonly string _settingsPath;
+ private bool _isUpdatingUI;
+
+ public ModManager(MainWindow mainWindow, Action logAction)
+ {
+ _mainWindow = mainWindow;
+ _logAction = logAction;
+
+ var appDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "RustPlusDesk");
+ _settingsPath = Path.Combine(appDir, "modifications.json");
+ }
+
+ public void Initialize()
+ {
+ // Register mods here
+ _mods.Add(new SmartSwitchesMod());
+ _mods.Add(new GridCustomizationMod());
+
+ // Load saved settings
+ LoadSettings();
+
+ // Initialize all mods
+ foreach (var mod in _mods)
+ {
+ try
+ {
+ mod.Initialize(_mainWindow);
+ _logAction($"Loaded mod: {mod.Name} (Enabled: {mod.IsEnabled})");
+ }
+ catch (Exception ex)
+ {
+ _logAction($"Failed to initialize mod {mod.Name}: {ex.Message}");
+ }
+ }
+ }
+
+ public void OnChatReceived(string text, string author, ulong steamId)
+ {
+ foreach (var mod in _mods.Where(m => m.IsEnabled))
+ {
+ try
+ {
+ mod.OnChatReceived(text, author, steamId);
+ }
+ catch (Exception ex)
+ {
+ _logAction($"Error in mod {mod.Name} OnChatReceived: {ex.Message}");
+ }
+ }
+ }
+
+ public void OnDeviceStateChanged(uint entityId, bool isOn, string kind)
+ {
+ foreach (var mod in _mods.Where(m => m.IsEnabled))
+ {
+ try
+ {
+ mod.OnDeviceStateChanged(entityId, isOn, kind);
+ }
+ catch (Exception ex)
+ {
+ _logAction($"Error in mod {mod.Name} OnDeviceStateChanged: {ex.Message}");
+ }
+ }
+ }
+
+ private void LoadSettings()
+ {
+ try
+ {
+ if (!File.Exists(_settingsPath)) return;
+ var json = File.ReadAllText(_settingsPath);
+ var settings = JsonSerializer.Deserialize>(json);
+ if (settings == null) return;
+
+ foreach (var mod in _mods)
+ {
+ if (settings.TryGetValue(mod.Id, out bool isEnabled))
+ {
+ mod.IsEnabled = isEnabled;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logAction($"Failed to load mod settings: {ex.Message}");
+ }
+ }
+
+ public void SaveSettings()
+ {
+ try
+ {
+ var settings = _mods.ToDictionary(m => m.Id, m => m.IsEnabled);
+ var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true });
+ Directory.CreateDirectory(Path.GetDirectoryName(_settingsPath)!);
+ File.WriteAllText(_settingsPath, json);
+ }
+ catch (Exception ex)
+ {
+ _logAction($"Failed to save mod settings: {ex.Message}");
+ }
+ }
+
+ public Grid CreateModsTabUI()
+ {
+ var mainGrid = new Grid();
+ mainGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(280) });
+ mainGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+
+ // Left side list box
+ var listBox = new ListBox
+ {
+ Margin = new Thickness(10),
+ Background = Brushes.Transparent,
+ BorderThickness = new Thickness(0, 0, 1, 0),
+ BorderBrush = (Brush)Application.Current.FindResource("CardStrokeColorDefaultBrush")
+ };
+
+ // Right side detail panel
+ var detailPanel = new StackPanel
+ {
+ Margin = new Thickness(20),
+ Visibility = Visibility.Collapsed
+ };
+
+ var modTitle = new TextBlock
+ {
+ FontSize = 22,
+ FontWeight = FontWeights.Bold,
+ Foreground = Brushes.White,
+ Margin = new Thickness(0, 0, 0, 10)
+ };
+
+ var modDesc = new TextBlock
+ {
+ FontSize = 14,
+ Foreground = (Brush)Application.Current.FindResource("TextFillColorSecondaryBrush"),
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(0, 0, 0, 20)
+ };
+
+ var toggleSwitch = new Wpf.Ui.Controls.ToggleSwitch
+ {
+ Content = "Enable Modification",
+ Margin = new Thickness(0, 0, 0, 20)
+ };
+
+ var customUiContainer = new ContentControl
+ {
+ Margin = new Thickness(0, 10, 0, 0),
+ HorizontalContentAlignment = HorizontalAlignment.Stretch,
+ VerticalContentAlignment = VerticalAlignment.Stretch
+ };
+
+ detailPanel.Children.Add(modTitle);
+ detailPanel.Children.Add(modDesc);
+ detailPanel.Children.Add(toggleSwitch);
+ detailPanel.Children.Add(customUiContainer);
+
+ Grid.SetColumn(listBox, 0);
+ Grid.SetColumn(detailPanel, 1);
+
+ mainGrid.Children.Add(listBox);
+ mainGrid.Children.Add(detailPanel);
+
+ // Populate mods
+ foreach (var mod in _mods)
+ {
+ var itemGrid = new Grid { Margin = new Thickness(5) };
+ itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+
+ var nameText = new TextBlock
+ {
+ Text = mod.Name,
+ FontWeight = FontWeights.SemiBold,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ var statusText = new TextBlock
+ {
+ Text = mod.IsEnabled ? "Enabled" : "Disabled",
+ FontSize = 11,
+ Foreground = mod.IsEnabled ? Brushes.LimeGreen : Brushes.Gray,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(10, 0, 0, 0)
+ };
+
+ Grid.SetColumn(nameText, 0);
+ Grid.SetColumn(statusText, 1);
+ itemGrid.Children.Add(nameText);
+ itemGrid.Children.Add(statusText);
+
+ var listBoxItem = new ListBoxItem
+ {
+ Content = itemGrid,
+ Tag = mod,
+ Padding = new Thickness(10)
+ };
+
+ listBox.Items.Add(listBoxItem);
+ }
+
+ // Selection change logic
+ listBox.SelectionChanged += (s, e) =>
+ {
+ if (listBox.SelectedItem is ListBoxItem selectedItem && selectedItem.Tag is IMod selectedMod)
+ {
+ detailPanel.Visibility = Visibility.Visible;
+ modTitle.Text = selectedMod.Name;
+ modDesc.Text = selectedMod.Description;
+
+ _isUpdatingUI = true;
+ toggleSwitch.IsChecked = selectedMod.IsEnabled;
+ _isUpdatingUI = false;
+
+ customUiContainer.Content = selectedMod.GetConfigUI();
+ }
+ else
+ {
+ detailPanel.Visibility = Visibility.Collapsed;
+ customUiContainer.Content = null;
+ }
+ };
+
+ // Define toggle event handlers once
+ toggleSwitch.Checked += (ts, ev) =>
+ {
+ if (_isUpdatingUI) return;
+ HandleToggleChange(listBox, toggleSwitch, modTitle);
+ };
+ toggleSwitch.Unchecked += (ts, ev) =>
+ {
+ if (_isUpdatingUI) return;
+ HandleToggleChange(listBox, toggleSwitch, modTitle);
+ };
+
+ return mainGrid;
+ }
+
+ private void HandleToggleChange(ListBox listBox, Wpf.Ui.Controls.ToggleSwitch toggleSwitch, TextBlock modTitle)
+ {
+ if (listBox.SelectedItem is ListBoxItem selectedItem && selectedItem.Tag is IMod selectedMod)
+ {
+ selectedMod.IsEnabled = toggleSwitch.IsChecked == true;
+ SaveSettings();
+
+ // Update status text in list item
+ if (selectedItem.Content is Grid grid && grid.Children.OfType().LastOrDefault() is TextBlock statusTxt)
+ {
+ statusTxt.Text = selectedMod.IsEnabled ? "Enabled" : "Disabled";
+ statusTxt.Foreground = selectedMod.IsEnabled ? Brushes.LimeGreen : Brushes.Gray;
+ }
+
+ _logAction($"Mod {selectedMod.Name} toggled: {(selectedMod.IsEnabled ? "Enabled" : "Disabled")}");
+ }
+ }
+ }
+}
diff --git a/modification/README.md b/modification/README.md
new file mode 100644
index 00000000..8ee022d0
--- /dev/null
+++ b/modification/README.md
@@ -0,0 +1,21 @@
+# ΠΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°Π½ΠΈΠ΅ ΠΏΠ°ΠΏΠΊΠΈ modifications (Modifications Sharing Guide)
+
+ΠΠ°ΠΏΠΊΠ° `modification` ΡΠΎΠ΄Π΅ΡΠΆΠΈΡ Π²ΡΠ΅ ΡΠ°ΠΉΠ»Ρ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»ΡΡΠΊΠΈΡ
ΠΌΠΎΠ΄ΠΈΡΠΈΠΊΠ°ΡΠΈΠΉ Π΄Π»Ρ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ Rust+ Desktop. ΠΡ ΠΌΠΎΠΆΠ΅ΡΠ΅ Π»Π΅Π³ΠΊΠΎ Π΄Π΅Π»ΠΈΡΡΡΡ ΡΡΠΎΠΉ ΠΏΠ°ΠΏΠΊΠΎΠΉ Ρ Π΄ΡΡΠ³ΠΈΠΌΠΈ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»ΡΠΌΠΈ ΠΈΠ»ΠΈ ΠΏΠ΅ΡΠ΅Π½ΠΎΡΠΈΡΡ Π΅Ρ Π½Π° Π΄ΡΡΠ³ΠΈΠ΅ ΠΊΠΎΠΌΠΏΡΡΡΠ΅ΡΡ.
+
+## ΠΠ°ΠΊ ΠΈΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΡ ΠΏΠ°ΠΏΠΊΡ `modification`
+
+1. **Π‘ΠΊΠΎΠΏΠΈΡΡΠΉΡΠ΅ ΠΏΠ°ΠΏΠΊΡ**: ΠΡΠΏΡΠ°Π²ΡΡΠ΅ ΠΏΠ°ΠΏΠΊΡ `modification` ΡΠ΅Π»ΠΈΠΊΠΎΠΌ Π΄ΡΡΠ³ΠΎΠΌΡ ΡΠ΅Π»ΠΎΠ²Π΅ΠΊΡ ΠΈΠ»ΠΈ ΠΏΠ΅ΡΠ΅Π½Π΅ΡΠΈΡΠ΅ Π΅Ρ Π½Π° Π½ΠΎΠ²ΡΠΉ ΠΊΠΎΠΌΠΏΡΡΡΠ΅Ρ.
+2. **ΠΠΎΠΌΠ΅ΡΡΠΈΡΠ΅ Π² ΠΊΠΎΡΠ΅Π½Ρ ΠΏΡΠΎΠ΅ΠΊΡΠ°**: Π‘ΠΊΠΎΠΏΠΈΡΡΠΉΡΠ΅ ΠΏΠΎΠ»ΡΡΠ΅Π½Π½ΡΡ ΠΏΠ°ΠΏΠΊΡ `modification` Π² ΠΊΠΎΡΠ½Π΅Π²ΡΡ Π΄ΠΈΡΠ΅ΠΊΡΠΎΡΠΈΡ ΠΏΡΠΎΠ΅ΠΊΡΠ° `rustplus-desktop` (ΡΡΠ΄Π° ΠΆΠ΅, Π³Π΄Π΅ Π½Π°Ρ
ΠΎΠ΄ΡΡΡΡ ΡΠ°ΠΉΠ»Ρ `RustPlusDesk.sln` ΠΈ ΠΏΠ°ΠΏΠΊΠ° `RustPlusDesktop`).
+3. **Π‘Π±ΠΎΡΠΊΠ° ΠΏΡΠΎΠ΅ΠΊΡΠ°**:
+ - ΠΠ²Π°ΠΆΠ΄Ρ ΠΊΠ»ΠΈΠΊΠ½ΠΈΡΠ΅ ΠΏΠΎ ΡΠ°ΠΉΠ»Ρ `build.bat` Π² ΠΊΠΎΡΠ½Π΅ ΠΏΡΠΎΠ΅ΠΊΡΠ° ΠΈΠ»ΠΈ Π²ΡΠΏΠΎΠ»Π½ΠΈΡΠ΅ ΠΊΠΎΠΌΠ°Π½Π΄Ρ `dotnet build` Π² ΠΊΠΎΠ½ΡΠΎΠ»ΠΈ.
+ - ΠΡΠΎΠ΅ΠΊΡ Π°Π²ΡΠΎΠΌΠ°ΡΠΈΡΠ΅ΡΠΊΠΈ ΠΏΠΎΠ΄Ρ
Π²Π°ΡΠΈΡ Π²ΡΠ΅ `.cs` ΡΠ°ΠΉΠ»Ρ ΠΈΠ· ΠΏΠ°ΠΏΠΊΠΈ `modification` ΠΏΡΠΈ ΠΊΠΎΠΌΠΏΠΈΠ»ΡΡΠΈΠΈ Π±Π»Π°Π³ΠΎΠ΄Π°ΡΡ Π½Π°ΡΡΡΠΎΠ΅Π½Π½ΡΠΌ ΠΏΡΠ°Π²ΠΈΠ»Π°ΠΌ Π² ΡΠ°ΠΉΠ»Π΅ ΠΏΡΠΎΠ΅ΠΊΡΠ° `.csproj`.
+4. **ΠΠ°ΠΏΡΡΠΊ**:
+ - ΠΡΠΏΠΎΠ»ΡΠ·ΡΠΉΡΠ΅ ΡΠ°ΠΉΠ» `run.bat` Π² ΠΊΠΎΡΠ½Π΅ Π΄Π»Ρ Π·Π°ΠΏΡΡΠΊΠ° ΡΠΊΠΎΠΌΠΏΠΈΠ»ΠΈΡΠΎΠ²Π°Π½Π½ΠΎΠ³ΠΎ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ.
+
+## Π‘ΠΎΠ΄Π΅ΡΠΆΠΈΠΌΠΎΠ΅ ΠΏΠ°ΠΏΠΊΠΈ
+
+- `IMod.cs` β ΠΎΠ±ΡΠΈΠΉ ΠΈΠ½ΡΠ΅ΡΡΠ΅ΠΉΡ Π΄Π»Ρ ΡΠΎΠ·Π΄Π°Π½ΠΈΡ Π½ΠΎΠ²ΡΡ
ΠΌΠΎΠ΄ΠΈΡΠΈΠΊΠ°ΡΠΈΠΉ.
+- `ModManager.cs` β ΠΌΠ΅Π½Π΅Π΄ΠΆΠ΅Ρ, ΠΊΠΎΡΠΎΡΡΠΉ ΠΈΠ½ΠΈΡΠΈΠ°Π»ΠΈΠ·ΠΈΡΡΠ΅Ρ Π²ΡΠ΅ ΠΌΠΎΠ΄Ρ ΠΈ Π²ΡΠ²ΠΎΠ΄ΠΈΡ ΠΈΡ
Π² Π½ΠΎΠ²ΡΡ Π²ΠΊΠ»Π°Π΄ΠΊΡ `π Mods` Π² Π½Π°ΡΡΡΠΎΠΉΠΊΠ°Ρ
ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ.
+- `SmartSwitchesMod.cs` β ΠΌΠΎΠ΄ΠΈΡΠΈΠΊΠ°ΡΠΈΡ "Π£ΠΌΠ½ΡΠ΅ ΠΏΠ΅ΡΠ΅ΠΊΠ»ΡΡΠ°ΡΠ΅Π»ΠΈ" (Π°Π²ΡΠΎΠΌΠ°ΡΠΈΡΠ΅ΡΠΊΠΎΠ΅ Π²ΡΠΊΠ»ΡΡΠ΅Π½ΠΈΠ΅ ΠΏΠΎ ΡΠ°ΠΉΠΌΠ΅ΡΡ ΠΈΠ»ΠΈ ΠΏΠ΅ΡΠ΅ΠΊΠ»ΡΡΠ΅Π½ΠΈΠ΅ ΡΠ΅ΡΠ΅Π· ΡΠ°Ρ).
+- `GridCustomizationMod.cs` β ΠΌΠΎΠ΄ΠΈΡΠΈΠΊΠ°ΡΠΈΡ Π΄Π»Ρ ΡΡΡΠ½ΠΎΠΉ ΠΊΠ°Π»ΠΈΠ±ΡΠΎΠ²ΠΊΠΈ ΠΊΠΎΠΎΡΠ΄ΠΈΠ½Π°ΡΠ½ΠΎΠΉ ΡΠ΅ΡΠΊΠΈ (X/Y ΡΠΌΠ΅ΡΠ΅Π½ΠΈΠ΅).
+- `MainWindow.Modifications.cs` β ΡΠ°ΡΡΠΈΡΠ΅Π½ΠΈΠ΅ ΠΎΡΠ½ΠΎΠ²Π½ΠΎΠ³ΠΎ ΠΎΠΊΠ½Π° Π΄Π»Ρ ΠΈΠ½ΡΠ΅Π³ΡΠ°ΡΠΈΠΈ ΠΌΠΎΠ΄ΠΎΠ².
diff --git a/modification/SmartSwitchesMod.cs b/modification/SmartSwitchesMod.cs
new file mode 100644
index 00000000..6fb0baf0
--- /dev/null
+++ b/modification/SmartSwitchesMod.cs
@@ -0,0 +1,181 @@
+using System;
+using System.Collections.Concurrent;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using RustPlusDesk.Models;
+using RustPlusDesk.Views;
+
+namespace RustPlusDesk.Modification
+{
+ public class SmartSwitchesMod : IMod
+ {
+ public string Id => "SmartSwitches";
+ public string Name => "Smart Switches";
+ public string Description => "Enables advanced timer-based and chat-based controls for Rust smart switches. " +
+ "Rename your switch to 'timer(300)[off]' to turn it off 300 seconds after it is turned on. " +
+ "Or rename it to 'chat(!Turret)' to toggle it (ON <-> OFF) whenever that string is written in team chat.";
+
+ public bool IsEnabled { get; set; } = true;
+
+ private MainWindow? _mainWindow;
+
+ // Thread-safe dictionary to keep track of active timer tasks for cancellation
+ private readonly ConcurrentDictionary _timerCancellationTokens = new();
+
+ // Regex helpers
+ private static readonly Regex TimerRegex = new Regex(@"timer\s*\(\s*(\d+)\s*\)\s*\[\s*off\s*\]", RegexOptions.IgnoreCase);
+ private static readonly Regex ChatRegex = new Regex(@"chat\s*\(\s*([^)]+)\s*\)", RegexOptions.IgnoreCase);
+
+ public void Initialize(MainWindow mainWindow)
+ {
+ _mainWindow = mainWindow;
+ }
+
+ public void OnChatReceived(string text, string author, ulong steamId)
+ {
+ if (_mainWindow == null || string.IsNullOrWhiteSpace(text)) return;
+
+ var devices = _mainWindow.ModGetDevices();
+ if (devices == null) return;
+
+ var messageText = text.Trim();
+
+ // Find all smart switches matching chat(trigger)
+ foreach (var dev in FlattenDevices(devices))
+ {
+ if (dev.IsGroup || string.IsNullOrWhiteSpace(dev.Name)) continue;
+
+ // Verify it's a smart switch
+ bool isSmartSwitch = string.Equals(dev.Kind, "SmartSwitch", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(dev.Kind, "Smart Switch", StringComparison.OrdinalIgnoreCase);
+
+ if (!isSmartSwitch) continue;
+
+ var chatMatch = ChatRegex.Match(dev.Name);
+ if (chatMatch.Success)
+ {
+ var trigger = chatMatch.Groups[1].Value.Trim();
+
+ // Compare trigger (case-insensitive, trimmed)
+ if (string.Equals(messageText, trigger, StringComparison.OrdinalIgnoreCase))
+ {
+ // Toggle logic: if IsOn is true, turn OFF, otherwise turn ON
+ bool targetState = !(dev.IsOn == true);
+ _mainWindow.ModLog($"Chat triggered toggle for switch '{dev.Name}' (#{dev.EntityId}) by '{author}': new state = {(targetState ? "ON" : "OFF")}");
+
+ // Fire-and-forget toggle call
+ _ = _mainWindow.ModToggleSwitchAsync(dev.EntityId, targetState);
+ }
+ }
+ }
+ }
+
+ public void OnDeviceStateChanged(uint entityId, bool isOn, string kind)
+ {
+ if (_mainWindow == null) return;
+
+ var devices = _mainWindow.ModGetDevices();
+ if (devices == null) return;
+
+ // Find the device
+ var dev = FindDeviceById(devices, entityId);
+ if (dev == null || string.IsNullOrWhiteSpace(dev.Name)) return;
+
+ // If the switch turns OFF, cancel any pending turn-off timers
+ if (!isOn)
+ {
+ if (_timerCancellationTokens.TryRemove(entityId, out var cts))
+ {
+ _mainWindow.ModLog($"Switch '{dev.Name}' (#{entityId}) turned OFF. Cancelling pending auto-off timer.");
+ try { cts.Cancel(); } catch { }
+ cts.Dispose();
+ }
+ return;
+ }
+
+ // If the switch turns ON, check if it has a timer(X)[off] rule
+ var timerMatch = TimerRegex.Match(dev.Name);
+ if (timerMatch.Success)
+ {
+ if (int.TryParse(timerMatch.Groups[1].Value, out int seconds) && seconds > 0)
+ {
+ // Cancel any previous timer for the same switch
+ if (_timerCancellationTokens.TryRemove(entityId, out var oldCts))
+ {
+ try { oldCts.Cancel(); } catch { }
+ oldCts.Dispose();
+ }
+
+ var newCts = new CancellationTokenSource();
+ _timerCancellationTokens[entityId] = newCts;
+
+ _mainWindow.ModLog($"Switch '{dev.Name}' (#{entityId}) turned ON. Scheduling auto-off in {seconds} seconds.");
+
+ // Start background task to turn it off
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(seconds), newCts.Token);
+
+ if (!newCts.Token.IsCancellationRequested)
+ {
+ _mainWindow.ModLog($"Timer expired for switch '{dev.Name}' (#{entityId}). Turning OFF.");
+ await _mainWindow.ModToggleSwitchAsync(entityId, false);
+ }
+ }
+ catch (TaskCanceledException)
+ {
+ // Expected cancellation
+ }
+ catch (Exception ex)
+ {
+ _mainWindow.ModLog($"Error in timer task for switch #{entityId}: {ex.Message}");
+ }
+ finally
+ {
+ // Clean up dictionary if this CTS is still the active one
+ if (_timerCancellationTokens.TryGetValue(entityId, out var activeCts) && activeCts == newCts)
+ {
+ _timerCancellationTokens.TryRemove(entityId, out _);
+ }
+ newCts.Dispose();
+ }
+ });
+ }
+ }
+ }
+
+ // Helper to flatten hierarchical device lists (handling groups)
+ private System.Collections.Generic.IEnumerable FlattenDevices(System.Collections.IEnumerable devices)
+ {
+ foreach (var item in devices)
+ {
+ if (item is SmartDevice dev)
+ {
+ yield return dev;
+ if (dev.IsGroup && dev.Children != null)
+ {
+ foreach (var child in FlattenDevices(dev.Children))
+ {
+ yield return child;
+ }
+ }
+ }
+ }
+ }
+
+ // Helper to find a specific device by its Entity ID
+ private SmartDevice? FindDeviceById(System.Collections.IEnumerable devices, uint entityId)
+ {
+ return FlattenDevices(devices).FirstOrDefault(d => d.EntityId == entityId);
+ }
+
+ public System.Windows.FrameworkElement? GetConfigUI()
+ {
+ return null;
+ }
+ }
+}
diff --git a/run.bat b/run.bat
new file mode 100644
index 00000000..e0e86dc8
--- /dev/null
+++ b/run.bat
@@ -0,0 +1,13 @@
+@echo off
+title Run Rust+ Desktop
+echo ==============================================
+echo Running Rust+ Desktop...
+echo ==============================================
+dotnet run --project RustPlusDesktop\RustPlusDesk.csproj
+if %errorlevel% neq 0 (
+ echo.
+ echo [ERROR] Failed to run the project. Make sure it builds correctly first.
+ echo ==============================================
+ pause
+ exit /b %errorlevel%
+)