8.9 KiB
@tauri-apps/api v2 — 프론트가 쓸 표면 박제
작성: 2026-08-15 | 근거: specs/004-tauri-shell/research.md R3
여기 적힌 건 전부 node_modules/@tauri-apps/api/ 의 실제 .d.ts·.js 에서 뽑은 것이다. 공식 가이드 예제는 옛 버전이 섞여 있어 시그니처 근거로 안 쓴다.
박제 기준 버전: 2.11.1 (package.json 은 ^2.11.1)
우리가 쓰는 건 딱 두 개 — invoke(JS→Rust)와 listen(Rust→JS). 나머지 모듈(window, menu, tray, path …)은 안 쓴다. 창·트레이·핫키는 전부 Rust 쪽에서 하고, 프론트는 우리가 만든 #[tauri::command] 만 부른다.
1. invoke — JS → Rust
// node_modules/@tauri-apps/api/core.d.ts:127
declare function invoke<T>(cmd: string, args?: InvokeArgs, options?: InvokeOptions): Promise<T>
// core.d.ts:105
type InvokeArgs = Record<string, unknown> | number[] | ArrayBuffer | Uint8Array
import { invoke } from "@tauri-apps/api/core"
await invoke<Snippet[]>("snippets_list")
await invoke("paste_code", { text })
Promise를 돌려준다 → 요청↔응답 짝맞춤이 공짜.snippetBridge.ts의reqId·pendingMap 이 새 껍데기 경로에선 통째로 필요 없어지는 근거 (research R6)- Rust 커맨드가
Err(String)을 주면 그 문자열로 reject 된다 → 한글 오류 메시지가 그대로Error로 올라와 sonner 토스트까지 흐름 (contracts/transport-mapping.md§3) - 인자 이름은 Rust 커맨드의 파라미터 이름과 맞아야 한다 (
paste_code(text: String)↔{ text })
속을 보면
// core.js:201
async function invoke(cmd, args = {}, options) {
return window.__TAURI_INTERNALS__.invoke(cmd, args, options)
}
window.__TAURI_INTERNALS__.invoke 를 그대로 부른다. 이게 아래 호스트 판별에서 중요해진다.
2. listen — Rust → JS
// event.d.ts:87
declare function listen<T>(
event: EventName,
handler: EventCallback<T>,
options?: Options
): Promise<UnlistenFn>
// event.d.ts:25,33,34
interface Event<T> {
event: string
id: number
payload: T
}
type EventCallback<T> = (event: Event<T>) => void
type UnlistenFn = () => void
import { listen } from "@tauri-apps/api/event"
const unlisten = await listen<BridgeMessage>("bridge", (e) => {
handle(e.payload) // ← 페이로드는 e.payload 안에 있다
})
⚠️ 함정 2개
(1) 페이로드가 한 겹 더 들어있다. WebView2 는 event.data 가 곧 메시지인데, Tauri 는 Event<T> 로 감싸서 event.payload 안에 있다. bridgeNavigate.ts 의 분기 본문을 그대로 재사용하려면 transport 층에서 e.payload 를 벗겨서 넘겨야 한다 (research R5 가 노린 "분기 로직 무변경"이 이 한 줄에 달림).
(2) listen 은 async 다. Promise<UnlistenFn> 을 돌려준다.
지금 bridgeNavigate.ts 의 initBridgeNavigate() 는 동기 함수고, WebView2 의 addEventListener 는 즉시 붙는다. Tauri 경로는 그렇지 않아서:
initBridgeNavigate()를 async 로 바꾸면 호출부(앱 루트)가 바뀐다 → SC-001("통로 파일 바깥 변경 0줄") 위반- 그래서 transport 안에서 Promise 를 삼키고(fire-and-forget) 밖에는 동기 시그니처를 유지하는 쪽이 맞다
- 대신 리스너 붙기 전에 도착한 푸시는 놓친다. 다행히 이 코드에는 이미 대비가 있다 —
lastPasteTarget스냅샷과pendingCaptureImage의 read-once 소비가 마운트 레이스를 막으려고 들어간 것이라, 같은 장치가 여기서도 먹는다
관련: once(1회), emit/emitTo(JS→Rust 이벤트) 도 있지만 우리는 안 쓴다. 계약상 JS→Rust 는 전부 invoke (transport-mapping §1).
3. 호스트 판별 — isTauri() 를 쓰지 말 것
패키지가 공식 함수를 하나 주긴 한다:
// core.js:278
function isTauri() {
return !!(globalThis || window).isTauri
}
이건 window.isTauri 라는 별개 플래그를 볼 뿐, invoke 가 실제로 쓰는 __TAURI_INTERNALS__ 와 다르다.
research R4 가 정한 __TAURI_INTERNALS__ 기준이 맞다 — invoke 가 바로 그걸 부르므로, "invoke 를 부를 수 있는가"를 직접 재는 셈이라 한 단계 더 정확하다.
그런데 "__TAURI_INTERNALS__" in window 도 부족하다 ⚠️
테스트용 clearMocks() 를 뜯어보면:
// mocks.js:4 — mockIPC 가 부르는 것
function mockInternals() {
window.__TAURI_INTERNALS__ = window.__TAURI_INTERNALS__ ?? {}
window.__TAURI_EVENT_PLUGIN_INTERNALS__ = window.__TAURI_EVENT_PLUGIN_INTERNALS__ ?? {}
}
// mocks.js:267 — clearMocks
function clearMocks() {
if (typeof window.__TAURI_INTERNALS__ !== "object") return
delete window.__TAURI_INTERNALS__.invoke
delete window.__TAURI_INTERNALS__.transformCallback
// ... 속성만 지운다. window.__TAURI_INTERNALS__ 객체 자체는 안 지움
}
clearMocks() 는 속성만 지우고 빈 객체를 남긴다. 그래서:
| 판별식 | mockIPC 후 | clearMocks 후 | 판정 |
|---|---|---|---|
"__TAURI_INTERNALS__" in window |
true | true (틀림) | ❌ 브라우저 모드 테스트가 tauri 로 오판됨 |
typeof window.__TAURI_INTERNALS__?.invoke === "function" |
true | false (맞음) | ✅ |
in 으로 재면 "브라우저에서는 조용히 no-op / 즉시 reject"(FR-003) 테스트가 tauri 경로를 타서 __TAURI_INTERNALS__.invoke is not a function 으로 터진다. 우리가 의도한 한글 "데스크톱 전용" reject 가 아니라 엉뚱한 TypeError.
결론: .invoke 가 함수인지로 판별한다. 실제 앱에서도 Tauri 가 앱 스크립트보다 먼저 내부 객체를 통째로 주입하므로 안전하고, 의미도 더 정확하다("통로가 있나"가 아니라 "통로가 작동하나").
4. 테스트 — 공식 mock 이 있다
// mocks.d.ts
export declare function mockIPC(
cb: (cmd: string, payload?: InvokeArgs) => unknown,
options?: MockIPCOptions
): void
export declare function clearMocks(): void
export declare function mockWindows(current: string, ..._additionalWindows: string[]): void
export declare function mockConvertFileSrc(osName: string): void
export interface MockIPCOptions {
shouldMockEvents?: boolean
} // 2.7.0+
import { mockIPC, clearMocks } from "@tauri-apps/api/mocks"
afterEach(() => clearMocks())
it("snippets_list 를 부른다", async () => {
mockIPC((cmd) => (cmd === "snippets_list" ? [] : undefined))
await expect(snippetsApi.list()).resolves.toEqual([])
})
shouldMockEvents: true 를 주면 listen/emit 도 mock 된다 — Rust 푸시(emit("bridge", ...))를 흉내내 bridgeNavigate 분기를 테스트할 수 있다.
mockIPC(() => {}, { shouldMockEvents: true })
// 이제 emit('bridge', {...}) 하면 listen 핸들러가 불림
⚠️ 주의:
plugin:event|로 시작하는 invoke 를 전부 가로챈다(mocks.js:89).shouldMockEvents를 켠 테스트에서는 이벤트 관련 invoke 가 우리 mock 콜백에 안 온다.
세 호스트 테스트할 때 (T012)
mockIPC 는 테스트 본문에서 런타임에 window.__TAURI_INTERNALS__ 를 심는다. 기존 브릿지 테스트가 window.chrome 을 런타임에 심는 것과 똑같은 방식이다.
→ transport 가 모듈 로드 시점에 호스트를 상수로 굳히면 이 mock 들이 전부 안 먹는다. research R4 의 "한 번 판별하고 굳힌다"를 문자 그대로 구현하면 테스트 3개 + 신규 테스트가 다 깨진다. 판별 시점을 최초 사용 시 1회로 하거나, 굳히되 테스트용 재판별 훅을 같이 내야 한다 (specs/004-tauri-shell/tasks.md T015 가 이 결정을 먼저 하라고 박아둔 이유).
clearMocks() 를 쓸 거면 위 §3 의 .invoke 판별식이 필수다.
5. 안 쓰는 것
패키지에 이만큼 더 있지만 이 기능에서는 안 쓴다 — 창·트레이·핫키·경로는 전부 Rust 담당이고, 프론트는 우리 커맨드만 부른다 (contracts/transport-mapping.md).
window, webviewWindow, webview, menu, tray, path, app, dpi, image
혹시 쓰게 되면 capabilities/default.json 에 권한을 추가해야 한다. 지금은 core:default 하나뿐이라 그것들은 런타임에 조용히 거부된다 (컴파일 에러 안 남). 자세한 건 4_rust_tauri/docs-lib/tauri-v2.md §9.