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
23 changes: 23 additions & 0 deletions OpenWhisp/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1814,6 +1814,29 @@ class AppState: ObservableObject {
return applyConfig(bundle)
}

/// Built-in config packs shipped in the app bundle (Resources/packs/*.json).
/// Parsing/sorting/dedup is done by the pure `ConfigPack.parseAll`; this just
/// reads the directory. Bad/too-new pack files are skipped, not fatal.
func bundledConfigPacks() -> [ConfigPack] {
guard let dir = Bundle.main.resourceURL?.appendingPathComponent("packs", isDirectory: true),
let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else {
return []
}
let files: [(name: String, data: Data)] = names
.filter { $0.hasSuffix(".json") }
.compactMap { name in
guard let data = try? Data(contentsOf: dir.appendingPathComponent(name)) else { return nil }
return (name, data)
}
return ConfigPack.parseAll(files)
}

/// Apply a pack's bundle (same path as a hand-imported file). Returns the summary.
@discardableResult
func applyPack(_ pack: ConfigPack) -> String {
applyConfig(pack.bundle)
}

// MARK: - Model

func availableModelsList() -> [(name: String, label: String, size: String)] {
Expand Down
11 changes: 11 additions & 0 deletions OpenWhisp/Resources/packs/concise-telegram.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"id": "openwhisp.concise-telegram",
"name": "Punchy Telegram Posts",
"description": "Swap the \"make a Telegram post\" prompt for a punchier, very-short variant with light emoji.",
"bundle": {
"schemaVersion": 1,
"prompts": {
"telegramPost": "Rewrite the user's text as a punchy, very short Telegram post. Cut it to the essentials — one or two tight sentences, no fluff. Keep the original language, meaning, names, URLs, and any code. Add at most one or two relevant emoji. Return only the post text, with no preamble, quotes, or markdown code fences."
}
}
}
26 changes: 26 additions & 0 deletions OpenWhisp/Resources/packs/developer-vocabulary.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"id": "openwhisp.developer-vocabulary",
"name": "Developer Vocabulary",
"description": "Bias whisper toward common dev tools, languages, and brand names, plus fix-ups for terms it routinely mishears.",
"bundle": {
"schemaVersion": 1,
"vocabulary": {
"terms": [
"Claude", "Anthropic", "OpenWhisp", "kubectl", "Kubernetes", "Docker",
"Postgres", "PostgreSQL", "Redis", "nginx", "GraphQL", "TypeScript",
"JavaScript", "Python", "Rust", "Swift", "SwiftUI", "Xcode", "GitHub",
"GitLab", "OAuth", "JWT", "Webhook", "API", "CLI", "SDK", "JSON", "YAML",
"Terraform", "Ansible", "CI/CD", "Grafana", "Prometheus", "async", "await"
],
"substitutions": [
{ "id": "00000000-0000-0000-0000-0000000000d1", "from": "clod code", "to": "Claude Code" },
{ "id": "00000000-0000-0000-0000-0000000000d2", "from": "cube cuddle", "to": "kubectl" },
{ "id": "00000000-0000-0000-0000-0000000000d3", "from": "cuber netties", "to": "Kubernetes" },
{ "id": "00000000-0000-0000-0000-0000000000d4", "from": "post gres", "to": "Postgres" },
{ "id": "00000000-0000-0000-0000-0000000000d5", "from": "java script", "to": "JavaScript" },
{ "id": "00000000-0000-0000-0000-0000000000d6", "from": "type script", "to": "TypeScript" },
{ "id": "00000000-0000-0000-0000-0000000000d7", "from": "git hub", "to": "GitHub" }
]
}
}
}
70 changes: 70 additions & 0 deletions OpenWhisp/Services/ConfigPack.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Foundation

