27寸屏左移后,这个小工具救了我无数次

27 寸屏放左边爆发新问题,177 行换回一个能点到的"显示桌面"

显示器换成 27 寸并挪到左侧当主屏之后,Windows 任务栏最右侧那条"显示桌面"细条跟着遭了殃:它固定在所有屏幕拼接后的最右端,如今落在笔记本屏的边缘。鼠标从 27 寸主屏划过去,快一点就冲出细条、甩到屏幕外。于是有了这个工具------一个托盘程序,点一下图标等于按一次 Win+D。5 个 C# 文件共 177 行,编译出来一个 48MB 的单文件 exe,代码全部贴在下面。

一、细线写死在最右端,主屏偏偏在左

这条细线的位置在系统里没有配置项:永远在任务栏最右端,也就是所有显示器拼接结果的右边缘。显示器在笔记本右侧时,细线正好挨着主屏,好点。把 27 寸设为主屏并放到左边之后,最右端属于笔记本屏,点击路径变成------从 27 寸出发,横穿整台笔记本,最后停在右边缘一条细线上。划快了指针直接出界。

上图中间那条竖线就是它。搬显示器之前它一直在那,搬完之后这个动作一天要做很多次,每次都费劲。

二、切换逻辑只有一个方法

不枚举窗口,不记录窗口状态。Shell 自带 COM 接口 IShellDispatch2.ToggleDesktop,行为与 Win+D 完全一致:第一次调用显示桌面,再次调用恢复现场。ShellToggle.cs 全文 30 行:

csharp 复制代码
using System;

namespace ShowDesktopTrayLite;

/// <summary>
/// Toggles the desktop exactly like pressing Win + D: first call shows the
/// desktop (minimizes all windows), second call restores them.
/// Implemented via the Shell's ToggleDesktop method (IShellDispatch2).
/// </summary>
internal static class ShellToggle
{
    public static void ToggleDesktop()
    {
        Type? shellType = Type.GetTypeFromProgID("Shell.Application");
        if (shellType is null)
            return;

        // Must be called on an STA thread (WinForms UI thread is STA).
        dynamic shell = Activator.CreateInstance(shellType)!;
        try
        {
            shell.ToggleDesktop();
        }
        finally
        {
            if (shell is IDisposable disposable)
                disposable.Dispose();
        }
    }
}

两个实现细节。ProgID "Shell.Application" 在运行时解析,不需要引用任何 COM 互操作程序集;调用必须在 STA 线程上,WinForms 的 UI 线程天然满足。没有用 SendInput 模拟 Win+D------模拟按键要经过前台窗口这一层,全屏程序、焦点切换都可能把按键吞掉;COM 调用直达 Shell,中间没有变量。

Shell 其实还提供 MinimizeAll 和 UndoMinimizeALL,但那是两个独立动作,"现在该最小化还是恢复"的判断要自己写;ToggleDesktop 自带切换语义,一次调用解决。

三、托盘:不建窗口,只放图标

托盘部分合计 78 行(TrayApplicationContext 58 行 + TrayIconFactory 20 行)。主程序用 ApplicationContext 而不是 Form:不建窗口,就没有窗口会被 Win+D 顺带最小化,任务栏上也不会多出一个程序图标,托盘里那个电脑图标就是它的全部。

csharp 复制代码
using System.Windows.Forms;

namespace ShowDesktopTrayLite;

/// <summary>
/// Hosts only a notify (tray) icon --- no visible window is ever created.
/// Left click toggles show-desktop, exactly like pressing Win + D.
/// </summary>
public sealed class TrayApplicationContext : ApplicationContext
{
    private readonly NotifyIcon _notifyIcon;

    public TrayApplicationContext()
    {
        _notifyIcon = new NotifyIcon
        {
            Icon = TrayIconFactory.Create(),
            Text = "显示桌面 / 恢复窗口 (Win+D)",
            Visible = true
        };

        var menu = new ContextMenuStrip();

        var toggle = new ToolStripMenuItem("切换 (显示桌面 / 恢复)", null, (_, _) => ShellToggle.ToggleDesktop());

        var autoStart = new ToolStripMenuItem("开机自启动");
        autoStart.Checked = AutoStart.IsEnabled;
        autoStart.Click += (_, _) => autoStart.Checked = AutoStart.Toggle();

        var exit = new ToolStripMenuItem("退出", null, (_, _) => Exit());

        menu.Items.Add(toggle);
        menu.Items.Add(new ToolStripSeparator());
        menu.Items.Add(autoStart);
        menu.Items.Add(exit);
        _notifyIcon.ContextMenuStrip = menu;

        _notifyIcon.MouseClick += (_, e) =>
        {
            if (e.Button == MouseButtons.Left)
                ShellToggle.ToggleDesktop();
        };
    }

