diff --git a/AGENTS.md b/AGENTS.md index eff637a..a55ecb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ Key files: - `AppDelegate.swift`: app lifecycle and hotkey event monitors - `OverlayWindowController.swift`: floating `NSPanel` lifecycle - `OverlayContentView.swift`: overlay host and Apple Translation session injection -- `TextDetector.swift`: Accessibility selected-text detection plus clipboard fallback +- `TextDetector.swift`: clipboard text detection through `NSPasteboard` - `TranslationEngine.swift`: Apple `TranslationSession` wrapper and language persistence - `TranslationViewModel.swift`: translation state, debounce, retranslation flow, UI coordination @@ -87,7 +87,7 @@ Keep SwiftUI views view-focused. Put AppKit/window/platform lifecycle code in de Add XCTest coverage for new app behavior in `transScanTests/`. Prefer focused tests around: -- text normalization and clipboard fallback +- text normalization and clipboard polling - translation engine session/language behavior - view model state transitions - shortcut validation and persistence diff --git a/CLAUDE.md b/CLAUDE.md index 9f4ce29..d3a7365 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,14 +8,14 @@ Start with `AGENTS.md`. It is the shared workflow contract for how agents should ## Project Overview -transScan은 macOS 전용 번역 오버레이 앱이다. 사용자가 텍스트를 드래그한 뒤 설정된 단축키를 누르면 메뉴바 앱이 floating overlay를 띄우고, 선택 텍스트 또는 클립보드 텍스트를 Apple Translation framework로 번역해 보여준다. Dock 아이콘 없이 메뉴바에 상주하는 도구다. +transScan은 macOS 전용 번역 오버레이 앱이다. 사용자가 텍스트를 복사한 뒤 설정된 단축키를 누르면 메뉴바 앱이 floating overlay를 띄우고, 클립보드 텍스트를 Apple Translation framework로 번역해 보여준다. Dock 아이콘 없이 메뉴바에 상주하는 도구다. ## Architecture Decisions - **번역 엔진**: Apple Translation framework의 `TranslationSession` 사용. OpenAI, Google Translate, DeepL, custom LLM API를 사용하지 않는다. -- **텍스트 감지**: Accessibility API(`AXUIElement`)로 선택 텍스트를 읽고, 실패하면 클립보드 fallback을 사용한다. +- **텍스트 감지**: 현재 안정 동작은 `NSPasteboard` 클립보드 변경 감지다. 직접 선택 텍스트 감지는 roadmap의 미래 목표로만 다룬다. - **UI**: SwiftUI + `NSPanel`. 오버레이는 floating, non-activating, draggable 패널이다. -- **앱 형태**: `LSUIElement=YES` menu bar app. `MenuBarExtra`에서 오버레이 열기, 설정, 권한 설정, 종료를 제공한다. +- **앱 형태**: `LSUIElement=YES` menu bar app. `MenuBarExtra`에서 오버레이 열기, 설정, 종료를 제공한다. - **단축키**: `HotkeyConfiguration`이 저장/검증을 담당한다. 기본값은 `⌥E`이고, 설정창에서 변경 가능하다. - **브랜딩**: 앱 아이콘, 메뉴바 아이콘, README/마케팅 로고를 분리해서 관리한다. 메뉴바 아이콘은 template image asset이어야 한다. @@ -31,7 +31,7 @@ transScan은 macOS 전용 번역 오버레이 앱이다. 사용자가 텍스트 2. `AppDelegate.swift` handles local/global hotkey monitors. 3. `OverlayWindowController.swift` owns the `NSPanel`. 4. `OverlayContentView.swift` injects the Translation session. -5. `TextDetector.swift` emits selected/clipboard text. +5. `TextDetector.swift` emits clipboard text. 6. `TranslationViewModel.swift` coordinates state and retranslation. 7. `TranslationEngine.swift` wraps Apple Translation and language persistence. diff --git a/README.ko.md b/README.ko.md index ddda7d2..5af1828 100644 --- a/README.ko.md +++ b/README.ko.md @@ -26,7 +26,7 @@ MVP는 macOS 전용 SwiftUI/AppKit 앱으로 구현되어 있습니다. - `LSUIElement=YES` 기반 메뉴바 앱 - `NSPanel` + SwiftUI로 만든 항상 위에 뜨는 번역 오버레이 - `UserDefaults`에 저장되는 사용자 설정 단축키 -- 클립보드 fallback과 Accessibility 선택 텍스트 감지 +- `NSPasteboard` 기반 클립보드 텍스트 감지 - 소스 언어 자동 감지 또는 수동 선택 - 타겟 언어 선택 및 소스/타겟 언어 전환 - `.translationTask`를 통한 Apple Translation framework 세션 주입 @@ -49,7 +49,6 @@ MVP에서 제외한 범위: - Xcode가 설치된 macOS 개발 환경 - 대상 macOS 버전의 Apple Translation framework 지원 -- 선택 텍스트 감지를 위한 손쉬운 사용 권한 현재 Xcode 프로젝트는 generated Info.plist 설정, `LSUIElement=YES`, bundle id `com.finn.transScan`을 사용합니다. @@ -81,7 +80,7 @@ xcodebuild -project transScan.xcodeproj -scheme transScan test 2. `AppDelegate.swift`가 global/local key monitor를 등록하고 오버레이를 토글합니다. 3. `OverlayWindowController.swift`가 플로팅 `NSPanel` 생명주기를 관리합니다. 4. `OverlayContentView.swift`가 SwiftUI 오버레이를 호스팅하고 `.translationTask`로 Translation session을 주입합니다. -5. `TextDetector.swift`가 Accessibility API와 클립보드 polling으로 텍스트를 감지합니다. +5. `TextDetector.swift`가 `NSPasteboard` 클립보드 변경을 감지합니다. 6. `TranslationViewModel.swift`가 감지 텍스트 debounce, 번역 상태, 언어 전환, UI 흐름을 관리합니다. 7. `TranslationEngine.swift`가 Apple Translation, 언어 저장, 언어팩 준비, 세션 준비 전 pending request를 처리합니다. @@ -97,7 +96,7 @@ transScan/ macOS app source OverlayContentView.swift overlay host and Translation session injection TranslationViewModel.swift translation state and UI flow TranslationEngine.swift Apple Translation wrapper - TextDetector.swift Accessibility and clipboard text detection + TextDetector.swift Clipboard text detection SettingsView.swift shortcut/settings UI transScanTests/ XCTest coverage @@ -181,7 +180,7 @@ macOS 특화 작업에는 `build-macos-apps` 플러그인을 사용합니다. 중점 테스트: -- 텍스트 정규화와 클립보드 fallback +- 텍스트 정규화와 클립보드 polling - 중복 텍스트 suppression - translation engine 언어/세션 동작 - view model 상태 전환 @@ -212,4 +211,4 @@ PR에는 다음을 포함합니다. - 관련 issue 또는 decision note - build/test 결과 - UI 변경의 screenshot 또는 screen recording -- 손쉬운 사용 권한 또는 수동 macOS 검증 notes +- 클립보드 흐름 또는 수동 macOS 검증 notes diff --git a/README.md b/README.md index 88adb8a..a561db0 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ The MVP is implemented as a macOS-only SwiftUI/AppKit app. - Menu bar app with `LSUIElement=YES` - Floating always-on-top overlay built with `NSPanel` + SwiftUI - Configurable hotkey stored in `UserDefaults` -- Accessibility selected-text detection with clipboard fallback +- Clipboard-based text detection through `NSPasteboard` - Source language auto-detect or manual source selection - Target language picker and source/target swap - Apple Translation framework session injection through `.translationTask` @@ -49,7 +49,6 @@ MVP non-goals: - macOS app development environment with Xcode - Apple Translation framework support on the target macOS version -- Accessibility permission for selected-text detection The current Xcode project uses generated Info.plist settings, `LSUIElement=YES`, and bundle id `com.finn.transScan`. @@ -81,7 +80,7 @@ The high-level path is: 2. `AppDelegate.swift` registers global/local key monitors and toggles the overlay. 3. `OverlayWindowController.swift` owns the floating `NSPanel` lifecycle. 4. `OverlayContentView.swift` hosts the SwiftUI overlay and injects the Translation session with `.translationTask`. -5. `TextDetector.swift` watches selected text with Accessibility APIs and falls back to clipboard polling. +5. `TextDetector.swift` watches clipboard changes through `NSPasteboard`. 6. `TranslationViewModel.swift` debounces detected text, tracks translation state, handles language swaps, and coordinates UI updates. 7. `TranslationEngine.swift` wraps Apple Translation, language persistence, language-pack preparation, and pending requests while the session is not ready. @@ -97,7 +96,7 @@ transScan/ macOS app source OverlayContentView.swift overlay host and Translation session injection TranslationViewModel.swift translation state and UI flow TranslationEngine.swift Apple Translation wrapper - TextDetector.swift Accessibility and clipboard text detection + TextDetector.swift Clipboard text detection SettingsView.swift shortcut/settings UI transScanTests/ XCTest coverage @@ -181,7 +180,7 @@ App tests live in `transScanTests/`. Prefer focused XCTest coverage around: -- text normalization and clipboard fallback +- text normalization and clipboard polling - duplicate text suppression - translation engine language/session behavior - view model state transitions @@ -212,4 +211,4 @@ For PRs, include: - linked issue or decision note if applicable - build/test results - screenshots or screen recordings for UI changes -- notes about Accessibility permission or manual macOS verification +- notes about clipboard flow or manual macOS verification diff --git a/docs/ADR.md b/docs/ADR.md index b18ba17..c52641a 100644 --- a/docs/ADR.md +++ b/docs/ADR.md @@ -13,10 +13,10 @@ --- -### ADR-002: 텍스트 입력 방식 — Accessibility API + Clipboard fallback -**결정**: 1순위 AXUIElement(Accessibility API)로 선택 텍스트 감지, 감지 실패 시 클립보드 fallback -**이유**: 사용자가 별도 복사 동작 없이 드래그만으로 번역 트리거 가능. PopClip 등 동일 방식 검증됨. -**트레이드오프**: Accessibility 권한 요청 필요 (System Settings > Privacy > Accessibility). 권한 미허용 시 클립보드 모드로만 동작. +### ADR-002: 텍스트 입력 방식 — Clipboard-first +**결정**: MVP의 안정 동작은 `NSPasteboard` 클립보드 변경 감지를 기준으로 한다. +**이유**: 현재 제품 흐름은 사용자가 명시적으로 복사한 텍스트를 팝업에서 계속 번역하는 경험에 맞춰져 있다. 클립보드는 앱별 구현 차이에 덜 흔들리고, 오버레이를 띄운 상태에서 새로 복사할 때마다 안정적으로 입력을 받을 수 있다. +**트레이드오프**: 드래그만으로 자동 번역되는 경험은 현재 MVP에서 보장하지 않는다. 사용자는 선택 후 복사 동작을 해야 하지만, 번역 결과는 오버레이를 띄운 상태에서 복사할 때마다 안정적으로 갱신된다. --- @@ -31,3 +31,16 @@ **결정**: Dock에 표시하지 않는 메뉴바 앱 (Info.plist `LSUIElement = YES`) + 상단 메뉴바에 `NSStatusItem` 아이콘 상시 표시 **이유**: 번역 툴은 항상 백그라운드 실행 상태여야 하고, Dock 아이콘은 불필요한 노이즈. 메뉴바 아이콘으로 실행 여부 확인 및 종료 가능. **트레이드오프**: 앱 종료/설정은 메뉴바 아이콘 클릭 메뉴에서만 접근 가능. + +--- + +### ADR-005: 번역 상태 관리 — TranslationSession bridge + ViewModel +**결정**: SwiftUI `.translationTask`에서 생성된 `TranslationSession`을 `TranslationEngine`에 주입하고, 텍스트 감지/debounce/재번역/에러 상태는 `TranslationViewModel`에서 관리한다. +**이유**: Apple Translation Framework의 `TranslationSession`은 view layer lifecycle에 묶여 있어 engine이 직접 생성할 수 없다. 따라서 session 생성은 SwiftUI가 담당하고, engine은 주입받은 session으로 번역 요청을 처리해야 한다. 텍스트 감지 이벤트는 반복적으로 들어올 수 있으므로 ViewModel에서 debounce, pending request, language change, retranslation 상태를 한 흐름으로 조정한다. +**트레이드오프**: view layer와 translation engine 사이에 명시적인 bridge가 필요하다. `.translationTask(engine.configuration)`와 `engine.provideSession(session)` 연결이 깨지면 번역 요청이 pending 상태로 남을 수 있으므로, overlay host view와 engine wiring을 핵심 architecture rule로 유지해야 한다. + +--- + +## Decision Log + +- 2026-05-28: current runtime에서 Accessibility observer, selected-text event path, 권한 UI를 제거하고 `NSPasteboard` polling만 유지했다. 직접 선택 텍스트 감지는 별도 안정화 작업으로 되돌릴 때 ADR-002를 다시 갱신한다. diff --git a/docs/PRD.md b/docs/PRD.md index 08026ac..35bad28 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1,7 +1,7 @@ # PRD: transScan ## 목표 -macOS에서 단축키 하나로 오버레이를 띄워, 드래그한 텍스트를 즉시 번역해주는 툴. +macOS에서 단축키 하나로 오버레이를 띄워, 복사한 텍스트를 즉시 번역해주는 툴. 브라우저나 앱을 전환하지 않고 읽으면서 바로 번역할 수 있게 한다. ## 사용자 @@ -11,8 +11,7 @@ macOS에서 단축키 하나로 오버레이를 띄워, 드래그한 텍스트 ## 핵심 기능 1. **단축키 오버레이** — 설정창에서 직접 녹화한 단축키로 플로팅 번역 창 토글 (기본값: `⌥E`) -2. **선택 텍스트 자동 번역** — Accessibility API로 드래그 선택된 텍스트를 감지해 즉시 번역 - - 선택 텍스트 감지 불가 시 클립보드 내용으로 fallback +2. **클립보드 기반 번역** — 사용자가 선택한 문장을 복사하면 클립보드 변경을 감지해 즉시 번역 3. **언어 자동 감지 + 타겟 언어 선택** — 소스 언어는 auto detect, 타겟 언어는 드롭다운으로 선택 4. **언어 방향 스왑** — `원어 → 번역어` 표시 영역을 클릭하면 방향 반전 5. **드래그 가능한 창** — 오버레이는 화면 하단 기본 위치, 마우스로 자유롭게 이동 가능 @@ -29,3 +28,7 @@ macOS에서 단축키 하나로 오버레이를 띄워, 드래그한 텍스트 - 미니멀, 다크/라이트 모드 시스템 설정 따름 - 오버레이는 작고 가벼운 느낌 — 읽는 흐름을 방해하지 않는 크기 - 언어 방향 표시(`EN → KO`)는 항상 상단에 노출 + +## Decision Log + +- 2026-05-28: 현재 MVP 요구사항을 clipboard-first로 확정했다. Accessibility 기반 직접 선택 텍스트 감지는 앱별 안정성 검증 전까지 현재 기능이나 권한 요구사항으로 문서화하지 않는다. diff --git a/docs/UI_GUIDE.md b/docs/UI_GUIDE.md index f7c7319..a29f434 100644 --- a/docs/UI_GUIDE.md +++ b/docs/UI_GUIDE.md @@ -22,7 +22,7 @@ ### 번역 결과 영역 - 번역된 텍스트 출력 - 입력 중(번역 대기 중)이면 로딩 인디케이터 표시 -- 번역할 텍스트 없으면 "텍스트를 드래그하거나 복사하세요" 안내 문구 +- 번역할 텍스트 없으면 "번역할 텍스트를 복사하세요" 안내 문구 ## 창 동작 diff --git a/docs/session-logs/2026-05-28.md b/docs/session-logs/2026-05-28.md new file mode 100644 index 0000000..5f6b662 --- /dev/null +++ b/docs/session-logs/2026-05-28.md @@ -0,0 +1,15 @@ +## 한 일 + +문서와 코드가 어긋나 있던 Accessibility 선택 텍스트 흐름을 정리했다. `TextDetector`는 `NSPasteboard` polling만 담당하게 줄였고, 권한 없으면 오버레이가 막히던 `TranslationViewModel` 경로와 설정/메뉴의 권한 CTA를 제거했다. + +DMG 배포 스크립트는 Release 최적화 기본값을 `-O`로 맞추고, DMG 서명 검증과 notarization 전 사전 검증을 추가했다. `xcodebuild -project transScan.xcodeproj -scheme transScan test -quiet`, `xcodebuild -project transScan.xcodeproj -scheme transScan build -quiet`, `bash -n`, `roadmap.progress.json` 검증, `git diff HEAD --check`는 통과했다. + +PRD/ADR에는 2026-05-28 Decision Log를 추가해 현재 MVP는 clipboard-first이고 Accessibility 직접 선택 텍스트 감지는 미래 안정화 목표라는 결정을 남겼다. + +## 이어할 것 + +다음 첫 작업은 mixed staged/un-staged diff를 검토해 커밋 단위를 확정하는 것이다. 그 다음 실제 Developer ID 서명 DMG로 `SIGNING_IDENTITY='Developer ID Application: ...' ./scripts/create_dmg.sh`와 `./scripts/notarize_dmg.sh build/transScan-*.dmg`를 실행해 `notarytool`, `stapler`, `spctl` 결과를 확인한다. + +## 책 메모 + +문서만 clipboard-first로 바꾸고 런타임에 Accessibility 권한 guard가 남아 있으면, 권한이 없는 사용자에게는 클립보드 MVP 자체가 막힌다. 기능을 미래 목표로 내릴 때는 docs뿐 아니라 ViewModel guard, menu/settings CTA, detector event source까지 같이 제거해야 한다. diff --git a/roadmap.md b/roadmap.md index 7231357..2dd9284 100644 --- a/roadmap.md +++ b/roadmap.md @@ -1,12 +1,13 @@ # transScan Roadmap -Updated: 2026-05-08 23:36:37 JST (+0900) -Current focus: 웹 배포용 DMG 패키징 완료 후 Developer ID/notarization 준비 +Updated: 2026-05-28 23:47:34 JST (+0900) +Current focus: clipboard-first 런타임 정리와 DMG notarization 스크립트 보강 완료, 실제 앱/배포 아티팩트 수동 검증 대기 ## 현주소 -- 메뉴바 전용 macOS 앱이다. `LSUIElement=YES`로 Dock 앱이 아니며, SwiftUI `MenuBarExtra`에서 오버레이 열기, `SettingsLink`, 권한 설정, 종료를 제공한다. -- 번역 오버레이는 SwiftUI + `NSPanel` 조합으로 구현되어 있고, 선택 텍스트는 Accessibility API와 클립보드 fallback으로 감지한다. +- 메뉴바 전용 macOS 앱이다. `LSUIElement=YES`로 Dock 앱이 아니며, SwiftUI `MenuBarExtra`에서 오버레이 열기, `SettingsLink`, 종료를 제공한다. +- 번역 오버레이는 SwiftUI + `NSPanel` 조합으로 구현되어 있고, 현재 안정 동작은 복사된 텍스트를 `NSPasteboard` 클립보드 polling으로 감지하는 흐름이다. +- 현재 앱 런타임에서는 Accessibility observer와 권한 CTA를 사용하지 않는다. 직접 선택 텍스트 감지는 미래 목표로만 남긴다. - SwiftUI `Settings` scene은 단축키 녹화 필드를 제공하며, 저장된 단축키는 메뉴바와 오버레이 상단 라벨에 동기화된다. - README는 앱 기준 소개/실행/아키텍처/AI handoff만 남기고 Python/Harness 설명은 제거했다. 번역 모델은 Apple `TranslationSession` 기반이라고 명시했다. - 기본 README는 영어 hero와 영어 본문을 쓰고, `README.ko.md`는 한국어 hero와 한국어 본문을 쓰도록 분리했다. @@ -39,7 +40,7 @@ Current focus: 웹 배포용 DMG 패키징 완료 후 Developer ID/notarization - [x] 원문/번역문 영역의 긴 문장 truncation, scroll, resize 기준 점검 - [x] 원문/번역 구분 UI를 경계선과 작은 라벨 포인트로 정리 - [ ] 원문과 번역문을 나란히 비교하는 compact mode 검토 -- [ ] 복사 버튼, 재번역 버튼, 권한 에러 CTA의 상태 피드백 정리 +- [ ] 복사 버튼, 재번역 버튼의 상태 피드백 정리 ### 4. 단어/핵심어 보조 기능 @@ -48,7 +49,14 @@ Current focus: 웹 배포용 DMG 패키징 완료 후 Developer ID/notarization - [ ] 번역창 하단에 `단어: 뜻` 주석 칩을 3-5개 표시하는 MVP 설계 - [ ] 드래그한 원문 단어와 번역어를 같이 하이라이트하는 고급 UX 가능성 조사 -### 5. 검증과 배포 준비 +### 5. 최종 목표: 선택 텍스트 직접 감지 + +- [ ] Accessibility API 기반 선택 텍스트 감지를 현재 기능으로 문서화할 수 있을 만큼 안정화 +- [ ] 앱별 선택 텍스트 노출 차이와 실패 케이스 기록 +- [ ] 권한 요청, 권한 거부, 클립보드 대체 흐름을 제품 UI에서 명확히 분리 +- [ ] 드래그만으로 번역되는 흐름을 실제 앱에서 검증한 뒤 README/PRD/ADR에 반영 + +### 6. 검증과 배포 준비 - [x] `xcodebuild -project transScan.xcodeproj -scheme transScan build` - [x] `xcodebuild -project transScan.xcodeproj -scheme transScan test` @@ -58,22 +66,23 @@ Current focus: 웹 배포용 DMG 패키징 완료 후 Developer ID/notarization - [ ] 실제 실행 앱에서 메뉴바 아이콘과 앱 아이콘 표시 확인 - [ ] UI 변경마다 스크린샷 또는 짧은 화면 녹화 남기기 -### 6. 앱 로컬라이제이션 +### 7. 앱 로컬라이제이션 - [ ] `transScan/*.swift`의 하드코딩된 한국어 UI 문자열 전체 감사 - [ ] Xcode String Catalog 또는 `Localizable.strings` 기반 구조 결정 -- [ ] 한국어/영어 리소스를 만들고 오버레이, 설정, 메뉴바, 권한 안내, 에러 문구 연결 +- [ ] 한국어/영어 리소스를 만들고 오버레이, 설정, 메뉴바, 에러 문구 연결 - [ ] 시스템 언어 전환 또는 scheme argument로 영어/한국어 UI를 각각 실행 검증 -### 7. DMG 배포 패키징 +### 8. DMG 배포 패키징 - [x] `scripts/create_dmg.sh`로 Release 앱을 Finder 설치창이 있는 DMG로 패키징 - [x] `scripts/dmg_background.swift`로 재생성 가능한 DMG 배경 생성 - [x] Finder 창 크기, 앱 아이콘, Applications alias, 배경, 화살표, 하단 안내 문구 레이아웃 정리 - [x] `build/`, `.bkit/`, Xcode `xcuserdata/` 등 로컬 산출물/상태 파일 ignore 및 추적 해제 -- [ ] Developer ID export, notarization, stapler 검증 플로우 추가 +- [x] Developer ID 서명, notarization, stapler 검증용 스크립트 플로우 추가 +- [ ] 실제 Developer ID 서명 DMG로 `notarytool`, `stapler`, `spctl` 검증 실행 - [ ] notarized DMG를 GitHub Release 또는 웹 다운로드 경로에 업로드하는 절차 문서화 ## 다음 첫 작업 -다음 첫 작업은 `scripts/create_dmg.sh`를 Developer ID export/notarization/stapler 플로우와 어떻게 연결할지 결정하는 것이다. 현재 DMG는 Finder 설치창까지 포함해 잘 만들어지지만, 웹 공개 배포용으로는 Developer ID 서명과 notarization 검증이 남아 있다. +다음 첫 작업은 현재 mixed staged/un-staged diff를 검토한 뒤 커밋 단위를 확정하는 것이다. 그 다음 실제 Developer ID 서명 DMG로 `scripts/create_dmg.sh`와 `scripts/notarize_dmg.sh`를 실행해 `notarytool`, `stapler`, `spctl` 결과를 확인한다. diff --git a/roadmap.progress.json b/roadmap.progress.json index d277f76..17b412a 100644 --- a/roadmap.progress.json +++ b/roadmap.progress.json @@ -1,20 +1,21 @@ { "schemaVersion": 1, "project": "transScan", - "updatedAt": "2026-05-08 23:36:37 JST (+0900)", - "deviceLabel": "bishoe01s-MacBook-Air-2.local", + "updatedAt": "2026-05-28 23:47:34 JST (+0900)", + "deviceLabel": "bishoe01s-MacBook-Air-4.local", "branch": "main", - "currentFocus": "웹 배포용 DMG 패키징 완료 후 Developer ID/notarization 준비", + "currentFocus": "clipboard-first 런타임 정리와 DMG notarization 스크립트 보강 완료, 실제 앱/배포 아티팩트 수동 검증 대기", "handoff": { - "nextFirstAction": "Decide how scripts/create_dmg.sh should connect to Developer ID export, notarization, and stapler verification.", + "nextFirstAction": "Review the mixed staged/un-staged diff and decide the commit scope before publishing.", "nextActions": [ - "Decide how scripts/create_dmg.sh should connect to Developer ID export, notarization, and stapler verification.", - "Archive or export a Developer ID signed Release app before rebuilding the styled DMG.", - "Run notarytool submit and stapler validation once Developer ID credentials are available.", + "Review the mixed staged/un-staged diff and decide the commit scope before publishing.", + "Run SIGNING_IDENTITY='Developer ID Application: ...' ./scripts/create_dmg.sh to build a signed Release DMG.", + "Run ./scripts/notarize_dmg.sh build/transScan-*.dmg and verify notarytool, stapler, and spctl output.", "Document the final web download path, likely GitHub Releases plus a landing-page download link.", + "Manually launch the built app and verify clipboard-first overlay behavior: copy text, open overlay, translate, close, menu bar reopen, shortcut toggle.", "Audit hard-coded Korean UI strings in transScan/*.swift.", "Decide whether to use Xcode String Catalog or Localizable.strings for Korean and English app UI resources.", - "Localize overlay, settings window, menu bar items, permission copy, shortcut recorder, and error messages.", + "Localize overlay, settings window, menu bar items, shortcut recorder, and error messages.", "Verify English and Korean UI by switching system language or using scheme launch arguments.", "Keep README.md as the English default and README.ko.md as the Korean version; verify both hero images render before PR.", "Launch the built app and visually verify the new MenuBarIcon in the macOS menu bar.", @@ -25,7 +26,9 @@ "Save a valid custom shortcut such as Control-Option-E and verify the menu bar label, overlay label, and actual toggle behavior.", "Run the macOS app and test close -> menu bar reopen -> shortcut toggle.", "Decide whether NSEvent monitoring is enough or Carbon RegisterEventHotKey is needed for stronger global shortcut behavior.", - "Research whether Translation/NaturalLanguage can support word annotations for the translation result." + "Research whether Translation/NaturalLanguage can support word annotations for the translation result.", + "Treat Accessibility-based direct selected-text detection as a future goal, not a current documented feature.", + "Stabilize Accessibility selected-text detection across app-specific failure cases before documenting it as current behavior." ] }, "tasks": [ @@ -122,6 +125,10 @@ { "title": "Run xcodebuild test", "status": "done" + }, + { + "title": "Run build and test after clipboard-first runtime cleanup", + "status": "done" } ] }, @@ -163,6 +170,33 @@ } ] }, + { + "id": "direct-selected-text-detection", + "title": "Future goal: direct selected-text detection", + "status": "todo", + "items": [ + { + "title": "Remove Accessibility selected-text detection and permission CTA from the current MVP runtime", + "status": "done" + }, + { + "title": "Stabilize Accessibility-based selected-text detection before documenting it as current behavior", + "status": "todo" + }, + { + "title": "Record app-specific selected-text exposure differences and failure cases", + "status": "todo" + }, + { + "title": "Separate permission request, permission denial, and clipboard replacement flows in the product UI", + "status": "todo" + }, + { + "title": "Update README, PRD, and ADR only after drag-to-translate behavior is verified in the running app", + "status": "todo" + } + ] + }, { "id": "app-localization", "title": "App localization for non-Korean users", @@ -177,7 +211,7 @@ "status": "todo" }, { - "title": "Add Korean and English UI resources for overlay, settings, menu bar, permissions, shortcuts, and errors", + "title": "Add Korean and English UI resources for overlay, settings, menu bar, shortcuts, and errors", "status": "todo" }, { @@ -209,6 +243,10 @@ }, { "title": "Connect packaging to Developer ID export, notarization, and stapler verification", + "status": "done" + }, + { + "title": "Run actual Developer ID signed DMG through notarytool, stapler, and spctl verification", "status": "todo" }, { diff --git a/scripts/create_dmg.sh b/scripts/create_dmg.sh index f148570..ec98991 100755 --- a/scripts/create_dmg.sh +++ b/scripts/create_dmg.sh @@ -6,19 +6,41 @@ APP_NAME="transScan" SCHEME="${SCHEME:-transScan}" CONFIGURATION="${CONFIGURATION:-Release}" BUILD_DIR="${BUILD_DIR:-$ROOT_DIR/build/dmg}" -DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-$ROOT_DIR/build/DerivedData}" +DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-$ROOT_DIR/build/DerivedData-distribution}" +USER_APP_PATH="${APP_PATH:-}" APP_PATH="${APP_PATH:-$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION/$APP_NAME.app}" LOGO_PATH="${LOGO_PATH:-$ROOT_DIR/transScan/logo_.icon/Assets/transcan_logo.png}" +DEVELOPMENT_TEAM="${DEVELOPMENT_TEAM:-9A96KXU385}" +SIGNING_IDENTITY="${SIGNING_IDENTITY:-}" +DEFAULT_DEVELOPER_ID="Developer ID Application: Jongmun Jeong (9A96KXU385)" + +if [[ -z "$SIGNING_IDENTITY" ]] && security find-identity -v -p codesigning | grep -q "\"$DEFAULT_DEVELOPER_ID\""; then + SIGNING_IDENTITY="$DEFAULT_DEVELOPER_ID" +fi + +XCODEBUILD_SIGNING_ARGS=() +if [[ -n "$SIGNING_IDENTITY" ]]; then + XCODEBUILD_SIGNING_ARGS=( + CODE_SIGN_STYLE=Manual + DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" + CODE_SIGN_IDENTITY="$SIGNING_IDENTITY" + CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO + ) +fi mkdir -p "$BUILD_DIR" -if [[ ! -d "$APP_PATH" ]]; then +if [[ -z "$USER_APP_PATH" ]]; then xcodebuild \ -project "$ROOT_DIR/transScan.xcodeproj" \ -scheme "$SCHEME" \ -configuration "$CONFIGURATION" \ -derivedDataPath "$DERIVED_DATA_PATH" \ - build + SWIFT_OPTIMIZATION_LEVEL="${SWIFT_OPTIMIZATION_LEVEL:--O}" \ + ENABLE_DEBUG_DYLIB=NO \ + ENABLE_PREVIEWS=NO \ + "${XCODEBUILD_SIGNING_ARGS[@]}" \ + clean build fi if [[ ! -d "$APP_PATH" ]]; then @@ -26,6 +48,11 @@ if [[ ! -d "$APP_PATH" ]]; then exit 1 fi +if [[ -e "$APP_PATH/Contents/MacOS/$APP_NAME.debug.dylib" || -e "$APP_PATH/Contents/MacOS/__preview.dylib" ]]; then + echo "Refusing to package debug/previews dylibs in $APP_PATH" >&2 + exit 1 +fi + VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_PATH/Contents/Info.plist" 2>/dev/null || echo 1.0)" BACKGROUND_PATH="$BUILD_DIR/background.png" STAGING_DIR="$(mktemp -d "$BUILD_DIR/staging.XXXXXX")" @@ -103,5 +130,9 @@ hdiutil convert "$RW_DMG" \ -o "$FINAL_DMG" >/dev/null hdiutil verify "$FINAL_DMG" +if [[ -n "$SIGNING_IDENTITY" ]]; then + codesign --force --sign "$SIGNING_IDENTITY" --timestamp "$FINAL_DMG" + codesign --verify --verbose "$FINAL_DMG" +fi shasum -a 256 "$FINAL_DMG" echo "Created $FINAL_DMG" diff --git a/scripts/notarize_dmg.sh b/scripts/notarize_dmg.sh new file mode 100755 index 0000000..d1302b6 --- /dev/null +++ b/scripts/notarize_dmg.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROFILE="${NOTARY_PROFILE:-transscan-notary}" + +if [[ $# -gt 1 ]]; then + echo "Usage: $0 [path/to/transScan.dmg]" >&2 + exit 64 +fi + +if [[ $# -eq 1 ]]; then + DMG_PATH="$1" +else + DMG_PATH="$(find "$ROOT_DIR/build" -maxdepth 1 -type f -name 'transScan-*.dmg' -print 2>/dev/null | sort | tail -n 1 || true)" +fi + +if [[ ! -f "$DMG_PATH" ]]; then + echo "DMG not found: $DMG_PATH" >&2 + echo "Run ./scripts/create_dmg.sh first." >&2 + exit 1 +fi + +if ! codesign --verify --verbose "$DMG_PATH"; then + echo "DMG is not signed or its signature is invalid: $DMG_PATH" >&2 + echo "Run SIGNING_IDENTITY='Developer ID Application: ...' ./scripts/create_dmg.sh first." >&2 + exit 1 +fi + +if ! xcrun notarytool history --keychain-profile "$PROFILE" >/dev/null 2>&1; then + cat >&2 < Void - - var body: some View { - VStack(spacing: 10) { - Image(systemName: "lock.shield") - .font(.system(size: 24)) - .foregroundColor(.secondary) - Text(AccessibilityPermissionCopy.requiredTitle) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - Text("드래그 선택 텍스트 감지를 위해 권한이 필요합니다.\n\(AccessibilityPermissionCopy.clipboardFallback)") - .font(.system(size: 12)) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - Button(AccessibilityPermissionCopy.openSettingsAction) { - onOpenSettings() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - .padding(16) - .frame(width: 360) - } -} diff --git a/transScan/PermissionManager.swift b/transScan/PermissionManager.swift deleted file mode 100644 index 18d28a2..0000000 --- a/transScan/PermissionManager.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// PermissionManager.swift -// transScan -// - -import AppKit -import ApplicationServices -import Combine - -enum AccessibilityPermissionCopy { - static let name = "손쉬운 사용 권한" - static let openSettingsAction = "손쉬운 사용 설정 열기" - static let requiredTitle = "\(name) 필요" - static let settingsPath = "시스템 설정 > 개인정보 보호 및 보안 > 손쉬운 사용" - static let settingsGuidance = "\(settingsPath)에서 transScan을 허용하세요." - static let clipboardFallback = "권한 없이도 클립보드로 복사한 텍스트는 번역할 수 있습니다." -} - -class PermissionManager: ObservableObject { - static let shared = PermissionManager() - - @Published var isAccessibilityGranted: Bool - - private var pollingTimer: Timer? - - private init() { - let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: false] as CFDictionary - isAccessibilityGranted = AXIsProcessTrustedWithOptions(options) - } - - func checkPermission() { - let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: false] as CFDictionary - isAccessibilityGranted = AXIsProcessTrustedWithOptions(options) - } - - func requestPermission() { - NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!) - startObserving() - } - - func startObserving() { - guard pollingTimer == nil else { return } - pollingTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - self?.checkPermission() - if self?.isAccessibilityGranted == true { - self?.stopObserving() - } - } - } - - func stopObserving() { - pollingTimer?.invalidate() - pollingTimer = nil - } -} diff --git a/transScan/SettingsView.swift b/transScan/SettingsView.swift index dac7143..fa8adc3 100644 --- a/transScan/SettingsView.swift +++ b/transScan/SettingsView.swift @@ -2,7 +2,6 @@ import SwiftUI struct SettingsView: View { @AppStorage(HotkeyConfiguration.userDefaultsKey) private var hotkeyStorage = HotkeyConfiguration.defaultValue.encoded - @ObservedObject private var permissionManager = PermissionManager.shared var body: some View { VStack(alignment: .leading, spacing: 18) { @@ -18,54 +17,21 @@ struct SettingsView: View { Divider() - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Text(AccessibilityPermissionCopy.name) - .font(.system(size: 13, weight: .medium)) - - Spacer() - - Label(permissionStatusText, systemImage: permissionStatusIcon) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(permissionManager.isAccessibilityGranted ? Color.green : Color.secondary) - } + VStack(alignment: .leading, spacing: 10) { + Text("오버레이") + .font(.system(size: 13, weight: .medium)) - Text(permissionDescription) + Text("텍스트를 복사한 뒤 오버레이를 열면 최근 클립보드 내용을 번역합니다.") .font(.system(size: 11)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - HStack(spacing: 8) { - Button("오버레이 열기") { - OverlayWindowController.shared.show() - } - - Button(AccessibilityPermissionCopy.openSettingsAction) { - permissionManager.requestPermission() - } + Button("오버레이 열기") { + OverlayWindowController.shared.show() } } } .padding(22) .frame(width: 380, alignment: .leading) - .onAppear { - permissionManager.checkPermission() - } - } - - private var permissionStatusText: String { - permissionManager.isAccessibilityGranted ? "허용됨" : "필요함" - } - - private var permissionStatusIcon: String { - permissionManager.isAccessibilityGranted ? "checkmark.circle.fill" : "exclamationmark.circle" - } - - private var permissionDescription: String { - if permissionManager.isAccessibilityGranted { - return "최근 복사한 텍스트를 바로 번역할 수 있습니다." - } - - return "\(AccessibilityPermissionCopy.settingsGuidance) \(AccessibilityPermissionCopy.clipboardFallback)" } } diff --git a/transScan/TextDetector.swift b/transScan/TextDetector.swift index ea99782..57f9df5 100644 --- a/transScan/TextDetector.swift +++ b/transScan/TextDetector.swift @@ -3,7 +3,6 @@ // transScan import AppKit -import ApplicationServices import Combine protocol TextDetectorProtocol { @@ -13,14 +12,8 @@ protocol TextDetectorProtocol { func latestClipboardText() -> String? } -enum TextDetectionSource { - case accessibility - case clipboard -} - struct TextDetectedEvent { let text: String - let source: TextDetectionSource } class TextDetector: TextDetectorProtocol { @@ -32,37 +25,20 @@ class TextDetector: TextDetectorProtocol { } private let textSubject = PassthroughSubject() - private var axObserver: AXObserver? - private var observedPID: pid_t = 0 private var clipboardTimer: Timer? private var lastClipboardChangeCount: Int = 0 private var lastEmittedText: String = "" - private var workspaceObserver: NSObjectProtocol? init() {} func startMonitoring() { lastClipboardChangeCount = NSPasteboard.general.changeCount - setupAXObserver() startClipboardPolling() - - workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( - forName: NSWorkspace.didActivateApplicationNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.setupAXObserver() - } } func stopMonitoring() { - teardownAXObserver() clipboardTimer?.invalidate() clipboardTimer = nil - if let observer = workspaceObserver { - NSWorkspace.shared.notificationCenter.removeObserver(observer) - workspaceObserver = nil - } } func latestClipboardText() -> String? { @@ -78,77 +54,15 @@ class TextDetector: TextDetectorProtocol { // Testable entry point: trim → validate → truncate → dedup → emit @discardableResult - func processRawInput(_ text: String, source: TextDetectionSource) -> Bool { + func processRawInput(_ text: String) -> Bool { guard let clipped = normalizedText(from: text) else { return false } guard clipped != lastEmittedText else { return false } - emit(text: clipped, source: source) + emit(text: clipped) return true } - private func setupAXObserver() { - teardownAXObserver() - - guard let frontApp = NSWorkspace.shared.frontmostApplication else { return } - let pid = frontApp.processIdentifier - - guard pid != ProcessInfo.processInfo.processIdentifier else { return } - - var observer: AXObserver? - let result = AXObserverCreate(pid, { _, element, _, refcon in - guard let refcon = refcon else { return } - let detector = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - detector.handleAXNotification(element: element) - }, &observer) - - guard result == .success, let observer = observer else { return } - - let appElement = AXUIElementCreateApplication(pid) - let notifResult = AXObserverAddNotification( - observer, - appElement, - kAXSelectedTextChangedNotification as CFString, - Unmanaged.passUnretained(self).toOpaque() - ) - - guard notifResult == .success else { return } - - CFRunLoopAddSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), .defaultMode) - axObserver = observer - observedPID = pid - } - - private func teardownAXObserver() { - guard let observer = axObserver else { return } - CFRunLoopRemoveSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), .defaultMode) - axObserver = nil - observedPID = 0 - } - - private func handleAXNotification(element: AXUIElement) { - guard let text = readSelectedText() else { return } - processRawInput(text, source: .accessibility) - } - - private func readSelectedText() -> String? { - guard let frontApp = NSWorkspace.shared.frontmostApplication else { return nil } - let pid = frontApp.processIdentifier - guard pid != ProcessInfo.processInfo.processIdentifier else { return nil } - - let appElement = AXUIElementCreateApplication(pid) - var focusedElement: CFTypeRef? - guard AXUIElementCopyAttributeValue(appElement, kAXFocusedUIElementAttribute as CFString, &focusedElement) == .success, - let focused = focusedElement - else { return nil } - - var selectedText: CFTypeRef? - guard AXUIElementCopyAttributeValue(focused as! AXUIElement, kAXSelectedTextAttribute as CFString, &selectedText) == .success, - let text = selectedText as? String - else { return nil } - - return text - } - private func startClipboardPolling() { + clipboardTimer?.invalidate() clipboardTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in self?.checkClipboard() } @@ -160,12 +74,12 @@ class TextDetector: TextDetectorProtocol { lastClipboardChangeCount = currentCount guard let text = NSPasteboard.general.string(forType: .string) else { return } - processRawInput(text, source: .clipboard) + processRawInput(text) } - private func emit(text: String, source: TextDetectionSource) { + private func emit(text: String) { lastEmittedText = text - let event = TextDetectedEvent(text: text, source: source) + let event = TextDetectedEvent(text: text) DispatchQueue.main.async { [weak self] in self?.textSubject.send(event) } diff --git a/transScan/TranslationResultView.swift b/transScan/TranslationResultView.swift index 936a7af..590ccdf 100644 --- a/transScan/TranslationResultView.swift +++ b/transScan/TranslationResultView.swift @@ -3,11 +3,9 @@ import AppKit struct TranslationResultView: View { let state: TranslationState - let detectionSource: TextDetectionSource? let sourceText: String? var onCopy: () -> Void var onRetranslate: () -> Void - var onOpenSettings: () -> Void @State private var copied = false @@ -254,7 +252,6 @@ struct TranslationResultView: View { private func errorTitle(for error: TranslationAppError) -> String { switch error { - case .permissionDenied: return AccessibilityPermissionCopy.requiredTitle case .unsupportedOS: return "지원되지 않는 OS" case .translationFailed: return "번역 실패" case .languagePackUnavailable: return "언어팩 없음" @@ -263,8 +260,6 @@ struct TranslationResultView: View { private func errorDescription(for error: TranslationAppError) -> String { switch error { - case .permissionDenied: - return AccessibilityPermissionCopy.settingsGuidance case .unsupportedOS: return "macOS 14 이상에서 사용 가능합니다." case .translationFailed: @@ -281,8 +276,6 @@ struct TranslationResultView: View { private func errorAction(for error: TranslationAppError) -> ErrorAction? { switch error { - case .permissionDenied: - return ErrorAction(label: AccessibilityPermissionCopy.openSettingsAction, handler: onOpenSettings) case .unsupportedOS: return nil case .translationFailed: diff --git a/transScan/TranslationViewModel.swift b/transScan/TranslationViewModel.swift index 3c113c5..d30616d 100644 --- a/transScan/TranslationViewModel.swift +++ b/transScan/TranslationViewModel.swift @@ -4,7 +4,6 @@ import Combine // MARK: - App Error enum TranslationAppError: Error, Equatable { - case permissionDenied case unsupportedOS case translationFailed case languagePackUnavailable @@ -37,7 +36,6 @@ class TranslationViewModel: ObservableObject { @Published var selectedSourceLanguage: SupportedLanguage? = nil // nil = auto @Published var detectedSourceLanguage: SupportedLanguage? = nil // from last translation @Published var targetLanguage: SupportedLanguage - @Published var detectionSource: TextDetectionSource? = nil @Published var lastInputText: String? = nil var displaySourceLanguage: SupportedLanguage? { @@ -50,29 +48,24 @@ class TranslationViewModel: ObservableObject { private let textDetector: TextDetectorProtocol private var translationEngine: TranslationEngineProtocol - private let checkPermission: () -> Bool private let debounceInterval: RunLoop.SchedulerTimeType.Stride init( - textDetector: TextDetectorProtocol = TextDetector.shared, - translationEngine: TranslationEngineProtocol = TranslationEngine.shared, - checkPermission: @escaping () -> Bool = { PermissionManager.shared.isAccessibilityGranted }, + textDetector: TextDetectorProtocol? = nil, + translationEngine: TranslationEngineProtocol? = nil, debounceInterval: RunLoop.SchedulerTimeType.Stride = .milliseconds(300) ) { - self.textDetector = textDetector - self.translationEngine = translationEngine - self.checkPermission = checkPermission + let resolvedTextDetector = textDetector ?? TextDetector.shared + let resolvedTranslationEngine = translationEngine ?? TranslationEngine.shared + + self.textDetector = resolvedTextDetector + self.translationEngine = resolvedTranslationEngine self.debounceInterval = debounceInterval - self.targetLanguage = translationEngine.targetLanguage - self.selectedSourceLanguage = translationEngine.sourceLanguage + self.targetLanguage = resolvedTranslationEngine.targetLanguage + self.selectedSourceLanguage = resolvedTranslationEngine.sourceLanguage } func onAppear() { - guard checkPermission() else { - state = .error(.permissionDenied) - return - } - textDetector.startMonitoring() textDetector.textPublisher @@ -164,7 +157,6 @@ class TranslationViewModel: ObservableObject { private func handleTextDetected(_ event: TextDetectedEvent) { lastInputText = event.text - detectionSource = event.source triggerTranslation(text: event.text) } diff --git a/transScanTests/TextDetectorTests.swift b/transScanTests/TextDetectorTests.swift index 8297399..5223131 100644 --- a/transScanTests/TextDetectorTests.swift +++ b/transScanTests/TextDetectorTests.swift @@ -7,13 +7,6 @@ import Combine import AppKit @testable import transScan -// MockAXReader demonstrates how AX reading would be injected if TextDetector -// were refactored to accept a reader dependency (future extension point). -class MockAXReader { - var stubbedText: String? = nil - func readSelectedText() -> String? { stubbedText } -} - final class TextDetectorTests: XCTestCase { var detector: TextDetector! var cancellables: Set! @@ -38,7 +31,7 @@ final class TextDetectorTests: XCTestCase { exp.fulfill() }.store(in: &cancellables) - detector.processRawInput("", source: .accessibility) + detector.processRawInput("") waitForExpectations(timeout: 0.2) } @@ -51,7 +44,7 @@ final class TextDetectorTests: XCTestCase { exp.fulfill() }.store(in: &cancellables) - detector.processRawInput(" \n\t ", source: .clipboard) + detector.processRawInput(" \n\t ") waitForExpectations(timeout: 0.2) } @@ -98,7 +91,7 @@ final class TextDetectorTests: XCTestCase { exp.fulfill() }.store(in: &cancellables) - detector.processRawInput(longText, source: .accessibility) + detector.processRawInput(longText) waitForExpectations(timeout: 0.5) } @@ -112,33 +105,7 @@ final class TextDetectorTests: XCTestCase { exp.fulfill() }.store(in: &cancellables) - detector.processRawInput(text, source: .accessibility) - - waitForExpectations(timeout: 0.5) - } - - func testAccessibilitySourceTaggedCorrectly() { - let exp = expectation(description: "Accessibility source") - - detector.textPublisher.sink { event in - XCTAssertEqual(event.source, .accessibility) - exp.fulfill() - }.store(in: &cancellables) - - detector.processRawInput("some text", source: .accessibility) - - waitForExpectations(timeout: 0.5) - } - - func testClipboardSourceTaggedCorrectly() { - let exp = expectation(description: "Clipboard source") - - detector.textPublisher.sink { event in - XCTAssertEqual(event.source, .clipboard) - exp.fulfill() - }.store(in: &cancellables) - - detector.processRawInput("some text", source: .clipboard) + detector.processRawInput(text) waitForExpectations(timeout: 0.5) } @@ -153,9 +120,9 @@ final class TextDetectorTests: XCTestCase { }.store(in: &cancellables) // First call emits and sets lastEmittedText synchronously - detector.processRawInput(text, source: .accessibility) + detector.processRawInput(text) // Second call with same text is blocked by dedup guard - detector.processRawInput(text, source: .clipboard) + detector.processRawInput(text) DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { XCTAssertEqual(emitCount, 1, "Duplicate text must not emit again") diff --git a/transScanTests/TranslationViewModelTests.swift b/transScanTests/TranslationViewModelTests.swift index 9da0f76..61ad462 100644 --- a/transScanTests/TranslationViewModelTests.swift +++ b/transScanTests/TranslationViewModelTests.swift @@ -7,13 +7,21 @@ import Combine class MockTextDetector: TextDetectorProtocol { let subject = PassthroughSubject() var latestClipboardTextValue: String? + private(set) var startMonitoringCallCount = 0 + private(set) var stopMonitoringCallCount = 0 var textPublisher: AnyPublisher { subject.eraseToAnyPublisher() } - func startMonitoring() {} - func stopMonitoring() {} + func startMonitoring() { + startMonitoringCallCount += 1 + } + + func stopMonitoring() { + stopMonitoringCallCount += 1 + } + func latestClipboardText() -> String? { latestClipboardTextValue } } @@ -110,11 +118,10 @@ final class TranslationViewModelTests: XCTestCase { super.tearDown() } - private func makeVM(permissionGranted: Bool = true) -> TranslationViewModel { + private func makeVM() -> TranslationViewModel { TranslationViewModel( textDetector: mockDetector, translationEngine: mockEngine, - checkPermission: { permissionGranted }, debounceInterval: .milliseconds(0) ) } @@ -131,7 +138,7 @@ final class TranslationViewModelTests: XCTestCase { // Controllable engine suspends in translate(), so state stays .translating mockEngine.onTranslateStarted = { _ in exp.fulfill() } - mockDetector.subject.send(TextDetectedEvent(text: "Hello", source: .accessibility)) + mockDetector.subject.send(TextDetectedEvent(text: "Hello")) wait(for: [exp], timeout: 0.5) XCTAssertEqual(vm.state, .translating) @@ -158,7 +165,7 @@ final class TranslationViewModelTests: XCTestCase { } } - mockDetector.subject.send(TextDetectedEvent(text: "Hello", source: .accessibility)) + mockDetector.subject.send(TextDetectedEvent(text: "Hello")) wait(for: [exp], timeout: 1.0) XCTAssertEqual(vm.detectedSourceLanguage, .english) @@ -184,7 +191,7 @@ final class TranslationViewModelTests: XCTestCase { } // Send first text — triggers translate("First"), suspends in continuation - mockDetector.subject.send(TextDetectedEvent(text: "First", source: .accessibility)) + mockDetector.subject.send(TextDetectedEvent(text: "First")) wait(for: [firstStarted], timeout: 0.5) // Send second text — should cancel first task, start translate("Second") @@ -199,7 +206,7 @@ final class TranslationViewModelTests: XCTestCase { } } - mockDetector.subject.send(TextDetectedEvent(text: "Second", source: .accessibility)) + mockDetector.subject.send(TextDetectedEvent(text: "Second")) wait(for: [secondStarted], timeout: 0.5) // Now: cont[0] = First (task cancelled), cont[1] = Second (active) @@ -228,7 +235,7 @@ final class TranslationViewModelTests: XCTestCase { } } - mockDetector.subject.send(TextDetectedEvent(text: "any", source: .accessibility)) + mockDetector.subject.send(TextDetectedEvent(text: "any")) wait(for: [exp], timeout: 1.0) } @@ -249,7 +256,7 @@ final class TranslationViewModelTests: XCTestCase { } } - mockDetector.subject.send(TextDetectedEvent(text: "Hello", source: .accessibility)) + mockDetector.subject.send(TextDetectedEvent(text: "Hello")) wait(for: [exp], timeout: 1.0) } @@ -270,7 +277,7 @@ final class TranslationViewModelTests: XCTestCase { } } - mockDetector.subject.send(TextDetectedEvent(text: "Hello", source: .accessibility)) + mockDetector.subject.send(TextDetectedEvent(text: "Hello")) wait(for: [exp], timeout: 1.0) } @@ -293,7 +300,7 @@ final class TranslationViewModelTests: XCTestCase { resultSub?.cancel() } } - mockDetector.subject.send(TextDetectedEvent(text: "Hello", source: .accessibility)) + mockDetector.subject.send(TextDetectedEvent(text: "Hello")) wait(for: [resultExp], timeout: 1.0) XCTAssertEqual(vm.detectedSourceLanguage, .english) @@ -350,7 +357,7 @@ final class TranslationViewModelTests: XCTestCase { } } - mockDetector.subject.send(TextDetectedEvent(text: "Hello", source: .accessibility)) + mockDetector.subject.send(TextDetectedEvent(text: "Hello")) wait(for: [initialStarted], timeout: 0.5) mockEngine.resolveNext(with: TranslationResult(translatedText: "안녕하세요", detectedSourceLanguage: .english)) @@ -391,7 +398,7 @@ final class TranslationViewModelTests: XCTestCase { } } - mockDetector.subject.send(TextDetectedEvent(text: "Hello", source: .accessibility)) + mockDetector.subject.send(TextDetectedEvent(text: "Hello")) wait(for: [initialStarted], timeout: 0.5) mockEngine.resolveNext(with: TranslationResult(translatedText: "안녕하세요", detectedSourceLanguage: .english)) @@ -430,11 +437,14 @@ final class TranslationViewModelTests: XCTestCase { mockEngine.resolveNext(with: TranslationResult(translatedText: "クリップボード", detectedSourceLanguage: .english)) } - // MARK: - Permission denied → .error(.permissionDenied) + // MARK: - Clipboard monitoring + + func testStartsClipboardMonitoringOnAppear() { + let vm = makeVM() - func testPermissionDeniedSetsErrorState() { - let vm = makeVM(permissionGranted: false) vm.onAppear() - XCTAssertEqual(vm.state, .error(.permissionDenied)) + + XCTAssertEqual(vm.state, .idle) + XCTAssertEqual(mockDetector.startMonitoringCallCount, 1) } }