/// A named, shippable configuration pack — a `ConfigBundle` plus display
/// metadata. Packs are config-only (no code): curated vocabularies, prompt
/// setups, or per-app profile sets a user can apply in one click. OpenWhisp
/// bundles a few built-ins; the same format works for community/user packs.
///
/// On apply, a pack goes through the exact same `ConfigBundle` import path as a
/// hand-exported file, so it only touches the sections it contains. Foundation-
/// only and `Codable`, so it lives in OpenWhispCore and the parsing/listing logic
/// is unit-tested.
struct ConfigPack: Codable, Equatable, Identifiable {
/// Stable identifier (used for the SwiftUI list and de-duplication).
var id: String
/// Short display name, e.g. "Developer Vocabulary".
var name: String
/// One-line description of what applying it does.
var packDescription: String
/// The configuration this pack applies.
var bundle: ConfigBundle

enum CodingKeys: String, CodingKey {
case id, name
case packDescription = "description"
case bundle
}

/// What applying this pack will change, derived from the bundle ("2 vocab
/// terms, 1 prompt"). Surfaced in the UI so the action is never opaque.
var contentsSummary: String { bundle.summary }

enum DecodeError: Error, Equatable {
case malformed(String)
case unsupportedVersion(found: Int, supported: Int)
}

/// Decode a single pack, validating the embedded bundle's schema version the
/// same way a hand-imported file is validated.
static func decode(from data: Data) throws -> ConfigPack {
let pack: ConfigPack
do {
pack = try JSONDecoder().decode(ConfigPack.self, from: data)
} catch {
throw DecodeError.malformed(error.localizedDescription)
}
guard pack.bundle.schemaVersion <= ConfigBundle.currentSchemaVersion else {
throw DecodeError.unsupportedVersion(
found: pack.bundle.schemaVersion,
supported: ConfigBundle.currentSchemaVersion
)
}
return pack
}

/// Parse a directory listing of `(filename, data)` pairs into valid packs,
/// sorted by name and de-duplicated by id (first occurrence wins). Malformed
/// or too-new files are skipped rather than failing the whole listing — a bad
/// pack must never hide the good ones. Pure, so it's unit-tested without IO.
static func parseAll(_ files: [(name: String, data: Data)]) -> [ConfigPack] {
var seen = Set<String>()
var packs: [ConfigPack] = []
for file in files.sorted(by: { $0.name < $1.name }) {
guard let pack = try? decode(from: file.data) else { continue }
guard !seen.contains(pack.id) else { continue }
seen.insert(pack.id)
packs.append(pack)
}
return packs.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
}
35 changes: 35 additions & 0 deletions OpenWhisp/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ struct SettingsView: View {

// Config import/export feedback.
@State private var configMessage: String = ""
// Built-in config packs (loaded from the app bundle on appear).
@State private var configPacks: [ConfigPack] = []

private var isCustomOpenAIModel: Bool {
openAIModelIsCustom || !SettingsView.presetOpenAIModels.contains(appState.openAIModel)
Expand Down Expand Up @@ -749,12 +751,45 @@ struct SettingsView: View {
Spacer()
}

if !configPacks.isEmpty {
Divider()
Text("Packs")
.font(.subheadline).fontWeight(.medium)
Text("One‑click config bundles. Applying a pack changes only the sections it contains.")
.font(.caption)
.foregroundColor(.secondary)

ForEach(configPacks) { pack in
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 2) {
Text(pack.name).fontWeight(.medium)
Text(pack.packDescription)
.font(.caption).foregroundColor(.secondary)
Text("Applies: \(pack.contentsSummary)")
.font(.caption2).foregroundColor(.secondary)
}
Spacer()
Button("Apply") {
let summary = appState.applyPack(pack)
configMessage = "Applied “\(pack.name)” (\(summary))."
}
}
.padding(8)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.08)))
}
}

if !configMessage.isEmpty {
Text(configMessage)
.font(.caption)
.foregroundColor(.secondary)
}
}
.onAppear {
if configPacks.isEmpty {
configPacks = appState.bundledConfigPacks()
}
}
}

private func exportConfig() {
Expand Down
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ let package = Package(
"TranscriptionEngine.swift",
"AudioCapture.swift",
"LiveChunkPipeline.swift",
"ConfigBundle.swift"
"ConfigBundle.swift",
"ConfigPack.swift"
]
),
.testTarget(
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +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).
- **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). Also includes one‑click **config packs** (e.g. a developer vocabulary) — small bundled config files; the same JSON format works for your own.

---

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

