diff --git a/README-JA.md b/README-JA.md index e58b51b..fa7c993 100644 --- a/README-JA.md +++ b/README-JA.md @@ -47,7 +47,7 @@ curl -fsSL https://raw.githubusercontent.com/cotore-game/mcserver-compose-kit/ma ```bash curl -fsSL https://raw.githubusercontent.com/cotore-game/mcserver-compose-kit/main/install.sh | \ - bash -s -- --version v1.1.3 + bash -s -- --version v1.1.4 ``` インストーラーはリリースの圧縮ファイルを取得し、SHA-256を検証して`~/.local/share/mcserver-compose-kit`へ配置します。また、`~/.local/bin`用のPATH設定を管理ブロックとして`~/.bashrc`へ追加します。 @@ -93,6 +93,8 @@ mcserver-kit ホーム画面から、サーバー作成・管理、全体設定、MCIDテンプレート、言語、動作環境診断、ヘルプへ進めます。矢印キーで選択し、Enterで決定します。 +サーバー操作や更新確認の実行中は、ひとつの待機画面を開いたまま点字スピナーを表示します。コマンドの出力は一時ファイルに蓄積し、終了後にエラーも含めて表示します。アニメーションのたびに画面を開閉する方式は廃止しました。メニューやボタンの配置・操作は従来どおりで、メニュー間の画面遷移自体を刷新する修正ではありません。 + インストールされているツールのバージョンは次で確認できます。 ```bash diff --git a/README.md b/README.md index 73a85f9..368346f 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Install a specific release: ```bash curl -fsSL https://raw.githubusercontent.com/cotore-game/mcserver-compose-kit/main/install.sh | \ - bash -s -- --version v1.1.3 + bash -s -- --version v1.1.4 ``` The installer downloads the release archive, verifies its SHA-256 checksum, and installs the program under `~/.local/share/mcserver-compose-kit`. It also adds a managed PATH block for `~/.local/bin` to `~/.bashrc`. @@ -92,6 +92,8 @@ mcserver-kit The dashboard includes server creation and management, global settings, MCID templates, language selection, diagnostics, and help. Use the arrow keys to select an item and Enter to open it. +While server actions or update checks run, a single waiting dialog shows a braille spinner. Command output is collected in a temporary file and displayed afterward, including errors. The waiting dialog no longer opens and closes for each animation frame. Menus, buttons, and navigation retain the existing layout; this fix does not redesign transitions between menus. + Check the installed toolkit version with: ```bash diff --git a/VERSION b/VERSION index 781dcb0..65087b4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.3 +1.1.4 diff --git a/libexec/mcserver-kit/home-tui.sh b/libexec/mcserver-kit/home-tui.sh index d88a8e9..a47624c 100755 --- a/libexec/mcserver-kit/home-tui.sh +++ b/libexec/mcserver-kit/home-tui.sh @@ -119,17 +119,10 @@ pause_for_enter() { run_and_show() { local title="$1" shift - local output pid result=0 frame_index=0 - local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + local output result=0 new_temp_file output - "$@" >"$output" 2>&1 & - pid=$! - while kill -0 "$pid" 2>/dev/null; do - whiptail --title "$title" --infobox "$(tr home.processing "${frames[frame_index]}" "$title")" 8 72 - frame_index=$(((frame_index + 1) % ${#frames[@]})) - sleep 0.15 - done - wait "$pid" || result=$? + python3 "${SCRIPT_DIR}/tui-progress.py" --output "$output" --title "$title" \ + --message "$(tr home.processing '{spinner}' "$title")" -- "$@" || result=$? if [[ ! -s "$output" ]]; then if ((result == 0)); then tr home.completed >"$output" diff --git a/libexec/mcserver-kit/tui-progress.py b/libexec/mcserver-kit/tui-progress.py new file mode 100644 index 0000000..e4de452 --- /dev/null +++ b/libexec/mcserver-kit/tui-progress.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Keep a single progress dialog open while a non-interactive command runs. + +Only the waiting screen uses curses. Menus and result dialogs remain whiptail. +""" +from __future__ import annotations + +import argparse +import curses +import os +from pathlib import Path +import signal +import subprocess +import sys +import unicodedata + +FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + + +def clipped(text: str, width: int) -> str: + result, used = [], 0 + for char in text: + if unicodedata.category(char).startswith("C"): + continue + size = 0 if unicodedata.combining(char) else (2 if unicodedata.east_asian_width(char) in "WF" else 1) + if used + size > width: + break + result.append(char) + used += size + return "".join(result) + + +def stop_command(process): + # Include grandchildren, not just the shell launching Docker or curl. + if process.poll() is None: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + process.wait() + return + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + + +def execute(screen, command, output, title, message): + window = None + size = None + if screen is not None: + curses.curs_set(0) + screen.timeout(100) + + def draw(frame): + nonlocal window, size + if screen is None: + return + current = screen.getmaxyx() + if current != size: + size = current + rows, columns = size + screen.erase() + screen.noutrefresh() + if rows < 4 or columns < 12: + window = None + else: + height, width = min(8, rows), min(72, columns) + window = curses.newwin(height, width, (rows - height) // 2, (columns - width) // 2) + window.box() + window.addstr(0, 2, clipped(" " + title + " ", width - 4)) + if window is not None: + height, width = window.getmaxyx() + row = min(3, height - 2) + window.move(row, 2) + window.addstr(clipped(message.replace("{spinner}", frame), width - 4)) + window.noutrefresh() + curses.doupdate() + + draw(FRAMES[0]) # Show the dialog before launching a potentially slow command. + with subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=output, + stderr=subprocess.STDOUT, start_new_session=True) as process: + try: + frame = 0 + while process.poll() is None: + if screen is None: + try: + process.wait(timeout=0.1) + except subprocess.TimeoutExpired: + pass + else: + draw(FRAMES[frame % len(FRAMES)]) + screen.getch() # Consume keys; they must not dismiss the next result. + frame += 1 + if screen is not None: + curses.flushinp() + return process.returncode if process.returncode >= 0 else 128 - process.returncode + finally: + stop_command(process) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output", required=True) + parser.add_argument("--title", required=True) + parser.add_argument("--message", required=True) + parser.add_argument("command", nargs=argparse.REMAINDER) + args = parser.parse_args() + command = args.command[1:] if args.command[:1] == ["--"] else args.command + if not command: + parser.error("a command is required after --") + + def interrupted(signum, _frame): + raise InterruptedError(signum) + + previous = signal.signal(signal.SIGTERM, interrupted) + try: + with Path(args.output).open("w", encoding="utf-8") as output: + try: + if sys.stdin.isatty() and sys.stdout.isatty(): + return curses.wrapper(execute, command, output, args.title, args.message) + # CI and redirected invocations have no interactive display. + return execute(None, command, output, args.title, args.message) + except KeyboardInterrupt: + print("Command interrupted (SIGINT).", file=output) + return 130 + except InterruptedError: + print("Command interrupted (SIGTERM).", file=output) + return 143 + except (OSError, curses.error) as error: + print(f"Could not complete command: {error}", file=output) + return 1 + finally: + signal.signal(signal.SIGTERM, previous) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 2833167..e51af15 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -529,7 +529,7 @@ CURL MCSERVER_KIT_UPDATE_CACHE_DIR="${temp_dir}/home-transition/cache" \ MCSERVER_KIT_TEST_EVENTS="$event_log" MCSERVER_KIT_TEST_MENU_COUNT="$menu_count" \ bash "${REPO_ROOT}/mcserver-kit" home)" - assert_equal 'present' "$(grep -Fq 'loading' "$event_log" && printf present)" 'update checks retain a progress dialog' + assert_equal 'absent' "$(grep -Fq 'loading' "$event_log" && printf present || printf absent)" 'progress no longer repeatedly opens whiptail infobox' assert_equal 'present' "$(grep -Fq 'result' "$event_log" && printf present)" 'update result is shown in a persistent dialog' assert_equal 'menu' "$(tail -n 1 "$event_log")" 'the home menu returns after viewing the update result' } @@ -950,6 +950,7 @@ WHIPTAIL } main() { + PYTHONDONTWRITEBYTECODE=1 python3 "${REPO_ROOT}/tests/test-tui-progress.py" TEST_TEMP_DIR="$(mktemp -d)" trap cleanup EXIT diff --git a/tests/test-tui-progress.py b/tests/test-tui-progress.py new file mode 100644 index 0000000..6c3c05b --- /dev/null +++ b/tests/test-tui-progress.py @@ -0,0 +1,211 @@ +"""Progress-only hotfix tests using real pseudo-terminals, without Docker.""" +import fcntl +import importlib.util +import json +import os +from pathlib import Path +import pty +import select +import signal +import struct +import subprocess +import sys +import tempfile +import termios +import time +import unittest + +ROOT = Path(__file__).resolve().parents[1] +TOOL = ROOT / "libexec/mcserver-kit/tui-progress.py" +spec = importlib.util.spec_from_file_location("tui_progress", TOOL) +progress = importlib.util.module_from_spec(spec) +spec.loader.exec_module(progress) + + +class ProgressTests(unittest.TestCase): + def setUp(self): + self.workspace = tempfile.TemporaryDirectory() + self.addCleanup(self.workspace.cleanup) + self.root = Path(self.workspace.name) + self.output = self.root / "output" + + def arguments(self, code): + return [sys.executable, str(TOOL), "--output", str(self.output), + "--title", "Test", "--message", "{spinner} Processing...", + "--", sys.executable, "-c", code] + + def test_character_width_and_controls(self): + self.assertEqual(progress.clipped("日本abc", 5), "日本a") + self.assertEqual(progress.clipped("\x1btext\n", 20), "text") + + def test_output_and_error_share_file_and_preserve_status(self): + result = subprocess.run(self.arguments( + "import sys; print('stdout'); print('stderr', file=sys.stderr); sys.exit(7)" + ), capture_output=True, timeout=5) + self.assertEqual(result.returncode, 7) + self.assertEqual(result.stdout, b"") + self.assertEqual(result.stderr, b"") + self.assertIn("stdout", self.output.read_text()) + self.assertIn("stderr", self.output.read_text()) + + def test_empty_success_does_not_invent_logs(self): + result = subprocess.run(self.arguments("pass"), capture_output=True, timeout=5) + self.assertEqual(result.returncode, 0) + self.assertEqual(self.output.read_bytes(), b"") + + def test_missing_command_is_reported(self): + args = self.arguments("pass") + args[args.index("--") + 1:] = [str(self.root / "not-a-command")] + result = subprocess.run(args, capture_output=True, timeout=5) + self.assertEqual(result.returncode, 1) + self.assertIn("Could not complete command", self.output.read_text()) + + def test_signal_exit_is_not_reported_as_success(self): + result = subprocess.run(self.arguments( + "import os, signal; os.kill(os.getpid(), signal.SIGTERM)" + ), capture_output=True, timeout=5) + self.assertEqual(result.returncode, 143) + + def test_command_is_executed_once(self): + counter = self.root / "counter" + code = f"from pathlib import Path; p=Path({str(counter)!r}); p.write_text(p.read_text()+'x' if p.exists() else 'x')" + subprocess.run(self.arguments(code), check=True, capture_output=True, timeout=5) + self.assertEqual(counter.read_text(), "x") + + def test_terminal_stays_open_until_command_finishes(self): + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 28, 94, 0, 0)) + process = subprocess.Popen( + self.arguments("import time; print('PRIVATE-COMMAND-OUTPUT', flush=True); time.sleep(0.9)"), + stdin=slave, stdout=slave, stderr=slave, + env=dict(os.environ, TERM="xterm-256color"), + ) + os.close(slave) + received = bytearray() + + def receive(duration): + deadline = time.monotonic() + duration + while time.monotonic() < deadline: + if select.select([master], [], [], 0.05)[0]: + try: + received.extend(os.read(master, 65536)) + except OSError: + break + + try: + deadline = time.monotonic() + 5 + while b"Processing" not in received and time.monotonic() < deadline: + receive(0.05) + self.assertIn(b"Processing", received) + receive(0.3) + self.assertIsNone(process.poll()) + self.assertNotIn(b"\x1b[?1049l", received, "must not return to shell between spinner frames") + self.assertNotIn(b"PRIVATE-COMMAND-OUTPUT", received, "logs belong to the later result dialog") + self.assertEqual(received.count(b"\x1b[2J"), 1, "must not clear the screen for each frame") + self.assertGreaterEqual(sum(frame.encode() in received for frame in progress.FRAMES), 2) + receive(1) + process.wait(timeout=3) + receive(0.1) + self.assertEqual(process.returncode, 0) + self.assertEqual(received.count(b"\x1b[?1049h"), 1) + self.assertEqual(received.count(b"\x1b[?1049l"), 1) + self.assertIn("PRIVATE-COMMAND-OUTPUT", self.output.read_text()) + finally: + if process.poll() is None: + process.terminate() + process.wait(timeout=5) + os.close(master) + + def test_termination_stops_command_group(self): + marker = self.root / "finished" + ready = self.root / "ready" + child_pid = self.root / "child-pid" + code = ( + "import os, time; from pathlib import Path; " + f"Path({str(child_pid)!r}).write_text(str(os.getpid())); " + f"Path({str(ready)!r}).touch(); time.sleep(3); Path({str(marker)!r}).touch()" + ) + process = subprocess.Popen(self.arguments(code), stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + deadline = time.monotonic() + 5 + while not ready.exists() and time.monotonic() < deadline: + time.sleep(0.02) + self.assertTrue(ready.exists()) + process.send_signal(signal.SIGTERM) + process.communicate(timeout=5) + self.assertEqual(process.returncode, 143) + self.assertFalse(marker.exists()) + with self.assertRaises(ProcessLookupError): + os.kill(int(child_pid.read_text()), 0) + finally: + if process.poll() is None: + process.kill() + process.communicate() + + + + def test_home_keeps_result_dialog_and_error_title(self): + server = self.root / "servers/demo" + server.mkdir(parents=True) + (server / "compose.yaml").write_text("services: {}\n") + config = self.root / "config.yml" + config.write_text(f"paths:\n server_root: {self.root / 'servers'}\n") + fake_bin = self.root / "bin" + fake_bin.mkdir() + queue = self.root / "queue.json" + events = self.root / "events.jsonl" + whiptail = fake_bin / "whiptail" + whiptail.write_text("""#!/usr/bin/env python3 +import json, os, pathlib, sys +args = sys.argv[1:] +queue = pathlib.Path(os.environ["TEST_QUEUE"]) +events = pathlib.Path(os.environ["TEST_EVENTS"]) +if "--menu" in args: + items = json.loads(queue.read_text()) + sys.stderr.write(items.pop(0)) + queue.write_text(json.dumps(items)) +elif "--textbox" in args: + path = pathlib.Path(args[args.index("--textbox") + 1]) + title = args[args.index("--title") + 1] + with events.open("a") as file: + file.write(json.dumps({"title": title, "text": path.read_text()}) + "\\n") +elif "--infobox" in args: + raise SystemExit("Unexpected old infobox loop") +""") + whiptail.chmod(0o755) + docker = fake_bin / "docker" + docker.write_text("""#!/bin/sh +case "$*" in + 'compose ps -a --format json') echo '[]' ;; + 'compose config --quiet') exit 0 ;; + 'compose up -d') echo command-output; echo command-diagnostic >&2; exit "$TEST_COMMAND_STATUS" ;; +esac +""") + docker.chmod(0o755) + cache = self.root / "cache" + cache.mkdir() + version = (ROOT / "VERSION").read_text().strip() + (cache / "update-check").write_text(f"{int(time.time())}\nv{version}\n") + for status in (0, 7): + with self.subTest(status=status): + queue.write_text(json.dumps(["servers", "demo", "start", "back", "__back", "exit"])) + events.write_text("") + result = subprocess.run( + ["bash", str(ROOT / "mcserver-kit"), "home"], + capture_output=True, text=True, timeout=40, + env=dict(os.environ, PATH=f"{fake_bin}:{os.environ['PATH']}", + MCSERVER_KIT_LANG="en", MCSERVER_KIT_TUI_TEST="true", + MCSERVER_KIT_CONFIG=str(config), MCSERVER_KIT_UPDATE_CACHE_DIR=str(cache), + TEST_QUEUE=str(queue), TEST_EVENTS=str(events), + TEST_COMMAND_STATUS=str(status)), + ) + self.assertEqual(result.returncode, 0, result.stderr) + records = [json.loads(line) for line in events.read_text().splitlines()] + self.assertEqual(len(records), 1) + self.assertIn("command-output", records[0]["text"]) + self.assertIn("command-diagnostic", records[0]["text"]) + self.assertEqual(records[0]["title"] == "Error", status != 0) + self.assertEqual(json.loads(queue.read_text()), []) + +if __name__ == "__main__": + unittest.main()