Initial version

This commit is contained in:
2026-08-06 22:29:07 +02:00
parent f0df14dda6
commit 71b1a50ce9
18 changed files with 2159 additions and 43 deletions
+3
View File
@@ -0,0 +1,3 @@
<Solution>
<Project Path="EonaCat.RdpColorizer\EonaCat.RdpColorizer.csproj" />
</Solution>
+416
View File
@@ -0,0 +1,416 @@
# ---> VisualStudio
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
<AssemblyName>EonaCat.RdpColorizer</AssemblyName>
<RootNamespace>EonaCat.RdpColorizer</RootNamespace>
<ApplicationIcon>icon.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="icon.ico">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<!-- Used to read another process's command line (mstsc.exe /v:target)
so the watcher can identify which server an RDP window belongs to. -->
<PackageReference Include="EonaCat.Json" Version="2.2.5" />
<PackageReference Include="System.Management" Version="8.0.0" />
</ItemGroup>
</Project>
+117
View File
@@ -0,0 +1,117 @@
using System.Text;
using static EonaCat.RdpColorizer.NativeMethods;
namespace EonaCat.RdpColorizer
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Continuously scans for Remote Desktop Connection windows anywhere on
/// the machine - not just ones this app launched - and automatically
/// (re)applies a saved profile's color/tag when the connected server
/// matches one on record. This is what lets tags survive an app
/// restart, and lets RDP windows opened outside this app (Start menu,
/// a shortcut, another tool) still get tagged.
/// </summary>
public sealed class GlobalRdpWatcher : IDisposable
{
private readonly ProfileStore _profiles;
private readonly System.Windows.Forms.Timer _scanTimer;
private readonly Dictionary<IntPtr, RdpSession> _tracked = new();
private bool _disposed;
public event Action<RdpSession>? SessionAttached;
public event Action<IntPtr>? SessionDetached;
public GlobalRdpWatcher(ProfileStore profiles, int scanIntervalMs = 1000)
{
_profiles = profiles;
_scanTimer = new System.Windows.Forms.Timer { Interval = scanIntervalMs };
_scanTimer.Tick += (_, _) => Scan();
}
public void Start() => _scanTimer.Start();
public void Stop() => _scanTimer.Stop();
/// <summary>Runs one scan pass immediately (in addition to the timer cadence).</summary>
public void ScanNow() => Scan();
private void Scan()
{
var found = new List<IntPtr>();
EnumWindows((hwnd, _) =>
{
if (!IsWindowVisible(hwnd))
{
return true;
}
var sb = new StringBuilder(256);
GetClassName(hwnd, sb, sb.Capacity);
if (sb.ToString() == "TscShellContainerClass")
{
found.Add(hwnd);
}
return true;
}, IntPtr.Zero);
// Stop tracking anything that has closed or is no longer an RDP window.
foreach (var hwnd in new List<IntPtr>(_tracked.Keys))
{
if (IsWindow(hwnd) && found.Contains(hwnd))
{
continue;
}
_tracked[hwnd].Dispose();
_tracked.Remove(hwnd);
SessionDetached?.Invoke(hwnd);
}
foreach (var hwnd in found)
{
if (_tracked.ContainsKey(hwnd))
{
continue; // already tagged this pass
}
string? target = RdpTargetResolver.Resolve(hwnd);
if (target == null)
{
continue;
}
RdpProfile? profile = _profiles.FindMatch(target);
if (profile == null)
{
continue; // no saved tag for this server - leave it alone
}
var session = new RdpSession(target, profile.Label, Color.FromArgb(profile.ColorArgb));
session.AttachToWindow(hwnd);
_tracked[hwnd] = session;
SessionAttached?.Invoke(session);
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_scanTimer.Stop();
_scanTimer.Dispose();
foreach (var s in _tracked.Values)
{
s.Dispose();
}
_tracked.Clear();
}
}
}
+295
View File
@@ -0,0 +1,295 @@
using Microsoft.Win32;
using System.Reflection;
namespace EonaCat.RdpColorizer
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public class MainForm : Form
{
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string RunValueName = "EonaCat.RdpColorizer";
private readonly List<RdpSession> _sessions = new();
private readonly ProfileStore _profiles = new();
private readonly GlobalRdpWatcher _watcher;
private readonly TextBox _targetBox;
private readonly TextBox _labelBox;
private readonly Button _colorButton;
private readonly Button _launchButton;
private readonly Button _attachButton;
private readonly Button _removeButton;
private readonly Button _manageProfilesButton;
private readonly CheckBox _startWithWindowsBox;
private readonly Label _watcherStatusLabel;
private readonly ListBox _sessionList;
private readonly NotifyIcon _trayIcon;
private Color _selectedColor = Color.FromArgb(0, 120, 212); // Windows accent blue
private readonly bool _startHidden;
private bool _reallyClose;
public MainForm(bool startHidden = false)
{
_startHidden = startHidden;
Text = "EonaCat RDP Colorizer";
Width = 470;
Height = 470;
FormBorderStyle = FormBorderStyle.FixedSingle;
MaximizeBox = false;
StartPosition = FormStartPosition.CenterScreen;
this.Icon = GetApplicationIcon();
var targetLabel = new Label { Text = "Server / IP:", Left = 12, Top = 18, Width = 90 };
_targetBox = new TextBox { Left = 110, Top = 15, Width = 330, PlaceholderText = "e.g. server01 or 10.0.0.5" };
var labelLabel = new Label { Text = "Tag text:", Left = 12, Top = 51, Width = 90 };
_labelBox = new TextBox { Left = 110, Top = 48, Width = 200, MaxLength = 12, PlaceholderText = "e.g. PROD" };
_colorButton = new Button { Text = "Choose color", Left = 320, Top = 47, Width = 120, BackColor = _selectedColor };
_colorButton.Click += ColorButton_Click;
_launchButton = new Button { Text = "Launch new tagged connection", Left = 12, Top = 90, Width = 215, Height = 32 };
_launchButton.Click += LaunchButton_Click;
_attachButton = new Button { Text = "Tag active RDP window", Left = 237, Top = 90, Width = 203, Height = 32 };
_attachButton.Click += AttachButton_Click;
var listLabel = new Label { Text = "Active tagged sessions:", Left = 12, Top = 135, Width = 200 };
_sessionList = new ListBox { Left = 12, Top = 157, Width = 430, Height = 130 };
_removeButton = new Button { Text = "Remove tag (RDP window stays open)", Left = 12, Top = 295, Width = 260, Height = 28 };
_removeButton.Click += RemoveButton_Click;
_manageProfilesButton = new Button { Text = "Manage saved profiles...", Left = 282, Top = 295, Width = 158, Height = 28 };
_manageProfilesButton.Click += (_, _) => new ProfileManagerForm(_profiles).ShowDialog(this);
_startWithWindowsBox = new CheckBox
{
Text = "Start with Windows (runs quietly in the tray and keeps auto-tagging)",
Left = 12,
Top = 335,
Width = 430,
Checked = IsStartWithWindowsEnabled()
};
_startWithWindowsBox.CheckedChanged += (_, _) => SetStartWithWindows(_startWithWindowsBox.Checked);
_watcherStatusLabel = new Label
{
Text = "Background watcher is running: any RDP window matching a saved profile is tagged automatically.",
Left = 12,
Top = 368,
Width = 430,
Height = 40,
ForeColor = SystemColors.GrayText
};
Controls.AddRange(new Control[]
{
targetLabel, _targetBox, labelLabel, _labelBox, _colorButton,
_launchButton, _attachButton, listLabel, _sessionList,
_removeButton, _manageProfilesButton, _startWithWindowsBox, _watcherStatusLabel
});
_trayIcon = new NotifyIcon
{
Icon = this.Icon,
Text = "EonaCat RDP Colorizer",
Visible = true,
};
var trayMenu = new ContextMenuStrip();
trayMenu.Items.Add("Open", null, (_, _) => ShowFromTray());
trayMenu.Items.Add("Exit", null, (_, _) => { _reallyClose = true; Close(); });
_trayIcon.ContextMenuStrip = trayMenu;
_trayIcon.DoubleClick += (_, _) => ShowFromTray();
// The watcher runs regardless of whether the main window is
// visible - it's what makes this behave like a background
// service instead of a one-shot tool.
_watcher = new GlobalRdpWatcher(_profiles);
_watcher.SessionAttached += OnWatcherSessionAttached;
_watcher.SessionDetached += OnWatcherSessionDetached;
_watcher.Start();
_watcher.ScanNow(); // catch RDP windows that were already open before this app started
}
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
if (_startHidden)
{
Hide();
ShowInTaskbar = false;
}
}
private void OnWatcherSessionAttached(RdpSession session)
{
if (InvokeRequired) { BeginInvoke(() => OnWatcherSessionAttached(session)); return; }
_sessions.Add(session);
_sessionList.Items.Add($"{session.Label} -> {session.Target} (auto)");
}
private void OnWatcherSessionDetached(IntPtr hwnd)
{
// The watcher already disposed the session; just let the list
// catch up on the next manual refresh - active sessions in the
// list that have closed are harmless to leave until Remove is
// clicked, since RdpSession.Dispose() is idempotent.
}
private void ShowFromTray()
{
Show();
ShowInTaskbar = true;
WindowState = FormWindowState.Normal;
Activate();
}
private void ColorButton_Click(object? sender, EventArgs e)
{
using var dlg = new ColorDialog { Color = _selectedColor, FullOpen = true };
if (dlg.ShowDialog() == DialogResult.OK)
{
_selectedColor = dlg.Color;
_colorButton.BackColor = _selectedColor;
}
}
private void LaunchButton_Click(object? sender, EventArgs e)
{
string target = _targetBox.Text.Trim();
if (string.IsNullOrEmpty(target))
{
MessageBox.Show(this, "Enter a server name or IP address first.", "RDP Colorizer");
return;
}
string label = string.IsNullOrWhiteSpace(_labelBox.Text) ? target : _labelBox.Text.Trim();
var session = new RdpSession(target, label, _selectedColor);
session.Launch();
_sessions.Add(session);
_sessionList.Items.Add($"{label} -> {target}");
// Remember this pairing so the watcher auto-tags this server
// again next time, even after this app restarts.
_profiles.AddOrUpdate(target, label, _selectedColor.ToArgb());
}
private void AttachButton_Click(object? sender, EventArgs e)
{
IntPtr hwnd = NativeMethods.FindWindow("TscShellContainerClass", null);
if (hwnd == IntPtr.Zero)
{
MessageBox.Show(this,
"No open Remote Desktop Connection window was found. Open one first, or use \"Launch\" instead.",
"RDP Colorizer");
return;
}
string resolvedTarget = RdpTargetResolver.Resolve(hwnd) ?? "(unknown)";
string label = string.IsNullOrWhiteSpace(_labelBox.Text) ? "RDP" : _labelBox.Text.Trim();
var session = new RdpSession(resolvedTarget, label, _selectedColor);
session.AttachToWindow(hwnd);
_sessions.Add(session);
_sessionList.Items.Add($"{label} -> {resolvedTarget}");
if (resolvedTarget != "(unknown)")
{
_profiles.AddOrUpdate(resolvedTarget, label, _selectedColor.ToArgb());
}
}
private void RemoveButton_Click(object? sender, EventArgs e)
{
int idx = _sessionList.SelectedIndex;
if (idx < 0)
{
MessageBox.Show(this, "Select a session in the list first.", "RDP Colorizer");
return;
}
_sessions[idx].Dispose();
_sessions.RemoveAt(idx);
_sessionList.Items.RemoveAt(idx);
}
private static bool IsStartWithWindowsEnabled()
{
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, false);
return key?.GetValue(RunValueName) != null;
}
private static void SetStartWithWindows(bool enabled)
{
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, true)
?? Registry.CurrentUser.CreateSubKey(RunKeyPath)!;
if (enabled)
{
string exePath = Application.ExecutablePath;
key.SetValue(RunValueName, $"\"{exePath}\" --background");
}
else
{
key.DeleteValue(RunValueName, false);
}
}
private static Icon? GetApplicationIcon()
{
try
{
// The icon is embedded as a resource in the assembly
var assembly = Assembly.GetExecutingAssembly();
if (assembly == null)
{
return null;
}
using var stream = assembly.GetManifestResourceStream("EonaCat.RdpColorizer.icon.ico");
if (stream == null)
{
return null;
}
return new Icon(stream);
}
catch
{
// Ignore errors loading icon
}
return null;
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
if (!_reallyClose && e.CloseReason == CloseReason.UserClosing)
{
// Closing the window just hides it - the watcher keeps
// running in the tray so auto-tagging doesn't stop.
e.Cancel = true;
Hide();
ShowInTaskbar = false;
_trayIcon.ShowBalloonTip(1500, "EonaCat RDP Colorizer",
"Still running in the background - matching RDP windows keep getting tagged automatically.",
ToolTipIcon.Info);
return;
}
_trayIcon.Visible = false;
_watcher.Dispose();
foreach (var s in _sessions)
{
s.Dispose();
}
base.OnFormClosing(e);
}
}
}
+121
View File
@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<assembly alias="System.Drawing" name="System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
</root>
+77
View File
@@ -0,0 +1,77 @@
using System.Runtime.InteropServices;
using System.Text;
namespace EonaCat.RdpColorizer
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
internal static class NativeMethods
{
// DWM window attributes (DWMWA_CAPTION_COLOR / DWMWA_TEXT_COLOR are
// only honored on Windows 11, build 22000 and later).
internal const int DWMWA_BORDER_COLOR = 34;
internal const int DWMWA_CAPTION_COLOR = 35;
internal const int DWMWA_TEXT_COLOR = 36;
[DllImport("dwmapi.dll", PreserveSig = true)]
internal static extern int DwmSetWindowAttribute(IntPtr hwnd, int dwAttribute, ref int pvAttribute, int cbAttribute);
[DllImport("user32.dll", CharSet = CharSet.Auto)]
internal static extern IntPtr FindWindow(string? lpClassName, string? lpWindowName);
[DllImport("user32.dll")]
internal static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
[DllImport("user32.dll")]
internal static extern bool IsWindow(IntPtr hWnd);
[DllImport("user32.dll")]
internal static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")]
internal static extern bool IsIconic(IntPtr hWnd);
[DllImport("user32.dll", SetLastError = true)]
internal static extern int GetWindowLong(IntPtr hWnd, int nIndex);
[DllImport("user32.dll", SetLastError = true)]
internal static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
[DllImport("user32.dll")]
internal static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
[DllImport("user32.dll")]
internal static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll")]
internal static extern int GetWindowThreadProcessId(IntPtr hWnd, out int lpdwProcessId);
[DllImport("user32.dll", CharSet = CharSet.Auto)]
internal static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
[DllImport("user32.dll", CharSet = CharSet.Auto)]
internal static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
internal delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
internal const int GWL_EXSTYLE = -20;
internal const int WS_EX_LAYERED = 0x80000;
internal const int WS_EX_TRANSPARENT = 0x20;
internal const int WS_EX_TOOLWINDOW = 0x80;
internal const int WS_EX_NOACTIVATE = 0x8000000;
internal const uint SWP_NOACTIVATE = 0x0010;
internal const uint SWP_SHOWWINDOW = 0x0040;
internal static readonly IntPtr HWND_TOPMOST = new IntPtr(-1);
[StructLayout(LayoutKind.Sequential)]
internal struct RECT
{
public int Left, Top, Right, Bottom;
public int Width => Right - Left;
public int Height => Bottom - Top;
}
}
}
@@ -0,0 +1,95 @@
using System.Reflection;
namespace EonaCat.RdpColorizer
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>Lets the user review and delete saved server -> tag/color profiles.</summary>
public sealed class ProfileManagerForm : Form
{
private readonly ProfileStore _store;
private readonly ListBox _list;
public ProfileManagerForm(ProfileStore store)
{
_store = store;
Text = "Saved profiles";
Width = 420;
Height = 340;
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
this.Icon = GetApplicationIcon();
_list = new ListBox { Left = 12, Top = 12, Width = 380, Height = 230 };
RefreshList();
var removeButton = new Button { Text = "Remove selected", Left = 12, Top = 252, Width = 150, Height = 28 };
removeButton.Click += (_, _) =>
{
int idx = _list.SelectedIndex;
if (idx < 0)
{
return;
}
_store.Remove(_store.Profiles[idx]);
RefreshList();
};
var closeButton = new Button { Text = "Close", Left = 242, Top = 252, Width = 150, Height = 28 };
closeButton.Click += (_, _) => Close();
var hint = new Label
{
Text = "These are the servers the background watcher will auto-tag on sight.",
Left = 12,
Top = 285,
Width = 380,
ForeColor = System.Drawing.SystemColors.GrayText
};
Controls.Add(_list);
Controls.Add(removeButton);
Controls.Add(closeButton);
Controls.Add(hint);
}
private void RefreshList()
{
_list.Items.Clear();
foreach (var p in _store.Profiles)
{
_list.Items.Add($"{p.Target} [{p.Label}]");
}
}
private static Icon? GetApplicationIcon()
{
try
{
// The icon is embedded as a resource in the assembly
var assembly = Assembly.GetExecutingAssembly();
if (assembly == null)
{
return null;
}
using var stream = assembly.GetManifestResourceStream("EonaCat.RdpColorizer.icon.ico");
if (stream == null)
{
return null;
}
return new Icon(stream);
}
catch
{
// Ignore errors loading icon
}
return null;
}
}
}
@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<assembly alias="System.Drawing" name="System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
</root>
+94
View File
@@ -0,0 +1,94 @@
using EonaCat.Json;
namespace EonaCat.RdpColorizer
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class RdpProfile
{
public string Target { get; set; } = "";
public string Label { get; set; } = "";
public int ColorArgb { get; set; }
}
/// <summary>
/// Persists known server -> tag/color mappings to
/// %AppData%\EonaCat.RdpColorizer\profiles.json so they survive app
/// restarts and let the background watcher auto re-tag matching RDP
/// windows without the user having to re-launch or re-attach manually.
/// </summary>
public sealed class ProfileStore
{
private readonly string _path;
private readonly List<RdpProfile> _profiles = new();
public IReadOnlyList<RdpProfile> Profiles => _profiles;
public ProfileStore()
{
string dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"EonaCat.RdpColorizer");
Directory.CreateDirectory(dir);
_path = Path.Combine(dir, "profiles.json");
Load();
}
public void Load()
{
_profiles.Clear();
if (!File.Exists(_path))
{
return;
}
try
{
string json = File.ReadAllText(_path);
var loaded = JsonHelper.ToObject<List<RdpProfile>>(json);
if (loaded != null)
{
_profiles.AddRange(loaded);
}
}
catch
{
// Corrupt or unreadable file - start fresh rather than crash.
}
}
public void Save()
{
string json = JsonHelper.ToJson(_profiles);
File.WriteAllText(_path, json);
}
/// <summary>Adds a new profile, or updates the label/color of an existing one for the same target.</summary>
public void AddOrUpdate(string target, string label, int colorArgb)
{
var existing = _profiles.FirstOrDefault(p => string.Equals(p.Target, target, StringComparison.OrdinalIgnoreCase));
if (existing != null)
{
existing.Label = label;
existing.ColorArgb = colorArgb;
}
else
{
_profiles.Add(new RdpProfile { Target = target, Label = label, ColorArgb = colorArgb });
}
Save();
}
public void Remove(RdpProfile profile)
{
_profiles.Remove(profile);
Save();
}
public RdpProfile? FindMatch(string target)
{
return _profiles.FirstOrDefault(p => string.Equals(p.Target, target, StringComparison.OrdinalIgnoreCase));
}
}
}
+30
View File
@@ -0,0 +1,30 @@
namespace EonaCat.RdpColorizer
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
internal static class Program
{
[STAThread]
private static void Main(string[] args)
{
// Prevent duplicate background watchers if the app is both set
// to start with Windows and launched manually.
using var mutex = new Mutex(true, "EonaCat.RdpColorizer.SingleInstance", out bool createdNew);
if (!createdNew)
{
MessageBox.Show(
"EonaCat RDP Colorizer is already running - check the system tray.",
"EonaCat RDP Colorizer");
return;
}
ApplicationConfiguration.Initialize();
bool startHidden = args.Contains("--background", StringComparer.OrdinalIgnoreCase);
Application.Run(new MainForm(startHidden));
GC.KeepAlive(mutex);
}
}
}
+200
View File
@@ -0,0 +1,200 @@
using System.Diagnostics;
using System.Text;
using static EonaCat.RdpColorizer.NativeMethods;
namespace EonaCat.RdpColorizer
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Represents one launched (or attached) Remote Desktop connection whose
/// title bar is colorized and labeled with an overlay tag. Polls the
/// target window's position so the tag follows it when moved or resized.
/// </summary>
public sealed class RdpSession : IDisposable
{
public string Target { get; }
public string Label { get; }
public Color TagColor { get; }
private Process? _process;
private IntPtr _rdpHwnd = IntPtr.Zero;
private readonly System.Windows.Forms.Timer _watchTimer;
private TagOverlayForm? _overlay;
private bool _disposed;
public RdpSession(string target, string label, Color tagColor)
{
Target = target;
Label = label;
TagColor = tagColor;
_watchTimer = new System.Windows.Forms.Timer { Interval = 150 };
_watchTimer.Tick += WatchTimer_Tick;
}
/// <summary>Launches mstsc.exe against the target and starts tracking it.</summary>
public void Launch()
{
var psi = new ProcessStartInfo
{
FileName = "mstsc.exe",
Arguments = $"/v:{Target}",
UseShellExecute = true
};
_process = Process.Start(psi);
_watchTimer.Start();
}
/// <summary>Attaches to an already-open mstsc window instead of launching a new one.</summary>
public void AttachToWindow(IntPtr hwnd)
{
_rdpHwnd = hwnd;
OnWindowFound();
_watchTimer.Start();
}
private void WatchTimer_Tick(object? sender, EventArgs e)
{
if (_rdpHwnd == IntPtr.Zero)
{
if (_process == null)
{
return;
}
IntPtr hwnd = FindMstscWindowForProcess(_process.Id);
if (hwnd != IntPtr.Zero)
{
_rdpHwnd = hwnd;
OnWindowFound();
}
return;
}
if (!IsWindow(_rdpHwnd))
{
// The RDP window was closed; clean up and stop watching.
Dispose();
return;
}
RepositionOverlay();
}
private void OnWindowFound()
{
ApplyCaptionColor();
CreateOverlay();
RepositionOverlay();
}
private void ApplyCaptionColor()
{
// DWMWA_CAPTION_COLOR/TEXT_COLOR only take effect on Windows 11
// (build 22000+). On older builds these calls fail silently and
// the overlay tag remains the only visible identifier.
try
{
int colorRef = ToColorRef(TagColor);
DwmSetWindowAttribute(_rdpHwnd, DWMWA_CAPTION_COLOR, ref colorRef, sizeof(int));
double luminance = (0.299 * TagColor.R + 0.587 * TagColor.G + 0.114 * TagColor.B) / 255.0;
Color textColor = luminance > 0.6 ? Color.Black : Color.White;
int textColorRef = ToColorRef(textColor);
DwmSetWindowAttribute(_rdpHwnd, DWMWA_TEXT_COLOR, ref textColorRef, sizeof(int));
}
catch
{
// Ignore - unsupported OS version.
}
}
private static int ToColorRef(Color c) => c.R | (c.G << 8) | (c.B << 16); // COLORREF is 0x00BBGGRR
private void CreateOverlay()
{
_overlay = new TagOverlayForm(Label, TagColor);
_overlay.Show();
int exStyle = GetWindowLong(_overlay.Handle, GWL_EXSTYLE);
exStyle |= WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE;
SetWindowLong(_overlay.Handle, GWL_EXSTYLE, exStyle);
}
private void RepositionOverlay()
{
if (_overlay == null || _overlay.IsDisposed)
{
return;
}
if (!GetWindowRect(_rdpHwnd, out RECT rect))
{
return;
}
// Hide the tag while the RDP window is minimized; there's no
// caption bar to sit on in that state.
if (IsIconic(_rdpHwnd))
{
if (_overlay.Visible)
{
_overlay.Hide();
}
return;
}
if (!_overlay.Visible)
{
_overlay.Show();
}
// Sit the tag near the left of the caption bar, just right of
// the system icon.
int tagX = rect.Left + (rect.Right - rect.Left) / 2 + 46;
int tagY = rect.Top + 6;
SetWindowPos(_overlay.Handle, HWND_TOPMOST, tagX, tagY,
_overlay.Width, _overlay.Height, SWP_NOACTIVATE | SWP_SHOWWINDOW);
}
private static IntPtr FindMstscWindowForProcess(int processId)
{
IntPtr found = IntPtr.Zero;
EnumWindows((hwnd, _) =>
{
GetWindowThreadProcessId(hwnd, out int pid);
if (pid != processId || !IsWindowVisible(hwnd))
{
return true;
}
var sb = new StringBuilder(256);
GetClassName(hwnd, sb, sb.Capacity);
if (sb.ToString() == "TscShellContainerClass")
{
found = hwnd;
return false; // stop enumeration
}
return true;
}, IntPtr.Zero);
return found;
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_watchTimer.Stop();
_watchTimer.Dispose();
_overlay?.Close();
_overlay?.Dispose();
}
}
}
+90
View File
@@ -0,0 +1,90 @@
using System.Management;
using System.Text;
using System.Text.RegularExpressions;
using static EonaCat.RdpColorizer.NativeMethods;
namespace EonaCat.RdpColorizer
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Figures out which server/IP an mstsc.exe window is actually connected
/// to, so the watcher can match it against saved profiles. Prefers the
/// process command line (exact, e.g. "10.0.0.5:3389") and falls back to
/// the window title (e.g. "SERVER01 - Remote Desktop Connection") when
/// the command line can't be read - which happens if mstsc.exe is
/// running elevated or under a different user account than this app.
/// </summary>
internal static class RdpTargetResolver
{
public static string? Resolve(nint hwnd)
{
GetWindowThreadProcessId(hwnd, out int pid);
return TryGetCommandLineTarget(pid) ?? TryGetTitleTarget(hwnd);
}
private static string? TryGetCommandLineTarget(int pid)
{
try
{
using var searcher = new ManagementObjectSearcher(
$"SELECT CommandLine FROM Win32_Process WHERE ProcessId = {pid}");
foreach (ManagementObject mo in searcher.Get())
{
string? cmdLine = mo["CommandLine"]?.ToString();
if (string.IsNullOrEmpty(cmdLine))
{
continue;
}
Match match = Regex.Match(cmdLine, @"/v:([^\s""]+)", RegexOptions.IgnoreCase);
if (match.Success)
{
return Normalize(match.Groups[1].Value);
}
}
}
catch
{
// WMI query can fail for permission reasons (e.g. mstsc.exe
// running elevated); fall back to reading the window title.
}
return null;
}
private static string? TryGetTitleTarget(nint hwnd)
{
var sb = new StringBuilder(512);
GetWindowText(hwnd, sb, sb.Capacity);
string title = sb.ToString();
// Typical titles: "SERVER01 - Remote Desktop Connection" or,
// while still negotiating, just "Remote Desktop Connection".
Match match = Regex.Match(title, @"^(.*?)\s*-\s*Remote Desktop Connection", RegexOptions.IgnoreCase);
if (match.Success && !string.IsNullOrWhiteSpace(match.Groups[1].Value))
{
return Normalize(match.Groups[1].Value);
}
return null;
}
private static string Normalize(string target)
{
target = target.Trim();
// Drop a trailing ":port" (but leave bare IPv6 addresses alone -
// we only strip when there's exactly one colon, i.e. IPv4/hostname:port).
int colonCount = target.Split(':').Length - 1;
if (colonCount == 1)
{
int idx = target.IndexOf(':');
target = target[..idx];
}
return target;
}
}
}
+74
View File
@@ -0,0 +1,74 @@
using System.Reflection;
namespace EonaCat.RdpColorizer
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// A tiny borderless, always-on-top badge showing a colored background
/// and label text. RdpSession positions this over an RDP window's
/// caption bar to act as a visual identifier (e.g. "PROD", "DEV-DB1").
/// </summary>
public sealed class TagOverlayForm : Form
{
public TagOverlayForm(string text, Color backColor)
{
FormBorderStyle = FormBorderStyle.None;
StartPosition = FormStartPosition.Manual;
ShowInTaskbar = false;
TopMost = true;
BackColor = backColor;
this.Icon = GetApplicationIcon();
// Pick readable text color based on the tag's brightness.
double luminance = (0.299 * backColor.R + 0.587 * backColor.G + 0.114 * backColor.B) / 255.0;
Color foreColor = luminance > 0.6 ? Color.Black : Color.White;
var label = new Label
{
Text = text,
AutoSize = false,
Dock = DockStyle.Fill,
TextAlign = ContentAlignment.MiddleCenter,
ForeColor = foreColor,
Font = new Font("Segoe UI", 8f, FontStyle.Bold)
};
Controls.Add(label);
using var g = CreateGraphics();
SizeF measured = g.MeasureString(text, label.Font);
Width = (int)measured.Width + 24;
Height = 20;
}
// Prevents the badge from stealing focus when shown.
protected override bool ShowWithoutActivation => true;
private static Icon? GetApplicationIcon()
{
try
{
// The icon is embedded as a resource in the assembly
var assembly = Assembly.GetExecutingAssembly();
if (assembly == null)
{
return null;
}
using var stream = assembly.GetManifestResourceStream("EonaCat.RdpColorizer.icon.ico");
if (stream == null)
{
return null;
}
return new Icon(stream);
}
catch
{
// Ignore errors loading icon
}
return null;
}
}
}
+121
View File
@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<assembly alias="System.Drawing" name="System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
</root>
Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

