7 Commits

Author SHA1 Message Date
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
8 changed files with 177 additions and 65 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

@@ -34,6 +34,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);

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.14</Version>
<AssemblyVersion>1.0.14.0</AssemblyVersion>
<FileVersion>1.0.14.0</FileVersion>
<Version>1.1.1</Version>
<AssemblyVersion>1.1.1.0</AssemblyVersion>
<FileVersion>1.1.1.0</FileVersion>
</PropertyGroup>
<ItemGroup>

View File

@@ -181,6 +181,15 @@ public partial class MainWindow : Window
var inputOptions = await CMMCommand.GetInputSourceOptions(m.SerialNumber);
DebugLogger.Log($" Input options count: {inputOptions.Count}");
// Some monitors don't report current input in their possible values list
// Add it if missing so user can see what's currently selected
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})");
}
DebugLogger.Log($" Getting power status...");
var powerStatus = await CMMCommand.GetMonPowerStatus(m.SerialNumber) ?? "Unknown";
DebugLogger.Log($" Power status: {powerStatus}");
@@ -188,7 +197,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 +381,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 +404,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 +429,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.0"
#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))

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

@@ -17,46 +17,51 @@ public static class CMMCommand
public static async Task ScanMonitor()
{
await BytesToFileAsync(new(CMMexe));
await ConsoleHelper.CmdCommandAsync($"{CMMexe} /smonitors {CMMsMonitors}");
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();
// Empty result means timeout - don't retry, monitor is unresponsive
if (string.IsNullOrEmpty(output) && exitCode == -1)
return string.Empty;
if (!string.IsNullOrEmpty(value) && value != "0") return value;
await Task.Delay(500);
await GetMonitorValue(monitorSN, vcpCode, reTry++);
};
// Parse output - ControlMyMonitor outputs the value directly
var value = output?.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).LastOrDefault()?.Trim();
return value;
if (!string.IsNullOrEmpty(value) && exitCode == 0)
return value;
// Only retry on non-timeout failures
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 +74,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 +89,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 +105,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 +129,7 @@ public static class CMMCommand
return options;
}
private static string GetInputSourceName(int vcpValue)
public static string GetInputSourceName(int vcpValue)
{
return vcpValue switch
{
@@ -177,7 +182,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();