14 KiB
Tauri v2 — 우리가 쓸 API 표면 박제
작성: 2026-08-15 | 근거: specs/004-tauri-shell/research.md R3
여기 적힌 건 전부 cargo fetch 로 받은 실제 crate 소스에서 뽑은 것이다. 공식 가이드(tauri-v2-llms-full.md)의 예제는 옛 버전이 섞여 있어서 시그니처 근거로 쓰지 않는다. 자세한 건 README.md 의 "왜 두 개인가".
박제 기준 버전
4_rust_tauri/src-tauri/Cargo.lock 이 잠근 값 (2026-08-15):
| crate | 버전 |
|---|---|
tauri |
2.11.5 |
tauri-build |
2.6.3 |
tauri-plugin-global-shortcut |
2.3.2 |
tauri-plugin-single-instance |
2.4.3 |
tauri-plugin-window-state |
2.4.1 |
tao (창·이벤트루프, tauri 가 물고 옴) |
0.35.3 |
global-hotkey (global-shortcut 가 물고 옴) |
0.8.0 |
windows (Win32, tao 가 물고 옴) |
0.61.3 |
소스 위치: %USERPROFILE%\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\<crate>-<version>\
1. 전역 핫키 — tauri-plugin-global-shortcut 2.3.2
핸들러는 3인자, ShortcutEvent 는 값으로 온다
research R3 이 "2개→3개로 바뀐 자리"라고 찍었던 바로 그것. 소스에서 확인한 정답:
// src/lib.rs:143
pub fn on_shortcut<S, F>(&self, shortcut: S, handler: F) -> Result<()>
where
S: TryInto<ShortcutWrapper>,
S::Error: std::error::Error,
F: Fn(&AppHandle<R>, &Shortcut, ShortcutEvent) + Send + Sync + 'static,
- 인자 3개:
(&AppHandle, &Shortcut, ShortcutEvent) - 3번째는
&ShortcutEvent가 아니라ShortcutEvent값 shortcut은TryInto<ShortcutWrapper>—"Ctrl+Shift+8"문자열 그대로 넘겨도 된다- 반환은
Result<()>— 등록 실패는Err로 오지 패닉이 아님 (FR-010 이 요구하는 "죽지 않기" 가 공짜)
같은 시그니처를 쓰는 다른 진입점:
pub fn register<S>(&self, shortcut: S) -> Result<()> // 핸들러 없이 등록만 (lib.rs:131)
pub fn on_shortcuts<S, T, F>(&self, shortcuts: S, handler: F) // 여러 개 한 번에 (lib.rs:167)
pub fn with_handler<F>(self, handler: F) -> Self // Builder 에 공용 핸들러 (lib.rs:380)
⚠️
tauri-v2-llms-full.md1948줄에는with_handler(|app, shortcut| ...)로 2인자 예제가 남아있다. 그건 옛날 것. 23080줄의 3인자가 맞다.
ShortcutState / ShortcutEvent 는 global-hotkey 것의 별명
// src/lib.rs:24
GlobalHotKeyEvent as ShortcutEvent, HotKeyState as ShortcutState,
global-hotkey 0.8.0 정의:
pub enum HotKeyState { Pressed, Released } // = ShortcutState
pub struct GlobalHotKeyEvent { // = ShortcutEvent
pub id: u32,
pub state: HotKeyState,
}
impl GlobalHotKeyEvent {
pub fn id(&self) -> u32 { self.id }
pub fn state(&self) -> HotKeyState { self.state }
}
필드와 메서드가 둘 다 공개라 event.state 도 event.state() 도 컴파일된다. 둘 중 뭘 써도 됨.
누를 때/뗄 때 둘 다 온다 — ShortcutState::Pressed 로 안 거르면 한 번 눌러 두 번 발동한다.
배선
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
// 플러그인 등록
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
// setup 안에서
app.global_shortcut().on_shortcut("Ctrl+Shift+8", move |app, _shortcut, event| {
if event.state() == ShortcutState::Pressed { /* ... */ }
})
2. 창 — tauri::WebviewWindow (tauri 2.11.5)
전부 src/webview/webview_window.rs. 전부 crate::Result<...> 를 돌려준다 — let _ = 로 무시하든 ? 로 올리든 결정할 것.
| 메서드 | 시그니처 | 줄 |
|---|---|---|
is_visible |
pub fn is_visible(&self) -> crate::Result<bool> |
1800 |
set_always_on_top |
pub fn set_always_on_top(&self, always_on_top: bool) -> crate::Result<()> |
2049 |
start_dragging |
pub fn start_dragging(&self) -> crate::Result<()> |
2138 |
show |
pub fn show(&self) -> crate::Result<()> |
2207 |
hide |
pub fn hide(&self) -> crate::Result<()> |
2212 |
set_focus |
pub fn set_focus(&self) -> crate::Result<()> |
2260 |
is_visible 만 bool 을 감싸 돌려주므로 토글 판정에서 unwrap_or(false) 같은 처리가 필요하다.
창 꺼내는 법은 Manager trait 의 get_webview_window("main") — 라벨은 tauri.conf.json 의 창 label 과 같아야 한다.
3. 푸시 (Rust → JS) — Emitter trait
// tauri-2.11.5/src/lib.rs:946
fn emit<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()>
use tauri::Emitter;를 안 하면app.emit(...)이 안 보인다. trait 메서드라서 import 필수 — research R3 이 "어디서 import 하는지"를 함정으로 찍은 자리가 이것- payload 는
Serialize + Clone Manager를 구현한 것(App,AppHandle,WebviewWindow)이면 다 부를 수 있다
우리 계약은 이벤트 이름 하나("bridge")에 {type, ...} 를 그대로 싣는 것 (research R5, contracts/transport-mapping.md §2):
use tauri::Emitter;
app.emit("bridge", serde_json::json!({ "type": "navigate", "path": "/snippet" }))
관련 trait 정리:
| trait | 뭘 주나 | import |
|---|---|---|
Emitter |
emit, emit_to, emit_filter |
use tauri::Emitter; |
Manager |
state, path, get_webview_window 등 |
use tauri::Manager; |
GlobalShortcutExt |
global_shortcut() |
use tauri_plugin_global_shortcut::GlobalShortcutExt; |
4. 경로 — app.path().app_local_data_dir()
// tauri-2.11.5/src/path/desktop.rs:256
pub fn app_local_data_dir(&self) -> Result<PathBuf> {
dirs::data_local_dir()
.ok_or(Error::UnknownPath)
.map(|dir| dir.join(&self.0.config().identifier))
}
%LocalAppData%\<identifier>다. identifier가 com.codeassist.app이면 %LocalAppData%\com.codeassist.app\.
창 상태와 핀은 이 경로에 둔다. 스니펫 DB는 기존 사용자 데이터를 이어야 하므로 이 함수를 쓰지 않고 %LocalAppData%\CodeAssist\snippets.db를 직접 지정한다(R8).
5. 트레이 — tauri::tray::TrayIconBuilder
show_menu_on_left_click 는 2.11.5 에 존재한다 (`src/tray/mod.rs:319). research R3 의 "이름·존재 여부" 걱정은 해소.
pub fn show_menu_on_left_click(mut self, enable: bool) -> Self // 빌더, mod.rs:319
pub fn set_show_menu_on_left_click(&self, enable: bool) -> crate::Result<()> // 런타임 변경, mod.rs:607
mod.rs:307 에 옛 이름이 #[deprecated] 로 남아있다 — 자동완성이 옛 이름을 물어올 수 있으니 위 이름을 쓸 것.
메뉴는 tauri::menu::{MenuBuilder, MenuItemBuilder, CheckMenuItemBuilder}:
let toggle = MenuItemBuilder::with_id("toggle", "열기").build(app)?;
let pin = CheckMenuItemBuilder::new("항상 위에 고정").build(app)?;
let menu = MenuBuilder::new(app).items(&[&toggle, &pin]).build()?;
TrayIconBuilder::new()
.menu(&menu)
.on_menu_event(move |app, event| match event.id().as_ref() {
"toggle" => { /* ... */ }
_ => {}
})
.build(app)?;
v1 → v2 이름 변경:
SystemTray→tray::TrayIconBuilder,SystemTrayMenu→menu::Menu,SystemTrayMenuItem→menu::PredefinedMenuItem.Builder::on_menu_event는 없어졌고App/AppHandle::on_menu_event나 위처럼 트레이 빌더에 붙인다.
6. 창 상태 저장 — tauri-plugin-window-state 2.4.1
⚠️ 기본값이 우리한테 함정이다
// src/lib.rs:52
pub struct StateFlags: u32 {
const SIZE = 1 << 0;
const POSITION = 1 << 1;
const MAXIMIZED = 1 << 2;
const VISIBLE = 1 << 3;
const DECORATIONS = 1 << 4;
const FULLSCREEN = 1 << 5;
}
impl Default for StateFlags { fn default() -> Self { Self::all() } } // ← 전부 켜짐
Builder::default() 로 쓰면 VISIBLE 과 DECORATIONS 까지 저장·복원한다.
우리 앱은 트레이 상주 + 제목표시줄 없음(decorations: false) 이라 이게 문제가 된다:
VISIBLE— 끌 때 창이 보이는 상태였으면 다음에 켤 때 창이 저절로 뜬다. 트레이 앱은 조용히 시작해야 하는데 어긋남DECORATIONS— 프레임리스 설정과 겹쳐 싸울 수 있는 자리
우리가 필요한 건 위치·크기뿐이다(FR-007, 핀 상태는 R8 대로 직접 저장). 그래서:
use tauri_plugin_window_state::StateFlags;
.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(StateFlags::SIZE | StateFlags::POSITION) // ← 기본값 쓰지 말 것
.build()
)
저장 파일
pub const DEFAULT_FILENAME: &str = ".window-state.json"; // src/lib.rs:36
→ %LocalAppData%\com.codeassist.app\.window-state.json. 핀(항상 위) 상태는 플래그에 없으므로 같은 폴더에 따로 저장한다(R8·R9).
기타 빌더 메서드: skip_initial_state(label) (lib.rs:369), build() (lib.rs:385).
7. 단일 인스턴스 — tauri-plugin-single-instance 2.4.3
// src/lib.rs:31
pub fn init<R: Runtime, F: FnMut(&AppHandle<R>, Vec<String>, String) + Send + Sync + 'static>(
f: F,
) -> TauriPlugin<R>
- 콜백 인자 3개:
(&AppHandle, argv: Vec<String>, cwd: String) FnMut이다 (Fn아님) — 캡쳐한 걸 안에서 바꿔도 됨- 두 번째 실행에서 이 콜백이 첫 번째 프로세스 쪽에서 불린다. 거기서 창을 소환하면 "두 번 실행해도 새 창 안 뜸"(FR-009)이 된다
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
let _ = window::show(app);
}))
첫 플러그인으로 등록할 것 — 플러그인 문서의 요구사항.
8. DPI 인지 (PerMonitorV2) — 할 일 없음 ✅
research R11 이 "Tauri v2 에서 DPI 인지를 어디서 켜는지 미확정, docs-lib 만들면서 확인"으로 남겨둔 것. 확인 결과: 우리가 켤 필요가 없다.
tao 0.35.3 이 이벤트 루프를 만들 때 무조건 부른다:
// tao-0.35.3/src/platform_impl/windows/dpi.rs:20
pub fn become_dpi_aware() {
static ENABLE_DPI_AWARENESS: Once = Once::new();
ENABLE_DPI_AWARENESS.call_once(|| unsafe {
if let Some(SetProcessDpiAwarenessContext) = *SET_PROCESS_DPI_AWARENESS_CONTEXT {
if !SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2).as_bool() {
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE);
}
} else if let Some(SetProcessDpiAwareness) = *SET_PROCESS_DPI_AWARENESS {
let _ = SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE);
} // ... 더 옛날 폴백
});
}
호출 지점: tao-0.35.3/src/platform_impl/windows/event_loop.rs:190 — 이벤트 루프 생성 시.
즉 Windows 10 1703 이상이면 PerMonitorV2 가 자동으로 켜진다. .NET 판이 app.manifest 를 따로 넣어야 했던 것과 다름.
그럼 매니페스트는?
tauri-build 2.6.3 이 기본으로 박아넣는 매니페스트(src/windows-app-manifest.xml)에는 <dpiAware> 항목이 아예 없다. Common-Controls v6 의존성 하나뿐. 위 런타임 호출이 그 역할을 대신하므로 그대로 둔다.
바꿔야 할 일이 생기면
tauri_build::WindowsAttributes::new().app_manifest(include_str!("app.manifest"))로 통째 교체하고Attributes::new().windows_attributes(..)에 물려try_build에 넘긴다 (tauri-buildsrc/lib.rs:337). 지금은 필요 없다.
US4(캡쳐) 작업 시 함의: 좌표가 어긋나면 원인이 DPI 인지 설정이 아니다. 논리↔물리 좌표 변환 쪽을 봐야 한다.
9. 커맨드 (JS → Rust) 와 capabilities
#[tauri::command]
fn window_hide(app: tauri::AppHandle) -> Result<(), String> { /* ... */ }
// lib.rs 에서
.invoke_handler(tauri::generate_handler![window_hide, /* ... */])
- 공유 상태는
.manage(MyState::default())로 넣고 커맨드 인자에서state: tauri::State<MyState>로 받는다 - 에러 타입은
Serialize여야 한다.String으로 돌려주면 프론트에서Error로 reject 됨 → 우리 계약의 한글 오류 메시지가 그대로 토스트로 흐른다 (contracts/transport-mapping.md§3)
capabilities
현재 capabilities/default.json 은 "permissions": ["core:default"] 하나다. 프론트가 core API 를 직접 안 부르고 우리 #[tauri::command] 만 invoke 하므로 이걸로 충분하다 — invoke/listen/emit 은 core:default 에 들어있다.
플러그인 권한(global-shortcut·window-state 는 permissions/default.toml 을 가지고 있음)은 프론트에서 그 플러그인의 JS API 를 직접 부를 때만 필요하다. 우리는 Rust 쪽에서만 쓰므로 추가 안 해도 된다.
빌드 후
src-tauri/gen/schemas/desktop-schema.json이 생기면 에디터가 권한 이름을 자동완성해준다. 권한이 모자라면 런타임에 "not allowed" 로 거부되지 컴파일 에러가 안 나므로, 프론트에서 플러그인 API 를 직접 부르기 시작하면 그때 여기를 의심할 것.
10. 한 장 요약 — import 안 하면 안 보이는 것들
컴파일 에러 중 제일 헷갈리는 게 "메서드가 없다"인데, 대부분 trait import 누락이다.
use tauri::Manager; // state(), path(), get_webview_window()
use tauri::Emitter; // emit()
use tauri_plugin_global_shortcut::GlobalShortcutExt; // global_shortcut()
use tauri_plugin_window_state::StateFlags; // with_state_flags()