Basic upload via web browser support, only single file almost works

This commit is contained in:
Henrik Rydgård
2025-10-26 01:00:11 +02:00
parent c686d48268
commit dc682fb2c8
13 changed files with 420 additions and 26 deletions
+5 -2
View File
@@ -2772,6 +2772,7 @@ set(NativeAssets
assets/asciifont_atlas.zim
assets/asciifont_atlas.meta
assets/debugger
assets/upload
assets/lang
assets/shaders
assets/themes
@@ -2915,6 +2916,7 @@ if(TargetBin)
file(GLOB_RECURSE SHADER_FILES assets/shaders/*)
file(GLOB_RECURSE THEME_FILE assets/themes/*)
file(GLOB_RECURSE DEBUGGER_FILES assets/debugger/*)
file(GLOB_RECURSE WEB_UPLOAD_FILES assets/upload/*)
file(GLOB_RECURSE VFPU_FILES assets/vfpu/*)
if(NOT IOS)
@@ -2926,17 +2928,18 @@ if(TargetBin)
set_source_files_properties(${SHADER_FILES} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/assets/shaders")
set_source_files_properties(${THEME_FILE} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/assets/themes")
set_source_files_properties(${DEBUGGER_FILES} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/assets/debugger")
set_source_files_properties(${WEB_UPLOAD_FILES} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/assets/upload")
set_source_files_properties(${VFPU_FILES} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/assets/vfpu")
endif()
if(IOS)
set(AssetCatalog "${CMAKE_SOURCE_DIR}/ios/assets.xcassets")
add_executable(${TargetBin} MACOSX_BUNDLE ${NativeAssets} ${BigFontAssets} ${UIImages} ${AssetCatalog} ${SHADER_FILES} ${THEME_FILE} ${DEBUGGER_FILES} ${FLASH0_FILES} ${LANG_FILES} ${NativeAppSource} "ios/Settings.bundle" "ios/Launch Screen.storyboard")
add_executable(${TargetBin} MACOSX_BUNDLE ${NativeAssets} ${BigFontAssets} ${UIImages} ${AssetCatalog} ${SHADER_FILES} ${THEME_FILE} ${DEBUGGER_FILES} ${WEB_UPLOAD_FILES} ${FLASH0_FILES} ${LANG_FILES} ${NativeAppSource} "ios/Settings.bundle" "ios/Launch Screen.storyboard")
if(NOT IOS_APP_STORE)
file(INSTALL "${CMAKE_SOURCE_DIR}/ext/vulkan/iOS/Frameworks/libMoltenVK.dylib" DESTINATION "${CMAKE_BINARY_DIR}/PPSSPP.app/Frameworks/")
endif()
else()
add_executable(${TargetBin} MACOSX_BUNDLE ${ICON_PATH_ABS} ${NativeAssets} ${BigFontAssets} ${UIImages} ${SHADER_FILES} ${THEME_FILE} ${DEBUGGER_FILES} ${FLASH0_FILES} ${LANG_FILES} ${NativeAppSource})
add_executable(${TargetBin} MACOSX_BUNDLE ${ICON_PATH_ABS} ${NativeAssets} ${BigFontAssets} ${UIImages} ${SHADER_FILES} ${THEME_FILE} ${DEBUGGER_FILES} ${WEB_UPLOAD_FILES} ${FLASH0_FILES} ${LANG_FILES} ${NativeAppSource})
file(INSTALL "${CMAKE_SOURCE_DIR}/ext/vulkan/macOS/Frameworks/libMoltenVK.dylib" DESTINATION "${CMAKE_BINARY_DIR}/${TargetBin}.app/Contents/Frameworks/")
if(USING_QT_UI)
add_custom_command(TARGET ${TargetBin} POST_BUILD COMMAND /bin/bash "${CMAKE_SOURCE_DIR}/Qt/macbundle.sh" "${CMAKE_BINARY_DIR}/PPSSPPQt.app")
+4
View File
@@ -38,6 +38,10 @@ public:
return header_.method;
}
const RequestHeader &Header() const {
return header_;
}
bool GetParamValue(const char *param_name, std::string *value) const {
return header_.GetParamValue(param_name, value);
}
+10
View File
@@ -361,6 +361,16 @@ void SplitString(std::string_view str, const char delim, std::vector<std::string
}
}
bool SplitStringOnce(std::string_view str, std::string_view *firstPart, std::string_view *secondPart, char delim) {
size_t pos = str.find(delim);
if (pos == std::string_view::npos) {
return false;
}
*firstPart = str.substr(0, pos);
*secondPart = str.substr(pos + 1);
return true;
}
void SplitString(std::string_view str, const char delim, std::vector<std::string> &output, bool trimOutput) {
size_t next = 0;
size_t pos = 0;
+3
View File
@@ -110,6 +110,9 @@ void SplitString(std::string_view str, const char delim, std::vector<std::string
// Try to avoid this when possible, in favor of the string_view version.
void SplitString(std::string_view str, const char delim, std::vector<std::string> &output, bool trimOutput = false);
// Splits on the first occurrence of delim. Returns true if the delimiter was found.
bool SplitStringOnce(std::string_view str, std::string_view *firstPart, std::string_view *secondPart, char delim);
void GetQuotedStrings(std::string_view str, std::vector<std::string> &output);
std::string ReplaceAll(std::string_view input, std::string_view src, std::string_view dest);
+174 -20
View File
@@ -18,6 +18,7 @@
#include <algorithm>
#include <mutex>
#include <thread>
#include <string_view>
#include "Common/Net/HTTPClient.h"
#include "Common/Net/HTTPServer.h"
@@ -50,7 +51,10 @@ static const int REPORT_PORT = 80;
static std::thread serverThread;
static ServerStatus serverStatus;
static std::mutex serverStatusLock;
static int serverFlags;
static WebServerFlags serverFlags;
std::mutex g_webServerLock;
static Path g_uploadPath; // TODO: Supply this through registration instead.
// NOTE: These *only* encode spaces, which is almost enough.
@@ -266,7 +270,7 @@ static void HandleListing(const http::ServerRequest &request) {
request.WriteHttpResponseHeader("1.0", 200, -1, "text/plain");
request.Out()->Printf("/\n");
if (serverFlags & (int)WebServerFlags::DISCS) {
if (serverFlags & WebServerFlags::DISCS) {
switch ((RemoteISOShareType)g_Config.iRemoteISOShareType) {
case RemoteISOShareType::RECENT:
// List the current discs in their recent order.
@@ -301,22 +305,30 @@ static void HandleListing(const http::ServerRequest &request) {
}
}
}
if (serverFlags & (int)WebServerFlags::DEBUGGER) {
if (serverFlags & WebServerFlags::DEBUGGER) {
request.Out()->Printf("/debugger\n");
}
}
static bool ServeDebuggerFile(const http::ServerRequest &request) {
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)
if (filename.find("..") != std::string_view::npos) {
// Don't allow directory traversal.
return false;
}
size_t size;
// TODO: ReadFile should take a string_view.
uint8_t *data = g_VFS.ReadFile(std::string(filename).c_str(), &size);
if (!data)
return false;
if (!data) {
// Try appending index.html
data = g_VFS.ReadFile((std::string(filename) + "/index.html").c_str(), &size);
if (!data) {
return false;
}
INFO_LOG(Log::HTTP, "Redirected to /index.html");
}
std::string ext = Path(filename).GetFileExtension();
const char *mimeType = "text/plain";
@@ -347,12 +359,13 @@ static void RedirectToDebugger(const http::ServerRequest &request) {
request.Out()->Push(payload);
}
// TODO: Allow registering ServeAssetFile roots as well.
static void HandleFallback(const http::ServerRequest &request) {
SetCurrentThreadName("HandleFallback");
AndroidJNIThreadContext jniContext;
if ((serverFlags & (int)WebServerFlags::DEBUGGER) != 0) {
if ((serverFlags & WebServerFlags::DEBUGGER) != 0) {
if (request.resource() == "/debugger/") {
RedirectToDebugger(request);
return;
@@ -360,13 +373,21 @@ static void HandleFallback(const http::ServerRequest &request) {
// Actually serve debugger files.
if (startsWith(request.resource(), "/debugger/")) {
if (ServeDebuggerFile(request)) {
if (ServeAssetFile(request)) {
return;
}
}
}
if (serverFlags & (int)WebServerFlags::DISCS) {
if (serverFlags & WebServerFlags::FILE_UPLOAD) {
if (startsWith(request.resource(), "/upload/")) {
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());
@@ -391,7 +412,7 @@ static void ForwardDebuggerRequest(const http::ServerRequest &request) {
// Hm, is this needed?
AndroidJNIThreadContext jniContext;
if (serverFlags & (int)WebServerFlags::DEBUGGER) {
if (serverFlags & WebServerFlags::DEBUGGER) {
// Check if this is a websocket request...
std::string upgrade;
if (!request.GetHeader("upgrade", &upgrade)) {
@@ -426,6 +447,140 @@ static void HandleUploadUI(const http::ServerRequest &request) {
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;
}
// 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.
u64 contentLength = request.Header().content_length;
if (contentLength == 0) {
WARN_LOG(Log::HTTP, "Bad content length");
return;
}
std::string firstBoundary = request.In()->ReadLine();
if (firstBoundary != "--" + boundary) {
WARN_LOG(Log::HTTP, "Bad boundary: Expected --%s but got %s", boundary);
return;
}
std::string disposition = request.In()->ReadLine();
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);
return;
}
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 (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 (filename.empty()) {
ERROR_LOG(Log::HTTP, "Didn't receive a filename");
return;
}
std::string fileContentType = request.In()->ReadLine();
std::string secondBoundary = request.In()->ReadLine();
Path destPath = uploadPath / filename;
if (File::Exists(destPath)) {
INFO_LOG(Log::HTTP, "File already exists: %s", destPath.ToVisualString().c_str());
return;
}
// Make sure the destination exists.
File::CreateFullPath(destPath.NavigateUp());
INFO_LOG(Log::HTTP, "Receiving '%s', writing to '%s' (%d 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.
Buffer buffer;
FILE *fp = File::OpenCFile(destPath, "wb");
if (!fp) {
ERROR_LOG(Log::HTTP, "Failed to open destination file '%s' for writing", destPath.ToVisualString().c_str());
return;
}
u64 bytesWritten = 0;
while (true) {
// NOTE: Lines here can be extremely long, especially in compressed data. So we should split ReadLine up if needed.
std::string line = request.In()->ReadLine();
if (line == "--" + boundary || line == "--" + boundary + "--") {
INFO_LOG(Log::HTTP, "Line matches boundary, breaking.");
// Done.
break;
}
// Write the line and a newline (since we ate it). TODO: This could be avoided.
size_t len = line.size();
if (fwrite(line.data(), 1, len, fp) != len || fwrite("\r\n", 1, 2, fp) != 2) {
ERROR_LOG(Log::HTTP, "Failed to write %d bytes to destination file '%s' - bailing", (int)len, destPath.ToVisualString().c_str());
fclose(fp);
return;
}
bytesWritten += line.size();
}
INFO_LOG(Log::HTTP, "Total bytes written: %d", (int)bytesWritten);
fclose(fp);
// NOTE: We already read the boundary above.
// Now the buffer should be empty.
if (!request.In()->Empty()) {
WARN_LOG(Log::HTTP, "We didn't drain the request.");
std::string extraLine = request.In()->ReadLine();
INFO_LOG(Log::HTTP, "Extra line: %s", extraLine.c_str());
}
INFO_LOG(Log::HTTP, "Upload of '%s' complete.", filename.c_str());
// request.WriteHttpResponseHeader("1.0", 200, -1, "text/plain");
const size_t blockSize = 16 * 1024;
}
void WebServerSetUploadPath(const Path &path) {
std::lock_guard<std::mutex> guard(g_webServerLock);
g_uploadPath = path;
}
static void WebServerThread() {
@@ -438,8 +593,7 @@ static void WebServerThread() {
// 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", &HandleUploadUI);
http->RegisterHandler("/upload_post", &HandleUploadPost);
http->RegisterHandler("/upload_file", &HandleUploadPost);
if (!http->Listen(g_Config.iRemoteISOPort, "debugger-webserver")) {
if (!http->Listen(0, "debugger-webserver")) {
@@ -479,11 +633,11 @@ bool StartWebServer(WebServerFlags flags) {
std::lock_guard<std::mutex> guard(serverStatusLock);
switch (serverStatus) {
case ServerStatus::RUNNING:
if ((serverFlags & (int)flags) == (int)flags) {
if (((int)serverFlags & (int)flags) == (int)flags) {
// Already running with these flags.
return false;
}
serverFlags |= (int)flags;
serverFlags |= flags;
return true;
case ServerStatus::FINISHED:
@@ -492,7 +646,7 @@ bool StartWebServer(WebServerFlags flags) {
case ServerStatus::STOPPED:
serverStatus = ServerStatus::STARTING;
serverFlags = (int)flags;
serverFlags = flags;
serverThread = std::thread(&WebServerThread);
return true;
@@ -508,8 +662,8 @@ bool StopWebServer(WebServerFlags flags) {
return false;
}
serverFlags &= ~(int)flags;
if (serverFlags == 0) {
serverFlags &= ~flags;
if (serverFlags == WebServerFlags::NONE) {
serverStatus = ServerStatus::STOPPING;
}
return true;
@@ -523,7 +677,7 @@ bool WebServerStopping(WebServerFlags flags) {
bool WebServerStopped(WebServerFlags flags) {
std::lock_guard<std::mutex> guard(serverStatusLock);
if (serverStatus == ServerStatus::RUNNING) {
return (serverFlags & (int)flags) == 0;
return !(serverFlags & flags);
}
return serverStatus == ServerStatus::STOPPED || serverStatus == ServerStatus::FINISHED;
}
@@ -537,7 +691,7 @@ void ShutdownWebServer() {
}
bool WebServerRunning(WebServerFlags flags) {
return RetrieveStatus() == ServerStatus::RUNNING && (serverFlags & (int)flags) != 0;
return RetrieveStatus() == ServerStatus::RUNNING && (serverFlags & flags) != 0;
}
int WebServerPort() {
+3
View File
@@ -19,6 +19,8 @@
#include "Common/Common.h"
class Path;
enum class WebServerFlags {
NONE = 0,
DISCS = 1,
@@ -37,4 +39,5 @@ bool WebServerRunning(WebServerFlags flags);
void ShutdownWebServer();
bool RemoteISOFileSupported(const std::string &filename);
void WebServerSetUploadPath(const Path &path);
int WebServerPort();
+1 -1
View File
@@ -818,7 +818,7 @@ void GameBrowser::Refresh() {
if (browseFlags_ & BrowseFlags::UPLOAD_BUTTON) {
topBar->Add(new Choice(ImageID("I_FOLDER_UPLOAD"), new UI::LinearLayoutParams(WRAP_CONTENT, 64.0f)))->OnClick.Add([this](UI::EventParams &e) {
screenManager_->push(new UploadScreen());
screenManager_->push(new UploadScreen(path_.GetPath()));
});
}
+4 -2
View File
@@ -4,9 +4,9 @@
#include "Common/StringUtils.h"
#include "Core/WebServer.h"
UploadScreen::UploadScreen() {
UploadScreen::UploadScreen(const Path &targetFolder) : targetFolder_(targetFolder) {
net::GetLocalIP4List(localIPs_);
WebServerSetUploadPath(targetFolder);
StartWebServer(WebServerFlags::FILE_UPLOAD);
}
@@ -36,6 +36,8 @@ void UploadScreen::CreateViews() {
root_->Add(new TextView(co->T("Connecting...")));
}
root_->Add(new TextView(std::string(co->T("Uploading to: ")) + targetFolder_.ToVisualString()));
//infoText->SetTextAlignment(TEXT_ALIGN_CENTER);
//verticalLayout->AddView(infoText);
//AddStandardBack(verticalLayout);
+2 -1
View File
@@ -32,7 +32,7 @@
class UploadScreen : public UIDialogScreenWithBackground {
public:
UploadScreen();
UploadScreen(const Path &targetFolder);
~UploadScreen();
void CreateViews() override;
@@ -44,4 +44,5 @@ protected:
private:
bool prevRunning_ = false;
std::vector<std::string> localIPs_;
Path targetFolder_;
};
+4
View File
@@ -311,6 +311,10 @@
<DeploymentContent>true</DeploymentContent>
<Link>Content\vfpu\%(Filename)%(Extension)</Link>
</None>
<None Include="..\Assets\upload\*.*">
<DeploymentContent>true</DeploymentContent>
<Link>Content\upload\%(Filename)%(Extension)</Link>
</None>
<None Include="..\Assets\debugger\.nojekyll">
<DeploymentContent>true</DeploymentContent>
<Link>Content\debugger\%(Filename)%(Extension)</Link>
+1
View File
@@ -4,6 +4,7 @@ cp -r ../assets/lang assets/
cp -r ../assets/shaders assets/
cp -r ../assets/themes assets/
cp -r ../assets/debugger assets/
cp -r ../assets/upload assets/
cp -r ../assets/ui_images assets/
cp ../assets/*.ini assets/
cp ../assets/Roboto-Condensed.ttf assets/Roboto-Condensed.ttf
+208
View File
@@ -0,0 +1,208 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>File/Folder Uploader</title>
<style>
body {
font-family: system-ui, sans-serif;
background: #f5f7fa;
color: #333;
padding: 2em;
line-height: 1.5;
}
h1 {
font-size: 1.4em;
margin-bottom: 1em;
}
input[type="file"] {
margin-bottom: 1em;
cursor: pointer;
}
button {
background: #0078d4;
color: white;
border: none;
padding: 0.6em 1.4em;
border-radius: 6px;
cursor: pointer;
font-size: 1em;
font-weight: 500;
transition: background 0.2s;
}
button:hover:enabled {
background: #005fa3;
}
button:disabled {
background: #a0a0a0;
cursor: not-allowed;
}
#status {
margin-top: 1em;
font-size: 0.95em;
}
progress {
width: 100%;
height: 16px;
appearance: none;
-webkit-appearance: none;
margin-top: 0.5em;
border-radius: 4px;
overflow: hidden;
display: none;
}
progress::-webkit-progress-bar {
background-color: #e3e3e3;
}
progress::-webkit-progress-value {
background-color: #0078d4;
}
progress::-moz-progress-bar {
background-color: #0078d4;
}
#totalContainer {
margin-top: 1em;
}
#summary {
margin-top: 1em;
font-size: 0.95em;
color: #0078d4;
}
</style>
</head>
<body>
<h1>Upload Files or Folders</h1>
<label>Choose files:</label>
<input type="file" id="fileInput" multiple>
<br>
<label>Or choose a folder:</label>
<input type="file" id="folderInput" webkitdirectory multiple>
<br>
<button id="uploadBtn" disabled>Upload</button>
<div id="status"></div>
<progress id="fileProgress" value="0" max="100"></progress>
<div id="totalContainer">
<progress id="totalProgress" value="0" max="100"></progress>
</div>
<div id="summary"></div>
<script>
const fileInput = document.getElementById("fileInput");
const folderInput = document.getElementById("folderInput");
const uploadBtn = document.getElementById("uploadBtn");
const fileProgress = document.getElementById("fileProgress");
const totalProgress = document.getElementById("totalProgress");
const status = document.getElementById("status");
const summary = document.getElementById("summary");
function getAllFiles() {
return [
...fileInput.files,
...folderInput.files
];
}
// Enable Upload button if either input has files
function updateUploadButton() {
uploadBtn.disabled = getAllFiles().length === 0;
}
fileInput.addEventListener("change", updateUploadButton);
folderInput.addEventListener("change", updateUploadButton);
uploadBtn.onclick = async () => {
const files = getAllFiles();
if (!files.length) return;
uploadBtn.disabled = true; // prevent double-clicks
fileProgress.style.display = "block";
totalProgress.style.display = "block";
summary.textContent = "";
const totalBytes = files.reduce((sum, f) => sum + f.size, 0);
let uploadedBytes = 0;
const startTime = performance.now();
for (let i = 0; i < files.length; i++) {
const file = files[i];
const relPath = file.webkitRelativePath || file.name;
status.textContent = `Uploading ${relPath} (${i + 1}/${files.length}) — ${file.size} bytes`;
fileProgress.value = 0;
try {
await uploadSingleFile(file, relPath, (loaded) => {
fileProgress.value = (loaded / file.size) * 100;
totalProgress.value = ((uploadedBytes + loaded) / totalBytes) * 100;
});
uploadedBytes += file.size;
} catch (err) {
status.textContent = `Error uploading ${relPath}: ${err.message}`;
break;
}
}
const elapsed = (performance.now() - startTime) / 1000;
const totalSizeStr = formatBytes(totalBytes);
const fileCount = files.length;
totalProgress.value = 100;
status.textContent = "All files uploaded successfully!";
summary.textContent = `Uploaded ${fileCount} file${fileCount > 1 ? "s" : ""} (${totalSizeStr}) in ${elapsed.toFixed(1)} s`;
setTimeout(() => {
fileProgress.style.display = "none";
totalProgress.style.display = "none";
updateUploadButton(); // enable upload if files still selected
}, 2000);
};
function uploadSingleFile(file, relPath, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/upload_file", true);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress(event.loaded);
};
xhr.onload = () => {
xhr.status === 200 ? resolve() : reject(new Error(`HTTP ${xhr.status}`));
};
xhr.onerror = () => reject(new Error("Network error"));
const formData = new FormData();
formData.append("file", file, relPath);
xhr.send(formData);
});
}
function formatBytes(bytes) {
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) {
bytes /= 1024;
i++;
}
return `${bytes.toFixed(2)} ${units[i]}`;
}
</script>
</body>
</html>
+1
View File
@@ -99,6 +99,7 @@ Source: "assets\*.*"; DestDir: "{app}\assets"
Source: "assets\shaders\*.*"; DestDir: "{app}\assets\shaders"
Source: "assets\themes\*.*"; DestDir: "{app}\assets\themes"
Source: "assets\debugger\*"; DestDir: "{app}\assets\debugger"; Flags: recursesubdirs
Source: "assets\upload\*"; DestDir: "{app}\assets\upload"; Flags: recursesubdirs
Source: "assets\lang\*.ini"; DestDir: "{app}\assets\lang"
Source: "assets\flash0\font\*.*"; DestDir: "{app}\assets\flash0\font"
Source: "assets\vfpu\*.*"; DestDir: "{app}\assets\vfpu"