This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This project uses Tuist as its build system with SPM for dependency management.
# Initial setup (install deps + generate Xcode project)
tuist install && tuist generate
# Regenerate after changing Project.swift or Package.swift
tuist generate
# Build from command line
xcodebuild -workspace MoePeek.xcworkspace -scheme MoePeek -configuration Debug build
# Open in Xcode
open MoePeek.xcworkspaceNo tests, linting, or CI/CD are currently configured.
- Swift 6.0+ with
SWIFT_STRICT_CONCURRENCY: complete - macOS 14.0+ deployment target, LSUIElement (menu bar app, no Dock icon)
- SwiftUI + AppKit hybrid: SwiftUI for views, AppKit NSPanel for non-activating floating windows
- Dependencies: KeyboardShortcuts (sindresorhus), Defaults (sindresorhus)
- License: AGPL-3.0
User Action (shortcut / mouse selection / OCR)
→ TranslationCoordinator (state machine: idle → grabbing → translating → streaming → completed/error)
→ Text Grabbing: TextSelectionManager (3-tier fallback: AX API → AppleScript → Clipboard)
→ Language Detection: LanguageDetector (NLLanguageRecognizer, auto-flips target if same as detected)
→ Translation: TranslationService protocol (OpenAI streaming API or Apple Translation on macOS 15+)
→ PopupPanelController (floating result panel at cursor)
- @MainActor everywhere: All UI controllers and coordinators are
@MainActor-isolated.@Observablemacro for state observation. - Non-activating NSPanels:
PopupPanelandTriggerIconPanelare borderless floating panels that never steal focus from the user's active app. - Coordinator pattern:
TranslationCoordinatorowns all translation logic and exposes a singleStateenum consumed by views. - Callback wiring in AppDelegate:
AppDelegate.setupSelectionMonitor()wires together SelectionMonitor → TriggerIconController → TranslationCoordinator → PopupPanelController via closures. - 3-tier text grabbing:
AccessibilityGrabber(AX API) →AppleScriptGrabber(Safari-specific) →ClipboardGrabber(⌘+C simulation). Each tier tried in order. - NSViewRepresentable over TextEditor:
TextEditorhas implicit insets; use customNSTextViewwrapper withtextContainerInset = .zero/lineFragmentPadding = 0when precise spacing control is needed. SeeSourceTextEditor. - Settings window activation:
SettingsLinkis unreliable in LSUIElement / non-activating panel contexts. UseNSApp.activate(ignoringOtherApps:)→sendAction("showPreferencesWindow:")(private but stable selector) → fallbackopenSettings(). - UI spacing constants: PopupView uses
contentHorizontalPaddingfor all edge insets; new subviews should reference it instead of hardcoding. - @Observable and computed properties:
@Observableonly tracks stored properties; computed property setters generate no observation notifications.- ❌ Never wrap "write to external state" logic as a computed property on an
@Observableclass and expose it as a SwiftUI binding — the classic symptom is a Toggle/Picker that appears frozen (the value is written, but the view never re-renders).- Broken:
var foo: Bool { get { ext.foo } set { ext.foo = newValue } }
- Broken:
- ✅ Fix: use a stored property +
didSetto sync outward, initialized from the external source ininit().var foo: Bool = false { didSet { ext.foo = foo } }+init() { foo = ext.foo }
- ✅ Safe: read-only computed properties derived from stored properties — access tracking propagates through them correctly.
- ❌ Never wrap "write to external state" logic as a computed property on an
| Directory | Purpose |
|---|---|
Sources/App/ |
SwiftUI app entry + AppDelegate lifecycle & wiring |
Sources/Core/ |
Text grabbing, OCR, permissions, TranslationCoordinator |
Sources/Services/ |
TranslationService protocol + OpenAI/Apple implementations |
Sources/UI/ |
PopupPanel, TriggerIcon, MenuBar, Settings, Onboarding |
Sources/Utilities/ |
Constants (Defaults keys, keyboard shortcuts), KeychainHelper, positioning |
The app supports English (development language) and Simplified Chinese (zh-Hans) via Xcode String Catalogs.
Resources/Localizable.xcstrings— Single String Catalog containing all localized strings with en keys and zh-Hans translations.- SwiftUI views use string literals as
LocalizedStringKey(automatic lookup). - Non-UI code (errors, coordinators) uses
String(localized:)for runtime localization. SupportedLanguagesinConstants.swiftusesLocale.current.localizedString(forIdentifier:)for dynamic language names.- Strings that should NOT be localized: API technical labels (
"Base URL:","API Key:","Model:"), LLM system prompts, provider IDs, copyright/license text, brand name "MoePeek".
TranslationCoordinator.swift— Central orchestrator with state machine; all translation flows route through hereAppDelegate.swift— Wires all components together; global shortcut registration and selection monitor setupConstants.swift— AllDefaults.KeysandKeyboardShortcuts.Namedefinitions; single source of truth for user preferencesTextSelectionManager.swift— 3-tier fallback logic for grabbing selected textPopupPanelController.swift— Manages floating panel lifecycle, positioning, and dismiss monitoring
The app requires Accessibility (for AX text grabbing) and Screen Recording (for OCR). PermissionManager polls every 1.5s since macOS has no permission-change callback. Onboarding flow shown on first launch guides users through granting both.
- Implement
TranslationProviderprotocol (withtranslateStreamreturningAsyncThrowingStream) - Register in
TranslationProviderRegistry.builtIn()(for OpenAI-compatible, just add a newOpenAICompatibleProviderinstance) - Settings UI is auto-generated from
provider.makeSettingsView(); provider is self-contained
- Follow Semantic Versioning (SemVer), tag format
v<MAJOR>.<MINOR>.<PATCH> - MAJOR: incompatible breaking changes; MINOR: new features (backward-compatible); PATCH: bug fixes
- During the
0.x.xphase, bump MINOR for each new feature (v0.1.0→v0.2.0) - Pushing a
v*tag triggers CI auto-build and release; seedocs/RELEASING.md
Use the following specialized skills when reviewing code changes:
- swift-concurrency — async/await, Actor, Sendable, Swift 6 strict concurrency
- swiftui-expert-skill — SwiftUI state management, view composition, modern API usage
- swiftui-performance-audit — rendering performance, excessive view updates, memory/CPU issues
- swiftui-view-refactor — view structure, dependency injection, @Observable usage patterns
- swiftui-ui-patterns — UI pattern design, page structure, component composition
Note this project uses a SwiftUI + AppKit hybrid architecture: SwiftUI handles view content (Settings, Onboarding, PopupView), while AppKit NSPanel/NSWindow manages window lifecycle and non-activating floating panels. Reviews must consider the interaction boundary between both.
showPreferencesWindow:— PrivateNSApplicationselector used to bring existing Settings window to front; hasopenSettings()fallback. Monitor on major macOS updates.
This is a long-running menu bar app — memory leaks accumulate over time. All code changes must be checked against these patterns:
Closure Captures
- Stored closures (callback properties, completion handlers) must use
[weak self]withguard let selfunwrapping - SwiftUI
.onChange/Buttonaction closures are lifecycle-managed by the framework and do not need[weak self] - System API callbacks like
KeyboardShortcuts.onKeyUp,Timer.scheduledTimer,NSEvent.addGlobalMonitorForEventsmust use[weak self]
NSPanel / NSWindow Lifecycle
- Windows with
isReleasedWhenClosed = falsemust be manually cleaned up on dismiss:panel.contentView = nil→panel = nil - Prevent NSHostingView's SwiftUI views from holding strong references back to window controllers
Timer / Event Monitor Cleanup
- All
Timerinstances must beinvalidate()d and set tonilin the corresponding stop/dismiss method - All monitors from
NSEvent.addGlobalMonitorForEventsmust be removed in bothstop()anddeinit PermissionManager's polling timer must callstopPolling()once all permissions are granted
AsyncThrowingStream
continuation.onTerminationmust cancel the associatedTaskto prevent task leaks when the stream is discarded
Inter-Controller Callback Chains
- The closure chain in
AppDelegate.setupSelectionMonitor()(SelectionMonitor → TriggerIconController → TranslationCoordinator → PopupPanelController) uses[weak self]throughout; new callbacks must follow this pattern