FrameBridge 0.3.1 community source release
This commit is contained in:
commit
b23819fff1
1354 files changed
+343716
No files matched your search
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""FrameBridge's two-pane physical console, using the existing user's tmux.
|
||||
|
||||
No login, privilege, service or desktop configuration is changed here.
|
||||
The normal entry point is called by an existing authenticated tty1 login.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def arguments():
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--node', default=os.environ.get('FRAMEBRIDGE_NODE', socket.gethostname()))
|
||||
p.add_argument('--socket')
|
||||
p.add_argument('--session')
|
||||
p.add_argument('--apply', action='store_true', help='Set up without attaching a client')
|
||||
p.add_argument('--probe', action='store_true', help='Read current pane metadata only')
|
||||
p.add_argument('--reflow', action='store_true', help='Resize existing panes without taking focus')
|
||||
p.add_argument('--refresh-monitor', action='store_true')
|
||||
p.add_argument('--layout', choices=('auto','horizontal','vertical'), default='auto')
|
||||
p.add_argument('--monitor', action='store_true', help=argparse.SUPPRESS)
|
||||
a = p.parse_args()
|
||||
a.node = re.sub(r'[^A-Za-z0-9_-]', '_', a.node)[:64] or 'NODE'
|
||||
a.socket = a.socket or str(Path('/run/user') / str(os.getuid()) / (a.node.lower() + '-tty1') / 'console.sock')
|
||||
a.session = a.session or a.node.upper() + '-FBTERM'
|
||||
if not re.fullmatch(r'[A-Za-z0-9_-]{1,90}', a.session):
|
||||
p.error('Session name must contain only letters, digits, underscore and hyphen')
|
||||
return a
|
||||
|
||||
|
||||
class Console:
|
||||
def __init__(self, args):
|
||||
self.a = args
|
||||
self.session_target = args.session
|
||||
self.window = '=' + args.session + ':0'
|
||||
self.env = {k:v for k,v in os.environ.items() if k not in ('TMUX', 'TMUX_PANE')}
|
||||
self.env['LC_ALL'] = 'C.UTF-8'
|
||||
|
||||
def tmux(self, *args, check=True):
|
||||
r = subprocess.run(['/usr/bin/tmux', '-S', self.a.socket, *args],
|
||||
env=self.env, capture_output=True, text=True, timeout=8)
|
||||
if check and r.returncode:
|
||||
raise RuntimeError('tmux ' + args[0] + ': ' + r.stderr.strip())
|
||||
return r.stdout.strip()
|
||||
|
||||
def exists(self):
|
||||
r = subprocess.run(['/usr/bin/tmux', '-S', self.a.socket, 'has-session', '-t', '=' + self.a.session],
|
||||
env=self.env, capture_output=True, timeout=5)
|
||||
if r.returncode == 0:
|
||||
matches=[line.rsplit('|',1)[1] for line in self.tmux('list-sessions','-F','#{session_name}|#{session_id}').splitlines()
|
||||
if line.rsplit('|',1)[0]==self.a.session]
|
||||
if len(matches)!=1:
|
||||
raise RuntimeError('Console session identity is ambiguous')
|
||||
self.session_target=matches[0]
|
||||
windows=self.tmux('list-windows','-t',self.session_target,'-F','#{window_id}').splitlines()
|
||||
if len(windows)!=1:
|
||||
raise RuntimeError('Use a dedicated console session containing exactly one window')
|
||||
self.window=windows[0]
|
||||
return True
|
||||
return False
|
||||
|
||||
def panes(self):
|
||||
fmt = '|'.join('#{' + f + '}' for f in ('pane_id','pane_pid','pane_current_command',
|
||||
'pane_left','pane_top','pane_width','pane_height','pane_active','pane_dead',
|
||||
'@framebridge_role'))
|
||||
rows = []
|
||||
for line in self.tmux('list-panes', '-t', self.window, '-F', fmt).splitlines():
|
||||
ident,pid,command,left,top,width,height,active,dead,role = line.split('|')
|
||||
rows.append(dict(id=ident,pid=int(pid),command=command,left=int(left),top=int(top),
|
||||
width=int(width),height=int(height),active=active=='1',dead=dead=='1',role=role))
|
||||
return rows
|
||||
|
||||
def monitor_command(self):
|
||||
return shlex.join(['/usr/bin/python3', str(HERE / 'framebridge_console.py'), '--monitor', '--node', self.a.node])
|
||||
|
||||
def locate(self, rows):
|
||||
shell = next((p for p in rows if p['role']=='shell'), None)
|
||||
monitor = next((p for p in rows if p['role']=='monitor'), None)
|
||||
# Adopt only the known original dashboard, never an arbitrary Python pane.
|
||||
for p in rows:
|
||||
if p['role']:
|
||||
continue
|
||||
try:
|
||||
cmd = Path('/proc', str(p['pid']), 'cmdline').read_bytes().split(b'\0')
|
||||
names = {Path(x.decode(errors='replace')).name for x in cmd if x}
|
||||
except OSError:
|
||||
names = set()
|
||||
if not monitor and ('fbterm_live.py' in names or 'fbterm_dashboard.py' in names):
|
||||
monitor = p
|
||||
elif not shell and p['command']=='bash' and not p['dead']:
|
||||
shell = p
|
||||
return shell, monitor
|
||||
|
||||
def layout(self, shell, monitor):
|
||||
active=next((p['id'] for p in self.panes() if p['active']),None)
|
||||
width,height = map(int, self.tmux('display-message','-p','-t',self.window,
|
||||
'#{window_width} #{window_height}').split())
|
||||
mode=getattr(self.a,'layout','auto')
|
||||
horizontal=mode=='horizontal' or (mode=='auto' and width>=160)
|
||||
if horizontal:
|
||||
self.tmux('select-layout','-t',self.window,'even-horizontal')
|
||||
current={p['id']:p for p in self.panes()}
|
||||
if current[monitor['id']]['left']>current[shell['id']]['left']:
|
||||
self.tmux('swap-pane','-d','-s',shell['id'],'-t',monitor['id'])
|
||||
monitor_width=max(2,min(max(2,width-21),max(24,round((width-1)*0.30))))
|
||||
self.tmux('resize-pane','-t',monitor['id'],'-x',str(monitor_width))
|
||||
else:
|
||||
self.tmux('select-layout','-t',self.window,'even-vertical')
|
||||
current={p['id']:p for p in self.panes()}
|
||||
if current[monitor['id']]['top']>current[shell['id']]['top']:
|
||||
self.tmux('swap-pane','-d','-s',shell['id'],'-t',monitor['id'])
|
||||
self.tmux('resize-pane','-t',monitor['id'],'-y',str(max(2,min(8,(height-3)//3))))
|
||||
if active:
|
||||
self.tmux('select-pane','-t',active)
|
||||
|
||||
def apply(self):
|
||||
existed=self.exists()
|
||||
if not existed:
|
||||
parent=Path(self.a.socket).parent
|
||||
parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
st=parent.stat()
|
||||
if st.st_uid!=os.getuid() or st.st_mode & 0o022:
|
||||
raise RuntimeError('Console socket directory is not private to this user')
|
||||
size=os.get_terminal_size(0) if os.isatty(0) else os.terminal_size((240,67))
|
||||
self.session_target,self.window=self.tmux('new-session','-d','-P','-F','#{session_id}|#{window_id}',
|
||||
'-x',str(size.columns),'-y',str(size.lines),
|
||||
'-s',self.a.session,'-n','Console','-c',str(Path.home()),'/bin/bash --login').split('|')
|
||||
rows=self.panes()
|
||||
shell,monitor=self.locate(rows)
|
||||
if self.a.reflow:
|
||||
if shell and monitor and len(rows)==2:
|
||||
# Zoom is an explicit user choice; never undo it in a resize hook.
|
||||
if self.tmux('display-message','-p','-t',self.window,'#{window_zoomed_flag}')!='1':
|
||||
self.layout(shell,monitor)
|
||||
return
|
||||
if not shell or shell['dead']:
|
||||
raise RuntimeError('Normal Bash pane is absent; refusing to replace another program')
|
||||
if len(rows)>2 or (len(rows)==2 and not monitor):
|
||||
raise RuntimeError('Unexpected console panes; refusing to modify them')
|
||||
original_pid=shell['pid']
|
||||
previous_focus=next((p['id'] for p in rows if p['active']),shell['id'])
|
||||
previous_zoom=self.tmux('display-message','-p','-t',self.window,'#{window_zoomed_flag}')=='1'
|
||||
if not monitor:
|
||||
ident=self.tmux('split-window','-d','-h','-l','30%','-t',shell['id'],'-P','-F','#{pane_id}',self.monitor_command())
|
||||
monitor=next(p for p in self.panes() if p['id']==ident)
|
||||
else:
|
||||
try:
|
||||
cmd=Path('/proc',str(monitor['pid']),'cmdline').read_bytes()
|
||||
except OSError:
|
||||
cmd=b''
|
||||
if self.a.refresh_monitor or monitor['dead'] or b'framebridge_console.py' not in cmd:
|
||||
self.tmux('respawn-pane','-k','-t',monitor['id'],self.monitor_command())
|
||||
for role,pane in [('shell',shell),('monitor',monitor)]:
|
||||
self.tmux('set-option','-p','-t',pane['id'],'@framebridge_role',role)
|
||||
self.tmux('set-option','-p','-t',monitor['id'],'remain-on-exit','on')
|
||||
self.tmux('select-pane','-t',shell['id'],'-T','SHELL | '+self.a.node+' | '+getpass.getuser())
|
||||
self.tmux('select-pane','-t',monitor['id'],'-T','FBTERM | ACTIVITY')
|
||||
self.tmux('set-window-option','-t',self.window,'automatic-rename','off')
|
||||
self.tmux('rename-window','-t',self.window,'Console')
|
||||
for key,value in [('pane-border-status','top'),('pane-border-style','fg=blue'),
|
||||
('pane-active-border-style','fg=cyan'),
|
||||
('pane-border-format',' #{?pane_active,>, } #{pane_title} '),
|
||||
('window-status-format',''),('window-status-current-format','')]:
|
||||
self.tmux('set-window-option','-t',self.window,key,value)
|
||||
for key,value in [('status','on'),('status-style','bg=blue,fg=white'),
|
||||
('status-left','#[bold] FRAMEBRIDGE #[default] | '+self.a.node+' | '),
|
||||
('status-left-length','80'),('status-right-length','110'),
|
||||
('status-right',' Ctrl+B, then o: switch | z: zoom | %H:%M %Z ')]:
|
||||
self.tmux('set-option','-t',self.session_target,key,value)
|
||||
self.tmux('set-option','-t',self.session_target,'mouse','off')
|
||||
# This server is dedicated to FrameBridge; do not alter global tmux configuration.
|
||||
for key in ('z','C-z','Z'):
|
||||
self.tmux('bind-key','-T','prefix',key,'resize-pane','-Z')
|
||||
# Reattachment must preserve the user's zoom and pane focus.
|
||||
if not previous_zoom:
|
||||
self.layout(shell,monitor)
|
||||
command=shlex.join(['/usr/bin/python3',str(HERE/'framebridge_console.py'),'--node',self.a.node,
|
||||
'--socket',self.a.socket,'--session',self.a.session,
|
||||
'--layout',getattr(self.a,'layout','auto'),'--reflow'])
|
||||
self.tmux('set-hook','-t',self.session_target,'client-resized[90]','run-shell -b '+shlex.quote(command))
|
||||
self.tmux('set-hook','-t',self.session_target,'client-attached[90]','run-shell -b '+shlex.quote(command))
|
||||
self.tmux('select-window','-t',self.window)
|
||||
selected=previous_focus if existed else shell['id']
|
||||
self.tmux('select-pane','-t',selected)
|
||||
if previous_zoom and self.tmux('display-message','-p','-t',self.window,'#{window_zoomed_flag}')!='1':
|
||||
self.tmux('resize-pane','-Z','-t',selected)
|
||||
current=next(p for p in self.panes() if p['id']==shell['id'])
|
||||
if current['pid']!=original_pid:
|
||||
raise RuntimeError('Bash identity changed unexpectedly')
|
||||
|
||||
|
||||
def supervise(node):
|
||||
while True:
|
||||
try:
|
||||
result=subprocess.run(['/usr/bin/python3','-u',str(HERE/'fbterm_dashboard.py'),'--node',node])
|
||||
print('\r\nFBTERM monitor stopped (exit '+str(result.returncode)+'). Restarting in 2 seconds.',flush=True)
|
||||
print('Ctrl+B o switches to the shell.',flush=True)
|
||||
time.sleep(2)
|
||||
except KeyboardInterrupt:
|
||||
print('\r\nFBTERM monitor restarting. Ctrl+B o switches to the shell.',flush=True)
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def main():
|
||||
args=arguments()
|
||||
if args.monitor:
|
||||
supervise(args.node)
|
||||
return 0
|
||||
console=Console(args)
|
||||
if args.probe:
|
||||
print(json.dumps({'node':args.node,'socket':args.socket,'session':args.session,
|
||||
'panes':console.panes() if console.exists() else [],'login':'existing-authenticated-login'}))
|
||||
return 0
|
||||
if not (args.apply or args.reflow):
|
||||
if not os.isatty(0) or os.ttyname(0)!='/dev/tty1' or os.environ.get('TMUX') or os.environ.get('SSH_TTY'):
|
||||
print('Open this console from an authenticated tty1 login. --probe is read-only.',file=sys.stderr)
|
||||
return 1
|
||||
parent=Path(args.socket).parent
|
||||
parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
info=parent.stat()
|
||||
if info.st_uid!=os.getuid() or info.st_mode & 0o022:
|
||||
raise RuntimeError('Console socket directory is not private to this user')
|
||||
fd=os.open(parent/'console-layout.lock',os.O_CREAT|os.O_RDWR|os.O_CLOEXEC|os.O_NOFOLLOW,0o600)
|
||||
try:
|
||||
info=os.fstat(fd)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid!=os.getuid():
|
||||
raise RuntimeError('Invalid console layout lock')
|
||||
fcntl.flock(fd,fcntl.LOCK_EX)
|
||||
console.apply()
|
||||
finally:
|
||||
os.close(fd)
|
||||
if args.apply:
|
||||
print(json.dumps({'node':args.node,'status':'READY','panes':console.panes()}))
|
||||
elif not args.reflow:
|
||||
os.execve('/usr/bin/tmux',['tmux','-S',args.socket,'attach-session','-t',console.session_target],console.env)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (RuntimeError,OSError,subprocess.TimeoutExpired) as e:
|
||||
print('FrameBridge console: '+str(e),file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
Reference in new issue
Block a user