Windows Terminal 应用状态管理:state.json 与 ApplicationState 的设计与实现
本文基于 Windows Terminal 仓库中的设计规范 [doc/specs/#8324 - Application State (TSM).md](https://gitcode.com/GitHub_Trending/term/terminal/blob/20588130d8ef2ba40eb56bdae88e04cce7fc5b5d/doc/specs/?utm_source=gitcode_repo_files#8324 - Application State (TSM).md) 展开,讲解 Windows Terminal 如何为"跨会话应用状态"("不再提示"对话框、动态 Profile 去重、窗口布局恢复等)提供一套独立于用户配置文件 settings.json 的持久化机制 state.json,并结合 src/cascadia/TerminalSettingsModel 下的实际源码,说明该规范如何演化为当前 ApplicationState 类的完整 API、双文件存储模型与延迟写盘策略。读完本文,你将掌握:该机制解决什么问题、为什么不在 settings.json 中存这些状态、state.json 放在哪里、包含哪些字段、读写与容错流程是怎样的,以及窗口布局恢复、命令面板历史、动态 Profile 等特性是如何构建在这套状态模型之上的。
一、Application State 要解决什么问题
规范文档开篇列出了三类需要跨会话保存、但不适合放进用户配置文件的状态:
- 对话框"不再提示"状态:用户在对话框上勾选
[ ] Do not ask again后(对应 issue #6641),应用需要记住这一选择,避免下次启动时反复询问; - 动态 Profile 去重记录:记录哪些动态 Profile(如 SSH 主机、
match规则生成的 Profile)已经生成过,以解决用户对 Profile "删了又冒出来" 的不满(对应 issue #3231); - 窗口状态恢复:窗口在屏幕上的位置、激活的会话状态、布局等,为将来的窗口恢复功能做准备(对应 issue #961)。
规范作者的观点很明确:上述设置不适合存进用户的 settings.json,理由有三:
- 这些状态不需要立即传播到其他 Windows Terminal 实例;
- 它们并不面向用户手工编辑;
- 把它们存到
settings.json之外,可以避免程序去修补用户配置文件(patch user's settings file)所固有的风险。
因此规范的解法是:在 settings.json 旁边单独存放一个应用状态档案 state.json,并通过 Microsoft.Terminal.Settings 命名空间下的一组 API 来访问。
二、规范中的 API 设计与"无显式 Save"原则
规范给出的初始 API 草图(WinRT IDL)如下:
namespace Microsoft.Terminal.Settings {
[default_interface]
runtimeclass ApplicationState {
// GetForCurrentApplication will return an object deserialized from state.json.
static ApplicationState GetForCurrentApplication();
void Clear();
IVector<guid> GeneratedProfiles;
Boolean ShowCloseOnExitWarning;
// ... further settings ...
}
}
其中规范还特别强调了两个设计点:
- 将 JSON 反/序列化集中在一处:把这些状态暴露到统一命名空间下的唯一动机,就是让 JSON 的读写只发生在
ApplicationState这一个地方; - 没有显式的
Save或Commit机制:对应用状态的修改会在"稍短的一段时间后"被持久化(committed durably a short duration after they're made)。
UI/UX 层面,规范认为该机制不直接影响界面,但可以考虑在设置页加一个"重置所有对话框"按钮("reset all dialogs")。同时规范明确:state.json 不预期被手工编辑,因此无需为了人类可读性做缩进序列化。
"可靠性"与"潜在问题"部分还给出两条重要原则:复用现有的 JSON 解析器(不引入新的安全攻击面);一旦状态文件损坏,应抛弃整个状态载荷而不是尝试抢救——宁可丢失状态,也要"做正确的事"。这两点在源码中都有直接对应,下文逐一展开。
三、实际实现:ApplicationState 与双文件模型
3.1 从规范到落地:API 的演化
规范草稿中的 GetForCurrentApplication/Clear/ShowCloseOnExitWarning 在落地时演化成了 ApplicationState.idl 中定义的 API(命名空间也扩展为 Microsoft.Terminal.Settings.Model):
[default_interface] runtimeclass ApplicationState {
static ApplicationState SharedInstance();
void Flush();
void Reset();
void AppendPersistedWindowLayout(WindowLayout layout);
Boolean DismissBadge(String badgeId);
Boolean BadgeDismissed(String badgeId);
void SaveWorkspace(String name, WindowLayout layout);
Boolean RemoveWorkspace(String name);
Boolean RenameWorkspace(String oldName, String newName);
WindowLayout TakeWorkspace(String name);
Windows.Foundation.Collections.IMapView<String, WindowLayout> AllPersistedWorkspaces();
String SettingsHash;
Windows.Foundation.Collections.IVector<WindowLayout> PersistedWindowLayouts;
Windows.Foundation.Collections.IVector<String> RecentCommands;
Windows.Foundation.Collections.IVector<InfoBarMessage> DismissedMessages;
Windows.Foundation.Collections.IVector<String> AllowedCommandlines;
}
可以看到规范的三大意图全部保留:SharedInstance() 对应 GetForCurrentApplication()(返回反序列化自 state.json 的单例);Reset() 对应 Clear();而 GeneratedProfiles、"不再提示"对话框等状态则演化为 DismissedMessages(配合 InfoBarMessage 枚举,见 ApplicationState.idl)及若干字段。Flush() 则提供了规范中"无显式 Save"原则之外的一个强制落盘手段。
3.2 状态文件放在哪里
ApplicationState.cpp 定义了两个文件名:
static constexpr std::wstring_view stateFileName{ L"state.json" };
static constexpr std::wstring_view elevatedStateFileName{ L"elevated-state.json" };
目录由 FileUtils.cpp 中的 GetBaseSettingsPath() 决定,与 settings.json 同目录:
- 打包安装的包(Microsoft Store 版):
%LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\; - 非打包版本(GitHub 直装版):
%LOCALAPPDATA%\Microsoft\Windows Terminal\; - 便携模式(可执行文件旁存在
.portable标记文件):可执行文件所在目录下的settings\子目录。
构造函数 ApplicationState(stateRoot) 会基于该目录初始化两条路径 _sharedPath(state.json)与 _elevatedPath(elevated-state.json),并立即 _read()。SharedInstance() 使用 C++ 函数内静态对象保证全局单例(ApplicationState.cpp)。
3.3 状态字段清单:Shared 与 Local 两类
ApplicationState.h 中用一个 X-macro MTSM_APPLICATION_STATE_FIELDS(X) 集中声明了所有持久化字段,这也是头文件注释所说的"加新字段只需要改 IDL 和这个宏":
| JSON 键 | C++ 属性 | 类型 | 来源 | 用途 |
|---|---|---|---|---|
settingsHash |
SettingsHash |
hstring |
Shared | 缓存 settings.json 的哈希,避免设置未变时做昂贵的处理(如更新 Jumplist) |
generatedProfiles |
GeneratedProfiles |
unordered_set<guid> |
Shared | 记录已生成的动态 Profile,防止"删了又冒出来" |
persistedWindowLayouts |
PersistedWindowLayouts |
IVector<WindowLayout> |
Local | 上一次会话遗留的窗口布局队列,用于启动时恢复 |
recentCommands |
RecentCommands |
IVector<hstring> |
Shared | 命令面板(Command Palette)的历史命令行 |
dismissedMessages |
DismissedMessages |
IVector<InfoBarMessage> |
Shared | 已被用户关闭的 InfoBar 提示("Do not ask again" 的落地形态) |
allowedCommandlines |
AllowedCommandlines |
IVector<hstring> |
Local | 管理员实例中被用户允许执行的命令行 |
dismissedBadges |
DismissedBadges |
unordered_set<hstring> |
Local | 设置 UI 中被用户隐藏的徽章(badge) |
persistedWorkspaces |
PersistedWorkspaces |
IMap<hstring, WindowLayout> |
Local | 以窗口名命名的工作区布局(具名工作区保存/恢复) |
sshFolderGenerated |
SSHFolderGenerated |
bool(默认 false) |
Shared | 标记 SSH 动态 Profile 文件夹是否已生成 |
FileSource 枚举(ApplicationState.h)区分了两种字段:
- Shared:存于
state.json,提权(管理员)与非提权实例共享; - Local:提权与非提权实例各存各的,提权实例写入独立的
elevated-state.json。
这个设计解决了 Windows 特有的问题:管理员权限的 Terminal 实例不应该把窗口位置、允许的命令行等"本实例私事"写进普通用户实例共享的文件里(反之亦然)。
3.4 WindowLayout:恢复的粒度
被持久化的窗口布局由 WindowLayout 描述,包含四个字段:
TabLayout(IVector<ActionAndArgs>):以"动作 + 参数"序列描述该窗口里每个标签页应打开什么;InitialPosition:初始位置;InitialSize:初始尺寸;LaunchMode:启动模式(最大化等)。
其 JSON 转换由 ApplicationState.cpp 中特化的 ConversionTrait<WindowLayout> 完成,ToJson/FromJson 静态方法支持把单个布局序列化成字符串,便于外部存储场景复用。
四、写盘机制:延迟提交、防抖与强制刷新
规范中"修改会在稍后持久化、没有显式 Save"的原则,在实现中由一个 til::throttled_func 精确落地(ApplicationState.cpp):
_throttler{
til::throttled_func_options{
.delay = std::chrono::seconds{ 1 },
.debounce = true,
.trailing = true,
},
[this]() { _write(); }
}
- 延迟 1 秒、防抖(debounce)、尾部触发(trailing):连续快速修改只会在静默 1 秒后真正写盘一次;
- 所有 setter(宏
MTSM_APPLICATION_STATE_GEN生成的存取器,见 ApplicationState.cpp)以及AppendPersistedWindowLayout、SaveWorkspace、DismissBadge等方法在修改内存状态后都会调用_throttler()排程一次写盘; Flush()会取消等待中的计时器并立即同步执行写盘,析构函数中调用它,保证进程退出前最后一次修改不丢(ApplicationState.cpp);- 写
state.json使用til::io::write_utf8_string_to_file_atomic原子写(先写临时文件再重命名),普通实例的 Local/Shared 状态全部原子写入同一个state.json。
提权实例的写盘更讲究。_write()(ApplicationState.cpp)在提权时不直接覆盖 state.json,而是先把现有 state.json 读成一个 JSON blob,再把"本实例可见的 Shared 属性"叠写到该 blob 之上后写回——这样普通用户实例的 Local 属性(如窗口布局)能原样留在 state.json 中不被清空;本提权实例自己的 Local 属性则单独写入 elevated-state.json。读取侧(_read)对称地处理:提权时只从 state.json 读 Shared 字段,再叠加 elevated-state.json 中的 Local 字段;非提权时从 state.json 读全部字段。
此外,_readLocalContents() 在提权读取 elevated-state.json 时会校验文件权限,权限不对就删除该文件,避免读到恶意篡改的数据(ApplicationState.cpp)。写 elevated-state.json 时特意不用原子写,防止未提权用户通过"替换文件重命名"的方式覆写提权文件。
五、容错策略:损坏即弃,重置即删
规范"Potential Issues"一节说:状态文件是用户可能误编辑的又一个文件,一旦损坏应丢弃整个载荷而不是抢救。源码注释直接印证了这一点(ApplicationState.cpp):
// * ANY errors during app state will result in the creation of a new empty state.
// * ANY errors during runtime will result in changes being partially ignored.
_read() 中任何 JSON 解析失败都会以 WEB_E_INVALID_JSON_STRING 抛出并进入 CATCH_LOG(),最终得到一份空状态——即"重新开始",与规范意图完全一致。
规范中的 Clear() 演化为 Reset(),实现上比"清空对象"更彻底:直接删除 state.json 与 elevated-state.json 两个文件,再把内存状态重置为空(ApplicationState.cpp)。注释解释了原因:如果只清空内存对象而不删文件,下一次写盘时 std::nullopt 的字段不会从 JSON 中移除旧键,数据就会"复活"。Reset() 被设置在"清除应用状态"的路径调用(见 CascadiaSettingsSerialization.cpp 与 CascadiaSettingsSerialization.cpp),即规范 UI/UX 一节设想的"reset"入口在设置 UI 中落地的方式。
六、状态模型支撑的实际功能
以下功能均以 ApplicationState::SharedInstance() 为唯一入口,印证了规范"序列化集中在一处"的目标:
6.1 动态 Profile 去重:GeneratedProfiles
CascadiaSettingsSerialization.cpp 中的 SettingsLoader::DisableDeletedProfiles() 正是规范"哪些动态 Profile 已生成"的落地:
- 遍历所有非用户来源(generated)的 Profile;
- 若其 GUID 不在
GeneratedProfiles集合中,则加入集合(新面孔,允许显示); - 若已在集合中(即上次会话已生成过),则把该 Profile 标记为
Deleted(true)与Hidden(true)。
效果:用户从 settings.json 或设置 UI 删掉一个动态 Profile 后,下次加载它会被自动隐藏,不会再"冒出来"——这正是规范开头提到的用户不满(issue #3231)的解法。SSH 文件夹的生成则用 SSHFolderGenerated 布尔位做一次性标记。
6.2 "Do not ask again":DismissedMessages
规范中 ShowCloseOnExitWarning 一类布尔字段的通用化形态是 DismissedMessages(IVector<InfoBarMessage>,枚举含 CloseOnExitInfo、KeyboardServiceWarning 等)。TerminalPage.cpp 在展示 InfoBar 前查询该集合,用户关闭提示后 ID 即被写入状态并持久化,跨会话生效。
6.3 窗口布局恢复:PersistedWindowLayouts 与具名工作区
- 窗口关闭时,TerminalPage.cpp 调用
SaveWorkspace/AppendPersistedWindowLayout把当前窗口的WindowLayout记入状态;TabManagement.cpp 也会按需保存工作区; - 下一次启动时,TerminalWindow.cpp 读取
PersistedWindowLayouts并恢复遗留窗口,窗口改名时经 TerminalWindow.cpp 的RenameWorkspace迁移条目; - TerminalPage.cpp 通过
AllPersistedWorkspaces()列出可恢复的具名工作区,用户删除时调用RemoveWorkspace; TakeWorkspace提供**原子的"取出即删除"**语义,源码注释说明这是启动路径专用 API,保证同一工作区只会被一个调用者领取。
6.4 命令面板历史:RecentCommands
CommandPalette.cpp 从 RecentCommands 读取历史命令行、去重后回填,属于 Shared 字段,提权与非提权实例共享同一份历史。
6.5 设置哈希:SettingsHash
AppLogic.cpp 的 _ProcessLazySettingsChanges() 将当前 settings.json 的哈希与 applicationState.SettingsHash() 比对,仅在设置真正变化时才执行更新 Jumplist 等昂贵操作,并把新哈希写回状态。这是"应用状态与用户配置解耦"带来的一个额外收益。
6.6 设置 UI 徽章:DismissedBadge
ActionsViewModel.cpp 用 DismissBadge/BadgeDismissed 记住用户对 Actions 页徽章的关闭操作——这是规范中"不再提示"思想在设置 UI 中的新应用。
七、单元测试对关键语义的验证
ApplicationStateTests.cpp 用指向临时目录(%TEMP%\WT_ApplicationStateTests)的一次性 ApplicationState 实例测试工作区持久化 API,不触碰真实用户状态,覆盖:
SaveAndLookupWorkspace:保存后可通过AllPersistedWorkspaces查回;RemoveWorkspaceReturnsFalseWhenMissing:删除不存在的条目返回false,删除后再次删除仍为false;RenameWorkspaceMigratesEntry/RenameWorkspaceNoOpForEmptyOrEqualNames/RenameWorkspaceNoOpForMissingEntry:改名迁移、空名/同名 no-op、"重命名为空串即删除旧条目"三种边界;TakeWorkspaceRemovesAndReturns/TakeWorkspaceReturnsNullWhenMissing:验证原子性——同一名称第二次TakeWorkspace必须返回 null,这正是启动恢复路径依赖的保证。
八、小结
从 [doc/specs/#8324 - Application State (TSM).md](https://gitcode.com/GitHub_Trending/term/terminal/blob/20588130d8ef2ba40eb56bdae88e04cce7fc5b5d/doc/specs/?utm_source=gitcode_repo_files#8324 - Application State (TSM).md) 的草案到 src/cascadia/TerminalSettingsModel 下的实现,这条主线保持了规范的全部核心主张:独立于 settings.json 的 state.json 文件、集中一处做 JSON 序列化、无显式 Save 的延迟持久化、面向非手工编辑场景、损坏即弃的容错策略。实现在此基础上做了三处实质性扩展:
- 字段宏驱动:
MTSM_APPLICATION_STATE_FIELDS让"加一个持久化字段"退化为在宏里加一行; - Shared/Local 双文件模型:
state.json与elevated-state.json分离提权/非提权实例的私有状态,并以"叠加写回"的方式保证互不破坏; - 状态面扩展:从最初的
GeneratedProfiles扩展到窗口布局队列、具名工作区、命令历史、InfoBar 免打扰、设置哈希等,成为窗口恢复、命令面板、动态 Profile 去重等多个特性的公共底座。
读者若要进一步深入,可依次查看 ApplicationState.idl(对外 API)、ApplicationState.h(字段与实现骨架)、ApplicationState.cpp(读写与提权逻辑)、FileUtils.cpp(状态文件目录解析)与 ApplicationStateTests.cpp(行为验证)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00