mirror of
https://github.com/minetest/minetest.git
synced 2024-11-30 03:23:45 +01:00
Add server side ncurses terminal
This adds a chat console the server owner can use for administration or to talk with players. It runs in its own thread, which makes the user interface immune to the server's lag, behaving just like a client, except timeout. As it uses the same console code as the f10 console, things like nick completion or a scroll buffer basically come for free. The terminal itself is written in a general way so that adding a client version later on is just about implementing an interface. Fatal errors are printed after the console exists and the ncurses terminal buffer gets cleaned up with endwin(), so that the error still remains visible. The server owner can chose their username their entered text will have in chat and where players can send PMs to. Once the username is secured with a password to prevent anybody to take over the server, the owner can execute admin tasks over the console. This change includes a contribution by @kahrl who has improved ncurses library detection.
This commit is contained in:
parent
1384108f8c
commit
5e507c9829
@ -7,6 +7,15 @@
|
||||
|
||||
-- Initialize some very basic things
|
||||
function core.debug(...) core.log(table.concat({...}, "\t")) end
|
||||
if core.print then
|
||||
local core_print = core.print
|
||||
-- Override native print and use
|
||||
-- terminal if that's turned on
|
||||
function print(...)
|
||||
core_print(table.concat({...}, "\t"))
|
||||
end
|
||||
core.print = nil -- don't pollute our namespace
|
||||
end
|
||||
math.randomseed(os.time())
|
||||
os.setlocale("C", "numeric")
|
||||
minetest = core
|
||||
|
189
cmake/Modules/FindNcursesw.cmake
Normal file
189
cmake/Modules/FindNcursesw.cmake
Normal file
@ -0,0 +1,189 @@
|
||||
#.rst:
|
||||
# FindNcursesw
|
||||
# ------------
|
||||
#
|
||||
# Find the ncursesw (wide ncurses) include file and library.
|
||||
#
|
||||
# Based on FindCurses.cmake which comes with CMake.
|
||||
#
|
||||
# Checks for ncursesw first. If not found, it then executes the
|
||||
# regular old FindCurses.cmake to look for for ncurses (or curses).
|
||||
#
|
||||
#
|
||||
# Result Variables
|
||||
# ^^^^^^^^^^^^^^^^
|
||||
#
|
||||
# This module defines the following variables:
|
||||
#
|
||||
# ``CURSES_FOUND``
|
||||
# True if curses is found.
|
||||
# ``NCURSESW_FOUND``
|
||||
# True if ncursesw is found.
|
||||
# ``CURSES_INCLUDE_DIRS``
|
||||
# The include directories needed to use Curses.
|
||||
# ``CURSES_LIBRARIES``
|
||||
# The libraries needed to use Curses.
|
||||
# ``CURSES_HAVE_CURSES_H``
|
||||
# True if curses.h is available.
|
||||
# ``CURSES_HAVE_NCURSES_H``
|
||||
# True if ncurses.h is available.
|
||||
# ``CURSES_HAVE_NCURSES_NCURSES_H``
|
||||
# True if ``ncurses/ncurses.h`` is available.
|
||||
# ``CURSES_HAVE_NCURSES_CURSES_H``
|
||||
# True if ``ncurses/curses.h`` is available.
|
||||
# ``CURSES_HAVE_NCURSESW_NCURSES_H``
|
||||
# True if ``ncursesw/ncurses.h`` is available.
|
||||
# ``CURSES_HAVE_NCURSESW_CURSES_H``
|
||||
# True if ``ncursesw/curses.h`` is available.
|
||||
#
|
||||
# Set ``CURSES_NEED_NCURSES`` to ``TRUE`` before the
|
||||
# ``find_package(Ncursesw)`` call if NCurses functionality is required.
|
||||
#
|
||||
#=============================================================================
|
||||
# Copyright 2001-2014 Kitware, Inc.
|
||||
# modifications: Copyright 2015 kahrl <kahrl@gmx.net>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions
|
||||
# are met:
|
||||
#
|
||||
# * Redistributions of source code must retain the above copyright
|
||||
# notice, this list of conditions and the following disclaimer.
|
||||
#
|
||||
# * Redistributions in binary form must reproduce the above copyright
|
||||
# notice, this list of conditions and the following disclaimer in the
|
||||
# documentation and/or other materials provided with the distribution.
|
||||
#
|
||||
# * Neither the names of Kitware, Inc., the Insight Software Consortium,
|
||||
# nor the names of their contributors may be used to endorse or promote
|
||||
# products derived from this software without specific prior written
|
||||
# permission.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
#
|
||||
# ------------------------------------------------------------------------------
|
||||
#
|
||||
# The above copyright and license notice applies to distributions of
|
||||
# CMake in source and binary form. Some source files contain additional
|
||||
# notices of original copyright by their contributors; see each source
|
||||
# for details. Third-party software packages supplied with CMake under
|
||||
# compatible licenses provide their own copyright notices documented in
|
||||
# corresponding subdirectories.
|
||||
#
|
||||
# ------------------------------------------------------------------------------
|
||||
#
|
||||
# CMake was initially developed by Kitware with the following sponsorship:
|
||||
#
|
||||
# * National Library of Medicine at the National Institutes of Health
|
||||
# as part of the Insight Segmentation and Registration Toolkit (ITK).
|
||||
#
|
||||
# * US National Labs (Los Alamos, Livermore, Sandia) ASC Parallel
|
||||
# Visualization Initiative.
|
||||
#
|
||||
# * National Alliance for Medical Image Computing (NAMIC) is funded by the
|
||||
# National Institutes of Health through the NIH Roadmap for Medical Research,
|
||||
# Grant U54 EB005149.
|
||||
#
|
||||
# * Kitware, Inc.
|
||||
#=============================================================================
|
||||
|
||||
include(CheckLibraryExists)
|
||||
|
||||
find_library(CURSES_NCURSESW_LIBRARY NAMES ncursesw
|
||||
DOC "Path to libncursesw.so or .lib or .a")
|
||||
|
||||
set(CURSES_USE_NCURSES FALSE)
|
||||
set(CURSES_USE_NCURSESW FALSE)
|
||||
|
||||
if(CURSES_NCURSESW_LIBRARY)
|
||||
set(CURSES_USE_NCURSES TRUE)
|
||||
set(CURSES_USE_NCURSESW TRUE)
|
||||
endif()
|
||||
|
||||
if(CURSES_USE_NCURSESW)
|
||||
get_filename_component(_cursesLibDir "${CURSES_NCURSESW_LIBRARY}" PATH)
|
||||
get_filename_component(_cursesParentDir "${_cursesLibDir}" PATH)
|
||||
|
||||
find_path(CURSES_INCLUDE_PATH
|
||||
NAMES ncursesw/ncurses.h ncursesw/curses.h
|
||||
HINTS "${_cursesParentDir}/include"
|
||||
)
|
||||
|
||||
# Previous versions of FindCurses provided these values.
|
||||
if(NOT DEFINED CURSES_LIBRARY)
|
||||
set(CURSES_LIBRARY "${CURSES_NCURSESW_LIBRARY}")
|
||||
endif()
|
||||
|
||||
CHECK_LIBRARY_EXISTS("${CURSES_NCURSESW_LIBRARY}"
|
||||
cbreak "" CURSES_NCURSESW_HAS_CBREAK)
|
||||
if(NOT CURSES_NCURSESW_HAS_CBREAK)
|
||||
find_library(CURSES_EXTRA_LIBRARY tinfo HINTS "${_cursesLibDir}"
|
||||
DOC "Path to libtinfo.so or .lib or .a")
|
||||
find_library(CURSES_EXTRA_LIBRARY tinfo )
|
||||
endif()
|
||||
|
||||
# Report whether each possible header name exists in the include directory.
|
||||
if(NOT DEFINED CURSES_HAVE_NCURSESW_NCURSES_H)
|
||||
if(EXISTS "${CURSES_INCLUDE_PATH}/ncursesw/ncurses.h")
|
||||
set(CURSES_HAVE_NCURSESW_NCURSES_H "${CURSES_INCLUDE_PATH}/ncursesw/ncurses.h")
|
||||
else()
|
||||
set(CURSES_HAVE_NCURSESW_NCURSES_H "CURSES_HAVE_NCURSESW_NCURSES_H-NOTFOUND")
|
||||
endif()
|
||||
endif()
|
||||
if(NOT DEFINED CURSES_HAVE_NCURSESW_CURSES_H)
|
||||
if(EXISTS "${CURSES_INCLUDE_PATH}/ncursesw/curses.h")
|
||||
set(CURSES_HAVE_NCURSESW_CURSES_H "${CURSES_INCLUDE_PATH}/ncursesw/curses.h")
|
||||
else()
|
||||
set(CURSES_HAVE_NCURSESW_CURSES_H "CURSES_HAVE_NCURSESW_CURSES_H-NOTFOUND")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
find_library(CURSES_FORM_LIBRARY form HINTS "${_cursesLibDir}"
|
||||
DOC "Path to libform.so or .lib or .a")
|
||||
find_library(CURSES_FORM_LIBRARY form )
|
||||
|
||||
# Need to provide the *_LIBRARIES
|
||||
set(CURSES_LIBRARIES ${CURSES_LIBRARY})
|
||||
|
||||
if(CURSES_EXTRA_LIBRARY)
|
||||
set(CURSES_LIBRARIES ${CURSES_LIBRARIES} ${CURSES_EXTRA_LIBRARY})
|
||||
endif()
|
||||
|
||||
if(CURSES_FORM_LIBRARY)
|
||||
set(CURSES_LIBRARIES ${CURSES_LIBRARIES} ${CURSES_FORM_LIBRARY})
|
||||
endif()
|
||||
|
||||
# Provide the *_INCLUDE_DIRS result.
|
||||
set(CURSES_INCLUDE_DIRS ${CURSES_INCLUDE_PATH})
|
||||
set(CURSES_INCLUDE_DIR ${CURSES_INCLUDE_PATH}) # compatibility
|
||||
|
||||
# handle the QUIETLY and REQUIRED arguments and set CURSES_FOUND to TRUE if
|
||||
# all listed variables are TRUE
|
||||
include(FindPackageHandleStandardArgs)
|
||||
FIND_PACKAGE_HANDLE_STANDARD_ARGS(Ncursesw DEFAULT_MSG
|
||||
CURSES_LIBRARY CURSES_INCLUDE_PATH)
|
||||
set(CURSES_FOUND ${NCURSESW_FOUND})
|
||||
|
||||
else()
|
||||
find_package(Curses)
|
||||
set(NCURSESW_FOUND FALSE)
|
||||
endif()
|
||||
|
||||
mark_as_advanced(
|
||||
CURSES_INCLUDE_PATH
|
||||
CURSES_CURSES_LIBRARY
|
||||
CURSES_NCURSES_LIBRARY
|
||||
CURSES_NCURSESW_LIBRARY
|
||||
CURSES_EXTRA_LIBRARY
|
||||
CURSES_FORM_LIBRARY
|
||||
)
|
@ -89,6 +89,9 @@ Run speed tests
|
||||
.B \-\-migrate <value>
|
||||
Migrate from current map backend to another. Possible values are sqlite3,
|
||||
leveldb, redis, and dummy.
|
||||
.TP
|
||||
.B \-\-terminal
|
||||
Display an interactive terminal over ncurses during execution.
|
||||
|
||||
.SH ENVIRONMENT
|
||||
.TP
|
||||
|
@ -160,6 +160,20 @@ find_package(Lua REQUIRED)
|
||||
|
||||
find_package(GMP REQUIRED)
|
||||
|
||||
option(ENABLE_CURSES "Enable ncurses console" TRUE)
|
||||
set(USE_CURSES FALSE)
|
||||
|
||||
if(ENABLE_CURSES)
|
||||
find_package(Ncursesw)
|
||||
if(CURSES_FOUND)
|
||||
set(USE_CURSES TRUE)
|
||||
message(STATUS "ncurses console enabled.")
|
||||
include_directories(${CURSES_INCLUDE_DIRS})
|
||||
else()
|
||||
message(STATUS "ncurses not found!")
|
||||
endif()
|
||||
endif(ENABLE_CURSES)
|
||||
|
||||
option(ENABLE_LEVELDB "Enable LevelDB backend" TRUE)
|
||||
set(USE_LEVELDB FALSE)
|
||||
|
||||
@ -322,6 +336,7 @@ set(common_SRCS
|
||||
areastore.cpp
|
||||
ban.cpp
|
||||
cavegen.cpp
|
||||
chat.cpp
|
||||
clientiface.cpp
|
||||
collision.cpp
|
||||
content_abm.cpp
|
||||
@ -387,6 +402,7 @@ set(common_SRCS
|
||||
sound.cpp
|
||||
staticobject.cpp
|
||||
subgame.cpp
|
||||
terminal_chat_console.cpp
|
||||
tool.cpp
|
||||
treegen.cpp
|
||||
version.cpp
|
||||
@ -431,7 +447,6 @@ set(client_SRCS
|
||||
${sound_SRCS}
|
||||
${client_network_SRCS}
|
||||
camera.cpp
|
||||
chat.cpp
|
||||
client.cpp
|
||||
clientmap.cpp
|
||||
clientmedia.cpp
|
||||
@ -558,6 +573,9 @@ if(BUILD_CLIENT)
|
||||
${CGUITTFONT_LIBRARY}
|
||||
)
|
||||
endif()
|
||||
if (USE_CURSES)
|
||||
target_link_libraries(${PROJECT_NAME} ${CURSES_LIBRARIES})
|
||||
endif()
|
||||
if (USE_LEVELDB)
|
||||
target_link_libraries(${PROJECT_NAME} ${LEVELDB_LIBRARY})
|
||||
endif()
|
||||
@ -585,6 +603,9 @@ if(BUILD_SERVER)
|
||||
)
|
||||
set_target_properties(${PROJECT_NAME}server PROPERTIES
|
||||
COMPILE_DEFINITIONS "SERVER")
|
||||
if (USE_CURSES)
|
||||
target_link_libraries(${PROJECT_NAME}server ${CURSES_LIBRARIES})
|
||||
endif()
|
||||
if (USE_LEVELDB)
|
||||
target_link_libraries(${PROJECT_NAME}server ${LEVELDB_LIBRARY})
|
||||
endif()
|
||||
|
@ -25,7 +25,7 @@ with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
#include <vector>
|
||||
#include <list>
|
||||
|
||||
// Chat console related classes, only used by the client
|
||||
// Chat console related classes
|
||||
|
||||
struct ChatLine
|
||||
{
|
||||
|
82
src/chat_interface.h
Normal file
82
src/chat_interface.h
Normal file
@ -0,0 +1,82 @@
|
||||
/*
|
||||
Minetest
|
||||
Copyright (C) 2015 est31 <MTest31@outlook.com>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU Lesser General Public License as published by
|
||||
the Free Software Foundation; either version 2.1 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
*/
|
||||
|
||||
#ifndef CHAT_INTERFACE_H
|
||||
#define CHAT_INTERFACE_H
|
||||
|
||||
#include "util/container.h"
|
||||
#include <string>
|
||||
#include <queue>
|
||||
#include "irrlichttypes.h"
|
||||
|
||||
enum ChatEventType {
|
||||
CET_CHAT,
|
||||
CET_NICK_ADD,
|
||||
CET_NICK_REMOVE,
|
||||
CET_TIME_INFO,
|
||||
};
|
||||
|
||||
class ChatEvent {
|
||||
protected:
|
||||
ChatEvent(ChatEventType a_type) { type = a_type; }
|
||||
public:
|
||||
ChatEventType type;
|
||||
};
|
||||
|
||||
struct ChatEventTimeInfo : public ChatEvent {
|
||||
ChatEventTimeInfo(
|
||||
u64 a_game_time,
|
||||
u32 a_time) :
|
||||
ChatEvent(CET_TIME_INFO),
|
||||
game_time(a_game_time),
|
||||
time(a_time)
|
||||
{}
|
||||
|
||||
u64 game_time;
|
||||
u32 time;
|
||||
};
|
||||
|
||||
struct ChatEventNick : public ChatEvent {
|
||||
ChatEventNick(ChatEventType a_type,
|
||||
const std::string &a_nick) :
|
||||
ChatEvent(a_type), // one of CET_NICK_ADD, CET_NICK_REMOVE
|
||||
nick(a_nick)
|
||||
{}
|
||||
|
||||
std::string nick;
|
||||
};
|
||||
|
||||
struct ChatEventChat : public ChatEvent {
|
||||
ChatEventChat(const std::string &a_nick,
|
||||
const std::wstring &an_evt_msg) :
|
||||
ChatEvent(CET_CHAT),
|
||||
nick(a_nick),
|
||||
evt_msg(an_evt_msg)
|
||||
{}
|
||||
|
||||
std::string nick;
|
||||
std::wstring evt_msg;
|
||||
};
|
||||
|
||||
struct ChatInterface {
|
||||
MutexedQueue<ChatEvent *> command_queue; // chat backend --> server
|
||||
MutexedQueue<ChatEvent *> outgoing_queue; // server --> chat backend
|
||||
};
|
||||
|
||||
#endif
|
@ -19,12 +19,19 @@
|
||||
#cmakedefine01 USE_CURL
|
||||
#cmakedefine01 USE_SOUND
|
||||
#cmakedefine01 USE_FREETYPE
|
||||
#cmakedefine01 USE_CURSES
|
||||
#cmakedefine01 USE_LEVELDB
|
||||
#cmakedefine01 USE_LUAJIT
|
||||
#cmakedefine01 USE_SPATIAL
|
||||
#cmakedefine01 USE_SYSTEM_GMP
|
||||
#cmakedefine01 USE_REDIS
|
||||
#cmakedefine01 HAVE_ENDIAN_H
|
||||
#cmakedefine01 CURSES_HAVE_CURSES_H
|
||||
#cmakedefine01 CURSES_HAVE_NCURSES_H
|
||||
#cmakedefine01 CURSES_HAVE_NCURSES_NCURSES_H
|
||||
#cmakedefine01 CURSES_HAVE_NCURSES_CURSES_H
|
||||
#cmakedefine01 CURSES_HAVE_NCURSESW_NCURSES_H
|
||||
#cmakedefine01 CURSES_HAVE_NCURSESW_CURSES_H
|
||||
|
||||
#endif
|
||||
|
||||
|
@ -37,6 +37,10 @@ with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
#include "filesys.h"
|
||||
#endif
|
||||
|
||||
#if USE_CURSES
|
||||
#include "terminal_chat_console.h"
|
||||
#endif
|
||||
|
||||
/*
|
||||
Assert
|
||||
*/
|
||||
@ -44,6 +48,10 @@ with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
void sanity_check_fn(const char *assertion, const char *file,
|
||||
unsigned int line, const char *function)
|
||||
{
|
||||
#if USE_CURSES
|
||||
g_term_console.stopAndWaitforThread();
|
||||
#endif
|
||||
|
||||
errorstream << std::endl << "In thread " << std::hex
|
||||
<< thr_get_current_thread_id() << ":" << std::endl;
|
||||
errorstream << file << ":" << line << ": " << function
|
||||
@ -57,6 +65,10 @@ void sanity_check_fn(const char *assertion, const char *file,
|
||||
void fatal_error_fn(const char *msg, const char *file,
|
||||
unsigned int line, const char *function)
|
||||
{
|
||||
#if USE_CURSES
|
||||
g_term_console.stopAndWaitforThread();
|
||||
#endif
|
||||
|
||||
errorstream << std::endl << "In thread " << std::hex
|
||||
<< thr_get_current_thread_id() << ":" << std::endl;
|
||||
errorstream << file << ":" << line << ": " << function
|
||||
|
16
src/log.cpp
16
src/log.cpp
@ -181,6 +181,14 @@ void Logger::addOutput(ILogOutput *out, LogLevel lev)
|
||||
m_outputs[lev].push_back(out);
|
||||
}
|
||||
|
||||
void Logger::addOutputMasked(ILogOutput *out, LogLevelMask mask)
|
||||
{
|
||||
for (size_t i = 0; i < LL_MAX; i++) {
|
||||
if (mask & LOGLEVEL_TO_MASKLEVEL(i))
|
||||
m_outputs[i].push_back(out);
|
||||
}
|
||||
}
|
||||
|
||||
void Logger::addOutputMaxLevel(ILogOutput *out, LogLevel lev)
|
||||
{
|
||||
assert(lev < LL_MAX);
|
||||
@ -188,15 +196,19 @@ void Logger::addOutputMaxLevel(ILogOutput *out, LogLevel lev)
|
||||
m_outputs[i].push_back(out);
|
||||
}
|
||||
|
||||
void Logger::removeOutput(ILogOutput *out)
|
||||
LogLevelMask Logger::removeOutput(ILogOutput *out)
|
||||
{
|
||||
LogLevelMask ret_mask = 0;
|
||||
for (size_t i = 0; i < LL_MAX; i++) {
|
||||
std::vector<ILogOutput *>::iterator it;
|
||||
|
||||
it = std::find(m_outputs[i].begin(), m_outputs[i].end(), out);
|
||||
if (it != m_outputs[i].end())
|
||||
if (it != m_outputs[i].end()) {
|
||||
ret_mask |= LOGLEVEL_TO_MASKLEVEL(i);
|
||||
m_outputs[i].erase(it);
|
||||
}
|
||||
}
|
||||
return ret_mask;
|
||||
}
|
||||
|
||||
void Logger::setLevelSilenced(LogLevel lev, bool silenced)
|
||||
|
@ -25,6 +25,7 @@ with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
#include <string>
|
||||
#include <fstream>
|
||||
#include "threads.h"
|
||||
#include "irrlichttypes.h"
|
||||
|
||||
class ILogOutput;
|
||||
|
||||
@ -38,12 +39,16 @@ enum LogLevel {
|
||||
LL_MAX,
|
||||
};
|
||||
|
||||
typedef u8 LogLevelMask;
|
||||
#define LOGLEVEL_TO_MASKLEVEL(x) (1 << x)
|
||||
|
||||
class Logger {
|
||||
public:
|
||||
void addOutput(ILogOutput *out);
|
||||
void addOutput(ILogOutput *out, LogLevel lev);
|
||||
void addOutputMasked(ILogOutput *out, LogLevelMask mask);
|
||||
void addOutputMaxLevel(ILogOutput *out, LogLevel lev);
|
||||
void removeOutput(ILogOutput *out);
|
||||
LogLevelMask removeOutput(ILogOutput *out);
|
||||
void setLevelSilenced(LogLevel lev, bool silenced);
|
||||
|
||||
void registerThread(const std::string &name);
|
||||
|
95
src/main.cpp
95
src/main.cpp
@ -45,10 +45,15 @@ with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
#include "httpfetch.h"
|
||||
#include "guiEngine.h"
|
||||
#include "map.h"
|
||||
#include "player.h"
|
||||
#include "mapsector.h"
|
||||
#include "fontengine.h"
|
||||
#include "gameparams.h"
|
||||
#include "database.h"
|
||||
#include "config.h"
|
||||
#if USE_CURSES
|
||||
#include "terminal_chat_console.h"
|
||||
#endif
|
||||
#ifndef SERVER
|
||||
#include "client/clientlauncher.h"
|
||||
#endif
|
||||
@ -277,6 +282,8 @@ static void set_allowed_options(OptionList *allowed_options)
|
||||
_("Set gameid (\"--gameid list\" prints available ones)"))));
|
||||
allowed_options->insert(std::make_pair("migrate", ValueSpec(VALUETYPE_STRING,
|
||||
_("Migrate from current map backend to another (Only works when using minetestserver or with --server)"))));
|
||||
allowed_options->insert(std::make_pair("terminal", ValueSpec(VALUETYPE_FLAG,
|
||||
_("Feature an interactive terminal (Only works when using minetestserver or with --server)"))));
|
||||
#ifndef SERVER
|
||||
allowed_options->insert(std::make_pair("videomodes", ValueSpec(VALUETYPE_FLAG,
|
||||
_("Show available video modes"))));
|
||||
@ -816,21 +823,83 @@ static bool run_dedicated_server(const GameParams &game_params, const Settings &
|
||||
if (cmd_args.exists("migrate"))
|
||||
return migrate_database(game_params, cmd_args);
|
||||
|
||||
try {
|
||||
// Create server
|
||||
Server server(game_params.world_path, game_params.game_spec, false,
|
||||
bind_addr.isIPv6());
|
||||
server.start(bind_addr);
|
||||
if (cmd_args.exists("terminal")) {
|
||||
#if USE_CURSES
|
||||
bool name_ok = true;
|
||||
std::string admin_nick = g_settings->get("name");
|
||||
|
||||
// Run server
|
||||
name_ok = name_ok && !admin_nick.empty();
|
||||
name_ok = name_ok && string_allowed(admin_nick, PLAYERNAME_ALLOWED_CHARS);
|
||||
|
||||
if (!name_ok) {
|
||||
if (admin_nick.empty()) {
|
||||
errorstream << "No name given for admin. "
|
||||
<< "Please check your minetest.conf that it "
|
||||
<< "contains a 'name = ' to your main admin account."
|
||||
<< std::endl;
|
||||
} else {
|
||||
errorstream << "Name for admin '"
|
||||
<< admin_nick << "' is not valid. "
|
||||
<< "Please check that it only contains allowed characters. "
|
||||
<< "Valid characters are: " << PLAYERNAME_ALLOWED_CHARS_USER_EXPL
|
||||
<< std::endl;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
ChatInterface iface;
|
||||
bool &kill = *porting::signal_handler_killstatus();
|
||||
dedicated_server_loop(server, kill);
|
||||
} catch (const ModError &e) {
|
||||
errorstream << "ModError: " << e.what() << std::endl;
|
||||
return false;
|
||||
} catch (const ServerError &e) {
|
||||
errorstream << "ServerError: " << e.what() << std::endl;
|
||||
return false;
|
||||
|
||||
try {
|
||||
// Create server
|
||||
Server server(game_params.world_path,
|
||||
game_params.game_spec, false, bind_addr.isIPv6(), &iface);
|
||||
|
||||
g_term_console.setup(&iface, &kill, admin_nick);
|
||||
|
||||
g_term_console.start();
|
||||
|
||||
server.start(bind_addr);
|
||||
// Run server
|
||||
dedicated_server_loop(server, kill);
|
||||
} catch (const ModError &e) {
|
||||
g_term_console.stopAndWaitforThread();
|
||||
errorstream << "ModError: " << e.what() << std::endl;
|
||||
return false;
|
||||
} catch (const ServerError &e) {
|
||||
g_term_console.stopAndWaitforThread();
|
||||
errorstream << "ServerError: " << e.what() << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tell the console to stop, and wait for it to finish,
|
||||
// only then leave context and free iface
|
||||
g_term_console.stop();
|
||||
g_term_console.wait();
|
||||
|
||||
g_term_console.clearKillStatus();
|
||||
} else {
|
||||
#else
|
||||
errorstream << "Cmd arg --terminal passed, but "
|
||||
<< "compiled without ncurses. Ignoring." << std::endl;
|
||||
} {
|
||||
#endif
|
||||
try {
|
||||
// Create server
|
||||
Server server(game_params.world_path, game_params.game_spec, false,
|
||||
bind_addr.isIPv6());
|
||||
server.start(bind_addr);
|
||||
|
||||
// Run server
|
||||
bool &kill = *porting::signal_handler_killstatus();
|
||||
dedicated_server_loop(server, kill);
|
||||
|
||||
} catch (const ModError &e) {
|
||||
errorstream << "ModError: " << e.what() << std::endl;
|
||||
return false;
|
||||
} catch (const ServerError &e) {
|
||||
errorstream << "ServerError: " << e.what() << std::endl;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
@ -1059,69 +1059,14 @@ void Server::handleCommand_ChatMessage(NetworkPacket* pkt)
|
||||
return;
|
||||
}
|
||||
|
||||
// If something goes wrong, this player is to blame
|
||||
RollbackScopeActor rollback_scope(m_rollback,
|
||||
std::string("player:")+player->getName());
|
||||
|
||||
// Get player name of this client
|
||||
std::wstring name = narrow_to_wide(player->getName());
|
||||
std::string name = player->getName();
|
||||
std::wstring wname = narrow_to_wide(name);
|
||||
|
||||
// Run script hook
|
||||
bool ate = m_script->on_chat_message(player->getName(),
|
||||
wide_to_narrow(message));
|
||||
// If script ate the message, don't proceed
|
||||
if (ate)
|
||||
return;
|
||||
|
||||
// Line to send to players
|
||||
std::wstring line;
|
||||
// Whether to send to the player that sent the line
|
||||
bool send_to_sender_only = false;
|
||||
|
||||
// Commands are implemented in Lua, so only catch invalid
|
||||
// commands that were not "eaten" and send an error back
|
||||
if (message[0] == L'/') {
|
||||
message = message.substr(1);
|
||||
send_to_sender_only = true;
|
||||
if (message.length() == 0)
|
||||
line += L"-!- Empty command";
|
||||
else
|
||||
line += L"-!- Invalid command: " + str_split(message, L' ')[0];
|
||||
}
|
||||
else {
|
||||
if (checkPriv(player->getName(), "shout")) {
|
||||
line += L"<";
|
||||
line += name;
|
||||
line += L"> ";
|
||||
line += message;
|
||||
} else {
|
||||
line += L"-!- You don't have permission to shout.";
|
||||
send_to_sender_only = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (line != L"")
|
||||
{
|
||||
/*
|
||||
Send the message to sender
|
||||
*/
|
||||
if (send_to_sender_only) {
|
||||
SendChatMessage(pkt->getPeerId(), line);
|
||||
}
|
||||
/*
|
||||
Send the message to others
|
||||
*/
|
||||
else {
|
||||
actionstream << "CHAT: " << wide_to_narrow(line)<<std::endl;
|
||||
|
||||
std::vector<u16> clients = m_clients.getClientIDs();
|
||||
|
||||
for (std::vector<u16>::iterator i = clients.begin();
|
||||
i != clients.end(); ++i) {
|
||||
if (*i != pkt->getPeerId())
|
||||
SendChatMessage(*i, line);
|
||||
}
|
||||
}
|
||||
std::wstring answer_to_sender = handleChat(name, wname, message, pkt->getPeerId());
|
||||
if (!answer_to_sender.empty()) {
|
||||
// Send the answer to sender
|
||||
SendChatMessage(pkt->getPeerId(), answer_to_sender);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -29,6 +29,7 @@ with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
#define PLAYERNAME_SIZE 20
|
||||
|
||||
#define PLAYERNAME_ALLOWED_CHARS "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"
|
||||
#define PLAYERNAME_ALLOWED_CHARS_USER_EXPL "'a' to 'z', 'A' to 'Z', '0' to '9', '-', '_'"
|
||||
|
||||
struct PlayerControl
|
||||
{
|
||||
|
@ -45,6 +45,16 @@ int ModApiServer::l_get_server_status(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
// print(text)
|
||||
int ModApiServer::l_print(lua_State *L)
|
||||
{
|
||||
NO_MAP_LOCK_REQUIRED;
|
||||
std::string text;
|
||||
text = luaL_checkstring(L, 1);
|
||||
getServer(L)->printToConsoleOnly(text);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// chat_send_all(text)
|
||||
int ModApiServer::l_chat_send_all(lua_State *L)
|
||||
{
|
||||
@ -505,6 +515,8 @@ void ModApiServer::Initialize(lua_State *L, int top)
|
||||
API_FCT(get_modpath);
|
||||
API_FCT(get_modnames);
|
||||
|
||||
API_FCT(print);
|
||||
|
||||
API_FCT(chat_send_all);
|
||||
API_FCT(chat_send_player);
|
||||
API_FCT(show_formspec);
|
||||
|
@ -46,6 +46,9 @@ private:
|
||||
// the returned list is sorted alphabetically for you
|
||||
static int l_get_modnames(lua_State *L);
|
||||
|
||||
// print(text)
|
||||
static int l_print(lua_State *L);
|
||||
|
||||
// chat_send_all(text)
|
||||
static int l_chat_send_all(lua_State *L);
|
||||
|
||||
|
@ -38,7 +38,7 @@ private:
|
||||
// log([level,] text)
|
||||
// Writes a line to the logger.
|
||||
// The one-argument version logs to infostream.
|
||||
// The two-argument version accept a log level: error, action, info, or verbose.
|
||||
// The two-argument version accepts a log level.
|
||||
static int l_log(lua_State *L);
|
||||
|
||||
// get us precision time
|
||||
|
137
src/server.cpp
137
src/server.cpp
@ -148,7 +148,8 @@ Server::Server(
|
||||
const std::string &path_world,
|
||||
const SubgameSpec &gamespec,
|
||||
bool simple_singleplayer_mode,
|
||||
bool ipv6
|
||||
bool ipv6,
|
||||
ChatInterface *iface
|
||||
):
|
||||
m_path_world(path_world),
|
||||
m_gamespec(gamespec),
|
||||
@ -175,6 +176,7 @@ Server::Server(
|
||||
m_clients(&m_con),
|
||||
m_shutdown_requested(false),
|
||||
m_shutdown_ask_reconnect(false),
|
||||
m_admin_chat(iface),
|
||||
m_ignore_map_edit_events(false),
|
||||
m_ignore_map_edit_events_peer_id(0),
|
||||
m_next_sound_id(0)
|
||||
@ -575,6 +577,36 @@ void Server::AsyncRunStep(bool initial_step)
|
||||
U32_MAX);
|
||||
}
|
||||
|
||||
/*
|
||||
Listen to the admin chat, if available
|
||||
*/
|
||||
if (m_admin_chat) {
|
||||
if (!m_admin_chat->command_queue.empty()) {
|
||||
MutexAutoLock lock(m_env_mutex);
|
||||
while (!m_admin_chat->command_queue.empty()) {
|
||||
ChatEvent *evt = m_admin_chat->command_queue.pop_frontNoEx();
|
||||
if (evt->type == CET_NICK_ADD) {
|
||||
// The terminal informed us of its nick choice
|
||||
m_admin_nick = ((ChatEventNick *)evt)->nick;
|
||||
if (!m_script->getAuth(m_admin_nick, NULL, NULL)) {
|
||||
errorstream << "You haven't set up an account." << std::endl
|
||||
<< "Please log in using the client as '"
|
||||
<< m_admin_nick << "' with a secure password." << std::endl
|
||||
<< "Until then, you can't execute admin tasks via the console," << std::endl
|
||||
<< "and everybody can claim the user account instead of you," << std::endl
|
||||
<< "giving them full control over this server." << std::endl;
|
||||
}
|
||||
} else {
|
||||
assert(evt->type == CET_CHAT);
|
||||
handleAdminChat((ChatEventChat *)evt);
|
||||
}
|
||||
delete evt;
|
||||
}
|
||||
}
|
||||
m_admin_chat->outgoing_queue.push_back(
|
||||
new ChatEventTimeInfo(m_env->getGameTime(), m_env->getTimeOfDay()));
|
||||
}
|
||||
|
||||
/*
|
||||
Do background stuff
|
||||
*/
|
||||
@ -1100,16 +1132,19 @@ PlayerSAO* Server::StageTwoClientInit(u16 peer_id)
|
||||
|
||||
// Send information about joining in chat
|
||||
{
|
||||
std::wstring name = L"unknown";
|
||||
std::string name = "unknown";
|
||||
Player *player = m_env->getPlayer(peer_id);
|
||||
if(player != NULL)
|
||||
name = narrow_to_wide(player->getName());
|
||||
name = player->getName();
|
||||
|
||||
std::wstring message;
|
||||
message += L"*** ";
|
||||
message += name;
|
||||
message += narrow_to_wide(name);
|
||||
message += L" joined the game.";
|
||||
SendChatMessage(PEER_ID_INEXISTENT,message);
|
||||
if (m_admin_chat)
|
||||
m_admin_chat->outgoing_queue.push_back(
|
||||
new ChatEventNick(CET_NICK_ADD, name));
|
||||
}
|
||||
}
|
||||
Address addr = getPeerAddress(player->peer_id);
|
||||
@ -1432,6 +1467,16 @@ void Server::handlePeerChanges()
|
||||
}
|
||||
}
|
||||
|
||||
void Server::printToConsoleOnly(const std::string &text)
|
||||
{
|
||||
if (m_admin_chat) {
|
||||
m_admin_chat->outgoing_queue.push_back(
|
||||
new ChatEventChat("", utf8_to_wide(text)));
|
||||
} else {
|
||||
std::cout << text;
|
||||
}
|
||||
}
|
||||
|
||||
void Server::Send(NetworkPacket* pkt)
|
||||
{
|
||||
m_clients.send(pkt->getPeerId(),
|
||||
@ -2665,9 +2710,13 @@ void Server::DeleteClient(u16 peer_id, ClientDeletionReason reason)
|
||||
os << player->getName() << " ";
|
||||
}
|
||||
|
||||
actionstream << player->getName() << " "
|
||||
std::string name = player->getName();
|
||||
actionstream << name << " "
|
||||
<< (reason == CDR_TIMEOUT ? "times out." : "leaves game.")
|
||||
<< " List of players: " << os.str() << std::endl;
|
||||
if (m_admin_chat)
|
||||
m_admin_chat->outgoing_queue.push_back(
|
||||
new ChatEventNick(CET_NICK_REMOVE, name));
|
||||
}
|
||||
}
|
||||
{
|
||||
@ -2700,6 +2749,77 @@ void Server::UpdateCrafting(Player* player)
|
||||
plist->changeItem(0, preview);
|
||||
}
|
||||
|
||||
std::wstring Server::handleChat(const std::string &name, const std::wstring &wname,
|
||||
const std::wstring &wmessage, u16 peer_id_to_avoid_sending)
|
||||
{
|
||||
// If something goes wrong, this player is to blame
|
||||
RollbackScopeActor rollback_scope(m_rollback,
|
||||
std::string("player:") + name);
|
||||
|
||||
// Line to send
|
||||
std::wstring line;
|
||||
// Whether to send line to the player that sent the message, or to all players
|
||||
bool broadcast_line = true;
|
||||
|
||||
// Run script hook
|
||||
bool ate = m_script->on_chat_message(name,
|
||||
wide_to_utf8(wmessage));
|
||||
// If script ate the message, don't proceed
|
||||
if (ate)
|
||||
return L"";
|
||||
|
||||
// Commands are implemented in Lua, so only catch invalid
|
||||
// commands that were not "eaten" and send an error back
|
||||
if (wmessage[0] == L'/') {
|
||||
std::wstring wcmd = wmessage.substr(1);
|
||||
broadcast_line = false;
|
||||
if (wcmd.length() == 0)
|
||||
line += L"-!- Empty command";
|
||||
else
|
||||
line += L"-!- Invalid command: " + str_split(wcmd, L' ')[0];
|
||||
} else {
|
||||
line += L"<";
|
||||
line += wname;
|
||||
line += L"> ";
|
||||
line += wmessage;
|
||||
}
|
||||
|
||||
/*
|
||||
Tell calling method to send the message to sender
|
||||
*/
|
||||
if (!broadcast_line) {
|
||||
return line;
|
||||
} else {
|
||||
/*
|
||||
Send the message to others
|
||||
*/
|
||||
actionstream << "CHAT: " << wide_to_narrow(line) << std::endl;
|
||||
|
||||
std::vector<u16> clients = m_clients.getClientIDs();
|
||||
|
||||
for (u16 i = 0; i < clients.size(); i++) {
|
||||
u16 cid = clients[i];
|
||||
if (cid != peer_id_to_avoid_sending)
|
||||
SendChatMessage(cid, line);
|
||||
}
|
||||
}
|
||||
return L"";
|
||||
}
|
||||
|
||||
void Server::handleAdminChat(const ChatEventChat *evt)
|
||||
{
|
||||
std::string name = evt->nick;
|
||||
std::wstring wname = utf8_to_wide(name);
|
||||
std::wstring wmessage = evt->evt_msg;
|
||||
|
||||
std::wstring answer = handleChat(name, wname, wmessage);
|
||||
|
||||
// If asked to send answer to sender
|
||||
if (!answer.empty()) {
|
||||
m_admin_chat->outgoing_queue.push_back(new ChatEventChat("", answer));
|
||||
}
|
||||
}
|
||||
|
||||
RemoteClient* Server::getClient(u16 peer_id, ClientState state_min)
|
||||
{
|
||||
RemoteClient *client = getClientNoEx(peer_id,state_min);
|
||||
@ -2831,9 +2951,14 @@ void Server::notifyPlayer(const char *name, const std::wstring &msg)
|
||||
if (!m_env)
|
||||
return;
|
||||
|
||||
if (m_admin_nick == name && !m_admin_nick.empty()) {
|
||||
m_admin_chat->outgoing_queue.push_back(new ChatEventChat("", msg));
|
||||
}
|
||||
|
||||
Player *player = m_env->getPlayer(name);
|
||||
if (!player)
|
||||
if (!player) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (player->peer_id == PEER_ID_INEXISTENT)
|
||||
return;
|
||||
|
15
src/server.h
15
src/server.h
@ -32,6 +32,7 @@ with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
#include "util/numeric.h"
|
||||
#include "util/thread.h"
|
||||
#include "environment.h"
|
||||
#include "chat_interface.h"
|
||||
#include "clientiface.h"
|
||||
#include "network/networkpacket.h"
|
||||
#include <string>
|
||||
@ -171,7 +172,8 @@ public:
|
||||
const std::string &path_world,
|
||||
const SubgameSpec &gamespec,
|
||||
bool simple_singleplayer_mode,
|
||||
bool ipv6
|
||||
bool ipv6,
|
||||
ChatInterface *iface = NULL
|
||||
);
|
||||
~Server();
|
||||
void start(Address bind_addr);
|
||||
@ -369,6 +371,8 @@ public:
|
||||
u8* ser_vers, u16* prot_vers, u8* major, u8* minor, u8* patch,
|
||||
std::string* vers_string);
|
||||
|
||||
void printToConsoleOnly(const std::string &text);
|
||||
|
||||
void SendPlayerHPOrDie(PlayerSAO *player);
|
||||
void SendPlayerBreath(u16 peer_id);
|
||||
void SendInventory(PlayerSAO* playerSAO);
|
||||
@ -472,6 +476,12 @@ private:
|
||||
void DeleteClient(u16 peer_id, ClientDeletionReason reason);
|
||||
void UpdateCrafting(Player *player);
|
||||
|
||||
// This returns the answer to the sender of wmessage, or "" if there is none
|
||||
std::wstring handleChat(const std::string &name, const std::wstring &wname,
|
||||
const std::wstring &wmessage,
|
||||
u16 peer_id_to_avoid_sending = PEER_ID_INEXISTENT);
|
||||
void handleAdminChat(const ChatEventChat *evt);
|
||||
|
||||
v3f findSpawnPos();
|
||||
|
||||
// When called, connection mutex should be locked
|
||||
@ -597,6 +607,9 @@ private:
|
||||
std::string m_shutdown_msg;
|
||||
bool m_shutdown_ask_reconnect;
|
||||
|
||||
ChatInterface *m_admin_chat;
|
||||
std::string m_admin_nick;
|
||||
|
||||
/*
|
||||
Map edit event queue. Automatically receives all map edits.
|
||||
The constructor of this class registers us to receive them through
|
||||
|
452
src/terminal_chat_console.cpp
Normal file
452
src/terminal_chat_console.cpp
Normal file
@ -0,0 +1,452 @@
|
||||
/*
|
||||
Minetest
|
||||
Copyright (C) 2015 est31 <MTest31@outlook.com>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU Lesser General Public License as published by
|
||||
the Free Software Foundation; either version 2.1 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
#if USE_CURSES
|
||||
#include "version.h"
|
||||
#include "terminal_chat_console.h"
|
||||
#include "porting.h"
|
||||
#include "settings.h"
|
||||
#include "util/numeric.h"
|
||||
#include "util/string.h"
|
||||
|
||||
TerminalChatConsole g_term_console;
|
||||
|
||||
// include this last to avoid any conflicts
|
||||
// (likes to set macros to common names, conflicting various stuff)
|
||||
#if CURSES_HAVE_NCURSESW_NCURSES_H
|
||||
#include <ncursesw/ncurses.h>
|
||||
#elif CURSES_HAVE_NCURSESW_CURSES_H
|
||||
#include <ncursesw/curses.h>
|
||||
#elif CURSES_HAVE_CURSES_H
|
||||
#include <curses.h>
|
||||
#elif CURSES_HAVE_NCURSES_H
|
||||
#include <ncurses.h>
|
||||
#elif CURSES_HAVE_NCURSES_NCURSES_H
|
||||
#include <ncurses/ncurses.h>
|
||||
#elif CURSES_HAVE_NCURSES_CURSES_H
|
||||
#include <ncurses/curses.h>
|
||||
#endif
|
||||
|
||||
// Some functions to make drawing etc position independent
|
||||
static bool reformat_backend(ChatBackend *backend, int rows, int cols)
|
||||
{
|
||||
if (rows < 2)
|
||||
return false;
|
||||
backend->reformat(cols, rows - 2);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void move_for_backend(int row, int col)
|
||||
{
|
||||
move(row + 1, col);
|
||||
}
|
||||
|
||||
void TerminalChatConsole::initOfCurses()
|
||||
{
|
||||
initscr();
|
||||
cbreak(); //raw();
|
||||
noecho();
|
||||
keypad(stdscr, TRUE);
|
||||
nodelay(stdscr, TRUE);
|
||||
timeout(100);
|
||||
|
||||
// To make esc not delay up to one second. According to the internet,
|
||||
// this is the value vim uses, too.
|
||||
set_escdelay(25);
|
||||
|
||||
getmaxyx(stdscr, m_rows, m_cols);
|
||||
m_can_draw_text = reformat_backend(&m_chat_backend, m_rows, m_cols);
|
||||
}
|
||||
|
||||
void TerminalChatConsole::deInitOfCurses()
|
||||
{
|
||||
endwin();
|
||||
}
|
||||
|
||||
void *TerminalChatConsole::run()
|
||||
{
|
||||
BEGIN_DEBUG_EXCEPTION_HANDLER
|
||||
|
||||
std::cout << "========================" << std::endl;
|
||||
std::cout << "Begin log output over terminal"
|
||||
<< " (no stdout/stderr backlog during that)" << std::endl;
|
||||
// Make the loggers to stdout/stderr shut up.
|
||||
// Go over our own loggers instead.
|
||||
LogLevelMask err_mask = g_logger.removeOutput(&stderr_output);
|
||||
LogLevelMask out_mask = g_logger.removeOutput(&stdout_output);
|
||||
|
||||
g_logger.addOutput(&m_log_output);
|
||||
|
||||
// Inform the server of our nick
|
||||
m_chat_interface->command_queue.push_back(
|
||||
new ChatEventNick(CET_NICK_ADD, m_nick));
|
||||
|
||||
{
|
||||
// Ensures that curses is deinitialized even on an exception being thrown
|
||||
CursesInitHelper helper(this);
|
||||
|
||||
while (!stopRequested()) {
|
||||
|
||||
int ch = getch();
|
||||
if (stopRequested())
|
||||
break;
|
||||
|
||||
step(ch);
|
||||
}
|
||||
}
|
||||
|
||||
if (m_kill_requested)
|
||||
*m_kill_requested = true;
|
||||
|
||||
g_logger.removeOutput(&m_log_output);
|
||||
g_logger.addOutputMasked(&stderr_output, err_mask);
|
||||
g_logger.addOutputMasked(&stdout_output, out_mask);
|
||||
|
||||
std::cout << "End log output over terminal"
|
||||
<< " (no stdout/stderr backlog during that)" << std::endl;
|
||||
std::cout << "========================" << std::endl;
|
||||
|
||||
END_DEBUG_EXCEPTION_HANDLER
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void TerminalChatConsole::typeChatMessage(const std::wstring &msg)
|
||||
{
|
||||
// Discard empty line
|
||||
if (msg.empty())
|
||||
return;
|
||||
|
||||
// Send to server
|
||||
m_chat_interface->command_queue.push_back(
|
||||
new ChatEventChat(m_nick, msg));
|
||||
|
||||
// Print if its a command (gets eaten by server otherwise)
|
||||
if (msg[0] == L'/') {
|
||||
m_chat_backend.addMessage(L"", (std::wstring)L"Issued command: " + msg);
|
||||
}
|
||||
}
|
||||
|
||||
void TerminalChatConsole::handleInput(int ch, bool &complete_redraw_needed)
|
||||
{
|
||||
// Helpful if you want to collect key codes that aren't documented
|
||||
/*if (ch != ERR) {
|
||||
m_chat_backend.addMessage(L"",
|
||||
(std::wstring)L"Pressed key " + utf8_to_wide(
|
||||
std::string(keyname(ch)) + " (code " + itos(ch) + ")"));
|
||||
complete_redraw_needed = true;
|
||||
}//*/
|
||||
|
||||
// All the key codes below are compatible to xterm
|
||||
// Only add new ones if you have tried them there,
|
||||
// to ensure compatibility with not just xterm but the wide
|
||||
// range of terminals that are compatible to xterm.
|
||||
|
||||
switch (ch) {
|
||||
case ERR: // no input
|
||||
break;
|
||||
case 27: // ESC
|
||||
// Toggle ESC mode
|
||||
m_esc_mode = !m_esc_mode;
|
||||
break;
|
||||
case KEY_PPAGE:
|
||||
m_chat_backend.scrollPageUp();
|
||||
complete_redraw_needed = true;
|
||||
break;
|
||||
case KEY_NPAGE:
|
||||
m_chat_backend.scrollPageDown();
|
||||
complete_redraw_needed = true;
|
||||
break;
|
||||
case KEY_ENTER:
|
||||
case '\r':
|
||||
case '\n': {
|
||||
std::wstring text = m_chat_backend.getPrompt().submit();
|
||||
typeChatMessage(text);
|
||||
break;
|
||||
}
|
||||
case KEY_UP:
|
||||
m_chat_backend.getPrompt().historyPrev();
|
||||
break;
|
||||
case KEY_DOWN:
|
||||
m_chat_backend.getPrompt().historyNext();
|
||||
break;
|
||||
case KEY_LEFT:
|
||||
// Left pressed
|
||||
// move character to the left
|
||||
m_chat_backend.getPrompt().cursorOperation(
|
||||
ChatPrompt::CURSOROP_MOVE,
|
||||
ChatPrompt::CURSOROP_DIR_LEFT,
|
||||
ChatPrompt::CURSOROP_SCOPE_CHARACTER);
|
||||
break;
|
||||
case 545:
|
||||
// Ctrl-Left pressed
|
||||
// move word to the left
|
||||
m_chat_backend.getPrompt().cursorOperation(
|
||||
ChatPrompt::CURSOROP_MOVE,
|
||||
ChatPrompt::CURSOROP_DIR_LEFT,
|
||||
ChatPrompt::CURSOROP_SCOPE_WORD);
|
||||
break;
|
||||
case KEY_RIGHT:
|
||||
// Right pressed
|
||||
// move character to the right
|
||||
m_chat_backend.getPrompt().cursorOperation(
|
||||
ChatPrompt::CURSOROP_MOVE,
|
||||
ChatPrompt::CURSOROP_DIR_RIGHT,
|
||||
ChatPrompt::CURSOROP_SCOPE_CHARACTER);
|
||||
break;
|
||||
case 560:
|
||||
// Ctrl-Right pressed
|
||||
// move word to the right
|
||||
m_chat_backend.getPrompt().cursorOperation(
|
||||
ChatPrompt::CURSOROP_MOVE,
|
||||
ChatPrompt::CURSOROP_DIR_RIGHT,
|
||||
ChatPrompt::CURSOROP_SCOPE_WORD);
|
||||
break;
|
||||
case KEY_HOME:
|
||||
// Home pressed
|
||||
// move to beginning of line
|
||||
m_chat_backend.getPrompt().cursorOperation(
|
||||
ChatPrompt::CURSOROP_MOVE,
|
||||
ChatPrompt::CURSOROP_DIR_LEFT,
|
||||
ChatPrompt::CURSOROP_SCOPE_LINE);
|
||||
break;
|
||||
case KEY_END:
|
||||
// End pressed
|
||||
// move to end of line
|
||||
m_chat_backend.getPrompt().cursorOperation(
|
||||
ChatPrompt::CURSOROP_MOVE,
|
||||
ChatPrompt::CURSOROP_DIR_RIGHT,
|
||||
ChatPrompt::CURSOROP_SCOPE_LINE);
|
||||
break;
|
||||
case KEY_BACKSPACE:
|
||||
case '\b':
|
||||
case 127:
|
||||
// Backspace pressed
|
||||
// delete character to the left
|
||||
m_chat_backend.getPrompt().cursorOperation(
|
||||
ChatPrompt::CURSOROP_DELETE,
|
||||
ChatPrompt::CURSOROP_DIR_LEFT,
|
||||
ChatPrompt::CURSOROP_SCOPE_CHARACTER);
|
||||
break;
|
||||
case KEY_DC:
|
||||
// Delete pressed
|
||||
// delete character to the right
|
||||
m_chat_backend.getPrompt().cursorOperation(
|
||||
ChatPrompt::CURSOROP_DELETE,
|
||||
ChatPrompt::CURSOROP_DIR_RIGHT,
|
||||
ChatPrompt::CURSOROP_SCOPE_CHARACTER);
|
||||
break;
|
||||
case 519:
|
||||
// Ctrl-Delete pressed
|
||||
// delete word to the right
|
||||
m_chat_backend.getPrompt().cursorOperation(
|
||||
ChatPrompt::CURSOROP_DELETE,
|
||||
ChatPrompt::CURSOROP_DIR_RIGHT,
|
||||
ChatPrompt::CURSOROP_SCOPE_WORD);
|
||||
break;
|
||||
case 21:
|
||||
// Ctrl-U pressed
|
||||
// kill line to left end
|
||||
m_chat_backend.getPrompt().cursorOperation(
|
||||
ChatPrompt::CURSOROP_DELETE,
|
||||
ChatPrompt::CURSOROP_DIR_LEFT,
|
||||
ChatPrompt::CURSOROP_SCOPE_LINE);
|
||||
break;
|
||||
case 11:
|
||||
// Ctrl-K pressed
|
||||
// kill line to right end
|
||||
m_chat_backend.getPrompt().cursorOperation(
|
||||
ChatPrompt::CURSOROP_DELETE,
|
||||
ChatPrompt::CURSOROP_DIR_RIGHT,
|
||||
ChatPrompt::CURSOROP_SCOPE_LINE);
|
||||
break;
|
||||
case KEY_TAB:
|
||||
// Tab pressed
|
||||
// Nick completion
|
||||
m_chat_backend.getPrompt().nickCompletion(m_nicks, false);
|
||||
break;
|
||||
default:
|
||||
// Add character to the prompt,
|
||||
// assuming UTF-8.
|
||||
if (IS_UTF8_MULTB_START(ch)) {
|
||||
m_pending_utf8_bytes.append(1, (char)ch);
|
||||
m_utf8_bytes_to_wait += UTF8_MULTB_START_LEN(ch) - 1;
|
||||
} else if (m_utf8_bytes_to_wait != 0) {
|
||||
m_pending_utf8_bytes.append(1, (char)ch);
|
||||
m_utf8_bytes_to_wait--;
|
||||
if (m_utf8_bytes_to_wait == 0) {
|
||||
std::wstring w = utf8_to_wide(m_pending_utf8_bytes);
|
||||
m_pending_utf8_bytes = "";
|
||||
// hopefully only one char in the wstring...
|
||||
for (size_t i = 0; i < w.size(); i++) {
|
||||
m_chat_backend.getPrompt().input(w.c_str()[i]);
|
||||
}
|
||||
}
|
||||
} else if (IS_ASCII_PRINTABLE_CHAR(ch)) {
|
||||
m_chat_backend.getPrompt().input(ch);
|
||||
} else {
|
||||
// Silently ignore characters we don't handle
|
||||
|
||||
//warningstream << "Pressed invalid character '"
|
||||
// << keyname(ch) << "' (code " << itos(ch) << ")" << std::endl;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void TerminalChatConsole::step(int ch)
|
||||
{
|
||||
bool complete_redraw_needed = false;
|
||||
|
||||
// empty queues
|
||||
while (!m_chat_interface->outgoing_queue.empty()) {
|
||||
ChatEvent *evt = m_chat_interface->outgoing_queue.pop_frontNoEx();
|
||||
switch (evt->type) {
|
||||
case CET_NICK_REMOVE:
|
||||
m_nicks.remove(((ChatEventNick *)evt)->nick);
|
||||
break;
|
||||
case CET_NICK_ADD:
|
||||
m_nicks.push_back(((ChatEventNick *)evt)->nick);
|
||||
break;
|
||||
case CET_CHAT:
|
||||
complete_redraw_needed = true;
|
||||
// This is only used for direct replies from commands
|
||||
// or for lua's print() functionality
|
||||
m_chat_backend.addMessage(L"", ((ChatEventChat *)evt)->evt_msg);
|
||||
break;
|
||||
case CET_TIME_INFO:
|
||||
ChatEventTimeInfo *tevt = (ChatEventTimeInfo *)evt;
|
||||
m_game_time = tevt->game_time;
|
||||
m_time_of_day = tevt->time;
|
||||
};
|
||||
delete evt;
|
||||
}
|
||||
while (!m_log_output.queue.empty()) {
|
||||
complete_redraw_needed = true;
|
||||
std::pair<LogLevel, std::string> p = m_log_output.queue.pop_frontNoEx();
|
||||
if (p.first > m_log_level)
|
||||
continue;
|
||||
|
||||
m_chat_backend.addMessage(
|
||||
utf8_to_wide(Logger::getLevelLabel(p.first)),
|
||||
utf8_to_wide(p.second));
|
||||
}
|
||||
|
||||
// handle input
|
||||
if (!m_esc_mode) {
|
||||
handleInput(ch, complete_redraw_needed);
|
||||
} else {
|
||||
switch (ch) {
|
||||
case ERR: // no input
|
||||
break;
|
||||
case 27: // ESC
|
||||
// Toggle ESC mode
|
||||
m_esc_mode = !m_esc_mode;
|
||||
break;
|
||||
case 'L':
|
||||
m_log_level--;
|
||||
m_log_level = MYMAX(m_log_level, LL_NONE + 1); // LL_NONE isn't accessible
|
||||
break;
|
||||
case 'l':
|
||||
m_log_level++;
|
||||
m_log_level = MYMIN(m_log_level, LL_MAX - 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// was there a resize?
|
||||
int xn, yn;
|
||||
getmaxyx(stdscr, yn, xn);
|
||||
if (xn != m_cols || yn != m_rows) {
|
||||
m_cols = xn;
|
||||
m_rows = yn;
|
||||
m_can_draw_text = reformat_backend(&m_chat_backend, m_rows, m_cols);
|
||||
complete_redraw_needed = true;
|
||||
}
|
||||
|
||||
// draw title
|
||||
move(0, 0);
|
||||
clrtoeol();
|
||||
addstr(PROJECT_NAME_C);
|
||||
addstr(" ");
|
||||
addstr(g_version_hash);
|
||||
|
||||
u32 minutes = m_time_of_day % 1000;
|
||||
u32 hours = m_time_of_day / 1000;
|
||||
minutes = (float)minutes / 1000 * 60;
|
||||
|
||||
if (m_game_time)
|
||||
printw(" | Game %d Time of day %02d:%02d ",
|
||||
m_game_time, hours, minutes);
|
||||
|
||||
// draw text
|
||||
if (complete_redraw_needed && m_can_draw_text)
|
||||
draw_text();
|
||||
|
||||
// draw prompt
|
||||
if (!m_esc_mode) {
|
||||
// normal prompt
|
||||
ChatPrompt& prompt = m_chat_backend.getPrompt();
|
||||
std::string prompt_text = wide_to_utf8(prompt.getVisiblePortion());
|
||||
move(m_rows - 1, 0);
|
||||
clrtoeol();
|
||||
addstr(prompt_text.c_str());
|
||||
// Draw cursor
|
||||
s32 cursor_pos = prompt.getVisibleCursorPosition();
|
||||
if (cursor_pos >= 0) {
|
||||
move(m_rows - 1, cursor_pos);
|
||||
}
|
||||
} else {
|
||||
// esc prompt
|
||||
move(m_rows - 1, 0);
|
||||
clrtoeol();
|
||||
printw("[ESC] Toggle ESC mode |"
|
||||
" [CTRL+C] Shut down |"
|
||||
" (L) in-, (l) decrease loglevel %s",
|
||||
Logger::getLevelLabel((LogLevel) m_log_level).c_str());
|
||||
}
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
void TerminalChatConsole::draw_text()
|
||||
{
|
||||
ChatBuffer& buf = m_chat_backend.getConsoleBuffer();
|
||||
for (u32 row = 0; row < buf.getRows(); row++) {
|
||||
move_for_backend(row, 0);
|
||||
clrtoeol();
|
||||
const ChatFormattedLine& line = buf.getFormattedLine(row);
|
||||
if (line.fragments.empty())
|
||||
continue;
|
||||
for (u32 i = 0; i < line.fragments.size(); ++i) {
|
||||
const ChatFormattedFragment& fragment = line.fragments[i];
|
||||
addstr(wide_to_utf8(fragment.text).c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void TerminalChatConsole::stopAndWaitforThread()
|
||||
{
|
||||
clearKillStatus();
|
||||
stop();
|
||||
wait();
|
||||
}
|
||||
|
||||
#endif
|
131
src/terminal_chat_console.h
Normal file
131
src/terminal_chat_console.h
Normal file
@ -0,0 +1,131 @@
|
||||
/*
|
||||
Minetest
|
||||
Copyright (C) 2015 est31 <MTest31@outlook.com>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU Lesser General Public License as published by
|
||||
the Free Software Foundation; either version 2.1 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
*/
|
||||
|
||||
#ifndef TERMINAL_CHAT_CONSOLE_H
|
||||
#define TERMINAL_CHAT_CONSOLE_H
|
||||
|
||||
#include "chat.h"
|
||||
#include "threading/thread.h"
|
||||
#include "chat_interface.h"
|
||||
#include "log.h"
|
||||
|
||||
#include <sstream>
|
||||
|
||||
class TermLogOutput : public ILogOutput {
|
||||
public:
|
||||
|
||||
void logRaw(LogLevel lev, const std::string &line)
|
||||
{
|
||||
queue.push_back(std::make_pair(lev, line));
|
||||
}
|
||||
|
||||
virtual void log(LogLevel lev, const std::string &combined,
|
||||
const std::string &time, const std::string &thread_name,
|
||||
const std::string &payload_text)
|
||||
{
|
||||
std::ostringstream os(std::ios_base::binary);
|
||||
os << time << ": [" << thread_name << "] " << payload_text;
|
||||
|
||||
queue.push_back(std::make_pair(lev, os.str()));
|
||||
}
|
||||
|
||||
MutexedQueue<std::pair<LogLevel, std::string> > queue;
|
||||
};
|
||||
|
||||
class TerminalChatConsole : public Thread {
|
||||
public:
|
||||
|
||||
TerminalChatConsole() :
|
||||
Thread("TerminalThread"),
|
||||
m_log_level(LL_ACTION),
|
||||
m_utf8_bytes_to_wait(0),
|
||||
m_kill_requested(NULL),
|
||||
m_esc_mode(false),
|
||||
m_game_time(0),
|
||||
m_time_of_day(0)
|
||||
{}
|
||||
|
||||
void setup(
|
||||
ChatInterface *iface,
|
||||
bool *kill_requested,
|
||||
const std::string &nick)
|
||||
{
|
||||
m_nick = nick;
|
||||
m_kill_requested = kill_requested;
|
||||
m_chat_interface = iface;
|
||||
}
|
||||
|
||||
virtual void *run();
|
||||
|
||||
// Highly required!
|
||||
void clearKillStatus() { m_kill_requested = NULL; }
|
||||
|
||||
void stopAndWaitforThread();
|
||||
|
||||
private:
|
||||
// these have stupid names so that nobody missclassifies them
|
||||
// as curses functions. Oh, curses has stupid names too?
|
||||
// Well, at least it was worth a try...
|
||||
void initOfCurses();
|
||||
void deInitOfCurses();
|
||||
|
||||
void draw_text();
|
||||
|
||||
void typeChatMessage(const std::wstring &m);
|
||||
|
||||
void handleInput(int ch, bool &complete_redraw_needed);
|
||||
|
||||
void step(int ch);
|
||||
|
||||
// Used to ensure the deinitialisation is always called.
|
||||
struct CursesInitHelper {
|
||||
TerminalChatConsole *cons;
|
||||
CursesInitHelper(TerminalChatConsole * a_console)
|
||||
: cons(a_console)
|
||||
{ cons->initOfCurses(); }
|
||||
~CursesInitHelper() { cons->deInitOfCurses(); }
|
||||
};
|
||||
|
||||
int m_log_level;
|
||||
std::string m_nick;
|
||||
|
||||
u8 m_utf8_bytes_to_wait;
|
||||
std::string m_pending_utf8_bytes;
|
||||
|
||||
std::list<std::string> m_nicks;
|
||||
|
||||
int m_cols;
|
||||
int m_rows;
|
||||
bool m_can_draw_text;
|
||||
|
||||
bool *m_kill_requested;
|
||||
ChatBackend m_chat_backend;
|
||||
ChatInterface *m_chat_interface;
|
||||
|
||||
TermLogOutput m_log_output;
|
||||
|
||||
bool m_esc_mode;
|
||||
|
||||
u64 m_game_time;
|
||||
u32 m_time_of_day;
|
||||
};
|
||||
|
||||
extern TerminalChatConsole g_term_console;
|
||||
|
||||
#endif
|
@ -43,6 +43,7 @@ public:
|
||||
void testStrToIntConversion();
|
||||
void testStringReplace();
|
||||
void testStringAllowed();
|
||||
void testAsciiPrintableHelper();
|
||||
void testUTF8();
|
||||
void testWrapRows();
|
||||
void testIsNumber();
|
||||
@ -68,6 +69,7 @@ void TestUtilities::runTests(IGameDef *gamedef)
|
||||
TEST(testStrToIntConversion);
|
||||
TEST(testStringReplace);
|
||||
TEST(testStringAllowed);
|
||||
TEST(testAsciiPrintableHelper);
|
||||
TEST(testUTF8);
|
||||
TEST(testWrapRows);
|
||||
TEST(testIsNumber);
|
||||
@ -232,6 +234,18 @@ void TestUtilities::testStringAllowed()
|
||||
UASSERT(string_allowed_blacklist("hello123", "123") == false);
|
||||
}
|
||||
|
||||
void TestUtilities::testAsciiPrintableHelper()
|
||||
{
|
||||
UASSERT(IS_ASCII_PRINTABLE_CHAR('e') == true);
|
||||
UASSERT(IS_ASCII_PRINTABLE_CHAR('\0') == false);
|
||||
|
||||
// Ensures that there is no cutting off going on...
|
||||
// If there were, 331 would be cut to 75 in this example
|
||||
// and 73 is a valid ASCII char.
|
||||
int ch = 331;
|
||||
UASSERT(IS_ASCII_PRINTABLE_CHAR(ch) == false);
|
||||
}
|
||||
|
||||
void TestUtilities::testUTF8()
|
||||
{
|
||||
UASSERT(wide_to_utf8(utf8_to_wide("")) == "");
|
||||
|
@ -32,8 +32,26 @@ with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
#define STRINGIFY(x) #x
|
||||
#define TOSTRING(x) STRINGIFY(x)
|
||||
|
||||
// Checks whether a value is an ASCII printable character
|
||||
#define IS_ASCII_PRINTABLE_CHAR(x) \
|
||||
(((unsigned int)(x) >= 0x20) && \
|
||||
( (unsigned int)(x) <= 0x7e))
|
||||
|
||||
// Checks whether a byte is an inner byte for an utf-8 multibyte sequence
|
||||
#define IS_UTF8_MULTB_INNER(x) (((unsigned char)x >= 0x80) && ((unsigned char)x < 0xc0))
|
||||
#define IS_UTF8_MULTB_INNER(x) \
|
||||
(((unsigned char)(x) >= 0x80) && \
|
||||
( (unsigned char)(x) <= 0xbf))
|
||||
|
||||
// Checks whether a byte is a start byte for an utf-8 multibyte sequence
|
||||
#define IS_UTF8_MULTB_START(x) \
|
||||
(((unsigned char)(x) >= 0xc2) && \
|
||||
( (unsigned char)(x) <= 0xf4))
|
||||
|
||||
// Given a start byte x for an utf-8 multibyte sequence
|
||||
// it gives the length of the whole sequence in bytes.
|
||||
#define UTF8_MULTB_START_LEN(x) \
|
||||
(((unsigned char)(x) < 0xe0) ? 2 : \
|
||||
(((unsigned char)(x) < 0xf0) ? 3 : 4))
|
||||
|
||||
typedef std::map<std::string, std::string> StringMap;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user