final class ConfigPackTests: XCTestCase {

private func packJSON(id: String, name: String, version: Int = 1, terms: [String] = ["x"]) -> Data {
let termsJSON = terms.map { "\"\($0)\"" }.joined(separator: ",")
return Data("""
{
"id": "\(id)",
"name": "\(name)",
"description": "desc for \(name)",
"bundle": { "schemaVersion": \(version), "vocabulary": { "terms": [\(termsJSON)], "substitutions": [] } }
}
""".utf8)
}

// MARK: decode

func testDecodeValidPack() throws {
let pack = try ConfigPack.decode(from: packJSON(id: "a", name: "Alpha"))
XCTAssertEqual(pack.id, "a")
XCTAssertEqual(pack.name, "Alpha")
XCTAssertEqual(pack.packDescription, "desc for Alpha")
XCTAssertEqual(pack.contentsSummary, "1 vocab term")
}

func testDescriptionMapsFromJSONKey() throws {
// The Swift property is `packDescription` but the JSON key is "description".
let pack = try ConfigPack.decode(from: packJSON(id: "a", name: "Alpha"))
XCTAssertEqual(pack.packDescription, "desc for Alpha")
}

func testRejectsPackWithNewerBundleVersion() {
XCTAssertThrowsError(try ConfigPack.decode(from: packJSON(id: "a", name: "A", version: 999))) { error in
XCTAssertEqual(error as? ConfigPack.DecodeError,
.unsupportedVersion(found: 999, supported: ConfigBundle.currentSchemaVersion))
}
}

func testMalformedPackThrows() {
XCTAssertThrowsError(try ConfigPack.decode(from: Data("garbage".utf8))) { error in
guard case ConfigPack.DecodeError.malformed = error else {
return XCTFail("expected .malformed, got \(error)")
}
}
}

// MARK: parseAll

func testParseAllSortsByName() {
let files = [
(name: "z.json", data: packJSON(id: "1", name: "Zebra")),
(name: "a.json", data: packJSON(id: "2", name: "Apple")),
(name: "m.json", data: packJSON(id: "3", name: "Mango"))
]
XCTAssertEqual(ConfigPack.parseAll(files).map(\.name), ["Apple", "Mango", "Zebra"])
}

func testParseAllDedupesByIdFirstWins() {
// Two files share id "dup"; sorted by filename, "a.json" wins.
let files = [
(name: "b.json", data: packJSON(id: "dup", name: "Second")),
(name: "a.json", data: packJSON(id: "dup", name: "First"))
]
let packs = ConfigPack.parseAll(files)
XCTAssertEqual(packs.count, 1)
XCTAssertEqual(packs.first?.name, "First")
}

func testParseAllSkipsBadFilesButKeepsGoodOnes() {
let files = [
(name: "good.json", data: packJSON(id: "g", name: "Good")),
(name: "broken.json", data: Data("not json".utf8)),
(name: "future.json", data: packJSON(id: "f", name: "Future", version: 999))
]
// A malformed or too-new pack must never hide the valid ones.
XCTAssertEqual(ConfigPack.parseAll(files).map(\.name), ["Good"])
}

func testParseAllEmpty() {
XCTAssertTrue(ConfigPack.parseAll([]).isEmpty)
}

// MARK: Shipped packs are valid

/// Loads the actual pack files shipped in OpenWhisp/Resources/packs and
/// asserts they decode — so an authoring typo fails CI, not a user's import.
func testShippedPacksAreValid() throws {
// Walk up from this test file to the repo root, then into Resources/packs.
let here = URL(fileURLWithPath: #filePath)
let repoRoot = here
.deletingLastPathComponent() // OpenWhispCoreTests
.deletingLastPathComponent() // Tests
.deletingLastPathComponent() // repo root
let packsDir = repoRoot
.appendingPathComponent("OpenWhisp/Resources/packs", isDirectory: true)

let names = try FileManager.default.contentsOfDirectory(atPath: packsDir.path)
.filter { $0.hasSuffix(".json") }
XCTAssertFalse(names.isEmpty, "expected shipped packs in \(packsDir.path)")

var ids = Set<String>()
for name in names {
let data = try Data(contentsOf: packsDir.appendingPathComponent(name))
let pack = try ConfigPack.decode(from: data) // throws on any authoring error
XCTAssertFalse(pack.name.isEmpty, "\(name): empty name")
XCTAssertFalse(pack.packDescription.isEmpty, "\(name): empty description")
XCTAssertNotEqual(pack.contentsSummary, "nothing", "\(name): pack applies nothing")
XCTAssertTrue(ids.insert(pack.id).inserted, "\(name): duplicate pack id \(pack.id)")
}
}
}
5 changes: 4 additions & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,10 @@ seam, plus optionally lifting more orchestration out of `AppState`.
`ConfigBundle` (OpenWhispCore, unit-tested); Settings → Backup & Sharing.
Partial bundles supported (import touches only the sections present), which is
the foundation packs reuse.
- Ship rule/prompt **packs** (config-only).
- ✅ **Config packs** (config-only) — named `ConfigPack` bundles shipped in
Resources/packs (Developer Vocabulary, Punchy Telegram Posts), applied one-click
via the same import path. Pure `ConfigPack.parseAll` (sort/dedup/skip-bad) is
unit-tested, and a test loads the shipped packs so an authoring typo fails CI.
- Add `ScriptPostProcessor` (stdin→stdout, opt-in, timeout, fail-open).

### Phase 4 — Output & ergonomics
Expand Down
Loading