首页
/ Windows Terminal 默认终端机制全解析:Delegation/Handoff 设计(492)及其在开源仓库中的落地实现

Windows Terminal 默认终端机制全解析:Delegation/Handoff 设计(492)及其在开源仓库中的落地实现

2026-09-04 22:43:51作者:乔或婵

本文基于仓库中的规格文档 [doc/specs/#492 - Default Terminal/spec.md](https://gitcode.com/GitHub_Trending/term/terminal/blob/20588130d8ef2ba40eb56bdae88e04cce7fc5b5d/doc/specs/?utm_source=gitcode_repo_files#492 - Default Terminal/spec.md) 展开,系统讲解 Windows"默认终端"(delegation terminal,简称 defterm)机制的设计动机、三层组件架构、两级 COM 交接(handoff)契约,以及注册表、App Extension 目录发现、设置 UI 等关键配置路径。读完本文,你将能够理解从 conhost.exe 被系统拉起、到会话被委托给 OpenConsole.exe(updated console)、最终由 WindowsTerminal.exe(terminal UX)接管这一完整链路在源码中是如何实现的,并能定位到负责读写 HKCU\Console\%%Startup 注册表项的具体代码。

一、什么是"默认终端":问题背景

Windows 从一开始就提供了单一且唯一的默认终端宿主行为:所谓默认终端,就是当某个命令行应用在没有附带终端的情况下被启动时,操作系统会替它启动的那个终端宿主。该规格的初衷,是把这项体验的选择权完全交给用户——允许客户在第一方与第三方终端之间自由选择,替换自己的默认终端体验。

规格的"灵感"部分回顾了终端团队的演进路线:先是为老版 console host 补齐用户长期期望的特性;接着实现了 Virtual Terminal 序列的接收端,让 WSL、Docker 等跨平台命令行应用得以运行;随后又通过 ConPTY 把 console 环境的发送端暴露给第一方/第三方应用,使其可以在自己的 UI 中托管命令行客户端;最终打造了 Windows Terminal 作为该模型的旗舰实现。

然而,替代 console host 体验的入口点始终是"先启动你的替代终端,再在其内部启动命令行应用"。这与 Linux/Mac 生态(ConEmu、Cmder、Console2 等)的习惯一致,但 Windows 很早就提供了不同的能力:可以从 shell 或内核直接启动一个未指定终端的命令行应用,系统在发现缺少终端时,会即时(just-in-time)拉起唯一能保证存在的终端 conhost.exe 并附加上去。

因此本规格的核心命题非常直接:允许用户任意选择哪一个终端,作为"启动时未附带终端"的命令行应用的即时附加终端。这一步完成后,终端体验即与操作系统发布节奏解耦。

二、三个组件与四种场景

设计由三个可独立替换的组件构成:

  1. Inbox console(系统内置 console):位于每个 Windows 安装内部的 conhost.exe
  2. Updated console(更新的 console):随 Windows Terminal 一同分发的 OpenConsole.exe,提供更新的 console 服务端体验。
  3. Terminal UX(终端界面):运行在 VT 序列之上的 WindowsTerminal.exe

由此产生四种组件组合场景:

场景 说明
替换 console API server + 替换 terminal UX 即当前的 Windows Terminal 场景:OpenConsole.exe 打包在包内,作为 WindowsTerminal.exe 的 console API server 和 ConPTY 环境
替换 console API server + 传统 terminal UX 当前不显式分发,但技术上直接运行 OpenConsole.exe 即可实现
Inbox console API server + 替换 terminal UX WSL 做 Windows interop 时的做法;VS Code 使用 ConPTY 环境时同样如此(使用 node-pty 的第三方终端同理)
Inbox console API server + inbox terminal UX 即现状:conhost.exe 作为默认应用运行

设计目标是在第一方或第三方场景中,允许任意组件按需替换,实现最大化的组合自由度。

三、Inbox console:委托机制的设计

3.1 保留 conhost.exe 的理由

内置 console 会更新为支持"将新接入的 console 客户端连接委托给另一个 console API server"(前提是后者已注册且可用)。规格明确解释为何不干脆移除 conhost.exe

  1. 任何委托操作失败时的最后一道兜底(last chance fall-back);
  2. 为根本不需要窗口的应用持续提供宿主;
  3. 继续支持旧版 conhostv1.dll 环境(如果用户选择)。

3.2 委托工作流

规格描述的通用流程如下:

  • 某个命令行客户端从开始菜单、运行框或任何 CreateProcess/ShellExecute 路径启动,且没有已附加的 console server;
  • C:\windows\system32\conhost.exe 照旧由 kernelbase.dll 内部的初始化例程拉起;
  • inbox console 接受首个连接,并检查连接包中的 ShowWindow 信息(该信息由内核的进程创建例程根据 CreateProcess 参数填入,可参见 CREATE_NO_WINDOW 标志);
  • 若会话即将创建窗口,则检查是否注册了委托/更新版 console,存在则交接给它;
  • 否则按常规方式启动。

这个工作流带来的收益:需要改动的内置组件只有 conhost.exe 本身——一个已经持续从开源更新的可执行体。kernelbase.dll 的 console 初始化例程、conclnt.lib 通信协议、condrv.sys 驱动均无需改动;代码增量小,便于尽早进入操作系统产品进行自宿主验证,也增加了回移到在装 Windows 10 版本的可能性(规格的例证是 WSL2 回移到 1903/1909)。

规格还记录了一个"未来可选项":若不存在更新版 console,检查是否有愿意使用内置 ConPTY 的 terminal UX 注册,启动它并转型为 PTY——该项最终被从 v1 中裁剪,理由是为了向终端用户简化叙事,第一版以打包交付的方式提供,因为向用户解释"console"与"terminal"的区别极其困难。

3.3 注册方式:HKCU\Console\%%Startup

注册的最终形态(V1 NOTE 确认)是:HKCU\Console\%%Startup 子键下的两个 REG_SZ 值:

  • DelegationConsole:指定用于承接连接处理过程的替换 console;
  • DelegationTerminal:指定用于承接会话的 terminal UX。

两个值存放的是 COM 服务器 CLSID(以 GUID 字符串形式写入),而非最初设想的二进制路径。%%Startup 子键的作用是把这些键隔离出来,便于将来对其施加 ACL 或其他保护——这些选择本质上是 per-user 的。至于为何叫 %%:这些子键传统上用于解析客户端二进制自身的 console 偏好路径,而 %% 永远不会解析成有效路径或可展开的路径变量,因此是安全的"保留名"。

这一点在源码中可以得到精确印证。src/propslib/DelegationConfig.cpp 定义了键名与 App Extension 名称:

#define DELEGATION_CONSOLE_KEY_NAME L"DelegationConsole"
#define DELEGATION_TERMINAL_KEY_NAME L"DelegationTerminal"

#define DELEGATION_CONSOLE_EXTENSION_NAME L"com.microsoft.windows.console.host"
#define DELEGATION_TERMINAL_EXTENSION_NAME L"com.microsoft.windows.terminal.host"

写路径 DelegationConfig::s_Set 打开 HKCU\Console 后用 REG_SZ 写入 %%Startup 子键,值由 StringFromCLSID 生成;读路径 s_GetDelegationPairIIDFromString 解析 GUID,并做了规整化处理:任一值为空 GUID(CLSID_Default)时归为 Default("让 Windows 决定"),任一值为 CLSID_Conhost 时归为 Conhost,否则构成 Custom 对。这正对应规格中"per-user 选择 + 保留保护空间"的设计意图。

规格对 %%Startup 的注册还附带一条 V1 NOTE:系统没有为它增加额外的迁移逻辑,因为 HKCU\Console* 及其子键本就在系统迁移逻辑覆盖范围内,新增子键会自然随迁移携带。

3.4 两级交接契约(一):ConsoleEstablishHandoff

inbox console 与更新版 console 之间建立方法契约。规格给出的原型是:

HRESULT ConsoleEstablishHandoff(HANDLE server, HANDLE driverInputEvent,
                                const PortableConnectMessage* const msg,
                                HANDLE signalPipe, HANDLE inboxProcess, HANDLE* process);

各参数含义(完整继承规格说明):

  • HANDLE server:console 驱动的服务器侧句柄,配合 DeviceIoControl 与客户端命令行应用收发消息;
  • HANDLE driverInputEvent:输入事件。它在首条消息读取之前就被创建并赋给驱动,用于在"首条消息是一个我们尚无数据填充的输入请求"时跟踪阻塞状态。inbox console 在取出连接包、决定委托之前就已创建并注册了它,因此要把所有权转移给更新版 console;
  • const PortableConnectMessage* const msgCONSOLE_API_MSG 结构同时包含来自驱动的真实数据包和与包状态、排序、完成、错误、缓冲区相关的管理数据,是每类消息的宽作用域结构,会随 conserver.lib 的包处理演进而变化。此参数只是一个版本无关的可移植变体,只针对 connect 消息传递初始连接信息结构、包排序信息等该消息类型专属的有效载荷;它刻意丢弃了对特定 API 服务例程集的引用——因为交接的目的恰恰就是"必要时获得更新的例程"。V1 NOTE 中该结构定名为 CONSOLE_PORTABLE_ATTACH_MSG
  • const PortableArguments* const args(v1 裁剪):最初设想传递启动参数(含原始命令行和 in/out 句柄),因为 ConsoleArguments 结构可能随版本变化。裁剪理由是"默认点亮"(default light-up)场景下传入的只有 server 句柄,其余参数都与 PTY 操作相关,而本特性针对的就是"未指定任何参数"的默认应用启动;
  • HANDLE signalPipe:编写期间发现 Ctrl+C 等信号必须能传回原始的 conhost.exe,因为操作系统赋予了它对原始附加客户端应用的特殊特权,该特权无法转移给被委托的 console。这条通道保留给被委托者,让它把信号经原始 conhost 发回以指挥底层客户端(这也意味着原始 conhost.exe 不能关闭,必须在整个会话期间留在进程树中维持该控制权);
  • HANDLE inboxProcess:由于必须让 inbox conhost.exe 继续存活,就需要跟踪它的生命周期。它若以任何方式消失,整条链必须拆除,因为我们的操作已被破坏;
  • HANDLE* process:与 inboxProcess 相反,这是把自身进程句柄交还给 conhost 以便其跟踪。inbox console 完成委托后只保留非常有限的能力,被委托者消失时,会话将无法继续,需要拆除(并关闭客户端);
  • 返回 HRESULT:属于初始化路径中的"老式方法",此前已从 NTSTATUS 为主迁移到 HRESULT 为主以利用 wil,此处延续该模式而不改用异常。S_OK 表示交接成功,inbox console 可以清理自身并停止处理该会话。

触发时机:当连接包被解析出可见性信息时(规格指向 srvinit.cpp),尝试解析已注册的交接并调用它。规格还记录了方案演进的完整轨迹:最初设想是 LoadLibrary/GetProcAddress 直接加载更新版 console 的导出契约(保持同一进程空间,避免句柄跨进程以及照顾那些"钻进程树"找宿主的应用),最终 V1 落地的方案是 out-of-proc COM server/client——保持新旧代码的隔离性,且由于原始 conhost.exe 为信号目的仍然存活,不再担心进程树探查问题。WinRT 则被明确排除:conhost.exe 没有 WinRT,为其增加会显著加大编译复杂度与链接体积,而收益完全被经典 COM 覆盖。

委托完成后,inbox console 需清理会话相关的线程、句柄和状态;规格 V1 NOTE 确认:内置侧"清理掉一切能清理的,然后进入等待子/被委托进程句柄退出的状态",同时维持一个监听信号到来的线程,以便在用驱动赋予的特权向客户端发命令时随时可用。

源码印证:COM 契约与两级 Handoff 的实现

规格中的契约在当前仓库中有完整的对应实现。COM 接口定义在 src/host/proxy/IConsoleHandoff.idl

typedef struct _CONSOLE_PORTABLE_ATTACH_MSG
{
    DWORD IdLowPart;
    LONG IdHighPart;
    ULONG64 Process;
    ULONG64 Object;
    ULONG Function;
    ULONG InputSize;
    ULONG OutputSize;
} CONSOLE_PORTABLE_ATTACH_MSG;

[
    object,
    uuid(E686C757-9A35-4A1C-B3CE-0BCC8B5C69F4)
] interface IConsoleHandoff : IUnknown
{
    HRESULT EstablishHandoff([in, system_handle(sh_file)] HANDLE server,
                             [in, system_handle(sh_event)] HANDLE inputEvent,
                             [in, ref] PCCONSOLE_PORTABLE_ATTACH_MSG msg,
                             [in, system_handle(sh_pipe)] HANDLE signalPipe,
                             [in, system_handle(sh_process)] HANDLE inboxProcess,
                             [out, system_handle(sh_process)] HANDLE* process);
};

[
    object,
    uuid(746E6BC0-AB05-4E38-AB14-71E86763141F)
] interface IDefaultTerminalMarker : IUnknown
{
};

注意每个句柄参数上的 system_handle 属性——这正是规格"Potential Issues"一节讨论的问题(COM 原生不公开跨进程传句柄的手段)在 V1 的解法:微软为此申请获批并公开文档化了该属性,而代码零改动,因为公开 IDL 编译器本就认识它。末尾的 IDefaultTerminalMarker 空接口是规格文本之后新增的能力,后文第四节详述。

OpenConsole 一侧的实现是 src/host/exe/CConsoleHandoff.cpp 中的 CConsoleHandoff::EstablishHandoff:它把 CONSOLE_PORTABLE_ATTACH_MSG 中"恰好够开始服务"的描述符字段填入一个全新的 CONSOLE_API_MSG(标题、窗口状态等会在新会话真正服务时重新从驱动取回),用 DuplicateHandle 复制四个入站句柄(COM 契约规定入站 HANDLE 归调用者所有、方法退出即释放,复制后才能存活于本进程生命周期),然后调用核心例程 ConsoleEstablishHandoff,最后交还一个带 SYNCHRONIZE 权限的本进程句柄供对方跟踪。

发送侧(即 inbox conhost 的委托动作)在 src/server/IoDispatchers.cppCoCreateInstance(Globals.delegationPair.console, ..., CLSCTX_LOCAL_SERVER, ...) 实例化委托目标,打包 CONSOLE_PORTABLE_ATTACH_MSGCreatePipe 出信号管道,DuplicateHandle 出自身进程句柄,然后执行"关键时刻"——handoff->EstablishHandoff(...)。成功后它会关闭已移交句柄、启动 HostSignalInputThread 监听对端信号并中继给系统,UnlockConsole()WaitForSingleObject(clientProcess, INFINITE) 等待被委托者退出(源码注释说明这是为部分需要 PID 连续性的客户端而保留),最后 ExitProcess 清理。任一步骤抛出则记录 ConsoleHandoffFailed 并回退到正常启动——正是规格承诺的"渐进降级回 conhost 风格 Win32+GDI 体验"。

接收侧 ConsoleEstablishHandoff 位于 src/host/srvinit.cpp:它被特性开关 TIL_FEATURE_RECEIVEINCOMINGHANDOFF_ENABLED 保护,未启用时直接返回 ERROR_NOT_SUPPORTED(这对应了"委托失败则降级"的可靠性设计)。

四、Updated console:从 API server 到 ConPTY 模式

更新版替换 console 拥有与 inbox console 相同的 console API server 能力,但通常是更新、或按场景定制的版本,核心是改进对终端应用的 ConPTY 支持。

收到上节的 handoff 后,更新版 console 需要:

  • 建立自己的一套 IO 线程、设备通信基础设施和 API 消息例程,同时保存收到的句柄;
  • 重新解析命令行参数(v1 裁剪,与"默认点亮无参数"的裁剪一致);
  • 像正常收到一样分发 attach 消息,然后继续执行。

之后要么有一个 terminal UX 注册项通过 ConPTY 接管会话,要么更新版 console 自行启动其更新版的 conhost UX(v1 中此路已通终端注册项)。

4.1 两级交接契约(二):EstablishPtyHandoff

terminal 注册复用同一套机制,使用另一个键:HKCU\Console\%%Startup 下的 REG_SZDelegationTerminal。契约原型:

HRESULT EstablishPtyHandoff(HANDLE in, HANDLE out, HANDLE signal,
                            HANDLE ref, HANDLE server, HANDLE client);
  • HANDLE in:从 ConPTY 读取客户端应用输出、供终端显示的句柄;
  • HANDLE out:把用户在终端的输入写入 ConPTY 的句柄;
  • HANDLE signal:ConPTY 的带外通信信号句柄,在 PTY server 与终端应用之间传递;
  • COORD size(V1 NOTE 裁剪):初始窗口大小曾作为连接结构中的偏好传入。事实证明没有必要,"resize 操作自然理顺了";
  • HANDLE ref:指向 console 驱动与会话的"客户端引用句柄"。终端持有一份拷贝,使会话在松手前保持存活(链上其他 console 宿主以及客户端自身也应各持一份);
  • HANDLE server:PTY 的进程句柄,终端侧据此监控 PTY 是否还活着;
  • HANDLE client:底层客户端应用的进程句柄,终端用它跟踪退出处理。

规格同样标注了该契约最初考虑的是进程内导出函数,V1 采用了 COM 备选方案,与上文保持一致。契约调用成功则 UX 责任移交给终端,console 进入 PTY 模式;失败则 console 启动交互式(interactive)回退。

源码印证:srvinit.cpp 的接收链路

ConsoleEstablishHandoff 的实现把上述契约逐条落实:

  1. 重新读取 s_GetDelegationPair();若不是 Custom(例如是 conhost 因 DxD 默认化把会话交给 OpenConsole 的场景),则把委托对强制设为 TerminalDelegationPair
  2. CreateThreadpoolWait 监视 inbox 进程句柄——inbox 进程消失时调用 RundownAndExit(E_APPLICATION_MANAGER_NOT_RUNNING),即规格所述"链上任一环节消失则整体拆除";
  3. hostSignalPipe 构造 RemoteConsoleControl 实例,成为中继信号的通道(对应 signalPipe 参数);
  4. 再建一条本地信号管道(CreatePipe),一端传给终端、一端留给 PTY 侧;
  5. OpenProcess 打开客户端进程句柄(client 参数),DeviceHandle::CreateClientHandle(..., L"\\Reference", FALSE) 创建会话引用句柄(ref 参数);
  6. CoCreateInstance(g.delegationPair.terminal, ..., CLSCTX_LOCAL_SERVER, ...) 实例化终端侧的 ITerminalHandoff3 对象;
  7. 在正式交接前提前完成通常稍后才会做的连接信息解析:构造 ConDrvDeviceCommConsoleInitializeConnectInfo,并复用 SetUpConsole 的逻辑从标题/链接/STARTUPINFO 中解析设置,把标题、图标路径/索引、wShowWindow 打包进 TERMINAL_STARTUP_INFO 一并交给终端——这比规格原型更进一步,规格只描述了 in/out/signal/ref/server/client 六个句柄,实际实现把启动窗口信息也带了过去;
  8. 调用 handoff->EstablishPtyHandoff(...) 交出六句柄与启动信息;
  9. fmt::format(L" --headless --signal {:#x}") 构造命令行(--headless 使终端以无 UI 信号模式参与),构造 ConsoleArguments 并走 ConsoleCreateIoThread 进入 PTY 服务循环。

值得补充的是规格文本之后的源码演进:从 src/host/srvinit.cppsrc/server/IoDispatchers.cpp 的结构看,当前实现新增了一个"默认化但需确认"的机制——当注册表中未设置自定义委托(IsDefault)且 DxD(DirectX 渲染路径)velocity 开启时,conhost 会把委托对切换为 TerminalDelegationPair 并置位 defaultTerminalMarkerCheckRequired;随后发送侧在实例化委托对象后会尝试 QueryInterface 到空接口 IDefaultTerminalMarker(见 IConsoleHandoff.idl),若目标终端未实现该 marker,则回退为 ConhostDelegationPair 并不再委托。可以推断,这是让用户在"让 Windows 决定"状态下把 Windows Terminal 设为默认、同时允许终端包通过实现/不实现 marker 接口来表态的一套自协商机制,属于规格设计精神("默认 + 渐进降级")在当前源码中的自然延伸。

五、Terminal UX:接受已存在的 ConPTY 连接

终端本身是运行在 ConPTY 连接之上的完整呈现与输入方案,把 API 服务与用户体验的职责分离开。

规格的"今日基线"是:终端知道如何启动并自行创建其下的 ConPTY。规格要求终端更新为接受启动时已存在的 ConPTY 连接(多进程模型到来后则作为入站连接),并将其接到一个新 tab/pane,而不是用 winconpty.lib 自建。对"全新启动"场景,具体设想:

  • 终端需通过参数解析(划掉)新入口点(COM 备选方案)检测入站连接,并把该连接的 PTY in/out/signal 句柄存进启动参数信息;
  • 新 tab 控件实例化时,原本会启动"默认 profile"的初始创建,改为把已接收的 PTY in/out/signal 句柄放进 ConPtyConnection 对象,如同它已被创建过一样;
  • 之后一切照常运行,连接即会接入并在该会话内被托管。

规格还完整列出了两个未决问题及其选项:

问题一:加载哪个 profile/设置? 对入站客户端我们一无所知,很难判断该应用哪些用户偏好。候选方案:

  • 仅用默认值,不应用任何 profile 特定设置;
  • 部分使用默认 profile 的信息(可能因图标/配色等造成奇怪的失配);
  • 创建一个专门用于入站连接的"inbound profile";
  • 添加启发式:尝试把连接客户端二进制的名称/路径与已有 profile 匹配,命中则用其设置,否则回退。

规格结论(Proposal):先立即采用第一项(仅默认值)完成引导,其余方案留作后续修订探索。

问题二:入站句柄是"裸句柄"而非 winconpty.lib 提供的透明 HPCON 两条对策:

  • winconpty.lib 添加方法,把入站裸句柄打包进 HPCON 结构,使后续生命周期处理一致;
  • 或者把 COM server 入口点(或参数入口点)直接放进该库,让它当场打包并交出一个现成的 HPCON

六、UI/UX 设计与设置体验

规格给出的整体用户体验目标有四步:

  1. 用户通过开始菜单、Win+X、资源管理器、运行框(Win+R)或其他 Windows 应用启动一个命令行客户端;
  2. console 系统按既有设置透明地启动、把自身委托给更新版 console、切换为 ConPTY 模式,随后一个 Windows Terminal 副本启动,首 tab 打开并托管该命令行应用。规格特别注明:不排除第三方对"更新版 console"或"terminal"任一(或两者)的注册,示例只是为简洁而取"黄金路径";
  3. 用户可以像与原始 console host 一样与应用交互,并额外获得一项好处:短命运行不再"闪现即消失"——如今从运行框跑 ipconfig 这类命令会一闪而过,而 Terminal 的默认状态倾向于保留 tab 并提示客户端已退出,运行框 ipconfig 的用户将得到优于 console host 默认"快速消失"的体验;
  4. 若委托的任何部分失败,则渐进降级回 conhost 风格的 Win32+GDI 体验,用户看不到任何差异。

6.1 配置委托操作的位置(四个入口)

规格列举了四个配置入口及演进状态:

  • 直接操作注册表:最先可用、且持续可用,后续再推进下面几种。V1 NOTE:未额外增加迁移逻辑,因 HKCU\Console* 子键本就随迁移携带;
  • Windows Terminal 内部:新设置 UI 中需要一页用于配置 HKCU\Console\%%Startup 下的委托键(或链接到 Windows 设置面板,划掉);
  • console 属性表(property sheet)内部:与 Terminal 相同,只是用 comctl 控件替代 XAML(± 一个指向 Windows 设置面板的链接);
  • Windows 设置面板内部(大概率在开发者设置页):最终归宿,但因 Windows 产品时间线最难实现,可能不在初版交付。V1 NOTE:做到了

6.2 配置委托操作的方式(两档演进)

  • 指定路径/server ID:初版方案;
  • 列出已注册 server 或从应用目录发现的清单:理想形态——搜索已安装应用目录 ± COM 目录,把符合契约的应用以可滚动列表呈现。

规格的"最终落地过程"(V1 NOTE 确认):在 Terminal 的 APPX 包内使用 App Extensions 声明可用于 DelegationConsoleDelegationTerminal 字段的 COM GUID;向 propslib.lib 增加配置类 DelegationConfig,负责从应用状态目录查找并列出这些扩展供选择,同时管理注册表键的读写。V1 的另一条 NOTE:配置选项允许替换 console 与 terminal 的配对在 UI 上联动调整(lock-step)——不是说代码禁止其他组合,只是初版为减少用户困惑只做了最小组合。

源码印证:目录发现与设置模型

DelegationConfig::s_GetAvailablePackages 完整实现了上述目录发现:依次把 DefaultDelegationPair("让 Windows 决定")与 ConhostDelegationPair 作为兜底选项推入列表,然后通过 _lookupCatalog 分别用扩展名 com.microsoft.windows.console.hostcom.microsoft.windows.terminal.host 打开 AppExtensionCatalogFindAllAsync,从每个扩展的包元数据(名称、发布者、logo、FamilyName、版本)和扩展自定义属性 XML 中解析出 Clsid(经 IIDFromString 转为 IID),再把同包的 console/terminal 组合成 Custom 配对(源码坦承该嵌套循环"性能不好",但未找到可一次查询同包全部扩展的 AppModel 接口)。随后用 s_GetDelegationPair() 读当前注册表状态,在列表中定位"当前项";找不到时默认回退到第一项(即"让 Windows 决定")。

DelegationConfig 还内置了几组常量 CLSID,可作为排查参考:CLSID_Conhost0xb23d10c0-e52e-411e-9d5b-c09fdf709c7d)、CLSID_WindowsTerminalConsole / CLSID_WindowsTerminalTerminal(零售版配对)以及对应的 Dev 通道 CLSID。写侧入口 s_SetDefaultByPackage 就是把配对中的两个 CLSID 分别写回 DelegationConsole/DelegationTerminal 两个键。

