Tauri 常见注意项:首屏、窗口与交互细节

前言

桌面端 Tauri 启动时,系统会先拉起 WebView 再加载前端资源,主窗口若过早露出,首屏容易出现白屏或半渲染界面。
本文说明如何在 Tauri 2 里把主窗先藏起来、等首屏就绪再显示,以及如何配置或代码居中窗口,并用 CSS 减少界面文字被误选。
进一步覆盖无边框时的标题栏与拖拽区划分、深浅色与系统主题协同,以及最小化到托盘、关闭与退出的常见交互分工。
另补充系统字体栈、字号与缩放感知,以及将文件拖入指定区域并给出校验失败时的明确提示,便于批处理类工具落地。
不展开路由框架细节,示例以前端调用 @tauri-apps/api 为主;涉及托盘时补充少量 Rust 与 capability 配置。
依赖与版本号请与你仓库里的 package.jsonCargo.toml 对齐;若项目基于更早的 Tauri 大版本,请以该版本文档为准替换接口与配置名称。

依赖

本节不新增必选 Rust crate,主要依赖 Tauri 官方的前端 API 包。
脚手架生成的项目里,package.json 通常已声明 @tauri-apps/api
下面是一条最小依赖示例,版本号请改为你本地已锁定的范围。

1
2
3
4
5
{
"dependencies": {
"@tauri-apps/api": "^2.0.0"
}
}

首次拉包或改依赖后,需要在项目根目录安装依赖。
下面命令在项目根执行,用于安装或同步 node_modules

1
npm install

若启用下文「托盘」中的系统托盘,需要在 src-tauri/Cargo.tomltauri 打开 tray-icon 特性;若已有 features = [...],请将 tray-icon 并入该数组而不是另起一行覆盖。

1
2
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }

加载后显示

思路是进程启动时先不露出主窗,等首屏脚本跑完(或路由首屏挂载完成)再调用窗口 API,让白屏留在不可见阶段。
src-tauri/tauri.conf.jsonapp.windows 里为主窗口设置 visible: false,可在进程启动阶段就避免窗口闪现未完成界面。
下面 JSON 表示标签为 main 的窗口初始不可见;具体字段以你本地的 JSON Schema 或官方文档为准。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"app": {
"windows": [
{
"label": "main",
"title": "My App",
"width": 900,
"height": 600,
"resizable": true,
"visible": false
}
]
}
}

前端在页面就绪后再调用 show() 露出窗口;下面在浏览器 DOMContentLoaded 后获取当前窗口并执行 show()
你也可以把同样逻辑放进 Vue 的 onMounted、React 的 useEffect 等位置。

1
2
3
4
5
6
import { getCurrentWindow } from "@tauri-apps/api/window";

window.addEventListener("DOMContentLoaded", async () => {
const appWindow = getCurrentWindow();
await appWindow.show();
});

若还需要在显示前拉取配置或鉴权,只要在 await show() 之前插入你的异步逻辑即可。

居中

tauri.conf.json 的同一窗口项上可开启 center: true,尽量在创建时把窗口放到屏幕中央。
可与上一节的 visible: false 写在同一窗口配置里一并使用。
下面片段在窗口对象上同时关闭初始可见性并启用居中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"app": {
"windows": [
{
"label": "main",
"title": "My App",
"width": 900,
"height": 600,
"resizable": true,
"visible": false,
"center": true
}
]
}
}

有时仅靠配置不够稳定,尤其在多显示器或动态改尺寸的场景,你可以在 lib.rsBuilder setup 钩子里对主窗口再调用一次 center()
下面在 main 中与 run 链式组合,于 setup 阶段获取 main 窗口并居中;入口路径需与你项目模板一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
use tauri::Manager;

fn main() {
tauri::Builder::default()
.setup(|app| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.center();
}
Ok(())
})
.run(tauri::generate_context!())
.expect("while running tauri application");
}

禁止选择

