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
+192
View File
@@ -0,0 +1,192 @@
# @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
```ts
// 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
```
```ts
import { invoke } from "@tauri-apps/api/core"
await invoke<Snippet[]>("snippets_list")
await invoke("paste_code", { text })
```
- **`Promise` 를 돌려준다** → 요청↔응답 짝맞춤이 공짜. `snippetBridge.ts``reqId`·`pending` Map 이 새 껍데기 경로에선 통째로 필요 없어지는 근거 (research R6)
- Rust 커맨드가 `Err(String)` 을 주면 **그 문자열로 reject** 된다 → 한글 오류 메시지가 그대로 `Error` 로 올라와 sonner 토스트까지 흐름 (`contracts/transport-mapping.md` §3)
- 인자 이름은 Rust 커맨드의 파라미터 이름과 맞아야 한다 (`paste_code(text: String)``{ text }`)
### 속을 보면
```js
// 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
```ts
// 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
```
```ts
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()` 를 쓰지 말 것
패키지가 공식 함수를 하나 주긴 한다:
```js
// 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()` 를 뜯어보면:
```js
// 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 이 있다
```ts
// 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+
```
```ts
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` 분기를 테스트할 수 있다.
```ts
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.