//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 }