dotnet 职业技术学院

博客

dotnet 职业技术学院

C# 跨设备前后端开发探索

dotnet 职业技术学院 发布于 2019-04-16

每个人都拥有 好奇心,好奇心驱使着我们总是去尝试做一些有趣的事情。

带起你的好奇心,本文将使用 C# 开发各种各样好玩的东西。


C# 跨设备前后端开发探索

本文内容已加入 2019 年 4 月 13 日的广州 .NET 俱乐部第 2 届线下沙龙

0x00 序章

好奇心

每个人都拥有 好奇心,好奇心驱使着我们总是去尝试做一些有趣的事情。

比如这件事:

手机上打字慢

在好奇心的驱使下,我们立刻 尝试 我们的想法。

我们需要用电脑打字,手机端出字;于是我们需要开发的是一款云输入法。而一个最简单的云驱动的软件需要至少一个 Web 后端、一个桌面端和一个移动端。

还没开始呢,就这么复杂。

需要至少三个端

先搞起来

摆在我们面前的,有两条路可以选:

  1. 先掌握所有理论知识再实践
  2. 无论什么技术,先搞起来

如果先搞起来,那么我们能够迅速出效果,出产品,出玩具,那么这种成就感会鼓励我们继续完善我们的代码,继续去做更多好玩的东西。

而如果是先掌握所有理论知识再实践,这是我们从学校带来的学习方式,我们中的多数人在校期间就是这么学习的。虽然对学霸来说可以无视,但对于我们这样大多数的小伙伴来说,简直就是“从入门到放弃”。

从入门到放弃

如果先搞起来呢?如果我们连“入门”都不需要呢?是不是就不需要放弃了!

怎么才能够先搞起来?我们需要调整一下心态——我们不是在学,而是在玩!

我们需要做的是降低学习成本,甚至入门不学习,那么立刻就能玩起来!

搞起来

我们有 C#,还有什么不能马上搞起来!

0x01 C# 跨设备前后端开发

打开 Visual Studio 2019,我们先搞起来!

Visual Studio 2019

Web 后端

创建一个 Asp.NET Core Web 应用程序

输入项目的名称

选择 API 开发

对于简单的云服务来说,使用 Asp.NET Core 开发是非常简单快速的。你可以阅读林德熙的博客入门 Asp.NET Core 开发:

Windows 桌面端

开发 Windows 桌面端

我们是要玩的呀,什么东西好玩。我们自己就是用户,用户看得到的部分才是最具有可玩性的。这就是指客户端或者 Web 前端。

我们现在要拿 C# 写客户端,一般 C# 或者 .NET 的开发者拿什么来写桌面客户端呢?

  • WPF 或者 Windows Forms 应用程序

WPF 程序

Windows Forms 程序

公共代码

我们现在已经有至少两个端了。由于我们是同一个软件系统,所以实际上非常容易出现公共代码。典型的就是一些数据模型的定义,以及 Web API 的访问代码,还有一些业务需要的其他公共代码等等。

所以,我们最好使用一个新的项目将这些代码整合起来。

我们选用 .NET Standard 项目来存放这些代码,这样可以在各种 .NET 中使用这些库。

.NET Standard 类库

控制台

由于我们多数的代码都可以放到 .NET Standard 类库中,以确保绝大多数的代码都是平台和框架无关的,所以实际上我们在其他各个端项目中的代码会是很少的。

这个时候,写一个控制台程序来测试我们的项目,控制台程序的部分其实只需要很少的用于控制控制台输入输出的代码,其他多数的代码例如用来访问 Web API 的代码都是不需要放在控制台项目中的,放到 .NET Standard 的类库中编写就可以做到最大程度的共用了。

控制台程序

iOS 端

接下来要完成这个云键盘程序,我们还需要开发一个移动端。使用 Xamarin 可以帮助我们完成这样的任务。

Xamarin.Forms

Xamarin 自定义键盘扩展

关于使用 Xamarin.Forms 开发一个键盘扩展,可以阅读我的另一篇博客:

Web 前端

于是,我们仅仅使用 C# 还有客户端开发者熟悉的 XAML 就开发出了三个端了。

三个端

这三个端中,有两个都是客户端,于是就会存在向用户分发客户端的问题。虽然可以让用户去商店下载,但是提供一个官方下载页面可以让用户在一处地方找到所有端的下载和部署方法。

这需要使用到前端。然而如何使用 C# 代码来编写去前端呢?

如何使用 C# 来编写前端?

使用 CSHTML5!

你可以前往 CSHTML5 的官网 下载 Visual Studio 的插件,这样你就可以在 Visual Studio 中编写 CSHTML5 的代码了,还有设计器的支持。

CSHTML5 如何编译 C# 和 XAML 代码

0x02 C# 还能做什么?

于是我们使用 XAML + C# 就编写出了各个端了。

各个端

如果没有 GUI,那么跨平台将是非常容易的一件事情。例如我们想要在 Mac 电脑上也做一个打字发送的一方,那么一个控制台应用也是能够直接完成的。

没有 GUI,更容易跨平台

不过,这并不是说,我们只能通过控制台来开发桌面端应用。

我们还有:

利用这些平台,我们能开发其他桌面平台的 GUI 客户端。

另外,利用 ML.NET,我们还能用 C# 进行机器学习。可参见:Bean.Hsiang - 博客园

利用 Roslyn,我们还能用直接做编译器,然后你还有什么不能做的?关于 Roslyn 的入门,可以阅读:从零开始学习 dotnet 编译过程和 Roslyn 源码分析 - walterlv

还有 IoT。

还有其他……

0x03 终章

每个人都拥有 好奇心,好奇心驱使着我们总是去尝试做一些有趣的事情。

