dotnet 职业技术学院

Windows

dotnet 职业技术学院

| windows

按类别查找文章:Windows


uwp dotnet dotnet-core dotnet-standard csharp 技术 gif解析 wpf C# WPF D2D DirectX UWP DirectComposition win2d SharpDX VisualStudio dotnetremoting rpc visualstudio Win10 编程 slack web team msbuild linux xamarin ios resharper git nuget xaml algorithm powershell ime vscode directx roslyn markdown ui ux miscellaneous cpp sharpdx algorithms JavaScript Unicode c# 工具 系统 架构 编译 数学 几何 C#入门 原理 Powershell 性能测试 命令行 asp aspdotnetcore dotnetcore 控制台 WMI .net framework .net源代码 源代码分析 Latex C 算法 Emit Roslyn MSBuild 编译器 调试 渲染 Resharper 打包 Pandoc PowerShell VisualStudio插件 TotalCommander 软件 Jenkins gif await 安装包 InnoSetup 触摸 WPF调试 图片处理 黑科技 UI sublimetext usb 笔迹 输入法 数据库 sqlite Framework dotnetframework remoting 布局 mvvm frp Avalonia 设计规范 规范 反射 jekyll DevOps AzureDevOps 源代码 多线程 VisualStudio调试 doevents 性能优化 水印 uwp文件 pip python 软件设计 文档 docfx 资源分享 p2p 爬虫 SublimeText AE dotpeek 反编译 btsync pandoc Telegram 聊天软件 微信 P2P PPT v8 .NET JVM Direct2D MobaXterm 代理 ssh vps 代理服务器 mock 单元测试 NuGet dnc 进程通信 IPC pipe ScrollViewer WPF源代码 ink dotnettool tool Github GithubAction Diagnostics DUMP Xamarin GTK
2020
03-05 2020

为何使用 DirectComposition

本文主要翻译Why use DirectComposition,介绍 DirectComposition 的功能和优点。

2019
12-23 2019

屏幕边缘上有趣的 1 个像素,看不见、摸不到

如果你的屏幕分辨率是 1920×1080,那么一个全屏的窗口程序尺寸是多少呢?想都不用想,是 1920×1080。

那么输入设备输入的坐标是多少呢?是 X∈[0, 1919] ?还是 X∈[1, 1920] ?还是 X∈[0, 1920]


鼠标输入与触摸输入

一个有趣的问题,因为 1920×1080 分辨率的屏幕,其横向只有 1920 个像素,也就是说如果需要区分一个像素,那么只需要 1920 个数值就够了。这意味着 X∈[0, 1919] 或者 X∈[1, 1920] 的取值范围就能表示横向的所有像素了。

那么实际上最左侧的点的输入数值是多少呢?最右侧的点的输入数值是多少呢?

我写了一个最大化全屏的程序专门用来测试鼠标和触摸输入的数值是多少。

鼠标输入

▲ 在鼠标输入的情况下,最右侧其实是 1919(我的屏幕是 2560×1080,所以最右侧是 2559)

测量的时候,鼠标是直接往右移动到底,移到不能动为止。

那么在触摸输入的时候又如何?

触摸输入

▲ 在触摸输入的情况下,最右侧是 1920(我的屏幕是 2560×1080,所以最右侧是 2560)

测量的时候,是让手指近乎在屏幕外触摸,不断触摸到能够在屏幕上看到的最小或最大值为止。

有趣的 1 像素

发现上面实验中有趣的现象了吗?明明只有 1920×1080 的屏幕分辨率,窗口明明只有 1920×1080 那么大,鼠标下收到正常范围内的输入坐标,而触摸下我们能收到超出我们窗口大小 1 像素的触摸事件!

问题并没有完——

如果说,触摸给了你超出窗口大小的坐标,那么你能如何使用这个坐标呢?虽然程序里收到什么坐标都无所谓(至少不崩),但如果你真拿它来渲染,就会在屏幕之外。

更有趣的是,虽然你能收到这个“在屏幕边缘之外”的坐标,但这个消息并不总会发送到你的程序里。更多的时候,你的程序根本就不会收到这个触摸事件,于是我们也就不能在程序里面更新窗口上显示的坐标到 1920 了,就像鼠标一样。

于是,你可能遇到的问题是:

  1. 如果你在屏幕的左侧边缘触摸,你的程序可以一直收到触摸事件,你能够得到正确的响应;
  2. 如果你在屏幕的右侧边缘触摸,你将仅能偶尔收到零星的刚好超出窗口大小的触摸坐标,大多数时候收不到触摸事件,于是你可能无法获知用户在屏幕右侧边缘进行触摸。

防踩坑秘籍

林德熙小伙伴告诉我说可以特意把窗口的尺寸做大一个像素。我试过了,确实能够让触摸在整个屏幕上生效,但对于双屏用户来说,就能在另外一个屏幕上看到“露馅儿”了的窗口,对于我这种强迫症患者来说,显然是不能接受的。

我的建议是,并不需要对这种情况进行什么特殊的处理。

12-08 2019

Windows 系统的默认字体是什么?应用的默认字体是什么?

作为中文应用的开发者,我们多半会认为系统的默认字体是“微软雅黑”。然而如果真的产生了这种误解,则很容易在开发本地化应用的时候踩坑。

于是本文带你了解 Windows 系统的默认字体。


Windows 10/8.1/8/7/Vista

Windows 操作系统的默认字体是 Segoe UI(发音为 see go 这两个单词),默认的字体大小为 9 点。

Segoe UI

Segoe UI 是 Segoe 字体家族中专为显示器显示而设计的一款字体。当然,Windows 系统中的其他字体也遵循这一命名规则,带 UI 后缀的适用于界面显示,而不带 UI 后缀的适用于打印和其他排版设计。

Segoe UI包含拉丁(Latin),希腊(Greek),西里尔字母(Cyrillic)和阿拉伯(Arabic)字符,覆盖了基本的英文俄文字母、数字和一些常用符号。然而其他语言就没有了。

其他语言的默认字体分别是:

语言 字体
日语(Japanese) Meiryo
韩语(Korean) Malgun Gothic
繁体中文(Chinese (Traditional)) Microsoft JhengHei
简体中文(Chinese (Simplified)) Microsoft YaHei
希伯来语(Hebrew) Gisha
泰语(Thai) Leelawadee

Windows 操作系统在启动应用程序的时候,会根据当前系统用户的地区决定默认字体应该采用哪一个。

Windows XP 及更早系统

早期版本的 Windows,默认字体是 Tahoma。简体中文下则是宋体。


参考资料

11-27 2019

WPF 程序如何跨窗口/跨进程设置控件焦点

WPF 程序提供了 Focus 方法和 TraversalRequest 来在 WPF 焦点范围内转移焦点。但如果 WPF 窗口中嵌入了其他框架的 UI(比如另一个子窗口),那么就需要使用其他的方法来设置焦点了。


一个粗略的设置方法是,使用 Win32 API:

SetFocus(hwnd);

传入的是要设置焦点的窗口的句柄。


参考资料

11-21 2019

发现电脑屏幕总是不自动关闭?看看你是否打开了这些程序……

本文收集一些已知的导致电脑屏幕不关闭的程序。如果你发现无论你设置多短的屏幕关闭超时时间但一直都不关闭,那么可以参考本文检查是否打开了这些程序。


电源和睡眠

先检查一下你系统设置中的电源和睡眠选项,时间不应该太长。一定要先看看这里,别到时候折腾了半天发现是自己设错了就亏了……

电源和睡眠

另外,找程序的时候,不要第一眼看过去没有就忽略它了。因为你可能像我一样有很多个桌面。最好还是用任务管理器找,不会漏掉。

多个桌面

大多数游戏

如果你有游戏没关,你第一个就应该怀疑它!

我不想把我正在玩的游戏列举出来,因为容易过时还会暴露些什么……

应用

  • Microsoft PowerPoint 在演示模式下
  • 金山 WPS 演示 在演示模式下
  • Wallpaper Engine

工具

  • GPU-Z 只要打开就会

为什么我想整理这份名单

因为我总是时不时发现某一天电脑屏幕一直亮着。到了晚上很刺眼的,而且费电……

所以,每发现一个就补充一个好了。如果你有已知的,麻烦在评论区告诉我哟!如果看不到评论区,可以前往这里评论,或者给我发邮件

11-20 2019

PowerShell 的命令行启动参数(可用于执行命令、传参或进行环境配置)

有一些程序不支持被直接启动,而要求通过命令行启动。这个时候,你就需要使用 PowerShell 或者 PowerShell Core 来启动这样的程序。我们都知道如何在命令行或各种终端中启动一个程序,但是当你需要自动启动这个程序的时候,你就需要知道如何通过 PowerShell 或其他命令行终端来启动一个程序,而不是手工输入然后回车运行了。

本文就介绍 PowerShell 的命令行启动参数。利用这些参数,你可以自动化地通过 PowerShell 程序来完成一些原本需要通过手工执行的操作或者突破一些限制。


一些必须通过命令行启动的程序

一般来说,编译生成的 exe 程序都可以直接启动,即便是命令行程序也是如此。但是有一些程序就是要做一些限制。比如下面的 FRP 反向代理程序:

FRP 反向代理程序限制必须从命令行启动

借助 cmd.exe 来启动的方法可以参见我的另一篇博客:

那么我们如何能够借助于 PowerShell 或者 PowerShell 来启动它呢?

PowerShell 的帮助文档

先打开一个 PowerShell。

对于 Windows 自带的基于 .NET Framework 的 PowerShell,使用 powershell 命令可以直接启动 PowerShell。对于基于 .NET Core 版本的 PowerShell Core,使用 pwsh 命令可以直接启动。

关于 .NET Core 版本的 PowerShell Core 可以参见我的另一篇博客:

接下来输入下面三个命令中的任何一个:

  • PowerShell -Help
  • PowerShell -?
  • PowerShell /?

或者对于 PowerShell Core 来说,是下面三个命令中的任何一个:

  • pwsh -Help
  • pwsh -?
  • pwsh /?

你就可以看到 PowerShell 的使用说明:

PowerShell 的使用说明

PowerShell 的启动参数示例

使用 PowerShell 间接启动一个程序并传入参数

下面的命令,使用 PowerShell 间接启动 frpc.exe 反向代理程序,并给 frpc.exe 程序传入 -c ./frpc.ini 的启动参数:

> pwsh -Command "D:\walterlv\frpc.exe -c ./frpc.ini"

或者简写为:

> pwsh -c "D:\walterlv\frpc.exe -c ./frpc.ini"

实际上使用 PowerShell 来做这些事情简直是用牛刀杀鸡,因为本身 PowerShell 非常强大。我们只是因为一些程序的限制不得不使用这样的方案来启动程序而已。

比如其中之一,执行脚本。

使用 PowerShell 执行命令/脚本后保留窗口不退出

需要加上 -NoExit 参数。

> pwsh -NoExit -c "D:\walterlv\frpc.exe -c ./frpc.ini"

一定要注意,-c 和后面的命令必须放到最末尾,因为 -c 后面的所有字符串都会被解析为需要执行的命令。

使用 PowerShell 执行多条命令/脚本

多条脚本之间使用 ; 作为分隔:

> pwsh -c "D:\walterlv\frpc.exe -c ./frpc.ini"; "D:\walterlv\frps.exe -c ./frps.ini"

如果引号里面还需要写引号,则可以把里面的引号改成单引号 ' 或者把外面的引号改为单引号 '

使用 PowerShell 间接执行一个脚本

# Execute a PowerShell Command in a session
PowerShell -Command "Get-EventLog -LogName security"

# Run a script block in a session
PowerShell -Command {Get-EventLog -LogName security}

# An alternate way to run a command in a new session
PowerShell -Command "& {Get-EventLog -LogName security}"

附 PowerShell 的全部启动参数说明

PowerShell[.exe] [-PSConsoleFile <文件> | -Version <版本>]
    [-NoLogo] [-NoExit] [-Sta] [-Mta] [-NoProfile] [-NonInteractive]
    [-InputFormat {Text | XML}] [-OutputFormat {Text | XML}]
    [-WindowStyle <样式>] [-EncodedCommand <Base64 编码命令>]
    [-ConfigurationName <字符串>]
    [-File <文件路径> <参数>] [-ExecutionPolicy <执行策略>]
    [-Command { - | <脚本块> [-args <参数数组>]
                  | <字符串> [<命令参数>] } ]

PowerShell[.exe] -Help | -? | /?

-PSConsoleFile
    加载指定的 Windows PowerShell 控制台文件。若要创建控制台
    文件,请在 Windows PowerShell 中使用 Export-Console。

-Version
    启动指定版本的 Windows PowerShell。
    使用参数输入版本号,如 "-version 2.0"。

-NoLogo
    启动时隐藏版权标志。

-NoExit
    运行启动命令后不退出。

-Sta
    使用单线程单元启动 shell。
    单线程单元(STA)是默认值。

-Mta
    使用多线程单元启动 shell。

-NoProfile
    不加载 Windows PowerShell 配置文件。

-NonInteractive
    不向用户显示交互式提示。

-InputFormat
    描述发送到 Windows PowerShell 的数据的格式。有效值为
    "Text" (文本字符串)或 "XML" (序列化的 CLIXML 格式)。

-OutputFormat
    确定如何设置 Windows PowerShell 输出内容的格式。有效值
    为 "Text" (文本字符串)或 "XML" (序列化的 CLIXML 格式)。

-WindowStyle
    将窗口样式设置为 Normal、Minimized、Maximized 或 Hidden。

-EncodedCommand
    接受 base-64 编码字符串版本的命令。使用此参数
    向 Windows PowerShell 提交需要复杂引号
    或大括号的命令。

-ConfigurationName
    指定运行 Windows PowerShell 的配置终结点。
    该终结点可以是在本地计算机上注册的任何终结点,包括
    默认的 Windows PowerShell 远程处理终结点或具有特定用户角色功能
    的自定义终结点。

-File
    在本地作用域("dot-sourced")中运行指定的脚本,以便
    脚本创建的函数和变量可以在当前
    会话中使用。输入脚本文件路径和任何参数。
    File 必须是命令中的最后一个参数,因为在 File 参数
    名称后面键入的所有字符都将解释
    为后跟脚本参数的脚本文件路径。

-ExecutionPolicy
    设置当前会话的默认执行策略,并将其保存
    在 $env:PSExecutionPolicyPreference 环境变量中。
    该参数不会更改在注册表中
    设置的 Windows PowerShell 执行策略。

-Command
    执行指定的命令(和任何参数),就好像它们是
    在 Windows PowerShell 命令提示符下键入的一样,然后退出,除非
    指定了 NoExit。Command 的值可以为 "-"、字符串或
    脚本块。

    如果 Command 的值为 "-",则从标准输入中读取
    命令文本。

    如果 Command 的值为脚本块,则脚本块必须
    用大括号({})括起来。只有在 Windows PowerShell 中运行 PowerShell.exe 时,
    才能指定脚本块。脚本块的结果将作为反序列化的 XML 对象
    (而非活动对象)返回到父 Shell。

    如果 Command 的值为字符串,则 Command 必须是命令中的
    最后一个参数,因为在命令后面键入的所有字符
    都将解释为命令参数。

    若要编写运行 Windows PowerShell 命令的字符串,请使用以下格式:
        "& {<命令>}"
    其中,引号表示一个字符串,调用运算符(&)
    导致执行命令。

-Help, -?, /?
    显示此消息。如果在 Windows PowerShell 中键入 PowerShell.exe
    命令,请在命令参数前面添加连字符(-),而不是添加正
    斜杠(/)。你可以在 Cmd.exe 中使用连字符或正斜杠。

示例
    PowerShell -PSConsoleFile SqlSnapIn.Psc1
    PowerShell -version 2.0 -NoLogo -InputFormat text -OutputFormat XML
    PowerShell -ConfigurationName AdminRoles
    PowerShell -Command {Get-EventLog -LogName security}
    PowerShell -Command "& {Get-EventLog -LogName security}"

    # To use the -EncodedCommand parameter:
    $command = 'dir "c:\program files" '
    $bytes = [System.Text.Encoding]::Unicode.GetBytes($command)
    $encodedCommand = [Convert]::ToBase64String($bytes)
    powershell.exe -encodedCommand $encodedCommand

参考资料

10-22 2019

.NET/C# 在 64 位进程中读取 32 位进程重定向后的注册表

我们知道,32 位程序在读取注册表的时候,会自动将注册表的路径映射到 32 位路径下,即在 Wow6432Node 子节点下。但是 64 位程序不会映射到 32 位路径下。那么 64 位程序如何读取到 32 位程序写入的注册表路径呢?


Wow6432Node

Wow6432Node

对于 32 位程序,读取注册表路径的时候,会读到 Wow6432Node 节点下的项:

32 位

这张图读取的就是前面截图中的节点。

那么怎样编译的程序是 32-bit 的程序呢?

x86

AnyCPU 32-bit preferred

对于 64 位程序,读取的时候就不会有 Wow6432Node 路径部分。由于我没有在那个路径放注册表项,所以会得到 null

null

那么怎样编译的程序是 64-bit 的程序呢?

x64

AnyCPU

如何在 64 位程序中读取 32 位注册表路径

前面我们的例子代码是这样的:

var value = RegistryHive.LocalMachine.Read(@"SOFTWARE\Walterlv");

可以看到,相同的代码,在 32 位和 64 位进程下得到的结果是不同的:

  • 32 位进程在 32 位系统上,64 位进程在 64 位系统上,读取的路径会是传入的路径;
  • 32 位进程在 64 位系统上,读取的路径会包含 Wow6432Node

那么如何在 64 位进程中读取 32 位注册表路径呢?

方法是在打开注册表项的时候,传入 RegistryView.Registry32

RegistryKey.OpenBaseKey(root, RegistryView.Registry32);

Walterlv.Win32

可以在我的 GitHub 仓库中查看完整的实现。当然,除了上面那句话,其他都不是关键代码,在哪里都可以找得到的。


参考资料

10-22 2019

解决 WPF 嵌套的子窗口在改变窗口大小的时候闪烁的问题

因为 Win32 的窗口句柄是可以跨进程传递的,所以可以用来实现跨进程 UI。不过,本文不会谈论跨进程 UI 的具体实现,只会提及其实现中的一个重要缓解,使用子窗口的方式。

你有可能在使用子窗口之后,发现拖拽改变窗口大小的时候,子窗口中的内容不断闪烁。如果你也遇到了这样的问题,那么正好可以阅读本文来解决。


问题

你可以看一下下面的这张动图,感受一下窗口的闪烁:

窗口闪烁

实际上在拖动窗口的时候,是一直都在闪的,只是每次闪烁都非常快,截取 gif 的时候截不到。

如果你希望实际跑一跑项目看看,可以使用下面的代码:

我特地提取了一个提交下的代码,如果你要尝试,不能使用 master 分支,因为 master 分支修复了闪烁的问题。

后来使用 CreateWindowEx 创建了一个纯 Win32 窗口,这种闪烁现象更容易被截图:

Win32 窗口闪烁

Win32 窗口闪烁 - 动图

解决

    public class HwndWrapper : HwndHost
    {
        protected override HandleRef BuildWindowCore(HandleRef hwndParent)
        {
            const int WS_CHILD = 0x40000000;
++          const int WS_CLIPCHILDREN = 0x02000000;
            var owner = ((HwndSource)PresentationSource.FromVisual(this)).Handle;

            var parameters = new HwndSourceParameters("demo")
            {
                ParentWindow = owner,
--              WindowStyle = (int)(WS_CHILD),
++              WindowStyle = (int)(WS_CHILD | WS_CLIPCHILDREN),
            };
            var source = new HwndSource(parameters);
            source.RootVisual = new ChildPage();
            return new HandleRef(this, source.Handle);
        }

        protected override void DestroyWindowCore(HandleRef hwnd)
        {
        }
    }

原因

正在探索……


参考资料

10-22 2019

.NET 实现 NTFS 文件系统的硬链接 mklink /J(Junction)

我们知道 Windows 系统 NTFS 文件系统提供了硬连接功能,可以通过 mklink 命令开启。如果能够通过代码实现,那么我们能够做更多有趣的事情。

本文提供使用 .NET/C# 代码创建 NTFS 文件系统的硬连接功能(目录联接)。


目录联接

以管理员权限启动 CMD(命令提示符),输入 mklink 命令可以得知 mklink 的用法。

C:\WINDOWS\system32>mklink
创建符号链接。

MKLINK [[/D] | [/H] | [/J]] Link Target

        /D      创建目录符号链接。默认为文件
                符号链接。
        /H      创建硬链接而非符号链接。
        /J      创建目录联接。
        Link    指定新的符号链接名称。
        Target  指定新链接引用的路径
                (相对或绝对)

我们本次要用 .NET/C# 代码实现的是 /J 目录联接。实现的效果像这样:

目录联接

这些文件夹带有一个“快捷方式”的角标,似乎是另一些文件夹的快捷方式一样。但这些与快捷方式的区别在于,应用程序读取路径的时候,目录联接会成为路径的一部分。

比如在 D:\Walterlv\NuGet\ 中创建 debug 目录联接,目标设为 D:\Walterlv\DemoRepo\bin\Debug,那么,你在各种应用程序中使用以下两个路径将被视为同一个:

  • D:\Walterlv\NuGet\debug\DemoRepo-1.0.0.nupkg
  • D:\Walterlv\DemoRepo\bin\Debug\DemoRepo-1.0.0.nupkg

或者这种:

  • D:\Walterlv\NuGet\debug\publish\
  • D:\Walterlv\DemoRepo\bin\Debug\publish\

使用 .NET/C# 实现

本文的代码主要参考自 jeff.brownManipulating NTFS Junction Points in .NET - CodeProject 一文中所附带的源代码。

由于随时可能更新,所以你可以前往 GitHub 仓库打开此代码:

使用 JunctionPoint

如果希望在代码中创建目录联接,则直接使用:

JunctionPoint.Create("walterlv.demo", @"D:\Developments", true);

后面的 true 指定如果目录联接存在,则会覆盖掉原来的目录联接。


参考资料

10-10 2019

什么是模态窗口?本文带你了解模态窗口的本质

做 Windows 桌面应用开发的小伙伴们对“模态窗口”(Modal Dialog)一定不陌生。如果你希望在模态窗口之上做更多的事情,或者自己实现一套模态窗口类似的机制,那么你可能需要了解模态窗口的本质。

本文不会太深,只是从模态窗口一词出发,抵达大家都熟知的一些知识为止。


开发中的模态窗口

在各种系统、语言和框架中,只要有用户可以看见的界面,都存在模态窗口的概念。从交互层面来说,它的形式是在保留主界面作为环境来显示的情况下,打开一个新的界面,用户只能在新的界面上操作,完成或取消后才能返回主界面。从作用上来说,通常是要求用户必须提供某些信息后才能继续操作,或者单纯只是为了广告。

模态窗口的三个特点

如果你希望自己搞一套模态窗口出来,那么只需要满足这三点即可。你可以随便加戏但那都无关紧要。

  1. 保留主界面显示的同时,禁用主界面的用户交互;
  2. 显示子界面,主界面在子界面操作完成后返回;
  3. 当用户试图跳过子界面的交互的时候进行强提醒。

拿 Windows 系统中的模态对话框为例子,大概就像下面这两张图片这样:

有一个小的子界面盖住了主界面,要求用户必须进行选择。Windows 系统设置因为让背景变暗了,所以用户肯定会看得到需要进行的交互;而任务管理器没有让主界面变暗,所以用户在操作子界面的时候,模态窗口的边框和标题栏闪烁以提醒用户注意。

Windows 系统设置

任务管理器

实现模态窗口

对于 Windows 操作系统来说,模态窗口并不是一个单一的概念,你并不能仅通过一个 API 调用就完成显示模态窗口,你需要在不同的时机调用不同的 API 来完成一个模态窗口。如果要完整实现一个自己的模态窗口,则需要编写实现以上三个特点的代码。

当然,你可能会发现实际上你显示一个模态窗口仅仅一句话调用就够了,那是因为你所用的应用程序框架帮你完成了模态窗口的一系列机制。

关于 WPF 框架是如何实现模态窗口的,可以阅读:直击本质:WPF 框架是如何实现模态窗口的

关于如何自己实现一个跨越线程/进程边界的模态窗口,可以阅读:实现 Windows 系统上跨进程/跨线程的模态窗口

如果你希望定制以上第三个特点中强提醒的动画效果,可以阅读:WPF window 子窗口反馈效果(抖动/阴影渐变) - 唐宋元明清2188 - 博客园

API 调用

为了在 Windows 上实现模态窗口,需要一些 Win32 API 调用(当然,框架够用的话直接用框架就好)。

禁用主窗口

我们需要使用到 BOOL EnableWindow(HWND hWnd, BOOL bEnable); 来启用与禁用某个窗口。

EnableWindow(hWnd, false);
try
{
    // 模态显示一个窗口。
}
finally
{
    EnableWindow(hWnd, true);
}
[DllImport("user32")]
private static extern bool EnableWindow(IntPtr hwnd, bool bEnable);

阻塞代码等待操作完成

因为 async/await 的出现,阻塞其实可以使用 await 来实现。虽然这不是真正的阻塞,但可以真实反应出“异步”这个过程,也就是虽然这里在等待,但实际上依然能够继续在同一个线程响应用户的操作。

UWP 中的新 API 当然已经都是使用 async/await 来实现模态等待了,不过 WPF/Windows Forms 比较早,只能使用 Dispatcher 线程模型来实现模态等待。

于是我们可以考虑直接使用现成的 Dispatcher 线程模型来完成等待,方法是调用下面两个当中的任何一个:

  • Window.ShowDialog 也就是直接使用窗口原生的模态
  • Dispatcher.PushFrame 新开一个消息循环以阻塞当前代码的同时继续响应 UI 交互

上面 Window.ShowDialog 的本质也是在调用 Dispatcher.PushFrame,详见:

关于 PushFrame 新开消息循环阻塞的原理可以参考:

当然,还有其他可以新开消息循环的方法。

进行 UI 强提醒

由于我们一开始禁用了主窗口,所以如果用户试图操作主窗口是不会有效果的。然而如果用户不知道当前显示了一个模态窗口需要操作,那么给出提醒也是必要的。

简单的在 UI 上的提醒是最简单的了,比如:

  • 将主界面变暗(UWP 应用,Web 应用喜欢这么做)
  • 将主界面变模糊(iOS 应用喜欢这么做)
  • 在模态窗口上增加一个很厚重的阴影(Android 应用喜欢这么做)

然而 Windows 和 Mac OS 这些古老的系统由于兼容性负担不能随便那么改,于是需要有其他的提醒方式。

Windows 采用的方式是让标题栏闪烁,让阴影闪烁。

而这些特效的处理,来自于子窗口需要处理一些特定的消息 WM_SETCURSOR

详见:WPF window 子窗口反馈效果(抖动/阴影渐变) - 唐宋元明清2188 - 博客园

通常你不需要手工处理这些消息,但是如果你完全定制了窗口样式,则可能需要自行做一个这样的模态窗口提醒效果。

10-10 2019

直击本质:WPF 框架是如何实现模态窗口的

想知道你在 WPF 编写 Window.ShowDialog() 之后,WPF 框架是如何帮你实现模态窗口的吗?

本文就带你来了解这一些。


Window.ShowDialog

WPF 显示模态窗口的方法就是 Window.ShowDialog,因此我们直接进入这个方法查看。由于 .NET Core 版本的 WPF 已经开源,我们会使用 .NET Core 版本的 WPF 源代码。

Window.ShowDialog 的源代码可以在这里查看:

这个方法非常长,所以我只把其中与模态窗口最关键的代码和相关注释留下,其他都删除(这当然是不可编译的):

public Nullable<bool> ShowDialog()
{
    // NOTE:
    // _threadWindowHandles is created here.  This reference is nulled out in EnableThreadWindows
    // when it is called with a true parameter.  Please do not null it out anywhere else.
    // EnableThreadWindow(true) is called when dialog is going away.  Once dialog is closed and
    // thread windows have been enabled, then there no need to keep the array list around.
    // Please see BUG 929740 before making any changes to how _threadWindowHandles works.
    _threadWindowHandles = new ArrayList();
    //Get visible and enabled windows in the thread
    // If the callback function returns true for all windows in the thread, the return value is true.
    // If the callback function returns false on any enumerated window, or if there are no windows
    // found in the thread, the return value is false.
    // No need for use to actually check the return value.
    UnsafeNativeMethods.EnumThreadWindows(SafeNativeMethods.GetCurrentThreadId(),
                                            new NativeMethods.EnumThreadWindowsCallback(ThreadWindowsCallback),
                                            NativeMethods.NullHandleRef);
    //disable those windows
    EnableThreadWindows(false);

    try
    {
        _showingAsDialog = true;
        Show();
    }
    catch
    {
        // NOTE:
        // See BUG 929740.
        // _threadWindowHandles is created before calling ShowDialog and is deleted in
        // EnableThreadWindows (when it's called with true).
        //
        // Window dlg = new Window();
        // Button b = new button();
        // b.OnClick += new ClickHandler(OnClick);
        // dlg.ShowDialog();
        //
        //
        // void OnClick(...)
        // {
        //      dlg.Close();
        //      throw new Exception();
        // }
        //
        //
        // If above code is written, then we get inside this exception handler only after the dialog
        // is closed.  In that case all the windows that we disabled before showing the dialog have already
        // been enabled and _threadWindowHandles set to null in EnableThreadWindows.  Thus, we don't
        // need to do it again.
        //
        // In any other exception cases, we get in this handler before Dialog is closed and thus we do
        // need to enable all the disable windows.
        if (_threadWindowHandles != null)
        {
            // Some exception case. Re-enable the windows that were disabled
            EnableThreadWindows(true);
        }
    }
}

觉得代码还是太长?不要紧,我再简化一下:

  1. EnumThreadWindows 获取当前线程的所有窗口
  2. 把当前线程中的所有窗口都禁用掉(用的是 Win32 API 的禁用哦,这不会导致窗口内控件的样式变为禁用状态)
  3. 将窗口显示出来(如果出现异常,则还原之前禁用的窗口)

可以注意到禁用掉的窗口是“当前线程”的哦。

ShowHelper

接下来的重点方法是 Window.ShowDialog 中的那句 Show()。在 Show() 之前设置了 _showingAsDialogtrue,于是这里会调用 ShowHelper 方法并传入 true

下面的代码也是精简后的 ShowHelper 方法:

private object ShowHelper(object booleanBox)
{
    try
    {
        // tell users we're going modal
        ComponentDispatcher.PushModal();

        _dispatcherFrame = new DispatcherFrame();
        Dispatcher.PushFrame(_dispatcherFrame);
    }
    finally
    {
        // tell users we're going non-modal
        ComponentDispatcher.PopModal();
    }
}

可以看到,重点是 PushModalPopModal 以及 PushFrame

PushFrame 的效果就是让调用 ShowDialog 的代码看起来就像阻塞了一样(实际上就是阻塞了,只不过开了新的消息循环看起来 UI 不卡)。

关于 PushFrame 为什么能够“阻塞”你的代码的同时还能继续响应 UI 操作的原理,可以阅读:

那么 ComponentDispatcher.PushModalComponentDispatcher.PopModal 呢?可以在这里(ComponentDispatcherThread.cs)看它的代码,实际上是为了模态计数以及引发事件的,对模态的效果没有本质上的影响。

