主题
晞晗IM(XHIM)Windows 接入指南
第一次在空白 Windows 工程接入? 请先看 Windows 从零快速接入。本页保留 P/Invoke、SafeHandle、 native runtime、打包和高级二次开发细节。
ListDeviceSessionsAsync、RevokeDeviceSessionAsync、取消及同步策略示例见 Windows QuickStart 的登录设备管理。
当前状态:
.NET 8C# 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.jsonNuGet 会从 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-x64 和 dotnet 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-failurearm64 使用独立目录和 -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=development 和 development_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;公开状态事件仍会正常 投递。LoginAsync、LogoutAsync、ShutdownAsync、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 均带服务端 Sequence 和 ExpiresAtMilliseconds。状态不落 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会调用 nativecancel_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前不可读取对象;- 完成后只通过
XHIMMediaCacheReader的ReadAsync/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 临时路径永远存在;
XHIMServerMediaUploader、IXHIMMediaUploader和带 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 调用作用域内完成托管深拷贝;
- 类型实现
IAsyncDisposable,DisposeAsync()幂等;SafeHandle 只作为异常 遗漏释放时的后台 finalizer 兜底; - Key 由
XHIMOfflineDatabaseKeyProvider从 DPAPI/Credential Manager 等安全 层注入,临时数组用CryptographicOperations.ZeroMemory清理; CancellationToken在排队前和 native 返回后生效,不会为取消而提前释放 串行锁;未知 status 保留原始Code。
业务代码见 Windows 快速接入。禁止把正式 Key 写入源码、Registry、appsettings.json、命令行或日志。