使用你熟悉的语言 C#,不需要太多额外的入门,即可玩转你身边各种你需要的技术栈,玩出各种各样你自己期望尝试开发的小东西。

.NET 应用启用与禁用自动生成绑定重定向 (bindingRedirect),解决不同版本 dll 的依赖问题

dotnet 职业技术学院 发布于 2019-04-12

当你的项目中多个不同的项目以及不同的依赖存在不同的依赖程序集时,可能会因为依赖于不同版本的程序集而产生冲突。而绑定重定向可以帮助解决不同程序集的依赖版本不同的问题,使整个程序使用统一个版本的 dll 来运行整个应用程序。

然而,如果我们就是需要使用一个分离的不同版本,那么我们就需要禁用掉自动生成绑定重定向。本文介绍如何禁用自动生成绑定重定向。


本文的结论只有一句,就是在项目中设置属性 <AutoGenerateBindingRedirects>false</AutoGenerateBindingRedirects>。阅读本文全文是了解更多与绑定重定向此场景相关的知识。

绑定重定向

从 .NET Framework 4.5.1 开始到后面的 .NET Core 所有版本,编译器会自动向你的程序集中插入绑定重定向。如果你升级使用了新的 csproj 格式,即便你用了旧的 .NET Framework 也会自动生成绑定重定向。

关于新旧 csproj 格式,你可以参考我的另一篇博客:将 WPF、UWP 以及其他各种类型的旧 csproj 迁移成 Sdk 风格的 csproj - walterlv

你可以在你的应用程序的 App.config 文件中查看到自动生成的绑定重定向。当然,编译之后这个 App.config 文件会编程 “你的程序集名称.config” 文件,例如对于我的 Walterlv.Demo.exe 程序对应 Walterlv.Demo.exe.config 文件。

一个典型的包含绑定重定向的文件大概是下面这样的:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <startup useLegacyV2RuntimeActivationPolicy="true">
        <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6" />
    </startup>
    <runtime>
        <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
            <dependentAssembly>
                <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
                <bindingRedirect oldVersion="0.0.0.0-11.0.0.0" newVersion="11.0.0.0" />
            </dependentAssembly>
            <dependentAssembly>
                <assemblyIdentity name="System.ValueTuple" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
                <bindingRedirect oldVersion="0.0.0.0-4.0.3.0" newVersion="4.0.3.0" />
            </dependentAssembly>
        </assemblyBinding>
    </runtime>
</configuration>

上面 dependentAssembly 以及 bindingRedirect 就是在描述绑定重定向。

对于上面的代码,指的是:

  1. 如果依赖中发现了任何 0.0.0.0-11.0.0.0 区间版本号的 Newtonsoft.Json 程序集的引用,都将使用 11.0.0.0 版本的。
  2. 如果以来中发现了任何 0.0.0.0-4.0.3.0 区间版本号的 System.ValueTuple 程序集的引用,都将使用 4.0.3.0 版本的(这个其实使用的 NuGet 包版本是 4.5)。

引用同名但不同版本的 dll

绑定重定向多数时候都是在帮助我们解决依赖问题,然而我们总有一些时候不是按照常规的方式来使用依赖,例如下文这样的方式:

以上文章的场景,是需要在同一个解决方案的不同项目中引用不同版本的同名 dll。解决方法是像下面这样:

<dependentAssembly>
    <assemblyIdentity name="LiteDB" publicKeyToken="4ee40123013c9f27" culture="neutral" />
    <codeBase version="2.0.2.0" href="LiteDB.2.0.2.0\LiteDB.dll" />
    <codeBase version="4.0.0.0" href="LiteDB.4.0.0.0\LiteDB.dll" />
</dependentAssembly>

于是,如果引用了 2.0.2.0 版本的 LiteDB 的时候,会去应用程序所在目录的 LiteDB.2.0.2.0 子目录中查找名为 LiteDB.dll 的引用 dll;而如果引用了 4.0.0.0 版本的 LiteDB 的时候,会去应用程序所在目录的 LiteDB.4.0.0.0 子目录中查找名为 LiteDB.dll 的引用 dll。这种方式使用两个 dll 互不干扰。

禁用绑定重定向

如果你的项目从 .NET Framework 4.5 或者更早版本升级到 .NET Framework 4.5.1 或者 .NET Core 的版本,或者 csproj 的格式升级到了新的基于 Microsoft.NET.Sdk 的版本,那么绑定重定向就会从之前的手动编程自动生成。

但是如果你编写了上一节中我们讲到的你需要引用同名程序集的多个版本的时候,如果依然自动生成绑定重定向,那么上面的功能会失效。

解决方法,便是禁用自动生成绑定重定向。在你的主项目中添加一个属性:

<AutoGenerateBindingRedirects>false</AutoGenerateBindingRedirects>

参考资料

如何更精准地设置 C# / .NET Core 项目的输出路径?(包括添加和删除各种前后缀)

dotnet 职业技术学院 发布于 2019-04-12

我们都知道可以通过在 Visual Studio 中设置输出路径(OutputPath)来更改项目输出文件所在的位置。对于 .NET Core 所使用的 Sdk 风格的 csproj 格式来说,你可能会发现实际生成路径中带了 netcoreapp3.0 或者 net472 这样的子文件夹。

然而有时我们并不允许生成这样的子文件夹。本文将介绍可能影响实际输出路径的各种设置。


项目和输出路径

对于这样的一个简单的项目文件,这个项目的实际输出路径可能是像下图那样的。

<Project>
  <ItemGroup>
    <TargetFramework>netcoreapp3.0</TargetFramework>
    <OutputPath>bin\$(Configuration)</OutputPath>
  </ItemGroup>
</Project>

输出路径带有框架子文件夹

