diff --git a/README.md b/README.md index 5cd05ef..c661054 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,8 @@ the fewest ways to go wrong: brew install whisper-cpp ffmpeg uv uv tool install --python 3.13 git+https://github.com/ZyxWorks/murmurflow murmurflow setup # downloads both speech models (~2.1 GB, once) -murmurflow install # dictation is live now, and after every login +murmurflow install # set it up (starts nothing) +murmurflow on # dictation is live now, and after every login ``` On Windows the only difference is whisper: there is no package for it, so the installer downloads @@ -76,6 +77,7 @@ winget install Gyan.FFmpeg astral-sh.uv uv tool install --python 3.13 git+https://github.com/ZyxWorks/murmurflow murmurflow setup murmurflow install +murmurflow on ``` If `murmurflow` is not found afterwards, `~/.local/bin` is not on your `PATH` — `uv tool @@ -298,7 +300,7 @@ macOS will ask for two the first time, and neither can be granted from a script: 2. **Accessibility** — to type into the app you're using. *System Settings → Privacy & Security → Accessibility* -**Both rows are called `MurmurFlow`.** `murmurflow install` builds a small app bundle at +**Both rows are called `MurmurFlow`.** `murmurflow on` builds a small app bundle at `~/Applications/MurmurFlow.app` purely so that is true. Without it macOS names the row after the *interpreter* — `python3.13` — which nobody scrolling for "murmurflow" finds, and switching that on would hand the microphone and your keyboard to every other Python tool sharing it. @@ -485,7 +487,10 @@ A broken polish command degrades to the plain transcript. It never costs you the ## Commands ``` -murmurflow install install (or UPDATE) dictation, and keep it live after every login +murmurflow install set it up on this Mac; starts nothing (a listener already on updates) +murmurflow on dictation on, now and after every login +murmurflow off dictation off, now and after every restart, until `on` +murmurflow update the newest code; restarts dictation only if it is on murmurflow listen run the daemon in this terminal instead (blocks) murmurflow doctor what is missing, and the one command that fixes each thing murmurflow keytest does this Mac actually see your trigger key? @@ -497,9 +502,13 @@ murmurflow transcribe transcribe an audio file and print the text murmurflow pause lend the trigger key to another program for a while murmurflow resume take it back murmurflow trigger print the trigger key this install is on, for another program to read -murmurflow uninstall stop dictation and remove it from login +murmurflow uninstall `off`, and remove the MurmurFlow.app too ``` +`install`, `on`, `off`, `update` and `doctor` mean the same here as in `zyx` and in agent-office's +`office`: install sets up and starts nothing, on and off both last across a restart, update never +switches on something that is off. + Every clip the daemon handles is logged — how long you held the key, how much audio actually landed, the peak level, the transcribe time, how many characters came back and the app the paste went to. **Not what you said.** That file is `~/.murmurflow/listen.log`, `murmurflow doctor` prints @@ -511,14 +520,15 @@ you leave it on, and deletes the clip when you turn it off. this machine cannot poll, a model path that is not there, a language you did not say you speak. All of those used to be accepted and then fail silently, which is the same symptom as broken hardware. -**`install` is also `update`.** The listener does not run your checkout — `uv tool install` made +**`update` is the update.** The listener does not run your checkout — `uv tool install` made a copy of the package and launchd runs that — so a `git pull` alone changes a directory the running -program never reads, silently. `murmurflow install` therefore re-installs the package from wherever +program never reads, silently. `murmurflow update` therefore re-installs the package from wherever it came from first (a local checkout, a git URL, PyPI), then re-executes itself out of the new copy -and registers the agent. Two commands, and only because the first one is git: +and restarts the listener if it is on. `install` and `on` do the same re-install first, so the old +habit still works. From a checkout, two commands, and only because the first one is git: ```sh -git pull && murmurflow install +git pull && murmurflow update ``` An update that cannot run never blocks the install: no `uv`, a source that has moved, a network diff --git a/install.sh b/install.sh index 34baf39..04e7102 100644 --- a/install.sh +++ b/install.sh @@ -67,6 +67,7 @@ say "Downloading the speech models (~2.1 GB, once)" say "Turning dictation on" "$MF" install +"$MF" on say "Done" echo "Open a NEW terminal for the 'murmurflow' command; dictation itself is already live." diff --git a/murmurflow/cli.py b/murmurflow/cli.py index f302263..ad1a5bb 100644 --- a/murmurflow/cli.py +++ b/murmurflow/cli.py @@ -1,8 +1,9 @@ """``murmurflow`` — the command line. Ten verbs, and most people only ever type two. -``setup`` then ``install`` is the whole happy path. Everything else here exists because dictation -fails in exactly four ways — the key is not seen, the microphone is not heard, the model is not -found, the text is not typed — and each of those has its own verb that answers it in one run. +``setup``, ``install``, ``on`` is the whole happy path; ``off`` and ``update`` mean what they mean +in zyx and agent-office. Everything else here exists because dictation fails in exactly four ways +— the key is not seen, the microphone is not heard, the model is not found, the text is not typed — +and each of those has its own verb that answers it in one run. """ from __future__ import annotations @@ -149,31 +150,32 @@ def _update_command(receipt: Path) -> list[str] | None: return None -def _update() -> None: +def _update() -> bool: """Re-install this package from its source, then re-exec into the new copy. Usually a no-op. Never blocks the install: a machine with no ``uv``, a source that has gone away, a network that is down — all of them print a line and carry on with the copy that is already here. An update that could not run is an inconvenience; an ``install`` that refuses to run is a dead tool. + False only when an update was tried and failed, so the ``update`` verb can say so. """ receipt = None if os.environ.get(_RESYNCED) else _receipt() if receipt is None: - return + return True command = _update_command(receipt) if command is None: - return + return True _out("updating the installed copy from its source...") try: done = subprocess.run(command, capture_output=True, text=True, timeout=300, check=False) except (OSError, subprocess.SubprocessError) as error: _out(f"[!] could not update it ({error}) — installing the copy already here") - return + return False if done.returncode != 0: detail = (done.stderr or "").strip().splitlines() _out( f"[!] could not update it ({detail[-1] if detail else 'unknown error'}) — installing the copy already here" ) - return + return False _out("[OK] code updated") os.environ[_RESYNCED] = "1" try: @@ -186,23 +188,34 @@ def _update() -> None: # lazy import opens a path that is gone. Half an install is worse than none, and re-running # the command is now free — the update is already done, and the guard above skips it. _out(f"[!] updated, but could not restart into the new copy ({error}).") - _out(" Run `murmurflow install` once more — the update itself is done.") + # The SAME verb, not `install`: `install` never starts a stopped listener, so after a + # failed `murmurflow on` it would leave dictation off while the hint promised otherwise. + again = " ".join(["murmurflow", *sys.argv[1:]]) + _out(f" Run `{again}` once more — the update itself is done.") raise SystemExit(1) from error -def _install() -> int: - """Update the installed copy, warm the microphone, then install the launchd agent. - - This is the ONE command: `git pull && murmurflow install` and the machine is running what the - repo says. See :func:`_update` for why the update belongs here rather than in a verb of its own. - """ - _update() # may re-exec; anything after this line runs in the NEW copy +def _ready() -> bool: + """Can a listener run on this machine at all. Says why not.""" ready, hint = dictate.available() if not ready: _out(hint) - return 2 + return False if not service.supported(): _out(f"murmurflow has no always-on listener for {sys.platform} yet — see the README") + return False + return True + + +def _install() -> int: + """Update the installed copy and warm the microphone. It never switches dictation ON. + + `install` means the same in every tool here (zyx, agent-office): put it on the machine, start + nothing — `on` starts it. A listener that is ALREADY on comes back on the new code, so + `git pull && murmurflow install` is still a whole update; a first install ends by naming `on`. + """ + _update() # may re-exec; anything after this line runs in the NEW copy + if not _ready(): return 2 # The FIRST ever CoreAudio access on a Mac takes ~10 seconds. Paying it here, explicitly, means @@ -219,8 +232,67 @@ def _install() -> int: else: _out("[!] could not open the microphone — grant Microphone access and re-run") + if service.running(): + return _start() # installing IS updating: a live listener comes back on the new code + _out("[OK] installed. Dictation is off until you switch it on:") + _out(" murmurflow on") + return 0 + + +def _on() -> int: + """Dictation on, now and after every login, on the newest code — the same `on` as `zyx on`.""" + _update() # may re-exec; anything after this line runs in the NEW copy + if not _ready(): + return 2 + return _start() + + +def _off() -> int: + """Dictation off, now and after every restart, until `on`. Deletes nothing you would miss.""" + ok, detail = service.uninstall() + # The warm whisper-server is detached on purpose and would otherwise sit on ~1.8 GB until the + # next reboot, long after the thing that talked to it was stopped. + freed = dictate.stop_server() + _out( + "[OK] dictation is off, also after a restart. `murmurflow on` brings it back." + if ok + else f"[!] {detail}" + ) + if freed: + _out("[OK] stopped the warm whisper-server") + return 0 if ok else 1 + + +def _update_verb() -> int: + """The newest code. A listener that is on restarts on it; one that is off stays off.""" + if not os.environ.get(_RESYNCED) and _receipt() is None: + _out( + "[!] this copy was not installed with `uv tool`, so there is nothing to update it from." + ) + _out(" From a checkout: `git pull`, then `murmurflow on`.") + return 1 + if not _update(): # may re-exec; anything after this line runs in the NEW copy + return 1 + if not service.running(): + _out("[OK] up to date. Dictation is off, so it stays off — `murmurflow on` starts it.") + return 0 + ok, detail = service.install() # rewrite the agent too, the way `install` always has + _out( + "[OK] up to date, and dictation restarted on it." + if ok + else f"[!] updated, but could not restart it: {detail}" + ) + return 0 if ok else 1 + + +def _start() -> int: + """Register the login agent and start it now, then name what the user still has to grant.""" ok, detail = service.install() - _out(f"[OK] installed {service.LABEL}" if ok else f"[!] could not install it: {detail}") + _out( + "[OK] dictation is on, now and after every login." + if ok + else f"[!] could not switch it on: {detail}" + ) _out("") # Installing is the exact moment a second daemon joins the key, so it is the moment to say so. # Silence here costs the user a session of "it worked yesterday" before anyone runs the health @@ -260,20 +332,14 @@ def _install() -> int: def _uninstall() -> int: - ok, detail = service.uninstall() - # The warm whisper-server is detached on purpose and would otherwise sit on ~1.8 GB until the - # next reboot, long after the thing that talked to it was removed. - freed = dictate.stop_server() + """`off`, and the .app with it.""" + code = _off() # And the .app, or `uninstall` leaves an application in ~/Applications forever. Recreating it # later at the same path from the same interpreter reproduces the cdhash, so the Privacy grant # is not spent by removing it. - removed = service.remove_identity() - _out("[OK] dictation stopped and removed from login." if ok else f"[!] {detail}") - if freed: - _out("[OK] stopped the warm whisper-server") - if removed: + if service.remove_identity(): _out("[OK] removed the MurmurFlow.app bundle") - return 0 if ok else 1 + return code # --- diagnosis -------------------------------------------------------------------------------- @@ -462,7 +528,7 @@ def _doctor(*, verbs: bool = False) -> int: installed, f"login agent: {'installed' if installed else 'not installed'}" + (f", {'running' if service.running() else 'not loaded'}" if installed else ""), - "murmurflow install", + "murmurflow on", ) ) for ok, line, fix in rows: @@ -497,7 +563,8 @@ def _doctor(*, verbs: bool = False) -> int: ("keytest", "does this Mac see your key, and does it read your gesture the way you think"), ("devices", "list microphones (then: config set inputName )"), ("pause / resume", "lend the trigger key to another program, and take it back"), - ("install / uninstall", "turn dictation on or off for every login"), + ("on / off", "dictation on or off — both last across a restart"), + ("update", "the newest code; restarts dictation only if it is on"), ("--help", "everything else"), ) @@ -844,8 +911,11 @@ def main(argv: list[str] | None = None) -> int: sub = parser.add_subparsers(dest="command") sub.add_parser("listen", help="run the press-to-talk daemon in this terminal (blocks)") - sub.add_parser("install", help="install the login agent so dictation is always live") - sub.add_parser("uninstall", help="stop dictation and remove it from login") + sub.add_parser("install", help="set it up on this Mac (starts nothing; a running one updates)") + sub.add_parser("on", help="dictation on, now and after every login") + sub.add_parser("off", help="dictation off, now and after every restart") + sub.add_parser("update", help="the newest code; restarts dictation only if it is on") + sub.add_parser("uninstall", help="off, and remove the MurmurFlow.app too") sub.add_parser("doctor", help="what is missing, and the one command that fixes each thing") sub.add_parser("devices", help="list microphones") sub.add_parser("toggle", help="start/stop one recording (for a Shortcuts binding)") @@ -889,6 +959,12 @@ def main(argv: list[str] | None = None) -> int: return _listen(trigger=getattr(args, "trigger", "")) if command == "install": return _install() + if command == "on": + return _on() + if command == "off": + return _off() + if command == "update": + return _update_verb() if command == "uninstall": return _uninstall() if command == "setup": diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py new file mode 100644 index 0000000..07f07cb --- /dev/null +++ b/tests/test_lifecycle.py @@ -0,0 +1,84 @@ +"""install / on / off / update mean the same here as in zyx and agent-office. + +install sets up and starts nothing; on and off both last across a restart; update never switches +on a listener that is off. "start" below is the login agent being (re)registered and loaded. +""" + +from pathlib import Path + +from murmurflow import cli + + +def _machine(monkeypatch, *, running, updated=True): + calls = [] + monkeypatch.delenv(cli._RESYNCED, raising=False) + monkeypatch.setattr(cli, "_receipt", lambda: Path("uv-receipt.toml")) + monkeypatch.setattr(cli, "_update", lambda: calls.append("update") or updated) + monkeypatch.setattr(cli, "_ready", lambda: True) + monkeypatch.setattr(cli, "_tcc_entry", lambda: "MurmurFlow") + monkeypatch.setattr(cli.dictate, "start", lambda: None) # no microphone in a test + monkeypatch.setattr(cli.dictate, "stop_server", lambda: False) + monkeypatch.setattr(cli.dictate, "rival_listeners", lambda: []) + monkeypatch.setattr(cli.dictate, "trigger_hint", lambda: "Double-tap Control") + monkeypatch.setattr(cli.service, "is_macos", lambda: False) # never open System Settings + monkeypatch.setattr(cli.service, "running", lambda: running) + monkeypatch.setattr(cli.service, "install", lambda: calls.append("start") or (True, "")) + monkeypatch.setattr(cli.service, "uninstall", lambda: calls.append("stop") or (True, "")) + monkeypatch.setattr(cli.service, "remove_identity", lambda: calls.append("remove-app") or True) + return calls + + +def test_install_never_switches_dictation_on(monkeypatch): + calls = _machine(monkeypatch, running=False) + assert cli.main(["install"]) == 0 + assert "start" not in calls + + +def test_install_over_a_running_listener_brings_it_back_on_the_new_code(monkeypatch): + """`git pull && murmurflow install` was the whole update, and must stay one.""" + calls = _machine(monkeypatch, running=True) + assert cli.main(["install"]) == 0 + assert calls == ["update", "start"] + + +def test_on_updates_then_starts(monkeypatch): + calls = _machine(monkeypatch, running=False) + assert cli.main(["on"]) == 0 + assert calls == ["update", "start"] + + +def test_off_stops_it_and_keeps_the_app(monkeypatch): + calls = _machine(monkeypatch, running=True) + assert cli.main(["off"]) == 0 + assert calls == ["stop"] + + +def test_uninstall_is_off_plus_the_app(monkeypatch): + calls = _machine(monkeypatch, running=True) + assert cli.main(["uninstall"]) == 0 + assert calls == ["stop", "remove-app"] + + +def test_update_leaves_an_off_listener_off(monkeypatch): + calls = _machine(monkeypatch, running=False) + assert cli.main(["update"]) == 0 + assert calls == ["update"] + + +def test_update_restarts_a_listener_that_is_on(monkeypatch): + calls = _machine(monkeypatch, running=True) + assert cli.main(["update"]) == 0 + assert calls == ["update", "start"] + + +def test_an_update_that_failed_says_so_and_restarts_nothing(monkeypatch): + calls = _machine(monkeypatch, running=True, updated=False) + assert cli.main(["update"]) == 1 + assert calls == ["update"] + + +def test_update_from_a_copy_uv_did_not_install_refuses_rather_than_pretends(monkeypatch): + calls = _machine(monkeypatch, running=True) + monkeypatch.setattr(cli, "_receipt", lambda: None) + assert cli.main(["update"]) == 1 + assert calls == [] diff --git a/tests/test_murmurflow.py b/tests/test_murmurflow.py index be781e6..cbca456 100644 --- a/tests/test_murmurflow.py +++ b/tests/test_murmurflow.py @@ -1239,9 +1239,7 @@ def _popen(cmd, **kwargs): raise OSError("not really spawning anything in a test") monkeypatch.setattr(speech, "server_up", lambda _port=0: False) - monkeypatch.setattr( - speech, "serve_command", lambda _setup: ["whisper-server", "--convert"] - ) + monkeypatch.setattr(speech, "serve_command", lambda _setup: ["whisper-server", "--convert"]) monkeypatch.setattr(speech.subprocess, "Popen", _popen) assert dictate.start_server() is False cwd = Path(str(seen["kwargs"]["cwd"]))