Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@

## ✨ Features

- **🔒 100% Local** - All audio processing happens on your machine. No internet required — the Tiny model ships bundled and works out of the box offline.
- **✍️ Per-App Writing Styles** - Dictation is shaped for the app receiving it: filenames and paths in Cursor or VS Code, `*bold*` in Slack, plain sentences in Messages, full stops in Mail. Runs on-device with deterministic rules — no LLM, no network.
- **🔒 On-device** - After the model is downloaded, audio processing stays on your Mac. The Tiny Whisper model ships bundled so you can dictate immediately; larger models need a one-time download. No required Voca account.
- **⌨️ System-Wide Text Injection** - Transcribed text is typed wherever your cursor is: browsers, Slack, VS Code, spreadsheets, terminals - everywhere.
- **🎯 Push-to-Talk** - Hold a hotkey (default: Right Option) to record. Release to transcribe.
Expand Down
113 changes: 113 additions & 0 deletions Sources/VocaMac/Models/AppIdentity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// AppIdentity.swift
// VocaMac
//
// Shared identity and matching for other macOS applications. Auto-pause and
// writing styles both need "is this running app the one the user configured",
// and both must answer it the same way, so the rules live here once.

import Foundation
import AppKit

/// Lightweight view of a running process, used for matching and picker UI.
struct RunningAppSnapshot: Hashable {
var displayName: String
var bundleIdentifier: String?
var processName: String?

init(displayName: String, bundleIdentifier: String? = nil, processName: String? = nil) {
self.displayName = displayName
self.bundleIdentifier = bundleIdentifier
self.processName = processName
}
}

/// Matching rules shared by every feature that keys off another app.
enum AppIdentityMatching {

/// Normalize a process or configured name for comparison.
///
/// Strips any directory component, lowercases, and drops a `.exe` suffix
/// so `/Applications/Foo.app/Contents/MacOS/Foo` and `foo` compare equal.
static func normalizeProcessName(_ name: String) -> String {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "" }
let base = (trimmed as NSString).lastPathComponent.lowercased()
if base.hasSuffix(".exe") {
return String(base.dropLast(4))
}
return base
}

/// Whether a configured entry identifies the same app as a running snapshot.
///
/// Matches on case-insensitive bundle identifier equality **or** normalized
/// executable basename equality. `configuredID` is the fallback used when
/// the entry carries no explicit process name.
static func matches(
configuredBundleIdentifier: String?,
configuredProcessName: String?,
configuredID: String,
snapshot: RunningAppSnapshot
) -> Bool {
if let configuredBundle = configuredBundleIdentifier?.lowercased(),
let snapshotBundle = snapshot.bundleIdentifier?.lowercased(),
configuredBundle == snapshotBundle {
return true
}

let configuredProcess = configuredProcessName.map { normalizeProcessName($0) }
?? normalizeProcessName(configuredID)
guard !configuredProcess.isEmpty else { return false }

if let snapshotProcess = snapshot.processName.map({ normalizeProcessName($0) }),
configuredProcess == snapshotProcess {
return true
}

// Auto-pause lets a user type an app's bundle ID into the same field
// that normally holds a process name. Compare the configured name
// against the snapshot's bundle ID too, so `com.apple.Terminal` typed
// by hand still matches. Whole-string equality, not a suffix test:
// matching `code` against `com.microsoft.VSCode` would bind an app the
// user never named.
if let snapshotBundle = snapshot.bundleIdentifier.map({ normalizeProcessName($0) }),
configuredProcess == snapshotBundle {
return true
}

return false
}

/// Snapshot the currently running applications, excluding VocaMac itself.
///
/// Deduplicated: helper processes of one app can share a name and bundle
/// ID, and a list with two identical rows is both confusing in the picker
/// and ambiguous as a SwiftUI identity.
static func workspaceRunningApps() -> [RunningAppSnapshot] {
var seen = Set<RunningAppSnapshot>()
var result: [RunningAppSnapshot] = []
for app in NSWorkspace.shared.runningApplications {
guard let snapshot = snapshot(for: app), seen.insert(snapshot).inserted else { continue }
result.append(snapshot)
}
return result
}

/// Convert one `NSRunningApplication` into a snapshot, or `nil` when it is
/// VocaMac itself, has no usable name, or is missing.
static func snapshot(for app: NSRunningApplication?) -> RunningAppSnapshot? {
guard let app else { return nil }
// Compare only when we have an identifier of our own: in an unbundled
// dev build both sides are nil, which would hide every unbundled app.
if let ownBundleID = Bundle.main.bundleIdentifier, app.bundleIdentifier == ownBundleID {
return nil
}
let display = app.localizedName ?? app.bundleIdentifier ?? app.executableURL?.lastPathComponent
guard let display, !display.isEmpty else { return nil }
return RunningAppSnapshot(
displayName: display,
bundleIdentifier: app.bundleIdentifier,
processName: app.executableURL?.lastPathComponent ?? app.localizedName
)
}
}
Loading
Loading