有没有办法可以不要生成这样的子文件夹呢?答案是可以的。

我在 解读 Microsoft.NET.Sdk 的源码,你能定制各种奇怪而富有创意的编译过程 一文中有说到如何解读 Microsoft.NET.Sdk,而我们的答案就是从解读这个 Sdk 而来。

影响输出路径的属性

OutputPath 属性由这些部分组成:

$(BaseOutputPath)\$(PlatformName)\$(Configuration)\$(RuntimeIdentifier)\$(TargetFramework.ToLowerInvariant())\

如果以上所有属性都有值,那么生成的路径可能就像下面这样:

bin\x64\Debug\win7-x64\netcoreapp3.0

具体的,这些属性以及其相关的设置有:

  • $(BaseOutputPath) 默认值 bin\,你也可以修改。

  • $(PlatformName) 默认值是 $(Platform),而 $(Platform) 的默认值是 AnyCPU;当这个值等于 AnyCPU 的时候,这个值就不会出现在路径中。

  • $(Configuration) 默认值是 Debug

  • $(RuntimeIdentifier) 这个值和 $(PlatformTarget) 互为默认值,任何一个先设置都会影响另一个;此值即 x86x64 等标识符。可以通过 $(AppendRuntimeIdentifierToOutputPath) 属性指定是否将此加入到输出路径中。

  • $(TargetFramework) 这是在 csproj 文件中强制要求指定的,如果不设置的话项目是无法编译的;可以通过 $(AppendTargetFrameworkToOutputPath) 属性指定是否将此加入到输出路径中。

现在,你应该可以更轻松地设置你的输出路径,而不用担心总会出现各种意料之外的子文件夹了吧!

在 Visual Studio 新旧不同的 csproj 项目格式中启用混合模式调试程序(开启本机代码调试)

dotnet 职业技术学院 发布于 2019-04-12

因为我使用 Visual Studio 主要用来编写 .NET 托管程序,所以平时调试的时候是仅限托管代码的。不过有时需要在托管代码中混合调试本机代码,那么就需要额外在项目中开启本机代码调试。

本文介绍如何开启本机代码调试。


本文涉及到新旧 csproj 项目格式,不懂这个也不影响你完成开启本机代码调试。不过如果你希望了解,可以阅读:将 WPF、UWP 以及其他各种类型的旧 csproj 迁移成 Sdk 风格的 csproj - walterlv

在旧格式的项目中开启

旧格式指的是 Visual Studio 2015 及以前版本的 Visual Studio 使用的项目格式。目前 Visual Studio 2017 和 2019 对这种格式的支持还是很完善的。

在项目上右键 -> 属性 -> Debug,这时你可以在底部的调试引擎中发现 Enable native code debugging 选项,开启它你就开启了本机代码调试,于是也就可以使用混合模式调试程序。

在旧格式中开启本机代码调试

在新格式的项目中开启

如果你在你项目属性的 Debug 标签下没有找到上面那个选项,那么有可能你的项目格式是新格式的。

新格式中没有开启本机代码调试的选项

这个时候,你需要在 lauchsettings.json 文件中设置。这个文件在你项目的 Properties 文件夹下。

如果你没有找到这个文件,那么随便在上图那个框框中写点什么(比如在启动参数一栏中写 吕毅是逗比),然后保存。我们就能得到一个 lauchsettings.json 文件。

launchsettings.json 文件

打开它,然后删掉刚刚的逗比行为,添加 "nativeDebugging": true。这时,你的 lauchsettings.json 文件影响像下面这样:

{
  "profiles": {
    "Walterlv.Debugging": {
      "commandName": "Project",
      "nativeDebugging": true
    }
  }
}

这时你就可以开启本机代码调试了。当然,新的项目格式支持设置多个这样的启动项,于是你可以分别配置本机和非本机的多种配置:

{
  "profiles": {
    "Walterlv.Debugging": {
      "commandName": "Project"
    },
    "本机调试": {
      "commandName": "Project",
      "nativeDebugging": true
    }
  }
}

现在,你可以选择你项目的启动方式了,其中一个是开启了本机代码调试的方式。

选择项目的启动方式

关于这些配置的更多博客,你可以阅读:VisualStudio 使用多个环境进行调试 - 林德熙


参考资料

将 C++/WinRT 中的线程切换体验带到 C# 中来(WPF 版本)

dotnet 职业技术学院 发布于 2019-03-30

如果你要在 WPF 程序中使用线程池完成一个特殊的任务,那么使用 .NET 的 API Task.Run 并传入一个 Lambda 表达式可以完成。不过,使用 Lambda 表达式会带来变量捕获的一些问题,比如说你需要区分一个变量作用于是在 Lambda 表达式中,还是当前上下文全局(被 Lambda 表达式捕获到的变量)。然后,在静态分析的时候,也难以知道此 Lambda 表达式在整个方法中的执行先后顺序,不利于分析潜在的 Bug。

在使用 async/await 关键字编写异步代码的时候,虽然说实质上也是捕获变量,但这时没有显式写一个 Lambda 表达式,所有的变量都是被隐式捕获的变量,写起来就像在一个同步方法一样,便于理解。


C++/WinRT

以下 C++/WinRT 的代码来自 Raymond Chen 的示例代码。Raymond Chen 写了一个 UWP 的版本用于模仿 C++/WinRT 的线程切换效果。在看他编写的 UWP 版本之前我也思考了可以如何实现一个 .NET / WPF 的版本,然后成功做出了这样的效果。

Raymond Chen 的版本可以参见:C++/WinRT envy: Bringing thread switching tasks to C# (UWP edition) - The Old New Thing

