Files
CODE_ASSISTANT/4_rust_tauri/docs-lib/tauri-v2.md
T
2026-09-16 17:22:14 +09:00

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
  • shortcutTryInto<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.md 1948줄에는 with_handler(|app, shortcut| ...)2인자 예제가 남아있다. 그건 옛날 것. 23080줄의 3인자가 맞다.

ShortcutState / ShortcutEventglobal-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.stateevent.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_visiblebool 을 감싸 돌려주므로 토글 판정에서 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_click2.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 이름 변경: SystemTraytray::TrayIconBuilder, SystemTrayMenumenu::Menu, SystemTrayMenuItemmenu::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() 로 쓰면 VISIBLEDECORATIONS 까지 저장·복원한다.

우리 앱은 트레이 상주 + 제목표시줄 없음(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-build src/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/emitcore:default 에 들어있다.

플러그인 권한(global-shortcut·window-statepermissions/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()