The problem#
Firefox Marionette only allows a single client to connect a time, so I'd like to have a program in charge of holding that connection that can communicate with the other parts of the system that know what I want Firefox to actually do. While a common way of handling this is to run an HTTP server, that seems pretty heavyweight and would allow access to any user on the machine.
This can be generalized to any case where we want to be able to control one program from another on the same computer. Some reasons why we might not be able to simply act from the first program is if the latter has different access permissions or is holding onto some state like, as mentioned, an open socket.
The solution#
The two programs can communicate over a named pipe, also
known as a FIFO. The mkfifo
command creates one on
the filesystem:
$ mkfifo a_pipe
$ chown server_uid:clients_gid a_pipe
$ chmod 620 a_pipe
Then you can use tail -f a_pipe
to watch the pipe and
echo something > pipe
to write to the pipe. To add a bit more
structure, here's a very simple server and client in Python where the
client sends one command per line as JSON and the server processes the
commands one at a time:
# server.py
import fileinput
import json
def process_cmd(cmd, *args):
print(f"In process_cmd({cmd})...")
for line in fileinput.input():
try:
process_cmd(*json.loads(line))
except Exception as ex:
print("Command failed:")
print(ex, flush=True)
# client.py
import json
import sys
print(json.dumps(sys.argv[1:]))
# Run the server.
$ tail -f a_pipe | python server.py
# Send some commands using the client.
$ python client.py cmd foo bar > a_pipe
$ python client.py another_cmd baz > a_pipe
Then the server will print out
In process_cmd(cmd)...
In process_cmd(another_cmd)...
The details#
Creating the named pipe#
Named pipes look a lot like files, so you can put them on the filesystem anywhere you would put a file, and assign them permissions like any other file. This allows you to effectively use Unix file system permissions to control access to your server program.
Server lifetime management#
Note that you should only listen to a named pipe from a single program
at a time. There's nothing stopping you from running tail -f a_pipe
multiple times at once, but each message will go to only one of them. If
you want to make sure no other server process is reading the pipe, the
simplest way is to just delete it and recreate it. You could also use
lsof
to check what processes have it open. Consider using
the --pid
option of tail
to make sure it doesn't
stay open longer than it should.
Connecting within Python#
The example code above does all of the I/O in the shell, so the server
could actually be written in any language. But this also means that
attaching to the pipe has to happen at the same time the server process
is started. Instead, it's pretty easy to do the equivalent of tail -f
in Python code:
path = './a_pipe'
while True:
with open(path, 'r') as f:
for line in f:
process_cmd(json.loads(line))
Similarly, the client connection could happen inside a larger Python program:
with open(path, 'w') as f:
f.write(json.dumps(sys.argv[1:]) + '\n')
Replying to the client#
This communication mechanism is unidirectional, so if the client needs to get any information from the server (e.g., a confirmation that the command was received and/or completed), then you need another communication channel.
One possibility is a second named pipe going the other direction, but since sending messages on a pipe blocks if there is no listener, that would let a buggy client deadlock the server, so might not be the best idea. Another possibility would be writing the response to a file, either in a known location or specified in the command arguments. Alternatively, if bidirectional communication is needed, then a different IPC mechanism may be more appropriate.
A Marionette command handler#
Below is a basic version of a process_cmd()
function for communicating
with Firefox Marionette.
As discussed above, the listtabs
command communicates back to the
client by writing a file ./tabs.json
which contains a JSON object with
the handles, window positions, and URLs of the current tabs.
Also, it uses remove-decorations
from my post
on borderless Firefox to remove the window borders
from all Firefox windows when resizing. Note that this means that this
Python code has to be run by the same user that owns the graphical
session Firefox is running under; otherwise it's just connecting to a
socket which any user could do.
import subprocess
from marionette_driver.marionette import Marionette
client = Marionette('localhost', port=2828)
info = client.start_session()
pid = info['moz:processID']
print(f"Connected to Firefox Marionette (pid={pid}).", flush=True)
def process_cmd(cmd, *args):
if cmd == 'navigate':
client.navigate(args[0])
elif cmd == 'newtab':
client.open('tab', True, False)
elif cmd == 'newwindow':
client.open('window', True, False)
elif cmd == 'listtabs':
tabs = []
original_window = client.current_window_handle
for handle in client.window_handles:
client.switch_to_window(handle)
tabs.append({
"handle": handle,
"rect": client.window_rect,
"url": client.get_url()})
client.switch_to_window(original_window)
with open('tabs.json', 'w') as f:
f.write(json.dumps(tabs))
elif cmd == 'switchtab':
client.switch_to_window(args[0])
elif cmd == 'closetab':
client.close()
elif cmd == 'fullscreen':
client.fullscreen()
elif cmd == 'resize':
client.set_window_rect(*map(int, args))
subprocess.run(f'xdotool search --pid {pid} --onlyvisible '
+ '| xargs -n1 remove-decorations',
shell=True)
else:
raise Exception(f"Unknown command: {cmd}")
Comments
Have something to add? Post a comment by sending an email to comments@aweirdimagination.net. You may use Markdown for formatting.
There are no comments yet.