winrt::fire_and_forget MyPage::Button_Click()
{
  // We start on a UI thread.
  auto lifetime = get_strong();

  // Get the control's value from the UI thread.
  auto v = SomeControl().Value();

  // Move to a background thread.
  co_await winrt::resume_background();

  // Do the computation on a background thread.
  auto result1 = Compute1(v);
  auto other = co_await ContactWebServiceAsync();
  auto result2 = Compute2(result1, other);

  // Return to the UI thread to provide an interim update.
  co_await winrt::resume_foreground(Dispatcher());

  // Back on the UI thread: We can update UI elements.
  TextBlock1().Text(result1);
  TextBlock2().Text(result2);

  // Back to the background thread to do more computations.
  co_await winrt::resume_background();

  auto extra = co_await GetExtraDataAsync();
  auto result3 = Compute3(result1, result2, extra);

  // Return to the UI thread to provide a final update.
  co_await winrt::resume_foreground(Dispatcher());

  // Update the UI one last time.
  TextBlock3().Text(result3);
}

可以看到,使用 co_await winrt::resume_background(); 可以将线程切换至线程池,使用 co_await winrt::resume_foreground(Dispatcher()); 可以将线程切换至 UI。

也许你会觉得这样没什么好处,因为 C#/.NET 的版本里面 Lambda 表达式一样可以这么做:

await Task.Run(() =>
{
    // 这里的代码会在线程池执行。
});
// 这里的代码会回到 UI 线程执行。

但是,现在我们给出这样的写法:

// 仅在某些特定的情况下才使用线程池执行,而其他情况依然在主线程执行 DoSomething()。
if (condition) {
  co_await winrt::resume_background();
}

DoSomething();

你就会发现 Lambda 的版本变得很不好理解了。

C# / .NET / WPF 版本

我们现在编写一个自己的 Awaiter 来实现这样的线程上下文切换。

关于如何编写一个 Awaiter,可以阅读我的其他博客:

这里,我直接贴出我编写的 DispatcherSwitcher 类的全部源码。

using System;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using System.Windows.Threading;

namespace Walterlv.ThreadSwitchingTasks
{
    public static class DispatcherSwitcher
    {
        public static ThreadPoolAwaiter ResumeBackground() => new ThreadPoolAwaiter();

        public static ThreadPoolAwaiter ResumeBackground(this Dispatcher dispatcher)
            => new ThreadPoolAwaiter();

        public static DispatcherAwaiter ResumeForeground(this Dispatcher dispatcher) =>
            new DispatcherAwaiter(dispatcher);

        public class ThreadPoolAwaiter : INotifyCompletion
        {
            public void OnCompleted(Action continuation)
            {
                Task.Run(() =>
                {
                    IsCompleted = true;
                    continuation();
                });
            }

            public bool IsCompleted { get; private set; }

            public void GetResult()
            {
            }

            public ThreadPoolAwaiter GetAwaiter() => this;
        }

        public class DispatcherAwaiter : INotifyCompletion
        {
            private readonly Dispatcher _dispatcher;

            public DispatcherAwaiter(Dispatcher dispatcher) => _dispatcher = dispatcher;

            public void OnCompleted(Action continuation)
            {
                _dispatcher.InvokeAsync(() =>
                {
                    IsCompleted = true;
                    continuation();
                });
            }

            public bool IsCompleted { get; private set; }

            public void GetResult()
            {
            }

            public DispatcherAwaiter GetAwaiter() => this;
        }
    }
}

Raymond Chen 取的类名是 ThreadSwitcher,不过我认为可能 Dispatcher 在 WPF 中更能体现其线程切换的含义。

于是,我们来做一个试验。以下代码在 MainWindow.xaml.cs 里面,如果你使用 Visual Studio 创建一个 WPF 的空项目的话是可以找到的。随便放一个 Button 添加事件处理函数。

private async void DemoButton_Click(object sender, RoutedEventArgs e)
{
    var id0 = Thread.CurrentThread.ManagedThreadId;

    await Dispatcher.ResumeBackground();

    var id1 = Thread.CurrentThread.ManagedThreadId;

    await Dispatcher.ResumeForeground();

    var id2 = Thread.CurrentThread.ManagedThreadId;
}

id0 和 id2 在主线程上,id1 是线程池中的一个线程。

这样,我们便可以在一个上下文中进行线程切换了,而不需要使用 Task.Run 通过一个 Lambda 表达式来完成这样的任务。

现在,这种按照某些特定条件才切换到后台线程执行的代码就很容易写出来了。

// 仅在某些特定的情况下才使用线程池执行,而其他情况依然在主线程执行 DoSomething()。
if (condition)
{
    await Dispatcher.ResumeBackground();
}

DoSomething();

Raymond Chen 的版本

Raymond Chen 后来在另一篇博客中也编写了一份 WPF / Windows Forms 的线程切换版本。请点击下方的链接跳转至原文阅读:

我在为他的代码添加了所有的注释后,贴在了下面:

using System;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Windows.Forms;
using System.Windows.Threading;

namespace Walterlv.Windows.Threading
{
    /// <summary>
    /// 提供类似于 WinRT 中的线程切换体验。
    /// </summary>
    /// <remarks>
    /// https://devblogs.microsoft.com/oldnewthing/20190329-00/?p=102373
    /// https://blog.walterlv.com/post/bring-thread-switching-tasks-to-csharp-for-wpf.html
    /// </remarks>
    public class ThreadSwitcher
    {
        /// <summary>
        /// 将当前的异步等待上下文切换到 WPF 的 UI 线程中继续执行。
        /// </summary>
        /// <param name="dispatcher">WPF 一个 UI 线程的调度器。</param>
        /// <returns>一个可等待对象,使用 await 等待此对象可以使后续任务切换到 UI 线程执行。</returns>
        public static DispatcherThreadSwitcher ResumeForegroundAsync(Dispatcher dispatcher) =>
            new DispatcherThreadSwitcher(dispatcher);

