10 Commits

Author SHA1 Message Date
3c6cc15281 Check for updates when tray menu opens
All checks were successful
Build / build (push) Successful in 9h0m7s
Build and Release / build (push) Successful in 8h0m11s
- Checks for updates in background when user clicks tray icon
- Shows clickable blue banner when update available
- Rate limited to once per hour to avoid spam
- Version 1.1.5

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 02:56:53 -05:00
ce3402f1a9 Add About dialog with version info
All checks were successful
Build / build (push) Successful in 9h0m7s
Build and Release / build (push) Successful in 8h0m11s
- About button added next to Exit in header
- Shows app name, version, author, and company
- Version 1.1.4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 02:51:12 -05:00
0e530238f6 Ship ControlMyMonitor.exe alongside instead of embedding
All checks were successful
Build / build (push) Successful in 9h0m7s
Build and Release / build (push) Successful in 8h0m11s
- Remove embedded resource pattern (dropper behavior triggers AV)
- ControlMyMonitor.exe now copied to output directory as content
- Removes extraction to temp folder at runtime
- Should eliminate Wacatac.H!ml false positive

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 02:47:54 -05:00
fc3ebe14be Add Reset and Detect buttons to Config dialog
All checks were successful
Build / build (push) Successful in 9h0m7s
Build and Release / build (push) Successful in 9h0m12s
- Reset button: clears custom labels, unhides all ports, removes discovered ports
- Detect button: tries common VCP 60 values to discover available input ports
- Version bumped to 1.1.2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 02:41:42 -05:00
0c860d19ea Remove batch file creation to reduce AV false positives
All checks were successful
Build / build (push) Successful in 9h0m10s
Build and Release / build (push) Successful in 9h0m12s
- Refactored all process execution to use direct exe calls
- Added ExecuteExeAsync method for direct process execution
- Removed dynamic .bat file creation that triggered Wacatac detection
- All commands now run ControlMyMonitor.exe directly with arguments

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 02:29:16 -05:00
89c922c265 v1.1.0 - Fix shortcut icons and update publisher
All checks were successful
Build / build (push) Successful in 9h0m7s
Build and Release / build (push) Successful in 8h0m12s
- Add explicit icon to all shortcuts (Start menu, desktop, startup)
- Include ico file in installer for shortcut icons
- Set installer icon
- Update publisher to MarketAlly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 02:13:30 -05:00
38f5aa325c Add portable zip to release workflow
All checks were successful
Build / build (push) Successful in 9h0m8s
Build and Release / build (push) Successful in 9h0m13s
- Create MonitorControl-Portable-{version}.zip alongside installer
- Updated release notes to mention both options
- Portable version useful for users with antivirus false positives

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 02:06:32 -05:00
ac53fbf80e Fix missing input source in dropdown and update metadata
All checks were successful
Build / build (push) Successful in 9h0m28s
Build and Release / build (push) Successful in 8h0m32s
- Add current input to options if monitor doesn't report it in possible values
- Never hide the currently active input port in config filtering
- Make GetInputSourceName public for reuse
- Update company to MarketAlly, author to David H. Friedel Jr
- Add application icon to exe

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 02:03:11 -05:00
139be6f779 Add debug logging for dropdown selection and power unsupported state
All checks were successful
Build / build (push) Successful in 8h0m11s
Build and Release / build (push) Successful in 8h0m12s
- Added detailed logging in CreateInputRow to help diagnose dropdown
  selection issues after config label changes
- Power button now shows "Power Unsupported" and is disabled for
  monitors that don't support DDC/CI power control

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 12:24:55 -05:00
dfec8c07b5 Show 'Unsupported' in config dialog for monitors without input options
All checks were successful
Build / build (push) Successful in 8h0m10s
Build and Release / build (push) Successful in 9h0m14s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 12:14:28 -05:00
11 changed files with 500 additions and 97 deletions

View File

