Repair the official desktop console without replacing existing work
This commit is contained in:
1 parent
7c9371b76a
commit
e98d0d70e6
7 files changed
+445
-17
No files matched your search
@@ -0,0 +1,15 @@
|
||||
# Desktop console upgrade
|
||||
|
||||
The desktop console and its authenticated tty1 view share the existing local Bash and FBTERM activity dashboard. A desktop attachment preserves its Bash process, other work windows and login. The dashboard is read-only; the Bash pane is the command prompt.
|
||||
|
||||
Older private installations used a user-level desktop launcher. That launcher could hide the package launcher and reject a valid console after other work windows were added. Version 0.3.3 targets the existing named `Console` window, while preserving the other windows.
|
||||
|
||||
During an upgrade, a running profile can migrate the exact recognized old launcher to the packaged CLI. Custom launchers and changed legacy components are preserved. The old desktop entry is backed up privately in the profile; the console socket/session binding is recorded there. No login or boot policy changes.
|
||||
|
||||
For an explicit owner repair, use the real configured profile:
|
||||
|
||||
```sh
|
||||
fb --state-dir /absolute/path/to/profile --json console desktop migrate-legacy
|
||||
```
|
||||
|
||||
Then open **FrameBridge Console** from the desktop. The command does not open a generic replacement terminal, recreate the Bash session or delete additional work windows.
|
||||
@@ -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):
|
||||
@@ -231,6 +243,7 @@ def run(state, node="LOCAL", *, inside=False, mode="auto"):
|
||||
result = subprocess.run([
|
||||
sys.executable, str(ui), "--node", ctx["node"],
|
||||
"--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,
|
||||
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'])
|
||||
'--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