diff --git a/README.windows.md b/README.windows.md index 2a9f0c3..8b4a5e6 100644 --- a/README.windows.md +++ b/README.windows.md @@ -62,8 +62,9 @@ python dikte.py ## Troubleshooting -- **Recording does not start:** does `ffmpeg -version` run? Does - `dikte devices` list your microphone? +- **Recording does not start:** does `dikte doctor` find ffmpeg, and does + `dikte devices` list your microphone? `devices` also takes a fresh listing, + which is what to run after plugging one in. - **Nothing is pasted:** a normal-privilege process cannot type into an elevated (administrator) window; run Dikte elevated too, or paste by hand. The text lands on the clipboard either way. diff --git a/cli.py b/cli.py index e6b4450..6a76cc9 100644 --- a/cli.py +++ b/cli.py @@ -655,7 +655,10 @@ def cmd_devices(opts): "default": name == default} for name, desc in audio.list_monitors()] if not mics and not monitors: - return fail(opts, "pactl found nothing; is PipeWire running?") + # Which program was asked, and so which one to go and look at, is not + # the same on all four systems: naming pactl on Windows sends somebody + # after a program that was never going to be there. + return fail(opts, audio.sound().missing) lines = ["Microphones:"] lines += [f" {'*' if item['chosen'] else ' '} {item['name']}\n" @@ -791,9 +794,20 @@ def cmd_status(opts): def cmd_doctor(opts): """What the settings window checks behind its buttons, in one pass.""" conf = cfg.Config() - wanted = ["pw-record", "wl-copy", "ydotool", "ffmpeg", "pactl", "kwriteconfig6", - assistant.executable(assistant.provider(conf)) or "claude", - cleanup.executable(cleanup.provider(conf))] + # The two the clipboard and the key press go through come out of the table + # rather than being spelled here, because they are not the same pair on all + # four systems: X11 pastes with xclip where Wayland pastes with wl-copy, a + # Mac shells out for one half and Windows for neither. A row saying ydotool + # is missing on a machine that would never have run it is not a diagnosis, + # it is a red mark to explain away. + here = paste.desktop() + wanted = [here.clipboard, here.keyboard] + if sys.platform.startswith("linux"): + # Recording, the device list, and KDE's shortcut registry. + wanted += ["pw-record", "pactl", "kwriteconfig6"] + wanted += ["ffmpeg", + assistant.executable(assistant.provider(conf)) or "claude", + cleanup.executable(cleanup.provider(conf))] programs = {name: shutil.which(name) or "" for name in wanted if name} target = conf.transcribe_target() cleaner = cleanup.provider(conf) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5fe4c36..1948916 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,12 +12,14 @@ import json import unittest from unittest import mock +import audio import cli import config as cfg import ggml import hotkey import ipc -from tests.support import DikteTest, fake_urlopen +import paste +from tests.support import DikteTest, fake_urlopen, only_these_tools class Options: @@ -435,6 +437,30 @@ class Doctor(DikteTest): self.assertIn("OpenRouter key, cleaning up on some/model", self.run_doctor(as_json=False, cleanup_model="some/model")) + def test_it_asks_after_the_programs_this_desktop_actually_uses(self): + """A missing ydotool on a Mac is a red mark with nothing behind it.""" + with mock.patch.object(cli.paste, "desktop", return_value=paste.MACOS): + mac = self.run_doctor()["programs"] + with mock.patch.object(cli.paste, "desktop", return_value=paste.WAYLAND): + wayland = self.run_doctor()["programs"] + self.assertIn("pbcopy", mac) + self.assertNotIn("ydotool", mac) + self.assertIn("ydotool", wayland) + self.assertIn("ffmpeg", mac) # the one every system records through + + def test_a_system_that_shells_out_for_neither_half_is_asked_for_neither(self): + # shutil.which is faked as well as the platform: the real one reads + # sys.platform too, and reaches for a Windows API this machine has not + # got the moment it is told it is on Windows. + with mock.patch.object(cli.paste, "desktop", return_value=paste.WINDOWS), \ + only_these_tools("ffmpeg"), \ + mock.patch.object(cli.sys, "platform", "win32"): + programs = self.run_doctor()["programs"] + self.assertNotIn("", programs) + self.assertEqual([name for name in ("wl-copy", "ydotool", "pactl", + "pw-record", "kwriteconfig6") + if name in programs], []) + def test_cleanup_on_a_cli_is_a_question_about_the_program(self): reply = self.run_doctor(cleanup_provider="codex", cleanup_codex_model="gpt-5.4") @@ -446,6 +472,25 @@ class Doctor(DikteTest): cleanup_codex_model="gpt-5.4")) +class Devices(DikteTest): + def test_a_machine_with_nothing_names_its_own_missing_program(self): + """The Windows README sends people here, and pactl is not on it.""" + for here, expected in ((audio.DSHOW, "ffmpeg"), + (audio.PULSE, "pulseaudio-utils")): + with self.subTest(sound=expected): + with mock.patch.object(cli.audio, "sound", return_value=here), \ + mock.patch.object(cli.audio, "list_sources", + return_value=[]), \ + mock.patch.object(cli.audio, "list_monitors", + return_value=[]), \ + mock.patch.object(cli.audio, "default_monitor", + return_value=""), \ + captured() as (out, _err): + code = cli.cmd_devices(Options(json=True)) + self.assertEqual(code, 1) + self.assertIn(expected, json.loads(out.getvalue())["error"]) + + class Finding(DikteTest): def test_no_history_at_all(self): self.assertIsNone(cli._find_history("last"))