Fill Suno fields with accessibility adapter

This commit is contained in:
diyaa
2026-09-14 22:29:53 +02:00
parent 448d390671
commit 3fced12bef
6 changed files with 229 additions and 15 deletions
@@ -0,0 +1,111 @@
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 = NSWorkspace.shared.frontmostApplication 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 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]
.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
}
}
@@ -4,11 +4,20 @@ import SwiftUI
struct FinalReviewView: View {
@Binding var project: SongProject
@Environment(\.openURL) private var openURL
private let sunoFieldFiller: any SunoFieldFilling
@State private var newFieldKey = ""
@State private var newFieldValue = ""
@State private var handoffStatus: HandoffStatus?
init(
project: Binding<SongProject>,
sunoFieldFiller: any SunoFieldFilling = MacOSSunoAccessibilityFieldFiller()
) {
_project = project
self.sunoFieldFiller = sunoFieldFiller
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ArabicLyricsReviewView(project: $project)
@@ -196,21 +205,37 @@ struct FinalReviewView: View {
}
openURL(SunoHandoffDestination.createPageURL) { accepted in
handoffStatus = accepted
? .opened
: .failed("Unable to open the Suno Create page.")
guard accepted else {
handoffStatus = .failed("Unable to open the Suno Create page.")
return
}
let payload = SunoHandoffPayload(
title: project.title,
lyrics: output.lyricsText,
stylePrompt: output.stylePrompt
)
Task {
handoffStatus = .fieldFill(await sunoFieldFiller.fill(payload))
}
}
}
}
private enum HandoffStatus: Equatable {
case opened
case fieldFill(SunoFieldFillReport)
case failed(String)
var title: String {
switch self {
case .opened:
return "Suno Create opened in your browser."
case let .fieldFill(report):
let fieldsToCopy = report.results.compactMap { field, result in
if case .copyRequired = result { return field.displayName }
return nil
}
return fieldsToCopy.isEmpty
? "Suno fields filled in your browser."
: "Copy these fields into Suno: \(fieldsToCopy.joined(separator: ", "))."
case let .failed(message):
return message
}
@@ -218,8 +243,10 @@ private enum HandoffStatus: Equatable {
var systemImage: String {
switch self {
case .opened:
return "checkmark.circle.fill"
case let .fieldFill(report):
return report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
? "exclamationmark.triangle.fill"
: "checkmark.circle.fill"
case .failed:
return "exclamationmark.triangle.fill"
}
@@ -227,8 +254,10 @@ private enum HandoffStatus: Equatable {
var foregroundStyle: Color {
switch self {
case .opened:
return .green
case let .fieldFill(report):
return report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
? .orange
: .green
case .failed:
return .red
}
@@ -7,3 +7,66 @@ public protocol SunoHandoffService: Sendable {
public enum SunoHandoffDestination {
public static let createPageURL = URL(string: "https://suno.com/create")!
}
public struct SunoHandoffPayload: Equatable, Sendable {
public let title: String
public let lyrics: String
public let stylePrompt: String
public init(title: String, lyrics: String, stylePrompt: String) {
self.title = title
self.lyrics = lyrics
self.stylePrompt = stylePrompt
}
public func value(for field: SunoHandoffField) -> String {
switch field {
case .title:
return title
case .lyrics:
return lyrics
case .stylePrompt:
return stylePrompt
}
}
}
public enum SunoHandoffField: String, CaseIterable, Equatable, Identifiable, Sendable {
case title
case lyrics
case stylePrompt
public var id: String { rawValue }
public var displayName: String {
switch self {
case .title:
return "Title"
case .lyrics:
return "Lyrics"
case .stylePrompt:
return "Style prompt"
}
}
}
public enum SunoFieldFillResult: Equatable, Sendable {
case filled
case copyRequired(String)
}
public struct SunoFieldFillReport: Equatable, Sendable {
public let results: [SunoHandoffField: SunoFieldFillResult]
public init(results: [SunoHandoffField: SunoFieldFillResult]) {
self.results = results
}
public static func copyRequired(for fields: [SunoHandoffField], reason: String) -> SunoFieldFillReport {
SunoFieldFillReport(results: Dictionary(uniqueKeysWithValues: fields.map { ($0, .copyRequired(reason)) }))
}
}
public protocol SunoFieldFilling: Sendable {
func fill(_ payload: SunoHandoffPayload) async -> SunoFieldFillReport
}