Files
ppsspp/Core/WebServer.cpp
T
Henrik RydgårdandClaude Opus 5 7084d6c800 wsdbg: add --launch and --quiet, and stop teaching a redundant wait
--launch starts PPSSPP itself, learns the debugger port from its output,
connects once the socket accepts, and kills it on exit. That replaces
some wrapper scripts, which had to background the emulator,
poll a log file for "Listening on port N", sleep a guessed interval before
connecting, and taskkill afterwards. The polling was also racy - a fixed port
plus a leftover process from an earlier run is a good way to drive the wrong
emulator - and nothing cleaned up, so --timeout's wall-clock budget left
processes alive for hours.

To make that dependable, the port is now also written straight to stderr from
Core/WebServer.cpp, outside the log system entirely. A tool that launches
PPSSPP has to learn the port before it can connect to anything, so that line
must not be losable to a log level or a disabled channel. wsdbg watches both
child streams for it, since which one it lands on depends on how a given build
routes logging.

--quiet sends the broadcast.config.set that every script was hand-writing,
disabling the logger and input broadcasts. The log one is expensive - each line
gets encoded as JSON and pushed down the socket.

The rest is one bug, in the docs rather than the code, which cost a lot of time:
the scripting example paired cpu.runUntilTime with ":wait cpu.stepping". --sync
already waits for the cpu.stepping that follows a resume-family command, so the
explicit :wait waits for a second one that never comes and burns the whole
--sync-timeout. With --sync-timeout 400 that turns a 3-second run into a
7-minute one that looks exactly like a slow boot, because the emulator really
has stopped where it was asked to. Example fixed, and both that and muting the
'stepping' category (same failure, different cause) are called out - wsdbg now
warns when a raw broadcast.config.set disables 'stepping'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
2026-08-20 01:42:36 +02:00

936 lines
29 KiB
C++

