Files
switchnote/source/audio_upload_server.cpp
2026-10-29 03:55:40 +00:00

702 lines
40 KiB
C++

#include "audio_upload_server.hpp"
#include <algorithm>
#include <array>
#include <atomic>
#include <cctype>
#include <cstdio>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <map>
#include <new>
#include <sstream>
#include <string>
#include <vector>
#ifdef __SWITCH__
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <switch.h>
#include <unistd.h>
#endif
namespace {
#ifdef __SWITCH__
constexpr std::uint16_t kUploadPort = 8766;
constexpr std::size_t kMaxHeaderBytes = 16 * 1024;
constexpr std::size_t kMaxBgmUploadBytes = 32 * 1024 * 1024;
constexpr std::size_t kMaxSfxUploadBytes = 3 * 1024 * 1024;
struct ServerState {
Thread thread{};
std::atomic<bool> running{false};
std::atomic<bool> connected{false};
std::atomic<int> pendingTrack{-1};
std::atomic<int> uploadOutcome{0}; // 0 idle, 1 processing, 2 imported, 3 rejected
std::atomic<int> activeClient{-1};
bool photoTransfer = false;
int listenFd = -1;
std::string bindAddress;
std::string uploadDirectory;
std::string assetsRoot;
std::string noteSingular = "Switchnote";
std::string sessionCode;
std::string sessionToken;
std::uint32_t pairedAddress = 0;
unsigned int failedCodeAttempts = 0;
};
constexpr unsigned int kMaxCodeAttempts = 8;
bool validPairCode(const std::string& code) {
if (code.size() != 7 || code[3] != '-') return false;
for (std::size_t i = 0; i < code.size(); ++i)
if (i != 3 && (code[i] < '0' || code[i] > '9')) return false;
return true;
}
std::string hexToken(const std::array<std::uint8_t, 32>& bytes) {
constexpr char digits[] = "0123456789abcdef";
std::string token;
token.reserve(bytes.size() * 2);
for (const std::uint8_t byte : bytes) {
token.push_back(digits[byte >> 4]);
token.push_back(digits[byte & 15]);
}
return token;
}
std::string trim(std::string value) {
while (!value.empty() && (value.back() == '\r' || value.back() == '\n' ||
value.back() == ' ' || value.back() == '\t')) value.pop_back();
std::size_t first = 0;
while (first < value.size() && (value[first] == ' ' || value[first] == '\t')) ++first;
return value.substr(first);
}
std::string lower(std::string value) {
for (char& c : value) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return value;
}
bool constantTimeEqual(const std::string& a, const std::string& b) {
const std::size_t length = std::max(a.size(), b.size());
unsigned int difference = static_cast<unsigned int>(a.size() ^ b.size());
for (std::size_t i = 0; i < length; ++i) {
const unsigned char av = i < a.size() ? static_cast<unsigned char>(a[i]) : 0;
const unsigned char bv = i < b.size() ? static_cast<unsigned char>(b[i]) : 0;
difference |= static_cast<unsigned int>(av ^ bv);
}
return difference == 0;
}
bool sendAll(int fd, const void* bytes, std::size_t length) {
const auto* data = static_cast<const std::uint8_t*>(bytes);
std::size_t sent = 0;
while (sent < length) {
const ssize_t count = ::send(fd, data + sent, length - sent, 0);
if (count <= 0) return false;
sent += static_cast<std::size_t>(count);
}
return true;
}
void respond(int fd, int status, const char* reason, const std::string& type,
const void* body, std::size_t length, bool html = false) {
std::ostringstream headers;
headers << "HTTP/1.1 " << status << ' ' << reason << "\r\n"
<< "Content-Type: " << type << "\r\n"
<< "Content-Length: " << length << "\r\n"
<< "Connection: close\r\n"
<< "Cache-Control: no-store\r\n"
<< "X-Content-Type-Options: nosniff\r\n"
<< "Referrer-Policy: no-referrer\r\n"
<< "X-Frame-Options: DENY\r\n";
if (html) headers << "Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'self' blob:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'\r\n";
headers << "\r\n";
const std::string header = headers.str();
sendAll(fd, header.data(), header.size());
if (length) sendAll(fd, body, length);
}
void respondText(int fd, int status, const char* reason, const char* body) {
respond(fd, status, reason, "text/plain; charset=utf-8", body, std::strlen(body));
}
std::string htmlEscape(const std::string& value) {
std::string escaped;
escaped.reserve(value.size());
for (const char c : value) {
switch (c) {
case '&': escaped += "&amp;"; break;
case '<': escaped += "&lt;"; break;
case '>': escaped += "&gt;"; break;
case '\"': escaped += "&quot;"; break;
case '\'': escaped += "&#39;"; break;
default: escaped.push_back(c); break;
}
}
return escaped;
}
std::string jsEscape(const std::string& value) {
std::string escaped;
escaped.reserve(value.size());
for (const char c : value) {
if (c == '\\' || c == '\'') escaped.push_back('\\');
if (c == '\n' || c == '\r') continue;
escaped.push_back(c);
}
return escaped;
}
std::string brandedPage(std::string page, const std::string& noteSingular) {
const std::string name = noteSingular.empty() ? "Switchnote" : noteSingular;
auto replaceAll = [&](const std::string& from, const std::string& to) {
std::size_t at = 0;
while ((at = page.find(from, at)) != std::string::npos) {
page.replace(at, from.size(), to);
at += to.size();
}
};
replaceAll("' </style></head>", "</style></head>");
replaceAll("'Adding audio to the {{NOTE}}…'", "'Adding audio to the {{NOTE_JS}}…'");
replaceAll("'Audio added to the {{NOTE}}.'", "'Audio added to the {{NOTE_JS}}.'");
replaceAll("Photo added to the Switchnote.", "Photo added to the {{NOTE_JS}}.");
replaceAll("{{NOTE}}", htmlEscape(name));
replaceAll("{{NOTE_JS}}", jsEscape(name));
return page;
}
std::string uploadPage(const std::string& noteSingular) {
return brandedPage(R"HTML(<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Audio upload</title>
<style>
@font-face{font-family:Seurat;src:url('/assets/font') format('opentype')}*{box-sizing:border-box}body{margin:0;min-height:100vh;color:#252831;font-family:Seurat,'Trebuchet MS',sans-serif;background-color:#fff;background-image:linear-gradient(#d9e2ef 1px,transparent 1px),linear-gradient(90deg,#d9e2ef 1px,transparent 1px);background-size:24px 24px}.shell{max-width:920px;margin:5vh auto;padding:22px}.card{background:#fff;border:2px solid #e2e6f2;border-radius:18px;box-shadow:0 5px 0 #d5d8df,0 8px 20px #18243b22;padding:clamp(20px,4vw,34px)}p{line-height:1.5;color:#6c7280;margin-top:0}label{display:block;font-weight:700;margin:18px 0 7px}input,select{width:100%;font:inherit;padding:13px 14px;border:2px solid #d8dce5;border-radius:12px;background:#fff;color:#252831}input:focus,select:focus{outline:3px solid #ffb77e;border-color:#ff751e}.row{display:grid;grid-template-columns:1fr 1fr;gap:16px}.button{margin-top:22px;width:100%;padding:14px 18px;border:3px solid #fff;border-radius:28px;background:#ffd3a6;color:#ee6818;font:inherit;font-weight:700;box-shadow:0 4px 0 #cfd2d8,0 0 0 2px #e5e7ec;cursor:pointer;transition:transform .12s,box-shadow .12s}.button:hover{transform:translateY(-2px)}.button:active{transform:translateY(2px);box-shadow:0 1px 0 #cfd2d8}.button:disabled{opacity:.5;cursor:not-allowed}.status{min-height:28px;margin:15px 0 0;padding:10px 12px;border-radius:10px;background:#f1f3f8;color:#555d6b}.status.ok{background:#effbdd;color:#4e8d2c}.status.error{background:#fff0ed;color:#ba342d}.connected{margin:0 0 20px;padding:15px 18px;border-radius:13px;background:#effbdd;color:#4e8d2c;font-size:20px}.footer{margin:20px 4px;color:#697183;font-size:13px}[hidden]{display:none!important}@media(max-width:620px){.shell{padding:14px}.row{grid-template-columns:1fr}.card{padding:20px}}
</style></head><body><main class="shell">
<section class="card"><div id="pair"><p>Enter the code shown in Sound on your Nintendo Switch.</p>
<label for="code">Connection code</label><input id="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" maxlength="7" placeholder="000-000"><div id="pairStatus" class="status" role="status">Enter all six digits to connect.</div></div>
<div id="connected" class="connected" role="status" hidden>Connected to Nintendo Switch</div>
<div id="uploadPanel" hidden><p>Choose a track and an audio file. BGM is trimmed to 60 seconds; each sound effect is trimmed to 2 seconds.</p>
<div class="row"><div><label for="track">Track</label><select id="track"><option value="0">BGM · Music</option><option value="1">SE1 · Sound effect 1</option><option value="2">SE2 · Sound effect 2</option><option value="3">SE3 · Sound effect 3</option></select></div><div><label for="file">Audio file</label><input id="file" type="file" accept="audio/*,.wav,.mp3,.ogg,.m4a,.flac"></div></div>
<button class="button" id="send">Upload to Switch</button><div id="status" class="status" role="status">Ready to upload.</div></div></section>
<div class="footer">Ver 0.1.51</div></main>
<script>
'use strict';
const $ = id => document.getElementById(id);
let sessionToken = '', connecting = false;
function message(value, kind = '') {
const target = sessionToken ? $('status') : $('pairStatus');
target.className = 'status' + (kind ? ' ' + kind : ''); target.textContent = value;
}
async function connect(code) {
if (connecting || sessionToken) return;
connecting = true; message('Connecting…');
try {
const response = await fetch('/api/connect', {method: 'POST', headers: {'Authorization': 'Bearer ' + code}});
const reply = (await response.text()).trim();
if (!response.ok) throw new Error(reply || 'Could not connect to the Switch.');
if (!/^[a-f0-9]{64}$/.test(reply)) throw new Error('The Switch sent an invalid connection response.');
if ($('code').value !== code) return;
sessionToken = reply;
$('pair').hidden = true; $('connected').hidden = false; $('uploadPanel').hidden = false;
} catch (error) { message(error.message || 'Could not connect to the Switch.', 'error'); }
finally { connecting = false; }
}
$('code').addEventListener('input', () => {
const digits = $('code').value.replace(/\D/g, '').slice(0, 6);
$('code').value = digits.length > 3 ? digits.slice(0, 3) + '-' + digits.slice(3) : digits;
if (digits.length === 6) connect($('code').value);
});
async function wavFromFile(file, maxSeconds) {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) throw new Error('This browser cannot convert audio. Try a WAV file in a newer browser.');
const context = new AudioContextClass({sampleRate: 48000});
try {
const audio = await context.decodeAudioData(await file.arrayBuffer());
if (!audio.length) throw new Error('This audio file is empty.');
const frames = Math.min(audio.length, Math.floor(maxSeconds * audio.sampleRate));
const data = new ArrayBuffer(44 + frames * 4), view = new DataView(data);
const write = (at, text) => { for (let i = 0; i < text.length; i++) view.setUint8(at + i, text.charCodeAt(i)); };
write(0, 'RIFF'); view.setUint32(4, data.byteLength - 8, true); write(8, 'WAVE');
write(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
view.setUint16(22, 2, true); view.setUint32(24, audio.sampleRate, true);
view.setUint32(28, audio.sampleRate * 4, true); view.setUint16(32, 4, true);
view.setUint16(34, 16, true); write(36, 'data'); view.setUint32(40, frames * 4, true);
const left = audio.getChannelData(0), right = audio.getChannelData(Math.min(1, audio.numberOfChannels - 1));
for (let i = 0; i < frames; i++) {
view.setInt16(44 + i * 4, Math.round(Math.max(-1, Math.min(1, left[i])) * 32767), true);
view.setInt16(46 + i * 4, Math.round(Math.max(-1, Math.min(1, right[i])) * 32767), true);
}
return {blob: new Blob([data], {type: 'audio/wav'}), trimmed: frames < audio.length};
} finally { await context.close(); }
}
$('send').addEventListener('click', async () => {
const file = $('file').files[0], track = $('track').value;
if (!sessionToken) { message('Connect to the Switch first.', 'error'); return; }
if (!file) { message('Choose an audio file first.', 'error'); return; }
if (file.size > 128 * 1024 * 1024) { message('This source file is too large to prepare in the browser.', 'error'); return; }
$('send').disabled = true;
try {
message('Preparing ' + file.name + '…');
const prepared = await wavFromFile(file, track === '0' ? 60 : 2), wav = prepared.blob;
if (wav.size > (track === '0' ? 32 : 3) * 1024 * 1024)
throw new Error('The converted audio is too large for this track.');
message('Sending ' + file.name + '…');
const headers = {'Authorization': 'Bearer ' + sessionToken};
const response = await fetch('/api/upload', {method: 'POST', headers: {...headers, 'Content-Type': 'audio/wav', 'X-Audio-Track': track}, body: wav});
const reply = await response.text();
if (!response.ok) throw new Error(reply || 'Upload failed.');
message('Adding audio to the {{NOTE}}…');
let outcome = 'processing';
for (let i = 0; i < 60 && outcome === 'processing'; i++) {
await new Promise(resolve => setTimeout(resolve, 250));
const result = await fetch('/api/status', {headers});
if (!result.ok) throw new Error('The Sound menu was closed before import finished.');
outcome = (await result.text()).trim();
}
if (outcome !== 'imported') throw new Error(outcome === 'processing' ? 'The Switch is still processing this audio. Check the Sound menu.' : 'The Switch could not add this audio. Check its duration.');
message(prepared.trimmed ? 'Audio added and trimmed to ' + (track === '0' ? '60' : '2') + ' seconds.' : 'Audio added to the {{NOTE}}.', 'ok'); $('file').value = '';
} catch (error) { message(error.message || 'Upload failed. Keep this page open and try again.', 'error'); }
finally { $('send').disabled = false; }
});
</script></body></html>)HTML", noteSingular);
}
std::string photoPage(const std::string& noteSingular) {
return brandedPage(R"HTML(<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Photo import</title><style>
@font-face{font-family:Seurat;src:url('/assets/font') format('opentype')}*{box-sizing:border-box}body{margin:0;min-height:100vh;color:#252831;font-family:Seurat,'Trebuchet MS',sans-serif;background:#fff;background-image:linear-gradient(#d9e2ef 1px,transparent 1px),linear-gradient(90deg,#d9e2ef 1px,transparent 1px);background-size:24px 24px}.shell{max-width:1000px;margin:3vh auto;padding:18px}.card{background:#fff;border:2px solid #e2e6f2;border-radius:18px;box-shadow:0 5px 0 #d5d8df,0 8px 20px #18243b22;padding:clamp(18px,3vw,30px)}h1{margin:0 0 8px;font-size:30px}p{line-height:1.45;color:#6c7280;margin-top:0}.connected{padding:12px 15px;border-radius:13px;background:#effbdd;color:#4e8d2c;font-size:18px}label{display:block;margin:13px 0 6px}input,select{width:100%;padding:10px 12px;border:2px solid #d8dce5;border-radius:10px;background:white;color:#252831;font:inherit}.grid{display:grid;grid-template-columns:minmax(220px,1fr) minmax(300px,1.25fr);gap:20px}.opts{display:grid;grid-template-columns:1fr 1fr;gap:10px}.preview{width:100%;aspect-ratio:5/3;background-color:#e8ecf4;background-image:linear-gradient(45deg,#cad0dc 25%,transparent 25%),linear-gradient(-45deg,#cad0dc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#cad0dc 75%),linear-gradient(-45deg,transparent 75%,#cad0dc 75%);background-size:20px 20px;background-position:0 0,0 10px,10px -10px,-10px 0;border:2px solid #d8dce5;border-radius:12px;image-rendering:pixelated}.button{margin-top:16px;width:100%;padding:13px;border:3px solid white;border-radius:28px;background:#ffd3a6;color:#ee6818;font:inherit;box-shadow:0 4px 0 #cfd2d8,0 0 0 2px #e5e7ec;cursor:pointer}.button:disabled{opacity:.5}.status{margin-top:12px;padding:10px;border-radius:9px;background:#f1f3f8;color:#555d6b}.status.error{background:#fff0ed;color:#ba342d}[hidden]{display:none!important}@media(max-width:680px){.grid{grid-template-columns:1fr}.opts{grid-template-columns:1fr 1fr}}
' </style></head><body><main class="shell"><section class="card"><h1>Photo Import</h1><div id="pair"><p>Connect to your {{NOTE}} over local Wi-Fi using the code shown on screen.</p><label>Connection code</label><input id="code" inputmode="numeric" maxlength="7" placeholder="000-000"><div id="pairStatus" class="status">Enter all six digits to connect.</div></div><div id="panel" hidden><div class="connected">Connected to Nintendo Switch</div><p>Choose an image and adjust its canvas placement before sending it to the current {{NOTE}}.</p><div class="grid"><div><label>Image</label><input id="file" type="file" accept="image/png,image/jpeg,image/webp"><div class="opts"><div><label>Color mode</label><select id="mode"><option value="color">Full color</option><option value="ink">Single ink</option></select></div><div><label>Dithering</label><select id="dither"><option value="none">None</option><option value="ordered">Ordered</option></select></div><div><label>Ink color</label><input id="ink" type="color" value="#171717"></div><div><label>Layer</label><select id="layer"><option value="0">A · Back</option><option value="1">B</option><option value="2">C</option><option value="3">D · Front</option></select></div><div><label>Size</label><input id="size" type="range" min="25" max="200" value="100"><small id="sizeLabel">100%</small></div><div><label>Position X</label><input id="px" type="range" min="-300" max="300" value="0"></div><div><label>Position Y</label><input id="py" type="range" min="-180" max="180" value="0"></div></div></div><div><canvas id="preview" class="preview" width="600" height="360"></canvas><button class="button" id="send" disabled>Upload to {{NOTE}}</button><div id="status" class="status">Choose an image to preview.</div></div></div></section></main><script>
'use strict';const $=id=>document.getElementById(id);let token='',busy=false,img=null;const canvas=$('preview'),ctx=canvas.getContext('2d',{willReadFrequently:true});function say(v,e=false){$('status').textContent=v;$('status').className='status'+(e?' error':'')}async function pair(code){if(busy||token)return;busy=true;$('pairStatus').textContent='Connecting…';try{const r=await fetch('/api/connect',{method:'POST',headers:{Authorization:'Bearer '+code}}),t=(await r.text()).trim();if(!r.ok)throw Error(t||'Could not connect.');if(!/^[a-f0-9]{64}$/.test(t))throw Error('Invalid Switch response.');token=t;$('pair').hidden=true;$('panel').hidden=false}catch(e){$('pairStatus').textContent=e.message;$('pairStatus').className='status error'}finally{busy=false}}$('code').addEventListener('input',()=>{const d=$('code').value.replace(/\D/g,'').slice(0,6);$('code').value=d.length>3?d.slice(0,3)+'-'+d.slice(3):d;if(d.length===6)pair($('code').value)});function render(){if(!img)return;ctx.clearRect(0,0,600,360);const s=Number($('size').value)/100,w=img.width*s,h=img.height*s,x=(600-w)/2+Number($('px').value),y=(360-h)/2+Number($('py').value);ctx.drawImage(img,x,y,w,h);const im=ctx.getImageData(0,0,600,360),p=im.data,ink=$('ink').value.match(/[a-f\d]{2}/gi).map(v=>parseInt(v,16)),mono=$('mode').value==='ink',ordered=$('dither').value==='ordered',bayer=[0,8,2,10,12,4,14,6,3,11,1,9,15,7,13,5];for(let yy=0;yy<360;yy++)for(let xx=0;xx<600;xx++){const i=(yy*600+xx)*4;if(p[i+3]<24){p[i+3]=0;continue}if(mono){let l=(p[i]*299+p[i+1]*587+p[i+2]*114)/1000;if(ordered)l+=(bayer[(yy&3)*4+(xx&3)]-7.5)*12;const v=l<128?0:255;p[i]=v?255:ink[0];p[i+1]=v?255:ink[1];p[i+2]=v?255:ink[2]}else if(ordered){const t=(bayer[(yy&3)*4+(xx&3)]-7.5)*3;p[i]=Math.max(0,Math.min(255,p[i]+t));p[i+1]=Math.max(0,Math.min(255,p[i+1]+t));p[i+2]=Math.max(0,Math.min(255,p[i+2]+t))}}ctx.putImageData(im,0,0);$('sizeLabel').textContent=$('size').value+'%';$('send').disabled=!token}$('file').addEventListener('change',()=>{const f=$('file').files[0];if(!f)return;if(f.size>16*1024*1024){say('Choose an image under 16 MB.',true);return}img=new Image();img.onload=()=>{render();say('Preview ready.');};img.onerror=()=>say('Could not read that image.',true);img.src=URL.createObjectURL(f)});['mode','dither','ink','size','px','py'].forEach(id=>$(id).addEventListener('input',render));$('send').addEventListener('click',async()=>{if(!token||!img||busy)return;busy=true;$('send').disabled=true;try{render();say('Preparing image…');const data=ctx.getImageData(0,0,600,360).data;const r=await fetch('/api/photo',{method:'POST',headers:{Authorization:'Bearer '+token,'Content-Type':'application/octet-stream','X-Photo-Layer':$('layer').value,'X-Photo-Mode':$('mode').value,'X-Photo-Dither':$('dither').value,'X-Photo-Size':$('size').value,'X-Photo-X':$('px').value,'X-Photo-Y':$('py').value},body:data});const t=await r.text();if(!r.ok)throw Error(t||'Upload failed.');say('Image sent. Importing to the selected layer…');let o='processing';for(let i=0;i<80&&o==='processing';i++){await new Promise(a=>setTimeout(a,250));const q=await fetch('/api/status',{headers:{Authorization:'Bearer '+token}});if(!q.ok)throw Error('Photo menu closed before import finished.');o=(await q.text()).trim()}if(o!=='imported')throw Error(o==='rejected'?'Could not import this image.':'Switch is still processing the image.');say('Photo added to the Switchnote.');}catch(e){say(e.message||'Upload failed.',true)}finally{busy=false;$('send').disabled=false}});
</script></body></html>)HTML", noteSingular);
}
bool readAsset(const std::string& path, std::vector<char>& result, const std::string& root) {
std::string relative;
if (path == "/assets/font") relative = "/fonts/FOT-Seurat.otf";
else return false;
std::ifstream file(root + relative, std::ios::binary | std::ios::ate);
if (!file) return false;
const std::streamoff length = file.tellg();
if (length <= 0 || length > 4 * 1024 * 1024) return false;
result.resize(static_cast<std::size_t>(length));
file.seekg(0, std::ios::beg);
return static_cast<bool>(file.read(result.data(), length));
}
bool receiveHeaders(int fd, std::string& request, std::size_t& bodyStart) {
char buffer[4096];
while (true) {
const std::size_t split = request.find("\r\n\r\n");
if (split != std::string::npos) { bodyStart = split + 4; return true; }
if (request.size() >= kMaxHeaderBytes) return false;
const ssize_t count = ::recv(fd, buffer, sizeof(buffer), 0);
if (count <= 0) return false;
request.append(buffer, static_cast<std::size_t>(count));
if (request.size() > kMaxHeaderBytes + 4096) return false;
}
}
bool storeUpload(ServerState* state, int fd, int track, const std::string& request,
std::size_t bodyStart, std::size_t bodySize) {
const std::string base = state->uploadDirectory + "/audio-transfer-" + std::to_string(track);
const std::string temporary = base + ".part";
const std::string destination = base + ".wav";
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
if (!output) {
respondText(fd, 500, "Internal Server Error", "Could not save the upload.");
return false;
}
const std::size_t inBuffer = std::min(bodySize, request.size() - bodyStart);
char wavHeader[12]{};
std::size_t headerCount = 0;
auto append = [&](const char* bytes, std::size_t count) {
const std::size_t prefix = std::min(count, sizeof(wavHeader) - headerCount);
if (prefix) { std::memcpy(wavHeader + headerCount, bytes, prefix); headerCount += prefix; }
output.write(bytes, static_cast<std::streamsize>(count));
};
append(request.data() + bodyStart, inBuffer);
std::size_t received = inBuffer;
char buffer[8192];
while (output && received < bodySize && state->running.load(std::memory_order_acquire)) {
const std::size_t needed = std::min(sizeof(buffer), bodySize - received);
const ssize_t count = ::recv(fd, buffer, needed, 0);
if (count <= 0) break;
append(buffer, static_cast<std::size_t>(count));
received += static_cast<std::size_t>(count);
}
if (!output || received != bodySize) {
output.close();
std::remove(temporary.c_str());
respondText(fd, 400, "Bad Request", "Upload was interrupted.");
return false;
}
if (std::memcmp(wavHeader, "RIFF", 4) != 0 || std::memcmp(wavHeader + 8, "WAVE", 4) != 0) {
output.close();
std::remove(temporary.c_str());
respondText(fd, 415, "Unsupported Media Type", "This file is not a valid WAV container.");
return false;
}
output.flush();
output.close();
// This path is reserved for the transient PC transfer. A previous session
// can leave it behind if the app was closed during import.
std::remove(destination.c_str());
if (!output || std::rename(temporary.c_str(), destination.c_str()) != 0) {
std::remove(temporary.c_str());
respondText(fd, 500, "Internal Server Error", "Could not finalize the upload.");
return false;
}
state->uploadOutcome.store(1, std::memory_order_release);
state->pendingTrack.store(track, std::memory_order_release);
const char* success = "WAV received by Switch. Importing now.";
respond(fd, 200, "OK", "text/plain; charset=utf-8", success, std::strlen(success));
return true;
}
bool storePhotoUpload(ServerState* state, int fd, const std::string& request,
std::size_t bodyStart, std::size_t bodySize,
const std::map<std::string, std::string>& headers) {
auto number = [&](const char* key, int low, int high, int& value) {
const auto found = headers.find(key);
if (found == headers.end() || found->second.empty()) return false;
char* end = nullptr;
const long parsed = std::strtol(found->second.c_str(), &end, 10);
if (!end || *end || parsed < low || parsed > high) return false;
value = static_cast<int>(parsed);
return true;
};
int layer = 0, size = 0, offsetX = 0, offsetY = 0;
if (headers.find("x-photo-mode") == headers.end() || headers.find("x-photo-dither") == headers.end() ||
!number("x-photo-layer", 0, 3, layer) || !number("x-photo-size", 25, 200, size) ||
!number("x-photo-x", -300, 300, offsetX) || !number("x-photo-y", -180, 180, offsetY) ||
(headers.at("x-photo-mode") != "color" && headers.at("x-photo-mode") != "ink") ||
(headers.at("x-photo-dither") != "none" && headers.at("x-photo-dither") != "ordered")) {
respondText(fd, 400, "Bad Request", "Photo options are invalid.");
return false;
}
constexpr std::size_t expectedBytes = 600U * 360U * 4U;
if (bodySize != expectedBytes || request.size() < bodyStart || request.size() - bodyStart > bodySize) {
respondText(fd, 400, "Bad Request", "Processed photo has an invalid canvas size.");
return false;
}
const std::string path = state->uploadDirectory + "/photo-transfer.rgba";
const std::string configPath = state->uploadDirectory + "/photo-transfer.cfg";
std::ofstream output(path, std::ios::binary | std::ios::trunc);
if (!output) { respondText(fd, 500, "Internal Server Error", "Could not open the photo transfer file."); return false; }
const std::size_t buffered = std::min(bodySize, request.size() - bodyStart);
output.write(request.data() + bodyStart, static_cast<std::streamsize>(buffered));
std::size_t received = buffered;
char buffer[8192];
while (output && received < bodySize && state->running.load(std::memory_order_acquire)) {
const ssize_t count = ::recv(fd, buffer, std::min(sizeof(buffer), bodySize - received), 0);
if (count <= 0) break;
output.write(buffer, count);
received += static_cast<std::size_t>(count);
}
output.flush();
output.close();
if (!output || received != bodySize) { std::remove(path.c_str()); respondText(fd, 400, "Bad Request", "Photo upload was interrupted."); return false; }
std::ofstream config(configPath, std::ios::binary | std::ios::trunc);
config << layer << '\n' << headers.at("x-photo-mode") << '\n' << headers.at("x-photo-dither")
<< '\n' << size << '\n' << offsetX << '\n' << offsetY << '\n';
config.flush();
if (!config) { config.close(); std::remove(path.c_str()); respondText(fd, 500, "Internal Server Error", "Could not save photo options."); return false; }
state->uploadOutcome.store(1, std::memory_order_release);
state->pendingTrack.store(4, std::memory_order_release);
const char* success = "Photo received by Switch. Importing now.";
respond(fd, 200, "OK", "text/plain; charset=utf-8", success, std::strlen(success));
return true;
}
void serveClient(ServerState* state, int fd, std::uint32_t clientAddress) {
timeval timeout{};
timeout.tv_sec = 4;
::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
std::string request;
std::size_t bodyStart = 0;
if (!receiveHeaders(fd, request, bodyStart)) {
respondText(fd, 400, "Bad Request", "Invalid HTTP request.");
return;
}
const std::size_t lineEnd = request.find("\r\n");
if (lineEnd == std::string::npos) return;
std::istringstream firstLine(request.substr(0, lineEnd));
std::string method, path, version;
firstLine >> method >> path >> version;
if (version != "HTTP/1.1" && version != "HTTP/1.0") {
respondText(fd, 400, "Bad Request", "Invalid HTTP version.");
return;
}
std::map<std::string, std::string> headers;
std::size_t cursor = lineEnd + 2;
while (cursor < bodyStart - 2) {
const std::size_t end = request.find("\r\n", cursor);
if (end == std::string::npos || end == cursor) break;
const std::size_t colon = request.find(':', cursor);
if (colon == std::string::npos || colon >= end) {
respondText(fd, 400, "Bad Request", "Malformed request header.");
return;
}
headers[lower(trim(request.substr(cursor, colon - cursor)))] = trim(request.substr(colon + 1, end - colon - 1));
cursor = end + 2;
}
if (method == "GET" && path == "/") {
const std::string page = state->photoTransfer ? photoPage(state->noteSingular) : uploadPage(state->noteSingular);
respond(fd, 200, "OK", "text/html; charset=utf-8", page.data(), page.size(), true);
return;
}
if (method == "GET" && path == "/assets/font") {
std::vector<char> asset;
if (!readAsset(path, asset, state->assetsRoot)) {
respondText(fd, 404, "Not Found", "Asset not found.");
return;
}
respond(fd, 200, "OK", "font/otf", asset.data(), asset.size());
return;
}
const bool pairing = method == "POST" && path == "/api/connect";
const bool photoUpload = state->photoTransfer && method == "POST" && path == "/api/photo";
if (!pairing && !photoUpload && !((method == "POST" && path == "/api/upload") || (method == "GET" && path == "/api/status"))) {
respondText(fd, 404, "Not Found", "Not found.");
return;
}
const std::string auth = headers["authorization"];
if (pairing) {
if (state->connected.load(std::memory_order_acquire) && clientAddress != state->pairedAddress) {
respondText(fd, 409, "Conflict", "Another PC is already connected. Reopen Sound to pair again.");
return;
}
if (state->failedCodeAttempts >= kMaxCodeAttempts) {
respondText(fd, 429, "Too Many Requests", "Too many incorrect codes. Reopen Sound for a new code.");
return;
}
if (!constantTimeEqual(auth, "Bearer " + state->sessionCode)) {
++state->failedCodeAttempts;
respondText(fd, 403, "Forbidden", "Connection code is incorrect.");
return;
}
state->pairedAddress = clientAddress;
state->connected.store(true, std::memory_order_release);
respond(fd, 200, "OK", "text/plain; charset=utf-8",
state->sessionToken.data(), state->sessionToken.size());
return;
}
if (!state->connected.load(std::memory_order_acquire) || clientAddress != state->pairedAddress ||
!constantTimeEqual(auth, "Bearer " + state->sessionToken)) {
respondText(fd, 403, "Forbidden", "Connect to the Switch first.");
return;
}
if (method == "GET") {
const int outcome = state->uploadOutcome.load(std::memory_order_acquire);
respondText(fd, 200, "OK", outcome == 2 ? "imported" : outcome == 3 ? "rejected" : "processing");
return;
}
if (state->pendingTrack.load(std::memory_order_acquire) >= 0) {
respondText(fd, 409, "Conflict", "The previous upload is still being added on the Switch.");
return;
}
if (headers.find("transfer-encoding") != headers.end()) {
respondText(fd, 400, "Bad Request", "Chunked upload is not supported.");
return;
}
char* end = nullptr;
const std::string lengthHeader = headers["content-length"];
const unsigned long long bodyLength = std::strtoull(lengthHeader.c_str(), &end, 10);
if (photoUpload) {
constexpr unsigned long long photoBytes = 600ULL * 360ULL * 4ULL;
if (lengthHeader.empty() || !end || *end != '\0' || bodyLength != photoBytes ||
lower(headers["content-type"]) != "application/octet-stream") {
respondText(fd, 413, "Payload Too Large", "Send a processed 600 by 360 RGBA photo.");
return;
}
storePhotoUpload(state, fd, request, bodyStart, static_cast<std::size_t>(bodyLength), headers);
return;
}
if (lengthHeader.empty() || !end || *end != '\0' || bodyLength < 12 || bodyLength > kMaxBgmUploadBytes) {
respondText(fd, 413, "Payload Too Large", "Choose a WAV under 32 MiB.");
return;
}
const std::string trackHeader = headers["x-audio-track"];
if (trackHeader.size() != 1 || trackHeader[0] < '0' || trackHeader[0] > '3') {
respondText(fd, 400, "Bad Request", "Choose BGM or one of SE1-SE3.");
return;
}
const int track = trackHeader[0] - '0';
if (track > 0 && bodyLength > kMaxSfxUploadBytes) {
respondText(fd, 413, "Payload Too Large", "A sound-effect WAV must be under 3 MiB.");
return;
}
const std::string contentType = lower(headers["content-type"]);
if (contentType != "audio/wav" && contentType != "audio/x-wav" && contentType != "application/octet-stream") {
respondText(fd, 415, "Unsupported Media Type", "Select a WAV audio file.");
return;
}
storeUpload(state, fd, track, request, bodyStart, static_cast<std::size_t>(bodyLength));
}
void serverThread(void* argument) {
auto* state = static_cast<ServerState*>(argument);
while (state->running.load(std::memory_order_acquire)) {
fd_set readers;
FD_ZERO(&readers);
FD_SET(state->listenFd, &readers);
timeval wait{};
wait.tv_sec = 0;
wait.tv_usec = 100000;
const int ready = ::select(state->listenFd + 1, &readers, nullptr, nullptr, &wait);
if (ready <= 0) continue;
if (!state->running.load(std::memory_order_acquire)) break;
sockaddr_in peer{};
socklen_t peerSize = sizeof(peer);
const int client = ::accept(state->listenFd, reinterpret_cast<sockaddr*>(&peer), &peerSize);
if (client < 0) continue;
state->activeClient.store(client, std::memory_order_release);
serveClient(state, client, peer.sin_addr.s_addr);
::shutdown(client, SHUT_RDWR);
::close(client);
state->activeClient.store(-1, std::memory_order_release);
}
}
#endif
} // namespace
AudioUploadServer::AudioUploadServer() = default;
AudioUploadServer::~AudioUploadServer() { stop(); }
bool AudioUploadServer::start(const std::string& bindAddress, const std::string& uploadDirectory,
const std::string& assetsRoot, const std::string& sessionCode,
std::string& error, bool photoTransfer,
const std::string& noteSingular) {
stop();
#ifdef __SWITCH__
if (!validPairCode(sessionCode) || uploadDirectory.empty()) {
error = "Invalid upload server configuration.";
return false;
}
auto* state = new (std::nothrow) ServerState{};
if (!state) { error = "Not enough memory to open the sound server."; return false; }
state->bindAddress = bindAddress;
state->uploadDirectory = uploadDirectory;
state->assetsRoot = assetsRoot;
state->noteSingular = noteSingular;
state->sessionCode = sessionCode;
state->photoTransfer = photoTransfer;
std::array<std::uint8_t, 32> tokenBytes{};
if (R_FAILED(csrngGetRandomBytes(tokenBytes.data(), tokenBytes.size()))) {
delete state;
error = "Could not create a secure upload session.";
return false;
}
state->sessionToken = hexToken(tokenBytes);
state->listenFd = ::socket(AF_INET, SOCK_STREAM, 0);
if (state->listenFd < 0) { delete state; error = "Could not create the upload socket."; return false; }
int reuse = 1;
::setsockopt(state->listenFd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_port = htons(kUploadPort);
if (::inet_pton(AF_INET, bindAddress.c_str(), &address.sin_addr) != 1 ||
::bind(state->listenFd, reinterpret_cast<sockaddr*>(&address), sizeof(address)) != 0 ||
::listen(state->listenFd, 2) != 0) {
::close(state->listenFd);
delete state;
error = "Could not listen on " + bindAddress + ":" + std::to_string(kUploadPort) + ".";
return false;
}
state->running.store(true, std::memory_order_release);
const Result created = threadCreate(&state->thread, serverThread, state, nullptr, 0x10000, 0x3B, -2);
if (R_FAILED(created)) {
state->running.store(false, std::memory_order_release);
::close(state->listenFd);
delete state;
error = "Could not start the upload service thread.";
return false;
}
if (R_FAILED(threadStart(&state->thread))) {
state->running.store(false, std::memory_order_release);
::close(state->listenFd);
threadClose(&state->thread);
delete state;
error = "Could not start the upload service thread.";
return false;
}
impl_ = state;
error.clear();
return true;
#else
(void)bindAddress; (void)uploadDirectory; (void)assetsRoot; (void)sessionCode; (void)photoTransfer; (void)noteSingular;
error = "The Switch upload server is available in the homebrew app.";
return false;
#endif
}
void AudioUploadServer::stop() {
#ifdef __SWITCH__
auto* state = static_cast<ServerState*>(impl_);
if (!state) return;
state->running.store(false, std::memory_order_release);
const int client = state->activeClient.load(std::memory_order_acquire);
if (client >= 0) ::shutdown(client, SHUT_RDWR);
threadWaitForExit(&state->thread);
if (state->listenFd >= 0) ::close(state->listenFd);
threadClose(&state->thread);
delete state;
impl_ = nullptr;
#endif
}
bool AudioUploadServer::isRunning() const {
#ifdef __SWITCH__
const auto* state = static_cast<const ServerState*>(impl_);
return state && state->running.load(std::memory_order_acquire);
#else
return false;
#endif
}
bool AudioUploadServer::isConnected() const {
#ifdef __SWITCH__
const auto* state = static_cast<const ServerState*>(impl_);
return state && state->connected.load(std::memory_order_acquire);
#else
return false;
#endif
}
bool AudioUploadServer::takeCompletedUpload(int& track) {
#ifdef __SWITCH__
auto* state = static_cast<ServerState*>(impl_);
if (!state) return false;
track = state->pendingTrack.load(std::memory_order_acquire);
return track >= 0 && track <= 4;
#else
(void)track;
return false;
#endif
}
void AudioUploadServer::finishCompletedUpload(bool success) {
#ifdef __SWITCH__
auto* state = static_cast<ServerState*>(impl_);
if (state) {
state->uploadOutcome.store(success ? 2 : 3, std::memory_order_release);
state->pendingTrack.store(-1, std::memory_order_release);
}
#else
(void)success;
#endif
}