设置 UI 侧的 WinRT 模型见 src/cascadia/TerminalSettingsModel/DefaultTerminal.hDefaultTerminal.cppDefaultTerminal::Available()s_GetAvailablePackages 的结果包装成 Name()/Author()/Version()/Icon() 可显示的项列表并标记当前项;Current()s_SetDefaultByPackage 写注册表,失败仅记录日志(源码注释:注册表键写保护之类不值得让 UI 崩溃),并写出一条 DefaultTerminalChanged 遥测事件。console 属性表一侧则由 src/propsheet/TerminalPropsheetPage.cppcomctl 控件复用同一套 DelegationConfig 完成相同的读/写。

6.3 传统 console 状态的配置

规格原设想(后被 V1 NOTE 裁剪为低优先级):既然默认启动可能直接进 Windows Terminal,Terminal 设置 UI 里应提供开关来翻转某些 HKCU\Console 值(如设置/移除 legacy console 状态)。V1 NOTE 的结论:不提供该设置;想切回 console 就切回 console 后用属性表或注册表键自行配置。规格同时保留了每启动一次的调试与高级访问后门:直接调用类似 conhost.exe cmd.exe 的形式,即使已设默认也会强制用 inbox conhost 启动 cmd.exe

6.4 规格记录的两项担忧

  • Windows 的状态分离策略:作者认为 HKCU\Console 已属于 OS 交换(OS Swap)时应保持可变并向前携带的"用户状态",尤其是 OS swap 体验一直在改善;
  • 安装程序/提权脚本覆写 Delegation 键的能力:这是默认应用注册在操作系统里的老问题。备选方案探讨过协议处理器、或把配置存放到仅 SYSTEM 可 ACL 的注册表保护区域。V1 NOTE:已通过子键为将来的 ACL 机制预留了位置,但当前未做任何强制。

