快速整合
本頁從一個新建的 Tauri 2 工程開始,做一個能收發訊息的桌面應用:註冊外掛、給視窗授權、新增引擎頁、在視窗中登入 IM、顯示會話和訊息、傳送文字和檔案、點選通知開啟會話,最後說明各系統打包時要做的配置。
準備工作
- 在控制台使用註冊時自動建立的
demo應用,或另外建立應用,在概覽的“接入憑證”中複製 AppKey(如1575529652#demo)和 IM 服務的地址,即“REST 接入地址”(如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 中。外掛釋出在 crates.io,版本必須與 npm 包 @deeprespond/im-tauri 相同,請寫確切的版本號(當前版本見 npm 與 crates.io):
[dependencies]
tauri = { version = "2.12", features = [] }
tauri-plugin-deeprespond-im = "=1.1.1"
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 的會話、訊息、事件與錯誤處理:各模組的完整用法。
獨立頻道視窗生命週期
獨立 RTC 頻道的媒體連線只在介面視窗中執行,引擎只負責轉發需要登入身份的請求,引擎視窗不能加入頻道。視窗關閉、被系統凍結、客戶端被銷燬或引擎重啟時,SDK 斷開媒體;程序被強制結束時不保證能通知服務端離開。視窗最小化不算離開。
銷燬客戶端時,SDK 最多等 5 秒讓頻道退出,這與應用退出時的收尾時間是兩回事。各系統的裝置許可權和 WebView 能力請在目標系統上實測,完整說明見獨立頻道。
