You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
koreader/plugins/terminal.koplugin/main.lua

606 lines
21 KiB
Lua

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

--[[--
This plugin provides a terminal emulator (VT52 (+some ANSI))
@module koplugin.terminal
]]
local Device = require("device")
local ffi = require("ffi")
local C = ffi.C
-- for terminal emulator
ffi.cdef[[
static const int SIGTERM = 15;
int grantpt(int fd) __attribute__((nothrow, leaf));
int unlockpt(int fd) __attribute__((nothrow, leaf));
char *ptsname(int fd) __attribute__((nothrow, leaf));
pid_t setsid(void) __attribute__((nothrow, leaf));
static const int TCIFLUSH = 0;
int tcdrain(int fd) __attribute__((nothrow, leaf));
int tcflush(int fd, int queue_selector) __attribute__((nothrow, leaf));
]]
local function check_prerequisites()
local ptmx_name = "/dev/ptmx"
local ptmx = C.open(ptmx_name, bit.bor(C.O_RDWR, C.O_NONBLOCK, C.O_CLOEXEC))
if C.grantpt(ptmx) ~= 0 then
C.close(ptmx)
return false
end
if C.unlockpt(ptmx) ~= 0 then
C.close(ptmx)
return false
end
C.close(ptmx)
return true
end
-- grantpt and friends are necessary (introduced on Android in API 21).
-- So sorry for the Tolinos with (Android 4.4.x).
-- Maybe https://f-droid.org/de/packages/jackpal.androidterm/ could be an alternative then.
if (Device:isAndroid() and Device.firmware_rev < 21) or not check_prerequisites() then
return
end
local Aliases = require("aliases")
local Dispatcher = require("dispatcher")
local DataStorage = require("datastorage")
local Font = require("ui/font")
local InfoMessage = require("ui/widget/infomessage")
local InputDialog = require("ui/widget/inputdialog")
local MultiConfirmBox = require("ui/widget/multiconfirmbox")
local ScrollTextWidget = require("ui/widget/scrolltextwidget")
local SpinWidget = require("ui/widget/spinwidget")
local UIManager = require("ui/uimanager")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local TermInputText = require("terminputtext")
local TextWidget = require("ui/widget/textwidget")
local bit = require("bit")
local lfs = require("libs/libkoreader-lfs")
local logger = require("logger")
local _ = require("gettext")
local T = require("ffi/util").template
local CHUNK_SIZE = 80 * 40 -- max. nb of read bytes (reduce this, if taps are not detected)
local Terminal = WidgetContainer:new{
name = "terminal",
history = "",
is_shell_open = false,
buffer_size = 1024 * G_reader_settings:readSetting("terminal_buffer_size", 16), -- size in kB
refresh_time = 0.2,
terminal_data = ".",
}
function Terminal:init()
self:onDispatcherRegisterActions()
self.ui.menu:registerToMainMenu(self)
self.chunk_size = CHUNK_SIZE
self.chunk = ffi.new('uint8_t[?]', self.chunk_size)
self.terminal_data = DataStorage:getDataDir()
lfs.mkdir(self.terminal_data .. "/scripts")
os.remove("terminal.pid") -- clean leftover from last run
end
function Terminal:spawnShell(cols, rows)
if self.is_shell_open then
self.input_widget:resize(rows, cols)
self.input_widget:interpretAnsiSeq(self:receive())
return true
end
local shell = G_reader_settings:readSetting("terminal_shell", "sh")
local ptmx_name = "/dev/ptmx"
self.ptmx = C.open(ptmx_name, bit.bor(C.O_RDWR, C.O_NONBLOCK, C.O_CLOEXEC))
if self.ptmx == -1 then
logger.err("Terminal: can not open", ptmx_name, ffi.string(C.strerror(ffi.errno())))
return false
end
if C.grantpt(self.ptmx) ~= 0 then
logger.err("Terminal: can not grantpt", ffi.string(C.strerror(ffi.errno())))
C.close(self.ptmx)
return false
end
if C.unlockpt(self.ptmx) ~= 0 then
logger.err("Terminal: can not unockpt", ffi.string(C.strerror(ffi.errno())))
C.close(self.ptmx)
return false
end
local ptsname = C.ptsname(self.ptmx)
if ptsname then
self.slave_pty = ffi.string(ptsname)
else
logger.err("Terminal: ptsname failed")
C.close(self.ptmx)
return false
end
logger.dbg("Terminal: slave_pty", self.slave_pty)
local pid = C.fork()
if pid < 0 then
logger.err("Terminal: fork failed", ffi.string(C.strerror(ffi.errno())))
return false
elseif pid == 0 then
C.close(self.ptmx)
C.setsid()
pid = C.getpid()
local pid_file = io.open("terminal.pid", "w")
if pid_file then
pid_file:write(pid)
pid_file:close()
end
local pts = C.open(self.slave_pty, C.O_RDWR)
if pts == -1 then
logger.err("Terminal: cannot open slave pty: ", pts)
return false
end
C.dup2(pts, 0);
C.dup2(pts, 1);
C.dup2(pts, 2);
C.close(pts);
if cols and rows then
if not Device:isAndroid() then
os.execute("stty cols " .. cols .. " rows " .. rows)
end
end
C.setenv("TERM", "vt52", 1)
C.setenv("ENV", "./plugins/terminal.koplugin/profile", 1)
C.setenv("BASH_ENV", "./plugins/terminal.koplugin/profile", 1)
C.setenv("TERMINAL_DATA", self.terminal_data, 1)
if Device:isAndroid() then
C.setenv("ANDROID", "ANDROID", 1)
end
if C.execlp(shell, shell) ~= 0 then
-- the following two prints are shown in the terminal emulator.
print("Terminal: something has gone really wrong in spawning the shell\n\n:-(\n")
print("Maybe an incorrect shell: '" .. shell .. "'\n")
os.exit()
end
os.exit()
return
end
self.is_shell_open = true
if Device:isAndroid() then
-- feed the following commands to the running shell
self:transmit("export TERM=vt52\n")
self:transmit("stty cols " .. cols .. " rows " .. rows .."\n")
end
self.input_widget:resize(rows, cols)
self.input_widget:interpretAnsiSeq(self:receive())
logger.info("Terminal: spawn done")
return true
end
function Terminal:receive()
local last_result = ""
repeat
C.tcdrain(self.ptmx)
local count = tonumber(C.read(self.ptmx, self.chunk, self.chunk_size))
if count > 0 then
last_result = last_result .. string.sub(ffi.string(self.chunk), 1, count)
end
until count <= 0 or #last_result >= self.chunk_size - 1
return last_result
end
function Terminal:refresh(reset)
if reset then
self.refresh_time = 1/32
UIManager:unschedule(Terminal.refresh)
end
local next_text = self:receive()
if next_text ~= "" then
self.input_widget:interpretAnsiSeq(next_text)
self.input_widget:trimBuffer(self.buffer_size)
if self.is_shell_open then
UIManager:tickAfterNext(function()
UIManager:scheduleIn(self.refresh_time, Terminal.refresh, self)
end)
end
else
if self.is_shell_open then
if self.refresh_time > 5 then
self.refresh_time = self.refresh_time
elseif self.refresh_time > 1 then
self.refresh_time = self.refresh_time * 1.1
else
self.refresh_time = self.refresh_time * 2
end
UIManager:scheduleIn(self.refresh_time, Terminal.refresh, self)
end
end
end
function Terminal:transmit(chars)
C.write(self.ptmx, chars, #chars)
self:refresh(true)
end
--- kills a running shell
-- @param ask if true ask if a shell is running, don't kill
-- @return pid if shell is running, -1 otherwise
function Terminal:killShell(ask)
UIManager:unschedule(Terminal.refresh)
local pid_file = io.open("terminal.pid", "r")
if not pid_file then
return -1
end
local pid = pid_file:read("*n")
pid_file:close()
if ask then
return pid
else
local terminate = "\03\n\nexit\n"
self:transmit(terminate)
-- do other things before killing first
self.is_shell_open = false
self.history = ""
os.remove("terminal.pid")
C.close(self.ptmx)
C.kill(pid, C.SIGTERM)
local status = ffi.new('int[1]')
-- status = tonumber(status[0])
-- If still running: ret = 0 , status = 0
-- If exited: ret = pid , status = 0 or 9 if killed
-- If no more running: ret = -1 , status = 0
C.waitpid(pid, status, 0) -- wait until shell is terminated
return -1
end
end
function Terminal:getCharSize()
local tmp = TextWidget:new{
text = " ",
face = self.input_face,
}
return tmp:getSize().w
end
function Terminal:generateInputDialog()
return InputDialog:new{
title = _("Terminal emulator"),
input = self.history,
input_face = self.input_face,
para_direction_rtl = false,
input_type = "string",
allow_newline = false,
cursor_at_end = true,
fullscreen = true,
inputtext_class = TermInputText,
buttons = {{
{
text = "", -- tabulator "⇤" and "⇥"
callback = function()
self:transmit("\009")
end,
},
{
text = "/", -- slash
callback = function()
self:transmit("/")
end,
},
{
-- @translators This is the ESC-key on the keyboard.
text = _("Esc"),
callback = function()
self:transmit("\027")
end,
},
{
-- @translators This is the CTRL-key on the keyboard.
text = _("Ctrl"),
callback = function()
self.ctrl = true
end,
},
{
-- @translators This is the CTRL-C key combination.
text = _("Ctrl-C"),
callback = function()
self:transmit("\003")
-- consume and drop everything
C.tcflush(self.ptmx, C.TCIFLUSH)
while self:receive() ~= "" do
C.tcflush(self.ptmx, C.TCIFLUSH)
end
self.input_widget:addChars("\003", true) -- as we flush the queue
end,
},
{
text = "", --clear
callback = function()
self.history = ""
self.input = {}
self.input_dialog:setInputText("$ ")
end,
},
{
text = "",
callback = function()
self.input_widget:upLine()
end,
hold_callback = function()
self.input_widget:scrollUp()
end,
},
{
text = "",
callback = function()
self.input_widget:downLine()
end,
hold_callback = function()
self.input_widget:scrollDown()
end,
},
{
text = "", -- settings menu
callback = function ()
UIManager:close(self.input_widget.keyboard)
Aliases:show(self.terminal_data .. "/scripts/aliases",
function()
UIManager:show(self.input_widget.keyboard)
UIManager:setDirty(self.input_dialog, "fast") -- is there a better solution
end,
self)
end,
},
{
text = "", --cancel
callback = function()
UIManager:show(MultiConfirmBox:new{
text = _("You can close the terminal, but leave the shell open for further commands or quit it now."),
choice1_text = _("Close"),
choice1_callback = function()
self.history = self.input_dialog:getInputText()
-- trim trialing spaces and newlines
while self.history:sub(#self.history, #self.history) == "\n"
or self.history:sub(#self.history, #self.history) == " " do
self.history = self.history:sub(1, #self.history - 1)
end
UIManager:close(self.input_dialog)
if self.touchmenu_instance then
self.touchmenu_instance:updateItems()
end
end,
choice2_text = _("Quit"),
choice2_callback = function()
self.history = ""
self:killShell()
UIManager:close(self.input_dialog)
if self.touchmenu_instance then
self.touchmenu_instance:updateItems()
end
end,
})
end,
},
}},
enter_callback = function()
self:transmit("\r")
end,
strike_callback = function(chars)
if self.ctrl and #chars == 1 then
chars = string.char(chars:upper():byte() - ("A"):byte()+1)
self.ctrl = false
end
if chars == "\n" then
chars = "\r\n"
end
self:transmit(chars)
end,
}
end
function Terminal:onClose()
self:killShell()
end
function Terminal:onTerminalStart(touchmenu_instance)
self.touchmenu_instance = touchmenu_instance
self.input_face = Font:getFace("smallinfont",
G_reader_settings:readSetting("terminal_font_size", 14))
self.ctrl = false
self.input_dialog = self:generateInputDialog()
self.input_widget = self.input_dialog._input_widget
local scroll_bar_width = ScrollTextWidget.scroll_bar_width
+ ScrollTextWidget.text_scroll_span
self.maxc = math.floor((self.input_widget.width - scroll_bar_width) / self:getCharSize())
self.maxr = math.floor(self.input_widget.height
/ self.input_widget:getLineHeight())
self.store_position = 1
logger.dbg("Terminal: resolution= " .. self.maxc .. "x" .. self.maxr)
if self:spawnShell(self.maxc, self.maxr) then
UIManager:show(self.input_dialog)
UIManager:scheduleIn(0.25, Terminal.refresh, self, true)
self.input_dialog:onShowKeyboard(true)
end
end
function Terminal:addToMainMenu(menu_items)
menu_items.terminal = {
text = _("Terminal emulator"),
-- sorting_hint = "more_tools",
keep_menu_open = true,
sub_item_table = {
{
text = _("About terminal emulator"),
callback = function()
local about_text = _([[Terminal emulator can start a shell (command prompt).
There are two environment variables TERMINAL_HOME and TERMINAL_DATA containing the path of the install and the data folders.
Commands to be executed on start can be placed in:
'$TERMINAL_DATA/scripts/profile.user'.
Aliases (shortcuts) to frequently used commands can be placed in:
'$TERMINAL_DATA/scripts/aliases'.]])
if not Device:isAndroid() then
about_text = about_text .. "\n\n" .. _("You can use 'shfm' as a file manager, '?' shows shfms help message.")
end
UIManager:show(InfoMessage:new{
text = about_text,
})
end,
keep_menu_open = true,
separator = true,
},
{
text_func = function()
local state = self.is_shell_open and _("running") or _("not running")
return T(_("Open terminal session (%1)"), state)
end,
callback = function(touchmenu_instance)
self:onTerminalStart(touchmenu_instance)
end,
keep_menu_open = true,
},
{
text = _("End terminal session"),
enabled_func = function()
return self:killShell(true) >= 0
end,
callback = function(touchmenu_instance)
self:killShell()
if touchmenu_instance then touchmenu_instance:updateItems() end
end,
keep_menu_open = true,
separator = true,
},
{
text_func = function()
return T(_("Font size: %1"),
G_reader_settings:readSetting("terminal_font_size", 14))
end,
callback = function(touchmenu_instance)
local cur_size = G_reader_settings:readSetting("terminal_font_size")
local size_spin = SpinWidget:new{
value = cur_size,
value_min = 10,
value_max = 30,
value_hold_step = 2,
default_value = 14,
title_text = _("Terminal emulator font size"),
callback = function(spin)
G_reader_settings:saveSetting("terminal_font_size", spin.value)
if touchmenu_instance then touchmenu_instance:updateItems() end
end,
}
UIManager:show(size_spin)
end,
keep_menu_open = true,
},
{
text_func = function()
return T(_("Buffer size: %1 kB"),
G_reader_settings:readSetting("terminal_buffer_size", 16))
end,
callback = function(touchmenu_instance)
local cur_buffer = G_reader_settings:readSetting("terminal_buffer_size")
local buffer_spin = SpinWidget:new{
value = cur_buffer,
value_min = 10,
value_max = 30,
value_hold_step = 2,
default_value = 16,
title_text = _("Terminal emulator buffer size (kB)"),
callback = function(spin)
G_reader_settings:saveSetting("terminal_buffer_size", spin.value)
if touchmenu_instance then touchmenu_instance:updateItems() end
end,
}
UIManager:show(buffer_spin)
end,
keep_menu_open = true,
},
{
text_func = function()
return T(_("Shell executable: %1"),
G_reader_settings:readSetting("terminal_shell", "sh"))
end,
callback = function(touchmenu_instance)
self.shell_dialog = InputDialog:new{
title = _("Shell to use"),
description = _("Here you can select the startup shell.\nDefault: sh"),
input = G_reader_settings:readSetting("terminal_shell", "sh"),
buttons = {{
{
text = _("Cancel"),
callback = function()
UIManager:close(self.shell_dialog)
end,
},
{
text = _("Default"),
callback = function()
G_reader_settings:saveSetting("terminal_shell", "sh")
UIManager:close(self.shell_dialog)
if touchmenu_instance then
touchmenu_instance:updateItems()
end
end,
},
{
text = _("Save"),
is_enter_default = true,
callback = function()
local new_shell = self.shell_dialog:getInputText()
if new_shell == "" then
new_shell = "sh"
end
G_reader_settings:saveSetting("terminal_shell", new_shell)
UIManager:close(self.shell_dialog)
if touchmenu_instance then
touchmenu_instance:updateItems()
end
end
},
}}}
UIManager:show(self.shell_dialog)
self.shell_dialog:onShowKeyboard()
end,
keep_menu_open = true,
},
}
}
end
function Terminal:onDispatcherRegisterActions()
Dispatcher:registerAction("terminal",
{category = "none", event = "TerminalStart", title = _("Terminal emulator"), device = true})
end
return Terminal