@@ -82,6 +82,17 @@ jobs:
echo Installer directory contents:
dir installer
- name: Create Portable Zip
shell: powershell
run: |
$version = "${{ gitea.ref_name }}".TrimStart("v")
$buildDir = "DellMonitorControl\bin\Release\net9.0-windows"
$zipName = "MonitorControl-Portable-$version.zip"
Write-Host "Creating portable zip: $zipName"
Compress-Archive -Path "$buildDir\*" -DestinationPath $zipName -Force
Write-Host "Zip created successfully"
- name: Create Release
shell: powershell
env:
@@ -104,7 +115,7 @@ jobs:
$body = @{
tag_name = $tag
name = "Monitor Control $tag"
body = "## Monitor Control $tag`n`n### Installation`nDownload and run the installer.`n`n### Features`n- System tray monitor control`n- Brightness/Contrast adjustment`n- Input source switching`n- Quick-switch toolbar`n`n### Requirements`n- Windows 10/11`n- .NET 9.0 Runtime"
body = "## Monitor Control $tag`n`n### Installation`n- **Installer**: Download and run the setup exe`n- **Portable**: Download the zip, extract anywhere, and run DellMonitorControl.exe`n`n### Features`n- System tray monitor control`n- Brightness/Contrast adjustment`n- Input source switching`n- Quick-switch toolbar`n`n### Requirements`n- Windows 10/11`n- .NET 9.0 Runtime"
} | ConvertTo-Json
$release = Invoke-RestMethod -Uri "$baseUrl/repos/$repo/releases" -Method Post -Headers @{"Authorization"="token $env:GITEA_TOKEN"; "Content-Type"="application/json"} -Body $body
@@ -113,10 +124,15 @@ jobs:
}
# Upload installer
$filePath = "C:\build\app\installer\MonitorControl-Setup-$version.exe"
$fileName = "MonitorControl-Setup-$version.exe"
Write-Host "Uploading $fileName..."
$installerPath = "C:\build\app\installer\MonitorControl-Setup-$version.exe"
$installerName = "MonitorControl-Setup-$version.exe"
Write-Host "Uploading $installerName..."
curl.exe -X POST -H "Authorization: token $env:GITEA_TOKEN" -F "attachment=@$installerPath" "$baseUrl/repos/$repo/releases/$releaseId/assets?name=$installerName"
curl.exe -X POST -H "Authorization: token $env:GITEA_TOKEN" -F "attachment=@$filePath" "$baseUrl/repos/$repo/releases/$releaseId/assets?name=$fileName"
# Upload portable zip
$zipPath = "MonitorControl-Portable-$version.zip"
$zipName = "MonitorControl-Portable-$version.zip"
Write-Host "Uploading $zipName..."
curl.exe -X POST -H "Authorization: token $env:GITEA_TOKEN" -F "attachment=@$zipPath" "$baseUrl/repos/$repo/releases/$releaseId/assets?name=$zipName"
Write-Host "Installer uploaded successfully"
Write-Host "All assets uploaded successfully"

View File

@@ -98,12 +98,20 @@
<!-- Footer Buttons -->
<Border Grid.Row="2" Background="#3A3A3A" CornerRadius="0,0,8,8" Padding="12,10">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Cancel" Width="80" Margin="0,0,8,0" Click="CancelButton_Click"
Style="{StaticResource DarkButton}"/>
<Button Content="Save" Width="80" Click="SaveButton_Click"
Style="{StaticResource PrimaryButton}"/>
</StackPanel>
<Grid>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<Button Content="Reset" Width="70" Margin="0,0,8,0" Click="ResetButton_Click"
Style="{StaticResource DarkButton}" ToolTip="Clear all custom labels and unhide all ports"/>
<Button Name="btnDetect" Content="Detect" Width="70" Click="DetectButton_Click"
Style="{StaticResource DarkButton}" ToolTip="Try to detect available input ports"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Cancel" Width="80" Margin="0,0,8,0" Click="CancelButton_Click"
Style="{StaticResource DarkButton}"/>
<Button Content="Save" Width="80" Click="SaveButton_Click"
Style="{StaticResource PrimaryButton}"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</Border>

View File

