734 lines
19 KiB
Lua
734 lines
19 KiB
Lua
--- Fetch information at a specific interval.
|
|
--
|
|
-- @author Emmanuel Lepage-Vallee <elv1313@gmail.com>
|
|
-- @copyright 2020 Emmanuel Lepage-Vallee
|
|
-- @classmod gears.watcher
|
|
|
|
local capi = {awesome = awesome}
|
|
local protected_call = require("gears.protected_call")
|
|
local gtimer = require("gears.timer")
|
|
local gtable = require("gears.table")
|
|
local lgi = require("lgi")
|
|
local Gio = lgi.Gio
|
|
local GLib = lgi.GLib
|
|
|
|
local module = {}
|
|
|
|
-- This is awful.util.shell
|
|
module._shell = os.getenv("SHELL") or "/bin/sh"
|
|
|
|
local end_of_file
|
|
do
|
|
-- API changes, bug fixes and lots of fun. Figure out how a EOF is signalled.
|
|
local input
|
|
if not pcall(function()
|
|
-- No idea when this API changed, but some versions expect a string,
|
|
-- others a table with some special(?) entries
|
|
input = Gio.DataInputStream.new(Gio.MemoryInputStream.new_from_data(""))
|
|
end) then
|
|
input = Gio.DataInputStream.new(Gio.MemoryInputStream.new_from_data({}))
|
|
end
|
|
local line, length = input:read_line()
|
|
if not line then
|
|
-- Fixed in 2016: NULL on the C side is transformed to nil in Lua
|
|
end_of_file = function(arg)
|
|
return not arg
|
|
end
|
|
elseif tostring(line) == "" and #line ~= length then
|
|
-- "Historic" behaviour for end-of-file:
|
|
-- - NULL is turned into an empty string
|
|
-- - The length variable is not initialized
|
|
-- It's highly unlikely that the uninitialized variable has value zero.
|
|
-- Use this hack to detect EOF.
|
|
end_of_file = function(arg1, arg2)
|
|
return #arg1 ~= arg2
|
|
end
|
|
else
|
|
assert(tostring(line) == "", "Cannot determine how to detect EOF")
|
|
-- The above uninitialized variable was fixed and thus length is
|
|
-- always 0 when line is NULL in C. We cannot tell apart an empty line and
|
|
-- EOF in this case.
|
|
require("gears.debug").print_warning("Cannot reliably detect EOF on an "
|
|
.. "GIOInputStream with this LGI version")
|
|
end_of_file = function(arg)
|
|
return tostring(arg) == ""
|
|
end
|
|
end
|
|
end
|
|
|
|
module._end_of_file = end_of_file
|
|
|
|
function module._read_lines(input_stream, line_callback, done_callback, close)
|
|
local stream = Gio.DataInputStream.new(input_stream)
|
|
local function done()
|
|
if close then
|
|
stream:close()
|
|
end
|
|
stream:set_buffer_size(0)
|
|
if done_callback then
|
|
protected_call(done_callback)
|
|
end
|
|
end
|
|
local start_read, finish_read
|
|
start_read = function()
|
|
stream:read_line_async(GLib.PRIORITY_DEFAULT, nil, finish_read)
|
|
end
|
|
finish_read = function(obj, res)
|
|
local line, length = obj:read_line_finish(res)
|
|
if type(length) ~= "number" then
|
|
-- Error
|
|
print("Error in awful.spawn.read_lines:", tostring(length))
|
|
done()
|
|
elseif end_of_file(line, length) then
|
|
-- End of file
|
|
done()
|
|
else
|
|
-- Read a line
|
|
-- This needs tostring() for older lgi versions which returned
|
|
-- "GLib.Bytes" instead of Lua strings (I guess)
|
|
protected_call(line_callback, tostring(line))
|
|
|
|
-- Read the next line
|
|
start_read()
|
|
end
|
|
end
|
|
start_read()
|
|
end
|
|
|
|
function module._with_line_callback(cmd, callbacks)
|
|
local stdout_callback, stderr_callback, done_callback, exit_callback =
|
|
callbacks.stdout, callbacks.stderr, callbacks.output_done, callbacks.exit
|
|
local have_stdout, have_stderr = stdout_callback ~= nil, stderr_callback ~= nil
|
|
local pid, _, stdin, stdout, stderr = capi.awesome.spawn(cmd,
|
|
false, false, have_stdout, have_stderr, exit_callback)
|
|
if type(pid) == "string" then
|
|
-- Error
|
|
return pid
|
|
end
|
|
|
|
local done_before = false
|
|
local function step_done()
|
|
if have_stdout and have_stderr and not done_before then
|
|
done_before = true
|
|
return
|
|
end
|
|
if done_callback then
|
|
done_callback()
|
|
end
|
|
end
|
|
if have_stdout then
|
|
module._read_lines(Gio.UnixInputStream.new(stdout, true),
|
|
stdout_callback, step_done, true)
|
|
end
|
|
if have_stderr then
|
|
module._read_lines(Gio.UnixInputStream.new(stderr, true),
|
|
stderr_callback, step_done, true)
|
|
end
|
|
assert(stdin == nil)
|
|
return pid
|
|
end
|
|
|
|
function module._easy_async(cmd, callback)
|
|
local stdout = ''
|
|
local stderr = ''
|
|
local exitcode, exitreason
|
|
local function parse_stdout(str)
|
|
stdout = stdout .. str .. "\n"
|
|
end
|
|
local function parse_stderr(str)
|
|
stderr = stderr .. str .. "\n"
|
|
end
|
|
local function done_callback()
|
|
return callback(stdout, stderr, exitreason, exitcode)
|
|
end
|
|
local exit_callback_fired = false
|
|
local output_done_callback_fired = false
|
|
local function exit_callback(reason, code)
|
|
exitcode = code
|
|
exitreason = reason
|
|
exit_callback_fired = true
|
|
if output_done_callback_fired then
|
|
return done_callback()
|
|
end
|
|
end
|
|
local function output_done_callback()
|
|
output_done_callback_fired = true
|
|
if exit_callback_fired then
|
|
return done_callback()
|
|
end
|
|
end
|
|
return module._with_line_callback(
|
|
cmd, {
|
|
stdout=parse_stdout,
|
|
stderr=parse_stderr,
|
|
exit=exit_callback,
|
|
output_done=output_done_callback
|
|
})
|
|
end
|
|
|
|
function module._read_async(path, callback, fail_callback)
|
|
local cancel = Gio.Cancellable()
|
|
|
|
Gio.File.new_for_path(path):load_contents_async(cancel, function(file, task)
|
|
local content = file:load_contents_finish(task)
|
|
if content then
|
|
callback(path, content)
|
|
elseif fail_callback then
|
|
fail_callback(path)
|
|
end
|
|
end)
|
|
|
|
return cancel
|
|
end
|
|
|
|
-- Posix files and commands always end with a newline.
|
|
-- It is nearly always unwanted, so we strip it by default.
|
|
local function remove_posix_extra_newline(content)
|
|
-- Remove the trailing `\n`
|
|
if content:sub(-1) == '\n' then
|
|
content = content:sub(1, -2)
|
|
end
|
|
|
|
return content
|
|
end
|
|
|
|
--- Make sure we sort transactions in a way obsolete ones are
|
|
-- not used.
|
|
|
|
local function add_transaction(self, transaction)
|
|
table.insert(self._private.transactions, transaction)
|
|
end
|
|
|
|
local function remove_transaction(self, transaction)
|
|
|
|
-- Too late, abort.
|
|
for _, cancel in pairs(transaction.pending) do
|
|
cancel:cancel()
|
|
end
|
|
|
|
for k, t in ipairs(self._private.transactions) do
|
|
if t == transaction then
|
|
table.remove(self._private.transactions, k)
|
|
end
|
|
end
|
|
|
|
end
|
|
|
|
-- Keys can also be labels or object, # wont work.
|
|
local function count_files(files)
|
|
local ret = 0
|
|
|
|
for _ in pairs(files) do
|
|
ret = ret + 1
|
|
end
|
|
|
|
return ret
|
|
end
|
|
|
|
-- When there is multiple files, we need to wait
|
|
-- until are of them are read.
|
|
local function gen_file_transaction(self)
|
|
if count_files(self.files) == 0 then return nil end
|
|
|
|
local ret = {
|
|
counter = count_files(self.files),
|
|
files = gtable.clone(self.files, false),
|
|
filter = self.filter,
|
|
labels = {},
|
|
content = {},
|
|
failed = {},
|
|
pending = {},
|
|
|
|
}
|
|
|
|
local function finished(file, content)
|
|
assert(not ret.content[file])
|
|
self:emit_signal("file::acquired", file, content)
|
|
ret.pending[file] = nil
|
|
|
|
ret.counter = ret.counter - 1
|
|
ret.content[file] = content
|
|
|
|
if ret.counter > 0 then return end
|
|
|
|
self:emit_signal("files::acquired", ret.content, ret.failed)
|
|
|
|
-- Make the final table using the stored keys.
|
|
local contents = {}
|
|
|
|
for path, ctn in pairs(ret.content) do
|
|
if self.strip_newline then
|
|
ctn = remove_posix_extra_newline(ctn)
|
|
end
|
|
|
|
contents[self._private.file_keys[path]] = ctn
|
|
|
|
if self.labels_as_properties and type(ret.labels[path]) == "string" then
|
|
local val
|
|
|
|
if ret.filter then
|
|
val = ret.filter(ret.labels[path], ctn)
|
|
else
|
|
val = ctn
|
|
end
|
|
|
|
-- Make sure the signals are not fired for nothing.
|
|
if val ~= self[ret.labels[path]] then
|
|
self[ret.labels[path]] = val
|
|
end
|
|
end
|
|
end
|
|
|
|
local ctn = count_files(ret.files) == 1 and contents[next(contents)] or contents
|
|
|
|
if ret.filter and not self.labels_as_properties then
|
|
self._private.value = ret.filter(ctn, ret.failed)
|
|
else
|
|
self._private.value = ctn
|
|
end
|
|
|
|
self:emit_signal("property::value", self._private.value)
|
|
|
|
remove_transaction(self, ret)
|
|
end
|
|
|
|
local function read_error(file)
|
|
ret.pending[file] = nil
|
|
table.insert(ret.failed, file)
|
|
self:emit_signal("file::failed", file)
|
|
end
|
|
|
|
for label, file in pairs(ret.files) do
|
|
ret.labels[file] = label
|
|
|
|
local cancel = module._read_async(file, finished, read_error)
|
|
|
|
ret.pending[file] = cancel
|
|
end
|
|
|
|
return ret
|
|
end
|
|
|
|
local modes_start, modes_abort = {}, {}
|
|
|
|
modes_start["none"] = function() --[[nop]] end
|
|
modes_abort["none"] = function() --[[nop]] end
|
|
|
|
modes_start["files"] = function(self)
|
|
if not self._private.init then return end
|
|
|
|
local t = gen_file_transaction(self)
|
|
|
|
add_transaction(self, t)
|
|
end
|
|
|
|
modes_abort["files"] = function(self)
|
|
for _, t in ipairs(self._private.transactions) do
|
|
remove_transaction(self, t)
|
|
end
|
|
end
|
|
|
|
modes_start["command"] = function(self)
|
|
if not self._private.init then return end
|
|
|
|
local com = self._private.command
|
|
|
|
if self._private.shell then
|
|
assert(
|
|
type(com) == "string",
|
|
"When using `gears.watcher` with `shell = true`, "..
|
|
"the command must be a string"
|
|
)
|
|
|
|
com = {module._shell, '-c', com}
|
|
end
|
|
|
|
module._easy_async(com, function(lines, stderr, err, errcode)
|
|
if self.strip_newline then
|
|
lines = remove_posix_extra_newline(lines)
|
|
end
|
|
|
|
if self.filter then
|
|
self._private.value = self.filter(lines, stderr, err, errcode)
|
|
else
|
|
self._private.value = lines
|
|
end
|
|
end)
|
|
end
|
|
|
|
modes_abort["command"] = function()
|
|
--TODO
|
|
end
|
|
|
|
function module:_set_declarative_handler(parent, key, ids)
|
|
assert(type(key) == "string", "A watcher can only be attached to properties")
|
|
|
|
table.insert(self._private.targets, {
|
|
ids = ids,
|
|
parent = parent,
|
|
property = key
|
|
})
|
|
|
|
if self._private.value then
|
|
parent[key] = self._private.value
|
|
end
|
|
end
|
|
|
|
--- Abort the current transactions.
|
|
--
|
|
-- If files are currently being read or commands executed,
|
|
-- abort it. This does prevent new transaction from being
|
|
-- started. Use `:stop()` for that.
|
|
--
|
|
-- @method abort
|
|
-- @see stop
|
|
|
|
function module:abort()
|
|
modes_abort[self._private.mode](self)
|
|
end
|
|
|
|
--- Emitted when a file is read.
|
|
--
|
|
-- @signal file::acquired
|
|
-- @tparam string path The path.
|
|
-- @tparam string content The file content.
|
|
-- @see files::acquired
|
|
-- @see file::failed
|
|
|
|
--- When reading a file failed.
|
|
-- @signal file::failed
|
|
-- @tparam string path The path.
|
|
|
|
--- Emitted when all files are read.
|
|
--
|
|
-- @signal files::acquired
|
|
-- @tparam table contents Path as keys and content as values.
|
|
-- @tparam table failed The list of files which could not be read.
|
|
|
|
--- A file path.
|
|
--
|
|
-- @DOC_text_gears_watcher_simple_EXAMPLE@
|
|
--
|
|
-- @property file
|
|
-- @tparam string file
|
|
-- @propemits true false
|
|
-- @see files
|
|
|
|
--- A list or map of files.
|
|
--
|
|
-- It is often necessary to query multiple files. When reading from `proc`,
|
|
-- some data is split across multiple files, such as the battery charge.
|
|
--
|
|
-- This property accepts 2 format. One uses is a plain table of paths while
|
|
-- the other is a label->path map. Depending on what is being read, both make
|
|
-- sense.
|
|
--
|
|
-- **Simple path list:**
|
|
--
|
|
-- @DOC_text_gears_watcher_files1_EXAMPLE@
|
|
--
|
|
-- **With labels:**
|
|
--
|
|
-- @DOC_text_gears_watcher_files2_EXAMPLE@
|
|
--
|
|
-- @property files
|
|
-- @tparam table files
|
|
-- @propemits true false
|
|
-- @see labels_as_properties
|
|
|
|
function module:get_file()
|
|
return self._private.files[1]
|
|
end
|
|
|
|
function module:set_file(path)
|
|
self:set_files({path})
|
|
end
|
|
|
|
function module:get_files()
|
|
return self._private.files
|
|
end
|
|
|
|
function module:set_files(paths)
|
|
self:abort()
|
|
|
|
self._private.files = paths or {}
|
|
self._private.mode = "files"
|
|
|
|
self:emit_signal("property::files", self._private.files )
|
|
self:emit_signal("property::file" , select(2, next(self._private.files)))
|
|
|
|
-- It is possible to give names to each files. For modules like
|
|
-- battery widgets, which require reading multiple long paths from
|
|
-- `proc`, it makes the user code more readable.
|
|
self._private.file_keys = {}
|
|
|
|
for k, v in pairs(self._private.files) do
|
|
self._private.file_keys[v] = type(k) == "number" and v or k
|
|
end
|
|
|
|
modes_start["files"](self)
|
|
end
|
|
|
|
--- Add a file to the files list.
|
|
--
|
|
-- @method append_file
|
|
-- @tparam string path The path.
|
|
-- @tparam[opt] string The key.
|
|
|
|
function module:append_file(path, key)
|
|
self:abort()
|
|
|
|
if self._private.files[path] then return end
|
|
|
|
key = key or (#self._private.files + 1)
|
|
|
|
self._private.mode = "files"
|
|
|
|
self._private.files[key] = path
|
|
self._private.file_keys[path] = key
|
|
|
|
self:emit_signal("property::files", self._private.files )
|
|
self:emit_signal("property::file" , select(2, next(self._private.files)))
|
|
|
|
modes_start["files"](self)
|
|
end
|
|
|
|
--- Remove a file to the files list.
|
|
--
|
|
-- @method remove_file
|
|
-- @tparam string path The path or the key.
|
|
|
|
--- A filter to post-process the file or command.
|
|
--
|
|
-- It can be used, for example, to convert the string to a number or turn
|
|
-- the various file content into the final value. The filter argument
|
|
-- depend on various `gears.watcher` properties (as documented below).
|
|
--
|
|
-- **The callback parameters for a single file:**
|
|
-- (1) The file content (string)
|
|
--
|
|
-- **The callback parameters for a multiple files (paths):**
|
|
-- (1) Tables with the paths as keys and string content as value.
|
|
--
|
|
-- **The callback parameters for a multiple files (labels):**
|
|
-- (1) Tables with the keys as keys and string content as value.
|
|
--
|
|
-- **The callback when `labels_as_properties` is true:
|
|
-- (1) The label name
|
|
-- (2) The content
|
|
--
|
|
-- **The callback parameters for a command:**
|
|
-- (1) Stdout as first parameter
|
|
-- (2) Stderr as second parameter
|
|
-- (3) Exitreason
|
|
-- (4) Exitcode
|
|
--
|
|
-- @property filter
|
|
-- @tparam function filter
|
|
-- @propemits true false
|
|
|
|
function module:get_filter()
|
|
return self._private.filter
|
|
end
|
|
|
|
function module:set_filter(filter)
|
|
self:abort()
|
|
|
|
self._private.filter = filter
|
|
|
|
self:emit_signal("property::filter", filter)
|
|
|
|
modes_start[self._private.mode](self)
|
|
end
|
|
|
|
--- A command.
|
|
--
|
|
-- If you plan to use pipes or any shell features, do not
|
|
-- forget to also set `shell` to `true`.
|
|
--
|
|
-- @DOC_text_gears_watcher_command1_EXAMPLE@
|
|
--
|
|
-- @property command
|
|
-- @tparam table|string command
|
|
-- @propemits true false
|
|
|
|
function module:get_command()
|
|
return self._private.command
|
|
end
|
|
|
|
function module:set_command(command)
|
|
self._private.command = command
|
|
self._private.mode = "command"
|
|
self:emit_signal("property::command")
|
|
modes_start["command"](self)
|
|
end
|
|
|
|
--- Use a shell when calling the command.
|
|
--
|
|
-- This means you can use `|`, `&&`, `$?` in your command.
|
|
--
|
|
-- @DOC_text_gears_watcher_command2_EXAMPLE@
|
|
---
|
|
-- @property shell
|
|
-- @tparam[opt=false] boolean|string shell
|
|
-- @propemits true false
|
|
|
|
function module:get_shell()
|
|
return self._private.shell or false
|
|
end
|
|
|
|
function module:set_shell(shell)
|
|
self._private.shell = shell
|
|
self:emit_signal("property::shell")
|
|
end
|
|
|
|
--- In files mode, when paths have labels, apply them as properties.
|
|
--
|
|
-- @DOC_wibox_widget_declarative_watcher_EXAMPLE@
|
|
--
|
|
-- @property labels_as_properties
|
|
-- @tparam[opt=false] boolean labels_as_properties
|
|
-- @see files
|
|
|
|
--- The interval between the content refresh.
|
|
--
|
|
-- (in seconds)
|
|
--
|
|
-- @property interval
|
|
-- @tparam number interval
|
|
-- @see gears.timer.timeout
|
|
-- @propemits true false
|
|
|
|
-- There is not get_timeout/set_timeout, so we can't make aliases.
|
|
module.get_interval = gtimer.get_timeout
|
|
module.set_interval = gtimer.set_timeout
|
|
|
|
--- The current value of the watcher.
|
|
--
|
|
-- If there is no filter, this will be a string. If a filter is used,
|
|
-- then it is whatever it returns.
|
|
--
|
|
-- @property value
|
|
-- @propemits false false
|
|
|
|
function module:get_value()
|
|
return self._private.value
|
|
end
|
|
|
|
--- Strip the extra trailing newline.
|
|
--
|
|
-- All posix compliant text file and commands end with a newline.
|
|
-- Most of the time, this is inconvinient, so `gears.watcher` removes
|
|
-- them by default. Set this to `false` if this isn't the desired
|
|
-- behavior.
|
|
--
|
|
-- @property strip_newline
|
|
-- @tparam[opt=true] boolean strip_newline
|
|
-- @propemits true false
|
|
|
|
function module:get_strip_newline()
|
|
return self._private.newline
|
|
end
|
|
|
|
function module:set_strip_newline(value)
|
|
self._private.newline = value
|
|
self:emit_signal("property::strip_newline", value)
|
|
end
|
|
|
|
--- Start the timer.
|
|
-- @method start
|
|
-- @emits start
|
|
-- @baseclass gears.timer
|
|
-- @see stop
|
|
|
|
--- Stop the timer.
|
|
-- @method stop
|
|
-- @emits stop
|
|
-- @baseclass gears.timer
|
|
-- @see abort
|
|
-- @see start
|
|
|
|
--- The timer is started.
|
|
-- @property started
|
|
-- @tparam boolean started
|
|
-- @propemits false false
|
|
-- @baseclass gears.timer
|
|
|
|
--- Restart the timer.
|
|
-- This is equivalent to stopping the timer if it is running and then starting
|
|
-- it.
|
|
-- @method again
|
|
-- @baseclass gears.timer
|
|
-- @emits start
|
|
-- @emits stop
|
|
|
|
--- Create a new `gears.watcher` object.
|
|
--
|
|
-- @constructorfct gears.watcher
|
|
-- @tparam table args
|
|
-- @tparam string args.file A file path.
|
|
-- @tparam table args.files A list or map of files.
|
|
-- @tparam function args.filter A filter to post-process the file or command.
|
|
-- @tparam table|string args.command A command (without a shell).
|
|
-- @tparam[opt=false] boolean args.shell Use a shell when calling the command.
|
|
-- @param args.initial_value The value to use before the first "real" value is acquired.
|
|
-- @tparam number args.interval The interval between the content refresh.
|
|
-- @tparam boolean args.labels_as_properties Set the file labels as properties.
|
|
-- @tparam boolean args.started The timer is started.
|
|
|
|
local function new(_, args)
|
|
local ret = gtimer()
|
|
ret.timeout = 5000
|
|
|
|
local newargs = gtable.clone(args or {}, false)
|
|
|
|
ret._private.mode = "none"
|
|
ret._private.transactions = {}
|
|
ret._private.targets = {}
|
|
ret._private.files = {}
|
|
|
|
if newargs.autostart == nil then
|
|
newargs.autostart = true
|
|
end
|
|
|
|
if newargs.strip_newline == nil then
|
|
newargs.strip_newline = true
|
|
end
|
|
|
|
gtable.crush(ret, module , true )
|
|
gtable.crush(ret, newargs, false)
|
|
|
|
ret.shell = args.shell
|
|
-- ret:set_shell(args.shell)
|
|
|
|
local function value_callback()
|
|
for _, target in ipairs(ret._private.targets) do
|
|
target.parent[target.property] = ret.value
|
|
end
|
|
end
|
|
|
|
local function update_callback()
|
|
modes_start[ret._private.mode](ret)
|
|
end
|
|
|
|
ret:connect_signal("property::value", value_callback)
|
|
ret:connect_signal("timeout", update_callback)
|
|
|
|
if args.initial_value then
|
|
ret._private.value = args.initial_value
|
|
end
|
|
|
|
ret._private.init = true
|
|
|
|
if newargs.autostart then
|
|
ret:start()
|
|
modes_start[ret._private.mode](ret)
|
|
end
|
|
|
|
return ret
|
|
end
|
|
|
|
--@DOC_object_COMMON@
|
|
|
|
return setmetatable(module, {__call = new})
|