Files
CODE_ASSISTANT/specs/004-tauri-shell/research.md
T
2026-09-16 17:22:14 +09:00

12 KiB

Phase 0: Research — Tauri 기준 데스크톱 앱

Created: 2026-08-11 | Updated: 2026-09-09 | Plan: plan.md

기술 결정 12건. 각 항목은 결정 / 왜 / 버린 대안 형식.


R1. 개발 환경: Rust + MSVC 준비 완료

현황 (2026-08-15 확인): rustup + stable-x86_64-pc-windows-msvc, VS 2026 Community의 C++ 도구와 Windows 11 SDK 설치가 끝났다. rustc로 실제 링크까지 통과했다.

결정: 현재 MSVC 툴체인을 그대로 쓴다. 별도 Build Tools나 GNU 툴체인을 추가하지 않는다.

: Tauri와 Win32 API를 이 PC의 실제 Windows 환경에서 빌드·검증할 수 있다. 이미 준비된 도구 외 설치는 필요 없다.

영향 범위: 모든 Rust 작업을 바로 시작할 수 있다.


R2. 이미 있는 미완성 스캐폴드를 어떻게 할 것인가

현황: 4_rust_tauri/create-tauri-app 으로 뽑은 뼈대 + 손으로 쓴 소스가 섞여 있다. Cargo.toml 의 lib 이름은 codeassist_tauri_lib 인데 main.rs 는 생성기 기본값 _rust_tauri_lib::run() 을 부른다 → 현재 빌드 불가.

결정: 이어 쓴다. 단 자산을 두 등급으로 나눠 다르게 취급한다.

등급 대상 취급
신뢰 icons/(13개), build.rs, capabilities/, .gitignore 그대로 유지. 생성기 산출물이라 검증됨
재검증 대상 손으로 쓴 src/**/*.rs, Cargo.toml, tauri.conf.json 컴파일 검증 0줄. 첫 빌드에서 전부 다시 본다

: 아이콘 13종(.ico/.icns/.png 다중 해상도)은 손으로 못 만들고, 지우고 재생성해도 같은 생성기를 다시 돌리는 것이라 얻는 게 없다. 반대로 손으로 쓴 Rust 는 한 줄도 컴파일 안 해봤으므로 "있으니 맞겠지"로 넘기면 안 된다.

버린 대안: 통째로 지우고 재생성 — 아이콘을 다시 뽑아야 하고 결과물이 같음. 순수 낭비.

첫 할 일: main.rs 의 lib 이름 한 줄 정합.


R3. Tauri v2 API 를 기억으로 쓰지 않는다 (docs-lib)

결정: 첫 Rust 코드를 쓰기 전에 4_rust_tauri/docs-lib/tauri-v2.md 를 만들고, 실제로 쓸 API 표면을 거기 박제한 뒤 그걸 보고 짠다. 2_frontend/docs-lib/tauri-api.md 도 같이(프론트 invoke/listen 용).

: CLAUDE.md §3 의 명시 규칙이기도 하지만, 이 건에서 특히 위험하다. Tauri v2 는 2.x 안에서도 시그니처가 바뀐 자리가 있다 — 특히:

  • global_shortcut().on_shortcut() 핸들러 인자 개수 (2개 → 3개)
  • 트레이 show_menu_on_left_click 의 이름·존재 여부
  • Emitter/Manager trait 를 어디서 import 하는지

이걸 기억으로 쓰면 첫 빌드가 에러 벽에 부딪히고, 그때 "내가 틀린 건지 버전이 다른 건지"를 구분 못 해 시간을 크게 잃는다.

박제할 표면: 플러그인 4종 init, TrayIconBuilder, Menu/CheckMenuItem, WebviewWindow(show/hide/set_focus/set_always_on_top/start_dragging/is_visible), #[tauri::command] + State, app.emit, app.path().app_local_data_dir(), capabilities 권한 이름.

버린 대안: 일단 짜고 컴파일 에러로 배우기 — 에러 메시지가 매크로 안에서 나면 원인이 안 보인다. 특히 generate_handler! 안 에러는 해독이 어렵다.


R4. 프론트 호스트 감지: 사용 시점 자동 판별, 빌드는 한 벌

결정: bridge를 사용할 때 작동하는 통로를 판별한다. 별도 빌드 플래그나 테스트 전용 reset API는 만들지 않는다.

호스트 판별 전송 수단
Tauri typeof window.__TAURI_INTERNALS__?.invoke === "function" invoke() + listen()
참고용 legacy window.chrome?.webview 있음 postMessage + addEventListener("message")
없음(브라우저) 둘 다 없음 단방향 no-op / 응답 필요는 즉시 reject

판별 순서: tauriwebview2browser. Windows의 Tauri도 WebView2 위에서 돌 수 있으므로 Tauri의 실제 invoke 통로를 먼저 확인한다.

왜 사용 시점인가: 호스트 확인은 속성 두 개를 보는 작은 작업이고, 테스트가 런타임에 mock을 심어도 별도 재판별 훅 없이 실제 흐름을 검증할 수 있다. 앱에서는 호스트가 바뀌지 않으므로 결과도 결정적이다.

버린 대안:

  • Vite define / 환경변수 — 브라우저 개발 화면과 Tauri가 서로 다른 번들을 갖게 됨
  • 모듈 로드 때 상수로 고정 + reset API — 테스트만 위한 공개 표면이 생김
  • 공식 isTauri() — 별도 window.isTauri 플래그를 보므로 실제 invoke 가능 여부보다 약함

R5. Rust→JS 푸시: 단일 채널에 {type, ...} 그대로

결정: 이벤트 이름은 "bridge" 하나. 페이로드는 기존 계약과 같은 모양을 유지한다.

app.emit("bridge", { "type": "navigate", "path": "/snippet" })

: bridgeNavigate.ts 안의 navigate / paste.target / capture.image 분기와 CustomEvent dispatch를 그대로 재사용할 수 있다. 바뀌는 건 리스너를 붙이는 transport뿐이다.

버린 대안: 메시지 종류마다 이벤트 이름 분리 — Tauri 관용에는 가깝지만 기존 분기 로직을 다시 쓰게 되어 코드가 늘어난다.


R6. JS→Rust: invoke 가 reqId 인프라를 대체한다

결정: snippetBridge.ts 의 reqId 채번·pending Map·리스너 필터를 삭제하고, request(type, payload) 시그니처만 남겨 invoke(커맨드명, payload) 로 넘긴다.

: 요청↔응답 짝맞춤은 invoke 가 Promise 로 이미 해준다. 그 위에 또 얹으면 같은 일을 두 번 한다. snippets.api.tsrequest("snippets.list") 를 그대로 부르므로 호출부 무변경.

타입명 → 커맨드명 매핑 표는 contracts/transport-mapping.md.

주의: 기존 껍데기 경로에서는 reqId 인프라가 그대로 필요하다. 그래서 이 코드는 지우는 게 아니라 transport.ts 의 webview2 구현 안으로 옮긴다.


R7. 단축키: Tauri가 표준 조합을 소유한다

결정: Tauri 앱은 Ctrl+Shift+7/8/9를 쓴다. 각각 스니펫·챗봇·캡쳐다.

: Tauri가 기준 앱이므로 사용자가 익숙한 조합을 그대로 이어받는다. 참고용 legacy 앱과 동시 실행은 지원하지 않는다.

같이 결정: 등록 실패는 앱을 죽이지 않고 어떤 조합이 실패했는지 알린다(FR-010).


R8. 상태와 기존 데이터 경로

결정:

데이터 경로 처리
창 위치·크기·핀 %LocalAppData%\com.codeassist.app\ 제품용 identifier에 저장
스니펫 DB %LocalAppData%\CodeAssist\snippets.db 기존 파일을 직접 열어 이어서 사용

: 창 상태는 Tauri 제품 설정으로 관리하고, 사용자가 쌓은 스니펫은 별도 이전 없이 보존해야 한다(FR-020).

스니펫 규칙: 기존 스키마와 저널 모드를 바꾸지 않는다. legacy 코드는 참고 원본으로 남아 있으므로 데이터 형식을 함부로 갈라놓지 않는다.

버린 대안: 새 DB로 복사 — 데이터가 두 벌로 갈리고 이후 변경이 나뉜다.


R9. 창 상태 저장은 플러그인에 맡긴다

결정: tauri-plugin-window-state 를 쓰고, 화면 밖 복원 방지도 플러그인 기본 동작에 맡긴다. 다만 핀(항상 위) 상태만 직접 저장한다(플러그인이 always-on-top 은 안 봄).

: .NET 판의 JsonWindowPlacementStore(46줄) + WindowPlacement.IsVisibleWithin(가상 화면 겹침 판정)이 통째로 사라진다. 직접 짤 이유가 없다.

버린 대안: 직접 구현해서 .NET 과 동형 유지 — 동형일 필요가 없다. 사용자에게 보이는 결과(껐다 켜면 그 자리)만 같으면 됨.


R10. 붙여넣기: .NET 이 박제한 함정을 그대로 옮긴다

참조 원본: 3_windowsApp/CodeAssist.Shell/Platform/PasteService.cs — 주석에 "함정 박제"라고 적혀 있는 자리.

결정: 순서를 그대로 지킨다.

  1. 클립보드에 코드 적재
  2. 우리 창을 숨기기 전에 직전 창 활성화 (SetForegroundWindow)
  3. 짧게 대기 (창 전환 느린 대상 대비)
  4. Ctrl+V 주입
  5. 클립보드 원복 안 함 — 붙인 내용을 남긴다(사용자 요청 사항)

왜 순서가 중요한가: Windows 의 포그라운드 락 때문에 SetForegroundWindow우리 창이 떠 있을 때만 먹힌다. 창을 먼저 숨기면 실패한다. 이건 .NET 판에서 이미 한 번 당한 자리라, 새로 설계하지 말고 베낀다.

대기 시간: .NET 은 80ms. 그대로 시작하되 조정 가능한 값으로 둔다 — 물리적 타이밍이라 환경 따라 다를 수 있고, 최소 코드로는 안 보이는 종류의 값이다.

실패 시: 붙이기는 포기하되 코드는 클립보드에 남긴다 → 사용자가 직접 Ctrl+V (FR-014).

Rust 수단: windows crate 로 SetForegroundWindow / keybd_event(또는 SendInput) 직접 호출. 플러그인 없음.


R11. 캡쳐: DPI 인지와 좌표 변환

참조 원본: legacy 앱의 캡쳐 구현과 4_rust_tauri/docs-lib/tauri-v2.md §8.

결정: Tauri 런타임이 이미 켜는 PerMonitorV2 DPI 인지를 그대로 쓰고, 오버레이는 전체 가상 화면을 덮는 투명·항상위 창으로 만든다. 드래그로 고른 논리 좌표를 물리 픽셀로 변환한 뒤 해당 영역을 자른다.

: 배율이 다른 모니터에서 논리 좌표를 그대로 픽셀로 쓰면 고른 영역과 결과가 어긋난다. 별도 DPI 매니페스트는 필요 없다.

Rust 수단: windows crate로 BitBlt를 직접 호출한다. 기존 Windows 동작을 가장 적은 의존성으로 옮길 수 있다.


R12. 테스트 전략: 자동으로 될 것만 자동으로

결정:

대상 방식 근거
프론트 transport 선택·브라우저 동작 vitest 자동 순수 분기이며 호스트 3종을 mock으로 태울 수 있음
기존 브릿지 테스트 3개 vitest — transport mock으로 교체 화면 소비자 동작을 유지하는 회귀 검사
챗봇 토글 규칙, 스니펫 검증 cargo test 자동 순수 로직
창·트레이·핫키 실제 Tauri 앱 수동 확인 OS 자원 테스트 하네스가 본체보다 커짐
붙여넣기·캡쳐 실제 Windows 환경 수동 확인 포커스·모니터 배율에 달림

왜 실제 Tauri 확인을 붙이나: transport mock만 맞고 실제 invoke/listen 권한이나 이벤트 연결이 깨진 상태를 자동 검사만으로는 잡을 수 없다. 브라우저 확인 뒤 Tauri 앱을 직접 실행한다.


미해결로 남기는 것

항목 왜 지금 안 정하나
붙여넣기 대기 시간 최종값 (R10) 물리 타이밍. 80ms로 시작하고 실기기에서 조정

R11 DPI 인지 — 해소 (2026-08-15)

docs-lib(4_rust_tauri/docs-lib/tauri-v2.md §8)에서 확인했다. tao 0.35.3이 이벤트 루프를 만들 때 SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)를 호출하므로 우리가 별도 설정할 것은 없다.

귀결: US4에서 좌표가 어긋나면 DPI 설정이 아니라 논리↔물리 좌표 변환을 본다.