Desktop features
This page covers the desktop features the Tauri SDK adds on top of the Web SDK: system notifications and clicks, badges, attention requests and the tray, picking and sending files by local path, local file previews, downloading and opening files, how audio and video calls differ per system, sleep and network changes, diagnostics and logs, and desktop-specific events and errors.
These features are on the client's desktop property: im.desktop in UI windows and engine.client.desktop in the engine page, used the same way.
| You want to | See |
|---|---|
| Show system notifications, open the conversation when one is clicked | System notifications, Notification clicks |
| Handle calls when no window is open | Call notifications |
| Show the unread count on the Dock or taskbar | Badges |
| Flash the taskbar or tray icon | Attention, Tray |
| Pick, drag and drop, and send local files | Where files can come from, Send local files |
| Download files and open them with system apps | Downloading and opening |
| Handle sleep and wake, proxies, certificates | Network and proxies |
| Logs, diagnostics packages, common problems | Diagnostics and logs |
System notifications
Automatic mode
The engine option notifications.mode defaults to auto: the SDK shows system notifications itself, with no code from you. A new message shows a notification when all of these hold:
- The user's push settings and the conversation's Do Not Disturb say to alert, which is the result of
im.push.shouldNotify(); for the rules, see Alert decision in the Web SDK. - The user isn't looking at that conversation (see "Looking at" under Multiple windows).
- The window responsible for alerting isn't focused, or no window is visible (see Who alerts). While the user is using the app, no system notification pops up, and you can show something in the UI instead.
What notifications contain and how they behave:
- Title and body. The title is the sender or group name, and the body follows the user's preview setting; with previews off, just "你收到了一条新消息" ("You received a new message"). Chinese only; for other languages use the engine option
notifications.format, see Notification text. - Friend requests, group invitations and join requests also alert; turn them off with
notifications.notices: false. - One notification per conversation. A new notification replaces the conversation's old one, so a busy group doesn't fill up Notification Center.
- Sound. Uses the system's default notification sound; turn it off with
notifications.sound: false. New notifications in the same conversation within 3 seconds only replace the content without sounding again, and all notifications together sound at most once per second. - Attention. Along with the notification the SDK asks the system for attention (flashing taskbar button on Windows, bouncing Dock icon on macOS); turn it off with
attention: false. - On sign-out and user switch, the app's shown notifications are cleared and the badge is reset to zero.
- System Do Not Disturb (Focus Assist on Windows, Focus on macOS) is handled by the system; the SDK submits notifications as usual.
Automatic notifications are always submitted by the engine, whichever window is responsible for alerting, so they're never duplicated.
Notifications when the session ends
In automatic mode, when the account is pushed offline by a sign-in on another device or the sign-in expires and no window is visible, the engine shows a notification (such as "Your account signed in on another device"), and clicking it shows the main window. Otherwise an app sitting in the tray would silently stop receiving messages. When a window is visible, tell the user yourself in auth.stateChanged, as in the Web SDK's session end.
Notification clicks
When the user clicks a notification (or a button on it), the engine page and every UI window get desktop.notificationClicked. The window that handles the click calls claim() to claim it:
import { getCurrentWindow } from '@tauri-apps/api/window';
im.on('desktop.notificationClicked', (e) => {
// 有多个窗口时只让主窗口处理,避免每个窗口都打开会话
if (getCurrentWindow().label !== 'main' || !e.conversation_id) return;
e.claim();
openConversation(e.conversation_id); // 正式会话的 ID 就是 conversation_key
const win = getCurrentWindow();
void win.unminimize().then(() => win.show()).then(() => win.setFocus());
});- When no window claims it. If no page calls
claim()within 300 milliseconds (for example all windows are closed), the plugin calls theon_notification_clickyou registered in Rust. Without one, it shows and focuses the main window (main_window; if that window doesn't exist, it callson_main_window_missing). - Bringing the window to the front. Hidden and minimized windows still receive the event. Calling
show()andsetFocus()in a window needs permissions such ascore:window:allow-show; see Grant window permissions. - Kinds of clicks.
kindismessage(a new message, withconversation_idandseq),notice(a friend request, group invitation or join request, withuserorgroup_idindata),call(an incoming call; see Call notifications),session(the session ended) orcustom(a notification you showed withdesktop.notify()). - App launched by a click. macOS Notification Center keeps notifications after the app exits, and clicking one starts the app; the first window to create a client gets the click, with
launchedtrue. On Windows, clicking a notification after the app exits only starts the app without passing on what was clicked.
Handle unclaimed clicks in Rust, for example to reopen the main window with the conversation after it was closed:
let im = tauri_plugin_deeprespond_im::Builder::<tauri::Wry>::new()
.on_notification_click(|app, click| {
let url = match &click.conversation_id {
Some(id) => format!("index.html?conversation={id}"),
None => "index.html".to_string(),
};
let _ = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App(url.into()))
.title("IM")
.inner_size(1000.0, 700.0)
.build();
})
.build();click has id, kind, conversation_id, seq, action, data and launched, with the same meanings as above.
Manual mode
With notifications.mode set to manual, the SDK doesn't show notifications for new messages and requests; you decide:
- When a UI window is responsible for alerting, call
im.desktop.notify()inmessage.receivedbased onalertandnotify. - When the engine is responsible (no visible window), handle it in the engine page's
onAlert; see Show notifications yourself.
im.on('message.received', ({ message, notify, alert }) => {
const decision = notify as NotifyDecision | null;
if (!alert || !decision?.notify) return;
if (document.hasFocus() && message.conversation_id === currentConversation) return; // 正在看这个会话
void im.desktop.notify({
id: `msg:${message.conversation_id}`,
title: decision.title || '新消息',
body: decision.body ?? '',
conversation_id: message.conversation_id,
seq: message.seq ?? undefined,
});
});In automatic mode you can still show your own notifications (such as business alerts) with desktop.notify(); clicking them also emits desktop.notificationClicked, with kind custom.
Show a notification
desktop.notify() shows a system notification:
try {
await im.desktop.notify({
id: 'order-1001', // 同一 id 的新通知替换旧的
title: '订单已发货',
body: '你的订单 1001 已发货',
data: { order_id: '1001' }, // 点击时原样交回
actions: [{ id: 'view', title: '查看' }], // 点击按钮时事件的 action 为 view
});
} catch (err) {
if (err instanceof DRError && err.code === 'permission_denied') showToast('请在系统设置中允许本应用的通知');
}
// 清除一个会话的通知;不传参数时清除本应用的全部通知
await im.desktop.clearNotifications({ conversation_id: '99584445836689408' });idcan't start withdr:(used by the SDK's own notifications); without one, every call shows a new notification.datavalues are all strings, at most 1 KB in total;actionscan have up to 2 buttons. On Linux, whether buttons show depends on the desktop environment's notification service.
Notification permission
| System | Notes |
|---|---|
| macOS | The SDK asks for notification permission before showing the first notification. If the user declines, showing notifications fails with permission_denied (notification_denied) and no further prompt appears; the user must turn it on in System Settings. The app must run from a signed .app; see Packaging and signing |
| Windows | The app must be installed through an installer; see Packaging and signing. If the user or group policy turned off the app's notifications, it fails with permission_denied (notification_disabled) |
| Linux | Shown by the desktop environment's notification service, with no permission step |
im.desktop.notificationPermission() reads the current state: granted, denied, or default when not asked yet (macOS) or unknown; always granted on Linux. im.desktop.requestNotificationPermission() asks for permission on macOS and returns the current state on other systems. Use these to prompt the user on a settings page.
Call notifications
When a call comes in with no visible window, the engine is responsible for alerting. With notifications.calls at auto (the default), the SDK shows a notification with Answer and Decline buttons and plays a ringtone on a loop in the engine page (the file in notifications.ringtone, or one synthesized by the SDK). Once the call is answered, declined, cancelled or handled on another of the user's devices, the notification clears and the ringtone stops.
- Decline is done by the SDK directly, without a window.
- Answer must happen in a UI window. The plugin calls
on_call_actionin Rust (where you open a window), or shows the main window if it isn't registered, and also emitsdesktop.notificationClicked(kindcall,actionaccept,data.call_idthe call ID). The window finds the call and answers it:
im.on('desktop.notificationClicked', (e) => {
if (e.kind !== 'call' || e.action !== 'accept') return;
const call = im.calls.incoming.find((c) => c.call_id === e.data?.call_id);
if (!call) return;
e.claim();
void call.join();
});- Clicking the notification body (not a button) leaves
actionempty and is handled like a normal click, usually by showing the incoming call UI so the user can choose. - On Linux, if the notification service doesn't support buttons, only the notification shows, and clicking it opens a window.
- When a window is visible, that window handles the call on
call.incoming, as in the Web SDK's Receive calls. - To handle calls yourself when no window is open (such as popping up a small always-on-top window), set
notifications.callstomanual; see Handle calls yourself.
on_call_action is called when a button on a call notification is clicked, with action accept or decline and call_id the call ID. "Decline" has already been carried out by the SDK; this just tells you.
Badges
The engine option badge defaults to auto: the SDK shows the unread count where the system provides a place for it.
| System | Where | Notes |
|---|---|---|
| macOS | Number on the Dock icon | Belongs to the whole app; shows even without windows |
| Windows | Number overlay on the taskbar button; "99+" above 99 | Attaches to a window with a taskbar button. Not shown when every window is closed or hidden (in the tray); only the tray icon is left |
| Linux | Number on the launcher icon (Unity launcher API) | Needs desktop support (such as Ubuntu's Dock or KDE Plasma) and the app's .desktop file installed, named after the app's identifier, productName or executable name. Apps installed from .deb have it; AppImages run directly don't, and show no badge |
- What counts. Muted conversations aren't counted by default; set the engine option
badgeIncludesMuted: trueto count them. - Showing it yourself. With
badgeset tomanual, the SDK doesn't show it; set it withim.desktop.setBadge(count), and clear it withsetBadge(null)or0. - Unread changes. The engine and every window get
desktop.unreadChanged({ total, badge }, wheretotalis the total unread andbadgethe badge count computed as above), with changes within 500 milliseconds merged into one. In Rust, get the same numbers withon_unread_changedorapp.deeprespond_im().unread().
Attention
im.desktop.requestAttention() asks the system to get the user's attention: on Windows the taskbar button flashes until the window is focused; on macOS the Dock icon bounces once. With { critical: true }, Windows keeps flashing and macOS keeps bouncing until the user switches to the app. On Linux it sets the window's "needs attention" hint; under Wayland the compositor decides, and it may have no effect.
In automatic mode the SDK already does this when showing a notification (the attention option). On Windows, with no window that has a taskbar button, there's nothing to flash.
Tray
You create the tray icon and menu with Tauri's tray API; the SDK doesn't create a tray (see Minimize to the tray for an example). The SDK helps in two ways:
- Unread count: the
desktop.unreadChangedevent, andon_unread_changedin Rust, for updating the tray tooltip or menu. - Flashing tray icon:
im.desktop.trayBlink()alternates the tray icon between two icons, stopping and restoring the first icon automatically when any window is focused.
With every window closed, only the engine page is running, so flashing usually starts there:
// src/engine.ts
import { createDRTauriEngine } from '@deeprespond/im-tauri/engine';
const engine = createDRTauriEngine({ appKey: '1575529652#demo', apiUrl: 'https://im.example.com' });
// 未读数增加时开始闪烁,读完时停止
let lastBadge = 0;
engine.client.on('desktop.unreadChanged', ({ badge }) => {
if (badge > lastBadge) void engine.client.desktop.trayBlink({ trayId: 'main', icons: ['icons/tray.png', 'icons/tray-empty.png'] });
if (badge === 0) void engine.client.desktop.trayBlink(null);
lastBadge = badge;
});trayIdis the ID given when creating the tray (such asTrayIconBuilder::with_id("main")in Rust); an unknown one fails withlocal_validation(tray_not_found).iconsare the relative paths of two PNG icons in the app's resource directory, which must be bundled throughbundle.resourcesintauri.conf.json, such as"resources": ["icons/tray.png", "icons/tray-empty.png"].intervalMsis the switch interval, 500 milliseconds by default, settable from 200 to 5000.- Needs the plugin's
trayfeature (on by default); without it, calls fail withunsupported(tray_feature_disabled).
Files
Where files can come from
Besides Blob, everywhere the Web SDK accepts a file also accepts a local path. So that windows can't use the SDK to read any file on the computer, the plugin only accepts these paths:
| Source | Description |
|---|---|
im.desktop.pickFiles() | The system open-file dialog, returning the paths the user picked; an empty array if cancelled |
| Files dropped on the window | Received with onFilesDropped(); see below |
| Directories you allow in Rust | Allowed with allow_read_dir(path); every file in them can be read |
| The SDK's own directories | Downloaded files and processed temporary files |
Other paths are rejected with permission_denied (path_not_allowed). Paths returned by Tauri's dialog plugin aren't accepted; pick files with im.desktop.pickFiles(). Paths from picking and dropping are only valid for the current run; attachments already in the send queue stay readable after a restart.
// 选择图片:filters 按扩展名过滤
const paths = await im.desktop.pickFiles({
multiple: true,
filters: [{ name: '图片', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic'] }],
});
for (const path of paths) {
await im.messages.send({ conversation_key: conversationKey }, { type: 'image', file: { path } });
}Receive drops with onFilesDropped() exported by @deeprespond/im-tauri. It only passes on readable regular files dropped on this window, ignoring folders:
import { onFilesDropped } from '@deeprespond/im-tauri';
const off = onFilesDropped((paths) => {
for (const path of paths) {
void im.messages.send({ conversation_key: conversationKey }, { type: 'file', file: { path } });
}
});
// 不再接收时
off();The window must keep Tauri's drag and drop on (the window config's dragDropEnabled, on by default), or the plugin receives no drops. Paths from Tauri's own drag-and-drop events are accepted too.
Places to write work the same way: the system Downloads folder, paths returned by im.desktop.pickSavePath(), and directories you allow with allow_write_dir(path).
let im = tauri_plugin_deeprespond_im::Builder::<tauri::Wry>::new()
.allow_read_dir("/opt/myapp/exports") // 你的应用自己生成文件的目录
.allow_write_dir("/opt/myapp/inbox")
.build();Send local files
Give a file as { path, name?, type? } when sending image, voice, video and file messages, in im.files.upload() and im.files.prepare(), and when uploading avatars.
await im.messages.send(
{ conversation_key: conversationKey },
{ type: 'file', file: { path, name: '季度报告.pdf' } }, // name 省略时取文件名
);
const info = await im.files.upload({ path }, { purpose: 'attachment', kind: 'file' });- File contents never enter page memory. The plugin reads the file's information and type, and at upload time reads the content in chunks and sends it straight to file storage, so even large files don't use WebView memory.
- Checks at call time. Only whether the file exists, is readable and is within the size limit; otherwise the call rejects with
local_validation,reasonfile_not_found,file_unreadableortoo_large. nameandtype. Default to the file name, and the type is judged from the file's content, not its extension.Blobstill works. Pasted images, screenshots and otherBlobs are first written to a temporary file by the plugin, then handled by path.- Processing before upload. Same rules as the Web SDK's Processing before upload (compressing images, removing metadata such as location, reading video size and duration, capturing a cover), done by the plugin, with these differences:
- HEIC photos are converted to images only on Linux with the plugin's
heicfeature (and libheif 1.17 or later installed); otherwise they're sent as file messages automatically. - On Linux, videos given by path get no cover and are sent without one, which doesn't affect display.
- With the engine option
media.preprocessset, local files are read into memory first to be passed to it.
- HEIC photos are converted to images only on Linux with the plugin's
im.files.prepare({ path }, kind): the result has noblob;file.pathis the processed file (preview it withdesktop.fileUrl()), and the other fields are as in the Web SDK.
Attachments in the send queue. Once a message is in the send queue, the SDK makes sure it can still be sent after the app restarts:
- Attachments up to 200 MB (the engine option
outbox.persistAttachmentMaxBytes) are copied after processing and kept in the app data directory. - For larger ones only the original file's path is recorded. If the original is deleted or moved before sending, the message fails with
send_abandoned(attachment_lost). If it was modified (for example the user edited the file again), the uploaded part is discarded and the message fails withsend_abandoned(attachment_changed), so an inconsistent file is never sent. Ask the user to pick the file again.
Local file previews
To preview a picked image or video before sending, get an address with im.desktop.fileUrl(path) and put it in <img> or <video>:
img.src = im.desktop.fileUrl(path); // 如 drim-file://localhost/...,Windows 上为 http://drim-file.localhost/...- Only paths allowed for reading can be previewed, and only windows granted the plugin permission get a response.
- The content security policy's
img-srcandmedia-srcmust allowdrim-file:andhttp://drim-file.localhost; see Content security policy. - On Linux this address only works in
<img>and<video>, not withfetch.
Received images and videos are shown from download addresses as usual, as in the Web SDK's Showing and downloading files.
Downloading and opening
im.desktop.download() has the plugin download a file to disk, and suits files of any size:
const task = im.desktop.download({ message }, {
onProgress: (loaded, total) => {
if (total) progressBar.value = loaded / total;
},
});
cancelButton.onclick = () => task.cancel();
const { path, size } = await task; // 默认保存到系统的“下载”目录
console.log(path, size);
await im.desktop.revealPath(path); // 在文件管理器中显示- Source.
{ message }takes the file from a message. You can also pass a file address (such as a message'sbody.urlor a group file address), which the SDK exchanges for a download address first; other addresses are downloaded directly. - Where it's saved. By default the system Downloads folder; on a name clash it becomes "name (1).ext" (
conflict: 'overwrite'overwrites instead).saveTocan be a directory or full path, and must be the Downloads folder, a path returned byim.desktop.pickSavePath()(overwritten directly, since the system save dialog already asked), or a directory you allowed; otherwise it rejects withpermission_denied(path_not_allowed). - File name. Taken from the message, with path separators,
..and characters the system doesn't allow removed, so it never writes outside the target directory. - Resuming. If the network drops during the same run, the download continues from where it stopped; if the download address expired, the SDK gets a new one and continues. After an app restart it starts over.
- Cancelling. Call
task.cancel()or abort thesignal; the task rejects withabortedand the partial file is deleted. - Origin marking. Downloaded files are marked as coming from the internet the way the system does, so the system still warns when the user opens a program among them.
- To let the user choose where to save, call
pickSavePath()first:
const saveTo = await im.desktop.pickSavePath({ defaultName: String(message.body?.name ?? 'file') });
if (saveTo) await im.desktop.download({ message }, { saveTo });Opening and revealing. im.desktop.openPath(path) opens a file with the system's default program, and im.desktop.revealPath(path) shows it in the file manager. They only work on files the SDK downloaded and paths allowed for reading.
Executable and script files (such as .exe, .msi, .bat, .ps1, .js, .lnk, .app, .pkg, .dmg, .sh, .desktop, .deb, and programs with execute permission on macOS and Linux) can't be opened with openPath() and reject with permission_denied (executable_file). Running a file received in a chat with one click is a common attack, so reveal the file instead and warn the user:
try {
await im.desktop.openPath(path);
} catch (err) {
if (err instanceof DRError && err.reason === 'executable_file') {
await im.desktop.revealPath(path);
showToast('这是一个程序文件,请确认来源可信后再打开');
} else {
throw err;
}
}im.files.fetchBlob() still works in Tauri: the window fetches the content straight from the download address into memory, so it only suits small files. For large files use desktop.download().
Voice messages
Record with the Web SDK's im.files.recordVoice(); see Recording voice in the Web SDK.
- macOS:
Info.plistmust have the microphone usage description, or the system kills the app when it records; for signing entitlements, see Packaging and signing. - Windows: if the user turned off "Let desktop apps access your microphone" in system settings, recording rejects with
permission_required. - Linux: depends on GStreamer's audio encoding plugins; without them it rejects with
unsupported, so hide the record button.
Audio and video calls
Calls work as in the Web SDK's Audio and video calls. The media connection runs in the window where the user started or answered the call, and closing that window counts as hanging up; when the app exits, the engine hangs up the ongoing call first.
| System | Voice and video calls | Configuration needed |
|---|---|---|
| Windows | Supported | None. The plugin allows the app's own pages to use the camera and microphone automatically; "Let desktop apps access your camera/microphone" in system settings is up to the user |
| macOS | Supported | Camera and microphone usage descriptions in Info.plist, plus signing and sandbox entitlements; see Packaging and signing. The system asks the user on first use, and the WebView also asks once the first time a page uses a device |
| Linux | Not supported yet | — |
- Unsupported systems.
im.calls.supported(andim.desktop.info.calls_supported) isfalse, and starting or answering rejects withunsupported(calls_unavailable).call.incomingis still emitted for incoming calls, so you can say "Answer on your phone"; the user's other devices ring as usual. - Autoplay. Tauri's WebView allows sound without a user click by default, so ringtones and the other side's audio aren't blocked. If you changed WebView2's startup arguments (the window config's
additionalBrowserArgs), include--autoplay-policy=no-user-gesture-requiredyourself. - Busy is judged only by calls in progress in this app, as in the Web SDK.
- For calls with no visible window, see Call notifications.
Network and proxies
- Always treated as foreground. Minimizing windows or sitting in the tray doesn't affect the connection, and desktop apps don't register for offline push. If the user's phone is in the background it still gets pushes while the SDK alerts on the computer, so both alert; users who don't want that can mute on the phone.
- Sleep and wake. When the system wakes from sleep, the SDK checks the connection immediately, reconnects if there's no answer within 5 seconds, and then fills in messages from while it slept. It also emits
desktop.systemResumed({ slept_ms }). - Network changes. After changes such as switching from wired to wireless or connecting to a VPN, the SDK checks the connection immediately and emits
desktop.networkChanged({ online }). If the app starts at login before the network is up, it connects as soon as the network is ready. - Screen lock only affects "looking at"; it doesn't disconnect.
- Proxies. API requests and the connection come from the WebView and use the system proxy settings. File uploads and downloads come from the plugin and also use the system proxy by default, or one you set with
proxy(url)in Rust. If the two differ you may see "messages work but files don't". - Certificates. The plugin uses the system certificate store, like the WebView. If a corporate network installs its own root certificate, or a private deployment uses a self-signed certificate the system trusts, file uploads and downloads work too.
Diagnostics and logs
Logs. The engine's and each window's logs work as in the Web SDK, and the logger option can pass them to your own logging system; see Logging in the Web SDK. The engine window is hidden and you can't see its console, so we recommend turning on the log file in Rust:
let im = tauri_plugin_deeprespond_im::Builder::<tauri::Wry>::new()
.log_file(true) // 默认关闭
.build();- The engine, every window and the plugin write to the same set of files, one JSON entry per line, tagged with the source (
plugin,engine,window:{window label}). - Each file is up to 10 MB, 5 are kept, for up to 7 days.
- By default they're in
deeprespond_imunder the app's log directory; change it withlog_dir(path):
| System | Directory |
|---|---|
| Windows | %LOCALAPPDATA%\{identifier}\logs\deeprespond_im |
| macOS | ~/Library/Logs/{identifier}/deeprespond_im |
| Linux | ~/.local/share/{identifier}/logs/deeprespond_im |
Logs contain no tokens, passwords or message content.
Diagnostics package. When users report a problem, have them export a diagnostics package to attach:
const zip = await im.desktop.exportDiagnostics(); // 返回压缩包的路径,默认在应用的缓存目录中
await im.desktop.revealPath(zip);The package has the latest log files; the SDK, system and WebView versions; the engine and connection state; the proxy settings (without usernames and passwords); and the local database size. It contains no tokens or message content. saveTo sets the directory or path to save to, with the same rules as saveTo for downloads.
im.desktop.info returns system and version information synchronously: os, os_version, webview_version, sdk_version, calls_supported.
Common problems:
| Symptom | Check first |
|---|---|
| Messages work but files don't | Whether the WebView and plugin proxy settings match (see Network and proxies) |
| No notifications on Windows, or clicks do nothing | Whether the app was installed through an installer (see Packaging and signing) |
| No notifications on macOS | Whether it runs from a .app, is signed, and the user allowed notifications |
| Signing in again on every start on Linux | Whether the Secret Service is running, and whether secure_storage_fallback is Fallback::None (see Sign-in tokens) |
im.engineState stays starting or becomes failed | Engine page errors in the log file: whether engine_url is right, the engine page is in the build inputs, and the same AppKey is used |
Every call in a window fails with capability_missing | Whether the window was granted deeprespond-im:window in capabilities |
| Heartbeats are off or alerts are late | "Wake check behind" warnings in the log |
Events
Besides the Web SDK's events, the Tauri SDK has the following, with exact payload types in both im.on() and engine.client.on() (DesktopEvents, exported by @deeprespond/im-tauri).
| Event | Payload | Description |
|---|---|---|
engine.stateChanged | { state: 'starting' | 'ready' | 'restarting' | 'failed' } | The engine state changed; only emitted in UI windows. See Engine state |
desktop.notificationClicked | NotificationClickEvent | A notification or one of its buttons was clicked |
desktop.unreadChanged | { total: number; badge: number } | The total unread or badge count changed |
desktop.systemResumed | { slept_ms: number } | The system woke from sleep |
desktop.networkChanged | { online: boolean } | The network changed |
window.roleChanged | { isLeader: boolean; isAlertTab: boolean } | This window started or stopped being responsible for alerting; replaces the Web SDK's tabs.roleChanged. isLeader is always false in UI windows |
storage.full | { freed_users: number } | The disk is full; see Disk full |
storage.reset | reason adds corrupted, schema_downgrade, encryption_changed | The local database was rebuilt after corruption, a downgrade, or turning encryption on or off |
Errors
Besides the Web SDK's error codes (see Events and errors), the Tauri SDK has:
code | details.reason | When |
|---|---|---|
engine_unavailable | starting, restarting, failed | The engine wasn't ready within 10 seconds, or rebuilding stopped; see Engine state |
invalid_state | another_instance | Another process is using the same local data; see Single instance |
unsupported | version_mismatch | The npm package and Rust plugin versions differ |
unsupported | sqlcipher_required | Local database encryption was requested without the plugin's sqlcipher feature |
unsupported | not_engine_window, not_ui_window, not_tauri | The client was created in the wrong place |
unsupported | calls_unavailable | This system doesn't support audio and video calls (Linux) |
unsupported | tray_feature_disabled | trayBlink() was called with the plugin's tray feature off |
permission_denied | capability_missing | The window wasn't granted deeprespond-im:window |
permission_denied | path_not_allowed | The path is outside what's allowed for reading or writing |
permission_denied | executable_file | openPath() was used on an executable or script file |
permission_denied | notification_denied | The user declined notification permission on macOS |
permission_denied | notification_disabled | The user or group policy turned off the app's notifications on Windows |
local_validation | file_not_found, file_unreadable, too_large | The local file doesn't exist, can't be read, or is over the upload limit |
local_validation | reserved_id, too_large, invalid_value | In desktop.notify(), id starts with dr:, data is over 1 KB, or there are more than 2 buttons |
local_validation | tray_not_found | No tray icon matches the trayId given to trayBlink() |
send_abandoned | attachment_lost, attachment_changed | A large attachment recorded only by path was deleted or modified before sending |
storage_error | quota_exceeded, disk_full | Not enough disk space |
API reference
Methods returning a Promise reject with DRError on failure. Every method needs the window to have the plugin permission; otherwise it rejects with permission_denied (capability_missing).
im.desktop.notify()
Shows a system notification.
| Parameter | Type | Required | Description |
|---|---|---|---|
n | DesktopNotification | Yes | See DesktopNotification |
Returns: Promise<void>.
Errors: local_validation (required: missing title or body; reserved_id; too_large; invalid_value), permission_denied (notification_denied, notification_disabled).
im.desktop.clearNotifications()
Clears notifications shown by the app.
| Parameter | Type | Required | Description |
|---|---|---|---|
p.conversation_id | string | No | Only clear this conversation's notifications; clears all if omitted |
Returns: Promise<void>.
im.desktop.notificationPermission()
Reads the notification permission. Returns: Promise<'granted' | 'denied' | 'default'>; see Notification permission.
im.desktop.requestNotificationPermission()
Asks for notification permission on macOS; returns the current state on other systems. Returns: Promise<'granted' | 'denied'>.
im.desktop.setBadge()
Sets the badge when badge is manual.
| Parameter | Type | Required | Description |
|---|---|---|---|
count | number | null | Yes | A non-negative integer; null or 0 clears it |
Returns: Promise<void>. Errors: local_validation (invalid_value).
im.desktop.requestAttention()
Asks the system to get the user's attention; see Attention.
| Parameter | Type | Required | Description |
|---|---|---|---|
p.critical | boolean | No | Keep flashing or bouncing until the user switches to the app. Default false |
Returns: Promise<void>.
im.desktop.trayBlink()
Starts or stops flashing the tray icon; see Tray.
| Parameter | Type | Required | Description |
|---|---|---|---|
p | { trayId: string; icons: [string, string]; intervalMs?: number } | null | Yes | trayId is the tray's ID; icons are the relative paths of two icons in the resource directory; intervalMs is the interval, default 500, range 200–5000. null stops |
Returns: Promise<void>. Errors: local_validation (tray_not_found, file_not_found, invalid_path), unsupported (tray_feature_disabled).
im.desktop.pickFiles()
Opens the system file picker; the returned paths are allowed for reading.
| Parameter | Type | Required | Description |
|---|---|---|---|
p.multiple | boolean | No | Whether several files can be picked; default false |
p.filters | Array<{ name: string; extensions: string[] }> | No | Filter by extension, without the dot |
Returns: Promise<string[]>; an empty array if the user cancels.
im.desktop.pickSavePath()
Opens the system save dialog; the returned path is allowed for writing.
| Parameter | Type | Required | Description |
|---|---|---|---|
p.defaultName | string | No | Default file name |
Returns: Promise<string | null>; null if the user cancels.
im.desktop.fileUrl()
Synchronously returns a preview address for a local file; see Local file previews.
| Parameter | Type | Required | Description |
|---|---|---|---|
path | string | Yes | A path allowed for reading |
Returns: string.
im.desktop.download()
Downloads a file to disk; see Downloading and opening.
| Parameter | Type | Required | Description |
|---|---|---|---|
source | string | { message: MessageView } | Yes | A file address or other address; or an image, voice, video or file message |
p | DownloadParams | No | See DownloadParams |
Returns: DownloadTask; see DownloadTask.
Errors: aborted (cancelled), permission_denied (path_not_allowed), not_found (the file doesn't exist or has expired), url_expired (a download address given directly has expired), storage_error (disk_full), network_error.
im.desktop.openPath()
Opens a file with the system's default program.
| Parameter | Type | Required | Description |
|---|---|---|---|
path | string | Yes | A file the SDK downloaded or a path allowed for reading |
Returns: Promise<void>. Errors: permission_denied (executable_file, path_not_allowed), local_validation (file_not_found).
im.desktop.revealPath()
Shows a file in the file manager. Parameters and errors are the same as openPath(), without executable_file.
im.desktop.exportDiagnostics()
Exports a diagnostics package; see Diagnostics and logs.
| Parameter | Type | Required | Description |
|---|---|---|---|
p.saveTo | string | No | Directory or full path to save to; the app's cache directory if omitted |
Returns: Promise<string>, the package's path.
im.desktop.info
Read-only DesktopInfo; see DesktopInfo.
onFilesDropped()
Receives files dropped on this window. Import from @deeprespond/im-tauri.
| Parameter | Type | Required | Description |
|---|---|---|---|
handler | (paths: string[]) => void | Yes | The readable regular files dropped |
Returns: () => void, which stops receiving.
Types
DesktopNotification
| Field | Type | Required | Description |
|---|---|---|---|
id | string | No | A new notification with the same id replaces the old one; can't start with dr:. Without it every call is a new notification |
title | string | Yes | Title |
body | string | Yes | Body |
conversation_id | string | No | Passed back with the click event; clearNotifications() clears by it |
seq | number | No | Passed back with the click event |
data | Record<string, string> | No | Passed back as is on click; at most 1 KB in total |
sound | boolean | No | Whether to play the system sound; default true |
actions | Array<{ id: string; title: string }> | No | Buttons, up to 2; the event's action is the clicked button's id |
NotificationClickEvent
The payload of desktop.notificationClicked.
| Field | Type | Description |
|---|---|---|
id | string | The notification's id |
kind | 'message' | 'notice' | 'call' | 'session' | 'custom' | New message, request, incoming call, session ended, or your own notification |
conversation_id | string | Conversation ID (for new messages and custom notifications that included it) |
seq | number | The message's seq |
action | string | The clicked button's id; absent when the body was clicked |
data | Record<string, string> | The notification's data; { call_id } for calls, { user } or { group_id } for requests |
launched | boolean | Whether this click launched the app |
claim | () => void | Claims the click: the plugin won't call on_notification_click or show the main window |
DownloadParams
| Field | Type | Description |
|---|---|---|
saveTo | string | Directory or full path to save to; the system Downloads folder by default |
conflict | 'rename' | 'overwrite' | Rename (default) or overwrite on a name clash |
onProgress | (loaded: number, total: number | null) => void | Download progress; total is null when unknown |
signal | AbortSignal | Aborts the download |
DownloadTask
A Promise that resolves to { path: string; size: number } (the saved path and size), with a cancel() method to cancel the download.
DesktopInfo
| Field | Type | Description |
|---|---|---|
os | 'windows' | 'macos' | 'linux' | The system |
os_version | string | System version |
webview_version | string | WebView version |
sdk_version | string | Tauri SDK version |
calls_supported | boolean | Whether audio and video calls work, same as im.calls.supported |
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.
