12 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
bd7a58a5fe Fix retry loop - don't retry on timeout
All checks were successful
Build / build (push) Successful in 9h0m11s
Build and Release / build (push) Successful in 8h0m13s
- Fixed buggy recursive retry logic
- Reduced max retries from 5 to 2
- Don't retry if timeout occurred (empty result)
- Monitor is unresponsive, retrying just wastes time

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 12:01:47 -05:00
af8b09cfa2 Fix DDC/CI timeout using Task.WhenAny
All checks were successful
Build / build (push) Successful in 9h0m11s
Build and Release / build (push) Successful in 8h0m12s
Previous timeout using CancellationToken didn't work because
ReadToEndAsync blocks waiting for process output. Now using
Task.WhenAny with Task.Delay to properly timeout and kill
hung processes after 5 seconds.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 11:57:13 -05:00
11 changed files with 534 additions and 121 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.13</Version>
<AssemblyVersion>1.0.13.0</AssemblyVersion>
<FileVersion>1.0.13.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

@@ -28,18 +28,57 @@ internal class ConsoleHelper
p.StartInfo.FileName = command;
p.Start();
using var cts = new CancellationTokenSource(timeoutMs);
try
var readTask = p.StandardOutput.ReadToEndAsync();
var completedTask = await Task.WhenAny(readTask, Task.Delay(timeoutMs));
if (completedTask != readTask)
{
var output = await p.StandardOutput.ReadToEndAsync(cts.Token);
await p.WaitForExitAsync(cts.Token);
return output;
}
catch (OperationCanceledException)
{
try { p.Kill(); } catch { }
// Timeout - kill the process
try { p.Kill(true); } catch { }
return string.Empty;
}
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) =>
@@ -63,23 +102,28 @@ internal class ConsoleHelper
}
p.StandardInput.WriteLine("exit");
using var cts = new CancellationTokenSource(timeoutMs);
try
var readTask = Task.Run(async () =>
{
var result = await p.StandardOutput.ReadToEndAsync(cts.Token);
var error = await p.StandardError.ReadToEndAsync(cts.Token);
var result = await p.StandardOutput.ReadToEndAsync();
var error = await p.StandardError.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(error))
result = result + "\r\n<Error Message>:\r\n" + error;
await p.WaitForExitAsync(cts.Token);
p.Close();
Debug.WriteLine(result);
return result;
}
catch (OperationCanceledException)
});
var completedTask = await Task.WhenAny(readTask, Task.Delay(timeoutMs));
if (completedTask != readTask)
{
try { p.Kill(); } catch { }
// Timeout - kill the process
try { p.Kill(true); } catch { }
return string.Empty;
}
var output = await readTask;
p.Close();
Debug.WriteLine(output);
return output;
}
public static string Command(string fileName, params string[] 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,53 +10,63 @@ 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");
}
private static async Task<string> GetMonitorValue(string monitorSN, string vcpCode = "D6", int? reTry = 0)
public static async Task Sleep(string monitorSN)
{
var value = string.Empty;
while (reTry <= 5)
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);
File.Delete(cmdFileName);
// Execute directly without batch file wrapper
var (output, exitCode) = await ConsoleHelper.ExecuteExeAsync(
CMMexe,
$"/GetValue {monitorSN} {vcpCode}");
value = values.Split("\r\n", StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
// Timeout
if (exitCode == -1)
return string.Empty;
if (!string.IsNullOrEmpty(value) && value != "0") return value;
await Task.Delay(500);
await GetMonitorValue(monitorSN, vcpCode, reTry++);
};
// ControlMyMonitor returns the value as the exit code
// Exit code > 0 means success with that value
if (exitCode > 0)
return exitCode.ToString();
return value;
// 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 failure
if (attempt < maxRetries)
await Task.Delay(300);
}
return string.Empty;
}
#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)
@@ -69,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)
@@ -84,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)
@@ -100,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;
@@ -124,7 +134,7 @@ public static class CMMCommand
return options;
}
private static string GetInputSourceName(int vcpValue)
public static string GetInputSourceName(int vcpValue)
{
return vcpValue switch
{
@@ -177,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();
@@ -270,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());
}
}