diff --git a/EonaCat.RdpColorizer.slnx b/EonaCat.RdpColorizer.slnx
new file mode 100644
index 0000000..8f23fca
--- /dev/null
+++ b/EonaCat.RdpColorizer.slnx
@@ -0,0 +1,3 @@
+
+
+
diff --git a/EonaCat.RdpColorizer/.gitignore b/EonaCat.RdpColorizer/.gitignore
new file mode 100644
index 0000000..7e2e97c
--- /dev/null
+++ b/EonaCat.RdpColorizer/.gitignore
@@ -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
+
diff --git a/EonaCat.RdpColorizer/EonaCat.RdpColorizer.csproj b/EonaCat.RdpColorizer/EonaCat.RdpColorizer.csproj
new file mode 100644
index 0000000..f1dd5f4
--- /dev/null
+++ b/EonaCat.RdpColorizer/EonaCat.RdpColorizer.csproj
@@ -0,0 +1,28 @@
+
+
+
+ WinExe
+ net8.0-windows
+ true
+ enable
+ enable
+ PerMonitorV2
+ EonaCat.RdpColorizer
+ EonaCat.RdpColorizer
+ icon.ico
+
+
+
+
+ Never
+
+
+
+
+
+
+
+
+
+
diff --git a/EonaCat.RdpColorizer/GlobalRdpWatcher.cs b/EonaCat.RdpColorizer/GlobalRdpWatcher.cs
new file mode 100644
index 0000000..aaa06a3
--- /dev/null
+++ b/EonaCat.RdpColorizer/GlobalRdpWatcher.cs
@@ -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.
+
+ ///
+ /// 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.
+ ///
+ public sealed class GlobalRdpWatcher : IDisposable
+ {
+ private readonly ProfileStore _profiles;
+ private readonly System.Windows.Forms.Timer _scanTimer;
+ private readonly Dictionary _tracked = new();
+ private bool _disposed;
+
+ public event Action? SessionAttached;
+ public event Action? 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();
+
+ /// Runs one scan pass immediately (in addition to the timer cadence).
+ public void ScanNow() => Scan();
+
+ private void Scan()
+ {
+ var found = new List();
+ 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(_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();
+ }
+ }
+}
diff --git a/EonaCat.RdpColorizer/MainForm.cs b/EonaCat.RdpColorizer/MainForm.cs
new file mode 100644
index 0000000..f558042
--- /dev/null
+++ b/EonaCat.RdpColorizer/MainForm.cs
@@ -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 _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);
+ }
+ }
+}
diff --git a/EonaCat.RdpColorizer/MainForm.resx b/EonaCat.RdpColorizer/MainForm.resx
new file mode 100644
index 0000000..ce407d7
--- /dev/null
+++ b/EonaCat.RdpColorizer/MainForm.resx
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
\ No newline at end of file
diff --git a/EonaCat.RdpColorizer/NativeMethods.cs b/EonaCat.RdpColorizer/NativeMethods.cs
new file mode 100644
index 0000000..cecb0fb
--- /dev/null
+++ b/EonaCat.RdpColorizer/NativeMethods.cs
@@ -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;
+ }
+ }
+}
diff --git a/EonaCat.RdpColorizer/ProfileManagerForm.cs b/EonaCat.RdpColorizer/ProfileManagerForm.cs
new file mode 100644
index 0000000..ab5682f
--- /dev/null
+++ b/EonaCat.RdpColorizer/ProfileManagerForm.cs
@@ -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.
+
+ /// Lets the user review and delete saved server -> tag/color profiles.
+ 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;
+ }
+ }
+}
diff --git a/EonaCat.RdpColorizer/ProfileManagerForm.resx b/EonaCat.RdpColorizer/ProfileManagerForm.resx
new file mode 100644
index 0000000..ce407d7
--- /dev/null
+++ b/EonaCat.RdpColorizer/ProfileManagerForm.resx
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
\ No newline at end of file
diff --git a/EonaCat.RdpColorizer/ProfileStore.cs b/EonaCat.RdpColorizer/ProfileStore.cs
new file mode 100644
index 0000000..1782eb1
--- /dev/null
+++ b/EonaCat.RdpColorizer/ProfileStore.cs
@@ -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; }
+ }
+
+ ///
+ /// 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.
+ ///
+ public sealed class ProfileStore
+ {
+ private readonly string _path;
+ private readonly List _profiles = new();
+
+ public IReadOnlyList 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>(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);
+ }
+
+ /// Adds a new profile, or updates the label/color of an existing one for the same target.
+ 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));
+ }
+ }
+}
diff --git a/EonaCat.RdpColorizer/Program.cs b/EonaCat.RdpColorizer/Program.cs
new file mode 100644
index 0000000..d3106c3
--- /dev/null
+++ b/EonaCat.RdpColorizer/Program.cs
@@ -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);
+ }
+ }
+}
diff --git a/EonaCat.RdpColorizer/RdpSession.cs b/EonaCat.RdpColorizer/RdpSession.cs
new file mode 100644
index 0000000..f4be34e
--- /dev/null
+++ b/EonaCat.RdpColorizer/RdpSession.cs
@@ -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.
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ /// Launches mstsc.exe against the target and starts tracking it.
+ public void Launch()
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = "mstsc.exe",
+ Arguments = $"/v:{Target}",
+ UseShellExecute = true
+ };
+ _process = Process.Start(psi);
+ _watchTimer.Start();
+ }
+
+ /// Attaches to an already-open mstsc window instead of launching a new one.
+ 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();
+ }
+ }
+}
diff --git a/EonaCat.RdpColorizer/RdpTargetResolver.cs b/EonaCat.RdpColorizer/RdpTargetResolver.cs
new file mode 100644
index 0000000..97e1144
--- /dev/null
+++ b/EonaCat.RdpColorizer/RdpTargetResolver.cs
@@ -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.
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+ }
+}
diff --git a/EonaCat.RdpColorizer/TagOverlayForm.cs b/EonaCat.RdpColorizer/TagOverlayForm.cs
new file mode 100644
index 0000000..35a6c68
--- /dev/null
+++ b/EonaCat.RdpColorizer/TagOverlayForm.cs
@@ -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.
+
+ ///
+ /// 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").
+ ///
+ 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;
+ }
+ }
+}
diff --git a/EonaCat.RdpColorizer/TagOverlayForm.resx b/EonaCat.RdpColorizer/TagOverlayForm.resx
new file mode 100644
index 0000000..ce407d7
--- /dev/null
+++ b/EonaCat.RdpColorizer/TagOverlayForm.resx
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
\ No newline at end of file
diff --git a/EonaCat.RdpColorizer/icon.ico b/EonaCat.RdpColorizer/icon.ico
new file mode 100644
index 0000000..406f265
Binary files /dev/null and b/EonaCat.RdpColorizer/icon.ico differ
diff --git a/LICENSE b/LICENSE
index 494f695..ab37379 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,73 +1,204 @@
-Apache License
-Version 2.0, January 2004
-http://www.apache.org/licenses/
+
+ Apache License
+ 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");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ 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
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
index 986ad13..e6f4498 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,106 @@
-# EonaCat.RdpColorizer
+# EonaCat RDP Colorizer
-EonaCat RdpColorizer
\ No newline at end of file
+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).