        /// <summary>
        /// 将当前的异步等待上下文切换到 Windows Forms 的 UI 线程中继续执行。
        /// </summary>
        /// <param name="control">Windows Forms 的一个控件。</param>
        /// <returns>一个可等待对象,使用 await 等待此对象可以使后续任务切换到 UI 线程执行。</returns>
        public static ControlThreadSwitcher ResumeForegroundAsync(Control control) =>
            new ControlThreadSwitcher(control);

        /// <summary>
        /// 将当前的异步等待上下文切换到线程池中继续执行。
        /// </summary>
        /// <returns>一个可等待对象,使用 await 等待此对象可以使后续的任务切换到线程池执行。</returns>
        public static ThreadPoolThreadSwitcher ResumeBackgroundAsync() =>
            new ThreadPoolThreadSwitcher();
    }

    /// <summary>
    /// 提供一个可切换到 WPF 的 UI 线程执行上下文的可等待对象。
    /// </summary>
    public struct DispatcherThreadSwitcher : INotifyCompletion
    {
        internal DispatcherThreadSwitcher(Dispatcher dispatcher) =>
            _dispatcher = dispatcher;

        /// <summary>
        /// 当使用 await 关键字异步等待此对象时,将调用此方法返回一个可等待对象。
        /// </summary>
        public DispatcherThreadSwitcher GetAwaiter() => this;

        /// <summary>
        /// 获取一个值,该值指示是否已完成线程池到 WPF UI 线程的切换。
        /// </summary>
        public bool IsCompleted => _dispatcher.CheckAccess();

        /// <summary>
        /// 由于进行线程的上下文切换必须使用 await 关键字,所以不支持调用同步的 <see cref="GetResult"/> 方法。
        /// </summary>
        public void GetResult()
        {
        }

        /// <summary>
        /// 当异步状态机中的前一个任务结束后,将调用此方法继续下一个任务。在此可等待对象中,指的是切换到 WPF 的 UI 线程。
        /// </summary>
        /// <param name="continuation">将异步状态机推进到下一个异步状态。</param>
        public void OnCompleted(Action continuation) => _dispatcher.BeginInvoke(continuation);

        private readonly Dispatcher _dispatcher;
    }

    /// <summary>
    /// 提供一个可切换到 Windows Forms 的 UI 线程执行上下文的可等待对象。
    /// </summary>
    public struct ControlThreadSwitcher : INotifyCompletion
    {
        internal ControlThreadSwitcher(Control control) =>
            _control = control;

        /// <summary>
        /// 当使用 await 关键字异步等待此对象时,将调用此方法返回一个可等待对象。
        /// </summary>
        public ControlThreadSwitcher GetAwaiter() => this;

        /// <summary>
        /// 获取一个值,该值指示是否已完成线程池到 Windows Forms UI 线程的切换。
        /// </summary>
        public bool IsCompleted => !_control.InvokeRequired;

        /// <summary>
        /// 由于进行线程的上下文切换必须使用 await 关键字,所以不支持调用同步的 <see cref="GetResult"/> 方法。
        /// </summary>
        public void GetResult()
        {
        }

        /// <summary>
        /// 当异步状态机中的前一个任务结束后,将调用此方法继续下一个任务。在此可等待对象中,指的是切换到 Windows Forms 的 UI 线程。
        /// </summary>
        /// <param name="continuation">将异步状态机推进到下一个异步状态。</param>
        public void OnCompleted(Action continuation) => _control.BeginInvoke(continuation);

        private readonly Control _control;
    }

    /// <summary>
    /// 提供一个可切换到线程池执行上下文的可等待对象。
    /// </summary>
    public struct ThreadPoolThreadSwitcher : INotifyCompletion
    {
        /// <summary>
        /// 当使用 await 关键字异步等待此对象时,将调用此方法返回一个可等待对象。
        /// </summary>
        public ThreadPoolThreadSwitcher GetAwaiter() => this;

        /// <summary>
        /// 获取一个值,该值指示是否已完成 UI 线程到线程池的切换。
        /// </summary>
        public bool IsCompleted => SynchronizationContext.Current == null;

        /// <summary>
        /// 由于进行线程的上下文切换必须使用 await 关键字,所以不支持调用同步的 <see cref="GetResult"/> 方法。
        /// </summary>
        public void GetResult()
        {
        }

        /// <summary>
        /// 当异步状态机中的前一个任务结束后,将调用此方法继续下一个任务。在此可等待对象中,指的是切换到线程池中。
        /// </summary>
        /// <param name="continuation">将异步状态机推进到下一个异步状态。</param>
        public void OnCompleted(Action continuation) => ThreadPool.QueueUserWorkItem(_ => continuation());
    }
}

参考资料

WPF 的命令的自动刷新时机——当你 CanExecute 会返回 true 但命令依旧不可用时可能是这些原因

dotnet 职业技术学院 发布于 2019-03-29

在 WPF 中,你可以使用 Command="{Binding WalterlvCommand}" 的方式来让 XAML 中的一个按钮或其他控件绑定一个命令。这样,按钮的可用性会自动根据 WalterlvCommand 当前 CanExecute 的状态来改变。这本是一个非常智能的特性,直到你可能发现你按钮的可用性状态不正确……

本文介绍默认情况下,WPF 在 UI 上的这些命令会在什么时机进行刷新;以及没有及时刷新时,可以如何强制让这些命令的可用性状态进行刷新。了解了这些,你可能能够解决你在 WPF 程序中命令绑定的一些坑。


This post is written in multiple languages. Please select yours:

一个最简单的例子

