Repair the official desktop console without replacing existing work
This commit is contained in:
1 parent
7c9371b76a
commit
e98d0d70e6
7 files changed
+447
-19
No files matched your search
@@ -592,6 +592,8 @@ def build_parser():
|
||||
con_sub = console.add_subparsers(dest="console_command")
|
||||
login = con_sub.add_parser("login", help="Optional work-view opening after authenticated tty1 login; not service startup.")
|
||||
login.add_argument("console_login_action", choices=("enable", "status", "disable"))
|
||||
desktop = con_sub.add_parser("desktop", help="Migrate a recognized legacy desktop console into this package.")
|
||||
desktop.add_argument("console_desktop_action", choices=("migrate-legacy",))
|
||||
tty = sub.add_parser("tty", help="Manage persistent Bash sessions; persistence is between calls, not reboots.")
|
||||
ts = tty.add_subparsers(dest="tty_command", required=True, metavar="ACTION")
|
||||
tty_service = ts.add_parser("service", help="Manage the terminal daemon.")
|
||||
@@ -764,6 +766,12 @@ def _dispatch(parser, args):
|
||||
return _interaction_required("fb nodes run")
|
||||
return nodes_command(args, state)
|
||||
if args.command == "console":
|
||||
if getattr(args, "console_desktop_action", None):
|
||||
if args.node != "LOCAL" or args.status or args.login_action:
|
||||
raise ValueError("Desktop console migration must target the local profile alone.")
|
||||
result = _module("console_desktop").migrate_legacy(state)
|
||||
_show(result)
|
||||
return _result_code(result)
|
||||
action = getattr(args, "console_login_action", None) or args.login_action
|
||||
if getattr(args, "console_login_action", None) and args.login_action:
|
||||
raise ValueError("Choose one console login command form.")
|
||||
|
||||
@@ -49,8 +49,10 @@ def context(state, node="LOCAL", *, create=False):
|
||||
label = _node(state, node)
|
||||
key = hashlib.sha256(str(state).encode()).hexdigest()[:20]
|
||||
runtime = _private_runtime(Path("/run/user") / str(os.getuid()))
|
||||
root = runtime / "framebridge-expanded-console"
|
||||
profile = root / key
|
||||
from .console_desktop import legacy_context
|
||||
legacy = legacy_context(state, label, runtime)
|
||||
root = legacy["socket"].parent if legacy else runtime / "framebridge-expanded-console"
|
||||
profile = root if legacy else root / key
|
||||
if create:
|
||||
private_dir(state)
|
||||
for path in (root, profile):
|
||||
@@ -63,7 +65,7 @@ def context(state, node="LOCAL", *, create=False):
|
||||
return {
|
||||
"state": state, "node": label, "runtime": runtime,
|
||||
"socket": profile / "console.sock",
|
||||
"session": "FB-" + label + "-" + key[:10],
|
||||
"session": legacy["session"] if legacy else "FB-" + label + "-" + key[:10],
|
||||
"jobs_root": state / "home/.local/share/framebridge/fbterm",
|
||||
}
|
||||
|
||||
@@ -75,10 +77,20 @@ def _active_graphical_display(env=None):
|
||||
env = os.environ if env is None else env
|
||||
display = env.get("DISPLAY", "")
|
||||
sid = env.get("XDG_SESSION_ID", "")
|
||||
if (re.fullmatch(r":[0-9]+(?:\.[0-9]+)?", display) is None
|
||||
or re.fullmatch(r"[A-Za-z0-9_-]{1,80}", sid) is None):
|
||||
if re.fullmatch(r":[0-9]+(?:\.[0-9]+)?", display) is None:
|
||||
return False
|
||||
try:
|
||||
# Desktop launchers started through the user bus may omit XDG_SESSION_ID.
|
||||
# Ask logind for this user's display session and validate every field below.
|
||||
if not sid:
|
||||
session = subprocess.run([
|
||||
"loginctl", "show-user", str(os.getuid()), "--property=Display", "--value",
|
||||
], capture_output=True, text=True, timeout=5)
|
||||
if session.returncode:
|
||||
return False
|
||||
sid = session.stdout.strip()
|
||||
if re.fullmatch(r"[A-Za-z0-9_-]{1,80}", sid) is None:
|
||||
return False
|
||||
result = subprocess.run([
|
||||
"loginctl", "show-session", sid, "--no-pager",
|
||||
"-p", "User", "-p", "Type", "-p", "Active", "-p", "Remote",
|
||||
@@ -142,7 +154,7 @@ def status(state, node="LOCAL"):
|
||||
try:
|
||||
result = subprocess.run([
|
||||
"/usr/bin/tmux", "-S", str(sock), "list-panes",
|
||||
"-t", "=" + ctx["session"],
|
||||
"-t", "=" + ctx["session"] + ":Console",
|
||||
"-F", "#{pane_id}|#{pane_pid}|#{pane_current_command}|#{pane_width}|#{pane_height}|#{@framebridge_role}",
|
||||
], capture_output=True, text=True, timeout=5)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
@@ -230,7 +242,8 @@ def run(state, node="LOCAL", *, inside=False, mode="auto"):
|
||||
try:
|
||||
result = subprocess.run([
|
||||
sys.executable, str(ui), "--node", ctx["node"],
|
||||
"--socket", str(ctx["socket"]), "--session", ctx["session"] ,
|
||||
"--socket", str(ctx["socket"]), "--session", ctx["session"],
|
||||
"--jobs-root", str(ctx["jobs_root"]),
|
||||
"--layout", "horizontal" if inside else "vertical", "--apply",
|
||||
], env=env, capture_output=True, text=True, timeout=20)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Migrate the recognized pre-package desktop console without replacing its Bash."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pwd
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
RECORD = "console-desktop.json"
|
||||
LEGACY_WRAPPER_SHA256 = "71a6436d7411606312b009aee62a6d736a0d31a31194abb445f600740d8174f5"
|
||||
|
||||
|
||||
def _need(value, error):
|
||||
if not value:
|
||||
raise ValueError(error)
|
||||
|
||||
|
||||
def _owned(path, *, directory=False):
|
||||
path = Path(path)
|
||||
for parent in [path, *path.parents]:
|
||||
_need(not parent.is_symlink(), "CONSOLE_DESKTOP_SYMLINK_REFUSED")
|
||||
info = path.lstat()
|
||||
kind = stat.S_ISDIR if directory else stat.S_ISREG
|
||||
_need(kind(info.st_mode) and info.st_uid == os.getuid() and not info.st_mode & 0o022,
|
||||
"CONSOLE_DESKTOP_PATH_NOT_OWNED")
|
||||
return info
|
||||
|
||||
|
||||
def _snapshot(path):
|
||||
_owned(path)
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(fd, "rb") as stream:
|
||||
info = os.fstat(stream.fileno())
|
||||
_need(stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid()
|
||||
and not info.st_mode & 0o022 and info.st_size <= 32768,
|
||||
"CONSOLE_DESKTOP_FILE_UNTRUSTED")
|
||||
return stream.read(), stat.S_IMODE(info.st_mode)
|
||||
|
||||
|
||||
def _atomic(path, data, mode=0o600):
|
||||
fd, temporary = tempfile.mkstemp(prefix=".fb-desktop-", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
os.fchmod(stream.fileno(), mode)
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temporary)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _legacy_entry(home):
|
||||
return ("[Desktop Entry]\nVersion=1.0\nType=Application\nName=FrameBridge Console\n"
|
||||
"Comment=Open the persistent local Bash and FBTERM activity console\n"
|
||||
"Exec=" + str(home / ".local/bin/framebridge-console") + "\n"
|
||||
"Icon=utilities-terminal\nTerminal=false\nCategories=System;TerminalEmulator;\n"
|
||||
"StartupWMClass=Gnome-terminal\n").encode()
|
||||
|
||||
|
||||
def _new_entry(state):
|
||||
# Desktop Entry Exec quoting, without a shell or environment expansion.
|
||||
_need(not any(ord(char) < 32 or ord(char) == 127 for char in str(state)),
|
||||
"CONSOLE_DESKTOP_STATE_PATH_INVALID")
|
||||
quoted = str(state).replace("%", "%%").replace("\\", "\\\\").replace('"', '\\"').replace("`", "\\`").replace("$", "\\$")
|
||||
return ("[Desktop Entry]\nVersion=1.0\nType=Application\nName=FrameBridge Console\n"
|
||||
"Comment=Open the persistent local Bash and FBTERM activity console\n"
|
||||
'Exec=/usr/bin/fb --state-dir "' + quoted + '" console\n'
|
||||
"Icon=utilities-terminal\nTerminal=false\nCategories=System;TerminalEmulator;\n"
|
||||
"StartupWMClass=Gnome-terminal\n").encode()
|
||||
|
||||
|
||||
def _legacy_identity(node, runtime):
|
||||
return {"socket": runtime / (node.lower() + "-tty1") / "console.sock",
|
||||
"session": node.upper() + "-FBTERM"}
|
||||
|
||||
|
||||
def legacy_context(state, node, runtime):
|
||||
"""Return a user-approved legacy binding; ordinary profiles remain isolated."""
|
||||
path = Path(state) / RECORD
|
||||
if not path.exists() and not path.is_symlink():
|
||||
return None
|
||||
state_info = _owned(Path(state), directory=True)
|
||||
_need(not state_info.st_mode & 0o077, "CONSOLE_DESKTOP_STATE_NOT_PRIVATE")
|
||||
raw, mode = _snapshot(path)
|
||||
_need(mode == 0o600, "CONSOLE_DESKTOP_RECORD_NOT_PRIVATE")
|
||||
try:
|
||||
record = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError("CONSOLE_DESKTOP_RECORD_INVALID") from None
|
||||
expected = _legacy_identity(node, Path(runtime))
|
||||
_need(type(record) is dict and record.get("schema") == 1
|
||||
and record.get("state") == str(state) and record.get("node") == node
|
||||
and record.get("socket") == str(expected["socket"])
|
||||
and record.get("session") == expected["session"], "CONSOLE_DESKTOP_RECORD_INVALID")
|
||||
return expected
|
||||
|
||||
|
||||
def migrate_legacy(state, *, home=None, node=None, runtime=None):
|
||||
"""Change only the exact known legacy entry; called under the profile owner's UID."""
|
||||
from .config import private_dir, state_path
|
||||
import socket
|
||||
uid = os.getuid()
|
||||
_need(uid != 0 and os.geteuid() == uid, "CONSOLE_DESKTOP_USER_REQUIRED")
|
||||
state = state_path(state)
|
||||
home = Path(home if home is not None else pwd.getpwuid(uid).pw_dir)
|
||||
node = node or re.sub(r"[^A-Za-z0-9_-]", "_", socket.gethostname())[:64] or "NODE"
|
||||
runtime = Path(runtime if runtime is not None else Path("/run/user") / str(uid))
|
||||
desktop = home / ".local/share/applications/framebridge-console.desktop"
|
||||
if not desktop.exists() and not desktop.is_symlink():
|
||||
return {"ok": True, "changed": False, "reason": "NO_LEGACY_DESKTOP_ENTRY"}
|
||||
_owned(home, directory=True)
|
||||
_owned(desktop.parent, directory=True)
|
||||
before, mode = _snapshot(desktop)
|
||||
after = _new_entry(state)
|
||||
if before == after:
|
||||
_need(legacy_context(state, node, runtime) is not None, "CONSOLE_DESKTOP_RECORD_MISSING")
|
||||
return {"ok": True, "changed": False, "reason": "ALREADY_MIGRATED"}
|
||||
if before != _legacy_entry(home):
|
||||
return {"ok": True, "changed": False, "reason": "CUSTOM_DESKTOP_ENTRY_PRESERVED"}
|
||||
wrapper, _ = _snapshot(home / ".local/bin/framebridge-console")
|
||||
_need(hashlib.sha256(wrapper).hexdigest() == LEGACY_WRAPPER_SHA256,
|
||||
"CONSOLE_DESKTOP_LEGACY_WRAPPER_CHANGED")
|
||||
identity = _legacy_identity(node, runtime)
|
||||
parent = identity["socket"].parent
|
||||
_owned(runtime, directory=True)
|
||||
_owned(parent, directory=True)
|
||||
info = identity["socket"].lstat()
|
||||
_need(stat.S_ISSOCK(info.st_mode) and info.st_uid == uid and not info.st_mode & 0o077,
|
||||
"CONSOLE_DESKTOP_LEGACY_SOCKET_UNTRUSTED")
|
||||
result = subprocess.run(["/usr/bin/tmux", "-S", str(identity["socket"]), "list-windows",
|
||||
"-t", "=" + identity["session"], "-F", "#{window_id}|#{window_name}"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
rows = result.stdout.splitlines()
|
||||
_need(result.returncode == 0 and len([row for row in rows
|
||||
if re.fullmatch(r"@[0-9]+\|Console", row)]) == 1,
|
||||
"CONSOLE_DESKTOP_LEGACY_WINDOW_UNVERIFIED")
|
||||
window = next(row.split("|", 1)[0] for row in rows if re.fullmatch(r"@[0-9]+\|Console", row))
|
||||
panes = subprocess.run(["/usr/bin/tmux", "-S", str(identity["socket"]), "list-panes",
|
||||
"-t", window, "-F", "#{pane_current_command}|#{pane_dead}|#{@framebridge_role}"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
pane_rows = [row.split("|") for row in panes.stdout.splitlines()]
|
||||
_need(panes.returncode == 0 and len(pane_rows) == 2
|
||||
and all(len(row) == 3 and row[1] == "0" for row in pane_rows)
|
||||
and {row[2] for row in pane_rows} == {"shell", "monitor"}
|
||||
and any(row == ["bash", "0", "shell"] for row in pane_rows),
|
||||
"CONSOLE_DESKTOP_LEGACY_PANES_UNVERIFIED")
|
||||
private_dir(state)
|
||||
backup = state / "console-desktop-legacy.backup"
|
||||
if backup.exists() or backup.is_symlink():
|
||||
old, backup_mode = _snapshot(backup)
|
||||
_need(old == before and backup_mode == 0o600, "CONSOLE_DESKTOP_BACKUP_CHANGED")
|
||||
else:
|
||||
_atomic(backup, before)
|
||||
record = {"schema": 1, "state": str(state), "node": node,
|
||||
"socket": str(identity["socket"]), "session": identity["session"],
|
||||
"desktop_before_sha256": hashlib.sha256(before).hexdigest()}
|
||||
_atomic(state / RECORD, (json.dumps(record, sort_keys=True, indent=2) + "\n").encode())
|
||||
_need(_snapshot(desktop)[0] == before, "CONSOLE_DESKTOP_CHANGED_DURING_MIGRATION")
|
||||
_atomic(desktop, after, mode)
|
||||
return {"ok": True, "changed": True, "desktop": str(desktop),
|
||||
"session": identity["session"], "socket": str(identity["socket"]),
|
||||
"backup": str(backup), "runtime_changed": False}
|
||||
@@ -29,6 +29,7 @@ def arguments():
|
||||
p.add_argument('--node', default=os.environ.get('FRAMEBRIDGE_NODE', socket.gethostname()))
|
||||
p.add_argument('--socket')
|
||||
p.add_argument('--session')
|
||||
p.add_argument('--jobs-root', type=Path)
|
||||
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')
|
||||
@@ -68,10 +69,14 @@ class Console:
|
||||
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]
|
||||
windows=[line.split('|',1) for line in self.tmux(
|
||||
'list-windows','-t',self.session_target,'-F','#{window_id}|#{window_name}').splitlines()]
|
||||
if not windows or any(len(row)!=2 or not re.fullmatch(r'@[0-9]+',row[0]) for row in windows):
|
||||
raise RuntimeError('Console window identity is invalid')
|
||||
selected=windows if len(windows)==1 else [row for row in windows if row[1]=='Console']
|
||||
if len(selected)!=1:
|
||||
raise RuntimeError('The dedicated Console window is absent or ambiguous')
|
||||
self.window=selected[0][0]
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -87,7 +92,10 @@ class Console:
|
||||
return rows
|
||||
|
||||
def monitor_command(self):
|
||||
return shlex.join(['/usr/bin/python3', str(HERE / 'framebridge_console.py'), '--monitor', '--node', self.a.node])
|
||||
command=['/usr/bin/python3', str(HERE / 'framebridge_console.py'), '--monitor', '--node', self.a.node]
|
||||
if getattr(self.a,'jobs_root',None) is not None:
|
||||
command.extend(['--jobs-root',str(self.a.jobs_root)])
|
||||
return shlex.join(command)
|
||||
|
||||
def locate(self, rows):
|
||||
shell = next((p for p in rows if p['role']=='shell'), None)
|
||||
@@ -164,7 +172,7 @@ class Console:
|
||||
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:
|
||||
if self.a.refresh_monitor or monitor['dead'] or str(HERE/'framebridge_console.py').encode() 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)
|
||||
@@ -190,9 +198,12 @@ class Console:
|
||||
# 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'])
|
||||
reflow=['/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']
|
||||
if getattr(self.a,'jobs_root',None) is not None:
|
||||
reflow.extend(['--jobs-root',str(self.a.jobs_root)])
|
||||
command=shlex.join(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)
|
||||
@@ -205,10 +216,13 @@ class Console:
|
||||
raise RuntimeError('Bash identity changed unexpectedly')
|
||||
|
||||
|
||||
def supervise(node):
|
||||
def supervise(node, jobs_root=None):
|
||||
command=['/usr/bin/python3','-u',str(HERE/'fbterm_dashboard.py'),'--node',node]
|
||||
if jobs_root is not None:
|
||||
command.extend(['--root',str(jobs_root)])
|
||||
while True:
|
||||
try:
|
||||
result=subprocess.run(['/usr/bin/python3','-u',str(HERE/'fbterm_dashboard.py'),'--node',node])
|
||||
result=subprocess.run(command)
|
||||
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)
|
||||
@@ -220,7 +234,7 @@ def supervise(node):
|
||||
def main():
|
||||
args=arguments()
|
||||
if args.monitor:
|
||||
supervise(args.node)
|
||||
supervise(args.node, args.jobs_root)
|
||||
return 0
|
||||
console=Console(args)
|
||||
if args.probe:
|
||||
|
||||
@@ -33,6 +33,26 @@ class ConsoleTests(unittest.TestCase):
|
||||
mock.patch.object(Path, "read_text", return_value="tty7\n"):
|
||||
self.assertTrue(console._active_graphical_display({"DISPLAY": ":0", "XDG_SESSION_ID": "19"}))
|
||||
|
||||
def test_desktop_launcher_without_session_environment_uses_verified_own_display(self):
|
||||
with mock.patch.object(console.subprocess, "run", side_effect=[
|
||||
SimpleNamespace(returncode=0, stdout="19\n"), self.desktop()]) as run, \
|
||||
mock.patch.object(Path, "read_text", return_value="tty7\n"):
|
||||
self.assertTrue(console._active_graphical_display({"DISPLAY": ":0"}))
|
||||
self.assertEqual(run.call_args_list[0].args[0], [
|
||||
"loginctl", "show-user", str(os.getuid()), "--property=Display", "--value"])
|
||||
self.assertIn("19", run.call_args_list[1].args[0])
|
||||
|
||||
def test_missing_primary_session_does_not_guess_a_display_session(self):
|
||||
with mock.patch.object(console.subprocess, "run", return_value=
|
||||
SimpleNamespace(returncode=0, stdout="\n")) as run:
|
||||
self.assertFalse(console._active_graphical_display({"DISPLAY": ":0"}))
|
||||
run.assert_called_once()
|
||||
|
||||
def test_invalid_supplied_session_is_not_replaced(self):
|
||||
with mock.patch.object(console.subprocess, "run") as run:
|
||||
self.assertFalse(console._active_graphical_display({"DISPLAY": ":0", "XDG_SESSION_ID": "../bad"}))
|
||||
run.assert_not_called()
|
||||
|
||||
def test_private_virtual_display_is_not_chosen_as_user_desktop(self):
|
||||
with mock.patch.object(console.subprocess, "run", return_value=self.desktop()), \
|
||||
mock.patch.object(Path, "read_text", return_value="tty7\n"):
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import shlex
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from framebridge_expanded import console_desktop
|
||||
from framebridge_expanded.console_ui import framebridge_console as ui
|
||||
|
||||
|
||||
@unittest.skipIf(os.getuid() == 0, "desktop migration requires a normal user")
|
||||
class DesktopMigrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
root = Path(self.temp.name)
|
||||
self.home = root / "home"
|
||||
self.home.mkdir(mode=0o700)
|
||||
self.state = self.home / "profile"
|
||||
self.runtime = root / "runtime"
|
||||
self.runtime.mkdir(mode=0o700)
|
||||
parent = self.runtime / "testhost-tty1"
|
||||
parent.mkdir(mode=0o700)
|
||||
self.sock = socket.socket(socket.AF_UNIX)
|
||||
self.sock.bind(str(parent / "console.sock"))
|
||||
os.chmod(parent / "console.sock", 0o600)
|
||||
self.addCleanup(self.sock.close)
|
||||
self.desktop = self.home / ".local/share/applications/framebridge-console.desktop"
|
||||
self.desktop.parent.mkdir(parents=True, mode=0o700)
|
||||
self.desktop.write_bytes(console_desktop._legacy_entry(self.home))
|
||||
self.wrapper = self.home / ".local/bin/framebridge-console"
|
||||
self.wrapper.parent.mkdir(parents=True, mode=0o700)
|
||||
self.wrapper.write_bytes(b"#!/usr/bin/python3\npass\n")
|
||||
self.old_hash = hashlib.sha256(self.wrapper.read_bytes()).hexdigest()
|
||||
|
||||
def migrate(self, windows="@0|Console\n@1|FB-piloto\n@3|FB-Muffin\n"):
|
||||
def result(args, **kwargs):
|
||||
text = windows if "list-windows" in args else "bash|0|shell\npython3|0|monitor\n"
|
||||
return SimpleNamespace(returncode=0, stdout=text)
|
||||
with mock.patch.object(console_desktop, "LEGACY_WRAPPER_SHA256", self.old_hash), \
|
||||
mock.patch.object(console_desktop.subprocess, "run", side_effect=result) as run:
|
||||
value = console_desktop.migrate_legacy(
|
||||
self.state, home=self.home, node="TestHost", runtime=self.runtime)
|
||||
return value, run
|
||||
|
||||
def test_known_legacy_icon_keeps_existing_socket_and_session(self):
|
||||
before = self.desktop.read_bytes()
|
||||
value, run = self.migrate()
|
||||
self.assertTrue(value["changed"])
|
||||
self.assertFalse(value["runtime_changed"])
|
||||
self.assertEqual((self.state / "console-desktop-legacy.backup").read_bytes(), before)
|
||||
self.assertEqual(self.desktop.read_bytes(), console_desktop._new_entry(self.state))
|
||||
context = console_desktop.legacy_context(self.state, "TestHost", self.runtime)
|
||||
self.assertEqual(context["session"], "TESTHOST-FBTERM")
|
||||
self.assertEqual(context["socket"], self.runtime / "testhost-tty1/console.sock")
|
||||
self.assertEqual(run.call_count, 2)
|
||||
self.assertIn("list-windows", run.call_args_list[0].args[0])
|
||||
self.assertIn("list-panes", run.call_args_list[1].args[0])
|
||||
second, run = self.migrate()
|
||||
self.assertFalse(second["changed"])
|
||||
run.assert_not_called()
|
||||
|
||||
def test_custom_desktop_entry_is_preserved(self):
|
||||
self.desktop.write_text("[Desktop Entry]\nExec=my-custom-console\n")
|
||||
before = self.desktop.read_bytes()
|
||||
value, run = self.migrate()
|
||||
self.assertEqual(value["reason"], "CUSTOM_DESKTOP_ENTRY_PRESERVED")
|
||||
self.assertEqual(self.desktop.read_bytes(), before)
|
||||
self.assertFalse(self.state.exists())
|
||||
run.assert_not_called()
|
||||
|
||||
def test_changed_legacy_wrapper_does_not_get_replaced(self):
|
||||
self.wrapper.write_text("custom owner command\n")
|
||||
before = self.desktop.read_bytes()
|
||||
with self.assertRaisesRegex(ValueError, "LEGACY_WRAPPER_CHANGED"):
|
||||
self.migrate()
|
||||
self.assertEqual(self.desktop.read_bytes(), before)
|
||||
self.assertFalse(self.state.exists())
|
||||
|
||||
def test_ambiguous_console_window_prevents_migration(self):
|
||||
before = self.desktop.read_bytes()
|
||||
with self.assertRaisesRegex(ValueError, "LEGACY_WINDOW_UNVERIFIED"):
|
||||
self.migrate("@0|Console\n@1|Console\n")
|
||||
self.assertEqual(self.desktop.read_bytes(), before)
|
||||
self.assertFalse(self.state.exists())
|
||||
|
||||
def test_symlinked_entry_is_refused_without_touching_target(self):
|
||||
self.desktop.unlink()
|
||||
target = self.home / "owner-file"
|
||||
target.write_text("keep\n")
|
||||
self.desktop.symlink_to(target)
|
||||
with self.assertRaisesRegex(ValueError, "SYMLINK_REFUSED"):
|
||||
self.migrate()
|
||||
self.assertEqual(target.read_text(), "keep\n")
|
||||
|
||||
def test_foreign_profile_cannot_reuse_the_binding(self):
|
||||
self.migrate()
|
||||
record = self.state / console_desktop.RECORD
|
||||
raw = record.read_text().replace(str(self.state), str(self.state) + "-other")
|
||||
record.write_text(raw)
|
||||
with self.assertRaisesRegex(ValueError, "RECORD_INVALID"):
|
||||
console_desktop.legacy_context(self.state, "TestHost", self.runtime)
|
||||
|
||||
|
||||
class ConsoleWindowSelectionTests(unittest.TestCase):
|
||||
def console(self, windows):
|
||||
console = ui.Console(SimpleNamespace(socket="/no-real-socket", session="NODE-FBTERM"))
|
||||
def tmux(*args, **kwargs):
|
||||
if args[0] == "list-sessions":
|
||||
return "NODE-FBTERM|$0"
|
||||
if args[0] == "list-windows":
|
||||
return windows
|
||||
raise AssertionError(args)
|
||||
with mock.patch.object(ui.subprocess, "run", return_value=SimpleNamespace(returncode=0)), \
|
||||
mock.patch.object(console, "tmux", side_effect=tmux):
|
||||
value = console.exists()
|
||||
return console, value
|
||||
|
||||
def test_monitor_receives_profile_jobs_root_as_an_explicit_argument(self):
|
||||
args = SimpleNamespace(socket="/owned/socket", session="NODE-FBTERM", node="NODE",
|
||||
jobs_root=Path("/profile with space/jobs"))
|
||||
command = shlex.split(ui.Console(args).monitor_command())
|
||||
self.assertEqual(command[-2:], ["--jobs-root", "/profile with space/jobs"])
|
||||
|
||||
def test_desktop_entry_escapes_percent_field_codes(self):
|
||||
entry = console_desktop._new_entry(Path("/home/user/100%complete/profile"))
|
||||
self.assertIn(b"100%%complete", entry)
|
||||
|
||||
def test_desktop_entry_refuses_newline_in_profile_path(self):
|
||||
with self.assertRaisesRegex(ValueError, "STATE_PATH_INVALID"):
|
||||
console_desktop._new_entry(Path("/home/user/profile\nExec=bad"))
|
||||
|
||||
def test_extra_owner_windows_do_not_prevent_console_attachment(self):
|
||||
console, value = self.console("@0|Console\n@1|FB-piloto\n@3|FB-Muffin")
|
||||
self.assertTrue(value)
|
||||
self.assertEqual(console.window, "@0")
|
||||
self.assertEqual(console.session_target, "$0")
|
||||
|
||||
def test_ambiguous_console_window_is_not_guessed(self):
|
||||
with self.assertRaisesRegex(RuntimeError, "absent or ambiguous"):
|
||||
self.console("@0|Console\n@1|Console")
|
||||
|
||||
def test_multiwindow_session_without_console_is_preserved(self):
|
||||
with self.assertRaisesRegex(RuntimeError, "absent or ambiguous"):
|
||||
self.console("@0|Owner work\n@1|Other")
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("tmux"), "tmux is required")
|
||||
class RealConsolePreservationTests(unittest.TestCase):
|
||||
def test_apply_preserves_shell_and_other_windows_in_existing_session(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
sock = str(Path(directory) / "console.sock")
|
||||
def tmux(*args):
|
||||
return subprocess.check_output(["/usr/bin/tmux", "-S", sock, *args], text=True).strip()
|
||||
try:
|
||||
tmux("new-session", "-d", "-s", "TEST-FBTERM", "-n", "Console", "/bin/bash --noprofile --norc")
|
||||
shell = tmux("display-message", "-p", "-t", "=TEST-FBTERM:Console", "#{pane_pid}")
|
||||
tmux("new-window", "-d", "-t", "=TEST-FBTERM", "-n", "FB-piloto", "/bin/bash --noprofile --norc")
|
||||
tmux("new-window", "-d", "-t", "=TEST-FBTERM", "-n", "FB-Muffin", "/bin/bash --noprofile --norc")
|
||||
fmt = "#{window_name}|#{pane_id}|#{pane_pid}"
|
||||
other = tmux("list-panes", "-a", "-F", fmt).splitlines()[1:]
|
||||
jobs = Path(directory) / "profile-jobs"
|
||||
args = SimpleNamespace(socket=sock, session="TEST-FBTERM", node="Test", jobs_root=jobs,
|
||||
layout="horizontal", reflow=False, refresh_monitor=False)
|
||||
ui.Console(args).apply()
|
||||
panes = tmux("list-panes", "-t", "=TEST-FBTERM:Console", "-F", "#{pane_pid}|#{@framebridge_role}")
|
||||
self.assertIn(shell + "|shell", panes)
|
||||
start_commands = tmux("list-panes", "-t", "=TEST-FBTERM:Console", "-F", "#{pane_start_command}")
|
||||
self.assertIn("--jobs-root", start_commands)
|
||||
self.assertIn(str(jobs), start_commands)
|
||||
after = tmux("list-panes", "-a", "-F", fmt).splitlines()
|
||||
for row in other:
|
||||
self.assertIn(row, after)
|
||||
self.assertEqual(tmux("list-windows", "-t", "=TEST-FBTERM", "-F", "#{window_name}"),
|
||||
"Console\nFB-piloto\nFB-Muffin")
|
||||
finally:
|
||||
subprocess.run(["/usr/bin/tmux", "-S", sock, "kill-server"], capture_output=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user