diff --git a/pyinstaller.spec b/pyinstaller.spec index 48316f6..e3020fa 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -72,7 +72,7 @@ exe = EXE(pyz, analysis.binaries, analysis.datas, # bootloader_ignore_signals = False, - # console = True, + console = True, # hide_console = None, # disable_windowed_traceback = False, # debug = False, diff --git a/src/kleinanzeigen_bot/__main__.py b/src/kleinanzeigen_bot/__main__.py index 94b427b..de67206 100644 --- a/src/kleinanzeigen_bot/__main__.py +++ b/src/kleinanzeigen_bot/__main__.py @@ -6,8 +6,14 @@ from gettext import gettext as _ import kleinanzeigen_bot from kleinanzeigen_bot.utils.exceptions import CaptchaEncountered +from kleinanzeigen_bot.utils.launch_mode_guard import ensure_not_launched_from_windows_explorer from kleinanzeigen_bot.utils.misc import format_timedelta +# --------------------------------------------------------------------------- # +# Refuse GUI/double-click launch on Windows +# --------------------------------------------------------------------------- # +ensure_not_launched_from_windows_explorer() + # --------------------------------------------------------------------------- # # Main loop: run bot → if captcha → sleep → restart # --------------------------------------------------------------------------- # diff --git a/src/kleinanzeigen_bot/utils/launch_mode_guard.py b/src/kleinanzeigen_bot/utils/launch_mode_guard.py new file mode 100644 index 0000000..d3e0201 --- /dev/null +++ b/src/kleinanzeigen_bot/utils/launch_mode_guard.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ +import ctypes, sys # isort: skip + +from kleinanzeigen_bot.utils.i18n import get_current_locale +from kleinanzeigen_bot.utils.misc import is_frozen + + +def _is_launched_from_windows_explorer() -> bool: + """ + Returns True if this process is the *only* one attached to the console, + i.e. the user started us by double-clicking in Windows Explorer. + """ + if not is_frozen(): + return False # Only relevant when compiled exe + + if sys.platform != "win32": + return False # Only relevant on Windows + + # Allocate small buffer for at most 3 PIDs + DWORD = ctypes.c_uint + pids = (DWORD * 3)() + n = int(ctypes.windll.kernel32.GetConsoleProcessList(pids, 3)) + return n <= 2 # our PID (+ maybe conhost.exe) -> console dies with us # noqa: PLR2004 # Magic value used in comparison + + +def ensure_not_launched_from_windows_explorer() -> None: + """ + Terminates the application if the EXE was started by double-clicking in Windows Explorer + instead of from a terminal (cmd.exe / PowerShell). + """ + + if not _is_launched_from_windows_explorer(): + return + + if get_current_locale().language == "de": + banner = ( + "\n" + " ┌─────────────────────────────────────────────────────────────┐\n" + " │ Kleinanzeigen-Bot ist ein *Kommandozeilentool*. │\n" + " │ │\n" + " │ Du hast das Programm scheinbar per Doppelklick gestartet. │\n" + " │ │\n" + " │ -> Bitte starte es stattdessen in einem Terminal: │\n" + " │ │\n" + " │ kleinanzeigen-bot.exe [OPTIONEN] │\n" + " │ │\n" + " │ Schneller Weg, ein Terminal zu öffnen: │\n" + " │ 1. Drücke Win + R, gib cmd ein und drücke Enter. │\n" + " │ 2. Wechsle per `cd` in das Verzeichnis mit dieser Datei. │\n" + " │ 3. Gib den obigen Befehl ein und drücke Enter. │\n" + " │ │\n" + " │─────────────────────────────────────────────────────────────│\n" + " │ Drücke , um dieses Fenster zu schließen. │\n" + " └─────────────────────────────────────────────────────────────┘\n" + ) + else: + banner = ( + "\n" + " ┌─────────────────────────────────────────────────────────────┐\n" + " │ Kleinanzeigen-Bot is a *command-line* tool. │\n" + " │ │\n" + " │ It looks like you launched it by double-clicking the EXE. │\n" + " │ │\n" + " │ -> Please run it from a terminal instead: │\n" + " │ │\n" + " │ kleinanzeigen-bot.exe [OPTIONS] │\n" + " │ │\n" + " │ Quick way to open a terminal: │\n" + " │ 1. Press Win + R , type cmd and press Enter. │\n" + " │ 2. cd to the folder that contains this file. │\n" + " │ 3. Type the command above and press Enter. │\n" + " │ │\n" + " │─────────────────────────────────────────────────────────────│\n" + " │ Press to close this window. │\n" + " └─────────────────────────────────────────────────────────────┘\n" + ) + + print(banner, file = sys.stderr, flush = True) + input() # keep window open + sys.exit(1) diff --git a/tests/unit/test_launch_mode_guard.py b/tests/unit/test_launch_mode_guard.py new file mode 100644 index 0000000..7d816b5 --- /dev/null +++ b/tests/unit/test_launch_mode_guard.py @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: © Jens Bergmann and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ + +import builtins, importlib, sys # isort: skip +from unittest import mock + +import pytest + +from kleinanzeigen_bot.utils.i18n import Locale + + +# --- Platform-specific test for Windows double-click guard --- +@pytest.mark.parametrize( + ("compiled_exe", "windows_double_click_launch", "expected_error_msg_lang"), + [ + (True, True, "en"), # Windows Explorer double-click - English locale + (True, True, "de"), # Windows Explorer double-click - German locale + (True, False, None), # Windows Terminal launch - compiled exe + (False, False, None), # Windows Terminal launch - from source code + ], +) +@pytest.mark.skipif(sys.platform != "win32", reason = "ctypes.windll only exists on Windows") +def test_guard_triggers_on_double_click_windows( + monkeypatch:pytest.MonkeyPatch, + capsys:pytest.CaptureFixture[str], + compiled_exe:bool, + windows_double_click_launch:bool | None, + expected_error_msg_lang:str | None +) -> None: + # Prevent blocking in tests + monkeypatch.setattr(builtins, "input", lambda: None) + + # Simulate target platform + monkeypatch.setattr(sys, "platform", "win32") + + # Simulate compiled executable + monkeypatch.setattr( + "kleinanzeigen_bot.utils.misc.is_frozen", + lambda: compiled_exe, + ) + + # Force specific locale + if expected_error_msg_lang: + monkeypatch.setattr( + "kleinanzeigen_bot.utils.i18n.get_current_locale", + lambda: Locale(expected_error_msg_lang), + ) + + # Spy on sys.exit + exit_mock = mock.Mock(wraps = sys.exit) + monkeypatch.setattr(sys, "exit", exit_mock) + + # Simulate double-click launch on Windows + if windows_double_click_launch is not None: + pid_count = 2 if windows_double_click_launch else 3 # 2 -> Explorer, 3 -> Terminal + k32 = mock.Mock() + k32.GetConsoleProcessList.return_value = pid_count + monkeypatch.setattr("ctypes.windll.kernel32", k32) + + # Reload module to pick up system monkeypatches + guard = importlib.reload( + importlib.import_module("kleinanzeigen_bot.utils.launch_mode_guard") + ) + + if expected_error_msg_lang: + with pytest.raises(SystemExit) as exc: + guard.ensure_not_launched_from_windows_explorer() + assert exc.value.code == 1 + exit_mock.assert_called_once_with(1) + + captured = capsys.readouterr() + if expected_error_msg_lang == "de": + assert "Du hast das Programm scheinbar per Doppelklick gestartet." in captured.err + else: + assert "It looks like you launched it by double-clicking the EXE." in captured.err + assert not captured.out # nothing to stdout + else: + guard.ensure_not_launched_from_windows_explorer() + exit_mock.assert_not_called() + captured = capsys.readouterr() + assert not captured.err # nothing to stderr + + +# --- Platform-agnostic tests for non-Windows and non-frozen code paths --- +@pytest.mark.parametrize( + ("platform", "compiled_exe"), + [ + ("linux", True), + ("linux", False), + ("darwin", True), + ("darwin", False), + ], +) +def test_guard_non_windows_and_non_frozen( + monkeypatch:pytest.MonkeyPatch, + platform:str, + compiled_exe:bool +) -> None: + monkeypatch.setattr(sys, "platform", platform) + monkeypatch.setattr("kleinanzeigen_bot.utils.misc.is_frozen", lambda: compiled_exe) + # Reload module to pick up system monkeypatches + guard = importlib.reload( + importlib.import_module("kleinanzeigen_bot.utils.launch_mode_guard") + ) + # Should not raise or print anything + guard.ensure_not_launched_from_windows_explorer()