10-10 2019

Windows 系统上用 .NET/C# 查找所有窗口,并获得窗口的标题、位置、尺寸、最小化、可见性等各种状态

在 Windows 应用开发中,如果需要操作其他的窗口,那么可以使用 EnumWindows 这个 API 来枚举这些窗口。

你可以使用本文编写的一个类型,查找到所有窗口中你关心的信息。


需要使用的 API

枚举所有窗口仅需要使用到 EnumWindows,其中需要定义一个委托 WndEnumProc 作为传入参数的类型。

剩下的我们需要其他各种方法用于获取窗口的其他属性。

private delegate bool WndEnumProc(IntPtr hWnd, int lParam);

[DllImport("user32")]
private static extern bool EnumWindows(WndEnumProc lpEnumFunc, int lParam);

[DllImport("user32")]
private static extern IntPtr GetParent(IntPtr hWnd);

[DllImport("user32")]
private static extern bool IsWindowVisible(IntPtr hWnd);

[DllImport("user32")]
private static extern int GetWindowText(IntPtr hWnd, StringBuilder lptrString, int nMaxCount);

[DllImport("user32")]
private static extern int GetClassName(IntPtr hWnd, StringBuilder lpString, int nMaxCount);

[DllImport("user32")]
private static extern bool GetWindowRect(IntPtr hWnd, ref LPRECT rect);

[StructLayout(LayoutKind.Sequential)]
private readonly struct LPRECT
{
    public readonly int Left;
    public readonly int Top;
    public readonly int Right;
    public readonly int Bottom;
}

枚举所有窗口

我将以上 API 封装成 FindAll 函数,并提供过滤器可以给大家过滤众多的窗口使用。

比如,我写了下面一个简单的示例,可以输出当前可见的所有窗口以及其位置和尺寸:

using System;

namespace Walterlv.WindowDetector
{
    class Program
    {
        static void Main(string[] args)
        {
            var windows = WindowEnumerator.FindAll();
            for (int i = 0; i < windows.Count; i++)
            {
                var window = windows[i];
                Console.WriteLine($@"{i.ToString().PadLeft(3, ' ')}. {window.Title}
     {window.Bounds.X}, {window.Bounds.Y}, {window.Bounds.Width}, {window.Bounds.Height}");
            }
            Console.ReadLine();
        }
    }
}

获取所有窗口以及其位置和尺寸

这里的 FindAll 方法,我提供了一个默认参数,可以指定如何过滤所有枚举到的窗口。如果不指定,则会找可见的,包含标题的,没有最小化的窗口。如果你希望找一些看不见的窗口,可以自己写过滤条件。

什么都不要过滤的话,就传入 _ => true,意味着所有的窗口都会被枚举出来。

附源码

因为源代码会经常更新,所以建议在这里查看:

无法访问的话,可以看下面:

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Text;

namespace Walterlv.WindowDetector
{
    /// <summary>
    /// 包含枚举当前用户空间下所有窗口的方法。
    /// </summary>
    public class WindowEnumerator
    {
        /// <summary>
        /// 查找当前用户空间下所有符合条件的窗口。如果不指定条件,将仅查找可见窗口。
        /// </summary>
        /// <param name="match">过滤窗口的条件。如果设置为 null,将仅查找可见窗口。</param>
        /// <returns>找到的所有窗口信息。</returns>
        public static IReadOnlyList<WindowInfo> FindAll(Predicate<WindowInfo> match = null)
        {
            var windowList = new List<WindowInfo>();
            EnumWindows(OnWindowEnum, 0);
            return windowList.FindAll(match ?? DefaultPredicate);

            bool OnWindowEnum(IntPtr hWnd, int lparam)
            {
                // 仅查找顶层窗口。
                if (GetParent(hWnd) == IntPtr.Zero)
                {
                    // 获取窗口类名。
                    var lpString = new StringBuilder(512);
                    GetClassName(hWnd, lpString, lpString.Capacity);
                    var className = lpString.ToString();

                    // 获取窗口标题。
                    var lptrString = new StringBuilder(512);
                    GetWindowText(hWnd, lptrString, lptrString.Capacity);
                    var title = lptrString.ToString().Trim();

                    // 获取窗口可见性。
                    var isVisible = IsWindowVisible(hWnd);

                    // 获取窗口位置和尺寸。
                    LPRECT rect = default;
                    GetWindowRect(hWnd, ref rect);
                    var bounds = new Rectangle(rect.Left, rect.Top, rect.Right - rect.Left, rect.Bottom - rect.Top);

                    // 添加到已找到的窗口列表。
                    windowList.Add(new WindowInfo(hWnd, className, title, isVisible, bounds));
                }

                return true;
            }
        }

        /// <summary>
        /// 默认的查找窗口的过滤条件。可见 + 非最小化 + 包含窗口标题。
        /// </summary>
        private static readonly Predicate<WindowInfo> DefaultPredicate = x => x.IsVisible && !x.IsMinimized && x.Title.Length > 0;

        private delegate bool WndEnumProc(IntPtr hWnd, int lParam);

        [DllImport("user32")]
        private static extern bool EnumWindows(WndEnumProc lpEnumFunc, int lParam);

        [DllImport("user32")]
        private static extern IntPtr GetParent(IntPtr hWnd);

        [DllImport("user32")]
        private static extern bool IsWindowVisible(IntPtr hWnd);

        [DllImport("user32")]
        private static extern int GetWindowText(IntPtr hWnd, StringBuilder lptrString, int nMaxCount);

        [DllImport("user32")]
        private static extern int GetClassName(IntPtr hWnd, StringBuilder lpString, int nMaxCount);

        [DllImport("user32")]
        private static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab);

        [DllImport("user32")]
        private static extern bool GetWindowRect(IntPtr hWnd, ref LPRECT rect);

        [StructLayout(LayoutKind.Sequential)]
        private readonly struct LPRECT
        {
            public readonly int Left;
            public readonly int Top;
            public readonly int Right;
            public readonly int Bottom;
        }
    }

    /// <summary>
    /// 获取 Win32 窗口的一些基本信息。
    /// </summary>
    public readonly struct WindowInfo
    {
        public WindowInfo(IntPtr hWnd, string className, string title, bool isVisible, Rectangle bounds) : this()
        {
            Hwnd = hWnd;
            ClassName = className;
            Title = title;
            IsVisible = isVisible;
            Bounds = bounds;
        }

        /// <summary>
        /// 获取窗口句柄。
        /// </summary>
        public IntPtr Hwnd { get; }

        /// <summary>
        /// 获取窗口类名。
        /// </summary>
        public string ClassName { get; }

        /// <summary>
        /// 获取窗口标题。
        /// </summary>
        public string Title { get; }

        /// <summary>
        /// 获取当前窗口是否可见。
        /// </summary>
        public bool IsVisible { get; }

        /// <summary>
        /// 获取窗口当前的位置和尺寸。
        /// </summary>
        public Rectangle Bounds { get; }

        /// <summary>
        /// 获取窗口当前是否是最小化的。
        /// </summary>
        public bool IsMinimized => Bounds.Left == -32000 && Bounds.Top == -32000;
    }
}

10-10 2019

使用 SetWindowCompositionAttribute 来控制程序的窗口边框和背景(可以做 Acrylic 亚克力效果、模糊效果、主题色效果等)

Windows 系统中有一个没什么文档的 API,SetWindowCompositionAttribute,可以允许应用的开发者将自己窗口中的内容渲染与窗口进行组合。这可以实现很多系统中预设的窗口特效,比如 Windows 7 的毛玻璃特效,Windows 8/10 的前景色特效,Windows 10 的模糊特效,以及 Windows 10 1709 的亚克力(Acrylic)特效。而且这些组合都发生在 dwm 进程中,不会额外占用应用程序的渲染性能。

本文介绍 SetWindowCompositionAttribute 可以实现的所有效果。你可以通过阅读本文了解到与系统窗口可以组合渲染到哪些程度。


试验用的源代码

本文将创建一个简单的 WPF 程序来验证 SetWindowCompositionAttribute 能达到的各种效果。你也可以不使用 WPF,得到类似的效果。

简单的项目文件结构是这样的:

  • [项目] Walterlv.WindowComposition
    • App.xaml
    • App.xaml.cs
    • MainWindow.xaml
    • MainWindow.xaml.cs
    • WindowAccentCompositor

其中,App.xaml 和 App.xaml.cs 保持默认生成的不动。

为了验证此 API 的效果,我需要将 WPF 主窗口的背景色设置为纯透明或者 null,而设置 ControlTemplate 才能彻彻底底确保所有的样式一定是受我们自己控制的,我们在 ControlTemplate 中没有指定任何可以显示的内容。MainWindow.xaml 的全部代码如下:

<Window x:Class="Walterlv.WindowComposition.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="欢迎访问吕毅的博客:blog.walterlv.com" Height="450" Width="800">
    <Window.Template>
        <ControlTemplate TargetType="Window">
            <AdornerDecorator>
                <ContentPresenter />
            </AdornerDecorator>
        </ControlTemplate>
    </Window.Template>
    <!-- 我们注释掉 WindowChrome,是因为即将验证 WindowChrome 带来的影响。 -->
    <!--<WindowChrome.WindowChrome>
        <WindowChrome GlassFrameThickness="-1" />
    </WindowChrome.WindowChrome>-->
    <Grid>
    </Grid>
</Window>

而 MainWindow.xaml.cs 中,我们简单调用一下我们即将写的调用 SetWindowCompositionAttribute 的类型。

using System.Windows;
using System.Windows.Media;
using Walterlv.Windows.Effects;

namespace Walterlv.WindowComposition
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            var compositor = new WindowAccentCompositor(this);
            compositor.Composite(Color.FromRgb(0x18, 0xa0, 0x5e));
        }
    }
}

还剩下一个 WindowAccentCompositor.cs 文件,因为比较长放到博客里影响阅读,所以建议前往这里查看:

而其中对我们最终渲染效果有影响的就是 AccentPolicy 类型的几个属性。其中 AccentState 属性是下面这个枚举,而 GradientColor 将决定窗口渲染时叠加的颜色。

private enum AccentState
{
    ACCENT_DISABLED = 0,
    ACCENT_ENABLE_GRADIENT = 1,
    ACCENT_ENABLE_TRANSPARENTGRADIENT = 2,
    ACCENT_ENABLE_BLURBEHIND = 3,
    ACCENT_ENABLE_ACRYLICBLURBEHIND = 4,
    ACCENT_INVALID_STATE = 5,
}

影响因素

经过试验,对最终显示效果有影响的有这些:

  • 选择的 AccentState 枚举值
  • 使用的 GradientColor 叠加色
  • 是否使用 WindowChrome 让客户区覆盖非客户区
  • 目标操作系统(Windows 7/8/8.1/10)

使用 WindowChrome,你可以用你自己的 UI 覆盖掉系统的 UI 窗口样式。关于 WindowChrome 让客户区覆盖非客户区的知识,可以阅读:

需要注意的是,WindowChromeGlassFrameThickness 属性可以设置窗口边框的粗细,设置为 0 将导致窗口没有阴影,设置为负数将使得整个窗口都是边框。

排列组合

我们依次来看看效果。

AccentState=ACCENT_DISABLED

使用 ACCENT_DISABLED 时,GradientColor 叠加色没有任何影响,唯一影响渲染的是 WindowChrome 和操作系统。


不使用 WindowChrome,在 Windows 10 上:

without WindowChrome in Windows 10


不使用 WindowChrome 在 Windows 7 上:

without WindowChrome in Windows 7


在 Windows 10 上,使用 WindowChrome

<WindowChrome.WindowChrome>
    <WindowChrome />
</WindowChrome.WindowChrome>

with WindowChrome in Windows 10


在 Windows 7 上,使用 WindowChrome

with WindowChrome in Windows 7

当然,以上边框比较细,跟系统不搭,可以设置成其他值:

bold thickness WindowChrome in Windows 7


在 Windows 10 上,使用 WindowChrome 并且 GlassFrameThickness 设置为 -1

<WindowChrome.WindowChrome>
    <WindowChrome GlassFrameThickness="-1" />
</WindowChrome.WindowChrome>

-1 glass frame in Windows 10


而在 Windows 7 上,这就是非常绚丽的全窗口的 Aero 毛玻璃特效:

-1 glass frame in Windows 7

AccentState=ACCENT_ENABLE_GRADIENT

使用 ACCENT_DISABLED 时,GradientColor 叠加色会影响到最终的渲染效果。

还记得我们前面叠加的颜色是什么吗?

叠加的颜色

接下来别忘了然后把它误以为是我系统的主题色哦!


不使用 WindowChrome,在 Windows 10 上:

without WindowChrome in Windows 10

另外,你会注意到左、下、右三个方向上边框会深一些。那是 Windows 10 的窗口阴影效果,因为实际上 Windows 10 叠加的阴影也是窗口区域的一部分,只是一般人看不出来而已。我们叠加了颜色之后,这里就露馅儿了。

另外,这个颜色并不是我们自己的进程绘制的哦,是 dwm 绘制的颜色。

如果不指定 GradientColor 也就是保持为 0,你将看到上面绿色的部分全是黑色的;嗯,包括阴影的部分……

without WindowChrome in Windows 10 - default gradient color


不使用 WindowChrome 在 Windows 7 上:

without WindowChrome in Windows 7

可以看出,在 Windows 7 上,GradientColor 被无视了。


而使用 WindowChrome 在 Windows 10 上,则可以得到整个窗口的叠加色:

<WindowChrome.WindowChrome>
    <WindowChrome GlassFrameThickness="16 48 16 16" />
</WindowChrome.WindowChrome>

with WindowChrome in Windows 10

可以注意到,窗口获得焦点的时候,整个窗口都是叠加色;而窗口失去焦点的时候,指定了边框的部分颜色会更深(换其他颜色叠加可以看出来是叠加了半透明黑色)。

如果你希望失去焦点的时候,边框部分不要变深,请将边框设置为 -1

<WindowChrome.WindowChrome>
    <WindowChrome GlassFrameThickness="-1" />
</WindowChrome.WindowChrome>

使用 WindowChrome 在 Windows 7 上,依然没有任何叠加色的效果:

with WindowChrome in Windows 7

AccentState=ACCENT_ENABLE_TRANSPARENTGRADIENT

使用 ACCENT_ENABLE_TRANSPARENTGRADIENT 时,GradientColor 叠加色没有任何影响,唯一影响渲染的是 WindowChrome 和操作系统。


不使用 WindowChrome,在 Windows 10 上:

without WindowChrome frame in Windows 10

依然左、下、右三个方向上边框会深一些,那是 Windows 10 的窗口阴影效果。


不使用 WindowChrome 在 Windows 7 上:

without WindowChrome in Windows 7

GradientColor 也是被无视的,而且效果跟之前一样。


使用 WindowChrome 在 Windows 10 上,在获得焦点的时候整个背景是系统主题色;而失去焦点的时候是灰色,但边框部分是深色。

with WindowChrome frame in Windows 10

依然可以将边框设置为 -1 使得边框不会变深:

with WindowChrome in Windows 10


使用 WindowChrome 在 Windows 7 上,依然是老样子:

with WindowChrome in Windows 7

AccentState=ACCENT_ENABLE_BLURBEHIND

ACCENT_ENABLE_BLURBEHIND 可以在 Windows 10 上做出模糊效果,就跟 Windows 10 早期版本的模糊效果是一样的。你可以看我之前的一篇博客,那时亚克力效果还没出来:

使用 ACCENT_ENABLE_BLURBEHIND 时,GradientColor 叠加色没有任何影响,唯一影响渲染的是 WindowChrome 和操作系统。


在 Windows 10 上,没有使用 WindowChrome

模糊效果

你可能需要留意一下那个“诡异”的模糊范围,你会发现窗口的阴影外侧也是有模糊的!!!你能忍吗?肯定不能忍,所以还是乖乖使用 WindowChrome 吧!


在 Windows 7 上,没有使用 WindowChrome,效果跟其他值一样,依然没有变化:

without WindowChrome in Windows 7


在 Windows 10 上,使用 WindowChrome

with WindowChrome in Windows 10


使用 WindowChrome 在 Windows 7 上,依然是老样子:

with WindowChrome in Windows 7

AccentState=ACCENT_ENABLE_ACRYLICBLURBEHIND

从 Windows 10 (1803) 开始,Win32 程序也能添加亚克力效果了,因为 SetWindowCompositionAttribute 的参数枚举新增了 ACCENT_ENABLE_ACRYLICBLURBEHIND

亚克力效果相信大家不陌生,那么在 Win32 应用程序里面使用的效果是什么呢?


不使用 WindowChrome,在 Windows 10 上:

without WindowChrome in Windows 10

咦!等等!这不是跟之前一样吗?


嗯,下面就是不同了,亚克力效果支持与半透明的 GradientColor 叠加,所以我们需要将传入的颜色修改为半透明:

    var compositor = new WindowAccentCompositor(this);
--  compositor.Composite(Color.FromRgb(0x18, 0xa0, 0x5e));
++  compositor.Composite(Color.FromArgb(0x3f, 0x18, 0xa0, 0x5e));

acrylic without WindowChrome


那么如果改为全透明会怎么样呢?

不幸的是,完全没有效果!!!

    var compositor = new WindowAccentCompositor(this);
--  compositor.Composite(Color.FromRgb(0x18, 0xa0, 0x5e));
++  compositor.Composite(Color.FromArgb(0x00, 0x18, 0xa0, 0x5e));

no acrylic without WindowChrome


接下来是使用 WindowChrome 时:

<WindowChrome.WindowChrome>
    <WindowChrome GlassFrameThickness="16 48 16 16" />
</WindowChrome.WindowChrome>

acrylic with WindowChrome frame

然而周围有一圈偏白色的渐变是什么呢?那个其实是 WindowChrome 设置的边框白,被亚克力效果模糊后得到的混合效果。


所以,如果要获得全窗口的亚克力效果,请将边框设置成比较小的值:

<WindowChrome.WindowChrome>
    <WindowChrome GlassFrameThickness="0 1 0 0" />
</WindowChrome.WindowChrome>

acrylic with thin WindowChrome frame


记得不要像前面的那些效果一样,如果设置成 -1,你将获得纯白色与设置的 Gradient 叠加色的亚克力特效,是个纯色:

acrylic with WindowChrome -1 frame


你可以将叠加色的透明度设置得小一些,这样可以看出叠加的颜色:

    var compositor = new WindowAccentCompositor(this);
--  compositor.Composite(Color.FromRgb(0x18, 0xa0, 0x5e));
++  compositor.Composite(Color.FromArgb(0xa0, 0x18, 0xa0, 0x5e));

acrylic with darker gradient color


那么可以设置为全透明吗?

    var compositor = new WindowAccentCompositor(this);
--  compositor.Composite(Color.FromRgb(0x18, 0xa0, 0x5e));
++  compositor.Composite(Color.FromArgb(0x00, 0x18, 0xa0, 0x5e));

很不幸,最终你会完全看不到亚克力效果,而变成了毫无特效的透明窗口:

acrylic with transparent gradient color

最上面那根白线,是我面前面设置边框为 0 1 0 0 导致的。


如果在这种情况下,将边框设置为 0 会怎样呢?记得前面我们说过的吗,会导致阴影消失哦!

呃……你将看到……这个……

什么都没有……

acrylic with zero WindowChrome frame thickness

是不是找到了一条新的背景透明异形窗口的方法?

还是省点心吧,亚克力效果在 Win32 应用上的性能还是比较堪忧的……

想要背景透明,请参见:


不用考虑 Windows 7,因为大家都知道不支持。实际效果会跟前面的一模一样。

AccentState=ACCENT_INVALID_STATE

这个值其实不用说了,因为 AccentState 在不同系统中可用的值不同,为了保证向后兼容性,对于新系统中设置的值,旧系统其实就视之为 ACCENT_INVALID_STATE

那么如果系统认为设置的是 ACCENT_INVALID_STATE 会显示成什么样子呢?

答案是,与 ACCENT_DISABLED 完全相同。

总结

由于 Windows 7 上所有的值都是同样的效果,所以下表仅适用于 Windows 10。

  效果
ACCENT_DISABLED 黑色(边框为纯白色)
ACCENT_ENABLE_GRADIENT GradientColor 颜色(失焦后边框为深色)
ACCENT_ENABLE_TRANSPARENTGRADIENT 主题色(失焦后边框为深色)
ACCENT_ENABLE_BLURBEHIND 模糊特效(失焦后边框为灰色)
ACCENT_ENABLE_ACRYLICBLURBEHIND 与 GradientColor 叠加颜色的亚克力特效
ACCENT_INVALID_STATE 黑色(边框为纯白色)

在以上的特效之下,WindowChrome 可以让客户区覆盖非客户区,或者让整个窗口都获得特效,而不只是标题栏。

附源代码

请参见 GitHub 地址以获得最新代码。如果不方便访问,那么就看下面的吧。

using System;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;

namespace Walterlv.Windows.Effects
{
    /// <summary>
    /// 为窗口提供模糊特效。
    /// </summary>
    public class WindowAccentCompositor
    {
        private readonly Window _window;

        /// <summary>
        /// 创建 <see cref="WindowAccentCompositor"/> 的一个新实例。
        /// </summary>
        /// <param name="window">要创建模糊特效的窗口实例。</param>
        public WindowAccentCompositor(Window window) => _window = window ?? throw new ArgumentNullException(nameof(window));

        public void Composite(Color color)
        {
            Window window = _window;
            var handle = new WindowInteropHelper(window).EnsureHandle();

            var gradientColor =
                // 组装红色分量。
                color.R << 0 |
                // 组装绿色分量。
                color.G << 8 |
                // 组装蓝色分量。
                color.B << 16 |
                // 组装透明分量。
                color.A << 24;

            Composite(handle, gradientColor);
        }

        private void Composite(IntPtr handle, int color)
        {
            // 创建 AccentPolicy 对象。
            var accent = new AccentPolicy
            {
                AccentState = AccentState.ACCENT_ENABLE_ACRYLICBLURBEHIND,
                GradientColor = 0,
            };

            // 将托管结构转换为非托管对象。
            var accentPolicySize = Marshal.SizeOf(accent);
            var accentPtr = Marshal.AllocHGlobal(accentPolicySize);
            Marshal.StructureToPtr(accent, accentPtr, false);

            // 设置窗口组合特性。
            try
            {
                // 设置模糊特效。
                var data = new WindowCompositionAttributeData
                {
                    Attribute = WindowCompositionAttribute.WCA_ACCENT_POLICY,
                    SizeOfData = accentPolicySize,
                    Data = accentPtr,
                };
                SetWindowCompositionAttribute(handle, ref data);
            }
            finally
            {
                // 释放非托管对象。
                Marshal.FreeHGlobal(accentPtr);
            }
        }

        [DllImport("user32.dll")]
        private static extern int SetWindowCompositionAttribute(IntPtr hwnd, ref WindowCompositionAttributeData data);

        private enum AccentState
        {
            ACCENT_DISABLED = 0,
            ACCENT_ENABLE_GRADIENT = 1,
            ACCENT_ENABLE_TRANSPARENTGRADIENT = 2,
            ACCENT_ENABLE_BLURBEHIND = 3,
            ACCENT_ENABLE_ACRYLICBLURBEHIND = 4,
            ACCENT_INVALID_STATE = 5,
        }

        [StructLayout(LayoutKind.Sequential)]
        private struct AccentPolicy
        {
            public AccentState AccentState;
            public int AccentFlags;
            public int GradientColor;
            public int AnimationId;
        }

        [StructLayout(LayoutKind.Sequential)]
        private struct WindowCompositionAttributeData
        {
            public WindowCompositionAttribute Attribute;
            public IntPtr Data;
            public int SizeOfData;
        }

        private enum WindowCompositionAttribute
        {
            // 省略其他未使用的字段
            WCA_ACCENT_POLICY = 19,
            // 省略其他未使用的字段
        }
    }
}

10-07 2019

使用傲梅分区助手无损合并分区,无损调整分区大小

Windows 本身就提供了强大的磁盘和分区管理工具,一个是操作简单的“磁盘管理”,一个是功能强大的命令行版的“diskpart”。不过这两个都有一些限制,一是不能影响到系统文件,二是其修改的分区不能被应用程序占用(diskpart 可在下次重启时做到)。另外,系统为了管理工具操作的效率和正确性,也有一些功能没有开放。

DiskGenius 是个强大的工具,不过傲梅也很良心。本文介绍使用傲梅分区助手来管理磁盘。


下载

傲梅分区助手有绿色版、专业版和 PE 版。一般我们选择绿色版就好,如果你要改到系统分区,就需要使用集成了傲梅分区助手的 PE 系统。

下面是专业版的截图:

专业版

下面是 PE 版的截图,也是我实际操作分区时截下来的图:

PE 版

不要吐槽为何我用的是古老的 1709 系统,实际上我的系统盘是下面那个 I 盘。不然为什么我会把系统的版本号放到卷标中呢?

调整分区大小

在 PE 系统中找到傲梅分区助手,然后启动。在需要调整位置和大小的分区上右键点击选择“调整/移动分区”:

调整移动分区

然后在弹出的详细设置对话框中调整分区的位置和大小。如果是 SSD,建议点击“高级”然后勾选“允许分区对齐以优化SSD或HDD硬盘”,这可以开启 4K 对齐以大幅优化 SSD 的读写性能。

4K 对齐

最后点击确定。

注意这个时候还没有开始执行真正的操作!

合并分区

合并分区功能可以将你一个磁盘中的多个分区无损合并成一个。

合并分区

选择好将哪个分区合并到哪一个,这时另一个分区中的所有文件会放到目标分区中的一个文件夹里。合并完之后你自己移动好这些文件即可。

因为我的分区在合并过程中的操作没有截图,所以只能看到下面这个提前在磁盘管理中的截图:

无法删除

开始执行真正的操作

在你设置好你的所有操作之后,点击左上角的“提交”按钮,这可以开始依次执行之前所有设置的磁盘最终状态。

提交

在提交界面,你可以看到即将进行的所有操作的简介,以及预计完成这些操作所花的时间。

操作预览

虽然上图只是示例,但我实际将我在下面这篇博客中删除出来的空余空间全部合并在一起,并且还额外合并了两个都需要保留数据的分区。这个过程傲梅的预计时间是 9小时18分,实际上也刚好在 9 个小时左右!

所以,如果你打算开始进行大量的磁盘调整、对拷或者其他无损分区操作:

  • 请提前准备好大量你不用电脑的时间。
  • 请提前准备好大量你不用电脑的时间。
  • 请提前准备好大量你不用电脑的时间。

这是我实际上在 PE 中操作的截图:

分区调整过程

10-07 2019

EFI 分区/恢复分区不可删除?你需要使用命令行了(配合鼠标操作)

Windows 系统在安装的时候,会自动为我们的磁盘划分一个恢复分区和一个 EFI 分区。如果后面不打算再用这些分区的时候,却发现无法删除。

本文将提供解决方法。


因为误操作会导致数据丢失,所以我将两种不同的解决方法分开成两篇文章以避免干扰:

无法删除

看下图,有两种不同类型的无法删除:

  1. 有完整菜单只是删除按钮不可用的 EFI 分区;
  2. 仅有一个“帮助”菜单的恢复分区。

删除方法会略有不同,我会在合适的地方提示你使用正确的方法的。

无法删除

我的磁盘 2 原本包含两个可见分区,一个是图中黑色色块,原来放的是旧操作系统,一个是图中的 D 盘,放大量文件。因为我新买了一个大容量 SSD 专门用来放操作系统,所以原来操作系统所在的磁盘就可以回收与 D 盘合并。

然而悲剧的是,中间隔着一个 820MB 的恢复分区,导致我没有办法为 D 分区扩容。

更麻烦的是,在磁盘管理中,这三个我不会再使用的恢复分区都不可删除。

PS. 吐槽一下,大版本升级一次 Windows 10 竟然会在后面给我多创建一个恢复分区……

解决办法

在实操之前,你必须清除地知道你每一步在做什么,否则你需要承担丢失数据的后果:

  • 如果你在网上找到一些操作 disk 的命令,请不要相信——因为此命令清除的是整个磁盘而不只是单个分区

第一步:打开命令提示符

打开开始菜单,输入 cmd 然后回车确定,我们可以打开命令提示符

cmd

第二步:打开 diskpart

cmd 中输入 diskpart 然后回车,你会看到一个 UAC 提示弹窗,点击“是”之后会启动一个新的管理员权限启动的命令提示符,这里运行着 Diskpart 程序。

diskpart

第三步:找到要操作的磁盘

输入 list disk 回车,我们可以看到自己计算机中所有已经插入的磁盘。

DISKPART> list disk

  磁盘 ###  状态           大小     可用     Dyn  Gpt
  --------  -------------  -------  -------  ---  ---
  磁盘 0    联机              238 GB      0 B
  磁盘 1    联机              931 GB  2048 KB
  磁盘 2    联机              489 GB   199 GB        *
  磁盘 3    联机              476 GB  1024 KB        *

请注意,这里看到的是磁盘,而不是平时在“计算机”中看到的分区——每一个磁盘都可以包含一个到多个分区哦!

你有两种方法来确认我们即将操作的是哪个磁盘:

  1. 前面我们在磁盘管理中看到的那个界面,也就是本文一开始的那张图,可以直接看出我们要删除的分区在“磁盘 2”上。
  2. 根据分区的大小去猜,相信分区少的时候你可以猜对。

好的,我们知道要操作的是“磁盘 2”,于是我们输入命令 select disk 2(如果你是其他磁盘请换成自己的数字):

DISKPART> select disk 2

磁盘 2 现在是所选磁盘。

紧接着,我们输入 list partition 列出此磁盘上的所有分区:

DISKPART> list partition

  分区 ###       类型              大小     偏移量
  -------------  ----------------  -------  -------
  分区      1    恢复                 499 MB  1024 KB
  分区      2    系统                 100 MB   500 MB
  分区      3    保留                  16 MB   600 MB
  分区      5    恢复                 820 MB   199 GB
  分区      6    主要                 288 GB   200 GB

通过分区,我们也能再次确认我们找到了正确的要操作的磁盘。

截至目前,我们还没有对系统进行任何更改,所以你操作错了也不用担心。但接下来你就需要谨慎一些。

第 4.1 步:删除分区(仅适用于 EFI 分区)

EFI 分区

因为我不再将此磁盘用作系统盘,所以里面除了那个 288GB 的数据部分不能动之外,其他系统生成的部分都是需要删除的,所以接下来我需要对分区 1 2 5 都进行一遍以下操作(你的目的不同可能需要删除的分区也不一样)。

先选中要操作的分区:

DISKPART> select partition 1

分区 1 现在是所选分区。

然后更改其 ID:

DISKPART> SET ID=ebd0a0a2-b9e5-4433-87c0-68b6b72699c7

DiskPart 成功设置了分区 ID

然后操作其他的分区。

完整的截图如下:

完整的截图

这个时候,回到磁盘管理中,F5 刷新,你可以看到原本不可删除的 EFI 分区,现在可以直接使用鼠标删除了。点击一下“删除卷”即可。

在磁盘管理中删除卷

第 4.2 步:删除分区(适用于所有类型的分区)

恢复分区

