Initial Commit

This commit is contained in:
2026-09-16 17:22:14 +09:00
commit 858ee9e9da
335 changed files with 123898 additions and 0 deletions
+332
View File
@@ -0,0 +1,332 @@
# 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개로 바뀐 자리"라고 찍었던 바로 그것. 소스에서 확인한 정답:
```rust
// 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 이 요구하는 "죽지 않기" 가 공짜)
같은 시그니처를 쓰는 다른 진입점:
```rust
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` / `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<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
```rust
// 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):
```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<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 의 "이름·존재 여부" 걱정은 해소.
```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<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)이 된다
```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`)에는 **`<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
```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<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 누락이다.
```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()
```