Quickstart
This page starts from a new Tauri 2 project and builds a desktop app that sends and receives messages: register the plugins, grant window permissions, add the engine page, sign in to IM from a window, show conversations and messages, send text and files, open a conversation when a notification is clicked, and finally the configuration each system needs for packaging.
Before you start
- In the console, use the
demoapp created when you signed up, or create an app. Under "Access credentials" on the Overview, copy the AppKey (such as1575529652#demo) and the IM service address, shown as "REST endpoint" (such ashttps://im.example.com). - Give your server a "get IM sign-in ticket" endpoint that issues a sign-in ticket for the current user; see the Web SDK Quickstart for how. This endpoint must allow cross-origin requests from the desktop app's pages, whose origins are
tauri://localhost(macOS, Linux) andhttp://tauri.localhost(Windows), and during development the dev server address (such ashttp://localhost:1420). - Install Rust and each system's dependencies as Tauri describes, and create a Vite + TypeScript project from the official template:
npm create tauri-app@latest im-desktop -- --template vanilla-ts --manager npm
cd im-desktopThe UI code on this page uses no front-end framework. With React or Vue, use the Web SDK bindings for the UI part; see Using it in React and Vue.
Install
Front-end dependencies:
npm install @deeprespond/im-tauri @deeprespond/im-web @tauri-apps/apiRust dependencies go in src-tauri/Cargo.toml. The plugin is published on crates.io, and its version must match the npm package @deeprespond/im-tauri, so pin the exact version (see npm and crates.io for the current one):
[dependencies]
tauri = { version = "2.12", features = [] }
tauri-plugin-deeprespond-im = "=1.1.1"
tauri-plugin-single-instance = "2"The single-instance plugin isn't required but is recommended; see Run only one instance for why.
Register the plugins
Register the single-instance plugin and the IM plugin in src-tauri/src/lib.rs:
// 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_urlis the engine page's path among the front-end assets. A remotehttp://orhttps://address makes plugin initialization fail and the app error at startup (development builds allow the local dev server's address), so remote pages can't get the sign-in tokens.- The plugin creates the engine window automatically after the app starts. Its label is always
deeprespond-im-engine; don't create a window of your own with that label. - All three settings above are the defaults and can be left out. For every setting, see Rust settings.
Grant window permissions
UI windows need the plugin permission deeprespond-im:window to use the SDK. Add it for your windows in src-tauri/capabilities/default.json (keep the template's other permissions, such as 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"
]
}windowslists the labels of windows that use the SDK;*works as a wildcard, such aschat-*for conversation windows you open later. In a window without the permission, calls fail withpermission_denied(capability_missing) after the client is created.- The plugin grants the engine window's permissions automatically; you don't write them.
- Don't grant it to windows that load remote web pages (such as an embedded help page or a third-party site): a page with this permission can send and receive messages as the current user and read local chat history.
core:window:allow-showand the other two bring the window to the front when a notification is clicked (see below); remove them if you don't need that.
Add the engine page
The engine page is a hidden page that only creates the engine client; there's one for the whole app. It's another entry point of your front end, built together with the UI pages. Create engine.html next to index.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(),
});Add the engine page to the build inputs in vite.config.ts (keep the template's existing settings such as clearScreen and server):
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
// ……模板中原有的设置
build: {
target: 'es2022',
rollupOptions: {
input: { main: 'index.html', engine: 'engine.html' },
},
},
});- Options such as the server address, notification mode and badges are all set in the engine page; for every option, see Engine page options.
- Keep the engine page small: only import the SDK and alert-related code, no UI framework, and show nothing.
- If you don't need to customize it, copy the template
@deeprespond/im-tauri/templates/engine.htmlfrom the npm package; it reads{ appKey, apiUrl }fromim-config.jsin the same directory.
ticketProvider runs in the engine page, and the engine page and UI windows don't share memory. So keep your own system's sign-in token somewhere both can read, such as localStorage (shared by the windows of the same app) or your own Rust command. This page puts the function that gets a ticket in a module both use:
// 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;
}Use it in a window
A UI window creates its client with createDRTauriClient(), which only needs the AppKey; the engine decides the server address and the rest. From there it works exactly like the 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);
}- Whichever window calls sign-in, the engine performs it. After signing in, the sign-in is restored automatically when windows close and when the app starts again, so you only need to sign in when
im.auth.currentUserisnull. - The engine is ready soon after the app starts. Until then, calls in a window that need the server wait in a queue for up to 10 seconds, then fail with
engine_unavailable. Reading local data (the conversation list, messages) doesn't wait for the engine. - A window can have only one client per AppKey, released automatically when the window closes.
Full example
Put the following files into the project above, replace the AppKey, server address and ticket endpoint, and run 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);
}
}In this example:
- The conversation list and message list are written the same way as in the Web SDK Quickstart.
- When a new message arrives while the app isn't in the foreground, the SDK shows a system notification automatically, with no code from you. When it's clicked, the window gets
desktop.notificationClicked; callclaim()to say you handled it, otherwise the plugin shows the main window. See Notification clicks. - Files are picked with
im.desktop.pickFiles()and sent by local path,{ path }, so even large files never load into page memory. See Send local files. - Desktop events such as
engine.stateChangedanddesktop.notificationClickedhave exact payload types inim.on()(DesktopEvents).
Content security policy
Tauri's content security policy (CSP) goes in app.security.csp in src-tauri/tauri.conf.json and applies to the engine page and every window. If you set one, allow the addresses the SDK uses:
{
"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'"
}
}
}| Directive | Addresses to allow |
|---|---|
connect-src | Tauri's ipc: and http://ipc.localhost; the IM service address (including wss://); your own server; for audio and video, the media service address; if windows use im.files.fetchBlob(), the file storage and CDN addresses |
img-src, media-src | File storage and CDN addresses (showing images, playing video); the plugin's local file protocol drim-file:, written http://drim-file.localhost on Windows, so include both (for previewing local files and capturing video covers; see Local file previews) |
worker-src | blob:: the SDK uses Workers for timers and file processing |
The Rust plugin does the file uploads and downloads, which aren't subject to CSP, so connect-src doesn't need the file storage addresses for them.
Run only one instance
Only one process may use a given set of local data. When the user starts the app a second time, the second process doesn't create an engine: calls in its windows that need the server fail with invalid_state (details.reason is another_instance), and im.engineState is failed.
So also use Tauri's single-instance plugin, as in Register the plugins: in its callback, call tauri_plugin_deeprespond_im::handle_args(app, &argv), then show the existing main window. On Windows, clicking a notification may make the system start the app again with arguments; those arguments must go to the plugin through handle_args for the click to reach the running app.
Packaging and signing
Build installers for each system with npm run tauri build. Note the following configuration per system.
Windows
- Notifications only work properly after installing. Windows requires apps to be registered with the system for notifications, which Tauri's NSIS or MSI installers do. In versions run directly during development (such as
npm run tauri dev), notifications appear to come from PowerShell and clicks don't return to the app; executables run without installing may not show notifications at all. - Clicking notifications after the app exits. When a notification left in Action Center is clicked after the app has exited, it only starts the app; it doesn't open the conversation.
- Signing. SmartScreen blocks unsigned installers; sign them with a code-signing certificate.
- WebView2. For intranet environments, bundle the WebView2 runtime in the installer; see Requirements.
- Taskbar. Badge icons and flashing on the taskbar attach to a window that has a taskbar button. When every window is closed (minimized to the tray), there's no badge or flashing, only the tray icon; see Tray.
macOS
Usage descriptions. Put the microphone and camera usage descriptions in
src-tauri/Info.plistand point to it frombundle.macOS.infoPlistintauri.conf.json. Apps that only send voice messages and don't make calls still need the microphone description, or the system kills the app when it records: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>Deployment target.
bundle.macOS.minimumSystemVersionmust be at least12.0.Signing and notarization. Sign with a fixed certificate and notarize. When the signature changes, the system asks whether to allow access to the sign-in saved in the keychain, so sign development builds with the same development certificate too; unsigned apps may show notifications without the app's name and icon. Tauri turns on the Hardened Runtime when signing, so apps that record or make calls need
com.apple.security.device.audio-inputin their entitlements, pluscom.apple.security.device.camerafor video calls.Notifications need the
.app.npm run tauri devruns the executable directly, and system notifications don't show. To test notifications, build a.appwithnpm run tauri build -- --debugand run that.Sandbox (apps on the Mac App Store): also needs the entitlements for network access
com.apple.security.network.client, user-selected filescom.apple.security.files.user-selected.read-write, and the Downloads foldercom.apple.security.files.downloads.read-write. In the sandbox, user-selected files are only readable during the current run, so a pending attachment over 200 MB that was only recorded by path may be unreadable after a restart, and that message fails withsend_abandoned(attachment_lost).
Linux
- Packages. Ship
.debor AppImage. The system needs WebKitGTK 4.1 (2.40 or later). Flatpak and Snap aren't supported yet. - Keychain. The key for the saved sign-in is stored in the Secret Service (GNOME Keyring, KWallet). On desktop environments without a running Secret Service (such as a bare window manager), it's stored by default in a file only the user can read; see Tokens and local data.
- Notifications. Shown by the desktop environment's notification service; whether notification buttons (such as Answer and Decline for calls) appear depends on that service.
- Launcher badges. Need the app's
.desktopfile on the system; see Badges. - Recording. Depends on GStreamer's audio encoding plugins; without them
im.files.recordVoice()rejects withunsupported. - Audio and video calls. Not supported yet; see Platform limits.
Debugging
- The engine window is hidden, so you can't see its console. During development, turn on the log file (
.log_file(true)in Rust); the engine page, every window and the plugin write to the same set of files. See Diagnostics and logs. - After you edit the engine page, the dev server's hot reload reloads it, and the plugin treats that as an engine rebuild: calls in windows wait briefly in a queue and then continue automatically.
- Only one instance can run per system user on a computer. To have two users message each other, run it on another computer, or sign in as the other user in a browser with the Web SDK.
Next steps
- Engine and windows: every engine page option, multiple windows, who alerts, exiting and tray apps, engine rebuilds.
- Desktop features: system notifications, badges and the tray, local files, downloading and opening, diagnostics.
- The Web SDK's Conversations, Messages and Events and errors: full usage of each module.
Standalone channel window lifecycle
The media connection of standalone RTC channels only runs in UI windows; the engine only forwards requests that need the user's sign-in, and the engine window can't join channels. When a window closes, is frozen by the system, the client is destroyed or the engine restarts, the SDK disconnects the media; if the process is force-killed, the server may not be told it left. Minimizing a window doesn't count as leaving.
When the client is destroyed, the SDK waits up to 5 seconds for the channel to leave; this is separate from the cleanup time when the app exits. Test device permissions and WebView capabilities on each target system; for full details, see Standalone channels.
