import AppKit import ApplicationServices import MusicAssistantCore struct MacOSSunoAccessibilityFieldFiller: SunoFieldFilling { func fill(_ payload: SunoHandoffPayload) async -> SunoFieldFillReport { guard AXIsProcessTrusted() else { return .copyRequired( for: SunoHandoffField.allCases, reason: "Allow Accessibility access in System Settings, then reopen AI Music Studio." ) } try? await Task.sleep(for: .seconds(2)) guard let application = sunoBrowserApplication() else { return .copyRequired(for: SunoHandoffField.allCases, reason: "The Suno browser window was not found.") } let processIdentifier = application.processIdentifier pressCustomMode(in: accessibilityElements(for: processIdentifier)) try? await Task.sleep(for: .milliseconds(500)) let elements = accessibilityElements(for: processIdentifier) return SunoFieldFillReport(results: Dictionary(uniqueKeysWithValues: SunoHandoffField.allCases.map { field in (field, fill(field, with: payload.value(for: field), in: elements)) })) } private func pressCustomMode(in elements: [AXUIElement]) { guard let customMode = elements.first(where: { element in role(of: element) == kAXButtonRole as String && labels(for: element).contains { $0.localizedCaseInsensitiveContains("custom") } }) else { return } AXUIElementPerformAction(customMode, kAXPressAction as CFString) } private func fill(_ field: SunoHandoffField, with value: String, in elements: [AXUIElement]) -> SunoFieldFillResult { guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return .filled } guard let element = elements.first(where: { matches(field, element: $0) }) else { return .copyRequired("The \(field.displayName) field was not found.") } var isSettable = DarwinBoolean(false) guard AXUIElementIsAttributeSettable(element, kAXValueAttribute as CFString, &isSettable) == .success, isSettable.boolValue, AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, value as CFTypeRef) == .success else { return .copyRequired("The \(field.displayName) field could not be filled.") } return .filled } private func matches(_ field: SunoHandoffField, element: AXUIElement) -> Bool { let role = role(of: element) guard role == kAXTextFieldRole as String || role == kAXTextAreaRole as String else { return false } let labels = labels(for: element) switch field { case .title: return labels.contains { $0.localizedCaseInsensitiveContains("title") } case .lyrics: return labels.contains { $0.localizedCaseInsensitiveContains("lyrics") } case .stylePrompt: return labels.contains { label in label.localizedCaseInsensitiveContains("style") || label.localizedCaseInsensitiveContains("prompt") } } } private func accessibilityElements(for processIdentifier: pid_t) -> [AXUIElement] { descendants(of: AXUIElementCreateApplication(processIdentifier)) } private func sunoBrowserApplication() -> NSRunningApplication? { guard let applicationURL = NSWorkspace.shared.urlForApplication(toOpen: SunoHandoffDestination.createPageURL), let bundleIdentifier = Bundle(url: applicationURL)?.bundleIdentifier else { return NSWorkspace.shared.frontmostApplication } return NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).first ?? NSWorkspace.shared.frontmostApplication } private func descendants(of root: AXUIElement) -> [AXUIElement] { var elements: [AXUIElement] = [] var pending = [root] while let element = pending.popLast() { elements.append(element) pending.append(contentsOf: children(of: element)) } return elements } private func children(of element: AXUIElement) -> [AXUIElement] { attribute(kAXChildrenAttribute, from: element) as? [AXUIElement] ?? [] } private func role(of element: AXUIElement) -> String? { attribute(kAXRoleAttribute, from: element) as? String } private func labels(for element: AXUIElement) -> [String] { [kAXTitleAttribute, kAXDescriptionAttribute, kAXHelpAttribute, kAXIdentifierAttribute, kAXPlaceholderValueAttribute] .compactMap { attribute($0, from: element) as? String } } private func attribute(_ name: String, from element: AXUIElement) -> Any? { var value: CFTypeRef? guard AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success else { return nil } return value } }