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
49 changes: 49 additions & 0 deletions OpenWhisp/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1765,6 +1765,55 @@ class AppState: ObservableObject {
textOutput.setClipboard(entry.text)
}

// MARK: - Config import / export

/// Snapshot the user-editable config (profiles, vocabulary, prompts) as a
/// portable bundle. History and secrets are intentionally excluded.
func exportConfig() -> ConfigBundle {
ConfigBundle(
profiles: profiles,
vocabulary: vocabulary,
prompts: ConfigBundle.Prompts(
telegramPost: telegramPostPrompt,
voiceCommandWakeWord: voiceCommandWakeWord
)
)
}

/// Apply the sections present in `bundle` to the live settings (each setter's
/// didSet persists it). Sections absent from the bundle are left untouched, so
/// a vocab-only pack only changes vocabulary. Returns the bundle's summary for
/// user feedback.
@discardableResult
func applyConfig(_ bundle: ConfigBundle) -> String {
if let importedProfiles = bundle.profiles {
profiles = importedProfiles
}
if let importedVocab = bundle.vocabulary {
vocabulary = importedVocab
}
if let prompts = bundle.prompts {
if let tg = prompts.telegramPost { telegramPostPrompt = tg }
if let wake = prompts.voiceCommandWakeWord { voiceCommandWakeWord = wake }
}
return bundle.summary
}

/// Write the current config to `url` as JSON. Throws on encode/write failure.
func exportConfig(to url: URL) throws {
let data = try exportConfig().jsonData()
try data.write(to: url, options: .atomic)
}

/// Read, validate, and apply a config bundle from `url`. Throws
/// `ConfigBundle.DecodeError` on malformed/too-new data. Returns the summary.
@discardableResult
func importConfig(from url: URL) throws -> String {
let data = try Data(contentsOf: url)
let bundle = try ConfigBundle.decode(from: data)
return applyConfig(bundle)
}

// MARK: - Model