七、能力评估:可访问性、安全、可靠性、兼容性与性能

7.1 可访问性

可访问性应用最可能依赖"钻进程树或窗口句柄"来定位待朗读内容;若它们硬编码了 console 类应用的规则,对另一个终端环境的替换可能感到意外。规格逐一点名 NVDA、JAWS 与 Narrator,指出就作者所知这三者尽可能通过 UI Automation 驱动交互,且团队过去已与这三家合作改进过对 conhost.exe 与 Windows Terminal 的支持——对再次协作以理解新的 UI 委托抱有较高信心。

7.2 安全

规格直面"房间里的大象":你打算拉一个完全陌生的二进制进 conhost.exe 进程然后……把一切委托给它?是的。V1 NOTE:现在是 out-of-proc 了,但受 COM 机制所限,其权限级别与原始进程相同。)

论证核心:被拉起来托管命令行客户端的 conhost.exe 与那个"部分启动、正等待其 server 就绪"的客户端二进制运行在同一完整性级别——这是 Windows 长期以来的既有保护。同一完整性级别内的一切本就预期可以互相篡改;被委托加载的二进制同样处于该级别;恶意行为者本来就能用任何其他手段在同一完整性级别内启动该二进制,作为客户端启动的一部分。

规格提出的可选缓解是 WinVerifyTrust:校验 OpenConsole.exe 的证书链,确保只有微软签名的才能充当替代 server 宿主。它不阻止第三方随产品再分发(例如从开源仓库拿走的)OpenConsole.exe,但能阻止任意一个恰好符合委托方法签名接口的"随机二进制"被引入。作者坦承其价值有限:只能防止有人被"骗到"用配置方法委托 conhost,挡不住直接夺取/替换 System32 中的 conhost.exe——而后者本就因 SYSTEM 属主和替换所需权限受到一定保护,所以这一缓解"可能是无关紧要的"。