@@ -1,7 +1,10 @@
using CMM.Library.Config;
using CMM.Library.Method;
using CMM.Library.ViewModel;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
@@ -34,6 +37,22 @@ public partial class ConfigWindow : Window
spPorts.Children.Clear();
_portRows.Clear();
// Show unsupported message if no ports available
if (_availablePorts == null || _availablePorts.Count == 0)
{
spPorts.Children.Add(new TextBlock
{
Text = "Unsupported",
Foreground = Brushes.Gray,
FontSize = 16,
FontStyle = FontStyles.Italic,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 40, 0, 40)
});
return;
}
foreach (var port in _availablePorts)
{
var existingPortConfig = config.Ports.FirstOrDefault(p => p.VcpValue == port.Value);
@@ -164,6 +183,107 @@ public partial class ConfigWindow : Window
Close();
}
private void ResetButton_Click(object sender, RoutedEventArgs e)
{
// Reset all port rows to default values
foreach (var row in _portRows)
{
row.CustomLabel = "";
row.IsHidden = false;
row.ShowInQuickSwitch = false;
}
// Reload the UI to reflect changes
LoadPortConfiguration();
// Clear any discovered ports from config
var config = MonitorConfigManager.GetMonitorConfig(_serialNumber);
config.Ports.Clear();
MonitorConfigManager.SaveMonitorConfig(config);
MonitorConfigManager.ClearCache();
}
private async void DetectButton_Click(object sender, RoutedEventArgs e)
{
btnDetect.IsEnabled = false;
btnDetect.Content = "...";
try
{
// Common VCP 60 values: 15=DP1, 16=DP2, 17=HDMI1, 18=HDMI2, 3=DVI1, 4=DVI2, 1=VGA1, 2=VGA2
var commonPorts = new[] { 15, 16, 17, 18, 3, 4, 1, 2 };
var detectedPorts = new List<int>();
// Get current input so we can restore it
var currentInput = await CMMCommand.GetInputSource(_serialNumber);
foreach (var vcpValue in commonPorts)
{
// Skip ports we already know about
if (_availablePorts.Any(p => p.Value == vcpValue))
continue;
try
{
// Try to set the input - if it succeeds, the port exists
await CMMCommand.SetInputSource(_serialNumber, vcpValue);
await Task.Delay(500); // Give monitor time to respond
// Check if the input actually changed
var newInput = await CMMCommand.GetInputSource(_serialNumber);
if (newInput == vcpValue)
{
detectedPorts.Add(vcpValue);
}
}
catch
{
// Port doesn't exist or isn't supported
}
}
// Restore original input if we have one
if (currentInput.HasValue)
{
await CMMCommand.SetInputSource(_serialNumber, currentInput.Value);
}
if (detectedPorts.Count > 0)
{
// Add detected ports to the available list and config
foreach (var vcpValue in detectedPorts)
{
var name = CMMCommand.GetInputSourceName(vcpValue);
_availablePorts.Add(new InputSourceOption(vcpValue, name));
MonitorConfigManager.AddDiscoveredPort(_serialNumber, _monitorName, vcpValue, name);
}
// Reload to show new ports
MonitorConfigManager.ClearCache();
LoadPortConfiguration();
MessageBox.Show($"Detected {detectedPorts.Count} new port(s):\n" +
string.Join("\n", detectedPorts.Select(v => $" • {CMMCommand.GetInputSourceName(v)}")),
"Detection Complete", MessageBoxButton.OK, MessageBoxImage.Information);
}
else
{
MessageBox.Show("No additional ports were detected.", "Detection Complete",
MessageBoxButton.OK, MessageBoxImage.Information);
}
}
catch (Exception ex)
{
MessageBox.Show($"Detection failed: {ex.Message}", "Error",
MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
btnDetect.Content = "Detect";
btnDetect.IsEnabled = true;
}
}
private class PortConfigRow
{
public int VcpValue { get; set; }

View File

@@ -8,14 +8,16 @@
<RootNamespace>DellMonitorControl</RootNamespace>
<Product>ControlMyMonitorManagement</Product>
<UseWPF>true</UseWPF>
<Company>Dang</Company>
<Copyright>Copyright © DangWang $([System.DateTime]::Now.ToString(yyyy))</Copyright>
<Company>MarketAlly</Company>
<Authors>David H. Friedel Jr</Authors>
<Copyright>Copyright © MarketAlly $([System.DateTime]::Now.ToString(yyyy))</Copyright>
<ApplicationIcon>MonitorIcon.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup>
<Version>1.0.15</Version>
<AssemblyVersion>1.0.15.0</AssemblyVersion>
<FileVersion>1.0.15.0</FileVersion>
<Version>1.1.5</Version>
<AssemblyVersion>1.1.5.0</AssemblyVersion>
<FileVersion>1.1.5.0</FileVersion>
</PropertyGroup>
<ItemGroup>

View File

@@ -187,9 +187,26 @@
<!-- Header -->
<Border Grid.Row="0" Background="#444" CornerRadius="8,8,0,0" Padding="12,10">
<Grid>
<TextBlock Text="Monitor Control" Foreground="White" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center"/>
<Button Content="Exit" HorizontalAlignment="Right" Click="ExitButton_Click"
Style="{StaticResource DarkButton}" FontSize="11"/>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<TextBlock Text="Monitor Control" Foreground="White" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="About" Margin="0,0,6,0" Click="AboutButton_Click"
Style="{StaticResource DarkButton}" FontSize="11"/>
<Button Content="Exit" Click="ExitButton_Click"
Style="{StaticResource DarkButton}" FontSize="11"/>
</StackPanel>
</Grid>
<!-- Update Banner -->
<Border Name="updateBanner" Grid.Row="1" Background="#0078D4" CornerRadius="3"
Padding="8,4" Margin="0,8,0,0" Visibility="Collapsed" Cursor="Hand"
MouseLeftButtonUp="UpdateBanner_Click">
<TextBlock Name="updateText" Text="Update available!" Foreground="White"
FontSize="11" HorizontalAlignment="Center"/>
</Border>
</Grid>
</Border>

View File

@@ -18,6 +18,8 @@ public partial class MainWindow : Window
private List<(XMonitor Monitor, List<InputSourceOption> Options)> _loadedMonitors = new();
private Storyboard? _spinnerStoryboard;
private DispatcherTimer? _showLogButtonTimer;
private UpdateInfo? _pendingUpdate;
private DateTime _lastUpdateCheck = DateTime.MinValue;
public MainWindow()
{
@@ -53,12 +55,50 @@ public partial class MainWindow : Window
};
_showLogButtonTimer.Start();
// Check for updates in background (max once per hour)
if ((DateTime.Now - _lastUpdateCheck).TotalMinutes > 60)
{
_lastUpdateCheck = DateTime.Now;
_ = CheckForUpdatesAsync();
}
Dispatcher.BeginInvoke(new Action(() =>
{
Top = workArea.Bottom - ActualHeight - 10;
}), DispatcherPriority.Loaded);
}
private async Task CheckForUpdatesAsync()
{
try
{
var update = await UpdateChecker.CheckForUpdateAsync();
if (update != null)
{
_pendingUpdate = update;
await Dispatcher.InvokeAsync(() => ShowUpdateBanner(update));
}
}
catch
{
// Silently ignore update check failures
}
}
private void ShowUpdateBanner(UpdateInfo update)
{
updateBanner.Visibility = Visibility.Visible;
updateText.Text = $"v{update.LatestVersion} available - Click to update";
}
private void UpdateBanner_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (_pendingUpdate != null && !string.IsNullOrEmpty(_pendingUpdate.DownloadUrl))
{
UpdateChecker.OpenDownloadPage(_pendingUpdate.DownloadUrl);
}
}
private void Window_Deactivated(object sender, EventArgs e)
{
Hide();
@@ -70,6 +110,85 @@ public partial class MainWindow : Window
Application.Current.Shutdown();
}
private void AboutButton_Click(object sender, RoutedEventArgs e)
{
var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
var versionStr = $"{version?.Major}.{version?.Minor}.{version?.Build}";
var aboutWindow = new Window
{
Title = "About Monitor Control",
Width = 300,
Height = 180,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
ResizeMode = ResizeMode.NoResize,
WindowStyle = WindowStyle.None,
AllowsTransparency = true,
Background = Brushes.Transparent
};
var border = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0xF0, 0x33, 0x33, 0x33)),
CornerRadius = new CornerRadius(8),
BorderBrush = new SolidColorBrush(Color.FromRgb(0x55, 0x55, 0x55)),
BorderThickness = new Thickness(1),
Padding = new Thickness(20)
};
var stack = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
stack.Children.Add(new TextBlock
{
Text = "Monitor Control",
Foreground = Brushes.White,
FontSize = 18,
FontWeight = FontWeights.SemiBold,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 0, 0, 5)
});
stack.Children.Add(new TextBlock
{
Text = $"Version {versionStr}",
Foreground = Brushes.LightGray,
FontSize = 12,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 0, 0, 10)
});
stack.Children.Add(new TextBlock
{
Text = "by David H. Friedel Jr",
Foreground = Brushes.Gray,
FontSize = 11,
HorizontalAlignment = HorizontalAlignment.Center
});
stack.Children.Add(new TextBlock
{
Text = "MarketAlly",
Foreground = Brushes.Gray,
FontSize = 11,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 0, 0, 15)
});
var closeBtn = new Button
{
Content = "OK",
Width = 80,
HorizontalAlignment = HorizontalAlignment.Center,
Style = (Style)FindResource("DarkButton")
};
closeBtn.Click += (s, args) => aboutWindow.Close();
stack.Children.Add(closeBtn);
border.Child = stack;
aboutWindow.Content = border;
aboutWindow.ShowDialog();
}
private void ShowLogButton_Click(object sender, RoutedEventArgs e)
{
var logWindow = new Window
@@ -181,6 +300,27 @@ public partial class MainWindow : Window
var inputOptions = await CMMCommand.GetInputSourceOptions(m.SerialNumber);
DebugLogger.Log($" Input options count: {inputOptions.Count}");
// Add any previously discovered ports from config
var discoveredPorts = MonitorConfigManager.GetDiscoveredPorts(m.SerialNumber, inputOptions);
foreach (var port in discoveredPorts)
{
inputOptions.Insert(0, port);
DebugLogger.Log($" Added discovered port from config: {port.Value} ({port.Name})");
}
// Some monitors don't report current input in their possible values list
// Add it if missing and save to config for future
if (inputSource.HasValue && !inputOptions.Any(o => o.Value == inputSource.Value))
{
var currentInputName = CMMCommand.GetInputSourceName(inputSource.Value);
inputOptions.Insert(0, new InputSourceOption(inputSource.Value, currentInputName));
DebugLogger.Log($" Added missing current input: {inputSource.Value} ({currentInputName})");
// Save this discovered port so it appears in future even when not current
MonitorConfigManager.AddDiscoveredPort(m.SerialNumber, m.MonitorName, inputSource.Value, currentInputName);
DebugLogger.Log($" Saved discovered port to config");
}
DebugLogger.Log($" Getting power status...");
var powerStatus = await CMMCommand.GetMonPowerStatus(m.SerialNumber) ?? "Unknown";
DebugLogger.Log($" Power status: {powerStatus}");
@@ -188,7 +328,8 @@ public partial class MainWindow : Window
_loadedMonitors.Add((m, inputOptions));
// Apply config to filter hidden ports and use custom labels
var filteredOptions = MonitorConfigManager.ApplyConfigToOptions(m.SerialNumber, inputOptions);
// Pass currentInput so we never hide the currently active port
var filteredOptions = MonitorConfigManager.ApplyConfigToOptions(m.SerialNumber, inputOptions, inputSource);
DebugLogger.Log($" Filtered options count: {filteredOptions.Count}");
// Monitor name header with Config button
@@ -371,6 +512,10 @@ public partial class MainWindow : Window
private StackPanel CreateInputRow(int? currentInput, List<InputSourceOption> options, string serialNumber)
{
DebugLogger.Log($" CreateInputRow: currentInput={currentInput}, optionCount={options.Count}");
foreach (var opt in options)
DebugLogger.Log($" Option: Value={opt.Value}, Name={opt.Name}");
var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 3) };
row.Children.Add(new TextBlock { Text = "Input", Foreground = Brushes.LightGray, Width = 70, FontSize = 12, VerticalAlignment = VerticalAlignment.Center });
@@ -390,9 +535,14 @@ public partial class MainWindow : Window
if (currentInput.HasValue)
{
var index = options.FindIndex(o => o.Value == currentInput.Value);
DebugLogger.Log($" Selection: Looking for Value={currentInput.Value}, found at index={index}");
if (index >= 0)
combo.SelectedIndex = index;
}
else
{
DebugLogger.Log($" Selection: currentInput is null, no selection");
}
// Add event handler AFTER setting the initial selection
combo.SelectionChanged += async (s, e) =>
@@ -410,26 +560,33 @@ public partial class MainWindow : Window
var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 3) };
row.Children.Add(new TextBlock { Text = "Power", Foreground = Brushes.LightGray, Width = 70, FontSize = 12, VerticalAlignment = VerticalAlignment.Center });
var isUnsupported = string.IsNullOrEmpty(status) || status == "Unknown";
var btn = new Button
{
Content = status,
Content = isUnsupported ? "Power Unsupported" : status,
Width = 170,
Tag = serialNumber,
Style = (Style)FindResource("DarkButton")
Style = (Style)FindResource("DarkButton"),
IsEnabled = !isUnsupported
};
btn.Click += async (s, e) =>
if (!isUnsupported)
{
if (s is Button b && b.Tag is string sn)
btn.Click += async (s, e) =>
{
var current = await CMMCommand.GetMonPowerStatus(sn);
if (current == "Sleep" || current == "PowerOff")
await CMMCommand.PowerOn(sn);
else
await CMMCommand.Sleep(sn);
await Task.Delay(1000);
b.Content = await CMMCommand.GetMonPowerStatus(sn);
}
};
if (s is Button b && b.Tag is string sn)
{
var current = await CMMCommand.GetMonPowerStatus(sn);
if (current == "Sleep" || current == "PowerOff")
await CMMCommand.PowerOn(sn);
else
await CMMCommand.Sleep(sn);
await Task.Delay(1000);
b.Content = await CMMCommand.GetMonPowerStatus(sn);
}
};
}
row.Children.Add(btn);
return row;