func availableModelsList() -> [(name: String, label: String, size: String)] {
Expand Down
103 changes: 103 additions & 0 deletions OpenWhisp/Services/ConfigBundle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import Foundation

/// A portable, versioned snapshot of OpenWhisp's user-editable configuration —
/// per-app profiles, custom vocabulary, and named-command prompts. This is the
/// interchange format for **export/import** (back up or move your setup between
/// machines) and the basis for shippable config **packs** (Phase 3).
///
/// Foundation-only and `Codable`, so it lives in OpenWhispCore and the
/// serialization/merge logic is unit-tested. AppState maps its `@Published`
/// settings to/from this type; the bundle itself knows nothing about AppKit.
///
/// **Tolerant by design:** every section is optional on decode, so a partial
/// bundle (e.g. a vocab-only pack) round-trips cleanly and forward-compatible
/// fields a newer app adds won't break an older importer.
struct ConfigBundle: Codable, Equatable {
/// Schema version for forward/backward compatibility. Bump on breaking change.
var schemaVersion: Int
/// Per-app override profiles (nil = section absent, distinct from empty list).
var profiles: [AppProfile]?
/// Custom vocabulary (terms + substitutions).
var vocabulary: Vocabulary?
/// Named-command prompts.
var prompts: Prompts?

static let currentSchemaVersion = 1

/// User-editable prompt/config strings for the voice-command system.
struct Prompts: Codable, Equatable {
/// Prompt used for the built-in "make a Telegram post" action.
var telegramPost: String?
/// Wake word that introduces a spoken command (e.g. "computer").
var voiceCommandWakeWord: String?
}

init(
schemaVersion: Int = ConfigBundle.currentSchemaVersion,
profiles: [AppProfile]? = nil,
vocabulary: Vocabulary? = nil,
prompts: Prompts? = nil
) {
self.schemaVersion = schemaVersion
self.profiles = profiles
self.vocabulary = vocabulary
self.prompts = prompts
}

// MARK: - Serialization

enum DecodeError: Error, Equatable {
/// The data wasn't a valid ConfigBundle JSON object.
case malformed(String)
/// The bundle's schemaVersion is newer than this app understands.
case unsupportedVersion(found: Int, supported: Int)
}

/// Encode to pretty, stable JSON suitable for a file the user might read/edit.
func jsonData() throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
return try encoder.encode(self)
}

/// Decode a bundle, rejecting a schema from the future (we can't know what a
/// higher version means). Lower/equal versions decode tolerantly.
static func decode(from data: Data) throws -> ConfigBundle {
let bundle: ConfigBundle
do {
bundle = try JSONDecoder().decode(ConfigBundle.self, from: data)
} catch {
throw DecodeError.malformed(error.localizedDescription)
}
guard bundle.schemaVersion <= currentSchemaVersion else {
throw DecodeError.unsupportedVersion(
found: bundle.schemaVersion, supported: currentSchemaVersion
)
}
return bundle
}

// MARK: - Summary

/// Human-readable summary of what a bundle contains, for import confirmation
/// and pack listings. Empty sections are omitted.
var summary: String {
var parts: [String] = []
if let profiles, !profiles.isEmpty {
parts.append("\(profiles.count) app profile\(profiles.count == 1 ? "" : "s")")
}
if let vocabulary {
let t = vocabulary.terms.count
let s = vocabulary.substitutions.count
if t > 0 { parts.append("\(t) vocab term\(t == 1 ? "" : "s")") }
if s > 0 { parts.append("\(s) substitution\(s == 1 ? "" : "s")") }
}
if let prompts {
var promptCount = 0
if let p = prompts.telegramPost, !p.isEmpty { promptCount += 1 }
if let w = prompts.voiceCommandWakeWord, !w.isEmpty { promptCount += 1 }
if promptCount > 0 { parts.append("\(promptCount) prompt\(promptCount == 1 ? "" : "s")") }
}
return parts.isEmpty ? "nothing" : parts.joined(separator: ", ")
}
}
60 changes: 60 additions & 0 deletions OpenWhisp/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ struct SettingsView: View {
@State private var newProfileName: String = ""
@State private var profileAddMessage: String = ""

// Config import/export feedback.
@State private var configMessage: String = ""

private var isCustomOpenAIModel: Bool {
openAIModelIsCustom || !SettingsView.presetOpenAIModels.contains(appState.openAIModel)
}
Expand Down Expand Up @@ -92,6 +95,7 @@ struct SettingsView: View {
liveChunkAdvancedSection
profilesSection
historySection
backupSection
whisperSection
permissionsSection
statusSection
Expand Down Expand Up @@ -733,6 +737,62 @@ struct SettingsView: View {
return when
}

private var backupSection: some View {
settingsSection("Backup & Sharing") {
Text("Export your per‑app profiles, vocabulary, and command prompts to a JSON file you can back up, edit, or share. Import replaces only the sections present in the file.")
.font(.caption)
.foregroundColor(.secondary)

HStack {
Button("Export Config…") { exportConfig() }
Button("Import Config…") { importConfig() }
Spacer()
}

if !configMessage.isEmpty {
Text(configMessage)
.font(.caption)
.foregroundColor(.secondary)
}
}
}

private func exportConfig() {
let panel = NSSavePanel()
panel.allowedContentTypes = [.json]
panel.nameFieldStringValue = "openwhisp-config.json"
panel.prompt = "Export"
panel.message = "Save your OpenWhisp config (profiles, vocabulary, prompts)"
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
try appState.exportConfig(to: url)
configMessage = "Exported \(appState.exportConfig().summary) to \(url.lastPathComponent)."
} catch {
configMessage = "Export failed: \(error.localizedDescription)"
}
}

private func importConfig() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowedContentTypes = [.json]
panel.prompt = "Import"
panel.message = "Choose an OpenWhisp config file to import"
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let summary = try appState.importConfig(from: url)
configMessage = "Imported \(summary)."
} catch let ConfigBundle.DecodeError.unsupportedVersion(found, supported) {
configMessage = "This file needs a newer OpenWhisp (config v\(found); this app supports up to v\(supported))."
} catch ConfigBundle.DecodeError.malformed {
configMessage = "Couldn't read that file — it doesn't look like an OpenWhisp config."
} catch {
configMessage = "Import failed: \(error.localizedDescription)"
}
}