恢复分区不能使用上面 4.1 中的方法删除,如果你在 4.1 的操作之后还发现存在不可删除的恢复分区,请尝试使用我的另一篇博客:


参考资料

10-07 2019

EFI 分区/恢复分区不可删除?你需要使用命令行了(全命令行操作)

Windows 系统在安装的时候,会自动为我们的磁盘划分一个恢复分区和一个 EFI 分区。如果后面不打算再用这些分区的时候,却发现无法删除。

本文将提供解决方法。


因为误操作会导致数据丢失,所以我将两种不同的解决方法分开成两篇文章以避免干扰:

无法删除

看下图,有两种不同类型的无法删除:

  1. 有完整菜单只是删除按钮不可用的 EFI 分区;
  2. 仅有一个“帮助”菜单的恢复分区。

使用本文提供的方法,你可以删除以上两种不同类型的分区。

无法删除

我的磁盘 2 原本包含两个可见分区,一个是图中黑色色块,原来放的是旧操作系统,一个是图中的 D 盘,放大量文件。因为我新买了一个大容量 SSD 专门用来放操作系统,所以原来操作系统所在的磁盘就可以回收与 D 盘合并。

然而悲剧的是,中间隔着一个 820MB 的恢复分区,导致我没有办法为 D 分区扩容。

更麻烦的是,在磁盘管理中,这三个我不会再使用的恢复分区都不可删除。

PS. 吐槽一下,大版本升级一次 Windows 10 竟然会在后面给我多创建一个恢复分区……

解决办法

在实操之前,你必须清除地知道你每一步在做什么,否则你需要承担丢失数据的后果:

  • 如果你在网上找到一些操作 disk 的命令,请不要相信——因为此命令清除的是整个磁盘而不只是单个分区

第一步:打开命令提示符

打开开始菜单,输入 cmd 然后回车确定,我们可以打开命令提示符

cmd

第二步:打开 diskpart

cmd 中输入 diskpart 然后回车,你会看到一个 UAC 提示弹窗,点击“是”之后会启动一个新的管理员权限启动的命令提示符,这里运行着 Diskpart 程序。

diskpart

第三步:找到要操作的磁盘

输入 list disk 回车,我们可以看到自己计算机中所有已经插入的磁盘。

DISKPART> list disk

  磁盘 ###  状态           大小     可用     Dyn  Gpt
  --------  -------------  -------  -------  ---  ---
  磁盘 0    联机              238 GB      0 B
  磁盘 1    联机              931 GB  2048 KB
  磁盘 2    联机              489 GB   199 GB        *
  磁盘 3    联机              476 GB  1024 KB        *

请注意,这里看到的是磁盘,而不是平时在“计算机”中看到的分区——每一个磁盘都可以包含一个到多个分区哦!

你有两种方法来确认我们即将操作的是哪个磁盘:

  1. 前面我们在磁盘管理中看到的那个界面,也就是本文一开始的那张图,可以直接看出我们要删除的分区在“磁盘 2”上。
  2. 根据分区的大小去猜,相信分区少的时候你可以猜对。

好的,我们知道要操作的是“磁盘 2”,于是我们输入命令 select disk 2(如果你是其他磁盘请换成自己的数字):

DISKPART> select disk 2

磁盘 2 现在是所选磁盘。

紧接着,我们输入 list partition 列出此磁盘上的所有分区:

DISKPART> list partition

  分区 ###       类型              大小     偏移量
  -------------  ----------------  -------  -------
  分区      1    恢复                 499 MB  1024 KB
  分区      2    系统                 100 MB   500 MB
  分区      3    保留                  16 MB   600 MB
  分区      5    恢复                 820 MB   199 GB
  分区      6    主要                 288 GB   200 GB

通过分区,我们也能再次确认我们找到了正确的要操作的磁盘。

截至目前,我们还没有对系统进行任何更改,所以你操作错了也不用担心。但接下来你就需要谨慎一些。

第四步:删除分区

因为我不再将此磁盘用作系统盘,所以里面除了那个 288GB 的数据部分不能动之外,其他系统生成的部分都是需要删除的,所以接下来我需要对分区 1 2 5 都进行一遍以下操作(你的目的不同可能需要删除的分区也不一样)。

先选中要操作的分区:

DISKPART> select partition 1

分区 1 现在是所选分区。

然后输入 delete part override 删除这个分区:

DISKPART> delete part override

DiskPart 成功地删除了所选分区。

接着,依次删除其他分区。下面是删除其中前两个分区后的截图:

删除分区

所有分区删除完毕之后,可以看到我的整个磁盘现在只剩下我要留下的重要数据分区了。

DISKPART> list partition

  分区 ###       类型              大小     偏移量
  -------------  ----------------  -------  -------
  分区      6    主要                 288 GB   200 GB

这时回到磁盘管理中,可以看到大量已被删除的未分配的空间连在了一起。

未分配

可以将我的 D 盘扩展更多空间啦!


参考资料

09-27 2019

推荐几款连字字体,在代码编辑器中启用连字字体(Visual Studio Code)

启用转为编程设计的连字字体,可以给你的变成带来不一样的体验。


连字字体

微软随 Windows Terminal 设计了一款新的字体 Cascadia Code,而这是一款连字字体。

你可以看到,在 Windows Terminal 的终端中,=> == != 符号显示成了更容易理解的连字符号:

Cascadia Code

在 Cascadia Code 发布之前,Fira Code 是一款特别火的连字字体,下面是 Fira Code 连字字体在 Visual Studio Code 中的显示效果:

Fira Code in Visual Studio Code

而显示的,其实是下面这一段代码:

x =>
{
    if (x >= 2 || x == 0)
    {
        Console.WriteLine(" >=> 欢迎访问吕毅的博客 ~~> blog.walterlv.com");
    }
}

连字字体推荐

作为微软的粉丝,当然首推 Cascadia Code!不过我喜欢比较细的字体风格,目前 Cascadia Code 还没有提供细体,因此我可能还需要等一些时间才正式入坑。

在这里可以关注 Cascadia Code 的状态:

灵台,你也可以在这里找到其他一些好看的用于编程的连字字体:

相关的开源项目链接:

以 Fira Code 为例安装的话,去它的 GitHub 的 release 页面:

下载最新的发布文件 FiraCode_1.207.zip

下载解压后,你会看到五个不同的文件夹,这是四种不同的字体类型:

  • otf (Open Type)
  • ttf (True Type)
  • variable_ttf (Variable True Type)
  • woff (Web Open Font Format)
  • woff2 (Web Open Font Format)

对于 Open Type 和 True Type 的选择,一般有对应的 Open Type 类型字体的时候就优先选择 Open Type 类型的,因为 True Type 格式是比较早期的,限制比较多,比如字符的数量受到限制,而 Open Type 是基于 Unicode 字符集来设计的新的跨平台的字体格式。

Variable True Type 是可以无极变换的 True Type 字体。

而 Web Open Font Format 主要为网络传输优化,其特点是字体均经过压缩,其大小会比较小。

我们点击进入 otf 文件夹,然后全选所有的字体文件,右键,安装,等待安装完成即可。

在编辑器中启用

在 Visual Studio Code 中启用

在 Visual Studio Code 中启用连字字体需要用到两个选项:

"editor.fontFamily": "Fira Code Light, Consolas, Microsoft YaHei",
"editor.fontLigatures": true,

打开 Visual Studio Code 设置

然后点击新打开的标签右上角的 {} 图标以打开 json 形式编辑的设置:

使用 json 编辑设置

然后修改把上面两个设置增加或替换进去即可。下面是我的设置的部分截图:

设置启用连字字体

在 Visual Studio 或其他 Windows 系统自带软件中启用

只需要将字体设置成 Fira Code 即可。


参考资料

09-19 2019

使用 SetParent 制作父子窗口的时候,如何设置子窗口的窗口样式以避免抢走父窗口的焦点

制作传统 Win32 程序以及 Windows Forms 程序的时候,一个用户看起来独立的窗口本就是通过各种父子窗口嵌套完成的,有大量窗口句柄,窗口之间形成父子关系。不过,对于 WPF 程序来说,一个独立的窗口实际上只有一个窗口句柄,窗口内的所有内容都是 WPF 绘制的。

如果你不熟悉 Win32 窗口中的父子窗口关系和窗口样式,那么很有可能遇到父子窗口之间“抢夺焦点”的问题,本文介绍如何解决这样的问题。


“抢夺焦点”

下图中的上下两个部分是两个不同的窗口,他们之间通过 SetParent 建立了父子关系。

注意看下面的窗口标题栏,当我在这些不同区域间点击的时候,窗口标题栏在黑色和灰色之间切换:

抢夺焦点

这说明当子窗口获得焦点的时候,父窗口会失去焦点并显示失去焦点的样式。

你可以在这篇博客中找到一个简单的例子:

解决办法

而原因和解决方法仅有一个,就是子窗口需要有一个子窗口的样式。

具体来说,子窗口必须要有 WS_CHILD 样式。

你可以看看 Spyxx.exe 抓出来的默认普通窗口和子窗口的样式差别:

![默认普通窗口]](/static/posts/2019-09-19-10-21-31.png)

▲ 默认普通窗口

子窗口

▲ 子窗口


参考资料

09-05 2019

.NET/C# 阻止屏幕关闭,阻止系统进入睡眠状态

在 Windows 系统中,一段时间不操作键盘和鼠标,屏幕便会关闭,系统会进入睡眠状态。但有些程序(比如游戏、视频和演示文稿)在运行过程中应该阻止屏幕关闭,否则屏幕总是关闭,会导致体验会非常糟糕。

本文介绍如何编写 .NET/C# 代码临时阻止屏幕关闭以及系统进入睡眠状态。


Windows API

我们需要使用到一个 Windows API:

/// <summary>
/// Enables an application to inform the system that it is in use, thereby preventing the system from entering sleep or turning off the display while the application is running.
/// </summary>
[DllImport("kernel32")]
private static extern ExecutionState SetThreadExecutionState(ExecutionState esFlags);

使用到的枚举用 C# 类型定义是:

[Flags]
private enum ExecutionState : uint
{
    /// <summary>
    /// Forces the system to be in the working state by resetting the system idle timer.
    /// </summary>
    SystemRequired = 0x01,

    /// <summary>
    /// Forces the display to be on by resetting the display idle timer.
    /// </summary>
    DisplayRequired = 0x02,

    /// <summary>
    /// This value is not supported. If <see cref="UserPresent"/> is combined with other esFlags values, the call will fail and none of the specified states will be set.
    /// </summary>
    [Obsolete("This value is not supported.")]
    UserPresent = 0x04,

    /// <summary>
    /// Enables away mode. This value must be specified with <see cref="Continuous"/>.
    /// <para />
    /// Away mode should be used only by media-recording and media-distribution applications that must perform critical background processing on desktop computers while the computer appears to be sleeping.
    /// </summary>
    AwaymodeRequired = 0x40,

    /// <summary>
    /// Informs the system that the state being set should remain in effect until the next call that uses <see cref="Continuous"/> and one of the other state flags is cleared.
    /// </summary>
    Continuous = 0x80000000,
}

以上所有的注释均照抄自微软的官方 API 文档:

API 封装

如果你擅长阅读英文,那么以上的 API 函数、枚举和注释足够你完成你的任务了。

不过,我这里提供一些封装,以应对一些常用的场景。

using System;
using System.Runtime.InteropServices;

namespace Walterlv.Windows
{
    /// <summary>
    /// 包含控制屏幕关闭以及系统休眠相关的方法。
    /// </summary>
    public static class SystemSleep
    {
        /// <summary>
        /// 设置此线程此时开始一直将处于运行状态,此时计算机不应该进入睡眠状态。
        /// 此线程退出后,设置将失效。
        /// 如果需要恢复,请调用 <see cref="RestoreForCurrentThread"/> 方法。
        /// </summary>
        /// <param name="keepDisplayOn">
        /// 表示是否应该同时保持屏幕不关闭。
        /// 对于游戏、视频和演示相关的任务需要保持屏幕不关闭;而对于后台服务、下载和监控等任务则不需要。
        /// </param>
        public static void PreventForCurrentThread(bool keepDisplayOn = true)
        {
            SetThreadExecutionState(keepDisplayOn
                ? ExecutionState.Continuous | ExecutionState.SystemRequired | ExecutionState.DisplayRequired
                : ExecutionState.Continuous | ExecutionState.SystemRequired);
        }

        /// <summary>
        /// 恢复此线程的运行状态,操作系统现在可以正常进入睡眠状态和关闭屏幕。
        /// </summary>
        public static void RestoreForCurrentThread()
        {
            SetThreadExecutionState(ExecutionState.Continuous);
        }

        /// <summary>
        /// 重置系统睡眠或者关闭屏幕的计时器,这样系统睡眠或者屏幕能够继续持续工作设定的超时时间。
        /// </summary>
        /// <param name="keepDisplayOn">
        /// 表示是否应该同时保持屏幕不关闭。
        /// 对于游戏、视频和演示相关的任务需要保持屏幕不关闭;而对于后台服务、下载和监控等任务则不需要。
        /// </param>
        public static void ResetIdle(bool keepDisplayOn = true)
        {
            SetThreadExecutionState(keepDisplayOn
                ? ExecutionState.SystemRequired | ExecutionState.DisplayRequired
                : ExecutionState.SystemRequired);
        }
    }
}

如果你对这段封装中的 keepDisplayOn 参数,也就是 ExecutionState.DisplayRequired 枚举不了解,看看下图直接就懂了。一个指的是屏幕关闭,一个指的是系统进入睡眠。

电源和睡眠

此封装后,使用则相当简单:

// 阻止系统睡眠,阻止屏幕关闭。
SystemSleep.PreventForCurrentThread();

// 恢复此线程曾经阻止的系统休眠和屏幕关闭。
SystemSleep.RestoreForCurrentThread();

或者:

// 重置系统计时器,临时性阻止系统睡眠和屏幕关闭。
// 此效果类似于手动使用鼠标或键盘控制了一下电脑。
SystemSleep.ResetIdle();

在使用 PreventForCurrentThread 这个 API 的时候,你需要避免程序对空闲时机的控制不好,导致屏幕始终不关闭。

如果你发现无论你设置了多么短的睡眠时间和屏幕关闭时间,屏幕都不会关闭,那就是有某个程序阻止了屏幕关闭,你可以:


参考资料

08-27 2019

使用 SetParent 跨进程设置父子窗口时的一些问题(小心卡死)

在微软的官方文档中,说 SetParent 可以在进程内设置,也可以跨进程设置。当使用跨进程设置窗口的父子关系时,你需要注意本文提到的一些问题,避免踩坑。


跨进程设置 SetParent

关于 SetParent 函数设置窗口父子关系的文档可以看这个:

在这篇文章的 DPI 感知一段中明确写明了在进程内以及跨进程设置父子关系时的一些行为。虽然没有明确说明支持跨进程设置父子窗口,不过这段文字就几乎说明 Windows 系统对于跨进程设置窗口父子关系还是支持的。

但 Raymond Chen 在 Is it legal to have a cross-process parent/child or owner/owned window relationship? 一文中有另一段文字:

If I remember correctly, the documentation for Set­Parent used to contain a stern warning that it is not supported, but that remark does not appear to be present any more. I have a customer who is reparenting windows between processes, and their application is experiencing intermittent instability.
如果我没记错的话,SetParent 的文档曾经包含一个严厉的警告表明它不受支持,但现在这段备注似乎已经不存在了。我就遇到过一个客户跨进程设置窗口之间的父子关系,然后他们的应用程序间歇性不稳定。

这里表明了 Raymond Chen 对于跨进程设置父子窗口的一些担忧,但从文档趋势来看,还是支持的。只是这种担忧几乎说明跨进程设置 SetParent 存在一些坑。

那么本文就说说跨进程设置父子窗口的一些坑。

消息循环强制同步

消息循环

我们会感觉到 Windows 中某个窗口有响应(比如鼠标点击有反应),是因为这个窗口在处理 Windows 消息。窗口进行消息循环不断地处理消息使得各种各样的用户输入可以被处理,并正确地在界面上显示。

一个典型的消息循环大概像这样:

while(GetMessage(ref msg, IntPtr.Zero, 0, 0))
{
    TranslateMessage(ref msg);
    DispatchMessage(ref msg);
}

对于显示了窗口的某个线程调用了 GetMessage 获取了消息,Windows 系统就会认为这个线程有响应。相反,如果长时间不调用 GetMessage,Windows 就会认为这个线程无响应。TranslateMessage 则是翻译一些消息(比如从按键消息翻译成字符消息)。真正处理 GetMessage 中的内容则是后面的调度消息 DispatchMessage,是这个函数的调用使得我们 UI 界面上的内容可以有可见的反映。

一般来说,每个创建了窗口的线程都有自己独立的消息循环,且不会互相影响。然而一旦这些窗口之间建立了父子关系之后就会变得麻烦起来。

强制同步

Windows 会让具有父子关系的所有窗口的消息循环强制同步。具体指的是,所有具有父子关系的窗口消息循环,其消息循环会串联成一个队列(这样才可以避免消息循环的并发)。

也就是说,如果你有 A、B、C、D 四个窗口,分属不同进程,A 是 B、C、D 窗口的父窗口,那么当 A 在处理消息的时候,B、C、D 的消息循环就会卡在 GetMessage 的调用。同样,无论是 B、C 还是 D 在处理消息的时候,其他窗口也会同样卡在 GetMessage 的调用。这样,所有进程的 UI 线程实际上会互相等待,所有通过消息循环执行的代码都不会同时执行。然而实际上 Windows GUI 应用程序的开发中基本上 UI 代码都是通过消息循环来执行的,所以这几乎等同于所有进程的 UI 线程强制同步成类似一个 UI 线程的效果了。

带来的副作用也就相当明显,任何一个进程卡了 UI,其他进程的 UI 将完全无响应。当然,不依赖消息循环的代码不会受此影响,比如 WPF 应用程序的动画和渲染。

如何解决

对于 SetParent 造成的这些问题,实际上没有官方的解决方案,你需要针对你不同的业务采用不同的解决办法。

正如 Raymond Chen 所说:

(It’s one of those “if you don’t already know what the consequences are, then you are not smart enough to do it correctly” things. You must first become the master of the rules before you can start breaking them.)
正如有些人说的“如果你不知道后果,那么你也不足以正确地完成某件事情”。在开始破坏规则之前,您必须先成为规则的主人。

你必须清楚跨进程设置父子窗口带来的各种副作用,然后针对性地给出解决方案:

  1. 比如所有窗口会强制串联成一个队列,那么可以考虑将暂时不显示的窗口断开父子关系;
  2. 比如设置窗口的位置大小等操作,必须考虑此窗口不是顶层窗口的问题,需要跨越进程到顶层窗口来操作;

参考资料

08-14 2019

System.InvalidOperationException:“BuildWindowCore 无法返回寄宿的子窗口句柄。”

当试图在 WPF 窗口中嵌套显示 Win32 子窗口的时候,你有可能出现错误:“BuildWindowCore 无法返回寄宿的子窗口句柄。”。

这是很典型的 Win32 错误,本文介绍如何修复此错误。


我们在 MainWindow 中嵌入一个其他的窗口来承载新的 WPF 控件。一般情况下我们当然不会这么去做,但是如果我们要跨越进程边界来完成 WPF 渲染内容的融合的时候,就需要嵌入一个新的窗口了。

WPF 中可以使用 HwndSource 来包装一个 WPF 控件到 Win32 窗口,使用自定义的继承自 HwndHost 的类可以把 Win32 窗口包装成 WPF 控件。由于窗口句柄是可以跨越进程边界传递的,所以这样的方式可以完成跨进程的 WPF 控件显示。

问题

你有可能在调试嵌入窗口代码的时候遇到错误:

错误

System.InvalidOperationException:“BuildWindowCore 无法返回寄宿的子窗口句柄。”

英文是:

BuildWindowCore failed to return the hosted child window handle.

原因和解决办法

此异常的原因非常简单,是 HwndSourceBuildWindowCore 的返回值有问题。具体来说,就是子窗口的句柄返回了 0。

也就是下面这段代码中 return new HandleRef(this, IntPtr.Zero) 这句,第二个参数是 0。

protected override HandleRef BuildWindowCore(HandleRef hwndParent)
{
    const int WS_CHILD = 1073741824;
    const int WS_CLIPCHILDREN = 33554432;
    var parameters = new HwndSourceParameters("demo")
    {
        ParentWindow = hwndParent.Handle,
        WindowStyle = (int)(WS_CHILD | WS_CLIPCHILDREN),
        TreatAncestorsAsNonClientArea = true,
    };
    var source = new HwndSource(parameters);
    source.RootVisual = new Button();
    return new HandleRef(this, _handle);
}

要解决,就需要传入正确的句柄值。当然上面的代码为了示例,故意传了一个不知道哪里的 _handle,实际上应该传入 source.Handle 才是正确的。

08-14 2019

System.InvalidOperationException:“寄宿 HWND 必须是子窗口。”

当试图在 WPF 窗口中嵌套显示 Win32 子窗口的时候,你有可能出现错误:“System.InvalidOperationException:“寄宿 HWND 必须是子窗口。””。

这是很典型的 Win32 错误,本文介绍如何修复此错误。


一个最简的嵌入其他窗口的例子

我们在 MainWindow 中嵌入一个其他的窗口来承载新的 WPF 控件。一般情况下我们当然不会这么去做,但是如果我们要跨越进程边界来完成 WPF 渲染内容的融合的时候,就需要嵌入一个新的窗口了。

WPF 中可以使用 HwndSource 来包装一个 WPF 控件到 Win32 窗口,使用自定义的继承自 HwndHost 的类可以把 Win32 窗口包装成 WPF 控件。由于窗口句柄是可以跨越进程边界传递的,所以这样的方式可以完成跨进程的 WPF 控件显示。

下面是最简单的一个例子,为了简单,没有跨进程传递 Win32 窗口句柄,而是直接创建出来。

using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;

namespace Walterlv.Demo.HwndWrapping
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            Loaded += OnLoaded;
        }

        private void OnLoaded(object sender, RoutedEventArgs e)
        {
            Content = new HwndWrapper();
        }
    }

    public class HwndWrapper : HwndHost
    {
        private HwndSource _source;

        protected override HandleRef BuildWindowCore(HandleRef hwndParent)
        {
            var parameters = new HwndSourceParameters("walterlv");
            _source = new HwndSource(parameters);
            // 这里的 ChildPage 是一个继承自 UseControl 的 WPF 控件,你可以自己创建自己的 WPF 控件。
            _source.RootVisual = new ChildPage();
            return new HandleRef(this, _source.Handle);
        }

        protected override void DestroyWindowCore(HandleRef hwnd)
        {
            _source?.Dispose();
        }
    }
}

寄宿 HWND 必须是子窗口

当运行此代码的时候,会提示错误:

System.InvalidOperationException:“寄宿 HWND 必须是子窗口。”

或者英文版:

System.InvalidOperationException:”Hosted HWND must be a child window.”

这是一个 Win32 错误,因为我们试图将一个普通的窗口嵌入到另一个窗口中,而实际上要完成嵌入需要子窗口才行。

那么如何设置一个 Win32 窗口为子窗口呢?使用 SetWindowLong 来设置 Win32 窗口的样式是可以的。不过我们因为使用了 HwndSource,所以可以通过 HwndSourceParameters 来更方便地设置窗口样式。

我们需要将 HwndSourceParameters 那一行改成这样:

++  const int WS_CHILD = 0x40000000;
--  var parameters = new HwndSourceParameters("walterlv");
++  var parameters = new HwndSourceParameters("walterlv")
++  {
++      ParentWindow = hwndParent.Handle,
++      WindowStyle = WS_CHILD,
++  };

最关键的是两点:

  1. 需要设置此窗口为子窗口,也就是设置 WindowStyleWS_CHILD
  2. 需要设置此窗口的父窗口,也就是设置 ParentWindowhwndParent.Handle(我们使用参数中传入的 hwndParent 作为父窗口)。

现在再运行,即可正常显示此嵌套窗口:

嵌套窗口

另外,WindowStyle 属性最好加上 WS_CLIPCHILDREN,详情请阅读:


参考资料

08-14 2019

System.InvalidOperationException:“寄宿的 HWND 必须是指定父级的子窗口。”

当试图在 WPF 窗口中嵌套显示 Win32 子窗口的时候,你有可能出现错误:“寄宿的 HWND 必须是指定父级的子窗口。”。

这是很典型的 Win32 错误,本文介绍如何修复此错误。


我们在 MainWindow 中嵌入一个其他的窗口来承载新的 WPF 控件。一般情况下我们当然不会这么去做,但是如果我们要跨越进程边界来完成 WPF 渲染内容的融合的时候,就需要嵌入一个新的窗口了。

WPF 中可以使用 HwndSource 来包装一个 WPF 控件到 Win32 窗口,使用自定义的继承自 HwndHost 的类可以把 Win32 窗口包装成 WPF 控件。由于窗口句柄是可以跨越进程边界传递的,所以这样的方式可以完成跨进程的 WPF 控件显示。

问题

你有可能在调试嵌入窗口代码的时候遇到错误:

错误

System.InvalidOperationException:“寄宿的 HWND 必须是指定父级的子窗口。”

英文是:

Hosted HWND must be a child window of the specified parent.

原因和解决办法

出现此错误,是因为同一个子窗口被两次设置为同一个窗口的子窗口。

具体来说,就是 A 窗口使用 HwndHost 设置成了 B 的子窗口,随后 A 又通过一个新的 HwndHost 设置成了新子窗口。

要解决,则必须确保一个窗口只能使用 HwndHost 设置一次子窗口。

08-01 2019

通过 AppSwitch 禁用 WPF 内置的触摸让 WPF 程序可以处理 Windows 触摸消息

WPF 框架自己实现了一套触摸机制,但同一窗口只能支持一套触摸机制,于是这会禁用系统的触摸消息(WM_TOUCH)。这能够很大程度提升 WPF 程序的触摸响应速度,但是很多时候又会产生一些 Bug。

如果你有需要,可以考虑禁用 WPF 的内置的实时触摸(RealTimeStylus)。本文介绍禁用方法,使用 AppSwitch,而不是网上广为流传的反射方法。


如何设置 AppSwitch

在你的应用程序的 app.config 文件中加入 Switch.System.Windows.Input.Stylus.DisableStylusAndTouchSupport=true 开关,即可关闭 WPF 内置的实时触摸,而改用 Windows 触摸消息(WM_TOUCH)。

<configuration>
  <runtime>
    <AppContextSwitchOverrides value="Switch.System.Windows.Input.Stylus.DisableStylusAndTouchSupport=true" />
  </runtime>
</configuration>

如果你的解决方案中没有找到 app.config 文件,可以创建一个:

新建文件

应用程序配置文件

然后,把上面的代码拷贝进去即可。

反射禁用的方法

微软的官方文档也有提到使用放射禁用的方法,但一般不推荐这种调用内部 API 的方式,比较容易在 .NET 的版本更新中出现问题:

此方法可以解决的问题一览

拖拽窗口或者调整窗口大小时不能实时跟随的问题

在部分设备上启动即崩溃

在透明窗口上触摸会挡住 UWP 程序


参考资料

07-20 2019

如何为你的 Windows 应用程序关联 URL 协议,以便在浏览器中也能打开你的应用

移动程序关联 URL 是常态,桌面应用程序其实也早就支持关联 URL 以便在浏览器中打开。当我们的程序关联了一个 URL 协议之后,开发的网站上就可以通过这个 URL 与程序进行互操作,这很互联网。

对于 Windows 桌面应用来说,关联一个 URL 协议是通过修改注册表来实现的。本文介绍如何为你的应用关联一个 URL 协议。


URL 协议

一个常用的 URL 协议是这样子的:https://walterlv.com。前面的 https 就是协议名称,而 https:// 放在一起就是在使用 https 协议。

本文我们将定义一个 walterlv 协议,然后关联到我们本地安装的一个桌面应用程序上,然后使用 walterlv://open?id=1 来打开一个 id 为 1 的逗比。

注册一个 URL 协议

要在 Windows 系统上注册一个 URL 协议,你只需要两个步骤:

  • 好好想一个协议名称
  • 在注册表中添加协议关联

好好想一个协议名称

就知道你想不出来名字,于是可以使用命名生成工具:Whitman,其原理可阅读 冷算法:自动生成代码标识符(类名、方法名、变量名) - 吕毅

然后本文使用协议名称 walterlv

在注册表中添加协议关联

你需要在注册表的 HKEY_LOCAL_MACHINE\Software\Classes 或者 HKEY_CURRENT_USER\Software\Classes 添加一些子键:

