501 lines
22 KiB
Lua
501 lines
22 KiB
Lua
--- === hs.ipc ===
|
|
---
|
|
--- Provides Hammerspoon with the ability to create both local and remote message ports for inter-process communication.
|
|
---
|
|
--- The most common use of this module is to provide support for the command line tool `hs` which can be added to your terminal shell environment with [hs.ipc.cliInstall](#cliInstall). The command line tool will not work unless the `hs.ipc` module is loaded first, so it is recommended that you add `require("hs.ipc")` to your Hammerspoon `init.lua` file (usually located at ~/.hammerspoon/init.lua) so that it is always available when Hammerspoon is running.
|
|
---
|
|
--- This module is based heavily on code from Mjolnir by [Steven Degutis](https://github.com/sdegutis/).
|
|
|
|
local USERDATA_TAG = "hs.ipc"
|
|
local module = require("hs.libipc")
|
|
|
|
local timer = require("hs.timer")
|
|
local settings = require("hs.settings")
|
|
local log = require("hs.logger").new(USERDATA_TAG, (settings.get(USERDATA_TAG .. ".logLevel") or "warning"))
|
|
local json = require("hs.json")
|
|
local fnutils = require("hs.fnutils")
|
|
|
|
-- private variables and methods -----------------------------------------
|
|
|
|
local MSG_ID = {
|
|
REGISTER = 100, -- register an instance with the v2 cli
|
|
UNREGISTER = 200, -- unregister an instance with the v2 cli
|
|
|
|
LEGACYCHK = 900, -- query to test if we are the v2 ipc or not (v1 will ignore the id and evaluate)
|
|
COMMAND = 500, -- a command from the user from a v2 cli
|
|
QUERY = 501, -- an internal query from the v2 cli
|
|
|
|
LEGACY = 0, -- v1 cli/ipc only used the 0 msgID
|
|
|
|
ERROR = -1, -- result was an error
|
|
OUTPUT = 1, -- print output
|
|
RETURN = 2, -- result
|
|
CONSOLE = 3, -- cloned console output
|
|
}
|
|
|
|
local originalPrint = print
|
|
local printReplacement = function(...)
|
|
originalPrint(...)
|
|
for _,v in pairs(module.__registeredCLIInstances) do
|
|
if v._cli.console and v.print and not v._cli.quietMode then
|
|
-- v.print(...)
|
|
-- make it more obvious what is console output versus the command line's
|
|
local things = table.pack(...)
|
|
local stdout = (things.n > 0) and tostring(things[1]) or ""
|
|
for i = 2, things.n do
|
|
stdout = stdout .. "\t" .. tostring(things[i])
|
|
end
|
|
v._cli.remote:sendMessage(stdout .. "\n", MSG_ID.CONSOLE)
|
|
end
|
|
end
|
|
end
|
|
print = printReplacement -- luacheck: ignore
|
|
|
|
local originalReload = hs.reload
|
|
local reloadReplacement = function(...)
|
|
hs._reloadTriggered = true
|
|
return originalReload(...)
|
|
end
|
|
hs.reload = reloadReplacement
|
|
|
|
-- Public interface ------------------------------------------------------
|
|
|
|
--- hs.ipc.cliColors([colors]) -> table
|
|
--- Function
|
|
--- Get or set the terminal escape codes used to produce colorized output in the `hs` command line tool
|
|
---
|
|
--- Parameters:
|
|
--- * colors - an optional table or explicit nil specifying the colors to use when colorizing output for the command line tool. If you specify an explicit nil, the colors will revert to their defaults. If you specify a table it must contain one or more of the following keys with the terminal key sequence as a string for the value:
|
|
--- * initial - this color is used for the initial tagline when starting the command line tool and for output to the Hammerspoon console that is redirected to the instance. Defaults to "\27[35m" (foreground magenta).
|
|
--- * input - this color is used for input typed in by the user into the cli instance. Defaults to "\27[33m" (foreground yellow).
|
|
--- * output - this color is used for output generated by the commands executed within the instance and the results returned. Defaults to "\27[36m" (foreground cyan).
|
|
--- * error - this color is used for lua errors generated by the commands executed within the instance. Defaults to "\27[31m" (foreground red).
|
|
---
|
|
--- Returns:
|
|
--- * a table describing the colors used when colorizing output in the `hs` command line tool.
|
|
---
|
|
--- Notes:
|
|
--- * For a brief intro into terminal colors, you can visit a web site like this one [http://jafrog.com/2013/11/23/colors-in-terminal.html](http://jafrog.com/2013/11/23/colors-in-terminal.html)
|
|
--- * Lua doesn't support octal escapes in it's strings, so use `\x1b` or `\27` to indicate the `escape` character e.g. `ipc.cliSetColors{ initial = "", input = "\27[33m", output = "\27[38;5;11m" }`
|
|
---
|
|
--- * Changes made with this function are saved with `hs.settings` with the following labels and will persist through a reload or restart of Hammerspoon: "ipc.cli.color_initial", "ipc.cli.color_input", "ipc.cli.color_output", and "ipc.cli.color_error"
|
|
module.cliColors = function(...)
|
|
local args = table.pack(...)
|
|
if args.n > 0 then
|
|
if type(args[1]) == "nil" then
|
|
settings.clear("ipc.cli.color_initial")
|
|
settings.clear("ipc.cli.color_input")
|
|
settings.clear("ipc.cli.color_output")
|
|
settings.clear("ipc.cli.color_error")
|
|
else
|
|
local colors = args[1]
|
|
if colors.initial then settings.set("ipc.cli.color_initial", colors.initial) end
|
|
if colors.input then settings.set("ipc.cli.color_input", colors.input) end
|
|
if colors.output then settings.set("ipc.cli.color_output", colors.output) end
|
|
if colors.error then settings.set("ipc.cli.color_error", colors.error) end
|
|
end
|
|
end
|
|
local colors = {}
|
|
colors.initial = settings.get("ipc.cli.color_initial") or "\27[35m" ;
|
|
colors.input = settings.get("ipc.cli.color_input") or "\27[33m" ;
|
|
colors.output = settings.get("ipc.cli.color_output") or "\27[36m" ;
|
|
colors.error = settings.get("ipc.cli.color_error") or "\27[31m" ;
|
|
return setmetatable(colors, {
|
|
__tostring = function(self)
|
|
local res = ""
|
|
for k,v in fnutils.sortByKeys(self) do res = res .. string.format("%-7s = %q\n", k, v) end
|
|
return res
|
|
end,
|
|
})
|
|
end
|
|
|
|
--- hs.ipc.cliGetColors() -> table
|
|
--- Deprecated
|
|
--- See [hs.ipc.cliColors](#cliColors).
|
|
local seenDeprecatedGetColors = false
|
|
module.cliGetColors = function()
|
|
if not seenDeprecatedGetColors then
|
|
seenDeprecatedGetColors = true
|
|
log.w("hs.ipc.cliGetColors is deprecated and may be removed in the future. Please use hs.ipc.cliColors instead.")
|
|
end
|
|
return module.cliColors()
|
|
end
|
|
|
|
--- hs.ipc.cliSetColors(table) -> table
|
|
--- Deprecated
|
|
--- See [hs.ipc.cliColors](#cliColors).
|
|
local seenDeprecatedSetColors = false
|
|
module.cliSetColors = function(colors)
|
|
if not seenDeprecatedSetColors then
|
|
seenDeprecatedSetColors = true
|
|
log.w("hs.ipc.cliSetColors is deprecated and may be removed in the future. Please use hs.ipc.cliColors instead.")
|
|
end
|
|
return module.cliColors(colors)
|
|
end
|
|
|
|
--- hs.ipc.cliResetColors()
|
|
--- Deprecated
|
|
--- See [hs.ipc.cliColors](#cliColors).
|
|
local seenDeprecatedResetColors = false
|
|
module.cliResetColors = function()
|
|
if not seenDeprecatedResetColors then
|
|
seenDeprecatedResetColors = true
|
|
log.w("hs.ipc.cliResetColors is deprecated and may be removed in the future. Please use hs.ipc.cliColors instead.")
|
|
end
|
|
return module.cliColors(nil)
|
|
end
|
|
|
|
--- hs.ipc.cliSaveHistory([state]) -> boolean
|
|
--- Function
|
|
--- Get or set whether or not the command line tool saves a history of the commands you type.
|
|
---
|
|
--- Parameters:
|
|
--- * state - an optional boolean (default false) specifying whether or not a history of the commands you type into the command line tool should be saved between sessions.
|
|
---
|
|
--- Returns:
|
|
--- * the current, possibly changed, value
|
|
---
|
|
--- Notes:
|
|
--- * If this is enabled, your history is saved in `hs.configDir .. ".cli.history"`, which is usually "~/.hammerspoon/.cli.history".
|
|
--- * If you have multiple invocations of the command line tool running at the same time, only the history of the last one cleanly exited is saved; this is a limitation of the readline wrapper Apple has provided for libedit and at present no workaround is known.
|
|
---
|
|
--- * Changes made with this function are saved with `hs.settings` with the label "ipc.cli.saveHistory" and will persist through a reload or restart of Hammerspoon.
|
|
module.cliSaveHistory = function(...)
|
|
local args = table.pack(...)
|
|
if args.n > 0 then
|
|
settings.set("ipc.cli.saveHistory", args[1] and true or nil)
|
|
end
|
|
return settings.get("ipc.cli.saveHistory") or false
|
|
end
|
|
|
|
--- hs.ipc.cliSaveHistorySize([size]) -> number
|
|
--- Function
|
|
--- Get or set whether the maximum number of commands saved when command line tool history saving is enabled.
|
|
---
|
|
--- Parameters:
|
|
--- * size - an optional integer (default 1000) specifying the maximum number of commands to save when [hs.ipc.cliSaveHistory](#cliSaveHistory) is set to true.
|
|
---
|
|
--- Returns:
|
|
--- * the current, possibly changed, value
|
|
---
|
|
--- Notes:
|
|
--- * When [hs.ipc.cliSaveHistory](#cliSaveHistory) is enabled, your history is saved in `hs.configDir .. ".cli.history"`, which is usually "~/.hammerspoon/.cli.history".
|
|
--- * If you have multiple invocations of the command line tool running at the same time, only the history of the last one cleanly exited is saved; this is a limitation of the readline wrapper Apple has provided for libedit and at present no workaround is known.
|
|
---
|
|
--- * Changes made with this function are saved with `hs.settings` with the label "ipc.cli.historyLimit" and will persist through a reload or restart of Hammerspoon.
|
|
module.cliSaveHistorySize = function(...)
|
|
local args = table.pack(...)
|
|
if args.n > 0 then
|
|
settings.set("ipc.cli.historyLimit", math.tointeger(args[1]) or nil)
|
|
end
|
|
return settings.get("ipc.cli.historyLimit") or 1000
|
|
end
|
|
|
|
--- hs.ipc.cliStatus([path][,silent]) -> bool
|
|
--- Function
|
|
--- Gets the status of the `hs` command line tool
|
|
---
|
|
--- Parameters:
|
|
--- * path - An optional string containing a path to look for the `hs` tool. Defaults to `/usr/local`
|
|
--- * silent - An optional boolean indicating whether or not to print errors to the Hammerspoon Console
|
|
---
|
|
--- Returns:
|
|
--- * A boolean, true if the `hs` command line tool is correctly installed, otherwise false
|
|
module.cliStatus = function(p, s)
|
|
local path = p or "/usr/local"
|
|
local mod_path = hs.processInfo["frameworksPath"]
|
|
local resource_path = hs.processInfo["resourcePath"]
|
|
local frameworks_path = hs.processInfo["frameworksPath"]
|
|
|
|
local silent = s or false
|
|
|
|
local bin_file = os.execute("[ -f \""..path.."/bin/hs\" ]")
|
|
local man_file = os.execute("[ -f \""..path.."/share/man/man1/hs.1\" ]")
|
|
local bin_link = os.execute("[ -L \""..path.."/bin/hs\" ]")
|
|
local man_link = os.execute("[ -L \""..path.."/share/man/man1/hs.1\" ]")
|
|
local bin_ours = os.execute("[ \""..path.."/bin/hs\" -ef \""..frameworks_path.."/hs/hs\" ]")
|
|
local man_ours = os.execute("[ \""..path.."/share/man/man1/hs.1\" -ef \""..resource_path.."/man/hs.man\" ]")
|
|
|
|
local result = bin_file and man_file and bin_link and man_link and bin_ours and man_ours or false
|
|
local broken = false
|
|
|
|
if not bin_ours and bin_file then
|
|
if not silent then
|
|
print([[cli installation problem: 'hs' is not ours.]])
|
|
end
|
|
broken = true
|
|
end
|
|
if not man_ours and man_file then
|
|
if not silent then
|
|
print([[cli installation problem: 'hs.1' is not ours.]])
|
|
end
|
|
broken = true
|
|
end
|
|
if bin_file and not bin_link then
|
|
if not silent then
|
|
print([[cli installation problem: 'hs' is an independant file won't be updated when Hammerspoon is.]])
|
|
end
|
|
broken = true
|
|
end
|
|
if not bin_file and bin_link then
|
|
if not silent then
|
|
print([[cli installation problem: 'hs' is a dangling link.]])
|
|
end
|
|
broken = true
|
|
end
|
|
if man_file and not man_link then
|
|
if not silent then
|
|
print([[cli installation problem: man page for 'hs.1' is an independant file and won't be updated when Hammerspoon is.]])
|
|
end
|
|
broken = true
|
|
end
|
|
if not man_file and man_link then
|
|
if not silent then
|
|
print([[cli installation problem: man page for 'hs.1' is a dangling link.]])
|
|
end
|
|
broken = true
|
|
end
|
|
if ((bin_file and bin_link) and not (man_file and man_link)) or ((man_file and man_link) and not (bin_file and bin_link)) then
|
|
if not silent then
|
|
print([[cli installation problem: incomplete installation of 'hs' and 'hs.1'.]])
|
|
end
|
|
broken = true
|
|
end
|
|
|
|
return broken and "broken" or result
|
|
end
|
|
|
|
--- hs.ipc.cliInstall([path][,silent]) -> bool
|
|
--- Function
|
|
--- Installs the `hs` command line tool
|
|
---
|
|
--- Parameters:
|
|
--- * path - An optional string containing a path to install the tool in. Defaults to `/usr/local`
|
|
--- * silent - An optional boolean indicating whether or not to print errors to the Hammerspoon Console
|
|
---
|
|
--- Returns:
|
|
--- * A boolean, true if the tool was successfully installed, otherwise false
|
|
---
|
|
--- Notes:
|
|
--- * If this function fails, it is likely that you have some old/broken symlinks. You can use `hs.ipc.cliUninstall()` to forcibly tidy them up
|
|
--- * You may need to pre-create `/usr/local/bin` and `/usr/local/share/man/man1` in a terminal using sudo, and adjust permissions so your login user can write to them
|
|
module.cliInstall = function(p, s)
|
|
local path = p or "/usr/local"
|
|
local silent = s or false
|
|
if module.cliStatus(path, true) == false then
|
|
local mod_path = hs.processInfo["frameworksPath"]
|
|
local resource_path = hs.processInfo["resourcePath"]
|
|
os.execute("ln -s \""..mod_path.."/hs/hs\" \""..path.."/bin/\"")
|
|
os.execute("ln -s \""..resource_path.."/man/hs.man\" \""..path.."/share/man/man1/hs.1\"")
|
|
end
|
|
return module.cliStatus(path, silent)
|
|
end
|
|
|
|
--- hs.ipc.cliUninstall([path][,silent]) -> bool
|
|
--- Function
|
|
--- Uninstalls the `hs` command line tool
|
|
---
|
|
--- Parameters:
|
|
--- * path - An optional string containing a path to remove the tool from. Defaults to `/usr/local`
|
|
--- * silent - An optional boolean indicating whether or not to print errors to the Hammerspoon Console
|
|
---
|
|
--- Returns:
|
|
--- * A boolean, true if the tool was successfully removed, otherwise false
|
|
---
|
|
--- Notes:
|
|
--- * This function used to be very conservative and refuse to remove symlinks it wasn't sure about, but now it will unconditionally remove whatever it finds at `path/bin/hs` and `path/share/man/man1/hs.1`. This is more likely to be useful in situations where this command is actually needed (please open an Issue on GitHub if you disagree!)
|
|
module.cliUninstall = function(p, s)
|
|
local path = p or "/usr/local"
|
|
local silent = s or false
|
|
os.execute("rm \""..path.."/bin/hs\"")
|
|
os.execute("rm \""..path.."/share/man/man1/hs.1\"")
|
|
return not module.cliStatus(path, silent)
|
|
end
|
|
|
|
module.__registeredCLIInstances = {}
|
|
-- cleanup in case someone goes away without saying goodbye
|
|
module.__registeredInstanceCleanup = timer.doEvery(60, function()
|
|
for k, v in pairs(module.__registeredCLIInstances) do
|
|
if v._cli.remote and not v._cli.remote:isValid() then
|
|
log.df("pruning %s; message port is no longer valid", k)
|
|
v._cli.remote:delete()
|
|
module.__registeredCLIInstances[k] = nil
|
|
elseif not v._cli.remote then
|
|
module.__registeredCLIInstances[k] = nil
|
|
end
|
|
end
|
|
end)
|
|
|
|
module.__defaultHandler = function(_, msgID, msg)
|
|
if msgID == MSG_ID.LEGACYCHK then
|
|
-- the message sent will be a mathematical equation; the original ipc will evaluate it because it ignored
|
|
-- the msgid. We send back a version string instead
|
|
return "version:2.0a"
|
|
elseif msgID == MSG_ID.REGISTER then -- registering a new instance
|
|
local instanceID, arguments = msg:match("^([%w-]+)\0(.*)$")
|
|
if not instanceID then instanceID, arguments = msg, nil end
|
|
local scriptArguments = nil
|
|
local quietMode = false
|
|
local console = "none"
|
|
if arguments then
|
|
arguments = json.decode(arguments)
|
|
scriptArguments = {}
|
|
local seenSeparator = false
|
|
for i, v in ipairs(arguments) do
|
|
if i > 1 and (v == "--" or v:match("^~") or v:match("^%.?/")) then seenSeparator = true end
|
|
if seenSeparator then
|
|
table.insert(scriptArguments, v)
|
|
else
|
|
if v == "-q" then quietMode = true end
|
|
if v == "-C" then console = "mirror" end
|
|
if v == "-P" then console = "legacy" end
|
|
end
|
|
end
|
|
if #scriptArguments == 0 then scriptArguments = arguments end
|
|
end
|
|
log.df("registering %s", instanceID)
|
|
|
|
module.__registeredCLIInstances[instanceID] = setmetatable({
|
|
_cli = {
|
|
remote = module.remotePort(instanceID),
|
|
console = console,
|
|
_args = arguments,
|
|
args = scriptArguments,
|
|
quietMode = quietMode,
|
|
},
|
|
print = function(...)
|
|
local parent = module.__registeredCLIInstances[instanceID]._cli
|
|
if parent.quietMode then return end
|
|
local things = table.pack(...)
|
|
local stdout = (things.n > 0) and tostring(things[1]) or ""
|
|
for i = 2, things.n do
|
|
stdout = stdout .. "\t" .. tostring(things[i])
|
|
end
|
|
module.__registeredCLIInstances[instanceID]._cli.remote:sendMessage(stdout .. "\n", MSG_ID.OUTPUT)
|
|
if type(parent.console) == "nil" then
|
|
originalPrint(...)
|
|
end
|
|
end,
|
|
}, {
|
|
__index = _G,
|
|
__newindex = function(_, key, value)
|
|
_G[key] = value
|
|
end,
|
|
})
|
|
elseif msgID == MSG_ID.UNREGISTER then -- unregistering an instance
|
|
log.df("unregistering %s", msg)
|
|
module.__registeredCLIInstances[msg]._cli.remote:delete()
|
|
module.__registeredCLIInstances[msg] = nil
|
|
elseif msgID == MSG_ID.COMMAND or msgID == MSG_ID.QUERY then
|
|
local instanceID, code = msg:match("^([%w-]*)\0(.*)$")
|
|
-- print(msg, instanceID, code)
|
|
if instanceID then
|
|
|
|
if hs._consoleInputPreparser then
|
|
if type(hs._consoleInputPreparser) == "function" then
|
|
local status, code2 = pcall(hs._consoleInputPreparser, code)
|
|
if status then
|
|
code = code2
|
|
else
|
|
hs.luaSkinLog.ef("console preparse error: %s", code2)
|
|
end
|
|
else
|
|
hs.luaSkinLog.e("console preparser must be a function or nil")
|
|
end
|
|
end
|
|
|
|
local fnEnv = module.__registeredCLIInstances[instanceID]
|
|
local fn, err = load("return " .. code, "return " .. code, "bt", fnEnv)
|
|
if not fn then fn, err = load(code, code, "bt", fnEnv) end
|
|
local results = fn and table.pack(pcall(fn)) or { false, err, n = 2 }
|
|
|
|
local str = (results.n > 1) and tostring(results[2]) or ""
|
|
for i = 3, results.n do
|
|
str = str .. "\t" .. tostring(results[i])
|
|
end
|
|
|
|
if #str > 0 then str = str .. "\n" end
|
|
if msgID == MSG_ID.COMMAND then
|
|
if not hs._reloadTriggered then
|
|
fnEnv._cli.remote:sendMessage(str, results[1] and MSG_ID.RETURN or MSG_ID.ERROR)
|
|
end
|
|
return results[1] and "ok" or "error"
|
|
else
|
|
return str
|
|
end
|
|
else
|
|
log.ef("unexpected message received: %s", msg)
|
|
end
|
|
elseif msgID == MSG_ID.LEGACY then
|
|
log.df("in legacy handler")
|
|
local _, str = (msg:sub(1,1) == "r"), msg:sub(2)
|
|
|
|
if hs._consoleInputPreparser then
|
|
if type(hs._consoleInputPreparser) == "function" then
|
|
local status, s2 = pcall(hs._consoleInputPreparser, str)
|
|
if status then
|
|
str = s2
|
|
else
|
|
hs.luaSkinLog.ef("console preparse error: %s", s2)
|
|
end
|
|
else
|
|
hs.luaSkinLog.e("console preparser must be a function or nil")
|
|
end
|
|
end
|
|
|
|
local originalprint = print
|
|
local fakestdout = ""
|
|
print = function(...) -- luacheck: ignore
|
|
originalprint(...)
|
|
local things = table.pack(...)
|
|
for i = 1, things.n do
|
|
if i > 1 then fakestdout = fakestdout .. "\t" end
|
|
fakestdout = fakestdout .. tostring(things[i])
|
|
end
|
|
fakestdout = fakestdout .. "\n"
|
|
end
|
|
|
|
-- local fn = raw and rawhandler or module.handler
|
|
local fn = function(ss)
|
|
local fn, err = load("return " .. ss)
|
|
if not fn then fn, err = load(ss) end
|
|
if fn then return fn() else return err end
|
|
end
|
|
|
|
local results = table.pack(pcall(function() return fn(str) end))
|
|
|
|
str = ""
|
|
for i = 2, results.n do
|
|
if i > 2 then str = str .. "\t" end
|
|
str = str .. tostring(results[i])
|
|
end
|
|
|
|
print = originalprint -- luacheck: ignore
|
|
return fakestdout .. str
|
|
else
|
|
log.ef("unexpected message id received: %d, %s", msgID, msg)
|
|
end
|
|
end
|
|
|
|
module.__default = module.localPort("Hammerspoon", module.__defaultHandler)
|
|
|
|
-- Return Module Object --------------------------------------------------
|
|
|
|
return setmetatable(module, {
|
|
__index = function(self, key)
|
|
if key == "handler" then
|
|
log.e("Setting a specialized handler is no longer supported in hs.ipc. Use `hs.ipc.localPort` to setup your own message port for handling custom requests.")
|
|
return nil
|
|
else
|
|
return rawget(self, key) -- probably not necessary, but...
|
|
end
|
|
end,
|
|
__newindex = function(self, key, value)
|
|
if key == "handler" then
|
|
log.e("Setting a specialized handler is no longer supported in hs.ipc. Use `hs.ipc.localPort` to setup your own message port for handling custom requests.")
|
|
else
|
|
rawset(self, key, value)
|
|
end
|
|
end,
|
|
})
|