Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ build/
KarutaBot/dist/
KarutaBot/build/
KarutaBot/*.spec
!KarutaBot/Aeyori.spec
config.json
ocr_debug
__pycache__/
Expand Down
61 changes: 61 additions & 0 deletions KarutaBot/Aeyori.spec
Original file line number Diff line number Diff line change
@@ -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"),
)
12 changes: 9 additions & 3 deletions KarutaBot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 10 additions & 10 deletions KarutaBot/build.bat
Original file line number Diff line number Diff line change
@@ -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%
51 changes: 49 additions & 2 deletions KarutaBot/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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):
Expand All @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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()
22 changes: 17 additions & 5 deletions KarutaBot/vote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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...")
Expand All @@ -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
Expand Down
27 changes: 21 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion SHA256SUMS.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2a68384374b4c4eaf1b9a027bc55ff16d433854fb6411e5ac70f9f304640a68f Aeyori.exe
b3c1b80ff29da8a866548df8eb96ad41eee0d9b07e4b25477af34ca80d980b7f Aeyori.exe
37 changes: 37 additions & 0 deletions tests/test_vote_dependencies.py
Original file line number Diff line number Diff line change
@@ -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()