# 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\-\` --- ## 1. 전역 핫키 — `tauri-plugin-global-shortcut` 2.3.2 ### 핸들러는 **3인자**, `ShortcutEvent` 는 **값**으로 온다 research R3 이 "2개→3개로 바뀐 자리"라고 찍었던 바로 그것. 소스에서 확인한 정답: ```rust // src/lib.rs:143 pub fn on_shortcut(&self, shortcut: S, handler: F) -> Result<()> where S: TryInto, S::Error: std::error::Error, F: Fn(&AppHandle, &Shortcut, ShortcutEvent) + Send + Sync + 'static, ``` - 인자 **3개**: `(&AppHandle, &Shortcut, ShortcutEvent)` - 3번째는 **`&ShortcutEvent` 가 아니라 `ShortcutEvent` 값** - `shortcut` 은 `TryInto` — **`"Ctrl+Shift+8"` 문자열 그대로 넘겨도 된다** - 반환은 `Result<()>` — 등록 실패는 `Err` 로 오지 패닉이 아님 (FR-010 이 요구하는 "죽지 않기" 가 공짜) 같은 시그니처를 쓰는 다른 진입점: ```rust pub fn register(&self, shortcut: S) -> Result<()> // 핸들러 없이 등록만 (lib.rs:131) pub fn on_shortcuts(&self, shortcuts: S, handler: F) // 여러 개 한 번에 (lib.rs:167) pub fn with_handler(self, handler: F) -> Self // Builder 에 공용 핸들러 (lib.rs:380) ``` > ⚠️ `tauri-v2-llms-full.md` 1948줄에는 `with_handler(|app, shortcut| ...)` 로 **2인자** 예제가 남아있다. **그건 옛날 것.** 23080줄의 3인자가 맞다. ### `ShortcutState` / `ShortcutEvent` 는 `global-hotkey` 것의 별명 ```rust // src/lib.rs:24 GlobalHotKeyEvent as ShortcutEvent, HotKeyState as ShortcutState, ``` `global-hotkey` 0.8.0 정의: ```rust 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` 로 안 거르면 한 번 눌러 두 번 발동한다. ### 배선 ```rust 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` | 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 ```rust // tauri-2.11.5/src/lib.rs:946 fn emit(&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): ```rust 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()` ```rust // tauri-2.11.5/src/path/desktop.rs:256 pub fn app_local_data_dir(&self) -> Result { dirs::data_local_dir() .ok_or(Error::UnknownPath) .map(|dir| dir.join(&self.0.config().identifier)) } ``` **`%LocalAppData%\`다.** 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 의 "이름·존재 여부" 걱정은 해소. ```rust 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}`: ```rust 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 ### ⚠️ 기본값이 우리한테 함정이다 ```rust // 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 대로 직접 저장). 그래서: ```rust use tauri_plugin_window_state::StateFlags; .plugin( tauri_plugin_window_state::Builder::default() .with_state_flags(StateFlags::SIZE | StateFlags::POSITION) // ← 기본값 쓰지 말 것 .build() ) ``` ### 저장 파일 ```rust 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 ```rust // src/lib.rs:31 pub fn init, Vec, String) + Send + Sync + 'static>( f: F, ) -> TauriPlugin ``` - 콜백 인자 3개: `(&AppHandle, argv: Vec, cwd: String)` - `FnMut` 이다 (`Fn` 아님) — 캡쳐한 걸 안에서 바꿔도 됨 - **두 번째 실행에서 이 콜백이 첫 번째 프로세스 쪽에서 불린다.** 거기서 창을 소환하면 "두 번 실행해도 새 창 안 뜸"(FR-009)이 된다 ```rust .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 이 이벤트 루프를 만들 때 **무조건** 부른다: ```rust // 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`)에는 **`` 항목이 아예 없다.** Common-Controls v6 의존성 하나뿐. 위 런타임 호출이 그 역할을 대신하므로 그대로 둔다. > **바꿔야 할 일이 생기면** `tauri_build::WindowsAttributes::new().app_manifest(include_str!("app.manifest"))` 로 통째 교체하고 `Attributes::new().windows_attributes(..)` 에 물려 `try_build` 에 넘긴다 (tauri-build `src/lib.rs:337`). **지금은 필요 없다.** **US4(캡쳐) 작업 시 함의**: 좌표가 어긋나면 원인이 DPI 인지 설정이 아니다. 논리↔물리 좌표 변환 쪽을 봐야 한다. --- ## 9. 커맨드 (JS → Rust) 와 capabilities ```rust #[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` 로 받는다 - **에러 타입은 `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 누락이다. ```rust 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() ```