122 lines
5.0 KiB
Swift
122 lines
5.0 KiB
Swift
import AppKit
|
|
import ApplicationServices
|
|
import MusicAssistantCore
|
|
|
|
struct MacOSSunoAccessibilityFieldFiller: SunoFieldFilling {
|
|
func fill(_ payload: SunoHandoffPayload) async -> SunoFieldFillReport {
|
|
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
|
|
guard AXIsProcessTrustedWithOptions(options) else {
|
|
return .copyRequired(
|
|
for: SunoHandoffField.allCases,
|
|
reason: "Allow Accessibility access to fill this field automatically."
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|