7.3 可靠性

单看这个改动本身就可能提升宿主系统可靠性:现有的即时启动 console host 在放弃并返回"命令行应用无法启动"之前只有一次初始化用户体验的机会;而现在启动过程有了多个阶段,可以在放弃前对多个版本/应用做多次尝试。

其中一层:内置 conhost.exe 寻找替换其 server 活动的 OpenConsole.exe。委托二进制理论上因"启动中再加载一个进程"可能带来版本/解析/路径/依赖问题而损失一点可靠性,但同时换来在 Windows 发布周期之外快速修复的能力——修复以"天"为单位到达,而非"月到年"。

另一层:conhost.exe 或被委托的 OpenConsole.exe server 寻找终端 UX 宿主(WindowsTerminal.exe 或另一个注册的第一/第三方宿主),把会话托管职责与其分担。同样存在额外进程启动带来的理论可靠性损失,但换来显著收益:缩小每个二进制必须完成的事的范围。把用户交互从 conhost.exe/OpenConsole.exe 中移除并委托出去,意味着更少的运行面、UX 交互干扰 API 服务(或反之)的机会更小,且这些外部组件可以按"天"的时间线修复而非"月或年"。

7.4 兼容性

规格指出的一个具体破坏场景:某些应用在命令行应用启动时会钻进程树,通过 HWND 识别宿主终端窗口来注入输入、抽取输出或挂钩/绑定宿主服务。由于默认启动的 UI 可能不再是名为 conhost.exe 的进程(进程名探查会失败),且找到的 HWND 可能是 ConPTY 的假 HWND 或完全属于另一个 UI 的 HWND,这些应用可能失效。

