Skip to content
Open
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
7 changes: 6 additions & 1 deletion Sources/AudioRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ final class AudioRecorder: NSObject, ObservableObject {
}

func reset() {
Logger.debugLog("=== RESET CALLED (this blocks notifications) ===", log: Logger.audio)
resetPending = true
stop()
wait()
Expand All @@ -112,6 +113,7 @@ final class AudioRecorder: NSObject, ObservableObject {
recorder = nil
isRecording = false
resetPending = false
Logger.log("Reset complete, resetPending now false", log: Logger.audio)
}

deinit {
Expand All @@ -135,14 +137,17 @@ extension AudioRecorder: AVAudioRecorderDelegate {

func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
isRecording = false
Logger.log("Recording finished: \(flag)", log: Logger.audio)
Logger.log("Recording finished: \(flag), resetPending: \(resetPending)", log: Logger.audio)

if !resetPending {
if !flag {
NotificationCenter.default.post(name: .recordingError, object: "Recording failed")
} else {
Logger.log("Posting didFinishRecording notification with URL: \(outputURL?.path ?? "nil")", log: Logger.audio)
NotificationCenter.default.post(name: .didFinishRecording, object: outputURL)
}
} else {
Logger.log("NOTIFICATION BLOCKED: resetPending is true", log: Logger.audio, type: .error)
}
}
}
99 changes: 81 additions & 18 deletions Sources/GenericHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -439,36 +439,99 @@ enum GenericHelper {
}

static func paste(text: String) -> Bool {
Logger.debugLog("=== PASTE FUNCTION CALLED ===", log: Logger.general)
if GenericHelper.logSensitiveData() {
Logger.log("Auto pasting: \(text)", log: Logger.general)
}

// Do not paste if WhisperClip is the active app
if isWhisperClipActive() {
if GenericHelper.logSensitiveData() {
Logger.log("Paste skipped: WhisperClip is frontmost app", log: Logger.general)
}
Logger.log("Paste skipped: WhisperClip is frontmost app", log: Logger.general)
return false
}

// ⌘V in whichever app is active
let script = #"""
tell application "System Events"
key code 9 using {command down}
end tell
"""#
var err: NSDictionary?
NSAppleScript(source: script)?.executeAndReturnError(&err)
// Get current frontmost app
if let frontApp = NSWorkspace.shared.frontmostApplication {
Logger.log("Current frontmost app: \(frontApp.localizedName ?? "unknown") [\(frontApp.bundleIdentifier ?? "unknown")]", log: Logger.general)
}

if let err {
Logger.log("AppleScript error: \(err.description)", log: Logger.general, type: .error)
return false
} else {
if GenericHelper.logSensitiveData() {
Logger.log("Auto pasted: \(text)", log: Logger.general)
// Wait for user to switch to target app (max 3 seconds)
Logger.log("Waiting for user to switch to target app...", log: Logger.general)
var waitCount = 0
while isWhisperClipActive() && waitCount < 30 {
Thread.sleep(forTimeInterval: 0.1)
waitCount += 1
}
Logger.log("Wait complete. Waited \(waitCount * 100)ms", log: Logger.general)

// Additional small delay to ensure app is ready
Thread.sleep(forTimeInterval: 0.2)

// Get current app bundle identifier
let frontApp = NSWorkspace.shared.frontmostApplication
let bundleId = frontApp?.bundleIdentifier ?? ""

// Special handling for iTerm2
if bundleId == "com.googlecode.iterm2" {
Logger.log("Detected iTerm2, using AppleScript injection", log: Logger.general)
let script = """
tell application "iTerm2"
tell current session of current window
write text "\(text.replacingOccurrences(of: "\"", with: "\\\""))"
end tell
end tell
"""
var err: NSDictionary?
NSAppleScript(source: script)?.executeAndReturnError(&err)

if let err = err {
Logger.log("iTerm2 AppleScript error: \(err.description)", log: Logger.general, type: .error)
} else {
Logger.log("Successfully sent text to iTerm2 via AppleScript", log: Logger.general)
return true
}
return true
}

// Try to use Accessibility API to insert text directly
Logger.log("Attempting to paste using Accessibility API...", log: Logger.general)

let systemWideElement = AXUIElementCreateSystemWide()
var focusedElement: CFTypeRef?
let result = AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedUIElementAttribute as CFString, &focusedElement)

if result == .success, let element = focusedElement {
Logger.log("Found focused element, attempting to insert text", log: Logger.general)
let axElement = element as! AXUIElement

// Try to set the value directly
let valueResult = AXUIElementSetAttributeValue(axElement, kAXValueAttribute as CFString, text as CFTypeRef)

if valueResult == .success {
Logger.log("Successfully inserted text via Accessibility API", log: Logger.general)
return true
} else {
Logger.log("Failed to set value via Accessibility (error: \(valueResult.rawValue)), falling back to keyboard events", log: Logger.general)
}
} else {
Logger.log("No focused element found (error: \(result.rawValue)), using keyboard events", log: Logger.general)
}

// Fallback: Use CGEvent to simulate Cmd+V
Logger.log("Using CGEvent fallback to simulate Cmd+V...", log: Logger.general)
let source = CGEventSource(stateID: .combinedSessionState)

let vKeyDown = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: true)
let vKeyUp = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: false)

vKeyDown?.flags = .maskCommand
vKeyUp?.flags = .maskCommand

vKeyDown?.post(tap: .cghidEventTap)
Thread.sleep(forTimeInterval: 0.05)
vKeyUp?.post(tap: .cghidEventTap)

Logger.log("CGEvent paste complete", log: Logger.general)
return true
}

static func sendEnter() -> Bool {
Expand Down
21 changes: 21 additions & 0 deletions Sources/Logger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,27 @@ public enum Logger {

}

/// Log a debug message (only if debug logging is enabled in settings)
/// - Parameters:
/// - message: The message to log
/// - log: The OSLog instance to use (defaults to general)
/// - type: The type of log message (defaults to .debug)
/// - file: The file name where the log was called (automatically captured)
/// - function: The function name where the log was called (automatically captured)
/// - line: The line number where the log was called (automatically captured)
public static func debugLog(_ message: String,
log: OSLog = Logger.general,
type: OSLogType = .debug,
file: String = #file,
function: String = #function,
line: Int = #line) {
// Only log if debug logging is enabled
guard SettingsStore.shared.debugLogging else { return }

// Use the regular log function
self.log(message, log: log, type: type, file: file, function: function, line: line)
}

private static func formatLogMessage(_ message: String, log: OSLog, type: OSLogType, file: String, function: String, line: Int) -> String {
let category: String
switch log {
Expand Down
30 changes: 25 additions & 5 deletions Sources/MicrophoneView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -260,19 +260,23 @@ struct MicrophoneView: View {
startedByHotkey = false
}
.onReceive(NotificationCenter.default.publisher(for: .didFinishRecording)) { notif in
Logger.debugLog("=== RECEIVED didFinishRecording NOTIFICATION ===", log: Logger.audio)
if overlayShown {
RecordingOverlayManager.shared.hide()
overlayShown = false
}

if let fileUrl = notif.object as? URL {
Logger.log("File URL: \(fileUrl.path)", log: Logger.audio)
if !GenericHelper.fileExists(file: fileUrl) {
Logger.log("Recording file \(fileUrl.path) not found", log: Logger.audio, type: .error)
resetState(error: "Recording file not found. Please try again.")
return
}

Logger.log("File exists, calling transcribeAudio", log: Logger.audio)
transcribeAudio(url: fileUrl, source: .microphone)
} else {
Logger.log("ERROR: No file URL in notification", log: Logger.audio, type: .error)
}
}
.onReceive(NotificationCenter.default.publisher(for: .recordingError)) { notif in
Expand Down Expand Up @@ -334,17 +338,22 @@ struct MicrophoneView: View {
}

private func transcribeAudio(url: URL, source: TranscriptionSource, filename: String? = nil) {
Logger.debugLog("=== TRANSCRIBE AUDIO CALLED ===", log: Logger.audio)
if isTranscribing {
Logger.log("Already transcribing", log: Logger.audio)
resetState(error: "Already transcribing. Please wait for the current transcription to finish.")
return
}
isTranscribing = true
Logger.log("Starting transcription task", log: Logger.audio)

Task {
do {
Logger.log("Creating voice to text model", log: Logger.audio)
let voiceToTextModel = VoiceToTextFactory.createVoiceToText()
Logger.log("Processing audio file: \(url.path)", log: Logger.audio)
let text = try await voiceToTextModel.process(filepath: url.path)
Logger.log("Transcription complete, text length: \(text.count)", log: Logger.audio)

audio.reset()
if GenericHelper.logSensitiveData() {
Expand Down Expand Up @@ -403,16 +412,27 @@ struct MicrophoneView: View {
}

private func processText(text: String, source: TranscriptionSource, filename: String? = nil) async {
Logger.debugLog("=== PROCESS TEXT CALLED ===", log: Logger.audio)
Logger.log("Text length: \(text.count)", log: Logger.audio)
defer {
self.isTranscribing = false
self.isProcessing = false
}

Logger.log("Copying to clipboard", log: Logger.audio)
GenericHelper.copyToClipboard(text: text)
let pasted = GenericHelper.paste(text: text)

if pasted && settings.autoEnter {
_ = GenericHelper.sendEnter()

var pasted = false
if settings.autoPaste {
Logger.log("Calling paste function", log: Logger.audio)
pasted = GenericHelper.paste(text: text)
Logger.log("Paste returned: \(pasted)", log: Logger.audio)

if pasted && settings.autoEnter {
_ = GenericHelper.sendEnter()
}
} else {
Logger.log("Auto-paste disabled in settings", log: Logger.audio)
}

self.resultText = text
Expand Down
24 changes: 22 additions & 2 deletions Sources/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ struct DefaultSettings {
static let hasCompletedOnboarding = false
static let language = "auto"
static let sttEngine = STTEngine.parakeet
static let autoPaste = true
static let autoEnter = false
static let debugLogging = false
static let startMinimized = false
static let displayRecordingOverlay = false
static let overlayPosition = "topRight"
Expand Down Expand Up @@ -52,7 +54,9 @@ class SettingsStore: ObservableObject {
case hasCompletedOnboarding = "hasCompletedOnboarding"
case language = "language"
case sttEngine = "sttEngine"
case autoPaste = "autoPaste"
case autoEnter = "autoEnter"
case debugLogging = "debugLogging"
case startMinimized = "startMinimized"
case displayRecordingOverlay = "displayRecordingOverlay"
case overlayPosition = "overlayPosition"
Expand Down Expand Up @@ -94,13 +98,25 @@ class SettingsStore: ObservableObject {
defaults.set(sttEngine.rawValue, forKey: Keys.sttEngine.rawValue)
}
}


@Published var autoPaste: Bool = DefaultSettings.autoPaste {
didSet {
defaults.set(autoPaste, forKey: Keys.autoPaste.rawValue)
}
}

@Published var autoEnter: Bool = DefaultSettings.autoEnter {
didSet {
defaults.set(autoEnter, forKey: Keys.autoEnter.rawValue)
}
}


@Published var debugLogging: Bool = DefaultSettings.debugLogging {
didSet {
defaults.set(debugLogging, forKey: Keys.debugLogging.rawValue)
}
}

@Published var startMinimized: Bool = DefaultSettings.startMinimized {
didSet {
defaults.set(startMinimized, forKey: Keys.startMinimized.rawValue)
Expand Down Expand Up @@ -247,7 +263,9 @@ class SettingsStore: ObservableObject {
} else {
self.sttEngine = DefaultSettings.sttEngine
}
self.autoPaste = defaults.object(forKey: Keys.autoPaste.rawValue) == nil ? DefaultSettings.autoPaste : defaults.bool(forKey: Keys.autoPaste.rawValue)
self.autoEnter = defaults.object(forKey: Keys.autoEnter.rawValue) == nil ? DefaultSettings.autoEnter : defaults.bool(forKey: Keys.autoEnter.rawValue)
self.debugLogging = defaults.object(forKey: Keys.debugLogging.rawValue) == nil ? DefaultSettings.debugLogging : defaults.bool(forKey: Keys.debugLogging.rawValue)
self.startMinimized = defaults.object(forKey: Keys.startMinimized.rawValue) == nil ? DefaultSettings.startMinimized : defaults.bool(forKey: Keys.startMinimized.rawValue)
self.displayRecordingOverlay = defaults.object(forKey: Keys.displayRecordingOverlay.rawValue) == nil ? DefaultSettings.displayRecordingOverlay : defaults.bool(forKey: Keys.displayRecordingOverlay.rawValue)
self.overlayPosition = defaults.string(forKey: Keys.overlayPosition.rawValue) ?? DefaultSettings.overlayPosition
Expand Down Expand Up @@ -336,7 +354,9 @@ class SettingsStore: ObservableObject {
hasCompletedOnboarding = DefaultSettings.hasCompletedOnboarding
language = DefaultSettings.language
sttEngine = DefaultSettings.sttEngine
autoPaste = DefaultSettings.autoPaste
autoEnter = DefaultSettings.autoEnter
debugLogging = DefaultSettings.debugLogging
startMinimized = DefaultSettings.startMinimized
displayRecordingOverlay = DefaultSettings.displayRecordingOverlay
overlayPosition = DefaultSettings.overlayPosition
Expand Down
17 changes: 15 additions & 2 deletions Sources/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,17 +167,30 @@ struct SettingsView: View {
Text("Auto Actions")
.font(.headline)
.foregroundColor(.white)


Toggle("Auto-paste after transcription", isOn: Binding(
get: { settings.autoPaste },
set: { newValue in
settings.autoPaste = newValue
}
))

Text("Automatically paste transcribed text into the active application.")
.font(.caption)
.foregroundColor(.gray)

Toggle("Auto-press Enter after paste", isOn: Binding(
get: { settings.autoEnter },
set: { newValue in
settings.autoEnter = newValue
}
))

.disabled(!settings.autoPaste)

Text("Automatically press Enter after pasting transcribed text into the active application.")
.font(.caption)
.foregroundColor(.gray)
.opacity(settings.autoPaste ? 1.0 : 0.5)
}
.padding()
}
Expand Down