Skip to content

Commit a4ae260

Browse files
authored
Merge pull request #12 from STRML/swift-full-rewrite
Swift rewrite of the dictation client (hark serve / hark agent). Merged with a merge commit rather than a squash so the branch history stays an ancestor of main — the integration fixes in dy/swift-rewrite-integration are built on this head.
2 parents 8d12f7b + c7a163f commit a4ae260

27 files changed

Lines changed: 3030 additions & 0 deletions

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,9 @@ config.toml
1818

1919
# Local migration notes, not part of the project.
2020
LAPTOP-MIGRATION.local.md
21+
22+
# SwiftPM build artifacts (the Swift rewrite).
23+
swift/.build/
24+
25+
# Generated app bundle (rebuilt by swift/Packaging/build-app.sh).
26+
swift/Packaging/Hark.app/

docs/superpowers/specs/2026-07-31-native-client-design.md

Lines changed: 591 additions & 0 deletions
Large diffs are not rendered by default.

swift/Package.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// swift-tools-version: 5.9
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "Hark",
6+
platforms: [.macOS(.v13)],
7+
products: [
8+
.executable(name: "hark", targets: ["hark"])
9+
],
10+
targets: [
11+
// Pure / server-logic core: config, key, sanitize, wav, server, whisper.
12+
.target(name: "HarkCore"),
13+
// macOS agent: hotkey, recorder, dictate client, controller. Kept out
14+
// of HarkCore so tests run headless in CI (no TCC, no hardware).
15+
.executableTarget(name: "hark", dependencies: ["HarkCore"]),
16+
.testTarget(name: "HarkCoreTests", dependencies: ["HarkCore"]),
17+
]
18+
)

swift/Packaging/Info.plist

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>CFBundleIdentifier</key>
6+
<string>com.drycodeworks.hark-agent</string>
7+
<key>CFBundleName</key>
8+
<string>Hark</string>
9+
<key>CFBundleExecutable</key>
10+
<string>hark</string>
11+
<key>CFBundlePackageType</key>
12+
<string>APPL</string>
13+
<key>CFBundleShortVersionString</key>
14+
<string>0.1.0</string>
15+
<key>CFBundleVersion</key>
16+
<string>1</string>
17+
<key>LSMinimumSystemVersion</key>
18+
<string>13.0</string>
19+
<key>LSUIElement</key>
20+
<true/>
21+
<key>NSMicrophoneUsageDescription</key>
22+
<string>Hark records your voice while you hold the dictate hotkey, to transcribe it.</string>
23+
<key>NSLocalNetworkUsageDescription</key>
24+
<string>Hark talks to the transcription server on your local network.</string>
25+
<key>NSAppTransportSecurity</key>
26+
<dict>
27+
<key>NSAllowsLocalNetworking</key>
28+
<true/>
29+
</dict>
30+
</dict>
31+
</plist>

swift/Packaging/Makefile

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Makefile for Hark — build, package, sign, verify, clean, test.
2+
# Rules use tabs (this file is written with tab-indented recipe lines).
3+
4+
PKG_DIR := Packaging
5+
APP := $(PKG_DIR)/Hark.app
6+
7+
.PHONY: build app sign verify clean test
8+
9+
## build: compile the release binary
10+
build:
11+
swift build -c release
12+
13+
## app: run the packaging script (build release + assemble + sign Hark.app)
14+
app:
15+
./$(PKG_DIR)/build-app.sh
16+
17+
## sign: re-sign the assembled bundle (ad-hoc unless HARK_SIGN_IDENTITY is set)
18+
sign:
19+
codesign --force --deep --sign "$${HARK_SIGN_IDENTITY:--}" \
20+
--options runtime \
21+
--entitlements $(PKG_DIR)/entitlements.plist \
22+
$(APP)
23+
24+
## verify: codesign verification of the bundle
25+
verify:
26+
codesign --verify --deep --strict --verbose=2 $(APP)
27+
28+
## clean: remove build products and the assembled bundle
29+
clean:
30+
rm -rf .build $(APP)
31+
32+
## test: run the test suite
33+
test:
34+
swift test