规格给出的两条对策:

  1. 至少要提供对"默认应用委托到另一终端"的整体退出机制(opt-out);
  2. 可能还要提供按进程名、策略、清单或其他方式的按客户端应用的退出机制。

V1 NOTE 的实际交付:没有按客户端的粒度,开关是 per-user 的,可以在三个不同位置调整(对应上文三个配置入口)。

7.5 性能、功耗与效率

规格坦率承认:仅凭场景性质,替换默认应用就会付出一定性能/功耗/效率代价——要加载多个进程、启动期间执行测试与分支、很可能还要加载此前未加载的 COM/WinRT 与打包数据来解析默认应用加载的最终状态,预计会撞上 Windows 产品内部性能与功耗门槛上的若干失败项。此外,几乎所有东西都走 ConPTY 时,效率低于直接渲染到 conhost.exe 内嵌的 GDI UI,因为该场景存在多层转换与解析。

规格给出的缓解措施:

  1. 延迟加载接口加载与打包数据查找库,只在确定应用是交互式后才拉入进程空间。这能省下那些通常在系统启动早期运行(并把 conhost.exe 当宿主环境)的非交互脚本/应用的 commit 与功耗成本。不过额外的导出库链接与额外代码仍会带来设计如此(by-design)的磁盘 commit 成本;
  2. Profile Guided Optimization(PGO):计划对 OpenConsole.exeWindowsTerminal.exe 二进制启动 PGO,为这一场景优化启动路径,并把再分发的 OpenConsole.exe 二进制的优化重心偏移到 ConPTY 角色,忽略通常不用的交互 Win32/GDI 部分。若最终把 Windows Terminal 做成 in-box 默认应用,可能还需要在 Windows 内部添加 PGO 场景来专门调优 conhost.exe——现有 PGO 跑在若干 conhost.exe 交互场景上,对这些场景统统不相关;新的 PGO 场景应聚焦"默认应用委托例程"以及被托管应用的非交互场景(委托不发生但同样不涉及 Win32/GDI)。

