快速集成
本页从一个新建的 Tauri 2 工程开始,做一个能收发消息的桌面应用:注册插件、给窗口授权、添加引擎页、在窗口中登录 IM、显示会话和消息、发送文字和文件、点击通知打开会话,最后说明各系统打包时要做的配置。
准备工作
- 在控制台创建应用,在应用详情中记下 AppKey(如
1575529652#demo)和 IM 服务的地址(如https://im.example.com)。 - 业务服务端提供一个“获取 IM 登录凭证”的接口,为当前用户签发登录凭证,写法见 Web SDK 的快速集成。这个接口要允许来自桌面应用页面的跨域请求,页面的来源是
tauri://localhost(macOS、Linux)和http://tauri.localhost(Windows),开发时是开发服务器的地址(如http://localhost:1420)。 - 按 Tauri 的说明装好 Rust 和各系统的依赖,用官方模板创建一个 Vite + TypeScript 的工程:
npm create tauri-app@latest im-desktop -- --template vanilla-ts --manager npm
cd im-desktop本页的界面代码不用前端框架;用 React、Vue 的,界面部分换成 Web SDK 的绑定包即可,见在 React 和 Vue 中使用。
安装
前端依赖:
npm install @deeprespond/im-tauri @deeprespond/im-web @tauri-apps/apiRust 依赖写在 src-tauri/Cargo.toml 中。插件的版本必须与 npm 包 @deeprespond/im-tauri 相同,请写确切的版本号:
[dependencies]
tauri = { version = "2.12", features = [] }
tauri-plugin-deeprespond-im = "=2.0.0"
tauri-plugin-single-instance = "2"单实例插件不是必需的,但建议使用,原因见只运行一个实例。
注册插件
在 src-tauri/src/lib.rs 中注册单实例插件和 IM 插件:
// src-tauri/src/lib.rs
use tauri::Manager;
fn show_main<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
if let Some(w) = app.get_webview_window("main") {
let _ = w.unminimize();
let _ = w.show();
let _ = w.set_focus();
}
}
pub fn run() {
tauri::Builder::default()
// 单实例插件最先注册:再次启动应用时,把参数交给 IM 插件(其中可能有通知的点击),并显示主窗口
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
tauri_plugin_deeprespond_im::handle_args(app, &argv);
show_main(app);
}))
.plugin(
tauri_plugin_deeprespond_im::Builder::new()
.engine_url("engine.html") // 引擎页,必须是打包在应用中的页面
.main_window("main") // 通知点击没有窗口处理时,显示并聚焦这个窗口
.exit_when_last_window_closed(true) // 关闭最后一个窗口时退出;缩到托盘的应用设为 false
.build(),
)
.run(tauri::generate_context!())
.expect("error while running tauri application");
}engine_url是引擎页在前端资源中的路径。写成http://、https://的远程地址时插件初始化失败、应用启动报错(开发构建允许本机开发服务器的地址),防止远程页面拿到登录令牌。- 插件在应用启动后自动创建引擎窗口,窗口的标签固定为
deeprespond-im-engine,不要用这个标签创建你自己的窗口。 - 以上三项都是默认值,可以省略。全部设置见Rust 侧的设置。
给窗口授权
界面窗口要获得插件的权限 deeprespond-im:window 才能使用 SDK。在 src-tauri/capabilities/default.json 中给你的窗口加上它(模板中原有的其他权限,如 opener:default,照常保留):
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "界面窗口",
"windows": ["main", "chat-*"],
"permissions": [
"core:default",
"core:window:allow-show",
"core:window:allow-unminimize",
"core:window:allow-set-focus",
"deeprespond-im:window"
]
}windows列出使用 SDK 的窗口标签,可以用*通配,如以后打开的会话窗口chat-*。没有授权的窗口创建客户端后,调用以permission_denied(capability_missing)失败。- 引擎窗口的权限由插件自动授予,不需要写。
- 加载远程网页的窗口不要授予(如内嵌的帮助页、第三方网站):有了这个权限的页面可以以当前用户的身份收发消息、读取本地的聊天记录。
core:window:allow-show等三项用于点击通知后把窗口显示到前面(见下文),不需要时可以去掉。
添加引擎页
引擎页是一个不显示的页面,只创建引擎客户端,整个应用一个。它是前端的另一个入口,和界面窗口的页面一起构建。在 index.html 旁边新建 engine.html:
<!-- engine.html -->
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>engine</title>
</head>
<body>
<script type="module" src="/src/engine.ts"></script>
</body>
</html>// src/engine.ts
import { createDRTauriEngine } from '@deeprespond/im-tauri/engine';
import { fetchTicket } from './ticket';
createDRTauriEngine({
appKey: '1575529652#demo',
apiUrl: 'https://im.example.com',
appVersion: '1.0.0',
// 凭证失效、或登录状态过期时,SDK 用它重新取一次凭证
ticketProvider: () => fetchTicket(),
});在 vite.config.ts 中把引擎页加入构建的入口(模板中原有的 clearScreen、server 等设置保持不变):
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
// ……模板中原有的设置
build: {
target: 'es2022',
rollupOptions: {
input: { main: 'index.html', engine: 'engine.html' },
},
},
});- 服务端地址、通知方式、角标等选项都在引擎页中设置,全部选项见引擎页的选项。
- 引擎页应尽量小:只引入 SDK 和提醒相关的代码,不引入界面框架,不显示界面。
- 不需要定制的,也可以直接复制 npm 包中的模板
@deeprespond/im-tauri/templates/engine.html,它从同一目录的im-config.js读取{ appKey, apiUrl }。
ticketProvider 在引擎页中运行,引擎页和界面窗口不共享内存。所以业务系统的登录令牌要放在两边都能读到的地方,如 localStorage(同一应用的窗口共用)或你自己的 Rust 命令。本页把取凭证的函数放在一个两边共用的模块中:
// src/ticket.ts:向业务服务端取 IM 登录凭证(假设业务系统的令牌在登录后保存在 localStorage 中)
export async function fetchTicket(): Promise<string> {
const res = await fetch('https://app.example.com/api/im-ticket', {
method: 'POST',
headers: { Authorization: `Bearer ${localStorage.getItem('app_token') ?? ''}` },
});
if (!res.ok) throw new Error(`取凭证失败:${res.status}`);
return ((await res.json()) as { ticket: string }).ticket;
}在窗口中使用
界面窗口用 createDRTauriClient() 创建客户端,只需要 AppKey,服务端地址等由引擎决定。之后的用法与 Web SDK 完全相同:
import { createDRTauriClient } from '@deeprespond/im-tauri';
import { fetchTicket } from './ticket';
const im = createDRTauriClient({ appKey: '1575529652#demo' });
if (!im.auth.currentUser) {
const { user } = await im.auth.loginWithTicket({ ticket: await fetchTicket() });
console.log('已登录', user.username);
}- 登录在哪个窗口调用都由引擎执行。登录后关闭窗口、再次启动应用,都会自动恢复登录,所以只在
im.auth.currentUser为null时才需要登录。 - 引擎在应用启动后很快就绪;在这之前窗口中需要服务端的调用排队等待,最长 10 秒,超时以
engine_unavailable失败。读取本地数据(会话列表、消息)不需要等引擎。 - 一个窗口中每个 AppKey 只能有一个客户端,窗口关闭时自动释放。
完整示例
把以下文件放进上面的工程,替换其中的 AppKey、服务地址和取凭证的接口,运行 npm run tauri dev。
<!-- index.html -->
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>IM 示例</title>
</head>
<body>
<p id="status">未登录</p>
<button id="logout">退出</button>
<ul id="conversations"></ul>
<form id="open"><input id="peer" placeholder="对方用户名"> <button>开始聊天</button></form>
<div id="messages"></div>
<form id="send"><input id="text" placeholder="消息"> <button>发送</button> <button id="file" type="button">文件</button></form>
<script type="module" src="/src/main.ts"></script>
</body>
</html>// src/main.ts
import { createDRTauriClient, DRError, type LiveList, type ConversationView, type MessageList } from '@deeprespond/im-tauri';
import { conversationTitle, displayName, summarize } from '@deeprespond/im-web/render';
import { describeError } from '@deeprespond/im-web/locale/zh-CN';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { fetchTicket } from './ticket';
const $ = (id: string) => document.getElementById(id) as HTMLElement;
const value = (id: string) => (document.getElementById(id) as HTMLInputElement).value.trim();
const showError = (err: unknown) => alert(err instanceof DRError ? describeError(err) : String(err));
const im = createDRTauriClient({ appKey: '1575529652#demo' });
// ---- 状态 ----
function showStatus() {
const user = im.auth.currentUser?.username ?? '未登录';
$('status').textContent = `${user} · ${im.connection.state.kind}`;
}
im.on('connection.stateChanged', showStatus);
im.on('auth.stateChanged', (state) => {
showStatus();
if (state.kind === 'signed_in') startConversationList();
if (state.kind === 'ended') alert(describeError({ code: state.code, reason: state.reason }));
});
im.on('auth.userSwitched', () => startConversationList());
im.on('engine.stateChanged', ({ state }) => {
if (state === 'failed') $('status').textContent = 'IM 服务启动失败,请重新启动应用';
});
// ---- 会话列表 ----
let conversations: LiveList<ConversationView> | null = null;
function startConversationList() {
conversations?.dispose();
conversations = im.conversations.list();
conversations.subscribe(renderConversations);
renderConversations();
}
function renderConversations() {
const me = im.auth.currentUser?.username;
const items = conversations?.getSnapshot().items ?? [];
$('conversations').replaceChildren(...items.map((c) => {
const li = document.createElement('li');
const last = c.last_message ? summarize(c.last_message, { nameOf: (u) => displayName(im, u), self: me }) : '';
li.textContent = `${conversationTitle(im, c)}${c.unread_count ? ` (${c.unread_count})` : ''}:${last}`;
li.onclick = () => openConversation(c.conversation_key);
return li;
}));
}
// ---- 消息列表 ----
let current: MessageList | null = null;
function openConversation(key: string) {
current?.dispose();
const list = im.messages.open(key);
current = list;
const renderMessages = () => {
$('messages').replaceChildren(...list.getSnapshot().items.map((m) => {
const p = document.createElement('p');
const text = m.type === 'text' ? String(m.body?.text ?? '') : summarize(m, { nameOf: (u) => displayName(im, u) });
p.textContent = `${displayName(im, m.sender)}:${text}${m._local.status === 'sent' ? '' : `(${m._local.status})`}`;
return p;
}));
};
list.subscribe(renderMessages);
renderMessages();
list.setViewing({ visible: true, atBottom: true });
list.markRead();
}
$('open').onsubmit = async (e) => {
e.preventDefault();
const view = await im.conversations.openSingle(value('peer'));
openConversation(view.conversation_key);
};
// ---- 发送文字 ----
$('send').onsubmit = async (e) => {
e.preventDefault();
const text = value('text');
if (!current || !text) return;
(document.getElementById('text') as HTMLInputElement).value = '';
try {
await im.messages.send({ conversation_key: current.conversation_key }, { type: 'text', body: { text } });
} catch (err) {
showError(err);
}
};
// ---- 发送文件:系统的选择对话框给出本地路径,文件内容不进入页面的内存 ----
$('file').onclick = async () => {
if (!current) return;
try {
for (const path of await im.desktop.pickFiles({ multiple: true })) {
await im.messages.send({ conversation_key: current.conversation_key }, { type: 'file', file: { path } });
}
} catch (err) {
showError(err);
}
};
// ---- 点击系统通知:打开会话,把窗口显示到前面 ----
im.on('desktop.notificationClicked', (e) => {
if (!e.conversation_id) return;
e.claim();
openConversation(e.conversation_id);
const win = getCurrentWindow();
void win.unminimize().then(() => win.show()).then(() => win.setFocus());
});
// ---- 退出 ----
$('logout').onclick = async () => {
current?.dispose();
current = null;
$('messages').replaceChildren();
await im.auth.logout();
};
// ---- 启动:已登录的自动恢复,否则用凭证登录 ----
showStatus();
if (im.auth.currentUser) {
startConversationList();
} else {
try {
await im.auth.loginWithTicket({ ticket: await fetchTicket() });
} catch (err) {
$('status').textContent = err instanceof DRError ? describeError(err) : String(err);
}
}这个示例中:
- 会话列表、消息列表的写法与 Web SDK 的快速集成相同;
- 应用不在前台时收到新消息,SDK 自动显示系统通知,不需要写代码;点击通知时窗口收到
desktop.notificationClicked,调用claim()表示已处理,否则插件会显示主窗口,见通知的点击; - 选择文件用
im.desktop.pickFiles(),发送时传本地路径{ path },大文件也不会读进页面的内存,见发送本地文件; engine.stateChanged、desktop.notificationClicked等桌面事件在im.on()中有确切的载荷类型(DesktopEvents)。
内容安全策略
Tauri 的内容安全策略(CSP)写在 src-tauri/tauri.conf.json 的 app.security.csp 中,对引擎页和全部窗口生效。设置了 CSP 的,要允许 SDK 用到的地址:
{
"app": {
"security": {
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https://im.example.com wss://im.example.com https://app.example.com; img-src 'self' data: blob: drim-file: http://drim-file.localhost https://files.example.com; media-src 'self' blob: drim-file: http://drim-file.localhost https://files.example.com; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'"
}
}
}| 指令 | 要允许的地址 |
|---|---|
connect-src | Tauri 的 ipc: 和 http://ipc.localhost;IM 服务的地址(含 wss://);你的业务服务端;使用音视频的,媒体服务的地址;在窗口中用 im.files.fetchBlob() 的,文件存储和 CDN 的地址 |
img-src、media-src | 文件存储和 CDN 的地址(显示图片、播放视频);插件的本地文件协议 drim-file:,Windows 上的写法为 http://drim-file.localhost,两个都写(预览本地文件、截取视频封面,见本地文件的预览) |
worker-src | blob::SDK 在 Worker 中计时和处理文件 |
文件的上传和下载由 Rust 插件完成,不受 CSP 限制,connect-src 中不需要为它们允许文件存储的地址。
只运行一个实例
同一份本地数据只允许一个进程使用。用户第二次启动应用时,第二个进程不创建引擎,它的窗口中需要服务端的调用以 invalid_state(details.reason 为 another_instance)失败,im.engineState 为 failed。
所以请同时使用 Tauri 的单实例插件,并像注册插件中那样:在它的回调中调用 tauri_plugin_deeprespond_im::handle_args(app, &argv),再显示已有的主窗口。Windows 上点击通知时,系统可能以带参数的方式再启动一次应用,这些参数要经 handle_args 交给插件,点击才能送到已在运行的应用中。
打包与签名
用 npm run tauri build 生成各系统的安装包。各系统要注意以下配置。
Windows
- 用安装包安装后通知才正常:Windows 的通知要求应用在系统中登记过,Tauri 的 NSIS 或 MSI 安装包会完成登记。开发时直接运行的版本(如
npm run tauri dev),通知显示为由 PowerShell 发出,点击回不到应用;没有经过安装、直接运行的可执行文件可能不显示通知。 - 应用退出后点击通知:操作中心里留下的通知在应用退出后被点击时,只会启动应用,不会打开对应的会话。
- 签名:未签名的安装包会被 SmartScreen 拦截,请用代码签名证书签名。
- WebView2:内网环境请让安装包内置 WebView2 运行时,见运行环境。
- 任务栏:任务栏上的数字图标和闪烁要依附在有任务栏按钮的窗口上。全部窗口都关闭(缩到托盘)时没有角标和闪烁,只能靠托盘图标,见托盘。
macOS
用途说明:在
src-tauri/Info.plist中写入麦克风和摄像头的用途说明,并在tauri.conf.json的bundle.macOS.infoPlist中指向它。只发语音消息、不做通话的应用也要写麦克风的说明,否则录音时应用会被系统终止:xml<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>NSMicrophoneUsageDescription</key> <string>用于语音消息和通话</string> <key>NSCameraUsageDescription</key> <string>用于视频通话</string> </dict> </plist>部署目标:
bundle.macOS.minimumSystemVersion不低于12.0。签名与公证:请用固定的证书签名并公证。签名变化后,系统会弹窗询问是否允许访问之前保存在钥匙串中的登录信息,开发时也请用同一个开发证书签名;未签名的应用,通知可能不显示应用的名称和图标。签名时 Tauri 默认开启 Hardened Runtime,使用录音、通话的应用要在 entitlements 中加入
com.apple.security.device.audio-input,视频通话另加com.apple.security.device.camera。通知要求从
.app运行:npm run tauri dev直接运行可执行文件,这时不显示系统通知。测试通知时用npm run tauri build -- --debug打出.app后运行。沙盒(上架 Mac App Store 的应用):另外需要网络访问
com.apple.security.network.client、用户选择的文件com.apple.security.files.user-selected.read-write、“下载”目录com.apple.security.files.downloads.read-write的权利。沙盒中,用户选择的文件只在本次运行中可读:超过 200 MB、只记了路径的待发附件在应用重启后可能读不到,这条消息以send_abandoned(attachment_lost)失败。
Linux
- 安装包:发布
.deb或 AppImage。系统要有 WebKitGTK 4.1(2.40 以上)。暂不支持 Flatpak、Snap。 - 钥匙串:登录信息的密钥保存在 Secret Service(GNOME 钥匙环、KWallet)中。没有运行 Secret Service 的桌面环境(如只有简单的窗口管理器)默认改存到只有本用户可读的文件中,见令牌与本地数据。
- 通知:由桌面环境的通知服务显示;通知上的按钮(如来电的“接听”“拒绝”)是否显示取决于通知服务。
- 启动器角标:需要系统中有应用的
.desktop文件,见角标。 - 录音:依赖 GStreamer 的音频编码插件,不可用时
im.files.recordVoice()以unsupported拒绝。 - 音视频通话:暂不支持,见各系统的限制。
调试
- 引擎窗口不显示,看不到它的控制台。开发时请打开日志文件(Rust 中
.log_file(true)),引擎页、各窗口和插件的日志写入同一组文件,见诊断与日志。 - 修改引擎页后,开发服务器的热更新会重新加载引擎页,插件按引擎重建处理:窗口中的调用短暂排队,之后自动恢复。
- 同一台电脑的同一个系统用户只能运行一个实例。要用两个用户互相发消息,请在另一台电脑上运行,或在浏览器中用 Web SDK 登录另一个用户。
下一步
- 引擎与窗口:引擎页的全部选项、多个窗口、谁来提醒、退出与托盘应用、引擎的重建;
- 桌面能力:系统通知、角标与托盘、本地文件、下载与打开、诊断;
- Web SDK 的会话、消息、事件与错误处理:各模块的完整用法。
独立频道窗口生命周期
独立频道的媒体只在界面窗口运行,引擎转发 HTTP 鉴权;隐藏引擎不支持媒体加入。窗口关闭、冻结、客户端销毁或引擎重启会清理媒体;不保证进程强杀时 HTTP 退出成功。普通最小化不等于离开。
频道主动销毁的退出尝试最多等待 5 秒,不替代应用整体退出的等待规则。各系统设备权限与 WebView 能力需验收,完整说明见独立频道。