HKEY_CURRENT_USER\Software\Classes
    walterlv
        (Default) = 吕毅的特殊链接
        URL Protocol = WalterlvProtocol
        Shell
            Open
                Command
                    (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\Walterlv.Windows.Association.exe" "%1"

Classes 中的那个根键 walterlv 就是我们的协议名称,也就是 walterlv:// 的那个前缀。

walterlv 根键 中的 (Default) 属性给出的是链接的名称;如果后面没有设置打开方式(也就是那个 Shell\Open\Command)的话,那么在 Chrome 里打开就会显示为那个名称(如下图)。

默认的协议名称

URL Protocol 这个注册表项是必须存在的,但里面的值是什么其实无所谓。这只是表示 walterlv 是一个协议。

接下来 Shell\Open\Command 中的 (Default) 值设置为一个打开此协议用的命令行。其中路径后面的 "%1" 是文件资源管理器传入的参数,其实就是文件的完整路径。我们加上了引号是避免解析命令行的时候把包含空格的路径拆成了多个参数。

在正确填写了注册表的以上内容之后,在 Chrome 里打开此链接将看到以下 URL 打开提示:

带有打开命令的协议

关于注册表路径的说明

HKEY_LOCAL_MACHINE 主键是此计算机上的所有用户共享的注册表键值,而 HKEY_CURRENT_USER 是当前用户使用的注册表键值。而我们在注册表的 HKEY_CLASSES_ROOT 中也可以看到跟 HKEY_LOCAL_MACHINE\Software\ClassesHKEY_CURRENT_USER\Software\Classes 中一样的文件关联项,是因为 HKEY_CLASSES_ROOTHKEY_LOCAL_MACHINE\Software\ClassesHKEY_CURRENT_USER\Software\Classes 合并之后的一个视图,其中用户键值会覆盖此计算机上的相同键值。

也就是说,如果你试图修改文件关联,那么需要去 HKEY_LOCAL_MACHINE\Software\ClassesHKEY_CURRENT_USER\Software\Classes 中,但如果只是去查看文件关联的情况,则只需要去 HKEY_CLASSES_ROOT 中。

写入计算机范围内的注册表项需要管理员权限,而写入用户范围内的注册表项不需要管理员权限;你可以酌情选用。

额外说明

感谢 人猿 提供的补充信息:

假如初次点击不打开,并且勾选了始终,那么以后这个弹框就没有了,而程序也不会打开,需要做下配置的修改 谷歌浏览器:C:\Users(你的用户名)\AppData\Local\Google\Chrome\User Data\Default\Preferences 火狐浏览器:先关闭浏览器C:\Users(你的用户名)\AppData\Roaming\Mozilla\Firefox\Profiles\4uasyvvi.default 找到handlers.json

07-13 2019

软件界面中一些易混淆/易用错的界面文案,以及一些约定俗成的文案约定

经常有小伙伴跟我撕到底一些常用同音的词语应该使用哪个的问题。于是我将一些常用的软件界面中用错的文案整理出来,为自己和其他开发者提供我 已经整理的结论可以溯源的资料


词语

下面列举出来的一些词语,有的我写的是 “推荐”,指两者都是正确的,但更应该使用 “推荐” 中的词语;而有的我写的是 “正确”,指只有这一个才是正确的,而其他写法是错误的。

无论哪一种,都说明了理由和可溯源的资料。

撤销 / 撤消

  • 推荐:在软件界面中推荐使用 “撤销”。应该逐渐淘汰“撤消”的使用。
  • 实际:国产软件主要使用“撤销”,而国外软件的中文版本两者都有使用。

看《现代汉语词典》:

第五、六、七版:

【撤销】chèxiāo [动] 取消:~处分|~职务。也作撤消。 【撤消】chèxiāo 同“撤销”。

第三版:

【撤销】chèxiāo [动] 撤除;取消 |> ~原判决 | ~多余的机构。☞ 不宜写作“撤消”。 【撤消】chèxiāo 现在一般写作“撤销”。

可见,“撤消”已经被淘汰,现全部应该使用“撤销”。

那么实际中大家是如何使用的呢?

Windows 系统和 Office 套件使用的是“撤消”:

Windows 文件资源管理器

Office 套件

撤销恢复 / 撤销重做,撤消恢复 / 撤消重做

  • 正确:恢复。

撤销:Undo。恢复:Redo。重做:Repeat。

有些软件会出现此错误,估计跟 Office 的使用有关。

在正常情况下,Office 的左上角有一对按钮:“撤消” 和 “重做”。但是,“重做” 的意思真的是 “重复上一步操作”。当你点了 “撤消” 之后,这个 “重做” 按钮会消失,变成 “恢复” 按钮,意思是将刚刚 “撤消” 的操作 “恢复” 回来。

因此,如果只是在 Office 软件里看了一眼就把文案抄过来了,那就会出现 “撤消重做” 这样的误用;实际上应该是 “撤销恢复”。

Office 套件

账号 / 帐号,账户 / 帐户

  • 推荐:在软件 zhànghào / zhànghù 界面中推荐使用 “账号” 和 “账户”。
  • 实际:各大软件平分秋色,都有使用。

第一批异形词整理表 中对于 “账” 和 “帐” 的用法有一项相关的说明,明确 “账本”(zhàngběn)一词是普通话书面语中推荐的使用词形,而 “帐本” 是 “账本” 异形词。

其对于 “账” 和 “帐” 的解释如下:

“账”是“帐”的分化字。古人常把账目记于布帛上悬挂起来以利保存,故称日用的账目为“帐”。后来为了与帷帐分开,另造形声字“账”,表示与钱财有关。“账”“帐”并存并用后,形成了几十组异形词。《简化字总表》、《现代汉语通用字表》中“账”“帐”均收,可见主张分化。二字分工如下:“账”用于货币和货物出入的记载、债务等,如“账本、报账、借账、还账”等;“帐”专表用布、纱、绸子等制成的遮蔽物,如“蚊帐、帐篷、青纱帐(比喻用法)”等。

从主张分化的目的来看,其更推荐在表示“货币和货物出入的记载、债务”时使用“账”,而在表示“布、纱、绸子等制成的遮蔽物”时使用“帐”。那么软件界面中应该使用哪一个呢?

对于“支付宝”/“京东”/“淘宝”/“微信钱包”/各类银行这些一看就跟钱相关的应用里面,很明显推荐使用“账户”。另外一些如论坛 zhànghào,QQ zhànghào 等没有明前与钱相关的应用,其通常也包含一些虚拟的服务行为记录、以及与其他用户相关的虚拟交易方式(例如论坛币、Q 币),因此也推荐使用“账户”。

然而还有一些与这些虚拟交易也没有关系的,非营利组织的或者完全个人的 zhànghào,应该使用什么呢?这些 zhànghù 通常只做一些密码记录、行为记录、用户个人设置个人偏好存储等。从含义上讲,这些信息与“账”描述中的“货物出入的记载”这一句是相关的,而与“帐”中的“布、纱、绸子等制成的遮蔽物”不相关。因此,即便是这些与钱不直接相关的用户 zhànghù 或者 zhànghào 也更加推荐使用 “账号” 和 “账户”。

那么实际中大家是如何使用的呢?

在我们刚刚参考的维基文库中,其使用的就是 “账号”:

维基文库

京东/1号店/支付宝的登录页面使用了 “账号”(淘宝使用了“会员名”来规避了这种争议词的使用):

京东

淘宝使用了“会员名”来规避了这种争议词的使用。

QQ/微信/网易中使用的是 “帐号”:

QQ 登录页面

Windows 系统采用了 “帐户” 一词。不过其中文版对此异形词做了很友好的适配,无论你输入哪一个词,最终都可以搜到你想要的 zhànghù:

Windows 系统设置中的帐户

你以为微软统一使用 “帐户” 吗?实际上可以看看下面这个页面,两个词都有使用。微软一定很纠结。

纠结的微软

登录 / 登陆

  • 正确:“登录” 才是正确用法。“登陆”根本就不是计算机术语。
  • 实际:主流软、大公司基本都正确使用了 “登录”,但其他网站就不好说了各种乱用。

标点符号

句号

  • 推荐:句子的结尾必须有句号或者可以承担句号职责的标点;而短语后面则不应该加句号或同类标点。
  • 实际:很多不成熟的软件会在句子结尾不带任何句号或同类标点。

为什么连句号也要拿出来说呢?

省略号

从早期的界面设计中一直延续下来一个约定:

如果某个按钮有后续操作,那么这个按钮的名称后面需要带上省略号 “…”。

注意,这是半个省略号 “…”,而不是三个点 “…”。无论中文还是英文都如此。 正在搜寻资料确认到底是什么。

后续操作指的是“需要提供额外的信息”。例如“保存”直接存成文件,而“另存为”需要提供一个新的文件名。因此“保存”没有省略号而“另存为”则有省略号。

这个约定在微软的 Windows 系统中和苹果的 macOS 系统中原本一直都有执行下去,就像下面这样:

Windows 系统

Mac 系统

直到后来发现,如果继续执行这项约定,那么整个界面中将充斥着省略号,非常影响美观。

于是后来就只在菜单中保留这项约定,其他常显界面中就去掉了省略号:

Windows 文件资源管理器

Windows 设置

Visual Studio 中的菜单项

额外说明

可能需要解释一下异形词,来自维基文库:

异形词(variant forms of the same word)

普通话书面语中并存并用的同音(本规范中指声、韵、调完全相同)、同义(本规范中指理性意义、色彩意义和语法意义完全相同)而书写形式不同的词语。

而异体字:

异体字(variant forms of a Chinese character)

与规定的正体字同音、同义而写法不同的字。本规范中专指被《第一批异体字整理表》淘汰的异体字。

对于异形词,其不同的写法需要用在不同的场景中;对于异体字,则需要逐渐淘汰使用。


参考资料

07-05 2019

如何在 Windows 10 中安装 WSL2 的 Linux 子系统

本文介绍如何在 Windows 10 中安装 WSL2 的 Linux 子系统


第一步:启用虚拟机平台和 Linux 子系统功能

以管理员权限启动 PowerShell,然后输入以下命令启用虚拟机平台:

Enable-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform

以管理员权限启动 PowerShell,然后输入以下命令启用 Linux 子系统功能:

Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux

在以上每一步命令执行完之后,PowerShell 中可能会提示你重新启动计算机。按“Y”可以重新启动。

启用 VirtualMachinePlatform

启用 Microsoft-Windows-Subsystem-Linux

正在启用 Linux 子系统

当然,这个命令跟你在控制面板中启用“适用于 Windows 的 Linux 子系统”功能是一样的。

在控制面板中启用虚拟机平台和 Linux 子系统

第二步:安装一个 Linux 发行版

打开微软商店应用,在搜索框中输入“Linux”然后搜索,你可以看到搜索结果中有很多的 Linux 发行版可以选择。选择一个你喜欢的 Linux 发行版本然后安装:

搜索 Linux

选择一个 Linux 发行版本然后安装:

安装一个 Linux 发行版

需要注意,在商店中的安装并没有实际上完成 Linux 子系统的安装,你还需要运行一次已安装的 Linux 发行版以执行真正的安装操作。

安装 Linux

第三步:启用 WSL2

重要:你的操作系统版本必须至少大于或等于 Windows 10.0.18917 !

使用 wsl -l 可以列出当前系统上已经安装的 Linux 子系统名称。注意这里的 -l 是列表“list”的缩写,是字母 l 不是其他字符。

wsl -l

如果提示 wsl 不是内部或外部命令,说明你没有启用“适用于 Windows 的 Linux 子系统”,请先完成本文第一步。

如果提示没有发现任何已安装的 Linux,说明你没有安装 Linux 发行版,或者只是去商店下载了,没有运行它执行真正的安装,请先完成本文第二步。

使用 wsl --set-version <Distro> 2 命令可以设置一个 Linux 发行版的 WSL 版本。命令中 <Distro> 替换为你安装的 Linux 发型版本的名称,也就是前面通过 wsl -l 查询到的名称。

本文的示例使用的是小白门喜欢的 Ubuntu 发行版。

wsl --set-version Ubuntu> 2

设置 WSL2

当然,使用以下命令可以在以后安装 Linux 的时候默认启用 WSL2:

wsl --set-default-version 2

参考资料

07-03 2019

如何给 Windows Terminal 增加一个新的终端(以 Bash 为例)

Windows Terminal 的预览版本可以在微软应用商店下载,下载完后它原生就可以打开三个不同的终端 PowerShell Core、CMD 和 PowerShell。然而我的计算机上还安装了一个 Bash 可以如何添加到 Windows Terminal 里呢?

本文将介绍添加一个新终端应该如何修改配置。


下载安装 Windows Terminal

Windows Terminal 预览版已上架微软应用商店,你可以前往下载:

随后,在开始菜单中启动 Windows Terminal。

Windows Terminal

打开配置文件

在界面的右上角点按下拉按钮,点击“Settings”可以打开配置文件。

Settings

这个配置文件虽然看起来有 300+ 行,但实际上结构非常简单。我把它折叠起来加上一点点注释你应该很容易看出其配置文件的结构。

配置文件的结构

新增一个 profile

我们把原来的一个 profile 复制一份出来,这样我们就能够写一份自己的终端配置了。

新复制出来一个 profile

下面是我添加的 Bash 的配置。如果你是通过安装 Git for Windows 而安装的 Git Bash,那么默认路径就是 C:\Program Files\Git\bin\bash.exe

{
    "acrylicOpacity" : 0.5,
    "closeOnExit" : true,
    "colorScheme" : "Campbell",
    "commandline" : "C:\\Program Files\\Git\\bin\\bash.exe",
    "cursorColor" : "#FFFFFF",
    "cursorShape" : "bar",
    "fontFace" : "Monaco",
    "fontSize" : 12,
    "guid" : "{1d4e097e-fe87-4164-97d7-3ca794c316fd}",
    "historySize" : 9001,
    "icon" : "C:\\Users\\walterlv\\Resources\\Icons\\git-bash.png",
    "name" : "Bash",
    "padding" : "0, 0, 0, 0",
    "snapOnInput" : true,
    "startingDirectory" : "%USERPROFILE%",
    "useAcrylic" : true
},

注意,必须要改的有这些项:

  1. commandline 你需要改成你的新的终端的路径;
  2. guid 必须使用新的跟其他终端不重复的 guid;
  3. name 改为终端的名称(本例中是 Bash,虽然不是必须,但强烈建议修改)

Visual Studio 自带了一个 guid 生成工具,你可以在菜单的工具中找到:

Visual Studio 自带的 GUID 生成工具

你也可以在网上搜索 GUID 生成器得到很多在线的 GUID 生成工具。

另外,还有一些可选的参数:

  • useAcrylic 使用亚克力效果
  • acrylicOpacity 亚克力效果透明度
  • colorScheme 配色方案(配置文件后面自带了五种配色方案,你也可以额外再添加新的配色方案)
  • fontFace 字体名称
  • fontSize 字号大小
  • icon 图标
  • startingDirectory 初始路径

其中,你可能需要一个 icon 文件,下面有一个 Git Bash 的图标,有需要自取:

Git Bash 图标

最终效果

在你按下 Ctrl+S 保存这个配置文件之后,配置将会立刻生效。你可以在你的 Windows Terminal 中看到你新增的 Bash 终端了。

最终效果

06-16 2019

Windows 10 解决无法完整下载安装语言包(日语输入法无法下载使用)

最近我想在我的 Windows 10 上安装一个新的语言包,在 “设置” -> “时间和语言” -> “语言” 中,添加了新的语言之后,语言进入了下载状态。但是没过一小会儿,下载进度条就结束了,提示语言已经下载安装完成。但实际上只能作为显示使用,(日语)输入法却不能使用。

我找了很多的资料试图解决这个问题,但发现竟然没有任何一种现有方法可以解决我的问题(这可能是日语输入法特有的问题吧)。最终解决后,我将网上搜集到的方法以及我实际解决所使用的方法都收录进来,方便大家后续解决问题。


问题描述

网上找到了一段跟我几乎一样的描述,可以前往这里查看。我发现他描述得非常准确,所以就直接引用了他的原话:

添加语言的时候能下载显示语言,点进选项后发现输入语言没有自动下载和安装,手动点下载,进度条在卡在前半不动,几秒后自动跳掉。

造成的影响是:1.日文输入法能出现,但无法切换到假名状态,只能输入英文;……

我能够添加完成日语,并且它也能作为我的显示语言正常显示。但是进入语言之后,发现里面的三个可供下载的扩展选项都没有下载。而如果手动点击下载,无论如何也没有反应。由于输入法就是这里的第一个扩展选项,所以虽然可以切换到日语的微软输入法,但是只能输入英文字母,而无法输入任何日语文字(にほんご)。

如下图,无论怎么点击都不会下载。重启无效。

怎么点都没反应

解决

网上的解决方案有很多种,我这里整理最有可能解决问题的两种。

  • 删除下载缓存(通用解决方案)
  • 暂时关闭 UAC(本次我是此方法成功的)
  • 其他方法(请点击本文最后的参考链接,包含我的各种参考资料)

删除下载缓存

前往文件夹:C:\Windows\SoftwareDistribution\Download

这里面的内容都是 Windows 的各种下载的缓存。如果是因为下载的文件损坏,那么删除此文件夹中的全部内容通常可以解决问题。

你不用担心删除此文件夹会出现什么问题,因为重新下载那些缓存所付出的代价往往比修复的问题本身更小。

在时机尝试中,我删除了此文件夹后,重新启动计算机。我发现再点击语言下载之后不会是没有反应了,而是出现了一小会儿的进度条;再随后才继续恢复成没有下载的状态。再之后,也是怎么点击下载也没有反应了。

于是几乎可以认定语言包的下载缓存确认是在这个路径中的,但是导致无法下载安装的本质原因却不是这个。

暂时关闭 UAC

后来我尝试了网上的其他各种方案,都没有解决。包括删除重新安装语言包,包括使用 PowerShell 脚本删除语言列表项,包括清理注册表项等等。

我突然间异想天开认为有可能是 UAC(用户账户控制)的问题,但是无论使用中文还是英文搜索,无论使用谷歌还是必应搜索引擎,无论翻了多少页,都没有找到此问题与 UAC 有关的文章、帖子或解决方案。

但我还是尝试了。

我打开了 UAC 设置,临时把滑块从最顶部拖到最底部,以关闭 UAC。

UAC 设置

点击“下载”后,终于有反应可以继续完成下载了。看起来是解决了,但这三个下载按钮只有一个可以继续下载安装。但是我重启计算机之后,三个按钮都可以正常点击下载安装了。

已经可以开始下载安装了

已经可以开始下载安装了

最后,我把 UAC 拖到最顶部还原我的设置。

关于为什么我会拖到最顶部,你可以阅读我的另一篇博客:

进程监控与调试

当然,我还尝试过使用 Visual Studio 附加 SystemSettings.exe 进程进行调试,发现在每次点击“下载”没有反应的时候会看到出现了一个“线程已结束”的输出,并没有实际上的意义。

我也希望通过 Process Monitor 查看下载失败时是否涉及到 IO,结果也没有什么线索。

其他方法

另外,有小伙伴说可以去另一台可以下载安装的电脑上拷贝 C:\Windows\IME\IMEJP 目录过来也可以使用。

期望

幸好最终解决了问题,希望可以帮到读者。

如果你有其他方法解决了问题,或者说你试过了各种方法也没有解决问题,欢迎在本文原文的评论区留言,也许能找到更合适的解决办法。


参考资料

06-04 2019

git 配置错误导致无法推送远端仓库?本文介绍各种修复方式

无论你使用原生的 git 命令行,还是使用其他的 GUI 客户端来管理你的 git 仓库,都会遇到 git 远程仓库的身份认证机制。如果在某个远程仓库第一次认证的时候输入了错误的信息,那么 git 以及一部分 git GUI 客户端会记住这个错误的身份认证信息,使得以后也不能继续与远程仓库进行交互了。

本文介绍如何清除 git 的身份认证信息,以便你可以重新获得输入正确身份认证的机会。


凭据管理器

如果你使用基于 https 的身份认证方式操作 git 远端,并且输入了错误的密码,那么这部分密码将保存在 Windows 的凭据管理器中。

在 Windows 搜索框中搜索“凭据管理器”或者在控制面板中进入“用户账户”->“凭据管理器”可以打开凭据管理界面。我们需要选择右边的“Windows 凭据”标签。

随后,在下方的“普通凭据”中,找到出现问题的 git 远程仓库地址,然后展开,将其删除。

凭据管理器

删除之后,再次在 git 命令行或者基于 git 命令行的客户端的 GUI 客户端中使用 git 操作远端仓库将会重新提示输入这个远端仓库的用户名和密码。

.ssh

基于 SSH 的身份认证方式需要自己手工方式都是需要自己手动配置好才可以正常使用的,不会给你像 https 那样输错密码的机会。如果配置错误则不能操作远端仓库。当然,配错了直接删掉重新再来一次就好了。参见网上一大堆的配置方法:git-ssh 配置和使用 - fedl - SegmentFault 思否

配置好的 SSH

另外,有一些客户端如 Tortoise 会自带一份认证管理工具。TortoiseGit 自带了 TortoiseGitPlink,它声称比自带的 SSH 要好用但问题是你得单独为它配置一遍……(逃

命名 SSH 配好了而没有配 TortoiseGitPlink 的时候,它分分钟挂给你看:

TortoiseGitPlink

那么如何修复呢?

方法一:替换 SSH 客户端

替换为与 git 命令行相同的 SSH 客户端可以避免重复配置公私钥对。

打开 TortoiseGit 的设置页面,切换到“网络”标签,然后将 SSH 客户端改为 SSH。通常在 C:\Program Files\Git\usr\bin 目录中,如果没找到,也可以去 C:\Program Files (x86)\Git\bin\ssh.exe 目录寻找。

SSH 客户端

方法二:导入已有的 SSH 配置

打开 C:\Program Files\TortoiseGit\bin\puttygen.exe 程序,然后点击“Load”,选择 git 客户端早已配好的 ssh 私钥。如果打开文件对话框中你找不到密钥文件,可能需要将过滤器设置为所有文件(*.*)。(如果之前没配好 SSH,那么建议去配置一下,不然 SSH 的认证方式将只有 TortoiseGit 客户端工具可用。本节接下来的内容将默认你已经配好 SSH,在远端仓库添加了公钥。)

puttygen

导入成功

导入成功之后,点击保存私钥,选择一个合适的路径存下来。

随后,打开 C:\Program Files\TortoiseGit\bin\puttygen.exe 程序。打开之后,你会在任务栏通知区域看到它的图标,右键点击 Add Key 然后选择我们刚刚保存的私钥。

Add Key

随后,你需要保持 puttygen.exe 一直处于运行状态,以便 TortoiseGit 可以一直使用。


参考资料

06-01 2019

使用 ProcessMonitor 找到进程所操作的文件的路径

很多系统问题都是可以修的,不需要重装系统,但是最近我还是重装了。发现之前正在玩的一款游戏的存档没有了……因为我原有系统的数据并没有删除,所以我还是能找回原来的游戏存档的。但是,我怎么知道这款游戏将存档放在了那个路径下呢?搜索当然是好方法,不过我喜欢玩的游戏大多是冷门游戏,有些搜不到。于是我就用 Process Monitor 找到了存档所在,恢复了我的游戏进度。

本文介绍如何使用 ProcessMonitor 找出进程创建和修改的文件路径。


下载 Process Monitor

Process Monitor 是微软极品工具箱的一部分,你可以在此页面下载:

打开 Process Monitor

当你一开始打开 Process Monitor 的时候,列表中会立刻刷出大量的进程的操作记录。这么多的记录会让我们找到目标进程操作的文件有些吃力,于是我们需要设置规则。

Process Monitor 的工具栏按钮并不多,而且我们这一次的目标只会用到其中的两个:

  • 清除列表(将已经记录的所有数据清空,便于聚焦到我们最关心的数据中)
  • 设置过滤器(防止大量无关的进程操作进入列表中干扰我们的查找)

Process Monitor 的工具栏按钮

设置过滤规则

我启动了我想要玩的游戏,在任务管理器中发现它的进程名称是 RIME.exe。呃……如果你也想玩,给你个链接:

点击设置过滤规则按钮,可以看到下面的界面:

设置过滤器

可以选定 某个名词 与另一个字符串 进行某种操作 之后 引入 (Include)排除 (Exclude)

我希望找到 RIME 这款游戏的游戏存档位置,所以我需要进入游戏,玩到第一个会存档的地方之后观察监视的操作记录。

所以我希望的过滤器规则是:

  1. 将所有不是 RIME.exe 进程的记录全部排除;
  2. 将不是文件操作的记录全部排除;
  3. 将读文件的记录排除(这样剩下的只会是写文件,毕竟游戏读文件很频繁的)。

于是我设置了这些规则:

[ProcessName] is [RIME.exe]      then [Exclude]
[Operation]   is [RegOpenKey]    then [Exclude]
[Operation]   is [RegCloseKey]   then [Exclude]
[Operation]   is [RegQueryKey]   then [Exclude]
[Operation]   is [RegQueryValue] then [Exclude]
[Operation]   is [RegEnumKey]    then [Exclude]
[Operation]   is [RegSetInfoKey] then [Exclude]
[Operation]   is [ReadFile]      then [Exclude]

这样,剩下的记录将主要是文件写入以及一些不常见的操作了。

分析记录

现在,我在游戏里面玩到了第一个存档点,终于在 Process Monitor 的进程列表中看到了创建文件和写入文件相关的操作了。

记录的列表

通过观察 Path 的值,我可以知道 RIME 游戏的存档放在了 %LocalAppData%\SirenGame 文件夹下。

于是我关掉 RIME 游戏,将原来系统中的此文件夹覆盖到新系统中的此文件夹之后,再次打开游戏,我恢复了我的全部游戏存档了。

05-31 2019

如何为 Win32 的打开和保存对话框编写文件过滤器(Filter)

在使用 Win32 / WPF / Windows Forms 的打开或保存文件对话框的时候,多数情况下我们都会考虑编写文件过滤器。UWP 中有 FileTypeFilter 集合可以添加不同的文件种类,但 Win32 中却是一个按一定规则组合而成的字符串。

因为其包含一定的格式,所以可能写错。本文介绍如何编写 Filter。


编写 Filter

Filter 使用竖线分隔不同种类的过滤器,比如 图片|*.png;*.jpg|文本|*.txt|walterlv 的自定义格式|*.lvyi

var dialog = new OpenFileDialog();
dialog.Filter = "图片|*.png;*.jpg|文本|*.txt|walterlv 的自定义格式|*.lvyi";
dialog.ShowDialog(this);

过滤器的显示效果

有时我们会看到一些程序的过滤器里面显示了过滤器本身,而不止是名称,实际上是因为名称中包含了过滤器:

图片 (png, jpg)|*.png;*.jpg|文本 (txt)|*.txt|walterlv 的自定义格式 (lvyi)|*.lvyi

名称中包含过滤器

你不可以在过滤器中省略名称或者过滤器任何一个部分,否则会抛出异常。

附:如何显示对话框

对于 .NET Core 版本的 WPF 或者 Windows Forms 程序来说,需要安装 Windows 兼容 NuGet 包:

安装后可以使用 Windows Forms 版本的 OpenFileDialog 或者 WPF 版本的 Microsoft.Win32.OpenFileDialog


参考资料

05-25 2019

cmd.exe 的命令行启动参数(可用于执行命令、传参或进行环境配置)

有一些程序不支持被直接启动,而要求通过命令行启动。这个时候,你就需要使用 cmd.exe 来启动这样的程序。我们都知道如何在 cmd.exe 中启动一个程序,但是当你需要自动启动这个程序的时候,你就需要知道如何通过 cmd.exe 来启动一个程序,而不是手工输入然后回车运行了。

本文就介绍 cmd.exe 的命令行启动参数。利用这些参数,你可以自动化地通过 cmd.exe 程序来完成一些原本需要通过手工执行的操作或者突破一些限制。


一些必须通过命令行启动的程序

一般来说,编译生成的 exe 程序都可以直接启动,即便是命令行程序也是如此。但是有一些程序就是要做一些限制。比如下面的 FRP 反向代理程序:

FRP 反向代理程序限制必须从命令行启动

那么我们如何能够借助于 cmd.exe 来启动它呢?接下来说明。

顺便,使用 PowerShell 来启动的方法可以参见我的另一篇博客:

cmd.exe 的帮助文档

先打开一个 cmd,然后输入:

> cmd /?

你就可以看到 cmd.exe 的使用说明:

cmd.exe 的使用说明

启动 Windows 命令解释器的一个新实例

CMD [/A | /U] [/Q] [/D] [/E:ON | /E:OFF] [/F:ON | /F:OFF] [/V:ON | /V:OFF]
    [[/S] [/C | /K] string]

你可以随时输入上面的 cmd /? 命令来查看这些参数详细说明,所以本文不会非常详细地列举各个参数的含义,只会列出一些常见的使用示例。

cmd.exe 的启动参数示例

使用 cmd.exe 间接启动一个程序并传入参数

下面的命令,使用 cmd 间接启动 frpc.exe 反向代理程序,并给 frpc.exe 程序传入 -c ./frpc.ini 的启动参数:

> cmd /c D:\walterlv\frp\frpc.exe -c ./frpc.ini

关于为什么会用这种方式启动 frpc.exe,则是为了设置 frpc.exe 为开机自动启动。

因为我写了一些 Asp.NET Core 的服务,详见:

另外,间接启动一个程序的时候也可以传入 /k 参数。与 /c 参数不同的是:

  • /c 在执行完程序之后,cmd.exe 也会终止
  • /k 在执行完程序之后,cmd.exe 依然会继续运行

所以 /c 命令会更适用于自动化的脚本,而 /k 命令则更适用于半自动化的脚本。

cmd.exe 启动参数使用中的坑

在上面的例子中,我们的路径中不涉及到空格。我们知道,路径中有空格的话,在命令行中使用需要加上引号。但实际上如果你真的给路径加上了引号,会发现 cmd.exe 就开始不识别你的命令路径了。

这个时候,你需要在整个传给 cmd.exe 的命令外层再加一层引号:

> cmd /c " "D:\walterlv folders\frp\frpc.exe" -c ./frpc.ini "

以上,感谢 林德熙 挥泪踩出来的坑,详见:

附 cmd.exe 的全部启动参数说明

启动 Windows 命令解释器的一个新实例

CMD [/A | /U] [/Q] [/D] [/E:ON | /E:OFF] [/F:ON | /F:OFF] [/V:ON | /V:OFF]
    [[/S] [/C | /K] string]

/C 执行字符串指定的命令然后终止 /K 执行字符串指定的命令但保留 /S 修改 /C 或 /K 之后的字符串处理(见下) /Q 关闭回显 /D 禁止从注册表执行 AutoRun 命令(见下) /A 使向管道或文件的内部命令输出成为 ANSI /U 使向管道或文件的内部命令输出成为 Unicode /T:fg 设置前台/背景颜色(详细信息见 COLOR /?) /E:ON 启用命令扩展(见下) /E:OFF 禁用命令扩展(见下) /F:ON 启用文件和目录名完成字符(见下) /F:OFF 禁用文件和目录名完成字符(见下) /V:ON 使用 ! 作为分隔符启用延迟的环境变量 扩展。例如,/V:ON 会允许 !var! 在执行时 扩展变量 var。var 语法会在输入时 扩展变量,这与在一个 FOR 循环内不同。 /V:OFF 禁用延迟的环境扩展。

注意,如果字符串加有引号,可以接受用命令分隔符 “&&” 分隔多个命令。另外,由于兼容性 原因,/X 与 /E:ON 相同,/Y 与 /E:OFF 相同,且 /R 与 /C 相同。任何其他开关都将被忽略。

如果指定了 /C 或 /K,则会将该开关之后的 命令行的剩余部分作为一个命令行处理,其中,会使用下列逻辑 处理引号(“)字符:

1.  如果符合下列所有条件,则会保留
    命令行上的引号字符:

    - 不带 /S 开关
    - 正好两个引号字符
    - 在两个引号字符之间无任何特殊字符,
      特殊字符指下列字符: &<>()@^|
    - 在两个引号字符之间至少有
      一个空格字符
    - 在两个引号字符之间的字符串是某个
      可执行文件的名称。

2.  否则,老办法是看第一个字符
    是否是引号字符,如果是,则去掉首字符并
    删除命令行上最后一个引号,保留
    最后一个引号之后的所有文本。

如果 /D 未在命令行上被指定,当 CMD.EXE 开始时,它会寻找 以下 REG_SZ/REG_EXPAND_SZ 注册表变量。如果其中一个或 两个都存在,这两个变量会先被执行。

HKEY_LOCAL_MACHINE\Software\Microsoft\Command Processor\AutoRun

    和/或

HKEY_CURRENT_USER\Software\Microsoft\Command Processor\AutoRun

命令扩展是按默认值启用的。你也可以使用 /E:OFF ,为某一 特定调用而停用扩展。你 可以在机器上和/或用户登录会话上 启用或停用 CMD.EXE 所有调用的扩展,这要通过设置使用 REGEDIT.EXE 的注册表中的一个或两个 REG_DWORD 值:

HKEY_LOCAL_MACHINE\Software\Microsoft\Command Processor\EnableExtensions

    和/或

HKEY_CURRENT_USER\Software\Microsoft\Command Processor\EnableExtensions

到 0x1 或 0x0。用户特定设置 比机器设置有优先权。命令行 开关比注册表设置有优先权。

在批处理文件中,SETLOCAL ENABLEEXTENSIONS 或 DISABLEEXTENSIONS 参数 比 /E:ON 或 /E:OFF 开关有优先权。请参阅 SETLOCAL /? 获取详细信息。

命令扩展包括对下列命令所做的 更改和/或添加:

DEL or ERASE
COLOR
CD or CHDIR
MD or MKDIR
PROMPT
PUSHD
POPD
SET
SETLOCAL
ENDLOCAL
IF
FOR
CALL
SHIFT
GOTO
START (同时包括对外部命令调用所做的更改)
ASSOC
FTYPE

有关特定详细信息,请键入 commandname /? 查看。

延迟环境变量扩展不按默认值启用。你 可以用/V:ON 或 /V:OFF 开关,为 CMD.EXE 的某个调用而 启用或停用延迟环境变量扩展。你 可以在机器上和/或用户登录会话上启用或停用 CMD.EXE 所有 调用的延迟扩展,这要通过设置使用 REGEDIT.EXE 的注册表中的 一个或两个 REG_DWORD 值:

HKEY_LOCAL_MACHINE\Software\Microsoft\Command Processor\DelayedExpansion

    和/或

HKEY_CURRENT_USER\Software\Microsoft\Command Processor\DelayedExpansion

到 0x1 或 0x0。用户特定设置 比机器设置有优先权。命令行开关 比注册表设置有优先权。

在批处理文件中,SETLOCAL ENABLEDELAYEDEXPANSION 或 DISABLEDELAYEDEXPANSION 参数比 /V:ON 或 /V:OFF 开关有优先权。请参阅 SETLOCAL /? 获取详细信息。

如果延迟环境变量扩展被启用, 惊叹号字符可在执行时间被用来 代替一个环境变量的数值。

你可以用 /F:ON 或 /F:OFF 开关为 CMD.EXE 的某个 调用而启用或禁用文件名完成。你可以在计算上和/或 用户登录会话上启用或禁用 CMD.EXE 所有调用的完成, 这可以通过使用 REGEDIT.EXE 设置注册表中的下列 REG_DWORD 的全部或其中之一:

HKEY_LOCAL_MACHINE\Software\Microsoft\Command Processor\CompletionChar
HKEY_LOCAL_MACHINE\Software\Microsoft\Command Processor\PathCompletionChar

    和/或

HKEY_CURRENT_USER\Software\Microsoft\Command Processor\CompletionChar
HKEY_CURRENT_USER\Software\Microsoft\Command Processor\PathCompletionChar

由一个控制字符的十六进制值作为一个特定参数(例如,0x4 是Ctrl-D,0x6 是 Ctrl-F)。用户特定设置优先于机器设置。 命令行开关优先于注册表设置。

如果完成是用 /F:ON 开关启用的,两个要使用的控制符是: 目录名完成用 Ctrl-D,文件名完成用 Ctrl-F。要停用 注册表中的某个字符,请用空格(0x20)的数值,因为此字符 不是控制字符。

如果键入两个控制字符中的一个,完成会被调用。完成功能将 路径字符串带到光标的左边,如果没有通配符,将通配符附加 到左边,并建立相符的路径列表。然后,显示第一个相符的路 径。如果没有相符的路径,则发出嘟嘟声,不影响显示。之后, 重复按同一个控制字符会循环显示相符路径的列表。将 Shift 键跟控制字符同时按下,会倒着显示列表。如果对该行进行了 任何编辑,并再次按下控制字符,保存的相符路径的列表会被 丢弃,新的会被生成。如果在文件和目录名完成之间切换,会 发生同样现象。两个控制字符之间的唯一区别是文件完成字符 符合文件和目录名,而目录完成字符只符合目录名。如果文件 完成被用于内置式目录命令(CD、MD 或 RD),就会使用目录 完成。 用引号将相符路径括起来,完成代码可以正确处理含有空格 或其他特殊字符的文件名。同时,如果备份,然后从行内调用 文件完成,完成被调用时位于光标右方的文字会被调用。

需要引号的特殊字符是: <space> ()[]{}^=;!'+,~(&()`

05-23 2019

WPF 使用 AppBar 将窗口停靠在桌面上,让其他程序不占用此窗口的空间(附我封装的附加属性)

本文介绍如何使用 Windows 的 AppBar 相关 API 实现固定停靠在桌面上的特殊窗口。


停靠窗口

你可能并不明白停靠窗口是什么意思。

看下图,你可能使用过 OneNote 的停靠窗口功能。当打开一个新的 OneNote 停靠窗口之后,这个新的 OneNote 窗口将固定显示在桌面的右侧,其他的窗口就算最大化也只会占据剩余的空间。

OneNote 的这种功能可以让你在一边浏览网页或做其他事情的时候,以便能够做笔记。同时又不用担心其他窗口最大化的时候会占据记笔记的一部分空间。

OneNote 的停靠窗口

这其实也是 Windows 任务栏所使用的方法。

OneNote 中给出的名称叫做“停靠窗口”,于是这可以代表微软希望用户对这个概念的理解名词。

只是,这个概念在 Windows API 中的名称叫做 AppBar。

AppBar

要做出停靠窗口的效果,最核心的 API 是 SHAppBarMessage,用于发送 AppBar 消息给操作系统,以便让操作系统开始处理此窗口已形成一个 AppBar 窗口。也就是我们在用户交互上所说的“停靠窗口”。

虽然说要让一个窗口变成 AppBar 只需要一点点代码,但是要让整个停靠窗口工作得真的像一个停靠窗口,依然需要大量的辅助代码。所以我将其封装成了一个 DesktopAppBar 类,方便 WPF 程序来调用。

如何使用

以下使用,你需要先获取我封装的源码才可以编译通过:

你可以在 XAML 中使用:

<Window x:Class="Walterlv.Demo.DesktopDocking.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:dock="clr-namespace:Walterlv.Demo.DesktopDocking"
        mc:Ignorable="d" Title="Walterlv 的停靠窗口" Height="450" Width="500"
        dock:DesktopAppBar.AppBar="Right">
    <StackPanel Background="#ffcd42">
        <TextBlock FontSize="64" Margin="64" TextAlignment="Center" Text="walterlv 的停靠窗口" />
        <Button Content="再停靠一个 - blog.walterlv.com" FontSize="32" Padding="32" Margin="32" Background="#f9d77b" BorderThickness="0"
                Click="Button_Click"/>
    </StackPanel>
</Window>

核心代码是其中的一处属性赋值 dock:DesktopAppBar.AppBar="Right",以及前面的命名空间声明 xmlns:dock="clr-namespace:Walterlv.Demo.DesktopDocking"

你也可以在 C# 代码中使用:

using System;
using System.Windows;

namespace Walterlv.Demo.DesktopDocking
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        protected override void OnSourceInitialized(EventArgs e)
        {
            base.OnSourceInitialized(e);
            DesktopAppBar.SetAppBar(this, AppBarEdge.Right);
        }
    }
}

