//go:build darwin && appstore
|
|
package textoutput
|
|
import (
|
"strings"
|
"unicode/utf8"
|
"voicesnap/internal/axinput"
|
"voicesnap/internal/logger"
|
)
|
|
func newPlatformRouter(paster Paster) *Router {
|
return &Router{
|
paster: paster,
|
driver: axinput.NewDriver(),
|
}
|
}
|
|
func (r *Router) Output(text string, keepClipboard bool) Result {
|
return r.OutputWithOptions(text, OutputOptions{KeepClipboard: keepClipboard})
|
}
|
|
func (r *Router) OutputWithOptions(text string, options OutputOptions) Result {
|
if text == "" {
|
return r.remember(Result{OK: false, Method: "ax_direct_insert", Message: "text is empty"})
|
}
|
if r.driver == nil {
|
return r.remember(Result{
|
OK: false,
|
Text: text,
|
Method: "ax_direct_insert",
|
Message: "AX driver is unavailable",
|
FallbackAvailable: false,
|
})
|
}
|
direct := r.driver.DirectInsert(text)
|
if direct.OK && direct.Inserted {
|
return r.remember(Result{
|
OK: true,
|
Text: text,
|
Method: direct.Method,
|
Message: direct.Message,
|
Target: direct.Target,
|
DirectInserted: true,
|
})
|
}
|
fallbackAvailable := clipboardFallbackSafeForTarget(Result{Target: direct.Target})
|
category, codes := directInsertDiagnostic(direct)
|
logger.Info(
|
"Text output direct insert route status=driver_result ok=%t inserted=%t needs_permission=%t category=%s codes=%s text_runes=%d",
|
direct.OK,
|
direct.Inserted,
|
direct.NeedsPermission,
|
category,
|
codes,
|
utf8.RuneCountInString(text),
|
)
|
if fallbackAvailable && options.AutoPasteExperiment {
|
autoPaste := r.autoPasteClipboardExperiment(text, direct, options)
|
if autoPaste.OK && autoPaste.Pasted {
|
return r.remember(Result{
|
OK: true,
|
Text: text,
|
Method: "clipboard_auto_paste_experiment",
|
Message: "clipboard fallback auto-paste experiment sent one paste event",
|
Target: direct.Target,
|
DirectInserted: false,
|
})
|
}
|
return r.remember(Result{
|
OK: false,
|
Text: text,
|
Method: direct.Method,
|
Message: direct.Message,
|
Target: direct.Target,
|
NeedsPermission: direct.NeedsPermission,
|
DirectInserted: false,
|
FallbackAvailable: fallbackAvailable,
|
})
|
}
|
if fallbackAvailable && options.KeepClipboard {
|
clipboard := r.driver.CopyTextToClipboard(text)
|
logger.Info(
|
"Text output copy-to-clipboard route status=driver_result ok=%t prepared=%t text_runes=%d",
|
clipboard.OK,
|
clipboard.Prepared,
|
utf8.RuneCountInString(text),
|
)
|
}
|
return r.remember(Result{
|
OK: false,
|
Text: text,
|
Method: direct.Method,
|
Message: direct.Message,
|
Target: direct.Target,
|
NeedsPermission: direct.NeedsPermission,
|
DirectInserted: false,
|
FallbackAvailable: fallbackAvailable,
|
})
|
}
|
|
func (r *Router) autoPasteClipboardExperiment(text string, direct axinput.DirectInsertResult, options OutputOptions) axinput.AutoPasteResult {
|
textRunes := utf8.RuneCountInString(text)
|
guard := options.AutoPasteGuard
|
if !guard.OK || strings.TrimSpace(guard.Identity) == "" {
|
logger.Info("Text output auto-paste experiment status=blocked_guard_unavailable text_runes=%d", textRunes)
|
return axinput.AutoPasteResult{OK: false, Message: "frontmost guard is unavailable"}
|
}
|
currentIdentity := r.driver.FrontmostAppIdentity()
|
if !currentIdentity.OK || strings.TrimSpace(currentIdentity.Identity) == "" {
|
logger.Info("Text output auto-paste experiment status=blocked_current_identity_unavailable text_runes=%d", textRunes)
|
return axinput.AutoPasteResult{OK: false, Message: "frontmost identity is unavailable before clipboard write"}
|
}
|
if currentIdentity.Identity != guard.Identity {
|
logger.Info("Text output auto-paste experiment status=blocked_frontmost_changed_before_clipboard text_runes=%d", textRunes)
|
return axinput.AutoPasteResult{OK: false, Message: "frontmost app changed before clipboard write"}
|
}
|
if !autoPasteSafeForIdentity(currentIdentity.Identity) || !clipboardFallbackSafeForTarget(Result{Target: direct.Target}) {
|
logger.Info(
|
"Text output auto-paste experiment status=blocked_non_writable text_runes=%d identity_present=%t target_known=%t",
|
textRunes,
|
currentIdentity.Identity != "",
|
strings.TrimSpace(direct.Target) != "" && strings.TrimSpace(direct.Target) != "target=unknown",
|
)
|
return axinput.AutoPasteResult{OK: false, Message: "automatic paste is disabled for this target"}
|
}
|
var token string
|
if options.KeepClipboard {
|
clipboard := r.driver.CopyTextToClipboard(text)
|
logger.Info(
|
"Text output copy-to-clipboard route status=driver_result ok=%t prepared=%t text_runes=%d",
|
clipboard.OK,
|
clipboard.Prepared,
|
textRunes,
|
)
|
if !clipboard.OK || !clipboard.Prepared {
|
logger.Info(
|
"Text output auto-paste experiment status=blocked_clipboard_unprepared text_runes=%d clipboard_ok=%t clipboard_prepared=%t",
|
textRunes,
|
clipboard.OK,
|
clipboard.Prepared,
|
)
|
return axinput.AutoPasteResult{OK: false, Message: "clipboard is not prepared"}
|
}
|
} else {
|
clipboard := r.driver.PrepareClipboardFallback(text)
|
logger.Info(
|
"Text output auto-paste experiment clipboard prepare status=driver_result ok=%t prepared=%t token_present=%t text_runes=%d",
|
clipboard.OK,
|
clipboard.Prepared,
|
clipboard.Token != "",
|
textRunes,
|
)
|
if !clipboard.OK || !clipboard.Prepared || clipboard.Token == "" {
|
logger.Info(
|
"Text output auto-paste experiment status=blocked_clipboard_token_unavailable text_runes=%d clipboard_ok=%t clipboard_prepared=%t token_present=%t",
|
textRunes,
|
clipboard.OK,
|
clipboard.Prepared,
|
clipboard.Token != "",
|
)
|
return axinput.AutoPasteResult{OK: false, Message: "clipboard restore token is unavailable"}
|
}
|
token = clipboard.Token
|
}
|
result := r.driver.AutoPasteClipboardIfFrontmostUnchanged(guard.Identity)
|
logger.Info(
|
"Text output auto-paste experiment status=driver_result ok=%t pasted=%t frontmost_unchanged=%t text_runes=%d",
|
result.OK,
|
result.Pasted,
|
result.FrontmostUnchanged,
|
textRunes,
|
)
|
if token != "" {
|
restore := r.driver.RestoreClipboardFallback(token)
|
logger.Info(
|
"Text output auto-paste experiment clipboard restore status=driver_result ok=%t restored=%t token_present=%t text_runes=%d",
|
restore.OK,
|
restore.Restored,
|
token != "",
|
textRunes,
|
)
|
if result.OK && result.Pasted && !restore.OK && !restore.Restored {
|
return axinput.AutoPasteResult{OK: false, Pasted: result.Pasted, FrontmostUnchanged: result.FrontmostUnchanged, Message: "clipboard restore did not complete"}
|
}
|
}
|
return result
|
}
|
|
func directInsertDiagnostic(result axinput.DirectInsertResult) (string, string) {
|
if result.NeedsPermission {
|
return "accessibility_permission", "none"
|
}
|
message := strings.ToLower(result.Message)
|
switch {
|
case strings.Contains(message, "focused element unavailable"):
|
return "focused_element_unavailable", extractAXCodeSummary(result.Message)
|
case strings.Contains(message, "text state is not readable"):
|
return "focused_text_state_unreadable", extractAXCodeSummary(result.Message)
|
case strings.Contains(message, "value is not settable"):
|
return "focused_value_not_settable", "none"
|
case strings.Contains(message, "selected range is unreadable"):
|
return "selected_range_unreadable", "none"
|
case strings.Contains(message, "selected range is out of bounds"):
|
return "selected_range_out_of_bounds", "none"
|
case strings.Contains(message, "replacement failed"):
|
return "ax_value_replacement_failed", extractAXCodeSummary(result.Message)
|
case strings.Contains(message, "not observable after write"):
|
return "ax_value_verify_failed", extractAXCodeSummary(result.Message)
|
default:
|
return "direct_insert_failed", extractAXCodeSummary(result.Message)
|
}
|
}
|
|
func extractAXCodeSummary(message string) string {
|
allowed := map[string]bool{
|
"frontmost": true,
|
"focused_app": true,
|
"focused_app_element": true,
|
"range": true,
|
"system": true,
|
"value": true,
|
"verify": true,
|
}
|
parts := strings.FieldsFunc(message, func(r rune) bool {
|
return r == ' ' || r == ',' || r == ';' || r == '\n' || r == '\t'
|
})
|
codes := make([]string, 0, len(parts))
|
for _, part := range parts {
|
key, value, ok := strings.Cut(strings.Trim(part, ".:"), "=")
|
if !ok || !allowed[key] || !isSignedDecimal(value) {
|
continue
|
}
|
codes = append(codes, key+"="+value)
|
}
|
if len(codes) == 0 {
|
return "none"
|
}
|
return strings.Join(codes, " ")
|
}
|
|
func isSignedDecimal(value string) bool {
|
if value == "" {
|
return false
|
}
|
if value[0] == '-' || value[0] == '+' {
|
value = value[1:]
|
}
|
if value == "" {
|
return false
|
}
|
for _, r := range value {
|
if r < '0' || r > '9' {
|
return false
|
}
|
}
|
return true
|
}
|