diff --git a/.gitignore b/.gitignore
index c218176a..23641831 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,4 +40,6 @@ installer/Output/*
!LICENSE
!docs/
!RustPlusDesktop/*.png
-!RustPlusDesktop/*.jpg
\ No newline at end of file
+!RustPlusDesktop/*.jpg
+RustPlusDesktop/RustPlusDesk_lbvqmzga_wpftmp.csproj
+RustPlusDesktop/Setup.iss
diff --git a/3.1.0.png b/3.1.0.png
new file mode 100644
index 00000000..2e178042
Binary files /dev/null and b/3.1.0.png differ
diff --git a/App-Version3.png b/App-Version3.png
new file mode 100644
index 00000000..51cb92fa
Binary files /dev/null and b/App-Version3.png differ
diff --git a/LICENSE.txt b/LICENSE
similarity index 100%
rename from LICENSE.txt
rename to LICENSE
diff --git a/README.md b/README.md
index 171d5c79..996b1648 100644
--- a/README.md
+++ b/README.md
@@ -1,96 +1,446 @@
+
+
+[](https://www.patreon.com/c/Pronwan)
+
+
# Rust+ Desktop App (Unofficial)
+
+
⚠️ **Note**: This is an **unofficial** project and is not affiliated with Facepunch Studios or the game *Rust*.
+
It is open source so anyone can verify there is **no malware or hidden components**.
+⚠️ **Note**: If you used it for a while and can't pair new servers anymore, simply click on the Pairing button with right mouse button and select to delete the config file.
+
---
+
## 🔍 What is this?
+
+
The **Rust+ Desktop App** is a Windows application built on the official Rust+ Companion API.
+
It lets you pair Rust servers, monitor in-game events, control Smart Devices, and view dynamic map markers — all on your PC.
+By now it's more than 'just' Rust Plus. It's Rust² you could say... That's why this is our new icon ;) Was about time.
+
+
The app ships as a single installer (bundling .NET, Node.js, WebView2 runtime, RustPlusAPI, etc.), so you don’t have to install dependencies manually.
+
+
---
+
+
## 🚀 Latest Release
+
+
➡️ **[Download the latest RustPlusDesk-Setup.exe](../../releases/latest)**
-*(We publish the signed/packaged installer as a GitHub Release asset for clean versioning and smaller repositories.)*
+
+*(I publish the signed/packaged installer as a GitHub Release asset for clean versioning and smaller repositories.)*
+
+[](https://youtu.be/tmbAn3lIKmM)
+*(click the image to watch on YouTube)*
+
+## Update 4.2 — Cargo Ship Overhaul (May 5th)
+
+
+**🚢 Smart Cargo Tracking**
+
+- Route Learning: After the first full Cargo cycle, the app remembers docking times, total map life, and trigger points — saved per server and map wipe, resets automatically after a wipe.
+- Docking Countdown: A live countdown appears below the Cargo Ship while it's anchored at harbor. Docking duration is learned per server; the default fallback is 8 minutes.
+- Remaining On-Map Timer: Once a full cycle is tracked, a remaining-time countdown is shown in the Event Dock and on the Cargo Ship marker.
+
+
+**💬 Cargo Chat Notifications**
+
+- Arrival Warning: Team chat alert ~5 minutes before Cargo docks at the next harbor (requires a learned route).
+- Docking Alert: Notification when Cargo anchors at a harbor.
+- Departure Warning: Notification 5 minutes before Cargo leaves.
+- All three can be toggled individually via right-click on the Chat Alerts button.
+
+**🛢️ Oil Rig Crate Countdown**
+- The app detects when a Chinook hovers over an Oil Rig and automatically starts a crate countdown on the map.
+
+
+
+## Update 4.1.0 - Crosshair Editor (Right Click Crosshair icon to access) (May 1st)
+
+**𖦏 Custom Crosshairs**
+- **Draw your own**: intuitive pixel-art style editor. Supports drawing tools (Pen, Pixel, Line, Square, Circle), custom colors, adjustable thickness and opacity, and full Undo/Redo support.
+- **Upload PNGs**: Upload existing PNG images to use as crosshairs. The editor automatically scales them to fit the pixel grid. You can also right-click to erase individual pixels and easily rename or delete your creations.
+
+---
+
+## Update 4.0.0 - The Evolution Update | Major Map & Stability Overhaul (April 30th)
+
+**🚀 Key Highlights**
+- **Rebuilt Core Architecture**: A massive refactoring of over 4,000 lines of code into a modular system, ensuring the app is faster and future-proof.
+- **"Dead Reckoning" Resilience**: Markers and shops no longer disappear during brief server lags. The app now uses predictive interpolation to keep player and event icons moving smoothly even when data is delayed.
+- **Interactive Event Dock**: A new real-time sidebar for active events (Patrol Heli, Cargo Ship, Chinook, etc.). Click any event to **auto-lock and track** it dynamically across the map.
+- **Smart Shop Clustering**: Multiple vending machines in one base are now grouped into clean cluster icons. Hovering over them reveals a redesigned, scrollable list of all items without map clutter.
+
+
+
+**🛠 Improvements**
+- **60 FPS Map Animations**: Butter-smooth zooming and panning with a new cinematic "Overview Dip" when jumping across the map.
+- **Modern Shop Search**: Powered by WebView2, the new search interface is near-instant and includes advanced arbitrage (Profit Trades) and pathfinding tools.
+- **Offline Icon Caching**: All item icons are now securely cached locally using SHA1 hashes, making map loads instant and saving massive bandwidth.
+- **Flexible UI**: Added a GridSplitter for a resizable sidebar and the ability to hide the system console to maximize map space.
+
+**🙌 Special Thanks**
+This milestone release was made possible by the incredible contribution of **[JawadYzbk](https://github.com/JawadYzbk)**, who rebuilt the core architecture and implemented the advanced map features!
---
+## Update 3.5.0 - Player Intelligence & Background Ops (April 22nd)
+**🚀 New Features**
+- **Advanced Activity Intelligence**: Introducing a full-scale player tracking system! View 12-week GitHub-style activity grids and 24-hour heatmaps to predict when your enemies (or friends) are most likely to be online or sleeping.
+- **Background Operations**: The app can now reside in your System Tray. Collect player data 24/7 without having the main window open.
+- **Single Instance Management**: Launching the app via `rustplus://` links or a second desktop shortcut now automatically focuses your already running instance.
+- **Auto-Start**: New option to launch the app minimized with Windows, so your tracking database is always up to date.
+
+**🛠 Improvements & Fixes**
+- **Battlemetrics Accuracy**: Completely overhauled server identification. Fixed an issue where servers on shared IP ranges (like Rustoria) were sometimes incorrectly identified.
+- **Tray Menu**: Dynamic tray context menu showing current tracking status and last update time.
+
+**🙌 Special Thanks**
+A massive shout-out to [JawadYzbk](https://github.com/JawadYzbk) for contributing this entire intelligence system and background logic!
+
+## Update 3.4.0 - Custom Alarms & Device Grouping (April 26)
+**🚀 New Features**
+- Customizable Smart Alarms: You can now set individual popup alerts and custom audio files. Perfect for turning up the volume and getting woken up specifically for Raids!
+- Smart Device Groups: Organize your setup by merging devices into groups. You can rename these groups and control multiple devices simultaneously with a single click (bringing the power of hotkeys to the UI).
+
+**🛠 Improvements**
+- Enhanced Team Uploads: Device uploads for team members now fully support hierarchical group structures. No matter how many devices you manage, everything stays organized and easy to navigate.
+
+## Update Notes 3.3.1 (February 16th 26)
+- **New Pre Deep Sea Notification:** Before Deep Sea is triggered, you can get a notification in Team Chat (around 3 minutes ahead of actual spawn) -> note that the direction will always be shown in West - this is not the actual spawn location. It's just coming from the fact that Deep Sea shops have negative X-coordinates.
+- **Stability Patch:** Even on weak servers the connection should now be more stable and smart devices should work more reliably. Reduced duplicate chat fetches, made shop search and shops more stable with caching icons to local drive.
+
+## Update Notes 3.3.0 (January 18th 26)
+- **New Oilrig Countdown:** When Oilrig is triggered, a crate icon with the remaining time appears on the map. Optional Team Chat notifications remind your team every 5 minutes until the crate unlocks.
+- **Leader Auto-Promote:** No more AFK leaders! Team members can now type `!leader` in chat to be instantly promoted to team leader (requires current leader to have the app open).
+
+## Update Notes 3.2.1 (November 21st 25)
+- You can now share Smart Devices with your team! No more pairing in-game needed.
+ One guy who pairs the devices is enough - rest of the team just imports with 1 click.
+
+## Update Notes 3.1.2 (November 17th 25)
+Version 3.1.2 brings full Storage Monitor integration and the following optimizations:
+
+- Shop alerts now also trigger when item was sold out and then comes back online
+- Storage Monitor shows traffic light upkeep indicator (from 1 hr. and less)
+- Map can be zoomed with NUM +/-
+- No duplicate chat notifications when server had been desynced for a short amount of time
+
+## Update Notes 3.0.0 (October 30th 25)
+- FULL Shop Analytics Overhaul!
+ This comes with instant check for profit trades, trade route check (Buy X for Y) and more
+- Map Overlay
+ You can draw, set markers, share your map markers with team mates
+- Shop Alarm system
+ Get alerts (in chat or audio alerts) when a new shop pops up or when a suspicious shop disappeared or when your desired item is back in stock
+- new Patch Notes Button with all new features explained
+
+... and more
+
+
+## Update Notes 2.0.5 (October 6th 25)
+- Global Device Hotkeys are here! Assign one key to multiple devices to group them together.
+- new Update Button (Bug: reads current version as 0.0.0 so it will always find an update - will be fixed in the future)
+- new Pairing possibility through Edge Browser + better Logs
+- Mini Map Overlay for ingame use
+- Crosshair Overlay
+- Team Management
+- Camera Support
+- Promoting Teammember to Leader
+- Death Markers
+- Grid Corrections
+- Notifications in Chat for Deaths, Spawns, Online, Offline
+- added fetching icon symbols from rusthelp.com (including Blueprint Fragments)
+
+
+
+
+
+Enjoy! :)
+---
+
+
+
## ✨ Features
+
+
- Pair Rust servers via Steam + Rust+ Companion
+- **Player Activity Intelligence: 12-week heatmaps & 24h activity forecasts**
+- **Persistent Background Tracking & System Tray integration**
+- **Single Instance Management (Named Pipes)**
+- Share Smart Devices and device groups with your Team
+- Track Storage Monitors and Upkeep Time
- Auto-start listener when connecting to a server
-- Dynamic map (Cargo, Patrol Heli, Airdrops, Players, …)
+- Dynamic map (Cargo, Patrol Heli, Chinook, Travelling Vendor, Players, …)
- Smart Devices (pair in-game while connected — shows up instantly)
-- Local storage of paired servers & devices
+- Local storage of paired servers & devices, map overlays
+- Vending Machine Search System for Buy and Sell orders
+- Profit Trade analytics and deep trade route search (buy X for Y)
- Open-source for transparency and trust
- Team Chat support and event spawn posts to chat
+- Camera Support (no pannable cams yet)
+- Mini Map and Crosshairs as Rust Overlay
+- Death Markers
+- Profile Icons
+- Chat-Notifications for spawns, shops, deaths, events and more
---
+
+
## 🐞 Known Issues
-- **Chat crash**: Opening the chat while not connected to a server will crash the app
+
- **Mixed languages**: Some UI texts may still show in German if a translation was missed
+
+- **Server-Hopping:**: Hopping through servers too quickly can cause the Listener to crash
+
+- **Many shops**: Hovering 8+ shops at once can cause the Tooltip to flicker
+
- Please report other issues in the [Issues section](../../issues)
---
+
+
## 🛠️ Installation & Setup
+
+
1. **Download & install**
+
- Get the installer from **[Releases](../../releases/latest)** and run it
-2. **First run**
- - A browser popup will ask you to authorize your Steam account
+
+
+2. **First run**
+
+ - Click Pairing (Listening) to start the initial setup of the Listener.
+ ⚠️ **IMPORTANT**: IF error message pops up, please restart the app, rightclick on the button and click on "Try Pairing with Edge".
+
+ - A browser popup will ask you to **pair with Companion** (Facepunch)
+
+ let it run until it's set up (needed only once)
+
+ - Click on "**Login with Steam**" and authorize your local PC to Steam (localhost)
+
- Allow the connection → your Steam account is linked
-3. **Pair a server**
+
+
+4. **Pair a server**
+
- In the app, click **Listening (Pairing)**
+
- In *Rust*, click the **Rust+ Pairing Link**
+
- The server will appear automatically in the app
-4. **Connect**
+
+
+5. **Connect**
+
- Select the server and click **Connect**
+
- Future sessions won’t require another Steam login
-5. **Smart Devices**
- - While connected, pair a device in-game → it appears instantly in the app
+6. **Smart Devices**
+
+ - While connected, pair a device or server in-game → it appears instantly in the app
+
+7. **If the FCM Listener won't start after a while of using the app**
+ - you probably have to do the Pairing Process again.
+ - Rightclick the Pairing button and select "Delete Config + Pair".
+ - That's it.
+
+8. **Alternative manuall pairing**
+ - You can do the pairing manually through PowerShell.
+
+ - Open PowerShell,
+ - Go to your installation folder (e.g. -> a: -> cd programs -> cd RustPlusDesk)
+ - Then copy paste this Power Shell code to the console. (Press enter twice) This should pair manually and open a popup in browser:
+
+$node = ".\runtime\node-win-x64\node.exe"
+$cli = "$env:LOCALAPPDATA\RustPlusDesk\runtime\rustplus-cli\node_modules\@liamcottle\rustplus.js\cli\index.js"
+$cfg = "$env:APPDATA\RustPlusDesk\rustplusjs-config.json"
+
+if (!(Test-Path $cli)) {
+ $zip = ".\runtime\rustplus-cli.zip"
+ $dst = "$env:LOCALAPPDATA\RustPlusDesk\runtime\rustplus-cli"
+ New-Item -ItemType Directory -Force -Path $dst | Out-Null
+ Expand-Archive -Path $zip -DestinationPath $dst -Force
+}
+
+& $node $cli fcm-register --config-file "$cfg"
+
+## 🛠️ Why initial NCM registration is required:
+
+ NCM Registration Explanation
+On first launch, the app needs to establish a connection to the Rust+ Companion API.
+For this, a bundled Node.js process (rustplus-cli) is started, which takes care of two things:
+
+**Registration with Facepunch/Steam**
+
+ - Opens a browser window to the official Rust+ Companion login page.
+
+ - After logging in with Steam, an auth token is generated and passed back to the app.
+
+ - This token is saved in the app’s config file so the process only needs to be done once per installation.
+
+**Local listener for callbacks and notifications**
+
+ - The Node process starts a small HTTP server on localhost: to receive the auth token.
+
+ - Afterwards, it continues running as a background listener to receive notifications (chat, alarms, events) via Google FCM and forward them to the app.
+
+**Requirements for successful registration**
+
+ - Node.js runtime and rustplus-cli are shipped with the app – no manual installation required.
+
+ - Firewall/Antivirus must not block the Node process:
+
+ - Local loopback (127.0.0.1) must be accessible for the callback port.
+
+**Outbound connections must be allowed on:**
+
+ - TCP 5228–5230 (Google FCM, mtalk.google.com)
+
+ - TCP 443 (HTTPS to Steam, Facepunch, Google)
+
+ - Browser redirect must be allowed (some security tools or proxies may block it).
+
+ - A valid Steam login is required to complete the auth flow.
+
+**👉 After successful registration, the token is stored at**
+%APPDATA%\RustPlusDesk\rustplusjs-config.json.
+You only need to re-register if this file is missing or corrupted.
+
+
+
+🔧 Troubleshooting Registration
+
+If the initial pairing does not work (no browser window opens, or it keeps restarting):
+
+- **Check if Node is running**
+ - Open *Task Manager* → *Details* → look for `node.exe`.
+ - Or run:
+ ```powershell
+ tasklist | findstr node.exe
+ ```
+
+- **Check if a local port is listening**
+ - Run:
+ ```powershell
+ netstat -ano | findstr LISTENING | findstr 127.0.0.1
+ ```
+ - You should see a `127.0.0.1:` entry with the same PID as `node.exe`.
+ - If not: Firewall or antivirus may be blocking the local callback server.
+
+- **Check outbound connections**
+ Test if the required ports are open:
+ ```powershell
+ Test-NetConnection mtalk.google.com -Port 5228
+ Test-NetConnection companion-rust.facepunch.com -Port 443
+ Test-NetConnection steamcommunity.com -Port 443
+ All should return TcpTestSucceeded : True
+- **Config reset**
+If all else fails, close the app and delete:
+%APPDATA%\RustPlusDesk\rustplusjs-config.json
+On next launch the registration will run again.
+
---
+
+
## 📸 Screenshots
-### Main Backgrounds
+
+
+### Main Screenshots
+

+

+

+

+

+

+

+

+
+
### Video Overview
+
[](https://www.youtube.com/watch?v=4NlFuLPK4wk)
+
*(click the image to watch on YouTube)*
+
+
---
+
+
## 📜 License
-Licensed under the [MIT License](./LICENSE).
-You may use, modify, and redistribute the software as long as the copyright notice is preserved.
+
+
+This project is licensed under the [GNU GPLv3](./LICENSE).
+
+SPDX-License-Identifier
+
+GPL-3.0-or-later
+
+
+
+## Release Checksum:
+
+SHA256-Hash von RustPlusDesk-Setup.exe:
+
+5991535374198c10a7e38748d5c698c5a69df8305ace397afc6d52fd479bf480
---
+
+
## 🙌 Contributing
+
+
Found a bug or want to help?
-Open an [Issue](../../issues) or create a Pull Request.
\ No newline at end of file
+
+Open an [Issue](../../issues) or create a Pull Request.
+
+
+
+## Support?
+
+
+
+Sure, why not :)
+
+**https://streamelements.com/pronwan/tip**
+
diff --git a/RustPlusDesktop/AlarmPopupWindow.xaml b/RustPlusDesktop/AlarmPopupWindow.xaml
index 6db4f436..a4333590 100644
--- a/RustPlusDesktop/AlarmPopupWindow.xaml
+++ b/RustPlusDesktop/AlarmPopupWindow.xaml
@@ -1,31 +1,113 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/RustPlusDesktop/AlarmPopupWindow.xaml.cs b/RustPlusDesktop/AlarmPopupWindow.xaml.cs
index 9a44dd0c..2e396e30 100644
--- a/RustPlusDesktop/AlarmPopupWindow.xaml.cs
+++ b/RustPlusDesktop/AlarmPopupWindow.xaml.cs
@@ -1,4 +1,4 @@
-using System.Collections.ObjectModel;
+using System.Collections.ObjectModel;
using System.Windows;
using RustPlusDesk.Models;
@@ -16,8 +16,33 @@ public AlarmWindow()
public void Add(AlarmNotification n)
{
- _items.Add(n);
- if (_items.Count > 0) List.ScrollIntoView(_items[^1]);
+ _items.Insert(0, n);
+ // Optional: Scroll zum neuesten Element am Anfang
+ List.ScrollIntoView(_items[0]);
+ }
+
+ public void UpdateOrAdd(AlarmNotification n)
+ {
+ for (int i = _items.Count - 1; i >= 0; i--)
+ {
+ // Wir aktualisieren nur Einträge, die noch die Standard-Nachricht haben
+ if (_items[i].Message == "Alarm activated!" && _items[i].Server == n.Server)
+ {
+ // Match, wenn IDs identisch ODER wenn einer von beiden keine ID hat (Fuzzy-Match für FCM ohne ID)
+ if (_items[i].EntityId == n.EntityId || _items[i].EntityId == null || n.EntityId == null)
+ {
+ _items[i] = n;
+ return;
+ }
+ }
+ }
+ Add(n);
+ }
+
+ private void Window_MouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
+ {
+ if (e.ChangedButton == System.Windows.Input.MouseButton.Left)
+ DragMove();
}
private void BtnClear_Click(object sender, RoutedEventArgs e) => _items.Clear();
diff --git a/RustPlusDesktop/App-Version2.png b/RustPlusDesktop/App-Version2.png
new file mode 100644
index 00000000..27b14138
Binary files /dev/null and b/RustPlusDesktop/App-Version2.png differ
diff --git a/RustPlusDesktop/App.xaml b/RustPlusDesktop/App.xaml
index 7a78854c..afa7f1c9 100644
--- a/RustPlusDesktop/App.xaml
+++ b/RustPlusDesktop/App.xaml
@@ -1,4 +1,760 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 10
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RustPlusDesktop/App.xaml.cs b/RustPlusDesktop/App.xaml.cs
index 9fbe7185..d52164c9 100644
--- a/RustPlusDesktop/App.xaml.cs
+++ b/RustPlusDesktop/App.xaml.cs
@@ -1,4 +1,4 @@
-using Microsoft.Win32;
+using Microsoft.Win32;
using System;
using System.IO;
using System.IO.Pipes;
@@ -7,6 +7,11 @@
using System.Threading.Tasks;
using System.Windows;
using RustPlusDesk.Views;
+using RustPlusDesk.Services;
+using System.Drawing;
+using System.Linq;
+using System.Windows.Forms;
+using Application = System.Windows.Application;
namespace RustPlusDesk;
@@ -17,6 +22,10 @@ public partial class App : Application
private const string PipeName = "RustPlusDeskLinkPipe";
private MainWindow? _main;
+ private System.Windows.Forms.NotifyIcon? _trayIcon;
+
+ [System.Runtime.InteropServices.DllImport("user32.dll")]
+ private static extern bool SetForegroundWindow(IntPtr hWnd);
protected override void OnStartup(StartupEventArgs e)
{
@@ -24,28 +33,130 @@ protected override void OnStartup(StartupEventArgs e)
EnsureUrlProtocolRegistered();
+ bool isBackgroundArg = e.Args.Contains("--background");
bool createdNew;
_single = new Mutex(initiallyOwned: true, name: SingleMutexName, createdNew: out createdNew);
if (!createdNew)
{
- // schon laufend → Link (falls vorhanden) an laufende Instanz schicken und beenden
+ // Already running
if (e.Args.Length > 0 && e.Args[0].StartsWith("rustplus://", StringComparison.OrdinalIgnoreCase))
_ = SendLinkToRunningInstanceAsync(e.Args[0]);
+ else if (!isBackgroundArg)
+ _ = SendCommandToRunningInstanceAsync("SHOWUI");
+
Shutdown();
return;
}
- // erste/laufende Instanz
- _main = new MainWindow();
- _main.Show();
+ SetupTrayIcon();
+
+ // Start polling if enabled and we have a server
+ if (TrackingService.IsBackgroundTrackingEnabled)
+ {
+ var (host, port, name) = TrackingService.LastServer;
+ if (!string.IsNullOrEmpty(host))
+ TrackingService.StartPolling(host, port, name);
+ }
+
+ if (isBackgroundArg && TrackingService.StartMinimizedEnabled)
+ {
+ // Started by Windows (auto-start) and minimized is enabled
+ if (e.Args.Length > 0 && e.Args[0].StartsWith("rustplus://", StringComparison.OrdinalIgnoreCase))
+ ShowMainWindow();
+ }
+ else
+ {
+ // Manual start by user, or auto-start with minimized disabled
+ ShowMainWindow();
+ }
- // Pipe-Server für zukünftige Links starten
_ = StartPipeServerAsync();
- // Falls diese Instanz selbst mit Link gestartet wurde: direkt verarbeiten
if (e.Args.Length > 0 && e.Args[0].StartsWith("rustplus://", StringComparison.OrdinalIgnoreCase))
- _main.HandleRustPlusLink(e.Args[0]);
+ _main?.HandleRustPlusLink(e.Args[0]);
+ }
+
+ private void ShowMainWindow()
+ {
+ if (_main == null)
+ {
+ _main = new MainWindow();
+ _main.Closed += (s, ev) => _main = null;
+ }
+ _main.Show();
+ _main.WindowState = WindowState.Normal;
+ _main.Activate();
+ _main.Topmost = true; _main.Topmost = false;
+ }
+
+ private void SetupTrayIcon()
+ {
+ _trayIcon = new System.Windows.Forms.NotifyIcon();
+ _trayIcon.Icon = System.Drawing.Icon.ExtractAssociatedIcon(System.Diagnostics.Process.GetCurrentProcess().MainModule!.FileName!);
+ _trayIcon.Text = "Rust+ Desk Tracker";
+ _trayIcon.Visible = true;
+
+ var menu = new System.Windows.Forms.ContextMenuStrip();
+
+ // Dynamic update on open
+ menu.Opening += (s, e) =>
+ {
+ menu.Items.Clear();
+ var status = TrackingService.IsTracking ? "Active" : "Idle";
+ var last = TrackingService.LastPullTime?.ToString("HH:mm:ss") ?? "--:--:--";
+
+ var statusItem = new System.Windows.Forms.ToolStripMenuItem($"Tracking: {status}");
+ statusItem.Enabled = false;
+ menu.Items.Add(statusItem);
+
+ var lastItem = new System.Windows.Forms.ToolStripMenuItem($"Last update: {last}");
+ lastItem.Enabled = false;
+ menu.Items.Add(lastItem);
+
+ menu.Items.Add(new System.Windows.Forms.ToolStripSeparator());
+ menu.Items.Add("Open Rust+ Desk", null, (s, ex) => ShowMainWindow());
+ menu.Items.Add(new System.Windows.Forms.ToolStripSeparator());
+ menu.Items.Add("Exit", null, (s, ex) => {
+ _trayIcon.Visible = false;
+ Current.Shutdown();
+ });
+ };
+
+ _trayIcon.MouseUp += (s, e) =>
+ {
+ if (e.Button == System.Windows.Forms.MouseButtons.Right)
+ {
+ // Ensure the window exists to provide a handle for focus management
+ if (_main == null)
+ {
+ _main = new MainWindow();
+ _main.Closed += (s, ev) => _main = null;
+ }
+
+ // This is a known fix for NotifyIcon context menus in WPF.
+ // It ensures the menu opens on the first click and closes when clicking away.
+ var handle = new System.Windows.Interop.WindowInteropHelper(_main).Handle;
+ SetForegroundWindow(handle);
+
+ menu.Show(System.Windows.Forms.Control.MousePosition);
+ }
+ };
+
+ _trayIcon.DoubleClick += (s, e) => ShowMainWindow();
+
+ // Also update tray tooltip periodically or on event
+ TrackingService.OnOnlinePlayersUpdated += () => {
+ var last = TrackingService.LastPullTime?.ToString("HH:mm:ss") ?? "--:--";
+ if (_trayIcon != null)
+ _trayIcon.Text = $"Rust+ Desk (Tracking {last})";
+ };
+ }
+
+ protected override void OnExit(ExitEventArgs e)
+ {
+ if (_trayIcon != null) _trayIcon.Visible = false;
+ base.OnExit(e);
}
private static void EnsureUrlProtocolRegistered()
@@ -63,19 +174,21 @@ private static void EnsureUrlProtocolRegistered()
catch { /* unkritisch */ }
}
- private static async Task SendLinkToRunningInstanceAsync(string link)
+ private static async Task SendCommandToRunningInstanceAsync(string cmd)
{
try
{
using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out);
await client.ConnectAsync(1500);
- var data = Encoding.UTF8.GetBytes(link + "\n");
+ var data = Encoding.UTF8.GetBytes(cmd + "\n");
await client.WriteAsync(data, 0, data.Length);
await client.FlushAsync();
}
- catch { /* wenn keiner lauscht: ignore */ }
+ catch { }
}
+ private static async Task SendLinkToRunningInstanceAsync(string link) => await SendCommandToRunningInstanceAsync(link);
+
private async Task StartPipeServerAsync()
{
while (true)
@@ -87,20 +200,25 @@ private async Task StartPipeServerAsync()
await server.WaitForConnectionAsync();
using var reader = new StreamReader(server, Encoding.UTF8);
var link = await reader.ReadLineAsync();
- if (!string.IsNullOrWhiteSpace(link) &&
- link.StartsWith("rustplus://", StringComparison.OrdinalIgnoreCase) &&
- _main != null)
+ if (!string.IsNullOrWhiteSpace(link) && _main != null)
{
_main.Dispatcher.Invoke(() =>
{
- // Fenster in den Vordergrund holen
- if (_main.WindowState == WindowState.Minimized) _main.WindowState = WindowState.Normal;
- _main.Activate();
- _main.Topmost = true; _main.Topmost = false;
-
- _main.HandleRustPlusLink(link);
+ if (link == "SHOWUI")
+ {
+ ShowMainWindow();
+ }
+ else if (link.StartsWith("rustplus://", StringComparison.OrdinalIgnoreCase))
+ {
+ ShowMainWindow();
+ _main.HandleRustPlusLink(link);
+ }
});
}
+ else if (link == "SHOWUI")
+ {
+ Dispatcher.Invoke(ShowMainWindow);
+ }
}
catch
{
diff --git a/RustPlusDesktop/BoolToVisibilityConverter.cs b/RustPlusDesktop/BoolToVisibilityConverter.cs
index 2d0790ec..015de550 100644
--- a/RustPlusDesktop/BoolToVisibilityConverter.cs
+++ b/RustPlusDesktop/BoolToVisibilityConverter.cs
@@ -12,11 +12,17 @@ public sealed class BoolToVisibilityConverter : IValueConverter
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var b = value is bool v && v;
- if (Invert) b = !b;
+
+ // Param-Unterstützung, ohne vorhandene Invert-Verwendungen zu brechen:
+ bool invert = Invert;
+ if (parameter is string s && s.Equals("invert", StringComparison.OrdinalIgnoreCase))
+ invert = !invert;
+
+ if (invert) b = !b;
return b ? Visibility.Visible : Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
- => (value is Visibility v && v == Visibility.Visible) ^ Invert;
+ => (value is Visibility vis && vis == Visibility.Visible) ^ Invert;
}
}
\ No newline at end of file
diff --git a/RustPlusDesktop/CameraFrame.cs b/RustPlusDesktop/CameraFrame.cs
new file mode 100644
index 00000000..8be53d84
--- /dev/null
+++ b/RustPlusDesktop/CameraFrame.cs
@@ -0,0 +1,38 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace RustPlusDesk.Views
+{
+ public sealed class CameraEntity
+ {
+ public int EntityId { get; }
+ public int Type { get; }
+ public ulong SteamId { get; } // <- ulong
+ public double X { get; }
+ public double Y { get; }
+ public double Z { get; }
+ public string Label { get; }
+
+ public CameraEntity(double x, double y, double z, string label, int entityId = 0, int type = 0, ulong steamId = 0)
+ {
+ X = x; Y = y; Z = z;
+ Label = label ?? "";
+ EntityId = entityId;
+ Type = type;
+ SteamId = steamId;
+ }
+ }
+
+ // Einheitlicher Frame-Typ mit optionalen Extras (Mime, Zeitstempel, Entities)
+ public sealed record CameraFrame(
+ byte[] Bytes,
+ string? Mime,
+ int Width,
+ int Height,
+ IReadOnlyList Entities
+);
+}
+
diff --git a/RustPlusDesktop/CameraWindow.xaml b/RustPlusDesktop/CameraWindow.xaml
new file mode 100644
index 00000000..53308cd9
--- /dev/null
+++ b/RustPlusDesktop/CameraWindow.xaml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RustPlusDesktop/CameraWindow.xaml.cs b/RustPlusDesktop/CameraWindow.xaml.cs
new file mode 100644
index 00000000..6dacfdec
--- /dev/null
+++ b/RustPlusDesktop/CameraWindow.xaml.cs
@@ -0,0 +1,268 @@
+using RustPlusDesk.Services;
+using RustPlusDesk.Services;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Shapes;
+using System.Windows.Threading;
+
+namespace RustPlusDesk.Views
+{
+
+
+ public partial class CameraWindow : Window
+ {
+ private readonly RustPlusClientReal _real;
+ private readonly string _cameraId;
+ private readonly DispatcherTimer _timer = new();
+ private bool _running;
+
+ private static readonly string[] EnvWords = { "tree", "bush", "ore", "stone", "hemp", "barrel", "crate", "rock", "node", "stump", "collectible" };
+
+ // candidate type ids that usually mean "player" (adjust after looking at the log)
+
+
+ // cache team names (by name) and ids (by steamId) to color labels
+ private readonly HashSet _teamNames = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet _teamSteamIds = new();
+
+ // candidate "player" type(s) based on your log: 2
+ private static readonly HashSet PlayerTypeIds = new() { 2 };
+
+
+ private async Task RefreshTeamAsync()
+ {
+ try
+ {
+ var team = await _real.GetTeamInfoAsync();
+ _teamNames.Clear();
+ _teamSteamIds.Clear();
+ if (team?.Members != null)
+ {
+ foreach (var m in team.Members)
+ {
+ if (!string.IsNullOrWhiteSpace(m.Name)) _teamNames.Add(m.Name!);
+ if (m.SteamId != 0) _teamSteamIds.Add(m.SteamId);
+ }
+ }
+ }
+ catch { /* ignore */ }
+ }
+
+ //------- FORMER DRAW METHOD FOR OVERLAY ELLYPSES -------
+ // private static (Brush fill, Brush stroke, double sizePx, bool showLabel) StyleFor(CameraEntity e)
+ // {
+ // Heuristik:
+ // if (e.IsPlayer)
+ // return (Brushes.LimeGreen, Brushes.Black, 10, true);
+
+ // Beispiele für andere Typen, falls du sie später mappen willst:
+ // type==3 -> Tiere, type==4 -> Turret (nur Beispiele!):
+ // if (e.Type == 4) // Turret?
+ // return (new SolidColorBrush(Color.FromArgb(220, 30, 144, 255)), Brushes.Black, 9, true); // blau
+ // if (e.Type == 3) // Tier/NPC?
+ // return (new SolidColorBrush(Color.FromArgb(220, 255, 140, 0)), Brushes.Black, 8, true); // orange
+
+ // Default: Umwelt → klein & halbtransparent, ohne Label
+ // return (new SolidColorBrush(Color.FromArgb(160, 255, 255, 255)), Brushes.Transparent, 6, true);
+ // }
+
+
+ public CameraWindow(RustPlusClientReal real, string cameraId)
+ {
+ InitializeComponent();
+ _real = real;
+ _cameraId = cameraId;
+ _real.CameraEntities += OnCameraEntities;
+ TxtId.Text = cameraId;
+
+
+ // FPS Dropdown
+ CmbFps.SelectionChanged += (_, __) => ApplyFps();
+
+ Loaded += async (_, __) =>
+ {
+ ApplyFps();
+ _running = true;
+ Overlay.SizeChanged += (_, __2) => DrawOverlay(); Img.SizeChanged += (_, __2) => DrawOverlay();
+
+ await RefreshTeamAsync();
+
+ // optional: erstes Bild
+ var first = await _real.GetCameraFrameViaNodeAsync(_cameraId, timeoutMs: 5000);
+
+ if (first?.Bytes != null) ShowFrame(first);
+
+ _timer.Tick += Timer_Tick;
+ _timer.Start();
+
+ // Thumbnails für diese Kamera pausieren (im MainWindow hast du _camBusy)
+ if (Owner is MainWindow mw) mw._camBusy.Add(_cameraId);
+ };
+
+ Closed += (_, __) =>
+ {
+ _running = false;
+ _timer.Stop();
+ _timer.Tick -= Timer_Tick;
+ if (Owner is MainWindow mw) mw._camBusy.Remove(_cameraId);
+ };
+
+ }
+
+ private IReadOnlyList _lastEnts = Array.Empty();
+ private int _lastW = 160, _lastH = 90; private double _lastVFovDeg = 65;
+
+ private void OnCameraEntities(string camId, double vFovDeg, int w, int h, List ents)
+ {
+ if (!string.Equals(camId, _cameraId, StringComparison.OrdinalIgnoreCase)) return;
+ _lastEnts = ents; _lastW = (w > 0 ? w : _lastW); _lastH = (h > 0 ? h : _lastH); _lastVFovDeg = (vFovDeg > 0 ? vFovDeg : _lastVFovDeg);
+
+ // Diagnose
+ System.Diagnostics.Debug.WriteLine($"[cam-ui] ents={ents.Count} vfov={_lastVFovDeg} size={_lastW}x{_lastH}");
+
+ Dispatcher.Invoke(DrawOverlay);
+ }
+
+ private void DrawOverlay()
+ {
+ Overlay.Children.Clear();
+ if (_lastEnts is null || _lastEnts.Count == 0 || Img.Source is null) return;
+
+ double viewW = Img.ActualWidth, viewH = Img.ActualHeight;
+ if (viewW <= 1 || viewH <= 1) return;
+
+ // Bild-auf-Canvas-Mapping (Uniform)
+ double scale = Math.Min(viewW / _lastW, viewH / _lastH);
+ double offX = (Overlay.ActualWidth - _lastW * scale) / 2.0;
+ double offY = (Overlay.ActualHeight - _lastH * scale) / 2.0;
+
+ // Projektions-FOV
+ double vf = _lastVFovDeg * Math.PI / 180.0;
+ double aspect = _lastW / (double)_lastH;
+ double hf = 2.0 * Math.Atan(Math.Tan(vf / 2.0) * aspect);
+
+ const double blobLiftPx = 12; // "optischer Lift", damit der Text auf der grauen Pille sitzt
+
+ foreach (var e in _lastEnts)
+ {
+ // nur Spieler labeln: (a) Type==2 (laut Log) ODER (b) Name vorhanden
+ bool isLikelyPlayer = PlayerTypeIds.Contains(e.Type) || !string.IsNullOrWhiteSpace(e.Label);
+ if (!isLikelyPlayer) continue;
+
+ if (e.Z <= 0.01) continue; // hinter der Kamera
+
+ // Pinhole-Projektion
+ double xndc = (e.X / e.Z) / Math.Tan(hf / 2.0);
+ double yndc = (e.Y / e.Z) / Math.Tan(vf / 2.0);
+ double u = (xndc * 0.5 + 0.5) * _lastW;
+ double v = (-yndc * 0.5 + 0.5) * _lastH;
+
+ if (u < -10 || u > _lastW + 10 || v < -10 || v > _lastH + 10) continue;
+
+ // Team-Farbe (Name oder SteamID)
+ bool isTeam = (e.SteamId != 0 && _teamSteamIds.Contains(e.SteamId))
+ || (!string.IsNullOrWhiteSpace(e.Label) && _teamNames.Contains(e.Label));
+ var brush = isTeam ? Brushes.LimeGreen : Brushes.OrangeRed;
+
+ // Anzeige-Text: nur Name, kein Fallback für Umwelt
+ var text = string.IsNullOrWhiteSpace(e.Label) ? "player" : e.Label;
+ if (!PlayerTypeIds.Contains(e.Type) && string.IsNullOrWhiteSpace(e.Label))
+ continue; // keine Umwelt beschriften
+
+ var tb = new TextBlock
+ {
+ Text = text,
+ Foreground = brush,
+ FontSize = 14,
+ Background = new SolidColorBrush(Color.FromArgb(120, 0, 0, 0)),
+ Padding = new Thickness(4, 1, 4, 1),
+ UseLayoutRounding = true,
+ SnapsToDevicePixels = true
+ };
+
+ // Größe messen, damit wir zentrieren können
+ tb.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+ var sz = tb.DesiredSize;
+
+ // Zentriert auf dem Blob, leicht nach oben gezogen
+ var x = offX + u * scale - sz.Width / 2.0;
+ var y = offY + (v - blobLiftPx) * scale - sz.Height / 2.0;
+
+ Overlay.Children.Add(tb);
+ Canvas.SetLeft(tb, x);
+ Canvas.SetTop(tb, y);
+ }
+ }
+ // einmalig:
+ // Loaded += (_,__) => { Overlay.SizeChanged += (_,__) => DrawOverlay(); Img.SizeChanged += (_,__) => DrawOverlay(); };
+
+
+
+ private void ApplyFps()
+ {
+ if (CmbFps.SelectedItem is ComboBoxItem it && int.TryParse(it.Content?.ToString(), out var fps) && fps > 0)
+ _timer.Interval = TimeSpan.FromMilliseconds(1000.0 / fps);
+ else
+ _timer.Interval = TimeSpan.FromMilliseconds(500); // default 2 FPS
+ }
+
+ private int _snapInFlight = 0;
+
+ private async void Timer_Tick(object? sender, EventArgs e)
+ {
+ if (!_running) return;
+ if (System.Threading.Interlocked.Exchange(ref _snapInFlight, 1) == 1) return;
+
+ try
+ {
+ var frame = await _real.GetCameraFrameViaNodeAsync(_cameraId, timeoutMs: 4000);
+ if (frame?.Bytes != null) ShowFrame(frame);
+ else TxtStatus.Text = "no frame";
+ }
+ catch (Exception ex)
+ {
+ TxtStatus.Text = ex.Message;
+ }
+ finally
+ {
+ System.Threading.Interlocked.Exchange(ref _snapInFlight, 0);
+ }
+ }
+
+ private void ShowFrame(CameraFrame frame)
+ {
+ try
+ {
+ var bi = new BitmapImage();
+ using var ms = new MemoryStream(frame.Bytes);
+ bi.BeginInit();
+ bi.CacheOption = BitmapCacheOption.OnLoad;
+ bi.StreamSource = ms;
+ bi.EndInit();
+ bi.Freeze();
+ Img.Source = bi;
+
+ TxtStatus.Text = (frame.Width > 0 && frame.Height > 0)
+ ? $"{frame.Width}×{frame.Height}"
+ : "snapshot";
+
+ // Wenn du dennoch eine einfache Fallback-Liste willst:
+ if ((_lastEnts == null || _lastEnts.Count == 0) && frame.Entities != null && frame.Entities.Count > 0)
+ {
+ _lastEnts = frame.Entities;
+ DrawOverlay();
+ }
+ }
+ catch { /* tolerant */ }
+ }
+
+ }
+}
diff --git a/RustPlusDesktop/ChatWindow.xaml b/RustPlusDesktop/ChatWindow.xaml
index 9973f8c3..595e906c 100644
--- a/RustPlusDesktop/ChatWindow.xaml
+++ b/RustPlusDesktop/ChatWindow.xaml
@@ -1,34 +1,74 @@
+ Title="Team-Chat"
+ Height="420"
+ Width="520"
+ WindowStartupLocation="CenterOwner"
+ Background="{DynamicResource Surface}"
+ Foreground="{DynamicResource TextPrimary}">
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+ Height="60"
+ Background="{DynamicResource AccentBrush}"
+ Foreground="White"
+ BorderThickness="0"
+ Click="BtnSend_Click">
+
+
-
+
\ No newline at end of file
diff --git a/RustPlusDesktop/Converters/ItemsCountConverter.cs b/RustPlusDesktop/Converters/ItemsCountConverter.cs
new file mode 100644
index 00000000..2a655416
--- /dev/null
+++ b/RustPlusDesktop/Converters/ItemsCountConverter.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Collections;
+using System.Globalization;
+using System.Windows.Data;
+
+namespace RustPlusDesk.Converters
+{
+ public sealed class ItemsCountConverter : IValueConverter
+ {
+ public object Convert(object value, Type t, object p, CultureInfo c)
+ {
+ if (value is System.Collections.IEnumerable e) { int n = 0; foreach (var _ in e) n++; return n; }
+ return 0;
+ }
+ public object ConvertBack(object v, Type t, object p, CultureInfo c) => Binding.DoNothing;
+ }
+}
\ No newline at end of file
diff --git a/RustPlusDesktop/Converters/KindToStorageVisibilityConverter.cs b/RustPlusDesktop/Converters/KindToStorageVisibilityConverter.cs
new file mode 100644
index 00000000..f7eca525
--- /dev/null
+++ b/RustPlusDesktop/Converters/KindToStorageVisibilityConverter.cs
@@ -0,0 +1,21 @@
+// using System;
+// using System.Globalization;
+// using System.Windows;
+// using System.Windows.Data;
+using System;
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+namespace RustPlusDesk.Converters {
+public sealed class KindToStorageVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type t, object p, CultureInfo c)
+ {
+ var k = value as string;
+ return (string.Equals(k, "StorageMonitor", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(k, "Storage Monitor", StringComparison.OrdinalIgnoreCase))
+ ? Visibility.Visible : Visibility.Collapsed;
+ }
+ public object ConvertBack(object v, Type t, object p, CultureInfo c) => Binding.DoNothing;
+}
+}
\ No newline at end of file
diff --git a/RustPlusDesktop/Converters/StorageChipConverter.cs b/RustPlusDesktop/Converters/StorageChipConverter.cs
new file mode 100644
index 00000000..aff0b4a7
--- /dev/null
+++ b/RustPlusDesktop/Converters/StorageChipConverter.cs
@@ -0,0 +1,62 @@
+using RustPlusDesk.Models;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Windows.Data;
+
+namespace RustPlusDesk.Converters
+{
+ public sealed class StorageChipConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is not StorageSnapshot snap)
+ return string.Empty;
+
+ // TC: immer Upkeep – egal ob 0 oder >0
+ if (snap.IsToolCupboard)
+ {
+ var secs = snap.UpkeepSeconds ?? 0;
+
+ // Sonderfall: 0 → mit Prefix "Upkeep: 0s"
+ if (secs <= 0)
+ return "Upkeep: 0s";
+
+ // Ab hier: nur noch kompakte Dauer ohne "Upkeep:"
+ int days = secs / 86400;
+ int rem = secs % 86400;
+ int hours = rem / 3600;
+ rem = rem % 3600;
+ int mins = rem / 60;
+ int secsLeft = rem % 60;
+
+ var parts = new List();
+
+ if (days > 0)
+ parts.Add($"{days}d");
+ if (hours > 0)
+ parts.Add($"{hours}h");
+ if (mins > 0)
+ parts.Add($"{mins}m");
+
+ // Falls alles auf 0, aber >0 Sekunden übrig, z. B. 45s
+ if (parts.Count == 0 && secsLeft > 0)
+ parts.Add($"{secsLeft}s");
+
+ // Sicherstellen, dass wir überhaupt etwas anzeigen
+ if (parts.Count == 0)
+ parts.Add("0s");
+
+ return string.Join(" ", parts);
+ }
+
+ // Alles andere: Boxen, Container → Items
+ var count = snap.ItemsCount;
+ return count == 1 ? "1 item" : $"{count} items";
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+ }
+}
+
diff --git a/RustPlusDesktop/Converters/UpkeepBackgroundConverter.cs b/RustPlusDesktop/Converters/UpkeepBackgroundConverter.cs
new file mode 100644
index 00000000..d61579a7
--- /dev/null
+++ b/RustPlusDesktop/Converters/UpkeepBackgroundConverter.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Globalization;
+using System.Windows.Data;
+using System.Windows.Media;
+using RustPlusDesk.Models; // falls SmartDevice hier liegt
+
+namespace RustPlusDesk.Converters
+{
+ using System;
+ using System.Globalization;
+ using System.Windows.Data;
+ using System.Windows.Media;
+ using RustPlusDesk.Models; // wo StorageSnapshot liegt
+
+ public class UpkeepBackgroundConverter : IValueConverter
+ {
+ private static readonly Brush DefaultBrush =
+ new SolidColorBrush(Color.FromArgb(0x33, 0x40, 0xA0, 0x70)); // dein #3340A070
+
+ private static readonly Brush OrangeBrush =
+ new SolidColorBrush(Color.FromArgb(0x66, 0xFF, 0xA5, 0x00)); // transparentes Orange
+
+ private static readonly Brush RedBrush =
+ new SolidColorBrush(Color.FromArgb(0x66, 0xFF, 0x44, 0x44)); // transparentes Rot
+
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ // Wir erwarten hier direkt den StorageSnapshot
+ if (value is not StorageSnapshot snap)
+ return DefaultBrush;
+
+ // Nur richtige TCs einfärben
+ if (!snap.IsToolCupboard)
+ return DefaultBrush;
+
+ var secs = snap.UpkeepSeconds ?? 0;
+
+ // 0 → Rot
+ if (secs <= 0)
+ return RedBrush;
+
+ // < 1 Stunde → Orange
+ if (secs < 3600)
+ return OrangeBrush;
+
+ // sonst Standard
+ return DefaultBrush;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ => Binding.DoNothing;
+ }
+}
\ No newline at end of file
diff --git a/RustPlusDesktop/Corel Auto-Preserve/TitleScreen-rust-plus-desktop.png b/RustPlusDesktop/Corel Auto-Preserve/TitleScreen-rust-plus-desktop.png
new file mode 100644
index 00000000..dec3274c
Binary files /dev/null and b/RustPlusDesktop/Corel Auto-Preserve/TitleScreen-rust-plus-desktop.png differ
diff --git a/RustPlusDesktop/Corel Auto-Preserve/TitleScreen-rust-plus-desktop2.png b/RustPlusDesktop/Corel Auto-Preserve/TitleScreen-rust-plus-desktop2.png
new file mode 100644
index 00000000..b791e5e5
Binary files /dev/null and b/RustPlusDesktop/Corel Auto-Preserve/TitleScreen-rust-plus-desktop2.png differ
diff --git a/RustPlusDesktop/Corel Auto-Preserve/V2-1.png b/RustPlusDesktop/Corel Auto-Preserve/V2-1.png
new file mode 100644
index 00000000..43012c59
Binary files /dev/null and b/RustPlusDesktop/Corel Auto-Preserve/V2-1.png differ
diff --git a/RustPlusDesktop/Corel Auto-Preserve/donate.png b/RustPlusDesktop/Corel Auto-Preserve/donate.png
new file mode 100644
index 00000000..c690741e
Binary files /dev/null and b/RustPlusDesktop/Corel Auto-Preserve/donate.png differ
diff --git a/RustPlusDesktop/CrosshairEditorWindow.xaml b/RustPlusDesktop/CrosshairEditorWindow.xaml
new file mode 100644
index 00000000..be04cb83
--- /dev/null
+++ b/RustPlusDesktop/CrosshairEditorWindow.xaml
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RustPlusDesktop/CrosshairEditorWindow.xaml.cs b/RustPlusDesktop/CrosshairEditorWindow.xaml.cs
new file mode 100644
index 00000000..cd8a67ac
--- /dev/null
+++ b/RustPlusDesktop/CrosshairEditorWindow.xaml.cs
@@ -0,0 +1,452 @@
+using System;
+using System.IO;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Shapes;
+
+namespace RustPlusDesk
+{
+ public partial class CrosshairEditorWindow : Window
+ {
+ private enum ToolType { Pixel, Pen, Line, Rectangle, Ellipse }
+
+ private ToolType _currentTool = ToolType.Pixel;
+ private Brush _currentBrush = Brushes.White;
+ private double CurrentThickness => ThicknessSlider.Value;
+ private double CurrentOpacity => OpacitySlider.Value;
+
+ private Point _startPoint;
+ private UIElement? _currentShape;
+ private Polyline? _currentPolyline;
+ private bool _isErasing = false;
+ private System.Collections.Generic.HashSet _currentPixelStroke = new();
+
+ private System.Collections.Generic.Stack> _undoStack = new();
+ private System.Collections.Generic.List _currentStrokeElements = new();
+
+ private WriteableBitmap? _bgBitmap;
+
+ public CustomCrosshair? SavedCrosshair { get; private set; }
+ private CustomCrosshair? _editingCrosshair;
+
+ public CrosshairEditorWindow(CustomCrosshair? cc = null)
+ {
+ InitializeComponent();
+ _editingCrosshair = cc;
+ if (cc != null && !string.IsNullOrEmpty(cc.Base64Image))
+ {
+ try
+ {
+ byte[] bytes = Convert.FromBase64String(cc.Base64Image);
+ var bitmap = new BitmapImage();
+ bitmap.BeginInit();
+ bitmap.CacheOption = BitmapCacheOption.OnLoad;
+ bitmap.StreamSource = new MemoryStream(bytes);
+ bitmap.EndInit();
+ bitmap.Freeze();
+
+ _bgBitmap = new WriteableBitmap(new FormatConvertedBitmap(bitmap, PixelFormats.Pbgra32, null, 0));
+ BackgroundImage.Source = _bgBitmap;
+ }
+ catch { }
+ }
+ }
+
+ private void Tool_Checked(object sender, RoutedEventArgs e)
+ {
+ if (sender is RadioButton rb && rb.Tag is string tag)
+ {
+ if (Enum.TryParse(tag, out var tool))
+ {
+ _currentTool = tool;
+ }
+ }
+ }
+
+ private void Color_Checked(object sender, RoutedEventArgs e)
+ {
+ if (sender is RadioButton rb && rb.Tag is string tag)
+ {
+ try
+ {
+ var converter = new BrushConverter();
+ _currentBrush = (Brush?)converter.ConvertFromString(tag) ?? Brushes.White;
+ }
+ catch
+ {
+ _currentBrush = Brushes.White;
+ }
+ }
+ }
+
+ private void BtnClear_Click(object sender, RoutedEventArgs e)
+ {
+ DrawCanvas.Children.Clear();
+ BackgroundImage.Source = null;
+ _bgBitmap = null;
+ _undoStack.Clear();
+ }
+
+ private void BtnUpload_Click(object sender, RoutedEventArgs e)
+ {
+ var dlg = new Microsoft.Win32.OpenFileDialog
+ {
+ Filter = "PNG Image (*.png)|*.png|All files (*.*)|*.*"
+ };
+
+ if (dlg.ShowDialog() == true)
+ {
+ try
+ {
+ var bitmap = new BitmapImage(new Uri(dlg.FileName));
+ var rtb = new RenderTargetBitmap(64, 64, 96, 96, PixelFormats.Pbgra32);
+ var visual = new DrawingVisual();
+ using (var context = visual.RenderOpen())
+ {
+ context.DrawImage(bitmap, new Rect(0, 0, 64, 64));
+ }
+ rtb.Render(visual);
+
+ _bgBitmap = new WriteableBitmap(rtb);
+ BackgroundImage.Source = _bgBitmap;
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"Could not load image: {ex.Message}");
+ }
+ }
+ }
+
+ private void DrawCanvas_MouseDown(object sender, MouseButtonEventArgs e)
+ {
+ if (e.LeftButton != MouseButtonState.Pressed && e.RightButton != MouseButtonState.Pressed) return;
+
+ _startPoint = e.GetPosition(DrawCanvas);
+ DrawCanvas.CaptureMouse();
+ _currentStrokeElements = new System.Collections.Generic.List();
+
+ if (_currentTool == ToolType.Pixel)
+ {
+ _isErasing = e.RightButton == MouseButtonState.Pressed;
+ _currentPixelStroke.Clear();
+ if (_isErasing)
+ ErasePixelAt(_startPoint);
+ else
+ DrawPixelAt(_startPoint);
+ }
+ else
+ {
+ if (e.LeftButton != MouseButtonState.Pressed)
+ {
+ DrawCanvas.ReleaseMouseCapture();
+ return;
+ }
+
+ switch (_currentTool)
+ {
+ case ToolType.Pen:
+ _currentPolyline = new Polyline
+ {
+ Stroke = _currentBrush,
+ StrokeThickness = CurrentThickness,
+ Opacity = CurrentOpacity,
+ StrokeStartLineCap = PenLineCap.Round,
+ StrokeEndLineCap = PenLineCap.Round,
+ StrokeLineJoin = PenLineJoin.Round
+ };
+ _currentPolyline.Points.Add(_startPoint);
+ _currentShape = _currentPolyline;
+ DrawCanvas.Children.Add(_currentShape);
+ _currentStrokeElements.Add(_currentShape);
+ break;
+ case ToolType.Line:
+ _currentShape = new Line
+ {
+ Stroke = _currentBrush,
+ StrokeThickness = CurrentThickness,
+ Opacity = CurrentOpacity,
+ X1 = _startPoint.X,
+ Y1 = _startPoint.Y,
+ X2 = _startPoint.X,
+ Y2 = _startPoint.Y,
+ StrokeStartLineCap = PenLineCap.Round,
+ StrokeEndLineCap = PenLineCap.Round
+ };
+ DrawCanvas.Children.Add(_currentShape);
+ _currentStrokeElements.Add(_currentShape);
+ break;
+ case ToolType.Rectangle:
+ _currentShape = new Rectangle
+ {
+ Stroke = _currentBrush,
+ StrokeThickness = CurrentThickness,
+ Opacity = CurrentOpacity,
+ Width = 0,
+ Height = 0
+ };
+ Canvas.SetLeft(_currentShape, _startPoint.X);
+ Canvas.SetTop(_currentShape, _startPoint.Y);
+ DrawCanvas.Children.Add(_currentShape);
+ _currentStrokeElements.Add(_currentShape);
+ break;
+ case ToolType.Ellipse:
+ _currentShape = new Ellipse
+ {
+ Stroke = _currentBrush,
+ StrokeThickness = CurrentThickness,
+ Opacity = CurrentOpacity,
+ Width = 0,
+ Height = 0
+ };
+ Canvas.SetLeft(_currentShape, _startPoint.X);
+ Canvas.SetTop(_currentShape, _startPoint.Y);
+ DrawCanvas.Children.Add(_currentShape);
+ _currentStrokeElements.Add(_currentShape);
+ break;
+ }
+ }
+ }
+
+ private void DrawCanvas_MouseMove(object sender, MouseEventArgs e)
+ {
+ if (!DrawCanvas.IsMouseCaptured) return;
+ if (_currentTool != ToolType.Pixel && _currentShape == null) return;
+
+ var pos = e.GetPosition(DrawCanvas);
+
+ switch (_currentTool)
+ {
+ case ToolType.Pixel:
+ if (_isErasing)
+ ErasePixelAt(pos);
+ else
+ DrawPixelAt(pos);
+ break;
+ case ToolType.Pen:
+ if (_currentPolyline != null)
+ {
+ _currentPolyline.Points.Add(pos);
+ }
+ break;
+ case ToolType.Line:
+ if (_currentShape is Line line)
+ {
+ line.X2 = pos.X;
+ line.Y2 = pos.Y;
+ }
+ break;
+ case ToolType.Rectangle:
+ if (_currentShape is Rectangle rect)
+ {
+ var x = Math.Min(pos.X, _startPoint.X);
+ var y = Math.Min(pos.Y, _startPoint.Y);
+ var w = Math.Abs(pos.X - _startPoint.X);
+ var h = Math.Abs(pos.Y - _startPoint.Y);
+
+ // Hold shift for square
+ if (Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift))
+ {
+ var side = Math.Max(w, h);
+ w = h = side;
+ x = _startPoint.X < pos.X ? _startPoint.X : _startPoint.X - side;
+ y = _startPoint.Y < pos.Y ? _startPoint.Y : _startPoint.Y - side;
+ }
+
+ Canvas.SetLeft(rect, x);
+ Canvas.SetTop(rect, y);
+ rect.Width = w;
+ rect.Height = h;
+ }
+ break;
+ case ToolType.Ellipse:
+ if (_currentShape is Ellipse ellipse)
+ {
+ var x = Math.Min(pos.X, _startPoint.X);
+ var y = Math.Min(pos.Y, _startPoint.Y);
+ var w = Math.Abs(pos.X - _startPoint.X);
+ var h = Math.Abs(pos.Y - _startPoint.Y);
+
+ // Hold shift for perfect circle
+ if (Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift))
+ {
+ var side = Math.Max(w, h);
+ w = h = side;
+ x = _startPoint.X < pos.X ? _startPoint.X : _startPoint.X - side;
+ y = _startPoint.Y < pos.Y ? _startPoint.Y : _startPoint.Y - side;
+ }
+
+ Canvas.SetLeft(ellipse, x);
+ Canvas.SetTop(ellipse, y);
+ ellipse.Width = w;
+ ellipse.Height = h;
+ }
+ break;
+ }
+ }
+
+ private void DrawCanvas_MouseUp(object sender, MouseButtonEventArgs e)
+ {
+ if (DrawCanvas.IsMouseCaptured)
+ {
+ DrawCanvas.ReleaseMouseCapture();
+ }
+ if (_currentStrokeElements.Count > 0)
+ {
+ _undoStack.Push(new System.Collections.Generic.List(_currentStrokeElements));
+ // Optionally limit undo stack size
+ if (_undoStack.Count > 50)
+ {
+ // keep simple, no strict removal needed or just let it grow.
+ }
+ }
+ _currentShape = null;
+ _currentPolyline = null;
+ _currentPixelStroke.Clear();
+ _currentStrokeElements.Clear();
+ _isErasing = false;
+ }
+
+ private void Window_KeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Z && (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control)
+ {
+ if (_undoStack.Count > 0)
+ {
+ var lastStroke = _undoStack.Pop();
+ foreach (var el in lastStroke)
+ {
+ DrawCanvas.Children.Remove(el);
+ }
+ }
+ }
+ }
+
+ private void DrawPixelAt(Point pos)
+ {
+ double x = Math.Floor(pos.X / 8.0) * 8.0;
+ double y = Math.Floor(pos.Y / 8.0) * 8.0;
+ var pt = new Point(x, y);
+
+ if (_currentPixelStroke.Add(pt))
+ {
+ var rect = new Rectangle
+ {
+ Fill = _currentBrush,
+ Width = 8,
+ Height = 8,
+ Opacity = CurrentOpacity
+ };
+ Canvas.SetLeft(rect, x);
+ Canvas.SetTop(rect, y);
+ DrawCanvas.Children.Add(rect);
+ _currentStrokeElements.Add(rect);
+ }
+ }
+
+ private void ErasePixelAt(Point pos)
+ {
+ double x = Math.Floor(pos.X / 8.0) * 8.0;
+ double y = Math.Floor(pos.Y / 8.0) * 8.0;
+ var pt = new Point(x, y);
+
+ if (_currentPixelStroke.Add(pt))
+ {
+ var toRemove = new System.Collections.Generic.List();
+ foreach (UIElement child in DrawCanvas.Children)
+ {
+ if (child is Rectangle r && Canvas.GetLeft(r) == x && Canvas.GetTop(r) == y)
+ {
+ toRemove.Add(child);
+ }
+ }
+ foreach (var el in toRemove)
+ {
+ DrawCanvas.Children.Remove(el);
+ }
+
+ if (_bgBitmap != null)
+ {
+ int bx = (int)(x / 8.0);
+ int by = (int)(y / 8.0);
+ if (bx >= 0 && bx < _bgBitmap.PixelWidth && by >= 0 && by < _bgBitmap.PixelHeight)
+ {
+ byte[] emptyPixels = new byte[4];
+ _bgBitmap.WritePixels(new Int32Rect(bx, by, 1, 1), emptyPixels, 4, 0);
+ }
+ }
+ }
+ }
+
+ private void BtnCancel_Click(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ Close();
+ }
+
+ private void BtnSave_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ // Render Canvas to 64x64 image
+ var targetWidth = 64;
+ var targetHeight = 64;
+
+ var visual = new DrawingVisual();
+ using (var context = visual.RenderOpen())
+ {
+ var brush = new VisualBrush(RenderGrid);
+ context.DrawRectangle(brush, null, new Rect(0, 0, targetWidth, targetHeight));
+ }
+
+ var rtb = new RenderTargetBitmap(targetWidth, targetHeight, 96, 96, PixelFormats.Pbgra32);
+ rtb.Render(visual);
+
+ var encoder = new PngBitmapEncoder();
+ encoder.Frames.Add(BitmapFrame.Create(rtb));
+
+ string base64;
+ using (var ms = new MemoryStream())
+ {
+ encoder.Save(ms);
+ base64 = Convert.ToBase64String(ms.ToArray());
+ }
+
+ var list = CustomCrosshairManager.LoadCrosshairs();
+ var newIndex = list.Count + 1;
+
+ var ch = new CustomCrosshair
+ {
+ Id = _editingCrosshair?.Id ?? Guid.NewGuid().ToString(),
+ Name = _editingCrosshair?.Name ?? $"CUSTOM {newIndex}",
+ Base64Image = base64
+ };
+
+ if (_editingCrosshair != null)
+ {
+ var existingIdx = list.FindIndex(c => c.Id == _editingCrosshair.Id);
+ if (existingIdx >= 0)
+ list[existingIdx] = ch;
+ else
+ list.Add(ch);
+ }
+ else
+ {
+ list.Add(ch);
+ }
+
+ CustomCrosshairManager.SaveCrosshairs(list);
+
+ SavedCrosshair = ch;
+ DialogResult = true;
+ Close();
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"Error saving crosshair: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+ }
+}
diff --git a/RustPlusDesktop/CrosshairWindow.xaml b/RustPlusDesktop/CrosshairWindow.xaml
new file mode 100644
index 00000000..4e6db4cd
--- /dev/null
+++ b/RustPlusDesktop/CrosshairWindow.xaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/RustPlusDesktop/CrosshairWindow.xaml.cs b/RustPlusDesktop/CrosshairWindow.xaml.cs
new file mode 100644
index 00000000..e9a4c8b0
--- /dev/null
+++ b/RustPlusDesktop/CrosshairWindow.xaml.cs
@@ -0,0 +1,364 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Documents;
+using System.Windows.Input;
+using System.Windows.Interop;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Shapes;
+
+namespace RustPlusDesk
+{
+ public enum CrosshairStyle
+ {
+ GreenDot,
+ MiniGreen,
+ OpenCrossRG,
+ ThinRedCircle,
+ SquareDot, // neu
+ MagentaDot, // neu
+ MagentaOpenCross, // neu
+ RangeLine, // neu
+ Custom // neu
+ }
+
+ public partial class CrosshairWindow : Window
+ {
+ const int GWL_EXSTYLE = -20;
+ const int WS_EX_TRANSPARENT = 0x00000020;
+ const int WS_EX_TOOLWINDOW = 0x00000080;
+ const int WS_EX_NOACTIVATE = 0x08000000;
+
+ [DllImport("user32.dll")]
+ static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex);
+
+ [DllImport("user32.dll")]
+ static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
+
+ protected override void OnSourceInitialized(EventArgs e)
+ {
+ base.OnSourceInitialized(e);
+
+ var hwnd = new WindowInteropHelper(this).Handle;
+ var styles = GetWindowLongPtr(hwnd, GWL_EXSTYLE);
+ var newStyles = (IntPtr)((styles.ToInt64())
+ | WS_EX_TRANSPARENT
+ | WS_EX_NOACTIVATE
+ | WS_EX_TOOLWINDOW);
+ SetWindowLongPtr(hwnd, GWL_EXSTYLE, newStyles);
+ }
+
+
+ private CrosshairStyle _style;
+ public string? CustomBase64 { get; set; }
+
+ public CrosshairWindow()
+ {
+ InitializeComponent();
+ Presenter.IsHitTestVisible = false;
+ SetStyle(CrosshairStyle.GreenDot);
+ UseLayoutRounding = true;
+ SnapsToDevicePixels = true;
+ RenderOptions.SetEdgeMode(this, EdgeMode.Aliased); // Linien wirken bei 1px knackiger
+ Presenter.IsHitTestVisible = false;
+ }
+
+ public void SetStyle(CrosshairStyle style)
+ {
+ _style = style;
+
+ // Fenstergröße je Stil
+ switch (_style)
+ {
+ case CrosshairStyle.MiniGreen: Width = Height = 24; break;
+ case CrosshairStyle.OpenCrossRG: Width = Height = 32; break;
+ case CrosshairStyle.ThinRedCircle: Width = Height = 36; break;
+ case CrosshairStyle.SquareDot: Width = Height = 28; break; // kompakt
+ case CrosshairStyle.MagentaDot: Width = Height = 32; break;
+ case CrosshairStyle.MagentaOpenCross: Width = Height = 32; break;
+ case CrosshairStyle.RangeLine: Width = 36; Height = 72; break; // höher für Skala
+ case CrosshairStyle.Custom: Width = Height = 64; break;
+ case CrosshairStyle.GreenDot:
+ default: Width = Height = 32; break;
+ }
+
+ RenderStyle();
+ }
+
+ private void RenderStyle()
+ {
+ if (Presenter != null) Presenter.Content = null;
+
+ UIElement el = _style switch
+ {
+ CrosshairStyle.GreenDot => BuildGreenDot(),
+ CrosshairStyle.MiniGreen => BuildMiniGreen(),
+ CrosshairStyle.OpenCrossRG => BuildOpenCrossRG_Small(),
+ CrosshairStyle.ThinRedCircle => BuildThinRedCircle(),
+ CrosshairStyle.SquareDot => BuildSquareDot(),
+ CrosshairStyle.MagentaDot => BuildMagentaDot(),
+ CrosshairStyle.MagentaOpenCross => BuildMagentaOpenCross(),
+ CrosshairStyle.RangeLine => BuildRangeLine(),
+ CrosshairStyle.Custom => BuildCustom(),
+ _ => BuildGreenDot()
+ };
+
+ Presenter.Content = el;
+ }
+
+ private UIElement BuildCustom()
+ {
+ if (string.IsNullOrEmpty(CustomBase64)) return new Grid();
+ try
+ {
+ byte[] bytes = Convert.FromBase64String(CustomBase64);
+ var bitmap = new BitmapImage();
+ bitmap.BeginInit();
+ bitmap.CacheOption = BitmapCacheOption.OnLoad;
+ bitmap.StreamSource = new System.IO.MemoryStream(bytes);
+ bitmap.EndInit();
+ bitmap.Freeze();
+
+ var img = new Image
+ {
+ Source = bitmap,
+ Width = 64,
+ Height = 64,
+ Stretch = Stretch.Uniform
+ };
+ return img;
+ }
+ catch
+ {
+ return new Grid();
+ }
+ }
+
+ private UIElement BuildGreenDot()
+ {
+ var g = new Grid();
+ g.Children.Add(new Ellipse { Width = 8, Height = 8, Fill = Brushes.Lime });
+ g.Children.Add(new Ellipse { Width = 14, Height = 14, Stroke = Brushes.Lime, StrokeThickness = 1, Opacity = 0.35 });
+ return g;
+
+ }
+
+ private UIElement BuildMiniGreen()
+ {
+ var g = new Grid();
+ // winziger Punkt ohne Glow/Schnickschnack
+ g.Children.Add(new Ellipse { Width = 3, Height = 3, Fill = Brushes.Lime });
+ return g;
+
+ }
+
+ private static readonly Brush Magenta = new SolidColorBrush(Color.FromRgb(255, 0, 255));
+ private static readonly Brush MagentaSoft = new SolidColorBrush(Color.FromRgb(255, 120, 255));
+
+ private UIElement BuildSquareDot()
+ {
+ var canvas = new Canvas
+ {
+ Width = Width,
+ Height = Height,
+ IsHitTestVisible = false,
+ SnapsToDevicePixels = true
+ };
+
+ double side = Math.Min(Width, Height) * 0.90; // Gesamtgröße des Quadrats
+ double cx = Width / 2.0, cy = Height / 2.0;
+ double half = side / 2.0;
+
+ // Ecklängen (wie weit die Corner-Linien in X/Y reinragen)
+ double cornerLen = side * 0.18; // ~18% der Seitenlänge
+ double th = 1.4; // Linienstärke
+
+ Brush stroke = Brushes.White;
+ double x1 = cx - half, x2 = cx + half;
+ double y1 = cy - half, y2 = cy + half;
+
+ // Oben links
+ canvas.Children.Add(new Line { X1 = x1, Y1 = y1, X2 = x1 + cornerLen, Y2 = y1, Stroke = stroke, StrokeThickness = th });
+ canvas.Children.Add(new Line { X1 = x1, Y1 = y1, X2 = x1, Y2 = y1 + cornerLen, Stroke = stroke, StrokeThickness = th });
+
+ // Oben rechts
+ canvas.Children.Add(new Line { X1 = x2 - cornerLen, Y1 = y1, X2 = x2, Y2 = y1, Stroke = stroke, StrokeThickness = th });
+ canvas.Children.Add(new Line { X1 = x2, Y1 = y1, X2 = x2, Y2 = y1 + cornerLen, Stroke = stroke, StrokeThickness = th });
+
+ // Unten links
+ canvas.Children.Add(new Line { X1 = x1, Y1 = y2, X2 = x1 + cornerLen, Y2 = y2, Stroke = stroke, StrokeThickness = th });
+ canvas.Children.Add(new Line { X1 = x1, Y1 = y2 - cornerLen, X2 = x1, Y2 = y2, Stroke = stroke, StrokeThickness = th });
+
+ // Unten rechts
+ canvas.Children.Add(new Line { X1 = x2 - cornerLen, Y1 = y2, X2 = x2, Y2 = y2, Stroke = stroke, StrokeThickness = th });
+ canvas.Children.Add(new Line { X1 = x2, Y1 = y2 - cornerLen, X2 = x2, Y2 = y2, Stroke = stroke, StrokeThickness = th });
+
+ // Mittelpunkt
+ var dot = new Ellipse { Width = 2.8, Height = 2.8, Fill = Brushes.White };
+ Canvas.SetLeft(dot, cx - 1.4);
+ Canvas.SetTop(dot, cy - 1.4);
+ canvas.Children.Add(dot);
+
+ return canvas;
+ }
+
+ private UIElement BuildMagentaDot()
+ {
+ var g = new Grid { IsHitTestVisible = false };
+
+ // Außenring (soft / glow)
+ g.Children.Add(new Ellipse
+ {
+ Width = 14,
+ Height = 14,
+ Stroke = MagentaSoft,
+ StrokeThickness = 1.0,
+ Fill = Brushes.Transparent,
+ Opacity = 0.35
+ });
+
+ // Innenring (kräftiger, Mitte transparent)
+ g.Children.Add(new Ellipse
+ {
+ Width = 8,
+ Height = 8,
+ Stroke = Magenta, // kräftiger Ton
+ StrokeThickness = 2.0, // „dicker“
+ Fill = Brushes.Transparent // Mitte bleibt transparent
+ });
+
+ return g;
+ }
+
+ private UIElement BuildMagentaOpenCross()
+ {
+ var canvas = new Canvas { Width = Width, Height = Height, IsHitTestVisible = false, SnapsToDevicePixels = true };
+ double cx = Width / 2.0, cy = Height / 2.0;
+ double len = 6, gap = 3, th = 1.2;
+
+ canvas.Children.Add(LineV(cx, cy - gap - len, cy - gap, Magenta, th)); // oben
+ canvas.Children.Add(LineV(cx, cy + gap, cy + gap + len, Magenta, th)); // unten
+ canvas.Children.Add(LineH(cx - gap - len, cx - gap, cy, Magenta, th)); // links
+ canvas.Children.Add(LineH(cx + gap, cx + gap + len, cy, Magenta, th)); // rechts
+ return canvas;
+ }
+
+ private UIElement BuildRangeLine()
+ {
+ var canvas = new Canvas
+ {
+ Width = Width,
+ Height = Height,
+ IsHitTestVisible = false,
+ SnapsToDevicePixels = true
+ };
+
+ double cx = Width / 2.0, cy = Height / 2.0;
+ double lineLen = Height * 0.45;
+ double th = 1.2;
+
+ // zentraler Punkt
+ var dot = new Ellipse
+ {
+ Width = 3,
+ Height = 3,
+ Fill = Brushes.Yellow
+ };
+ Canvas.SetLeft(dot, cx - 1.5);
+ Canvas.SetTop(dot, cy - 1.5);
+ canvas.Children.Add(dot);
+
+ // Hauptlinie (nach unten)
+ var mainLine = new Line
+ {
+ X1 = cx,
+ Y1 = cy + 3,
+ X2 = cx,
+ Y2 = cy + lineLen,
+ Stroke = Brushes.Yellow,
+ StrokeThickness = th,
+ StrokeStartLineCap = PenLineCap.Round,
+ StrokeEndLineCap = PenLineCap.Round
+ };
+ canvas.Children.Add(mainLine);
+
+ // Ticks: alle 10 px kurz, alle 50 px lang
+ for (double dy = 10; dy <= lineLen; dy += 10)
+ {
+ bool major = Math.Abs(dy % 50) < 0.0001;
+ double tick = major ? 8 : 4;
+ double y = cy + dy;
+
+ var tickLine = new Line
+ {
+ X1 = cx - tick,
+ Y1 = y,
+ X2 = cx + tick,
+ Y2 = y,
+ Stroke = Brushes.Yellow,
+ StrokeThickness = major ? 1.4 : 1.0
+ };
+ canvas.Children.Add(tickLine);
+ }
+
+ return canvas;
+ }
+
+ // kleine Helfer:
+ static UIElement LineH(double x1, double x2, double y, Brush b, double t) =>
+ new Line { X1 = x1, X2 = x2, Y1 = y, Y2 = y, Stroke = b, StrokeThickness = t, StrokeStartLineCap = PenLineCap.Round, StrokeEndLineCap = PenLineCap.Round };
+
+ static UIElement LineV(double x, double y1, double y2, Brush b, double t) =>
+ new Line { X1 = x, X2 = x, Y1 = y1, Y2 = y2, Stroke = b, StrokeThickness = t, StrokeStartLineCap = PenLineCap.Round, StrokeEndLineCap = PenLineCap.Round };
+
+ private UIElement BuildOpenCrossRG_Small()
+ {
+ // kleines offenes Kreuz
+ var canvas = new Canvas
+ {
+ Width = Width,
+ Height = Height,
+ IsHitTestVisible = false
+ };
+
+ double cx = Width / 2.0, cy = Height / 2.0;
+ double len = 6; // vorher ~14
+ double gap = 3; // vorher ~8
+ double th = 1.2; // dünner
+
+ canvas.Children.Add(LineV(cx, cy - gap - len, cy - gap, Brushes.Red, th)); // oben (rot)
+ canvas.Children.Add(LineV(cx, cy + gap, cy + gap + len, Brushes.Lime, th)); // unten (grün)
+ canvas.Children.Add(LineH(cx - gap - len, cx - gap, cy, Brushes.Lime, th)); // links (grün)
+ canvas.Children.Add(LineH(cx + gap, cx + gap + len, cy, Brushes.Red, th)); // rechts (rot)
+
+ return canvas;
+
+ static UIElement LineH(double x1, double x2, double y, Brush b, double t) =>
+ new Line { X1 = x1, X2 = x2, Y1 = y, Y2 = y, Stroke = b, StrokeThickness = t, StrokeStartLineCap = PenLineCap.Round, StrokeEndLineCap = PenLineCap.Round };
+
+ static UIElement LineV(double x, double y1, double y2, Brush b, double t) =>
+ new Line { X1 = x, X2 = x, Y1 = y1, Y2 = y2, Stroke = b, StrokeThickness = t, StrokeStartLineCap = PenLineCap.Round, StrokeEndLineCap = PenLineCap.Round };
+ }
+
+ private UIElement BuildThinRedCircle()
+ {
+ var g = new Grid();
+ g.Children.Add(new Ellipse
+ {
+ Width = 14,
+ Height = 14,
+ Stroke = new SolidColorBrush(Color.FromRgb(255, 120, 120)),
+ StrokeThickness = 1
+ });
+ g.Children.Add(new Ellipse { Width = 2, Height = 2, Fill = new SolidColorBrush(Color.FromRgb(255, 170, 170)) });
+ return g;
+ }
+ }
+}
\ No newline at end of file
diff --git a/RustPlusDesktop/CustomCrosshairManager.cs b/RustPlusDesktop/CustomCrosshairManager.cs
new file mode 100644
index 00000000..ffe12afe
--- /dev/null
+++ b/RustPlusDesktop/CustomCrosshairManager.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text.Json;
+
+namespace RustPlusDesk
+{
+ public class CustomCrosshair
+ {
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+ public string Name { get; set; } = "CUSTOM";
+ public string Base64Image { get; set; } = "";
+ }
+
+ public static class CustomCrosshairManager
+ {
+ private static readonly string SavePath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "RustPlusDesk", "custom_crosshairs.json");
+
+ public static List LoadCrosshairs()
+ {
+ try
+ {
+ if (!File.Exists(SavePath)) return new List();
+ var json = File.ReadAllText(SavePath);
+ return JsonSerializer.Deserialize>(json) ?? new List();
+ }
+ catch
+ {
+ return new List();
+ }
+ }
+
+ public static void SaveCrosshairs(List crosshairs)
+ {
+ try
+ {
+ var dir = Path.GetDirectoryName(SavePath);
+ if (dir != null && !Directory.Exists(dir))
+ {
+ Directory.CreateDirectory(dir);
+ }
+ var json = JsonSerializer.Serialize(crosshairs, new JsonSerializerOptions { WriteIndented = true });
+ File.WriteAllText(SavePath, json);
+ }
+ catch
+ {
+ // Ignore for now
+ }
+ }
+ }
+}
diff --git a/RustPlusDesktop/DeviceImportItem.cs b/RustPlusDesktop/DeviceImportItem.cs
new file mode 100644
index 00000000..c1b13fea
--- /dev/null
+++ b/RustPlusDesktop/DeviceImportItem.cs
@@ -0,0 +1,75 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace RustPlusDesk.Models
+{
+ public sealed class DeviceImportItem : INotifyPropertyChanged
+ {
+ public ulong OwnerSteamId { get; init; }
+ public string OwnerName { get; init; } = "";
+ public uint EntityId { get; init; }
+ public string? Kind { get; init; }
+ public string? Name { get; init; }
+ public string? Alias { get; init; }
+ public bool AlreadyPresent { get; init; }
+
+ public ExportedDeviceDto? OriginalDto { get; init; }
+
+ // neu:
+ public string ServerName { get; init; } = ""; // Name des aktuellen Server-Profils
+
+ // Hauptanzeige: Alias > Name > #ID
+ public string DisplayName
+ {
+ get
+ {
+ // Label = Alias > Name
+ var label = !string.IsNullOrWhiteSpace(Alias) ? Alias! :
+ !string.IsNullOrWhiteSpace(Name) ? Name! :
+ null;
+
+ // Wenn Alias/Name vorhanden → "Garage (#123456)"
+ if (!string.IsNullOrWhiteSpace(label))
+ return $"{label} (#{EntityId})";
+
+ // Sonst nur "#123456"
+ return $"#{EntityId}";
+ }
+ }
+
+ // kleine Zusatzzeile unterhalb (z.B. Owner usw.)
+ public string Tagline =>
+ AlreadyPresent
+ ? $"{OwnerName} (already in your list)"
+ : OwnerName;
+
+ // Status-Text rechts
+ public string ExtraInfo => ExistsState switch
+ {
+ "ok" => "Reachable in current map",
+ "missing" => "Missing in current map",
+ "err" => "Status unknown",
+ "local" => "Already present",
+ _ => ""
+ };
+ // NEU: wird für das Binding der Checkbox verwendet
+ public bool IsSelectable => !AlreadyPresent;
+ private bool _isSelected;
+ public bool IsSelected
+ {
+ get => _isSelected;
+ set { if (_isSelected != value) { _isSelected = value; OnProp(); } }
+ }
+
+ private string _existsState = "?";
+ public string ExistsState
+ {
+ get => _existsState;
+ set { if (_existsState != value) { _existsState = value; OnProp(); OnProp(nameof(ExtraInfo)); } }
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ private void OnProp([CallerMemberName] string? n = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
+ }
+}
\ No newline at end of file
diff --git a/RustPlusDesktop/DeviceImportWindow.xaml b/RustPlusDesktop/DeviceImportWindow.xaml
new file mode 100644
index 00000000..4fbac925
--- /dev/null
+++ b/RustPlusDesktop/DeviceImportWindow.xaml
@@ -0,0 +1,204 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/RustPlusDesktop/DeviceImportWindow.xaml.cs b/RustPlusDesktop/DeviceImportWindow.xaml.cs
new file mode 100644
index 00000000..0ad1427e
--- /dev/null
+++ b/RustPlusDesktop/DeviceImportWindow.xaml.cs
@@ -0,0 +1,132 @@
+using System.Collections.ObjectModel;
+using RustPlusDesk.Models;
+using RustPlusDesk.Services;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Windows;
+
+
+namespace RustPlusDesk.Views
+{
+
+
+ public partial class DeviceImportWindow : Window
+ {
+ public ObservableCollection Devices { get; } = new();
+ private readonly Func> _probe;
+
+ public DeviceImportWindow(
+ List devices,
+ Func> probe)
+ {
+ InitializeComponent();
+
+ // Liste in ObservableCollection kopieren
+ Devices.Clear();
+ foreach (var d in devices)
+ Devices.Add(d);
+
+ _probe = probe;
+ DataContext = this;
+ }
+
+ private async void BtnCheckStatus_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is FrameworkElement fe)
+ fe.IsEnabled = false;
+
+ try
+ {
+ // 1) Alle Devices nach EntityId gruppieren (Duplikate vermeiden)
+ var groups = Devices
+ .GroupBy(d => d.EntityId)
+ .ToList();
+
+ // Cache für Ergebnisse
+ var cache = new Dictionary();
+
+ int probedCount = 0;
+
+ foreach (var group in groups)
+ {
+ var id = group.Key;
+ EntityProbeResult result;
+
+ if (!cache.TryGetValue(id, out result))
+ {
+ try
+ {
+ // tatsächlicher Probe-Call
+ result = await _probe(id);
+ }
+ catch
+ {
+ result = new EntityProbeResult(false, null, null);
+ }
+
+ cache[id] = result;
+
+ probedCount++;
+
+ // kleine Pause nach jedem Request
+ await Task.Delay(80);
+
+ // zusätzliche Pause nach jeweils 5 Geräten
+ if (probedCount % 5 == 0)
+ await Task.Delay(250);
+ }
+
+ // 2) Ergebnis auf alle Items mit dieser ID anwenden
+ var state = result.Exists ? "ok" : "missing";
+
+ foreach (var item in group)
+ item.ExistsState = state;
+ }
+ }
+ finally
+ {
+ if (sender is FrameworkElement fe2)
+ fe2.IsEnabled = true;
+ }
+ }
+
+ public IReadOnlyList SelectedItems =>
+ Devices.Where(d => d.IsSelected && !d.AlreadyPresent).ToList();
+
+
+
+ private void BtnSelectAll_Click(object sender, RoutedEventArgs e)
+ {
+ foreach (var it in Devices)
+ {
+ if (!it.AlreadyPresent)
+ it.IsSelected = true;
+ }
+ }
+
+ private void BtnDeselectAll_Click(object sender, RoutedEventArgs e)
+ {
+ foreach (var it in Devices)
+ it.IsSelected = false;
+ }
+
+ private void BtnCancel_Click(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ }
+
+ private void BtnImport_Click(object sender, RoutedEventArgs e)
+ {
+ if (!SelectedItems.Any())
+ {
+ DialogResult = false;
+ return;
+ }
+
+ DialogResult = true;
+ }
+ }
+
+}
diff --git a/RustPlusDesktop/DiscordWebhookService.cs b/RustPlusDesktop/DiscordWebhookService.cs
new file mode 100644
index 00000000..4bdfcdf4
--- /dev/null
+++ b/RustPlusDesktop/DiscordWebhookService.cs
@@ -0,0 +1,175 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace RustPlusDesk.Services;
+
+///
+/// Posts Discord webhook embeds for in-game events. One global webhook URL,
+/// per-server mute, per-event-type toggle. Messages go through a single
+/// async queue with rate limiting so a chaotic raid never hits Discord's
+/// 30 requests/minute webhook cap.
+///
+public static class DiscordWebhookService
+{
+ // Discord allows ~30 webhook posts per minute per route. Stay safely under.
+ private const int MaxRequestsPerMinute = 25;
+ private const int MinSpacingMs = 60_000 / MaxRequestsPerMinute; // 2.4s between sends
+
+ private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) };
+ private static readonly BlockingCollection _queue = new(boundedCapacity: 200);
+ private static readonly CancellationTokenSource _cts = new();
+ private static Task? _worker;
+ private static long _lastSendTicks;
+
+ public static event Action? OnLog;
+
+ public static void Start()
+ {
+ if (_worker != null) return;
+ _worker = Task.Run(() => WorkerLoopAsync(_cts.Token));
+ }
+
+ public static void Stop()
+ {
+ try { _cts.Cancel(); } catch { }
+ _queue.CompleteAdding();
+ }
+
+ ///
+ /// Queue an embed for delivery. No-op if the URL is empty, the server is
+ /// muted, or the event type is disabled. Caller checks none of that.
+ ///
+ public static void QueueEvent(string serverHost, string serverName, string eventTag,
+ string title, string description, int colorRgb,
+ string? gridCoord = null)
+ {
+ var url = TrackingService.DiscordWebhookUrl;
+ if (string.IsNullOrWhiteSpace(url)) return;
+ if (TrackingService.IsDiscordServerMuted(serverHost)) return;
+ if (!TrackingService.IsDiscordEventEnabled(eventTag)) return;
+
+ var msg = new QueuedMessage(url, serverName ?? "", title ?? "", description ?? "",
+ colorRgb, gridCoord);
+ try { _queue.Add(msg); } catch (InvalidOperationException) { /* shutdown */ }
+ }
+
+ ///
+ /// Bypasses event-type and per-server filters for the "Test connection" button.
+ /// Still requires a configured URL.
+ ///
+ public static async Task SendTestAsync()
+ {
+ var url = TrackingService.DiscordWebhookUrl;
+ if (string.IsNullOrWhiteSpace(url)) return false;
+
+ var payload = BuildPayload("Rust+ Desktop", "Webhook test successful. You'll receive in-game alerts here.",
+ serverName: "Test", colorRgb: 0x4FC3F7, gridCoord: null);
+ return await PostAsync(url, payload).ConfigureAwait(false);
+ }
+
+ private static async Task WorkerLoopAsync(CancellationToken ct)
+ {
+ try
+ {
+ foreach (var msg in _queue.GetConsumingEnumerable(ct))
+ {
+ await SpaceOutAsync(ct).ConfigureAwait(false);
+
+ var payload = BuildPayload(msg.Title, msg.Description, msg.ServerName, msg.ColorRgb, msg.GridCoord);
+ await PostAsync(msg.Url, payload).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException) { /* shutdown */ }
+ catch (Exception ex)
+ {
+ OnLog?.Invoke($"[discord] worker stopped: {ex.Message}");
+ }
+ }
+
+ private static async Task SpaceOutAsync(CancellationToken ct)
+ {
+ var last = Interlocked.Read(ref _lastSendTicks);
+ if (last == 0) return;
+ var elapsed = (Environment.TickCount64 - last);
+ var waitMs = MinSpacingMs - (int)elapsed;
+ if (waitMs > 0)
+ await Task.Delay(waitMs, ct).ConfigureAwait(false);
+ }
+
+ private static async Task PostAsync(string url, string jsonPayload)
+ {
+ try
+ {
+ using var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
+ using var resp = await _http.PostAsync(url, content).ConfigureAwait(false);
+ Interlocked.Exchange(ref _lastSendTicks, Environment.TickCount64);
+
+ if (resp.IsSuccessStatusCode) return true;
+
+ // Don't echo the URL into logs (it's a credential).
+ OnLog?.Invoke($"[discord] webhook returned {(int)resp.StatusCode}");
+
+ // 429 from Discord means we miscounted: back off explicitly.
+ if ((int)resp.StatusCode == 429)
+ await Task.Delay(2000).ConfigureAwait(false);
+
+ return false;
+ }
+ catch (Exception ex)
+ {
+ OnLog?.Invoke($"[discord] webhook send failed: {ex.Message}");
+ return false;
+ }
+ }
+
+ private static string BuildPayload(string title, string description, string serverName,
+ int colorRgb, string? gridCoord)
+ {
+ var fields = new List