The released installer could not start: it carried a better-sqlite3 build for the
host Node runtime instead of Electron, so the app died immediately with a module
version mismatch when it opened its database.
Packaging now proves the Electron build of every runtime-sensitive native module
before an installer or archive exists, and installers are produced only from that
verified tree, so the mistake cannot pass silently. The release pipelines run the
same check.
The default local model also pointed at a retired model: a *.gguf name that
Ollama cannot serve, while the settings, onboarding, and guide screens
recommended an older model. All of them now use the model the service code
already preferred.
Auto-update could not work at all: the installer was 189 MB because it carried
the local speech engine and ffmpeg, and the download feed rejects uploads over
about 100 MiB, so update metadata could never be published.
The installer now leaves those components out and the app fetches them the first
time they are needed, verifying every part and the joined archive before
installing. The installer is 90.6 MiB, the update feed is published again, and
updates stay small because the engine is not re-sent on every release.
The fetch is visible and recoverable: the download runs with progress, a failed
install cleans up after itself, and Settings > STT shows the runtime status with
a manual download action for when the automatic one cannot run.
Local dictation had never produced a transcript on an installed build. The
engine itself was healthy; every connection to it was broken.
Installed builds shipped no speech engine at all: the packaging config had no
entry for the faster-whisper sidecar and no pipeline step built one, so the app
always fell back to a system Python without the runtime. Development was broken
too, because the sidecar and SoX paths were resolved against the Vite output
directory instead of the app root, which also meant recording failed with a SoX
ENOENT. On hosts where localhost resolves only to IPv6, every local request was
refused outright, which silently disabled both local transcription and the local
LLM.
The sidecar is now built and bundled (including the Silero VAD data it needs),
gated by a packaging check that fails when the engine or its data is missing.
Paths are discovered from the app root and fail loudly when the engine is
absent. Local engine URLs are normalized to the IPv4 loopback, decoding is tuned
so repeated hallucinations cannot compound (the same transcript now takes about
a fifth of the time), the engine is warmed up at startup, and holding the hotkey
now shows the text forming live in the recording tip.
Several desktop paths quietly substituted defaults or partial results: a
config write could fall back to a throwaway in-memory store, speech provider
errors were absorbed into empty transcriptions, and meeting exports built
file names from raw titles.
Writes now fail explicitly when the store is unavailable, provider and model
failures reach the UI as errors, and export names pass through one
sanitizer. Settings, license, ad, and support surfaces use the shared theme
tokens, unused hotkey helpers are gone, and the package gains strict
node/renderer typecheck configs plus red-team e2e scenarios for these flows.
When mediation had no programmatic fill, the banner and rewarded surfaces
collapsed to empty space. Direct house sponsors now serve their own copy and
click-through, with the same settlement accounting used by the mediated
network, and the mediation engine tests cover the added path.
Users could only rebuild their spoken-word dictionary entry by entry. Import
and export now round-trip the whole list, reporting duplicate and invalid
entries per row instead of failing the batch, so a dictionary survives a
reinstall or a move to another machine.
Desktop clients had two competing update sources: the runtime pointed at a
legacy GitLab registry while the Forgejo packages were filled in by
hardcoded, version-pinned scripts. Operators could not tell which feed was
authoritative, and no release could be reproduced from a tag.
Auto-update now reads a single canonical Forgejo registry feed, updated by
a version-agnostic publisher that runs from the tag on Forgejo, GitLab, and
GitHub CI alike. Channel, minimum supported version, forced install,
full-versus-delta thresholds, staged rollout, and a remote kill switch come
from one policy file the client fetches alongside the feed. Tag creation is
gated on a clean tree, matching version surfaces, and a changelog section.
refactor-wave WS2가 AppConfig 인터페이스에 7키 추가 + as never 16건 제거했으나
CONFIG_DEFAULTS(ConfigService.ts) 기본값 누락 → 기존 사용자 config(0.1.x) 마이그레이션
시 configGet undefined → main 프로세스 .map() 크래시 (v0.2.0-alpha 앱 실행 불가).
수정: CONFIG_DEFAULTS에 7키 기본값 추가.
검증: dev 실행 — main 빌드 + electron start 정상 (수정 전엔 이 단계 크래시).
교훈: 배포 전 dev 실행 + 산물 e2e 필수. typecheck GREEN ≠ 런타임 안전.
동기 부여된 모션만 — 히어로(maxTilt 8) + 4개 스탯 타일(maxTilt 6). 나머지 데이터 밀집/드롭존/리스트 섹션은 MetalCard 유지 (틸트 = unmotivated slop 회피).
Co-Authored-By: Claude <noreply@anthropic.com>
- 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는 실패가 아닌 기존 진행 합류
- 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)
- 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 추가