使用以上代码中的任何一种方式,你就可以让你的窗口在右边停靠了。

停靠的窗口

从图中我们可以发现,我们的示例窗口停靠在了右边,其宽度就是我们在 XAML 中设置的窗口宽度(当然这是我封装的逻辑,而不是 AppBar 的原生逻辑)。

同时我们还能注意到,Visual Studio 的窗口是处于最大化的状态的——这是停靠窗口的最大优势——可以让其他窗口的工作区缩小,在最大化的时候不会覆盖到停靠窗口的内容。

另外,如果设置了第二个停靠窗口,那么第二个停靠窗口会挤下第一个窗口的位置。

两个停靠窗口

如何还原

Windows AppBar 的 API 有一个很不好的设定,如果进程退出了,那么 AppBar 所占用的空间 并不会还原!!!

不过不用担心,我在封装的代码里面加入了窗口关闭时还原空间的代码,如果你正常关闭窗口,那么停靠窗口占用的空间就会及时还原回来。

当然,你也可以适时调用下面的代码:

DesktopAppBar.SetAppBar(this, AppBarEdge.None);

附源码

由于源码一直在持续改进,所以本文中贴的源代码可能不是最新的。你可以在以下仓库找到这段源码的最新版本:

using System;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Interop;

// ReSharper disable IdentifierTypo
// ReSharper disable InconsistentNaming
// ReSharper disable EnumUnderlyingTypeIsInt
// ReSharper disable MemberCanBePrivate.Local
// ReSharper disable UnusedMember.Local
// ReSharper disable UnusedMember.Global

namespace Walterlv.Demo.DesktopDocking
{
    /// <summary>
    /// 表示窗口停靠到桌面上时的边缘方向。
    /// </summary>
    public enum AppBarEdge
    {
        /// <summary>
        /// 窗口停靠到桌面的左边。
        /// </summary>
        Left = 0,

        /// <summary>
        /// 窗口停靠到桌面的顶部。
        /// </summary>
        Top,

        /// <summary>
        /// 窗口停靠到桌面的右边。
        /// </summary>
        Right,

        /// <summary>
        /// 窗口停靠到桌面的底部。
        /// </summary>
        Bottom,

        /// <summary>
        /// 窗口不停靠到任何方向,而是成为一个普通窗口占用剩余的可用空间(工作区)。
        /// </summary>
        None
    }

    /// <summary>
    /// 提供将窗口停靠到桌面某个方向的能力。
    /// </summary>
    public class DesktopAppBar
    {
        /// <summary>
        /// 标识 Window.AppBar 的附加属性。
        /// </summary>
        public static readonly DependencyProperty AppBarProperty = DependencyProperty.RegisterAttached(
            "AppBar", typeof(AppBarEdge), typeof(DesktopAppBar),
            new PropertyMetadata(AppBarEdge.None, OnAppBarEdgeChanged));

        /// <summary>
        /// 获取 <paramref name="window"/> 当前的停靠边缘。
        /// </summary>
        /// <param name="window">要获取停靠边缘的窗口。</param>
        /// <returns>停靠边缘。</returns>
        public static AppBarEdge GetAppBar(Window window) => (AppBarEdge)window.GetValue(AppBarProperty);

        /// <summary>
        /// 设置 <paramref name="window"/> 的停靠边缘方向。
        /// </summary>
        /// <param name="window">要设置停靠的窗口。</param>
        /// <param name="value">要设置的停靠边缘方向。</param>
        public static void SetAppBar(Window window, AppBarEdge value) => window.SetValue(AppBarProperty, value);

        private static readonly DependencyProperty AppBarProcessorProperty = DependencyProperty.RegisterAttached(
            "AppBarProcessor", typeof(AppBarWindowProcessor), typeof(DesktopAppBar), new PropertyMetadata(null));

        [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalse")]
        private static void OnAppBarEdgeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if (DesignerProperties.GetIsInDesignMode(d))
            {
                return;
            }

            var oldValue = (AppBarEdge) e.OldValue;
            var newValue = (AppBarEdge) e.NewValue;
            var oldEnabled = oldValue is AppBarEdge.Left
                             || oldValue is AppBarEdge.Top
                             || oldValue is AppBarEdge.Right
                             || oldValue is AppBarEdge.Bottom;
            var newEnabled = newValue is AppBarEdge.Left
                             || newValue is AppBarEdge.Top
                             || newValue is AppBarEdge.Right
                             || newValue is AppBarEdge.Bottom;
            if (oldEnabled && !newEnabled)
            {
                var processor = (AppBarWindowProcessor) d.GetValue(AppBarProcessorProperty);
                processor.Detach();
            }
            else if (!oldEnabled && newEnabled)
            {
                var processor = new AppBarWindowProcessor((Window) d);
                d.SetValue(AppBarProcessorProperty, processor);
                processor.Attach(newValue);
            }
            else if (oldEnabled && newEnabled)
            {
                var processor = (AppBarWindowProcessor) d.GetValue(AppBarProcessorProperty);
                processor.Update(newValue);
            }
        }

        /// <summary>
        /// 包含对 <see cref="Window"/> 进行操作以便使其成为一个桌面停靠窗口的能力。
        /// </summary>
        private class AppBarWindowProcessor
        {
            /// <summary>
            /// 创建 <see cref="AppBarWindowProcessor"/> 的新实例。
            /// </summary>
            /// <param name="window">需要成为停靠窗口的 <see cref="Window"/> 的实例。</param>
            public AppBarWindowProcessor(Window window)
            {
                _window = window;
                _callbackId = RegisterWindowMessage("AppBarMessage");
                _hwndSourceTask = new TaskCompletionSource<HwndSource>();

                var source = (HwndSource) PresentationSource.FromVisual(window);
                if (source == null)
                {
                    window.SourceInitialized += OnSourceInitialized;
                }
                else
                {
                    _hwndSourceTask.SetResult(source);
                }

                _window.Closed += OnClosed;
            }

            private readonly Window _window;
            private readonly TaskCompletionSource<HwndSource> _hwndSourceTask;
            private readonly int _callbackId;

            private WindowStyle _restoreStyle;
            private Rect _restoreBounds;
            private ResizeMode _restoreResizeMode;
            private bool _restoreTopmost;

            private AppBarEdge Edge { get; set; }

            /// <summary>
            /// 在可以获取到窗口句柄的时候,给窗口句柄设置值。
            /// </summary>
            private void OnSourceInitialized(object sender, EventArgs e)
            {
                _window.SourceInitialized -= OnSourceInitialized;
                var source = (HwndSource) PresentationSource.FromVisual(_window);
                _hwndSourceTask.SetResult(source);
            }

            /// <summary>
            /// 在窗口关闭之后,需要恢复窗口设置过的停靠属性。
            /// </summary>
            private void OnClosed(object sender, EventArgs e)
            {
                _window.Closed -= OnClosed;
                _window.ClearValue(AppBarProperty);
            }

            /// <summary>
            /// 将窗口属性设置为停靠所需的属性。
            /// </summary>
            private void ForceWindowProperties()
            {
                _window.WindowStyle = WindowStyle.None;
                _window.ResizeMode = ResizeMode.NoResize;
                _window.Topmost = true;
            }

            /// <summary>
            /// 备份窗口在成为停靠窗口之前的属性。
            /// </summary>
            private void BackupWindowProperties()
            {
                _restoreStyle = _window.WindowStyle;
                _restoreBounds = _window.RestoreBounds;
                _restoreResizeMode = _window.ResizeMode;
                _restoreTopmost = _window.Topmost;
            }

            /// <summary>
            /// 使一个窗口开始成为桌面停靠窗口,并开始处理窗口停靠消息。
            /// </summary>
            /// <param name="value">停靠方向。</param>
            public async void Attach(AppBarEdge value)
            {
                var hwndSource = await _hwndSourceTask.Task;

                BackupWindowProperties();

                var data = new APPBARDATA();
                data.cbSize = Marshal.SizeOf(data);
                data.hWnd = hwndSource.Handle;

                data.uCallbackMessage = _callbackId;
                SHAppBarMessage((int) ABMsg.ABM_NEW, ref data);
                hwndSource.AddHook(WndProc);

                Update(value);
            }

            /// <summary>
            /// 更新一个窗口的停靠方向。
            /// </summary>
            /// <param name="value">停靠方向。</param>
            public async void Update(AppBarEdge value)
            {
                var hwndSource = await _hwndSourceTask.Task;

                Edge = value;


                var bounds = TransformToAppBar(hwndSource.Handle, _window.RestoreBounds, value);
                ForceWindowProperties();
                Resize(_window, bounds);
            }

            /// <summary>
            /// 使一个窗口从桌面停靠窗口恢复成普通窗口。
            /// </summary>
            public async void Detach()
            {
                var hwndSource = await _hwndSourceTask.Task;

                var data = new APPBARDATA();
                data.cbSize = Marshal.SizeOf(data);
                data.hWnd = hwndSource.Handle;

                SHAppBarMessage((int) ABMsg.ABM_REMOVE, ref data);

                _window.WindowStyle = _restoreStyle;
                _window.ResizeMode = _restoreResizeMode;
                _window.Topmost = _restoreTopmost;

                Resize(_window, _restoreBounds);
            }

            private IntPtr WndProc(IntPtr hwnd, int msg,
                IntPtr wParam, IntPtr lParam, ref bool handled)
            {
                if (msg == _callbackId)
                {
                    if (wParam.ToInt32() == (int) ABNotify.ABN_POSCHANGED)
                    {
                        var hwndSource = _hwndSourceTask.Task.Result;
                        var bounds = TransformToAppBar(hwndSource.Handle, _window.RestoreBounds, Edge);
                        Resize(_window, bounds);
                        handled = true;
                    }
                }

                return IntPtr.Zero;
            }

            private static void Resize(Window window, Rect bounds)
            {
                window.Left = bounds.Left;
                window.Top = bounds.Top;
                window.Width = bounds.Width;
                window.Height = bounds.Height;
            }

            private Rect TransformToAppBar(IntPtr hWnd, Rect area, AppBarEdge edge)
            {
                var data = new APPBARDATA();
                data.cbSize = Marshal.SizeOf(data);
                data.hWnd = hWnd;
                data.uEdge = (int) edge;

                if (data.uEdge == (int) AppBarEdge.Left || data.uEdge == (int) AppBarEdge.Right)
                {
                    data.rc.top = 0;
                    data.rc.bottom = (int) SystemParameters.PrimaryScreenHeight;
                    if (data.uEdge == (int) AppBarEdge.Left)
                    {
                        data.rc.left = 0;
                        data.rc.right = (int) Math.Round(area.Width);
                    }
                    else
                    {
                        data.rc.right = (int) SystemParameters.PrimaryScreenWidth;
                        data.rc.left = data.rc.right - (int) Math.Round(area.Width);
                    }
                }
                else
                {
                    data.rc.left = 0;
                    data.rc.right = (int) SystemParameters.PrimaryScreenWidth;
                    if (data.uEdge == (int) AppBarEdge.Top)
                    {
                        data.rc.top = 0;
                        data.rc.bottom = (int) Math.Round(area.Height);
                    }
                    else
                    {
                        data.rc.bottom = (int) SystemParameters.PrimaryScreenHeight;
                        data.rc.top = data.rc.bottom - (int) Math.Round(area.Height);
                    }
                }

                SHAppBarMessage((int) ABMsg.ABM_QUERYPOS, ref data);
                SHAppBarMessage((int) ABMsg.ABM_SETPOS, ref data);

                return new Rect(data.rc.left, data.rc.top,
                    data.rc.right - data.rc.left, data.rc.bottom - data.rc.top);
            }

            [StructLayout(LayoutKind.Sequential)]
            private struct RECT
            {
                public int left;
                public int top;
                public int right;
                public int bottom;
            }

            [StructLayout(LayoutKind.Sequential)]
            private struct APPBARDATA
            {
                public int cbSize;
                public IntPtr hWnd;
                public int uCallbackMessage;
                public int uEdge;
                public RECT rc;
                public readonly IntPtr lParam;
            }

            private enum ABMsg : int
            {
                ABM_NEW = 0,
                ABM_REMOVE,
                ABM_QUERYPOS,
                ABM_SETPOS,
                ABM_GETSTATE,
                ABM_GETTASKBARPOS,
                ABM_ACTIVATE,
                ABM_GETAUTOHIDEBAR,
                ABM_SETAUTOHIDEBAR,
                ABM_WINDOWPOSCHANGED,
                ABM_SETSTATE
            }

            private enum ABNotify : int
            {
                ABN_STATECHANGE = 0,
                ABN_POSCHANGED,
                ABN_FULLSCREENAPP,
                ABN_WINDOWARRANGE
            }

            [DllImport("SHELL32", CallingConvention = CallingConvention.StdCall)]
            private static extern uint SHAppBarMessage(int dwMessage, ref APPBARDATA pData);

            [DllImport("User32.dll", CharSet = CharSet.Auto)]
            private static extern int RegisterWindowMessage(string msg);
        }
    }
}

参考资料

05-16 2019

通过修改环境变量修改当前进程使用的系统 Temp 文件夹的路径

Windows 系统提供了一个在 Windows 单个用户下全局的 Temp 文件夹,用于给各种不同的应用程序提供一个临时目录。但是,直到 Windows 10 推出存储感知功能之前,这个文件夹都一直只归各个应用程序自己管理,应用自己需要删除里面的文件。另外,进程多了,临时文件也会互相影响(例如个数过多、进程读写竞争等等)。

本文介绍将自己当前进程的 Temp 文件夹临时修改到应用程序自己的一个临时目录下,避免与其他程序之间的各种影响,同时也比较容易自行清理。


如何修改 Temp 文件夹的路径

在程序启动的时候,调用如下方法:

var newTempFolder = @"C:\Walterlv\ApplicationTemp";
Environment.SetEnvironmentVariable("TEMP", newTempFolder);
Environment.SetEnvironmentVariable("TMP", newTempFolder);

这样,可以将当前进程的临时文件夹设置到 C:\Walterlv\ApplicationTemp 文件夹下。

上面设置了两个环境变量,实际上 .NET Framework 中主要使用的临时文件夹环境变量是 TMP 那个。

使用临时文件夹中的临时文件

使用 Path.GetTempPath() 可以获取临时文件夹的路径:

var tempPath = Path.GetTempPath();

使用 Path.GetTempFileName() 可以生成一个唯一的临时文件文件名:

var tempPath = Path.GetTempFileName();

不过,使用此方法需要注意,这要求临时文件夹必须存在。如果你使用了前面的方法修改了临时文件夹的地址,请务必确保文件夹存在。

扩展阅读

如果使用 Path.GetTempFileName() 方法创建的临时文件数量达到了 65535 个,而又不及时删除掉创建的文件的话,那么再调用此方法将抛出异常 IOException

需要注意的是,此 API 调用创建的文件数量是当前用户账户下所有程序共同累计的,其他程序用“满”了你的进程也一样会挂。当然,如果你使用的不是 .NET 的 API,而是使用原生 Win32 API,那么你可以指定临时文件名前缀,相同临时文件名前缀的程序会累计数量。而 .NET 中此 API 使用的是 tmp 前缀,所以所有的 .NET 程序会共享这 65535 个文件累计;其他程序使用其他前缀使则分别累计。

另外,如果此方法无法再生成一个唯一的文件名的时候也会抛出异常。

为了解决这些异常,在用户端的解决方案是删除临时文件夹。而在程序端的解决方案是 —— 本文。

本文是为了和 林德熙 一起解决一个光标问题时提出的解决方案的一种。更多关于光标问题的内容可以阅读以下链接:


参考资料

05-15 2019

Directory Opus 使用命令编辑器添加 PowerShell / CMD / Bash 等多种终端到自定义菜单

使用 Directory Opus 替代 Windows 自带的文件资源管理器来管理你计算机上的文件可以极大地提高你的文件处理效率。

本文将教你如何使用 Directory Opus 的命令编辑器功能编写一组菜单,我们将在这组菜单里面集成各种各样的终端。


命令编辑器

如果你是从下面这篇文章阅读过来的,那么你现在应该正好已经打开了一个命令编辑器:

如果你并没有打开命令编辑器,那么可以再阅读上面这篇文章打开一个。

设置 -> 自定义工具栏新建 -> 新建按钮编辑

添加三个终端命令

请参考 Directory Opus 使用命令编辑器集成 TortoiseGit 的各种功能 一文中添加自定义按钮的方法,同样地添加另外的三个按钮。这里,我将三个不同终端的添加参数放到了下面,你可以参考添加:

PowerShell Core

PowerShell Core

Git Bash

Git Bash

特别注意,在函数一栏的参数中,我们传入了一个路径参数。那个参数的末尾必须加上 \.,否则 Git Bash 是无法启动的。

CMD

CMD

添加一个菜单

在添加完上面的三个命令之后,你应该可以在工具栏上看到三个可以启动不同终端的窗口。现在我们需要将它们都集成到一个菜单中。

新建一个菜单

在工具栏上空白处右键,新建 -> 新建菜单,然后右键,编辑这个菜单:

新建菜单

编辑新建菜单

然后,我们又可以弹出一个命令编辑器窗口,由于菜单本身不打开命令只会显示子菜单,所以里面非常简单。设置图标和显示的文字即可。

菜单的命令编辑窗口

然后,依然保持在工具栏的编辑状态,将我们前面创建的三个按钮依次拖入菜单中即可形成一个菜单:

拖入到菜单中

新建一个菜单按钮

在工具栏上空白处右键,新建 -> 新建菜单按钮,这样的菜单除了显示子项之外,还可以执行命令。然后右键,编辑这个菜单:

新建菜单按钮

编辑新建菜单按钮

然后,我们又可以弹出一个命令编辑器窗口,如果我们不打算让这个菜单按钮额外具备一些功能,则值设置图标和文字即可。

菜单按钮的命令编辑窗口

当然,我更期望在这里将你希望默认打开的终端参数设进去,比较方便一些。

然后,依然保持在工具栏的编辑状态,将我们前面创建的三个按钮依次拖入菜单中即可形成一个菜单:

拖入到菜单按钮中

后续

关于命令设置的详细细节,可以继续阅读我的另一篇博客:

最后,在自定义完按钮之后,不要忘了关闭最开始弹出来的“自定义工具栏”的对话框。

“自定义工具栏”对话框

05-15 2019

Directory Opus 使用命令编辑器集成 TortoiseGit 的各种功能

使用 Directory Opus 替代 Windows 自带的文件资源管理器来管理你计算机上的文件可以极大地提高你的文件处理效率。

本文将教你如何使用 Directory Opus 的命令编辑器功能创建一个命令——跟 TortoiseGit 进行集成。


命令编辑器

如果你是从下面这篇文章阅读过来的,那么你现在应该正好已经打开了一个命令编辑器:

如果你并没有打开命令编辑器,那么可以再阅读上面这篇文章打开一个。

设置 -> 自定义工具栏新建 -> 新建按钮编辑

寻找命令

我在 Windows 系统上使用任务管理器查看进程的各项属性 一文中告诉大家可以在任务管理器中查看某个正在运行中的进程的命令行参数,于是我们可以通过这样的方式得知如何集成 TortoiseGit 的各项功能。

比如,我们在一个文件夹中从文件资源管理器中右键,选择 Git 克隆...,等待打开一个 TortoiseGit 的克隆窗口。

TortoiseGit 的 Git 克隆菜单项

这时,我们去任务管理器中查看此任务的命令行参数:

TortoiseGit 克隆的命令行参数

> TortoiseGitProc.exe /command:clone /path:"D:\walterlv" /hwnd:0000000000161264

那么接下来,我们将这些信息逐一填入到命令编辑器窗格中。

填写命令

命令编辑器

函数

函数一栏,如果你只是简单地希望启动一个程序传入参数的话,那么称之为“启动程序”可能更合适一些。

但是,我依然倾向于在后面继续保持“函数”的称呼,因为这才能体现出 Directory Opus 自定义按钮命令的强大。所以如果你后面看到我提及“函数”,那么指的就是这里的功能。

Directory Opus 相比于 Total Commander 的一大特点便是其鼠标支持,这在“函数”一栏的填写中也有所体现。你可以在函数一栏的最右侧看到一个文件夹图标,点击之后可以选择你想启动的程序。

现在,我们通过这个按钮找到 TortoiseGitProc.exe 程序,于是我们就可以在“函数”一栏中自动填入 TortoiseGitProc.exe 的程序路径。

我们在任务管理器中看到了应该给 TortoiseGitProc.exe 传入的参数,所以我们直接在此文本框的后面继续添加参数。添加后的整个文本框中的内容应该是下面这样的:

"C:\Program Files\TortoiseGit\bin\TortoiseGitProc.exe" /command:clone /path:"{sourcepath}"

这里出现了一个 {sourcepaht},这是一个表示当前路径的变量,稍后会作详细的说明。

图标、说明、显示说明、提示信息

我们在“函数”一栏中添加了一个可以启动的程序之后,Directory Opus 的命令编辑窗口会自动帮我们从主程序中获取一个可以显示的图标。

选择了程序之后,出现了图标

点击一下这个图标,可以选择此程序的其他图标。我们现在正在做的是一个 Git Clone 的按钮,所以我们选择一个表示克隆仓库的图标:

选择图标

接着,我们需要进行一些基础的设置:

  • 图标:将显示大图标打勾,可以使用更大更清晰的图标,这对于我这种 UI 党来说会更加友好。
  • 说明:这是最终会出现在按钮上的文字。我填写了“Git 克隆…”,后面的三个点在 Windows 系统中是一种交互惯例,表示点击后还需要用户给出额外的信息才能完成指定的任务。
  • 显示说明:说明文字会出现在图标的哪个方向。我选择了右侧,这跟 Directory Opus 上的多数已有工具栏是保持一致的。
  • 提示信息:上你把鼠标移动到按钮上的时候,将显示的工具提示说明。可以使用比较长的一段话清晰地说明这个按钮是干什么用的。

设置了基础属性的命令

开始于

开始于,指的是点击此按钮运行我们指定的“函数”时,如果函数打开了一个进程,那么此进程的工作路径是什么。

我们先填入 {sourcepath}。这里,我们再次使用了 {sourcepath} 这个变量。稍后会进行说明。

实际上到此为止,如果你按下“确定”按钮,你将在工具栏上看见一个“Git 克隆…”按钮。

“Git 克隆...”按钮

高级

如果你没有关闭此窗口,那么点击“高级…”,我们将打开高级的命令编辑器。现在我们可以注意到下面出现了一个非常大的函数编辑窗口,而此前的“函数”、“开始于”、“运行”选项都消失了。这是因为此函数编辑窗口涵盖了消失的这些按钮的所有功能,而且更为强大,因为可以使用更多种类的命令。

高级命令编辑器

函数类型

留意命令编辑器中的命令,我们此前用鼠标点击的操作实际上对应了两行命令:

cd {sourcepath}
C:\Program Files\TortoiseGit\bin\TortoiseGitProc.exe /command:clone /path:"{sourcepath}"

第一行是将路径转换到 {sourcepath} 变量所指示的路径中,第二行是启动一个程序并传入适当的参数。

而这个参数是什么意思呢?如何可以输入呢?

请点击命令编辑器上面的“参数”按钮,这时会弹出一个菜单,对各种各样可以输入的参数放在一起进行了分类存放。

因为我们要克隆 Git 仓库需要现在 Directory Opus 里面先进入一个文件夹,然后将 Git 仓库克隆到此仓库中,所以我们实际上是希望拿到 Directory Opus 当前正在浏览的文件夹。

{sourcepath} 表示正在操作的源路径,而正在操作的源路径就是 Directory Opus 的当前文件夹(如果你有多个文件夹窗格,则是当前激活的那个窗口所在的文件夹)。所以我们选用了此参数。

Directory Opus 可以输入的参数

最后一步

在自定义完按钮之后,不要忘了关闭最开始弹出来的“自定义工具栏”的对话框。

“自定义工具栏”对话框

05-15 2019

在 Directory Opus 中添加自定义的工具栏按钮提升效率

使用 Directory Opus 替代 Windows 自带的文件资源管理器来管理你计算机上的文件可以极大地提高你的文件处理效率。

Directory Opus 自定义的工具栏按钮可以执行非常复杂的命令,所以充分利用自定义工具栏按钮的功能可以更大程度上提升工作效率。


Directory Opus 的工具栏

这是我的 Directory Opus 的界面(暂时将左侧的树关掉了):

Directory Opus

下图是我目前添加的一些工具栏按钮:

Directory Opus 的工具栏按钮

自定义工具栏按钮

自定义的方法是,点击顶部的 设置 -> 自定义工具栏

自定义工具栏菜单

这时,会弹出自定义工具栏的对话框,并且所有可以被定制的工具栏现在都会进入编辑状态等待着我们对其进行编辑:

正在自定义工具栏

添加一个自定义按钮

你并不需要在自定义工具栏对话框上进行任何操作,只需要在一个现有的工具栏上点击右键,然后点击 新建 -> 新建按钮

新建按钮

