Embed Suno handoff web surface
This commit is contained in:
@@ -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(
|
||||
title: project.title,
|
||||
lyrics: output.lyricsText,
|
||||
stylePrompt: output.stylePrompt
|
||||
)
|
||||
Task {
|
||||
handoffStatus = .fieldFill(await sunoFieldFiller.fill(payload))
|
||||
}
|
||||
}
|
||||
sunoPayload = SunoHandoffPayload(
|
||||
title: project.title,
|
||||
lyrics: output.lyricsText,
|
||||
stylePrompt: output.stylePrompt
|
||||
)
|
||||
}
|
||||
|
||||
private var fieldsToCopy: [SunoHandoffField]? {
|
||||
|
||||
Reference in New Issue
Block a user