Archived
chore: scaffold Voice iOS app (Phase 0)
XcodeGen project, SwiftUI iOS 17 target dev.shane.voice, SPM livekit-client-swift 2.16.0, mic usage + ATS LAN exception in Info.plist, placeholder conversation screen and Settings with server URL field.
This commit is contained in:
commit
297fbaa19c
17 files changed
+1056
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
.DS_Store
|
||||||
|
DerivedData/
|
||||||
|
xcuserdata/
|
||||||
|
*.xcresult
|
||||||
|
build/
|
||||||
|
.swiftpm/
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
name: xcodebuildmcp
|
||||||
|
description: Official skill for XcodeBuildMCP. Use when doing iOS/macOS/watchOS/tvOS/visionOS work (build, test, run, debug, log, UI automation).
|
||||||
|
---
|
||||||
|
|
||||||
|
# XcodeBuildMCP
|
||||||
|
|
||||||
|
Use XcodeBuildMCP tools instead of raw `xcodebuild`, `xcrun`, or `simctl`.
|
||||||
|
|
||||||
|
Capabilities:
|
||||||
|
- Session defaults: Configure project, scheme, simulator, and device defaults to avoid repetitive parameters
|
||||||
|
- Project discovery: Find Xcode projects/workspaces, list schemes, inspect build settings
|
||||||
|
- Simulator workflows: Build, run, test, install, and launch apps on iOS simulators; manage simulator state (boot, erase, location, appearance)
|
||||||
|
- Device workflows: Build, test, install, and launch apps on physical devices with code signing
|
||||||
|
- macOS workflows: Build, run, and test macOS applications
|
||||||
|
- Log capture: Stream and capture logs from simulators and devices
|
||||||
|
- LLDB debugging: Attach debugger, set breakpoints, inspect stack traces and variables, execute LLDB commands
|
||||||
|
- UI automation: Capture screenshots, inspect view hierarchy with coordinates, perform taps/swipes/gestures, type text, press hardware buttons
|
||||||
|
- SwiftPM: Build, run, test, and manage Swift Package Manager projects
|
||||||
|
- Project scaffolding: Generate new iOS/macOS project templates
|
||||||
|
|
||||||
|
Only simulator workflow tools are enabled by default. If capabilities like device, macOS, debugging, or UI automation are not available, the user must configure XcodeBuildMCP to enable them. See https://xcodebuildmcp.com/docs/configuration for workflow configuration.
|
||||||
|
|
||||||
|
## Step 1: Establish Session Context
|
||||||
|
|
||||||
|
- Call `session_show_defaults` before the first build/run/test action in a session.
|
||||||
|
- Use `discover_projs` only when defaults show missing or incorrect project/workspace context.
|
||||||
|
- Do not run discovery speculatively or in parallel with `session_show_defaults`.
|
||||||
|
- For simulator run intent, prefer the combined build-and-run tool instead of separate build then run calls.
|
||||||
|
- Do not chain build-only then build-and-run unless the user explicitly requests both.
|
||||||
|
|
||||||
|
## Step 2: Understand Workflow-Scoped Tool Availability
|
||||||
|
|
||||||
|
- Not all tools are enabled by default; tool availability depends on enabled workflows.
|
||||||
|
- If a tool is expected but missing, check enabled workflows first.
|
||||||
|
- Update enabled workflows in `.xcodebuildmcp/config.yaml`, then ask user to reload/restart the session to surface refreshes.
|
||||||
|
|
||||||
|
## Step 3: Report Context Clearly
|
||||||
|
|
||||||
|
- Return the active defaults context used for execution (project/workspace, scheme, simulator/device).
|
||||||
|
- For failures, include the exact failing step and the next actionable command/tool call.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
schemaVersion: 1
|
||||||
|
sessionDefaults:
|
||||||
|
projectPath: /Users/shane/dev/voice-ios/Voice.xcodeproj
|
||||||
|
scheme: Voice_iOS
|
||||||
|
simulatorName: iPhone 17 Pro
|
||||||
|
simulatorId: AFDA137E-1657-428E-BF01-E5CD7989772C
|
||||||
|
simulatorPlatform: iOS Simulator
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
- If using XcodeBuildMCP, use the installed XcodeBuildMCP skill before calling XcodeBuildMCP tools.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal-platform",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Hope Voice</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionDomains</key>
|
||||||
|
<dict>
|
||||||
|
<key>192.168.86.2</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSIncludesSubdomains</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Hope Voice uses the microphone for live voice conversation.</string>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
final class SettingsStore: ObservableObject {
|
||||||
|
@Published var serverURL: String {
|
||||||
|
didSet { UserDefaults.standard.set(serverURL, forKey: Keys.serverURL) }
|
||||||
|
}
|
||||||
|
@Published var voiceID: String {
|
||||||
|
didSet { UserDefaults.standard.set(voiceID, forKey: Keys.voiceID) }
|
||||||
|
}
|
||||||
|
@Published var modelID: String? {
|
||||||
|
didSet { UserDefaults.standard.set(modelID, forKey: Keys.modelID) }
|
||||||
|
}
|
||||||
|
@Published var textMode: Bool {
|
||||||
|
didSet { UserDefaults.standard.set(textMode, forKey: Keys.textMode) }
|
||||||
|
}
|
||||||
|
@Published var volume: Double {
|
||||||
|
didSet { UserDefaults.standard.set(volume, forKey: Keys.volume) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Keys {
|
||||||
|
static let serverURL = "serverURL"
|
||||||
|
static let voiceID = "voiceID"
|
||||||
|
static let modelID = "modelID"
|
||||||
|
static let textMode = "textMode"
|
||||||
|
static let volume = "volume"
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let d = UserDefaults.standard
|
||||||
|
serverURL = d.string(forKey: Keys.serverURL) ?? "https://192.168.86.2:9178"
|
||||||
|
voiceID = d.string(forKey: Keys.voiceID) ?? ""
|
||||||
|
modelID = d.string(forKey: Keys.modelID)
|
||||||
|
textMode = d.bool(forKey: Keys.textMode)
|
||||||
|
volume = d.object(forKey: Keys.volume) as? Double ?? 0.8
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmedServerURL: String {
|
||||||
|
serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct VoiceApp: App {
|
||||||
|
@StateObject private var settings = SettingsStore()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ConversationView(settings: settings)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ConversationView: View {
|
||||||
|
@ObservedObject var settings: SettingsStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Spacer()
|
||||||
|
Circle()
|
||||||
|
.fill(.blue.gradient)
|
||||||
|
.frame(width: 96, height: 96)
|
||||||
|
Text("Hope Voice")
|
||||||
|
.font(.title.bold())
|
||||||
|
Text(settings.trimmedServerURL)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.navigationTitle("Voice")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
NavigationLink {
|
||||||
|
SettingsView(settings: settings)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "gearshape")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ConversationView(settings: SettingsStore())
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@ObservedObject var settings: SettingsStore
|
||||||
|
@State private var urlText: String = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Server") {
|
||||||
|
TextField("https://192.168.86.2:9178", text: $urlText)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.onAppear { urlText = settings.serverURL }
|
||||||
|
.onChange(of: urlText) { _, newValue in
|
||||||
|
settings.serverURL = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section("Interaction") {
|
||||||
|
Toggle("Text mode", isOn: $settings.textMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SettingsView(settings: SettingsStore())
|
||||||
|
}
|
||||||
+344
@@ -0,0 +1,344 @@
|
|||||||
|
# UPDATE_PATH — iOS App for Hope Voice
|
||||||
|
|
||||||
|
Branch: `feat/ios-app` (created from empty repo state)
|
||||||
|
|
||||||
|
Goal: a native iOS app that is **full parity with the web UI** of
|
||||||
|
`/Users/shane/dev/hope-voice-api`, connecting to the Unraid deployment at
|
||||||
|
`https://192.168.86.2:9178`. The server side is NOT modified in this project —
|
||||||
|
the app reuses the existing `token_server.py` endpoint and media API exactly as
|
||||||
|
the web UI does.
|
||||||
|
|
||||||
|
## Decisions (confirmed)
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Scope | Full parity with web UI: voice chat, transcripts, text mode, voice picker, model picker, background tasks panel, media player + library |
|
||||||
|
| LiveKit client | `livekit-client-swift` (official Apple SDK via SPM) |
|
||||||
|
| Auth | Reuse `POST /token` on the existing token server (no new endpoint, no secrets in the app) |
|
||||||
|
| Server URL | Configurable in Settings, default `https://192.168.86.2:9178` |
|
||||||
|
| TLS | ATS exception for the LAN host + pinned self-signed cert (`NSPinnedDomainKeys`) |
|
||||||
|
|
||||||
|
## What we are replacing / building against
|
||||||
|
|
||||||
|
The "voice api" being replaced is whatever a stock LiveKit demo would use
|
||||||
|
(devkey/devsecret minted on-device, hardcoded URLs). It is replaced by:
|
||||||
|
|
||||||
|
1. **Token endpoint** — `POST {base}/token` (nginx → token_server.py :8091).
|
||||||
|
- Request body is ignored server-side; response:
|
||||||
|
`{ "token": "<JWT>", "room": "voice-shane-<hex8>", "user": { "id", "display_name" } }`
|
||||||
|
- The JWT carries a `roomConfig.agents` claim, so the agent is dispatched
|
||||||
|
when we join. **Joining with a self-minted token would get no agent** —
|
||||||
|
this endpoint is mandatory, not optional.
|
||||||
|
- Room name is unique per call (uuid suffix). It scopes media-server player
|
||||||
|
state; send it as `room` in every `/media/api/` call.
|
||||||
|
|
||||||
|
2. **LiveKit signaling** — `{base}/livekit/` (wss), proxied to :7880.
|
||||||
|
Web UI connects to the same origin: `wss://192.168.86.2:9178/livekit`.
|
||||||
|
|
||||||
|
3. **Media API** — `{base}/media/api/*` (FastAPI, :8093) + static
|
||||||
|
`/media/files/`, `/media/thumbs/`.
|
||||||
|
|
||||||
|
## Protocol reference (from web/app.js + agent/agent.py)
|
||||||
|
|
||||||
|
### Data channel messages (LiveKit `publishData`, all reliable)
|
||||||
|
|
||||||
|
| Direction | Topic | Payload | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| app → agent | `voice-control` | `{"type":"set_voice","voice":<azure voice id>}` | live TTS voice change |
|
||||||
|
| app → agent | `voice-control` | `{"type":"set_model","model":<id>}` | live LLM model change; agent acks with `model_changed` |
|
||||||
|
| app → agent | `voice-control` | `{"type":"get_models"}` | request model list (agent replies `models`) |
|
||||||
|
| app → agent | `conversation-control` | `{"type":"set_mode","mode":"voice"\|"text"}` | interaction mode; text mode mutes mic server-side |
|
||||||
|
| app → agent | `media-control` | `{"type":"media_state","state":"playing"\|"paused"\|"stopped","track_id":...}` | tells agent to disable/enable its mic (music ↔ conversation exclusivity) |
|
||||||
|
| agent → app | `voice-control` | `{"type":"models","models":[...],"selected":...}` / `{"type":"model_changed","model":...}` | model list + applied-model ack |
|
||||||
|
| agent → app | `transcript` | `{"type":"transcript","role":"user"\|"agent","text":...}` | final transcripts (secondary source) |
|
||||||
|
| agent → app | any | `{"type":"task_event", ...}` | background task lifecycle: `created`/`step`/`completed`/`failed` with `task_id`, `description`, `role`, `content`, `result`, `error` |
|
||||||
|
| agent → app | — | `{"type":"agent_ready"}` | agent pipeline ready (wait for this before enabling mic) |
|
||||||
|
|
||||||
|
Agent identity: participant kind `.agent` or identity `"voice-assistant"`.
|
||||||
|
Scan `remoteParticipants` after connect AND watch `participantConnected`
|
||||||
|
(agent may pre-exist in the room).
|
||||||
|
|
||||||
|
Text chat input: `localParticipant.sendText(text, topic: "lk.chat")` (the web
|
||||||
|
UI's text-mode path; the agent consumes it as a user turn).
|
||||||
|
|
||||||
|
### Transcripts — two sources
|
||||||
|
|
||||||
|
1. **Primary:** LiveKit built-in transcription stream on track source
|
||||||
|
`.transcription` (`lk.transcription`). In livekit-client-swift this arrives
|
||||||
|
as `AudioStreamTrack` with source `transcription`; register a text-stream
|
||||||
|
handler / read the track's `TextStream`. Word-by-word partials update the
|
||||||
|
same row in place until final. Role is derived from which participant owns
|
||||||
|
the transcribed track (attribute `lk.transcribed_track_id`).
|
||||||
|
2. **Secondary:** `transcript` data messages above.
|
||||||
|
|
||||||
|
UI must dedupe: when a final transcript arrives, replace any partial row for
|
||||||
|
the same role.
|
||||||
|
|
||||||
|
### Media API (all under `{base}/media/api/`, JSON)
|
||||||
|
|
||||||
|
| Endpoint | Method | Body / Query | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/state` | GET | `?room=<livekit room>` | Returns `{track, desired: "playing"\|"paused"\|"stopped", pending: [jobs], choices: [], seek_hint: {ts, position}}`. Poll every 1.5 s. |
|
||||||
|
| `/report` | POST | `{room, state, track_id, position, duration?}` | Browser/app is the player; report real playback back. |
|
||||||
|
| `/play` | POST | `{room, video_id?, title?, channel?, track_id?, start_paused?}` | Start a track (search result or library id). |
|
||||||
|
| `/control` | POST | `{room, action: "pause"\|"resume"\|"stop"}` | Desired-state control. |
|
||||||
|
| `/search` | POST | `{room, query, limit}` | → `{results: [{video_id, title, channel, duration, in_library}]}`; library matches first. |
|
||||||
|
| `/library` | GET | — | → `{tracks: [{id, title, channel, duration, status, kind, art_url, audio_url}]}` |
|
||||||
|
| `/tracks/{id}` | DELETE | — | Remove from library. |
|
||||||
|
| `/streams` | POST | `{room, name, url}` | Add live stream. |
|
||||||
|
| `/stream/{track_id}` | GET | — | Progressive/Range stream URL (audio element source). |
|
||||||
|
| `/healthz` | GET | — | Liveness. |
|
||||||
|
|
||||||
|
Static: `GET {base}/media/files/<file>` and `{base}/media/thumbs/<img>` (Range
|
||||||
|
supported; use as direct AVPlayer URLs).
|
||||||
|
|
||||||
|
### Media reconciliation rules (port verbatim from app.js)
|
||||||
|
|
||||||
|
- App is the player (AVPlayer). Media server holds per-room **desired** state.
|
||||||
|
- On join: if a track was loaded before, carry it into the new room with
|
||||||
|
`start_paused: true` and remember `wasPlayingBeforeTalk`.
|
||||||
|
- Poll `/state`; when `track.id` changes → load new AVPlayer item; when a
|
||||||
|
previously-null `audio_url` appears (download finished growing) → wire src.
|
||||||
|
- `desired == "playing"` + paused → play; otherwise pause.
|
||||||
|
- Apply `seek_hint` once per `ts`.
|
||||||
|
- On play/pause/ended/timeupdate(≥5 s): `/report`; publish `media_state` on
|
||||||
|
`media-control` so the agent mics off/on.
|
||||||
|
- Ending conversation: if `wasPlayingBeforeTalk` → `/control {action:"resume"}`.
|
||||||
|
|
||||||
|
### Web UI feature inventory (parity checklist)
|
||||||
|
|
||||||
|
- [ ] Connect flow: token → join → wait for agent (10 s, retry once with fresh
|
||||||
|
token after 15 s) → wait `agent_ready` (10 s) → enable mic / text mode
|
||||||
|
- [ ] State machine: idle / connecting / listening / thinking / speaking,
|
||||||
|
inferred from local + remote audio levels; orb pulse animation
|
||||||
|
- [ ] Mic meter + agent audio visualizer bars
|
||||||
|
- [ ] Transcript list with partials, delivery-marker stripping
|
||||||
|
(`[appreciative]` etc.), history persistence (last 50), clear button
|
||||||
|
- [ ] Voice / text mode toggle (persisted); text composer sends `lk.chat`
|
||||||
|
- [ ] Mute toggle
|
||||||
|
- [ ] Settings sheet: voice picker (13 Azure voices, persisted) + model picker
|
||||||
|
(from `models` message, persisted; ack via `model_changed`)
|
||||||
|
- [ ] Background tasks panel: badge count, cards with steps/result/error
|
||||||
|
- [ ] Media panel: now-playing card (art, title, channel, status), play/pause,
|
||||||
|
±10 s, stop, seek bar, volume (persisted), search results, agent-offered
|
||||||
|
choices, library list with delete, add-stream form, download progress
|
||||||
|
- [ ] Screen wake lock while connected (`NSProcessInfo.beginActivity`)
|
||||||
|
- [ ] Reconnect-on-disconnect handling; stale-room guards
|
||||||
|
|
||||||
|
## App architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
voice-ios/
|
||||||
|
├── UPDATE_PATH.md ← this file
|
||||||
|
├── AGENTS.md
|
||||||
|
├── opencode.json (existing)
|
||||||
|
├── VoiceApp.xcodeproj (generated via XcodeGen or SPM-based project)
|
||||||
|
├── Package.swift (SPM: livekit-client-swift)
|
||||||
|
└── Sources/
|
||||||
|
├── App/
|
||||||
|
│ ├── VoiceApp.swift @main, scene setup
|
||||||
|
│ ├── AppRouter.swift tab/root navigation
|
||||||
|
│ └── SettingsStore.swift UserDefaults-backed: serverURL, voice, model, mode, volume
|
||||||
|
├── Networking/
|
||||||
|
│ ├── ServerConfig.swift base URL parsing (https only), LiveKit wss derivation
|
||||||
|
│ ├── TokenClient.swift POST /token → TokenResponse(token, room, user)
|
||||||
|
│ ├── MediaAPI.swift typed client for all /media/api endpoints
|
||||||
|
│ └── TLSPinning.swift URLSessionDelegate pinning the self-signed cert (fallback when NSPinnedDomainKeys is insufficient for WebRTC)
|
||||||
|
├── LiveKit/
|
||||||
|
│ ├── VoiceSession.swift Room lifecycle: connect, agent discovery, agent_ready wait, retry-once reconnect, disconnect teardown
|
||||||
|
│ ├── AgentMonitor.swift agent participant detection (kind .agent / identity), audio level observation
|
||||||
|
│ ├── TranscriptHandler.swift transcription stream + transcript data messages → TranscriptEvent stream (partial/final, role)
|
||||||
|
│ └── ControlChannel.swift publishData helpers: set_voice, set_model, get_models, set_mode, media_state; inbound models/model_changed/task_event/agent_ready parsing
|
||||||
|
├── Features/
|
||||||
|
│ ├── Conversation/
|
||||||
|
│ │ ├── ConversationViewModel.swift state machine (idle/connecting/listening/thinking/speaking), audio-level inference, mic toggle, mode switch
|
||||||
|
│ │ ├── ConversationView.swift orb + status + transcript list + composer
|
||||||
|
│ │ ├── OrbView.swift pulse animation driven by levels
|
||||||
|
│ │ └── TranscriptListView.swift partials, delivery-marker stripping, history
|
||||||
|
│ ├── Settings/
|
||||||
|
│ │ ├── SettingsView.swift server URL, voice picker, model picker, clear history
|
||||||
|
│ │ └── ModelPickerView.swift from agent-published list
|
||||||
|
│ ├── Tasks/
|
||||||
|
│ │ ├── TaskStore.swift task_event → [TaskItem]
|
||||||
|
│ │ └── TasksPanelView.swift badge + cards
|
||||||
|
│ └── Media/
|
||||||
|
│ ├── MediaPlayerController.swift AVPlayer + /state polling (1.5 s) + reconciliation + reporting
|
||||||
|
│ ├── MediaAPIModels.swift Track, Job, StateResponse, SearchResult…
|
||||||
|
│ └── MediaPanelView.swift now-playing card, transport, seek, search, library, add-stream
|
||||||
|
└── Support/
|
||||||
|
├── DeliveryMarkers.swift regex strip of [style] markers
|
||||||
|
├── AudioLevels.swift smoothing (0.6/0.4 EMA), thresholds (user 0.035, agent 0.02)
|
||||||
|
└── WakeLock.swift beginActivity while session active
|
||||||
|
└── Resources/
|
||||||
|
├── Info.plist / entitlements NSMicrophoneUsageDescription, ATS exceptions
|
||||||
|
└── Assets.xcassets app icon (port favicon.svg)
|
||||||
|
```
|
||||||
|
|
||||||
|
UI framework: **SwiftUI** (single-window conversation UI + sheets for settings,
|
||||||
|
tasks, media — mirrors the web's panel model). Audio rendering of remote TTS is
|
||||||
|
handled by livekit-client-swift's `Room` audio engine; local mic via its
|
||||||
|
capture. No manual WebRTC.
|
||||||
|
|
||||||
|
### Key technical notes / risks
|
||||||
|
|
||||||
|
- **ATS + self-signed cert.** `NSAppTransportSecurity`:
|
||||||
|
`NSExceptionDomains["192.168.86.2"] = { NSExceptionAllowsInsecureHTTPLoads: false, NSIncludesSubdomains: true }` plus pinning the cert via
|
||||||
|
`NSPinnedDomainKeys` (bundle the `.cer` from `https://192.168.86.2:9178`,
|
||||||
|
extract with `openssl s_client`). If LiveKit's WebRTC sockets bypass the
|
||||||
|
pinned URLSession, fall back to a custom `URLSessionDelegate` in
|
||||||
|
`TLSPinning.swift` that validates the peer cert against the bundled one.
|
||||||
|
Cert is regenerated at container start (entrypoint.sh) — document re-export
|
||||||
|
steps; pinning will break on cert rotation until the app ships an update or
|
||||||
|
we move to a real cert. **Mitigation option:** keep a "trust this server"
|
||||||
|
first-run flow that caches the SPKI hash in Keychain instead of hard-pinning.
|
||||||
|
- **Audio session.** `AVAudioSession` category `.playAndRecord`, mode
|
||||||
|
`.spokenAudio`, options `[.defaultToSpeaker, .allowBluetoothA2DP]`; activate
|
||||||
|
on connect, deactivate on disconnect. livekit-client-swift manages the
|
||||||
|
engine but we own the session config.
|
||||||
|
- **Agent dispatch timing.** If the agent worker isn't up (~15 s after server
|
||||||
|
start), join gets no agent and there is no retry — hence the web UI's
|
||||||
|
reconnect-once flow; port it exactly (fresh token, fresh room name).
|
||||||
|
- **Text stream in livekit-client-swift.** The Swift SDK exposes
|
||||||
|
`AudioStreamTrack.textStream` / data messages; verify partial-word behavior
|
||||||
|
during Phase 2 spike. If transcription track source isn't delivered on this
|
||||||
|
LiveKit version, the secondary `transcript` topic still gives final text —
|
||||||
|
degrade gracefully (no word-by-word).
|
||||||
|
- **AVPlayer vs progressive webm/opus.** iOS plays m4a/AAC and mp3 natively;
|
||||||
|
**webm/opus may not play in AVPlayer**. Downloaded tracks are raw bestaudio
|
||||||
|
(often webm). Spike in Phase 5: if AVPlayer rejects webm, options are
|
||||||
|
(a) accept the limitation + surface "not playable on iOS" for those tracks,
|
||||||
|
(b) add an optional server-side transcode later (out of scope here), or
|
||||||
|
(c) use a lightweight opus decoder. Decide at spike; default to (a).
|
||||||
|
- **Background.** Out of scope: no background audio continuation beyond the
|
||||||
|
standard AVPlayer behavior while foregrounded; wake lock keeps screen on
|
||||||
|
during conversation only.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
### Phase 0 — Project scaffold (0.5 day)
|
||||||
|
- XcodeGen spec (`project.yml`) or hand-rolled `.xcodeproj`; iOS 17+ target,
|
||||||
|
SwiftUI lifecycle, single app "Voice" (bundle id `dev.shane.voice`).
|
||||||
|
- SPM dependency: `livekit-client-swift` (pin latest stable; verify the
|
||||||
|
transcription-track API surface against the pinned version).
|
||||||
|
- Info.plist: `NSMicrophoneUsageDescription`, ATS exceptions + pinned cert
|
||||||
|
resource, URL types none.
|
||||||
|
- Empty app boots to a placeholder screen; builds & runs in simulator and on
|
||||||
|
device (mic needs real device for full testing — simulator mic works for
|
||||||
|
capture tests).
|
||||||
|
- **Exit:** app installs on an iPhone on the LAN; Settings screen shows
|
||||||
|
server URL field defaulting to `https://192.168.86.2:9178`.
|
||||||
|
|
||||||
|
### Phase 1 — Connectivity & token (0.5 day)
|
||||||
|
- `ServerConfig` (parse base URL, derive `wss://host/livekit`, validate https).
|
||||||
|
- `TokenClient`: POST /token with URLSession using the pinned-cert delegate;
|
||||||
|
decode `TokenResponse`; surface 503 user-file errors clearly.
|
||||||
|
- Connection test screen: "Get token" button → shows room name + user display
|
||||||
|
name (proves TLS pinning + endpoint work end-to-end).
|
||||||
|
- **Exit:** on device, token fetch succeeds against the Unraid deployment;
|
||||||
|
wrong URL / offline produce clean error states.
|
||||||
|
|
||||||
|
### Phase 2 — LiveKit session spike (1 day) ← de-risk first
|
||||||
|
- `VoiceSession`: create `Room`, connect with token, observe events.
|
||||||
|
- Agent discovery: scan + watch for kind `.agent` / identity
|
||||||
|
"voice-assistant"; wait for `agent_ready` data message; implement
|
||||||
|
reconnect-once flow (fresh token on agent timeout).
|
||||||
|
- Mic: enable local mic after `agent_ready`; mute toggle.
|
||||||
|
- Remote audio: confirm TTS plays through the SDK's audio engine on device.
|
||||||
|
- **Transcript spike:** wire transcription track source + `transcript` topic;
|
||||||
|
log partials vs finals. Decide degradation path if word-by-word unavailable.
|
||||||
|
- **Exit:** on a real iPhone, start → agent joins → speak → hear TTS back →
|
||||||
|
see at least final transcripts; disconnect cleans up audio session.
|
||||||
|
|
||||||
|
### Phase 3 — Conversation UI (1–2 days)
|
||||||
|
- `ConversationViewModel` state machine ported from app.js (`inferState`,
|
||||||
|
EMA smoothing, thresholds), driven by local/remote audio levels from the SDK.
|
||||||
|
- Orb + status line + mic meter; thinking chip (>2 s); barge-in hint while
|
||||||
|
speaking.
|
||||||
|
- Transcript list: partials in place, final replace, delivery-marker strip,
|
||||||
|
history (UserDefaults JSON, last 50), clear button, timestamps.
|
||||||
|
- Voice/text mode toggle (persisted): text mode disables mic + sends
|
||||||
|
`set_mode`; composer sends via `sendText(topic:"lk.chat")`.
|
||||||
|
- Screen wake lock while connected; disconnect teardown path.
|
||||||
|
- **Exit:** full voice conversation on device with live UI states matching the
|
||||||
|
web UI's behavior; text mode round-trips.
|
||||||
|
|
||||||
|
### Phase 4 — Settings: voices + models (0.5 day)
|
||||||
|
- Voice picker sheet: 13 Azure voices from app.js `VOICES`; persist selection;
|
||||||
|
send `set_voice` on change and on agent join (sync-on-join like the web UI).
|
||||||
|
- Model picker: listen for `models` message, render list, persist preference,
|
||||||
|
send `set_model`, apply ack via `model_changed`; note state when not
|
||||||
|
connected ("Start a conversation to load the model list").
|
||||||
|
- **Exit:** changing voice/model in the sheet takes effect live on the agent;
|
||||||
|
choices survive relaunch.
|
||||||
|
|
||||||
|
### Phase 5 — Media player (1–2 days) ← second de-risk spike inside
|
||||||
|
- `MediaAPI` client + models for all endpoints above.
|
||||||
|
- `MediaPlayerController`: AVPlayer item from `audio_url`; poll `/state`
|
||||||
|
every 1.5 s; reconciliation rules ported verbatim (track swap, late
|
||||||
|
audio_url wiring, desired-state apply, seek_hint once-per-ts); `/report`
|
||||||
|
on play/pause/ended + ≥5 s while playing; publish `media_state` on
|
||||||
|
`media-control`; enter-media-scope carry-over with `start_paused: true`;
|
||||||
|
resume-after-talk.
|
||||||
|
- **webm spike:** load a downloaded webm track in AVPlayer on device; record
|
||||||
|
result and apply decision (a/b/c from risks).
|
||||||
|
- Media panel UI: now-playing card (AsyncImage art, title, channel, status),
|
||||||
|
transport (play/pause, ±10 s, stop), seek bar, volume slider (persisted),
|
||||||
|
download progress row.
|
||||||
|
- **Exit:** play a library track; pause/skip/seek work and are visible to the
|
||||||
|
agent (mic disables while playing); ending a conversation resumes playback.
|
||||||
|
|
||||||
|
### Phase 6 — Media search, choices, library (1 day)
|
||||||
|
- Search form → results list with `in_library` tag; Play buttons → `/play`.
|
||||||
|
- Agent-offered choices rendered from `state.choices`.
|
||||||
|
- Library list: refresh on open, play (ready only), delete with confirm.
|
||||||
|
- Add-stream form (name + url) → `/streams`.
|
||||||
|
- **Exit:** full media panel parity with the web UI, including library
|
||||||
|
management and stream addition.
|
||||||
|
|
||||||
|
### Phase 7 — Background tasks panel (0.5 day)
|
||||||
|
- `TaskStore`: fold `task_event` messages into task items (created/step/
|
||||||
|
completed/failed), badge = active count.
|
||||||
|
- Panel: cards sorted by created_at desc, steps list with role styling,
|
||||||
|
result/error blocks; open from toolbar like the web UI.
|
||||||
|
- **Exit:** ask Hope to research something; panel shows live steps and final
|
||||||
|
result identical to the web UI.
|
||||||
|
|
||||||
|
### Phase 8 — Polish, hardening, parity pass (1 day)
|
||||||
|
- Error states: token 503, agent never joins, media server down, network loss
|
||||||
|
mid-call (reconnect banner), mic permission denied fallback.
|
||||||
|
- Stale-room guards on every async callback (port the `=== room` checks).
|
||||||
|
- Audio session interruption handling (phone call, Siri) — resume or pause
|
||||||
|
conversation cleanly.
|
||||||
|
- Accessibility: labels/traits matching the web UI's ARIA roles; Dynamic Type.
|
||||||
|
- Side-by-side parity test against the web UI on the same deployment for every
|
||||||
|
checklist item above.
|
||||||
|
- **Exit:** parity checklist fully ticked; no console-level crash paths in the
|
||||||
|
connect/disconnect/reconnect matrix.
|
||||||
|
|
||||||
|
### Phase 9 — Docs & handoff (0.5 day)
|
||||||
|
- README.md: build, run, device requirements, cert re-pinning steps after
|
||||||
|
server restart/rotation, how to point at another deployment.
|
||||||
|
- AGENTS.md updates (build/test commands, conventions).
|
||||||
|
- Commit history cleaned up per conventional commits.
|
||||||
|
|
||||||
|
**Total estimate: ~7–8 focused days.** Phases 2 and 5 contain the two real
|
||||||
|
technical risks (transcription API surface; webm playback) and are sequenced
|
||||||
|
early for that reason.
|
||||||
|
|
||||||
|
## Verification strategy
|
||||||
|
|
||||||
|
- Every phase ends with a manual pass on a **real iPhone** on the LAN against
|
||||||
|
`https://192.168.86.2:9178` (mic + WebRTC don't work in simulator).
|
||||||
|
- XcodeBuildMCP for build/run/screenshot loops where useful; device testing
|
||||||
|
via Xcode directly.
|
||||||
|
- No server-side changes are made to hope-voice-api; if a gap is found there
|
||||||
|
(e.g., token endpoint needs an iOS-friendly response), it is documented as a
|
||||||
|
follow-up ticket, not silently patched in this repo.
|
||||||
|
|
||||||
|
## Out of scope (explicit)
|
||||||
|
|
||||||
|
- Login/user selection (server pins `VOICE_DEFAULT_USER` until the planned
|
||||||
|
login flow; app just displays the returned user).
|
||||||
|
- Background audio / lock-screen controls beyond AVPlayer defaults.
|
||||||
|
- Server-side changes (transcode, new endpoints, real TLS cert).
|
||||||
|
- iPad/macCatalyst/Apple Watch targets.
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */; };
|
||||||
|
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; };
|
||||||
|
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F76881B4AE51DEE74511CC /* SettingsView.swift */; };
|
||||||
|
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */; };
|
||||||
|
D9A0059E342955A06A021D6C /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = A6BAD55715F383C0DB5F306F /* LiveKit */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
2A4C3CE2CA6FF828452803D1 /* Voice.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Voice.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
2FED8976B60FBC13BC980FCB /* VoiceApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceApp.swift; sourceTree = "<group>"; };
|
||||||
|
3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = "<group>"; };
|
||||||
|
48F76881B4AE51DEE74511CC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
F79CBCE3DCE9D63A26B167D9 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
D9A0059E342955A06A021D6C /* LiveKit in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
0DF0591DF1D767F89099F62C /* Settings */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
48F76881B4AE51DEE74511CC /* SettingsView.swift */,
|
||||||
|
);
|
||||||
|
path = Settings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
6F99423E59017FED59B4D71D /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2A4C3CE2CA6FF828452803D1 /* Voice.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
84ADAF12A43A57888CA178A6 /* Conversation */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */,
|
||||||
|
);
|
||||||
|
path = Conversation;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
8DA8BEF2EE3E0F4181A94EB9 /* Features */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
84ADAF12A43A57888CA178A6 /* Conversation */,
|
||||||
|
0DF0591DF1D767F89099F62C /* Settings */,
|
||||||
|
);
|
||||||
|
path = Features;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
B3D0379BEBE1E5F4AA58DA2D = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
FCC61E9990B71498171F9D34 /* Sources */,
|
||||||
|
6F99423E59017FED59B4D71D /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
CE49A38E40AAF659A9281B85 /* App */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */,
|
||||||
|
2FED8976B60FBC13BC980FCB /* VoiceApp.swift */,
|
||||||
|
);
|
||||||
|
path = App;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
FCC61E9990B71498171F9D34 /* Sources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CE49A38E40AAF659A9281B85 /* App */,
|
||||||
|
8DA8BEF2EE3E0F4181A94EB9 /* Features */,
|
||||||
|
);
|
||||||
|
path = Sources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
57E3A292BDE525A22EB7ADE4 /* Voice_iOS */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 67CE95EFE28F976D1CF1A59A /* Build configuration list for PBXNativeTarget "Voice_iOS" */;
|
||||||
|
buildPhases = (
|
||||||
|
548DD373FE3F2A49C30AA9CB /* Sources */,
|
||||||
|
F79CBCE3DCE9D63A26B167D9 /* Frameworks */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = Voice_iOS;
|
||||||
|
packageProductDependencies = (
|
||||||
|
A6BAD55715F383C0DB5F306F /* LiveKit */,
|
||||||
|
);
|
||||||
|
productName = Voice_iOS;
|
||||||
|
productReference = 2A4C3CE2CA6FF828452803D1 /* Voice.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
201BFA602F0A78910611E5F5 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
LastUpgradeCheck = 1430;
|
||||||
|
TargetAttributes = {
|
||||||
|
57E3A292BDE525A22EB7ADE4 = {
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 9F111BF768E1A4FB89724F08 /* Build configuration list for PBXProject "Voice" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
Base,
|
||||||
|
en,
|
||||||
|
);
|
||||||
|
mainGroup = B3D0379BEBE1E5F4AA58DA2D;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
packageReferences = (
|
||||||
|
8C4532D91555015807E47884 /* XCRemoteSwiftPackageReference "client-sdk-swift" */,
|
||||||
|
);
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = 6F99423E59017FED59B4D71D /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
57E3A292BDE525A22EB7ADE4 /* Voice_iOS */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
548DD373FE3F2A49C30AA9CB /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */,
|
||||||
|
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */,
|
||||||
|
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */,
|
||||||
|
1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
25699B8C57BC38421732D5E6 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"DEBUG=1",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
8DB4436439AC31CB0409E1A4 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = Resources/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = dev.shane.voice;
|
||||||
|
PRODUCT_NAME = Voice;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.9;
|
||||||
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
97702898F64A55FAD1D4E4D9 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = Resources/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = dev.shane.voice;
|
||||||
|
PRODUCT_NAME = Voice;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.9;
|
||||||
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
BC9661613EA9B797B71B7F82 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
67CE95EFE28F976D1CF1A59A /* Build configuration list for PBXNativeTarget "Voice_iOS" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
97702898F64A55FAD1D4E4D9 /* Debug */,
|
||||||
|
8DB4436439AC31CB0409E1A4 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
9F111BF768E1A4FB89724F08 /* Build configuration list for PBXProject "Voice" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
25699B8C57BC38421732D5E6 /* Debug */,
|
||||||
|
BC9661613EA9B797B71B7F82 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
8C4532D91555015807E47884 /* XCRemoteSwiftPackageReference "client-sdk-swift" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/livekit/client-sdk-swift";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 2.0.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
A6BAD55715F383C0DB5F306F /* LiveKit */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 8C4532D91555015807E47884 /* XCRemoteSwiftPackageReference "client-sdk-swift" */;
|
||||||
|
productName = LiveKit;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
};
|
||||||
|
rootObject = 201BFA602F0A78910611E5F5 /* Project object */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "727398c0b292ba4052728cc638944a00ac7067321e283dedd2630f3e865fa408",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "client-sdk-swift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/livekit/client-sdk-swift",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "79fb2beee98e45556bffebefa50b5d05c3382af1",
|
||||||
|
"version" : "2.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "livekit-uniffi-xcframework",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/livekit/livekit-uniffi-xcframework.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "7c161254ce7cd55debc48023f69a917076b12a26",
|
||||||
|
"version" : "0.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-protobuf",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-protobuf.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8",
|
||||||
|
"version" : "1.38.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "webrtc-xcframework",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/livekit/webrtc-xcframework.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "46f2af86f06b9a8a9158d37cadda5cb5a214e4c4",
|
||||||
|
"version" : "144.7559.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
|
"xcodebuildmcp": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["/Users/shane/.local/bin/xcodebuildmcp", "mcp"],
|
||||||
|
"enabled": true,
|
||||||
|
"environment": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
name: Voice
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: dev.shane
|
||||||
|
deploymentTarget:
|
||||||
|
iOS: "17.0"
|
||||||
|
createIntermediateGroups: true
|
||||||
|
packages:
|
||||||
|
LiveKit:
|
||||||
|
url: https://github.com/livekit/client-sdk-swift
|
||||||
|
from: "2.0.0"
|
||||||
|
targets:
|
||||||
|
Voice:
|
||||||
|
type: application
|
||||||
|
platform: [iOS]
|
||||||
|
sources:
|
||||||
|
- Sources
|
||||||
|
dependencies:
|
||||||
|
- package: LiveKit
|
||||||
|
product: LiveKit
|
||||||
|
resources:
|
||||||
|
- path: Resources/Assets.xcassets
|
||||||
|
info:
|
||||||
|
path: Resources/Info.plist
|
||||||
|
properties:
|
||||||
|
CFBundleDisplayName: Hope Voice
|
||||||
|
UILaunchScreen: {}
|
||||||
|
NSMicrophoneUsageDescription: "Hope Voice uses the microphone for live voice conversation."
|
||||||
|
NSAppTransportSecurity:
|
||||||
|
NSExceptionDomains:
|
||||||
|
192.168.86.2:
|
||||||
|
NSIncludesSubdomains: true
|
||||||
|
NSExceptionAllowsInsecureHTTPLoads: false
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: dev.shane.voice
|
||||||
|
GENERATE_INFOPLIST_FILE: NO
|
||||||
|
INFOPLIST_FILE: Resources/Info.plist
|
||||||
|
SWIFT_VERSION: "5.9"
|
||||||
|
TARGETED_DEVICE_FAMILY: "1"
|
||||||
|
CURRENT_PROJECT_VERSION: 1
|
||||||
|
MARKETING_VERSION: "0.1.0"
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
Reference in new issue
Block a user