Files
CODE_ASSISTANT/2_frontend/docs-lib/tauri-api.md
T
2026-09-16 17:22:14 +09:00

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.tsreqId·pending Map 이 새 껍데기 경로에선 통째로 필요 없어지는 근거 (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.tsinitBridgeNavigate()동기 함수고, 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.