Add files via upload
This commit is contained in:
35 files changed
+14482
No files matched your search
@@ -0,0 +1,701 @@
|
||||
#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 += "&"; break;
|
||||
case '<': escaped += "<"; break;
|
||||
case '>': escaped += ">"; break;
|
||||
case '\"': escaped += """; break;
|
||||
case '\'': escaped += "'"; 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
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
#include "branding_bundle.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <utility>
|
||||
|
||||
namespace branding {
|
||||
namespace {
|
||||
|
||||
void put16(std::vector<unsigned char>& out, std::uint16_t v) {
|
||||
out.push_back(static_cast<unsigned char>(v));
|
||||
out.push_back(static_cast<unsigned char>(v >> 8));
|
||||
}
|
||||
void put32(std::vector<unsigned char>& out, std::uint32_t v) {
|
||||
put16(out, static_cast<std::uint16_t>(v));
|
||||
put16(out, static_cast<std::uint16_t>(v >> 16));
|
||||
}
|
||||
std::uint16_t get16(const std::vector<unsigned char>& in, std::size_t p) {
|
||||
return static_cast<std::uint16_t>(in[p] | (static_cast<std::uint16_t>(in[p + 1]) << 8));
|
||||
}
|
||||
std::uint32_t get32(const std::vector<unsigned char>& in, std::size_t p) {
|
||||
return static_cast<std::uint32_t>(get16(in, p)) |
|
||||
(static_cast<std::uint32_t>(get16(in, p + 2)) << 16);
|
||||
}
|
||||
std::uint32_t crc32(const unsigned char* data, std::size_t size) {
|
||||
std::uint32_t crc = 0xffffffffU;
|
||||
for (std::size_t i = 0; i < size; ++i) {
|
||||
crc ^= data[i];
|
||||
for (int bit = 0; bit < 8; ++bit)
|
||||
crc = (crc >> 1) ^ (0xedb88320U & (0U - (crc & 1U)));
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
std::string escape(const std::string& value) {
|
||||
std::string out = "\"";
|
||||
for (unsigned char c : value) {
|
||||
if (c == '"' || c == '\\') { out.push_back('\\'); out.push_back(static_cast<char>(c)); }
|
||||
else if (c < 0x20) {
|
||||
static constexpr char hex[] = "0123456789abcdef";
|
||||
out += "\\u00"; out.push_back(hex[c >> 4]); out.push_back(hex[c & 15]);
|
||||
} else out.push_back(static_cast<char>(c));
|
||||
}
|
||||
out.push_back('"');
|
||||
return out;
|
||||
}
|
||||
void appendUtf8(std::string& out, std::uint32_t c) {
|
||||
if (c <= 0x7f) out.push_back(static_cast<char>(c));
|
||||
else if (c <= 0x7ff) { out.push_back(static_cast<char>(0xc0 | (c >> 6))); out.push_back(static_cast<char>(0x80 | (c & 63))); }
|
||||
else { out.push_back(static_cast<char>(0xe0 | (c >> 12))); out.push_back(static_cast<char>(0x80 | ((c >> 6) & 63))); out.push_back(static_cast<char>(0x80 | (c & 63))); }
|
||||
}
|
||||
bool jsonString(const std::string& json, const std::string& key, std::string& value) {
|
||||
const std::string marker = "\"" + key + "\"";
|
||||
std::size_t p = json.find(marker);
|
||||
if (p == std::string::npos) return false;
|
||||
p = json.find(':', p + marker.size());
|
||||
if (p == std::string::npos) return false;
|
||||
while (++p < json.size() && (json[p] == ' ' || json[p] == '\t' || json[p] == '\r' || json[p] == '\n')) {}
|
||||
if (p >= json.size() || json[p++] != '"') return false;
|
||||
value.clear();
|
||||
while (p < json.size()) {
|
||||
unsigned char c = static_cast<unsigned char>(json[p++]);
|
||||
if (c == '"') return true;
|
||||
if (c != '\\') { value.push_back(static_cast<char>(c)); continue; }
|
||||
if (p >= json.size()) return false;
|
||||
c = static_cast<unsigned char>(json[p++]);
|
||||
switch (c) {
|
||||
case '"': case '\\': case '/': value.push_back(static_cast<char>(c)); break;
|
||||
case 'b': value.push_back('\b'); break; case 'f': value.push_back('\f'); break;
|
||||
case 'n': value.push_back('\n'); break; case 'r': value.push_back('\r'); break; case 't': value.push_back('\t'); break;
|
||||
case 'u': {
|
||||
if (p + 4 > json.size()) return false;
|
||||
std::uint32_t cp = 0;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
const char h = json[p++]; cp <<= 4;
|
||||
if (h >= '0' && h <= '9') cp |= static_cast<unsigned>(h - '0');
|
||||
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
|
||||
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(h - 'A' + 10);
|
||||
else return false;
|
||||
}
|
||||
appendUtf8(value, cp); break;
|
||||
}
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool writeConfig(const std::string& path, const Bundle& bundle, std::string& error) {
|
||||
if (bundle.app.empty() || bundle.app.size() > 40 || bundle.singular.empty() || bundle.singular.size() > 24 ||
|
||||
bundle.plural.empty() || bundle.plural.size() > 24 || bundle.logo.empty() || bundle.logo.size() > 8U * 1024U * 1024U) {
|
||||
error = "Branding values or logo are outside supported limits."; return false;
|
||||
}
|
||||
const std::string json = "{\"app\":" + escape(bundle.app) + ",\"singular\":" + escape(bundle.singular) +
|
||||
",\"plural\":" + escape(bundle.plural) + "}";
|
||||
struct Entry { std::string name; const unsigned char* data; std::uint32_t size; std::uint32_t crc; std::uint32_t offset; };
|
||||
const std::string names[2] = {"branding.json", "logo.png"};
|
||||
const std::string jsonOwned = json;
|
||||
const unsigned char* data[2] = {reinterpret_cast<const unsigned char*>(jsonOwned.data()), bundle.logo.data()};
|
||||
const std::uint32_t sizes[2] = {static_cast<std::uint32_t>(jsonOwned.size()), static_cast<std::uint32_t>(bundle.logo.size())};
|
||||
std::vector<unsigned char> archive;
|
||||
Entry entries[2]{};
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
Entry& e = entries[i]; e.name = names[i]; e.data = data[i]; e.size = sizes[i]; e.crc = crc32(e.data, e.size); e.offset = static_cast<std::uint32_t>(archive.size());
|
||||
put32(archive, 0x04034b50U); put16(archive, 20); put16(archive, 0); put16(archive, 0);
|
||||
put16(archive, 0); put16(archive, 0); put32(archive, e.crc); put32(archive, e.size); put32(archive, e.size);
|
||||
put16(archive, static_cast<std::uint16_t>(e.name.size())); put16(archive, 0);
|
||||
archive.insert(archive.end(), e.name.begin(), e.name.end()); archive.insert(archive.end(), e.data, e.data + e.size);
|
||||
}
|
||||
const std::uint32_t centralOffset = static_cast<std::uint32_t>(archive.size());
|
||||
for (const Entry& e : entries) {
|
||||
put32(archive, 0x02014b50U); put16(archive, 20); put16(archive, 20); put16(archive, 0); put16(archive, 0);
|
||||
put16(archive, 0); put16(archive, 0); put32(archive, e.crc); put32(archive, e.size); put32(archive, e.size);
|
||||
put16(archive, static_cast<std::uint16_t>(e.name.size())); put16(archive, 0); put16(archive, 0); put16(archive, 0);
|
||||
put16(archive, 0); put32(archive, 0); put32(archive, e.offset);
|
||||
archive.insert(archive.end(), e.name.begin(), e.name.end());
|
||||
}
|
||||
const std::uint32_t centralSize = static_cast<std::uint32_t>(archive.size()) - centralOffset;
|
||||
put32(archive, 0x06054b50U); put16(archive, 0); put16(archive, 0); put16(archive, 2); put16(archive, 2);
|
||||
put32(archive, centralSize); put32(archive, centralOffset); put16(archive, 0);
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
if (!out) { error = "Could not create branding.config."; return false; }
|
||||
out.write(reinterpret_cast<const char*>(archive.data()), static_cast<std::streamsize>(archive.size()));
|
||||
out.flush();
|
||||
if (!out) { error = "Could not write branding.config."; return false; }
|
||||
error.clear(); return true;
|
||||
}
|
||||
|
||||
bool readConfig(const std::string& path, Bundle& bundle, std::string& error) {
|
||||
std::ifstream in(path, std::ios::binary | std::ios::ate);
|
||||
if (!in) { error = "Copy a .config file to the Switchnote folder first."; return false; }
|
||||
const std::streamoff length = in.tellg();
|
||||
if (length < 22 || length > 9 * 1024 * 1024) { error = "Branding config size is invalid."; return false; }
|
||||
std::vector<unsigned char> bytes(static_cast<std::size_t>(length)); in.seekg(0); in.read(reinterpret_cast<char*>(bytes.data()), length);
|
||||
if (!in) { error = "Could not read branding config."; return false; }
|
||||
std::string json; std::vector<unsigned char> logo; std::size_t p = 0;
|
||||
while (p + 4 <= bytes.size() && get32(bytes, p) == 0x04034b50U) {
|
||||
if (p + 30 > bytes.size()) { error = "Branding config archive is truncated."; return false; }
|
||||
const std::uint16_t flags = get16(bytes, p + 6), method = get16(bytes, p + 8), nameSize = get16(bytes, p + 26), extraSize = get16(bytes, p + 28);
|
||||
const std::uint32_t expectedCrc = get32(bytes, p + 14), compressed = get32(bytes, p + 18), uncompressed = get32(bytes, p + 22);
|
||||
const std::size_t nameAt = p + 30, dataAt = nameAt + nameSize + extraSize;
|
||||
if (flags != 0 || method != 0 || compressed != uncompressed || dataAt > bytes.size() || compressed > bytes.size() - dataAt) { error = "Only standard uncompressed branding.config files are supported."; return false; }
|
||||
const std::string name(bytes.begin() + static_cast<std::ptrdiff_t>(nameAt), bytes.begin() + static_cast<std::ptrdiff_t>(nameAt + nameSize));
|
||||
const auto* data = bytes.data() + dataAt;
|
||||
if (crc32(data, uncompressed) != expectedCrc) { error = "Branding config file checksum failed."; return false; }
|
||||
if (name == "branding.json") json.assign(reinterpret_cast<const char*>(data), uncompressed);
|
||||
else if (name == "logo.png") logo.assign(data, data + uncompressed);
|
||||
else if (name.find('/') != std::string::npos || name.find('\\') != std::string::npos || name == "..") { error = "Branding config contains an unsafe file path."; return false; }
|
||||
p = dataAt + compressed;
|
||||
}
|
||||
if (json.empty() || logo.empty() || logo.size() > 8U * 1024U * 1024U ||
|
||||
!jsonString(json, "app", bundle.app) || !jsonString(json, "singular", bundle.singular) || !jsonString(json, "plural", bundle.plural) ||
|
||||
bundle.app.empty() || bundle.app.size() > 40 || bundle.singular.empty() || bundle.singular.size() > 24 || bundle.plural.empty() || bundle.plural.size() > 24) {
|
||||
error = "Branding config is missing valid names or a logo."; return false;
|
||||
}
|
||||
bundle.logo = std::move(logo);
|
||||
error.clear(); return true;
|
||||
}
|
||||
|
||||
} // namespace branding
|
||||
@@ -0,0 +1,232 @@
|
||||
#include "branding_config_server.hpp"
|
||||
#include "branding_bundle.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#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 kPort = 8767;
|
||||
constexpr std::size_t kMaxRequest = 8 * 1024 * 1024 + 16 * 1024;
|
||||
std::string portalAppName = "Switchnote Studio";
|
||||
std::string portalNoteSingular = "Switchnote";
|
||||
std::string portalNotePlural = "Switchnotes";
|
||||
struct State {
|
||||
Thread thread{};
|
||||
std::atomic<bool> running{false};
|
||||
std::atomic<bool> connected{false};
|
||||
std::atomic<bool> pending{false};
|
||||
std::atomic<std::uint64_t> lastSeenSeconds{0};
|
||||
std::atomic<int> activeClient{-1};
|
||||
int listenFd = -1;
|
||||
std::string bindAddress, dataDirectory, assetsRoot, code, token;
|
||||
std::uint32_t pairedAddress = 0;
|
||||
unsigned int failedCodeAttempts = 0;
|
||||
};
|
||||
|
||||
void logEvent(const State* st, const std::string& message) {
|
||||
std::ofstream log(st->dataDirectory + "/branding-server.log", std::ios::app);
|
||||
if (log) log << static_cast<long long>(std::time(nullptr)) << " " << message << '\n';
|
||||
}
|
||||
|
||||
bool equalToken(const std::string& a, const std::string& b) {
|
||||
unsigned int diff = static_cast<unsigned int>(a.size() ^ b.size());
|
||||
for (std::size_t i = 0; i < std::max(a.size(), b.size()); ++i)
|
||||
diff |= static_cast<unsigned int>((i < a.size() ? a[i] : 0) ^ (i < b.size() ? b[i] : 0));
|
||||
return diff == 0;
|
||||
}
|
||||
bool sendAll(int fd, const char* data, std::size_t size) {
|
||||
std::size_t sent = 0;
|
||||
while (sent < size) { const ssize_t n = ::send(fd, data + sent, size - sent, 0); if (n <= 0) return false; sent += static_cast<std::size_t>(n); }
|
||||
return true;
|
||||
}
|
||||
void reply(int fd, int status, const char* reason, const std::string& type, const std::string& body) {
|
||||
std::ostringstream h; h << "HTTP/1.1 " << status << ' ' << reason << "\r\nContent-Type: " << type
|
||||
<< "\r\nContent-Length: " << body.size() << "\r\nConnection: close\r\nCache-Control: no-store\r\n"
|
||||
<< "X-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\nX-Frame-Options: DENY\r\n"
|
||||
<< "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\r\n";
|
||||
const std::string headers = h.str(); sendAll(fd, headers.data(), headers.size()); if (!body.empty()) sendAll(fd, body.data(), body.size());
|
||||
}
|
||||
void textReply(int fd, int status, const char* reason, const std::string& body) { reply(fd, status, reason, "text/plain; charset=utf-8", body); }
|
||||
std::string htmlEscape(const std::string& value) {
|
||||
std::string out;
|
||||
out.reserve(value.size());
|
||||
for (char c : value) {
|
||||
switch (c) {
|
||||
case '&': out += "&"; break;
|
||||
case '<': out += "<"; break;
|
||||
case '>': out += ">"; break;
|
||||
case '\"': out += """; break;
|
||||
case '\'': out += "'"; break;
|
||||
default: out += c; break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
std::string page() {
|
||||
std::string html = R"HTML(<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Branding Config Portal</title><style>
|
||||
@font-face{font-family:Seurat;src:url('/assets/font')}*{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: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)}h1{font-size:clamp(24px,4vw,34px);margin:0 0 10px}p{line-height:1.5;color:#6c7280;margin-top:0}label{display:block;font-weight:600;margin:18px 0 7px}input{width:100%;font:inherit;padding:13px 14px;border:2px solid #d8dce5;border-radius:12px;background:#fff;color:#252831}input:focus{outline:3px solid #ffb77e;border-color:#ff751e}.button{margin-top:22px;padding:13px 22px;border:3px solid #fff;border-radius:28px;background:#ffd3a6;color:#ee6818;font:inherit;box-shadow:0 4px 0 #cfd2d8,0 0 0 2px #e5e7ec;cursor:pointer;transition:transform .12s}.button:hover{transform:translateY(-2px)}.button:active{transform:translateY(2px)}.button:disabled{opacity:.5}.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{padding:13px 16px;border-radius:13px;background:#effbdd;color:#4e8d2c;font-size:18px}[hidden]{display:none!important}.footer{margin:18px 4px;color:#697183;font-size:13px}@media(max-width:620px){.shell{padding:14px}.card{padding:20px}}
|
||||
</style></head><body><main class="shell"><section class="card"><h1>Branding Config Portal</h1><div id="pair"><p>Enter the code shown on your Nintendo Switch.</p><label for="code">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="connected" class="connected" hidden>Connected to Nintendo Switch</div><form id="form" hidden><p>Set the app name and the words used for notes. Upload a transparent PNG or JPEG for the title screen logo. Restart the app after saving for changes to apply.</p><label for="app">App name</label><input id="app" maxlength="40" required value="{{APP_NAME}}"><label for="singular">Singular name</label><input id="singular" maxlength="24" required value="{{NOTE_NAME}}"><label for="plural">Plural name</label><input id="plural" maxlength="24" required value="{{NOTES_NAME}}"><label for="logo">Title screen logo (PNG or JPEG, up to 8 MB)</label><input id="logo" type="file" accept="image/png,image/jpeg"><button class="button" id="save">Save branding</button><div id="status" class="status">Ready.</div></form></section><div class="footer">Ver 0.1.51</div></main><script>
|
||||
'use strict';
|
||||
const $=id=>document.getElementById(id);
|
||||
let token='',busy=false;
|
||||
function msg(v,k=''){const e=token?$('status'):$('pairStatus');e.className='status'+(k?' '+k:'');e.textContent=v}
|
||||
async function pollConnection(){if(!token)return;try{const r=await fetch('/api/status',{headers:{Authorization:'Bearer '+token},cache:'no-store'});if(!r.ok)throw Error();const state=await r.json();$('connected').textContent=state.connected?'Connected to Nintendo Switch':'Disconnected from Nintendo Switch';$('connected').className=state.connected?'connected':'connected status error'}catch{$('connected').textContent='Disconnected from Nintendo Switch';$('connected').className='connected status error'}}
|
||||
async function connect(code){if(busy||token)return;busy=true;msg('Connecting…');try{const r=await fetch('/api/connect',{method:'POST',headers:{Authorization:'Bearer '+code}}),v=(await r.text()).trim();if(!r.ok)throw Error(v||'Could not connect.');if(!/^[a-f0-9]{64}$/.test(v))throw Error('Invalid Switch response.');token=v;$('pair').hidden=true;$('connected').hidden=false;$('form').hidden=false;const current=await fetch('/api/config',{headers:{Authorization:'Bearer '+token}});if(current.ok){const saved=await current.json();$('app').value=saved.app;$('singular').value=saved.singular;$('plural').value=saved.plural}pollConnection()}catch(e){msg(e.message,'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)connect($('code').value)});
|
||||
$('form').addEventListener('submit',async e=>{e.preventDefault();if(!token||busy)return;busy=true;$('save').disabled=true;try{const f=$('logo').files[0];if(f){if(f.size>8*1024*1024)throw Error('Logo must be 8 MB or smaller.');if(!['image/png','image/jpeg'].includes(f.type))throw Error('Choose a PNG or JPEG image.');const upload=await fetch('/api/logo',{method:'PUT',headers:{Authorization:'Bearer '+token,'Content-Type':f.type},body:f});if(!upload.ok)throw Error(await upload.text()||'Logo upload failed.')}const data=new URLSearchParams({app:$('app').value,singular:$('singular').value,plural:$('plural').value});const save=await fetch('/api/config',{method:'POST',headers:{Authorization:'Bearer '+token,'Content-Type':'application/x-www-form-urlencoded'},body:data});if(!save.ok)throw Error(await save.text()||'Could not save branding.');msg('Branding saved on Nintendo Switch.','ok')}catch(err){msg(err.message||'Save failed.','error')}finally{busy=false;$('save').disabled=false}});
|
||||
setInterval(pollConnection,2000);
|
||||
</script></body></html>)HTML";
|
||||
auto replace = [&](const std::string& marker, const std::string& value) {
|
||||
const std::size_t at = html.find(marker);
|
||||
if (at != std::string::npos) html.replace(at, marker.size(), htmlEscape(value));
|
||||
};
|
||||
replace("{{APP_NAME}}", portalAppName);
|
||||
replace("{{NOTE_NAME}}", portalNoteSingular);
|
||||
replace("{{NOTES_NAME}}", portalNotePlural);
|
||||
const std::string loaderInput = "<label for=\"loading\">Loading animation (GIF or WebP)</label><input id=\"loading\" type=\"file\" accept=\"image/gif,image/webp\"><small>Animated images are reduced to eight small frames for the Switch.</small>";
|
||||
const std::size_t formEnd = html.find("</form>");
|
||||
if (formEnd != std::string::npos) html.insert(formEnd, loaderInput);
|
||||
const std::string bundleControls = "<div style=\"margin-top:24px;padding-top:18px;border-top:2px solid #e2e6f2\"><strong>Branding file</strong><p>Save or load your branding and title logo as a .config file.</p><button class=\"button\" type=\"button\" id=\"exportBundle\">Export .config</button> <button class=\"button\" type=\"button\" id=\"chooseBundle\">Import .config</button><input id=\"bundleFile\" type=\"file\" accept=\".config,application/zip\" hidden></div>";
|
||||
const std::size_t bundleFormEnd = html.find("</form>");
|
||||
if (bundleFormEnd != std::string::npos) html.insert(bundleFormEnd, bundleControls);
|
||||
const std::string uploadScript = R"JS(
|
||||
async function uploadLoading(file){if(!file)return;if(!['image/gif','image/webp'].includes(file.type))throw Error('Choose an animated GIF or WebP.');if(file.size>16*1024*1024)throw Error('Loading animation must be 16 MB or smaller.');const url=URL.createObjectURL(file),im=new Image();try{await new Promise((resolve,reject)=>{im.onload=resolve;im.onerror=()=>reject(Error('Could not decode that animation.'));im.src=url});const c=document.createElement('canvas');c.width=64;c.height=64;const g=c.getContext('2d');for(let i=0;i<8;i++){g.clearRect(0,0,64,64);const s=Math.min(64/im.naturalWidth,64/im.naturalHeight),w=im.naturalWidth*s,h=im.naturalHeight*s;g.drawImage(im,(64-w)/2,(64-h)/2,w,h);const png=await new Promise(resolve=>c.toBlob(resolve,'image/png'));if(!png)throw Error('Could not convert the animation.');const r=await fetch('/api/loading/'+i,{method:'PUT',headers:{Authorization:'Bearer '+token,'Content-Type':'image/png'},body:png});if(!r.ok)throw Error(await r.text()||'Could not upload the loading animation.');await new Promise(resolve=>setTimeout(resolve,125))}}finally{URL.revokeObjectURL(url)}}
|
||||
)JS";
|
||||
const std::string bundleScript = R"JS(
|
||||
async function exportBrandingBundle(){if(!token||busy)return;busy=true;$('exportBundle').disabled=true;try{msg('Preparing branding file…');const r=await fetch('/api/bundle',{headers:{Authorization:'Bearer '+token},cache:'no-store'});if(!r.ok)throw Error(await r.text()||'Could not export branding.');const blob=await r.blob(),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='branding.config';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);msg('Branding exported as branding.config.','ok')}catch(e){msg(e.message||'Could not export branding.','error')}finally{busy=false;$('exportBundle').disabled=false}}
|
||||
async function importBrandingBundle(file){if(!file||!token||busy)return;busy=true;$('chooseBundle').disabled=true;try{if(file.size>8*1024*1024+16384)throw Error('Branding file is too large.');msg('Importing branding…');const r=await fetch('/api/bundle',{method:'POST',headers:{Authorization:'Bearer '+token,'Content-Type':'application/octet-stream'},body:file});if(!r.ok)throw Error(await r.text()||'Could not import branding.');const current=await fetch('/api/config',{headers:{Authorization:'Bearer '+token},cache:'no-store'});if(current.ok){const saved=await current.json();$('app').value=saved.app;$('singular').value=saved.singular;$('plural').value=saved.plural}$('logo').value='';msg('Branding imported. Restart the app for changes to apply.','ok')}catch(e){msg(e.message||'Could not import branding.','error')}finally{busy=false;$('chooseBundle').disabled=false;$('bundleFile').value=''}}
|
||||
$('exportBundle').addEventListener('click',exportBrandingBundle);$('chooseBundle').addEventListener('click',()=>{if(!busy)$('bundleFile').click()});$('bundleFile').addEventListener('change',()=>importBrandingBundle($('bundleFile').files[0]));
|
||||
)JS";
|
||||
const std::string submitHook = "$('form').addEventListener('submit',async e=>{e.preventDefault();if(!token||busy)return;busy=true;$('save').disabled=true;try{const f=$('logo').files[0];";
|
||||
const std::string hookedSubmit = "$('form').addEventListener('submit',async e=>{e.preventDefault();if(!token||busy)return;busy=true;$('save').disabled=true;try{const loadingFile=$('loading').files[0];if(loadingFile)await uploadLoading(loadingFile);const f=$('logo').files[0];";
|
||||
const std::size_t submitAt = html.find(submitHook);
|
||||
if (submitAt != std::string::npos) html.replace(submitAt, submitHook.size(), hookedSubmit);
|
||||
const std::size_t scriptAt = html.find("setInterval(pollConnection,2000);");
|
||||
if (scriptAt != std::string::npos) html.insert(scriptAt, uploadScript);
|
||||
const std::size_t bundleScriptAt = html.find("setInterval(pollConnection,2000);");
|
||||
if (bundleScriptAt != std::string::npos) html.insert(bundleScriptAt, bundleScript);
|
||||
return html;
|
||||
}
|
||||
bool readRequest(int fd, std::string& request, std::size_t& bodyAt, std::size_t& bodySize) {
|
||||
char buffer[8192]; std::size_t headerEnd = std::string::npos;
|
||||
while (headerEnd == std::string::npos) { ssize_t n=::recv(fd,buffer,sizeof(buffer),0); if(n<=0)return false; request.append(buffer,static_cast<std::size_t>(n)); if(request.size()>16384)return false; headerEnd=request.find("\r\n\r\n"); }
|
||||
bodyAt=headerEnd+4; std::string headers=request.substr(0,headerEnd); std::size_t pos=headers.find("Content-Length:");
|
||||
bodySize=0;if(pos!=std::string::npos){pos+=15;while(pos<headers.size()&&headers[pos]==' ')++pos;bodySize=static_cast<std::size_t>(std::strtoul(headers.c_str()+pos,nullptr,10));}
|
||||
if(bodySize>kMaxRequest)return false;
|
||||
while(request.size()-bodyAt<bodySize){std::size_t n=std::min(sizeof(buffer),bodySize-(request.size()-bodyAt));ssize_t got=::recv(fd,buffer,n,0);if(got<=0)return false;request.append(buffer,static_cast<std::size_t>(got));}
|
||||
return true;
|
||||
}
|
||||
std::string decode(const std::string& s) { std::string o; for(std::size_t i=0;i<s.size();++i){if(s[i]=='+')o+=' ';else if(s[i]=='%'&&i+2<s.size()){char h[3]={s[i+1],s[i+2],0};o+=static_cast<char>(std::strtoul(h,nullptr,16));i+=2;}else o+=s[i];}return o; }
|
||||
std::string jsonEscape(const std::string& s){std::string o="\"";for(unsigned char c:s){if(c=='\\'||c=='\"'){o+='\\';o+=static_cast<char>(c);}else if(c<32){constexpr char h[]="0123456789abcdef";o+="\\u00";o+=h[c>>4];o+=h[c&15];}else o+=static_cast<char>(c);}o+='\"';return o;}
|
||||
std::map<std::string,std::string> fields(const std::string& b){std::map<std::string,std::string> m;std::size_t p=0;while(p<=b.size()){auto e=b.find('&',p);auto part=b.substr(p,e==std::string::npos?e:e-p);auto q=part.find('=');if(q!=std::string::npos)m[decode(part.substr(0,q))]=decode(part.substr(q+1));if(e==std::string::npos)break;p=e+1;}return m;}
|
||||
bool cleanValue(std::string& s, std::size_t max) { if(s.empty()||s.size()>max)return false;for(unsigned char c:s)if(c<32||c==127)return false;return true; }
|
||||
void handle(State* st,int fd){std::string req;std::size_t bodyAt=0,bodySize=0;if(!readRequest(fd,req,bodyAt,bodySize)){textReply(fd,400,"Bad Request","Invalid request.");return;}auto lineEnd=req.find("\r\n");if(lineEnd==std::string::npos)return;std::istringstream first(req.substr(0,lineEnd));std::string method,path,version;first>>method>>path>>version;std::string headers=req.substr(lineEnd+2,bodyAt-lineEnd-4);auto h=headers.find("Authorization: Bearer ");std::string auth=h==std::string::npos?"":headers.substr(h+22);auto trimEnd=auth.find("\r\n");if(trimEnd!=std::string::npos)auth.resize(trimEnd);auto peer=static_cast<std::uint32_t>(st->pairedAddress);sockaddr_in addr{};socklen_t addrLen=sizeof(addr);if(getpeername(fd,reinterpret_cast<sockaddr*>(&addr),&addrLen)==0)peer=ntohl(addr.sin_addr.s_addr);
|
||||
if(method=="GET"&&path=="/"){reply(fd,200,"OK","text/html; charset=utf-8",page());return;}if(method=="GET"&&path=="/favicon.ico"){reply(fd,204,"No Content","image/x-icon","");return;}if(method=="GET"&&path=="/api/health"){textReply(fd,200,"OK","Branding portal build: save-diagnostics-1");return;}if(method=="GET"&&path=="/assets/font"){std::ifstream f(st->assetsRoot+"/fonts/FOT-Seurat.otf",std::ios::binary|std::ios::ate);if(!f){textReply(fd,404,"Not Found","Font unavailable.");return;}auto n=f.tellg();if(n<=0||n>4*1024*1024){textReply(fd,404,"Not Found","Font unavailable.");return;}std::string data(static_cast<std::size_t>(n),'\0');f.seekg(0);f.read(data.data(),n);reply(fd,200,"OK","font/otf",data);return;}
|
||||
if(method=="POST"&&path=="/api/connect"){if(st->failedCodeAttempts>=8){textReply(fd,429,"Too Many Requests","Close and reopen the portal on Switch to try again.");return;}std::string code=auth;code.erase(std::remove(code.begin(),code.end(),'"'),code.end());if(!equalToken(code,st->code)){++st->failedCodeAttempts;logEvent(st,"pairing rejected");textReply(fd,403,"Forbidden","Incorrect code.");return;}st->pairedAddress=peer;std::array<std::uint8_t,32> random{};if(R_FAILED(csrngGetRandomBytes(random.data(),random.size()))){textReply(fd,500,"Internal Server Error","Could not create session.");return;}constexpr char hex[]="0123456789abcdef";st->token.clear();for(auto b:random){st->token+=hex[b>>4];st->token+=hex[b&15];}st->lastSeenSeconds.store(static_cast<std::uint64_t>(std::time(nullptr)));st->connected.store(true);logEvent(st,"paired PC session");textReply(fd,200,"OK",st->token);return;}
|
||||
if(!st->connected.load()||peer!=st->pairedAddress||!equalToken(auth,st->token)){textReply(fd,403,"Forbidden","Connect using the code on the Switch first.");return;}st->lastSeenSeconds.store(static_cast<std::uint64_t>(std::time(nullptr)));
|
||||
if(method=="GET"&&path=="/api/status"){textReply(fd,200,"OK","{\"connected\":true}");return;}
|
||||
if(method=="GET"&&path=="/api/config"){std::ifstream in(st->dataDirectory+"/branding.ini",std::ios::binary);std::string app=portalAppName,one=portalNoteSingular,many=portalNotePlural;if(in){std::string v;if(std::getline(in,v)&&!v.empty())app=v;if(std::getline(in,v)&&!v.empty())one=v;if(std::getline(in,v)&&!v.empty())many=v;}textReply(fd,200,"OK","{\"app\":"+jsonEscape(app)+",\"singular\":"+jsonEscape(one)+",\"plural\":"+jsonEscape(many)+"}");return;}
|
||||
if(method=="GET"&&path=="/api/bundle"){
|
||||
branding::Bundle bundle;std::ifstream config(st->dataDirectory+"/branding.ini",std::ios::binary);
|
||||
if(config){std::string value;if(std::getline(config,value)&&!value.empty())bundle.app=value;else bundle.app=portalAppName;if(std::getline(config,value)&&!value.empty())bundle.singular=value;else bundle.singular=portalNoteSingular;if(std::getline(config,value)&&!value.empty())bundle.plural=value;else bundle.plural=portalNotePlural;}
|
||||
else{bundle.app=portalAppName;bundle.singular=portalNoteSingular;bundle.plural=portalNotePlural;}
|
||||
std::string logoPath=st->dataDirectory+"/branding-logo.png";std::ifstream logo(logoPath,std::ios::binary|std::ios::ate);
|
||||
if(!logo){logoPath=st->assetsRoot+"/art/switchnotedefault.png";logo.open(logoPath,std::ios::binary|std::ios::ate);}
|
||||
if(!logo){logEvent(st,"branding export failed: no logo available");textReply(fd,500,"Internal Server Error","No title logo is available to export.");return;}
|
||||
const std::streamoff logoSize=logo.tellg();if(logoSize<=0||logoSize>8*1024*1024){textReply(fd,500,"Internal Server Error","The title logo has an invalid size.");return;}
|
||||
bundle.logo.resize(static_cast<std::size_t>(logoSize));logo.seekg(0);logo.read(reinterpret_cast<char*>(bundle.logo.data()),logoSize);if(!logo){textReply(fd,500,"Internal Server Error","Could not read the title logo.");return;}logo.close();
|
||||
const char* image=reinterpret_cast<const char*>(bundle.logo.data());const bool png=bundle.logo.size()>=8&&std::memcmp(image,"\x89PNG\r\n\x1a\n",8)==0;const bool jpg=bundle.logo.size()>=3&&static_cast<unsigned char>(image[0])==0xff&&static_cast<unsigned char>(image[1])==0xd8&&static_cast<unsigned char>(image[2])==0xff;if(!png&&!jpg){textReply(fd,415,"Unsupported Media Type","The current logo is not a supported PNG or JPEG.");return;}
|
||||
const std::string temp=st->dataDirectory+"/branding-export.pending.config";std::string error;if(!branding::writeConfig(temp,bundle,error)){logEvent(st,"branding export failed: "+error);textReply(fd,500,"Internal Server Error",error);return;}
|
||||
std::ifstream archive(temp,std::ios::binary|std::ios::ate);const std::streamoff size=archive?static_cast<std::streamoff>(archive.tellg()):-1;if(size<22||size>9*1024*1024){archive.close();std::remove(temp.c_str());textReply(fd,500,"Internal Server Error","Could not prepare branding.config.");return;}std::string bytes(static_cast<std::size_t>(size),'\0');archive.seekg(0);archive.read(bytes.data(),static_cast<std::streamsize>(size));archive.close();std::remove(temp.c_str());if(!archive){textReply(fd,500,"Internal Server Error","Could not read exported branding.config.");return;}logEvent(st,"branding config exported: "+std::to_string(bytes.size())+" bytes");reply(fd,200,"OK","application/octet-stream",bytes);return;
|
||||
}
|
||||
if(method=="POST"&&path=="/api/bundle"){
|
||||
if(bodySize<22||bodySize>kMaxRequest){textReply(fd,400,"Bad Request","Branding file size is invalid.");return;}
|
||||
const std::string temp=st->dataDirectory+"/branding-import.pending.config";{std::ofstream file(temp,std::ios::binary|std::ios::trunc);if(!file){textReply(fd,500,"Internal Server Error","Could not open a temporary branding file.");return;}file.write(req.data()+bodyAt,static_cast<std::streamsize>(bodySize));file.flush();if(!file){textReply(fd,500,"Internal Server Error","Could not store the branding file.");return;}}
|
||||
branding::Bundle bundle;std::string error;const bool parsed=branding::readConfig(temp,bundle,error);std::remove(temp.c_str());if(!parsed){logEvent(st,"branding import rejected: "+error);textReply(fd,400,"Bad Request",error);return;}
|
||||
auto valid=[](const std::string& value){return std::all_of(value.begin(),value.end(),[](unsigned char c){return std::isalnum(c)||c==' '||c=='-'||c=='_'||c=='.'||c>=128;});};if(!cleanValue(bundle.app,40)||!cleanValue(bundle.singular,24)||!cleanValue(bundle.plural,24)||!valid(bundle.app)||!valid(bundle.singular)||!valid(bundle.plural)){textReply(fd,400,"Bad Request","Branding names contain unsupported characters or exceed their limits.");return;}
|
||||
const char* image=reinterpret_cast<const char*>(bundle.logo.data());const bool png=bundle.logo.size()>=8&&std::memcmp(image,"\x89PNG\r\n\x1a\n",8)==0;const bool jpg=bundle.logo.size()>=3&&static_cast<unsigned char>(image[0])==0xff&&static_cast<unsigned char>(image[1])==0xd8&&static_cast<unsigned char>(image[2])==0xff;if(!png&&!jpg){textReply(fd,415,"Unsupported Media Type","Branding config must contain a PNG or JPEG logo.");return;}
|
||||
const std::string logoPath=st->dataDirectory+"/branding-logo.pending";{std::ofstream logo(logoPath,std::ios::binary|std::ios::trunc);if(!logo){textReply(fd,500,"Internal Server Error","Could not stage the title logo.");return;}logo.write(reinterpret_cast<const char*>(bundle.logo.data()),static_cast<std::streamsize>(bundle.logo.size()));logo.flush();if(!logo){textReply(fd,500,"Internal Server Error","Could not write the title logo.");return;}}
|
||||
const std::string configPath=st->dataDirectory+"/branding.ini";{std::ofstream config(configPath,std::ios::binary|std::ios::trunc);if(!config){std::remove(logoPath.c_str());textReply(fd,500,"Internal Server Error","Could not open branding settings on the SD card.");return;}config<<bundle.app<<'\n'<<bundle.singular<<'\n'<<bundle.plural<<'\n';config.flush();if(!config){std::remove(logoPath.c_str());textReply(fd,500,"Internal Server Error","Could not write branding settings to the SD card.");return;}}
|
||||
logEvent(st,"branding config imported: "+std::to_string(bodySize)+" bytes");st->pending.store(true,std::memory_order_release);textReply(fd,200,"OK","Imported.");return;
|
||||
}
|
||||
if(method=="PUT"&&path=="/api/logo"){if(bodySize<8||bodySize>8*1024*1024){logEvent(st,"logo upload rejected: invalid size");textReply(fd,400,"Bad Request","Logo must be 8 MB or smaller.");return;}const char* b=req.data()+bodyAt;bool png=bodySize>=8&&std::memcmp(b,"\x89PNG\r\n\x1a\n",8)==0;bool jpg=bodySize>=3&&static_cast<unsigned char>(b[0])==0xff&&static_cast<unsigned char>(b[1])==0xd8&&static_cast<unsigned char>(b[2])==0xff;if(!png&&!jpg){logEvent(st,"logo upload rejected: invalid image signature");textReply(fd,415,"Unsupported Media Type","Choose a valid PNG or JPEG logo.");return;}std::string tmp=st->dataDirectory+"/branding-logo.pending";std::ofstream out(tmp,std::ios::binary|std::ios::trunc);if(!out){logEvent(st,"logo upload failed: pending file open failed");textReply(fd,500,"Internal Server Error","Could not open logo file on the SD card.");return;}out.write(b,static_cast<std::streamsize>(bodySize));out.flush();out.close();if(!out){logEvent(st,"logo upload failed: pending file write failed");textReply(fd,500,"Internal Server Error","Could not store logo on the SD card.");return;}logEvent(st,"logo uploaded: "+std::to_string(bodySize)+" bytes");textReply(fd,200,"OK","Logo received.");return;}
|
||||
if(method=="PUT"&&path.size()==14&&path.rfind("/api/loading/",0)==0&&path.back()>='0'&&path.back()<='7'){if(bodySize<8||bodySize>128*1024){textReply(fd,400,"Bad Request","Loading frame is too large.");return;}const char*b=req.data()+bodyAt;if(std::memcmp(b,"\x89PNG\r\n\x1a\n",8)!=0){textReply(fd,415,"Unsupported Media Type","Loading frames must be PNG images.");return;}const std::string file=st->dataDirectory+"/branding-loading"+path.substr(path.size()-1)+".pending";std::ofstream out(file,std::ios::binary|std::ios::trunc);if(!out){textReply(fd,500,"Internal Server Error","Could not save loading frame.");return;}out.write(b,static_cast<std::streamsize>(bodySize));out.flush();if(!out){textReply(fd,500,"Internal Server Error","Could not write loading frame.");return;}logEvent(st,"loading frame uploaded: "+path.substr(path.size()-1));textReply(fd,200,"OK","Frame received.");return;}
|
||||
if(method=="POST"&&path=="/api/config"){auto v=fields(req.substr(bodyAt,bodySize));std::string app=v["app"],one=v["singular"],many=v["plural"];if(!cleanValue(app,40)||!cleanValue(one,24)||!cleanValue(many,24)){textReply(fd,400,"Bad Request","Enter names within the listed limits.");return;}auto valid=[](const std::string&s){return std::all_of(s.begin(),s.end(),[](unsigned char c){return std::isalnum(c)||c==' '||c=='-'||c=='_'||c=='.'||c>=128;});};if(!valid(app)||!valid(one)||!valid(many)){textReply(fd,400,"Bad Request","Names may use letters, numbers, spaces, hyphens, underscores and periods.");return;}
|
||||
// libnx's std::rename over FAT/SD can fail when replacing an existing
|
||||
// file, even after the old destination has been removed. Write the small
|
||||
// config directly instead of reporting a false save failure.
|
||||
const std::string dst=st->dataDirectory+"/branding.ini";
|
||||
std::ofstream out(dst,std::ios::binary|std::ios::trunc);
|
||||
if(!out){logEvent(st,"branding save failed: destination open failed");textReply(fd,500,"Internal Server Error","Could not open branding file on the SD card.");return;}
|
||||
out<<app<<'\n'<<one<<'\n'<<many<<'\n';out.flush();
|
||||
if(!out){out.close();logEvent(st,"branding save failed: write failed");textReply(fd,500,"Internal Server Error","Could not write branding file to the SD card.");return;}
|
||||
out.close();if(!out){logEvent(st,"branding save failed: close/flush failed");textReply(fd,500,"Internal Server Error","Could not finish writing branding file to the SD card.");return;}
|
||||
logEvent(st,"branding settings saved");st->pending.store(true,std::memory_order_release);textReply(fd,200,"OK","Saved.");return;}
|
||||
textReply(fd,404,"Not Found","Not found.");
|
||||
}
|
||||
void serverMain(void* arg){State* st=static_cast<State*>(arg);while(st->running.load()){fd_set set;FD_ZERO(&set);FD_SET(st->listenFd,&set);timeval wait{0,200000};if(select(st->listenFd+1,&set,nullptr,nullptr,&wait)<=0)continue;sockaddr_in peer{};socklen_t size=sizeof(peer);int fd=accept(st->listenFd,reinterpret_cast<sockaddr*>(&peer),&size);if(fd<0)continue;st->activeClient.store(fd);timeval timeout{5,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));handle(st,fd);st->activeClient.store(-1);::close(fd);}if(st->listenFd>=0){::close(st->listenFd);st->listenFd=-1;}st->running.store(false);}
|
||||
#endif
|
||||
}
|
||||
|
||||
BrandingConfigServer::BrandingConfigServer() = default;
|
||||
BrandingConfigServer::~BrandingConfigServer(){stop();}
|
||||
bool BrandingConfigServer::start(const std::string& ip,const std::string& data,const std::string& assets,const std::string& code,std::string& error,const std::string& appName,const std::string& noteSingular,const std::string& notePlural){
|
||||
#ifdef __SWITCH__
|
||||
portalAppName = appName;
|
||||
portalNoteSingular = noteSingular;
|
||||
portalNotePlural = notePlural;
|
||||
stop();State* s=new(std::nothrow) State();if(!s){error="Not enough memory to start the portal.";return false;}s->bindAddress=ip;s->dataDirectory=data;s->assetsRoot=assets;s->code=code;s->listenFd=::socket(AF_INET,SOCK_STREAM,0);if(s->listenFd<0){error="Could not create the local web server.";delete s;return false;}int yes=1;setsockopt(s->listenFd,SOL_SOCKET,SO_REUSEADDR,&yes,sizeof(yes));sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(kPort);if(inet_pton(AF_INET,ip.c_str(),&a.sin_addr)!=1||bind(s->listenFd,reinterpret_cast<sockaddr*>(&a),sizeof(a))<0||listen(s->listenFd,4)<0){error="Could not start the portal on port 8767.";::close(s->listenFd);delete s;return false;}s->running.store(true);if(R_FAILED(threadCreate(&s->thread,serverMain,s,nullptr,0x10000,0x3b,-2))){error="Could not create the portal service thread.";s->running.store(false);::close(s->listenFd);delete s;return false;}if(R_FAILED(threadStart(&s->thread))){error="Could not start the portal service.";s->running.store(false);::close(s->listenFd);threadClose(&s->thread);delete s;return false;}impl_=s;return true;
|
||||
#else
|
||||
(void)ip;(void)data;(void)assets;(void)code;(void)appName;(void)noteSingular;(void)notePlural;error="The branding portal is available in the Switch homebrew app.";return false;
|
||||
#endif
|
||||
}
|
||||
void BrandingConfigServer::stop(){
|
||||
#ifdef __SWITCH__
|
||||
State*s=static_cast<State*>(impl_);if(!s)return;s->running.store(false);int c=s->activeClient.load();if(c>=0)::shutdown(c,SHUT_RDWR);if(s->listenFd>=0)::shutdown(s->listenFd,SHUT_RDWR);threadWaitForExit(&s->thread);threadClose(&s->thread);delete s;impl_=nullptr;
|
||||
#endif
|
||||
}
|
||||
bool BrandingConfigServer::isRunning()const{
|
||||
#ifdef __SWITCH__
|
||||
auto*s=static_cast<State*>(impl_);return s&&s->running.load();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
bool BrandingConfigServer::isConnected()const{
|
||||
#ifdef __SWITCH__
|
||||
auto*s=static_cast<State*>(impl_);if(!s||!s->connected.load())return false;const auto now=static_cast<std::uint64_t>(std::time(nullptr));const auto last=s->lastSeenSeconds.load();return now>=last&&now-last<=6;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
bool BrandingConfigServer::takeConfigUpdate(){
|
||||
#ifdef __SWITCH__
|
||||
auto*s=static_cast<State*>(impl_);return s&&s->pending.exchange(false,std::memory_order_acq_rel);
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
#include "export.hpp"
|
||||
|
||||
#include <SDL.h>
|
||||
#include <SDL_image.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace sppm {
|
||||
namespace {
|
||||
|
||||
using Bytes = std::vector<std::uint8_t>;
|
||||
|
||||
void be32(Bytes& out, std::uint32_t value) {
|
||||
out.push_back(static_cast<std::uint8_t>(value >> 24));
|
||||
out.push_back(static_cast<std::uint8_t>(value >> 16));
|
||||
out.push_back(static_cast<std::uint8_t>(value >> 8));
|
||||
out.push_back(static_cast<std::uint8_t>(value));
|
||||
}
|
||||
void be16(Bytes& out, std::uint16_t value) {
|
||||
out.push_back(static_cast<std::uint8_t>(value >> 8));
|
||||
out.push_back(static_cast<std::uint8_t>(value));
|
||||
}
|
||||
void type(Bytes& out, const char* value) {
|
||||
out.insert(out.end(), value, value + 4);
|
||||
}
|
||||
std::size_t boxBegin(Bytes& out, const char* name) {
|
||||
const std::size_t start = out.size();
|
||||
be32(out, 0); type(out, name);
|
||||
return start;
|
||||
}
|
||||
void boxEnd(Bytes& out, std::size_t start) {
|
||||
const std::size_t size = out.size() - start;
|
||||
out[start] = static_cast<std::uint8_t>(size >> 24);
|
||||
out[start + 1] = static_cast<std::uint8_t>(size >> 16);
|
||||
out[start + 2] = static_cast<std::uint8_t>(size >> 8);
|
||||
out[start + 3] = static_cast<std::uint8_t>(size);
|
||||
}
|
||||
std::size_t fullBoxBegin(Bytes& out, const char* name, std::uint8_t version = 0,
|
||||
std::uint32_t flags = 0) {
|
||||
const auto start = boxBegin(out, name);
|
||||
out.push_back(version);
|
||||
out.push_back(static_cast<std::uint8_t>(flags >> 16));
|
||||
out.push_back(static_cast<std::uint8_t>(flags >> 8));
|
||||
out.push_back(static_cast<std::uint8_t>(flags));
|
||||
return start;
|
||||
}
|
||||
|
||||
Bytes mp4MovieBox(const std::vector<std::uint32_t>& sizes,
|
||||
const std::vector<std::uint32_t>& offsets,
|
||||
double fps) {
|
||||
constexpr std::uint32_t timeScale = 1000000;
|
||||
const std::uint32_t frameDelta = static_cast<std::uint32_t>(std::lround(timeScale / fps));
|
||||
const std::uint32_t duration = static_cast<std::uint32_t>(std::llround(sizes.size() * static_cast<double>(timeScale) / fps));
|
||||
Bytes out;
|
||||
auto moov = boxBegin(out, "moov");
|
||||
auto mvhd = fullBoxBegin(out, "mvhd");
|
||||
be32(out, 0); be32(out, 0); be32(out, timeScale);
|
||||
be32(out, duration);
|
||||
be32(out, 0x00010000); be16(out, 0x0100); be16(out, 0);
|
||||
be32(out, 0); be32(out, 0);
|
||||
const std::uint32_t matrix[] = {0x00010000,0,0, 0,0x00010000,0, 0,0,0x40000000};
|
||||
for (auto value : matrix) be32(out, value);
|
||||
for (int i = 0; i < 6; ++i) be32(out, 0);
|
||||
be32(out, 2); boxEnd(out, mvhd);
|
||||
|
||||
auto trak = boxBegin(out, "trak");
|
||||
auto tkhd = fullBoxBegin(out, "tkhd", 0, 7);
|
||||
be32(out, 0); be32(out, 0); be32(out, 1); be32(out, 0);
|
||||
be32(out, duration);
|
||||
be32(out, 0); be32(out, 0); be16(out, 0); be16(out, 0); be16(out, 0); be16(out, 0);
|
||||
for (auto value : matrix) be32(out, value);
|
||||
be32(out, static_cast<std::uint32_t>(kWidth) << 16);
|
||||
be32(out, static_cast<std::uint32_t>(kHeight) << 16); boxEnd(out, tkhd);
|
||||
|
||||
auto mdia = boxBegin(out, "mdia");
|
||||
auto mdhd = fullBoxBegin(out, "mdhd");
|
||||
be32(out, 0); be32(out, 0); be32(out, timeScale);
|
||||
be32(out, duration); be16(out, 0x55c4); be16(out, 0);
|
||||
boxEnd(out, mdhd);
|
||||
auto hdlr = fullBoxBegin(out, "hdlr");
|
||||
be32(out, 0); type(out, "vide"); be32(out, 0); be32(out, 0); be32(out, 0);
|
||||
out.insert(out.end(), {'S','w','i','t','c','h','n','o','t','e',' ','V','i','d','e','o',0});
|
||||
boxEnd(out, hdlr);
|
||||
auto minf = boxBegin(out, "minf");
|
||||
auto vmhd = fullBoxBegin(out, "vmhd", 0, 1);
|
||||
be16(out, 0); be16(out, 0); be16(out, 0); be16(out, 0); boxEnd(out, vmhd);
|
||||
auto dinf = boxBegin(out, "dinf");
|
||||
auto dref = fullBoxBegin(out, "dref"); be32(out, 1);
|
||||
auto url = fullBoxBegin(out, "url ", 0, 1); boxEnd(out, url); boxEnd(out, dref); boxEnd(out, dinf);
|
||||
auto stbl = boxBegin(out, "stbl");
|
||||
auto stsd = fullBoxBegin(out, "stsd"); be32(out, 1);
|
||||
auto jpeg = boxBegin(out, "mjpg");
|
||||
be32(out, 0); be16(out, 0); be16(out, 1);
|
||||
be16(out, 0); be16(out, 0); be32(out, 0); be32(out, 0); be32(out, 0);
|
||||
be16(out, kWidth); be16(out, kHeight); be32(out, 0x00480000); be32(out, 0x00480000);
|
||||
be32(out, 0); be16(out, 1); out.insert(out.end(), 32, 0); be16(out, 0x0018); be16(out, 0xffff);
|
||||
boxEnd(out, jpeg); boxEnd(out, stsd);
|
||||
auto stts = fullBoxBegin(out, "stts"); be32(out, 1); be32(out, static_cast<std::uint32_t>(sizes.size())); be32(out, frameDelta); boxEnd(out, stts);
|
||||
auto stsc = fullBoxBegin(out, "stsc"); be32(out, 1); be32(out, 1); be32(out, 1); be32(out, 1); boxEnd(out, stsc);
|
||||
auto stsz = fullBoxBegin(out, "stsz"); be32(out, 0); be32(out, static_cast<std::uint32_t>(sizes.size()));
|
||||
for (auto size : sizes) be32(out, size);
|
||||
boxEnd(out, stsz);
|
||||
auto stco = fullBoxBegin(out, "stco"); be32(out, static_cast<std::uint32_t>(offsets.size()));
|
||||
for (auto offset : offsets) be32(out, offset);
|
||||
boxEnd(out, stco);
|
||||
auto stss = fullBoxBegin(out, "stss"); be32(out, static_cast<std::uint32_t>(sizes.size()));
|
||||
for (std::size_t i = 0; i < sizes.size(); ++i) be32(out, static_cast<std::uint32_t>(i + 1));
|
||||
boxEnd(out, stss);
|
||||
boxEnd(out, stbl); boxEnd(out, minf); boxEnd(out, mdia); boxEnd(out, trak); boxEnd(out, moov);
|
||||
return out;
|
||||
}
|
||||
|
||||
Color opaqueFramePixel(const Frame& frame, const Palette& palette, std::size_t pixel) {
|
||||
Color result{255, 255, 255, 255};
|
||||
for (std::size_t layerIndex = frame.layers.size(); layerIndex-- > 0;) {
|
||||
const auto index = frame.layers[layerIndex][pixel];
|
||||
if (!index) continue;
|
||||
const auto& source = palette[index];
|
||||
if (!source.a) continue;
|
||||
const unsigned alpha = source.a;
|
||||
result.r = static_cast<std::uint8_t>((source.r * alpha + result.r * (255 - alpha) + 127) / 255);
|
||||
result.g = static_cast<std::uint8_t>((source.g * alpha + result.g * (255 - alpha) + 127) / 255);
|
||||
result.b = static_cast<std::uint8_t>((source.b * alpha + result.b * (255 - alpha) + 127) / 255);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool checkDocument(const Document& document, bool cover, std::string& error) {
|
||||
if (document.frames.empty() || document.frames.size() > kMaxFrames) {
|
||||
error = "frame count must be between 1 and 9999";
|
||||
return false;
|
||||
}
|
||||
if (document.metadata.fps < 1 || document.metadata.fps > 30) {
|
||||
error = "frame rate must be between 1 and 30 fps";
|
||||
return false;
|
||||
}
|
||||
if (cover && document.metadata.coverFrame >= document.frames.size()) {
|
||||
error = "cover frame is out of range";
|
||||
return false;
|
||||
}
|
||||
for (const Frame& frame : document.frames) {
|
||||
for (const auto& layer : frame.layers) {
|
||||
if (layer.size() != kPixelsPerLayer) {
|
||||
error = "a layer has the wrong pixel count";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
error.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Straight-alpha source-over compositing, with A foremost and D rearmost.
|
||||
Color compositePixel(const Frame& frame, const Palette& palette,
|
||||
std::size_t pixel) {
|
||||
Color result{0, 0, 0, 0};
|
||||
for (std::size_t layerIndex = frame.layers.size(); layerIndex-- > 0;) {
|
||||
const auto& layer = frame.layers[layerIndex];
|
||||
const std::uint8_t index = layer[pixel];
|
||||
if (index == 0) continue;
|
||||
const Color& source = palette[index];
|
||||
if (source.a == 0) continue;
|
||||
if (source.a == 255) {
|
||||
result = source;
|
||||
continue;
|
||||
}
|
||||
const std::uint32_t sourceWeight =
|
||||
static_cast<std::uint32_t>(source.a) * 255U;
|
||||
const std::uint32_t behindWeight =
|
||||
static_cast<std::uint32_t>(result.a) * (255U - source.a);
|
||||
const std::uint32_t total = sourceWeight + behindWeight;
|
||||
result.r = static_cast<std::uint8_t>(
|
||||
(source.r * sourceWeight + result.r * behindWeight + total / 2U) / total);
|
||||
result.g = static_cast<std::uint8_t>(
|
||||
(source.g * sourceWeight + result.g * behindWeight + total / 2U) / total);
|
||||
result.b = static_cast<std::uint8_t>(
|
||||
(source.b * sourceWeight + result.b * behindWeight + total / 2U) / total);
|
||||
result.a = static_cast<std::uint8_t>((total + 127U) / 255U);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::uint8_t nearestPaletteIndex(const Color& color, const Palette& palette) {
|
||||
std::uint32_t bestDistance = 3U * 255U * 255U + 1U;
|
||||
std::uint8_t best = 0;
|
||||
for (std::size_t i = 1; i < palette.size(); ++i) {
|
||||
const Color& candidate = palette[i];
|
||||
if (candidate.a == 0) continue;
|
||||
const int dr = static_cast<int>(color.r) - candidate.r;
|
||||
const int dg = static_cast<int>(color.g) - candidate.g;
|
||||
const int db = static_cast<int>(color.b) - candidate.b;
|
||||
const std::uint32_t distance =
|
||||
static_cast<std::uint32_t>(dr * dr + dg * dg + db * db);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = static_cast<std::uint8_t>(i);
|
||||
if (distance == 0) break;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// GIF has a single transparent index, so partially transparent colors are
|
||||
// composited first, then matched to the nearest RGB color in the palette.
|
||||
std::vector<std::uint8_t> gifPixels(const Frame& frame,
|
||||
const Palette& palette) {
|
||||
std::vector<std::uint8_t> pixels(kPixelsPerLayer, 0);
|
||||
bool hasPartialAlpha = false;
|
||||
for (std::size_t i = 1; i < palette.size(); ++i) {
|
||||
if (palette[i].a != 0 && palette[i].a != 255) {
|
||||
hasPartialAlpha = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasPartialAlpha) {
|
||||
for (std::size_t layerIndex = frame.layers.size(); layerIndex-- > 0;) {
|
||||
const auto& layer = frame.layers[layerIndex];
|
||||
for (std::size_t pixel = 0; pixel < pixels.size(); ++pixel) {
|
||||
const std::uint8_t index = layer[pixel];
|
||||
if (index != 0 && palette[index].a != 0) pixels[pixel] = index;
|
||||
}
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
std::unordered_map<std::uint32_t, std::uint8_t> matchCache;
|
||||
matchCache.reserve(512);
|
||||
for (std::size_t pixel = 0; pixel < pixels.size(); ++pixel) {
|
||||
const Color color = compositePixel(frame, palette, pixel);
|
||||
if (color.a < 128) continue;
|
||||
const std::uint32_t key =
|
||||
(static_cast<std::uint32_t>(color.r) << 16U) |
|
||||
(static_cast<std::uint32_t>(color.g) << 8U) | color.b;
|
||||
const auto found = matchCache.find(key);
|
||||
if (found != matchCache.end()) {
|
||||
pixels[pixel] = found->second;
|
||||
} else {
|
||||
const std::uint8_t index = nearestPaletteIndex(color, palette);
|
||||
matchCache.emplace(key, index);
|
||||
pixels[pixel] = index;
|
||||
}
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
struct BitPacker {
|
||||
std::vector<std::uint8_t> data;
|
||||
std::uint32_t pending = 0;
|
||||
unsigned pendingBits = 0;
|
||||
|
||||
void code(std::uint16_t value, unsigned width) {
|
||||
pending |= static_cast<std::uint32_t>(value) << pendingBits;
|
||||
pendingBits += width;
|
||||
while (pendingBits >= 8) {
|
||||
data.push_back(static_cast<std::uint8_t>(pending & 0xFFU));
|
||||
pending >>= 8U;
|
||||
pendingBits -= 8U;
|
||||
}
|
||||
}
|
||||
|
||||
void finish() {
|
||||
if (pendingBits != 0) data.push_back(static_cast<std::uint8_t>(pending));
|
||||
}
|
||||
};
|
||||
|
||||
std::vector<std::uint8_t> gifLzw(const std::vector<std::uint8_t>& pixels) {
|
||||
constexpr std::uint16_t clearCode = 256;
|
||||
constexpr std::uint16_t endCode = 257;
|
||||
std::unordered_map<std::uint32_t, std::uint16_t> dictionary;
|
||||
dictionary.reserve(4096);
|
||||
std::uint16_t nextCode = 258;
|
||||
unsigned codeWidth = 9;
|
||||
BitPacker writer;
|
||||
writer.data.reserve(pixels.size() / 2);
|
||||
writer.code(clearCode, codeWidth);
|
||||
std::uint16_t prefix = pixels.front();
|
||||
|
||||
for (std::size_t i = 1; i < pixels.size(); ++i) {
|
||||
const std::uint8_t suffix = pixels[i];
|
||||
const std::uint32_t key =
|
||||
(static_cast<std::uint32_t>(prefix) << 8U) | suffix;
|
||||
const auto found = dictionary.find(key);
|
||||
if (found != dictionary.end()) {
|
||||
prefix = found->second;
|
||||
continue;
|
||||
}
|
||||
|
||||
writer.code(prefix, codeWidth);
|
||||
if (nextCode < 4096) {
|
||||
dictionary.emplace(key, nextCode++);
|
||||
// GIF's decoder learns each new phrase one code later.
|
||||
if (codeWidth < 12 && nextCode > (1U << codeWidth)) ++codeWidth;
|
||||
} else {
|
||||
writer.code(clearCode, codeWidth);
|
||||
dictionary.clear();
|
||||
nextCode = 258;
|
||||
codeWidth = 9;
|
||||
}
|
||||
prefix = suffix;
|
||||
}
|
||||
writer.code(prefix, codeWidth);
|
||||
writer.code(endCode, codeWidth);
|
||||
writer.finish();
|
||||
return writer.data;
|
||||
}
|
||||
|
||||
void byte(std::ostream& out, std::uint8_t value) {
|
||||
out.put(static_cast<char>(value));
|
||||
}
|
||||
|
||||
void le16(std::ostream& out, std::uint16_t value) {
|
||||
byte(out, static_cast<std::uint8_t>(value));
|
||||
byte(out, static_cast<std::uint8_t>(value >> 8U));
|
||||
}
|
||||
|
||||
void be32(std::ostream& out, std::uint32_t value) {
|
||||
byte(out, static_cast<std::uint8_t>(value >> 24U));
|
||||
byte(out, static_cast<std::uint8_t>(value >> 16U));
|
||||
byte(out, static_cast<std::uint8_t>(value >> 8U));
|
||||
byte(out, static_cast<std::uint8_t>(value));
|
||||
}
|
||||
|
||||
std::uint32_t crc32(const char* type, const std::vector<std::uint8_t>& data) {
|
||||
std::uint32_t crc = 0xFFFFFFFFU;
|
||||
auto update = [&crc](std::uint8_t value) {
|
||||
crc ^= value;
|
||||
for (unsigned bit = 0; bit < 8; ++bit) {
|
||||
crc = (crc >> 1U) ^ ((crc & 1U) ? 0xEDB88320U : 0U);
|
||||
}
|
||||
};
|
||||
for (int i = 0; i < 4; ++i) update(static_cast<std::uint8_t>(type[i]));
|
||||
for (std::uint8_t value : data) update(value);
|
||||
return crc ^ 0xFFFFFFFFU;
|
||||
}
|
||||
|
||||
void pngChunk(std::ostream& out, const char* type,
|
||||
const std::vector<std::uint8_t>& data) {
|
||||
be32(out, static_cast<std::uint32_t>(data.size()));
|
||||
out.write(type, 4);
|
||||
if (!data.empty()) {
|
||||
out.write(reinterpret_cast<const char*>(data.data()),
|
||||
static_cast<std::streamsize>(data.size()));
|
||||
}
|
||||
be32(out, crc32(type, data));
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> zlibStored(const std::vector<std::uint8_t>& raw) {
|
||||
std::vector<std::uint8_t> encoded;
|
||||
encoded.reserve(raw.size() + 6 + (raw.size() / 65535 + 1) * 5);
|
||||
encoded.push_back(0x78); // zlib CMF: deflate, 32 KiB window
|
||||
encoded.push_back(0x01); // zlib FLG: no compression
|
||||
std::size_t offset = 0;
|
||||
while (offset < raw.size()) {
|
||||
const std::size_t length = std::min<std::size_t>(65535, raw.size() - offset);
|
||||
const bool final = offset + length == raw.size();
|
||||
const std::uint16_t len = static_cast<std::uint16_t>(length);
|
||||
const std::uint16_t inverse = static_cast<std::uint16_t>(~len);
|
||||
encoded.push_back(final ? 0x01 : 0x00);
|
||||
encoded.push_back(static_cast<std::uint8_t>(len));
|
||||
encoded.push_back(static_cast<std::uint8_t>(len >> 8U));
|
||||
encoded.push_back(static_cast<std::uint8_t>(inverse));
|
||||
encoded.push_back(static_cast<std::uint8_t>(inverse >> 8U));
|
||||
encoded.insert(encoded.end(), raw.begin() + static_cast<std::ptrdiff_t>(offset),
|
||||
raw.begin() + static_cast<std::ptrdiff_t>(offset + length));
|
||||
offset += length;
|
||||
}
|
||||
std::uint32_t a = 1;
|
||||
std::uint32_t b = 0;
|
||||
for (std::uint8_t value : raw) {
|
||||
a = (a + value) % 65521U;
|
||||
b = (b + a) % 65521U;
|
||||
}
|
||||
const std::uint32_t adler = (b << 16U) | a;
|
||||
encoded.push_back(static_cast<std::uint8_t>(adler >> 24U));
|
||||
encoded.push_back(static_cast<std::uint8_t>(adler >> 16U));
|
||||
encoded.push_back(static_cast<std::uint8_t>(adler >> 8U));
|
||||
encoded.push_back(static_cast<std::uint8_t>(adler));
|
||||
return encoded;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool exportGif(const Document& document, const std::string& path,
|
||||
std::string& error) {
|
||||
if (!checkDocument(document, false, error)) return false;
|
||||
if (path.empty()) {
|
||||
error = "GIF output path is empty";
|
||||
return false;
|
||||
}
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
if (!out) {
|
||||
error = "could not open GIF output";
|
||||
return false;
|
||||
}
|
||||
out.write("GIF89a", 6);
|
||||
le16(out, kWidth);
|
||||
le16(out, kHeight);
|
||||
byte(out, 0xF7); // global 256-color table, 8-bit color resolution
|
||||
byte(out, 1); // white in the default palette as the canvas background
|
||||
byte(out, 0);
|
||||
for (const Color& color : document.palette) {
|
||||
byte(out, color.r);
|
||||
byte(out, color.g);
|
||||
byte(out, color.b);
|
||||
}
|
||||
|
||||
// Netscape application extension: loop indefinitely.
|
||||
const std::array<std::uint8_t, 19> loop = {
|
||||
0x21, 0xFF, 0x0B, 'N', 'E', 'T', 'S', 'C', 'A', 'P', 'E', '2', '.', '0',
|
||||
0x03, 0x01, 0x00, 0x00, 0x00};
|
||||
out.write(reinterpret_cast<const char*>(loop.data()),
|
||||
static_cast<std::streamsize>(loop.size()));
|
||||
|
||||
for (std::size_t i = 0; i < document.frames.size(); ++i) {
|
||||
const double fps = frameRate(document.metadata);
|
||||
const unsigned start = static_cast<unsigned>(std::llround(i * 100.0 / fps));
|
||||
const unsigned end = static_cast<unsigned>(std::llround((i + 1U) * 100.0 / fps));
|
||||
const std::uint16_t delay =
|
||||
static_cast<std::uint16_t>(std::max(1U, end - start));
|
||||
byte(out, 0x21); byte(out, 0xF9); byte(out, 0x04);
|
||||
byte(out, 0x09); // restore background before next frame, index 0 transparent
|
||||
le16(out, delay);
|
||||
byte(out, 0); byte(out, 0);
|
||||
|
||||
byte(out, 0x2C); // full-canvas image descriptor
|
||||
le16(out, 0); le16(out, 0);
|
||||
le16(out, kWidth); le16(out, kHeight);
|
||||
byte(out, 0); // use global palette
|
||||
byte(out, 8); // LZW minimum code size
|
||||
const std::vector<std::uint8_t> pixels =
|
||||
gifPixels(document.frames[i], document.palette);
|
||||
const std::vector<std::uint8_t> compressed = gifLzw(pixels);
|
||||
for (std::size_t offset = 0; offset < compressed.size();) {
|
||||
const std::size_t length =
|
||||
std::min<std::size_t>(255, compressed.size() - offset);
|
||||
byte(out, static_cast<std::uint8_t>(length));
|
||||
out.write(reinterpret_cast<const char*>(compressed.data() + offset),
|
||||
static_cast<std::streamsize>(length));
|
||||
offset += length;
|
||||
}
|
||||
byte(out, 0); // terminate LZW sub-blocks
|
||||
if (!out) {
|
||||
error = "could not write GIF frame";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
byte(out, 0x3B);
|
||||
out.flush();
|
||||
if (!out) {
|
||||
error = "could not finish GIF output";
|
||||
return false;
|
||||
}
|
||||
error.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool exportPngCover(const Document& document, const std::string& path,
|
||||
std::string& error) {
|
||||
if (!checkDocument(document, true, error)) return false;
|
||||
if (path.empty()) {
|
||||
error = "PNG output path is empty";
|
||||
return false;
|
||||
}
|
||||
const Frame& frame = document.frames[document.metadata.coverFrame];
|
||||
std::vector<std::uint8_t> raw;
|
||||
raw.reserve(static_cast<std::size_t>(kHeight) * (1U + 4U * kWidth));
|
||||
for (std::size_t y = 0; y < kHeight; ++y) {
|
||||
raw.push_back(0); // PNG filter type 0
|
||||
for (std::size_t x = 0; x < kWidth; ++x) {
|
||||
const Color pixel = compositePixel(frame, document.palette,
|
||||
y * kWidth + x);
|
||||
raw.push_back(pixel.r);
|
||||
raw.push_back(pixel.g);
|
||||
raw.push_back(pixel.b);
|
||||
raw.push_back(pixel.a);
|
||||
}
|
||||
}
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
if (!out) {
|
||||
error = "could not open PNG output";
|
||||
return false;
|
||||
}
|
||||
constexpr std::array<std::uint8_t, 8> signature =
|
||||
{137, 80, 78, 71, 13, 10, 26, 10};
|
||||
out.write(reinterpret_cast<const char*>(signature.data()),
|
||||
static_cast<std::streamsize>(signature.size()));
|
||||
const std::vector<std::uint8_t> header = {
|
||||
static_cast<std::uint8_t>(kWidth >> 24U),
|
||||
static_cast<std::uint8_t>(kWidth >> 16U),
|
||||
static_cast<std::uint8_t>(kWidth >> 8U),
|
||||
static_cast<std::uint8_t>(kWidth),
|
||||
static_cast<std::uint8_t>(kHeight >> 24U),
|
||||
static_cast<std::uint8_t>(kHeight >> 16U),
|
||||
static_cast<std::uint8_t>(kHeight >> 8U),
|
||||
static_cast<std::uint8_t>(kHeight),
|
||||
8, 6, 0, 0, 0}; // RGBA, 8 bits/channel, no interlace
|
||||
pngChunk(out, "IHDR", header);
|
||||
pngChunk(out, "IDAT", zlibStored(raw));
|
||||
pngChunk(out, "IEND", {});
|
||||
out.flush();
|
||||
if (!out) {
|
||||
error = "could not finish PNG output";
|
||||
return false;
|
||||
}
|
||||
error.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool exportMp4(const Document& document, const std::string& path,
|
||||
std::string& error) {
|
||||
if (!checkDocument(document, false, error)) return false;
|
||||
if (path.empty()) { error = "MP4 output path is empty"; return false; }
|
||||
const double fps = frameRate(document.metadata);
|
||||
if (!(fps > 0.0 && fps <= 30.0)) { error = "frame rate must be between 0 and 30 fps"; return false; }
|
||||
|
||||
Bytes ftyp;
|
||||
auto ftypBox = boxBegin(ftyp, "ftyp");
|
||||
type(ftyp, "isom"); be32(ftyp, 0x00000200); type(ftyp, "isom"); type(ftyp, "iso2"); type(ftyp, "mp41"); type(ftyp, "mjpg");
|
||||
boxEnd(ftyp, ftypBox);
|
||||
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
if (!out) { error = "could not create MP4 file"; return false; }
|
||||
out.write(reinterpret_cast<const char*>(ftyp.data()), static_cast<std::streamsize>(ftyp.size()));
|
||||
const std::uint32_t mdatStart = static_cast<std::uint32_t>(ftyp.size());
|
||||
const std::uint8_t mdatHeader[8] = {0,0,0,0,'m','d','a','t'};
|
||||
out.write(reinterpret_cast<const char*>(mdatHeader), sizeof(mdatHeader));
|
||||
std::vector<std::uint32_t> sampleSizes;
|
||||
std::vector<std::uint32_t> sampleOffsets;
|
||||
sampleSizes.reserve(document.frames.size());
|
||||
sampleOffsets.reserve(document.frames.size());
|
||||
|
||||
constexpr int jpegCapacity = 2 * 1024 * 1024;
|
||||
std::vector<std::uint8_t> jpegBuffer(static_cast<std::size_t>(jpegCapacity));
|
||||
for (std::size_t frameIndex = 0; frameIndex < document.frames.size(); ++frameIndex) {
|
||||
SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat(0, kWidth, kHeight, 32, SDL_PIXELFORMAT_RGBA32);
|
||||
if (!surface) { error = "could not allocate frame image"; return false; }
|
||||
if (SDL_LockSurface(surface) != 0) {
|
||||
SDL_FreeSurface(surface); error = "could not prepare frame image"; return false;
|
||||
}
|
||||
auto* rows = static_cast<std::uint8_t*>(surface->pixels);
|
||||
for (std::size_t y = 0; y < kHeight; ++y) {
|
||||
auto* row = reinterpret_cast<std::uint32_t*>(rows + y * static_cast<std::size_t>(surface->pitch));
|
||||
for (std::size_t x = 0; x < kWidth; ++x) {
|
||||
const Color pixel = opaqueFramePixel(document.frames[frameIndex], document.palette, y * kWidth + x);
|
||||
row[x] = SDL_MapRGBA(surface->format, pixel.r, pixel.g, pixel.b, 255);
|
||||
}
|
||||
}
|
||||
SDL_UnlockSurface(surface);
|
||||
SDL_RWops* jpegStream = SDL_RWFromMem(jpegBuffer.data(), jpegCapacity);
|
||||
if (!jpegStream || IMG_SaveJPG_RW(surface, jpegStream, 0, 82) != 0) {
|
||||
if (jpegStream) SDL_RWclose(jpegStream);
|
||||
SDL_FreeSurface(surface); error = "could not encode an MP4 frame as JPEG"; return false;
|
||||
}
|
||||
const Sint64 jpegSize = SDL_RWtell(jpegStream);
|
||||
SDL_RWclose(jpegStream);
|
||||
SDL_FreeSurface(surface);
|
||||
if (jpegSize <= 0 || jpegSize > jpegCapacity) { error = "encoded frame is too large"; return false; }
|
||||
const auto position = out.tellp();
|
||||
if (position < 0 || static_cast<std::uint64_t>(position) > UINT32_MAX) {
|
||||
error = "MP4 exceeds the supported 4 GB file limit"; return false;
|
||||
}
|
||||
sampleOffsets.push_back(static_cast<std::uint32_t>(position));
|
||||
sampleSizes.push_back(static_cast<std::uint32_t>(jpegSize));
|
||||
out.write(reinterpret_cast<const char*>(jpegBuffer.data()), jpegSize);
|
||||
if (!out) { error = "could not write MP4 frame data"; return false; }
|
||||
}
|
||||
const auto endOfMedia = out.tellp();
|
||||
if (endOfMedia < 0 || static_cast<std::uint64_t>(endOfMedia) - mdatStart > UINT32_MAX) {
|
||||
error = "MP4 exceeds the supported 4 GB file limit"; return false;
|
||||
}
|
||||
const std::uint32_t mdatSize = static_cast<std::uint32_t>(static_cast<std::uint64_t>(endOfMedia) - mdatStart);
|
||||
out.seekp(mdatStart);
|
||||
const std::uint8_t encodedMdatSize[4] = {
|
||||
static_cast<std::uint8_t>(mdatSize >> 24), static_cast<std::uint8_t>(mdatSize >> 16),
|
||||
static_cast<std::uint8_t>(mdatSize >> 8), static_cast<std::uint8_t>(mdatSize)};
|
||||
out.write(reinterpret_cast<const char*>(encodedMdatSize), sizeof(encodedMdatSize));
|
||||
out.seekp(endOfMedia);
|
||||
const Bytes moov = mp4MovieBox(sampleSizes, sampleOffsets, fps);
|
||||
out.write(reinterpret_cast<const char*>(moov.data()), static_cast<std::streamsize>(moov.size()));
|
||||
out.flush();
|
||||
if (!out) { error = "could not finish MP4 index"; return false; }
|
||||
out.close();
|
||||
error.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace sppm
|
||||
+7796
File diff suppressed because it is too large.
Load diff
+1055
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user
