Skip to content

晞晗IM(XHIM)Windows 接入指南

第一次在空白 Windows 工程接入? 请先看 Windows 从零快速接入。本页保留 P/Invoke、SafeHandle、 native runtime、打包和高级二次开发细节。 ListDeviceSessionsAsyncRevokeDeviceSessionAsync、取消及同步策略示例见 Windows QuickStart 的登录设备管理

当前状态:.NET 8 C# Facade、SafeHandle、Task/异步事件流、WPF 会话/聊天控件、可选通话控件和 Console QuickStart 已提交;Headless Facade 与 Console Sample 已通过 .NET 8 -warnaserror 编译。正式售卖前仍需在 Windows x64/arm64 构建原生 DLL、用官方 Windows SDK 编译 WPF、执行结构布局测试并 产出签名 NuGet。

1. 商用交付边界

建议 NuGet 包结构:

text
XHIM.SDK.<version>.nupkg
├── ref/<tfm>/XHIM.SDK.dll
├── lib/<tfm>/XHIM.SDK.dll
├── runtimes/win-x64/native/xhim_core_v1.dll
├── runtimes/win-arm64/native/xhim_core_v1.dll
├── buildTransitive/
├── LICENSE
├── NOTICE
└── RELEASE-MANIFEST.json

NuGet 会从 runtimes/{rid}/native/ 选择目标平台 native assets,不能只把 DLL 放进 lib/。参考: NuGet native files.NET RID catalog

PDB、Source Link 和 .snupkg 由受保护构建器上传到访问受控的内部符号服务, 不放入客户公开制品根,也不与客户 NuGet 混签。Crash 证据通过 Release Manifest 中的完整 Commit 和内部符号索引关联。

SDK 必须明确支持的 .NET TFM、Windows 版本和 x64/arm64 RID。当前仓库交付 WPF UI 包;WinUI 3 项目可使用 Headless XHIM.SDK 自建界面,但当前不存在 XHIM.UI.WinUI 包。不要用 AnyCPU 掩盖 native 架构选择。

2. 客户项目接入(目标发行包)

正式发布后:

xml
<ItemGroup>
  <PackageReference Include="XHIM.SDK" Version="<version>" />
  <!-- 可选 UI 包 -->
  <PackageReference Include="XHIM.UI.Wpf" Version="<version>" />
</ItemGroup>

<PropertyGroup>
  <RuntimeIdentifier>win-x64</RuntimeIdentifier>
</PropertyGroup>

arm64 产品改用 win-arm64。CI 必须分别 dotnet publish -r win-x64dotnet publish -r win-arm64,并在干净机器验证 native DLL 能被解析。

3. 当前源码验证

使用 Visual Studio Generator 构建目标架构:

powershell
cmake -S C:\src\xhim -B C:\build\xhim-win-x64 `
  -A x64 `
  -DXHIM_BUILD_SHARED=ON `
  -DXHIM_BUILD_TESTS=ON `
  -DXHIM_WARNINGS_AS_ERRORS=ON `
  -DXHIM_SQLITE_PROVIDER=BUNDLED `
  -DXHIM_SQLITE_BUNDLED_DIR=C:\deps\sqlite-amalgamation
cmake --build C:\build\xhim-win-x64 --config Release
ctest --test-dir C:\build\xhim-win-x64 -C Release --output-on-failure

arm64 使用独立目录和 -A ARM64。不能在同一 build 目录切换架构。当前仓库 没有内置 SQLite amalgamation,产品团队必须提供经过许可证和版本审计的固定 源码,或注入正确目标平台的 SQLite::SQLite3

正式 DLL 应关闭 Local Preview、编入真实 Product Adapter,并通过 compatibility/abi-baseline/xhim_v1.exports 检查导出符号。

Windows Easy Connect 的无第三方依赖合同测试可在 macOS/Linux/Windows 重复 执行,不会加载 native DLL:

powershell
dotnet run --project .\platforms\windows\XHIM.ContractTests\XHIM.ContractTests.csproj `
  -c Release

测试覆盖配置/Development 登录请求、危险 URL 与非法端口、HTTP 环境门禁、 256 KiB 上限、错误原因净化、禁止 302 跳转,以及 Provider generation fence。

4. C# Facade API

空白项目优先使用 Easy Connect:

csharp
// Development Server:自动发现配置并免密码登录。
var client = await XHIMClient.ConnectAsync(
    "https://im.customer.example",
    currentUserId);

// Production:复用宿主已有业务登录态,SDK 自动续期。
var productionClient = await XHIMClient.ConnectAsync(
    "https://im.customer.example",
    currentUserId,
    authentication: XHIMAuthentication.Business(async cancellationToken =>
        await businessApi.GetXHIMCredentialAsync(
            currentUserId,
            cancellationToken)));

每次连接先读取公开的 GET /v1/sdk/config,自动填入 App ID、Bootstrap URL、 Endpoint Key ID 和 Ed25519 公钥,再复用现有 StartAsync/LoginAsync。 Development 模式调用 POST /v1/sdk/development:login;只有公开配置同时声明 environment=developmentdevelopment_login_enabled=true 才允许该模式, Test/Production 均拒绝,并只接受业务 Credential Provider 或 XHIMAuthentication.AccessToken(userToken)。Admin Key 从不进入 SDK 或客户 设备。

Bootstrap HttpClient 禁止 redirect、cookie 和自动解压;Server URL 拒绝 userinfo/query/fragment,单个响应最多 256 KiB。HTTP 发现完成后只有 environment=development 才继续,Production 必须 HTTPS。账号数据库默认写到 LocalApplicationData 下由 appId + userId 哈希得到的隔离目录,也可用 storageRootPath 指定客户私有根目录。

业务 Provider 在首次登录和 CredentialRequired 时调用,刷新任务按 generation 串行去重并直接复用 UpdateCredentialAsync;公开状态事件仍会正常 投递。LoginAsyncLogoutAsyncShutdownAsync、Dispose 或 Provider 替换都会推进 generation,旧 Provider 和已在途的旧代 refresh 不能再提交 Credential。当前代失败完成后,后续事件仍可重试。固定 Token 不会伪装成可 续期 Credential。

需要自定义底层策略时,再使用以下 Task 和异步事件流:

csharp
// 高级入口:XHIM/XHIMClient.cs
var policy = new XHIMRuntimePolicy(
    RequestTimeoutMilliseconds: 15_000,
    SyncPageSize: 200,
    ReconnectInitialDelayMilliseconds: 1_000,
    ReconnectMaxDelayMilliseconds: 30_000,
    MaxReconnectAttempts: 8);

public sealed class XHIMClient : IAsyncDisposable, IDisposable
{
    static Task<XHIMClient> ConnectAsync(
        string server,
        string userId,
        string? appId = null,
        XHIMAuthentication? authentication = null,
        string? storageRootPath = null,
        CancellationToken cancellationToken = default);
    XHIMClientState State { get; }
    IAsyncEnumerable<XHIMEvent> Events(CancellationToken cancellationToken);
    IAsyncEnumerable<XHIMProjectionChange> ProjectionEvents(
        CancellationToken cancellationToken);
    Task StartAsync(CancellationToken cancellationToken = default);
    Task LoginAsync(
        string accountHint,
        string accessToken,
        CancellationToken cancellationToken = default);
    Task UpdateCredentialAsync(
        string accessToken,
        CancellationToken cancellationToken = default);
    Task LogoutAsync(CancellationToken cancellationToken = default);
    Task<XHIMSendReceipt> SendTextAsync(
        string conversationId,
        string text,
        string? clientMessageId = null,
        CancellationToken cancellationToken = default);
    Task<XHIMSendReceipt> SendMessageAsync(
        string conversationId,
        XHIMOutgoingMessage message,
        string? clientMessageId = null,
        CancellationToken cancellationToken = default);
    Task<XHIMMediaTask> CreateMediaUploadAsync(
        XHIMMediaUploadIntent intent,
        CancellationToken cancellationToken = default);
    Task<XHIMMediaTask> GetMediaTaskAsync(
        string taskId,
        CancellationToken cancellationToken = default);
    Task<XHIMMediaTask> CancelMediaTaskAsync(
        string taskId,
        CancellationToken cancellationToken = default);
    Task<XHIMSendReceipt> RetryMessageAsync(
        string clientMessageId,
        CancellationToken cancellationToken = default);
    Task<XHIMSendReceipt> CancelMessageAsync(
        string clientMessageId,
        CancellationToken cancellationToken = default);
    Task<XHIMMessage> GetMessageAsync(
        string clientMessageId,
        CancellationToken cancellationToken = default);
    Task<XHIMMessagePage> ListMessagesAsync(
        string conversationId,
        byte[]? cursor = null,
        uint limit = 50,
        CancellationToken cancellationToken = default);
    Task<XHIMConversationPage> ListConversationsAsync(
        byte[]? cursor = null,
        uint limit = 50,
        CancellationToken cancellationToken = default);
    Task<XHIMConversationReadReceipt> MarkConversationReadAsync(
        string conversationId,
        long throughServerSequence,
        CancellationToken cancellationToken = default);
    Task ShutdownAsync(CancellationToken cancellationToken = default);
}

ProjectionEvents 与生命周期 Events 使用独立 Channel。事件含 origin、 social scope、账号 fence、消息/会话/实体 IDs、projection/entity revision、 sequence 和 affected count;它们是可合并失效通知,不是日志。WPF 使用 XHIMProjectionRequeryController 在捕获的 Dispatcher 上调用公开 SDK 查询, 未知 kind/schema 做宽范围重查,禁止 UI 直接读取 SQLite。

运行策略和 XHIMDeployment 在构造 Client 时复制,不能按单次 Task 修改。 客户可提供 Bootstrap URL、Endpoint Key ID 和 Ed25519 公钥;实际 Endpoint 必须验签且未过期。桌面业务不能静默关闭证书或签名校验。

解决方案位于 XHIM.sln,Headless 包为 XHIM/XHIM.csproj,可选 WPF 组件为 XHIM.UI.Wpf,控制台接入示例位于 XHIM.Sample

Facade 不公开 IntPtr、delegate、native struct 或 SQLite。CancellationToken 已接入稳定 C ABI 的逐请求协作取消:它取消托管等待并向 Core 请求取消;若业务 已经在服务端提交,取消不能回滚该事实,迟到 native completion 仍只负责安全 释放 context。

4.1 已读调用

ListMessagesAsync 返回最新消息在前的页面;NextCursor 是不透明二进制值, 只能原样用于同账号、同会话的下一页。聊天控件完成一批服务端确认消息的展示 后,提交其中最大的正 ServerSequence

csharp
var page = await client.ListMessagesAsync(
    conversationId,
    cancellationToken: cancellationToken);
var highestVisibleServerSequence = page.Messages
    .Select(message => message.ServerSequence)
    .OfType<long>()
    .DefaultIfEmpty(0)
    .Max();
if (highestVisibleServerSequence <= 0)
    return;

var receipt = await client.MarkConversationReadAsync(
    conversationId,
    highestVisibleServerSequence,
    cancellationToken);
// 使用 receipt.UnreadCount 更新 UI。

Pending/Sending 消息必须排除,Task 完成前不得乐观清零。收到 XHIMEvent.ConversationReadChanged 后重新加载该会话摘要;同值或更低值是 幂等 no-op。CancellationToken 会向 Core 请求取消并终止托管等待,但不撤销 已经提交的 markRead;最终 native completion 仍负责释放 context。

空页面没有可提交序号,业务应跳过 markRead。ListConversationsAsync 返回 置顶优先、随后按持久活动排序的摘要页。业务不得 P/Invoke 私有查询、直接读取 SQLite 或使用设备时间代替服务端序号。

实时 Presence/Typing(非持久投影)

PublishPresenceAsync/PublishTypingAsync 复用已登录会话,业务端无需传 Credential。权威回显与 XHIMEvent.PresenceChanged/ XHIMEvent.TypingChanged 均带服务端 SequenceExpiresAtMilliseconds。状态不落 SQLite;UI 必须到期清除,输入状态只在 开始/停止时发布,禁止逐按键调用。

4.2 自定义消息与 WPF Renderer

自定义消息插件拥有一个稳定 contentType。发送前由 XHIMMessagePluginRegistry 校验版本和 Payload:

csharp
var plugins = new XHIMMessagePluginRegistry();
plugins.Register(new ProductCardPlugin());
var outgoing = new XHIMOutgoingMessage(
    "com.xihan.product-card",
    1,
    Encoding.UTF8.GetBytes(cardJson),
    "[商品卡片]");
plugins.Validate(outgoing);
await client.SendMessageAsync(
    conversationId,
    outgoing,
    cancellationToken: cancellationToken);

收到消息后使用 plugins.Present(message)。未知类型、高版本、插件校验或展示 异常统一退回 FallbackText,原始 byte[] 仍由 Core 保存并可从时间线读取。

WPF 业务 Cell 通过 XHIMMessageRendererRegistry 注册 IXHIMMessageItemRenderer,只读取复制后的托管模型。Renderer 失败时返回基础 MessageItem,不能直接 P/Invoke、读取 SQLite 或持有 Native handle。完整插件 形态见 XHIM.Sample/Program.cs

5. P/Invoke 和 SafeHandle

微软建议 native resource 使用 SafeHandle,并要求托管签名与 C ABI 精确匹配。 参考: Native interop best practices

关键规则:

  • C ABI 使用 __cdecl,P/Invoke 必须指定一致 CallingConvention;
  • uint32_t/int32_t/uint64_t 分别映射 uint/int/ulong,不要把 C enum 直接当 C# bool
  • 结构体使用 StructLayout(LayoutKind.Sequential) 并验证 size/offset;
  • xhim_v1_bytes_view_t 使用 pointer + ulong length,Callback 内立即复制;
  • Client 使用 SafeHandle 封装,ReleaseHandle 调用 xhim_v1_client_destroy;正常生命周期仍先 ShutdownAsync
  • callback delegate 或 function pointer 的托管 owner 必须保持强引用;使用 GCHandle 传递 context 时每条完成/取消路径恰好释放一次;
  • CancellationToken 会调用 native cancel_request 并取消托管等待;它不 回滚已经提交的 SQLite 或服务端事务;
  • native Callback 来自 XHIM 私有线程,复制完成后投递到捕获的 SynchronizationContext/Dispatcher;
  • 不让异常越过 unmanaged callback;转为 Task failure 或可观察的致命状态;
  • XHIMException 完整复制 Domain/StableCode/NativeCode/Retryable/RetryAfterMilliseconds/UserAction/OperationId/TraceId,业务不解析 Message;
  • Easy Connect 的业务 Provider 会在 CredentialRequired 后串行调用 UpdateCredentialAsync,继续沿用 Core 中的 canonical account;高级手工 接入才由宿主处理该事件;
  • CancelMessageAsync 只取消尚未被 Transport 获取的本地 Outbox,不等于 服务端撤回;
  • MarkConversationReadAsync 必须等待 native receipt 后再更新 UI;托管层 不维护第二份 read sequence;
  • 进程退出和窗口关闭不能与仍在运行的 P/Invoke 并发销毁 handle。

若最低 TFM 支持 source-generated interop,可以评估 LibraryImport;需要覆盖 更旧 TFM 时可使用 DllImport,但两者必须共享同一 ABI 布局测试。

6. DLL 加载和 C++ Runtime

  • NuGet 包用 RID native assets,不修改系统 PATH,不把 DLL 安装进 System32;
  • native 依赖与 xhim_core_v1.dll 位于同一受控部署目录;
  • x64 进程只加载 x64 DLL,arm64 进程只加载 arm64 DLL;
  • 明确 /MD 与 Visual C++ Redistributable 策略,并写入部署文档;
  • 不从当前工作目录或用户可写任意目录搜索 native DLL;
  • Release Manifest 记录完整 Commit;PDB/Source Link 只进入内部符号服务, Crash 可按 Commit 映射,不从客户安装目录加载符号。

Visual C++ runtime 的部署策略参考: Microsoft C++ deployment

7. 生命周期、存储和多实例

Client 由应用账号容器或后台宿主服务持有,不由 Window/Page 持有。窗口关闭不 一定代表进程退出,托盘应用和后台进程必须定义明确 Owner。

  • 数据库放在 LocalApplicationData 下的账号隔离目录,不放安装目录、 Roaming、Temp 或用户可执行下载目录;
  • 单实例 App 应先取得产品级 instance lock,再打开 profile;
  • 多窗口共享同一 Client;不同进程不能同时打开同一数据库;
  • Token/密钥使用 DPAPI/企业平台安全存储,不进 Registry 明文或配置文件;正式 包把解封后的随机数据库密钥注入 SQLCipher Product Adapter;
  • shutdown 完成后再释放 SafeHandle 和 native DLL。

8. 附件消息与可选实时音视频

  • ChatView 提供照片、视频、文件选择和宿主相机动作;
  • SendImageAsync/SendVideoAsync/SendAudioAsync/SendFileAsync 默认调用 CreateMediaUploadAsync,在后台读取文件并计算 SHA-256,然后持久化上传任务 和依赖消息意图;返回 XHIMMediaTask 只表示受理成功,不表示上传完成;
  • GetMediaTaskAsync 查询完整的无路径快照;CancelMediaTaskAsync 是持久化 业务取消,和 CancellationToken 触发的通用 cancel_request 不同;
  • MEDIA_TASK_UPDATED 映射为 XHIMEvent.MediaTaskUpdated,事件仅暴露 TaskId/NativeState/SchemaVersion,收到事件后按 TaskId 查询详情;
  • Callback 内立即深拷贝 task 及嵌套 MediaRef。未知 state/direction 变为 Unknown,同时保留 NativeState/NativeDirection,方便新 Core 向前兼容;
  • 本地路径仅存在于 XHIMMediaUploadIntent 的调用输入和 Core 私有持久层, 不进入 XHIMMediaTask、事件、诊断或 wire payload;
  • CreateMediaDownloadAsync 以稳定 XHIMMediaRef 和扁平 CacheKey 创建持久化媒体下载;不得从 StoragePath 推导缓存路径,且任务到达 Completed 前不可读取对象;
  • 完成后只通过 XHIMMediaCacheReaderReadAsync/ReadChunksAsync 消费 已校验字节;open/read 在 worker,单 Reader 串行且可晚于 Client 销毁;
  • Windows Reference Adapter 明确返回 media_cache_unsupported(103),不会退化为不安全路径;Product Adapter 实现 no-follow native reader 后公开调用保持不变;
  • GetDiagnostics() 是线程安全的同步只读内存快照,不执行磁盘或网络 I/O, 未知 Client state 保留 native 原值,且不含客户数据或凭证;
  • NotifyNetworkAvailable() 只把系统“网络恢复”转成同步 best-effort 提示, 无 callback/request ID,既有 Core 重连定时器继续保底;
  • 文件选择结果必须复制到账号 SDK sandbox 并保留到任务终态,不能依赖系统 picker 临时路径永远存在;
  • XHIMServerMediaUploaderIXHIMMediaUploader 和带 uploader 参数的 Send*Async 仅保留为 [Obsolete] 非持久化兼容接口,不是默认商用路径;
  • WPF 或客户自建 WinUI 的 Dispatcher 切换只发生在 Facade/UI 层;
  • 实时音视频后续以独立 XHIM.Call.Windows 和厂商 Provider 交付,不阻塞纯 IM 首发;
  • 麦克风/摄像头、音频 endpoint、蓝牙、系统通知和厂商 RTC 对象不得进入稳定 C ABI 或消息 Payload。

9. UI Kit 与二次开发

当前交付边界:

text
XHIM.SDK             Headless .NET Facade
XHIM.UI.Wpf          WPF 主题、Renderer、资源和页面
XHIM.Call.Windows    未来 RTC/系统能力桥(当前不发布)

WPF UI 项目只依赖 XHIM.SDK。WinUI 3 客户当前直接依赖 Headless SDK 并实现 自己的页面。自定义消息通过 Renderer registry 注入,不能在控件中直接 P/Invoke 或访问 SQLite。

XHIM.UI.Wpf/Resources/XHIMLucideIcons.xaml 提供同一套 Lucide Geometry, XHIMTheme.xaml 提供默认 Brush。宿主可以合并并覆盖 Theme Brush,但不能删除 NuGet 中的 Lucide 许可证。来源与校验见 五端 UI 资源说明

10. 发布验收

  • 干净的 WPF 项目只装 XHIM.SDK/XHIM.UI.Wpf 即可 build/publish/run;WinUI 仅验收 Headless SDK 接入,不宣称有现成 UI 包;
  • win-x64 和 win-arm64 在真实目标系统分别验证;
  • DLL 导出、C struct 布局、CallingConvention、SafeHandle 和 callback GC 压力测试通过;
  • 单文件发布、裁剪和 AOT 若宣称支持,必须单独验证 native asset 与 callback;
  • Windows Defender/企业签名环境下安装、升级和回滚通过;
  • 客户 NuGet 包包含 LICENSE、NOTICE、Hash、签名和 SBOM;PDB/Source Link/ .snupkg 已单独进入受控内部符号服务;
  • 删除 UI 包后 Headless SDK 功能完整。

11. OfflineReader 平台契约

XHIMOfflineReader 使用当前 C ABI 的 read-only handle,复用 XHIMClient 的深拷贝投影器,覆盖消息、搜索、会话和六类社交分页。

  • 所有 P/Invoke 磁盘调用由 Task.Run 放到线程池,SemaphoreSlim 保证同句柄 串行;
  • borrowed page 在 native 调用作用域内完成托管深拷贝;
  • 类型实现 IAsyncDisposableDisposeAsync() 幂等;SafeHandle 只作为异常 遗漏释放时的后台 finalizer 兜底;
  • Key 由 XHIMOfflineDatabaseKeyProvider 从 DPAPI/Credential Manager 等安全 层注入,临时数组用 CryptographicOperations.ZeroMemory 清理;
  • CancellationToken 在排队前和 native 返回后生效,不会为取消而提前释放 串行锁;未知 status 保留原始 Code

业务代码见 Windows 快速接入。禁止把正式 Key 写入源码、Registry、appsettings.json、命令行或日志。

XHIM 客户端 SDK 与服务端文档