feat: actually implement system audio + refactor

This commit is contained in:
Owen Gretzinger
2025-07-10 16:45:19 -04:00
parent 417d751d7a
commit 297bb40b89
21 changed files with 1307 additions and 615 deletions
-196
View File
@@ -1,196 +0,0 @@
// AudioManager.swift
import AVFoundation
import Foundation
import SwiftUI
import ScreenCaptureKit
class AudioManager: NSObject, ObservableObject {
@Published var transcript = ""
@Published var isRecording = false
private var stream: SCStream?
private var webSocketTask: URLSessionWebSocketTask?
private let deepgramURL = URL(string: "wss://api.deepgram.com/v1/listen?encoding=linear16&sample_rate=48000&channels=1&interim_results=true")!
func startRecording() {
// Check if ScreenCaptureKit is available
guard #available(macOS 13.0, *) else {
print("ScreenCaptureKit requires macOS 13.0 or later")
return
}
Task {
do {
// Request permission for screen recording (includes system audio)
let canRecord = await requestScreenRecordingPermission()
if !canRecord {
print("Screen recording permission denied")
DispatchQueue.main.async {
self.isRecording = false
}
return
}
// Set up screen capture configuration
let config = SCStreamConfiguration()
config.capturesAudio = true
config.captureMicrophone = true
config.sampleRate = 48000
config.channelCount = 1
// Create content filter for desktop audio capture
let availableContent = try await SCShareableContent.current
guard let display = availableContent.displays.first else {
print("No displays available for capture")
DispatchQueue.main.async {
self.isRecording = false
}
return
}
let filter = SCContentFilter(display: display, excludingWindows: [])
// Create stream
stream = SCStream(filter: filter, configuration: config, delegate: self)
// Add audio output
try stream?.addStreamOutput(self, type: .audio, sampleHandlerQueue: DispatchQueue(label: "AudioQueue"))
try stream?.addStreamOutput(self, type: .microphone, sampleHandlerQueue: DispatchQueue(label: "MicQueue"))
// Start capture
try await stream?.startCapture()
DispatchQueue.main.async {
self.isRecording = true
}
// Connect to Deepgram
connectToDeepgram()
} catch {
print("Failed to start recording: \(error)")
DispatchQueue.main.async {
self.isRecording = false
}
}
}
}
func stopRecording() {
Task {
do {
try await stream?.stopCapture()
stream = nil
webSocketTask?.cancel(with: .normalClosure, reason: nil)
webSocketTask = nil
DispatchQueue.main.async {
self.isRecording = false
}
} catch {
print("Failed to stop recording: \(error)")
}
}
}
private func requestScreenRecordingPermission() async -> Bool {
// ScreenCaptureKit handles permission requests automatically
// This will show a system dialog if needed
do {
let _ = try await SCShareableContent.current
return true
} catch {
return false
}
}
private func connectToDeepgram() {
let session = URLSession(configuration: .default)
var request = URLRequest(url: deepgramURL)
if let key = KeychainHelper.shared.get(forKey: "deepgramKey"), !key.isEmpty {
request.addValue("Token \(key)", forHTTPHeaderField: "Authorization")
} else {
print("No Deepgram key found")
return
}
webSocketTask = session.webSocketTask(with: request)
webSocketTask?.resume()
receiveMessage()
}
private func sendAudioData(_ data: Data) {
webSocketTask?.send(.data(data)) { error in
if let error = error {
print("Send error: \(error)")
}
}
}
private func receiveMessage() {
webSocketTask?.receive { result in
switch result {
case .success(let message):
switch message {
case .string(let text):
self.parseTranscription(text)
case .data:
break
@unknown default:
break
}
self.receiveMessage() // Continue receiving
case .failure(let error):
print("Receive error: \(error)")
}
}
}
private func parseTranscription(_ text: String) {
guard let data = text.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String, type == "Results",
let channel = json["channel"] as? [String: Any],
let alternatives = channel["alternatives"] as? [[String: Any]],
let alt = alternatives.first,
let transcript = alt["transcript"] as? String,
!transcript.isEmpty else { return }
DispatchQueue.main.async {
if let isFinal = json["is_final"] as? Bool, isFinal {
self.transcript += transcript + " "
}
}
}
}
// MARK: - SCStreamDelegate
extension AudioManager: SCStreamDelegate {
func stream(_ stream: SCStream, didStopWithError error: Error) {
print("Stream stopped with error: \(error)")
DispatchQueue.main.async {
self.isRecording = false
}
}
}
// MARK: - SCStreamOutput
extension AudioManager: SCStreamOutput {
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
// Convert sample buffer to audio data and send to Deepgram
guard let audioBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else {
print("Failed to get data buffer from sample buffer")
return
}
var length: Int = 0
var dataPointer: UnsafeMutablePointer<Int8>?
let status = CMBlockBufferGetDataPointer(audioBuffer, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length, dataPointerOut: &dataPointer)
guard status == noErr, let pointer = dataPointer, length > 0 else {
print("Failed to get audio data pointer, status: \(status), length: \(length)")
return
}
let data = Data(bytes: pointer, count: length)
sendAudioData(data)
}
}
+17 -73
View File
@@ -8,80 +8,24 @@
import SwiftUI
struct ContentView: View {
@StateObject private var audioManager = SimpleAudioManager()
@State private var deepgramKey = ""
@State private var openAIKey = ""
@State private var userBlurb = ""
@State private var systemPrompt = "Generate meeting notes from transcript and notes."
var body: some View {
VStack(spacing: 20) {
Text("Notetaker Settings")
.font(.title)
TextField("Deepgram API Key", text: $deepgramKey)
TextField("OpenAI API Key", text: $openAIKey)
TextField("About Yourself (for prompt)", text: $userBlurb)
TextEditor(text: $systemPrompt)
.frame(height: 100)
Button("Save") {
saveSettings()
}
Divider()
Toggle("Capture System Audio", isOn: $audioManager.captureSystemAudio)
.disabled(audioManager.isRecording)
HStack {
Button("Start Recording") {
audioManager.startRecording()
}
.disabled(audioManager.isRecording)
Button("Stop Recording") {
audioManager.stopRecording()
}
.disabled(!audioManager.isRecording)
}
if audioManager.isRecording {
Text(audioManager.captureSystemAudio ? "🔴 Recording microphone + system audio..." : "🔴 Recording microphone...")
.foregroundColor(.red)
}
Text("Live Transcript:")
.font(.headline)
ScrollView {
Text(audioManager.transcript.isEmpty ? "No transcript yet..." : audioManager.transcript)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.frame(height: 150)
.border(Color.gray)
}
.padding()
.frame(minWidth: 500, minHeight: 600)
.onAppear {
loadSettings()
}
}
@State private var showingSettings = false
func saveSettings() {
_ = KeychainHelper.shared.save(deepgramKey, forKey: "deepgramKey")
_ = KeychainHelper.shared.save(openAIKey, forKey: "openAIKey")
_ = KeychainHelper.shared.save(userBlurb, forKey: "userBlurb")
_ = KeychainHelper.shared.save(systemPrompt, forKey: "systemPrompt")
print("Settings saved!")
}
private func loadSettings() {
deepgramKey = KeychainHelper.shared.get(forKey: "deepgramKey") ?? ""
openAIKey = KeychainHelper.shared.get(forKey: "openAIKey") ?? ""
userBlurb = KeychainHelper.shared.get(forKey: "userBlurb") ?? ""
systemPrompt = KeychainHelper.shared.get(forKey: "systemPrompt") ?? "Generate meeting notes from transcript and notes."
var body: some View {
VStack {
MeetingListView()
.toolbar {
ToolbarItem(placement: .automatic) {
Button {
showingSettings = true
} label: {
Label("Settings", systemImage: "gear")
}
}
}
}
.sheet(isPresented: $showingSettings) {
SettingsView()
}
}
}
+2
View File
@@ -4,5 +4,7 @@
<dict>
<key>NSAudioCaptureUsageDescription</key>
<string>Notetaker needs access to capture system audio for transcription.</string>
<key>NSScreenCaptureUsageDescription</key>
<string>Notetaker needs screen recording permission to capture system audio for transcription.</string>
</dict>
</plist>
-42
View File
@@ -1,42 +0,0 @@
// KeychainHelper.swift
// Helper for secure storage
import Foundation
import Security
class KeychainHelper {
static let shared = KeychainHelper()
private init() {}
func save(_ value: String, forKey key: String) -> Bool {
guard let data = value.data(using: .utf8) else { return false }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrService as String: "owen.notetaker" // Replace with your bundle ID
]
SecItemDelete(query as CFDictionary) // Delete if exists
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
func get(forKey key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrService as String: "owen.notetaker",
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true
]
var item: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
}
+392
View File
@@ -0,0 +1,392 @@
// AudioManager.swift
// Unified audio manager for microphone and system audio capture
import AVFoundation
import Foundation
import SwiftUI
import ScreenCaptureKit
// Distinguish which source (mic vs system) an audio buffer belongs to
private enum AudioSource {
case mic
case system
}
/// Manages audio capture from microphone and/or system audio and handles real-time transcription via Deepgram
class AudioManager: NSObject, ObservableObject {
@Published var transcript = ""
@Published var isRecording = false
@Published var captureSystemAudio = true
private var audioEngine = AVAudioEngine()
private var micSocketTask: URLSessionWebSocketTask?
private var systemSocketTask: URLSessionWebSocketTask?
private let deepgramURL = URL(string: "wss://api.deepgram.com/v1/listen?encoding=linear16&sample_rate=16000&channels=1&interim_results=true")!
// ScreenCaptureKit properties
private var stream: SCStream?
func startRecording() {
print("Starting recording...")
// First ensure everything is stopped and cleaned up
stopRecordingInternal()
if captureSystemAudio {
// Start microphone capture in parallel with system audio
startMicrophoneTap()
// Start system audio capture asynchronously
Task {
await startSystemAudioCapture()
}
} else {
startMicrophoneOnly()
}
}
private func stopRecordingInternal() {
print("Internal cleanup...")
// Stop system audio capture
if let stream = stream {
stream.stopCapture()
self.stream = nil
print("System audio capture stopped")
}
// Stop microphone capture
cleanupAudioEngine()
// Close WebSocket
micSocketTask?.cancel(with: .normalClosure, reason: nil)
micSocketTask = nil
systemSocketTask?.cancel(with: .normalClosure, reason: nil)
systemSocketTask = nil
// Reset state
DispatchQueue.main.async {
self.isRecording = false
}
print("Internal cleanup completed")
}
private func startMicrophoneOnly() {
print("Starting microphone-only recording...")
// Ensure audio engine is stopped and cleaned up
cleanupAudioEngine()
// Set up audio engine for microphone capture
let inputNode = audioEngine.inputNode
let recordingFormat = inputNode.outputFormat(forBus: 0)
// Convert to the format Deepgram expects: 16kHz, 16-bit, mono
let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16,
sampleRate: 16000,
channels: 1,
interleaved: false)!
let converter = AVAudioConverter(from: recordingFormat, to: targetFormat)!
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak self] (buffer, time) in
self?.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic)
}
audioEngine.prepare()
do {
try audioEngine.start()
DispatchQueue.main.async {
self.isRecording = true
}
connectToDeepgram(source: .mic)
print("Microphone recording started")
} catch {
print("Failed to start audio engine: \(error)")
}
}
/// Starts a microphone tap without creating a new Deepgram connection (used when also capturing system audio)
private func startMicrophoneTap() {
// Ensure audio engine is stopped and cleaned up
cleanupAudioEngine()
let inputNode = audioEngine.inputNode
let recordingFormat = inputNode.outputFormat(forBus: 0)
let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16,
sampleRate: 16000,
channels: 1,
interleaved: false)!
let converter = AVAudioConverter(from: recordingFormat, to: targetFormat)!
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak self] buffer, _ in
// Debug mic RMS
if let ch = buffer.floatChannelData?[0] {
let frameCount = Int(buffer.frameLength)
let samples = UnsafeBufferPointer(start: ch, count: frameCount)
let rms = sqrt(samples.map { $0 * $0 }.reduce(0, +) / Float(frameCount))
print("🎤 Mic RMS: \(rms)")
}
self?.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic)
}
audioEngine.prepare()
do {
try audioEngine.start()
connectToDeepgram(source: .mic)
} catch {
print("Failed to start microphone tap: \(error)")
}
}
private func cleanupAudioEngine() {
if audioEngine.isRunning {
audioEngine.stop()
}
// Reset the audio engine - this safely removes all taps
audioEngine.reset()
}
private func startSystemAudioCapture() async {
print("🎧 Starting system audio capture...")
do {
// Request screen capture permission
let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
// Exclude self to avoid feedback
let excludedApps = content.applications.filter { app in
Bundle.main.bundleIdentifier == app.bundleIdentifier
}
guard let display = content.displays.first else {
print("❌ No display found")
startMicrophoneOnly()
return
}
// Create filter
let filter = SCContentFilter(display: display, excludingApplications: excludedApps, exceptingWindows: [])
// Configure stream
let configuration = SCStreamConfiguration()
configuration.width = 2 // Minimal video settings
configuration.height = 2
configuration.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale.max)
configuration.capturesAudio = true
configuration.sampleRate = 48000
configuration.channelCount = 2
// Create stream
let stream = SCStream(filter: filter, configuration: configuration, delegate: self)
// Add stream output for audio processing
try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: .global(qos: .userInitiated))
// Add a minimal screen output so SCStream doesn't complain about missing video output
try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: .global(qos: .userInitiated))
// Start capture
try await stream.startCapture()
// Store reference
self.stream = stream
DispatchQueue.main.async {
self.isRecording = true
}
connectToDeepgram(source: .system)
print("✅ System audio capture started successfully")
} catch {
print("❌ Failed to start system audio capture: \(error)")
if case SCStreamError.userDeclined = error {
print("📍 Permission denied. User needs to enable screen recording in System Settings.")
}
// Fallback to microphone only
startMicrophoneOnly()
}
}
func stopRecording() {
print("Stopping recording...")
// Stop system audio capture
if let stream = stream {
stream.stopCapture()
self.stream = nil
}
// Stop microphone capture
cleanupAudioEngine()
// Close WebSocket
micSocketTask?.cancel(with: .normalClosure, reason: nil)
micSocketTask = nil
systemSocketTask?.cancel(with: .normalClosure, reason: nil)
systemSocketTask = nil
DispatchQueue.main.async {
self.isRecording = false
}
print("Recording stopped")
}
private func processAudioBuffer(_ buffer: AVAudioPCMBuffer, converter: AVAudioConverter, targetFormat: AVAudioFormat, source: AudioSource) {
var processBuffer = buffer
// Convert to target format (16kHz int16 mono) in a single step AVAudioConverter will handle resampling and downmixing
let outputFrameCapacity = AVAudioFrameCount(Double(processBuffer.frameLength) * targetFormat.sampleRate / processBuffer.format.sampleRate)
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outputFrameCapacity) else {
return
}
var error: NSError?
let status = converter.convert(to: outputBuffer, error: &error) { _, outStatus in
outStatus.pointee = .haveData
return processBuffer
}
guard status == .haveData, error == nil else {
return
}
// Convert to Data for Deepgram
guard let channelData = outputBuffer.int16ChannelData?[0] else {
return
}
let frameCount = Int(outputBuffer.frameLength)
let data = Data(bytes: channelData, count: frameCount * 2)
sendAudioData(data, source: source)
}
private func connectToDeepgram(source: AudioSource) {
guard let key = KeychainHelper.shared.get(forKey: "deepgramKey"), !key.isEmpty else {
print("❌ No Deepgram key found")
return
}
let session = URLSession(configuration: .default)
var request = URLRequest(url: deepgramURL)
request.addValue("Token \(key)", forHTTPHeaderField: "Authorization")
let task = session.webSocketTask(with: request)
task.resume()
switch source {
case .mic:
micSocketTask = task
case .system:
systemSocketTask = task
}
receiveMessage(for: source)
print("🌐 Connected to Deepgram (\(source))")
}
private func receiveMessage(for source: AudioSource) {
let task: URLSessionWebSocketTask? = (source == .mic) ? micSocketTask : systemSocketTask
task?.receive { [weak self] result in
switch result {
case .success(let message):
switch message {
case .string(let text):
self?.parseTranscription(text, source: source)
case .data:
break
@unknown default:
break
}
self?.receiveMessage(for: source) // continue loop
case .failure(let error):
print("❌ Receive error (\(source)): \(error)")
// Attempt reconnect if still recording
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
if self?.isRecording == true {
self?.connectToDeepgram(source: source)
}
}
}
}
}
private func parseTranscription(_ text: String, source: AudioSource) {
guard let data = text.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String, type == "Results",
let channel = json["channel"] as? [String: Any],
let alternatives = channel["alternatives"] as? [[String: Any]],
let alt = alternatives.first,
let transcriptText = alt["transcript"] as? String,
!transcriptText.isEmpty else { return }
let prefix = (source == .mic) ? "[MIC]" : "[SYS]"
DispatchQueue.main.async {
if let isFinal = json["is_final"] as? Bool, isFinal {
self.transcript += "\(prefix) \(transcriptText) "
}
}
}
private func sendAudioData(_ data: Data, source: AudioSource) {
let task: URLSessionWebSocketTask? = (source == .mic) ? micSocketTask : systemSocketTask
guard let socket = task, socket.state == .running else { return }
socket.send(.data(data)) { error in
if let error = error {
print("❌ Send error (\(source)): \(error)")
}
}
}
}
// MARK: - SCStreamDelegate & SCStreamOutput
extension AudioManager: SCStreamDelegate, SCStreamOutput {
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
guard type == .audio else { return }
guard sampleBuffer.isValid else { return }
// Convert CMSampleBuffer to AVAudioPCMBuffer
guard let pcmBuffer = sampleBuffer.asPCMBuffer else { return }
// Create converter for Deepgram format
let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16,
sampleRate: 16000,
channels: 1,
interleaved: false)!
guard let converter = AVAudioConverter(from: pcmBuffer.format, to: targetFormat) else { return }
processAudioBuffer(pcmBuffer, converter: converter, targetFormat: targetFormat, source: .system)
}
func stream(_ stream: SCStream, didStopWithError error: Error) {
print("❌ Stream stopped with error: \(error)")
DispatchQueue.main.async {
self.stream = nil
self.isRecording = false
}
}
}
// MARK: - CMSampleBuffer Extension
extension CMSampleBuffer {
var asPCMBuffer: AVAudioPCMBuffer? {
try? self.withAudioBufferList { audioBufferList, _ -> AVAudioPCMBuffer? in
guard let absd = self.formatDescription?.audioStreamBasicDescription else { return nil }
guard let format = AVAudioFormat(standardFormatWithSampleRate: absd.mSampleRate, channels: absd.mChannelsPerFrame) else { return nil }
return AVAudioPCMBuffer(pcmFormat: format, bufferListNoCopy: audioBufferList.unsafePointer)
}
}
}
+70
View File
@@ -0,0 +1,70 @@
// KeychainHelper.swift
// Secure storage helper for API keys and sensitive data
import Foundation
import Security
/// Manages secure storage of sensitive data using the macOS Keychain
class KeychainHelper {
static let shared = KeychainHelper()
private let serviceName = "owen.notetaker"
private init() {}
/// Saves a string value to the keychain
/// - Parameters:
/// - value: The string value to save
/// - key: The key to save the value under
/// - Returns: True if the save was successful, false otherwise
func save(_ value: String, forKey key: String) -> Bool {
guard let data = value.data(using: .utf8) else { return false }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrService as String: serviceName
]
// Delete any existing item
SecItemDelete(query as CFDictionary)
// Add the new item
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
/// Retrieves a string value from the keychain
/// - Parameter key: The key to retrieve the value for
/// - Returns: The string value if found, nil otherwise
func get(forKey key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrService as String: serviceName,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true
]
var item: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
/// Deletes a value from the keychain
/// - Parameter key: The key to delete
/// - Returns: True if the deletion was successful, false otherwise
func delete(forKey key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrService as String: serviceName
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess
}
}
@@ -0,0 +1,112 @@
// LocalStorageManager.swift
// Handles local storage of meetings and app data
import Foundation
/// Manages local file storage for meetings and app data
class LocalStorageManager {
static let shared = LocalStorageManager()
private let documentsDirectory: URL
private let meetingsDirectory: URL
private init() {
// Get the app's documents directory
documentsDirectory = FileManager.default.urls(for: .documentDirectory,
in: .userDomainMask).first!
// Create meetings subdirectory
meetingsDirectory = documentsDirectory.appendingPathComponent("Meetings")
// Ensure directory exists
try? FileManager.default.createDirectory(at: meetingsDirectory,
withIntermediateDirectories: true)
}
// MARK: - Meeting Management
/// Saves a meeting to local storage
/// - Parameter meeting: The meeting to save
/// - Returns: True if successful, false otherwise
func saveMeeting(_ meeting: Meeting) -> Bool {
let fileURL = meetingsDirectory.appendingPathComponent("\(meeting.id.uuidString).json")
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(meeting)
try data.write(to: fileURL)
print("✅ Saved meeting: \(meeting.id)")
return true
} catch {
print("❌ Failed to save meeting: \(error)")
return false
}
}
/// Loads all meetings from local storage
/// - Returns: Array of meetings, sorted by date (newest first)
func loadMeetings() -> [Meeting] {
do {
let fileURLs = try FileManager.default.contentsOfDirectory(at: meetingsDirectory,
includingPropertiesForKeys: nil)
.filter { $0.pathExtension == "json" }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let meetings = fileURLs.compactMap { url -> Meeting? in
guard let data = try? Data(contentsOf: url),
let meeting = try? decoder.decode(Meeting.self, from: data) else {
print("⚠️ Failed to decode meeting at: \(url)")
return nil
}
return meeting
}
return meetings.sorted { $0.date > $1.date }
} catch {
print("❌ Failed to load meetings: \(error)")
return []
}
}
/// Deletes a meeting from local storage
/// - Parameter meeting: The meeting to delete
/// - Returns: True if successful, false otherwise
func deleteMeeting(_ meeting: Meeting) -> Bool {
let fileURL = meetingsDirectory.appendingPathComponent("\(meeting.id.uuidString).json")
do {
try FileManager.default.removeItem(at: fileURL)
print("✅ Deleted meeting: \(meeting.id)")
return true
} catch {
print("❌ Failed to delete meeting: \(error)")
return false
}
}
// MARK: - Settings Management
/// Saves non-sensitive settings to local storage
/// - Parameter settings: The settings to save (sensitive data should use Keychain)
func saveSettings(_ settings: Settings) -> Bool {
// For now, all settings are stored in Keychain
// This method is here for future non-sensitive settings
return true
}
/// Gets the app's documents directory URL
var documentsDirectoryURL: URL {
documentsDirectory
}
/// Gets the meetings directory URL
var meetingsDirectoryURL: URL {
meetingsDirectory
}
}
+21
View File
@@ -0,0 +1,21 @@
import Foundation
struct Meeting: Codable, Identifiable, Hashable {
let id: UUID
let date: Date
var transcript: String
var userNotes: String
var generatedNotes: String
init(id: UUID = UUID(),
date: Date = Date(),
transcript: String = "",
userNotes: String = "",
generatedNotes: String = "") {
self.id = id
self.date = date
self.transcript = transcript
self.userNotes = userNotes
self.generatedNotes = generatedNotes
}
}
+20
View File
@@ -0,0 +1,20 @@
import Foundation
struct Settings: Codable {
var deepgramKey: String
var openAIKey: String
var userBlurb: String
var systemPrompt: String
static let defaultSystemPrompt = "Generate concise meeting notes from the transcript and any additional notes provided. Focus on key points, action items, and decisions made."
init(deepgramKey: String = "",
openAIKey: String = "",
userBlurb: String = "",
systemPrompt: String = Settings.defaultSystemPrompt) {
self.deepgramKey = deepgramKey
self.openAIKey = openAIKey
self.userBlurb = userBlurb
self.systemPrompt = systemPrompt
}
}
+59
View File
@@ -0,0 +1,59 @@
// NotesGenerator.swift
// Handles AI-powered note generation using OpenAI
import Foundation
/// Generates meeting notes using OpenAI API
class NotesGenerator {
static let shared = NotesGenerator()
private init() {}
/// Generates meeting notes from transcript and user notes
/// - Parameters:
/// - transcript: The meeting transcript
/// - userNotes: Additional notes from the user
/// - userBlurb: Information about the user for context
/// - systemPrompt: The system prompt for AI generation
/// - Returns: Generated meeting notes
func generateNotes(transcript: String,
userNotes: String,
userBlurb: String,
systemPrompt: String) async throws -> String {
// TODO: Implement OpenAI API integration
// For now, return a placeholder
// Simulate API delay
try await Task.sleep(nanoseconds: 2_000_000_000)
// Placeholder response
return """
# Meeting Notes
## Summary
This is a placeholder for AI-generated meeting notes.
## Key Points
- Transcript length: \(transcript.count) characters
- User notes provided: \(!userNotes.isEmpty)
## Action Items
- Implement OpenAI API integration
- Add proper prompt engineering
## Next Steps
Once OpenAI API is integrated, this will provide comprehensive meeting notes based on the transcript and any additional context provided.
"""
}
/// Validates if OpenAI API key is configured
/// - Returns: True if API key exists, false otherwise
func isConfigured() -> Bool {
guard let key = KeychainHelper.shared.get(forKey: "openAIKey"),
!key.isEmpty else {
return false
}
return true
}
}
-298
View File
@@ -1,298 +0,0 @@
// SimpleAudioManager.swift
// Audio manager for microphone and system audio capture
import AVFoundation
import Foundation
import SwiftUI
import ScreenCaptureKit
class SimpleAudioManager: NSObject, ObservableObject {
@Published var transcript = ""
@Published var isRecording = false
@Published var captureSystemAudio = true
private var audioEngine = AVAudioEngine()
private var systemAudioEngine = AVAudioEngine()
private var webSocketTask: URLSessionWebSocketTask?
private let deepgramURL = URL(string: "wss://api.deepgram.com/v1/listen?encoding=linear16&sample_rate=16000&channels=1&interim_results=true")!
// ScreenCaptureKit properties
private var stream: SCStream?
private var streamConfiguration: SCStreamConfiguration?
func startRecording() {
print("Starting recording...")
if captureSystemAudio {
startSystemAudioCapture()
} else {
startMicrophoneOnly()
}
}
private func startMicrophoneOnly() {
print("Starting microphone-only recording...")
// Set up audio engine for microphone capture
let inputNode = audioEngine.inputNode
let recordingFormat = inputNode.outputFormat(forBus: 0)
// Convert to the format Deepgram expects: 16kHz, 16-bit, mono
let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16,
sampleRate: 16000,
channels: 1,
interleaved: false)!
let converter = AVAudioConverter(from: recordingFormat, to: targetFormat)!
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak self] (buffer, time) in
self?.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat)
}
audioEngine.prepare()
do {
try audioEngine.start()
DispatchQueue.main.async {
self.isRecording = true
}
connectToDeepgram()
print("Microphone recording started")
} catch {
print("Failed to start audio engine: \(error)")
}
}
private func startSystemAudioCapture() {
print("Starting system audio capture...")
Task {
do {
// Request screen recording permission
let canRecord = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
// Get the main display
guard let display = canRecord.displays.first else {
print("❌ No display found")
return
}
// Configure stream for audio only
let configuration = SCStreamConfiguration()
configuration.capturesAudio = true
configuration.excludesCurrentProcessAudio = false
configuration.sampleRate = 16000
configuration.channelCount = 1
// Create content filter (we only want audio, but need to include display)
let filter = SCContentFilter(display: display, excludingWindows: [])
// Create and start stream
let stream = SCStream(filter: filter, configuration: configuration, delegate: self)
try await stream.startCapture()
self.stream = stream
self.streamConfiguration = configuration
DispatchQueue.main.async {
self.isRecording = true
}
connectToDeepgram()
print("System audio capture started")
} catch {
print("❌ Failed to start system audio capture: \(error)")
// Fallback to microphone only
startMicrophoneOnly()
}
}
}
func stopRecording() {
print("Stopping recording...")
// Stop system audio capture
if let stream = stream {
Task {
try? await stream.stopCapture()
}
self.stream = nil
}
// Stop microphone capture
if audioEngine.isRunning {
audioEngine.stop()
audioEngine.inputNode.removeTap(onBus: 0)
}
// Close WebSocket
webSocketTask?.cancel(with: .normalClosure, reason: nil)
webSocketTask = nil
DispatchQueue.main.async {
self.isRecording = false
}
print("Recording stopped")
}
private func processAudioBuffer(_ buffer: AVAudioPCMBuffer, converter: AVAudioConverter, targetFormat: AVAudioFormat) {
// Create output buffer for conversion
let outputFrameCapacity = AVAudioFrameCount(Double(buffer.frameLength) * targetFormat.sampleRate / buffer.format.sampleRate)
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outputFrameCapacity) else {
print("❌ Failed to create output buffer")
return
}
var error: NSError?
let status = converter.convert(to: outputBuffer, error: &error) { _, outStatus in
outStatus.pointee = .haveData
return buffer
}
guard status == .haveData, error == nil else {
print("❌ Audio conversion failed: \(error?.localizedDescription ?? "Unknown error")")
return
}
// Convert to Data for Deepgram
guard let channelData = outputBuffer.int16ChannelData?[0] else {
print("❌ No channel data available")
return
}
let frameCount = Int(outputBuffer.frameLength)
let data = Data(bytes: channelData, count: frameCount * 2)
print("🎵 Sending audio data: \(data.count) bytes")
sendAudioData(data)
}
private func connectToDeepgram() {
guard let key = KeychainHelper.shared.get(forKey: "deepgramKey"), !key.isEmpty else {
print("❌ No Deepgram key found")
return
}
print("🔑 Using Deepgram key: \(String(key.prefix(8)))...")
let session = URLSession(configuration: .default)
var request = URLRequest(url: deepgramURL)
request.addValue("Token \(key)", forHTTPHeaderField: "Authorization")
webSocketTask = session.webSocketTask(with: request)
webSocketTask?.resume()
receiveMessage()
print("🌐 Attempting to connect to Deepgram...")
}
private func sendAudioData(_ data: Data) {
guard webSocketTask?.state == .running else {
print("⚠️ WebSocket not connected, skipping audio data")
return
}
webSocketTask?.send(.data(data)) { error in
if let error = error {
print("❌ Send error: \(error)")
}
}
}
private func receiveMessage() {
webSocketTask?.receive { [weak self] result in
switch result {
case .success(let message):
switch message {
case .string(let text):
print("📝 Received: \(text)")
self?.parseTranscription(text)
case .data:
break
@unknown default:
break
}
self?.receiveMessage() // Continue receiving
case .failure(let error):
print("❌ Receive error: \(error)")
// Try to reconnect after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
if self?.isRecording == true {
print("🔄 Attempting to reconnect...")
self?.connectToDeepgram()
}
}
}
}
}
private func parseTranscription(_ text: String) {
guard let data = text.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String, type == "Results",
let channel = json["channel"] as? [String: Any],
let alternatives = channel["alternatives"] as? [[String: Any]],
let alt = alternatives.first,
let transcript = alt["transcript"] as? String,
!transcript.isEmpty else { return }
DispatchQueue.main.async {
if let isFinal = json["is_final"] as? Bool, isFinal {
self.transcript += transcript + " "
}
}
}
}
// MARK: - SCStreamDelegate
extension SimpleAudioManager: SCStreamDelegate {
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
guard type == .audio else { return }
// Convert CMSampleBuffer to AVAudioPCMBuffer
guard let formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer) else { return }
let audioStreamBasicDescription = CMAudioFormatDescriptionGetStreamBasicDescription(formatDescription)
guard let audioStreamBasicDescription = audioStreamBasicDescription else { return }
let format = AVAudioFormat(streamDescription: audioStreamBasicDescription)
guard let format = format else { return }
let frameCount = CMSampleBufferGetNumSamples(sampleBuffer)
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(frameCount)) else { return }
// Copy audio data
var blockBuffer: CMBlockBuffer?
var audioBufferListOut = AudioBufferList()
var audioBufferListSize = MemoryLayout<AudioBufferList>.size
let status = CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(
sampleBuffer,
bufferListSizeNeededOut: &audioBufferListSize,
bufferListOut: &audioBufferListOut,
bufferListSize: audioBufferListSize,
blockBufferAllocator: nil,
blockBufferMemoryAllocator: nil,
flags: 0,
blockBufferOut: &blockBuffer
)
guard status == noErr else { return }
buffer.frameLength = AVAudioFrameCount(frameCount)
// Convert to target format and send
let targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16,
sampleRate: 16000,
channels: 1,
interleaved: false)!
let converter = AVAudioConverter(from: format, to: targetFormat)!
processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat)
}
func stream(_ stream: SCStream, didStopWithError error: Error) {
print("❌ Stream stopped with error: \(error)")
}
}
@@ -0,0 +1,35 @@
import Foundation
import SwiftUI
@MainActor
class MeetingListViewModel: ObservableObject {
@Published var meetings: [Meeting] = []
@Published var isLoading = false
@Published var errorMessage: String?
init() {
loadMeetings()
}
func loadMeetings() {
isLoading = true
errorMessage = nil
DispatchQueue.main.async { [weak self] in
self?.meetings = LocalStorageManager.shared.loadMeetings()
self?.isLoading = false
}
}
func deleteMeeting(_ meeting: Meeting) {
meetings.removeAll { $0.id == meeting.id }
_ = LocalStorageManager.shared.deleteMeeting(meeting)
}
func createNewMeeting() -> Meeting {
let newMeeting = Meeting()
meetings.insert(newMeeting, at: 0)
_ = LocalStorageManager.shared.saveMeeting(newMeeting)
return newMeeting
}
}
@@ -0,0 +1,85 @@
import Foundation
import SwiftUI
import Combine
@MainActor
class MeetingViewModel: ObservableObject {
@Published var meeting: Meeting
@Published var isGeneratingNotes = false
@Published var errorMessage: String?
@Published var isRecording = false
private let audioManager = AudioManager()
private var cancellables = Set<AnyCancellable>()
init(meeting: Meeting = Meeting()) {
self.meeting = meeting
// Update meeting transcript when audio manager transcript changes
audioManager.$transcript
.sink { [weak self] newTranscript in
self?.meeting.transcript = newTranscript
}
.store(in: &cancellables)
// Update isRecording when audio manager recording state changes
audioManager.$isRecording
.sink { [weak self] isRecording in
self?.isRecording = isRecording
}
.store(in: &cancellables)
}
var captureSystemAudio: Bool {
get { audioManager.captureSystemAudio }
set { audioManager.captureSystemAudio = newValue }
}
func startRecording() {
audioManager.startRecording()
}
func stopRecording() {
audioManager.stopRecording()
saveMeeting()
}
func generateNotes() async {
isGeneratingNotes = true
errorMessage = nil
do {
// Load settings for generation
let userBlurb = KeychainHelper.shared.get(forKey: "userBlurb") ?? ""
let systemPrompt = KeychainHelper.shared.get(forKey: "systemPrompt") ?? Settings.defaultSystemPrompt
// Generate notes
meeting.generatedNotes = try await NotesGenerator.shared.generateNotes(
transcript: meeting.transcript,
userNotes: meeting.userNotes,
userBlurb: userBlurb,
systemPrompt: systemPrompt
)
saveMeeting()
} catch {
errorMessage = "Failed to generate notes: \(error.localizedDescription)"
}
isGeneratingNotes = false
}
func saveMeeting() {
_ = LocalStorageManager.shared.saveMeeting(meeting)
}
func copyTranscript() {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(meeting.transcript, forType: .string)
}
func copyNotes() {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(meeting.generatedNotes, forType: .string)
}
}
@@ -0,0 +1,51 @@
import Foundation
import SwiftUI
@MainActor
class SettingsViewModel: ObservableObject {
@Published var settings: Settings
@Published var isSaving = false
@Published var saveSuccessful = false
@Published var errorMessage: String?
init() {
self.settings = Settings()
loadSettings()
}
func loadSettings() {
// Load API keys from Keychain
settings.deepgramKey = KeychainHelper.shared.get(forKey: "deepgramKey") ?? ""
settings.openAIKey = KeychainHelper.shared.get(forKey: "openAIKey") ?? ""
settings.userBlurb = KeychainHelper.shared.get(forKey: "userBlurb") ?? ""
settings.systemPrompt = KeychainHelper.shared.get(forKey: "systemPrompt") ?? Settings.defaultSystemPrompt
}
func saveSettings() {
isSaving = true
errorMessage = nil
saveSuccessful = false
// Save to Keychain
let deepgramSaved = KeychainHelper.shared.save(settings.deepgramKey, forKey: "deepgramKey")
let openAISaved = KeychainHelper.shared.save(settings.openAIKey, forKey: "openAIKey")
let blurbSaved = KeychainHelper.shared.save(settings.userBlurb, forKey: "userBlurb")
let promptSaved = KeychainHelper.shared.save(settings.systemPrompt, forKey: "systemPrompt")
if deepgramSaved && openAISaved && blurbSaved && promptSaved {
saveSuccessful = true
// Show success briefly
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.saveSuccessful = false
}
} else {
errorMessage = "Failed to save some settings. Please try again."
}
isSaving = false
}
func resetToDefaults() {
settings.systemPrompt = Settings.defaultSystemPrompt
}
}
+136
View File
@@ -0,0 +1,136 @@
import SwiftUI
struct MeetingDetailView: View {
@StateObject private var viewModel: MeetingViewModel
init(meeting: Meeting) {
self._viewModel = StateObject(wrappedValue: MeetingViewModel(meeting: meeting))
}
var body: some View {
VStack(spacing: 20) {
// Recording Controls
VStack(spacing: 12) {
Toggle("Capture System Audio", isOn: $viewModel.captureSystemAudio)
.disabled(viewModel.isRecording)
HStack {
Button {
viewModel.startRecording()
} label: {
Label("Start Recording", systemImage: "record.circle")
}
.disabled(viewModel.isRecording)
.buttonStyle(.borderedProminent)
Button {
viewModel.stopRecording()
} label: {
Label("Stop Recording", systemImage: "stop.circle")
}
.disabled(!viewModel.isRecording)
.buttonStyle(.bordered)
}
if viewModel.isRecording {
Label("Recording...", systemImage: "dot.radiowaves.left.and.right")
.foregroundColor(.red)
}
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(10)
// Transcript and Notes
HStack(spacing: 20) {
// Transcript Column
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Live Transcript")
.font(.headline)
Spacer()
Button {
viewModel.copyTranscript()
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
}
ScrollView {
Text(viewModel.meeting.transcript.isEmpty ? "Transcript will appear here..." : viewModel.meeting.transcript)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.frame(maxHeight: .infinity)
.background(Color.gray.opacity(0.05))
.cornerRadius(8)
}
// Notes Column
VStack(alignment: .leading, spacing: 8) {
Text("Your Notes")
.font(.headline)
TextEditor(text: $viewModel.meeting.userNotes)
.font(.body)
.scrollContentBackground(.hidden)
.background(Color.gray.opacity(0.05))
.cornerRadius(8)
}
}
.frame(maxHeight: 300)
// Generated Notes Section
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Generated Notes")
.font(.headline)
Spacer()
if viewModel.isGeneratingNotes {
ProgressView()
.scaleEffect(0.7)
} else {
Button {
Task {
await viewModel.generateNotes()
}
} label: {
Label("Generate", systemImage: "sparkles")
}
.disabled(viewModel.meeting.transcript.isEmpty)
Button {
viewModel.copyNotes()
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
.disabled(viewModel.meeting.generatedNotes.isEmpty)
}
}
TextEditor(text: $viewModel.meeting.generatedNotes)
.font(.body)
.scrollContentBackground(.hidden)
.background(Color.gray.opacity(0.05))
.cornerRadius(8)
.frame(minHeight: 150)
}
}
.padding()
.navigationTitle("Meeting Notes")
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") {
viewModel.errorMessage = nil
}
} message: {
Text(viewModel.errorMessage ?? "")
}
}
}
#Preview {
NavigationStack {
MeetingDetailView(meeting: Meeting())
}
}
+78
View File
@@ -0,0 +1,78 @@
import SwiftUI
struct MeetingListView: View {
@StateObject private var viewModel = MeetingListViewModel()
@State private var navigationPath = NavigationPath()
var body: some View {
NavigationStack(path: $navigationPath) {
List {
if viewModel.meetings.isEmpty && !viewModel.isLoading {
ContentUnavailableView(
"No Meetings Yet",
systemImage: "mic.slash",
description: Text("Start a new meeting to begin transcribing")
)
} else {
ForEach(viewModel.meetings) { meeting in
NavigationLink(value: meeting) {
MeetingRowView(meeting: meeting)
}
}
.onDelete { indexSet in
for index in indexSet {
viewModel.deleteMeeting(viewModel.meetings[index])
}
}
}
}
.navigationTitle("Meetings")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
let newMeeting = viewModel.createNewMeeting()
navigationPath.append(newMeeting)
} label: {
Label("New Meeting", systemImage: "plus")
}
}
}
.navigationDestination(for: Meeting.self) { meeting in
MeetingDetailView(meeting: meeting)
}
}
.overlay {
if viewModel.isLoading {
ProgressView("Loading meetings...")
}
}
}
}
struct MeetingRowView: View {
let meeting: Meeting
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(meeting.date, style: .date)
.font(.headline)
if !meeting.transcript.isEmpty {
Text(meeting.transcript)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
} else {
Text("No transcript")
.font(.caption)
.foregroundColor(.secondary)
.italic()
}
}
.padding(.vertical, 4)
}
}
#Preview {
MeetingListView()
}
+96
View File
@@ -0,0 +1,96 @@
import SwiftUI
struct SettingsView: View {
@StateObject private var viewModel = SettingsViewModel()
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
Section("API Keys") {
SecureField("Deepgram API Key", text: $viewModel.settings.deepgramKey)
.textFieldStyle(.roundedBorder)
SecureField("OpenAI API Key", text: $viewModel.settings.openAIKey)
.textFieldStyle(.roundedBorder)
}
Section("Personal Information") {
VStack(alignment: .leading) {
Text("About Yourself")
.font(.caption)
.foregroundColor(.secondary)
TextEditor(text: $viewModel.settings.userBlurb)
.frame(minHeight: 80)
.scrollContentBackground(.hidden)
.background(Color.gray.opacity(0.05))
.cornerRadius(8)
}
}
Section("AI Generation") {
VStack(alignment: .leading) {
HStack {
Text("System Prompt")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Button("Reset to Default") {
viewModel.resetToDefaults()
}
.font(.caption)
}
TextEditor(text: $viewModel.settings.systemPrompt)
.frame(minHeight: 120)
.scrollContentBackground(.hidden)
.background(Color.gray.opacity(0.05))
.cornerRadius(8)
}
}
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
viewModel.saveSettings()
}
.disabled(viewModel.isSaving)
}
}
.overlay {
if viewModel.saveSuccessful {
VStack {
Spacer()
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Settings saved successfully")
}
.padding()
.background(Color.green.opacity(0.1))
.cornerRadius(10)
.padding()
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") {
viewModel.errorMessage = nil
}
} message: {
Text(viewModel.errorMessage ?? "")
}
}
}
}
#Preview {
SettingsView()
}
+6 -6
View File
@@ -4,17 +4,17 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.owen.notetaker</string>
</array>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.screen-capture</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.owen.notetaker</string>
</array>
<key>com.apple.security.device.screen-recording</key>
<true/>
</dict>
</plist>
+2
View File
@@ -12,6 +12,8 @@ struct notetakerApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.frame(minWidth: 800, minHeight: 600)
}
.windowResizability(.contentSize)
}
}