Files

212 lines
23 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } }
}
}