Wxwidgets: Plugin manager
This commit is contained in:
parent
f4eb4e3073
commit
0d39cfc692
11 changed files with 387 additions and 7 deletions
|
@ -4,7 +4,7 @@
|
|||
#include "library/loadlib.hpp"
|
||||
|
||||
void handle_post_loadlibrary();
|
||||
void autoload_libraries();
|
||||
void autoload_libraries(void(*on_error)(const std::string& err) = NULL);
|
||||
void with_loaded_library(const loadlib::module& l);
|
||||
|
||||
#endif
|
||||
|
|
|
@ -8,6 +8,7 @@ std::set<std::string> enumerate_directory(const std::string& dir, const std::str
|
|||
std::string get_absolute_path(const std::string& relative);
|
||||
uintmax_t file_get_size(const std::string& path);
|
||||
time_t file_get_mtime(const std::string& path);
|
||||
bool file_exists(const std::string& filename);
|
||||
bool file_is_regular(const std::string& filename);
|
||||
bool file_is_directory(const std::string& filename);
|
||||
bool ensure_directory_exists(const std::string& path);
|
||||
|
|
|
@ -63,6 +63,7 @@ void wxeditor_tasinput_display(wxWindow* parent);
|
|||
void wxeditor_macro_display(wxWindow* parent);
|
||||
void wxeditor_hexedit_display(wxWindow* parent);
|
||||
void wxeditor_multitrack_display(wxWindow* parent);
|
||||
bool wxeditor_plugin_manager_display(wxWindow* parent);
|
||||
|
||||
//Auxillary windows.
|
||||
void wxwindow_memorysearch_display();
|
||||
|
|
|
@ -227,6 +227,15 @@ Load <file> as movie or savestate file on startup.
|
|||
Instead of starting the emulator, only display the settings.
|
||||
\end_layout
|
||||
|
||||
\begin_layout Subsubsection
|
||||
--pluginmanager
|
||||
\end_layout
|
||||
|
||||
\begin_layout Standard
|
||||
Instead of starting the emulator, display the plugin manager (useful to
|
||||
disable some plugin that causes emulator to crash on startup)
|
||||
\end_layout
|
||||
|
||||
\begin_layout Subsubsection
|
||||
--lua=<file>
|
||||
\end_layout
|
||||
|
|
|
@ -83,7 +83,13 @@ Load <file> as movie or savestate file on startup.
|
|||
|
||||
Instead of starting the emulator, only display the settings.
|
||||
|
||||
4.1.5 --lua=<file>
|
||||
4.1.5 --pluginmanager
|
||||
|
||||
Instead of starting the emulator, display the plugin manager
|
||||
(useful to disable some plugin that causes emulator to crash on
|
||||
startup)
|
||||
|
||||
4.1.6 --lua=<file>
|
||||
|
||||
Run this Lua file on startup
|
||||
|
||||
|
|
|
@ -29,12 +29,28 @@ void with_loaded_library(const loadlib::module& l)
|
|||
}
|
||||
}
|
||||
|
||||
void autoload_libraries()
|
||||
void autoload_libraries(void(*on_error)(const std::string& err))
|
||||
{
|
||||
try {
|
||||
std::string extension = loadlib::library::extension();
|
||||
auto libs = enumerate_directory(get_config_path() + "/autoload", ".*");
|
||||
for(auto i : libs)
|
||||
with_loaded_library(*new loadlib::module(loadlib::library(i)));
|
||||
for(auto i : libs) {
|
||||
if(i.length() < extension.length() + 1)
|
||||
continue;
|
||||
if(i[i.length() - extension.length() - 1] != '.')
|
||||
continue;
|
||||
std::string tmp = i;
|
||||
if(tmp.substr(i.length() - extension.length()) != extension)
|
||||
continue;
|
||||
try {
|
||||
with_loaded_library(*new loadlib::module(loadlib::library(i)));
|
||||
} catch(std::exception& e) {
|
||||
std::string x = "Can't load '" + i + "': " + e.what();
|
||||
if(on_error)
|
||||
on_error(x);
|
||||
messages << x << std::endl;
|
||||
}
|
||||
}
|
||||
handle_post_loadlibrary();
|
||||
} catch(std::exception& e) {
|
||||
messages << e.what() << std::endl;
|
||||
|
|
|
@ -41,6 +41,12 @@ time_t file_get_mtime(const std::string& path)
|
|||
return boost_fs::last_write_time(boost_fs::path(path));
|
||||
}
|
||||
|
||||
bool file_exists(const std::string& filename)
|
||||
{
|
||||
boost::system::error_code ec;
|
||||
return boost_fs::exists(boost_fs::path(filename), ec);
|
||||
}
|
||||
|
||||
bool file_is_regular(const std::string& filename)
|
||||
{
|
||||
boost::system::error_code ec;
|
||||
|
|
|
@ -23,7 +23,7 @@ namespace zip
|
|||
int rename_overwrite(const char* oldname, const char* newname)
|
||||
{
|
||||
#if defined(_WIN32) || defined(_WIN64) || defined(TEST_WIN32_CODE)
|
||||
return MoveFileEx(oldname, newname, MOVEFILE_REPLACE_EXISTING);
|
||||
return MoveFileEx(oldname, newname, MOVEFILE_REPLACE_EXISTING) ? 0 : -1;
|
||||
#else
|
||||
return rename(oldname, newname);
|
||||
#endif
|
||||
|
|
326
src/platform/wxwidgets/editor-plugin.cpp
Normal file
326
src/platform/wxwidgets/editor-plugin.cpp
Normal file
|
@ -0,0 +1,326 @@
|
|||
#include <wx/wx.h>
|
||||
#include <wx/event.h>
|
||||
#include <wx/control.h>
|
||||
#include <wx/combobox.h>
|
||||
#include <wx/radiobut.h>
|
||||
#include "platform/wxwidgets/platform.hpp"
|
||||
#include "platform/wxwidgets/loadsave.hpp"
|
||||
#include "core/misc.hpp"
|
||||
#include "core/window.hpp"
|
||||
#include "library/directory.hpp"
|
||||
#include "library/loadlib.hpp"
|
||||
#include "library/string.hpp"
|
||||
#include "library/zip.hpp"
|
||||
#include <iostream>
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#if defined(_WIN32) || defined(_WIN64)
|
||||
#else
|
||||
#include <sys/stat.h>
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
std::string get_name(std::string path)
|
||||
{
|
||||
#if defined(_WIN32) || defined(_WIN64)
|
||||
const char* sep = "\\/";
|
||||
#else
|
||||
const char* sep = "/";
|
||||
#endif
|
||||
size_t p = path.find_last_of(sep);
|
||||
std::string name;
|
||||
if(p == std::string::npos)
|
||||
name = path;
|
||||
else
|
||||
name = path.substr(p + 1);
|
||||
return name;
|
||||
}
|
||||
|
||||
std::string strip_extension(std::string tmp, std::string ext)
|
||||
{
|
||||
regex_results r = regex("(.*)\\." + ext + "(|\\.disabled)", tmp);
|
||||
if(!r) return tmp;
|
||||
return r[1];
|
||||
}
|
||||
}
|
||||
|
||||
class wxeditor_plugins : public wxDialog
|
||||
{
|
||||
public:
|
||||
wxeditor_plugins(wxWindow* parent);
|
||||
void on_selection_change(wxCommandEvent& e);
|
||||
void on_add(wxCommandEvent& e);
|
||||
void on_rename(wxCommandEvent& e);
|
||||
void on_enable(wxCommandEvent& e);
|
||||
void on_delete(wxCommandEvent& e);
|
||||
void on_start(wxCommandEvent& e);
|
||||
void on_close(wxCommandEvent& e);
|
||||
private:
|
||||
void reload_plugins();
|
||||
wxListBox* plugins;
|
||||
wxButton* addbutton;
|
||||
wxButton* renamebutton;
|
||||
wxButton* enablebutton;
|
||||
wxButton* deletebutton;
|
||||
wxButton* startbutton;
|
||||
wxButton* closebutton;
|
||||
std::vector<std::pair<std::string, bool>> pluginstbl;
|
||||
std::string extension;
|
||||
std::string pathpfx;
|
||||
};
|
||||
|
||||
wxeditor_plugins::wxeditor_plugins(wxWindow* parent)
|
||||
: wxDialog(parent, wxID_ANY, wxT("lsnes: Plugin manager"), wxDefaultPosition, wxSize(-1, -1))
|
||||
{
|
||||
Center();
|
||||
wxFlexGridSizer* top_s = new wxFlexGridSizer(2, 1, 0, 0);
|
||||
SetSizer(top_s);
|
||||
pathpfx = get_config_path() + "/autoload";
|
||||
extension = loadlib::library::extension();
|
||||
|
||||
top_s->Add(plugins = new wxListBox(this, wxID_ANY, wxDefaultPosition, wxSize(400, 300)), 1, wxGROW);
|
||||
plugins->Connect(wxEVT_COMMAND_LISTBOX_SELECTED,
|
||||
wxCommandEventHandler(wxeditor_plugins::on_selection_change), NULL, this);
|
||||
|
||||
wxBoxSizer* pbutton_s = new wxBoxSizer(wxHORIZONTAL);
|
||||
pbutton_s->Add(addbutton = new wxButton(this, wxID_ANY, wxT("Add")), 0, wxGROW);
|
||||
pbutton_s->Add(renamebutton = new wxButton(this, wxID_ANY, wxT("Rename")), 0, wxGROW);
|
||||
pbutton_s->Add(enablebutton = new wxButton(this, wxID_ANY, wxT("Enable")), 0, wxGROW);
|
||||
pbutton_s->Add(deletebutton = new wxButton(this, wxID_ANY, wxT("Delete")), 0, wxGROW);
|
||||
pbutton_s->AddStretchSpacer();
|
||||
if(!parent)
|
||||
pbutton_s->Add(startbutton = new wxButton(this, wxID_ANY, wxT("Start")), 0, wxGROW);
|
||||
else
|
||||
startbutton = NULL;
|
||||
if(!parent)
|
||||
pbutton_s->Add(closebutton = new wxButton(this, wxID_EXIT, wxT("Quit")), 0, wxGROW);
|
||||
else
|
||||
pbutton_s->Add(closebutton = new wxButton(this, wxID_ANY, wxT("Close")), 0, wxGROW);
|
||||
addbutton->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(wxeditor_plugins::on_add), NULL,
|
||||
this);
|
||||
renamebutton->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(wxeditor_plugins::on_rename), NULL,
|
||||
this);
|
||||
enablebutton->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(wxeditor_plugins::on_enable), NULL,
|
||||
this);
|
||||
deletebutton->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(wxeditor_plugins::on_delete), NULL,
|
||||
this);
|
||||
if(startbutton)
|
||||
startbutton->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(wxeditor_plugins::on_start),
|
||||
NULL, this);
|
||||
closebutton->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(wxeditor_plugins::on_close), NULL,
|
||||
this);
|
||||
top_s->Add(pbutton_s, 0, wxGROW);
|
||||
reload_plugins();
|
||||
wxCommandEvent e;
|
||||
on_selection_change(e);
|
||||
Fit();
|
||||
}
|
||||
|
||||
void wxeditor_plugins::reload_plugins()
|
||||
{
|
||||
int sel = plugins->GetSelection();
|
||||
std::string name;
|
||||
if(sel == wxNOT_FOUND || sel >= pluginstbl.size())
|
||||
name = "";
|
||||
else
|
||||
name = pluginstbl[sel].first;
|
||||
|
||||
auto dir = enumerate_directory(pathpfx, ".*\\." + extension + "(|\\.disabled)");
|
||||
plugins->Clear();
|
||||
pluginstbl.clear();
|
||||
for(auto i : dir) {
|
||||
regex_results r = regex("(.*)\\." + extension + "(|\\.disabled)", get_name(i));
|
||||
if(!r) continue;
|
||||
pluginstbl.push_back(std::make_pair(r[1], r[2] == ""));
|
||||
std::string r1 = r[1];
|
||||
plugins->Append(towxstring(r1 + ((r[2] == "") ? "" : " (disabled)")));
|
||||
}
|
||||
|
||||
bool found = false;
|
||||
for(size_t i = 0; i < pluginstbl.size(); i++) {
|
||||
if(pluginstbl[i].first == name)
|
||||
plugins->SetSelection(i);
|
||||
}
|
||||
wxCommandEvent e;
|
||||
on_selection_change(e);
|
||||
}
|
||||
|
||||
void wxeditor_plugins::on_selection_change(wxCommandEvent& e)
|
||||
{
|
||||
int sel = plugins->GetSelection();
|
||||
if(sel == wxNOT_FOUND || sel >= pluginstbl.size()) {
|
||||
renamebutton->Enable(false);
|
||||
enablebutton->Enable(false);
|
||||
deletebutton->Enable(false);
|
||||
} else {
|
||||
enablebutton->SetLabel(towxstring(pluginstbl[sel].second ? "Disable" : "Enable"));
|
||||
renamebutton->Enable(true);
|
||||
enablebutton->Enable(true);
|
||||
deletebutton->Enable(true);
|
||||
}
|
||||
}
|
||||
|
||||
void wxeditor_plugins::on_add(wxCommandEvent& e)
|
||||
{
|
||||
try {
|
||||
std::string file = choose_file_load(this, "Choose plugin to add", ".",
|
||||
single_type(loadlib::library::extension(), loadlib::library::name()));
|
||||
std::string name = strip_extension(get_name(file), extension);
|
||||
std::string nname = pathpfx + "/" + name + "." + extension;
|
||||
bool overwrite_ok = false;
|
||||
bool first = true;
|
||||
int counter = 2;
|
||||
while(!overwrite_ok && (file_exists(nname) || file_exists(nname + ".disabled"))) {
|
||||
if(first) {
|
||||
wxMessageDialog* d3 = new wxMessageDialog(this,
|
||||
towxstring("Plugin '" + name + "' already exists.\n\nOverwrite?"),
|
||||
towxstring("Plugin already exists"),
|
||||
wxYES_NO | wxCANCEL | wxNO_DEFAULT | wxICON_QUESTION);
|
||||
int r = d3->ShowModal();
|
||||
d3->Destroy();
|
||||
first = false;
|
||||
if(r == wxID_YES)
|
||||
break;
|
||||
if(r == wxID_CANCEL) {
|
||||
reload_plugins();
|
||||
return;
|
||||
}
|
||||
}
|
||||
nname = pathpfx + "/" + name + "(" + (stringfmt() << counter++).str() + ")." + extension;
|
||||
}
|
||||
std::ifstream in(file, std::ios::binary);
|
||||
std::ofstream out(nname, std::ios::binary);
|
||||
if(!out) {
|
||||
show_message_ok(this, "Error", "Can't write file '" + nname + "'", wxICON_EXCLAMATION);
|
||||
reload_plugins();
|
||||
return;
|
||||
}
|
||||
if(!in) {
|
||||
remove(nname.c_str());
|
||||
show_message_ok(this, "Error", "Can't read file '" + file + "'", wxICON_EXCLAMATION);
|
||||
reload_plugins();
|
||||
return;
|
||||
}
|
||||
while(true) {
|
||||
char buf[4096];
|
||||
size_t r;
|
||||
r = in.readsome(buf, sizeof(buf));
|
||||
out.write(buf, r);
|
||||
if(!r)
|
||||
break;
|
||||
}
|
||||
if(!out) {
|
||||
remove(nname.c_str());
|
||||
show_message_ok(this, "Error", "Can't write file '" + nname + "'", wxICON_EXCLAMATION);
|
||||
reload_plugins();
|
||||
return;
|
||||
}
|
||||
//Set permissions.
|
||||
#if defined(_WIN32) || defined(_WIN64)
|
||||
#else
|
||||
struct stat s;
|
||||
if(stat(nname.c_str(), &s) < 0)
|
||||
s.st_mode = 0644;
|
||||
if(s.st_mode & 0400) s.st_mode |= 0100;
|
||||
if(s.st_mode & 040) s.st_mode |= 010;
|
||||
if(s.st_mode & 04) s.st_mode |= 01;
|
||||
chmod(nname.c_str(), s.st_mode & 0777);
|
||||
#endif
|
||||
std::string disname = nname + ".disabled";
|
||||
remove(disname.c_str());
|
||||
reload_plugins();
|
||||
} catch(canceled_exception& e) {
|
||||
}
|
||||
}
|
||||
|
||||
void wxeditor_plugins::on_rename(wxCommandEvent& e)
|
||||
{
|
||||
int sel = plugins->GetSelection();
|
||||
if(sel == wxNOT_FOUND || sel >= pluginstbl.size())
|
||||
return;
|
||||
std::string name = pluginstbl[sel].first;
|
||||
std::string name2;
|
||||
try {
|
||||
name2 = pick_text(this, "Rename plugin to", "Enter new name for plugin", name, false);
|
||||
} catch(canceled_exception& e) {
|
||||
return;
|
||||
}
|
||||
std::string oname = pathpfx + "/" + name + "." + extension + (pluginstbl[sel].second ? "" : ".disabled");
|
||||
std::string nname = pathpfx + "/" + name2 + "." + extension + (pluginstbl[sel].second ? "" : ".disabled");
|
||||
if(oname != nname)
|
||||
zip::rename_overwrite(oname.c_str(), nname.c_str());
|
||||
pluginstbl[sel].first = name2;
|
||||
reload_plugins();
|
||||
}
|
||||
|
||||
void wxeditor_plugins::on_enable(wxCommandEvent& e)
|
||||
{
|
||||
int sel = plugins->GetSelection();
|
||||
if(sel == wxNOT_FOUND || sel >= pluginstbl.size())
|
||||
return;
|
||||
std::string ename = pathpfx + "/" + pluginstbl[sel].first + "." + extension;
|
||||
std::string dname = pathpfx + "/" + pluginstbl[sel].first + "." + extension + ".disabled";
|
||||
bool ok;
|
||||
if(pluginstbl[sel].second)
|
||||
ok = !zip::rename_overwrite(ename.c_str(), dname.c_str());
|
||||
else
|
||||
ok = !zip::rename_overwrite(dname.c_str(), ename.c_str());
|
||||
if(!ok) {
|
||||
show_message_ok(this, "Error", "Can't enable/disable plugin '" + pluginstbl[sel].first +
|
||||
"'", wxICON_EXCLAMATION);
|
||||
reload_plugins();
|
||||
return;
|
||||
}
|
||||
pluginstbl[sel].second = !pluginstbl[sel].second;
|
||||
reload_plugins();
|
||||
}
|
||||
|
||||
void wxeditor_plugins::on_delete(wxCommandEvent& e)
|
||||
{
|
||||
int sel = plugins->GetSelection();
|
||||
if(sel == wxNOT_FOUND || sel >= pluginstbl.size())
|
||||
return;
|
||||
std::string oname = pathpfx + "/" + pluginstbl[sel].first + "." + extension +
|
||||
(pluginstbl[sel].second ? "" : ".disabled");
|
||||
if(remove(oname.c_str()) < 0) {
|
||||
int err = errno;
|
||||
show_message_ok(this, "Error", "Can't delete plugin '" + pluginstbl[sel].first +
|
||||
"': " + strerror(err), wxICON_EXCLAMATION);
|
||||
reload_plugins();
|
||||
return;
|
||||
}
|
||||
reload_plugins();
|
||||
}
|
||||
|
||||
void wxeditor_plugins::on_start(wxCommandEvent& e)
|
||||
{
|
||||
EndModal(wxID_OK);
|
||||
}
|
||||
|
||||
void wxeditor_plugins::on_close(wxCommandEvent& e)
|
||||
{
|
||||
EndModal(wxID_CANCEL);
|
||||
}
|
||||
|
||||
bool wxeditor_plugin_manager_display(wxWindow* parent)
|
||||
{
|
||||
int r;
|
||||
modal_pause_holder* hld = NULL;
|
||||
try {
|
||||
if(parent)
|
||||
hld = new modal_pause_holder();
|
||||
wxDialog* editor;
|
||||
try {
|
||||
editor = new wxeditor_plugins(parent);
|
||||
r = editor->ShowModal();
|
||||
} catch(...) {
|
||||
}
|
||||
editor->Destroy();
|
||||
if(hld) delete hld;
|
||||
return (r == wxID_OK);
|
||||
} catch(...) {
|
||||
if(hld) delete hld;
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -344,6 +344,7 @@ public:
|
|||
virtual bool OnCmdLineParsed(wxCmdLineParser& parser);
|
||||
private:
|
||||
bool settings_mode;
|
||||
bool pluginmanager_mode;
|
||||
std::string c_rom;
|
||||
std::string c_file;
|
||||
std::vector<std::string> cmdline;
|
||||
|
@ -374,6 +375,7 @@ bool lsnes_app::OnCmdLineParsed(wxCmdLineParser& parser)
|
|||
regex_results r;
|
||||
if(i == "--help" || i == "-h") {
|
||||
std::cout << "--settings: Show the settings dialog" << std::endl;
|
||||
std::cout << "--pluginmanager: Show the plugin manager" << std::endl;
|
||||
std::cout << "--rom=<filename>: Load specified ROM on startup" << std::endl;
|
||||
std::cout << "--load=<filename>: Load specified save/movie on starup" << std::endl;
|
||||
std::cout << "--lua=<filename>: Load specified Lua script on startup" << std::endl;
|
||||
|
@ -384,6 +386,8 @@ bool lsnes_app::OnCmdLineParsed(wxCmdLineParser& parser)
|
|||
}
|
||||
if(i == "--settings")
|
||||
settings_mode = true;
|
||||
if(i == "--pluginmanager")
|
||||
pluginmanager_mode = true;
|
||||
if(r = regex("--set=([^=]+)=(.+)", i))
|
||||
c_settings[r[1]] = r[2];
|
||||
if(r = regex("--lua=(.+)", i))
|
||||
|
@ -403,6 +407,10 @@ bool lsnes_app::OnInit()
|
|||
set_random_seed();
|
||||
bring_app_foreground();
|
||||
|
||||
if(pluginmanager_mode)
|
||||
if(!wxeditor_plugin_manager_display(NULL))
|
||||
return false;
|
||||
|
||||
ui_services = new ui_services_type();
|
||||
|
||||
ui_thread = this_thread_id();
|
||||
|
@ -419,7 +427,9 @@ bool lsnes_app::OnInit()
|
|||
controls.set_ports(ports);
|
||||
|
||||
std::string cfgpath = get_config_path();
|
||||
autoload_libraries();
|
||||
autoload_libraries([](const std::string& error) {
|
||||
show_message_ok(NULL, "Error loading plugin", error, wxICON_EXCLAMATION);
|
||||
});
|
||||
messages << "Saving per-user data to: " << get_config_path() << std::endl;
|
||||
messages << "--- Loading configuration --- " << std::endl;
|
||||
load_configuration();
|
||||
|
|
|
@ -133,6 +133,7 @@ enum
|
|||
wxID_DOWNLOAD,
|
||||
wxID_TRACELOG_FIRST,
|
||||
wxID_TRACELOG_LAST = wxID_TRACELOG_FIRST + 256,
|
||||
wxID_PLUGIN_MANAGER,
|
||||
};
|
||||
|
||||
|
||||
|
@ -1025,6 +1026,7 @@ wxwin_mainwindow::wxwin_mainwindow()
|
|||
if(loadlib::library::name() != "") {
|
||||
menu_separator();
|
||||
menu_entry(wxID_LOAD_LIBRARY, towxstring(std::string("Load ") + loadlib::library::name()));
|
||||
menu_entry(wxID_PLUGIN_MANAGER, towxstring("Plugin manager"));
|
||||
}
|
||||
menu_separator();
|
||||
menu_entry(wxID_RELOAD_ROM_IMAGE, wxT("Reload ROM"));
|
||||
|
@ -1606,6 +1608,9 @@ void wxwin_mainwindow::handle_menu_click_cancelable(wxCommandEvent& e)
|
|||
handle_post_loadlibrary();
|
||||
break;
|
||||
}
|
||||
case wxID_PLUGIN_MANAGER:
|
||||
wxeditor_plugin_manager_display(this);
|
||||
return;
|
||||
case wxID_RELOAD_ROM_IMAGE:
|
||||
runemufn([]() {
|
||||
lsnes_cmd.invoke("unpause-emulator");
|
||||
|
|
Loading…
Add table
Reference in a new issue