diff --git a/dikte/app.py b/dikte/app.py index 842ee51..7df7cf1 100644 --- a/dikte/app.py +++ b/dikte/app.py @@ -1122,6 +1122,15 @@ def _stay_out_of_the_dock(): def run_app(args): command = args[0] if args else "" + # One Dikte per user, checked by asking rather than by listening: see + # ipc.already_serving. Before the QApplication, so a second copy costs a + # moment and not a second tray icon. + if ipc.already_serving(): + print("dikte: already running; its Settings window has the attention") + if command: + ipc.send(command) + return 0 + app = QApplication(sys.argv) app.setApplicationName("Dikte") app.setDesktopFileName("dikte") diff --git a/dikte/integrate.py b/dikte/integrate.py index a55fcf3..10639d0 100644 --- a/dikte/integrate.py +++ b/dikte/integrate.py @@ -56,6 +56,17 @@ def packaged(): return bool(getattr(sys, "frozen", False)) +def windowed_executable(executable=None): + """The windowed executable installed beside this one, or None. + + Beside rather than at a known place, because the setup program lays the two + executables into the same directory wherever that directory was put: asking + from either of them finds the other without knowing where the install is. + """ + windowed = pathlib.Path(executable or sys.executable).with_name(WINDOWS_APP) + return windowed if windowed.is_file() else None + + def target(): """The file a launcher has to name to start this build again. @@ -74,8 +85,8 @@ def target(): # The windowed executable, whichever of the two is running: the console # one is what the `dikte` command names, and a sign-in that started # that one would open a console window nobody asked for. - windowed = executable.with_name(WINDOWS_APP) - if windowed.is_file(): + windowed = windowed_executable(executable) + if windowed is not None: return windowed return executable @@ -572,24 +583,60 @@ def _run_entry_name(): return f"HKCU\\{RUN_KEY}\\{RUN_VALUE}" +def _run_target(value): + """The executable a Run value names, out of the quoting the setup wrote. + + Only the first word matters here: it is the file whose existence says + whether the entry still starts anything. + """ + if value.startswith('"'): + closing = value.find('"', 1) + return value[1:closing] if closing > 0 else "" + return value.split(" ", 1)[0] + + +def _startup_shortcut(): + """Where install.ps1 -Autostart puts a checkout's sign-in entry.""" + appdata = os.environ.get("APPDATA") + if not appdata: + return None + return (pathlib.Path(appdata) / "Microsoft" / "Windows" / "Start Menu" + / "Programs" / "Startup" / "Dikte.lnk") + + def _windows_install(app, force=False): """Point the autostart entry at this build. What changed. Only `force`, which is what typing `dikte integrate` means, creates one. The call on every start repairs an entry that is already there and names an - executable somewhere else, which is what an installation moved to another - drive or reinstalled into another directory leaves behind; somebody who - unticked the box in the wizard, or turned it off since, is not asked again - by every start. + executable that is gone, which is what an installation moved to another + drive or reinstalled into another directory leaves behind. An entry naming + an executable that still exists is another installation that still works, + and is stood aside for the way the Linux half stands aside for another + menu entry; somebody who unticked the box in the wizard, or turned it off + since, is not asked again by every start either. """ command = f'"{app}"' current = _run_entry() + changed = [] + if force: + # install.ps1 -Autostart wrote this for a checkout. The Run value + # written below replaces it, and both left in place would be two + # Diktes at every sign-in. Only on force: the silent call on every + # start has not been asked to move the machine off its checkout. + shortcut = _startup_shortcut() + if shortcut is not None and shortcut.is_file(): + shortcut.unlink() + changed.append(shortcut) if not current and not force: - return [] - if current == command: - return [] - _write_run_entry(command) - return [_run_entry_name()] + return changed + if current != command: + theirs = _run_target(current) if current else "" + if not force and theirs and theirs != str(app) and os.path.exists(theirs): + return changed + _write_run_entry(command) + changed.append(_run_entry_name()) + return changed def _windows_remove(): diff --git a/dikte/ipc.py b/dikte/ipc.py index 02f668a..589045e 100644 --- a/dikte/ipc.py +++ b/dikte/ipc.py @@ -11,11 +11,14 @@ shortcut may still send. import json import os import shlex +import subprocess import sys +from PyQt6.QtCore import QLockFile from PyQt6.QtNetwork import QLocalSocket from . import integrate +from . import paths SERVER_NAME = "dikte-" + ( str(os.getuid()) if hasattr(os, "getuid") @@ -54,10 +57,9 @@ def launcher(): if not getattr(sys, "frozen", False): return [sys.executable, script_path()] if sys.platform == "win32": - windowed = os.path.join(os.path.dirname(sys.executable), - integrate.WINDOWS_APP) - if os.path.isfile(windowed): - return [windowed] + windowed = integrate.windowed_executable() + if windowed is not None: + return [str(windowed)] return [os.environ.get("APPIMAGE") or sys.executable] @@ -72,6 +74,60 @@ def command_for(verb): return shlex.join(launcher() + ([verb] if verb else [])) +def already_serving(): + """Whether a running instance answers on this user's name. + + Asked before an instance opens a server of its own, because listen() is + not the check: a Windows named pipe takes a second server on the same name + rather than refusing it, and everywhere else removeServer() would first + take the live socket away from the instance holding it. Either way two + whole Diktes then run, and the newer one's sweep() kills the whisper the + older one is answering dictations with. The probe is "status" and nothing + else: a verb with a side effect here would fire it during the relaunch a + slow-to-answer instance provokes, on top of the verb being forwarded. + """ + return send("status") is not None + + +def instance_lock(): + """This user's one-Dikte lock, taken before anything else is built. + + The probe above has a hole: two copies started in the same moment both ask + before either listens, and both come up. A lock file closes it, and + QLockFile writes the holder's pid into it, so a lock a killed instance + left behind identifies itself as stale and clears. None when the data + directory cannot be made, which a start should survive: the probe still + stands guard, just without the simultaneous-start case. + """ + try: + paths.DATA_DIR.mkdir(parents=True, exist_ok=True) + except OSError: + return None + lock = QLockFile(str(paths.DATA_DIR / "dikte.lock")) + # Never presume a lock is stale by age alone; the pid check is the truth. + lock.setStaleLockTime(0) + return lock + + +def respawn(arguments): + """Start this installation again with `arguments`, leaving this process. + + execv everywhere it works the way it says: the new process takes this + pid and nothing is left behind. On Windows execv mangles arguments with + spaces and leaves the two processes sharing a console, so the replacement + is started detached instead and the caller exits on its own. + """ + args = launcher() + list(arguments) + if sys.platform == "win32": + # By value where the names are missing, so the Windows half of this is + # testable from the suite's other platforms too. + detached = (getattr(subprocess, "DETACHED_PROCESS", 0x00000008) + | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)) + subprocess.Popen(args, creationflags=detached, close_fds=True) + return + os.execv(args[0], args) + + def send(cmd, wait=False, timeout=0, **args): """Send one request; the reply, or None when no instance is running. diff --git a/install.ps1 b/install.ps1 index c847131..67cf071 100644 --- a/install.ps1 +++ b/install.ps1 @@ -23,9 +23,20 @@ $autostartLink = Join-Path $startup "Dikte.lnk" $cmdShim = Join-Path $env:LOCALAPPDATA "Microsoft\WindowsApps\dikte.cmd" if ($Uninstall) { - foreach ($path in @($shortcut, $autostartLink, $cmdShim)) { + foreach ($path in @($shortcut, $autostartLink)) { if (Test-Path $path) { Remove-Item $path -Force; Write-Host "removed: $path" } } + # The packaged install writes the same shim, naming its own dikte-cli.exe. + # Only the one naming this checkout is ours to delete; taking the other + # would break the `dikte` command of an install this script never made. + if (Test-Path $cmdShim) { + if ((Get-Content $cmdShim -Raw).Contains($entry)) { + Remove-Item $cmdShim -Force + Write-Host "removed: $cmdShim" + } else { + Write-Host "left alone: $cmdShim (it names another install, not this checkout)" + } + } Write-Host "Dikte's shortcuts are gone. The repository and your settings are not." exit 0 } @@ -65,6 +76,15 @@ foreach ($path in @($shortcut) + $(if ($Autostart) { @($autostartLink) } else { Write-Host "shortcut: $path" } +if ($Autostart) { + # The packaged build keeps its sign-in entry in the registry. Left there + # beside the shortcut written above, both would start a Dikte at sign-in, + # and this install is the one being asked for. + Remove-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" ` + -Name "Dikte" -ErrorAction SilentlyContinue + Write-Host "autostart: the Startup shortcut replaces any registry Run entry a packaged install left" +} + # --- the dikte command ------------------------------------------------------ # The interpreter by its full path rather than by name: the one checked above is # the one the command line should run, whatever a later PATH change puts first. diff --git a/packaging/dikte.iss b/packaging/dikte.iss index d16d784..d8b5b0a 100644 --- a/packaging/dikte.iss +++ b/packaging/dikte.iss @@ -59,6 +59,13 @@ Name: "autostart"; Description: "Start Dikte when I sign in" [Files] Source: "{#Source}\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion +[InstallDelete] +; A checkout's install.ps1 -Autostart is a shortcut in the Startup folder. The +; registry entry this setup writes replaces it, and both left in place would be +; two Diktes at every sign-in. Only under the autostart task: somebody who +; unticked the box has not asked for their checkout's entry to go. +Type: files; Name: "{userstartup}\Dikte.lnk"; Tasks: autostart + [Icons] Name: "{autoprograms}\Dikte"; Filename: "{app}\Dikte.exe" @@ -111,7 +118,14 @@ begin end; procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +var + Shim: AnsiString; begin + { Only the shim this setup wrote, which is the one naming its dikte-cli.exe. + install.ps1 writes the same file for a checkout, naming that checkout's + Python, and a shim somebody else wrote is not this uninstaller's to take. } if CurUninstallStep = usUninstall then - DeleteFile(ShimPath()); + if LoadStringFromFile(ShimPath(), Shim) + and (Pos(ExpandConstant('{app}\dikte-cli.exe'), Shim) > 0) then + DeleteFile(ShimPath()); end; diff --git a/tests/test_integrate.py b/tests/test_integrate.py index f5a5f97..23c6121 100644 --- a/tests/test_integrate.py +++ b/tests/test_integrate.py @@ -475,6 +475,17 @@ class Windows(unittest.TestCase): self.installed = pathlib.Path(self.tmp.name).resolve() self.app = self.installed / "Dikte.exe" self.app.write_text("") + # APPDATA pointed into the sandbox, so that the Startup folder these + # tests delete from is never the machine's own. + appdata = mock.patch.dict(os.environ, + {"APPDATA": str(self.installed / "Roaming")}) + appdata.start() + self.addCleanup(appdata.stop) + + def startup_shortcut(self): + """Where install.ps1 -Autostart puts a checkout's sign-in entry.""" + return (self.installed / "Roaming" / "Microsoft" / "Windows" + / "Start Menu" / "Programs" / "Startup" / "Dikte.lnk") def _write(self, command): self.value = command @@ -510,6 +521,45 @@ class Windows(unittest.TestCase): self.assertEqual(len(self.install()), 1) self.assertEqual(self.value, f'"{self.app}"') + def test_an_entry_for_another_working_install_is_left_alone(self): + """The same courtesy the Linux half pays another menu entry: an entry + naming an executable that still exists is an installation that still + works, and a start of this one has no business redirecting it.""" + other = self.installed / "Elsewhere" / "Dikte.exe" + other.parent.mkdir() + other.write_text("") + self.value = f'"{other}"' + self.assertEqual(self.install(), []) + self.assertEqual(self.value, f'"{other}"') + + def test_asking_outright_overrules_a_working_other_install(self): + other = self.installed / "Elsewhere" / "Dikte.exe" + other.parent.mkdir() + other.write_text("") + self.value = f'"{other}"' + self.assertEqual(self.install(force=True), [integrate._run_entry_name()]) + self.assertEqual(self.value, f'"{self.app}"') + + def test_typing_it_sweeps_away_a_checkout_startup_shortcut(self): + """install.ps1 -Autostart writes it, the Run value replaces it, and + both left in place would be two Diktes at every sign-in.""" + shortcut = self.startup_shortcut() + shortcut.parent.mkdir(parents=True) + shortcut.write_text("") + changed = self.install(force=True) + self.assertIn(shortcut, changed) + self.assertFalse(shortcut.exists()) + + def test_a_start_leaves_a_checkout_startup_shortcut_alone(self): + """The silent call on every start has not been asked to move the + machine off its checkout.""" + shortcut = self.startup_shortcut() + shortcut.parent.mkdir(parents=True) + shortcut.write_text("") + self.value = f'"{self.app}"' + self.assertEqual(self.install(), []) + self.assertTrue(shortcut.exists()) + def test_running_it_again_changes_nothing(self): self.install(force=True) self.assertEqual(self.install(), []) @@ -521,6 +571,31 @@ class Windows(unittest.TestCase): self.assertEqual(self.remove(), []) +class WindowedExecutable(unittest.TestCase): + """The windowed executable, looked up beside whichever one is running. + + Beside rather than at a known place: the setup lays both executables into + one directory wherever that directory was put, so either can find the + other without knowing where the install is. + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.installed = pathlib.Path(self.tmp.name).resolve() + + def test_found_beside_the_named_executable(self): + windowed = self.installed / "Dikte.exe" + windowed.write_text("") + self.assertEqual( + integrate.windowed_executable(str(self.installed / "dikte-cli.exe")), + windowed) + + def test_none_when_no_setup_installed_one(self): + self.assertIsNone( + integrate.windowed_executable(str(self.installed / "dikte-cli.exe"))) + + class WindowsExecutableNames(unittest.TestCase): """The two Windows executables, read out of the files that name them. diff --git a/tests/test_ipc.py b/tests/test_ipc.py index 502574c..2c5d802 100644 --- a/tests/test_ipc.py +++ b/tests/test_ipc.py @@ -14,6 +14,7 @@ import unittest from unittest import mock from dikte import ipc +from tests.support import DikteTest class FakeSocket: @@ -185,5 +186,74 @@ class Send(unittest.TestCase): self.assertTrue(sock.disconnected) +class AlreadyServing(unittest.TestCase): + """The single-instance check, which listen() cannot be: a Windows pipe + takes a second server on the same name rather than refusing it.""" + + def probe(self, socket): + with mock.patch.object(ipc, "QLocalSocket", return_value=socket): + return ipc.already_serving() + + def test_nothing_running_means_go_ahead(self): + self.assertFalse(self.probe(FakeSocket(connected=False))) + + def test_an_answer_means_yield(self): + self.assertTrue(self.probe(FakeSocket(reply=b'{"ok": true}\n'))) + + def test_the_probe_has_no_side_effect(self): + """A probe that opened a window would open it during the relaunch a + slow instance provokes, on top of the verb being forwarded.""" + sock = FakeSocket(reply=b'{"ok": true}\n') + self.probe(sock) + self.assertEqual(sock.written.decode("utf-8").strip(), "status") + + def test_an_instance_too_old_to_answer_still_counts_as_running(self): + self.assertTrue(self.probe(FakeSocket(reply=b""))) + + +class InstanceLock(DikteTest): + def setUp(self): + super().setUp() + # The lock derives its home from paths, which DikteTest's cfg patches + # do not cover; without this the test would write into the real one. + from dikte import paths + self.patch_attr(paths, "DATA_DIR", self.path("data")) + + def test_one_holder_at_a_time(self): + first = ipc.instance_lock() + self.assertIsNotNone(first) + self.assertTrue(first.tryLock(0)) + second = ipc.instance_lock() + self.assertFalse(second.tryLock(0)) + first.unlock() + self.assertTrue(second.tryLock(0)) + second.unlock() + + def test_the_lock_lives_in_the_data_directory(self): + from dikte import paths + lock = ipc.instance_lock() + self.assertTrue(lock.tryLock(0)) + self.assertTrue((paths.DATA_DIR / "dikte.lock").exists()) + lock.unlock() + + +class Respawn(unittest.TestCase): + def test_windows_starts_a_detached_process_and_returns(self): + with mock.patch.object(sys, "platform", "win32"), \ + mock.patch.object(ipc, "launcher", return_value=["py", "x"]), \ + mock.patch.object(ipc.subprocess, "Popen") as popen: + ipc.respawn(["--gui"]) + self.assertEqual(popen.call_args.args[0], ["py", "x", "--gui"]) + self.assertEqual(popen.call_args.kwargs["creationflags"], + 0x00000008 | 0x00000200) + + def test_everywhere_else_the_process_is_replaced(self): + with mock.patch.object(sys, "platform", "linux"), \ + mock.patch.object(ipc, "launcher", return_value=["py", "x"]), \ + mock.patch.object(ipc.os, "execv") as execv: + ipc.respawn(["toggle", "--gui"]) + execv.assert_called_once_with("py", ["py", "x", "toggle", "--gui"]) + + if __name__ == "__main__": unittest.main()