// Copyright (c) 2014- PPSSPP Project.
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 2.0 or later versions.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License 2.0 for more details.
// A copy of the GPL 2.0 should have been included with the program.
// If not, see http://www.gnu.org/licenses/
// Official git repository and contact information can be found at
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
#include <algorithm>
#include <atomic>
#include <condition_variable>
#include <mutex>
#include <thread>
#include <string_view>
#include "Common/Net/HTTPClient.h"
#include "Common/Net/HTTPServer.h"
#include "Common/Net/Sinks.h"
#include "Common/Net/URL.h"
#include "Common/Thread/ThreadUtil.h"
#include "Common/Log.h"
#include "Common/File/FileUtil.h"
#include "Common/File/FileDescriptor.h"
#include "Common/File/DirListing.h"
#include "Common/File/VFS/VFS.h"
#include "Common/Data/Text/I18n.h"
#include "Common/TimeUtil.h"
#include "Common/StringUtils.h"
#include "Common/System/System.h"
#include "Common/System/OSD.h"
#include "Core/Util/RecentFiles.h"
#include "Core/Util/PathUtil.h"
#include "Core/Config.h"
#include "Core/WebServer.h"
#include "Core/Debugger/WebSocket.h"
enum class ServerStatus {
STOPPED,
STARTING,
RUNNING,
STOPPING,
FINISHED,
};
static const char *REPORT_HOSTNAME = "report.ppsspp.org";
static const int REPORT_PORT = 80;
static std::thread serverThread;
static ServerStatus serverStatus;
static std::mutex serverStatusLock;
static std::condition_variable serverStatusCond;
static WebServerFlags serverFlags;
// See WebServerSetRequireExactPort(). Atomic because it's set from whichever thread parsed the
// command line, and read from the server thread.
static std::atomic<bool> serverRequireExactPort;
std::mutex g_webServerLock;
static Path g_uploadPath; // TODO: Supply this through registration instead.
static std::atomic<int> g_uploadSessionId(1);
std::map<int, UploadProgress> g_uploadsInProgress;
// NOTE: These *only* encode spaces, which is almost enough.
std::vector<UploadProgress> GetUploadsInProgress() {
std::lock_guard<std::mutex> guard(g_webServerLock);
std::vector<UploadProgress> uploads;
uploads.reserve(g_uploadsInProgress.size());
for (const auto &pair : g_uploadsInProgress) {
uploads.push_back(pair.second);
}
return uploads;
}
std::string ServerUriEncode(std::string_view plain) {
return ReplaceAll(plain, " ", "%20");
}
std::string ServerUriDecode(std::string_view encoded) {
return ReplaceAll(encoded, "%20", " ");
}
static void UpdateStatus(ServerStatus s) {
{
std::lock_guard<std::mutex> guard(serverStatusLock);
serverStatus = s;
}
serverStatusCond.notify_all();
}
static ServerStatus RetrieveStatus() {
std::lock_guard<std::mutex> guard(serverStatusLock);
return serverStatus;
}
// This reports the local IP address to report.ppsspp.org, which can then
// relay that address to a mobile device on the same wifi/LAN searching for the server.
static bool RegisterServer(int port) {
bool success = false;
http::Client http(nullptr);
bool cancelled = false;
net::RequestProgress progress(&cancelled);
Buffer theVoid = Buffer::Void();
http.SetUserAgent(StringFromFormat("PPSSPP/%s", PPSSPP_GIT_VERSION));
char resource4[1024]{};
if (http.Resolve(REPORT_HOSTNAME, REPORT_PORT, net::DNSType::IPV4)) {
if (http.Connect()) {
std::string ip = http.GetLocalIpAsString();
snprintf(resource4, sizeof(resource4) - 1, "/match/update?local=%s&port=%d", ip.c_str(), port);
if (http.GET(http::RequestParams(resource4), &theVoid, &progress) > 0)
success = true;
theVoid.Skip(theVoid.size());
http.Disconnect();
}
}
if (http.Resolve(REPORT_HOSTNAME, REPORT_PORT, net::DNSType::IPV6)) {
// If IPv4 was successful, don't give this as much time (it blocks and sometimes IPv6 is broken.)
double timeout = success ? 2.0 : 10.0;
// We register both IPv4 and IPv6 in case the other client is using a different one.
if (resource4[0] != 0 && http.Connect(/*maxTries=*/ 2, timeout)) {
if (http.GET(http::RequestParams(resource4), &theVoid, &progress) > 0)
success = true;
theVoid.Skip(theVoid.size());
http.Disconnect();
}
// Currently, we're not using keepalive, so gotta reconnect...
if (http.Connect(/*maxTries=*/ 2, timeout)) {
char resource6[1024] = {};
std::string ip = http.GetLocalIpAsString();
snprintf(resource6, sizeof(resource6) - 1, "/match/update?local=%s&port=%d", ip.c_str(), port);
if (http.GET(http::RequestParams(resource6), &theVoid, &progress) > 0)
success = true;
theVoid.Skip(theVoid.size());
http.Disconnect();
}
}
return success;
}
bool RemoteISOFileSupported(const std::string &filename) {
// Disc-like files.
if (endsWithNoCase(filename, ".cso") || endsWithNoCase(filename, ".iso") || endsWithNoCase(filename, ".chd")) {
return true;
}
// May work - but won't have supporting files.
if (endsWithNoCase(filename, ".pbp")) {
return true;
}
// Debugging files.
if (endsWithNoCase(filename, ".ppdmp")) {
return true;
}
return false;
}
static std::string RemotePathForRecent(const std::string &filename) {
Path path(filename);
if (path.Type() == PathType::HTTP) {
// Don't re-share HTTP files from some other device.
return std::string();
}
#ifdef _WIN32
static const std::string sep = "\\/";
#else
static const std::string sep = "/";
#endif
size_t basepos = filename.find_last_of(sep);
std::string basename = "/" + (basepos == filename.npos ? filename : filename.substr(basepos + 1));
if (basename == "/EBOOT.PBP") {
// Go up one more folder.
size_t nextpos = filename.find_last_of(sep, basepos - 1);
basename = "/" + (nextpos == filename.npos ? filename : filename.substr(nextpos + 1));
}
// Let's not serve directories, since they won't work. Only single files.
// Maybe can do PBPs and other files later. Would be neat to stream virtual disc filesystems.
if (RemoteISOFileSupported(basename)) {
return ServerUriEncode(basename);
}
return std::string();
}
static Path LocalFromRemotePath(std::string_view path) {
// This happens for some reason...
if (startsWith(path, "//")) {
path.remove_prefix(1);
}
switch ((RemoteISOShareType)g_Config.iRemoteISOShareType) {
case RemoteISOShareType::RECENT:
for (const std::string &filename : g_recentFiles.GetRecentFiles()) {
std::string basename = RemotePathForRecent(filename);
if (basename == path) {
return Path(filename);
}
}
return Path();
case RemoteISOShareType::LOCAL_FOLDER:
{
std::string decoded = ServerUriDecode(path);
if (decoded.empty() || decoded.front() != '/') {
return Path();
}
// First reject backslashes, in case of any Windows shenanigans.
if (decoded.find('\\') != std::string::npos) {
return Path();
}
// Then, reject slashes combined with ".." to prevent directory traversal. Hope this is enough.
if (decoded.find("/..") != std::string::npos) {
return Path();
}
return Path(g_Config.sRemoteISOSharedDir) / decoded;
}
default:
return Path();
}
}
static void DiscHandler(const http::ServerRequest &request, const Path &filename) {
s64 sz = File::GetFileSize(filename);
if (sz == 0) {
// Probably failed
request.WriteHttpResponseHeader("1.0", 404, -1, "text/plain");
request.Out()->Push("File not found.");
return;
}
std::string range;
if (request.Method() == http::RequestHeader::HEAD) {
request.WriteHttpResponseHeader("1.0", 200, sz, "application/octet-stream", "Accept-Ranges: bytes\r\n");
} else if (request.GetHeader("range", &range)) {
s64 begin = 0, last = 0;
if (sscanf(range.c_str(), "bytes=%lld-%lld", &begin, &last) != 2) {
request.WriteHttpResponseHeader("1.0", 400, -1, "text/plain");
request.Out()->Push("Could not understand range request.");
return;
}
if (begin < 0 || begin > last || last >= sz) {
request.WriteHttpResponseHeader("1.0", 416, -1, "text/plain");
request.Out()->Push("Range goes outside of file.");
return;
}
FILE *fp = File::OpenCFile(filename, "rb");
if (!fp || File::Fseek(fp, begin, SEEK_SET) != 0) {
request.WriteHttpResponseHeader("1.0", 500, -1, "text/plain");
request.Out()->Push("File access failed.");
if (fp) {
fclose(fp);
}
return;
}
s64 len = last - begin + 1;
char contentRange[1024];
snprintf(contentRange, sizeof(contentRange), "Content-Range: bytes %lld-%lld/%lld\r\n", begin, last, sz);
request.WriteHttpResponseHeader("1.0", 206, len, "application/octet-stream", contentRange);
const size_t CHUNK_SIZE = 16 * 1024;
char *buf = new char[CHUNK_SIZE];
for (s64 pos = 0; pos < len; pos += CHUNK_SIZE) {
s64 chunklen = std::min(len - pos, (s64)CHUNK_SIZE);
if (fread(buf, chunklen, 1, fp) != 1)
break;
request.Out()->Push(buf, chunklen);
}
fclose(fp);
delete[] buf;
request.Out()->Flush();
} else {
request.WriteHttpResponseHeader("1.0", 418, -1, "text/plain");
request.Out()->Push("This server only supports range requests.");
}
}
static void HandleListing(const http::ServerRequest &request) {
AndroidJNIThreadContext jniContext;
request.WriteHttpResponseHeader("1.0", 200, -1, "text/plain");
request.Out()->Printf("/\n");
if (serverFlags & WebServerFlags::DISCS) {
switch ((RemoteISOShareType)g_Config.iRemoteISOShareType) {
case RemoteISOShareType::RECENT:
// List the current discs in their recent order.
for (const std::string &filename : g_recentFiles.GetRecentFiles()) {
std::string basename = RemotePathForRecent(filename);
if (!basename.empty()) {
request.Out()->Printf("%s\n", basename.c_str());
}
}
break;
case RemoteISOShareType::LOCAL_FOLDER:
{
std::vector<File::FileInfo> entries;
std::string resource(request.resource());
Path localDir = LocalFromRemotePath(resource);
File::GetFilesInDir(localDir, &entries);
for (const auto &entry : entries) {
// TODO: Support browsing into subdirs. How are folders marked?
if (!entry.isDirectory && !RemoteISOFileSupported(entry.name)) {
continue;
}
std::string name = entry.name;
if (entry.isDirectory) {
name.push_back('/');
}
std::string encoded = ServerUriEncode(name);
request.Out()->Printf("%s\n", encoded.c_str());
}
break;
}
}
}
if (serverFlags & WebServerFlags::DEBUGGER) {
request.Out()->Printf("/debugger\n");
}
}
std::string RenderTemplate(std::string_view input, const std::map<std::string, std::string>& values) {
std::string output;
output.reserve(input.size());
size_t i = 0;
while (i < input.size()) {
if (i + 3 < input.size() && input[i] == '{' && input[i + 1] == '{') {
size_t end = input.find("}}", i + 2);
if (end != std::string_view::npos) {
// Trim whitespace around key
size_t startKey = i + 2;
while (startKey < end && std::isspace(static_cast<unsigned char>(input[startKey])))
startKey++;
size_t endKey = end;
while (endKey > startKey && std::isspace(static_cast<unsigned char>(input[endKey - 1])))
endKey--;
std::string key(input.substr(startKey, endKey - startKey));
auto it = values.find(key);
if (it != values.end()) {
output += it->second;
} else {
// Keep the original placeholder if key not found
output.append(input.substr(i, end + 2 - i));
}
i = end + 2;
continue;
}
}
output.push_back(input[i]);
++i;
}
return output;
}
static bool ServeAssetFile(const http::ServerRequest &request) {
// Skip the slash at the start of the resource path.
std::string_view filename = request.resource().substr(1);
if (filename.find("..") != std::string_view::npos) {
// Don't allow directory traversal.
return false;
}
std::string ext = Path(filename).GetFileExtension();
std::string_view mimeType = "text/plain";
if (ext == ".html") {
mimeType = "text/html";
} else if (ext == ".ico") {
mimeType = "image/x-icon";
} else if (ext == ".js") {
mimeType = "application/javascript";
} else if (ext == ".svg") {
mimeType = "image/svg+xml";
} else if (ext == ".png") {
mimeType = "image/png";
} else if (ext == ".css") {
mimeType = "text/css";
}
size_t size;
// TODO: ReadFile should take a string_view.
uint8_t *data = g_VFS.ReadFile(std::string(filename).c_str(), &size);
if (!data) {
// Try appending index.html
data = g_VFS.ReadFile((std::string(filename) + "/index.html").c_str(), &size);
mimeType = "text/html";
if (!data) {
return false;
}
INFO_LOG(Log::HTTP, "Redirected to /index.html");
}
std::string html = std::string((const char *)data, size);
delete[] data;
// This is a gross, gross hack to have here in ServeAssetFile, but oh well.
if (mimeType == "text/html") {
if (html.find("<!--upload-->") != std::string::npos) {
std::string uploadPath = GetFriendlyPath(g_uploadPath);
std::string deviceName = System_GetProperty(SYSPROP_NAME);
std::string computerName = System_GetProperty(SYSPROP_COMPUTER_NAME);
if (!computerName.empty()) {
deviceName = computerName;
}
std::map<std::string, std::string> values = {
{"upload_path", uploadPath},
{"device_name", deviceName},
};
// Use templating to insert some information.
html = RenderTemplate(html, values);
}
}
request.WriteHttpResponseHeader("1.0", 200, html.size(), std::string(mimeType).c_str());
request.Out()->Push(html.data(), html.size());
return true;
}
static void RedirectToDebugger(const http::ServerRequest &request) {
static const std::string payload = "Redirecting to debugger UI...\r\n";
request.WriteHttpResponseHeader("1.0", 301, payload.size(), "text/plain", "Location: /debugger/index.html\r\n");
request.Out()->Push(payload);
}
// TODO: Allow registering ServeAssetFile roots as well.
static void HandleFallback(const http::ServerRequest &request) {
SetCurrentThreadName("HandleFallback");
AndroidJNIThreadContext jniContext;
if ((serverFlags & WebServerFlags::DEBUGGER) != 0) {
if (request.resource() == "/debugger/") {
RedirectToDebugger(request);
return;
}
// Actually serve debugger files.
if (startsWith(request.resource(), "/debugger/")) {
if (ServeAssetFile(request)) {
return;
}
}
}
if (serverFlags & WebServerFlags::FILE_UPLOAD) {
Path uploadPath;
{
std::lock_guard<std::mutex> guard(g_webServerLock);
uploadPath = g_uploadPath;
}
if (startsWith(request.resource(), "/upload") && !uploadPath.empty()) {
if (ServeAssetFile(request)) {
return;
}
}
}
if (serverFlags & WebServerFlags::DISCS) {
std::string_view resource = request.resource();
Path localPath = LocalFromRemotePath(resource);
INFO_LOG(Log::Loader, "Serving %.*s from %s", (int)resource.size(), resource.data(), localPath.c_str());
if (!localPath.empty()) {
if (File::IsDirectory(localPath)) {
HandleListing(request);
} else {
DiscHandler(request, localPath);
}
return;
}
}
static const std::string payload = "404 not found\r\n";
request.WriteHttpResponseHeader("1.0", 404, payload.size(), "text/plain");
request.Out()->Push(payload);
}
static void ForwardDebuggerRequest(const http::ServerRequest &request) {
SetCurrentThreadName("ForwardDebuggerRequest");
// Hm, is this needed?
AndroidJNIThreadContext jniContext;
if (serverFlags & WebServerFlags::DEBUGGER) {
// Check if this is a websocket request...
std::string upgrade;
if (!request.GetHeader("upgrade", &upgrade)) {
upgrade.clear();
}
// Yes - proceed with the socket.
if (strcasecmp(upgrade.c_str(), "websocket") == 0) {
HandleDebuggerRequest(request);
} else {
RedirectToDebugger(request);
}
} else {
HandleFallback(request);
}
}
class ProgressTracker {
public:
ProgressTracker(int sessionId, s64 totalSize) : sessionId_(sessionId) {
std::lock_guard<std::mutex> guard(g_webServerLock);
g_uploadsInProgress[sessionId_] = UploadProgress{totalSize};
}
~ProgressTracker() {
std::lock_guard<std::mutex> guard(g_webServerLock);
g_uploadsInProgress.erase(sessionId_);
}
void SetFile(std::string_view filename, size_t size) {
std::lock_guard<std::mutex> guard(g_webServerLock);
auto &upload = g_uploadsInProgress[sessionId_];
upload.filename = filename;
upload.currentFileSize = size;
upload.uploadedBytes = 0;
}
void AddBytes(size_t bytes) {
std::lock_guard<std::mutex> guard(g_webServerLock);
g_uploadsInProgress[sessionId_].uploadedBytes += bytes;
}
private:
int sessionId_;
};
enum class MultiPartResult {
MoveToNext,
RequestError,
LocalError,
Done,
};
static bool IsSafeUploadFilename(std::string_view filename) {
return !filename.empty() && filename != "." && filename != ".." &&
filename.find_first_of("/\\") == std::string_view::npos;
}
static MultiPartResult HandleMultipartPart(const http::ServerRequest &request, std::string boundary, const Path &uploadPath, ProgressTracker &progress) {
std::string firstBoundary = request.In()->ReadLine();
if (firstBoundary != "--" + boundary) {
WARN_LOG(Log::HTTP, "Bad boundary: Expected --%s but got %s", boundary.c_str(), firstBoundary.c_str());
return MultiPartResult::RequestError;
}
std::string disposition = request.In()->ReadLine();
INFO_LOG(Log::HTTP, "Disposition: %s", disposition.c_str());
std::vector<std::string_view> parts;
SplitString(disposition, ';', parts);
if (parts.size() < 2 || !startsWith(parts[0], "Content-Disposition: form-data")) {
WARN_LOG(Log::HTTP, "Bad content disposition: %s", disposition.c_str());
return MultiPartResult::RequestError;
}
std::string filename;
for (const auto &part : parts) {
std::string_view key;
std::string_view value;
if (SplitStringOnce(part, &key, &value, '=')) {
key = StripSpaces(key);
value = StripQuotes(StripSpaces(value));
if (key == "name") {
INFO_LOG(Log::HTTP, "Upload field name: %.*s", STR_VIEW(value));
} else if (key == "filename") {
INFO_LOG(Log::HTTP, "Upload filename: %.*s", STR_VIEW(value));
filename = value;
} else if (key == "fileSize") {
INFO_LOG(Log::HTTP, "Upload filesize: %.*s", STR_VIEW(value));
}
} else if (equalsNoCase(StripSpaces(part), "Content-Disposition: form-data")) {
// this is the first part, ok, ignore.
} else {
WARN_LOG(Log::HTTP, "Bad content disposition part: %.*s", STR_VIEW(part));
}
}
if (!IsSafeUploadFilename(filename)) {
ERROR_LOG(Log::HTTP, "Invalid upload filename");
return MultiPartResult::RequestError;
}
std::string fileContentType = request.In()->ReadLine();
std::string secondBoundary = request.In()->ReadLine();
Path destPath = uploadPath / filename;
bool dryRun = false;
if (File::Exists(destPath)) {
INFO_LOG(Log::HTTP, "File already exists, entering dry run mode: %s", destPath.ToVisualString().c_str());
dryRun = true;
}
// Make sure the destination exists.
File::CreateFullPath(destPath.NavigateUp());
INFO_LOG(Log::HTTP, "Receiving '%s', writing to '%s' (unknown number of bytes)...", filename.c_str(), destPath.ToVisualString().c_str());
// OK, enter a loop where we read some data until we hit the boundary again.
// The boundary is chosen to be "unique" and unlikely to appear in the file. We trust that.
FILE *fp = nullptr;
if (!dryRun) {
fp = File::OpenCFile(destPath, "wb");
if (!fp) {
ERROR_LOG(Log::HTTP, "Failed to open destination file '%s' for writing, entering dry run mode.", destPath.ToVisualString().c_str());
dryRun = true; // We still want to keep reading the input stream - there might be more files following.
}
}
progress.SetFile(filename, 0);
u64 bytesTransferred = 0;
char buffer[net::InputSink::BUFFER_SIZE];
while (true) {
bool terminatorFound = false;
size_t readBytes = request.In()->ReadBinaryUntilTerminator(buffer, sizeof(buffer), "\r\n--" + boundary, &terminatorFound);
if (fp) {
if (fwrite(buffer, 1, readBytes, fp) != readBytes) {
ERROR_LOG(Log::HTTP, "Failed to write %d bytes to destination file '%s' - disk full?", (int)readBytes, destPath.ToVisualString().c_str());
fclose(fp);
// Delete the partially written file, so the user doesn't try to play it.
File::Delete(destPath);
return MultiPartResult::RequestError;
}
}
progress.AddBytes(readBytes);
bytesTransferred += readBytes;
if (terminatorFound) {
INFO_LOG(Log::HTTP, "Found terminator, skipping and proceeding.");
break;
}
}
INFO_LOG(Log::HTTP, "Total bytes transferred: %d", (int)bytesTransferred);
if (fp) {
fclose(fp);
}
auto n = GetI18NCategory(I18NCat::NETWORKING);
g_OSD.Show(OSDType::MESSAGE_SUCCESS, ApplySafeSubstitutions(n->T("File transfer complete: %1"), filename));
// NOTE: We already read the boundary above.
// However if this is the last part, the boundary will have "--\r\n" after it, otherwise there will be a line break.
// So, let's read two more bytes, to see if it's "--" or "\r\n".
std::string ending(2, 'x');
request.In()->TakeExact(ending.data(), 2);
if (ending == "--") {
INFO_LOG(Log::HTTP, "Upload of '%s' complete, '--' encountered. %d bytes left in buffer.", filename.c_str(), (int)request.In()->ValidAmount());
// Read the final \r\n.
std::string finalCRLF;
finalCRLF.resize(2);
request.In()->TakeExact(finalCRLF.data(), 2);
if (finalCRLF != "\r\n") {
// Not a big deal, but log it.
WARN_LOG(Log::HTTP, "Expected final CRLF after ending '--', got '%02x %02x'", finalCRLF[0], finalCRLF[1]);
}
return MultiPartResult::Done;
} else if (ending == "\r\n") {
INFO_LOG(Log::HTTP, "Upload of '%s' complete, continuing to next part.", filename.c_str());
return MultiPartResult::MoveToNext;
} else {
WARN_LOG(Log::HTTP, "Unexpected upload ending: '%s'. Bailing.", ending.c_str());
return MultiPartResult::RequestError;
}
}
// Handles a POST to upload a file.
// This uses the HTTP multipart protocol, which is arcane and complicated, unfortunately.
static void HandleUploadPost(const http::ServerRequest &request) {
AndroidJNIThreadContext jniContext;
// Do some sanity checks.
if (request.Method() != http::RequestHeader::POST) {
ERROR_LOG(Log::HTTP, "Wrong method");
return;
}
Path uploadPath;
{
std::lock_guard<std::mutex> guard(g_webServerLock);
uploadPath = g_uploadPath;
}
if (uploadPath.empty()) {
ERROR_LOG(Log::HTTP, "HandleUploadPost should not happen while uploadPath is empty");
return;
}
// Now start handling things.
std::string contentType;
if (!request.GetHeader("content-type", &contentType)) {
return;
}
size_t bpos = contentType.find("boundary=");
if (bpos == std::string::npos) {
return;
}
const std::string boundary = contentType.substr(bpos + strlen("boundary="));
// The total length of the entire multipart thing. This is just above the full size of the upload, so let's use it for progress.
u64 contentLength = request.Header().content_length;
if (contentLength == 0) {
WARN_LOG(Log::HTTP, "Bad content length");
return;
}
const int sessionId = g_uploadSessionId.fetch_add(1);
ProgressTracker progress(sessionId, contentLength);
while (true) {
MultiPartResult result = HandleMultipartPart(request, boundary, uploadPath, progress);
switch (result) {
case MultiPartResult::Done:
goto exitLoop;
case MultiPartResult::RequestError:
request.WriteHttpResponseHeader("1.0", 400, -1, "text/plain"); // Bad request
return;
case MultiPartResult::LocalError:
request.WriteHttpResponseHeader("1.0", 500, -1, "text/plain"); // Server error
return;
case MultiPartResult::MoveToNext:
// Else just continue to the next part.
break;
}
}
exitLoop:
// Now the buffer should be empty.
if (!request.In()->Empty()) {
size_t remaining = request.In()->ValidAmount();
WARN_LOG(Log::HTTP, "We didn't fully drain the request (%d bytes still buffered)", (int)remaining);
// std::string extraBytes;
// extraBytes.resize(remaining);
// request.In()->TakeExact(extraBytes.data(), remaining);
}
request.WriteHttpResponseHeader("1.0", 200, -1, "text/plain");
}
void WebServerSetUploadPath(const Path &path) {
std::lock_guard<std::mutex> guard(g_webServerLock);
g_uploadPath = path;
}
WebServerFlags GetServerFlags() {
return serverFlags;
}
void OpenWebDebugger() {
if (!WebServerRunning(WebServerFlags::DEBUGGER)) {
StartWebServer(WebServerFlags::DEBUGGER);
}
System_LaunchUrl(LaunchUrlType::BROWSER_URL, "http://localhost:" + std::to_string(g_Config.iRemoteISOPort) + "/debugger/index.html");
}
static void WebServerThread() {
SetCurrentThreadName("HTTPServer");
AndroidJNIThreadContext context; // Destructor detaches.
auto http = new http::Server(new NewThreadExecutor());
http->RegisterHandler("/", &HandleListing);
// This lists all the (current) recent ISOs. It also handles the debugger, which is very ugly.
http->SetFallbackHandler(&HandleFallback);
http->RegisterHandler("/debugger", &ForwardDebuggerRequest);
http->RegisterHandler("/upload_file", &HandleUploadPost);
if (!http->Listen(g_Config.iRemoteISOPort, "debugger-webserver")) {
if (serverRequireExactPort) {
// Someone asked for this specific port (--debugger=PORT) - see
// WebServerSetRequireExactPort(). Coming up on a different one would just look like
// success while nothing can connect, so give up loudly instead.
ERROR_LOG(Log::FileSystem, "Unable to listen on port %d, and it was explicitly requested (debugger - webserver). Is another PPSSPP instance already using it?", g_Config.iRemoteISOPort);
// Headless treats this as fatal; the GUI app stays usable, so at least say why there's
// no debugger rather than leaving it a mystery.
if (serverFlags & WebServerFlags::DEBUGGER) {
g_OSD.Show(OSDType::MESSAGE_ERROR, "Debugger web server could not use port " + std::to_string(g_Config.iRemoteISOPort), 8.0f, "debugger");
}
UpdateStatus(ServerStatus::FINISHED);
return;
}
if (!http->Listen(0, "debugger-webserver")) {
ERROR_LOG(Log::FileSystem, "Unable to listen on any port (debugger - webserver)");
UpdateStatus(ServerStatus::FINISHED);
return;
}
}
UpdateStatus(ServerStatus::RUNNING);
g_Config.iRemoteISOPort = http->Port();
RegisterServer(http->Port());
double lastRegister = time_now_d();
INFO_LOG(Log::HTTP, "Entering web server loop. Listening on port %d", g_Config.iRemoteISOPort);
if (serverFlags & WebServerFlags::DEBUGGER) {
// And say it once more straight to stderr, outside the log system entirely. A tool that
// launches us (Tools/wsdbg --launch) has to learn the port from our output before it can
// connect to anything, so that one line must not be losable to a log level, a disabled
// channel, or a build that never installs a log output at all.
fprintf(stderr, "Debugger listening on port %d\n", g_Config.iRemoteISOPort);
fflush(stderr);
g_OSD.Show(OSDType::MESSAGE_SUCCESS, "Debugger web server running on port " + std::to_string(g_Config.iRemoteISOPort), 5.0f, "debugger");
g_OSD.SetClickCallback("debugger", []() {
OpenWebDebugger();
});
}
while (RetrieveStatus() == ServerStatus::RUNNING) {
constexpr double webServerSliceSeconds = 0.2f;
http->RunSlice(webServerSliceSeconds);
double now = time_now_d();
if (now > lastRegister + 540.0) {
RegisterServer(http->Port());
lastRegister = now;
}
}
INFO_LOG(Log::HTTP, "Leaving web server loop.");
http->Stop();
StopAllDebuggers();
delete http;
UpdateStatus(ServerStatus::FINISHED);
INFO_LOG(Log::HTTP, "Left web server loop.");
}
// Only adds flags.
bool StartWebServer(WebServerFlags flags) {
std::lock_guard<std::mutex> guard(serverStatusLock);
switch (serverStatus) {
case ServerStatus::RUNNING:
if (((int)serverFlags & (int)flags) == (int)flags) {
// Already running with these flags.
return false;
}
serverFlags |= flags;
return true;
case ServerStatus::FINISHED:
serverThread.join();
[[fallthrough]]; // Intentional fallthrough.
case ServerStatus::STOPPED:
serverStatus = ServerStatus::STARTING;
serverFlags = flags;
serverThread = std::thread(&WebServerThread);
return true;
default:
return false;
}
}
// Only removes flags.
bool StopWebServer(WebServerFlags flags) {
std::lock_guard<std::mutex> guard(serverStatusLock);
if (serverStatus != ServerStatus::RUNNING) {
return false;
}
serverFlags &= ~flags;
if (serverFlags == WebServerFlags::NONE) {
serverStatus = ServerStatus::STOPPING;
}
return true;
}
bool WebServerStopping(WebServerFlags flags) {
std::lock_guard<std::mutex> guard(serverStatusLock);
return serverStatus == ServerStatus::STOPPING;
}
bool WebServerStopped(WebServerFlags flags) {
std::lock_guard<std::mutex> guard(serverStatusLock);
if (serverStatus == ServerStatus::RUNNING) {
return !(serverFlags & flags);
}
return serverStatus == ServerStatus::STOPPED || serverStatus == ServerStatus::FINISHED;
}
void ShutdownWebServer() {
StopWebServer(WebServerFlags::ALL);
if (serverStatus != ServerStatus::STOPPED)
serverThread.join();
serverStatus = ServerStatus::STOPPED;
}
bool WebServerRunning(WebServerFlags flags) {
return RetrieveStatus() == ServerStatus::RUNNING && (serverFlags & flags) != 0;
}
int WebServerPort() {
return g_Config.iRemoteISOPort;
}
void WebServerSetRequireExactPort(bool require) {
serverRequireExactPort = require;
}
bool WebServerWaitForStartup() {
std::unique_lock<std::mutex> guard(serverStatusLock);
serverStatusCond.wait(guard, [] { return serverStatus != ServerStatus::STARTING; });
return serverStatus == ServerStatus::RUNNING;
}