<Button x:Name="TestCommand" Command="{Binding WalterlvCommand}" />
public class Walterlv
{
    // 省略了此命令的初始化。
    public WalterlvCommand WalterlvCommand { get; }
}

public class WalterlvCommand : ICommand
{
    public bool SomeFlag { get; set; }

    bool ICommand.CanExecute(object parameter)
    {
        // 判断命令的可用性。
        return SomeFlag;
    }

    void ICommand.Execute(object parameter)
    {
        // 省略了执行命令的代码。
    }
}

假如 SomeFlag 一开始是 false,5 秒种后变为 true,那么你会注意到这时的按钮状态并不会刷新。

var walterlv = new Walterlv();
TestCommand.DataContext = walterlv;

await Task.Delay(5000);
walterlv.WalterlvCommand.SomeFlag = true;

当然,以上所有代码会更像伪代码,如果你不熟悉 WPF,是一定编译不过的。我只是在表达这个意思。

如何手动刷新命令

调用以下代码,即可让 WPF 中的命令刷新其可用性:

CommandManager.InvalidateRequerySuggested();

WPF 的命令在何时刷新?

默认情况下,WPF 的命令只会在以下时机刷新可用性:

  • KeyUp
  • MouseUp
  • GotKeyboardFocus
  • LostKeyboardFocus

使用通俗的话来说,就是:

  • 键盘按下的按键抬起的时候
  • 在鼠标的左键或者右键松开的时候
  • 在任何一个控件获得键盘焦点或者失去键盘焦点的时候

这部分的代码可以在这里查看:

最关键的代码贴在这里:

// 省略前面。
if (e.StagingItem.Input.RoutedEvent == Keyboard.KeyUpEvent ||
    e.StagingItem.Input.RoutedEvent == Mouse.MouseUpEvent ||
    e.StagingItem.Input.RoutedEvent == Keyboard.GotKeyboardFocusEvent ||
    e.StagingItem.Input.RoutedEvent == Keyboard.LostKeyboardFocusEvent)
{
    CommandManager.InvalidateRequerySuggested();
}

然而,并不是只在这些时机进行刷新,还有其他的时机,比如这些:

When WPF Commands update their CanExecute states?

dotnet 职业技术学院 发布于 2019-03-29

When writing Command="{Binding WalterlvCommand}" into your XAML code and your button or other controls can automatically execute command and updating the command states, such as enabling or disabling the button.

We’ll talk about when the UI commands will refresh their can-execute states and how to force updating the states.


This post is written in multiple languages. Please select yours:

This post is written for my Stack Overflow answer:

A simple sample

<Button x:Name="TestCommand" Command="{Binding WalterlvCommand}" />
public class Walterlv
{
    // Assume that I've initialized this command.
    public WalterlvCommand WalterlvCommand { get; }
}

public class WalterlvCommand : ICommand
{
    public bool SomeFlag { get; set; }

    bool ICommand.CanExecute(object parameter)
    {
        // Return the real can execution state.
        return SomeFlag;
    }

    void ICommand.Execute(object parameter)
    {
        // The actual executing procedure.
    }
}

See this code below. After 5 seconds, the button will still be disabled even that we set the SomeFlat to true.

var walterlv = new Walterlv();
TestCommand.DataContext = walterlv;

await Task.Delay(5000);
walterlv.WalterlvCommand.SomeFlag = true;

How to update manually?

Call this method after you want to update your command states if it won’t update:

CommandManager.InvalidateRequerySuggested();

When do the commands update their states?

Commands only update when these general events happen:

  • KeyUp
  • MouseUp
  • GotKeyboardFocus
  • LostKeyboardFocus

You can see the code here:

And the key code is here:

if (e.StagingItem.Input.RoutedEvent == Keyboard.KeyUpEvent ||
    e.StagingItem.Input.RoutedEvent == Mouse.MouseUpEvent ||
    e.StagingItem.Input.RoutedEvent == Keyboard.GotKeyboardFocusEvent ||
    e.StagingItem.Input.RoutedEvent == Keyboard.LostKeyboardFocusEvent)
{
    CommandManager.InvalidateRequerySuggested();
}

Actually, not only those events above but also these methods below refresh the command states:

C#/.NET 如何获取一个异常(Exception)的关键特征,用来判断两个异常是否表示同一个异常

dotnet 职业技术学院 发布于 2019-03-26

在 .NET / C# 程序中出现异常是很常见的事情,程序出现异常后记录日志或者收集到统一的地方可以便于分析程序中各种各样此前未知的问题。但是,有些异常表示的是同一个异常,只是因为参数不同、状态不同、用户的语言环境不同就分开成多个异常的话,分析起来会有些麻烦。

本文将提供一个方法,将异常的关键信息提取出来,这样可以比较多次抛出的不同的异常实例是否表示的是同一个异常。


Exception.ToString()

以下是捕获到的一个异常实例,调用 ToString() 方法后拿到的结果:

System.NotSupportedException: BitmapMetadata  BitmapImage 上可用。
    System.Windows.Media.Imaging.BitmapImage.get_Metadata()
    System.Windows.Media.Imaging.BitmapFrame.Create(BitmapSource source)
    Walterlv.Demo.Exceptions.Foo.Take(string fileName)

在英文的系统上,拿到的结果可能是这样的:

System.NotSupportedException: BitmapMetadata is not available on BitmapImage.
   at System.Windows.Media.Imaging.BitmapImage.get_Metadata()
   at System.Windows.Media.Imaging.BitmapFrame.Create(BitmapSource source)
   at Walterlv.Demo.Exceptions.Foo.Take(string fileName)

这样,我们就不能使用 ToString() 来判断两个异常是否表示同一个异常了。

另外,在 ToString() 方法中,如果包含 PDB,那么异常堆栈中还会包含源代码文件的路径以及行号信息。

