awful.spawn: Add an `once` and `single_instance` methods.

This commit adds a way to leverage the xproperty and startup_id APIs
to persist an execution token across restarts. It allows to use
`awful.rules` on clients that were executed by a previous Awesome
instance.

The main limitations of these methods is the lack of entropy used to
build the token. If the command is the same in multiple
`awful.spawn.once`, then it will not work as expected. To mitigate this
issue, the system try to concatenate the `awful.rules` table after the
command and hash the resulting string. Given rules are a table, it can
have loops and/or issues with keys ordering. The hash function sort and
limite recursion to prevent a stack overflow. Another issue is the
unreliability of startup notifications.
This commit is contained in:
Emmanuel Lepage Vallee 2018-09-30 23:55:57 -04:00
parent 9250610a77
commit 927e7b2796
3 changed files with 239 additions and 4 deletions

View File

@ -8,7 +8,7 @@
-- Grab environment we need
local gdebug = require("gears.debug")
local spawn = require("awful.spawn")
local spawn = nil --TODO v5 deprecate
local set_shape = require("awful.client.shape").update.all
local object = require("gears.object")
local grect = require("gears.geometry").rectangle
@ -1138,8 +1138,10 @@ end
-- @tparam bool|function merge If true then merge tags (select the client's
-- first tag additionally) when the client is not visible.
-- If it is a function, it will be called with the client as argument.
-- @see awful.spawn.once
-- @see awful.spawn.single_instance
--
-- @function awful.client.run_or_raise
-- @deprecated awful.client.run_or_raise
-- @usage -- run or raise urxvt (perhaps, with tabs) on modkey + semicolon
-- awful.key({ modkey, }, 'semicolon', function ()
-- local matcher = function (c)
@ -1148,6 +1150,10 @@ end
-- awful.client.run_or_raise('urxvt', matcher)
-- end);
function client.run_or_raise(cmd, matcher, merge)
gdebug.deprecate("Use awful.spawn.single_instance instead of"..
"awful.client.run_or_raise", {deprecated_in=5})
spawn = spawn or require("awful.spawn")
local clients = capi.client.get()
local findex = gtable.hasitem(clients, capi.client.focus) or 1
local start = gmath.cycle(#clients, findex + 1)

View File

@ -374,6 +374,46 @@ end
rules.add_rule_source("awful.spawn", apply_spawn_rules, {}, {"awful.rules"})
local function apply_singleton_rules(c, props, callbacks)
local persis_id, info = c.single_instance_id, nil
-- This is a persistent property set by `awful.spawn`
if awesome.startup and persis_id then
info = aspawn.single_instance_manager.by_uid[persis_id]
elseif c.startup_id then
info = aspawn.single_instance_manager.by_snid[c.startup_id]
aspawn.single_instance_manager.by_snid[c.startup_id] = nil
elseif aspawn.single_instance_manager.by_pid[c.pid] then
info = aspawn.single_instance_manager.by_pid[c.pid].matcher(c) and
aspawn.single_instance_manager.by_pid[c.pid] or nil
end
if info then
c.single_instance_id = info.hash
gtable.crush(props, info.rules)
table.insert(callbacks, info.callback)
table.insert(info.instances, c)
-- Prevent apps with multiple clients from re-using this too often in
-- the first 30 seconds before the PID is cleared.
aspawn.single_instance_manager.by_pid[c.pid] = nil
end
end
--- The rule source for clients spawned by `awful.spawn.once` and `single_instance`.
--
-- **Has priority over:**
--
-- * `awful.rules`
--
-- **Depends on:**
--
-- * `awful.spawn`
--
-- @rulesources awful.spawn_once
rules.add_rule_source("awful.spawn_once", apply_singleton_rules, {"awful.spawn"}, {"awful.rules"})
--- Apply awful.rules.rules to a client.
-- @client c The client.
function rules.apply(c)

View File

@ -165,7 +165,9 @@ local lgi = require("lgi")
local Gio = lgi.Gio
local GLib = lgi.GLib
local util = require("awful.util")
local timer = require("gears.timer")
local gtable = require("gears.table")
local gtimer = require("gears.timer")
local aclient = require("awful.client")
local protected_call = require("gears.protected_call")
local spawn = {}
@ -210,6 +212,39 @@ do
end
end
local function hash_command(cmd, rules)
rules = rules or {}
cmd = type(cmd) == "string" and cmd or table.concat(cmd, ';')
-- Do its best at adding some entropy
local concat_rules = nil
concat_rules = function (r, depth)
if depth > 2 then return end
local keys = gtable.keys(rules)
for _, k in ipairs(keys) do
local v = r[k]
local t = type(v)
if t == "string" or t == "number" then
cmd = cmd..k..v
elseif t == "tag" then
cmd = cmd..k..v.name
elseif t == "table" and not t.connect_signal then
cmd = cmd .. k
concat_rules(v, depth + 1)
end
end
end
concat_rules(rules, 1)
local glibstr = GLib.String(cmd)
return string.format('%x', math.ceil(GLib.String.hash(glibstr)))
end
spawn.snid_buffer = {}
function spawn.on_snid_callback(c)
@ -220,7 +255,7 @@ function spawn.on_snid_callback(c)
--TODO v5: Remove this signal
c:emit_signal("spawn::completed_with_payload", props, callback)
timer.delayed_call(function()
gtimer.delayed_call(function()
spawn.snid_buffer[c.startup_id] = nil
end)
end
@ -435,6 +470,160 @@ function spawn.read_lines(input_stream, line_callback, done_callback, close)
start_read()
end
-- When a command should only be executed once or only have a single instance,
-- track the SNID set on them to prevent multiple execution.
spawn.single_instance_manager = {
by_snid = {},
by_pid = {},
by_uid = {},
}
aclient.property.persist("single_instance_id", "string")
-- Check if the client is running either using the rule source or the matcher.
local function is_running(hash, matcher)
local status = spawn.single_instance_manager.by_uid[hash]
if not status then return false end
if #status.instances == 0 then return false end
for _, c in ipairs(status.instances) do
if c.valid then return true end
end
if matcher then
for _, c in ipairs(client.get()) do
if matcher(c) then return true end
end
end
return false
end
-- Keep the data related to this hash.
local function register_common(hash, rules, matcher, callback)
local status = spawn.single_instance_manager.by_uid[hash]
if status then return status end
status = {
rules = rules,
callback = callback,
instances = setmetatable({}, {__mode = "v"}),
hash = hash,
exec = false,
matcher = matcher,
}
spawn.single_instance_manager.by_uid[hash] = status
return status
end
local function run_once_common(hash, cmd, keep_pid)
local pid, snid = spawn.spawn(cmd)
if type(pid) == "string" or not snid then return pid, snid end
assert(spawn.single_instance_manager.by_uid[hash])
local status = spawn.single_instance_manager.by_uid[hash]
status.exec = true
spawn.single_instance_manager.by_snid[snid] = status
if keep_pid then
spawn.single_instance_manager.by_pid[pid] = status
end
-- Prevent issues related to PID being re-used and a memory leak
gtimer {
single_shot = true,
autostart = true,
timeout = 30,
callback = function()
spawn.single_instance_manager.by_pid [pid ] = nil
spawn.single_instance_manager.by_snid[snid] = nil
end
}
return pid, snid
end
local function run_after_startup(f)
-- The clients are not yet managed during the first execution, so it will
-- miss existing instances.
if awesome.startup then
gtimer.delayed_call(f)
else
f()
end
end
--- Spawn a command if it has not been spawned before.
--
-- This function tries its best to preserve the state across `awesome.restart()`.
--
-- By default, when no `unique_id` is specified, this function will generate one by
-- hashing the command and its rules. If you have multiple instance of the same
-- command and rules, you need to specify an UID or only the first one will be
-- executed.
--
-- The `rules` are standard `awful.rules`.
--
-- This function depends on the startup notification protocol to be correctly
-- implemented by the command. See `client.startup_id` for more information.
-- Note that this also wont work with shell or terminal commands.
--
-- @tparam string|table cmd The command.
-- @tparam table rules The properties that need to be applied to the client.
-- @tparam[opt] function matcher A matching function to find the instance
-- among running clients.
-- @tparam[opt] string unique_id A string to identify the client so it isn't executed
-- multiple time.
-- @tparam[opt] function callback A callback function when the client is created.
-- @see awful.rules
function spawn.once(cmd, rules, matcher, unique_id, callback)
local hash = unique_id or hash_command(cmd, rules)
local status = register_common(hash, rules, matcher, callback)
run_after_startup(function()
if not status.exec and not is_running(hash, matcher) then
run_once_common(hash, cmd, matcher ~= nil)
end
end)
end
--- Spawn a command if an instance is not already running.
--
-- This is like `awful.spawn.once`, but will spawn new instances if the previous
-- has finished.
--
-- The `rules` are standard `awful.rules`.
--
-- This function depends on the startup notification protocol to be correctly
-- implemented by the command. See `client.startup_id` for more information.
-- Note that this also wont work with shell or terminal commands.
--
-- Note that multiple instances can still be spawned if the command is called
-- faster than the client has time to start.
--
-- @tparam string|table cmd The command.
-- @tparam table rules The properties that need to be applied to the client.
-- @tparam[opt] function matcher A matching function to find the instance
-- among running clients.
-- @tparam[opt] string unique_id A string to identify the client so it isn't executed
-- multiple time.
-- @tparam[opt] function callback A callback function when the client is created.
-- @see awful.rules
function spawn.single_instance(cmd, rules, matcher, unique_id, callback)
local hash = unique_id or hash_command(cmd, rules)
register_common(hash, rules, matcher, callback)
run_after_startup(function()
if not is_running(hash, matcher) then
return run_once_common(hash, cmd, matcher ~= nil)
end
end)
end
capi.awesome.connect_signal("spawn::canceled" , spawn.on_snid_cancel )
capi.awesome.connect_signal("spawn::timeout" , spawn.on_snid_cancel )
capi.client.connect_signal ("manage" , spawn.on_snid_callback )