Add support for onProgress and downloadFile to NETWORK:HttpRequest()
This commit is contained in:
+16
-15
@@ -2486,21 +2486,22 @@
|
||||
<Enum name='HttpErrorCode'>
|
||||
<EnumValue name=''HttpErrorCode_Blocked'' value='0'/>
|
||||
<EnumValue name=''HttpErrorCode_UnknownError'' value='1'/>
|
||||
<EnumValue name=''HttpErrorCode_CannotConnect'' value='2'/>
|
||||
<EnumValue name=''HttpErrorCode_Timeout'' value='3'/>
|
||||
<EnumValue name=''HttpErrorCode_Gzip'' value='4'/>
|
||||
<EnumValue name=''HttpErrorCode_UrlMalformed'' value='5'/>
|
||||
<EnumValue name=''HttpErrorCode_CannotCreateSocket'' value='6'/>
|
||||
<EnumValue name=''HttpErrorCode_SendError'' value='7'/>
|
||||
<EnumValue name=''HttpErrorCode_ReadError'' value='8'/>
|
||||
<EnumValue name=''HttpErrorCode_CannotReadStatusLine'' value='9'/>
|
||||
<EnumValue name=''HttpErrorCode_MissingStatus'' value='10'/>
|
||||
<EnumValue name=''HttpErrorCode_HeaderParsingError'' value='11'/>
|
||||
<EnumValue name=''HttpErrorCode_MissingLocation'' value='12'/>
|
||||
<EnumValue name=''HttpErrorCode_TooManyRedirects'' value='13'/>
|
||||
<EnumValue name=''HttpErrorCode_ChunkReadError'' value='14'/>
|
||||
<EnumValue name=''HttpErrorCode_CannotReadBody'' value='15'/>
|
||||
<EnumValue name=''HttpErrorCode_Cancelled'' value='16'/>
|
||||
<EnumValue name=''HttpErrorCode_FileError'' value='2'/>
|
||||
<EnumValue name=''HttpErrorCode_CannotConnect'' value='3'/>
|
||||
<EnumValue name=''HttpErrorCode_Timeout'' value='4'/>
|
||||
<EnumValue name=''HttpErrorCode_Gzip'' value='5'/>
|
||||
<EnumValue name=''HttpErrorCode_UrlMalformed'' value='6'/>
|
||||
<EnumValue name=''HttpErrorCode_CannotCreateSocket'' value='7'/>
|
||||
<EnumValue name=''HttpErrorCode_SendError'' value='8'/>
|
||||
<EnumValue name=''HttpErrorCode_ReadError'' value='9'/>
|
||||
<EnumValue name=''HttpErrorCode_CannotReadStatusLine'' value='10'/>
|
||||
<EnumValue name=''HttpErrorCode_MissingStatus'' value='11'/>
|
||||
<EnumValue name=''HttpErrorCode_HeaderParsingError'' value='12'/>
|
||||
<EnumValue name=''HttpErrorCode_MissingLocation'' value='13'/>
|
||||
<EnumValue name=''HttpErrorCode_TooManyRedirects'' value='14'/>
|
||||
<EnumValue name=''HttpErrorCode_ChunkReadError'' value='15'/>
|
||||
<EnumValue name=''HttpErrorCode_CannotReadBody'' value='16'/>
|
||||
<EnumValue name=''HttpErrorCode_Cancelled'' value='17'/>
|
||||
</Enum>
|
||||
<Enum name='ImageCacheMode'>
|
||||
<EnumValue name=''ImageCacheMode_Off'' value='0'/>
|
||||
|
||||
@@ -3562,16 +3562,20 @@ end
|
||||
<pre><code>
|
||||
NETWORK:HttpRequest{
|
||||
url="https://api.example.com",
|
||||
method="GET", -- default: "GET"
|
||||
body="", -- default: ""
|
||||
multipartBoundary="", -- default: ""
|
||||
headers={ -- default: {}
|
||||
method="GET", -- default: "GET"
|
||||
body="", -- default: ""
|
||||
multipartBoundary="", -- default: ""
|
||||
headers={ -- default: {}
|
||||
["Accept-Language"]="en-US",
|
||||
["Cookie"]="sessionId=42",
|
||||
},
|
||||
connectTimeout=3, -- default: 60
|
||||
transferTimeout=10, -- default: 1800
|
||||
onResponse=function(response) -- default: no callback
|
||||
connectTimeout=3, -- default: 60
|
||||
transferTimeout=10, -- default: 1800
|
||||
downloadFile="", -- default: no download file
|
||||
onProgress=function(currentBytes, totalBytes) -- default: no callback
|
||||
...
|
||||
end,
|
||||
onResponse=function(response) -- default: no callback
|
||||
...
|
||||
end,
|
||||
}
|
||||
@@ -3598,6 +3602,9 @@ NETWORK:HttpRequest{
|
||||
errorMessage="access to https://api.example.com is not allowed",
|
||||
}
|
||||
</code></pre>
|
||||
If the <code>downloadFile</code> parameter is set, the response body is not accumulated in memory, but written to <code>/Downloads/${downloadFile}</code>.
|
||||
The file is available in the <code>onResponse</code> callback where it can be unzipped/copied to another location using <code>FILEMAN:Unzip()</code>/<code>FILEMAN:Copy()</code> respectively.
|
||||
The file is deleted once the callback returns.
|
||||
</Function>
|
||||
<Function name='UrlEncode' return='string' arguments='string value'>
|
||||
Returns the URL encoded representation of <code>value</code>.
|
||||
|
||||
-1
@@ -149,5 +149,4 @@ set(IXWEBSOCKET_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/IXWebSocket-11.3.2")
|
||||
target_include_directories(ixwebsocket PUBLIC
|
||||
"zlib"
|
||||
$<BUILD_INTERFACE:${IXWEBSOCKET_INCLUDE_DIRS}/>
|
||||
$<INSTALL_INTERFACE:include/ixwebsocket>
|
||||
)
|
||||
|
||||
+182
-18
@@ -1,16 +1,22 @@
|
||||
#include "global.h"
|
||||
#include "NetworkManager.h"
|
||||
#include "LuaManager.h"
|
||||
#include "ProductInfo.h"
|
||||
#include "RageFile.h"
|
||||
#include "RageFileManager.h"
|
||||
#include "RageLog.h"
|
||||
#include "RageUtil.h"
|
||||
#include "StdString.h"
|
||||
#include "ver.h"
|
||||
|
||||
#include <ixwebsocket/IXHttpClient.h>
|
||||
#include <ixwebsocket/IXNetSystem.h>
|
||||
#include <ixwebsocket/IXUrlParser.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
#include <memory>
|
||||
#include <sstream>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
@@ -23,6 +29,7 @@ Preference<RString> NetworkManager::httpAllowHosts("HttpAllowHosts", "", nullptr
|
||||
static const char *HttpErrorCodeNames[] = {
|
||||
"Blocked",
|
||||
"UnknownError",
|
||||
"FileError",
|
||||
"CannotConnect",
|
||||
"Timeout",
|
||||
"Gzip",
|
||||
@@ -43,7 +50,7 @@ XToString(HttpErrorCode);
|
||||
StringToX(HttpErrorCode);
|
||||
LuaXType(HttpErrorCode);
|
||||
|
||||
NetworkManager::NetworkManager() : httpClient(true)
|
||||
NetworkManager::NetworkManager() : httpClient(true), downloadClient(true)
|
||||
{
|
||||
ix::initNetSystem();
|
||||
|
||||
@@ -54,6 +61,8 @@ NetworkManager::NetworkManager() : httpClient(true)
|
||||
lua_setglobal(L, "NETWORK");
|
||||
LUA->Release(L);
|
||||
}
|
||||
|
||||
this->ClearDownloads();
|
||||
}
|
||||
|
||||
NetworkManager::~NetworkManager()
|
||||
@@ -97,24 +106,75 @@ bool NetworkManager::IsUrlAllowed(const std::string& url)
|
||||
|
||||
HttpRequestFuturePtr NetworkManager::HttpRequest(const HttpRequestArgs& args)
|
||||
{
|
||||
ix::HttpRequestArgsPtr req = this->httpClient.createRequest(args.url, args.method);
|
||||
auto &client = args.downloadFile.empty() ? this->httpClient : this->downloadClient;
|
||||
auto downloadFile = std::make_shared<RageFile>();
|
||||
std::string downloadFilename;
|
||||
|
||||
ix::HttpRequestArgsPtr req = client.createRequest(args.url, args.method);
|
||||
req->body = args.body;
|
||||
req->multipartBoundary = args.multipartBoundary;
|
||||
|
||||
req->extraHeaders["User-Agent"] = this->GetUserAgent();
|
||||
for (const auto& entry : args.headers)
|
||||
{
|
||||
req->extraHeaders[entry.first] = entry.second;
|
||||
}
|
||||
|
||||
if (args.connectTimeout > 0) {
|
||||
if (!args.downloadFile.empty())
|
||||
{
|
||||
downloadFilename = std::string("/Downloads/") + args.downloadFile;
|
||||
if (!downloadFile->Open(downloadFilename, RageFile::WRITE | RageFile::STREAMED))
|
||||
{
|
||||
req->cancel = true;
|
||||
}
|
||||
|
||||
req->onChunkCallback = [args, downloadFile, req](const std::string& data) {
|
||||
if (downloadFile->Write(data.c_str(), data.size()) < 0)
|
||||
{
|
||||
req->cancel = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Don't timeout downloads by default. This value might be
|
||||
// overwritten below if a value is specified in the request.
|
||||
req->transferTimeout = INT_MAX;
|
||||
|
||||
}
|
||||
|
||||
if (args.connectTimeout > 0)
|
||||
req->connectTimeout = args.connectTimeout;
|
||||
}
|
||||
|
||||
if (args.transferTimeout > 0) {
|
||||
if (args.transferTimeout > 0)
|
||||
req->transferTimeout = args.transferTimeout;
|
||||
}
|
||||
|
||||
this->httpClient.performRequest(req, args.onResponse);
|
||||
if (args.onProgress)
|
||||
req->onProgressCallback = args.onProgress;
|
||||
|
||||
client.performRequest(req, [args, downloadFile, downloadFilename](const ix::HttpResponsePtr& response) {
|
||||
if (downloadFile->IsOpen())
|
||||
{
|
||||
RString error = downloadFile->GetError();
|
||||
downloadFile->Close();
|
||||
|
||||
if (!error.empty())
|
||||
{
|
||||
std::string errorMessage = "could not write to " + downloadFile->GetPath() + ": " + error;
|
||||
if (args.onFileError)
|
||||
args.onFileError(errorMessage);
|
||||
|
||||
FILEMAN->Remove(downloadFilename);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (args.onResponse)
|
||||
args.onResponse(response);
|
||||
|
||||
if (!args.downloadFile.empty())
|
||||
{
|
||||
FILEMAN->Remove(downloadFilename);
|
||||
}
|
||||
});
|
||||
|
||||
return std::make_shared<HttpRequestFuture>(req);
|
||||
}
|
||||
@@ -129,7 +189,28 @@ std::string NetworkManager::EncodeQueryParameters(const std::unordered_map<std::
|
||||
return this->httpClient.serializeHttpParameters(query);
|
||||
}
|
||||
|
||||
int HttpRequestFuture::collect(lua_State *L)
|
||||
std::string NetworkManager::GetUserAgent()
|
||||
{
|
||||
std::stringstream ss;
|
||||
ss << PRODUCT_FAMILY << "/" << product_version;
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
void NetworkManager::ClearDownloads()
|
||||
{
|
||||
vector<RString> files;
|
||||
FILEMAN->GetDirListing("/Downloads/*", files, false, true);
|
||||
|
||||
for (const auto& file : files)
|
||||
{
|
||||
if (FILEMAN->IsADirectory(file))
|
||||
FILEMAN->DeleteRecursive(file + "/");
|
||||
else
|
||||
FILEMAN->Remove(file);
|
||||
}
|
||||
}
|
||||
|
||||
int HttpRequestFuture::Collect(lua_State *L)
|
||||
{
|
||||
void *udata = luaL_checkudata(L, 1, "HttpRequestFuture");
|
||||
auto futptr = static_cast<HttpRequestFuturePtr*>(udata);
|
||||
@@ -152,7 +233,7 @@ int HttpRequestFuture::Cancel(lua_State *L)
|
||||
static void registerHttpRequestMetatable(lua_State *L)
|
||||
{
|
||||
const luaL_Reg HttpRequest_meta[] = {
|
||||
{"__gc", HttpRequestFuture::collect},
|
||||
{"__gc", HttpRequestFuture::Collect},
|
||||
{"Cancel", HttpRequestFuture::Cancel},
|
||||
{NULL, NULL},
|
||||
};
|
||||
@@ -183,6 +264,8 @@ public:
|
||||
luaL_checktype(L, 1, LUA_TTABLE);
|
||||
|
||||
HttpRequestArgs args;
|
||||
int onProgressRef = LUA_NOREF;
|
||||
int onResponseRef = LUA_NOREF;
|
||||
|
||||
lua_getfield(L, 1, "url");
|
||||
if (lua_isnil(L, -1))
|
||||
@@ -311,9 +394,44 @@ public:
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
int onResponseRef = LUA_NOREF;
|
||||
lua_getfield(L, 1, "downloadFile");
|
||||
if (!lua_isnil(L, -1))
|
||||
{
|
||||
if (lua_isstring(L, -1))
|
||||
{
|
||||
args.downloadFile = lua_tostring(L, -1);
|
||||
}
|
||||
else
|
||||
{
|
||||
luaL_error(L, "downloadFile must be a string");
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, 1, "onProgress");
|
||||
if (!lua_isnil(L, -1))
|
||||
{
|
||||
if (lua_isfunction(L, -1))
|
||||
{
|
||||
lua_pushvalue(L, -1);
|
||||
onProgressRef = luaL_ref(L, LUA_REGISTRYINDEX);
|
||||
|
||||
args.onProgress = [onProgressRef](int current, int total)
|
||||
{
|
||||
Lua *L = LUA->Get();
|
||||
handleProgress(L, current, total, onProgressRef);
|
||||
LUA->Release(L);
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
luaL_error(L, "onProgress must be a function");
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
// do this as the last check, so we don't leak lua references on error
|
||||
lua_getfield(L, 1, "onResponse");
|
||||
if (!lua_isnil(L, -1))
|
||||
{
|
||||
@@ -321,21 +439,39 @@ public:
|
||||
{
|
||||
lua_pushvalue(L, -1);
|
||||
onResponseRef = luaL_ref(L, LUA_REGISTRYINDEX);
|
||||
|
||||
args.onResponse = [onResponseRef](const ix::HttpResponsePtr& response)
|
||||
{
|
||||
Lua *L = LUA->Get();
|
||||
handleHttpResponse(L, response, onResponseRef);
|
||||
LUA->Release(L);
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
luaL_unref(L, LUA_REGISTRYINDEX, onProgressRef);
|
||||
luaL_error(L, "onResponse must be a function");
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
args.onFileError = [onProgressRef, onResponseRef](const std::string& errorMessage)
|
||||
{
|
||||
Lua *L = LUA->Get();
|
||||
|
||||
luaL_unref(L, LUA_REGISTRYINDEX, onProgressRef);
|
||||
|
||||
if (onResponseRef != LUA_NOREF)
|
||||
handleFileError(L, errorMessage, onResponseRef);
|
||||
|
||||
LUA->Release(L);
|
||||
};
|
||||
|
||||
args.onResponse = [onProgressRef, onResponseRef](const ix::HttpResponsePtr& response)
|
||||
{
|
||||
Lua *L = LUA->Get();
|
||||
|
||||
luaL_unref(L, LUA_REGISTRYINDEX, onProgressRef);
|
||||
|
||||
if (onResponseRef != LUA_NOREF)
|
||||
handleHttpResponse(L, response, onResponseRef);
|
||||
|
||||
LUA->Release(L);
|
||||
};
|
||||
|
||||
if (p->IsUrlAllowed(args.url))
|
||||
{
|
||||
auto fut = p->HttpRequest(args);
|
||||
@@ -349,6 +485,7 @@ public:
|
||||
else
|
||||
{
|
||||
LOG->Warn("blocked access to %s", args.url.c_str());
|
||||
luaL_unref(L, LUA_REGISTRYINDEX, onProgressRef);
|
||||
if (onResponseRef != LUA_NOREF)
|
||||
{
|
||||
handleUrlForbidden(L, args.url, onResponseRef);
|
||||
@@ -431,6 +568,23 @@ private:
|
||||
LuaHelpers::RunScriptOnStack(L, error, 1, 0, true);
|
||||
}
|
||||
|
||||
static void handleFileError(Lua *L, const std::string& errorMessage, int onResponseRef)
|
||||
{
|
||||
lua_rawgeti(L, LUA_REGISTRYINDEX, onResponseRef);
|
||||
luaL_unref(L, LUA_REGISTRYINDEX, onResponseRef);
|
||||
|
||||
lua_newtable(L);
|
||||
|
||||
LuaHelpers::Push(L, HttpErrorCode_FileError);
|
||||
lua_setfield(L, -2, "error");
|
||||
|
||||
lua_pushstring(L, errorMessage.c_str());
|
||||
lua_setfield(L, -2, "errorMessage");
|
||||
|
||||
RString error = "Lua error in HTTP response handler: ";
|
||||
LuaHelpers::RunScriptOnStack(L, error, 1, 0, true);
|
||||
}
|
||||
|
||||
static void handleHttpResponse(Lua *L, const ix::HttpResponsePtr& response, int onResponseRef)
|
||||
{
|
||||
lua_rawgeti(L, LUA_REGISTRYINDEX, onResponseRef);
|
||||
@@ -493,6 +647,16 @@ private:
|
||||
RString error = "Lua error in HTTP response handler: ";
|
||||
LuaHelpers::RunScriptOnStack(L, error, 1, 0, true);
|
||||
}
|
||||
|
||||
static void handleProgress(Lua *L, int current, int total, int onProgressRef)
|
||||
{
|
||||
lua_rawgeti(L, LUA_REGISTRYINDEX, onProgressRef);
|
||||
lua_pushinteger(L, current);
|
||||
lua_pushinteger(L, total);
|
||||
|
||||
RString error = "Lua error in HTTP progress handler: ";
|
||||
LuaHelpers::RunScriptOnStack(L, error, 2, 0, true);
|
||||
}
|
||||
};
|
||||
|
||||
LUA_REGISTER_CLASS(NetworkManager)
|
||||
|
||||
+13
-3
@@ -4,8 +4,10 @@
|
||||
#include "Preference.h"
|
||||
#include "StdString.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
@@ -21,6 +23,7 @@ enum HttpErrorCode
|
||||
{
|
||||
HttpErrorCode_Blocked,
|
||||
HttpErrorCode_UnknownError,
|
||||
HttpErrorCode_FileError,
|
||||
|
||||
// from IXWebSocket
|
||||
HttpErrorCode_CannotConnect,
|
||||
@@ -44,7 +47,7 @@ enum HttpErrorCode
|
||||
};
|
||||
const RString& HttpErrorCodeToString(HttpErrorCode dc);
|
||||
HttpErrorCode StringToHttpErrorCode(const RString& sDC);
|
||||
LuaDeclareType( HttpErrorCode );
|
||||
LuaDeclareType(HttpErrorCode);
|
||||
|
||||
struct HttpRequestArgs
|
||||
{
|
||||
@@ -55,7 +58,10 @@ struct HttpRequestArgs
|
||||
std::unordered_map<std::string, std::string> headers;
|
||||
int connectTimeout = -1;
|
||||
int transferTimeout = -1;
|
||||
std::function<void(const ix::HttpResponsePtr& response)> onResponse = [](const ix::HttpResponsePtr& response) {};
|
||||
std::string downloadFile;
|
||||
std::function<bool(int current, int total)> onProgress;
|
||||
std::function<void(const ix::HttpResponsePtr& response)> onResponse;
|
||||
std::function<void(const std::string& errorMessage)> onFileError;
|
||||
};
|
||||
|
||||
class HttpRequestFuture
|
||||
@@ -63,7 +69,7 @@ class HttpRequestFuture
|
||||
public:
|
||||
HttpRequestFuture(ix::HttpRequestArgsPtr& args) : args(args) {};
|
||||
|
||||
static int collect(lua_State *L);
|
||||
static int Collect(lua_State *L);
|
||||
static int Cancel(lua_State *L);
|
||||
|
||||
private:
|
||||
@@ -87,7 +93,11 @@ public:
|
||||
void PushSelf(lua_State *L);
|
||||
|
||||
private:
|
||||
std::string GetUserAgent();
|
||||
void ClearDownloads();
|
||||
|
||||
ix::HttpClient httpClient;
|
||||
ix::HttpClient downloadClient;
|
||||
|
||||
static Preference<bool> httpEnabled;
|
||||
static Preference<RString> httpAllowHosts;
|
||||
|
||||
@@ -354,6 +354,7 @@ void ArchHooks::MountUserFilesystems( const RString &sDirOfExecutable )
|
||||
FILEMAN->Mount( "dir", ssprintf("%s/" PRODUCT_ID "/CDTitles", dir), "/CDTitles" );
|
||||
FILEMAN->Mount( "dir", ssprintf("%s/" PRODUCT_ID "/Characters", dir), "/Characters" );
|
||||
FILEMAN->Mount( "dir", ssprintf("%s/" PRODUCT_ID "/Courses", dir), "/Courses" );
|
||||
FILEMAN->Mount( "dir", ssprintf("%s/" PRODUCT_ID "/Downloads", dir), "/Downloads" );
|
||||
FILEMAN->Mount( "dir", ssprintf("%s/" PRODUCT_ID "/NoteSkins", dir), "/NoteSkins" );
|
||||
FILEMAN->Mount( "dir", ssprintf("%s/" PRODUCT_ID "/Packages", dir), "/Packages" );
|
||||
FILEMAN->Mount( "dir", ssprintf("%s/" PRODUCT_ID "/Songs", dir), "/Songs" );
|
||||
|
||||
@@ -403,6 +403,7 @@ void ArchHooks::MountUserFilesystems( const RString &sDirOfExecutable )
|
||||
FILEMAN->Mount( "dir", sUserDataPath + "/CDTitles", "/CDTitles" );
|
||||
FILEMAN->Mount( "dir", sUserDataPath + "/Characters", "/Characters" );
|
||||
FILEMAN->Mount( "dir", sUserDataPath + "/Courses", "/Courses" );
|
||||
FILEMAN->Mount( "dir", sUserDataPath + "/Downloads", "/Downloads" );
|
||||
FILEMAN->Mount( "dir", sUserDataPath + "/Logs", "/Logs" );
|
||||
FILEMAN->Mount( "dir", sUserDataPath + "/NoteSkins", "/NoteSkins" );
|
||||
FILEMAN->Mount( "dir", sUserDataPath + "/Packages", "/Packages" );
|
||||
|
||||
@@ -82,6 +82,7 @@ void ArchHooks::MountUserFilesystems( const RString &sDirOfExecutable )
|
||||
FILEMAN->Mount( "dir", sAppDataDir + "/CDTitles", "/CDTitles" );
|
||||
FILEMAN->Mount( "dir", sAppDataDir + "/Characters", "/Characters" );
|
||||
FILEMAN->Mount( "dir", sAppDataDir + "/Courses", "/Courses" );
|
||||
FILEMAN->Mount( "dir", sAppDataDir + "/Downloads", "/Downloads" );
|
||||
FILEMAN->Mount( "dir", sAppDataDir + "/Logs", "/Logs" );
|
||||
FILEMAN->Mount( "dir", sAppDataDir + "/NoteSkins", "/NoteSkins" );
|
||||
FILEMAN->Mount( "dir", sAppDataDir + "/Packages", "/Packages" );
|
||||
|
||||
Reference in New Issue
Block a user