View File

@@ -1,7 +1,8 @@
#define MyAppName "Monitor Control"
#define MyAppVersion "1.0.0"
#define MyAppPublisher "Dang"
#define MyAppVersion "1.1.5"
#define MyAppPublisher "MarketAlly"
#define MyAppExeName "DellMonitorControl.exe"
#define MyAppIcon "MonitorIcon.ico"
#define SourcePath "bin\Release\net9.0-windows"
[Setup]
@@ -19,6 +20,7 @@ Compression=lzma
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=admin
SetupIconFile={#MyAppIcon}
UninstallDisplayIcon={app}\{#MyAppExeName}
[Languages]
@@ -30,12 +32,13 @@ Name: "startupicon"; Description: "Start with Windows"; GroupDescription: "Start
[Files]
Source: "bin\Release\net9.0-windows\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#MyAppIcon}"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\{#MyAppIcon}"
Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: startupicon
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\{#MyAppIcon}"; Tasks: desktopicon
Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\{#MyAppIcon}"; Tasks: startupicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent

View File

@@ -86,7 +86,8 @@ public static class MonitorConfigManager
public static List<InputSourceOption> ApplyConfigToOptions(
string serialNumber,
List<InputSourceOption> options)
List<InputSourceOption> options,
int? currentInput = null)
{
var monitorConfig = GetMonitorConfig(serialNumber);
@@ -101,8 +102,11 @@ public static class MonitorConfigManager
if (portConfig != null)
{
// Respect hidden setting - don't show hidden ports
if (portConfig.IsHidden)
// Never hide the currently active input - user needs to see what's selected
bool isCurrentInput = currentInput.HasValue && option.Value == currentInput.Value;
// Respect hidden setting - don't show hidden ports (unless it's the current input)
if (portConfig.IsHidden && !isCurrentInput)
continue;
if (!string.IsNullOrWhiteSpace(portConfig.CustomLabel))
@@ -122,4 +126,57 @@ public static class MonitorConfigManager
{
_cachedConfig = null;
}
/// <summary>
/// Save a discovered port that the monitor didn't report in its possible values.
/// This ensures ports like DisplayPort-1 are remembered even if the monitor firmware doesn't list them.
/// </summary>
public static void AddDiscoveredPort(string serialNumber, string monitorName, int vcpValue, string defaultName)
{
var config = Load();
var monitorConfig = config.Monitors.FirstOrDefault(m => m.SerialNumber == serialNumber);
if (monitorConfig == null)
{
monitorConfig = new MonitorConfig { SerialNumber = serialNumber, MonitorName = monitorName };
config.Monitors.Add(monitorConfig);
}
// Check if port already exists
if (monitorConfig.Ports.Any(p => p.VcpValue == vcpValue))
return;
// Add the discovered port
monitorConfig.Ports.Add(new PortConfig
{
VcpValue = vcpValue,
DefaultName = defaultName,
CustomLabel = "",
IsHidden = false,
ShowInQuickSwitch = false
});
Save(config);
}
/// <summary>
/// Get any discovered ports from config that the monitor didn't report.
/// </summary>
public static List<InputSourceOption> GetDiscoveredPorts(string serialNumber, List<InputSourceOption> reportedOptions)
{
var discovered = new List<InputSourceOption>();
var monitorConfig = GetMonitorConfig(serialNumber);
foreach (var port in monitorConfig.Ports)
{
// If this port isn't in the reported options, it's a discovered port
if (!reportedOptions.Any(o => o.Value == port.VcpValue))
{
var name = !string.IsNullOrWhiteSpace(port.CustomLabel) ? port.CustomLabel : port.DefaultName;
discovered.Add(new InputSourceOption(port.VcpValue, name));
}
}
return discovered;
}
}

View File

@@ -41,6 +41,46 @@ internal class ConsoleHelper
return await readTask;
}
/// <summary>
/// Execute an exe directly with arguments (no cmd.exe or batch file wrapper)
/// </summary>
public static async Task<(string Output, int ExitCode)> ExecuteExeAsync(string exePath, string arguments, int timeoutMs = 5000)
{
var p = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = exePath,
Arguments = arguments,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
}
};
p.Start();
var readTask = Task.Run(async () =>
{
var output = await p.StandardOutput.ReadToEndAsync();
await p.WaitForExitAsync();
return (output, p.ExitCode);
});
var completedTask = await Task.WhenAny(readTask, Task.Delay(timeoutMs));
if (completedTask != readTask)
{
try { p.Kill(true); } catch { }
return (string.Empty, -1);
}
var result = await readTask;
p.Close();
return result;
}
public static async Task<string> CmdCommandAsync(params string[] cmds) =>
await CommandAsync(cmdFileName, cmds);

View File

@@ -25,13 +25,10 @@
</PropertyGroup>
<ItemGroup>
<None Remove="Resource\ControlMyMonitor.exe" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resource\ControlMyMonitor.exe">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</EmbeddedResource>
<Content Include="Resource\ControlMyMonitor.exe">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>ControlMyMonitor.exe</Link>
</Content>
</ItemGroup>
<ItemGroup>

View File

@@ -10,47 +10,51 @@ namespace CMM.Library.Method;
/// </summary>
public static class CMMCommand
{
static readonly string CMMTmpFolder = Path.Combine(Path.GetTempPath(), $"CMM");
static readonly string CMMexe = Path.Combine(CMMTmpFolder, "ControlMyMonitor.exe");
static readonly string CMMTmpFolder = Path.Combine(Path.GetTempPath(), "CMM");
static readonly string CMMexe = Path.Combine(AppContext.BaseDirectory, "ControlMyMonitor.exe");
static readonly string CMMsMonitors = Path.Combine(CMMTmpFolder, "smonitors.tmp");
public static async Task ScanMonitor()
{
await BytesToFileAsync(new(CMMexe));
await ConsoleHelper.CmdCommandAsync($"{CMMexe} /smonitors {CMMsMonitors}");
// Ensure temp folder exists for output files
Directory.CreateDirectory(CMMTmpFolder);
await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/smonitors {CMMsMonitors}");
}
public static Task PowerOn(string monitorSN)
{
return ConsoleHelper.CmdCommandAsync($"{CMMexe} /SetValue {monitorSN} D6 1");
}
public static Task Sleep(string monitorSN)
public static async Task PowerOn(string monitorSN)
{
return ConsoleHelper.CmdCommandAsync($"{CMMexe} /SetValue {monitorSN} D6 4");
await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/SetValue {monitorSN} D6 1");
}
public static async Task Sleep(string monitorSN)
{
await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/SetValue {monitorSN} D6 4");
}
private static async Task<string> GetMonitorValue(string monitorSN, string vcpCode = "D6", int maxRetries = 2)
{
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
var cmdFileName = Path.Combine(CMMTmpFolder, $"{Guid.NewGuid()}.bat");
var cmd = $"{CMMexe} /GetValue {monitorSN} {vcpCode}\r\n" +
$"echo %errorlevel%";
File.WriteAllText(cmdFileName, cmd);
var values = await ConsoleHelper.ExecuteCommand(cmdFileName);
try { File.Delete(cmdFileName); } catch { }
// Execute directly without batch file wrapper
var (output, exitCode) = await ConsoleHelper.ExecuteExeAsync(
CMMexe,
$"/GetValue {monitorSN} {vcpCode}");
// Empty result means timeout - don't retry, monitor is unresponsive
if (string.IsNullOrEmpty(values))
// Timeout
if (exitCode == -1)
return string.Empty;
var value = values.Split("\r\n", StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
// ControlMyMonitor returns the value as the exit code
// Exit code > 0 means success with that value
if (exitCode > 0)
return exitCode.ToString();
// Also check stdout in case it outputs there
var value = output?.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).LastOrDefault()?.Trim();
if (!string.IsNullOrEmpty(value) && value != "0")
return value;
// Only retry on non-timeout failures
// Only retry on failure
if (attempt < maxRetries)
await Task.Delay(300);
}
@@ -60,9 +64,9 @@ public static class CMMCommand
#region Brightness (VCP Code 10)
public static Task SetBrightness(string monitorSN, int value)
public static async Task SetBrightness(string monitorSN, int value)
{
return ConsoleHelper.CmdCommandAsync($"{CMMexe} /SetValue {monitorSN} 10 {value}");
await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/SetValue {monitorSN} 10 {value}");
}
public static async Task<int?> GetBrightness(string monitorSN)
@@ -75,9 +79,9 @@ public static class CMMCommand
#region Contrast (VCP Code 12)
public static Task SetContrast(string monitorSN, int value)
public static async Task SetContrast(string monitorSN, int value)
{
return ConsoleHelper.CmdCommandAsync($"{CMMexe} /SetValue {monitorSN} 12 {value}");
await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/SetValue {monitorSN} 12 {value}");
}
public static async Task<int?> GetContrast(string monitorSN)
@@ -90,9 +94,9 @@ public static class CMMCommand
#region Input Source (VCP Code 60)
public static Task SetInputSource(string monitorSN, int value)
public static async Task SetInputSource(string monitorSN, int value)
{
return ConsoleHelper.CmdCommandAsync($"{CMMexe} /SetValue {monitorSN} 60 {value}");
await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/SetValue {monitorSN} 60 {value}");
}
public static async Task<int?> GetInputSource(string monitorSN)
@@ -106,7 +110,7 @@ public static class CMMCommand
var options = new List<InputSourceOption>();
var savePath = Path.Combine(CMMTmpFolder, $"{monitorSN}_vcp.tmp");
await ConsoleHelper.CmdCommandAsync($"{CMMexe} /sjson {savePath} {monitorSN}");
await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/sjson {savePath} {monitorSN}");
if (!File.Exists(savePath)) return options;
@@ -130,7 +134,7 @@ public static class CMMCommand
return options;
}
private static string GetInputSourceName(int vcpValue)
public static string GetInputSourceName(int vcpValue)
{
return vcpValue switch
{
@@ -183,7 +187,7 @@ public static class CMMCommand
static async Task ScanMonitorStatus(string savePath, XMonitor mon)
{
await ConsoleHelper.CmdCommandAsync($"{CMMexe} /sjson {savePath} {mon.MonitorID}");
await ConsoleHelper.ExecuteExeAsync(CMMexe, $"/sjson {savePath} {mon.MonitorID}");
var monitorModel = JsonHelper.JsonFormFile<IEnumerable<SMonitorModel>>(savePath);
var status = monitorModel.ReadMonitorStatus();
@@ -276,22 +280,4 @@ public static class CMMCommand
return monitors;
}
static void BytesToFile(FileInfo fi)
{
fi.Refresh();
if (fi.Exists) return;
if (!fi.Directory.Exists) fi.Directory.Create();
File.WriteAllBytes(fi.FullName, fi.Name.ResourceToByteArray());
}
static async Task BytesToFileAsync(FileInfo fi)
{
fi.Refresh();
if (fi.Exists) return;
if (!fi.Directory.Exists) fi.Directory.Create();
await File.WriteAllBytesAsync(fi.FullName, fi.Name.ResourceToByteArray());
}
}