- press가 STT 초기화를 await하며 action queue 점유 → release 수십 초 지연·
유령 세션 반복 버그 수정 (initSTT fire-and-forget)
- 프리플라이트: STT 모델 미설치 시 즉시 에러 + 메인 UI 경고 + 온보딩 오픈
- 사이드카: 기동 중 프로세스 사망 시 30초 대기 없이 즉시 실패,
restartCount 리셋, error 리스너 부재 미처리 예외 방지
- 실시간 부분 전사: 1.5s 간격 interim → RecordingTip에 말하는 내용 미리보기
- Ollama 미가용 후처리 스킵 시 warning 배너, voice:error 브로드캐스트 신설
- 앱 아이콘: icon.svg 마스터(d3ro 브랜드 — 메탈 섀시+오렌지 웨이브) → png/ico
생성 스크립트(generate-icons.mjs, sharp) + electron-builder win/mac 연결
- 트레이: createEmpty() 빈 아이콘 → 실제 앱 아이콘 (getAppIconPath)
- 마이크 테스트: 초기 무음 level:0을 종료로 오인하던 조기 종료 버그 —
AUDIO.TEST_LEVEL에 done 플래그 신설(SSOT), STOP 버튼이 실제 캡처 중지,
testDevice 실패 시 상태 롤백
- verifying/manifest 상태(바이트 진행률 없음)에서 인디터미넌트 바 + 친화적 문구
— 9.6GB 검증 ~2분간 멈춘 것처럼 보이던 문제 해소
- OnboardingModal 중복 실행 가드 (runningRef)
- LocalLLMService.pullModel: 동일 모델 동시 pull은 기존 promise 합류
- LocalSTTService.downloadModel: /download 409는 실패가 아닌 기존 진행 합류
- detectUpdateChannel:false — prerelease 버전이 alpha.yml을 만들어 latest.yml
글롭/가드에 안 걸리던 문제 (electron-updater 기본 채널 latest와 정렬)
- artifacts 글롭 *.yml로 방어적 확장, publish 스크립트 메타데이터 regex 확장
- package-macos: sox 미설치 시 brew install sox 폴백
- CI 태그를 실제 사내 runner에 맞게 교체: build-win-x64(TW-VIVEN-BUILD) /
build-mac-arm64(TW-BUILD-MAC-ARM64) / build-linux-x64(TW-BUILD01, release+검증 잡)
- package-macos: volta 부트스트랩 폴백 추가
- update-feed.ts + electron-builder.yml publish.url에 프로젝트 ID 1172 기입
- release-guide: 완료된 설정 반영 (D3RO_MAC_RUNNER, registry public pull)
- download-ollama.ps1: cuda_v11, cuda_v12, rocm_avx 런타임 삭제 (~1GB 절감)
첫 실행 시 gemma4:e4b는 CPU로 충분. GPU 가속 필요 시 별도 Ollama 설치 또는 차기 빌드.
- electron-builder.yml nsis: oneClick=true로 전환 — oneClick=false+perMachine=false가
System.dll 0xc0000005 유발하는 electron-builder 알려진 이슈(#7921) 회피.
- 이전 installer.nsh의 ReadRegDWORD + MessageBox 조합이 설치 중 System.dll에서
access violation 유발 (WER로 확인)
- electron-builder 기본 installer UX만 사용 → 안정화 후 단계적으로 커스텀 재추가
- installer.nsh는 최소 버전으로 보존(향후 참고)
- LicenseTab/LicenseModal: LemonSqueezy 키 입력 삭제, Payple 구독 UI로 전환
- useLicenseState 훅: 라이선스+클라우드 인증 공용 상태 관리 추출
- PREMIUM_MODEL_LIMITS: 3곳 중복 → @d3ro/core/constants 단일 소스
- i18n: LemonSqueezy 전용 키 18개 삭제, 구독 관련 키 13개 추가 (12 locale)
- DashboardPage: dashboard.model* → license.model* 키 통일
- OPEN_BILLING: app.isPackaged 기반 dev/prod URL 분기
- CloudSyncService: stored token 없을 때 auth-changed null emit 누락 수정
- Payple Edge Functions 3개 배포 ACTIVE 확인
- VoiceConversationPage: Snackbar + Alert(severity=warning filled) 하단 중앙
배너 추가, onError 콜백에서 setErrorBanner. formatErrorMessage 헬퍼 —
phase=stt + 'no speech' → i18n conversation.error.noSpeech, 그 외 →
phaseLabel: rawMessage.
- VoiceModeService._transcribe: minBytes 가드(0.5s @ 16kHz 16bit mono =
16000B) + 빈 result.text 가드. 양쪽 모두 _handleError(STTAudioTooShort /
STTNoAudioData, 'No speech detected...')로 recording-tip popup error
상태 3초 표시 재사용. 기존에는 빈 전사가 조용히 session completed +
빈 history entry 생성되던 경로 차단.
- VoiceModeService._errorHideTimer 필드: _handleError의 setTimeout(hide, 3000)
핸들 보관, _startSession 초기화 블록과 dispose()에서 clearTimeout. 다음
세션 시작 후에도 이전 에러 타이머가 살아 진행 중인 recording tip을 ~2초
지점에 숨기던 잠재 버그 수정 (실측 재현 및 해소 확인).
- i18n ko/en: conversation.error.phase.{stt,llm,tts} + conversation.error.noSpeech
4개 키 추가.
## U1 — Realtime TIMED_OUT 자동 재구독
CloudSyncService.startRealtime()의 subscribe() 콜백이 TIMED_OUT 한 번만
로깅하고 끝나던 것을 지수 백오프 재구독으로 교체.
- SUBSCRIBED: retry count reset + timer clear
- TIMED_OUT / CHANNEL_ERROR / CLOSED: _scheduleRealtimeRetry() 트리거
- 백오프: 1s → 3s → 10s (MAX 3회)
- 초과 시 warn 로깅 후 포기 (Auto push fallback으로만 동작)
- stopRealtime()에서 타이머/카운터 cleanup
Publication, RLS, setAuth 모두 설정 완료 상태에서도 매 기동 시 TIMED_OUT이
찍히는 증상. 근본 원인은 Supabase 서버 transient(네트워크/WebSocket
타임아웃) 의심. 이 패치로 완전 해결은 아니지만 일시적 네트워크 jitter
복구 윈도우를 확보하고, 3회 실패 시 명확한 포기 메시지를 남긴다.
## U3 — Refresh token 실패 시 재인증 이벤트
init()의 refreshSession 실패 브랜치에서 warn 로깅 + 토큰 삭제만 수행해
사용자가 "왜 로그아웃됐지?" 상태로 방치되던 문제.
- logger.warn → logger.error(에러 레벨 승격)
- sync-error 이벤트 emit (사용자에게 보여줄 메시지 포함)
- auth-changed { user: null } emit (renderer 게이트 재잠금)
- catch 브랜치도 동일 처리 (기존엔 로깅만)
## U4 — package-lock.json optional dep (유지 결정)
rollup 전 플랫폼 optional deps가 lock에 기록돼 있지만 Windows 빌드자
사전 fetch 효율 + 크로스플랫폼 CI 환경을 고려해 유지. apps/desktop/
package.json:56에 @rollup/rollup-win32-x64-msvc 명시 의존이 있는 이유도
동일. 이 이슈는 확인 후 close.
listening 상태에서 풀 몰입 계측기 모드로 전환되는 VoiceRecordingPanel 추가.
recording-tip 팝업의 9바 cos-분포 waveform(BAR_COUNT=9, SMOOTHING=0.5,
RANDOM_FACTOR=0.35, 100ms)을 React로 포팅해 REC LED + elapsed 타이머 +
"SPEAK NOW" 힌트까지 구성. thinking/speaking 상태에서는 메시지 리스트로
복귀해 대화 맥락 유지 + 점 3개 typing indicator 버블 추가.
VoiceConversationService에 AudioCaptureService audio-level forwarding과
사운드 훅 4개(recording-start / recording-stop / chime / error)를 삽입.
chime은 recording-stop.wav 재사용(SoundEffectService SoundName 확장).
VOICE_CONVERSATION.AUDIO_LEVEL 채널 신설 + preload onAudioLevel API.
U8 Bug 13 동반 해소: finishListening에서 minBytes 미달 또는 VAD 무음 판정으로
빈 텍스트가 나오는 경우 조용히 listening으로 복귀하던 것을 _emitError('stt')로
사용자 피드백(에러 사운드 + 에러 이벤트)을 노출하도록 수정. 사용자가 "⏹ 눌러도
반응 없음"으로 오해하던 증상 해소.
Voice Conversation이 Mac에서 한 번도 end-to-end 동작한 적 없었음을 발견. 3개 독립 버그가
중첩돼 있었고, 진단 로그를 추가해 단계적으로 분해하여 모두 해결.
Bug 10.5 — TTSPlaybackService 'spawn powershell ENOENT' (Mac)
- 원인: _speakOne이 spawn('powershell', ...) 하드코딩. 파일 주석부터 "Windows SAPI (PowerShell)
기반". V2-5 플랫폼 감사 때 이 파일 누락.
- Fix: _speakOne을 platform dispatcher로 변경, _speakOneMac(/usr/bin/say -r <wpm> --) +
_speakOneWindows(기존 PowerShell) 분리. 공통 close/error 핸들러는 _bindProcessHandlers로
추출. rate 매핑은 _getMacRate(180*speed, WPM 기준) / _getWindowsRate(-10~10, SAPI) 분리.
- 파급: 음성 대화뿐 아니라 TTS를 쓰는 모든 경로가 Mac에서 벙어리였음.
Bug 11 — VoiceConversationService.finishListening 오디오 버퍼 스냅샷 순서 (진짜 root cause)
- 증상: finishListening 호출 시 audioBuffer가 항상 0 bytes → "audio too short"로 조용히
listening 복귀. STT가 한 번도 안 탐. Windows에서도 동일 버그였을 가능성 매우 높음.
- 원인: _stopListening() 내부에 this._audioBuffers = []로 리셋하는 라인이 있는데,
finishListening이 concat을 stop 호출 **이후**에 수행 → 이미 빈 배열에서 concat.
19초짜리 1.7MB 오디오가 매번 증발.
- Fix: _stopListening() 호출 **전**에 const audioBuffer = Buffer.concat(this._audioBuffers)
스냅샷 저장. finishListening 내 redundant한 this._audioBuffers = [] 재설정도 제거(이미
_stopListening이 수행).
Bug 12 — Whisper 모델 사전 로드 누락
- 증상: Bug 11 fix 후 STT 경로로는 진입하지만 LocalSTTService가 "모델 로딩 중, 오디오 버퍼에
적재"로 pending 큐에 쌓기만 하고 아무도 로드를 트리거하지 않아 영원히 대기.
- 원인: MeetingMode/CaptionService는 startRecording 시 LocalSTTService.initialize()를 명시적으로
호출하지만 VoiceConversationService는 이 호출 누락.
- Fix: startSession에서 void getLocalSTTService().initialize().catch(...) fire-and-forget.
initialize는 같은 모델이 Ready면 즉시 return하므로 idempotent.
검증:
- desktop tsc --noEmit EXIT=0
- /usr/bin/say -r 180 "TTS 분기 테스트 성공" 직접 호출 → 스피커 소리 확인
- 사용자 재시연: Mic → 말함 → ⏹ Stop → 전사 → LLM 스트리밍 → TTS 소리 → 자동 listening
복귀 연속 대화까지 end-to-end 통과
남은 과제 (다음 세션 U6/U8):
- Voice Conversation UX 몰입 패널 (9바 waveform + REC LED + 타이머) — 설계 완료
- Bug 13: 빈 STT 결과 시 사용자 피드백 부재 (현재는 조용히 listening 복귀 → 버튼 먹통처럼 보임)
Bug 10 fix(MeetingModePage 인라인 가드)를 isImeComposingEvent helper로 추출하고,
한글 위험도 있는 나머지 7개 Enter 핸들러에 일괄 적용. 총 8곳이 이제 동일 helper 경유.
신규:
- apps/desktop/src/renderer/utils/keyboard.ts — isImeComposingEvent(e)
JSDoc에 Bug 10 원리(Chromium이 IME 조합 중 Enter를 2번 발화) + 권장 사용 패턴 포함
적용 8곳:
- pages/MeetingModePage.tsx:164 회의 메모 (기존 인라인 가드 4줄 교체)
- pages/KnowledgeBasePage.tsx:84 RAG 쿼리
- pages/VoiceConversationPage.tsx:113 텍스트 채팅
- pages/CommandsPage.tsx:336 키워드 추가 (Enter+Esc)
- components/meeting/MeetingChatPanel.tsx:111 미팅 챗
- components/meeting/EditableSegment.tsx:70 전사 세그먼트 편집 (Enter+Esc)
- components/meeting/MeetingDetailTabs.tsx:242 미팅 타이틀 (인라인 arrow → 블록)
- components/shared/HistoryEntryCard.tsx:64 태그 추가 (Enter+Esc)
/simplify 패스 품질 리뷰:
- Phase 3.3 CloudSyncService.pushOne 훅 8곳은 이미 fire-and-forget 1줄로 일관.
내부 try-catch가 에러 삼켜 로컬 write 차단 금지 철학 준수 → 수정 없음, 현 상태가 최적.
검증:
- desktop tsc --noEmit EXIT=0
- Vite HMR로 dev 프로세스 자동 반영 (재기동 없음)
- 한글 Enter 시연은 사용자 실측 대기 (VoiceConversationPage / MeetingDetailTabs 대표 2곳)
Phase 5 Part 3에서 남겨둔 U2(Fix 1 직접 시연 미수행)를 해소하고,
실증 중 발견한 한글 IME Enter 중복 addMemo 버그(Bug 10)를 같이 픽스.
## Bug 10 — 한글 IME Enter 중복 addMemo
- 증상: 실증 녹음 중 한글 메모 "메모 기능이 잘 작동하는지 봅니다"가
2ms 간격으로 2번 저장 (로그 21:47:57.410/.412, UUID c7f0db45 + a6e442cc).
5개 메모 중 이 하나만 중복 — 조합이 아직 확정 안 된 상태에서 Enter를
친 메모에서만 발생.
- 원인: 한글 IME 조합 중 Enter → Chromium이 keydown 2번 발화
((1) IME 조합 확정 isComposing=true/keyCode=229,
(2) 실제 Enter 액션). MeetingModePage.tsx:164-174의 handleMemoKeyDown이
e.key === 'Enter' && !e.shiftKey만 검사하고 isComposing을 무시해
두 이벤트에서 모두 handleAddMemo() 발화 → IPC 2회 → addMemo() 2회.
- 수정: MeetingModePage.tsx:168에 이중 가드 추가.
isComposing은 모던 표준, keyCode === 229는 조합 확정 시점에
isComposing이 false로 내려가면서 keyCode만 229로 주는 Chromium
엣지 케이스 호환.
- 검증: Vite HMR로 즉시 반영 후 재시연 녹음(78421e75)에서 한글 메모
5건 연속 → 전원 1:1 매핑, 중복 0.
## Fix 1 (Phase 3.3 startRecording pre-push) 직접 실증
재시연 녹음 78421e75 (21:51:24~21:51:50, 약 26초) 로그 타임라인:
21:51:24.937 pushOne meetings/78421e75 ok <- Fix 1 pre-push (676ms)
21:51:30.499 pushOne meeting_memos/4ed7e59a ok <- addMemo #1 (113ms)
21:51:32.202 pushOne meeting_memos/de011c15 ok <- addMemo #2 (78ms)
21:51:35.893 pushOne meeting_memos/3184fe5e ok <- addMemo #3 (66ms)
21:51:38.263 pushOne meeting_memos/00f041ef ok <- addMemo #4 (82ms)
21:51:40.740 pushOne meeting_memos/0808c4d2 ok <- addMemo #5 (64ms)
21:51:50.681 pushOne meetings/78421e75 ok <- _runPostProcessing (108ms)
21:51:50.709 pushOne history/70388090 ok <- CaptionService→History
meetings/78421e75가 녹음 시작 + 종료 두 번 push된 것이
Fix 1의 직접 증거 — 부모 row가 메모 push 시점에 이미 Supabase에
존재했기 때문에 메모 5건이 RLS/FK 차단 없이 통과. Bug 7 재현 방지 확인.
## 잠재 IME 버그 7곳
동일 패턴의 Enter 핸들러 7곳에 가드 없음 (KnowledgeBasePage:85,
VoiceConversationPage:114, MeetingChatPanel:113, EditableSegment:72,
MeetingDetailTabs:242, HistoryEntryCard:65, CommandsPage:337).
다음 세션 /simplify 패스에서 일괄 처리 예정.
## 검증
- desktop tsc --noEmit EXIT=0
- Meeting 녹음 2회차 실증 통과
memory/project_status.md SaaS [11] 섹션 추가.
SaaS [9] — 실증 A/B/C/D 전부 통과, 로컬→클라우드 push 최초 성공(pushed=1).
## Bug 4: 로컬 nanoid PK vs Supabase UUID PK 불일치
- 증상: Push history failed: invalid input syntax for type uuid: "fvy6bIzr..."
- 원인: 로컬 drizzle schema는 text PK + nanoid() 생성, Supabase는 uuid PK.
빅뱅 사이클 내내 push가 한 번도 성공한 적 없었음 (지난 pushed=0은 데이터 0건이라서).
- 픽스: 로컬을 UUID로 통일 (근본 해결, 땜질 금지).
14개 서비스 20곳 nanoid() → crypto.randomUUID() 일괄 교체.
nanoid 의존성 + electron.vite.config exclude 제거.
drizzle schema는 text PK 그대로 유지 (SQLite는 UUID 문자열 저장 가능).
## Bug 5: supabase_realtime publication 누락
- 증상: 로그인 직후 Realtime 채널 상태: TIMED_OUT
- 원인: initial_schema.sql이 transcripts 테이블만 publication에 추가.
데스크톱이 구독하는 meetings/history/dictionary는 누락 → postgres_changes 흐르지 않음.
- 픽스: 20260411000002_realtime_publication.sql 신규.
pg_publication_tables 카탈로그 체크 + 조건부 ADD TABLE (meetings/meeting_memos/
meeting_documents/history/dictionary 5개). supabase db push 적용.
## Bug 6: persistSession:false에서 realtime.setAuth 자동 전파 안 됨 (부분 픽스)
- 픽스: CloudSyncService.startRealtime()에 client.realtime.setAuth(access_token)
명시 호출 (채널 구성 이전).
- ⚠️ Bug 5+6 적용 후에도 Realtime 여전히 TIMED_OUT. 후속 조사 필요.
블로커 아님 — 주기 pull + Phase 3.3 auto push로 최종 일관성 유지.
## 실증 결과
- A 세션 자동 복원: Restored session for yunchan8804@gmail.com → DB 재오픈
- B push 경로: HistoryService created 56a767ac-... → Sync complete pushed=1 errors=0
- C 로그아웃 복귀: Realtime 종료 → users/_local/d3ro.db 복귀 → local mode
- D 재로그인 복원: 실증 A의 restore 경로와 동일, 같은 uuid DB 파일 보존
- E 웹 크로스 디바이스: Phase 3.3 이후로 지연 (Realtime 이슈 별건)
검증: desktop tsc --noEmit ✅, dev 재기동 ✅, push 최초 성공 ✅
빅뱅 Phase 5 실증 중 발견한 3개 버그 해결:
1. implicit flow 응답 무시:
- supabase-js가 persistSession:false 일 때 PKCE code_verifier 저장 못해
implicit flow로 fallback, fragment(#access_token=...&refresh_token=...)로
토큰 전달. handleDeepLink는 ?code=만 파싱하고 있어 무시됨.
- handleDeepLink에 fragment parser 추가 (access_token + refresh_token)
- CloudSyncService.handleAuthTokens() 신규 — setSession() 후
기존 _onAuthenticated SSOT로 수렴
- 에러 query/fragment(error, error_description) 상세 로깅
- 토큰 값은 로그에 안 찍히도록 (query=yes/no, fragment=yes/no 만)
2. Supabase RLS 무한 재귀:
- team_members_read_same_team 정책이 자기 테이블 재조회 →
Postgres RLS 엔진 무한 재귀 탐지 에러
- meetings / meeting_memos / meeting_documents / team_invites 정책이
team_members 서브쿼리 경유해서 전부 같이 터짐 + Realtime TIMED_OUT
- migration 20260411000001: SECURITY DEFINER 함수 2개
(user_team_ids, user_admin_team_ids) 신규 — Supabase 권장 패턴
- 영향 정책: team_members(4) + meetings(2) + meeting_memos(1) +
meeting_documents(2) + team_invites(1) 전부 함수 기반으로 재작성
- supabase db push 완료
3. LoginScreen stale 번들:
- Phase 1.5에서 import 제거했는데도 vite HMR/cache 어딘가에서
stale state 유지해서 렌더러에 계속 뜸
- LoginScreen.tsx 파일 자체 삭제 (vite 컴파일 대상 제거)
로그인 성공 확인: yunchan8804@gmail.com 으로 Google OAuth 완주 →
users/7da3dd02-9f2f-4ee9-a9b3-1c2c24875a93/d3ro.db 생성 → Initial sync 시작.
Phase 3 재정의: Supabase = source of truth 방향 철회.
로컬 entry point 철학 하에 "로컬이 SoT, 클라우드는 로그인 시 mirror"로 확정.
CloudSyncService._initialSync():
- _onAuthenticated 끝에 fire-and-forget 호출
- pushAll → pullAll 순차 실행
- signin: 익명 로컬 데이터를 사용자 계정으로 업로드
- restore: 다른 기기 변경 반영
- 실패 시 warn만, 수동 Sync 버튼으로 재시도 가능
types.ts:
- FeatureAccess.reason: 'login_required' 추가
- UpgradePromptEvent.reason: 'login_required' 추가
LicenseService:
- consumeQuota(): login_required 케이스 — promptUpgrade + D3ROError(TierRequired)
- promptUpgrade(): login_required 시 requiredTier='free'
(로그인만 하면 free로 바로 사용 가능)
- quota_exceeded 시 현재 tier 상위로 승격 제안
UpgradePromptModal:
- isLoginRequired 분기 — title/desc 교체
- Primary button: "로그인하기" → d3ro:open-settings event (tab=cloud)
i18n ko/en:
- license.loginRequired.title/desc/signIn 추가
비즈니스 모델 수정:
- 무료 로컬 모드 = 사용자 onboarding funnel 입구 (킬러 피처, 회원가입 0)
- 유료 클라우드 SaaS = 수익화 (OAuth 로그인 + pro + premium 모델 + sync)
- 이전 세션 SaaS [2] OAuth 강제 게이트 철회
db/index.ts:
- LOCAL_USER_ID = '_local' 상수 export
- openLocal() 헬퍼 — userData/users/_local/d3ro.db
- isLocalMode() 헬퍼
bootstrap.ts:
- database step 복원 (critical). openLocal() 호출로 앱 시작 시
로컬 DB 자동 오픈 — 로그인 없이 즉시 메인 UI 진입 가능
CloudSyncService._onSignOut():
- closeCurrent() 후 openLocal() 호출 — 로그아웃 시 로컬 모드 DB로 복귀
- 앱은 계속 로컬 모드로 동작 (entry point 철학)
App.tsx:
- AuthGate 상태머신 제거 (loading/login-required/authenticated/legacy)
- LoginScreen import 제거, 항상 AppLayout 렌더
- LoginScreen.tsx 파일은 유지 (추후 Welcome 화면 재활용 가능)
검증:
- desktop tsc --noEmit ✅
- desktop build ✅
- dev 런타임: [bootstrap] local database opened → Main window shown
- users/_local/d3ro.db 파일 생성 확인
배경:
사용자가 데스크톱 앱 Settings에서 직접 Supabase URL/Key를 입력해야 했음.
SaaS 데스크톱(Notion/Linear/Slack) 패턴에서는 사용자가 OAuth 로그인만 하면 끝이고
연결 정보는 빌드 타임에 박혀있어야 함. V1 single-user 잔재로 인한 UX 결함이었고
사용자가 'SaaS 설계가 잘못된 것 같다'고 우려할 만한 명확한 흠집.
[1] 빌드 타임 Supabase env 주입
- electron.vite.config.ts: defineConfig가 mode 받아 loadEnv('D3RO_') 호출
- .env, .env.local 자동 로드 (vite/electron-vite 표준)
- main 번들에 process.env.D3RO_SUPABASE_URL/ANON_KEY를 inline define으로 박음
- ConfigService:
- BUILD_TIME_SUPABASE_URL / BUILD_TIME_SUPABASE_ANON_KEY 상수
- isSupabaseBuildTimeConfigured() / getBuildTimeSupabase{Url,AnonKey}() 헬퍼
- configGet('supabaseUrl'/'supabaseAnonKey')는 빌드 타임 값을 절대 우선
- configSet은 빌드 타임 모드일 때 supabaseUrl/AnonKey 변경 거부 (덮어쓰기 방지)
- apps/desktop/.env.local 생성 (gitignored, llnocwyqvhgwpdjcqqyw 프로젝트)
- apps/desktop/env.example 템플릿 (committed)
[2] OAuth 첫 실행 강제 게이트
- CloudSyncState에 saasMode: boolean 필드 추가 (CloudSyncService.getState)
- LoginScreen.tsx 신규: Google/GitHub 버튼만 노출 (URL/Key 입력 필드 없음)
- MetalCard + PhosphorText D3RO 디자인 시스템
- i18n 키 재사용 (login.subtitle/google/github/terms)
- App.tsx에 AuthGate 추가:
- cloudSync.getState로 saasMode + authenticated 조회
- status: 'loading' | 'login-required' | 'authenticated' | 'legacy'
- saasMode이고 인증 안 됨 → LoginScreen 강제
- saasMode이고 인증 됨 → 기존 AppLayout
- saasMode 아님 → 'legacy' (기존 동작 유지, 개발자가 .env.local 설정 안 한 경우)
- onAuthChanged 구독으로 로그인 직후 자동 전환
- CloudSyncSection (Settings 안):
- saasMode일 때 URL/Key 입력 필드 완전 숨김 (OAuth 버튼만)
- !saasMode (legacy)일 때만 기존 입력 화면 유지
검증:
- typecheck OK
- dev 재시작 시 'CloudSync disabled — Supabase URL/key not configured' 로그 사라짐
→ 빌드 타임 env가 정상적으로 ConfigService에 주입되어 client 생성됨
- 첫 실행 시 사용자가 보는 화면 = LoginScreen (Google/GitHub만)
증상:
- STT 426ms로 빠른데 LLM refine 단계가 39초 소요 후 빈 문자열 반환
- _completeSession('') → finalText.length === 0이라 paste 호출 자체 건너뜀
- 사용자에게는 '느리고 paste 안 됨'으로 보임
원인:
- 사용자가 ollama pull qwen3:4b 한 직후 첫 호출 (모델 cold start 일부 있음)
- qwen3는 reasoning model이라 응답에 <think>...</think> 블록을 길게 출력
- system prompt에 '/no_think' 토큰 없음 → reasoning mode ON
- generate()는 data.response.trim() 그대로 반환 → think 블록 + 빈 본문이면
trim 후 빈 문자열
- 빈 문자열에 대한 fallback이 없어서 그대로 _completeSession('')
수정:
- SYSTEM_PROMPTS 모두에 '/no_think' 헤더 추가
- qwen3 reasoning 비활성화 → 응답 속도 대폭 단축
- 다른 모델(llama, mistral, gemma)은 토큰 무시 → 호환성 OK
- stripReasoningBlocks() 추가
- <think>...</think> + <thinking>...</thinking> 블록 제거 (gi flag)
- /no_think를 무시하는 모델 + 응답에 think tag가 섞여 들어오는 케이스 안전망
- processText() 결과:
- stripReasoningBlocks(result.text)
- 빈 문자열이면 원본 transcript fallback + warn 로그
증상:
- STT 전사는 정상 ('그럼 이제는 되는 건가요?')
- 로그상 'Text inserted (14 chars, 851ms)' 성공으로 보임
- 그런데 실제로 활성 앱에 텍스트가 안 들어감
원인:
- TextInsertService._insertViaClipboard가 nut.Key.LeftControl + V로 하드코딩
- macOS는 Cmd+V (LeftSuper+V)가 표준 paste 단축키
- Ctrl+V는 macOS에서 동작 안 함 (앱마다 다르지만 보통 noop)
- 클립보드 검증 로직도 거짓 양성:
- paste 후 clipboard.readText() === text면 '정상'으로 판단
- macOS에서는 Ctrl+V가 클립보드를 안 건드리므로 항상 일치 → 항상 'success' 로그
수정:
- pasteModKey = process.platform === 'darwin' ? LeftSuper : LeftControl
- pressKey/releaseKey 시 동적 modifier 사용
- releaseKey는 V → mod 순으로 release (modifier last 권장)
- 잘못된 검증 로직 제거 (의미 없는 로그였음)
- macOS는 paste 후 sleep 250ms (Win/Linux 150ms) — 일부 앱의 비동기 처리 여유
- clipboard write 후 20ms 짧은 지연 추가 (앱이 클립보드 변화 인식 시간)