Embed Suno handoff web surface

This commit is contained in:
diyaa
2026-09-14 22:51:16 +02:00
parent 3ed4046d73
commit cd066d0a82
4 changed files with 158 additions and 146 deletions
@@ -1,120 +0,0 @@
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
}
}
@@ -0,0 +1,141 @@
import MusicAssistantCore
import SwiftUI
import WebKit
struct SunoHandoffSheet: View {
let payload: SunoHandoffPayload
let onComplete: (SunoFieldFillReport) -> Void
@Environment(\.dismiss) private var dismiss
@State private var report: SunoFieldFillReport?
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Send to Suno")
.font(.headline)
Spacer()
Button("Close") {
dismiss()
}
}
.padding(12)
Divider()
SunoCreateWebView(payload: payload) { report in
self.report = report
onComplete(report)
}
if let report {
HStack {
Label(statusText(for: report), systemImage: statusIcon(for: report))
.font(.caption)
.foregroundStyle(statusColor(for: report))
Spacer()
}
.padding(12)
}
}
.frame(minWidth: 900, minHeight: 650)
}
private func statusText(for report: SunoFieldFillReport) -> String {
report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
? "Some fields need manual copying."
: "Suno fields are ready."
}
private func statusIcon(for report: SunoFieldFillReport) -> String {
report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
? "exclamationmark.triangle.fill"
: "checkmark.circle.fill"
}
private func statusColor(for report: SunoFieldFillReport) -> Color {
report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
? .orange
: .green
}
}
private struct SunoCreateWebView: NSViewRepresentable {
let payload: SunoHandoffPayload
let onReport: (SunoFieldFillReport) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(payload: payload, onReport: onReport)
}
func makeNSView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .default()
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = context.coordinator
webView.load(URLRequest(url: SunoHandoffDestination.createPageURL))
return webView
}
func updateNSView(_ webView: WKWebView, context: Context) {}
final class Coordinator: NSObject, WKNavigationDelegate {
let payload: SunoHandoffPayload
let onReport: (SunoFieldFillReport) -> Void
init(payload: SunoHandoffPayload, onReport: @escaping (SunoFieldFillReport) -> Void) {
self.payload = payload
self.onReport = onReport
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
webView.evaluateJavaScript(fillScript()) { value, _ in
guard let json = value as? String,
let data = json.data(using: .utf8),
let values = try? JSONSerialization.jsonObject(with: data) as? [String: Bool] else {
self.onReport(.copyRequired(for: SunoHandoffField.allCases, reason: "Suno fields could not be inspected."))
return
}
let results: [SunoHandoffField: SunoFieldFillResult] = Dictionary(
uniqueKeysWithValues: SunoHandoffField.allCases.map { field in
(field, values[field.rawValue] == true
? SunoFieldFillResult.filled
: SunoFieldFillResult.copyRequired("The \(field.displayName) field was not found."))
}
)
self.onReport(SunoFieldFillReport(results: results))
}
}
private func fillScript() -> String {
let payloadData = try! JSONEncoder().encode(payload)
let payloadJSON = String(data: payloadData, encoding: .utf8)!
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "\\'")
return """
(() => {
const payload = JSON.parse('\(payloadJSON)');
const fields = { title: false, lyrics: false, stylePrompt: false };
const custom = [...document.querySelectorAll('button,[role="button"]')].find(e => /custom/i.test(e.innerText));
if (custom) custom.click();
const candidates = [...document.querySelectorAll('input,textarea,[contenteditable="true"]')];
const label = e => [e.getAttribute('aria-label'), e.getAttribute('placeholder'), e.name, e.id].filter(Boolean).join(' ');
const set = (terms, value, key) => {
const element = candidates.find(e => terms.some(term => label(e).toLowerCase().includes(term)));
if (!element || !value) return;
if (element.isContentEditable) element.textContent = value; else element.value = value;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
fields[key] = true;
};
set(['title'], payload.title, 'title');
set(['lyric'], payload.lyrics, 'lyrics');
set(['style','prompt'], payload.stylePrompt, 'stylePrompt');
return JSON.stringify(fields);
})();
"""
}
}
}
@@ -4,20 +4,11 @@ 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
}
@State private var sunoPayload: SunoHandoffPayload?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -119,6 +110,16 @@ struct FinalReviewView: View {
.help("Add field")
}
}
.sheet(isPresented: Binding(
get: { sunoPayload != nil },
set: { if !$0 { sunoPayload = nil } }
)) {
if let sunoPayload {
SunoHandoffSheet(payload: sunoPayload) { report in
handoffStatus = .fieldFill(report)
}
}
}
}
private var additionalFieldKeys: [String] {
@@ -216,21 +217,11 @@ struct FinalReviewView: View {
return
}
openURL(SunoHandoffDestination.createPageURL) { accepted in
guard accepted else {
handoffStatus = .failed("Unable to open the Suno Create page.")
return
}
let payload = SunoHandoffPayload(
sunoPayload = SunoHandoffPayload(
title: project.title,
lyrics: output.lyricsText,
stylePrompt: output.stylePrompt
)
Task {
handoffStatus = .fieldFill(await sunoFieldFiller.fill(payload))
}
}
}
private var fieldsToCopy: [SunoHandoffField]? {
@@ -8,7 +8,7 @@ public enum SunoHandoffDestination {
public static let createPageURL = URL(string: "https://suno.com/create")!
}
public struct SunoHandoffPayload: Equatable, Sendable {
public struct SunoHandoffPayload: Codable, Equatable, Sendable {
public let title: String
public let lyrics: String
public let stylePrompt: String