Add Live Activities as configurable bar items (#30)
* Add Live Activities as configurable bar items * Fix activity owner monitoring and Morph list interactions * Keep activity rows stable during updates and tighten their layout * Use subtle theme tint for activity cards * Tighten activity title and progress spacing * Use theme accent for active progress rings * Fit compact activities to measured titles and badge * Expose Live Activities display controls in Settings * Open activity terminals and streamline the Morph header * Give Morph continuous icons and prioritize workspace navigation * Show activity progress inside its ring * Inset activity progress rings and strengthen their stroke * Fix activity lifecycle, command output handling and list navigation * Reduce activity update work and harden publisher lifetimes * Set up Live Activities for the detected user shell during installation * Document Activities setup, daily use and troubleshooting * Preserve Morph idle width across empty activity updates * Fix dimming flicker, hide inactive music tab and consolidate Activities docs * Fix panel clearance across styles and match previews to theme settings * Prevent panel blur from repainting neighboring islands --------- Co-authored-by: Samuel Onoja <[email protected]>
This commit is contained in:
1 parent
7a7df0106c
commit
6b0d903ad0
107 files changed
+9340
-291
No files matched your search
@@ -61,6 +61,7 @@ def main():
|
||||
("shell-flat", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "flat"}),
|
||||
("shell-floating", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "floating"}),
|
||||
("invalid-config", "startup_config", {"FERESE_TEST_STARTUP_CONFIG": "1"}),
|
||||
("activities", "activities", {"FERESE_TEST_ACTIVITIES": "1"}),
|
||||
]
|
||||
if args.suite == "desktop":
|
||||
cases.insert(0, ("lock", "lock", {"FERESE_TEST_LOCK": "1", "FERESE_TEST_BIN_DIR": "target/debug"}))
|
||||
|
||||
+7
-2
@@ -14,6 +14,7 @@ Build, bundle and install Ferese. Run as your normal user.
|
||||
--replace-portal-config Back up and replace modified Ferese portal config
|
||||
--offline Build using cached Cargo dependencies
|
||||
--resize-metrics Build with resize-barrier measurements
|
||||
--no-activities Skip automatic shell hook and Activities setup
|
||||
--dry-run Show commands without building or installing
|
||||
-h, --help Show this help
|
||||
|
||||
@@ -33,6 +34,7 @@ replace_portal_config=false
|
||||
offline=false
|
||||
resize_metrics=false
|
||||
dry_run=false
|
||||
no_activities=false
|
||||
while (($#)); do
|
||||
case $1 in
|
||||
--release-id|--bundle|--bundle-only)
|
||||
@@ -46,6 +48,7 @@ while (($#)); do
|
||||
--replace-portal-config) replace_portal_config=true; shift ;;
|
||||
--offline) offline=true; shift ;;
|
||||
--resize-metrics) resize_metrics=true; shift ;;
|
||||
--no-activities) no_activities=true; shift ;;
|
||||
--dry-run) dry_run=true; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) fail "unknown option: $1 (see --help)" ;;
|
||||
@@ -100,8 +103,10 @@ if $dry_run; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
user_setup=(python3 "$helper" user-setup)
|
||||
if $no_activities; then user_setup+=(--no-activities); fi
|
||||
if [[ -z $bundle_only ]] && ((EUID != 0)); then
|
||||
python3 "$helper" user-setup --check
|
||||
"${user_setup[@]}" --check
|
||||
fi
|
||||
if [[ ${prepare+x} ]]; then
|
||||
for tool in cargo desktop-file-validate dbus-run-session; do
|
||||
@@ -118,7 +123,7 @@ fi
|
||||
python3 "$helper" verify "$bundle_path"
|
||||
"${installer[@]}"
|
||||
if ((EUID != 0)); then
|
||||
if ! python3 "$helper" user-setup; then
|
||||
if ! "${user_setup[@]}"; then
|
||||
echo 'System installation succeeded; user setup is incomplete.' >&2
|
||||
echo "Retry as your normal user: python3 '$helper' user-setup" >&2
|
||||
exit 1
|
||||
|
||||
@@ -6,6 +6,7 @@ import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
@@ -173,6 +174,7 @@ def validate_bundle(bundle, *, installed=False):
|
||||
# release's recorded files without requiring these later additions.
|
||||
required = required - {'wallpapers/ferese-wallpaper-dark.png', 'wallpapers/ferese-wallpaper-light.png',
|
||||
'licenses/Ferese-LICENSE.txt'}
|
||||
required = {target for target in required if not target.startswith('activities/')}
|
||||
|
||||
if not required <= seen or actual != seen | {'manifest.json'}:
|
||||
fail('Bundle is incomplete or contains unlisted files')
|
||||
@@ -536,6 +538,73 @@ def system_command(args):
|
||||
print(f'System {args.command} complete: {manifest["release"]}')
|
||||
|
||||
|
||||
def user_file(path, content):
|
||||
# Respect dotfile symlinks and retain the original permissions.
|
||||
path = path.resolve()
|
||||
old = path.read_text() if path.exists() else None
|
||||
if old == content:
|
||||
return
|
||||
mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o600
|
||||
if old is not None:
|
||||
backup = path.with_name(path.name + '.before-activities-' + uuid.uuid4().hex)
|
||||
atomic_bytes(backup, old.encode(), mode)
|
||||
print(f'Backed up {path}: {backup}')
|
||||
atomic_bytes(path, content.encode(), mode)
|
||||
|
||||
|
||||
def setup_activities(config, *, ctl=None, assets=None):
|
||||
shell = Path(os.environ.get('SHELL') or pwd.getpwuid(os.getuid()).pw_shell).name
|
||||
if shell not in {'fish', 'zsh', 'bash'}:
|
||||
print(f'Live Activities: no automatic hook for {shell}; use feresectl run.')
|
||||
return
|
||||
root = config / 'ferese/activities'
|
||||
marker = root / 'installed.json'
|
||||
assets = assets or Path(PREFIX) / 'current/activities'
|
||||
if not assets.is_dir():
|
||||
assets = REPO / 'contrib/activities'
|
||||
ctl = ctl or Path(PREFIX) / 'current/feresectl'
|
||||
hook = assets / f'ferese.{shell}'
|
||||
content = hook.read_text()
|
||||
# Stage shell files before enabling the configuration. Repeated installs
|
||||
# update our hooks but do not undo a user's later panel/source edits.
|
||||
user_file(root / hook.name, content)
|
||||
if shell == 'fish':
|
||||
startup = config / 'fish/conf.d/ferese-activities.fish'
|
||||
# Fish accepts single-quoted paths with escaped backslashes and quotes.
|
||||
quoted = "'" + str(root / hook.name).replace('\\', '\\\\').replace("'", "\\'") + "'"
|
||||
loader = f'# Ferese Live Activities\nif status is-interactive; and test -r {quoted}\n source {quoted}\nend\n'
|
||||
user_file(startup, loader)
|
||||
else:
|
||||
import shlex
|
||||
quoted = shlex.quote(str(root / hook.name))
|
||||
if shell == 'bash':
|
||||
for name in ('bash-preexec.sh', 'LICENSE.md'):
|
||||
user_file(root / 'bash-preexec' / name, (assets / 'bash-preexec' / name).read_text())
|
||||
dependency = shlex.quote(str(root / 'bash-preexec/bash-preexec.sh'))
|
||||
loader = (f'if [[ $- == *i* && -r {quoted} ]]; then\n'
|
||||
f' [[ -n ${{bash_preexec_imported:-}} ]] || source {dependency}\n'
|
||||
f' source {quoted}\nfi')
|
||||
startup = Path.home() / '.bashrc'
|
||||
else:
|
||||
loader = f'if [[ -o interactive && -r {quoted} ]]; then\n source {quoted}\nfi'
|
||||
startup = Path(os.environ.get('ZDOTDIR') or Path.home()) / '.zshrc'
|
||||
begin, end = '# >>> Ferese Live Activities >>>', '# <<< Ferese Live Activities <<<'
|
||||
block = f'{begin}\n{loader}\n{end}\n'
|
||||
old = startup.read_text() if startup.exists() else ''
|
||||
if begin in old or end in old:
|
||||
if old.count(begin) != 1 or old.count(end) != 1 or old.index(end) < old.index(begin):
|
||||
fail(f'Malformed Ferese hook block in {startup}; file preserved')
|
||||
start, finish = old.index(begin), old.index(end) + len(end)
|
||||
updated = old[:start] + block.rstrip('\n') + old[finish:]
|
||||
else:
|
||||
updated = old + ('\n' if old and not old.endswith('\n') else '') + block
|
||||
user_file(startup, updated)
|
||||
if not marker.exists():
|
||||
subprocess.run([str(ctl), 'activity', 'setup', '--config', str(config / 'ferese/config.kdl')], check=True)
|
||||
atomic_json(marker, {'format': 1})
|
||||
print(f'Live Activities configured for {shell}. Open a new terminal to load the hook.')
|
||||
|
||||
|
||||
def user_setup(args):
|
||||
if os.geteuid() == 0:
|
||||
fail('Run user-setup as your normal user')
|
||||
@@ -553,6 +622,8 @@ def user_setup(args):
|
||||
|
||||
if not args.check:
|
||||
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
|
||||
if not args.no_activities:
|
||||
setup_activities(config, ctl=args.activity_ctl)
|
||||
print('User setup complete. Log out and select Ferese to use the installed release.')
|
||||
|
||||
|
||||
@@ -569,6 +640,8 @@ def parser():
|
||||
verify.add_argument('bundle', type=Path)
|
||||
user = commands.add_parser('user-setup')
|
||||
user.add_argument('--check', action='store_true')
|
||||
user.add_argument('--no-activities', action='store_true', help='Skip shell hook and Activities setup')
|
||||
user.add_argument('--activity-ctl', type=Path, help=argparse.SUPPRESS)
|
||||
for name in ('install', 'rollback', 'recover', 'uninstall'):
|
||||
command = commands.add_parser(name)
|
||||
command.add_argument('--root', type=Path, default=Path('/'), help='Destination filesystem root (for packaging/tests)')
|
||||
|
||||
@@ -182,6 +182,31 @@
|
||||
"source": "packaging/pam.d/ferese-lock.generic",
|
||||
"target": "pam/generic",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"source": "contrib/activities/ferese.fish",
|
||||
"target": "activities/ferese.fish",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"source": "contrib/activities/ferese.zsh",
|
||||
"target": "activities/ferese.zsh",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"source": "contrib/activities/ferese.bash",
|
||||
"target": "activities/ferese.bash",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"source": "contrib/activities/bash-preexec/bash-preexec.sh",
|
||||
"target": "activities/bash-preexec/bash-preexec.sh",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"source": "contrib/activities/bash-preexec/LICENSE.md",
|
||||
"target": "activities/bash-preexec/LICENSE.md",
|
||||
"mode": "0644"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Opt-in real broker/CLI checks on an isolated software-rendered desktop."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from session_socket import ipc_socket, ipc_environment
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
@unittest.skipUnless(os.environ.get("FERESE_TEST_ACTIVITIES") == "1", "opt-in isolated activities test")
|
||||
class Activities(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
host = os.environ.get("WAYLAND_DISPLAY")
|
||||
if host and not Path(host).is_absolute():
|
||||
host = str(Path(os.environ["XDG_RUNTIME_DIR"]) / host)
|
||||
cls.temporary = tempfile.TemporaryDirectory(prefix="ferese-activities-")
|
||||
cls.root = Path(cls.temporary.name)
|
||||
cls.runtime = cls.root / "runtime"
|
||||
cls.runtime.mkdir(mode=0o700)
|
||||
cls.config = cls.root / "config/ferese"
|
||||
cls.config.mkdir(parents=True)
|
||||
(cls.config / "config.kdl").write_text('status { keybinding-guide #false; }\npanel main { style morph; end { group tasks { item activities kind=activities; }; }; }\nactivities { sources { shell-hook #true; }; }\n')
|
||||
cls.env = dict(os.environ, XDG_RUNTIME_DIR=str(cls.runtime), XDG_CONFIG_HOME=str(cls.root / "config"), XDG_STATE_HOME=str(cls.root / "state"), XDG_CACHE_HOME=str(cls.root / "cache"), LIBGL_ALWAYS_SOFTWARE="1")
|
||||
cls.env.pop("FERESE_SOCKET", None)
|
||||
cls.env.pop("WAYLAND_SOCKET", None)
|
||||
for key in ("FERESE_SHELL_CONTROL_SOCKET", "DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_PID"):
|
||||
cls.env.pop(key, None)
|
||||
cls.log = (cls.root / "native.log").open("w+")
|
||||
cls.children = []
|
||||
cls.addClassCleanup(cls.cleanup)
|
||||
cls.ctl = ROOT / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl")
|
||||
if not host:
|
||||
cls.launch([os.environ.get("FERESE_TEST_WESTON", "weston"), "--backend=headless-backend.so", "--renderer=pixman", "--shell=kiosk-shell.so", "--socket=activity-host", "--idle-time=0", "--no-config"])
|
||||
cls.wait(lambda: (cls.runtime / "activity-host").is_socket())
|
||||
host = str(cls.runtime / "activity-host")
|
||||
cls.env["WAYLAND_DISPLAY"] = host
|
||||
bus_config = cls.root / "bus.conf"
|
||||
bus_config.write_text('<busconfig><type>session</type><listen>unix:tmpdir=/tmp</listen>'
|
||||
'<policy context="default"><allow send_destination="*"/>'
|
||||
'<allow receive_sender="*"/><allow own="*"/></policy></busconfig>')
|
||||
bus = subprocess.Popen(["dbus-daemon", "--config-file=" + str(bus_config), "--nofork", "--print-address=1"],
|
||||
env=cls.env, stdout=subprocess.PIPE, stderr=cls.log, text=True, start_new_session=True)
|
||||
cls.children.append(bus)
|
||||
cls.env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
|
||||
bus.stdout.close()
|
||||
binary = ROOT / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese")
|
||||
shell = ROOT / os.environ.get("FERESE_TEST_SHELL", "target/debug/ferese-shell")
|
||||
cls.launch([str(binary), "--backend", "nested", "--grant-shell-control", "--grant-effects", "--",
|
||||
"env", "ICED_BACKEND=tiny-skia", "WAYLAND_DEBUG=client", str(shell)])
|
||||
cls.wait(lambda: ipc_socket(cls.runtime).is_socket())
|
||||
cls.env = ipc_environment(cls.env)
|
||||
cls.wait(lambda: cls.query("outputs"))
|
||||
cls.wait(lambda: '"ferese-shell-top-bar"' in (cls.root / "native.log").read_text())
|
||||
|
||||
@classmethod
|
||||
def launch(cls, args, **kwargs):
|
||||
child = subprocess.Popen(args, env=cls.env, stdout=cls.log, stderr=cls.log, start_new_session=True, **kwargs)
|
||||
cls.children.append(child)
|
||||
return child
|
||||
|
||||
@classmethod
|
||||
def wait(cls, predicate, timeout=15):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
time.sleep(.025)
|
||||
cls.log.flush()
|
||||
raise AssertionError((cls.root / "native.log").read_text()[-8000:])
|
||||
|
||||
@classmethod
|
||||
def call(cls, *args, check=True):
|
||||
result = subprocess.run([str(cls.ctl), *args], env=cls.env, text=True, capture_output=True, timeout=5)
|
||||
if check and result.returncode:
|
||||
raise AssertionError(result.stderr)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def query(cls, *args):
|
||||
result = cls.call("--json", *args, check=False)
|
||||
return json.loads(result.stdout) if result.returncode == 0 else None
|
||||
|
||||
@classmethod
|
||||
def cleanup(cls):
|
||||
for child in reversed(cls.children):
|
||||
if child.poll() is None:
|
||||
os.killpg(child.pid, signal.SIGINT)
|
||||
try:
|
||||
child.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(child.pid, signal.SIGKILL)
|
||||
child.wait()
|
||||
if not cls.log.closed:
|
||||
cls.log.close()
|
||||
if (destination := os.environ.get("FERESE_TEST_LOG_DIR")) and (cls.root / "native.log").exists():
|
||||
Path(destination).mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(cls.root / "native.log", Path(destination) / "activities-native.log")
|
||||
cls.temporary.cleanup()
|
||||
|
||||
def test_live_watch_and_native_bar_render_progress_without_resizing(self):
|
||||
def bar():
|
||||
trace = (self.root / "native.log").read_text()
|
||||
matches = re.findall(r'get_layer_surface\(new id zwlr_layer_surface_v1[@#](\d+), wl_surface[@#](\d+)[^\n]*"ferese-shell-top-bar"', trace)
|
||||
if not matches:
|
||||
return None
|
||||
layer, surface = matches[-1]
|
||||
sizes = re.findall(r'zwlr_layer_surface_v1[@#]' + layer + r'\.set_size\((\d+), (\d+)\)', trace)
|
||||
frames = len(re.findall(r'wl_surface[@#]' + surface + r'\.frame\(', trace))
|
||||
return (tuple(map(int, sizes[-1])), frames) if sizes else None
|
||||
|
||||
self.call("activity", "start", "render", "--title", "Native Cargo build", "--progress", "0.2")
|
||||
try:
|
||||
self.wait(lambda: any(entry["id"]["local"] == "render" for entry in self.query("activity", "list")["activities"]))
|
||||
time.sleep(1.2) # Eligibility delay and shell morph settlement.
|
||||
before = self.wait(bar)
|
||||
snapshot = self.query("activity", "list")
|
||||
with socket.socket(socket.AF_UNIX) as watch:
|
||||
watch.settimeout(5)
|
||||
watch.connect(str(ipc_socket(self.runtime)))
|
||||
request = json.dumps({"version": 1, "id": 77, "type": "command", "command": "activity-watch", "args": {"since": snapshot["revision"]}}).encode()
|
||||
watch.sendall(struct.pack(">I", len(request)) + request)
|
||||
self.call("activity", "update", "render", "--progress", "0.8")
|
||||
def read_exact(count):
|
||||
data = b""
|
||||
while len(data) < count:
|
||||
part = watch.recv(count - len(data))
|
||||
self.assertTrue(part, "watch disconnected")
|
||||
data += part
|
||||
return data
|
||||
response = json.loads(read_exact(struct.unpack(">I", read_exact(4))[0]))
|
||||
self.assertEqual(response["id"], 77)
|
||||
current = next(entry for entry in response["result"]["activities"] if entry["id"]["local"] == "render")
|
||||
self.assertAlmostEqual(current["data"]["value"]["value"], .8)
|
||||
after = self.wait(lambda: (value := bar()) and value[1] > before[1] and value)
|
||||
self.assertEqual(after[0], before[0], "progress should not resize the island")
|
||||
self.assertTrue(all(child.poll() is None for child in self.children))
|
||||
finally:
|
||||
self.call("activity", "cancel", "render", check=False)
|
||||
|
||||
def test_owner_scope_quota_terminal_stale_handles_and_atomic_reload(self):
|
||||
for local in ("one", "two", "three"):
|
||||
self.call("activity", "start", local, "--title", "Build", "--progress", "0.2")
|
||||
failed = self.call("activity", "start", "four", "--title", "Build", check=False)
|
||||
self.assertNotEqual(failed.returncode, 0)
|
||||
snapshot = self.query("activity", "list")
|
||||
self.assertEqual(len(snapshot["activities"]), 3)
|
||||
handle = next(entry["id"] for entry in snapshot["activities"] if entry["id"]["local"] == "one")
|
||||
other = subprocess.run(["sh", "-c", '"$1" activity update one --progress 0.8; status=$?; exit "$status"', "sh", str(self.ctl)], env=self.env, capture_output=True)
|
||||
self.assertNotEqual(other.returncode, 0, "another caller must not update this owner's ID")
|
||||
stale = dict(handle, generation=handle["generation"] + 10)
|
||||
self.assertNotEqual(self.call("activity", "update", "one", "--handle", json.dumps(stale), "--progress", "0.8", check=False).returncode, 0)
|
||||
self.call("activity", "cancel", "two")
|
||||
self.call("activity", "cancel", "three")
|
||||
time.sleep(.35)
|
||||
self.call("activity", "end", "one", "--result", "ok")
|
||||
entry = self.query("activity", "list")["activities"][0]
|
||||
deadline = entry["result_deadline"]
|
||||
self.call("activity", "end", "one", "--result", "ok")
|
||||
self.assertEqual(self.query("activity", "list")["activities"][0]["result_deadline"], deadline)
|
||||
(self.config / "config.kdl").write_text("activities { max-visible 9; }\n")
|
||||
self.assertNotEqual(self.call("reload-config", check=False).returncode, 0)
|
||||
self.assertEqual(self.query("activity", "list")["limits"]["max_visible"], 2)
|
||||
self.wait(lambda: not self.query("activity", "list")["activities"])
|
||||
|
||||
def test_hook_nonmatches_are_discarded_and_owner_death_is_not_completion(self):
|
||||
self.call("activity", "hook", "nonmatch", "--command", "private unmatched value", "--cwd", "/private/cwd")
|
||||
self.assertNotIn("private", json.dumps(self.query("activity", "list")))
|
||||
wrapper = subprocess.Popen([str(self.ctl), "run", "--title", "Observed", "--", "sleep", "30"], env=self.env, start_new_session=True)
|
||||
try:
|
||||
self.wait(lambda: any(entry["data"]["title"] == "Observed" for entry in self.query("activity", "list")["activities"]))
|
||||
os.killpg(wrapper.pid, signal.SIGKILL)
|
||||
wrapper.wait()
|
||||
self.wait(lambda: not any(entry["data"]["title"] == "Observed" for entry in self.query("activity", "list")["activities"]))
|
||||
finally:
|
||||
if wrapper.poll() is None:
|
||||
os.killpg(wrapper.pid, signal.SIGKILL)
|
||||
wrapper.wait()
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Hook composition/status checks use a fake publisher and never run metadata."""
|
||||
from pathlib import Path
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import termios
|
||||
import time
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
class ActivityHooks(unittest.TestCase):
|
||||
def test_real_interactive_commands_publish_start_and_exit_status(self):
|
||||
for shell in ('bash', 'fish'):
|
||||
if not shutil.which(shell):
|
||||
continue
|
||||
with self.subTest(shell=shell), tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
publisher = root / 'feresectl'
|
||||
publisher.write_text('#!/usr/bin/env python3\nimport json, os, sys\n'
|
||||
'with open(os.environ["ACTIVITY_TEST_LOG"], "a") as f:\n'
|
||||
' f.write(json.dumps(sys.argv[1:]) + "\\n")\n')
|
||||
publisher.chmod(0o755)
|
||||
log = root / 'calls'
|
||||
startup = root / 'startup'
|
||||
dependency = f'source "{ROOT}/contrib/activities/bash-preexec/bash-preexec.sh"\n' if shell == 'bash' else ''
|
||||
startup.write_text(dependency + f'source "{ROOT}/contrib/activities/ferese.{shell}"\n')
|
||||
master, slave = pty.openpty()
|
||||
env = dict(os.environ, HOME=directory, XDG_CONFIG_HOME=directory, HISTFILE='/dev/null',
|
||||
PATH=str(root) + ':' + os.environ['PATH'], ACTIVITY_TEST_LOG=str(log), TERM='dumb', PS1='ACTIVITY_PROMPT> ')
|
||||
command = (['bash', '--noprofile', '--rcfile', str(startup), '-i'] if shell == 'bash' else
|
||||
['fish', '--no-config', '-i', '--init-command', f'function fish_prompt; printf "ACTIVITY_PROMPT> "; end; source {startup}'])
|
||||
process = subprocess.Popen(command, env=env, stdin=slave, stdout=slave, stderr=slave,
|
||||
start_new_session=True, preexec_fn=lambda: fcntl.ioctl(0, termios.TIOCSCTTY, 0))
|
||||
os.close(slave)
|
||||
trace = b''
|
||||
def wait_for(predicate):
|
||||
nonlocal trace
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
if select.select([master], [], [], .05)[0]:
|
||||
trace += os.read(master, 65536)
|
||||
if predicate():
|
||||
return
|
||||
self.fail(trace.decode(errors='replace'))
|
||||
def calls():
|
||||
return [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else []
|
||||
try:
|
||||
# Wait for the first prompt before sending an interactive command.
|
||||
wait_for(lambda: b'# ' in trace or b'$ ' in trace or b'> ' in trace)
|
||||
os.write(master, b'false\n')
|
||||
wait_for(lambda: any(call[:2] == ['activity', 'end'] for call in calls()))
|
||||
records = calls()
|
||||
start = next(call for call in records if call[:2] == ['activity', 'hook'])
|
||||
end = next(call for call in records if call[:2] == ['activity', 'end'])
|
||||
self.assertEqual(start[start.index('--command') + 1], 'false')
|
||||
self.assertEqual(end[2], start[2])
|
||||
self.assertEqual(end[end.index('--result') + 1], 'fail')
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
os.close(master)
|
||||
|
||||
@unittest.skipUnless(shutil.which("fish"), "fish unavailable")
|
||||
def test_fish_preserves_status_and_does_not_execute_metadata(self):
|
||||
script = f'''function feresectl; return 71; end
|
||||
source "{ROOT}/contrib/activities/ferese.fish"
|
||||
__ferese_preexec 'echo SHOULD_NOT_EXECUTE'
|
||||
false
|
||||
__ferese_postexec
|
||||
set saved $status
|
||||
printf 'status=%s\\n' $saved
|
||||
'''
|
||||
result = subprocess.run(["fish", "--no-config", "-i", "-c", script], capture_output=True, text=True, timeout=10)
|
||||
self.assertIn("status=1", result.stdout)
|
||||
self.assertNotIn("SHOULD_NOT_EXECUTE", result.stdout)
|
||||
|
||||
def test_bash_preserves_status_and_never_executes_captured_command(self):
|
||||
script = f'''
|
||||
feresectl() {{ return 71; }}
|
||||
source "{ROOT}/contrib/activities/bash-preexec/bash-preexec.sh"
|
||||
source "{ROOT}/contrib/activities/ferese.bash"
|
||||
_ferese_arm
|
||||
_ferese_preexec 'echo SHOULD_NOT_EXECUTE'
|
||||
false
|
||||
_ferese_finish
|
||||
saved=$?
|
||||
printf 'status=%s\\n' "$saved"
|
||||
'''
|
||||
result = subprocess.run(["bash", "--norc", "-i", "-c", script], capture_output=True, text=True, timeout=10)
|
||||
self.assertIn("status=1", result.stdout)
|
||||
self.assertNotIn("SHOULD_NOT_EXECUTE", result.stdout)
|
||||
|
||||
def test_bash_preserves_existing_debug_trap(self):
|
||||
script = f'''trap ':' DEBUG
|
||||
before=$(trap -p DEBUG)
|
||||
source "{ROOT}/contrib/activities/ferese.bash"
|
||||
after=$(trap -p DEBUG)
|
||||
[[ "$before" == "$after" ]]
|
||||
'''
|
||||
result = subprocess.run(["bash", "--norc", "-i", "-c", script], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
@unittest.skipUnless(shutil.which("zsh"), "zsh unavailable")
|
||||
def test_zsh_composes_hooks_and_preserves_status(self):
|
||||
script = f'''feresectl() {{ return 71; }}
|
||||
existing() {{ :; }}
|
||||
autoload -Uz add-zsh-hook
|
||||
add-zsh-hook precmd existing
|
||||
source "{ROOT}/contrib/activities/ferese.zsh"
|
||||
(( ${{precmd_functions[(Ie)existing]}} > 0 )) || exit 4
|
||||
_ferese_preexec 'echo SHOULD_NOT_EXECUTE'
|
||||
false
|
||||
_ferese_precmd
|
||||
saved=$?
|
||||
printf 'status=%s\\n' "$saved"
|
||||
'''
|
||||
result = subprocess.run(["zsh", "-f", "-i", "-c", script], capture_output=True, text=True, timeout=10)
|
||||
self.assertIn("status=1", result.stdout)
|
||||
self.assertNotIn("SHOULD_NOT_EXECUTE", result.stdout)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -517,6 +517,52 @@ class FrontendTest(unittest.TestCase):
|
||||
|
||||
|
||||
class UserSetupTest(unittest.TestCase):
|
||||
def test_activity_setup_detects_shell_and_preserves_later_config_edits(self):
|
||||
for shell in ('fish', 'bash', 'zsh'):
|
||||
with self.subTest(shell=shell), tempfile.TemporaryDirectory(prefix="ferese user's ") as directory:
|
||||
home = Path(directory)
|
||||
config = home / 'custom config'
|
||||
startup = (config / 'fish/conf.d/ferese-activities.fish' if shell == 'fish' else
|
||||
home / ('.bashrc' if shell == 'bash' else 'zdir/.zshrc'))
|
||||
startup.parent.mkdir(parents=True, exist_ok=True)
|
||||
if shell != 'fish':
|
||||
original = '# My existing shell configuration\nexport MY_VALUE=kept\n'
|
||||
real = home / 'dotfile'
|
||||
real.write_text(original)
|
||||
startup.symlink_to(real)
|
||||
env = dict(HOME=str(home), SHELL='/usr/bin/' + shell, ZDOTDIR=str(home / 'zdir'))
|
||||
with mock.patch.dict(os.environ, env), mock.patch.object(installer.subprocess, 'run') as run:
|
||||
installer.setup_activities(config, ctl=Path('/test/feresectl'), assets=REPO / 'contrib/activities')
|
||||
first = startup.read_text()
|
||||
installer.setup_activities(config, ctl=Path('/test/feresectl'), assets=REPO / 'contrib/activities')
|
||||
self.assertEqual(run.call_count, 1, 'later installs must not reset edited config')
|
||||
self.assertEqual(startup.read_text(), first)
|
||||
self.assertTrue((config / f'ferese/activities/ferese.{shell}').exists())
|
||||
if shell != 'fish':
|
||||
self.assertTrue(first.startswith(original))
|
||||
self.assertTrue(startup.is_symlink())
|
||||
self.assertEqual(len(list(home.glob('dotfile.before-activities-*'))), 1)
|
||||
if shutil.which(shell):
|
||||
check = subprocess.run([shell, '-n', str(startup)], capture_output=True)
|
||||
self.assertEqual(check.returncode, 0, check.stderr)
|
||||
if shell == 'bash':
|
||||
check = subprocess.run(['bash', '--rcfile', str(startup), '-i', '-c',
|
||||
'[[ ${preexec_functions[*]} == *_ferese_preexec* ]]'],
|
||||
env=dict(os.environ, **env), capture_output=True)
|
||||
self.assertEqual(check.returncode, 0, check.stderr)
|
||||
|
||||
def test_failed_activity_configuration_can_be_retried(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = Path(directory)
|
||||
with mock.patch.dict(os.environ, SHELL='/usr/bin/fish'), mock.patch.object(installer.subprocess, 'run') as run:
|
||||
run.side_effect = subprocess.CalledProcessError(1, 'feresectl')
|
||||
with self.assertRaises(subprocess.CalledProcessError):
|
||||
installer.setup_activities(config, assets=REPO / 'contrib/activities')
|
||||
self.assertFalse((config / 'ferese/activities/installed.json').exists())
|
||||
run.side_effect = None
|
||||
installer.setup_activities(config, assets=REPO / 'contrib/activities')
|
||||
self.assertTrue((config / 'ferese/activities/installed.json').exists())
|
||||
|
||||
def test_repeatable_migration_and_reload_after_failure(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
home = Path(directory)
|
||||
@@ -530,7 +576,7 @@ class UserSetupTest(unittest.TestCase):
|
||||
systemctl.chmod(0o755)
|
||||
env = dict(os.environ, HOME=str(home), XDG_CONFIG_HOME=str(home / '.config'), PATH=str(bin_dir))
|
||||
# User setup intentionally refuses root; emulate the user identity in root-run CI.
|
||||
command = [sys.executable, '-c', f"import runpy, os; os.geteuid = lambda: 1000; runpy.run_path({str(HELPER)!r}, run_name='__main__')", 'user-setup']
|
||||
command = [sys.executable, '-c', f"import runpy, os; os.geteuid = lambda: 1000; runpy.run_path({str(HELPER)!r}, run_name='__main__')", 'user-setup', '--no-activities']
|
||||
result = subprocess.run(command, env=env, capture_output=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(override.exists())
|
||||
|
||||
Reference in new issue
Block a user