|
|
@ -37,12 +37,19 @@ public final class InputBoxManager { |
|
|
public func ensureFocusedInputBox() { |
|
|
public func ensureFocusedInputBox() { |
|
|
resetSession() |
|
|
resetSession() |
|
|
|
|
|
|
|
|
guard AXIsProcessTrusted() else { |
|
|
|
|
|
|
|
|
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String |
|
|
|
|
|
let isTrusted = AXIsProcessTrustedWithOptions([promptKey: true] as CFDictionary) |
|
|
|
|
|
guard isTrusted else { |
|
|
print("InputBoxManager: Accessibility not trusted.") |
|
|
print("InputBoxManager: Accessibility not trusted.") |
|
|
return |
|
|
return |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
guard let frontApp = NSWorkspace.shared.frontmostApplication else { return } |
|
|
guard let frontApp = NSWorkspace.shared.frontmostApplication else { return } |
|
|
|
|
|
if let bid = frontApp.bundleIdentifier?.lowercased(), |
|
|
|
|
|
bid.contains("siri") || bid.contains("shortcuts") { |
|
|
|
|
|
print("InputBoxManager: Frontmost app is Siri/Shortcuts (\(bid)), preserving user app focus.") |
|
|
|
|
|
return |
|
|
|
|
|
} |
|
|
let appElem = AXUIElementCreateApplication(frontApp.processIdentifier) |
|
|
let appElem = AXUIElementCreateApplication(frontApp.processIdentifier) |
|
|
|
|
|
|
|
|
// 1. Check if an element is already focused in the frontmost application |
|
|
// 1. Check if an element is already focused in the frontmost application |
|
|
@ -292,6 +299,14 @@ public final class InputBoxManager { |
|
|
let normStreamed = streamedWords.map(cleanWord).filter { !$0.isEmpty } |
|
|
let normStreamed = streamedWords.map(cleanWord).filter { !$0.isEmpty } |
|
|
let normFinal = finalWords.map(cleanWord).filter { !$0.isEmpty } |
|
|
let normFinal = finalWords.map(cleanWord).filter { !$0.isEmpty } |
|
|
|
|
|
|
|
|
|
|
|
let wordsMatch: (String, String) -> Bool = { w1, w2 in |
|
|
|
|
|
if w1 == w2 { return true } |
|
|
|
|
|
if w1.count >= 2 && w2.count >= 2 { |
|
|
|
|
|
if w1.hasPrefix(w2) || w2.hasPrefix(w1) { return true } |
|
|
|
|
|
} |
|
|
|
|
|
return false |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
var unstreamedStartIndex: Int? = nil |
|
|
var unstreamedStartIndex: Int? = nil |
|
|
|
|
|
|
|
|
// Strategy 4A: Search for 3-word tail anchor from streamed text in final words |
|
|
// Strategy 4A: Search for 3-word tail anchor from streamed text in final words |
|
|
@ -300,7 +315,7 @@ public final class InputBoxManager { |
|
|
let a2 = normStreamed[normStreamed.count - 2] |
|
|
let a2 = normStreamed[normStreamed.count - 2] |
|
|
let a3 = normStreamed[normStreamed.count - 1] |
|
|
let a3 = normStreamed[normStreamed.count - 1] |
|
|
for j in stride(from: normFinal.count - 1, through: 2, by: -1) { |
|
|
for j in stride(from: normFinal.count - 1, through: 2, by: -1) { |
|
|
if normFinal[j] == a3 && normFinal[j - 1] == a2 && normFinal[j - 2] == a1 { |
|
|
|
|
|
|
|
|
if wordsMatch(normFinal[j], a3) && wordsMatch(normFinal[j - 1], a2) && wordsMatch(normFinal[j - 2], a1) { |
|
|
unstreamedStartIndex = j + 1 |
|
|
unstreamedStartIndex = j + 1 |
|
|
break |
|
|
break |
|
|
} |
|
|
} |
|
|
@ -312,7 +327,7 @@ public final class InputBoxManager { |
|
|
let a1 = normStreamed[normStreamed.count - 2] |
|
|
let a1 = normStreamed[normStreamed.count - 2] |
|
|
let a2 = normStreamed[normStreamed.count - 1] |
|
|
let a2 = normStreamed[normStreamed.count - 1] |
|
|
for j in stride(from: normFinal.count - 1, through: 1, by: -1) { |
|
|
for j in stride(from: normFinal.count - 1, through: 1, by: -1) { |
|
|
if normFinal[j] == a2 && normFinal[j - 1] == a1 { |
|
|
|
|
|
|
|
|
if wordsMatch(normFinal[j], a2) && wordsMatch(normFinal[j - 1], a1) { |
|
|
unstreamedStartIndex = j + 1 |
|
|
unstreamedStartIndex = j + 1 |
|
|
break |
|
|
break |
|
|
} |
|
|
} |
|
|
@ -324,7 +339,7 @@ public final class InputBoxManager { |
|
|
let a1 = normStreamed[normStreamed.count - 1] |
|
|
let a1 = normStreamed[normStreamed.count - 1] |
|
|
let searchStart = max(0, normFinal.count - max(15, normStreamed.count)) |
|
|
let searchStart = max(0, normFinal.count - max(15, normStreamed.count)) |
|
|
for j in stride(from: normFinal.count - 1, through: searchStart, by: -1) { |
|
|
for j in stride(from: normFinal.count - 1, through: searchStart, by: -1) { |
|
|
if normFinal[j] == a1 { |
|
|
|
|
|
|
|
|
if wordsMatch(normFinal[j], a1) { |
|
|
unstreamedStartIndex = j + 1 |
|
|
unstreamedStartIndex = j + 1 |
|
|
break |
|
|
break |
|
|
} |
|
|
} |
|
|
@ -335,7 +350,7 @@ public final class InputBoxManager { |
|
|
if unstreamedStartIndex == nil { |
|
|
if unstreamedStartIndex == nil { |
|
|
var prefixMatchCount = 0 |
|
|
var prefixMatchCount = 0 |
|
|
for i in 0..<min(normStreamed.count, normFinal.count) { |
|
|
for i in 0..<min(normStreamed.count, normFinal.count) { |
|
|
if normStreamed[i] == normFinal[i] { |
|
|
|
|
|
|
|
|
if wordsMatch(normStreamed[i], normFinal[i]) { |
|
|
prefixMatchCount = i + 1 |
|
|
prefixMatchCount = i + 1 |
|
|
} else { |
|
|
} else { |
|
|
break |
|
|
break |
|
|
@ -346,7 +361,12 @@ public final class InputBoxManager { |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// Suffix Evaluation: |
|
|
|
|
|
|
|
|
// Guaranteed Safety Fallback: If no anchor matched, point to streamedWords.count |
|
|
|
|
|
if unstreamedStartIndex == nil { |
|
|
|
|
|
unstreamedStartIndex = min(streamedWords.count, finalWords.count) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Suffix Evaluation: NEVER suppress paste! Always paste all remaining unstreamed words! |
|
|
if let startIndex = unstreamedStartIndex { |
|
|
if let startIndex = unstreamedStartIndex { |
|
|
if startIndex < finalWords.count { |
|
|
if startIndex < finalWords.count { |
|
|
// Only paste unstreamed trailing words |
|
|
// Only paste unstreamed trailing words |
|
|
@ -371,32 +391,6 @@ public final class InputBoxManager { |
|
|
return |
|
|
return |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// Case 5: Anti-Duplication Safety Guard |
|
|
|
|
|
// If more than half the words were streamed, the utterance is already in the input box! |
|
|
|
|
|
// NEVER paste the full text, as that guarantees 100% duplication! |
|
|
|
|
|
if streamedWords.count >= (finalWords.count / 2) { |
|
|
|
|
|
print("InputBoxManager: Most words already streamed (\(streamedWords.count) vs \(finalWords.count)). Suppressing full paste to prevent duplication.") |
|
|
|
|
|
if addTrailingSpace { |
|
|
|
|
|
let pb = NSPasteboard.general |
|
|
|
|
|
pb.clearContents() |
|
|
|
|
|
pb.setString(" ", forType: .string) |
|
|
|
|
|
self.postCleanCmdV() |
|
|
|
|
|
} |
|
|
|
|
|
restoreClipboardFullText() |
|
|
|
|
|
return |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Case 6: Fallback when only very few words were streamed (<50%) and no anchor was found |
|
|
|
|
|
if finalWords.count > streamedWords.count { |
|
|
|
|
|
let remainingWords = Array(finalWords.dropFirst(streamedWords.count)) |
|
|
|
|
|
let suffix = remainingWords.joined(separator: " ") |
|
|
|
|
|
let suffixToPaste = addTrailingSpace ? (" " + suffix + " ") : (" " + suffix) |
|
|
|
|
|
let pb = NSPasteboard.general |
|
|
|
|
|
pb.clearContents() |
|
|
|
|
|
pb.setString(suffixToPaste, forType: .string) |
|
|
|
|
|
self.postCleanCmdV() |
|
|
|
|
|
} |
|
|
|
|
|
restoreClipboardFullText() |
|
|
restoreClipboardFullText() |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|