From cd066d0a827e98e7511a321a73bcfd5b353511de Mon Sep 17 00:00:00 2001 From: diyaa Date: Mon, 14 Sep 2026 22:51:16 +0200 Subject: [PATCH] Embed Suno handoff web surface --- .../MacOSSunoAccessibilityFieldFiller.swift | 120 --------------- .../Integrations/SunoHandoffSheet.swift | 141 ++++++++++++++++++ .../Presentation/FinalReviewView.swift | 41 ++--- .../Suno/SunoHandoffService.swift | 2 +- 4 files changed, 158 insertions(+), 146 deletions(-) delete mode 100644 Sources/MusicAssistantApp/Integrations/MacOSSunoAccessibilityFieldFiller.swift create mode 100644 Sources/MusicAssistantApp/Integrations/SunoHandoffSheet.swift diff --git a/Sources/MusicAssistantApp/Integrations/MacOSSunoAccessibilityFieldFiller.swift b/Sources/MusicAssistantApp/Integrations/MacOSSunoAccessibilityFieldFiller.swift deleted file mode 100644 index 70f04a6..0000000 --- a/Sources/MusicAssistantApp/Integrations/MacOSSunoAccessibilityFieldFiller.swift +++ /dev/null @@ -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 - } -} diff --git a/Sources/MusicAssistantApp/Integrations/SunoHandoffSheet.swift b/Sources/MusicAssistantApp/Integrations/SunoHandoffSheet.swift new file mode 100644 index 0000000..fd3fd69 --- /dev/null +++ b/Sources/MusicAssistantApp/Integrations/SunoHandoffSheet.swift @@ -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); + })(); + """ + } + } +} diff --git a/Sources/MusicAssistantApp/Presentation/FinalReviewView.swift b/Sources/MusicAssistantApp/Presentation/FinalReviewView.swift index 6e40836..30df502 100644 --- a/Sources/MusicAssistantApp/Presentation/FinalReviewView.swift +++ b/Sources/MusicAssistantApp/Presentation/FinalReviewView.swift @@ -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, - 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]? { diff --git a/Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift b/Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift index 2039805..04c46c2 100644 --- a/Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift +++ b/Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift @@ -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