关于 ToString() 中输出的信息,可以阅读 StackTrace.ToString() 方法的源码来了解:

哪些信息是异常的关键信息

从默认的 ToString() 中我们可以得知,它包含三个部分:

  1. 异常类型的全名 Type.FullName
  2. 异常信息 Exception.Message
  3. 异常堆栈 Exception.StackTrace

考虑到 Message 部分受多语言影响非常严重,很难作为关键异常特征,所以我们在提取关键异常特征的时候,需要将这一部分去掉,只能作为此次异常的附加信息,而不能作为关键特征。

所以我们的关键特征就是:

  1. 异常类型的全名 Type.FullName
  2. 异常堆栈中所有帧的方法签名(这能保证语言无关)

比如本文一开始列举出来的异常堆栈,我们应该提取成:

System.NotSupportedException
  System.Windows.Media.Imaging.BitmapImage.get_Metadata()
  System.Windows.Media.Imaging.BitmapFrame.Create(BitmapSource source)
  Walterlv.Demo.Exceptions.Foo.Take(string fileName)

提取特征的 C# 代码

为了提取出以上的关键特征,我需要写一段 C# 代码来做这样的事情:

public (string typeName, IReadonlyList<string> frameSignature) GetDescriptor(Exception exception)
{
    var type = exception.GetType().FullName;
    var stackFrames = new StackTrace(exception).GetFrames() ?? new StackFrame[0];
    var frames = stackFrames.Select(x => x.GetMethod()).Select(m =>
        $"{m.DeclaringType?.FullName ?? "null"}.{m.Name}({string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"))})");
    return (type, frames.ToList());
}

一个是拿到 Exception 实例的类型名称,通过 exception.GetType().FullName

另一个拿到方法签名。

由于 Exception.StackTrace 属性得到的是一个字符串,而且此字符串还真的有可能根本不是异常信息呢,所以我们这里通过创建一个 StackTrace 的实例来从异常中获取真实的堆栈,当然如果拿不到我们这里使用空数组来表示。

随后,遍历异常堆栈中的所有帧,将方法名和方法的所有参数进行拼接,形成 ClassFullName.MethodName(ParameterType parameterName) 这样的形式,于是就拼接成类似 Exception.ToString() 中的格式了。

由于确定一个类型中是否是同一个方法时与返回值无关,所以我们甚至不需要将返回值加上就能唯一确定一个方法了。

一个完整的 ExceptionDescriptor

为了方便,我写了一个完整的 ExceptionDescriptor 类型来完成异常特征提取的事情。这个类同时重写了相等方法,这样可以直接使用相等方法来判断两个异常的关键信息是否表示的是同一个异常。

源码可以在这里找到:https://gist.github.com/walterlv/0ce95369aa78c5f0f38a527bef5779c2

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;

namespace Walterlv
{
    /// <summary>
    /// 包含一个 <see cref="Exception"/> 对象的关键特征,可使用此对象的实例判断两个不同的异常实例是否极有可能表示同一个异常。
    /// </summary>
    [DebuggerDisplay("{TypeName,nq}: {FrameSignature[0],nq}")]
    public class ExceptionDescriptor : IEquatable<ExceptionDescriptor>
    {
        /// <summary>
        /// 获取此异常的类型名称。
        /// </summary>
        public string TypeName { get; }

        /// <summary>
        /// 获取此异常堆栈中的所有帧的方法签名,指的是在一个类型中不会冲突的最小部分,所以不含返回值和可访问性。
        /// 比如 private void Foo(Bar b); 方法,在这里会写成 Foo(Bar b)。
        /// </summary>
        public IReadOnlyList<string> FrameSignature { get; }

        /// <summary>
        /// 从一个异常中提取出关键的异常特征,并创建 <see cref="ExceptionDescriptor"/> 的新实例。
        /// </summary>
        /// <param name="exception">要提取特征的异常。</param>
        public ExceptionDescriptor(Exception exception)
        {
            var type = exception.GetType().FullName;
            var stackFrames = new StackTrace(exception).GetFrames() ?? new StackFrame[0];
            var frames = stackFrames.Select(x => x.GetMethod()).Select(m =>
                $"{m.DeclaringType?.FullName ?? "null"}.{m.Name}({string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"))})");
            TypeName = type;
            FrameSignature = frames.ToList();
        }

        /// <summary>
        /// 根据异常的信息本身创建异常的关键特征。
        /// </summary>
        /// <param name="typeName">异常类型的完整名称。</param>
        /// <param name="frameSignature">
        /// 异常堆栈中的所有帧的方法签名,指的是在一个类型中不会冲突的最小部分,所以不含返回值和可访问性。
        /// 比如 private void Foo(Bar b); 方法,在这里会写成 Foo(Bar b)。
        /// </param>
        public ExceptionDescriptor(string typeName, IReadOnlyList<string> frameSignature)
        {
            TypeName = typeName;
            FrameSignature = frameSignature;
        }

        /// <summary>
        /// 判断此异常特征对象是否与另一个对象实例相等。
        /// 如果参数指定的对象是 <see cref="ExceptionDescriptor"/>,则判断特征是否相等。
        /// </summary>
        public override bool Equals(object obj)
        {
            if (ReferenceEquals(null, obj))
            {
                return false;
            }

            if (ReferenceEquals(this, obj))
            {
                return true;
            }

            if (obj.GetType() != this.GetType())
            {
                return false;
            }

            return Equals((ExceptionDescriptor) obj);
        }

        /// <summary>
        /// 判断此异常特征与另一个异常特征是否是表示同一个异常。
        /// </summary>
        public bool Equals(ExceptionDescriptor other)
        {
            if (ReferenceEquals(null, other))
            {
                return false;
            }

            if (ReferenceEquals(this, other))
            {
                return true;
            }

            return string.Equals(TypeName, other.TypeName) && FrameSignature.SequenceEqual(other.FrameSignature);
        }

        /// <inheritdoc />
        public override int GetHashCode()
        {
            unchecked
            {
                return ((TypeName != null ? StringComparer.InvariantCulture.GetHashCode(TypeName) : 0) * 397) ^
                       (FrameSignature != null ? FrameSignature.GetHashCode() : 0);
            }
        }

        /// <summary>
        /// 判断两个异常特征是否是表示同一个异常。
        /// </summary>
        public static bool operator ==(ExceptionDescriptor left, ExceptionDescriptor right)
        {
            return Equals(left, right);
        }

        /// <summary>
        /// 判断两个异常特征是否表示的不是同一个异常。
        /// </summary>
        public static bool operator !=(ExceptionDescriptor left, ExceptionDescriptor right)
        {
            return !Equals(left, right);
        }
    }
}