swift/Packaging/build-app.sh

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/bin/bash
2+
# build-app.sh — build the release binary and assemble a signed Hark.app bundle.
3+
#
4+
# ./Packaging/build-app.sh
5+
#
6+
# Signing identity comes from $HARK_SIGN_IDENTITY if set (e.g. a Developer ID),
7+
# otherwise ad-hoc ("-") is used. Default is ad-hoc — fine for local dev.
8+
set -euo pipefail
9+
10+
PKG_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11+
ROOT_DIR="$(cd "${PKG_DIR}/.." && pwd)"
12+
APP="${PKG_DIR}/Hark.app"
13+
14+
SIGN_IDENTITY="${HARK_SIGN_IDENTITY:-}"
15+
16+
echo "==> Building release binary"
17+
cd "${ROOT_DIR}"
18+
swift build -c release
19+
20+
echo "==> Assembling ${APP}"
21+
rm -rf "${APP}"
22+
mkdir -p "${APP}/Contents/MacOS" "${APP}/Contents/Resources"
23+
24+
cp ".build/release/hark" "${APP}/Contents/MacOS/hark"
25+
cp "${PKG_DIR}/Info.plist" "${APP}/Contents/Info.plist"
26+
cp "${PKG_DIR}/entitlements.plist" "${APP}/Contents/Resources/"
27+
chmod +x "${APP}/Contents/MacOS/hark"
28+
29+
echo "==> Signing"
30+
if [[ -n "${SIGN_IDENTITY}" ]]; then
31+
echo " using identity: ${SIGN_IDENTITY}"
32+
codesign --force --deep --sign "${SIGN_IDENTITY}" \
33+
--options runtime \
34+
--entitlements "${PKG_DIR}/entitlements.plist" \
35+
"${APP}"
36+
else
37+
echo " using ad-hoc signature (-)"
38+
codesign --force --deep --sign - \
39+
--options runtime \
40+
--entitlements "${PKG_DIR}/entitlements.plist" \
41+
"${APP}"
42+
fi
43+
44+
echo "==> Verifying"
45+
codesign --verify --deep --strict --verbose=2 "${APP}"
46+
echo "==> Entitlements embedded in binary:"
47+
codesign -d --entitlements :- "${APP}" 2>/dev/null || true
48+
echo "==> done. Bundle at: ${APP}"

