Skip to content

Commit 651bd66

Browse files
committed
feat(openwispr): openwispr mac application
0 parents  commit 651bd66

25 files changed

+2418
-0
lines changed

.github/workflows/ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
verify:
9+
runs-on: macos-15
10+
11+
steps:
12+
- name: Checkout
13+
uses: actions/checkout@v4
14+
15+
- name: Show Swift version
16+
run: swift --version
17+
18+
- name: Run lint, build, and tests
19+
run: ./scripts/check.sh

.github/workflows/release.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "*"
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
release:
13+
runs-on: macos-15
14+
15+
steps:
16+
- name: Checkout
17+
uses: actions/checkout@v4
18+
19+
- name: Show Swift version
20+
run: swift --version
21+
22+
- name: Build release package
23+
run: ./scripts/package-release.sh "${{ github.ref_name }}"
24+
25+
- name: Upload workflow artifact
26+
uses: actions/upload-artifact@v4
27+
with:
28+
name: OpenWispr-${{ github.ref_name }}
29+
path: dist/*
30+
31+
- name: Publish GitHub release
32+
uses: softprops/action-gh-release@v2
33+
with:
34+
tag_name: ${{ github.ref_name }}
35+
generate_release_notes: true
36+
files: |
37+
dist/*.zip
38+
dist/*.sha256

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.build/
2+
dist/
3+
DerivedData/
4+
*.xcodeproj/
5+
*.xcworkspace/
6+
.DS_Store

Package.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// swift-tools-version: 6.0
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "OpenWispr",
6+
platforms: [
7+
.macOS(.v14),
8+
],
9+
products: [
10+
.library(name: "OpenWisprCore", targets: ["OpenWisprCore"]),
11+
.executable(name: "OpenWispr", targets: ["OpenWispr"]),
12+
.executable(name: "OpenWisprSelfTest", targets: ["OpenWisprSelfTest"]),
13+
],
14+
targets: [
15+
.target(
16+
name: "OpenWisprCore",
17+
path: "Sources/OpenWisprCore"
18+
),
19+
.executableTarget(
20+
name: "OpenWispr",
21+
dependencies: ["OpenWisprCore"],
22+
path: "Sources/OpenWisprMac"
23+
),
24+
.executableTarget(
25+
name: "OpenWisprSelfTest",
26+
dependencies: ["OpenWisprCore"],
27+
path: "SelfTests"
28+
),
29+
]
30+
)

README.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# OpenWispr
2+
3+
A native macOS menu bar dictation app inspired by the Linux `openwispr-gnome-extension` flow.
4+
5+
## What it does
6+
7+
- Menu bar mic app with `idle -> recording -> processing` states
8+
- Global hotkeys for toggle recording and hold-to-talk
9+
- STT providers:
10+
- Local `whisper-cli`
11+
- OpenAI endpoint
12+
- Groq endpoint
13+
- Optional LLM transcript cleanup (OpenAI or Groq)
14+
- Optional FFmpeg silence trimming
15+
- Clipboard copy always + optional auto-paste (`Cmd+V` simulation)
16+
- Optional start-at-login toggle (when running as a bundled `.app`)
17+
18+
## Dependencies (Homebrew)
19+
20+
```bash
21+
brew install ffmpeg whisper-cpp
22+
```
23+
24+
Expected default paths:
25+
26+
- `ffmpeg`: `/opt/homebrew/bin/ffmpeg` (Apple Silicon)
27+
- `whisper-cli`: `/opt/homebrew/bin/whisper-cli` (Apple Silicon)
28+
29+
If your binaries are elsewhere, set paths in app Settings.
30+
31+
## Run
32+
33+
```bash
34+
swift run OpenWispr
35+
```
36+
37+
When running via `swift run`, macOS notifications are disabled automatically because this runtime is not a bundled `.app`.
38+
39+
## Permissions
40+
41+
For full behavior parity, macOS may prompt for:
42+
43+
- Microphone access
44+
- Accessibility access (for synthetic paste)
45+
46+
Start at login requires running the packaged `.app` (not `swift run`).
47+
48+
## Local model
49+
50+
For local STT, point `Local Model Path` to a valid Whisper model file, for example:
51+
52+
`~/openwispr/models/ggml-base.en.bin`
53+
54+
## Notes
55+
56+
- This is a standalone macOS app codebase (not a GNOME extension port-in-place).
57+
- API keys are currently stored in user defaults for speed of iteration.
58+
59+
## Packaging
60+
61+
Create an unsigned `.app` zip locally:
62+
63+
```bash
64+
./scripts/package-release.sh v0.1.0
65+
```
66+
67+
Artifacts are written to `dist/`:
68+
69+
- `OpenWispr-<tag>-macos-<arch>.zip`
70+
- `OpenWispr-<tag>-macos-<arch>.zip.sha256`
71+
72+
This package is intentionally unsigned for now.
73+
74+
## GitHub Releases
75+
76+
Pushing any git tag triggers `.github/workflows/release.yml`, which will:
77+
78+
- build the release package
79+
- upload it as a workflow artifact
80+
- create/update a GitHub Release for that tag with the zip + checksum attached
81+
82+
## Tests and Linting
83+
84+
```bash
85+
./scripts/lint.sh
86+
./scripts/test.sh
87+
./scripts/check.sh
88+
```
89+
90+
- `scripts/lint.sh`: strict style checks using `swift format lint`.
91+
- `scripts/test.sh`: self-test executable for shortcut parsing, response parsing, and path resolution.
92+
- `scripts/check.sh`: lint + build + tests in one command.

SelfTests/main.swift

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import Carbon
2+
import Foundation
3+
import OpenWisprCore
4+
5+
struct SelfTestRunner {
6+
private(set) var failures = 0
7+
8+
mutating func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
9+
if condition() {
10+
print("[pass] \(message)")
11+
} else {
12+
failures += 1
13+
print("[fail] \(message)")
14+
}
15+
}
16+
17+
mutating func run() {
18+
let parsedAngle = ShortcutParser.parse("<Control><Option>R")
19+
expect(parsedAngle != nil, "Shortcut parser handles angle-bracket style")
20+
expect(parsedAngle?.keyCode == UInt32(kVK_ANSI_R), "Shortcut parser maps key code")
21+
expect(
22+
parsedAngle?.modifiers == (UInt32(controlKey) | UInt32(optionKey)),
23+
"Shortcut parser maps modifier flags"
24+
)
25+
26+
expect(
27+
ShortcutParser.parse("<Control><Option>") == nil, "Shortcut parser rejects missing key token")
28+
29+
if let sttData = "{\"text\":\"hello world\"}".data(using: .utf8) {
30+
expect(
31+
TranscriptParser.parseSTTResponse(data: sttData) == "hello world",
32+
"STT parser extracts text")
33+
} else {
34+
failures += 1
35+
print("[fail] STT parser test data could not be created")
36+
}
37+
38+
if let llmData = "{\"choices\":[{\"message\":{\"content\":\" cleaned text \"}}]}".data(
39+
using: .utf8)
40+
{
41+
expect(
42+
TranscriptParser.parseLLMResponse(data: llmData) == "cleaned text",
43+
"LLM parser extracts cleaned content")
44+
} else {
45+
failures += 1
46+
print("[fail] LLM parser test data could not be created")
47+
}
48+
49+
expect(
50+
TranscriptParser.normalize("\"Hello\"") == "Hello",
51+
"Transcript normalizer strips wrapping quotes")
52+
expect(
53+
TranscriptParser.normalize("```markdown\nHello\n```") == "Hello",
54+
"Transcript normalizer strips code fences"
55+
)
56+
57+
let expanded = PathResolver.expand("~/openwispr/model.bin")
58+
expect(expanded.hasPrefix(NSHomeDirectory()), "Path resolver expands tilde prefix")
59+
60+
let unknownBinary = "openwispr-test-\(UUID().uuidString)"
61+
expect(
62+
PathResolver.resolveExecutable(unknownBinary) == unknownBinary,
63+
"Path resolver preserves unknown executable names"
64+
)
65+
66+
if failures == 0 {
67+
print("\nAll self-tests passed.")
68+
} else {
69+
print("\nSelf-tests failed: \(failures)")
70+
exit(1)
71+
}
72+
}
73+
}
74+
75+
var runner = SelfTestRunner()
76+
runner.run()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import Foundation
2+
3+
public enum PathResolver {
4+
public static func expand(_ path: String) -> String {
5+
(path as NSString).expandingTildeInPath
6+
}
7+
8+
public static func resolveExecutable(_ path: String) -> String {
9+
let expanded = expand(path)
10+
if expanded.contains("/") {
11+
return expanded
12+
}
13+
14+
for candidate in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] {
15+
let full = "\(candidate)/\(expanded)"
16+
if FileManager.default.fileExists(atPath: full) {
17+
return full
18+
}
19+
}
20+
21+
return expanded
22+
}
23+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import Carbon
2+
import Foundation
3+
4+
public struct KeyShortcut: Equatable {
5+
public let keyCode: UInt32
6+
public let modifiers: UInt32
7+
8+
public init(keyCode: UInt32, modifiers: UInt32) {
9+
self.keyCode = keyCode
10+
self.modifiers = modifiers
11+
}
12+
}
13+
14+
public enum ShortcutParser {
15+
public static func parse(_ rawShortcut: String) -> KeyShortcut? {
16+
let shortcut = rawShortcut.trimmingCharacters(in: .whitespacesAndNewlines)
17+
guard !shortcut.isEmpty else {
18+
return nil
19+
}
20+
21+
let modifierTokens: [String]
22+
let keyToken: String
23+
24+
if shortcut.contains("<") {
25+
let regex = try? NSRegularExpression(pattern: "<([^>]+)>")
26+
let nsRange = NSRange(shortcut.startIndex..<shortcut.endIndex, in: shortcut)
27+
let matches = regex?.matches(in: shortcut, range: nsRange) ?? []
28+
29+
modifierTokens = matches.compactMap { match in
30+
guard let range = Range(match.range(at: 1), in: shortcut) else { return nil }
31+
return String(shortcut[range]).lowercased()
32+
}
33+
34+
keyToken =
35+
regex?.stringByReplacingMatches(in: shortcut, range: nsRange, withTemplate: "")
36+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
37+
} else if shortcut.contains("+") {
38+
let parts = shortcut.split(separator: "+").map {
39+
String($0).trimmingCharacters(in: .whitespacesAndNewlines)
40+
}
41+
guard let last = parts.last else {
42+
return nil
43+
}
44+
45+
modifierTokens = parts.dropLast().map { $0.lowercased() }
46+
keyToken = last
47+
} else {
48+
modifierTokens = []
49+
keyToken = shortcut
50+
}
51+
52+
guard let keyCode = keyCodeForToken(keyToken) else {
53+
return nil
54+
}
55+
56+
var modifiers: UInt32 = 0
57+
for token in modifierTokens {
58+
switch token {
59+
case "control", "ctrl", "primary":
60+
modifiers |= UInt32(controlKey)
61+
case "option", "alt", "mod1":
62+
modifiers |= UInt32(optionKey)
63+
case "shift":
64+
modifiers |= UInt32(shiftKey)
65+
case "command", "cmd", "super", "meta", "mod4":
66+
modifiers |= UInt32(cmdKey)
67+
default:
68+
break
69+
}
70+
}
71+
72+
return KeyShortcut(keyCode: keyCode, modifiers: modifiers)
73+
}
74+
75+
private static func keyCodeForToken(_ rawToken: String) -> UInt32? {
76+
let token = rawToken.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
77+
guard !token.isEmpty else {
78+
return nil
79+
}
80+
81+
let keyMap: [String: UInt32] = [
82+
"a": UInt32(kVK_ANSI_A), "b": UInt32(kVK_ANSI_B), "c": UInt32(kVK_ANSI_C),
83+
"d": UInt32(kVK_ANSI_D), "e": UInt32(kVK_ANSI_E), "f": UInt32(kVK_ANSI_F),
84+
"g": UInt32(kVK_ANSI_G), "h": UInt32(kVK_ANSI_H), "i": UInt32(kVK_ANSI_I),
85+
"j": UInt32(kVK_ANSI_J), "k": UInt32(kVK_ANSI_K), "l": UInt32(kVK_ANSI_L),
86+
"m": UInt32(kVK_ANSI_M), "n": UInt32(kVK_ANSI_N), "o": UInt32(kVK_ANSI_O),
87+
"p": UInt32(kVK_ANSI_P), "q": UInt32(kVK_ANSI_Q), "r": UInt32(kVK_ANSI_R),
88+
"s": UInt32(kVK_ANSI_S), "t": UInt32(kVK_ANSI_T), "u": UInt32(kVK_ANSI_U),
89+
"v": UInt32(kVK_ANSI_V), "w": UInt32(kVK_ANSI_W), "x": UInt32(kVK_ANSI_X),
90+
"y": UInt32(kVK_ANSI_Y), "z": UInt32(kVK_ANSI_Z),
91+
"0": UInt32(kVK_ANSI_0), "1": UInt32(kVK_ANSI_1), "2": UInt32(kVK_ANSI_2),
92+
"3": UInt32(kVK_ANSI_3), "4": UInt32(kVK_ANSI_4), "5": UInt32(kVK_ANSI_5),
93+
"6": UInt32(kVK_ANSI_6), "7": UInt32(kVK_ANSI_7), "8": UInt32(kVK_ANSI_8),
94+
"9": UInt32(kVK_ANSI_9),
95+
"space": UInt32(kVK_Space),
96+
"f1": UInt32(kVK_F1), "f2": UInt32(kVK_F2), "f3": UInt32(kVK_F3),
97+
"f4": UInt32(kVK_F4), "f5": UInt32(kVK_F5), "f6": UInt32(kVK_F6),
98+
"f7": UInt32(kVK_F7), "f8": UInt32(kVK_F8), "f9": UInt32(kVK_F9),
99+
"f10": UInt32(kVK_F10), "f11": UInt32(kVK_F11), "f12": UInt32(kVK_F12),
100+
]
101+
102+
return keyMap[token]
103+
}
104+
}

0 commit comments

Comments
 (0)