桌面 WebView 里拖动鼠标容易选中整段界面文案,工具类、仪表盘类界面常希望禁止选择。
用 CSS 在根节点关闭 user-select 即可;需要复制或编辑的区域(输入框、contenteditable、可选中代码块)再单独允许选择,避免影响表单与无障碍需求。
下面是一段可放在全局样式或根组件中的示例,按需合并进你的样式体系。

1
2
3
4
5
6
7
8
9
10
11
12
13
html,
body,
#app {
-webkit-user-select: none;
user-select: none;
}

input,
textarea,
[contenteditable="true"] {
-webkit-user-select: text;
user-select: text;
}

若个别控件仍被选中文案,可检查是否被更具体的选择器覆盖了上述规则,或为该类节点单独写上 user-select: none

标题栏

无边框窗口需要自绘顶栏,并用 data-tauri-drag-region 标出可拖拽区域,否则用户无法移动窗口。
官方推荐把「可拖区域」与「可点按钮」拆成并列结构:拖拽放在一块区域,最小化、最大化、关闭放在另一块,避免点击被当成拖拽。
tauri.conf.json 对应窗口上关闭系统装饰,才能完全自绘标题栏。
下面只标出 decorations 字段,请合并进你已有的 windows 条目,勿覆盖其它宽高与可见性配置。

1
2
3
4
5
6
7
8
9
10
{
"app": {
"windows": [
{
"label": "main",
"decorations": false
}
]
}
}

启用拖拽与窗口控制命令前,需在 capability 里放行窗口相关权限;下列为常见组合,若你精简过权限表,请按运行报错逐项补齐。

1
2
3
4
5
6
7
8
9
10
11
12
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-capability",
"windows": ["main"],
"permissions": [
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close"
]
}

下面 HTML 将标题文本放在带 data-tauri-drag-region 的区域,关闭按钮单独占位;布局可按设计改为左侧拖拽、右侧按钮等。

1
2
3
4
5
6
<header class="titlebar">
<span data-tauri-drag-region class="titlebar-drag">我的应用</span>
<div class="titlebar-actions">
<button type="button" id="titlebar-close" aria-label="关闭">×</button>
</div>
</header>

顶栏需要固定高度并禁止文本选中,避免拖动时误选;下面给出与结构配套的最小样式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.titlebar {
height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
-webkit-user-select: none;
}
.titlebar-drag {
flex: 1;
padding: 0 12px;
line-height: 32px;
}
.titlebar-actions button {
width: 40px;
height: 32px;
border: none;
background: transparent;
cursor: pointer;
}

使用 @tauri-apps/api/window 把关闭按钮接到窗口 API;若需双击标题栏切换最大化,可查阅官方「窗口定制」里基于 startDragging 的手动实现。

1
2
3
4
5
6
7
import { getCurrentWindow } from "@tauri-apps/api/window";

const appWindow = getCurrentWindow();

document.getElementById("titlebar-close")?.addEventListener("click", () => {
void appWindow.close();
});

若子元素必须叠放在同一块拖拽区域内,可查阅当前 Tauri 版本文档中关于 data-tauri-drag-region 排除子节点的写法(例如排除属性或 false 取值),以免按钮无法点击。

主题

页面侧可用 prefers-color-scheme 切换配色;若希望窗口边框、标题栏等系统壳层与系统一致,可在前端对当前窗口使用 setThemeonThemeChanged
在 Windows 与较新的 macOS 上,窗口主题会影响 WebView 内 prefers-color-scheme 的取值。
Linux 上行为可能与桌面环境相关,以本机实测为准。

下面在启动时订阅系统主题变化,并把主题枚举同步到 document.documentElementdata-theme,供 CSS 变量或选择器使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { getCurrentWindow } from "@tauri-apps/api/window";

const root = document.documentElement;
const win = getCurrentWindow();

function syncDomTheme(theme: "light" | "dark") {
root.dataset.theme = theme;
}

const initial = await win.theme();
if (initial === "dark" || initial === "light") {
syncDomTheme(initial);
}

await win.onThemeChanged(({ payload }) => {
if (payload === "dark" || payload === "light") {
syncDomTheme(payload);
}
});