八、Potential Issues:COM 传句柄

COM 的既有契约没有内置在进程间直接传句柄的公开手段。规格指出 Windows 内部一直在做这件事("我们知道可行,因为它无时无刻不在发生"),只是没有公开。规格判断:向前推进的任务就是把这项能力公开——"如果它内部好用到足以支撑这种复杂功能……那它应该好用到足以面向公众"。

V1 NOTE 的结局:获批公开并文档化了(即 IDL 的 system_handle 属性,参见前文 IConsoleHandoff.idl 中的 system_handle(sh_file/sh_event/sh_pipe/sh_process) 用法);且不需要任何代码改动,因为公开 IDL 编译器本就认识这个属性的存在并做了正确处理,只是此前没有文档化。

九、Future considerations:OpenConsole.exe 的独立包化

规格还留了一扇门:希望将来能分发自带独立 app 包的 OpenConsole.exe,作为其他应用可依赖的 dependency。

背景:这是 2019 年春天 console 与 Terminal 开源时的原始管理层要求之一——为了持续的维护性与可维护性,要求达到"依赖可以独立于产品整体静态单元被单独修复"的状态。最初未达成该目标,但这个设计使之成为可能。

规格同时记录了该场景的一个负面:通过 APPX 做依赖解析与依赖包安装在多个方面存在缺陷——在没有 Store 或互联网的环境里很难/不可能完成;这个问题足够频繁,以至于 Windows Terminal 包选择内嵌 VC runtime 而非依赖应用平台的依赖解析。