+172 -41
View File
@@ -1,73 +1,204 @@
Apache License
Version 2.0, January 2004 Apache License
http://www.apache.org/licenses/ Version 2.0, January 2004
http://www.apache.org/licenses/
https://EonaCat.com/license/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
OF SOFTWARE BY EONACAT (JEROEN SAEY)
1. Definitions. 1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. "Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and (b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. (d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work. APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 EonaCat Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
You may obtain a copy of the License at You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
+105 -2
View File
@@ -1,3 +1,106 @@
# EonaCat.RdpColorizer # EonaCat RDP Colorizer
EonaCat RdpColorizer A Windows utility that colors the title bar of Remote Desktop windows and
overlays a small colored tag (e.g. `PROD`, `DEV-DB1`) on the caption, so you
can tell multiple RDP sessions apart at a glance. It runs in the background,
remembers which color/tag belongs to which server, and automatically
re-applies that tag any time a matching RDP window shows up - including
after you restart the app, and for windows opened outside the app entirely.
## A note on "running as a service"
A true Windows Service (the kind registered with the Service Control
Manager) runs in **Session 0**, which has been isolated from the
interactive desktop since Windows Vista. A Session 0 process cannot see or
draw on windows in your logon session at all - this is an OS-level
security boundary, not something that can be coded around. So a real SCM
service could never color or overlay another window's title bar.
What actually achieves the goal - always running, no manual relaunching,
automatic re-tagging - is a background app that starts at logon inside
your own session. That's what this app now does:
- It starts hidden in the **system tray** (no window, no taskbar entry)
when launched with `--background`, or normally shows its control panel
when opened by hand.
- Closing the window just hides it to the tray; the watcher keeps running
until you choose **Exit** from the tray icon's menu.
- Check **Start with Windows** in the app to have it launch automatically
at logon (writes a `HKCU\...\Run` entry pointing at the exe with
`--background` - no admin rights required).
If your environment specifically requires an SCM-registered service (e.g.
for a policy or monitoring reason), that service could still exist to
supervise/restart this tray app, but it would not be able to perform the
window coloring itself.
## How the coloring and tagging work
1. **Native caption color** - `DwmSetWindowAttribute` with
`DWMWA_CAPTION_COLOR` / `DWMWA_TEXT_COLOR`. Only honored on **Windows 11
(build 22000+)**; ignored on Windows 10.
2. **Overlay tag label** - a small always-on-top badge window positioned
over the caption bar, showing your text on your chosen color. Works on
any Windows version and follows the RDP window as it moves, resizes, or
restores (hidden while minimized).
## Auto re-attach by server / IP
- Whenever you tag a connection (Launch or Attach), the target
(server name or IP) plus your chosen label/color is saved to
`%AppData%\EonaCat.RdpColorizer\profiles.json`.
- A background watcher scans for RDP windows **system-wide** every second.
For each one it doesn't recognize yet, it resolves the actual connection
target - first by reading the `mstsc.exe` process's command line
(`/v:target`) via WMI, falling back to parsing the window title if that
fails (e.g. if `mstsc.exe` is running elevated or under a different
account than this app).
- If the resolved target matches a saved profile, the tag/color is applied
automatically - no manual step needed, including for connections opened
straight from the Start menu or a saved `.rdp` file.
- Use **Manage saved profiles...** to review or delete remembered
server → tag mappings.
## Requirements
- Windows 10 or 11
- [.NET 8 SDK](https://dotnet.microsoft.com/download) to build
## Build & run
```powershell
cd EonaCat.RdpColorizer
dotnet build
dotnet run
```
Or publish a standalone exe:
```powershell
dotnet publish -c Release -r win-x64 --self-contained false
```
## Using it
1. Enter a server name/IP and a short tag, pick a color, then click
**Launch new tagged connection** (runs `mstsc.exe /v:target`) or
**Tag active RDP window** to attach to one already open.
2. That server/tag/color is now remembered - close and reopen the app (or
just leave it running in the tray) and any future RDP window to that
same server gets tagged automatically.
3. Tick **Start with Windows** so this happens without you having to open
the app at all.
## Notes / limitations
- Windows caption theming (`DWMWA_CAPTION_COLOR`) is Windows 11 only;
there is no public equivalent for Windows 10 - the overlay tag is your
cross-version fallback.
- The overlay tag is a separate always-on-top window layered over the
caption, so it will show up as its own tiny entry in things like Alt+Tab
previews - expected behavior, not a bug.
- Command-line resolution via WMI requires the watcher to have permission
to query the `mstsc.exe` process; if it's elevated or owned by another
user, the watcher falls back to matching on the window title instead
(which shows the resolved computer name rather than the raw IP you
typed - keep that in mind when naming profiles for IP-based targets).