package textoutput import ( "strings" "sync" "time" "unicode/utf8" "voicesnap/internal/axinput" "voicesnap/internal/logger" ) type Paster interface { Paste(text string, keepClipboard bool) error TypeText(text string) error } type Result struct { OK bool `json:"ok"` Text string `json:"text,omitempty"` Method string `json:"method"` Message string `json:"message"` Target string `json:"target,omitempty"` NeedsPermission bool `json:"needsPermission"` DirectInserted bool `json:"directInserted"` FallbackAvailable bool `json:"fallbackAvailable"` CreatedAt time.Time `json:"createdAt"` } type AutoPasteGuard struct { OK bool Identity string Message string } type OutputOptions struct { KeepClipboard bool AutoPasteExperiment bool AutoPasteGuard AutoPasteGuard } type Router struct { mu sync.Mutex paster Paster driver axinput.Driver last Result } func NewRouter(paster Paster) *Router { return newPlatformRouter(paster) } func (r *Router) LastResult() Result { r.mu.Lock() defer r.mu.Unlock() return r.last } func (r *Router) CaptureAutoPasteGuard() AutoPasteGuard { if r.driver == nil { return AutoPasteGuard{OK: false, Message: "AX driver is unavailable"} } identity := r.driver.FrontmostAppIdentity() return AutoPasteGuard{OK: identity.OK, Identity: identity.Identity, Message: identity.Message} } func (r *Router) remember(result Result) Result { if result.CreatedAt.IsZero() { result.CreatedAt = time.Now() } r.mu.Lock() r.last = result r.mu.Unlock() return result } func (r *Router) PrepareLastClipboardFallback() axinput.ClipboardResult { r.mu.Lock() last := r.last r.mu.Unlock() if !last.FallbackAvailable || last.Text == "" { logger.Info("Text output fallback prepare route status=no_pending fallback_available=%t has_text=%t", last.FallbackAvailable, last.Text != "") return axinput.ClipboardResult{OK: false, Message: "no pending text output fallback is available"} } if !clipboardFallbackSafeForTarget(last) { logger.Info("Text output fallback prepare route status=blocked_non_writable text_runes=%d", utf8.RuneCountInString(last.Text)) return axinput.ClipboardResult{OK: false, Message: "clipboard fallback is disabled for this non-writable target"} } if r.driver == nil { logger.Info("Text output fallback prepare route status=no_driver text_runes=%d", utf8.RuneCountInString(last.Text)) return axinput.ClipboardResult{OK: false, Message: "clipboard fallback is unavailable for this output route"} } result := r.driver.PrepareClipboardFallback(last.Text) logger.Info( "Text output fallback prepare route status=driver_result ok=%t prepared=%t token_present=%t text_runes=%d", result.OK, result.Prepared, result.Token != "", utf8.RuneCountInString(last.Text), ) return result } func (r *Router) RestoreClipboardFallback(token string) axinput.ClipboardResult { if r.driver == nil { logger.Info("Text output fallback restore route status=no_driver token_present=%t", token != "") return axinput.ClipboardResult{OK: false, Message: "clipboard fallback is unavailable for this output route"} } result := r.driver.RestoreClipboardFallback(token) logger.Info( "Text output fallback restore route status=driver_result ok=%t restored=%t token_present=%t", result.OK, result.Restored, token != "", ) return result } func clipboardFallbackSafeForTarget(result Result) bool { target := strings.TrimSpace(strings.ToLower(result.Target)) if target == "" || target == "target=unknown" { return true } for _, marker := range []string{ "app=terminal", "app=iterm", "app=iterm2", "app=warp", "role=axterminal", "subrole=axterminal", "console", "shell", } { if strings.Contains(target, marker) { return false } } return true } func autoPasteSafeForIdentity(identity string) bool { normalized := strings.ToLower(strings.TrimSpace(identity)) if normalized == "" { return false } for _, marker := range []string{ "com.apple.terminal", "com.googlecode.iterm2", "iterm", "terminal", "warp", "shell", "console", } { if strings.Contains(normalized, marker) { return false } } return true }