diff --git a/.github/workflows/binaries.yml b/.github/workflows/binaries.yml index 1cf1db5..1ca90e7 100644 --- a/.github/workflows/binaries.yml +++ b/.github/workflows/binaries.yml @@ -38,6 +38,12 @@ jobs: pip install -r requirements.txt pip install pyinstaller + - name: Download Tor Binaries + shell: bash + run: | + chmod +x scripts/download_tor.sh + ./scripts/download_tor.sh + - name: Build with PyInstaller shell: bash run: | diff --git a/pyproject.toml b/pyproject.toml index 3b4ee26..e7a89f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ voxvera = ["templates/**", "src/**", "resources/**", "locales/**", "resources/bi [project] name = "voxvera" -version = "0.1.5" +version = "0.1.6" description = "VoxVera CLI utilities" readme = "README.md" authors = [{name = "VoxVera"}] @@ -26,9 +26,10 @@ dependencies = [ "Pillow", "jsmin", "htmlmin2", + "pypdf", "wcwidth", + "onionshare-cli", ] - [project.optional-dependencies] test = [ "pytest", diff --git a/requirements.txt b/requirements.txt index 3f77005..78a3e3d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,5 @@ jsmin htmlmin2 pypdf wcwidth +onionshare-cli + diff --git a/scripts/build_binaries.sh b/scripts/build_binaries.sh index 8ce10bc..75a1d11 100755 --- a/scripts/build_binaries.sh +++ b/scripts/build_binaries.sh @@ -19,6 +19,9 @@ mkdir -p voxvera/resources/bin echo "Building VoxVera binary for $(uname)..." +echo "Ensuring Tor binaries are downloaded..." +bash scripts/download_tor.sh + # Use python module to call pyinstaller PY_CMD="python3 -m PyInstaller" diff --git a/tests/test_cli.py b/tests/test_cli.py index 0885025..7116053 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -112,10 +112,15 @@ def test_check_all_present(capsys, monkeypatch): def test_check_missing(capsys, monkeypatch): - def fake_which(cmd): - return None if cmd == "onionshare-cli" else "/usr/bin/" + cmd + import builtins + original_import = builtins.__import__ - monkeypatch.setattr(shutil, "which", fake_which) + def fake_import(name, *args, **kwargs): + if name == "onionshare_cli": + raise ImportError("Mocked missing") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) cli.main(["check"]) captured = capsys.readouterr() assert "onionshare-cli" in captured.out diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 9a79c1e..208dd7f 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -46,7 +46,11 @@ def check_tor_running(): def check_onionshare_cli(): """Check if onionshare-cli is installed.""" - return shutil.which("onionshare-cli") is not None + try: + import onionshare_cli # noqa: F401 + return True + except ImportError: + return False @pytest.fixture(scope="module") @@ -222,12 +226,14 @@ class TestFullWorkflow: host_dir = tmp_path / "host" / "reachtest" assert host_dir.is_dir(), f"Build did not create {host_dir}" - # Launch onionshare-cli directly against the built host directory + # Launch onionshare-cli directly against the built host directory using our internal wrapper logfile = host_dir / "onionshare.log" log_fh = open(logfile, "w") cmd = [ - "onionshare-cli", + sys.executable, + "-m", "voxvera.cli", + "_internal_onionshare", "--website", "--public", "--persistent", diff --git a/tests/test_integrity.py b/tests/test_integrity.py index 892cc0c..7dbc3fe 100644 --- a/tests/test_integrity.py +++ b/tests/test_integrity.py @@ -115,9 +115,12 @@ def test_portable_bundle_integrity(tmp_path, monkeypatch): shutil.copytree(REPO_ROOT / "voxvera" / "locales", voxvera_dir / "locales") os.makedirs(voxvera_dir / "vendor", exist_ok=True) (voxvera_dir / "vendor" / "anchor.txt").write_text("anchor") - (project_root / "voxvera-run.sh").write_text("echo run") - (project_root / "uninstall.sh").write_text("echo uninstall") - (project_root / "uninstall.ps1").write_text("echo uninstall") + + # Copy real scripts from repo + for script in ["voxvera-run.sh", "uninstall.sh", "uninstall.ps1", "install.sh", "install.ps1", "setup.sh"]: + if (REPO_ROOT / script).exists(): + shutil.copy(REPO_ROOT / script, project_root / script) + # ROOT in cli.py points to the voxvera/ directory monkeypatch.setattr(cli, "ROOT", voxvera_dir) diff --git a/voxvera/__init__.py b/voxvera/__init__.py index d2e5332..bd9fcc0 100644 --- a/voxvera/__init__.py +++ b/voxvera/__init__.py @@ -1,7 +1,7 @@ import sys from pathlib import Path -__version__ = "0.1.5" +__version__ = "0.1.6" # Add the vendor directory to sys.path as a fallback. # For standard installs (pip/pipx), the installed dependencies in the venv diff --git a/voxvera/cli.py b/voxvera/cli.py index a27b6d4..62d9a2d 100644 --- a/voxvera/cli.py +++ b/voxvera/cli.py @@ -64,6 +64,14 @@ def t(path: str, **kwargs) -> str: def require_cmd(cmd: str): + if cmd == "onionshare-cli": + try: + import onionshare_cli # noqa: F401 + return True + except ImportError: + print("Required tool 'onionshare-cli' not bundled or installed.", file=sys.stderr) + return False + if shutil.which(cmd) is None: print( f"Required command '{cmd}' not found. Please install it.", file=sys.stderr @@ -75,10 +83,11 @@ def require_cmd(cmd: str): def check_deps(): console = Console() - # External CLI tools still required at runtime - cli_tools = ["onionshare-cli"] + # External CLI tools still required at runtime (none anymore if bundled) + cli_tools = [] # Python packages used by the build pipeline py_packages = { + "onionshare-cli": "onionshare_cli", "qrcode": "qrcode", "PIL (Pillow)": "PIL", "jsmin": "jsmin", @@ -445,6 +454,11 @@ def build_assets( "landing": l_data.get("landing", {}) } + # Override landing data with user custom config data so JS doesn't overwrite it + for field in ["title", "subtitle", "headline", "content", "url_message"]: + if field in data and data[field]: + all_locales[code]["landing"][field] = data[field] + html = html.replace("{{locales}}", json.dumps(all_locales)) # 1. Statically replace localization tokens {{t_web_...}} @@ -554,10 +568,29 @@ def get_tor_ports(): return socks_port, ctl_port -def serve(config_path: str) -> str | None: - if not require_cmd("onionshare-cli"): +def _internal_onionshare(): + """Hidden command to run the bundled onionshare-cli.""" + import platform + + # Try to add bundled Tor to PATH so OnionShare can find it + system = platform.system().lower() + platform_dir = "win" if "windows" in system else "mac" if "darwin" in system else "linux" + tor_dir = ROOT / "resources" / "tor" / platform_dir + if tor_dir.exists(): + os.environ["PATH"] = str(tor_dir) + os.pathsep + os.environ.get("PATH", "") + + try: + from onionshare_cli import main + # sys.argv is ['voxvera', '_internal_onionshare', '--website', ...] + # We need to make it look like ['onionshare-cli', '--website', ...] + sys.argv = ["onionshare-cli"] + sys.argv[2:] + sys.exit(main()) + except ImportError: + print("Error: onionshare-cli not bundled or installed.", file=sys.stderr) sys.exit(1) + +def serve(config_path: str) -> str | None: socks, ctl = get_tor_ports() folder_name = load_config(config_path)["folder_name"] @@ -572,8 +605,11 @@ def serve(config_path: str) -> str | None: env["TOR_SOCKS_PORT"] = socks env["TOR_CONTROL_PORT"] = ctl + # Run the bundled onionshare-cli via our hidden command + # sys.argv[0] is the current executable (python script or PyInstaller binary) cmd = [ - "onionshare-cli", + sys.argv[0], + "_internal_onionshare", "--website", "--public", "--persistent", @@ -584,6 +620,12 @@ def serve(config_path: str) -> str | None: "-v", str(dir_path), ] + + # If running via 'python -m voxvera.cli', sys.argv[0] might be the script path, + # so we should ensure it's executed correctly if not a PyInstaller binary. + if not getattr(sys, 'frozen', False) and sys.argv[0].endswith('.py'): + cmd = [sys.executable, sys.argv[0]] + cmd[1:] + log_fh = open(logfile, "w") proc = subprocess.Popen(cmd, stdout=log_fh, stderr=subprocess.STDOUT, env=env) @@ -848,6 +890,11 @@ def build_site(): "landing": l_data.get("landing", {}) } + # Override landing data with user custom config data so JS doesn't overwrite it + for field in ["title", "subtitle", "headline", "content", "url_message"]: + if field in data and data[field]: + all_locales[code]["landing"][field] = data[field] + html = html.replace("{{locales}}", json.dumps(all_locales)) # 1. Statically replace localization tokens (web tokens) @@ -1228,8 +1275,14 @@ def main(argv=None): sub.add_parser("build-docs", help="Generate localized documentation from templates") sub.add_parser("build-site", help="Refresh the main site/ directory assets and bundle") sub.add_parser("manage", help="Manage VoxVera servers interactively") + sub.add_parser("_internal_onionshare", help=argparse.SUPPRESS) + + args, unknown_args = parser.parse_known_args(argv) + + if args.command == "_internal_onionshare": + _internal_onionshare() + return - args = parser.parse_args(argv) config_path = Path(args.config).resolve() # Determine language