这时,你会看到一个新的按钮已经出现在了工具栏上:

新建的按钮

现在,在此按钮上点击右键,“编辑”,就打开了 Directory Opus 的命令编辑器:

命令编辑器

接下来,我们的操作就进入了本文的主要内容,也是最复杂的一部分内容了。

命令编辑器

要定义一个能够极大提升效率的按钮,命令编辑器中的多数框我们都是要使用的。

接下来我会通过两个示例来说明如何使用这个命令编辑器。

  1. Directory Opus 使用命令编辑器集成 TortoiseGit 的各种功能
  2. Directory Opus 使用命令编辑器添加 PowerShell / CMD / Bash 等多种终端到自定义菜单

在自定义完按钮之后,不要忘了关闭最开始弹出来的“自定义工具栏”的对话框。

一切皆命令

在阅读上面的博客定义完一些自己的命令之后,你再观察 Directory Opus 的其他工具栏按钮,包括左上角的菜单,你会发现其实 Directory Opus 中所有的功能按钮和菜单都是使用相同的机制建立起来的。

一切皆命令。

这些命令组成了 Directory Opus 主界面的绝大多数功能。

05-10 2019

使用 DISM 工具检查并修复 Windows 系统文件

DISM,Deployment Image Servicing and Management,部署映像服务和管理。本文介绍使用此工具检查并修复 Windows 的系统文件。


系统要求

Windows 8/8.1 和 Windows 10 开始提供 DISM 工具。

相比于我在另一篇博客中提及的 sfc,DISM 利用 Windows 系统镜像来完成修复,所以更容易修复成功。关于 sfc(System File Check)可以参见:

使用方法

使用管理员权限启动 CMD,然后输入命令:

DISM.exe /Online /Cleanup-image /Restorehealth

运行后等待其运行完成。

DISM 修复系统的命令

使用本地镜像

上面的命令依赖于 Windows Update 服务来获取在线的镜像进行恢复。如果 Windows Update 服务已经挂了,那么这个命令是无法正常完成的。

这时需要额外添加 /Source: 来指定修复所使用的本地文件:

DISM.exe /Online /Cleanup-Image /RestoreHealth /Source:C:\RepairSource\Windows /LimitAccess

C:\RepairSource\Windows 需要换成自己的本地镜像路径。


参考资料

05-09 2019

使用 System File Check (SFC) 工具检查并修复 Windows 系统文件

sfc.exe 这个程序的名称指的是 System File Check,用于做系统文件检查。本文介绍使用此命令检查并修复 Windows 系统文件。


系统要求

Windows Vista 及以上的操作系统才具有 sfc.exe 工具。 相比于 Windows 7 开始提供 dism 工具。

当然,虽然系统要求如此,但如果你使用的是 Windows 8/8.1 或者 Windows 10,那么便建议使用 DISM。可以阅读:

使用方法

使用管理员权限启动 CMD,然后输入命令:

sfc /scannow

接下来等待命令执行完成即可。

sfc /scannow

命令结果

如果以上命令可以正常完成,那么你可能会遇到三种不同的提示(以下为中英双语版本)

  • Windows Resource Protection did not find any integrity violations.
    • Windows 资源保护找不到任何完整性冲突。
  • Windows Resource Protection could not perform the requested operation.
    • Windows 资源保护无法执行请求的操作。
  • Windows Resource Protection found corrupt files and successfully repaired them. Details are included in the CBS.Log %WinDir%\Logs\CBS\CBS.log.
    • Windows 资源保护找到了损坏的文件并已成功将其修复。 详细信息包含在 CBS.Log(路径为 %WinDir%\Logs\CBS\CBS.log)中。
  • Windows Resource Protection found corrupt files but was unable to fix some of them. Details are included in the CBS.Log %WinDir%\Logs\CBS\CBS.log.
    • Windows 资源保护找到了损坏的文件但无法修复其中的某些文件。 详细信息包含在 CBS.Log(路径为 %WinDir%\Logs\CBS\CBS.log)中。

出现第一种提示,则说明没有任何丢失或损坏的系统文件。如果系统存在其他问题,则需要找其他方法来修复。

出现第二种提示,你需要确保 %WinDir%\WinSxS\Temp 下存在 PendingDeletes 和 PendingRenames 文件夹;然后去安全模式中重新尝试此命令。

出现第三种提示,则已经修复了损坏的文件。

而出现第四种提示的话,你可以多次尝试执行此命令。可能多次执行后逐渐修复了所有的文件,也可能毫无作用。这个时候需要考虑其他的方法来修复系统了。

此工具的其他命令

可以只做检查而不用尝试修复。

sfc /verifyonly

参考资料

04-30 2019

使用 EnumWindows 找到满足你要求的窗口

在 Windows 应用开发中,如果需要操作其他的窗口,那么可以使用 EnumWindows 这个 API 来枚举这些窗口。

本文介绍使用 EnumWindows 来枚举并找到自己关心的窗口(如 QQ/TIM 窗口)。


EnumWindows

你可以在微软官网了解到 EnumWindows

要在 C# 代码中使用 EnumWindows,你需要编写平台调用 P/Invoke 代码。使用我在另一篇博客中的方法可以自动生成这样的平台调用代码:

我这里直接贴出来:

[DllImport("user32.dll")]
public static extern int EnumWindows(WndEnumProc lpEnumFunc, int lParam);

遍历所有的顶层窗口

官方文档对此 API 的描述是:

Enumerates all top-level windows on the screen by passing the handle to each window, in turn, to an application-defined callback function.

遍历屏幕上所有的顶层窗口,然后给回调函数传入每个遍历窗口的句柄。

不过,并不是所有遍历的窗口都是顶层窗口,有一些非顶级系统窗口也会遍历到,详见:EnumWindows 中的备注节

所以,如果需要遍历得到所有窗口的集合,那么可以使用如下代码:

public static IReadOnlyList<int> EnumWindows()
{
    var windowList = new List<int>();
    EnumWindows(OnWindowEnum, 0);
    return windowList;

    bool OnWindowEnum(int hwnd, int lparam)
    {
        // 可自行加入一些过滤条件。
        windowList.Add(hwnd);
        return true;
    }
}

遍历具有指定类名或者标题的窗口

我们需要添加一些可以用于过滤窗口的 Win32 API。以下是我们即将用到的两个:

// 获取窗口的类名。
[DllImport("user32.dll")]
private static extern int GetClassName(int hWnd, StringBuilder lpString, int nMaxCount);

// 获取窗口的标题。
[DllImport("user32")]
public static extern int GetWindowText(int hwnd, StringBuilder lptrString, int nMaxCount);

于是根据类名找到窗口的方法:

public static IReadOnlyList<int> FindWindowByClassName(string className)
{
    var windowList = new List<int>();
    EnumWindows(OnWindowEnum, 0);
    return windowList;

    bool OnWindowEnum(int hwnd, int lparam)
    {
        var lpString = new StringBuilder(512);
        GetClassName(hwnd, lpString, lpString.Capacity);
        if (lpString.ToString().Equals(className, StringComparison.InvariantCultureIgnoreCase))
        {
            windowList.Add(hwnd);
        }

        return true;
    }
}

使用此方法,我们可以传入 "txguifoundation" 找到 QQ/TIM 的窗口:

var qqHwnd = FindWindowByClassName("txguifoundation");

要获取窗口的标题,或者把标题作为过滤条件,则使用 GetWindowText

在 QQ/TIM 中,窗口的标题是聊天对方的名字或者群聊名称。

var lptrString = new StringBuilder(512);
GetWindowText(hwnd, lptrString, lptrString.Capacity);

参考资料

04-24 2019

使用 7-Zip 的命令行版本来压缩和解压文件

7-Zip 也有一个简短的名称 7z。它的原生 UI 确实不怎么好看,非常有年代感;不过 7-Zip 的强大之处不在于 UI,而在于其算法和各种文件的支持情况。不过,7-Zip 提供了命令行的版本,让你摒除一切杂念,专心处理压缩文件的工作。

本文介绍如何通过命令行来使用 7-Zip。因为使用命令行,所以你甚至可以自动化地完成压缩文件的各种处理。


如何找到 7-Zip 的命令行版本

请前往官方网站下载 7-Zip:

下载安装完去其安装目录下可以找到 7-Zip 的命令行版本:

7-Zip 的安装目录

这些文件作用分别是:

  • 7zFM.exe 7-Zip 文件管理器的主 UI,直接从开始菜单打开 7-Zip 时的 UI 界面。依赖 7z.dll
  • 7zG.exe 7-Zip 的 GUI 模块,需要通过命令行指定参数调用。依赖 7z.dll
  • 7-zip.dll 与 Windows Shell 以及 7zFM.exe 集成。
  • 7z.exe 7-Zip 的命令行版本,需要通过命令行指定参数调用。
  • 7z.dll 7-Zip 的核心执行引擎。
  • 7z.sfx SFX 模块(Windows 版本)。
  • 7zCon.sfx SFX 模块(控制台版本)。
  • 7-zip.chm 7-Zip 的帮助说明文件。

命令行版本的 7z.exe 不依赖与其他 dll,所以我们将 7z.exe 文件拷出来即可使用完整的命令行版本的 7z。

使用命令行操作 7z.exe

如果你希望使用 .NET/C# 代码来自动化地调用 7z.exe,可以参考我的另一篇博客:

本文直接介绍 7z.exe 的命令行使用,你可以将其无缝地迁移至上面这篇博客中编写的 .NET/C# 代码中。

解压一个文件

> 7z x {fileName} -o{outputDirectory}

以上:

  • x 表示解压一个文件
  • {fileName} 是文件名称或者文件路径的占位符
  • {outputDirectory} 是解压后文件夹的占位符,必须是一个不存在的文件夹。
  • -o 表示指定输出路径

特别注意:-o{outputDirectory} 之间是 没有空格 的。

一个例子:

> 7z x C:\Users\walterlv\demo.7z -oC:\Users\walterlv\demo

7z 的强大之处还有一点就是可以解压各种文件——包括解压安装包:

> 7z x C:\Users\walterlv\nsis_installer_1.0.0.0.exe -oC:\Users\walterlv\nsis

这也是为什么我们考虑使用 7z 来解压缩,而不是使用相关的 NuGet 包来调用。

其他命令行操作

运行 7z.exe 后可以看到命令行中列出了可用的命令行命令:

a:将文件添加到压缩档案中
b:测试压缩或解压算法执行时的 CPU 占用
d:从压缩档案中删除文件
e:将压缩档案中的所有文件解压到指定路径,所有文件将输出到同一个目录中
h:计算文件的哈希值
i:显示有关支持格式的信息
l:列出压缩档案的内容
rn:重命名压缩档案中的文件
t:测试压缩档案的完整性
u:更新要进入压缩档案中的文件
x:将压缩档案中的所有文件解压到指定路径,并包含所有文件的完整路径

下面列出几个常用的命令。

a 添加文件

如果你需要压缩文件,或者将文件添加到现有的压缩档案中,则使用此命令。

将 subdir\ 文件夹中的所有文件加入到 walterlv.zip 文件中,所有的子文件和文件夹将会在压缩档案的 subdir 文件夹中:

7z a walterlv.zip subdir\

将 subdir\ 文件夹中的所有文件加入到 walterlv.zip 文件中,所有的子文件和文件夹路径不会包含 subdir 前缀:

7z a walterlv.zip .\subdir\*

d 删除文件

删除压缩档案 walterlv.zip 中的所有扩展名为 bak 的文件:

7z d walterlv.zip *.bak -r

e 解压文件

相比于 x,此命令会将压缩档案中的所有文件输出到同一个目录中。

04-23 2019

ClearType 的原理:Windows 上文本的亚像素控制

有位小伙伴问我为什么他电脑上的文本看起来比较虚。我去看了下,发现他电脑上关掉了 ClearType。

微软的 ClearType 技术通过控制亚像素来使得文本显示更为清晰。本文代理了解 Windows 系统上的文本是如何通过亚像素控制使得显示更为清晰的。


ClearType 打开和关闭之后的效果

看下图!同样的文本,在不同大小下以及开关 ClearType 下的显示效果:

文本效果预览

你应该能注意到,第 0x00 行,第 0x02 行是比较模糊的,第 0x01 行和第 0x03 行会更清晰一些。

如果你看不出来我说的效果,那么你需要调整你看图的姿势:

  1. 请确保以 100% 比例显示此图片,正在在电脑上看我博客的时候,就会以 100% 比例显示;
  2. 如果你看博客的显示器 DPI 不是 100%,那么也看不出效果,建议在一个 100% DPI 的显示器设备上看。

如果依然看不出来,至少你能感受到第 0x01 行和第 0x03 行的文本会更亮一些。

现在,我们将图片放大。就像下面这张图片一样,左边一半是没有启用 ClearType 的文本,右边是启用了 ClearType 的文本。我将他们放到了一张图片上以便更容易比较效果。

放大后的文本预览

可以注意到,没有开启 ClearType 的文本,其发虚的边框周围是灰色;而开启了 ClearType 的文本,其发虚的边框周围是彩色。

如何显示清晰的线条

像素内的 RGB

在开始显示线条之前,我们来看看显示器如何显示一个像素。下图是我放大的一个像素内的灯管。这是一种主流显示器上像素内的 RGB 排列。这三个灯管同时以规定的最大值亮起,我们将看到白色。当然,我放大这么大你是看不出来白色的,需要足够小才行。

一个像素

现在,我们缩小一点,观察 4×4 个像素:

4×4 个像素

清晰显示 1px 线条

我在另一篇博客中说过如何清晰显示一个线条:

要清晰显示 1 像素宽度的竖线,我们需要对齐像素显示,即在整数像素上显示这根线条。于是,我们需要点亮这一列像素中的所有 RGB:

亮起一列像素中的全部 RGB

嗯,最终看起来会像这样:

清晰显示的白色线条

清晰显示 1.3 px 线条

那么接下来,如何清晰显示 1.33 像素宽度的竖线呢?

传统方法是借用旁边像素,点亮旁边像素 33% 的亮度,于是线条大概是这样的:

传统 1.33 像素的线条

对应到灯管,大概是这样:

传统 1.33 像素亮起的灯管

但是,这样显示 1.33 像素使用了 2 个像素的宽度,用了 6 个灯管。

然而如果亮起的灯管是这样的:

亚像素控制的 1.33 像素灯管

因为现在依然是 RGB 三个灯管紧挨着一起量的,所以人类依然会看出白色来。由于此时灯管亮起的依然是硬边缘,所以依然清晰。

要控制这样亮起灯管,我们需要在左边像素显示白色,右边像素显示红色。

亚像素控制的 1.33 像素的线条

在这个线条中,右边的线条因为是红色,所以只会亮起红色灯管,而这是最靠近左边像素的灯管。

清晰显示 1.7 px 线条

同样的,如果要清晰显示 1.67 像素宽度的竖线,我们需要使用 5 列灯管:

亚像素控制的 1.67 像素灯管

这时,我们不止借用了右边像素显示红色,还借用了左边像素显示蓝色:

亚像素控制的 1.67 像素的线条

当然,也可以是在右边借用一个黄色的像素,也就是亮起 RG 两列灯管。借用哪一边取决于需要从像素的哪个位置开始显示。

文本的亚像素控制

由于文本的显示不像简单图形显示可以随意选取起点,文本因为图形非常复杂,为了保持文本形状不至于变形太多,任何位置开始显示一个像素的起点都是可能的,所以文本需要更多地选择借用左右像素的相邻灯管。

使用了 ClearType 效果的单个文字

在这张图中,果字最中间的竖线,借用了左侧像素的蓝色灯管,借用了右侧像素的红色和绿色灯管。横线的最右边,借用了右侧像素的红色灯管。其他像素以此类推。

ClearType

实际上,本文使用的显示器是 RGB 排列的,其他显示器还有更多像素排列方式,Windows 系统会自动根据像素排列方式选择合适的 ClearType 借用临近灯管的方式。

不过,识别错也是常态,你需要在 Windows 10 搜索框中输入 ClearType 打开 ClearType 的设置界面,选择最清晰的显示文字来调整这样的错误识别。

ClearType 设置 1

ClearType 设置 2

03-21 2019

制作一个极简的 .NET 客户端应用自安装或自更新程序

本文主要说的是 .NET 客户端应用,可以是只能在 Windows 端运行的基于 .NET Framework 或基于 .NET Core 的 WPF / Windows Forms 应用,也可以是其他基于 .NET Core 的跨平台应用。但是不是那些更新权限受到严格控制的 UWP / iOS / Android 应用。

本文将编写一个简单的程序,这个程序初次运行的时候会安装自己,如果已安装旧版本会更新自己,如果已安装最新则直接运行。


自安装或自更新的思路

简单的安装过程实际上是 解压 + 复制 + 配置 + 外部命令。这里,我只做 复制 + 配置 + 外部命令,并且把 配置 + 外部命令 合为一个步骤。

于是:

  1. 启动后,检查安装路径下是否有已经安装的程序;
  2. 如果没有,则直接复制自己过去;
  3. 如果有,则比较版本号,更新则复制过去。

本文用到的知识

使用

于是我写了一个简单的类型用来做自安装。创建完 SelfInstaller 的实例后,根据安装完的结果做不同的行为:

  • 显示安装成功的窗口
  • 显示正常的窗口
  • 关闭自己
using System.IO;
using System.Windows;
using Walterlv.Installing;

namespace Walterlv.ENPlugins.Presentation
{
    public partial class App : Application
    {
        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);

            var installer = new SelfInstaller(@"C:\Users\lvyi\AppData\Local\Walterlv");

            var state = installer.TryInstall();
            switch (state)
            {
                case InstalledState.Installed:
                case InstalledState.Updated:
                case InstalledState.UpdatedInUse:
                    new InstallTipWindow().Show();
                    break;
                case InstalledState.Same:
                case InstalledState.Ran:
                    new MainWindow().Show();
                    break;
                case InstalledState.ShouldRerun:
                    Shutdown();
                    break;
            }
        }
    }
}

附全部源码

本文代码在 https://gist.github.com/walterlv/33bdd62e2411c69c2699038e2bc97488

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;

namespace Walterlv.EasiPlugins.Installing
{
    /// <summary>
    /// 自安装或字更新的安装器。
    /// </summary>
    public class SelfInstaller
    {
        /// <summary>
        /// 初始化 <see cref="SelfInstaller"/> 的新实例。
        /// </summary>
        /// <param name="targetFilePath">要安装的主程序的目标路径。</param>
        /// <param name="installingProcedure">如果需要在安装后执行额外的安装步骤,则指定自定义的安装步骤。</param>
        public SelfInstaller(string targetFilePath, IInstallingProcedure installingProcedure = null)
        {
            var assembly = Assembly.GetCallingAssembly();
            var extensionName = assembly.GetCustomAttribute<AssemblyTitleAttribute>().Title;
            TargetFileInfo = new FileInfo(Path.Combine(
                targetFilePath ?? throw new ArgumentNullException(nameof(targetFilePath)),
                extensionName, extensionName + Path.GetExtension(assembly.Location)));
            InstallingProcedure = installingProcedure;
        }

        /// <summary>
        /// 获取要安装的主程序的目标路径。
        /// </summary>
        private FileInfo TargetFileInfo { get; }

        /// <summary>
        /// 获取或设置当应用重新启动自己的时候应该使用的参数。
        /// </summary>
        public string RunSelfArguments { get; set; } = "--rerun-reason {reason}";

        /// <summary>
        /// 获取此自安装器安装中需要执行的自定义安装步骤。
        /// </summary>
        public IInstallingProcedure InstallingProcedure { get; }

        /// <summary>
        /// 尝试安装,并返回安装结果。调用者可能需要对安装结果进行必要的操作。
        /// </summary>
        public InstalledState TryInstall()
        {
            var state0 = InstallOrUpdate();
            switch (state0)
            {
                // 已安装或更新,由已安装的程序处理安装后操作。
                case InstalledState.Installed:
                case InstalledState.Updated:
                case InstalledState.UpdatedInUse:
                case InstalledState.Same:
                    break;
                case InstalledState.ShouldRerun:
                    Process.Start(TargetFileInfo.FullName, BuildRerunArguments(state0.ToString(), false));
                    return state0;
            }

            var state1 = InstallingProcedure?.AfterInstall(TargetFileInfo.FullName) ?? InstalledState.Ran;

            if (state0 is InstalledState.UpdatedInUse || state1 is InstalledState.UpdatedInUse)
            {
                return InstalledState.UpdatedInUse;
            }

            if (state0 is InstalledState.Updated || state1 is InstalledState.Updated)
            {
                return InstalledState.Updated;
            }

            if (state0 is InstalledState.Installed || state1 is InstalledState.Installed)
            {
                return InstalledState.Installed;
            }

            return state1;
        }

        /// <summary>
        /// 进行安装或更新。执行后将返回安装状态以及安装后的目标程序路径。
        /// </summary>
        private InstalledState InstallOrUpdate()
        {
            var extensionFilePath = TargetFileInfo.FullName;
            var selfFilePath = Assembly.GetExecutingAssembly().Location;

            // 判断当前是否已经运行在插件目录下。如果已经在那里运行,那么不需要安装。
            if (string.Equals(extensionFilePath, selfFilePath, StringComparison.CurrentCultureIgnoreCase))
            {
                // 继续运行自己即可。
                return InstalledState.Ran;
            }

            // 判断插件目录下的软件版本是否比较新,如果插件目录已经比较新,那么不需要安装。
            var isOldOneExists = File.Exists(extensionFilePath);
            if (isOldOneExists)
            {
                var isNewer = CheckIfNewer();
                if (!isNewer)
                {
                    // 运行已安装目录下的自己。
                    return InstalledState.Same;
                }
            }

            // 将自己复制到插件目录进行安装。
            var succeedOnce = CopySelfToInstall();
            if (!succeedOnce)
            {
                // 如果不是一次就成功,说明目标被占用。
                return InstalledState.UpdatedInUse;
            }

            return isOldOneExists ? InstalledState.Updated : InstalledState.Installed;

            bool CheckIfNewer()
            {
                Version installedVersion;
                try
                {
                    var installed = Assembly.ReflectionOnlyLoadFrom(extensionFilePath);

                    var installedVersionString =
                        installed.GetCustomAttributesData()
                            .FirstOrDefault(x =>
                                x.AttributeType.FullName == typeof(AssemblyFileVersionAttribute).FullName)
                            ?.ConstructorArguments[0].Value as string ?? "0.0";
                    installedVersion = new Version(installedVersionString);
                }
                catch (FileLoadException)
                {
                    installedVersion = new Version(0, 0);
                }
                catch (BadImageFormatException)
                {
                    installedVersion = new Version(0, 0);
                }

                var current = Assembly.GetExecutingAssembly();
                var currentVersionString =
                    current.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version ?? "0.0";
                var currentVersion = new Version(currentVersionString);
                return currentVersion > installedVersion;
            }
        }

        /// <summary>
        /// 将自己复制到目标安装路径。
        /// </summary>
        private bool CopySelfToInstall()
        {
            var extensionFolder = TargetFileInfo.Directory.FullName;
            var extensionFilePath = TargetFileInfo.FullName;
            var selfFilePath = Assembly.GetExecutingAssembly().Location;

            if (!Directory.Exists(extensionFolder))
            {
                Directory.CreateDirectory(extensionFolder);
            }

            var isInUse = false;
            for (var i = 0; i < int.MaxValue; i++)
            {
                try
                {
                    if (i > 0)
                    {
                        File.Move(extensionFilePath, extensionFilePath + $".{i}.bak");
                    }

                    File.Copy(selfFilePath, extensionFilePath, true);
                    return !isInUse;
                }
                catch (IOException)
                {
                    // 不退出循环,于是会重试。
                    isInUse = true;
                }
            }

            return !isInUse;
        }

        /// <summary>
        /// 生成用于重启自身的启动参数。
        /// </summary>
        /// <param name="rerunReason">表示重启原因的一个单词(不能包含空格)。</param>
        /// <param name="includeExecutablePath"></param>
        /// <param name="executablePath"></param>
        /// <returns></returns>
        private string BuildRerunArguments(string rerunReason, bool includeExecutablePath, string executablePath = null)
        {
            if (rerunReason == null)
            {
                throw new ArgumentNullException(nameof(rerunReason));
            }

            if (rerunReason.Contains(" "))
            {
                throw new ArgumentException("重启原因不能包含空格", nameof(rerunReason));
            }

            var args = new List<string>();

            if (includeExecutablePath)
            {
                args.Add(string.IsNullOrWhiteSpace(executablePath)
                    ? Assembly.GetEntryAssembly().Location
                    : executablePath);
            }

            if (!string.IsNullOrWhiteSpace(RunSelfArguments))
            {
                args.Add(RunSelfArguments.Replace("{reason}", rerunReason));
            }

            return string.Join(" ", args);
        }
    }

    /// <summary>
    /// 表示安装完后的状态。
    /// </summary>
    public enum InstalledState
    {
        /// <summary>
        /// 已安装。
        /// </summary>
        Installed,

        /// <summary>
        /// 已更新。说明运行此程序时,已经存在一个旧版本的应用。
        /// </summary>
        Updated,

        /// <summary>
        /// 已更新。但是原始文件被占用,可能需要重启才可使用。
        /// </summary>
        UpdatedInUse,

        /// <summary>
        /// 已代理启动新的程序,所以此程序需要退出。
        /// </summary>
        ShouldRerun,

        /// <summary>
        /// 两个程序都是一样的,跑谁都一样。
        /// </summary>
        Same,

        /// <summary>
        /// 没有执行安装、更新或代理,表示此程序现在是正常启动。
        /// </summary>
        Ran,
    }
}

03-20 2019

应用程序清单 Manifest 中各种 UAC 权限级别的含义和效果

如果你的程序对 Windows 运行权限有要求,那么需要设置应用程序清单。本文介绍如何添加应用程序清单,并解释其中各项 UAC 权限设置的实际效果。


阅读本文之前,你可能需要了解如何创建应用程序清单文件。阅读我的另一篇博客可以了解:

各种不同的 UAC 清单选项

从默认生成的应用程序清单中,我们可以很容易的知道有四种不同的设置:

  • asInvoker
  • requireAdministrator
  • highestAvailable
  • 删除 requestedExecutionLevel 元素 (不要忘了还可以删除)

当然这里我们是没有考虑 uiAccess 的。你可以阅读我的另一篇博客了解 uiAccess 的一项应用:

asInvoker

父进程是什么权限级别,那么此应用程序作为子进程运行时就是什么权限级别。

默认情况下用户启动应用程序都是使用 Windows 资源管理器(explorer.exe)运行的;在开启了 UAC 的情况下,资源管理器是以标准用户权限运行的。于是对于用户点击打开的应用程序,默认就是以标准用户权限运行的。

如果已经以管理员权限启动了一个程序,那么这个程序启动的子进程也会是管理员权限。典型的情况是一个应用程序安装包安装的时候使用管理员权限运行,于是这个安装程序在安装完成后启动的这个应用程序进程实例就是管理员权限的。有时候这种设定会出现问题,你可以阅读 在 Windows 系统上降低 UAC 权限运行程序(从管理员权限降权到普通用户权限)

requireAdministrator

此程序需要以管理员权限运行。

在资源管理器中可以看到这样的程序图标的右下角会有一个盾牌图标。

管理员权限图标

用户在资源管理器中双击启动此程序,或者在程序中使用 Process.Start 启动此程序,会弹出 UAC 提示框。点击“是”会提权,点击“否”则操作取消。

UAC 弹窗

highestAvailable

此程序将以当前用户能获取的最高权限来运行。

这个概念可能会跟前面说的 requireAdministrator 弄混淆。

要更好的理解这两个概念的区别,你可能需要对 UAC 用户账户控制有一个初步的了解,可以阅读我的另一篇博客:

接下来的内容,都假设你已经了解了上文所述的 UAC 用户账户控制。

如果你指定为 highestAvailable

  • 当你在管理员账户下运行此程序,就会要求权限提升。资源管理器上会出现盾牌图标,双击或使用 Process.Start 启动此程序会弹出 UAC 提示框。在用户同意后,你的程序将获得完全访问令牌(Full Access Token)。
  • 当你在标准账户下运行此程序,此账户的最高权限就是标准账户。受限访问令牌(Limited Access Token)就是当前账户下的最高令牌了,于是 highestAvailable 已经达到了要求。资源管理器上不会出现盾牌图标,双击或使用 Process.Start 启动此程序也不会出现 UAC 提示框,此程序将以受限权限执行。

下图是一个例子。lvyi 是我安装系统时创建的管理员账号,但是我使用的是 walterlv 标准账号。正常是在 walterlv 账号下启动程序,但以管理员权限运行时,会要求输入 lvyi 账号的密码来提权,于是就会以 lvyi 的身份运行这个程序。这种情况下,那个管理员权限运行的程序会以为当前运行在 lvyi 这个账户下,程序员需要小心这里的坑,因为拿到的用户路径以及注册表不是你所期望的 walterlv 这个账号下的。

标准账户下运行管理员权限程序会切换账户

删除 requestedExecutionLevel 元素

删除 requestedExecutionLevel 元素指的是将下面标注的这一行删掉:

    <?xml version="1.0" encoding="utf-8"?>
    <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
      <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
        <security>
          <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
--          <requestedExecutionLevel level="asInvoker" uiAccess="false" />
          </requestedPrivileges>
        </security>
      </trustInfo>

注释中说删除 requestedExecutionLevel 元素将开启 UAC 虚拟化。

开启 UAC 虚拟化

我将这个节点删除后,运行我的 Demo 程序后 UAC 虚拟化将启用。默认这里是“已禁用”的。

不过在以下任意一种情况下,UAC 虚拟化即便删了 requestedExecutionLevel 也是不会开启的:

  1. 64 位进程
  2. 不可交互的进程(例如服务)
  3. 进程模拟用户的操作(如果一个进程像用户一样执行了某项操作,那么这个操作不会被虚拟化)
  4. 驱动等内核模式进程

这部分的列表你可以在这里查询到:Registry Virtualization - Windows applications - Microsoft Docs

这些值都用于什么场景?

  • asInvoker 是默认情况下的首选。如果你的程序没有什么特殊的需求,就使用 asInvoker;就算你的程序需要管理员程序做一些特殊的任务,那最好也写成 asInvoker,仅在必要的时候才进行管理员权限提升。
  • requireAdministrator,只有当你的程序大量进行需要管理员权限的操作的时候才建议使用 requireAdministrator 值,例如你正在做安装程序。
  • highestAvailable,当你的程序需要管理员权限,但又要求仅对当前用户修改时设置为此值。因为标准用户申请 UAC 提权之后会以其他用户的身份运行进程,这就不是对当前用户的操作了;使用 highestAvailable 来确保以当前用户运行。

为什么 UWP 程序不能指定 UAC 清单选项?

在我的另一篇博客 Windows 中的 UAC 用户账户控制 中说到了访问令牌。

UWP 程序只能获得受限访问令牌,没得选,所以也就不需要指定 UAC 清单选项了。这也是为什么当你关闭 UAC 之后,UWP 程序将全部闪退的重要原因。


参考资料

03-19 2019

Windows 系统上使用任务管理器查看进程的各项属性(命令行、DPI、管理员权限等)

Windows 系统上的任务管理器进化到 Windows 10 的 1809 版本后,又新增了几项可以查看的进程属性。