若你希望显式跟随系统而不是写死深浅色,可对当前窗口传入 null 作为主题,由系统决定壳层与 prefers-color-scheme;具体可调用形式以你锁定的 @tauri-apps/api 类型定义为准。

1
2
3
import { getCurrentWindow } from "@tauri-apps/api/window";

await getCurrentWindow().setTheme(null);

也可以在 Rust 的 setup 里对 WebviewWindow 调用 set_theme,与前端二选一或配合使用;平台支持与枚举名请以 docs.rs 上与你版本一致的 tauri 文档为准。

托盘

下载器、同步类应用常把「点关闭」做成隐藏主窗而非退出进程,并通过托盘图标再次打开;真正退出放在托盘菜单或设置里,避免用户找不到后台任务。
下面假设已在依赖里启用 tray-icon 特性;托盘与菜单的权限以你当前 Tauri 版本的 ACL 默认集成为准,若创建失败请对照官方「系统托盘」与「权限」文档补全 capability。
调用 hide 通常需要在 capability 中显式加入 core:window:allow-hide,该权限往往不在 core:window:default 内。

前端创建托盘图标并挂菜单,其中「退出」通过 invoke 调用你在 Rust 注册的命令结束进程;show 用于从托盘回到主窗。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { TrayIcon } from "@tauri-apps/api/tray";
import { Menu } from "@tauri-apps/api/menu";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { defaultWindowIcon } from "@tauri-apps/api/app";
import { invoke } from "@tauri-apps/api/core";

const appWindow = getCurrentWindow();

const menu = await Menu.new({
items: [
{
id: "show",
text: "显示主窗口",
action: async () => {
await appWindow.show();
await appWindow.setFocus();
},
},
{
id: "quit",
text: "退出",
action: async () => {
await invoke("quit_app");
},
},
],
});

await TrayIcon.new({
icon: await defaultWindowIcon(),
menu,
menuOnLeftClick: true,
action: (event) => {
if (event.type === "DoubleClick") {
void appWindow.show();
void appWindow.setFocus();
}
},
});

await appWindow.onCloseRequested(async (event) => {
event.preventDefault();
await appWindow.hide();
});

在 Rust 侧注册 quit_app 命令,在内部调用 AppHandle::exit;再把它挂进你项目里已有的 tauri::Builder 链,例如与居中一节使用的 setup 写在同一个 generate_handler 列表中。

1
2
3
4
#[tauri::command]
fn quit_app(app: tauri::AppHandle) {
app.exit(0);
}

在你现有 Builder 链上追加 invoke_handler,或把 quit_app 合并进已有的 generate_handler! 宏参数列表。

1
.invoke_handler(tauri::generate_handler![quit_app])

invoke("quit_app") 在运行时提示权限或命令未注册,请核对 capability 是否允许该命令,以及 generate_handler 是否包含 quit_app

若你希望关闭即真退出而非隐藏,可去掉 onCloseRequested 中的 preventDefault,或仅在用户勾选「最小化到托盘」时注册该监听。
托盘与窗口组合行为在不同操作系统上略有差异,发布前建议在 Windows、macOS 与目标 Linux 发行版上各测一轮。

字体

跨平台桌面端应尽量跟随系统 UI 字体,避免写死某一端专用字体名导致在其它系统回退不佳。
正文与界面字号优先用 rem 相对根元素,由用户在系统里调整「缩放与布局」时,整体比例更容易一致。
Windows 高分屏与自定义缩放比例下,若整页被 CSS transform 非整数缩放,文字与细线容易发糊;关键布局尽量少依赖根级缩放,边框宽度尽量用逻辑像素整数或 hairline 策略。
下面给出常见系统字体栈与根字号示例,可按品牌规范替换西文部分、保留 system-ui 与平台回退。

