212 lines
23 KiB
Swift
212 lines
23 KiB
Swift
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[..<boundary.lowerBound],encoding:.utf8) else { throw LibraryError.message("Invalid HTTP headers") }
|
||
let lines = header.components(separatedBy:"\r\n"),start = lines[0].split(separator:" ").map(String.init)
|
||
guard start.count == 3,["HTTP/1.1","HTTP/1.0"].contains(start[2]),start[1].hasPrefix("/"),!start[1].hasPrefix("//"),start[1].count <= 2048 else { throw LibraryError.message("Invalid HTTP request") }
|
||
var headers = [String:String]()
|
||
for line in lines.dropFirst() {
|
||
guard let colon = line.firstIndex(of:":"),!line.hasPrefix(" "),!line.hasPrefix("\t") else { throw LibraryError.message("Invalid HTTP header") }
|
||
let name = line[..<colon].lowercased(),value = line[line.index(after:colon)...].trimmingCharacters(in:.whitespaces)
|
||
guard !name.isEmpty,headers[name] == nil else { throw LibraryError.message("Duplicate HTTP header") };headers[name] = value
|
||
}
|
||
guard headers["transfer-encoding"] == nil else { throw LibraryError.message("Chunked requests are unsupported") }
|
||
let length: Int
|
||
if let text = headers["content-length"] { guard let n = Int(text),n >= 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..<end]))
|
||
}
|
||
}
|
||
private final class SingerTransport: @unchecked Sendable {
|
||
let queue = DispatchQueue(label:"KJMixPro.SingerHTTP")
|
||
var listener: NWListener?
|
||
var connections = [UUID:NWConnection]()
|
||
var requestsThisSecond = 0
|
||
var second = Date()
|
||
let handler: @Sendable (SingerHTTPRequest) async -> 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<ifaddrs>?
|
||
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 = #"""
|
||
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>KJMix Pro · Singer</title>
|
||
<style>:root{color-scheme:dark;font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#111318;color:#f5f5f7}body{max-width:850px;margin:auto;padding:22px}h1{font-size:25px}h2{font-size:19px}input,select,button{font:inherit;border:1px solid #393d48;border-radius:9px;padding:10px;background:#242831;color:inherit}button{cursor:pointer}button.primary{background:#087af5}input[type=search]{width:100%;box-sizing:border-box}.row{padding:16px 0;border-bottom:1px solid #30333b;display:flex;gap:12px;align-items:center}.details{flex:1;min-width:0}.sub{color:#a9afbd;font-size:14px;margin-top:6px}.actions{display:flex;gap:6px;flex-wrap:wrap}nav{display:flex;gap:8px;flex-wrap:wrap;margin:20px 0}.error{color:#ff9c9c}#login{padding:20px;background:#20232b;border-radius:12px}dialog{border:1px solid #555;border-radius:15px;background:#222630;max-width:85vw}label{display:block;margin:15px 0}button:focus-visible,input:focus-visible{outline:3px solid #479eff}@media(max-width:550px){.row{align-items:flex-start;flex-direction:column}.actions{width:100%}}</style>
|
||
<h1>KJMix Pro</h1><p id="who" class="sub">Your songs. Your next performance.</p><div id="login"><label>Singer invitation code <input id="code" type="password" autocomplete="off"></label><button class="primary" id="connect">Connect</button></div>
|
||
<div id="app" hidden><nav><button data-tab="tonight">Tonight</button><button data-tab="catalog">Find Songs</button><button data-tab="favourites">Favourites</button><button data-tab="repertoire">My Songs</button><button data-tab="history">History</button><button id="logout">Sign Out</button></nav><input id="search" type="search" placeholder="Search title, artist or album"><h2 id="heading">Tonight</h2><div id="rows"></div><button id="more" hidden>More Results</button></div><p role="status" id="status"></p>
|
||
<dialog id="settings"><h2>My Song Settings</h2><p id="settingsTitle"></p><label>Key <input id="pitch" type="number" min="-12" max="12" step="1"></label><label>Tempo % <input id="tempo" type="number" min="50" max="150" step="5"></label><label>Rating <select id="rating"><option value="0">Unrated</option><option value="1">★</option><option value="2">★★</option><option value="3">★★★</option><option value="4">★★★★</option><option value="5">★★★★★</option></select></label><button id="saveSettings" class="primary">Save</button> <button id="closeSettings">Cancel</button></dialog>
|
||
<script>
|
||
let token=location.hash.slice(1)||sessionStorage.getItem('kjmixToken')||'',tab='tonight',me,offset=0,editSong,searchTimer;
|
||
history.replaceState(null,'',location.pathname);const $=id=>document.getElementById(id);function status(text,error=false){$('status').textContent=text;$('status').className=error?'error':''}
|
||
async function api(path,data){const r=await fetch('/api/'+path,{method:data?'POST':'GET',headers:{Authorization:'Bearer '+token,...(data?{'Content-Type':'application/json'}:{})},body:data?JSON.stringify(data):undefined});const v=await r.json();if(!r.ok)throw Error(v.error||'Request failed');return v}
|
||
function el(tag,text,cls){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(cls)e.className=cls;return e}function button(text,fn){let e=el('button',text);e.onclick=()=>run(fn);return e}async function run(fn){try{status('');await fn()}catch(e){status(e.message,true)}}
|
||
async function connect(){me=await api('me');sessionStorage.setItem('kjmixToken',token);$('login').hidden=true;$('app').hidden=false;$('who').textContent=me.singer+' · '+me.venue;await render()}
|
||
async function update(){me=await api('me');await render()}
|
||
async function render(append=false){$('heading').textContent={tonight:'Tonight',catalog:'Find Songs',favourites:'Favourites',repertoire:'My Songs',history:'History'}[tab];$('search').hidden=tab==='history';if(!append)$('rows').replaceChildren();let list,total=0;if(tab==='catalog'){let v=await api('catalog?q='+encodeURIComponent($('search').value)+'&offset='+offset);list=v.songs;total=v.total}else if(tab==='history'){list=me.history}else{list=tab==='tonight'?me.tonight:me.repertoire;if(tab==='favourites')list=list.filter(x=>x.favourite);let q=$('search').value.toLowerCase();list=list.filter(x=>(x.title+' '+x.artist+' '+x.album).toLowerCase().includes(q))}
|
||
$('more').hidden=tab!=='catalog'||offset+list.length>=total;for(let song of list){let row=el('div',undefined,'row'),details=el('div',undefined,'details');details.append(el('div',song.title),el('div',tab==='history'?song.date:song.artist+' · '+song.album,'sub'));row.append(details);if(tab!=='history'){let actions=el('div',undefined,'actions');actions.append(button(tab==='tonight'?'Remove':'+ Tonight',async()=>{await api('tonight',{song:song.id,action:tab==='tonight'?'remove':'add'});await update()}),button(song.favourite?'★':'☆',async()=>{await api('favourite',{song:song.id,included:!song.favourite});await update()}),button('Key / Tempo',()=>{editSong=song;$('settingsTitle').textContent=song.title;$('pitch').value=song.pitch;$('tempo').value=Math.round(song.tempo*100);$('rating').value=song.rating;$('settings').showModal()}));if(tab==='tonight'){let i=me.tonight.findIndex(x=>x.id===song.id);if(i>0)actions.append(button('↑',async()=>{await api('tonight',{song:song.id,action:'move',before:me.tonight[i-1].id});await update()}));if(i<me.tonight.length-1)actions.append(button('↓',async()=>{await api('tonight',{song:song.id,action:'move',before:me.tonight[i+2]?.id??null});await update()}))}row.append(actions)}$('rows').append(row)}if(!list.length&&!append)$('rows').append(el('p','No songs yet. Use Find Songs to build your list.','sub'))}
|
||
$('connect').onclick=()=>{token=$('code').value.trim();run(connect)};document.querySelectorAll('[data-tab]').forEach(b=>b.onclick=()=>{tab=b.dataset.tab;offset=0;$('search').value='';run(render)});$('search').oninput=()=>{clearTimeout(searchTimer);searchTimer=setTimeout(()=>{offset=0;run(render)},350)};$('more').onclick=()=>{offset+=50;run(()=>render(true))};$('logout').onclick=()=>{sessionStorage.removeItem('kjmixToken');token='';location.reload()};$('closeSettings').onclick=()=>$('settings').close();$('saveSettings').onclick=()=>run(async()=>{await api('preferences',{song:editSong.id,pitch:Number($('pitch').value),tempo:Number($('tempo').value)/100,rating:Number($('rating').value)});$('settings').close();await update()});if(token)run(connect);
|
||
</script></html>
|
||
"""#
|
||
}
|
||
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 } }
|
||
}
|
||
}
|