Files
voice-cat/clients/apple/iOS/VoiceCatiOS/ServerListStore.swift
Talon 8c90e250f0 feat(apple): screen-audio sharing -- macOS ScreenCaptureKit, iOS ReplayKit
Implement system/desktop audio sharing on the Apple clients, feeding the
existing SCREEN_AUDIO Opus -> AEAD -> UDP path via vc_stream_feed_pcm. No
C++/protocol/codec changes -- the core was already ready (the Windows-only
loopback is #ifdef VOICECAT_HAS_LOOPBACK; off Windows the stream just waits
for fed PCM). Audio only; video is dropped.

macOS (in-process):
- ScreenAudioCapture.swift drives an audio-only SCStream
  (excludesCurrentProcessAudio), converts Float32 -> int16 in the channel's
  mono/stereo mode, and calls feedPcm. Capture starts on the self
  .streamStarted event (effective config known then). Wired into
  MainWindowController.screenAudioClicked().

iOS (forward-to-host, single session):
- VoiceCatBroadcast: a ReplayKit Broadcast Upload Extension consumes
  .audioApp only, resamples to 48kHz int16 stereo (AVAudioConverter), and
  writes a shared App Group SPSC ring (BroadcastAudioRing.swift). It does
  not link libvoicecat.
- Host BroadcastAudioPump drains the ring (reacting to the extension's
  Darwin notifications) and feeds the SCREEN_AUDIO stream it owns, downmixing
  to mono when the channel is mono. Screen audio appears as a second stream
  of the same user; no credentials persisted. UI is RPSystemBroadcastPicker
  View in VoiceControlsView. Removes the speculative BroadcastCredentials.

Docs: voice.md s9, CLAUDE.md status, PROGRESS.md.
2026-06-21 00:14:31 +02:00

93 lines
3.1 KiB
Swift

import Foundation
import Security
final class ServerListStore {
static let shared = ServerListStore()
private let groupId = "group.cat.voice.VoiceCat"
private let keychainService = "cat.voice.VoiceCatiOS"
// MARK: - App Group Container
var appGroupContainer: URL {
guard let url = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: groupId)
else {
// Fall back to app-only support dir if App Groups are unavailable (e.g. simulator
// without entitlements). TOFU pins won't be shared with the broadcast extension,
// but connect + auth still work.
return FileManager.default.urls(for: .applicationSupportDirectory,
in: .userDomainMask).first!
}
return url
}
private var voicecatDir: URL {
let dir = appGroupContainer.appendingPathComponent("voicecat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir,
withIntermediateDirectories: true)
return dir
}
var tofuStorePath: String { voicecatDir.appendingPathComponent("tofu_pins.txt").path }
private var serversURL: URL { voicecatDir.appendingPathComponent("servers.json") }
// MARK: - Server list persistence
func load() -> [SavedServer] {
guard let data = try? Data(contentsOf: serversURL),
let list = try? JSONDecoder().decode([SavedServer].self, from: data)
else { return [] }
return list
}
func save(_ servers: [SavedServer]) {
guard let data = try? JSONEncoder().encode(servers) else { return }
try? data.write(to: serversURL, options: .atomic)
}
// MARK: - Keychain
func savePassword(_ password: String, tag: String) {
let data = Data(password.utf8)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: keychainService,
kSecAttrAccount: tag,
kSecAttrAccessGroup: groupId,
]
SecItemDelete(query as CFDictionary)
var add = query
add[kSecValueData] = data
SecItemAdd(add as CFDictionary, nil)
}
func loadPassword(tag: String) -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: keychainService,
kSecAttrAccount: tag,
kSecAttrAccessGroup: groupId,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data
else { return nil }
return String(data: data, encoding: .utf8)
}
func deletePassword(tag: String) {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: keychainService,
kSecAttrAccount: tag,
kSecAttrAccessGroup: groupId,
]
SecItemDelete(query as CFDictionary)
}
}