主题
Windows 空白工程接入晞晗IM
这份文档面向 .NET 8 WPF。XHIM Windows 包拆分为:
text
XHIM.SDK Headless C# Facade + native runtime
XHIM.UI.Wpf 会话、聊天、通话占位控件和 Lucide Geometry本文只处理 Windows 客户端。开发环境只需领取 Server URL、测试 User ID 和 测试 Conversation ID、App ID;生产环境再由登录层提供一个业务鉴权回调。 客户端不需要数据库密码、服务端密钥、C++ 工具链或手工 Token。 服务端操作仅见 XHIM Server 文档。
跑通第一条消息后,所有公开方法、事件、模型和异常请查 SDK API Reference。
最短路径是“添加 NuGet → ConnectAsync → SendTextAsync”。
先确认发行包
只使用 Release Manifest 对应的签名 NuGet。包内必须按声明 RID 携带 xhim_core_v1.dll、托管程序集、checksum、LICENSE、NOTICE 和 SBOM;客户只 依赖 NuGet,不读取、编译或编辑 C++ Core。
1. 新建工程
Visual Studio 选择 Create a new project → WPF Application:
text
Framework .NET 8
Architecture 与 XHIM native runtime 一致,例如 x64不要选择 AnyCPU 后再混入单架构 native DLL。
2. 添加 NuGet
正式内部源:
powershell
dotnet add package XHIM.SDK --version <version>
dotnet add package XHIM.UI.Wpf --version <version>本地验收可把发行目录作为 NuGet Source:
powershell
dotnet nuget add source C:\xhimsdk\packages -n XHIMLocalNuGet 必须在 runtimes/win-x64/native/、runtimes/win-arm64/native/ 等声明 RID 下携带已签名 DLL。客户不手工 P/Invoke 或复制随机版本 DLL。
3. 创建账号 Client
Development XHIM Server 已启用免密码登录时,只需服务器地址和当前用户:
csharp
using XHIM;
var client = await XHIMClient.ConnectAsync(
"https://im.customer.example",
currentUserId);ConnectAsync 映射到底层最小配置:
| Core 配置 | Windows 来源 |
|---|---|
AppId | 显式 appId,否则 Bootstrap 默认值 |
StoragePath | storageRootPath 下按 App ID + User ID 隔离 |
Deployment.BootstrapUrl | server |
| Endpoint Key ID + Public Key | Bootstrap 公开配置 |
ConnectAsync 自动读取 GET /v1/sdk/config、选择默认 App ID、创建账号隔离 数据库、启动 Core,再调用 POST /v1/sdk/development:login 登录。前端不用 手工组装底层配置,也不用处理测试登录细节。
生产环境把现有业务登录态包装成一个鉴权回调:
csharp
var client = await XHIMClient.ConnectAsync(
"https://im.customer.example",
currentUserId,
authentication: XHIMAuthentication.Business(async cancellationToken =>
await businessApi.GetXHIMCredentialAsync(
currentUserId,
cancellationToken)));回调只负责向购买方自己的后端取得当前用户 XHIM 登录票据;何时调用、续期和 并发合并由 SDK 处理。新项目使用 Business Provider,不把固定票据写入源码或 配置。
生产 Server 必须使用 HTTPS。HTTP 只有在公开配置明确返回 environment=development 时才允许;发现请求禁止跳转,Server URL 不能带 userinfo、query 或 fragment,单个响应上限为 256 KiB。
在 App 的账号 Session/ViewModel 中持有 Client,并在退出账号时 await client.DisposeAsync()。Window 关闭重开不应创建第二个同账号数据库 连接。同一个 DLL 可连接不同客户域名,API/WSS/上传/下载地址只能使用服务端 签名结果。
Production Release 不允许 Local Preview、Development Easy Login、固定测试 账号或匿名降级。Window 和 ViewModel 不保存 XHIM 登录票据。
3.1 状态、会话和首条消息
csharp
var stateTask = Task.Run(async () =>
{
await foreach (var value in client.Events(cancellationToken))
if (value is XHIMEvent.StateChanged state)
Console.WriteLine($"XHIM state: {state.State}");
}, cancellationToken);
var conversationId = "xhim-demo-direct";
var clientMessageId = Guid.NewGuid().ToString("N");
await client.SendTextAsync(
conversationId,
"Hello from Windows",
clientMessageId,
cancellationToken);
var conversations = await client.ListConversationsAsync(
limit: 50,
cancellationToken: cancellationToken);
var page = await client.ListMessagesAsync(
conversationId,
limit: 50,
cancellationToken: cancellationToken);
var through = page.Messages
.Where(message => message.ServerSequence.HasValue)
.Select(message => message.ServerSequence!.Value)
.DefaultIfEmpty()
.Max();
if (through > 0)
await client.MarkConversationReadAsync(
conversationId,
through,
cancellationToken);
var persisted = await client.GetMessageAsync(
clientMessageId,
cancellationToken);
if (persisted.State is XHIMMessageState.Failed or
XHIMMessageState.Cancelled)
await client.RetryMessageAsync(clientMessageId, cancellationToken);发送成功表示可靠 Outbox 已受理;最终状态以 ServerAccepted 为准。NextCursor 是不透明 byte[],下一页只能原样传回对应查询。
在线状态与“正在输入”
Client 进入 Ready 后直接调用 SDK,不需要业务层处理 Token、HTTP 或 WebSocket:
csharp
await client.PublishPresenceAsync(
XHIMPresenceStatus.Online,
cancellationToken: cancellationToken);
await client.PublishTypingAsync(
conversationId,
isTyping: true,
cancellationToken: cancellationToken);
// 输入框清空、发送成功或页面退出时立即清除。
await client.PublishTypingAsync(
conversationId,
isTyping: false,
cancellationToken: cancellationToken);Events(...) 中分别处理 XHIMEvent.PresenceChanged 和 XHIMEvent.TypingChanged。不要每次按键都调用,只上报开始/停止两种转换; 显示层必须按 ExpiresAtMilliseconds 自动清除过期状态。TTL 留为 0 时由 服务端使用统一默认值。
3.2 服务端历史与账号视图
csharp
var history = await client.GetMessageHistoryAsync(
conversationId,
limit: 50,
cancellationToken: cancellationToken);
if (history.Continuation is not null)
_ = await client.GetMessageHistoryAsync(
conversationId,
history.Continuation,
limit: 50,
cancellationToken: cancellationToken);
var serverMessageId = history.Messages
.Select(message => message.ServerMessageId)
.FirstOrDefault(value => value is not null);
if (serverMessageId is not null)
_ = await client.DeleteMessageForSelfAsync(
conversationId,
serverMessageId,
Guid.NewGuid().ToString("N"),
cancellationToken: cancellationToken);
if (history.LatestServerSequence > 0)
{
var cleared = await client.ClearConversationAsync(
conversationId,
history.LatestServerSequence,
Guid.NewGuid().ToString("N"),
expectedRevision: history.View?.Revision ?? 0,
cancellationToken: cancellationToken);
_ = await client.HideConversationAsync(
conversationId,
Guid.NewGuid().ToString("N"),
expectedRevision: cleared.View.Revision,
cancellationToken: cancellationToken);
}服务端历史从新到旧返回,Continuation 只能原样传回。删除、清空和隐藏只改变 当前账号视图;同一次失败重试必须复用原 mutationId。后续 CAS 使用最新 View.Revision,冲突时先重拉历史。四个 API 都接受 CancellationToken 并 转发到 native 通用取消。
3.3 用户资料、单聊、Push 和取消
csharp
using System.Globalization;
using XHIM;
var me = await client.GetCurrentUserProfileAsync(cancellationToken);
var profiles = await client.GetUserProfilesAsync(
new[] { me.UserId, "bob" },
cancellationToken);
// null 表示保持不变;空字符串表示明确清空。
var updated = await client.UpdateCurrentUserProfileAsync(
new XHIMUserProfileUpdate(
DisplayName: "Alice Windows",
AvatarUrl: null,
Bio: ""),
cancellationToken);
var direct = await client.GetOrCreateDirectConversationAsync(
"bob",
cancellationToken);
await client.SendTextAsync(
direct.ConversationId,
"Hello from Windows",
cancellationToken: cancellationToken);Windows Push 或厂商 Push 回调取得 token 后,使用保存在系统凭据存储中的安装级 deviceId 注册。输入的 ToString() 会脱敏,回执也不含 token;业务日志仍 不得记录 provider token:
csharp
var registration = await client.RegisterPushDeviceAsync(
new XHIMPushDevice(
XHIMPushPlatform.Web,
pushInstallationId,
providerToken,
"production",
CultureInfo.CurrentUICulture.Name),
cancellationToken);
await client.DisablePushDeviceAsync(
pushInstallationId,
cancellationToken);每个 API 都接受 CancellationToken。取消只会调用 native cancel_request 并终止本次等待,不会 Dispose 共享 Client:
csharp
using var requestCancellation = new CancellationTokenSource();
var lookup = client.GetUserProfilesAsync(
new[] { "alice", "bob" },
requestCancellation.Token);
requestCancellation.Cancel();
try
{
await lookup;
}
catch (OperationCanceledException)
{
// 本次查询已取消,账号级 client 仍然可用。
}4. 加入 WPF 控件
XAML:
xml
<Window
xmlns:xhim="clr-namespace:XHIM.UI.Wpf;assembly=XHIM.UI.Wpf">
<xhim:ChatView x:Name="Chat"
Messages="{Binding Messages}" />
</Window>订阅动作:
csharp
Chat.SendRequested += async (_, text) =>
{
await client.SendTextAsync(conversationId, text);
await viewModel.ReloadAsync();
};
Chat.AttachmentPicked += async (_, attachment) =>
{
await viewModel.SendAttachmentAsync(
attachment.Kind,
attachment.LocalFilePath);
};
Chat.AttachmentActionRequested += (_, action) =>
{
if (action.Kind == XHIMAttachmentKind.Camera)
viewModel.OpenWindowsCamera();
};ConversationList 和 ChatView 使用 WPF ResourceDictionary 中的 XHIM 颜色与 Lucide Geometry。你可以在宿主 ResourceDictionary 覆盖主题 Brush; 业务 Cell 只读取托管模型,不读取 SQLite、不持有 native handle。
5. 发图片、视频和文件
ChatView 自带照片、视频和文件 OpenFileDialog;“拍照”动作通过 AttachmentActionRequested 交给宿主调用 Windows CameraCaptureUI,避免 SDK 擅自声明客户的相机权限和保存目录。
选择文件后直接提交持久化上传意图,不需要在 UI 层创建 uploader:
csharp
var accepted = await client.SendImageAsync(
conversationId,
attachment.LocalFilePath,
"image/jpeg",
imageWidth,
imageHeight,
cancellationToken: cancellationToken);
var fileAccepted = await client.SendFileAsync(
conversationId,
attachment.LocalFilePath,
detectedMimeType,
displayName: Path.GetFileName(attachment.LocalFilePath),
cancellationToken: cancellationToken);accepted/fileAccepted 只表示任务和依赖消息意图已经持久化受理,不表示 上传或消息发送已经完成。SDK 在后台线程读取文件元数据并计算 SHA-256; 任务快照、事件、诊断和网络 Payload 都不包含本地路径。视频和语音分别调用 SendVideoAsync、SendAudioAsync。
订阅事件后按 TaskId 查询完整任务:
csharp
await foreach (var value in client.Events(cancellationToken))
{
if (value is not XHIMEvent.MediaTaskUpdated update)
continue;
// 事件只携带 task id、原始 state 和 schema;详情始终重新查询。
var task = await client.GetMediaTaskAsync(
update.TaskId,
cancellationToken);
if (task.State == XHIMMediaTaskState.Completed)
ShowSent(task.ClientMessageId);
}WPF ViewModel 使用独立投影通道重查,不与生命周期事件竞争,也不读取 SQLite:
csharp
await using var projectionRefresh = new XHIMProjectionRequeryController(
client,
async (change, cancellation) =>
{
await viewModel.ReloadAsync(cancellation);
});控制器捕获当前 SynchronizationContext(通常是 WPF Dispatcher),订阅建立后 先查询一次,再串行处理 MessageUpserted/MessageStateChanged/ ConversationChanged/SocialChanged/SyncApplied。未知 kind/schema 保留为 ProjectionInvalidated 并宽范围重查;P/Invoke 回调返回前已复制所有字符串。
取消一次 GetMediaTaskAsync 的 CancellationToken 只会调用通用 cancel_request 并终止本次等待;要持久化取消上传任务,必须显式调用:
csharp
var cancelled = await client.CancelMediaTaskAsync(taskId, cancellationToken);业务需要自定义元数据时使用 CreateMediaUploadAsync(XHIMMediaUploadIntent)。 TaskId 和 ClientMessageId 可作为幂等键传入;未知 state/direction 映射为 Unknown,原值保留在 NativeState/NativeDirection。旧 XHIMServerMediaUploader、IXHIMMediaUploader 和带 uploader 参数的 Send*Async 已标记为过时的非持久化兼容路径,不再是默认接入方式。
收到消息中的稳定 XHIMMediaRef 后,媒体下载也直接创建持久化任务:
csharp
var download = await client.CreateMediaDownloadAsync(
new XHIMMediaDownloadIntent(
cacheKey: Guid.NewGuid().ToString("N"),
media: mediaRef),
cancellationToken);cacheKey 是 Core 私有缓存的扁平标识,不是文件路径。下载到达 Completed 前不可读取;不要拼接 StoragePath 猜测内部位置,也不要保存签名 URL。运行中可以调用 client.GetDiagnostics() 取得同步、只读、纯内存健康 快照;其中不含账号、消息正文、用户凭证、本地路径或临时 URL。
任务到达 Completed 后读取已校验字节:
csharp
await using var reader = await client.OpenMediaCacheReaderAsync(
cacheKey,
mediaRef,
cancellationToken);
await foreach (var chunk in reader.ReadChunksAsync(
cancellationToken: cancellationToken))
{
decoder.Append(chunk);
}open 会在 worker 上执行完整 SHA-256 校验;同一 Reader 的 read/dispose 串行, 停止异步枚举就是取消。当前 Windows Reference Adapter 会明确返回 XHIMException.Code == 103/media_cache_unsupported;客户 Product Adapter 实现安全 no-follow reader 后无需修改调用代码,SDK 永远不会退化为拼路径。
系统从离线恢复时可调用:
csharp
NetworkChange.NetworkAvailabilityChanged += (_, available) =>
{
if (available) client.NotifyNetworkAvailable();
};这是同步 best-effort 提示,无 callback/request ID;Core 的退避定时器仍保底。
5.1 位置、名片、回复、合并转发与本地搜索
csharp
using XHIM;
var location = XHIMStandardMessageFactory.Location(
31.2304,
121.4737,
"人民广场",
"上海市黄浦区");
await client.SendMessageAsync(conversationId, location);
var card = XHIMStandardMessageFactory.ContactCard("bob", "Bob");
await client.SendMessageAsync(conversationId, card);
var result = await client.SearchMessagesAsync(
new XHIMMessageSearchQuery(
"合同",
conversationId,
null,
["text/plain", "xhim.media.file"]));
foreach (var message in result.Messages)
Console.WriteLine(message.FallbackText);搜索只访问当前账号的本地安全文本投影。NextCursor 是不透明二进制值,继续 翻页时必须原样传回。
5.2 自定义消息
csharp
using System.Text;
var payload = Encoding.UTF8.GetBytes(
"""{"orderId":"order-1001","title":"待付款订单"}""");
var custom = new XHIMOutgoingMessage(
"com.customer.message.order-card",
1,
payload,
"[订单] 待付款订单");
await client.SendMessageAsync(
conversationId,
custom,
cancellationToken: cancellationToken);接收端按 ContentType + ContentVersion 解码,未知版本显示 FallbackText。 需要集中校验、会话预览和 WPF Renderer 时注册 XHIMMessagePluginRegistry;插件失败必须回退,不能阻塞时间线。
6. 收消息和退出
csharp
await foreach (var value in client.Events(cancellationToken))
{
if (value is XHIMEvent.StateChanged state &&
state.State == XHIMClientState.CredentialRequired)
ShowReconnectingCredentialState();
if (value is XHIMEvent.MessageMutated mutated)
Console.WriteLine($"Message changed: {mutated.ClientMessageId}");
await viewModel.ReloadAsync();
}使用 XHIMAuthentication.Business 或 Development 登录时,登录状态维护由 SDK 串行去重处理;事件仍公开给 UI 展示状态。Business Provider 失败时由账号会话 层恢复业务登录态,页面不直接调用登录票据接口。
编辑与撤回只接受已有 ServerMessageId 的服务端消息。CAS 版本来自 MutationRevision,同一次网络重试必须复用同一个 mutationId:
csharp
if (message.ServerMessageId is not { } serverMessageId) return;
var edited = await client.EditTextAsync(
message.ConversationId,
serverMessageId,
Guid.NewGuid().ToString("N"),
message.MutationRevision,
"修改后的内容",
cancellationToken);
var recalled = await client.RecallAsync(
edited.ConversationId,
serverMessageId,
Guid.NewGuid().ToString("N"),
edited.MutationRevision,
cancellationToken);返回值是服务端确认后的完整 XHIMMessage。未知 mutation 枚举映射到 Unknown,原始值仍在 NativeMutationKind;传入的 CancellationToken 会取消对应 native request。
置顶和免打扰使用服务端 CAS,同步版本来自 conversation.PreferenceRevision;草稿只保存到当前账号的加密本地库:
csharp
await client.SetConversationPreferenceAsync(
conversation.ConversationId,
isPinned: true,
notificationsMuted: false,
Guid.NewGuid().ToString("N"),
conversation.PreferenceRevision,
cancellationToken);
var text = "尚未发送的内容";
await client.SetLocalDraftAsync(
conversation.ConversationId,
new XHIMOutgoingMessage(
"text/plain",
1,
Encoding.UTF8.GetBytes(text),
text),
cancellationToken);
var draft = await client.GetLocalDraftAsync(
conversation.ConversationId,
cancellationToken);
await client.ClearLocalDraftAsync(
conversation.ConversationId,
cancellationToken);draft.IsPresent == false 表示没有草稿;本地草稿不会跨设备同步。
退出账号:
csharp
await client.LogoutAsync();
await client.DisposeAsync();7. 常见问题
BadImageFormatException:App RID、进程架构和 NuGet native DLL 不一致;DevelopmentLoginDisabled:当前地址不允许 Easy Login,改用 Business Provider;- 状态未到
Ready:检查 Bootstrap HTTPS、系统时间、防火墙和业务会话; - 能发送但页面不刷新:消费事件后重新调用
ListMessagesAsync,不要缓存旧 Page; - WPF 图标缺失:确认
XHIM.UI.WpfResourceDictionary 已进入输出目录。
连接错误按 XHIMConnectionException.Code 分支;运行时按 XHIMException.Code/Domain/StableCode/Retryable 分支。不要解析错误 Message 决定重试,也不要记录凭证、完整 Payload 或附件路径。
8. 双端与 Production 发布验收
- Alice/Bob 使用各自业务账号 Provider 加入同一会话互发;
- 验证 Server 序号、本地
ServerAccepted、断网恢复和自动保持登录; - 覆盖 win-x64/win-arm64 等声明 RID;
- 使用业务鉴权回调,禁用 Local Preview 和 Easy Login;
- 验证会话/历史分页、文本/媒体/自定义消息、已读、编辑撤回和社交治理;
- 验证 CancellationToken、失败幂等重试、前后台、推送和账号切换;
- 校验 Authenticode、NuGet 签名、DLL hash、符号、LICENSE、NOTICE 和 SBOM;
- 使用 Windows 防火墙允许 App 访问正式 HTTPS/WSS,不降低系统 TLS。
P/Invoke、SafeHandle、CancellationToken 和发布细节见 Windows 完整指南。
附录 A:好友、群组和黑名单写入
csharp
var request = await client.SendFriendRequestAsync(
"bob", "", Guid.NewGuid().ToString(), cancellationToken);
_ = await client.ResolveFriendRequestAsync(
request.RequestId, XHIMFriendRequestDecision.Accept,
Guid.NewGuid().ToString(), cancellationToken);
_ = await client.DeleteFriendshipAsync(
"bob", Guid.NewGuid().ToString(), cancellationToken);
var group = await client.CreateGroupAsync(
"项目群", ["alice", "bob"], Guid.NewGuid().ToString(),
cancellationToken);
var roster = await client.ChangeGroupMembersAsync(
group.Group.ConversationId, ["carol"], [],
group.Group.Revision, Guid.NewGuid().ToString(), cancellationToken);
_ = await client.SetBlockAsync(
"spam-user", true, Guid.NewGuid().ToString(), cancellationToken);
var join = await client.RequestGroupJoinAsync(
roster.Group.ConversationId, "", Guid.NewGuid().ToString(),
cancellationToken);
_ = await client.ResolveGroupJoinAsync(
join.Request.RequestId, XHIMGroupJoinDecision.Accept,
Guid.NewGuid().ToString(), cancellationToken);
_ = await client.ChangeGroupGovernanceAsync(
roster.Group.ConversationId,
roster.Group.Revision,
Guid.NewGuid().ToString(),
new XHIMGroupGovernanceChange.SetJoinApprovalRequired(true),
cancellationToken);
var left = await client.LeaveGroupAsync(
memberGroup.ConversationId,
memberGroup.Revision,
Guid.NewGuid().ToString(),
cancellationToken);
var dismissed = await client.DismissGroupAsync(
ownedGroup.ConversationId,
ownedGroup.Revision,
Guid.NewGuid().ToString(),
cancellationToken);mutationId 是逻辑写幂等键:同一请求重试复用,不同参数必须生成新值,否则为 IDEMPOTENCY_CONFLICT。expectedRevision 使用最新 Group.Revision,CAS 冲突后先重查。CancellationToken 会取消 native 请求等待,但不回滚已经提交 的事务。业务只根据 XHIMException.Code/Domain/StableCode 分支,不解析 Message,也不要记录用户凭证、申请附言或资料内容。 退出和解散结果的 GroupChange 是权威服务端投影,IdempotentReplay 表示 返回的是同一逻辑写入的既有结果。示例中的 memberGroup 和 ownedGroup 分别来自最新成员群、本人拥有群查询。
附录 B:离线只读数据库
使用 XHIMOfflineReader 读取已有账号数据库。它实现 IAsyncDisposable, SQLite/P/Invoke 与释放都在线程池运行:
csharp
await using var reader = await XHIMOfflineReader.OpenAsync(
new XHIMOfflineReaderConfiguration(
accountDatabasePath,
currentUserId),
async cancellationToken =>
{
// 从 DPAPI/Credential Manager 解封数据库随机 Key。
return await secureDatabaseKeys.UnwrapAsync(
currentUserId,
cancellationToken);
});
var first = await reader.MessagesAsync(conversationId);
if (first.NextCursor is { } cursor)
_ = await reader.MessagesAsync(conversationId, cursor);
_ = await reader.SearchMessagesAsync(
new XHIMMessageSearchQuery("合同"));
_ = await reader.ConversationsAsync();
_ = await reader.FriendRequestsAsync();
_ = await reader.FriendshipsAsync();
_ = await reader.GroupsAsync();
_ = await reader.GroupMembersAsync(groupId);
_ = await reader.BlocksAsync();
_ = await reader.GroupJoinRequestsAsync();不要把数据库 Key 放进 appsettings.json、Registry、源码或命令行。SDK 清理 自己的临时 Key 副本;SemaphoreSlim 串行每个 SafeHandle,且在 P/Invoke 返回前把 borrowed view 深拷贝成托管值。NextCursor 必须原样传回 SDK。 DisposeAsync() 可重复调用,关闭后所有查询都会以保留原始 status 的 XHIMException 失败。
附录 C:登录设备管理
csharp
var page = await client.ListDeviceSessionsAsync(cancellationToken);
var target = page.Sessions.FirstOrDefault(
session => !session.Current && session.Active);
if (target is not null)
{
var result = await client.RevokeDeviceSessionAsync(
target.SessionId,
Guid.NewGuid().ToString(),
cancellationToken);
// Changed=false 表示此前已过期/失活,仍是成功幂等结果。
}
// 同步只读、调用方持有;不依赖先调用列表,也不执行网络或磁盘 I/O。
var policy = client.DeviceSessionPolicySnapshot;同一次精确重试复用 mutationId,新操作生成新值。CancellationToken 走通用 native request cancel。自撤销返回 Current=true、Active=false,应用应回到 登录态。登录完成前或登出后读取策略会抛出 XHIMException(Code=2),即原生 INVALID_STATE。模型 ToString() 已脱敏,日志也不得输出会话 ID、设备 ID 或撤销原因。
附录 D:协议兼容快照(仅诊断/灰度)
csharp
var compatibility = client.CompatibilitySnapshot;
logger.LogInformation(
"XHIM protocol {ClientProtocol}/{ServerProtocol}",
compatibility.ClientProtocolVersion,
compatibility.ServerProtocolVersion);普通业务无需处理该快照:登录已经对不兼容服务端 fail-closed。该同步属性只 读取登录时严格验证并深拷贝的内存状态,不进行网络或磁盘 I/O;仅供诊断、支持 包和灰度发布观测。登录前或登出后会保留 XHIMException(Code=2)(原生 INVALID_STATE)。不要用 capability 绕过 登录结论,也不要记录 SDK/Server 版本或 capability 值;ToString() 只保留 协议号和数量。