    private void Exit()
    {
        _notifyIcon.Visible = false;
        _notifyIcon.Dispose();
        ExitThread();
    }

    protected override void Dispose(bool disposing)
    {
        if (disposing)
            _notifyIcon?.Dispose();
        base.Dispose(disposing);
    }
}

左键 MouseClick 触发切换;右键是菜单,三项:切换、开机自启动、退出。

图标没去找 .ico 文件,PNG 直接作为嵌入资源编进 exe------32x32、825 字节,运行时用 GetHicon() 转成带透明通道的 Icon:

csharp 复制代码
using System.Drawing;

namespace ShowDesktopTrayLite;

internal static class TrayIconFactory
{
    public static Icon Create()
    {
        var assembly = typeof(TrayIconFactory).Assembly;
        var resourceName = $"{assembly.GetName().Name}.029-computer.png";
        using var stream = assembly.GetManifestResourceStream(resourceName)
            ?? throw new InvalidOperationException($"Embedded resource '{resourceName}' not found.");
        using var bitmap = new Bitmap(stream);
        return Icon.FromHandle(bitmap.GetHicon());
    }
}

嵌入资源的名字规则是"程序集名.文件名",csproj 里一行 <EmbeddedResource Include="029-computer.png" /> 搞定。

四、开机自启:一个注册表键,零管理员权限

自启动开关写的是 HKCU\Software\Microsoft\Windows\CurrentVersion\Run------当前用户的键,不碰任务计划程序,全程不需要管理员权限。AutoStart.cs 全文 43 行:

csharp 复制代码
using Microsoft.Win32;

namespace ShowDesktopTrayLite;

/// <summary>
/// Persists the "run at logon" preference in the per-user registry Run key
/// (HKCU\Software\Microsoft\Windows\CurrentVersion\Run). No admin rights needed.
/// </summary>
internal static class AutoStart
{
    private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
    private const string ValueName = "ShowDesktopTrayLite";

    public static bool IsEnabled
    {
        get
        {
            using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath);
            return key?.GetValue(ValueName) is string;
        }
    }

    public static bool Toggle()
    {
        if (IsEnabled)
            Disable();
        else
            Enable();
        return IsEnabled;
    }

    public static void Enable()
    {
        using var key = Registry.CurrentUser.CreateSubKey(RunKeyPath);
        key.SetValue(ValueName, Environment.ProcessPath ?? string.Empty, RegistryValueKind.String);
    }

    public static void Disable()
    {
        using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: true);
        key?.DeleteValue(ValueName, throwOnMissingValue: false);
    }
}

Environment.ProcessPath 拿到当前 exe 的完整路径。单文件发布下它指向的就是那一个文件,拷到哪个目录,注册表里就写哪个目录。单实例靠一个本地互斥体,Program.cs 全文 26 行:

csharp 复制代码
using System.Threading;
using System.Windows.Forms;

namespace ShowDesktopTrayLite;

internal static class Program
{
    // Per-session mutex: prevents multiple tray icons in the same session.
    private const string MutexName = "Local\\ShowDesktopTrayLite";

    [STAThread]
    static void Main()
    {
        using var singleInstance = new Mutex(true, MutexName, out bool created);
        if (!created)
        {
            // An instance is already running in this session.
            return;
        }

        Application.SetHighDpiMode(HighDpiMode.PerMonitorV2);
        Application.EnableVisualStyles();
        Application.SetCompatibleTextRenderingDefault(false);
        Application.Run(new TrayApplicationContext());
    }
}

五、发布:一条命令,一个文件

发布配置固化在 csproj 里(28 行),之后只剩一条命令 dotnet publish -c Release:

xml 复制代码
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<DebugType>embedded</DebugType>

publish 目录里只有一个 ShowDesktopTrayLite.exe,48MB。体积来自自包含的 .NET 9 运行时------整个运行时压进了这一个文件,换来目标机器零依赖。嫌 48MB 大,把 SelfContained 改成 false,exe 缩到 1MB 以内,代价是目标机要装 .NET 桌面运行时。托盘这种小工具我选择背运行时,拷给谁都能双击。

这个工具解决的不是系统缺陷,是布局冲突:细线位置写死在拼接桌面的右端,显示器挪到左边,冲突就出现了。哪天微软把"显示桌面"按钮的位置做成可配置,这 177 行可以立刻删掉;在那之前,它先在托盘里待着。要加功能的话,下一步是把中键做成第二个动作,比如最小化全部窗口。

运行展示

点击图标后可以隐藏/显示桌面,右键功能: