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/Managertrait 를 어디서 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 |
판별 순서: tauri → webview2 → browser. 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.ts 는 request("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 — 주석에 "함정 박제"라고 적혀 있는 자리.
결정: 순서를 그대로 지킨다.
- 클립보드에 코드 적재
- 우리 창을 숨기기 전에 직전 창 활성화 (
SetForegroundWindow) - 짧게 대기 (창 전환 느린 대상 대비)
Ctrl+V주입- 클립보드 원복 안 함 — 붙인 내용을 남긴다(사용자 요청 사항)
왜 순서가 중요한가: 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 설정이 아니라 논리↔물리 좌표 변환을 본다.