十、小结:设计精髓与源码索引

规格 #492 的核心思想可以浓缩为三句话:保留 inbox console 作为兜底与信号特权持有者;两级委托(console→console 用 ConsoleEstablishHandoff,console→terminal 用 EstablishPtyHandoff)以 out-of-proc COM 完成会话责任转移,句柄经 system_handle 跨进程传递;渐进降级保证任何一环失败都回退到与今天完全一致的 conhost 体验。配置面收敛于 HKCU\Console\%%Startup 的两个 REG_SZ 键(存放 COM CLSID),通过 App Extension 目录发现实现"注册表 → 设置 UI 下拉列表"的体验升级。

排查或深入阅读时,以下文件是与本规格一一对应的入口点:

关注点 位置
规格原文 [doc/specs/#492 - Default Terminal/spec.md](https://gitcode.com/GitHub_Trending/term/terminal/blob/20588130d8ef2ba40eb56bdae88e04cce7fc5b5d/doc/specs/?utm_source=gitcode_repo_files#492 - Default Terminal/spec.md)
注册表键名 / App Extension 名称 / CLSID 读写 src/propslib/DelegationConfig.cppsrc/propslib/DelegationConfig.hpp
COM 接口与 CONSOLE_PORTABLE_ATTACH_MSG 定义 src/host/proxy/IConsoleHandoff.idl
inbox 侧委托发送(CoCreateInstance + EstablishHandoff + 信号中继线程) src/server/IoDispatchers.cpp
更新版 console 侧接收(PTY 管道建立、EstablishPtyHandoff、--headless --signal src/host/srvinit.cpp
COM 实现体(句柄复制、描述符回填) src/host/exe/CConsoleHandoff.cpp
设置 UI 的 WinRT 模型(DefaultTerminal) src/cascadia/TerminalSettingsModel/DefaultTerminal.hsrc/cascadia/TerminalSettingsModel/DefaultTerminal.cpp
console 属性表入口 src/propsheet/TerminalPropsheetPage.cpp

适用前提与限制提醒:本文描述的是当前仓库快照中的实现,其中委托接收路径受特性开关 TIL_FEATURE_RECEIVEINCOMINGHANDOFF_ENABLED 控制;注册表值当前存放的是 COM CLSID 字符串而非可执行文件路径(早期设想的路径方案已被划掉);"按客户端应用退出委托"的粒度在 V1 中未交付,仅存在 per-user 全局开关。

登录后查看全文
热门项目推荐
相关项目推荐