本文介绍可以使用任务管理器查看的各种进程属性。


如何查看进程的各种属性

在任务栏上右键,选择“任务管理器”;或者按下 Ctrl + Shift + Esc 可以打开任务管理器。如果你的电脑死掉了,也可以按 Ctrl + Alt + Del 再选择任务管理器打开。

在顶部列表标题上右键,可以选择列,在这里可以打开和关闭各种各样可以查看的进程属性。

任务管理器,选择列

名称、PID、状态

名称不用多说,就是启动这个进程时的程序文件的名称。

值得注意的是,名称自进程启动时就确定了,即便你在运行期间改了名字,进程名也不会变。关于运行期间改名,可以参见:

PID 可以唯一确定当前系统运行期间的一个进程,所以用 PID 来找到进程是最靠谱的(前提是你拿得到)。这里有一个有意思的事情,可以阅读这些文章:

进程的状态可以阅读:

路径名称、命令行

路径名称可以帮助我们了解这个进程是由计算机上的哪个程序启动产生的。

不过我更喜欢的是“命令行”。因为除了可以看进程的路径之外,还可以了解到它是如何启动的。比如下面这篇博客中,我就是在任务管理器了解到这些工具的启动参数的。

关于命令行中的路径,可以参见我的其他博客:

用户名、特权、UAC 虚拟化

我把这三项放在一起说,是因为这三项是与 UAC 相关的项。

用户名指的是启动此进程的那个用户的用户名,这在调试一些提权程序的时候可能会有用。因为对于管理员账户而言,提权前后是同一个用户;而对于标准账户,提权后进程将是管理员账户的进程,于是两个进程运行在不同的用户空间下,可能协作上会出现一些问题。

关于用户账户以及提权相关的问题,可以阅读 Windows 中的 UAC 用户账户控制 - 吕毅

特权(Privilege)指的是此进程是否运行在管理员权限下。值为“是”则运行在管理员权限下,值为“否”则运行在标准账户权限下。

关于特权级别相关的问题,可以阅读 Windows 中的 UAC 用户账户控制 - 吕毅

UAC 虚拟化相关的问题可以阅读 应用程序清单 Manifest 中各种 UAC 权限级别的含义和效果 - 吕毅

DPI 感知

可以查看进程的 DPI 感知级别。

进程的 DPI 感知级别有以下这些,名字来源于 Windows 系统任务管理器上的显示名称。

  • 不知道 (Unaware)
  • 系统 (System DPI Awareness)
  • 每个显示器 (Per-Monitor DPI Awareness)
  • 每个显示器(v2) (Per-Monitor V2 DPI Awareness)

关于 DPI 感知级别的更多内容,可以阅读我的其他博客:

03-19 2019

Windows 的 UAC 设置中的通知等级实际上只有两个档而已

Windows 系统中的 UAC 设置界面有四种不同的选项可以选,但实际上真正有意义的只有两个选项。

本文将介绍 UAC 这四个档设置的区别,帮助你合理的设置你的电脑。


UAC 设置界面

在 Windows 10 任务栏的搜索框中输入 uac 可以直接打开 UAC 设置界面。

搜索“更改用户账户控制设置”

下图是“用户账户控制设置”界面,想必小伙伴们应该已经很熟悉了。它有四个档:

  • 始终通知
  • 当应用试图安装软件或更改计算机设置时通知,使用安全桌面
  • 当应用试图安装软件或更改计算机设置时通知,不使用安全桌面
  • 从不通知

用户账户控制设置

实际上只有两个档

然而在微软的 Raymond Chen(陈瑞孟)在 There are really only two effectively distinct settings for the UAC slider 一文中说实际上只有两个档:

  • 始终通知
  • 辣鸡

Windows 系统是通过让一些 UAC 提权动作变成静默提权的方式来避免通知过多的问题,主要是让那些“看起来没什么危害的”系统设置不用通知。但是,这相当于开了一个后门,程序可以很容易注入到 explorer.exe 中然后获得提权,或者通过白名单方式把自己加入到静默提权中。

有了这个后门,大家就可以找到各种绕过 UAC 弹窗的方法,比如 NSudoUACME、QuickAdmin。你根本阻止不完这些绕过 UAC 弹窗的方法!

微软说:“绕过 UAC 弹窗不是漏洞,所以我们不会修补。” (也许将来绕过 UAC 弹窗的恶意软件泛滥的时候,微软就会做点什么了)

微软已经提供了全部弹窗这个选项,明明可以阻止各类程序绕过 UAC,但为什么默认设置是这个可以绕过的选项呢?

—— 因为用户希望如此。

Windows Vista 中,确实只有始终通知和关闭 UAC 两个选项,而且始终通知是默认选项;实际上 UAC 也确实只有这两个有实际意义的选项。但是始终通知会使得系统日常使用过程中真的有非常多的 UAC 弹窗,只要你试图修改一些可能影响其他用户的设置或者可能与 Windows 系统安全有关的操作,都会弹出 UAC 弹窗。大多数用户都会觉得这么多的 UAC 弹窗是很烦的。所以 Windows 7 开始不得不引入两个额外的中间状态,让一些已知的提权操作变成静默的,不弹 UAC 窗口。默认值是中间状态,因为大多数用户希望是这样的提醒级别。

中间档的差别

进程在试图提权的时候,会弹出 UAC 提示。对于 Windows 管理员账户来说,在控制面板里面的大量操作可能都是在影响所有用户,如果全部通知,那么在控制面板里面点击的很多功能都会弹出 UAC 提示(例如修改时间,这是个影响所有用户的操作,而且有些安全软件可能会因为系统时间改变而失效)。

那两个中间档就是指:

  • 在控制面板里的管理操作不用弹出提示
  • 在 Windows 资源管理器内部操作的时候不用弹出提示(启动子进程依然需要)
  • 打开任务管理器的时候不用弹出提示
  • 更改防火墙设置的时候不用弹出提示
  • 打开 UAC 设置界面的时候不用弹出提示

我的建议

现在 Windows 10 都发布了很多个版本了,离 UAC 最初引入到 Windows 系统中时已经过去了十多年时间,这么长的时间,足够很多应用兼容 Medium 的权限级别了。

如果你不了解 Medium 权限级别,可以阅读我的另一篇博客:Windows 中的 UAC 用户账户控制 - 吕毅

即便我们现在选择“始终通知”,也不会比当初 Windows 7 刚刚发布时的通知多了,更不会比当初 Windows Vista 刚刚引入时多。因为应用的 UAC 弹窗少了,而对 Windows 的管理操作也不是经常进行。

我现在日常使用的是“管理员账户 + 始终通知”,在某些情况下可能会使用“标准账户 + 始终通知”。并不会觉得多出了很多 UAC 弹窗。

目前感觉最明显的多出来的弹窗是:

  • 打开任务管理器的时候会弹窗
  • 添加防火墙信任的时候会弹窗
  • 在资源管理器中修改系统目录的时候会弹窗
  • 在 Windows 设置应用中的一些设置会弹窗

更多关于 UAC 的博客


参考资料

03-19 2019

Windows 中的 UAC 用户账户控制

阅读本文,你可以初步了解 Windows 上的 UAC 用户账户控制机制。本文不会涉及到 UAC 的底层实现原理和安全边界问题。


用户账户

在 Windows 中有多种不同的账户:

  • SYSTEM
  • Administrators 用户组
    • Administrator
    • 管理员账户
  • Users 用户组
    • 标准账户

我们需要将这些账户列举出来是因为在解释 UAC 账户控制的时候,会与此相关。

SYSTEM 在系统中拥有最高权限。

默认我们安装 Windows 时会创建一个管理员账户,这也是 Windows 系统推荐我们使用的管理员账户,其权限等级比 SYSTEM 低。

Administrator 的权限级别和我们用户创建的管理员账户的权限级别是一样的,但是访问令牌(Access Token)的管理方式不一样,所以这里我们需要分开说。

标准账户是我推荐大家使用的首选账户种类,因为在普通使用场景下,这个是最安全的。

Administrator 账户目前的主要作用就是准备 OOBE 开箱体验,不适合日常使用,因为很不安全。关于 OOBE 开箱体验与审核模式,可以阅读我的另一篇博客:

UAC 通知等级

用户账户控制设置

Windows Vista 开始引入了 UAC,不过在 Windows Vista 上只有两种 UAC 设置——开启和关闭。如果开启,那么应用试图安装软件或更改计算机、或者更改了 Windows 设置时将弹出 UAC 提示框;如果关闭,那么 UAC 就此关闭。Windows Vista 的 UAC 一直饱受诟病就是因为这种情况下的 UAC 提示是非常频繁的(而且以前的程序迁移到不需要管理员权限需要时间)。

在 Windows 7 上,在开启和关闭中间新引入了两个 UAC 级别,都是在更改 Windows 设置时不通知(实际上就是加了一些 UAC 提权的白名单)。只是一个会进入“黑屏”状态,另一个不会进入此状态。从表现上看这两个只是黑屏与不黑屏,但从安全性上讲黑屏的安全性会高很多。UAC 通知时进入的黑屏状态在 Windows 中称之为“安全桌面”,这时整个桌面进入了 SYSTEM 账户,原用户账户下的所有程序都无法得知此时 UAC 弹窗的情况,也无法通过模拟用户操作来跳过这个 UAC 框。而不黑屏时,不会切换到新的桌面环境,原有程序依然可以获得此 UAC 弹窗的一些信息,这很不安全。

但是!无论是 Windows Vista 还是 Windows 7,一旦你将 UAC 设置拖到最底,那么此时 UAC 将彻底关闭。如果你是管理员账户,那么运行的程序都将以管理员权限运行。

从 Windows 8 开始到现在的 Windows 10,虽然依然是上面四个设置,但拖到最底的“从不通知”时,UAC 依然是开启的状态。也就是说,用户正常启动的进程依然是标准权限,要获得管理员权限提升依然需要重启整个进程。这个安全性限制是很重要的。

特别说明!实际上 UAC 拖到最顶部,也就是所有 UAC 通知都显示 UAC 提示窗口才是真的在利用 UAC 保护你的电脑。因为 Windows 7 开始新增的两个中间级别都是在部分情况下静默提权,而这两种级别因为可以静默提权,所以也可以很容易被程序绕过。微软认为绕过 UAC 弹窗不是漏洞,因为这是用户自己的选择——如果用户选择全部通知是不会绕过的,用户选择了默认值,于是才可以绕过。所以这里推荐大家使用 UAC 的最高档,也就是全部提权都通知,这可以让大多数绕过 UAC 的方法失效。

虽然说通知等级给了用户四个设置项,但实际上真正有用的只有两个而已,参见我的另一篇博客:Windows 的 UAC 设置中的通知等级实际上只有两个档而已 - 吕毅

完整性级别(Integrity Level)

从 Windows Vista 开始,进程在创建的时候,可以得到一个访问令牌(Access Token),这个令牌有四个完整性级别:

  • System(系统)
  • High(高)
  • Medium(中)
  • Low(低)

System 令牌是对系统完全操作的令牌,对应 SYSTEM 用户拥有的最高权限,可以对 Windows 操作系统做任何事。通常一个服务进程会以 SYSTEM 用户启动,拿到 System 令牌。

High 对应 Administrators 组拥有的最高权限,也就是前面所说的 Administrator 用户和用户自己创建的管理员账户的权限级别。此权限级别用来管理计算机,可以修改其他用户,可以修改系统的设置,这些设置可能会造成安全问题(比如更改系统时间可能导致杀毒软件失效)。

Medium 对应 Users 组拥有的最高权限,也就是前面所说的用户自己创建的标准用户。此权限级别用来日常使用。Medium 权限在 Windows Vista(实际上是其内核 NT6)中相比于之前版本的 Windows 有一些权限的提升,不危及系统安全性的操作在 Medium 下即可以完成,不需要切换到 High 级别。Users 组的用户是没有 High 和 System 令牌的,程序在此用户账户下,无论如何也无法拿到 High 和 System 令牌的,因为这个用户没有这样的令牌;如果要权限提升,需要输入管理员账号密码,而这时拿到的是这个管理员账号的 High 和 System 令牌。

Low 并不对应者一个用户组,这是为了一些需要特殊保护的应用程序准备的。有些应用容易受到攻击,那么使用 Low 令牌启动这些应用程序,可以最大程度减少利用这些应用对系统造成攻击。比如 IE 浏览器的页面进程使用 Low 令牌运行,其对系统很难做出什么改动,甚至也影响不了当前用户的文件;当需要需系统计算机进行交互的时候,会与 IE 的 UI 进程(Medium 令牌)进行通信,请求协助完成。

当 UAC 是开启状态,无论是管理员账户还是标准账户,Windows 资源管理器进程(explorer.exe)都是以 Medium 令牌启动进程。由于子进程通常能够继承父进程的令牌完整性级别,所以这样的设定可以防止用户双击打开的程序得到过高的令牌,从而在用户不知情的情况下危及系统安全。

当程序需要以管理员权限运行(对应 High 级别的令牌)时,可以自己在 Manifest 里面声明,也可以自己使用 runas 谓词重启自己。而这个时候是会弹出 UAC 提示的,用户知情。

前面我们说过在 Administrators 组中,Administrator 账户和普通管理员账户要分开说。差别就在令牌的管理上。普通管理员账户下,正常启动进程使用的是继承自 explorer.exe 的 Medium 访问令牌,当进程需要提升权限时,会弹出 UAC 提示框来启动一个子进程以获得 High 令牌。而 Administrator 账户下,正常启动的进程也都获得了 High 令牌。

关于如何通过 Manifest 设置管理员权限运行,可以参考我的另一篇博客:

权限提升

在 Windows 系统中,不同权限的进程是隔离的(虽然不是完全隔离)。

如果你希望你的程序在执行某个操作的时候提升权限来执行,实际上你不能在你原来的进程上直接提升权限。你有很多种方法来提权,甚至绕过 UAC 来提权,但无论哪一种,你的进程实际上都是重启了,你是在新的提升的进程中执行了这个需要权限的操作。

对于管理员账户,如果启动一个普通进程,那么此进程在管理员账户下运行,获得的是 Medium 访问令牌。当此进程提升权限,将弹出 UAC 提示框,用户同意后继续使用此同一个管理员账户运行,但子进程将获得 High 访问令牌。

对于标准账户,如果启动一个普通进程,那么此进程在标准账户下运行,获得的是 Medium 访问令牌。当此进程提升权限,将弹出 UAC 提示框,用户输入管理员账号密码后,子进程将在输入的管理员账户下运行,获得此管理员的 High 访问令牌。标准账户没有 High 访问令牌,如果说绕过 UAC 来提权是为了获取 High 访问令牌,那么在标准账户下根本没有 High 访问令牌,所以你绕不过。

管理员账户的 UAC 弹窗是这样的,要求用户选“是”或者“否”:

UAC 弹窗

而标准账户的 UAC 弹窗是这样的,要求输入管理员账号和密码:

UAC 输入账号密码

以上两个弹窗都是蓝色的,代表发起此 UAC 请求的子进程其程序的证书是经过认证的。如果没有证书那么提示框是黄色的,如果证书过期,那么提示框是红色的。这可以帮助用户区分 UAC 弹窗做出决策(虽然实际上没什么用)。

以上在标准账户下用管理员账户打开子进程的例子可以看下图:

标准账户下运行管理员权限程序会切换账户

lvyi 是我安装系统时创建的管理员账号,但是我使用的是 walterlv 标准账号。正常是在 walterlv 账号下启动程序,但以管理员权限运行时,会要求输入 lvyi 账号的密码来提权,于是就会以 lvyi 的身份运行这个程序。这种情况下,那个管理员权限运行的程序会以为当前运行在 lvyi 这个账户下,程序员需要小心这里的坑,因为拿到的用户路径以及注册表不是你所期望的 walterlv 这个账号下的。

在上图中,你会发现当前账户下的任务管理器连管理员账户运行的程序图标都拿不到。

03-19 2019

Windows 下使用 runas 命令以指定的权限启动一个进程(非管理员、管理员)

在默认情况下,Windows 系统中启动一个进程会继承父进程的令牌。如果父进程是管理员权限,那么子进程就是管理员权限;如果父进程是标准用户权限,那么子进程也是标准用户权限。

我们也知道,可以使用一些方法为自己的应用程序提权。但是有没有方法可以任意指定一个权限然后运行呢?本文将介绍 Windows 下指定权限运行的做法。


runas 命令

runas 是 Windows 系统上自带的一个命令,通过此命令可以以指定权限级别间接启动我们的程序,而不止是继承父进程的权限。

打开 cmd 或者 PowerShell,输入 runas 命令可以看到其用法。

> runas
RUNAS 用法:

RUNAS [ [/noprofile | /profile] [/env] [/savecred | /netonly] ]
        /user:<UserName> program

RUNAS [ [/noprofile | /profile] [/env] [/savecred] ]
        /smartcard [/user:<UserName>] program

RUNAS /trustlevel:<TrustLevel> program

   /noprofile        指定不应该加载用户的配置文件。
                     这会加速应用程序加载,但
                     可能会造成一些应用程序运行不正常。
   /profile          指定应该加载用户的配置文件。
                     这是默认值。
   /env              要使用当前环境,而不是用户的环境。
   /netonly          只在指定的凭据限于远程访问的情况下才使用。
   /savecred         用用户以前保存的凭据。
   /smartcard        如果凭据是智能卡提供的,则使用这个选项。
   /user             <UserName> 应使用 USER@DOMAIN  DOMAIN\USER 形式
   /showtrustlevels  显示可以用作 /trustlevel 的参数的
                     信任级别。
   /trustlevel       <Level> 应该是在 /showtrustlevels 中枚举
                     的一个级别。
   program           EXE 的命令行。请参阅下面的例子

示例:
> runas /noprofile /user:mymachine\administrator cmd
> runas /profile /env /user:mydomain\admin "mmc %windir%\system32\dsa.msc"
> runas /env /user:user@domain.microsoft.com "notepad \"my file.txt\""

注意:  只在得到提示时才输入用户的密码。
注意:  /profile  /netonly 不兼容。
注意:  /savecred  /smartcard 不兼容。

提权运行或者降权运行

为了演示提权或者降权,我们需要有一个能够验证当前是否是管理员权限运行的程序。关于如何在程序中判断当前是否以管理员权限运行,可以阅读我和林德熙的博客:

本质上是这段代码:

var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
if (principal.IsInRole(WindowsBuiltInRole.Administrator))
{
    // 检测到当前进程是以管理员权限运行的。
}

此代码如果在 .NET Core 中编写,以上代码需要额外安装 Windows 兼容包:Microsoft.Windows.Compatibility

提权运行或者降权运行

我以标准用户权限和管理员权限分别启动了一个 PowerShell Core,然后准备在这两个窗口里面分别启动我的检测管理员权限的程序。

在两个 PowerShell 中运行命令

0x20000 是标准用户权限,现在运行命令:

> runas /trustlevel:0x20000 .\Walterlv.Demo.exe

运行结束后,两个进程都是非管理员权限

运行发现,两个进程现在都是标准用户权限。即使是管理员的 PowerShell 中运行的也都是非管理员权限。

0x40000 是管理员权限,现在运行命令:

> runas /trustlevel:0x40000 .\Walterlv.Demo.exe

运行结束后,两个进程都取得不高于当前 PowerShell 的最高权限

运行发现,非管理员的 PowerShell 启动的是非管理员权限的进程;而管理员的 PowerShell 启动的是管理员权限的进程。

使用 C# 代码来降权运行

使用 C# 代码,就是要将下面这一句翻译成 C#。

> runas /trustlevel:0x20000 .\Walterlv.Demo.exe

所以其实非常简单,就是 Process.Start 传入参数即可。

Process.Start("runas.exe", $"/trustlevel:0x20000 Walterlv.Demo.exe");

关于更多降权运行的方法,可以参考我的另一篇博客:


参考资料

03-19 2019

在 Windows 系统上降低 UAC 权限运行程序(从管理员权限降权到普通用户权限)

在 Windows 系统中,管理员权限和非管理员权限运行的程序之间不能使用 Windows 提供的通信机制进行通信。对于部分文件夹(ProgramData),管理员权限创建的文件是不能以非管理员权限修改和删除的。

然而,一个进程运行之后启动的子进程,会继承当前进程的 UAC 权限;于是有时我们会有降权运行的需要。本文将介绍 Windows 系统上降权运行的几种方法。


本文的降权运行指的是:

  1. 有一个 A 程序是以管理员权限运行的(典型的,如安装包);
  2. 有一个 B 程序会被 A 启动(我们期望降权运行的 B 程序)。

如何判断当前进程的 UAC 权限

通过下面的代码,可以获得当前进程的 UAC 权限。

var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);

而如果要判断是否是管理员权限,则使用:

if (principal.IsInRole(WindowsBuiltInRole.Administrator))
{
    // 当前正在以管理员权限运行。
}

此代码如果在 .NET Core 中编写,需要额外安装 Windows 兼容包:Microsoft.Windows.Compatibility

方法一:使用 runas 命令来运行程序(推荐)

使用 runas 命令来运行,可以指定一个权限级别:

> runas /trustlevel:0x20000 "C:\Users\walterlv\Desktop\walterlv.exe"
var subProcessFileName = "C:\Users\walterlv\Desktop\walterlv.exe";
Process.Start("runas.exe", $"/trustlevel:0x20000 {subProcessFileName}");

关于 runas 的更多细节,可以参考我的另一篇博客:

方法二:使用 explorer.exe 代理运行程序

请特别注意,使用 explorer.exe 代理运行程序的时候,是不能带参数的,否则 explorer.exe 将不会启动你的程序。

因为绝大多数用户启动系统的时候,explorer.exe 进程都是处于运行状态,而如果启动一个新的 explorer.exe,都会自动激活当前正在运行的进程而不会启动新的。

于是我们可以委托默认以普通权限运行的 explorer.exe 来代理启动我们需要启动的子进程,这时启动的子进程便是与 explorer.exe 相同权限的。

var subProcessFileName = "C:\Users\walterlv\Desktop\walterlv.exe";
Process.Start("explorer.exe", subProcessFileName);

如果用户计算机上的 UAC 是打开的,那么 explorer.exe 默认就会以标准用户权限运行。通过以上代码,walterlv.exe 就会以与 explorer.exe 相同权限运行,也就是降权运行了。

不过值得注意的是,Windows 7 上控制面板的 UAC 设置拉倒最低就是关掉 UAC 了;Windows 8 开始拉倒最底 UAC 还是打开的,只是不会提示 UAC 弹窗而已。也就是说,拉倒最底的话,Windows 7 的 UAC 就会关闭,explorer.exe 就会以管理员权限启动。

下面的代码,如果发现自己是以管理员权限运行的,那么就降权重新运行自己,然后自己退出。(当然在关闭 UAC 的电脑上是无效的。)

var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
if (principal.IsInRole(WindowsBuiltInRole.Administrator))
{
    // 检测到当前进程是以管理员权限运行的,于是降权启动自己之后,把自己关掉。
    Process.Start("explorer.exe", Assembly.GetEntryAssembly().Location);
    Shutdown();
    return;
}

请再次特别注意,使用 explorer.exe 代理运行程序的时候,是不能带参数的,否则 explorer.exe 将不会启动你的程序。

方法三:在启动进程时传入用户名和密码

ProcessStartInfo 中有 UserNamePassword 属性,设置此属性可以以此计算机上的另一个用户身份启动此进程。如果这个用户是普通用户,那么就会以普通权限运行此进程。

var processInfo = new ProcessStartInfo
{
    Verb = "runas",
    FileName = "walterlv.exe",
    UserName = "walterlv",
    Password = ReadPassword(),
    UseShellExecute = false,
    LoadUserProfile = true
};
Process.Start(processInfo);

上面的 ReadPassword 函数来自我的另一篇博客:如何让 .NET Core 命令行程序接受密码的输入而不显示密码明文 - walterlv

然而,此方法最大的问题在于——产品级的程序,不可能也不应该知道用户的密码!所以实际上这样的方法并不实用。

方法四:使用 Shell 进程的 Access Token 来启动进程

此方法需要较多的 Windows API 调用,我没有尝试过这种方法,但是你可以自行尝试下面的链接:


参考资料

03-17 2019

启用 Windows 审核模式(Audit Mode),以 Administrator 账户来设置电脑的开箱体验

在你刚刚安装完 Windows,在 Windows 开箱体验输入以创建你的用户账户之前,你可以按下 Ctrl + Shift + F3 来进入审核模式。

本文将介绍审核模式。


OOBE

OOBE,Out-of-Box Experience,开箱体验。对于 Windows 系统来说,就是当你买下电脑回来,兴奋地打开电脑开机后第一个看到的界面。

具体来说,就是设置你的账号以及各种个性化设置的地方。

本文即将要说的审核模式就是在这里开启的。当然你设置完账号也一样能开启,但开箱就是要来个干净整洁嘛,所以就是应该在还没有账号的时候进入审核模式。

进入审核模式

在 OOBE 界面中,按下 Ctrl + Shift + F3 两次即会进入审核模式。

实际上此时进入的账号是 Administrator 账号。我在 Windows 中的 UAC 用户账户控制 一文中说到,Administrator 账号下启动进程获取到的访问令牌都是完全访问令牌。所以在这里 UWP 程序是无法运行的(逃

当你进入审核模式之后,会看到自动启动了一个 sysprep 的程序,它位于 C:\Windows\System32\Sysprep 目录下。

系统准备工具

在审核模式下,重启也会继续进入审核模式。如果要关闭审核模式,则需要在 sysprep 程序中把下一次的启动选项改为开箱体验。

关于清理选项中的“通用”:如果你只为这台电脑或这个型号的电脑设置开箱体验,那么就关闭“通用”;如果把这个开箱体验做好之后会拷贝副本到其他型号的电脑上,那么就勾选“通用”。区别就是是否清理掉设备的特定的驱动文件。

清理 - 通用

当然,你现在就可以去 C:\Windows\System32\Sysprep 目录中启动 sysprep.exe,然后给你的电脑再带来一次开箱体验。

审核模式有什么作用?

从进入审核模式时打开的 sysprep.exe 程序可以看出来,这个模式主要就是为了准备开箱体验的。

你可以在这里以 Administrator 权限来为此计算机安装驱动,为将来此计算机的所有用户安装应用、存放一些你认为他们需要的文件。而这一切操作都不需要特地创建一个账号。可以说 Administrator 账户内置到系统里,主要的目的就是这个了,临时使用。而目前就是在审核模式中制作开箱体验。


参考资料

03-17 2019

如何创建应用程序清单文件 App.Manifest,如何创建不带清单的应用程序

如果你的程序对 Windows 运行权限有要求,那么需要设置应用程序清单。本文介绍如何添加应用程序清单,并解释其中各项权限设置的实际效果。


嵌入带默认设置的清单

对于 WPF 和 Windows Forms 程序,如果你什么都不做,那么就已经嵌入了一个带有默认设置的清单。

下图可以在 Visual Studio 中的项目上右键属性插件。

嵌入带默认设置的清单

新建一个自定义的清单文件

在项目上右键,添加,新建项。可以在新建模板中找到“应用程序清单文件”。确认后即添加了一个新的清单文件。这时,项目属性页中的清单也会自动设置为刚刚添加的清单文件。

按照清单模板新建清单

默认的清单中,包含 UAC 清单选项、系统兼容性选项、DPI 感知级别选项和 Windows 公共控件和对话框的主题选项。

<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
  <assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
  <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
    <security>
      <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
        <!-- UAC 清单选项
             如果想要更改 Windows 用户帐户控制级别,请使用
             以下节点之一替换 requestedExecutionLevel 节点。n
        <requestedExecutionLevel  level="asInvoker" uiAccess="false" />
        <requestedExecutionLevel  level="requireAdministrator" uiAccess="false" />
        <requestedExecutionLevel  level="highestAvailable" uiAccess="false" />

            指定 requestedExecutionLevel 元素将禁用文件和注册表虚拟化。
            如果你的应用程序需要此虚拟化来实现向后兼容性,则删除此
            元素。
        -->
        <requestedExecutionLevel level="asInvoker" uiAccess="false" />
      </requestedPrivileges>
    </security>
  </trustInfo>

  <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
    <application>
      <!-- 设计此应用程序与其一起工作且已针对此应用程序进行测试的
           Windows 版本的列表。取消评论适当的元素,
           Windows 将自动选择最兼容的环境。 -->

      <!-- Windows Vista -->
      <!--<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />-->

      <!-- Windows 7 -->
      <!--<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />-->

      <!-- Windows 8 -->
      <!--<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />-->

      <!-- Windows 8.1 -->
      <!--<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />-->

      <!-- Windows 10 -->
      <!--<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />-->

    </application>
  </compatibility>

  <!-- 指示该应用程序可以感知 DPI 且 Windows 在 DPI 较高时将不会对其进行
       自动缩放。Windows Presentation Foundation (WPF)应用程序自动感知 DPI,无需
       选择加入。选择加入此设置的 Windows 窗体应用程序(目标设定为 .NET Framework 4.6 )还应
       在其 app.config 中将 "EnableWindowsFormsHighDpiAutoResizing" 设置设置为 "true"。-->
  <!--
  <application xmlns="urn:schemas-microsoft-com:asm.v3">
    <windowsSettings>
      <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
    </windowsSettings>
  </application>
  -->

  <!-- 启用 Windows 公共控件和对话框的主题(Windows XP 和更高版本) -->
  <!--
  <dependency>
    <dependentAssembly>
      <assemblyIdentity
          type="win32"
          name="Microsoft.Windows.Common-Controls"
          version="6.0.0.0"
          processorArchitecture="*"
          publicKeyToken="6595b64144ccf1df"
          language="*"
        />
    </dependentAssembly>
  </dependency>
  -->

</assembly>

创建不带清单的应用程序

你也可以创建一个不带应用程序清单的应用程序。方法是在属性页中将清单设置为“创建不带清单的应用程序”。

创建不带清单的应用程序

03-10 2019

为 WPF 程序添加 Windows 跳转列表的支持

Windows 跳转列表是自 Windows 7 时代就带来的功能,这一功能是跟随 Windows 7 的任务栏而发布的。当时应用程序要想用上这样的功能需要调用 shell 提供的一些 API。

然而在 WPF 程序中使用 Windows 跳转列表功能非常简单,在 XAML 里面就能完成。本文将介绍如何让你的 WPF 应用支持 Windows 跳转列表功能。


一个简单的跳转列表程序

新建一个 WPF 程序,然后直接在 App.xaml 中添加跳转列表的代码。这里为了更快上手,我直接贴出整个 App.xaml 的代码。

<Application x:Class="Walterlv.Demo.WindowsTasks.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:Walterlv.Demo.WindowsTasks"
             StartupUri="MainWindow.xaml">
    <JumpList.JumpList>
        <JumpList ShowRecentCategory="True" ShowFrequentCategory="True">
            <JumpTask Title="启动新窗口" Description="启动一个新的空窗口" />
            <JumpTask Title="修改 walterlv 的个性化设置" Description="打开个性化设置页面并定位到 walterlv 的设置"
                      IconResourcePath="C:\Windows\System32\wmploc.dll" IconResourceIndex="17"
                      Arguments="--account" />
        </JumpList>
    </JumpList.JumpList>
</Application>

顺便的,我加了一个简单的图标,这样不至于显示一个默认的应用图标。

添加的简单的图标

运行此程序后就可以在任务栏上右击的时候看到跳转列表:

运行后看到的跳转列表

在这段程序中,我们添加了两个“任务”,在跳转列表中有一个“任务”分类。因为我的系统是英文,所以显示的是“Task”。

在任务分类中,有两个“任务”,启动新窗口 以及 修改 walterlv 的个性化设置。第一个任务只设了标题和鼠标移上去的提示信息,于是显示的图标就是应用本身的图标,点击之后也是启动任务自己。第二个任务设置了 Arguments 参数,于是点击之后会带里面设置的参数启动自己;同时设置了 IconResourcePathIconResourceIndex 用于指定图标。

这种图标的指定方式是 Windows 系统中非常常用的方式。你可以在我的另一篇博客中找到各种各样系统自带的图标;至于序号,则是自己去数。

定制跳转列表的功能

JumpList 有两个属性 ShowRecentCategoryShowFrequentCategory,如果指定为 true 则表示操作系统会自动为我们保存此程序最近使用的文件的最频繁使用的文件。

Windows 的跳转列表有两种不同的列表项,一种是“任务”,另一种是文件。至于这两种不同的列表项如何在跳转列表中安排,则是操作系统的事情。

这两种不同的列表项对应的类型分别是:

  • JumpTask
  • JumpPath

JumpTask 可以理解为这就是一个应用程序的快捷方式,可以指定应用程序的路径(ApplicationPath)、工作目录(WorkingDirectory)、启动参数(Arguments)和图标(IconResourcePathIconResourceIndex)。如果不指定路径,那么就默认为当前程序。也可以指定显示的名称(Title)和鼠标移上去可以看的描述(Description)。

JumpPath 则是一个路径,可以是文件或者文件夹的路径。通常用来作为最近使用文件的展示。特别说明:你必须关联某种文件类型这种类型的文件才会显示到 JumpPath 中。

另外,JumpTaskJumpPath 都有一个 CustomCategory 属性可以指定类别。对于 JumpTask,如果不指定类别,那么就会在默认的“任务”(Task)类别中。对于 JumpPath,如果不指定类别,就在最近的文件中。

JumpTask 如果不指定 TitleCustomCategory 属性,那么他会成为一个分隔符。


参考资料

03-10 2019

Windows 上的应用程序在运行期间可以给自己改名(可以做 OTA 自我更新)

程序如何自己更新自己呢?你可能会想到启动一个新的程序或者脚本来更新自己。然而 Windows 操作系统允许一个应用程序在运行期间修改自己的名称甚至移动自己到另一个文件夹中。利用这一点,我们可以很简单直接地做程序的 OTA 自动更新。

本文将介绍示例程序运行期间改名并解释其原理。


在程序运行期间手工改名

我们写一个简单的程序。

简单的程序

将它运行起来,然后删除。我们会发现无法删除它。

无法删除程序

但是,我们却可以很轻松地在资源管理器中对它进行改名,甚至将它从一个文件夹中移动到另一个文件夹中。

已经成功改名

值得注意的是,你不能跨驱动器移动此文件。

不止是 exe 文件,dll 文件也是可以改名的

实际上,不止是 exe 文件,在 exe 程序运行期间,即使用到了某些 dll 文件,这些 dll 文件也是可以改名的。

当然,一个 exe 的运行不一定在启动期间就加载好了所有的 dll,所以如果你在 exe 启动之后,某个 dll 加载之前改了那个 dll 的名称,那么会出现找不到 dll 的情况,可能导致程序崩溃。

为什么 Windows 上的可执行程序可以在运行期间改名?

Windows 的文件系统由两个主要的表示结构:一个是目录信息,它保存有关文件的元数据(如文件名、大小、属性和时间戳);第二个是文件的数据链。

当运行程序加载一个程序集的时候,会为此程序集创建一个内存映射文件。为了优化性能,往往只有实际用到的部分才会被加入到内存映射文件中;当需要用到程序集文件中的某块数据时,Windows 操作系统就会将需要的部分加载到内存中。但是,内存映射文件只会锁定文件的数据部分,以保证文件文件的数据不会被其他的进程修改。

这里就是关键,内存映射文件只会锁定文件的数据部分,而不会锁住文件元数据信息。这意味着你可以随意修改这些元数据信息而不会影响程序的正常运行。这就包括你可以修改文件名,或者把程序从一个文件夹下移动到另一个文件夹去。

但是跨驱动器移动文件,就意味着需要在原来的驱动器下删除文件,而这个操作会影响到文件的数据部分,所以此操作不被允许。

编写一个程序在运行期间自动改名

一般来说,需要 OTA 更新的程序是客户端程序,所以实际上真正需要此代码的是客户端应用。以下代码中我使用 .NET Core 3.0 来编写一个给自己改名的 WPF 程序。

using System.Diagnostics;
using System.IO;
using System.Windows;

namespace Walterlv.Windows.Updater
{
    public partial class App : Application
    {
        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);
            var fileName = Process.GetCurrentProcess().MainModule.FileName;
            var newFileName = Path.Combine(Path.GetDirectoryName(fileName), "OldUpdater.exe");
            File.Move(fileName, newFileName);
            // 省略的代码:将新下载下载的程序改名成 fileName。
        }
    }
}