1
2
3
4
5
6
7
8
9
10
11
:root {
font-size: 16px;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "Noto Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}

body {
text-size-adjust: 100%;
}

需要与系统缩放联动排障或做精细布局时,可对当前窗口读取 scaleFactor,或在缩放变化时更新 CSS 变量。
下面订阅缩放变化并写入 --sf,供你在样式中按需参与计算(不必强行使用)。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { getCurrentWindow } from "@tauri-apps/api/window";

const root = document.documentElement;

async function applyScale() {
const sf = await getCurrentWindow().scaleFactor();
root.style.setProperty("--sf", String(sf));
}

await applyScale();
await getCurrentWindow().onScaleChanged(async () => {
await applyScale();
});

拖入

批处理、导入类功能常要把文件拖到窗口内固定区域;用 Tauri 的 onDragDropEvent 能拿到系统给出的路径列表,比单纯依赖 DOM 的 drop 更贴近桌面端行为。
请在界面上画出明确的「拖放命中区」,并在校验失败时用文案或轻提示说明原因(扩展名不符、数量超限等),避免静默失败。
若收不到事件,请在 tauri.conf.json 对应窗口上确认 dragDropEnabledtrue;官方说明默认一般为开启,仅在需要改用页面内 HTML5 拖放时再在 Windows 上关闭。
下面片段仅标出该字段,请合并进已有窗口配置。

1
2
3
4
5
6
7
8
9
10
{
"app": {
"windows": [
{
"label": "main",
"dragDropEnabled": true
}
]
}
}

下面用一块带固定 class 的落点区域配合 getCurrentWebview 监听拖放。
drop 时只做扩展名校验与提示,真实读文件可再接 fs 插件或 Rust 命令。

1
2
3
4
5
<section class="drop-panel" aria-label="将文件拖到此处">
<p class="drop-panel-title">将文件拖到此处</p>
<p class="drop-panel-hint">仅示例:接受 .txt;其它类型会提示原因</p>
</section>
<p class="drop-error" id="drop-error" role="status" aria-live="polite"></p>

上面结构把命中区与错误提示分成独立节点,便于屏幕阅读器与后续样式控制。
下面样式用虚线框与最小高度强调落点;使用了 color-mix 与系统颜色关键字,若目标 WebView 较旧可改回固定十六进制色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.drop-panel {
min-height: 120px;
margin: 16px;
padding: 16px;
border: 2px dashed color-mix(in srgb, CanvasText 35%, transparent);
border-radius: 8px;
background: color-mix(in srgb, Canvas 92%, CanvasText 4%);
}

.drop-panel.is-active {
border-color: Highlight;
background: color-mix(in srgb, Highlight 12%, Canvas);
}

.drop-error {
min-height: 1.25em;
margin: 0 16px 16px;
color: CanvasText;
}

事件监听使用 @tauri-apps/api/webviewgetCurrentWebview;载荷类型为 enteroverleavedrop,其中 leave 表示悬停结束,用来去掉高亮。
下面在 enterover 时点亮落点区,在 leave 时还原,在 drop 时校验后缀并写入 #drop-error

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import { getCurrentWebview } from "@tauri-apps/api/webview";

const panel = document.querySelector(".drop-panel") as HTMLElement | null;
const errEl = document.getElementById("drop-error");

function showError(message: string) {
if (errEl) errEl.textContent = message;
}

await getCurrentWebview().onDragDropEvent((event) => {
const p = event.payload;
if (p.type === "enter" || p.type === "over") {
panel?.classList.add("is-active");
return;
}
if (p.type === "leave") {
panel?.classList.remove("is-active");
return;
}
if (p.type !== "drop") {
return;
}

panel?.classList.remove("is-active");

const paths = p.paths;
if (paths.length === 0) {
showError("未收到有效文件路径,请重试。");
return;
}

const bad = paths.filter((path) => !path.toLowerCase().endsWith(".txt"));
if (bad.length > 0) {
showError(`仅支持 .txt,以下条目类型不符:${bad.join(";")}`);
return;
}

showError("");
console.log("accepted", paths);
});

若你需要「只有落在矩形区域内才算命中」,可把 over 里的坐标与 panel.getBoundingClientRect() 对比,并注意与 scaleFactor、窗口坐标系的换算,细节以当前 @tauri-apps/api 的类型定义为准。

启用 dragDropEnabled 后,可能与页面内纯文本等其它拖放来源冲突,若遇此类问题请查阅当前版本的已知限制与社区讨论。