Add tabbed-run control channel to open apps in new tabs

Host listens on a unix socket; tabbed-run sends a command to open it as
a new maximized casilda tab. Spawned children inherit the full
environment plus TABBED_CASILDA_CTRL so tabbed-run works from any tab.
This commit is contained in:
Andrew Hamilton 2026-08-13 09:16:03 +10:00
parent 88778ecbb4
commit 134de7bb4d
2 changed files with 82 additions and 15 deletions

24
tabbed-run Executable file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env python3
import os
import shlex
import socket
import sys
ctrl = os.environ.get("TABBED_CASILDA_CTRL")
if not ctrl:
print(
"tabbed-run: TABBED_CASILDA_CTRL not set (run inside a tabbed-casilda terminal)",
file=sys.stderr,
)
sys.exit(1)
if len(sys.argv) < 2:
print("usage: tabbed-run COMMAND [ARGS...]", file=sys.stderr)
sys.exit(1)
payload = (shlex.join(sys.argv[1:]) + "\n").encode()
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.connect(ctrl)
s.sendall(payload)
except OSError as e:
print(f"tabbed-run: cannot reach host: {e}", file=sys.stderr)
sys.exit(1)

View file

@ -6,26 +6,17 @@
# ]
# ///
import argparse
import gi
import atexit
import os
import shlex
import shutil
import sys
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Casilda", "1.0")
from gi.repository import GLib, Gtk, Casilda
def make_tab(notebook, command):
compositor = Casilda.Compositor()
compositor.props.maximized = True
label = Gtk.Label(label=command)
notebook.append_page(compositor, label)
argv = shlex.split(command)
if not argv:
return
argv[0] = shutil.which(argv[0]) or argv[0]
compositor.spawn_async(None, argv, None, GLib.SpawnFlags.DEFAULT)
from gi.repository import Gio, GLib, Gtk, Casilda
class TabbedCasilda(Gtk.Application):
@ -33,6 +24,56 @@ class TabbedCasilda(Gtk.Application):
super().__init__(application_id="local.tabbed-casilda")
self.win = None
self.commands = commands
self.notebook = None
self.ctrl_path = os.path.join(
os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "tabbed-casilda.sock"
)
def add_tab(self, command, ctrl_spawn=False):
compositor = Casilda.Compositor()
compositor.props.maximized = True
label = Gtk.Label(label=command)
self.notebook.append_page(compositor, label)
argv = shlex.split(command)
if not argv:
return
argv[0] = shutil.which(argv[0]) or argv[0]
repo_dir = os.path.dirname(os.path.abspath(__file__))
env = dict(os.environ)
env["PATH"] = f"{repo_dir}:{env.get('PATH', '')}"
env["TABBED_CASILDA_CTRL"] = self.ctrl_path
if ctrl_spawn:
env["G_APPLICATION_NON_UNIQUE"] = "1"
envp = [f"{k}={v}" for k, v in env.items()]
compositor.spawn_async(None, argv, envp, GLib.SpawnFlags.DEFAULT)
def start_control_channel(self):
try:
os.unlink(self.ctrl_path)
except FileNotFoundError:
pass
service = Gio.SocketService()
address = Gio.UnixSocketAddress.new(self.ctrl_path)
service.add_address(
address, Gio.SocketType.STREAM, Gio.SocketProtocol.DEFAULT, None
)
service.connect("incoming", self.on_incoming)
service.start()
self.ctrl_service = service
atexit.register(self.cleanup_socket)
def cleanup_socket(self):
try:
os.unlink(self.ctrl_path)
except FileNotFoundError:
pass
def on_incoming(self, service, connection, source_object):
stream = Gio.DataInputStream.new(connection.get_input_stream())
line, _ = stream.read_line()
if line:
self.add_tab(line.decode(), ctrl_spawn=True)
return False
def do_activate(self):
if self.win:
@ -46,8 +87,10 @@ class TabbedCasilda(Gtk.Application):
)
nb = Gtk.Notebook()
win.set_child(nb)
self.notebook = nb
self.start_control_channel()
for command in self.commands:
make_tab(nb, command)
self.add_tab(command)
win.present()
self.win = win