于是,程序自己在运行后会改名。

程序已经自己改名

顺便的,以上代码仅适用于 .NET Framework 的桌面应用程序或者 .NET Core 3.0 的桌面应用程序。如果是 .NET Core 2.x,那么以上代码在获取到进程名称的时候可能是 dotnet.exe(已发布的 .NET Core 程序除外)。


参考资料

03-09 2019

.NET/C# 获取一个正在运行的进程的命令行参数

在自己的进程内部,我们可以通过 Main 函数传入的参数,也可以通过 Environment.GetCommandLineArgs 来获取命令行参数。

但是,可以通过什么方式来获取另一个运行着的程序的命令行参数呢?


进程内部获取传入参数的方法,可以参见我的另一篇博客:.NET 命令行参数包含应用程序路径吗?

.NET Framework / .NET Core 框架内部是不包含获取其他进程命令行参数的方法的,但是我们可以在任务管理器中看到,说明肯定存在这样的方法。

任务管理器中的命令行参数

实际上方法是有的,不过这个方法是 Windows 上的专属方法。

对于 .NET Framework,需要引用程序集 System.Management;对于 .NET Core 需要引用 Microsoft.Windows.Compatibility 这个针对 Windows 系统准备的兼容包(不过这个兼容包目前还是预览版本)。

<ItemGroup Condition="$(TargetFramework) == 'netcoreapp2.1'">
    <PackageReference Include="Microsoft.Windows.Compatibility" Version="2.1.0-preview.19073.11" />
</ItemGroup>
<ItemGroup Condition="$(TargetFramework) == 'net472'">
    <Reference Include="System.Management" />
</ItemGroup>

然后,我们使用 ManagementObjectSearcherManagementBaseObject 来获取命令行参数。

为了简便,我将其封装成一个扩展方法,其中包括对于一些异常的简单处理。

using System;
using System.Diagnostics;
using System.Linq;
using System.Management;

namespace Walterlv
{
    /// <summary>
    /// 为 <see cref="Process"/> 类型提供扩展方法。
    /// </summary>
    public static class ProcessExtensions
    {
        /// <summary>
        /// 获取一个正在运行的进程的命令行参数。
        /// 与 <see cref="Environment.GetCommandLineArgs"/> 一样,使用此方法获取的参数是包含应用程序路径的。
        /// 关于 <see cref="Environment.GetCommandLineArgs"/> 可参见:
        /// .NET 命令行参数包含应用程序路径吗?https://blog.walterlv.com/post/when-will-the-command-line-args-contain-the-executable-path.html
        /// </summary>
        /// <param name="process">一个正在运行的进程。</param>
        /// <returns>表示应用程序运行命令行参数的字符串。</returns>
        public static string GetCommandLineArgs(this Process process)
        {
            if (process is null) throw new ArgumentNullException(nameof(process));

            try
            {
                return GetCommandLineArgsCore();
            }
            catch (Win32Exception ex) when ((uint) ex.ErrorCode == 0x80004005)
            {
                // 没有对该进程的安全访问权限。
                return string.Empty;
            }
            catch (InvalidOperationException)
            {
                // 进程已退出。
                return string.Empty;
            }

            string GetCommandLineArgsCore()
            {
                using (var searcher = new ManagementObjectSearcher(
                    "SELECT CommandLine FROM Win32_Process WHERE ProcessId = " + process.Id))
                using (var objects = searcher.Get())
                {
                    var @object = objects.Cast<ManagementBaseObject>().SingleOrDefault();
                    return @object?["CommandLine"]?.ToString() ?? "";
                }
            }
        }
    }
}

使用此方法得到的命令行参数是一个字符串,而不是我们通常使用字符串时的字符串数组。如果你需要将其转换为字符串数组,可以使用我在另一篇博客中使用的方法:


参考资料

03-04 2019

如何为你的 Windows 应用程序关联一种或多种文件类型

对于 Windows 桌面应用来说,让应用关联一种或多种文件类型是通过修改注册表来实现的。

本文介绍如何为你的应用关联自定义的文件类型或者关联被广泛使用的文件类型。


文件关联

Windows 上的文件关联是通过文件的扩展名来实现的。有些文件类型是被广泛使用的公共类型,例如 .txt、.png、.mp4 文件;有些则是你自己的应用程序使用的私有类型,例如我自己定义一个 .lvyi 扩展名的文件类型。

我们会关联这些广泛使用的类型可能是因为我们自己写了一个自己的文本编辑器,于是我们会关联 .txt 或者 .md 类型。而我们关联自定义的文件类型是因为我们需要为我们自己的应用生态产生一些文件数据。

那么问题来了,我怎么知道我现在准备使用的扩展名是不是已经被广泛使用的公共类型呢?请进入此网站查看:Media Types

注册一个文件类型

要在 Windows 系统上注册一个文件类型,你需要做三个步骤:

  1. 取一个应用程序标识符(ProgID
  2. 在注册表中添加文件关联(用于告知 Windows 这个文件已经被关联)
  3. 为关联的程序添加谓词(用于打开这个文件)

取一个应用程序标识符

没错,我说的就是取名字,而且要求在 Windows 系统上全局唯一;所以这里取名字也是有讲究的。关于应用程序标识符的相关内容,可以阅读微软的官方文档:Programmatic Identifiers - Windows applications - Microsoft Docs

微软建议的 ProgID 的取名方式是这样的:

厂商名.应用名.版本号

这里的版本号通常是指的大版本号。例如版本号为 1.6.0.97 的应用,通常只取第一位,即 1。一个典型的建议的取名示例是这样的:

Walterlv.Foo.1

还是看微软自己的命名示例会更权威一点:

来自微软的 ProgID 命名示例

竟然取一个名字也能写这么多篇幅,看来程序员的命名果然是世界上的一大难题呀!赶紧试用一下我的命名神器吧 —— 点击下载,其原理可阅读 冷算法:自动生成代码标识符(类名、方法名、变量名) - 吕毅

在注册表中添加文件关联

你需要在注册表的 HKEY_LOCAL_MACHINE\Software\Classes 或者 HKEY_CURRENT_USER\Software\Classes 添加一些子键:

HKEY_CURRENT_USER\Software\Classes
    .walv
        (Default) = Walterlv.Foo.1
    .lvyi
        (Default) = Walterlv.Foo.1
    Walterlv.Foo.1
        (Default) = 吕毅的示例文件
        Shell
            Open
                Command
                    (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" "%1"
                      

前面的 .walvlvyi 是我自己定义的两种文件类型,我将它们的 (Default) 值设置成 Walterlv.Foo.1;而 Walterlv.Foo.1 就是前面说的应用程序标识符(ProgID)。后面的又新建了一个 Walterlv.Foo.1 的键,其 (Default) 值设置成了我们这个应用关联时使用的名称,也就是资源管理器中显示这个文件的时候使用的名称。

在注册表中的 Walterlv.Foo.1

只要我们完成了以上的步骤,我们就能在资源管理器中看到我们的文件关联(虽然双击打不开):

在资源管理器中看到的文件关联

关于注册表路径的说明

HKEY_LOCAL_MACHINE 主键是此计算机上的所有用户共享的注册表键值,而 HKEY_CURRENT_USER 是当前用户使用的注册表键值。而我们在注册表的 HKEY_CLASSES_ROOT 中也可以看到跟 HKEY_LOCAL_MACHINE\Software\ClassesHKEY_CURRENT_USER\Software\Classes 中一样的文件关联项,是因为 HKEY_CLASSES_ROOTHKEY_LOCAL_MACHINE\Software\ClassesHKEY_CURRENT_USER\Software\Classes 合并之后的一个视图,其中用户键值会覆盖此计算机上的相同键值。

也就是说,如果你试图修改文件关联,那么需要去 HKEY_LOCAL_MACHINE\Software\ClassesHKEY_CURRENT_USER\Software\Classes 中,但如果只是去查看文件关联的情况,则只需要去 HKEY_CLASSES_ROOT 中。

写入计算机范围内的注册表项需要管理员权限,而写入用户范围内的注册表项不需要管理员权限;你可以酌情选用。

为关联的程序添加谓词

我们需要为关联的程序添加谓词才能够使用我们的程序打开这个文件。通常进行文件关联时最常用的谓词是 open,添加路径为 HKEY_CURRENT_USER\Software\Classes\Walterlv.Foo.1\shell\Open\Command。添加后,我们可以在文件资源管理器中通过双击打开这个文件。

Walterlv.Foo.1
    (Default) = 吕毅的示例文件
    shell
        Open
            Command
                (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" -f "%1"

其中路径后面的 "%1" 是文件资源管理器传入的参数,其实就是文件的完整路径。我们加上了引号是避免解析命令行的时候把包含空格的路径拆成了多个参数。

还可以添加其他谓词,有一些是预定义的谓词,你也可以随便写其他的谓词。另外,还可以定义文件的图标。

Walterlv.Foo.1
    (Default) = 吕毅的示例文件
    DefaultIcon = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\lvyi-icon.ico"
    shell
        Open
            Command
                (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" open -f "%1"
        用逗比的方式打开
            Command
                (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" open -f "%1" --doubi

反注册文件类型

当你卸载你的程序的时候,需要反注册之前注册过的文件类型;而反注册的过程并不是把以上的过程完全反过来。

微软推荐我们只删除 ProgID 的键,而不删除文件扩展名的键;因为其他的程序可能已经关联了我们的文件扩展名。就算我们使用的是私有的格式,也有可能是我们程序的未来版本会关联这个扩展名。

总之,你需要做的,只是删除 ProgID 的键,文件扩展名的键不要去动它,Windows 自己会处理好 ProgID 删除之后文件关联的问题的。

一个完整的文件关联示例

HKEY_CLASSES_ROOT
    .walv
        (Default) = Walterlv.Foo.1
    .lvyi
        (Default) = Walterlv.Foo.1
        Content Type = text/xml
    Walterlv.Foo.1
        (Default) = Walterlv Foo
        AlwaysShowExt = 1
        DefaultIcon = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\lvyi-icon.ico"
        FriendlyTypeName = 吕毅的示例文件
        shell
            Open
                Command
                    (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" open -f "%1"
            用逗比的方式打开
                Command
                    (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" open -f "%1" --doubi
            Edit
                Command
                    (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" edit -f "%1"
            print
                command
                    (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" print -f "%1"
            printto
                command
                    (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" print -f "%1" -t "%2"

参考资料

03-02 2019

让你的 Windows 应用程序在任意路径也能够直接通过文件名执行

我们可以在任何路径下输入 explorer 来启动资源管理器,可以在任何路径中输入 git 来使用 git 相关的命令。我们知道可以通过将一个应用程序加入到环境变量中来获得这个效果,但是还有其他的方式吗?

我们将这个过程称之为向 Windows 注册一个应用程序路径。本文介绍向 Windows 注册一个应用程序路径的各种方法。


Windows 如何查找程序路径?

当我们在任意目录中输入一个命令的时候,Windows 会按照如下顺序寻找这个命令对应的可执行程序:

  • 当前的工作目录
  • Windows 文件夹(仅此文件夹,不会搜索子文件夹)
  • Windows\System32 文件夹
  • 环境变量 Path 值中的所有文件夹
  • 注册表 HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths

微软 推荐使用 App Paths 即修改此注册表项来添加可执行程序。

当然,你也可以使用当前用户键下的注册表项来实现同样的目的,程序使用当前用户路径写注册表是不需要管理员权限的。HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\App Paths

使用 App Paths 添加可执行程序

在注册表中打开 HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths 子键,你可以在里面找到当前通过此方法注册的所有可执行程序。

比如下图是 PowerShell Core 的 msi 包安装后添加的 pwsh.exe 键。

PowerShell Core

现在我们添加一个我们自己开发的程序 walterlv.exe,于是就直接在 App Paths 子键下添加一个 walterlv.exe 的键,并将其默认值设为 walterlv.exe 的完整路径。

新增的 walterlv.exe


参考资料

02-27 2019

电脑总是意外从睡眠状态唤醒,可以找出原因然后解决

在昏暗的夜晚,一个人躺在房间的床上,静静的思考着什么。突然间电脑屏幕亮了!什么鬼!到底是谁唤醒了我的电脑!!!

本文将介绍如何寻找唤醒电脑的真凶。


调查是谁唤醒了电脑

使用命令查看上一次是谁唤醒了电脑。

powercfg -lastwake

last wake

从图中可知上一次唤醒我计算机的是 英特尔® 以太网连接 I219-V 82186

查看还有谁可以唤醒电脑

使用命令查看所有可以唤醒电脑的设备。

powercfg -devicequery wake_armed

wake armed

发现能唤醒我电脑的设备是键盘鼠标以及刚刚的以太网。

wake timers

查看下一次计划的唤醒

使用命令可以查看下一次计划的唤醒。

powercfg -waketimers

当然这只能查到计划的唤醒,类似鼠标键盘还有以太网这种根据硬件状态触发的唤醒是看不到的。

修复意外的唤醒

由于我不知道到底是谁通过以太网唤醒了我的电脑,所以我直接关掉以太网的唤醒即可。

前往设备管理器,找到刚刚发现的硬件设备,查看属性。

设备管理器

然后我关闭了此设备唤醒电脑的设置。

关闭唤醒电脑


参考资料

02-24 2019

2019-2-24-2019-2-24-C#中的弃元

从 C# 7.0 开始,C# 支持弃元,这是一种在应用程序代码中人为取消使用的临时虚拟变量。


使用过ConcurrentDictionary<T,T>的同学肯定经历过这样的痛苦

var dict = new ConcurrentDictionary<int,int>
dict[1]=1;
var result = dict.TryRemove(1, out var value);
Console.WriteLine(result);

我明明没有用到字典中删除的值,但是还是非要将这个值赋给某个变量。

有了弃元之后,你就可以写出这样的代码

var dict = new ConcurrentDictionary<int,int>();
dict[1]=1;
var result = dict.TryRemove(1,out _);
Console.WriteLine(result);

我们看到,不但没有变量赋值,连类型声明都不需要了,代替的只是使用了一个下划线_

当然弃元不只是书写和语义上的提升,它还可以减少内存分配

除了out参数,我们还可以再元组析构,switch等语句中使用弃元写出优雅代码

例如,下面的例子我们只希望使用到日期中的年份

var (year,_,_) = GetDate();

private (string,string,string) GetDate()
{
	//...
    return (year,month,day);
}

例如,我们在switch的模式匹配中不期望对指定类型的对象进行使用

Foo("10");

void Foo(object item)
{
	switch (item)
	{
	     case int val:
             Console.WriteLine(val);
	         break;
	     case string _:
	         Console.WriteLine("Error");
	         break;
	}
}

参考链接:

02-24 2019

2019-2-24-元组投影初始值设定项

使用过命名元组的同学都知道,命名元组可以使用“有意义的字段名”来代替Item,用于表示元组的成员。在C#7.1中引入了“元组投影初始值设定项”(tuple projection initializers)提升了命名元组的编程体验


一般情况下,我们会采用下面这种方式进行命名元组的创建

var firstName = "Huang";
var secendName = "Tengxiao";
var fullName = (firstName:firstName,secendName:secendName);
Console.WriteLine(fullName.firstName);
Console.WriteLine(fullName.secendName);

但是在C#7.1之后可以使用如下写法,在式子中,元组采用构造时的变量名称对字段进行初始化

var firstName = "Huang";
var secendName = "Tengxiao";
var fullName = (firstName,secendName);
Console.WriteLine(fullName.firstName);
Console.WriteLine(fullName.secendName);

另外对于没有提供变量名称的初始化方式,元组会采用匿名元组默认的Item名称对字段名称进行初始化。例如

1550988997023

另外在以下两种情况下,不会将候选字段名称投影到元组字段:

  1. 候选名称是保留元组名称时。 示例包括 ItemXToString、 或 Rest
  2. 候选名称重复了另一元组的显式或隐式字段名称时。

如下面的例子,在使用保留字,或者出现重名的时候,都会采用匿名元组默认的Item名称对字段名称进行初始化。

var Item3 = "Huang";
var Rest = "Tengxiao";
var fullName = (Item3,Rest);
Console.WriteLine(fullName.Item1);
Console.WriteLine(fullName.Item2);
var pt1 = (X: 3, Y: 0);
var pt2 = (X: 3, Y: 4);

var xCoords = (pt1.X, pt2.X);
Console.WriteLine(xCoords.Item1);
Console.WriteLine(xCoords.Item2);

不过有个小tips,对于c#这种区分大小写命名的语言,只要采用如下的小写命名就不会同保留字产生冲突。

(快去统一你们团队的命名元组编程规范吧~)

var item3 = "Huang";
var rest = "Tengxiao";
var fullName = (item3,rest);
Console.WriteLine(fullName.item3);
Console.WriteLine(fullName.rest);

参考链接:

02-24 2019

2019-2-24-元组的相等性比较

元组作为轻量级的数据结构,在c#中具有广泛的引用。但是元组的比较一直以来都是对于成员的依次比较。好在C#7.3开始,引入了元素的相等性比较,让元组的易用性有了大幅提升。


绑定

微软对此的介绍是“从 C# 7.3 开始,元组类型支持 ==!= 运算符。 这些运算符按顺序将左边参数的每个成员与右边参数的每个成员进行比较,且比较是短路计算”

所以我们可以写出这样的代码,对元组进行比较

var left = (5, 10);
var right = (5, 10);
Console.WriteLine(left == right); 

此外,元组的比较也支持可空类型的提升转换,以及类型的隐式转换,例如下面代码中可空类型与非空类型的比较,(int,int)和(long,long)之间的比较

var left = (5, 10);
var right = (5, 10);

(int a, int b)? nullableTuple = right;
Console.WriteLine(left == nullableTuple.Value);
Console.WriteLine(left == nullableTuple);

(long a, long b) longTuple = (5, 10);
Console.WriteLine(left == longTuple);

参考链接:

02-19 2019

.NET/C# 将一个命令行参数字符串转换为命令行参数数组 args

我们通常得到的命令行参数是一个字符串数组 string[] args,以至于很多的命令行解析库也是使用数组作为解析的参数来源。

然而如我我们得到了一整个命令行字符串呢?这个时候可能我们原有代码中用于解析命令行的库或者其他辅助函数不能用了。那么如何转换成数组呢?


在 Windows 系统中有函数 CommandLineToArgvW 可以直接将一个字符串转换为命令行参数数组,我们可以直接使用这个函数。

LPWSTR * CommandLineToArgvW(
  LPCWSTR lpCmdLine,
  int     *pNumArgs
);

此函数在 shell32.dll 中,于是我们可以在 C# 中调用此函数。

为了方便使用,我将其封装成了一个静态方法。

using System;
using System.ComponentModel;
using System.Runtime.InteropServices;

namespace Walterlv
{
    public static class CommandLineExtensions
    {
        public static string[] ConvertCommandLineToArgs(string commandLine)
        {
            var argv = CommandLineToArgvW(commandLine, out var argc);
            if (argv == IntPtr.Zero)
            {
                throw new Win32Exception("在转换命令行参数的时候出现了错误。");
            }

            try
            {
                var args = new string[argc];
                for (var i = 0; i < args.Length; i++)
                {
                    var p = Marshal.ReadIntPtr(argv, i * IntPtr.Size);
                    args[i] = Marshal.PtrToStringUni(p);
                }

                return args;
            }
            finally
            {
                Marshal.FreeHGlobal(argv);
            }
        }

        [DllImport("shell32.dll", SetLastError = true)]
        static extern IntPtr CommandLineToArgvW([MarshalAs(UnmanagedType.LPWStr)] string lpCmdLine, out int pNumArgs);
    }
}

参考资料

01-30 2019

ReSharper 在 C 盘占用了太多空间了,本文告诉你如何安全地删除或转移这些文件

一个不小心,我的 SSD 又满了。到底是谁占用了那么多的空间!如果你是 ReSharper 的重度用户,那么可能你的调查结果会直指 JetBrains ReSharper。

本文将告诉你如何安全地删除这些文件来释放你的 C 盘空间,然后在 ReSharper 中设置其他的缓存目录。


消失的 C 盘空间

SSD 很贵的,看看都满成什么样儿了……我一个 SSD 分成了 C 和 D 两个分区,都满了。

近乎满了的 SSD

你可以使用 SpaceSniffer 来快速调查占用你大量 C 盘空间的到底是些什么文件。我之前写过一篇文章介绍如何使用它:

当你是 ReSharper 的重度用户的时候,你很有可能会看到如下的场景:

JetBrains 家的软件竟然占据了这么多空间

是的,JetBrains 家的软件竟然占用了 17.2GB 的 C 盘空间!他们一定认为所有的用户都是土豪,能够买 500GB 以上的 SSD 全部分配给 C 盘。

好的,吐槽就到这里,我们进入正题——删除这些文件。

删除 ReSharper 的缓存目录

注意:只有 Transient 文件夹是可以删除的

ReSharper 安装时的目录都在 %LocalAppData%\JetBrains 中。虽然运行时的缓存也在这里,但是如果你直接把这个目录删掉了,那么 ReSharper 插件以及 JetBrains 全家桶也就不能正常使用了。

Transient 意思跟 Temporary 差不多,就是短暂使用的文件。不过 ReSharper 竟然在这里堆了这么多。

Transient

删除掉这个文件夹不影响 ReSharper 及其他 JetBrains 全家桶的正常运行。

ReSharper 在设置中提供了清除缓存的按钮,但那个按钮点了其实释放不了多少空间的,本文最后一句将说明这个问题。

删除 Transient 目录

转移 ReSharper 的缓存目录

  1. 从 Visual Studio 的菜单中进入 ReSharper 的设置界面:ReSharper -> Options;
  2. 进入缓存设置选项:Environment -> General -> Caches -> Store solution。

在这里可以修改 ReSharper 缓存文件的存储位置。

不过可得提醒你一下,ReSharper 这么耗性能的插件,还是老老实实放 SSD 里面吧,SSD 再怎么贵比起你的时间来说可便宜多了呀!

ReSharper Options

更改缓存目录

可以在这个界面中看到,ReSharper 其实是提供了清除缓存的按钮(Clear)的,但是这个按钮点击之后其实只是会删除当前项目的缓存。而实际上 ReSharper 在你的电脑上积攒久了是众多缓存文件一起占用的太多空间,只删除最近正在使用的这个项目其实根本释放不了多少空间的。(比如我打开我的 Walterlv.CloudKeyboard 项目清除结果只删掉了不到 100M 的空间。)


参考资料

01-17 2019

不使用 U 盘等任何工具全新安装 Windows 操作系统

安装 Windows 有非常多种方法,现在我们要解决的问题是:

  1. 手头没有量产的 U 盘,或者懒得花时间去用 iso 文件量产 U 盘;
  2. 不想在 Windows 现有系统下安装(可能是为了全新安装,也可能是为了跳过安装序列号/产品密钥)

于是本文教你如何一步一步在 Windows RE 环境下安装操作系统。


准备工作

  1. Windows 10 的安装文件
    • 例如 cn_windows_10_consumer_editions_version_1809_updated_jan_2019_x64_dvd_34b4d4fb.iso
  2. 现有系统是 Windows 8/8.1/10 操作系统

第一步:解压 iso 文件

将 iso 文件解压到一个文件夹中,例如,我解压到 D:\Windows10 文件夹中。

解压 iso 到一个文件夹中

第二步:重启进入 RE 环境

现在,在开始菜单中点击电源按钮,这时会弹出电源选择菜单。注意:请按住 Shift 键不放,然后点击重启按钮,重启按钮点完之后才能松开 Shift 键。

按住 Shift 键点击重启按钮

第三步:等待进入 RE 环境

这时重启会进入 RE 环境。Windows RE 指的是 Windows Recovery Environment,也就是 Windows 恢复环境。你可以在这里进行很多系统之外的操作。相比于 PE 需要一个光盘或者 U 盘来承载,RE 是直接在你安装 Windows 8/8.1/10 时直接自带到机器硬盘上的。

进入 RE 环境

第四步:进入 RE 环境的命令提示符

依次进入 疑难解答 -> 高级选项 -> 命令提示符 -> 选择自己的账号 -> 输入自己的密码

注意,在选择命令提示符之后,计算机还会再重启一次,所以需要等一会儿才会到选择账号的界面。

疑难解答

高级选项

命令提示符

选择自己的账号

输入自己账号的密码

第五步:在命令提示符中找到安装程序

我们一开始将系统解压到了 D:\Windows10 文件夹下。一般来说,现在也应该是在 D 盘的 Windows10 文件夹下。不过有时候你会发现这里的 D 盘并不是你想象中那个 D 盘,你找不到那个文件夹和里面那个安装文件。这个时候可以去 C 盘、E 盘、F 盘等地方也看看。

命令提示符的操作这里就不赘述了,无非是 D: 跳转到某个盘符,cd 跳转到某个文件夹下,setup.exe 打开 setup.exe 这个程序。

打开 setup.exe

第六步:按照熟悉的安装系统的流程安装操作系统

现在,你应该可以看到熟悉的 Windows 10 安装界面了。

开始安装 Windows

比如,你可以在这里跳过产品密钥的输入:

跳过产品密钥的输入

选择 Windows 10 的安装版本

比如可以使用在 Windows 内部安装无法使用的“自定义”安装方式:

使用自定义的安装方式

甚至能在这里格式化所有分区,删除所有磁盘:

格式化分区或者删除磁盘

剩下的,祝你好运!

2018
04-19 2018

Moq基础(六)

这一章是最后一讲,我们讲一下Moq中值得注意的小技巧,以及对Moq使用的评价


04-18 2018

Moq基础(五)

上一章我们将讲了伪造属性和事件

这一章我们将参数匹配,回调,和验证


04-18 2018

Moq基础(四)

上一章讲了如何使用Setup伪造方法

这一章我们将伪造属性和事件


04-15 2018

Moq基础(三)

上一章区分了下stub,和mock

这一章我们的确要开始讲moq的api了


04-10 2018

Moq基础(二)

上一章介绍了手动创建单元测试依赖,和Moq的小demo

这一章我们看一下Moq的使用。


04-10 2018

Moq基础(一)

在教程开始之前,先回顾下单元测试应该有哪些特点?