Contributing to liltype
Welcome! This guide covers everything you need to contribute to liltype, an offline-first macOS menubar dictation app built with Swift 6.2.
Development Setup
Prerequisites
- macOS 14+ (Sonoma or later)
- Xcode with Swift 6.2
- Git
Getting Started
git clone https://github.com/jaeyunha/liltype.git
cd liltype
swift build # Compile the app
swift test # Run all tests
swift run liltype # Launch the app
Before opening a PR, ensure everything builds and tests pass:
swift build && swift test
Project Structure
liltype/
├── Sources/liltype/ # Main app source code
│ ├── *Configuration.swift # Settings bundles (protocol + value type + store)
│ ├── *Coordinator.swift # Stateful session orchestrators
│ ├── *Client.swift # External service clients
│ ├── *Permission.swift # System permission checks + UI
│ ├── *View.swift # SwiftUI components
│ └── DictationMenuController.swift # Central orchestrator
├── Tests/liltypeTests/ # Unit & integration tests (mirrors Sources/)
├── web/ # Next.js landing page (uses bun, not npm)
├── infra/ # Backend: TypeScript/Hono.js, AWS ECS Fargate
│ ├── src/handlers/ # API handlers (Stripe checkout, webhooks)
│ ├── ecs/ # ECS task definition
│ └── scripts/deploy.sh # Backend deployment
├── scripts/ # Build & deployment scripts
├── docs/ # Developer & user documentation
├── agent_docs/ # Architecture & testing patterns
└── CLAUDE.md # Project instructions & conventions
Architecture Overview
Protocol-Driven Dependency Injection
Every major concern is a protocol. The production implementation is injected at app startup. This design enables easy testing with mock implementations.
Key protocol families:
| Concern | Example Protocols | Production Implementation |
|---|---|---|
| Permissions | MicrophonePermissionControlling, AccessibilityPermissionControlling | AVFoundation, AX API, ScreenCaptureKit |
| Hotkeys | GlobalHotkeyRegistering, HotkeyConflictChecking | Carbon Events + NSEvent |
| Transcription | TranscriptionJobScheduling, WhisperModelTranscribing, CloudSpeechToTextTranscribing | WhisperKit, OpenAI/Deepgram/Groq/AssemblyAI |
| Settings | *SettingsStoring (13 protocols) | UserDefaults-backed stores |
| Output | TranscriptionOutputHandling | Clipboard + CGEvent paste |
| Models | DictationModelCatalogProviding, ModelDownloading | WhisperKit catalog, URLSession |
| Storage | LocalStorageWriting, ClipboardHistoryStoring | SQLite |
| Cloud | ProviderAPIKeyStoring, CloudCostEstimating | Keychain, provider APIs |
| Auth | AuthSessionManaging, SubscriptionStatusProviding | Supabase OAuth, UserDefaults cache |
| Meeting/Session | SessionRecordingManaging, SessionAudioCapturing | Live mic+system audio, chunk transcription |
DictationMenuController: The Central Orchestrator
Located at Sources/liltype/DictationMenuController.swift, this @MainActor ObservableObject is the heart of the app:
- ~79 protocol-typed dependencies injected via constructor
- ~40+ @Published properties for UI binding
- Responsibilities: Dictation lifecycle, meeting detection, model management, auth, settings, error handling
Production wiring is in LiltypeApp.swift:66; tests inject stubs.
Core Flows
Dictation:
Hotkey → Permission gate → Recording (live waveform) → [Release/Escape]
→ Transcribing (offline or cloud) → Output (clipboard + paste) → Idle
Meeting Capture:
App detection → Permission gate → Simultaneous mic + system audio capture
→ Chunked transcription (8s chunks, 2s overlap) → Session notes + workspace sync
Agent Sessions:
Session create → ACP/CLI transport → Transcript syncing → Workspace management
File Naming Conventions
Follow these patterns consistently:
-
*Configuration.swift— Settings bundle containing:- Protocol defining the interface
- Value types (request/response objects)
- UserDefaults-backed store implementation
- All in one file for one domain concern
-
*Coordinator.swift— Stateful session orchestrators (e.g.,MeetingSessionCoordinator) -
*Client.swift— External service clients (e.g.,ACPClientfor Agent Communication Protocol) -
*Permission.swift— System permission checks + alert UI -
*View.swift— SwiftUI UI components -
Tests mirror sources:
FileName.swift→FileNameTests.swift
The Configuration Pattern
The Configuration pattern is how liltype manages settings. Here's how to add a new configuration concern:
Step-by-Step Example
Let's say you're adding "Focus Mode Behavior" settings:
// FocusModeConfiguration.swift
import Foundation
// 1. Define the protocol interface
protocol FocusModeSettingsStoring: Sendable {
func loadFocusModeBehavior() -> FocusModeBehavior
func saveFocusModeBehavior(_ behavior: FocusModeBehavior) async
}
// 2. Define value types (Codable, Equatable, Sendable)
struct FocusModeBehavior: Codable, Equatable, Sendable {
let pauseDictationDuringFocus: Bool
let muteNotifications: Bool
let resumeOnExitFocus: Bool
static let `default` = FocusModeBehavior(
pauseDictationDuringFocus: true,
muteNotifications: true,
resumeOnExitFocus: true
)
}
// 3. Create UserDefaults-backed store
final class UserDefaultsFocusModeStore: FocusModeSettingsStoring {
private let userDefaults: UserDefaults
private let key = "com.liltype.focusMode"
init(userDefaults: UserDefaults = .standard) {
self.userDefaults = userDefaults
}
func loadFocusModeBehavior() -> FocusModeBehavior {
guard let data = userDefaults.data(forKey: key),
let decoded = try? JSONDecoder().decode(FocusModeBehavior.self, from: data) else {
return .default
}
return decoded
}
func saveFocusModeBehavior(_ behavior: FocusModeBehavior) async {
if let encoded = try? JSONEncoder().encode(behavior) {
userDefaults.set(encoded, forKey: key)
}
}
}
Integration Checklist
- Add the protocol to
DictationMenuControllerdependencies - Provide a default implementation in
DictationMenuController.init - Create a test file (
FocusModeConfigurationTests.swift) with UUID-isolated UserDefaults - Add a settings view if user-facing (
FocusModeSettingsView.swift)
Testing Conventions
UserDefaults Isolation
Every settings store test creates an isolated suite with a UUID. See HotkeyConfigurationTests.swift:6 for the canonical pattern:
import XCTest
final class FocusModeConfigurationTests: XCTestCase {
let suiteName = UUID().uuidString
var userDefaults: UserDefaults!
var store: UserDefaultsFocusModeStore!
override func setUp() {
super.setUp()
userDefaults = UserDefaults(suiteName: suiteName)
store = UserDefaultsFocusModeStore(userDefaults: userDefaults)
}
override func tearDown() {
userDefaults.removePersistentDomain(forName: suiteName)
super.tearDown()
}
func testDefaultBehavior() {
let behavior = store.loadFocusModeBehavior()
XCTAssertEqual(behavior, .default)
}
}
Controller Testing
DictationMenuController takes ~79 dependencies. Use NoOp* stubs for irrelevant deps; create targeted mocks only for behavior under test:
let mockFocusMonitor = MockFocusMonitor()
let controller = DictationMenuController(
focusMonitor: mockFocusMonitor,
hotkeyRegistering: NoOpGlobalHotkeyRegistering(),
transcriptionScheduling: NoOpTranscriptionJobScheduling(),
// ... other deps with NoOp stubs
)
Test Coverage Expectations
- Configuration stores: Comprehensive — load/save/defaults/edge cases
- Controller: Permission gates, state transitions, success & failure paths
- Session recording: Recording lifecycle, speaker diarization, chunk transcription
- Recording window: Anchor positioning, result layout, style settings
- Auth/Subscription: Session management, token lifecycle, subscription status caching
Code Style
Swift 6.2 Concurrency
- Use
@MainActorfor UI code - Mark async functions clearly
- Avoid
@unchecked Sendable— refactor instead - All value types must be
Sendable
Value Types
- Persistence: Use
Codablefor UserDefaults/Keychain serialization - Equality: Use
Equatablefor testing - Thread-safety: Mark as
Sendable
struct MySettings: Codable, Equatable, Sendable {
let value: String
static let `default` = MySettings(value: "default")
}
Enums for UserDefaults Serialization
Use String raw values for enum serialization:
enum ProcessingMode: String, Codable, Sendable {
case offline = "offline"
case cloudSTT = "cloudSTT"
case notes = "notes"
}
Secrets & Sensitive Data
- Keychain: Use for API keys, auth tokens, credentials
- Never: Store secrets in UserDefaults, plist, or source code
- Environment variables: For build-time secrets only (signing, notarization)
Web Development (Landing Page)
The landing page is a Next.js app deployed to Cloudflare Pages.
Setup
cd web
bun install # Use bun, not npm
bun run dev # Local development
bun run build # Production build
Package Manager
Use bun, not npm. The project uses bun.lock (not package-lock.json). bun is significantly faster and more reliable for this project.
Deployment
Deploy via the script:
scripts/deploy-web.sh # Builds + deploys to Cloudflare Pages
Live at: https://liltype.com
Pages
/— Home/pricing— Tier comparison & checkout links/privacy— Privacy policy/terms— Terms of service
Infrastructure
Backend (infra/)
- Language: TypeScript (Hono.js framework)
- Deployment: AWS ECS Fargate (256 CPU, 512 MB RAM)
- Database: Supabase PostgreSQL
- Port: 8080
API Handlers
create-checkout.ts— Stripe checkout session creationstripe-webhook.ts— Stripe webhook handler for subscription eventssubscription-status.ts— Fetch user subscription statuscreate-portal.ts— Stripe billing portal link
Deployment
infra/scripts/deploy.sh # Deploy backend to ECS
Environment Variables
Set in AWS SSM Parameter Store or ECS task definition:
SUPABASE_URL— Supabase API endpointSUPABASE_SECRET_KEY— Admin secretSTRIPE_SECRET_KEY— Stripe API keySTRIPE_WEBHOOK_SECRET— Webhook verificationSTRIPE_PLUS_PRICE_MONTHLY/YEARLY— Plus tier price IDsSTRIPE_PRO_PRICE_MONTHLY/YEARLY— Pro tier price IDsDEEPGRAM_API_KEY— Deepgram fallback API keyPORT— Listen port (8080)
Scripts Reference
| Script | Purpose |
|---|---|
scripts/build-dmg.sh | Build release/debug DMG with signing & notarization |
scripts/deploy-web.sh | Deploy landing page to Cloudflare Pages |
infra/scripts/deploy.sh | Deploy backend to AWS ECS |
infra/scripts/store-secrets.sh | Store secrets in AWS SSM Parameter Store |
build-dmg.sh Options
scripts/build-dmg.sh --version 1.0.0 --dev --skip-build
--version <value>— Set app version--bundle-id <value>— Override bundle ID--dist-dir <path>— Output directory (default: dist/)--dev— Separate dev build & DMG--skip-build— Skip swift build step
Git Workflow
Committing
Commit and push directly to the current branch — no need to create a feature branch first. The project uses a simple workflow:
- Make changes
- Verify with
swift build && swift test - Commit:
git commit -m "Clear, descriptive message" - Push:
git push origin main
Before Opening a PR
swift build && swift test # Must pass before PR
Ensure:
- All tests pass
- No new compiler warnings
- Code follows conventions in this guide
- Commit messages are clear
Additional Documentation
For deeper dives, see:
agent_docs/architecture.md— Protocol families, state machines, offline/cloud modes, DictationMenuController dependency mapagent_docs/testing.md— UserDefaults isolation patterns, controller testing, coverage expectations, fixture conventionsCLAUDE.md— Project instructions, build commands, file patterns, key conventions
Summary
- Build:
swift build && swift testbefore every PR - Pattern: Protocol-driven DI with UserDefaults for settings, SQLite for persistence, Keychain for secrets
- Testing: UUID-isolated UserDefaults, NoOp stubs, targeted mocks
- Code style: Swift 6.2 concurrency, @MainActor for UI, Sendable/Codable value types
- Deployment: DMG via
build-dmg.sh, web viadeploy-web.sh, backend viainfra/scripts/deploy.sh - Questions? Check
agent_docs/,CLAUDE.md, or ask in an issue
Happy contributing!