参考资料

C#/.NET 如何在第一次机会异常 FirstChanceException 中获取比较完整的异常堆栈

dotnet 职业技术学院 发布于 2019-03-24

FirstChangeException 事件中,我们通常只能拿到异常堆栈的第一帧,这对于我们捕捉到异常是好的,但对分析第一次机会异常可能并不利。

本文介绍如何在 FirstChangeException 事件中拿到比较完整的异常堆栈,而不只是第一帧。


第一次机会异常

.NET 程序代码中的任何一段代码,在刚刚抛出异常,还没有被任何处理的那一时刻,AppDomain 的实例会引发一个 FirstChanceException 事件,用于通知此时刚刚开始发生了一个异常。

这时,这个异常还没有寻找任何一个可以处理它的 catch 块,在此事件中,你几乎是第一时间拿到了这个异常的信息。

监听第一次机会异常的代码是这个样子的:

private void WalterlvDemo()
{
    AppDomain.CurrentDomain.FirstChanceException += OnFirstChanceException;
}

private void OnFirstChanceException(object sender, FirstChanceExceptionEventArgs e)
{
    // 在这里,可以通过 e.Exception 来获取到这个异常。
    Console.WriteLine(e.Exception.ToString());
}

只不过,在这里我们拿到的异常堆栈只有第一帧,因为这个时候,还没有任何 catch 块捕捉到这个异常。比如,我们只能拿到这个:

System.NotSupportedException: BitmapMetadata  BitmapImage 上可用。
    System.Windows.Media.Imaging.BitmapImage.get_Metadata()

一点知识Exception 实例的异常堆栈,是从第一次抛出异常的地方开始,到第一个 catch 它的地方结束,除非这个 catch 块中继续只用 throw; 抛出才继续向外延伸到下一个 catch

另外,你也可以用 ExceptionDispatchInfo 让内部异常的堆栈也连接起来,详见我的另一篇博客:

获取较完整的第一次机会异常堆栈

我们需要等到 FirstChanceException 事件中的异常被 catch 到,就能获取到第一次抛出的地方到 catch 处之间的所有帧。

所以,我们只需要稍作延迟,即可拿到较完整的异常堆栈:

private void WalterlvDemo()
{
    AppDomain.CurrentDomain.FirstChanceException += OnFirstChanceException;
}

private async void OnFirstChanceException(object sender, FirstChanceExceptionEventArgs e)
{
    // 刚刚进入第一次机会异常事件的时候,异常堆栈只有一行,因为此时还没有任何地方 catch。
    // 现在等待一点点时间,使得异常的堆栈能够延伸到 catch。等待多长不重要,关键是为了让异常得以找到第一个 catch。
    await Task.Delay(10);

    // 在这里,可以通过 e.Exception 来获取到这个异常。
    Console.WriteLine(e.Exception.ToString());
}

这样,我们可以得到:

System.NotSupportedException: BitmapMetadata  BitmapImage 上可用。
    System.Windows.Media.Imaging.BitmapImage.get_Metadata()
    System.Windows.Media.Imaging.BitmapFrame.Create(BitmapSource source)
    Walterlv.Demo.Exceptions.Foo.Take(string fileName)

这里,等待多长时间是不重要的,只要不是 0 就好。因为我们只需要当前调用堆栈中的异常处理执行完成即可。

关于等待时间,可以阅读我的另一篇博客:

如果需要对此异常进行后续的分析,可以参考我的另一篇博客:

制作一个极简的 .NET 客户端应用自安装或自更新程序

dotnet 职业技术学院 发布于 2019-03-21

本文主要说的是 .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,
    }
}

应用程序清单 Manifest 中各种 UAC 权限级别的含义和效果

dotnet 职业技术学院 发布于 2019-03-20

如果你的程序对 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 程序将全部闪退的重要原因。


参考资料

Windows 系统上使用任务管理器查看进程的各项属性(命令行、DPI、管理员权限等)

dotnet 职业技术学院 发布于 2019-03-19

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 感知级别的更多内容,可以阅读我的其他博客:

Windows 的 UAC 设置中的通知等级实际上只有两个档而已

dotnet 职业技术学院 发布于 2019-03-19

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 的博客


参考资料

Windows 中的 UAC 用户账户控制

dotnet 职业技术学院 发布于 2019-03-19

阅读本文,你可以初步了解 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 这个账号下的。

在上图中,你会发现当前账户下的任务管理器连管理员账户运行的程序图标都拿不到。

Windows 下使用 runas 命令以指定的权限启动一个进程(非管理员、管理员)

dotnet 职业技术学院 发布于 2019-03-19

在默认情况下,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");

关于更多降权运行的方法,可以参考我的另一篇博客:


参考资料