import Foundation import Network import SwiftUI import AppKit import Darwin struct SingerHTTPRequest: Sendable { var method: String;var target: String;var headers: [String:String];var body: Data } struct SingerHTTPResponse: Sendable { var status: Int = 200;var type = "application/json; charset=utf-8";var body: Data static func json(_ value: Any,status: Int = 200) -> Self { .init(status:status,body:(try? JSONSerialization.data(withJSONObject:value,options:[.sortedKeys])) ?? Data("{}".utf8)) } var wire: Data { let reason = [200:"OK",400:"Bad Request",401:"Unauthorized",403:"Forbidden",404:"Not Found",405:"Method Not Allowed",413:"Payload Too Large",500:"Internal Server Error",503:"Service Unavailable"][status] ?? "Error" let header = "HTTP/1.1 \(status) \(reason)\r\nContent-Type: \(type)\r\nContent-Length: \(body.count)\r\nConnection: close\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\nContent-Security-Policy: default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'\r\n\r\n" return Data(header.utf8)+body } } enum SingerHTTPParser { static func parse(_ data: Data) throws -> SingerHTTPRequest? { guard data.count <= 32768 else { throw LibraryError.message("Request too large") } guard let boundary = data.range(of:Data([13,10,13,10])) else { if data.count > 8192 { throw LibraryError.message("Headers too large") };return nil } guard boundary.lowerBound <= 8192,let header = String(data:data[..= 0,n <= 16384 else { throw LibraryError.message("Invalid body size") };length = n } else { length = 0 } let end = boundary.upperBound+length guard data.count >= end else { return nil } guard data.count == end else { throw LibraryError.message("Pipelined requests are unsupported") } return .init(method:start[0],target:start[1],headers:headers,body:Data(data[boundary.upperBound.. SingerHTTPResponse init(handler: @escaping @Sendable (SingerHTTPRequest) async -> SingerHTTPResponse) { self.handler = handler } func start(port: UInt16,loopback: Bool,state: @escaping @Sendable (Bool,String?) -> Void) throws { let parameters = NWParameters.tcp if loopback { parameters.requiredInterfaceType = .loopback } let listener = try NWListener(using:parameters,on:NWEndpoint.Port(rawValue:port)!) self.listener = listener listener.stateUpdateHandler = { value in switch value { case .ready:state(true,nil);case .failed(let error):state(false,error.localizedDescription);case .cancelled:state(false,nil);default:break } } listener.newConnectionHandler = { [weak self] connection in guard let self else { connection.cancel();return } if Date().timeIntervalSince(self.second) >= 1 { self.second = Date();self.requestsThisSecond = 0 };self.requestsThisSecond += 1 guard self.connections.count < 48,self.requestsThisSecond <= 80 else { connection.cancel();return } let id = UUID();self.connections[id] = connection;connection.start(queue:self.queue) self.queue.asyncAfter(deadline:.now()+8) { [weak self] in if let connection = self?.connections.removeValue(forKey:id) { connection.cancel() } } self.receive(connection,id:id,data:Data()) } listener.start(queue:queue) } private func receive(_ connection: NWConnection,id: UUID,data: Data) { connection.receive(minimumIncompleteLength:1,maximumLength:4096) { [weak self] bytes,_,complete,error in guard let self,self.connections[id] != nil else { return } var data = data;if let bytes { data.append(bytes) } do { if let request = try SingerHTTPParser.parse(data) { Task { let response = await self.handler(request) self.queue.async { guard self.connections[id] != nil else { return };connection.send(content:response.wire,completion:.contentProcessed { _ in self.queue.async { self.connections.removeValue(forKey:id);connection.cancel() } }) } } } else if complete || error != nil { self.connections.removeValue(forKey:id);connection.cancel() } else { self.receive(connection,id:id,data:data) } } catch { connection.send(content:SingerHTTPResponse.json(["error":"Invalid or oversized request"],status:400).wire,completion:.contentProcessed { _ in self.queue.async { self.connections.removeValue(forKey:id);connection.cancel() } }) } } } func stop() { queue.async { self.listener?.cancel();self.listener = nil;for connection in self.connections.values { connection.cancel() };self.connections.removeAll() } } deinit { listener?.cancel() } } @MainActor final class SingerWebServer: ObservableObject { @Published private(set) var running = false @Published private(set) var starting = false @Published private(set) var error: String? @Published private(set) var port: UInt16 = 8080 private var transport: SingerTransport? private var generation = UUID() func start(port: UInt16,loopback: Bool = false,handler: @escaping @Sendable (SingerHTTPRequest) async -> SingerHTTPResponse) { guard transport == nil,port >= 1024 else { return } let token = UUID();generation = token;starting = true;error = nil;self.port = port let transport = SingerTransport(handler:handler);self.transport = transport do { try transport.start(port:port,loopback:loopback) { [weak self] running,error in Task { @MainActor in guard let self,self.generation == token else { return };self.running = running;self.starting = false;self.error = error if error != nil { self.transport?.stop();self.transport = nil } } } } catch { self.error = error.localizedDescription;self.transport = nil;starting = false } } func stop() { generation = UUID();transport?.stop();transport = nil;running = false;starting = false } var localURL: String { var address = "localhost",head: UnsafeMutablePointer? if getifaddrs(&head) == 0 { defer { freeifaddrs(head) };var cursor = head while let entry = cursor { defer { cursor = entry.pointee.ifa_next } guard let socket = entry.pointee.ifa_addr,socket.pointee.sa_family == UInt8(AF_INET),entry.pointee.ifa_flags & UInt32(IFF_LOOPBACK) == 0 else { continue } var host = [CChar](repeating:0,count:Int(NI_MAXHOST)) if getnameinfo(socket,socklen_t(socket.pointee.sa_len),&host,socklen_t(host.count),nil,0,NI_NUMERICHOST) == 0 { address = String(cString:host);break } } } return "http://\(address):\(port)" } deinit { transport?.stop() } } @MainActor extension AppModel { func startSingerServer(port: UInt16? = nil,loopback: Bool = false) { guard currentVenue != nil else { error = "Choose a venue first";return } singerServer.start(port:port ?? UInt16(studio.serverPort),loopback:loopback) { [weak self] request in guard let self else { return .json(["error":"Host unavailable"],status:503) } return await self.singerRequest(request) } } func singerRequest(_ request: SingerHTTPRequest) -> SingerHTTPResponse { guard let components = URLComponents(string:"http://localhost"+request.target) else { return .json(["error":"Invalid URL"],status:400) } if request.method == "GET",components.path == "/" { return .init(type:"text/html; charset=utf-8",body:Data(Self.singerHTML.utf8)) } guard components.path.hasPrefix("/api/"),["GET","POST"].contains(request.method) else { return .json(["error":"Not found"],status:404) } if let origin = request.headers["origin"],origin != "http://"+(request.headers["host"] ?? "") { return .json(["error":"Origin is not allowed"],status:403) } guard let library,let currentVenue,let auth = request.headers["authorization"],auth.hasPrefix("Bearer "),let singer = try? library.singerForToken(String(auth.dropFirst(7)),venue:currentVenue) else { return .json(["error":"Your singer invitation is missing, expired, or for another venue"],status:401) } func songJSON(_ song: Song) -> [String:Any] { let preference = (try? library.preference(song:song.id,singer:singer)) ?? .init();return ["id":song.id,"title":song.title,"artist":song.artist,"album":song.album,"duration":song.duration,"pitch":preference.pitch,"tempo":preference.tempo,"rating":preference.rating,"favourite":favourites[singer]?.contains(song.id) == true] } do { if request.method == "GET",components.path == "/api/catalog" { let q = components.queryItems?.first{$0.name == "q"}?.value ?? "" guard q.count <= 300 else { throw LibraryError.message("Search is too long") } let offset = max(0,min(100000,Int(components.queryItems?.first{$0.name == "offset"}?.value ?? "0") ?? 0)) let matches = songs.filter{!$0.audio.hasPrefix("showscreen:") && SongSearch.matches($0,query:q)} return .json(["songs":Array(matches.dropFirst(offset).prefix(50)).map(songJSON),"total":matches.count,"offset":offset]) } if request.method == "GET",components.path == "/api/me" { let tonight = tonightSongs(for:singer),repertoire = self.songs(for:singer) return .json(["singer":singers.first{$0.id == singer}?.name ?? "Singer","venue":currentVenueName,"tonight":tonight.map(songJSON),"repertoire":repertoire.map(songJSON),"history":try library.history(venue:currentVenue,singer:singer).prefix(100).map{["title":$0.song,"date":$0.date]}]) } guard request.method == "POST",request.headers["content-type"]?.lowercased().hasPrefix("application/json") == true,let body = try JSONSerialization.jsonObject(with:request.body) as? [String:Any],let number = body["song"] as? NSNumber else { throw LibraryError.message("Send a song and JSON action") } let id = number.int64Value guard number.doubleValue == Double(id),let song = songs.first(where:{$0.id == id}),!song.audio.hasPrefix("showscreen:") else { throw LibraryError.message("Song unavailable") } if components.path == "/api/tonight" { switch body["action"] as? String { case "add":try library.addTonight(song:id,singer:singer,venue:currentVenue) case "remove":try library.removeTonight(song:id,singer:singer,venue:currentVenue) case "move":try library.placeTonight(song:id,singer:singer,venue:currentVenue,before:(body["before"] as? NSNumber)?.int64Value,addIfNeeded:false) default:throw LibraryError.message("Unknown list action") } } else if components.path == "/api/preferences" { guard let pitch = body["pitch"] as? Double,let tempo = body["tempo"] as? Double,let rating = body["rating"] as? Int else { throw LibraryError.message("Pitch, tempo and rating are required") } try library.savePreference(song:id,singer:singer,value:.init(pitch:pitch,tempo:tempo,rating:rating)) } else if components.path == "/api/favourite" { guard let included = body["included"] as? Bool else { throw LibraryError.message("Favourite value required") };try library.setFavourite(song:id,singer:singer,included:included) } else { return .json(["error":"Not found"],status:404) } try refresh();return .json(["ok":true]) } catch { return .json(["error":error.localizedDescription],status:400) } } static let singerHTML = #""" KJMix Pro · Singer

KJMix Pro

Your songs. Your next performance.

My Song Settings

"""# } struct SingerServerPane: View { @ObservedObject var model: AppModel @ObservedObject var server: SingerWebServer @State private var port = "8080" @State private var address = "" @State private var invitation: String? var body: some View { VStack(alignment:.leading,spacing:18) { HStack { Circle().fill(server.running ? .green : .secondary).frame(width:8,height:8);Text(server.running ? "Singer Web is running" : server.starting ? "Starting…" : "Singer Web is off").font(.headline);Spacer() TextField("Port",text:$port).frame(width:80).disabled(server.running || server.starting) Button(server.running || server.starting ? "Stop Server" : "Start Server") { if server.running || server.starting { server.stop() } else if let port = UInt16(port),port >= 1024 { model.startSingerServer(port:port) } else { model.error = "Use a port from 1024 to 65535" } }.disabled(model.currentVenue == nil) } Text("Singers on the same network can search your library, manage their own Tonight and favourites, and save key, tempo and ratings. Each invitation is restricted to one singer at this venue. The server stays off until you start it.").foregroundStyle(.secondary) HStack { Text("Address");TextField("http://your-mac.local:8080",text:$address);Button("Open") { if let url = URL(string:address),["http","https"].contains(url.scheme ?? "") { NSWorkspace.shared.open(url) } }.disabled(!server.running) } Text("Use this Mac’s local hostname or Wi-Fi IP address, including the port. This server uses HTTP on your local network; invitations grant access to that singer’s lists. Do not publish them.").font(.caption).foregroundStyle(.secondary) if let error = server.error { Text(error).foregroundStyle(.red) } List(model.venueSingers) { singer in HStack { Text(singer.name);Spacer() Button("Copy Invitation") { model.perform { guard let library = model.library,let venue = model.currentVenue,let url = URL(string:address),url.scheme == "http",url.host != nil else { throw LibraryError.message("Enter this Mac’s local server address first") };let token = try library.singerToken(singer:singer.id,venue:venue);let value = address.trimmingCharacters(in:CharacterSet(charactersIn:"/"))+"/#"+token;NSPasteboard.general.clearContents();NSPasteboard.general.setString(value,forType:.string);invitation = "Invitation copied for "+singer.name } }.disabled(!server.running) Button("Revoke") { model.perform { guard let venue = model.currentVenue else { return };try model.library?.revokeSinger(singer:singer.id,venue:venue);invitation = "Invitation revoked for "+singer.name } } }.padding(.vertical,4) } if let invitation { Text(invitation).font(.callout).foregroundStyle(.secondary) } }.padding(22).background(Color(nsColor:.windowBackgroundColor)).onAppear { port = String(server.running ? Int(server.port) : model.studio.serverPort);address = server.localURL }.onStudioChange(of:server.running) { running in if running { address = server.localURL } } } }