From 41d03f0a35a95daa7d54e7f29ab924776bedf6e4 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 15 Oct 2026 17:42:30 +0200 Subject: fix(cli): make the command-line client work on Windows Its suite had only run on Linux. On Windows, against a real hub and node and from the installed meshbay-cli.exe: - secrets.enc was written in text mode (os.open without O_BINARY): every 0x0A of the ciphertext became CR LF and the saved session "does not open with its key" on the next run. - isatty() is true for NUL, so a script run with `< NUL` or by the Task Scheduler was asked questions instead of told to pass --yes; output.terminal() checks for a real console. - Ctrl+C waited for the selector loop's next timer, up to half a minute in `chat --follow`; the loop now points the signal wakeup at its self-pipe, as the proactor does. - Right-click copy and paste raised: asyncio cannot start a process on the selector loop; the clipboard tool runs in a thread. - Tab completion of remote paths ran on the default (proactor) loop, which never opens a node, and wrote in the locale encoding; the PowerShell script now reads it as UTF-8. - `get -R` into a folder past MAX_PATH died with a traceback; get, put, the resume file and the interactive mode use the extended form at each disk call, as the node does. - `groups` showed a SQLite hub's naive UTC time as local time. The opt-in end-to-end suite now runs on Windows (16 tests: Chrome found under Program Files and started with the real environment, VLC as the player, Ctrl+C through a console of its own, PowerShell 5.1 completion of an accented name). Co-Authored-By: Claude Opus 5.5 --- docs/MESHBAY_DESIGN.md | 2 +- packages/meshbay-cli/src/meshbay_cli/__main__.py | 24 ++++- .../meshbay-cli/src/meshbay_cli/client/store.py | 5 +- .../src/meshbay_cli/client/transfers.py | 9 +- .../src/meshbay_cli/client/upload_plan.py | 33 +++++- .../src/meshbay_cli/commands/account.py | 14 +-- .../meshbay-cli/src/meshbay_cli/commands/chat.py | 4 +- .../src/meshbay_cli/commands/completion.py | 10 +- .../src/meshbay_cli/commands/context.py | 6 +- .../meshbay-cli/src/meshbay_cli/commands/get.py | 13 +-- .../meshbay-cli/src/meshbay_cli/commands/groups.py | 7 +- .../meshbay-cli/src/meshbay_cli/commands/join.py | 4 +- .../meshbay-cli/src/meshbay_cli/commands/login.py | 4 +- .../meshbay-cli/src/meshbay_cli/commands/put.py | 4 +- packages/meshbay-cli/src/meshbay_cli/complete.py | 4 +- packages/meshbay-cli/src/meshbay_cli/output.py | 34 +++++- packages/meshbay-cli/src/meshbay_cli/tui/chat.py | 13 ++- .../meshbay-cli/src/meshbay_cli/tui/clipboard.py | 28 ++--- packages/meshbay-cli/src/meshbay_cli/tui/panes.py | 3 +- .../meshbay-cli/src/meshbay_cli/tui/screens.py | 10 +- packages/meshbay-cli/tests/console.py | 48 +++++++++ packages/meshbay-cli/tests/test_cli_actions.py | 63 +++++++++++ packages/meshbay-cli/tests/test_clipboard.py | 23 +++- packages/meshbay-cli/tests/test_ctrl_c.py | 34 ++++++ packages/meshbay-cli/tests/test_e2e_local.py | 118 ++++++++++++++------- packages/meshbay-cli/tests/test_listing.py | 18 ++++ 26 files changed, 428 insertions(+), 107 deletions(-) create mode 100644 packages/meshbay-cli/tests/console.py create mode 100644 packages/meshbay-cli/tests/test_ctrl_c.py diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index d6240c7..e9f7b52 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -3946,7 +3946,7 @@ requirements, not compatibility notes. | **Case-insensitive, case-preserving** | The index needs a canonical identity and a **case-folding collision check** at scan time, reported to the operator rather than resolved silently. One directory indexed as two roots is the same problem one level up | | **Unicode normalization** | A name written on one platform in decomposed form and on another in composed form are different byte strings. **Normalize to NFC for identity and comparison; preserve the original bytes for display and for opening the file** | | **Reserved names and characters** | A group indexed on one platform can hold names another cannot create. The client sanitises on save **and says so**; the upload allowlist is the intersection across platforms, or some files are simply undownloadable | -| **Path length limits** | Windows refuses a path of 260 characters or more unless `LongPathsEnabled` is set, which it is not by default and which takes administrator rights to change. **The node hands every call that touches a group's disk the extended form** (`paths.long_path`, `\\?\`) and keeps the plain path in the index, the hash cache and the log: a directory is listed under its long form and its entries named from the plain one (`indexer.walk_files`, `wire._walk_dirs`). ffmpeg extends a long path itself and is given the plain one. `test_long_paths.py` holds indexing, serving, uploads, folders and enrichment with the setting off. What a client saves is not measured | +| **Path length limits** | Windows refuses a path of 260 characters or more unless `LongPathsEnabled` is set, which it is not by default and which takes administrator rights to change. **The node hands every call that touches a group's disk the extended form** (`paths.long_path`, `\\?\`) and keeps the plain path in the index, the hash cache and the log: a directory is listed under its long form and its entries named from the plain one (`indexer.walk_files`, `wire._walk_dirs`). ffmpeg extends a long path itself and is given the plain one. `test_long_paths.py` holds indexing, serving, uploads, folders and enrichment with the setting off. The command-line client does the same for what it saves and sends (`get -R` into a deep folder, the resume file beside it, `put -R` of a deep tree; `test_get_and_put_past_max_path`). What the desktop application saves is not measured | | **Coarse timestamps, local time** | mtime alone is not a change detector. Size plus mtime with tolerance, and rehash when in doubt | | **Watcher reliability** | Change notification drops events under load on Windows, and inotify is unreliable on a FUSE mount. **Periodic reconciliation is mandatory on both platforms** | | **No symlinks, no POSIX permissions** | Simplifications: nothing to defend against, and the node runs as the user anyway | diff --git a/packages/meshbay-cli/src/meshbay_cli/__main__.py b/packages/meshbay-cli/src/meshbay_cli/__main__.py index f5cee22..597e2fe 100644 --- a/packages/meshbay-cli/src/meshbay_cli/__main__.py +++ b/packages/meshbay-cli/src/meshbay_cli/__main__.py @@ -8,7 +8,9 @@ import argparse import asyncio import logging import os +import signal import sys +import threading from . import __version__ from .commands import ( @@ -113,15 +115,35 @@ async def _run(args: argparse.Namespace) -> int: await ctx.aclose() +class _WindowsSelectorLoop(asyncio.SelectorEventLoop): + """ + A selector loop that Ctrl+C wakes. Windows' select() is not interrupted by + a console event, and unlike the proactor (`windows_events.ProactorEventLoop. + run_forever`) this loop never points the signal wakeup at its self-pipe: Ctrl+C + waited for the next timer, which in `chat --follow` or `stream` can be half + a minute away (measured: 29 s on a loop whose next timer was 30 s off). + """ + + def run_forever(self): + if threading.current_thread() is not threading.main_thread(): + return super().run_forever() # signals reach the main thread only + old = signal.set_wakeup_fd(self._csock.fileno()) + try: + super().run_forever() + finally: + signal.set_wakeup_fd(old) + + def loop_factory(): """aiortc's ICE needs a selector loop: Windows' default proactor never completes a DataChannel handshake.""" - return asyncio.SelectorEventLoop if sys.platform == "win32" else None + return _WindowsSelectorLoop if sys.platform == "win32" else None def main(argv: list[str] | None = None) -> int: argv = sys.argv[1:] if argv is None else argv if argv[:1] == ["__complete"]: + _utf8_output() # what the completion scripts read from .complete import main as complete return complete(argv[1:]) if not argv and os.environ.get("MESHBAY_CLI_SELFTEST") == "1": diff --git a/packages/meshbay-cli/src/meshbay_cli/client/store.py b/packages/meshbay-cli/src/meshbay_cli/client/store.py index c48fe89..00ff9f3 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/store.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/store.py @@ -63,7 +63,10 @@ def data_dir() -> Path: def _write_private(path: Path, data: bytes) -> None: """Atomically, readable by this user only.""" tmp = path.with_name(path.name + ".tmp") - fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + # O_BINARY: without it Windows opens in text mode and writes every 0x0A of + # the ciphertext as CR LF, and the secrets no longer open. + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0) + fd = os.open(tmp, flags, 0o600) try: os.write(fd, data) os.fsync(fd) diff --git a/packages/meshbay-cli/src/meshbay_cli/client/transfers.py b/packages/meshbay-cli/src/meshbay_cli/client/transfers.py index 2cbf4cd..177c78b 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/transfers.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/transfers.py @@ -128,7 +128,8 @@ class Resume: sidecar = target.with_name(target.name + PART_SUFFIX + ".json") done = 0 try: - meta = json.loads(sidecar.read_text(encoding="utf-8")) + with open(long_path(sidecar), encoding="utf-8") as f: + meta = json.load(f) if meta.get("id") == entry["id"] and meta.get("size") == entry["size"]: # Every chunk but the last is whole, so the complete ones are # what the part's length says. @@ -198,8 +199,8 @@ def _prepare_part(resume: Resume, entry: dict, start: int) -> None: pass else: os.truncate(part, start * DOWNLOAD_CHUNK) - resume.sidecar.write_text(json.dumps({"id": entry["id"], "size": entry["size"]}), - encoding="utf-8") + with open(long_path(resume.sidecar), "w", encoding="utf-8") as f: + json.dump({"id": entry["id"], "size": entry["size"]}, f) def _publish(resume: Resume, target: Path, overwrite: bool) -> None: @@ -220,7 +221,7 @@ def _publish(resume: Resume, target: Path, overwrite: bool) -> None: raise TransferError(f"{target} already exists.") from None os.replace(part, dest) with contextlib.suppress(OSError): - resume.sidecar.unlink() + os.unlink(long_path(resume.sidecar)) if sys.platform == "win32": mark_of_the_web(target) diff --git a/packages/meshbay-cli/src/meshbay_cli/client/upload_plan.py b/packages/meshbay-cli/src/meshbay_cli/client/upload_plan.py index 070e686..b4ec781 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/upload_plan.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/upload_plan.py @@ -12,7 +12,7 @@ import stat from dataclasses import dataclass from pathlib import Path -from meshbay_common.paths import fold +from meshbay_common.paths import fold, long_path from ..errors import USAGE, CliError from ..output import safe @@ -36,7 +36,7 @@ def plan_upload(sources: list[str], recursive: bool): for src in sources: path = Path(src).expanduser() try: - st = path.stat() + st = os.stat(long_path(path)) except OSError as e: problems.append(f"{src}: {e.strerror}") continue @@ -51,18 +51,18 @@ def plan_upload(sources: list[str], recursive: bool): elif stat.S_ISDIR(st.st_mode): if not recursive: raise CliError(USAGE, f"{safe(src)} is a folder; use -R.") - for here, subdirs, names in os.walk(path): + for here, subdirs, names in _walk(path): rel = [top] + list(Path(here).relative_to(path).parts) dirs.append(rel) for d in list(subdirs): - if (Path(here) / d).is_symlink(): + if os.path.islink(long_path(Path(here) / d)): subdirs.remove(d) problems.append(f"{Path(here) / d} is a link to a folder; not " "followed") for n in names: p = Path(here) / n try: - st_n = p.stat() + st_n = os.stat(long_path(p)) except OSError as e: problems.append(f"{p}: {e.strerror}") continue @@ -74,6 +74,29 @@ def plan_upload(sources: list[str], recursive: bool): return dirs, files, problems +def _walk(top: Path): + """`os.walk` for a tree that may go past Windows' MAX_PATH: each folder is + listed under its long form (`long_path`, which only prefixes a long path, so + a short top alone would not do) and the paths given back stay plain.""" + stack = [top] + while stack: + here = stack.pop() + try: + with os.scandir(long_path(here)) as it: + entries = list(it) + except OSError: + continue + subdirs, names = [], [] + for e in entries: + try: + is_dir = e.is_dir() + except OSError: + is_dir = False + (subdirs if is_dir else names).append(e.name) + yield here, subdirs, names # the caller prunes and orders subdirs + stack.extend(here / d for d in reversed(subdirs)) + + def taken_names(tree: Tree, folder: str, names: list[str]) -> set[str]: """The names among `names` already in `folder`, compared without case.""" there = [i.name for i in tree.children(folder, include_unavailable=True)] diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/account.py b/packages/meshbay-cli/src/meshbay_cli/commands/account.py index 4499f0b..1fbef13 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/account.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/account.py @@ -35,7 +35,7 @@ from ..client.passphrase import passphrase_problem, solve_captcha from ..client.rewrap import Carried, carry from ..client.store import FILE_KEY_NOTICE, KEY_FILE from ..errors import AUTH, NOT_FOUND, REFUSED, USAGE, CliError -from ..output import confirm, err, safe, sentence +from ..output import confirm, err, safe, sentence, terminal from .context import Context HELP = ("your account: show it, register, verify, change or reset the passphrase, " @@ -102,7 +102,7 @@ async def run(ctx: Context) -> int: def _ask(prompt: str, given: str | None, what: str) -> str: if given: return given.strip() - if not sys.stdin.isatty(): + if not terminal(sys.stdin): raise CliError(USAGE, f"Name the {what}: there is no terminal to ask on.") value = input(prompt).strip() if not value: @@ -121,7 +121,7 @@ def _read_first_line(path: str) -> str: def _new_passphrase(given: str | None) -> str: """A new passphrase: `given`, else typed twice at a terminal; held to the floor.""" if given is None: - if not sys.stdin.isatty(): + if not terminal(sys.stdin): raise NoPassphrase("A new passphrase is needed and there is no terminal to " "ask on.") given = getpass.getpass("New passphrase: ") @@ -224,7 +224,7 @@ async def _register(ctx: Context) -> int: "Keep it somewhere safe, apart from your passphrase. If you forget the " "passphrase, it is what gives you your groups back.") del raw - if not sys.stdin.isatty() or ctx.args.json: + if not terminal(sys.stdin) or ctx.args.json: err(f"A code was sent to {safe(email)}. Then: meshbay-cli account verify CODE " f"--email {safe(email)}") return 0 @@ -355,7 +355,7 @@ async def _reset(ctx: Context) -> int: raise _hub_refusal(e, "No code sent") from e err(f"If {safe(username)} and {safe(email)} are an account's, a code is on its " "way there (valid one hour).") - if not sys.stdin.isatty(): + if not terminal(sys.stdin): err(f"Then: meshbay-cli account reset -u {safe(username)} --code CODE") return 0 code = _ask("The code: ", None, "code") @@ -365,7 +365,7 @@ async def _reset(ctx: Context) -> int: text = (sys.stdin.read() if a.recovery_key_file == "-" else Path(a.recovery_key_file).read_text(encoding="utf-8")) recovery = text.strip() - elif sys.stdin.isatty(): + elif terminal(sys.stdin): recovery = input("Your recovery key (Enter if you have none): ").strip() rkey = None if recovery: @@ -429,7 +429,7 @@ async def _email(ctx: Context, account: Account) -> int: await account.call(account.hub.change_email, address, ak) except HubError as e: raise _hub_refusal(e, "Not changed") from e - if ctx.args.json or not sys.stdin.isatty(): + if ctx.args.json or not terminal(sys.stdin): _say(ctx, {"email_verification_required": True, "email": address}, f"A code was sent to {safe(address)}. Then: meshbay-cli account email " "--code CODE") diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/chat.py b/packages/meshbay-cli/src/meshbay_cli/commands/chat.py index e410a6a..557592e 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/chat.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/chat.py @@ -28,7 +28,7 @@ from ..client.groups import my_groups, open_group from ..client.paths import group_arg, handle, resolve_group from ..client.roster import RosterCheck from ..errors import NODE, USAGE, CliError -from ..output import date, err, human_size, safe, sentence +from ..output import date, err, human_size, safe, sentence, terminal from .context import Context HELP = ("chat: alone, interactively; with a group, its last 10 messages (-N for N), " @@ -90,7 +90,7 @@ async def run(ctx: Context) -> int: if a.group is None: if a.message is not None or a.follow: raise CliError(USAGE, "Name the group to post to.") - if not (sys.stdin.isatty() and sys.stdout.isatty()): + if not (terminal(sys.stdin) and terminal(sys.stdout)): raise CliError(USAGE, "The interactive chat needs a terminal; name a group " "to read it, or to post with -m.") from ..tui.app import run_interactive diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/completion.py b/packages/meshbay-cli/src/meshbay_cli/commands/completion.py index 831c012..0fc3f30 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/completion.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/completion.py @@ -51,7 +51,15 @@ Register-ArgumentCompleter -Native -CommandName 'meshbay-cli', 'meshbay-cli.exe' $index = $i } } - $found = @(& meshbay-cli __complete powershell $index @words 2>$null) + # The client writes UTF-8; Windows PowerShell reads a program's output in the + # console's code page, which turns an accented name into another name. + $encoding = [Console]::OutputEncoding + try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch { } + try { + $found = @(& meshbay-cli __complete powershell $index @words 2>$null) + } finally { + try { [Console]::OutputEncoding = $encoding } catch { } + } $quote = { param($s) if ($s -match '[\s''"`$&|;(){}@,#]') { "'" + ($s -replace "'", "''") + "'" } else { $s } diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/context.py b/packages/meshbay-cli/src/meshbay_cli/commands/context.py index 827c101..0f7e842 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/context.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/context.py @@ -12,7 +12,7 @@ from ..client.hub import DEFAULT_HUB, Hub from ..client.paths import handle from ..client.store import Store from ..errors import USAGE, CliError -from ..output import safe +from ..output import safe, terminal class Context: @@ -45,7 +45,7 @@ class Context: if value is None: value = os.environ.get("MESHBAY_PASSWORD") if value is None: - if not sys.stdin.isatty(): + if not terminal(sys.stdin): raise NoPassphrase("A passphrase is needed and there is no terminal to ask " "on; pass --password-file or set MESHBAY_PASSWORD.") value = getpass.getpass(f"Passphrase for {self.username or self.stored_username}" @@ -62,7 +62,7 @@ class Context: def _device_code(self, group: dict, pending, *, first: bool) -> bool: """Show the code once. Wait only where somebody is there to approve it.""" - waiting = sys.stderr.isatty() and not self.args.quiet + waiting = terminal(sys.stderr) and not self.args.quiet if first: minutes = max(1, int((pending.expires_at - time.time()) // 60)) print(f"This machine is not yet a device of yours on the node hosting " diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/get.py b/packages/meshbay-cli/src/meshbay_cli/commands/get.py index eec73c4..33a9637 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/get.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/get.py @@ -12,7 +12,7 @@ import os from dataclasses import dataclass from pathlib import Path -from meshbay_common.paths import fold +from meshbay_common.paths import fold, long_path from ..client.groups import my_groups from ..client.names import UnsafeName, local_path @@ -147,7 +147,8 @@ async def _run(ctx: Context, account, pool: Pool) -> int: planned.add(key) if changed: renamed.append((shown, target)) - if target.exists() and not target.with_name(target.name + PART_SUFFIX).exists(): + if os.path.exists(long_path(target)) and not os.path.exists( + long_path(target.with_name(target.name + PART_SUFFIX))): if a.skip: return if not a.force: @@ -181,7 +182,7 @@ async def _run(ctx: Context, account, pool: Pool) -> int: for problem in problems: progress.note(f"meshbay-cli: {safe(problem)}") for d in dirs: - d.mkdir(parents=True, exist_ok=True) + os.makedirs(long_path(d), exist_ok=True) progress.plan(len(jobs), sum(int(j.entry["size"]) for j in jobs)) failed = len(problems) @@ -224,12 +225,12 @@ def _destination(dest: str | None, single_file: bool) -> tuple[Path, str | None] return Path.cwd(), None path = Path(dest).expanduser() if dest.endswith(("/", os.sep)): - path.mkdir(parents=True, exist_ok=True) + os.makedirs(long_path(path), exist_ok=True) return path, None - if path.is_dir(): + if os.path.isdir(long_path(path)): return path, None if single_file: - if not path.parent.is_dir(): + if not os.path.isdir(long_path(path.parent)): raise CliError(USAGE, f"{path.parent} is not a folder.") return path.parent, path.name raise CliError(USAGE, f"{dest} is not a folder (end it with / to create it).") diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/groups.py b/packages/meshbay-cli/src/meshbay_cli/commands/groups.py index 04395dd..5ee1356 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/groups.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/groups.py @@ -10,7 +10,7 @@ operator is another notion. import contextlib import json import sys -from datetime import datetime +from datetime import UTC, datetime from ..client.groups import my_groups from ..client.hub import HubError @@ -38,9 +38,12 @@ def local_time(iso: str | None) -> str: if not iso: return "" try: - return datetime.fromisoformat(iso).astimezone().strftime("%Y-%m-%d %H:%M") + moment = datetime.fromisoformat(iso) except ValueError: return "" + if moment.tzinfo is None: # a hub on SQLite sends UTC without saying so + moment = moment.replace(tzinfo=UTC) + return moment.astimezone().strftime("%Y-%m-%d %H:%M") async def run(ctx: Context) -> int: diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/join.py b/packages/meshbay-cli/src/meshbay_cli/commands/join.py index ee75c96..06494dd 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/join.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/join.py @@ -18,7 +18,7 @@ from ..client.invite import parse_link from ..client.paths import group_arg, handle, resolve_group from ..client.session import DeviceLinkPending, NodeRefusal from ..errors import AUTH, NODE, NOT_FOUND, USAGE, CliError -from ..output import err, safe, sentence +from ..output import err, safe, sentence, terminal from .context import Context from .invitations import find_invitation @@ -102,7 +102,7 @@ async def run(ctx: Context) -> int: except NodeRefusal as e: if e.refusal_code != "code_required" or code: raise - if not sys.stdin.isatty(): + if not terminal(sys.stdin): raise CliError(NODE, f"You are a member of {safe(name)}; its node has not " "admitted you yet and needs your invitation's code:\n" f" meshbay-cli join {name} --code CODE") from e diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/login.py b/packages/meshbay-cli/src/meshbay_cli/commands/login.py index cbd16ef..f26691c 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/login.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/login.py @@ -8,7 +8,7 @@ from ..client.account import Account from ..client.groups import my_groups from ..client.store import FILE_KEY_NOTICE, KEY_FILE from ..errors import USAGE, CliError -from ..output import err, safe, sentence +from ..output import err, safe, sentence, terminal from .context import Context from .logout import sign_out_stored @@ -24,7 +24,7 @@ def add_parser(sub, parent) -> None: async def run(ctx: Context) -> int: username = ctx.username or ctx.stored_username if not username: - if not sys.stdin.isatty(): + if not terminal(sys.stdin): raise CliError(USAGE, "Name the account with --user.") username = input("Username: ").strip() ctx.username = username diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/put.py b/packages/meshbay-cli/src/meshbay_cli/commands/put.py index 01a7233..cf80e02 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/put.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/put.py @@ -18,6 +18,8 @@ import tempfile import threading from pathlib import Path +from meshbay_common.paths import long_path + from ..client.groups import my_groups from ..client.paths import handle, parse, resolve_group from ..client.pool import Pool @@ -156,7 +158,7 @@ async def _send(ctx: Context, session, group: dict, folder: str, dirs: list[list progress.start(safe(name)) lock = threading.Lock() try: - with open(job.local, "rb") as fh: + with open(long_path(job.local), "rb") as fh: def read(offset: int, length: int) -> bytes: with lock: fh.seek(offset) diff --git a/packages/meshbay-cli/src/meshbay_cli/complete.py b/packages/meshbay-cli/src/meshbay_cli/complete.py index ac590ce..249d5b7 100644 --- a/packages/meshbay-cli/src/meshbay_cli/complete.py +++ b/packages/meshbay-cli/src/meshbay_cli/complete.py @@ -300,7 +300,9 @@ def candidates(shell: str, cword: int, words: list[str]) -> list[str]: return [c[:-1] for c in found if c.count("/") == 1] return found try: - found = asyncio.run(asyncio.wait_for(go(), DEADLINE)) + # The client's loop: on Windows the default one never opens a node. + from .__main__ import loop_factory + found = asyncio.run(asyncio.wait_for(go(), DEADLINE), loop_factory=loop_factory()) except Exception: found = [] return ([FILES] if kind[0] == "files+remote" else []) + found diff --git a/packages/meshbay-cli/src/meshbay_cli/output.py b/packages/meshbay-cli/src/meshbay_cli/output.py index d4c0758..1d46981 100644 --- a/packages/meshbay-cli/src/meshbay_cli/output.py +++ b/packages/meshbay-cli/src/meshbay_cli/output.py @@ -54,8 +54,36 @@ def columns(names: list[str]) -> str: return "\n".join(lines) +def terminal(stream) -> bool: + """ + Whether `stream` is a terminal a person sits at. `isatty()` alone is wrong on + Windows, where it means "a character device" and so says yes for NUL: a + script run with `< NUL`, or by the Task Scheduler, was asked questions it + could not answer instead of being told to pass --yes. + """ + try: + if not stream.isatty(): + return False + except (AttributeError, ValueError): + return False + if sys.platform != "win32": + return True + try: + fd = stream.fileno() + except (OSError, ValueError): + return True # not a file at all: take its word + import ctypes + import msvcrt + try: + handle = msvcrt.get_osfhandle(fd) + except OSError: + return False + mode = ctypes.c_uint32() + return bool(ctypes.windll.kernel32.GetConsoleMode(handle, ctypes.byref(mode))) + + def is_tty() -> bool: - return sys.stdout.isatty() + return terminal(sys.stdout) def err(message: str) -> None: @@ -71,7 +99,7 @@ class Progress: def __init__(self, arrow: str, *, quiet: bool = False, as_json: bool = False): self.arrow = arrow - self.live = sys.stderr.isatty() and not quiet and not as_json + self.live = terminal(sys.stderr) and not quiet and not as_json self.as_json = as_json and not quiet self.files_total = 0 self.files_done = 0 @@ -149,7 +177,7 @@ def confirm(question: str, yes: bool) -> None: from .errors import USAGE, CliError if yes: return - if not sys.stdin.isatty(): + if not terminal(sys.stdin): raise CliError(USAGE, f"{question} Not done: there is no terminal to ask on; " "pass --yes to confirm.") # On stderr, like every other word to the person: stdout is the result. diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/chat.py b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py index e4ea1fe..989ffe2 100644 --- a/packages/meshbay-cli/src/meshbay_cli/tui/chat.py +++ b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py @@ -21,6 +21,7 @@ import os import time from pathlib import Path +from meshbay_common.paths import long_path from rich.segment import Segment from rich.text import Text from textual import events @@ -412,9 +413,10 @@ class ChatRoomScreen(Screen): async def _put(self, arg: str) -> None: path = Path(os.path.abspath(os.path.expanduser(arg))) - if not path.is_file(): + if not os.path.isfile(long_path(path)): self.note(f"! {safe(arg)}: " + ("a folder; /put sends one file." - if path.is_dir() else "no such file."), style="bold red") + if os.path.isdir(long_path(path)) else "no such file."), + style="bold red") return name = path.name if problems := refused_names([name], []): @@ -430,9 +432,9 @@ class ChatRoomScreen(Screen): if found is None: return _, folder = found - size = path.stat().st_size + size = os.stat(long_path(path)).st_size advance, queued = self._progress("↑", name, size) - with open(path, "rb") as fh: + with open(long_path(path), "rb") as fh: def read(offset: int, length: int) -> bytes: fh.seek(offset) return fh.read(length) @@ -475,7 +477,8 @@ class ChatRoomScreen(Screen): style="bold red") return target = Path.cwd() / name.rsplit("/", 1)[-1] - if target.exists() and not target.with_name(target.name + PART_SUFFIX).exists(): + if os.path.exists(long_path(target)) and not os.path.exists( + long_path(target.with_name(target.name + PART_SUFFIX))): self.note(f"! {safe(str(target))} is here already; nothing was downloaded.", style="bold red") return diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py b/packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py index 1a1f0d7..e0bd315 100644 --- a/packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py +++ b/packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py @@ -12,6 +12,7 @@ skipped, never an error. import asyncio import os import shutil +import subprocess import sys TIMEOUT = 3.0 @@ -40,24 +41,25 @@ def _encode(text: str) -> bytes: return ("" + text).encode("utf-16-le") if sys.platform == "win32" else text.encode() -async def _run(cmd: list[str], data: bytes | None) -> bytes | None: +def _run_blocking(cmd: list[str], data: bytes | None) -> bytes | None: if shutil.which(cmd[0]) is None: return None env = {**os.environ, "LC_CTYPE": "UTF-8"} if sys.platform == "darwin" else None try: - proc = await asyncio.create_subprocess_exec( - *cmd, stdin=asyncio.subprocess.PIPE if data is not None else asyncio.subprocess.DEVNULL, - stdout=asyncio.subprocess.PIPE if data is None else asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL, env=env) - except OSError: - return None - try: - out, _ = await asyncio.wait_for(proc.communicate(data), TIMEOUT) - except TimeoutError: - proc.kill() - await proc.wait() + proc = subprocess.run( + cmd, input=data, stdin=None if data is not None else subprocess.DEVNULL, + stdout=subprocess.PIPE if data is None else subprocess.DEVNULL, + stderr=subprocess.DEVNULL, env=env, timeout=TIMEOUT, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + except (OSError, subprocess.TimeoutExpired): return None - return (out or b"") if proc.returncode == 0 else None + return (proc.stdout or b"") if proc.returncode == 0 else None + + +async def _run(cmd: list[str], data: bytes | None) -> bytes | None: + # In a thread, not asyncio's subprocesses: on Windows the client runs a + # selector loop (aiortc's ICE needs one), which cannot start a process. + return await asyncio.to_thread(_run_blocking, cmd, data) async def read_text() -> str | None: diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/panes.py b/packages/meshbay-cli/src/meshbay_cli/tui/panes.py index d1e3dd0..417e0c6 100644 --- a/packages/meshbay-cli/src/meshbay_cli/tui/panes.py +++ b/packages/meshbay-cli/src/meshbay_cli/tui/panes.py @@ -13,6 +13,7 @@ import stat from dataclasses import dataclass, field from pathlib import Path +from meshbay_common.paths import long_path from rich.text import Text from textual.app import ComposeResult from textual.binding import Binding @@ -274,7 +275,7 @@ class LocalPane(Pane): def _scan(path: Path) -> list[Row]: rows = [] - with os.scandir(path) as it: + with os.scandir(long_path(path)) as it: for e in it: try: st = e.stat() diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/screens.py b/packages/meshbay-cli/src/meshbay_cli/tui/screens.py index 0306325..39b5df3 100644 --- a/packages/meshbay-cli/src/meshbay_cli/tui/screens.py +++ b/packages/meshbay-cli/src/meshbay_cli/tui/screens.py @@ -14,9 +14,11 @@ the node's operator) or none is, then asked once for the batch. import asyncio import contextlib +import os import threading from pathlib import Path +from meshbay_common.paths import long_path from rich.text import Text from textual.app import ComposeResult from textual.binding import Binding @@ -214,8 +216,8 @@ class MainScreen(Screen): problems.append(str(e)) for p in problems: self.app.notify(p, severity="warning", markup=False) - existing = [t for _, t in plan if t.exists() - and not t.with_name(t.name + PART_SUFFIX).exists()] + existing = [t for _, t in plan if os.path.exists(long_path(t)) + and not os.path.exists(long_path(t.with_name(t.name + PART_SUFFIX)))] overwrite = False if existing: names = ", ".join(safe(t.name) for t in existing[:5]) + ( @@ -229,7 +231,7 @@ class MainScreen(Screen): if not overwrite: plan = [(e, t) for e, t in plan if t not in existing] for d in dirs: - await asyncio.to_thread(d.mkdir, parents=True, exist_ok=True) + await asyncio.to_thread(os.makedirs, long_path(d), exist_ok=True) remote.marks.clear() remote.redraw() session = await self.pool.session(group) @@ -320,7 +322,7 @@ class MainScreen(Screen): self.strip.update_job(job, state="running") lock = threading.Lock() try: - with open(item.local, "rb") as fh: + with open(long_path(item.local), "rb") as fh: def read(offset: int, length: int) -> bytes: with lock: fh.seek(offset) diff --git a/packages/meshbay-cli/tests/console.py b/packages/meshbay-cli/tests/console.py new file mode 100644 index 0000000..d3ea6c9 --- /dev/null +++ b/packages/meshbay-cli/tests/console.py @@ -0,0 +1,48 @@ +""" +A person's Ctrl+C, delivered to a client run started by a test. + +On Windows Ctrl+C is a console event, not a signal, so the run gets a hidden +console of its own and a helper attached to that console raises the event. The +run goes through a launcher that turns Ctrl+C handling back on first: a process +inherits "ignore Ctrl+C" from its parent, and whatever started the test suite +may have set it (an IDE, a CI runner, an agent's shell) where a person's console +never does — measured, the event then reached nothing at all. +""" + +import subprocess +import sys + +_LAUNCHER = ("import ctypes, signal, subprocess, sys\n" + "ctypes.windll.kernel32.SetConsoleCtrlHandler(None, 0)\n" + "signal.signal(signal.SIGINT, signal.SIG_IGN)\n" + "sys.exit(subprocess.call(sys.argv[1:]))\n") + +# os._exit: with the console swapped, flushing stdout at exit fails (code 120). +_SENDER = ("import ctypes, os, sys\n" + "k = ctypes.windll.kernel32\n" + "k.FreeConsole()\n" + "ok = k.AttachConsole(int(sys.argv[1])) and k.SetConsoleCtrlHandler(None, 1)\n" + "os._exit(0 if ok and k.GenerateConsoleCtrlEvent(0, 0) else 1)\n") + + +def interruptible(argv: list[str], env: dict | None) -> subprocess.Popen: + """`argv` started so that `ctrl_c` reaches it, its output read as UTF-8.""" + extra: dict = {} + if sys.platform == "win32": + si = subprocess.STARTUPINFO() + si.dwFlags |= subprocess.STARTF_USESHOWWINDOW + si.wShowWindow = 0 # SW_HIDE + extra = {"creationflags": subprocess.CREATE_NEW_CONSOLE, "startupinfo": si} + argv = [sys.executable, "-c", _LAUNCHER, *argv] + return subprocess.Popen(argv, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, encoding="utf-8", **extra) + + +def ctrl_c(proc: subprocess.Popen) -> None: + if sys.platform != "win32": + import signal + proc.send_signal(signal.SIGINT) + return + subprocess.run([sys.executable, "-c", _SENDER, str(proc.pid)], check=True, timeout=30, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NO_WINDOW) diff --git a/packages/meshbay-cli/tests/test_cli_actions.py b/packages/meshbay-cli/tests/test_cli_actions.py index 006c183..ad914fd 100644 --- a/packages/meshbay-cli/tests/test_cli_actions.py +++ b/packages/meshbay-cli/tests/test_cli_actions.py @@ -83,6 +83,17 @@ def test_no_passphrase_and_no_terminal_exits_2(hub, monkeypatch, capsys): assert "MESHBAY_PASSWORD" in capsys.readouterr().err +def test_the_null_device_is_no_terminal(hub, monkeypatch, capsys): + """`< NUL` on Windows: a character device, which isatty() calls a terminal. + A script run so was asked for a passphrase it could not type.""" + from meshbay_cli.output import terminal + with open(os.devnull) as null: + assert not terminal(null) + monkeypatch.setattr("sys.stdin", null) + assert main("--hub", hub.url, "-u", USERNAME, "login") == 2 + assert "MESHBAY_PASSWORD" in capsys.readouterr().err + + async def _session(): return FakeSession() @@ -177,6 +188,58 @@ def test_put_into_a_read_only_folder_sends_nothing(node, tmp_path, capsys): assert "read-only" in capsys.readouterr().err +def _max_path_in_force() -> bool: + if sys.platform != "win32": + return False + import winreg + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Control\FileSystem") as key: + return winreg.QueryValueEx(key, "LongPathsEnabled")[0] == 0 + except OSError: + return True + + +@pytest.mark.skipif(not _max_path_in_force(), reason="Windows with MAX_PATH in force") +def test_get_and_put_past_max_path(node, tmp_path, capsys): + """Windows refuses a plain path of 260 characters or more. The node uses the + long form at each disk call, and the client has to as well: `get -R` into a + deep folder died with a traceback creating its first folder.""" + import shutil + + from meshbay_common.paths import long_path + segment = "A folder name as long as the ones a real library has" + deep = tmp_path / "deep" + while len(str(deep)) < 240: + deep = deep / segment + try: + node.add("share/2024", "a.jpg", b"A" * 3000) + assert main("get", "-R", "Family/share/2024", "-o", f"{deep}{os.sep}") == 0, \ + capsys.readouterr().err + got = deep / "2024" / "a.jpg" + assert len(str(got)) >= 260 + with open(long_path(got), "rb") as f: + assert f.read() == b"A" * 3000 + + # A part left from an interrupted run, resumed past the limit too. + node.add("share/2024", "b.bin", b"B" * 10) + with open(long_path(deep / "2024" / "b.bin.meshbay-part"), "wb") as f: + f.write(b"B") + assert main("get", "-R", "Family/share/2024", "-o", f"{deep}{os.sep}", "-n") == 0 + with open(long_path(deep / "2024" / "b.bin"), "rb") as f: + assert f.read() == b"B" * 10 + + # And a tree past the limit, sent. + src = deep / "Scans" + os.makedirs(long_path(src / segment)) + with open(long_path(src / segment / "page.pdf"), "wb") as f: + f.write(b"%PDF") + assert main("put", "-R", str(src), "Family/share") == 0, capsys.readouterr().err + assert node.stored(f"share/Scans/{segment}/page.pdf") == b"%PDF" + finally: + shutil.rmtree("\\\\?\\" + str(tmp_path / "deep"), ignore_errors=True) + + def test_trust_lists_and_forgets_pins(hub, capsys): assert main("trust", "list") == 0 # nothing stored: nothing pinned assert main("--hub", hub.url, "-u", USERNAME, "--password", PASSPHRASE, diff --git a/packages/meshbay-cli/tests/test_clipboard.py b/packages/meshbay-cli/tests/test_clipboard.py index 6296317..20e90b6 100644 --- a/packages/meshbay-cli/tests/test_clipboard.py +++ b/packages/meshbay-cli/tests/test_clipboard.py @@ -10,7 +10,7 @@ import time import pytest from meshbay_cli.tui import clipboard -pytestmark = pytest.mark.skipif(sys.platform in ("win32", "darwin"), +unix_tools = pytest.mark.skipif(sys.platform in ("win32", "darwin"), reason="the Wayland and X11 tools") # Found before a test leaves only the fake tools on PATH. CAT, SLEEP = shutil.which("cat"), shutil.which("sleep") @@ -31,12 +31,14 @@ def wayland(tmp_path, monkeypatch): return held +@unix_tools async def test_what_is_copied_is_what_is_pasted(wayland): assert await clipboard.write_text("héllo — 你好\nsecond") is True assert wayland.read_text(encoding="utf-8") == "héllo — 你好\nsecond" assert await clipboard.read_text() == "héllo — 你好\nsecond" +@unix_tools async def test_no_tool_is_none_not_an_error(tmp_path, monkeypatch): monkeypatch.setenv("PATH", str(tmp_path)) monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") @@ -45,14 +47,33 @@ async def test_no_tool_is_none_not_an_error(tmp_path, monkeypatch): assert await clipboard.write_text("x") is False +@unix_tools async def test_a_failing_tool_is_skipped(wayland): (wayland.parent / "bin" / "wl-paste").write_text("#!/bin/sh\nexit 1\n") assert await clipboard.read_text() is None +@unix_tools async def test_a_hanging_tool_is_given_up(wayland, monkeypatch): (wayland.parent / "bin" / "wl-paste").write_text(f"#!/bin/sh\nexec {SLEEP} 30\n") monkeypatch.setattr(clipboard, "TIMEOUT", 0.3) started = time.monotonic() assert await clipboard.read_text() is None assert 0.3 <= time.monotonic() - started < 5 + + +def test_the_tools_run_on_a_selector_loop(monkeypatch): + """The client's loop on Windows (`__main__.loop_factory`), which cannot start + a process through asyncio: right-click copy and paste raised there.""" + import asyncio + + def tools(read): + return [[sys.executable, "-c", "print('pasted', end='')" if read + else "import sys; sys.stdin.buffer.read()"]] + monkeypatch.setattr(clipboard, "_tools", tools) + loop = asyncio.SelectorEventLoop() + try: + assert loop.run_until_complete(clipboard.read_text()) == "pasted" + assert loop.run_until_complete(clipboard.write_text("x")) is True + finally: + loop.close() diff --git a/packages/meshbay-cli/tests/test_ctrl_c.py b/packages/meshbay-cli/tests/test_ctrl_c.py new file mode 100644 index 0000000..53a798a --- /dev/null +++ b/packages/meshbay-cli/tests/test_ctrl_c.py @@ -0,0 +1,34 @@ +""" +Ctrl+C stops the client at once, whatever its loop is waiting for. + +On Windows the client runs a selector loop (aiortc's ICE needs one), and a +console event does not interrupt select(): Ctrl+C waited for the loop's next +timer — half a minute in `chat --follow` between pings. +""" + +import sys +import time + +from console import ctrl_c, interruptible + +IDLE = ("import asyncio, sys\n" + "from meshbay_cli.__main__ import loop_factory\n" + "print('ready', flush=True)\n" + "try:\n" + " asyncio.run(asyncio.sleep(60), loop_factory=loop_factory())\n" + "except KeyboardInterrupt:\n" + " sys.exit(130)\n") + + +def test_ctrl_c_wakes_an_idle_client_at_once(): + proc = interruptible([sys.executable, "-c", IDLE], None) + try: + assert proc.stdout.readline().strip() == "ready", proc.stderr.read() + started = time.monotonic() + ctrl_c(proc) + proc.wait(timeout=30) + assert proc.returncode == 130, proc.stderr.read() + assert time.monotonic() - started < 5 + finally: + if proc.poll() is None: + proc.kill() diff --git a/packages/meshbay-cli/tests/test_e2e_local.py b/packages/meshbay-cli/tests/test_e2e_local.py index 6b7869b..b131658 100644 --- a/packages/meshbay-cli/tests/test_e2e_local.py +++ b/packages/meshbay-cli/tests/test_e2e_local.py @@ -42,6 +42,7 @@ from pathlib import Path import httpx import pytest +from console import ctrl_c, interruptible pytestmark = [ pytest.mark.skipif(os.environ.get("MESHBAY_CLI_E2E") != "1", @@ -53,6 +54,28 @@ USERNAME = "alice-e2e-cli" PASSPHRASE = "a long enough test passphrase, nobody's" GROUP_NAME = "Some Saga/Box @Set" BIN = Path(sys.executable).parent +# The environment before conftest points HOME, USERPROFILE and LOCALAPPDATA at an +# empty directory for each test: Chrome on Windows does not start in one. +REAL_ENV = dict(os.environ) +# Windows refuses a control character in a file name, so a node there cannot +# hold the name with an escape character in it. +ESCAPE_NAME = sys.platform != "win32" + + +def _program(linux: str, *windows: str) -> str | None: + """A program by its Linux name, or where Windows installs it.""" + found = shutil.which(linux) + if found or sys.platform != "win32": + return found + for base in (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)")): + candidate = os.path.join(base or "", *windows) + if base and os.path.isfile(candidate): + return candidate + return None + + +CHROME = _program("google-chrome", "Google", "Chrome", "Application", "chrome.exe") +VLC = _program("cvlc", "VideoLAN", "VLC", "vlc.exe") def _free_port() -> int: @@ -139,8 +162,13 @@ def _build(root: Path, procs: list) -> dict: (share / "2024" / "A_Very_Long_Name_Without_Any_Spaces_In_It_1992-2000.flac")\ .write_bytes(secrets.token_bytes(2_500_000)) (share / "notes.txt").write_text("hello\n") - (share / "evil\x1b[31mred.txt").write_text("escape\n") - node_env = {**os.environ, "HOME": str(node_home), + if ESCAPE_NAME: + (share / "evil\x1b[31mred.txt").write_text("escape\n") + # USERPROFILE, LOCALAPPDATA and APPDATA: where the node's directories + # are on Windows; HOME alone would leave it in the developer's own. + node_env = {**os.environ, "HOME": str(node_home), "USERPROFILE": str(node_home), + "LOCALAPPDATA": str(node_home / "AppData" / "Local"), + "APPDATA": str(node_home / "AppData" / "Roaming"), "XDG_CONFIG_HOME": str(node_home / ".config"), "XDG_DATA_HOME": str(node_home / ".local/share"), "XDG_STATE_HOME": str(node_home / ".local/state")} @@ -398,7 +426,7 @@ def cli(world: dict, *args: str, stdin: str | None = None, env.update(env_extra or {}) exe = os.environ.get("MESHBAY_CLI_E2E_BIN") or str(BIN / "meshbay-cli") return subprocess.run([exe, *args], env=env, input=stdin, - capture_output=True, text=True, timeout=180) + capture_output=True, text=True, encoding="utf-8", timeout=180) def _handle() -> str: @@ -438,7 +466,8 @@ def test_the_cli_against_a_real_hub_and_node(world): out = r.stdout assert "plage de l'ouest.jpg" in out assert "A_Very_Long_Name_Without_Any_Spaces_In_It_1992-2000.flac" in out - assert "\x1b" not in out and "evil\\x1b[31mred.txt" in out + assert "\x1b" not in out + assert not ESCAPE_NAME or "evil\\x1b[31mred.txt" in out assert "été à la mer/" in out # By name alone, case-insensitively, and as JSON. @@ -467,8 +496,9 @@ def test_the_cli_against_a_real_hub_and_node(world): assert (got / "2024" / "été à la mer" / "plage de l'ouest.jpg").read_bytes() == \ (world["share"] / "2024" / "été à la mer" / "plage de l'ouest.jpg").read_bytes() # The escape character could not be written as it is: renamed, and said so. - assert "evil\\x1b[31mred.txt is saved as evil_[31mred.txt" in r.stderr - assert (got / "evil_[31mred.txt").exists() + if ESCAPE_NAME: + assert "evil\\x1b[31mred.txt is saved as evil_[31mred.txt" in r.stderr + assert (got / "evil_[31mred.txt").exists() r = cli(world, "get", f"{_handle()}/share/notes.txt", "-o", "-") assert r.returncode == 0 and r.stdout == "hello\n" @@ -486,7 +516,8 @@ def test_the_cli_against_a_real_hub_and_node(world): # notes.txt is there already, whatever the case: nothing sent. r = cli(world, "put", str(local / "Notes.TXT"), f"{_handle()}/share") assert r.returncode == 8 and "already there" in r.stderr - assert not (world["share"] / "Notes.TXT").exists() + # By listing, not exists(): on Windows that finds notes.txt under any case. + assert "Notes.TXT" not in os.listdir(world["share"]) r = cli(world, "put", "-", f"{_handle()}/share/piped.txt", stdin="through stdin") assert r.returncode == 0, r.stderr assert (world["share"] / "piped.txt").read_text() == "through stdin" @@ -739,10 +770,10 @@ class _Chrome: ignore_cleanup_errors=True) port = _free_port() self.proc = subprocess.Popen( - ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + [CHROME, "--headless=new", "--disable-gpu", "--no-sandbox", f"--remote-debugging-port={port}", f"--user-data-dir={self.profile.name}", "--lang=en-US", "about:blank"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=REAL_ENV) deadline = time.monotonic() + 30 while True: try: @@ -830,8 +861,7 @@ def _linked_group_page(world: dict) -> "_Chrome": raise -@pytest.mark.skipif(not shutil.which("google-chrome"), - reason="google-chrome is not available") +@pytest.mark.skipif(not CHROME, reason="Chrome is not available") def test_the_application_links_itself_from_its_group_page(world): _linked_group_page(world).close() @@ -997,8 +1027,7 @@ def _cli_signed_in(world: dict, home: str) -> None: time.sleep(5) -@pytest.mark.skipif(not shutil.which("google-chrome"), - reason="google-chrome is not available") +@pytest.mark.skipif(not CHROME, reason="Chrome is not available") def test_chat_between_the_command_line_and_the_application(world): """Posted here, read on the group page in Chrome; answered there, read here — the Python seal and the page's crypto.js, on a real node.""" @@ -1162,7 +1191,7 @@ async def test_put_and_get_in_the_interactive_chat_against_the_real_node( assert json.loads(r.stdout)[0]["attachment"] == { "filename": "from the room.bin", "size": len(sent), "type": "file"} - if shutil.which("google-chrome"): + if CHROME: await asyncio.to_thread(_the_page_shows_the_file, world, "from the room.bin") @@ -1183,13 +1212,11 @@ def test_chat_follow_against_the_real_node(world): """`chat --follow` in its own process: messages posted from another run arrive as the node pushes them, across more than one liveness ping, and Ctrl+C ends it. Runs on the machine the chat tests above linked.""" - import signal group = _handle() env = {**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-chat"), "PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring"} exe = os.environ.get("MESHBAY_CLI_E2E_BIN") or str(BIN / "meshbay-cli") - follower = subprocess.Popen([exe, "--json", "chat", "-1", group, "--follow"], env=env, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + follower = interruptible([exe, "--json", "chat", "-1", group, "--follow"], env) try: first = json.loads(follower.stdout.readline()) # the last one, at once assert first["text"] or first.get("attachment") # /put's, above @@ -1201,20 +1228,19 @@ def test_chat_follow_against_the_real_node(world): assert got["text"] == text and got["sender"] == USERNAME, got assert got["trust"] in ("pinned", "first", "linked") finally: - follower.send_signal(signal.SIGINT) + ctrl_c(follower) _, err = follower.communicate(timeout=20) assert follower.returncode == 130 assert "reconnecting" not in err, err @pytest.mark.skipif(not (shutil.which("ffmpeg") and shutil.which("ffprobe") - and shutil.which("cvlc")), + and VLC), reason="ffmpeg, ffprobe and VLC are needed") def test_stream_a_film_to_real_players(world, tmp_path): """A film put on the real node, then streamed: ffprobe reads it, ffmpeg seeks near its end, a foreign Host is refused, Ctrl+C stops it; then VLC started with --player plays it to the end and, closing, ends the stream.""" - import signal _cli_signed_in(world, "cli-stream") film = tmp_path / "Night Drive.mkv" subprocess.run(["ffmpeg", "-v", "error", "-f", "lavfi", "-i", @@ -1241,8 +1267,7 @@ def test_stream_a_film_to_real_players(world, tmp_path): env = {**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-stream"), "PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring"} exe = os.environ.get("MESHBAY_CLI_E2E_BIN") or str(BIN / "meshbay-cli") - streamer = subprocess.Popen([exe, "--json", "stream", path], env=env, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + streamer = interruptible([exe, "--json", "stream", path], env) try: line = streamer.stdout.readline() assert line, streamer.stderr.read() @@ -1266,20 +1291,24 @@ def test_stream_a_film_to_real_players(world, tmp_path): trust_env=False) assert foreign.status_code == 403 finally: - streamer.send_signal(signal.SIGINT) + ctrl_c(streamer) _, err = streamer.communicate(timeout=20) assert streamer.returncode == 130, err with pytest.raises(httpx.HTTPError): httpx.get(url, timeout=5, trust_env=False) - player = tmp_path / "vlc-to-the-end" log = tmp_path / "vlc.log" - player.write_text("#!/bin/sh\nexec cvlc --play-and-exit --intf dummy --vout dummy " - f"--aout dummy \"$1\" >{log} 2>&1\n") - player.chmod(0o755) + flags = "--play-and-exit --intf dummy --vout dummy --aout dummy" + if sys.platform == "win32": + player = tmp_path / "vlc-to-the-end.cmd" + player.write_text(f'@"{VLC}" {flags} "%~1" >"{log}" 2>&1\r\n') + else: + player = tmp_path / "vlc-to-the-end" + player.write_text(f"#!/bin/sh\nexec {VLC} {flags} \"$1\" >{log} 2>&1\n") + player.chmod(0o755) started = time.monotonic() r = subprocess.run([exe, "stream", "--player", str(player), path], env=env, - capture_output=True, text=True, timeout=120) + capture_output=True, text=True, encoding="utf-8", timeout=120) assert r.returncode == 0, r.stderr + log.read_text() assert "The player was closed" in r.stderr assert time.monotonic() - started > 10, "VLC played the film, in real time" @@ -1354,23 +1383,30 @@ def test_tab_completes_remote_paths_from_the_real_node(world): "COMP_CWORD=2\n_meshbay_cli\nprintf '%s\\n' \"${COMPREPLY[@]}\"\n"], env={**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-chat"), "PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring", - "PATH": f"{BIN}:{os.environ['PATH']}"}, - capture_output=True, text=True, timeout=60) + "PATH": f"{BIN}{os.pathsep}{os.environ['PATH']}"}, + capture_output=True, text=True, encoding="utf-8", timeout=60) assert bash.stdout.splitlines() == [group + "/"], (bash.stdout, bash.stderr) - pwsh = shutil.which("pwsh") + # PowerShell 7, or Windows PowerShell 5.1, which decodes a program's output + # with the console's code page unless told otherwise: an accented name is the + # test. The matches come back as code points, past PowerShell's own output. + pwsh = shutil.which("pwsh") or (sys.platform == "win32" and shutil.which("powershell")) if pwsh: ps = cli(world, "completion", "powershell").stdout - line = f"meshbay-cli ls '{group}/sh" - r = subprocess.run( - [pwsh, "-NoProfile", "-Command", - ps + f"\n$r = TabExpansion2 -inputScript \"{line}\" -cursorColumn {len(line)}" - "\n$r.CompletionMatches | ForEach-Object { $_.CompletionText }"], - env={**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-chat"), - "PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring", - "PATH": f"{BIN}:{os.environ['PATH']}"}, - capture_output=True, text=True, timeout=120) - assert r.stdout.splitlines() == [f"'{group}/share/'"], (r.stdout, r.stderr) + for line, want in ((f"meshbay-cli ls '{group}/sh", f"'{group}/share/'"), + (f"meshbay-cli ls '{group}/share/2024/é", + f"'{group}/share/2024/été à la mer/'")): + r = subprocess.run( + [pwsh, "-NoProfile", "-Command", + ps + f"\n$r = TabExpansion2 -inputScript \"{line}\" -cursorColumn {len(line)}" + "\n$r.CompletionMatches | ForEach-Object {" + " ($_.CompletionText.ToCharArray() | ForEach-Object { [int]$_ }) -join ',' }"], + env={**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-chat"), + "PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring", + "PATH": f"{BIN}{os.pathsep}{os.environ['PATH']}"}, + capture_output=True, text=True, timeout=120) + got = ["".join(map(chr, map(int, m.split(",")))) for m in r.stdout.split()] + assert got == [want], (got, r.stderr) def _cli(world: dict, *args: str, new: str | None = None, **kw): diff --git a/packages/meshbay-cli/tests/test_listing.py b/packages/meshbay-cli/tests/test_listing.py index 407074e..f3e6a85 100644 --- a/packages/meshbay-cli/tests/test_listing.py +++ b/packages/meshbay-cli/tests/test_listing.py @@ -71,3 +71,21 @@ def test_control_characters_and_bidi_overrides_are_escaped(): def test_sizes(): assert human_size(6) == "6 B" assert human_size(2_500_000) == "2.4 MB" + + +def test_an_activity_time_without_a_zone_is_utc(monkeypatch): + """A hub on SQLite sends `last_activity_at` without its zone; it is UTC, as + PostgreSQL's says it is, and is shown in local time either way.""" + import time + + from meshbay_cli.commands.groups import local_time + if not hasattr(time, "tzset"): + pytest.skip("needs time.tzset to set the local zone") + monkeypatch.setenv("TZ", "UTC-02:00") + time.tzset() + try: + assert local_time("2026-10-15T13:59:00") == "2026-10-15 15:59" + assert local_time("2026-10-15T13:59:00+00:00") == "2026-10-15 15:59" + finally: + monkeypatch.undo() + time.tzset() -- cgit v1.2.3