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:
Samuel OnojaandSamuel Onoja authored and GitHub committed 2026-10-12 22:52:25 +01:00
1 parent 7a7df0106c
commit 6b0d903ad0
107 files changed
+9340 -291

No files matched your search

+1
View File
@@ -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
View File
@@ -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
+73
View File
@@ -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)')
+25
View File
@@ -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"
}
]
}
+190
View File
@@ -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()
+132
View File
@@ -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()
+47 -1
View File
@@ -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())