diff --git a/.gitignore b/.gitignore index 483e275..48d1ea8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ KarutaBot/dist/ KarutaBot/build/ KarutaBot/*.spec +!KarutaBot/Aeyori.spec config.json ocr_debug __pycache__/ diff --git a/KarutaBot/Aeyori.spec b/KarutaBot/Aeyori.spec new file mode 100644 index 0000000..88c7148 --- /dev/null +++ b/KarutaBot/Aeyori.spec @@ -0,0 +1,61 @@ +# -*- mode: python ; coding: utf-8 -*- +"""Canonical PyInstaller configuration for the Windows release.""" + +from pathlib import Path + +from PyInstaller.utils.hooks import collect_all + + +spec_dir = Path(SPECPATH) + +datas = [] +binaries = [] +hiddenimports = [] + +# These packages use dynamic imports and/or ship runtime data that PyInstaller's +# normal import analysis cannot discover. In particular, collecting all of +# Selenium includes every WebDriver submodule plus the Selenium Manager binary +# used to locate/download a compatible ChromeDriver. +for package_name in ("easyocr", "torch", "torchvision", "selenium"): + package_datas, package_binaries, package_hiddenimports = collect_all(package_name) + datas += package_datas + binaries += package_binaries + hiddenimports += package_hiddenimports + + +a = Analysis( + [str(spec_dir / "launcher.py")], + pathex=[str(spec_dir)], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="Aeyori", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=str(spec_dir / "icon.ico"), +) diff --git a/KarutaBot/bot.py b/KarutaBot/bot.py index 3d696df..ce792ec 100644 --- a/KarutaBot/bot.py +++ b/KarutaBot/bot.py @@ -529,9 +529,15 @@ async def _do_vote_auto(app, client, channel): loop = asyncio.get_event_loop() try: from vote import auto_vote - except ImportError: - app.ui_log("❌ [Auto] vote.py or Selenium is unavailable") - app.ui_log(" Run: pip install selenium") + except ImportError as exc: + import sys + app.ui_log(f"❌ [Auto] vote.py or Selenium is unavailable: {exc}") + if getattr(sys, "frozen", False): + app.ui_log( + " This packaged build is incomplete; install a newer Aeyori build." + ) + else: + app.ui_log(" Run: pip install selenium") return try: diff --git a/KarutaBot/build.bat b/KarutaBot/build.bat index 242dd00..2665db2 100644 --- a/KarutaBot/build.bat +++ b/KarutaBot/build.bat @@ -1,13 +1,13 @@ @echo off -pyinstaller ^ - --onefile ^ - --noconsole ^ - --name "Aeyori" ^ - --icon=icon.ico ^ - --collect-all easyocr ^ - --collect-all torch ^ - --collect-all torchvision ^ - launcher.py +pushd "%~dp0.." +pyinstaller --clean --noconfirm KarutaBot\Aeyori.spec +set "BUILD_EXIT_CODE=%ERRORLEVEL%" +popd echo. -echo Build complete. Check dist\Aeyori.exe +if %BUILD_EXIT_CODE% equ 0 ( + echo Build complete. Check dist\Aeyori.exe +) else ( + echo Build failed with exit code %BUILD_EXIT_CODE%. +) pause +exit /b %BUILD_EXIT_CODE% diff --git a/KarutaBot/launcher.py b/KarutaBot/launcher.py index f84ba59..14fadf4 100644 --- a/KarutaBot/launcher.py +++ b/KarutaBot/launcher.py @@ -21,8 +21,19 @@ ("torch", "torch"), ("torchvision","torchvision"), ("easyocr", "easyocr"), + ("selenium", "selenium"), ] +# Selenium loads several WebDriver modules lazily. Importing only the top-level +# package is therefore not enough to prove that a frozen build is complete. +FROZEN_SELENIUM_MODULES = ( + "selenium.webdriver.chrome.options", + "selenium.webdriver.common.by", + "selenium.webdriver.common.selenium_manager", + "selenium.webdriver.support.expected_conditions", + "selenium.webdriver.support.ui", +) + def check_and_install(): """Returns list of packages that needed installing.""" needed = [] @@ -31,6 +42,13 @@ def check_and_install(): importlib.import_module(import_name) except ImportError: needed.append((import_name, pip_name)) + + if IS_FROZEN and ("selenium", "selenium") not in needed: + try: + for module_name in FROZEN_SELENIUM_MODULES: + importlib.import_module(module_name) + except ImportError: + needed.append(("selenium", "selenium")) return needed def install_package(pip_name, log_callback): @@ -39,7 +57,7 @@ def install_package(pip_name, log_callback): "❌ Packaged build is missing required modules and cannot self-install them." ) log_callback( - " Rebuild the EXE with bundled OCR dependencies instead of excluding them." + " Rebuild the EXE with all required application dependencies bundled." ) return False @@ -176,7 +194,7 @@ def main(): screen.show_error( "This EXE was built without required modules.\n" f"Missing: {missing}\n\n" - "Use a build that bundles OCR dependencies." + "Use a complete build made from KarutaBot/Aeyori.spec." ) else: screen.show_error( @@ -202,5 +220,34 @@ def main(): main.launch() +def check_bundle(report_path): + """Check the shipped runtime without opening the UI or contacting services.""" + import json + from pathlib import Path + + report = {"frozen": IS_FROZEN, "ok": False} + try: + missing = check_and_install() + if missing: + raise RuntimeError(f"Missing dependencies: {missing}") + for module_name in FROZEN_SELENIUM_MODULES: + importlib.import_module(module_name) + from selenium.webdriver.chrome.options import Options + from selenium.webdriver.chrome.webdriver import WebDriver + from selenium.webdriver.common.selenium_manager import SeleniumManager + + Options().to_capabilities() + manager = SeleniumManager()._get_binary() + if not manager.is_file(): + raise RuntimeError("Selenium Manager binary is missing") + report.update(ok=True, selenium_manager=manager.name) + except Exception as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + Path(report_path).write_text(json.dumps(report, indent=2), encoding="utf-8") + return 0 if report["ok"] else 1 + + if __name__ == "__main__": + if len(sys.argv) == 3 and sys.argv[1] == "--check-bundle": + sys.exit(check_bundle(sys.argv[2])) main() diff --git a/KarutaBot/vote.py b/KarutaBot/vote.py index e24d278..083ead9 100644 --- a/KarutaBot/vote.py +++ b/KarutaBot/vote.py @@ -43,8 +43,9 @@ def _create_driver(headless=True): or Selenium's Chrome driver is unavailable. """ from selenium import webdriver + from selenium.webdriver.chrome.options import Options - options = webdriver.ChromeOptions() + options = Options() if headless: options.add_argument("--headless=new") options.add_argument("--no-sandbox") @@ -959,6 +960,10 @@ def _log(msg): return True if result == "verification_required": return False + if result == "dependency_unavailable": + # Retrying cannot repair a missing module in this process (and a + # frozen executable cannot install packages into itself). + return False if result == "likely": if attempt == 1: _log("🗳 [Auto] Vote unconfirmed — retrying to verify...") @@ -982,13 +987,20 @@ def _do_vote_attempt(token, headless, _log, attempt): driver = _create_driver(headless=headless) except ImportError as ie: import sys - py = sys.executable _log(f"❌ [Auto] Import failed: {ie}") - _log(f' Run: & "{py}" -m pip install selenium') - return "failed" + if getattr(sys, "frozen", False): + _log( + " This packaged build is incomplete; install a newer Aeyori build." + ) + else: + _log(f' Run: & "{sys.executable}" -m pip install selenium') + return "dependency_unavailable" except Exception as exc: _log(f"❌ [Auto] Could not launch Chrome: {exc}") - _log(" Make sure Chrome or Chromium is installed on this system.") + _log( + " Make sure Chrome is installed and Selenium Manager can obtain " + "a compatible driver." + ) return "failed" # Step 1: Login to Discord via token injection diff --git a/README.md b/README.md index b9f9410..d69af09 100644 --- a/README.md +++ b/README.md @@ -97,15 +97,30 @@ messages, screenshots, issue reports, or source control. ## Build a Windows executable ```bash -pip install pyinstaller -pyinstaller --onefile --noconsole --name "Aeyori" \ - --icon=KarutaBot/icon.ico \ - --collect-all easyocr --collect-all torch \ - KarutaBot/launcher.py +pip install -r requirements.txt +pyinstaller --clean --noconfirm KarutaBot/Aeyori.spec ``` The generated executable is written to `dist/Aeyori.exe`. PyTorch and OCR make -the binary large. A Windows reputation warning is not proof that a file is safe; +the binary large. The spec file is the canonical release configuration; it +collects EasyOCR, PyTorch, TorchVision, and all Selenium modules and data, +including the bundled Selenium Manager executable used for ChromeDriver setup. +Do not replace the spec build with a bare `pyinstaller KarutaBot/launcher.py` +command, because Selenium loads parts of its WebDriver stack dynamically. + +Before publishing, run the actual generated executable with +`dist\Aeyori.exe --check-bundle bundle-check.json` and inspect the JSON report +for `"frozen": true` and `"ok": true`. This checks runtime dependency imports, +Chrome WebDriver modules, and the bundled Selenium Manager executable without +opening the UI, logging in, or voting. A failed check exits with status 1. + +Chrome must still be installed on the target computer. Selenium Manager normally +finds Chrome and obtains a matching ChromeDriver automatically, so users do not +need to copy Python modules or a driver beside `Aeyori.exe`. The first driver +setup may require network access; managed or offline computers can instead use a +compatible driver already available through Selenium Manager's cache or `PATH`. + +A Windows reputation warning is not proof that a file is safe; prefer a release published by this repository and verify its SHA-256 digest when one is provided. The current release digest is recorded in [`SHA256SUMS.txt`](SHA256SUMS.txt). diff --git a/SHA256SUMS.txt b/SHA256SUMS.txt index d40cf77..c76a733 100644 --- a/SHA256SUMS.txt +++ b/SHA256SUMS.txt @@ -1 +1 @@ -2a68384374b4c4eaf1b9a027bc55ff16d433854fb6411e5ac70f9f304640a68f Aeyori.exe +b3c1b80ff29da8a866548df8eb96ad41eee0d9b07e4b25477af34ca80d980b7f Aeyori.exe diff --git a/tests/test_vote_dependencies.py b/tests/test_vote_dependencies.py new file mode 100644 index 0000000..49b746a --- /dev/null +++ b/tests/test_vote_dependencies.py @@ -0,0 +1,37 @@ +"""Regression coverage for issue #5; never opens a browser or uses credentials.""" +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "KarutaBot")) +import vote + + +class VoteDependencyTests(unittest.TestCase): + def check_missing_dependency(self, frozen): + messages = [] + with patch.object(sys, "frozen", frozen, create=True), patch.object( + vote, "_create_driver", + side_effect=ModuleNotFoundError( + "No module named 'selenium.webdriver.chrome.options'" + ), + ) as create_driver, patch.object(vote.time, "sleep") as sleep: + self.assertFalse(vote.auto_vote("unused", ui_log=messages.append)) + create_driver.assert_called_once() + sleep.assert_not_called() + return "\n".join(messages) + + def test_frozen_missing_module_requires_new_build_without_retry(self): + output = self.check_missing_dependency(True) + self.assertIn("packaged build is incomplete", output) + self.assertNotIn("pip install", output) + + def test_source_missing_module_has_install_instruction_without_retry(self): + output = self.check_missing_dependency(False) + self.assertIn("pip install selenium", output) + self.assertNotIn("packaged build is incomplete", output) + + +if __name__ == "__main__": + unittest.main()