swift/Packaging/entitlements.plist

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>com.apple.security.device.audio-input</key>
6+
<true/>
7+
<key>com.apple.security.automation.apple-events</key>
8+
<false/>
9+
<key>com.apple.security.cs.allow-jit</key>
10+
<false/>
11+
</dict>
12+
</plist>
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import Foundation
2+
3+
/// Server deployment configuration — a port of `src/hark/config.py`.
4+
///
5+
/// Defaults describe the single-machine setup: bind to loopback, expose
6+
/// nothing. `~/.config/hark/config.toml` (outside the repo) overrides, exactly
7+
/// as it does for the Python server. A missing file is the ordinary case; a
8+
/// malformed one raises rather than silently falling back to defaults (that
9+
/// could bind the service somewhere the user did not ask for).
10+
public struct HarkConfig {
11+
public var bindHost: String // server.bind, default 127.0.0.1
12+
public var harkPort: Int // server.port, default 8911
13+
public var whisperHost: String // always 127.0.0.1 (not configurable)
14+
public var whisperPort: Int // whisper.port, default 8910
15+
public var modelPath: String // whisper.model
16+
public var vocabPrompt: String // whisper.prompt
17+
public var silenceRMSThreshold: Double // audio.silence_rms_threshold, default 150.0
18+
public var transcribeTimeout: Double // 60
19+
public var connectTimeout: Double // 5
20+
21+
public init(bindHost: String = "127.0.0.1",
22+
harkPort: Int = 8911,
23+
whisperPort: Int = 8910,
24+
modelPath: String = "~/.local/share/whisper-cpp/ggml-large-v3-turbo.bin",
25+
vocabPrompt: String = "",
26+
silenceRMSThreshold: Double = 150.0,
27+
transcribeTimeout: Double = 60.0,
28+
connectTimeout: Double = 5.0) {
29+
self.bindHost = bindHost
30+
self.harkPort = harkPort
31+
self.whisperHost = "127.0.0.1"
32+
self.whisperPort = whisperPort
33+
self.modelPath = modelPath
34+
self.vocabPrompt = vocabPrompt
35+
self.silenceRMSThreshold = silenceRMSThreshold
36+
self.transcribeTimeout = transcribeTimeout
37+
self.connectTimeout = connectTimeout
38+
}
39+
40+
public static var configFile: URL {
41+
if let env = ProcessInfo.processInfo.environment["HARK_CONFIG"] {
42+
return URL(fileURLWithPath: env)
43+
}
44+
return FileManager.default.homeDirectoryForCurrentUser
45+
.appendingPathComponent(".config/hark/config.toml")
46+
}
47+
48+
public static func load() -> HarkConfig {
49+
var cfg = HarkConfig()
50+
guard let text = try? String(contentsOf: configFile, encoding: .utf8) else {
51+
return cfg
52+
}
53+
do {
54+
let parsed = try MiniTOML.parse(text)
55+
if let s = parsed.section("server") {
56+
cfg.bindHost = s.string("bind") ?? cfg.bindHost
57+
cfg.harkPort = s.int("port") ?? cfg.harkPort
58+
}
59+
if let w = parsed.section("whisper") {
60+
cfg.whisperPort = w.int("port") ?? cfg.whisperPort
61+
cfg.modelPath = w.string("model") ?? cfg.modelPath
62+
cfg.vocabPrompt = w.string("prompt") ?? cfg.vocabPrompt
63+
}
64+
if let a = parsed.section("audio") {
65+
cfg.silenceRMSThreshold = a.double("silence_rms_threshold") ?? cfg.silenceRMSThreshold
66+
}
67+
} catch {
68+
fatalError("malformed hark config at \(configFile.path): \(error)")
69+
}
70+
return cfg
71+
}
72+
73+
public var whisperURL: String { "http://\(whisperHost):\(whisperPort)" }
74+
}
75+
76+
// MARK: - Minimal TOML subset parser
77+
78+
/// Parses just the shape `config.example.toml` uses: `[section]` headers,
79+
/// `key = value` pairs (string / integer / float / boolean), `#` comments.
80+
/// Unknown keys and sections are ignored so a config written for the Python
81+
/// server keeps working. Values that do not parse raise, never silently drop.
82+
struct MiniTOML {
83+
struct Section {
84+
let name: String
85+
var values: [String: String] = [:]
86+
func string(_ key: String) -> String? { values[key] }
87+
func int(_ key: String) -> Int? {
88+
guard let v = values[key], let i = Int(v) else { return nil }
89+
return i
90+
}
91+
func double(_ key: String) -> Double? {
92+
guard let v = values[key] else { return nil }
93+
if let i = Int(v) { return Double(i) }
94+
return Double(v)
95+
}
96+
}
97+
var sections: [String: Section] = [:]
98+
99+
func section(_ name: String) -> Section? { sections[name] }
100+
101+
static func parse(_ text: String) throws -> MiniTOML {
102+
var result = MiniTOML()
103+
var current: String = ""
104+
for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) {
105+
var line = String(rawLine)
106+
if let hash = line.firstIndex(of: "#") { line = String(line[..<hash]) }
107+
line = line.trimmingCharacters(in: .whitespaces)
108+
if line.isEmpty { continue }
109+
if line.hasPrefix("[") && line.hasSuffix("]") {
110+
current = String(line.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces)
111+
result.sections[current] = Section(name: current)
112+
continue
113+
}
114+
guard let eq = line.firstIndex(of: "=") else {
115+
throw TOMLError.malformed(line)
116+
}
117+
let key = String(line[..<eq]).trimmingCharacters(in: .whitespaces)
118+
var value = String(line[line.index(after: eq)...]).trimmingCharacters(in: .whitespaces)
119+
if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 {
120+
value = String(value.dropFirst().dropLast())
121+
}
122+
result.sections[current, default: Section(name: current)].values[key] = value
123+
}
124+
return result
125+
}
126+
}
127+
128+
enum TOMLError: Error, CustomStringConvertible {
129+
case malformed(String)
130+
var description: String { "malformed TOML line: \(self)" }
131+
}

0 commit comments

Comments
 (0)