// Inherit-aware bindings mapping the synthetic "__inherit__" tag to nil.
private func profileLanguageBinding(_ profile: Binding<AppProfile>) -> Binding<String> {
Binding(get: { profile.wrappedValue.language ?? "__inherit__" },
Expand Down
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ let package = Package(
"HotkeyControlling.swift",
"TranscriptionEngine.swift",
"AudioCapture.swift",
"LiveChunkPipeline.swift"
"LiveChunkPipeline.swift",
"ConfigBundle.swift"
]
),
.testTarget(
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ Settings has a **Basic** and an **Advanced** tab.
**Advanced**

- Engine (Whisper vs Apple Speech), raw model picker + paths, live‑chunk tuning, whisper.cpp backend (CLI vs warm server), per‑app modes, history, permissions, diagnostics.
- **Backup & Sharing** — export your per‑app profiles, vocabulary, and command prompts to a JSON file you can back up, hand‑edit, or share; import replaces only the sections present in the file (so a vocab‑only file touches just your vocabulary).

---

Expand Down
133 changes: 133 additions & 0 deletions Tests/OpenWhispCoreTests/ConfigBundleTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import XCTest
@testable import OpenWhispCore

final class ConfigBundleTests: XCTestCase {

private func sampleProfiles() -> [AppProfile] {
[
AppProfile(appBundleID: "com.tinyspeck.slackmacgap", displayName: "Slack",
language: "en", outputMode: "liveChunks", aiCleanupEnabled: false),
AppProfile(appBundleID: "com.apple.mail", displayName: "Mail", aiCleanupEnabled: true)
]
}
private func sampleVocab() -> Vocabulary {
Vocabulary(terms: ["Claude", "OpenWhisp"],
substitutions: [Vocabulary.Substitution(from: "clod code", to: "Claude Code")])
}

// MARK: Round-trip

func testFullRoundTrip() throws {
let original = ConfigBundle(
profiles: sampleProfiles(),
vocabulary: sampleVocab(),
prompts: .init(telegramPost: "Make it punchy", voiceCommandWakeWord: "computer")
)
let decoded = try ConfigBundle.decode(from: original.jsonData())
XCTAssertEqual(decoded, original)
}

func testEmptyBundleRoundTrips() throws {
let original = ConfigBundle()
let decoded = try ConfigBundle.decode(from: original.jsonData())
XCTAssertEqual(decoded, original)
XCTAssertNil(decoded.profiles)
XCTAssertNil(decoded.vocabulary)
XCTAssertNil(decoded.prompts)
}

func testPartialBundleRoundTrips() throws {
// A vocab-only "pack" — other sections absent, must stay nil (not empty).
// Reuse the SAME vocab instance: Substitution ids are random per call, and
// the round-trip must preserve them.
let vocab = sampleVocab()
let original = ConfigBundle(vocabulary: vocab)
let decoded = try ConfigBundle.decode(from: original.jsonData())
XCTAssertNil(decoded.profiles)
XCTAssertNil(decoded.prompts)
XCTAssertEqual(decoded.vocabulary, vocab)
}

func testSchemaVersionDefaultsToCurrent() {
XCTAssertEqual(ConfigBundle().schemaVersion, ConfigBundle.currentSchemaVersion)
}

// MARK: Tolerant decode

func testDecodesJSONMissingAllOptionalSections() throws {
let json = Data(#"{"schemaVersion": 1}"#.utf8)
let bundle = try ConfigBundle.decode(from: json)
XCTAssertEqual(bundle.schemaVersion, 1)
XCTAssertNil(bundle.profiles)
XCTAssertNil(bundle.vocabulary)
}

func testDecodeIgnoresUnknownForwardCompatibleKeys() throws {
// A field a future app added must not break an older importer.
let json = Data(#"{"schemaVersion": 1, "futureFeature": {"x": 1}, "prompts": {"telegramPost": "hi"}}"#.utf8)
let bundle = try ConfigBundle.decode(from: json)
XCTAssertEqual(bundle.prompts?.telegramPost, "hi")
}

// MARK: Version guard

func testRejectsNewerSchemaVersion() {
let json = Data(#"{"schemaVersion": 999}"#.utf8)
XCTAssertThrowsError(try ConfigBundle.decode(from: json)) { error in
XCTAssertEqual(error as? ConfigBundle.DecodeError,
.unsupportedVersion(found: 999, supported: ConfigBundle.currentSchemaVersion))
}
}

func testAcceptsOlderSchemaVersion() throws {
let json = Data(#"{"schemaVersion": 0, "prompts": {"voiceCommandWakeWord": "hey"}}"#.utf8)
let bundle = try ConfigBundle.decode(from: json)
XCTAssertEqual(bundle.prompts?.voiceCommandWakeWord, "hey")
}

// MARK: Malformed

func testMalformedJSONThrowsMalformed() {
let json = Data("not json at all".utf8)
XCTAssertThrowsError(try ConfigBundle.decode(from: json)) { error in
guard case ConfigBundle.DecodeError.malformed = error else {
return XCTFail("expected .malformed, got \(error)")
}
}
}

// MARK: Summary

func testSummaryListsNonEmptySections() {
let bundle = ConfigBundle(
profiles: sampleProfiles(), // 2 profiles
vocabulary: sampleVocab(), // 2 terms, 1 sub
prompts: .init(telegramPost: "x", voiceCommandWakeWord: "y") // 2 prompts
)
XCTAssertEqual(bundle.summary, "2 app profiles, 2 vocab terms, 1 substitution, 2 prompts")
}

func testSummarySingularGrammar() {
let bundle = ConfigBundle(
profiles: [sampleProfiles()[0]],
vocabulary: Vocabulary(terms: ["one"],
substitutions: [Vocabulary.Substitution(from: "a", to: "b")]),
prompts: .init(telegramPost: "x", voiceCommandWakeWord: nil)
)
XCTAssertEqual(bundle.summary, "1 app profile, 1 vocab term, 1 substitution, 1 prompt")
}

func testSummaryOmitsEmptyAndWhitespaceSections() {
// Empty profiles list, empty vocab, blank prompt strings -> "nothing".
let bundle = ConfigBundle(
profiles: [],
vocabulary: .empty,
prompts: .init(telegramPost: "", voiceCommandWakeWord: "")
)
XCTAssertEqual(bundle.summary, "nothing")
}

func testSummaryOfEmptyBundle() {
XCTAssertEqual(ConfigBundle().summary, "nothing")
}
}
Loading
Loading