Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions OpenWhisp/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ class AppState: ObservableObject {
}

@Published var openAIAPIKey: String {
didSet { KeychainStore.save(openAIAPIKey, key: "openAIAPIKey") }
didSet { secretStore.save(openAIAPIKey, key: "openAIAPIKey") }
}

@Published var openAIModel: String {
Expand Down Expand Up @@ -264,6 +264,10 @@ class AppState: ObservableObject {

// MARK: - Services

/// Platform secret backend (Keychain on macOS). Injected so the secret
/// logic is testable and a port can swap the implementation. See SecretStore.
let secretStore: SecretStore

var audioRecorder: AudioRecorder!
var whisperEngine: WhisperEngine!
var appleSpeechEngine: AppleSpeechEngine!
Expand Down Expand Up @@ -399,7 +403,8 @@ class AppState: ObservableObject {
Self.languageDisplayName(for: language)
}

private init() {
private init(secretStore: SecretStore = KeychainStore()) {
self.secretStore = secretStore
let savedWhisperBinaryPath = UserDefaults.standard.string(forKey: "whisperBinaryPath") ?? ""
whisperBinaryPath = Self.preferredWhisperCLIPath(savedPath: savedWhisperBinaryPath)

Expand Down Expand Up @@ -437,12 +442,12 @@ class AppState: ObservableObject {
translationTargetLanguage = UserDefaults.standard.string(forKey: "translationTargetLanguage") ?? "en"
// One-time migration: move any legacy plaintext key out of UserDefaults into the
// Keychain. didSet does not fire during init, so the keychain write must be explicit.
let keychainKey = KeychainStore.read(key: "openAIAPIKey")
let keychainKey = secretStore.read(key: "openAIAPIKey")
if let keychainKey, !keychainKey.isEmpty {
openAIAPIKey = keychainKey
} else if let legacyKey = UserDefaults.standard.string(forKey: "openAIAPIKey"),
!legacyKey.isEmpty {
KeychainStore.save(legacyKey, key: "openAIAPIKey")
secretStore.save(legacyKey, key: "openAIAPIKey")
UserDefaults.standard.removeObject(forKey: "openAIAPIKey")
openAIAPIKey = legacyKey
} else {
Expand Down
25 changes: 14 additions & 11 deletions OpenWhisp/Services/KeychainStore.swift
Original file line number Diff line number Diff line change
@@ -1,41 +1,44 @@
import Foundation
import Security

enum KeychainStore {
/// macOS Keychain-backed `SecretStore`. The Apple-only `Security` (`SecItem*`)
/// calls are isolated here; AppState depends on the `SecretStore` protocol, not
/// this type, so a port supplies its own backend.
final class KeychainStore: SecretStore {
private static let service = "com.openwhisp.app"
static func read(key: String) -> String? {

func read(key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrService as String: Self.service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]

var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func save(_ value: String, key: String) {

func save(_ value: String, key: String) {
let data = Data(value.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrService as String: Self.service,
kSecAttrAccount as String: key
]

if value.isEmpty {
SecItemDelete(query as CFDictionary)
return
}

let attributes: [String: Any] = [
kSecValueData as String: data
]

let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
var newItem = query
Expand Down
37 changes: 37 additions & 0 deletions OpenWhisp/Services/SecretStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation

/// Platform-agnostic secret storage seam (Phase 2.5 core extraction).
///
/// AppState talks to this protocol instead of a concrete keychain so the
/// secret-handling logic is testable (inject an in-memory store) and a future
/// non-macOS port can supply its own backend (Windows Credential Manager /
/// DPAPI, libsecret, etc.) without touching call sites.
///
/// Contract: `save` with an empty string deletes the entry; `read` returns nil
/// when no value is stored.
protocol SecretStore: AnyObject {
func read(key: String) -> String?
func save(_ value: String, key: String)
}

/// A non-persistent `SecretStore` used by tests and as a safe fallback. Pure
/// Foundation, so it lives in OpenWhispCore and is exercised by `swift test`.
final class InMemorySecretStore: SecretStore {
private var storage: [String: String] = [:]

init(seed: [String: String] = [:]) {
storage = seed
}

func read(key: String) -> String? {
storage[key]
}

func save(_ value: String, key: String) {
if value.isEmpty {
storage.removeValue(forKey: key)
} else {
storage[key] = value
}
}
}
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ let package = Package(
"SecureFieldPolicy.swift",
"DownloadProgressFormatter.swift",
"PrivacyStatus.swift",
"TranscriptCleaner.swift"
"TranscriptCleaner.swift",
"SecretStore.swift"
]
),
.testTarget(
Expand Down
48 changes: 48 additions & 0 deletions Tests/OpenWhispCoreTests/SecretStoreTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import XCTest
@testable import OpenWhispCore

/// Exercises the `SecretStore` contract via the in-memory implementation. The
/// macOS Keychain backend (KeychainStore) can't be unit-tested without a login
/// keychain, but it conforms to the same protocol and shares this contract.
final class SecretStoreTests: XCTestCase {
func testReadReturnsNilForUnknownKey() {
let store = InMemorySecretStore()
XCTAssertNil(store.read(key: "missing"))
}

func testSaveThenReadRoundTrips() {
let store = InMemorySecretStore()
store.save("sk-secret", key: "openAIAPIKey")
XCTAssertEqual(store.read(key: "openAIAPIKey"), "sk-secret")
}

func testSaveOverwritesExistingValue() {
let store = InMemorySecretStore()
store.save("old", key: "k")
store.save("new", key: "k")
XCTAssertEqual(store.read(key: "k"), "new")
}

func testSavingEmptyStringDeletesEntry() {
let store = InMemorySecretStore(seed: ["k": "value"])
XCTAssertEqual(store.read(key: "k"), "value")
store.save("", key: "k")
XCTAssertNil(store.read(key: "k"))
}

func testKeysAreIndependent() {
let store = InMemorySecretStore()
store.save("a", key: "first")
store.save("b", key: "second")
XCTAssertEqual(store.read(key: "first"), "a")
XCTAssertEqual(store.read(key: "second"), "b")
store.save("", key: "first")
XCTAssertNil(store.read(key: "first"))
XCTAssertEqual(store.read(key: "second"), "b")
}

func testSeedInitialization() {
let store = InMemorySecretStore(seed: ["preloaded": "x"])
XCTAssertEqual(store.read(key: "preloaded"), "x")
}
}
5 changes: 3 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,12 +236,13 @@ makes the macOS app more testable **and** converts "port the whole app" into

- Define platform protocols and dependency-inject them into `AppState` instead of
constructing concrete types:
- ✅ `SecretStore` (Keychain → Credential Manager) — extracted; `AppState`
injects it, `InMemorySecretStore` covers the logic in `swift test`.
- `TranscriptionEngine` (whisper CLI/server, Apple Speech)
- `AudioCapture` (AVAudioEngine today → WASAPI on Windows)
- `TextInserter` (AX + Cmd+V → UI Automation + SendInput)
- `HotkeyMonitor` (CGEventTap → RegisterHotKey)
- `MenuBarUI` / app shell, `Permissions`, `SecretStore` (Keychain → Credential
Manager), `LaunchAtLogin`
- `MenuBarUI` / app shell, `Permissions`, `LaunchAtLogin`
- Move the pipeline + post-processing + profile/vocab application out of `AppState`
into `OpenWhispCore` (the existing SwiftPM target), behind those protocols.
- Keep the macOS implementations as the first set of adapters. A Windows app then
Expand Down
Loading