mirror of
https://github.com/cemu-project/Cemu.git
synced 2025-07-06 15:01:18 +12:00
PairingDialog: Implement 'pairing' for bluez, reformat file
This commit is contained in:
parent
0d4176aab0
commit
00ff45d66b
3 changed files with 214 additions and 168 deletions
|
@ -4,233 +4,279 @@
|
||||||
#if BOOST_OS_WINDOWS
|
#if BOOST_OS_WINDOWS
|
||||||
#include <bluetoothapis.h>
|
#include <bluetoothapis.h>
|
||||||
#endif
|
#endif
|
||||||
|
#if BOOST_OS_LINUX
|
||||||
|
#include <bluetooth/bluetooth.h>
|
||||||
|
#include <bluetooth/hci.h>
|
||||||
|
#include <bluetooth/hci_lib.h>
|
||||||
|
#include <input/api/Wiimote/l2cap/L2CapWiimote.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
wxDECLARE_EVENT(wxEVT_PROGRESS_PAIR, wxCommandEvent);
|
wxDECLARE_EVENT(wxEVT_PROGRESS_PAIR, wxCommandEvent);
|
||||||
wxDEFINE_EVENT(wxEVT_PROGRESS_PAIR, wxCommandEvent);
|
wxDEFINE_EVENT(wxEVT_PROGRESS_PAIR, wxCommandEvent);
|
||||||
|
|
||||||
PairingDialog::PairingDialog(wxWindow* parent)
|
PairingDialog::PairingDialog(wxWindow* parent)
|
||||||
: wxDialog(parent, wxID_ANY, _("Pairing..."), wxDefaultPosition, wxDefaultSize, wxCAPTION | wxMINIMIZE_BOX | wxSYSTEM_MENU | wxTAB_TRAVERSAL | wxCLOSE_BOX)
|
: wxDialog(parent, wxID_ANY, _("Pairing..."), wxDefaultPosition, wxDefaultSize, wxCAPTION | wxMINIMIZE_BOX | wxSYSTEM_MENU | wxTAB_TRAVERSAL | wxCLOSE_BOX)
|
||||||
{
|
{
|
||||||
auto* sizer = new wxBoxSizer(wxVERTICAL);
|
auto* sizer = new wxBoxSizer(wxVERTICAL);
|
||||||
m_gauge = new wxGauge(this, wxID_ANY, 100, wxDefaultPosition, wxSize(350, 20), wxGA_HORIZONTAL);
|
m_gauge = new wxGauge(this, wxID_ANY, 100, wxDefaultPosition, wxSize(350, 20), wxGA_HORIZONTAL);
|
||||||
m_gauge->SetValue(0);
|
m_gauge->SetValue(0);
|
||||||
sizer->Add(m_gauge, 0, wxALL | wxEXPAND, 5);
|
sizer->Add(m_gauge, 0, wxALL | wxEXPAND, 5);
|
||||||
|
|
||||||
auto* rows = new wxFlexGridSizer(0, 2, 0, 0);
|
auto* rows = new wxFlexGridSizer(0, 2, 0, 0);
|
||||||
rows->AddGrowableCol(1);
|
rows->AddGrowableCol(1);
|
||||||
|
|
||||||
m_text = new wxStaticText(this, wxID_ANY, _("Searching for controllers..."));
|
m_text = new wxStaticText(this, wxID_ANY, _("Searching for controllers..."));
|
||||||
rows->Add(m_text, 0, wxALL | wxALIGN_CENTER_VERTICAL, 5);
|
rows->Add(m_text, 0, wxALL | wxALIGN_CENTER_VERTICAL, 5);
|
||||||
|
|
||||||
{
|
{
|
||||||
auto* right_side = new wxBoxSizer(wxHORIZONTAL);
|
auto* right_side = new wxBoxSizer(wxHORIZONTAL);
|
||||||
|
|
||||||
m_cancelButton = new wxButton(this, wxID_ANY, _("Cancel"));
|
m_cancelButton = new wxButton(this, wxID_ANY, _("Cancel"));
|
||||||
m_cancelButton->Bind(wxEVT_BUTTON, &PairingDialog::OnCancelButton, this);
|
m_cancelButton->Bind(wxEVT_BUTTON, &PairingDialog::OnCancelButton, this);
|
||||||
right_side->Add(m_cancelButton, 0, wxALL, 5);
|
right_side->Add(m_cancelButton, 0, wxALL, 5);
|
||||||
|
|
||||||
rows->Add(right_side, 1, wxALIGN_RIGHT, 5);
|
rows->Add(right_side, 1, wxALIGN_RIGHT, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
sizer->Add(rows, 0, wxALL | wxEXPAND, 5);
|
sizer->Add(rows, 0, wxALL | wxEXPAND, 5);
|
||||||
|
|
||||||
SetSizerAndFit(sizer);
|
SetSizerAndFit(sizer);
|
||||||
Centre(wxBOTH);
|
Centre(wxBOTH);
|
||||||
|
|
||||||
Bind(wxEVT_CLOSE_WINDOW, &PairingDialog::OnClose, this);
|
Bind(wxEVT_CLOSE_WINDOW, &PairingDialog::OnClose, this);
|
||||||
Bind(wxEVT_PROGRESS_PAIR, &PairingDialog::OnGaugeUpdate, this);
|
Bind(wxEVT_PROGRESS_PAIR, &PairingDialog::OnGaugeUpdate, this);
|
||||||
|
|
||||||
m_thread = std::thread(&PairingDialog::WorkerThread, this);
|
m_thread = std::thread(&PairingDialog::WorkerThread, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
PairingDialog::~PairingDialog()
|
PairingDialog::~PairingDialog()
|
||||||
{
|
{
|
||||||
Unbind(wxEVT_CLOSE_WINDOW, &PairingDialog::OnClose, this);
|
Unbind(wxEVT_CLOSE_WINDOW, &PairingDialog::OnClose, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
void PairingDialog::OnClose(wxCloseEvent& event)
|
void PairingDialog::OnClose(wxCloseEvent& event)
|
||||||
{
|
{
|
||||||
event.Skip();
|
event.Skip();
|
||||||
|
|
||||||
m_threadShouldQuit = true;
|
m_threadShouldQuit = true;
|
||||||
if (m_thread.joinable())
|
if (m_thread.joinable())
|
||||||
m_thread.join();
|
m_thread.join();
|
||||||
}
|
}
|
||||||
|
|
||||||
void PairingDialog::OnCancelButton(const wxCommandEvent& event)
|
void PairingDialog::OnCancelButton(const wxCommandEvent& event)
|
||||||
{
|
{
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
void PairingDialog::OnGaugeUpdate(wxCommandEvent& event)
|
void PairingDialog::OnGaugeUpdate(wxCommandEvent& event)
|
||||||
{
|
{
|
||||||
PairingState state = (PairingState)event.GetInt();
|
PairingState state = (PairingState)event.GetInt();
|
||||||
|
|
||||||
switch (state)
|
switch (state)
|
||||||
{
|
{
|
||||||
case PairingState::Pairing:
|
case PairingState::Pairing:
|
||||||
{
|
{
|
||||||
m_text->SetLabel(_("Found controller. Pairing..."));
|
m_text->SetLabel(_("Found controller. Pairing..."));
|
||||||
m_gauge->SetValue(50);
|
m_gauge->SetValue(50);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case PairingState::Finished:
|
case PairingState::Finished:
|
||||||
{
|
{
|
||||||
m_text->SetLabel(_("Successfully paired the controller."));
|
m_text->SetLabel(_("Successfully paired the controller."));
|
||||||
m_gauge->SetValue(100);
|
m_gauge->SetValue(100);
|
||||||
m_cancelButton->SetLabel(_("Close"));
|
m_cancelButton->SetLabel(_("Close"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case PairingState::NoBluetoothAvailable:
|
case PairingState::NoBluetoothAvailable:
|
||||||
{
|
{
|
||||||
m_text->SetLabel(_("Failed to find a suitable Bluetooth radio."));
|
m_text->SetLabel(_("Failed to find a suitable Bluetooth radio."));
|
||||||
m_gauge->SetValue(0);
|
m_gauge->SetValue(0);
|
||||||
m_cancelButton->SetLabel(_("Close"));
|
m_cancelButton->SetLabel(_("Close"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case PairingState::BluetoothFailed:
|
case PairingState::SearchFailed:
|
||||||
{
|
{
|
||||||
m_text->SetLabel(_("Failed to search for controllers."));
|
m_text->SetLabel(_("Failed to find controllers."));
|
||||||
m_gauge->SetValue(0);
|
m_gauge->SetValue(0);
|
||||||
m_cancelButton->SetLabel(_("Close"));
|
m_cancelButton->SetLabel(_("Close"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case PairingState::PairingFailed:
|
case PairingState::PairingFailed:
|
||||||
{
|
{
|
||||||
m_text->SetLabel(_("Failed to pair with the found controller."));
|
m_text->SetLabel(_("Failed to pair with the found controller."));
|
||||||
m_gauge->SetValue(0);
|
m_gauge->SetValue(0);
|
||||||
m_cancelButton->SetLabel(_("Close"));
|
m_cancelButton->SetLabel(_("Close"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case PairingState::BluetoothUnusable:
|
case PairingState::BluetoothUnusable:
|
||||||
{
|
{
|
||||||
m_text->SetLabel(_("Please use your system's Bluetooth manager instead."));
|
m_text->SetLabel(_("Please use your system's Bluetooth manager instead."));
|
||||||
m_gauge->SetValue(0);
|
m_gauge->SetValue(0);
|
||||||
m_cancelButton->SetLabel(_("Close"));
|
m_cancelButton->SetLabel(_("Close"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
default:
|
{
|
||||||
{
|
break;
|
||||||
break;
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void PairingDialog::WorkerThread()
|
|
||||||
{
|
|
||||||
const std::wstring wiimoteName = L"Nintendo RVL-CNT-01";
|
|
||||||
const std::wstring wiiUProControllerName = L"Nintendo RVL-CNT-01-UC";
|
|
||||||
|
|
||||||
#if BOOST_OS_WINDOWS
|
#if BOOST_OS_WINDOWS
|
||||||
const GUID bthHidGuid = {0x00001124,0x0000,0x1000,{0x80,0x00,0x00,0x80,0x5F,0x9B,0x34,0xFB}};
|
void PairingDialog::WorkerThread()
|
||||||
|
{
|
||||||
|
const std::wstring wiimoteName = L"Nintendo RVL-CNT-01";
|
||||||
|
const std::wstring wiiUProControllerName = L"Nintendo RVL-CNT-01-UC";
|
||||||
|
|
||||||
const BLUETOOTH_FIND_RADIO_PARAMS radioFindParams =
|
const GUID bthHidGuid = {0x00001124, 0x0000, 0x1000, {0x80, 0x00, 0x00, 0x80, 0x5F, 0x9B, 0x34, 0xFB}};
|
||||||
{
|
|
||||||
.dwSize = sizeof(BLUETOOTH_FIND_RADIO_PARAMS)
|
|
||||||
};
|
|
||||||
|
|
||||||
HANDLE radio = INVALID_HANDLE_VALUE;
|
const BLUETOOTH_FIND_RADIO_PARAMS radioFindParams =
|
||||||
HBLUETOOTH_RADIO_FIND radioFind = BluetoothFindFirstRadio(&radioFindParams, &radio);
|
{
|
||||||
if (radioFind == nullptr)
|
.dwSize = sizeof(BLUETOOTH_FIND_RADIO_PARAMS)};
|
||||||
{
|
|
||||||
UpdateCallback(PairingState::NoBluetoothAvailable);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
BluetoothFindRadioClose(radioFind);
|
HANDLE radio = INVALID_HANDLE_VALUE;
|
||||||
|
HBLUETOOTH_RADIO_FIND radioFind = BluetoothFindFirstRadio(&radioFindParams, &radio);
|
||||||
|
if (radioFind == nullptr)
|
||||||
|
{
|
||||||
|
UpdateCallback(PairingState::NoBluetoothAvailable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
BLUETOOTH_RADIO_INFO radioInfo =
|
BluetoothFindRadioClose(radioFind);
|
||||||
{
|
|
||||||
.dwSize = sizeof(BLUETOOTH_RADIO_INFO)
|
|
||||||
};
|
|
||||||
|
|
||||||
DWORD result = BluetoothGetRadioInfo(radio, &radioInfo);
|
BLUETOOTH_RADIO_INFO radioInfo =
|
||||||
if (result != ERROR_SUCCESS)
|
{
|
||||||
{
|
.dwSize = sizeof(BLUETOOTH_RADIO_INFO)};
|
||||||
UpdateCallback(PairingState::NoBluetoothAvailable);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BLUETOOTH_DEVICE_SEARCH_PARAMS searchParams =
|
DWORD result = BluetoothGetRadioInfo(radio, &radioInfo);
|
||||||
{
|
if (result != ERROR_SUCCESS)
|
||||||
.dwSize = sizeof(BLUETOOTH_DEVICE_SEARCH_PARAMS),
|
{
|
||||||
|
UpdateCallback(PairingState::NoBluetoothAvailable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
.fReturnAuthenticated = FALSE,
|
const BLUETOOTH_DEVICE_SEARCH_PARAMS searchParams =
|
||||||
.fReturnRemembered = FALSE,
|
{
|
||||||
.fReturnUnknown = TRUE,
|
.dwSize = sizeof(BLUETOOTH_DEVICE_SEARCH_PARAMS),
|
||||||
.fReturnConnected = FALSE,
|
|
||||||
|
|
||||||
.fIssueInquiry = TRUE,
|
.fReturnAuthenticated = FALSE,
|
||||||
.cTimeoutMultiplier = 5,
|
.fReturnRemembered = FALSE,
|
||||||
|
.fReturnUnknown = TRUE,
|
||||||
|
.fReturnConnected = FALSE,
|
||||||
|
|
||||||
.hRadio = radio
|
.fIssueInquiry = TRUE,
|
||||||
};
|
.cTimeoutMultiplier = 5,
|
||||||
|
|
||||||
BLUETOOTH_DEVICE_INFO info =
|
.hRadio = radio};
|
||||||
{
|
|
||||||
.dwSize = sizeof(BLUETOOTH_DEVICE_INFO)
|
|
||||||
};
|
|
||||||
|
|
||||||
while (!m_threadShouldQuit)
|
BLUETOOTH_DEVICE_INFO info =
|
||||||
{
|
{
|
||||||
HBLUETOOTH_DEVICE_FIND deviceFind = BluetoothFindFirstDevice(&searchParams, &info);
|
.dwSize = sizeof(BLUETOOTH_DEVICE_INFO)};
|
||||||
if (deviceFind == nullptr)
|
|
||||||
{
|
|
||||||
UpdateCallback(PairingState::BluetoothFailed);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (!m_threadShouldQuit)
|
while (!m_threadShouldQuit)
|
||||||
{
|
{
|
||||||
if (info.szName == wiimoteName || info.szName == wiiUProControllerName)
|
HBLUETOOTH_DEVICE_FIND deviceFind = BluetoothFindFirstDevice(&searchParams, &info);
|
||||||
{
|
if (deviceFind == nullptr)
|
||||||
BluetoothFindDeviceClose(deviceFind);
|
{
|
||||||
|
UpdateCallback(PairingState::BluetoothFailed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
UpdateCallback(PairingState::Pairing);
|
while (!m_threadShouldQuit)
|
||||||
|
{
|
||||||
|
if (info.szName == wiimoteName || info.szName == wiiUProControllerName)
|
||||||
|
{
|
||||||
|
BluetoothFindDeviceClose(deviceFind);
|
||||||
|
|
||||||
wchar_t passwd[6] = { radioInfo.address.rgBytes[0], radioInfo.address.rgBytes[1], radioInfo.address.rgBytes[2], radioInfo.address.rgBytes[3], radioInfo.address.rgBytes[4], radioInfo.address.rgBytes[5] };
|
UpdateCallback(PairingState::Pairing);
|
||||||
DWORD bthResult = BluetoothAuthenticateDevice(nullptr, radio, &info, passwd, 6);
|
|
||||||
if (bthResult != ERROR_SUCCESS)
|
|
||||||
{
|
|
||||||
UpdateCallback(PairingState::PairingFailed);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
bthResult = BluetoothSetServiceState(radio, &info, &bthHidGuid, BLUETOOTH_SERVICE_ENABLE);
|
wchar_t passwd[6] = {radioInfo.address.rgBytes[0], radioInfo.address.rgBytes[1], radioInfo.address.rgBytes[2], radioInfo.address.rgBytes[3], radioInfo.address.rgBytes[4], radioInfo.address.rgBytes[5]};
|
||||||
if (bthResult != ERROR_SUCCESS)
|
DWORD bthResult = BluetoothAuthenticateDevice(nullptr, radio, &info, passwd, 6);
|
||||||
{
|
if (bthResult != ERROR_SUCCESS)
|
||||||
UpdateCallback(PairingState::PairingFailed);
|
{
|
||||||
return;
|
UpdateCallback(PairingState::PairingFailed);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
UpdateCallback(PairingState::Finished);
|
bthResult = BluetoothSetServiceState(radio, &info, &bthHidGuid, BLUETOOTH_SERVICE_ENABLE);
|
||||||
return;
|
if (bthResult != ERROR_SUCCESS)
|
||||||
}
|
{
|
||||||
|
UpdateCallback(PairingState::PairingFailed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
BOOL nextDevResult = BluetoothFindNextDevice(deviceFind, &info);
|
UpdateCallback(PairingState::Finished);
|
||||||
if (nextDevResult == FALSE)
|
return;
|
||||||
{
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BluetoothFindDeviceClose(deviceFind);
|
BOOL nextDevResult = BluetoothFindNextDevice(deviceFind, &info);
|
||||||
}
|
if (nextDevResult == FALSE)
|
||||||
#else
|
{
|
||||||
UpdateCallback(PairingState::BluetoothUnusable);
|
break;
|
||||||
#endif
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BluetoothFindDeviceClose(deviceFind);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
#elif BOOST_OS_LINUX
|
||||||
|
void PairingDialog::WorkerThread()
|
||||||
|
{
|
||||||
|
constexpr static uint8_t liacLap[] = {0x00, 0x8b, 0x9e};
|
||||||
|
|
||||||
|
constexpr static auto isWiimoteName = [](std::string_view name) {
|
||||||
|
return name == "Nintendo RVL-CNT-01" || name == "Nintendo RVL-CNT-01-TR";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get default BT device
|
||||||
|
const auto hostId = hci_get_route(nullptr);
|
||||||
|
if (hostId < 0)
|
||||||
|
{
|
||||||
|
UpdateCallback(PairingState::NoBluetoothAvailable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for device
|
||||||
|
inquiry_info* info = nullptr;
|
||||||
|
const auto respCount = hci_inquiry(hostId, 5, 1, liacLap, &info, IREQ_CACHE_FLUSH);
|
||||||
|
if (respCount <= 0)
|
||||||
|
{
|
||||||
|
UpdateCallback(PairingState::SearchFailed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//! Open dev to read name
|
||||||
|
const auto hostDesc = hci_open_dev(hostId);
|
||||||
|
char nameBuffer[HCI_MAX_NAME_LENGTH] = {};
|
||||||
|
|
||||||
|
// Get device name and compare. Would use product and vendor id from SDP, but many third-party Wiimotes don't store them
|
||||||
|
auto& addr = info->bdaddr;
|
||||||
|
if (hci_read_remote_name(hostDesc, &addr, HCI_MAX_NAME_LENGTH, nameBuffer,
|
||||||
|
2000) != 0 || !isWiimoteName(nameBuffer))
|
||||||
|
{
|
||||||
|
UpdateCallback(PairingState::SearchFailed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cemuLog_log(LogType::Force, "Pairing Dialog: Found '{}' with address {:02x}", nameBuffer, fmt::join(addr.b, ":"));
|
||||||
|
|
||||||
|
UpdateCallback(PairingState::Finished);
|
||||||
|
L2CapWiimote::AddCandidateAddress(addr);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
void PairingDialog::WorkerThread()
|
||||||
|
{
|
||||||
|
UpdateCallback(PairingState::BluetoothUnusable);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
void PairingDialog::UpdateCallback(PairingState state)
|
void PairingDialog::UpdateCallback(PairingState state)
|
||||||
{
|
{
|
||||||
auto* event = new wxCommandEvent(wxEVT_PROGRESS_PAIR);
|
auto* event = new wxCommandEvent(wxEVT_PROGRESS_PAIR);
|
||||||
event->SetInt((int)state);
|
event->SetInt((int)state);
|
||||||
wxQueueEvent(this, event);
|
wxQueueEvent(this, event);
|
||||||
}
|
}
|
|
@ -17,7 +17,7 @@ private:
|
||||||
Pairing,
|
Pairing,
|
||||||
Finished,
|
Finished,
|
||||||
NoBluetoothAvailable,
|
NoBluetoothAvailable,
|
||||||
BluetoothFailed,
|
SearchFailed,
|
||||||
PairingFailed,
|
PairingFailed,
|
||||||
BluetoothUnusable
|
BluetoothUnusable
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <api/Wiimote/WiimoteDevice.h>
|
#include <input/api/Wiimote/WiimoteDevice.h>
|
||||||
#include <bluetooth/bluetooth.h>
|
#include <bluetooth/bluetooth.h>
|
||||||
|
|
||||||
class L2CapWiimote : public WiimoteDevice
|
class L2CapWiimote : public WiimoteDevice
|
||||||
|
@ -12,7 +12,7 @@ class L2CapWiimote : public WiimoteDevice
|
||||||
std::optional<std::vector<uint8>> read_data() override;
|
std::optional<std::vector<uint8>> read_data() override;
|
||||||
bool operator==(WiimoteDevice& o) const override;
|
bool operator==(WiimoteDevice& o) const override;
|
||||||
|
|
||||||
static void AddCandidateAddresses(const std::vector<bdaddr_t>& addrs);
|
static void AddCandidateAddress(bdaddr_t addr);
|
||||||
static std::vector<WiimoteDevicePtr> get_devices();
|
static std::vector<WiimoteDevicePtr> get_devices();
|
||||||
private:
|
private:
|
||||||
int m_recvFd;
|
int m_recvFd;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue