mirror of
https://github.com/nextcloud/desktop.git
synced 2024-11-25 14:36:01 +03:00
Windows Shell Integration: Use the QLocalSocket on windo and do the request assynchroniously
Squashed commit of the following: commit 4d9b072f560fa171a1390b7c74425614aa20e955 Author: Olivier Goffart <ogoffart@woboq.com> Date: Tue Oct 14 16:04:02 2014 +0200 Remove useless variable commit 8e85de0307ec5f31bf3f92a7de793fed7d41c2ea Author: Daniel Molkentin <danimo@owncloud.com> Date: Tue Oct 14 16:01:52 2014 +0200 Make Windows Explorer Extension build commit 8e2942cd9fd32e3af72d60cba0d06bd9d6222a45 Author: Daniel Molkentin <danimo@owncloud.com> Date: Tue Oct 14 11:39:37 2014 +0200 Fix compilation commit 0fc0c0e0e0c7e58ad97f62700256c7d1f8c0670b Author: Olivier Goffart <ogoffart@woboq.com> Date: Tue Oct 14 11:48:32 2014 +0200 Windows Shell Integration: Try to let the thread notify about changes when there are changes commit 4a1712b7c03269ca3007f167b8f313ea47655967 Author: Olivier Goffart <ogoffart@woboq.com> Date: Tue Oct 14 11:35:20 2014 +0200 Windows Shell Integration: Share the RemotePathChecker amongst all the OCOverlay instances commit 2d87408e9af5a4d7ab71c460ce606ba1f367c09f Author: Olivier Goffart <ogoffart@woboq.com> Date: Mon Oct 13 18:55:15 2014 +0200 Windows Shell Integration: Attempts to wait on multiple objects (WIP) commit e448e427b6d1561ad7a40d08fc6632f4d2b4ef44 Author: Daniel Molkentin <danimo@owncloud.com> Date: Mon Oct 13 17:58:02 2014 +0200 Introduce a worker thread commit 2344407ec0bc1ce173ebbacadcf3992d62d94078 Author: Olivier Goffart <ogoffart@woboq.com> Date: Mon Oct 13 17:03:47 2014 +0200 Windows Shell Integration: try to keep the socket open using a thread (WIP) commit ea6d5273ed60d8bc3f1c5d5c6936364d783a1c0f Author: Daniel Molkentin <danimo@owncloud.com> Date: Mon Oct 13 15:27:46 2014 +0200 Make Explorer plugin work again with named pipes This is a temporary hack, which needs more refactoring. commit 44a3437a44082379efa0078c9afd7b8bbde930de Author: Daniel Molkentin <danimo@owncloud.com> Date: Sat Oct 11 07:31:24 2014 +0200 Fix code commit 123390a0f3516c0078309d7048c6d2acb9293676 Author: Olivier Goffart <ogoffart@woboq.com> Date: Fri Oct 10 16:29:35 2014 +0200 Windows shell integration: Use named pipe (WIP) commit 9eea7e2321abeac6b8db0bd85bfce612dbf6bb20 Author: Olivier Goffart <ogoffart@woboq.com> Date: Wed Oct 1 12:04:13 2014 +0200 Windows Shell Integration: Simplify StringUtil This fixes a memory leak in CommunicationSocket::ReadLine
This commit is contained in:
parent
8231bc931b
commit
4b001a77b3
8 changed files with 206 additions and 165 deletions
|
@ -36,17 +36,13 @@ extern HINSTANCE instanceHandle;
|
|||
#define IDM_DISPLAY 0
|
||||
#define IDB_OK 101
|
||||
|
||||
namespace {
|
||||
static std::vector<std::wstring> s_watchedDirectories;
|
||||
}
|
||||
|
||||
OCOverlay::OCOverlay(int state)
|
||||
: _communicationSocket(0)
|
||||
, _referenceCount(1)
|
||||
, _checker(new RemotePathChecker(PORT))
|
||||
: _referenceCount(1)
|
||||
, _state(state)
|
||||
|
||||
{
|
||||
static RemotePathChecker s_remotePathChecker;
|
||||
_checker = &s_remotePathChecker;
|
||||
}
|
||||
|
||||
OCOverlay::~OCOverlay(void)
|
||||
|
@ -121,23 +117,13 @@ IFACEMETHODIMP OCOverlay::GetPriority(int *pPriority)
|
|||
|
||||
IFACEMETHODIMP OCOverlay::IsMemberOf(PCWSTR pwszPath, DWORD dwAttrib)
|
||||
{
|
||||
|
||||
//if(!_IsOverlaysEnabled())
|
||||
//{
|
||||
// return MAKE_HRESULT(S_FALSE, 0, 0);
|
||||
//}
|
||||
|
||||
// FIXME: Use Registry instead, this will only trigger once
|
||||
// and now follow any user changes in the client
|
||||
if (s_watchedDirectories.empty()) {
|
||||
s_watchedDirectories = _checker->WatchedDirectories();
|
||||
}
|
||||
auto watchedDirectories = _checker->WatchedDirectories();
|
||||
|
||||
wstring wpath(pwszPath);
|
||||
wpath.append(L"\\");
|
||||
//wpath.append(L"\\");
|
||||
vector<wstring>::iterator it;
|
||||
bool watched = false;
|
||||
for (it = s_watchedDirectories.begin(); it != s_watchedDirectories.end(); ++it) {
|
||||
for (it = watchedDirectories.begin(); it != watchedDirectories.end(); ++it) {
|
||||
if (StringUtil::begins_with(wpath, *it)) {
|
||||
watched = true;
|
||||
}
|
||||
|
|
|
@ -35,14 +35,13 @@ public:
|
|||
IFACEMETHODIMP_(ULONG) Release();
|
||||
|
||||
protected:
|
||||
~OCOverlay(void);
|
||||
~OCOverlay();
|
||||
|
||||
private:
|
||||
//bool _GenerateMessage(const wchar_t*, std::wstring*);
|
||||
|
||||
bool _IsOverlaysEnabled();
|
||||
long _referenceCount;
|
||||
CommunicationSocket* _communicationSocket;
|
||||
RemotePathChecker* _checker;
|
||||
int _state;
|
||||
};
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
#include <windows.h>
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
#include <array>
|
||||
|
||||
#include <fstream>
|
||||
|
||||
|
@ -30,8 +31,8 @@ using namespace std;
|
|||
|
||||
#define DEFAULT_BUFLEN 4096
|
||||
|
||||
CommunicationSocket::CommunicationSocket(int port)
|
||||
: _port(port), _clientSocket(INVALID_SOCKET)
|
||||
CommunicationSocket::CommunicationSocket()
|
||||
: _pipe(INVALID_HANDLE_VALUE)
|
||||
{
|
||||
}
|
||||
|
||||
|
@ -43,64 +44,42 @@ CommunicationSocket::~CommunicationSocket()
|
|||
bool CommunicationSocket::Close()
|
||||
{
|
||||
WSACleanup();
|
||||
bool closed = (closesocket(_clientSocket) == 0);
|
||||
shutdown(_clientSocket, SD_BOTH);
|
||||
_clientSocket = INVALID_SOCKET;
|
||||
return closed;
|
||||
if (_pipe == INVALID_HANDLE_VALUE) {
|
||||
return false;
|
||||
}
|
||||
CloseHandle(_pipe);
|
||||
_pipe = INVALID_HANDLE_VALUE;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
bool CommunicationSocket::Connect()
|
||||
{
|
||||
WSADATA wsaData;
|
||||
auto pipename = std::wstring(L"\\\\.\\pipe\\");
|
||||
pipename += L"ownCloud";
|
||||
|
||||
HRESULT iResult = WSAStartup(MAKEWORD(2, 2), &wsaData);
|
||||
_pipe = CreateFile(pipename.data(), GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);
|
||||
|
||||
if (iResult != NO_ERROR) {
|
||||
int error = WSAGetLastError();
|
||||
}
|
||||
|
||||
|
||||
_clientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||||
|
||||
if (_clientSocket == INVALID_SOCKET) {
|
||||
//int error = WSAGetLastError();
|
||||
Close();
|
||||
return false;
|
||||
}
|
||||
|
||||
struct sockaddr_in clientService;
|
||||
|
||||
clientService.sin_family = AF_INET;
|
||||
clientService.sin_addr.s_addr = inet_addr(PLUG_IN_SOCKET_ADDRESS);
|
||||
clientService.sin_port = htons(_port);
|
||||
|
||||
iResult = connect(_clientSocket, (SOCKADDR*)&clientService, sizeof(clientService));
|
||||
DWORD timeout = 500; // ms
|
||||
setsockopt(_clientSocket, SOL_SOCKET, SO_RCVTIMEO, (const char*) &timeout, sizeof(DWORD));
|
||||
|
||||
if (iResult == SOCKET_ERROR) {
|
||||
//int error = WSAGetLastError();
|
||||
Close();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
if (_pipe == INVALID_HANDLE_VALUE) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CommunicationSocket::SendMsg(const wchar_t* message)
|
||||
{
|
||||
const char* utf8_msg = StringUtil::toUtf8(message);
|
||||
size_t result = send(_clientSocket, utf8_msg, (int)strlen(utf8_msg), 0);
|
||||
delete[] utf8_msg;
|
||||
auto utf8_msg = StringUtil::toUtf8(message);
|
||||
|
||||
if (result == SOCKET_ERROR) {
|
||||
//int error = WSAGetLastError();
|
||||
closesocket(_clientSocket);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
DWORD numBytesWritten = 0;
|
||||
auto result = WriteFile( _pipe, utf8_msg.c_str(), DWORD(utf8_msg.size()), &numBytesWritten, NULL);
|
||||
|
||||
if (result) {
|
||||
return true;
|
||||
} else {
|
||||
// qWarning() << "Failed to send data." <<;
|
||||
// look up error code here using GetLastError()
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool CommunicationSocket::ReadLine(wstring* response)
|
||||
|
@ -109,21 +88,36 @@ bool CommunicationSocket::ReadLine(wstring* response)
|
|||
return false;
|
||||
}
|
||||
|
||||
vector<char> resp_utf8;
|
||||
char buffer;
|
||||
response->clear();
|
||||
|
||||
Sleep(50);
|
||||
|
||||
while (true) {
|
||||
int bytesRead = recv(_clientSocket, &buffer, 1, 0);
|
||||
if (bytesRead <= 0) {
|
||||
response = 0;
|
||||
int lbPos = 0;
|
||||
auto it = std::find(_buffer.begin() + lbPos, _buffer.end(), '\n');
|
||||
if (it != _buffer.end()) {
|
||||
*response = StringUtil::toUtf16(_buffer.data(), DWORD(it - _buffer.begin()));
|
||||
_buffer.erase(_buffer.begin(), it + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::array<char, 128> resp_utf8;
|
||||
DWORD numBytesRead = 0;
|
||||
DWORD totalBytesAvailable = 0;
|
||||
PeekNamedPipe(_pipe, NULL, 0, 0, &totalBytesAvailable, 0);
|
||||
if (totalBytesAvailable == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (buffer == '\n') {
|
||||
resp_utf8.push_back(0);
|
||||
*response = StringUtil::toUtf16(&resp_utf8[0], resp_utf8.size());
|
||||
return true;
|
||||
} else {
|
||||
resp_utf8.push_back(buffer);
|
||||
}
|
||||
auto result = ReadFile(_pipe, resp_utf8.data(), DWORD(resp_utf8.size()), &numBytesRead, NULL);
|
||||
if (!result) {
|
||||
// qWarning() << "Failed to read data from the pipe";
|
||||
return false;
|
||||
}
|
||||
if (numBytesRead <= 0) {
|
||||
return false;
|
||||
}
|
||||
_buffer.insert(_buffer.end(), resp_utf8.begin(), resp_utf8.begin()+numBytesRead);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,12 +20,13 @@
|
|||
#pragma warning (disable : 4251)
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <WinSock2.h>
|
||||
|
||||
class __declspec(dllexport) CommunicationSocket
|
||||
{
|
||||
public:
|
||||
CommunicationSocket(int port);
|
||||
CommunicationSocket();
|
||||
~CommunicationSocket();
|
||||
|
||||
bool Connect();
|
||||
|
@ -34,9 +35,11 @@ public:
|
|||
bool SendMsg(const wchar_t*);
|
||||
bool ReadLine(std::wstring*);
|
||||
|
||||
HANDLE Event() { return _pipe; }
|
||||
|
||||
private:
|
||||
int _port;
|
||||
SOCKET _clientSocket;
|
||||
HANDLE _pipe;
|
||||
std::vector<char> _buffer;
|
||||
};
|
||||
|
||||
#endif
|
|
@ -20,88 +20,120 @@
|
|||
#include <iostream>
|
||||
#include <sstream>
|
||||
#include <iterator>
|
||||
#include <unordered_set>
|
||||
#include <cassert>
|
||||
|
||||
#include <shlobj.h>
|
||||
|
||||
using namespace std;
|
||||
|
||||
RemotePathChecker::RemotePathChecker(int port)
|
||||
: _port(port)
|
||||
|
||||
// This code is run in a thread
|
||||
void RemotePathChecker::workerThreadLoop()
|
||||
{
|
||||
CommunicationSocket socket;
|
||||
std::unordered_set<std::wstring> asked;
|
||||
if (!socket.Connect()) {
|
||||
return;
|
||||
//FIXME! what if this fails! what if we are disconnected later?
|
||||
}
|
||||
|
||||
while(!_stop) {
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(_mutex);
|
||||
while (!_pending.empty() && !_stop) {
|
||||
auto filePath = _pending.front();
|
||||
_pending.pop();
|
||||
|
||||
lock.unlock();
|
||||
if (!asked.count(filePath)) {
|
||||
asked.insert(filePath);
|
||||
socket.SendMsg(wstring(L"RETRIEVE_FILE_STATUS:" + filePath + L'\n').data());
|
||||
}
|
||||
lock.lock();
|
||||
}
|
||||
}
|
||||
|
||||
std::wstring response;
|
||||
while (!_stop && socket.ReadLine(&response)) {
|
||||
if (StringUtil::begins_with(response, wstring(L"REGISTER_PATH:"))) {
|
||||
wstring responsePath = response.substr(14); // length of REGISTER_PATH:
|
||||
|
||||
std::unique_lock<std::mutex> lock(_mutex);
|
||||
_watchedDirectories.push_back(responsePath);
|
||||
} else if (StringUtil::begins_with(response, wstring(L"STATUS:")) ||
|
||||
StringUtil::begins_with(response, wstring(L"BROADCAST:"))) {
|
||||
|
||||
auto statusBegin = response.find(L':', 0);
|
||||
assert(statusBegin != std::wstring::npos);
|
||||
|
||||
auto statusEnd = response.find(L':', statusBegin + 1);
|
||||
if (statusEnd == std::wstring::npos) {
|
||||
// the command do not contains two colon?
|
||||
continue;
|
||||
}
|
||||
|
||||
auto responseStatus = response.substr(statusBegin+1, statusEnd - statusBegin-1);
|
||||
auto responsePath = response.substr(statusEnd+1);
|
||||
auto state = _StrToFileState(responseStatus);
|
||||
auto erased = asked.erase(responsePath);
|
||||
|
||||
{ std::unique_lock<std::mutex> lock(_mutex);
|
||||
_cache[responsePath] = state;
|
||||
}
|
||||
SHChangeNotify(SHCNE_MKDIR, SHCNF_PATH, responsePath.data(), NULL);
|
||||
}
|
||||
}
|
||||
|
||||
if (_stop)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
RemotePathChecker::RemotePathChecker()
|
||||
: _thread([this]{ this->workerThreadLoop(); } )
|
||||
, _newQueries(CreateEvent(NULL, true, true, NULL))
|
||||
{
|
||||
}
|
||||
|
||||
RemotePathChecker::~RemotePathChecker()
|
||||
{
|
||||
_stop = true;
|
||||
//_newQueries.notify_all();
|
||||
SetEvent(_newQueries);
|
||||
_thread.join();
|
||||
CloseHandle(_newQueries);
|
||||
}
|
||||
|
||||
vector<wstring> RemotePathChecker::WatchedDirectories()
|
||||
{
|
||||
vector<wstring> watchedDirectories;
|
||||
wstring response;
|
||||
bool needed = false;
|
||||
|
||||
CommunicationSocket socket(_port);
|
||||
socket.Connect();
|
||||
|
||||
while (socket.ReadLine(&response)) {
|
||||
if (StringUtil::begins_with(response, wstring(L"REGISTER_PATH:"))) {
|
||||
size_t pathBegin = response.find(L':', 0);
|
||||
if (pathBegin == -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// chop trailing '\n'
|
||||
wstring responsePath = response.substr(pathBegin + 1, response.length()-1);
|
||||
watchedDirectories.push_back(responsePath);
|
||||
}
|
||||
}
|
||||
|
||||
return watchedDirectories;
|
||||
std::unique_lock<std::mutex> lock(_mutex);
|
||||
return _watchedDirectories;
|
||||
}
|
||||
|
||||
bool RemotePathChecker::IsMonitoredPath(const wchar_t* filePath, int* state)
|
||||
{
|
||||
wstring request;
|
||||
wstring response;
|
||||
bool needed = false;
|
||||
assert(state); assert(filePath);
|
||||
|
||||
CommunicationSocket socket(_port);
|
||||
socket.Connect();
|
||||
request = L"RETRIEVE_FILE_STATUS:";
|
||||
request += filePath;
|
||||
request += L'\n';
|
||||
std::unique_lock<std::mutex> lock(_mutex);
|
||||
|
||||
if (!socket.SendMsg(request.c_str())) {
|
||||
return false;
|
||||
}
|
||||
auto path = std::wstring(filePath);
|
||||
|
||||
while (socket.ReadLine(&response)) {
|
||||
// discard broadcast messages
|
||||
if (StringUtil::begins_with(response, wstring(L"STATUS:"))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
auto it = _cache.find(path);
|
||||
if (it != _cache.end()) {
|
||||
*state = it->second;
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t statusBegin = response.find(L':', 0);
|
||||
if (statusBegin == -1)
|
||||
return false;
|
||||
_pending.push(filePath);
|
||||
SetEvent(_newQueries);
|
||||
return false;
|
||||
|
||||
size_t statusEnd = response.find(L':', statusBegin + 1);
|
||||
if (statusEnd == -1)
|
||||
return false;
|
||||
|
||||
|
||||
wstring responseStatus = response.substr(statusBegin+1, statusEnd - statusBegin-1);
|
||||
wstring responsePath = response.substr(statusEnd+1);
|
||||
if (responsePath == filePath) {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
*state = _StrToFileState(responseStatus);
|
||||
if (*state == StateNone) {
|
||||
return false;
|
||||
}
|
||||
needed = true;
|
||||
}
|
||||
|
||||
return needed;
|
||||
}
|
||||
|
||||
int RemotePathChecker::_StrToFileState(const std::wstring &str)
|
||||
RemotePathChecker::FileState RemotePathChecker::_StrToFileState(const std::wstring &str)
|
||||
{
|
||||
if (str == L"NOP" || str == L"NONE") {
|
||||
return StateNone;
|
||||
|
|
|
@ -16,6 +16,12 @@
|
|||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <queue>
|
||||
#include <thread>
|
||||
#include <mutex>
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
|
||||
#pragma once
|
||||
|
||||
|
@ -29,14 +35,32 @@ public:
|
|||
StateWarning, StateWarningSWM,
|
||||
StateNone
|
||||
};
|
||||
RemotePathChecker(int port);
|
||||
RemotePathChecker();
|
||||
~RemotePathChecker();
|
||||
std::vector<std::wstring> WatchedDirectories();
|
||||
bool IsMonitoredPath(const wchar_t* filePath, int* state);
|
||||
|
||||
private:
|
||||
int _StrToFileState(const std::wstring &str);
|
||||
int _port;
|
||||
FileState _StrToFileState(const std::wstring &str);
|
||||
std::mutex _mutex;
|
||||
std::thread _thread;
|
||||
std::atomic<bool> _stop;
|
||||
|
||||
// Everything here is protected by the _mutex
|
||||
|
||||
/** The list of paths we need to query. The main thread fill this, and the worker thread
|
||||
* send that to the socket. */
|
||||
std::queue<std::wstring> _pending;
|
||||
|
||||
std::unordered_map<std::wstring, FileState> _cache;
|
||||
std::vector<std::wstring> _watchedDirectories;
|
||||
|
||||
|
||||
// The main thread notifies when there are new items in _pending
|
||||
//std::condition_variable _newQueries;
|
||||
HANDLE _newQueries;
|
||||
|
||||
void workerThreadLoop();
|
||||
};
|
||||
|
||||
#endif
|
|
@ -11,22 +11,26 @@
|
|||
* details.
|
||||
*/
|
||||
|
||||
#include <Windows.h>
|
||||
#include <locale>
|
||||
#include <string>
|
||||
#include <codecvt>
|
||||
|
||||
#include "StringUtil.h"
|
||||
|
||||
char* StringUtil::toUtf8(const wchar_t *utf16, int len)
|
||||
std::string StringUtil::toUtf8(const wchar_t *utf16, int len)
|
||||
{
|
||||
int newlen = WideCharToMultiByte(CP_UTF8, 0, utf16, len, NULL, 0, NULL, NULL);
|
||||
char* str = new char[newlen];
|
||||
WideCharToMultiByte(CP_UTF8, 0, utf16, -1, str, newlen, NULL, NULL);
|
||||
return str;
|
||||
if (len < 0) {
|
||||
len = wcslen(utf16);
|
||||
}
|
||||
std::wstring_convert<std::codecvt_utf8_utf16<wchar_t> > converter;
|
||||
return converter.to_bytes(utf16, utf16+len);
|
||||
}
|
||||
|
||||
wchar_t* StringUtil::toUtf16(const char *utf8, int len)
|
||||
std::wstring StringUtil::toUtf16(const char *utf8, int len)
|
||||
{
|
||||
int newlen = MultiByteToWideChar(CP_UTF8, 0, utf8, len, NULL, 0);
|
||||
wchar_t* wstr = new wchar_t[newlen];
|
||||
MultiByteToWideChar(CP_UTF8, 0, utf8, -1, wstr, newlen);
|
||||
return wstr;
|
||||
if (len < 0) {
|
||||
len = strlen(utf8);
|
||||
}
|
||||
std::wstring_convert<std::codecvt_utf8_utf16<wchar_t> > converter;
|
||||
return converter.from_bytes(utf8, utf8+len);
|
||||
}
|
||||
|
|
|
@ -20,15 +20,14 @@
|
|||
|
||||
class __declspec(dllexport) StringUtil {
|
||||
public:
|
||||
static char* toUtf8(const wchar_t* utf16, int len = -1);
|
||||
static wchar_t* toUtf16(const char* utf8, int len = -1);
|
||||
|
||||
static std::string toUtf8(const wchar_t* utf16, int len = -1);
|
||||
static std::wstring toUtf16(const char* utf8, int len = -1);
|
||||
|
||||
template<class T>
|
||||
static bool begins_with(const T& input, const T& match)
|
||||
{
|
||||
return input.size() >= match.size()
|
||||
&& equal(match.begin(), match.end(), input.begin());
|
||||
&& std::equal(match.begin(), match.end(), input.begin());
|
||||
}
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in a new issue