From 69ca9f641121d714c4094ea11f16e81d02fb45e2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:23:04 -0400 Subject: [PATCH] Fix PyInstaller volatility and bundle dependencies (v0.2.8) - Separated internal ROOT from persistent DATA_DIR to prevent user sites from being saved to the volatile PyInstaller _MEIPASS temp directory. - Updated unit tests to correctly mock DATA_DIR and ensure complete isolation between tests. - Fixed binaries.yml and requirements.txt to correctly bundle the pinned Flask/Werkzeug versions, preventing onionshare-cli import failures in the standalone binary. --- .github/workflows/binaries.yml | 1 + pyproject.toml | 2 +- requirements.txt | 2 + tests/test_cli.py | 140 +++++++++++++++++++++------------ tests/test_e2e.py | 55 ++++++++----- voxvera/__init__.py | 2 +- voxvera/cli.py | 127 +++++++++++++++++++----------- 7 files changed, 214 insertions(+), 115 deletions(-) diff --git a/.github/workflows/binaries.yml b/.github/workflows/binaries.yml index 170b24f..cc7483a 100644 --- a/.github/workflows/binaries.yml +++ b/.github/workflows/binaries.yml @@ -35,6 +35,7 @@ jobs: - name: Install dependencies run: | pip install -r requirements.txt + pip install . pip install pyinstaller pyinstaller-hooks-contrib - name: Download Tor Binaries diff --git a/pyproject.toml b/pyproject.toml index 03bb1e9..1531687 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ voxvera = ["templates/**", "src/**", "resources/**", "locales/**", "resources/bi [project] name = "voxvera" -version = "0.2.7" +version = "0.2.8" description = "VoxVera CLI utilities" readme = "README.md" authors = [{name = "VoxVera"}] diff --git a/requirements.txt b/requirements.txt index 78a3e3d..c5c3e3c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,6 @@ htmlmin2 pypdf wcwidth onionshare-cli +flask<2.3.0 +werkzeug<3.0.0 diff --git a/tests/test_cli.py b/tests/test_cli.py index 7116053..b85a921 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,11 +14,26 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..") def _setup_tmp(monkeypatch, tmp_path): repo_root = Path(__file__).resolve().parent.parent - shutil.copytree(repo_root / "voxvera" / "src", tmp_path / "src") - shutil.copytree(repo_root / "voxvera" / "locales", tmp_path / "locales") - monkeypatch.setattr(cli, "ROOT", tmp_path) - monkeypatch.setattr(cli, "_src_res", lambda *p: tmp_path / "src" / Path(*p)) - monkeypatch.setattr(cli, "_locale_res", lambda *p: tmp_path / "locales" / Path(*p)) + + # Use a unique subdirectory for this test run to ensure isolation + test_data_dir = tmp_path / "data" + test_data_dir.mkdir(exist_ok=True) + + # Internal resources (ROOT) + res_root = test_data_dir / "voxvera" + res_root.mkdir() + shutil.copytree(repo_root / "voxvera" / "src", res_root / "src") + shutil.copytree(repo_root / "voxvera" / "locales", res_root / "locales") + shutil.copytree(repo_root / "voxvera" / "templates", res_root / "templates") + + monkeypatch.setattr(cli, "ROOT", res_root) + monkeypatch.setattr(cli, "DATA_DIR", test_data_dir) + monkeypatch.setattr(cli, "_src_res", lambda *p: res_root / "src" / Path(*p)) + monkeypatch.setattr(cli, "_locale_res", lambda *p: res_root / "locales" / Path(*p)) + monkeypatch.setattr(cli, "_template_res", lambda *p: res_root / "templates" / Path(*p)) + + # Create a default config.json in DATA_DIR + shutil.copy(res_root / "src" / "config.json", test_data_dir / "config.json") return repo_root @@ -31,10 +46,11 @@ def test_help(capsys): def test_init_template(tmp_path, monkeypatch): _setup_tmp(monkeypatch, tmp_path) + test_data_dir = tmp_path / "data" # Mock choose_language to return 'en' monkeypatch.setattr(cli, "choose_language", lambda *a: "en") cli.main(["init", "--template", "voxvera"]) - dest = tmp_path / "host" / "voxvera" + dest = test_data_dir / "host" / "voxvera" assert dest.is_dir() assert (dest / "config.json").exists() assert (dest / "index.html").exists() @@ -43,7 +59,8 @@ def test_init_template(tmp_path, monkeypatch): def test_build(tmp_path, monkeypatch): _setup_tmp(monkeypatch, tmp_path) cli.main(["build"]) - dest = tmp_path / "host" / "voxvera" + test_data_dir = tmp_path / "data" + dest = test_data_dir / "host" / "voxvera" assert dest.is_dir() assert (dest / "config.json").exists() assert (dest / "index.html").exists() @@ -54,14 +71,16 @@ def test_build_with_download(tmp_path, monkeypatch): download_file = tmp_path / "sample.zip" download_file.write_text("dummy") cli.main(["build", "--download", str(download_file)]) - dest = tmp_path / "host" / "voxvera" / "download" / "extra-content.zip" + test_data_dir = tmp_path / "data" + dest = test_data_dir / "host" / "voxvera" / "download" / "extra-content.zip" assert dest.is_file() def test_import(tmp_path, monkeypatch): repo = _setup_tmp(monkeypatch, tmp_path) - imports_dir = tmp_path / "imports" - imports_dir.mkdir() + test_data_dir = tmp_path / "data" + imports_dir = test_data_dir / "imports" + imports_dir.mkdir(parents=True, exist_ok=True) base_data = json.load(open(repo / "imports" / "example.json")) for sub in ["foo", "bar"]: data = dict(base_data) @@ -70,7 +89,8 @@ def test_import(tmp_path, monkeypatch): json.dump(data, fh) cli.main(["batch-import"]) for sub in ["foo", "bar"]: - dest = tmp_path / "host" / sub + test_data_dir = tmp_path / "data" + dest = test_data_dir / "host" / sub assert dest.is_dir() assert (dest / "index.html").exists() @@ -78,8 +98,9 @@ def test_import(tmp_path, monkeypatch): def test_import_preserves_session(tmp_path, monkeypatch): """Re-importing a config must not destroy the OnionShare session key.""" repo = _setup_tmp(monkeypatch, tmp_path) - imports_dir = tmp_path / "imports" - imports_dir.mkdir() + test_data_dir = tmp_path / "data" + imports_dir = test_data_dir / "imports" + imports_dir.mkdir(parents=True, exist_ok=True) base_data = json.load(open(repo / "imports" / "example.json")) base_data["folder_name"] = "keep" with open(imports_dir / "keep.json", "w") as fh: @@ -87,7 +108,8 @@ def test_import_preserves_session(tmp_path, monkeypatch): # first import creates the host dir cli.main(["batch-import"]) - host_dir = tmp_path / "host" / "keep" + test_data_dir = tmp_path / "data" + host_dir = test_data_dir / "host" / "keep" assert host_dir.is_dir() # simulate OnionShare having written a session key @@ -129,13 +151,15 @@ def test_check_missing(capsys, monkeypatch): def test_build_download_zip(tmp_path, monkeypatch): _setup_tmp(monkeypatch, tmp_path) - config = json.load(open(tmp_path / "src" / "config.json")) + res_root = tmp_path / "data" / "voxvera" + test_data_dir = tmp_path / "data" + config = json.load(open(res_root / "src" / "config.json")) folder_name = config["folder_name"] zip_path = tmp_path / "file.zip" with zipfile.ZipFile(zip_path, "w") as zf: zf.writestr("dummy.txt", "data") cli.main(["build", "--download", str(zip_path)]) - dest = tmp_path / "host" / folder_name / "download" / "extra-content.zip" + dest = test_data_dir / "host" / folder_name / "download" / "extra-content.zip" assert dest.is_file() @@ -143,9 +167,11 @@ def test_serve_updates_url(tmp_path, monkeypatch): """Test that serve() auto-generates onion URL for tear-off links.""" _setup_tmp(monkeypatch, tmp_path) cli.main(["build"]) - config = json.load(open(tmp_path / "src" / "config.json")) + res_root = tmp_path / "data" / "voxvera" + test_data_dir = tmp_path / "data" + config = json.load(open(res_root / "src" / "config.json")) folder_name = config["folder_name"] - dir_path = tmp_path / "host" / folder_name + dir_path = test_data_dir / "host" / folder_name # Set a custom URL that should be preserved (content link) config["url"] = "https://example.com/external-resource" @@ -197,7 +223,9 @@ def test_serve_updates_url(tmp_path, monkeypatch): def test_quickstart_noninteractive(tmp_path, monkeypatch): """Test quickstart auto-generates onion URL for tear-off links.""" _setup_tmp(monkeypatch, tmp_path) - config = json.load(open(tmp_path / "src" / "config.json")) + res_root = tmp_path / "data" / "voxvera" + test_data_dir = tmp_path / "data" + config = json.load(open(res_root / "src" / "config.json")) folder_name = config["folder_name"] # No need for TOR_* env vars anymore - auto-detection should work @@ -232,9 +260,12 @@ def test_quickstart_noninteractive(tmp_path, monkeypatch): monkeypatch.setattr(cli.subprocess, "Popen", FakePopen) monkeypatch.setattr(cli, "choose_language", lambda *a: "en") + config_path = test_data_dir / "config.json" cli.main(["quickstart", "--non-interactive"]) + # quickstart no longer calls serve, so we call it manually to test URL update + cli.serve(str(config_path)) - dir_path = tmp_path / "host" / folder_name + dir_path = test_data_dir / "host" / folder_name assert (dir_path / "index.html").exists() log_file = dir_path / "onionshare.log" assert onion_url in log_file.read_text() @@ -285,22 +316,23 @@ def test_build_without_url_skips_qr(tmp_path, monkeypatch, capsys): _setup_tmp(monkeypatch, tmp_path) # Remove URLs from config - config = json.load(open(tmp_path / "src" / "config.json")) + res_root = tmp_path / "data" / "voxvera" + test_data_dir = tmp_path / "data" + config = json.load(open(res_root / "src" / "config.json")) config["url"] = "" config["tear_off_link"] = "" - with open(tmp_path / "src" / "config.json", "w") as f: + with open(test_data_dir / "config.json", "w") as f: json.dump(config, f) - # Remove any existing QR files from src to test fresh generation + # Remove any existing QR files from src and host to test fresh generation for qr_file in ["qrcode-content.png", "qrcode-tear-offs.png"]: - qr_path = tmp_path / "src" / qr_file - if qr_path.exists(): - qr_path.unlink() - + (res_root / "src" / qr_file).unlink(missing_ok=True) + (test_data_dir / "host" / config["folder_name"] / qr_file).unlink(missing_ok=True) # Build should complete without error, just skip QR generation cli.main(["build"]) - dest = tmp_path / "host" / config["folder_name"] + test_data_dir = tmp_path / "data" + dest = test_data_dir / "host" / config["folder_name"] assert dest.is_dir() assert (dest / "config.json").exists() assert (dest / "index.html").exists() @@ -314,21 +346,22 @@ def test_build_with_only_url(tmp_path, monkeypatch): _setup_tmp(monkeypatch, tmp_path) # Set only url (content link) - config = json.load(open(tmp_path / "src" / "config.json")) + res_root = tmp_path / "data" / "voxvera" + test_data_dir = tmp_path / "data" + config = json.load(open(res_root / "src" / "config.json")) config["url"] = "https://example.com/external-resource" config["tear_off_link"] = "" - with open(tmp_path / "src" / "config.json", "w") as f: + with open(test_data_dir / "config.json", "w") as f: json.dump(config, f) - # Remove any existing QR files from src to test fresh generation + # Remove any existing QR files from src and host to test fresh generation for qr_file in ["qrcode-content.png", "qrcode-tear-offs.png"]: - qr_path = tmp_path / "src" / qr_file - if qr_path.exists(): - qr_path.unlink() - + (res_root / "src" / qr_file).unlink(missing_ok=True) + (test_data_dir / "host" / config["folder_name"] / qr_file).unlink(missing_ok=True) cli.main(["build"]) - dest = tmp_path / "host" / config["folder_name"] + test_data_dir = tmp_path / "data" + dest = test_data_dir / "host" / config["folder_name"] # Only content QR should exist (tear-off will be set by serve) assert (dest / "qrcode-content.png").exists() assert not (dest / "qrcode-tear-offs.png").exists() @@ -339,21 +372,22 @@ def test_build_with_only_tear_off_link(tmp_path, monkeypatch): _setup_tmp(monkeypatch, tmp_path) # Set only tear_off_link (before serve sets it to onion) - config = json.load(open(tmp_path / "src" / "config.json")) + res_root = tmp_path / "data" / "voxvera" + test_data_dir = tmp_path / "data" + config = json.load(open(res_root / "src" / "config.json")) config["url"] = "" config["tear_off_link"] = "http://preconfigured.onion" - with open(tmp_path / "src" / "config.json", "w") as f: + with open(test_data_dir / "config.json", "w") as f: json.dump(config, f) - # Remove any existing QR files from src to test fresh generation + # Remove any existing QR files from src and host to test fresh generation for qr_file in ["qrcode-content.png", "qrcode-tear-offs.png"]: - qr_path = tmp_path / "src" / qr_file - if qr_path.exists(): - qr_path.unlink() - + (res_root / "src" / qr_file).unlink(missing_ok=True) + (test_data_dir / "host" / config["folder_name"] / qr_file).unlink(missing_ok=True) cli.main(["build"]) - dest = tmp_path / "host" / config["folder_name"] + test_data_dir = tmp_path / "data" + dest = test_data_dir / "host" / config["folder_name"] # Only tear-off QR should exist (will be updated by serve) assert not (dest / "qrcode-content.png").exists() assert (dest / "qrcode-tear-offs.png").exists() @@ -400,15 +434,18 @@ def test_backward_compatibility_with_https_config(tmp_path, monkeypatch): _setup_tmp(monkeypatch, tmp_path) # Simulate old config with HTTPS URLs - config = json.load(open(tmp_path / "src" / "config.json")) + res_root = tmp_path / "data" / "voxvera" + test_data_dir = tmp_path / "data" + config = json.load(open(res_root / "src" / "config.json")) config["url"] = "https://example.com/old-style" config["tear_off_link"] = "https://example.com/tear-off" - with open(tmp_path / "src" / "config.json", "w") as f: + with open(test_data_dir / "config.json", "w") as f: json.dump(config, f) cli.main(["build"]) - dest = tmp_path / "host" / config["folder_name"] + test_data_dir = tmp_path / "data" + dest = test_data_dir / "host" / config["folder_name"] assert (dest / "qrcode-content.png").exists() assert (dest / "qrcode-tear-offs.png").exists() @@ -417,15 +454,18 @@ def test_long_urls_in_qr(tmp_path, monkeypatch): """Test that long URLs still generate valid QR codes.""" _setup_tmp(monkeypatch, tmp_path) - config = json.load(open(tmp_path / "src" / "config.json")) + res_root = tmp_path / "data" / "voxvera" + test_data_dir = tmp_path / "data" + config = json.load(open(res_root / "src" / "config.json")) # Very long URL (close to 200 char limit) config["url"] = "http://" + "a" * 190 + ".onion" config["tear_off_link"] = "https://example.com/" + "path/" * 20 - with open(tmp_path / "src" / "config.json", "w") as f: + with open(test_data_dir / "config.json", "w") as f: json.dump(config, f) cli.main(["build"]) - dest = tmp_path / "host" / config["folder_name"] + test_data_dir = tmp_path / "data" + dest = test_data_dir / "host" / config["folder_name"] assert (dest / "qrcode-content.png").exists() assert (dest / "qrcode-tear-offs.png").exists() diff --git a/tests/test_e2e.py b/tests/test_e2e.py index a09e9dc..a07e157 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -107,13 +107,18 @@ class TestFullWorkflow: # Setup temp workspace repo_root = Path(__file__).resolve().parent.parent - shutil.copytree(repo_root / "voxvera" / "src", tmp_path / "src") - shutil.copytree(repo_root / "voxvera" / "locales", tmp_path / "locales") + # Internal resources (ROOT) + res_root = tmp_path / "voxvera" + res_root.mkdir() + shutil.copytree(repo_root / "voxvera" / "src", res_root / "src") + shutil.copytree(repo_root / "voxvera" / "locales", res_root / "locales") + shutil.copytree(repo_root / "voxvera" / "templates", res_root / "templates") - # Monkeypatch ROOT to use temp directory - monkeypatch.setattr(cli, "ROOT", tmp_path) - monkeypatch.setattr(cli, "_src_res", lambda *p: tmp_path / "src" / Path(*p)) - monkeypatch.setattr(cli, "_locale_res", lambda *p: tmp_path / "locales" / Path(*p)) + monkeypatch.setattr(cli, "ROOT", res_root) + monkeypatch.setattr(cli, "DATA_DIR", tmp_path) + monkeypatch.setattr(cli, "_src_res", lambda *p: res_root / "src" / Path(*p)) + monkeypatch.setattr(cli, "_locale_res", lambda *p: res_root / "locales" / Path(*p)) + monkeypatch.setattr(cli, "_template_res", lambda *p: res_root / "templates" / Path(*p)) # Create test config config = { @@ -203,12 +208,17 @@ class TestFullWorkflow: # Setup repo_root = Path(__file__).resolve().parent.parent - shutil.copytree(repo_root / "voxvera" / "src", tmp_path / "src") - shutil.copytree(repo_root / "voxvera" / "locales", tmp_path / "locales") + res_root = tmp_path / "voxvera" + res_root.mkdir() + shutil.copytree(repo_root / "voxvera" / "src", res_root / "src") + shutil.copytree(repo_root / "voxvera" / "locales", res_root / "locales") + shutil.copytree(repo_root / "voxvera" / "templates", res_root / "templates") - monkeypatch.setattr(cli, "ROOT", tmp_path) - monkeypatch.setattr(cli, "_src_res", lambda *p: tmp_path / "src" / Path(*p)) - monkeypatch.setattr(cli, "_locale_res", lambda *p: tmp_path / "locales" / Path(*p)) + monkeypatch.setattr(cli, "ROOT", res_root) + monkeypatch.setattr(cli, "DATA_DIR", tmp_path) + monkeypatch.setattr(cli, "_src_res", lambda *p: res_root / "src" / Path(*p)) + monkeypatch.setattr(cli, "_locale_res", lambda *p: res_root / "locales" / Path(*p)) + monkeypatch.setattr(cli, "_template_res", lambda *p: res_root / "templates" / Path(*p)) config = { "name": "Reachability Test", @@ -333,12 +343,17 @@ class TestSiteFiles: # Setup repo_root = Path(__file__).resolve().parent.parent - shutil.copytree(repo_root / "voxvera" / "src", tmp_path / "src") - shutil.copytree(repo_root / "voxvera" / "locales", tmp_path / "locales") + res_root = tmp_path / "voxvera" + res_root.mkdir() + shutil.copytree(repo_root / "voxvera" / "src", res_root / "src") + shutil.copytree(repo_root / "voxvera" / "locales", res_root / "locales") + shutil.copytree(repo_root / "voxvera" / "templates", res_root / "templates") - monkeypatch.setattr(cli, "ROOT", tmp_path) - monkeypatch.setattr(cli, "_src_res", lambda *p: tmp_path / "src" / Path(*p)) - monkeypatch.setattr(cli, "_locale_res", lambda *p: tmp_path / "locales" / Path(*p)) + monkeypatch.setattr(cli, "ROOT", res_root) + monkeypatch.setattr(cli, "DATA_DIR", tmp_path) + monkeypatch.setattr(cli, "_src_res", lambda *p: res_root / "src" / Path(*p)) + monkeypatch.setattr(cli, "_locale_res", lambda *p: res_root / "locales" / Path(*p)) + monkeypatch.setattr(cli, "_template_res", lambda *p: res_root / "templates" / Path(*p)) config = { "name": "Test", @@ -407,11 +422,13 @@ class TestErrorHandling: class TestTorIntegration: """Tests for Tor integration.""" - def test_tor_auto_detection_falls_back_to_defaults(self, monkeypatch): + def test_tor_auto_detection_falls_back_to_defaults(self, monkeypatch, tmp_path): """Test that Tor port detection falls back to defaults when Tor isn't running.""" sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from voxvera import cli + monkeypatch.setattr(cli, "DATA_DIR", tmp_path) + # Clear environment variables monkeypatch.delenv("TOR_SOCKS_PORT", raising=False) monkeypatch.delenv("TOR_CONTROL_PORT", raising=False) @@ -440,11 +457,13 @@ class TestTorIntegration: assert socks == "9050" assert ctl == "9051" - def test_tor_env_vars_override_detection(self, monkeypatch): + def test_tor_env_vars_override_detection(self, monkeypatch, tmp_path): """Test that environment variables override auto-detection.""" sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from voxvera import cli + monkeypatch.setattr(cli, "DATA_DIR", tmp_path) + monkeypatch.setenv("TOR_SOCKS_PORT", "9999") monkeypatch.setenv("TOR_CONTROL_PORT", "8888") diff --git a/voxvera/__init__.py b/voxvera/__init__.py index 44c24bd..057e850 100644 --- a/voxvera/__init__.py +++ b/voxvera/__init__.py @@ -1,7 +1,7 @@ import sys from pathlib import Path -__version__ = "0.2.7" +__version__ = "0.2.8" # Add the vendor directory to sys.path. We prepend it to ensure our bundled # dependencies take priority over potentially incompatible system packages diff --git a/voxvera/cli.py b/voxvera/cli.py index 5bb5645..65b3b22 100644 --- a/voxvera/cli.py +++ b/voxvera/cli.py @@ -17,9 +17,15 @@ from voxvera import __version__ # package root (contains bundled templates and src/) if getattr(sys, 'frozen', False): ROOT = Path(sys._MEIPASS).joinpath("voxvera") + # Ensure the bundle root is in sys.path for collected packages + if sys._MEIPASS not in sys.path: + sys.path.insert(0, sys._MEIPASS) else: ROOT = Path(__file__).resolve().parent +# Persistent data directory (where host/ and projects live) +DATA_DIR = Path(os.environ.get("VOXVERA_DIR", Path.cwd())) + # Global locale state CURRENT_LOCALE = {} @@ -412,9 +418,13 @@ def copy_template(name: str) -> str: if not src.is_dir(): print(f"Template {name} not found", file=sys.stderr) sys.exit(1) - dest = ROOT / "host" / name - os.makedirs(ROOT / "host", exist_ok=True) + dest = DATA_DIR / "host" / name + os.makedirs(DATA_DIR / "host", exist_ok=True) shutil.copytree(src, dest, dirs_exist_ok=True) + # Clear any QR codes that might have been in the template + # to ensure a clean build slate. + for qr_file in ["qrcode-content.png", "qrcode-tear-offs.png"]: + (dest / qr_file).unlink(missing_ok=True) print(f"Template copied to {dest}") return str(dest) @@ -460,8 +470,6 @@ def build_assets( with resources.as_file(_src_res()) as src_dir: config_path = Path(config_path) - # generate QR codes (pure Python) - generate_qr(config_path, src_dir) data = load_config(config_path) with open(src_dir / "index-master.html", "r") as fh: @@ -547,10 +555,30 @@ def build_assets( html = html.replace("{{attachment_display}}", attachment_display) folder_name = data["folder_name"] - dest = ROOT / "host" / folder_name + dest = DATA_DIR / "host" / folder_name os.makedirs(dest, exist_ok=True) + # 1. Refresh QR codes directly in destination + # generate_qr only creates them if URLs are present in config + from voxvera.build import generate_qr + + # We generate to a temp dir first to ensure we ONLY get what's in current config + import tempfile + with tempfile.TemporaryDirectory() as qr_tmp: + qr_tmp_path = Path(qr_tmp) + generate_qr(config_path, qr_tmp_path) + + # Clear old QR codes from destination and copy new ones + for qr_file in ["qrcode-content.png", "qrcode-tear-offs.png"]: + qr_dest = dest / qr_file + qr_src = qr_tmp_path / qr_file + if qr_src.exists(): + shutil.copy(qr_src, qr_dest) + elif qr_dest.exists(): + qr_dest.unlink() + # Update download link to point to our viral source bundle + html = html.replace("download/download.zip", "download/voxvera-source.zip") with open(dest / "index.html", "w") as fh: @@ -571,12 +599,6 @@ def build_assets( if Path(config_path) != dest / "config.json": shutil.copy(config_path, dest / "config.json") - # Copy QR codes if they exist - for qr_file in ["qrcode-content.png", "qrcode-tear-offs.png"]: - qr_src = Path(src_dir) / qr_file - if qr_src.exists(): - shutil.copy(qr_src, dest / qr_file) - print(f"Flyer files created under {dest}") @@ -648,20 +670,24 @@ def _internal_onionshare(): 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 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) + except ImportError as e: + import traceback + traceback.print_exc() + print(f"Error: onionshare-cli not bundled or installed correctly: {e}", file=sys.stderr) + print(f"DEBUG: sys.path = {sys.path}", 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"] - dir_path = ROOT / "host" / folder_name + dir_path = DATA_DIR / "host" / folder_name if not dir_path.is_dir(): print(f"Directory {dir_path} not found", file=sys.stderr) sys.exit(1) @@ -928,25 +954,31 @@ def build_docs(): def build_site(): """Refresh the main site/ directory with the latest template, QRs, and portable bundle.""" + # When frozen, site/ source is in sys._MEIPASS, but we might want to output to CWD/site if getattr(sys, 'frozen', False): - site_dir = Path(sys._MEIPASS).joinpath("site") + src_site = Path(sys._MEIPASS).joinpath("site") + dest_site = Path.cwd() / "site" else: - site_dir = ROOT.parent / "site" + src_site = ROOT.parent / "site" + dest_site = src_site - if not site_dir.exists(): - print(f"Error: {site_dir} directory not found.") + if not src_site.exists(): + print(f"Error: {src_site} directory not found.") return - config_path = site_dir / "config.json" + config_path = dest_site / "config.json" if not config_path.exists(): - print(f"Error: {config_path} not found.") - return + # Fallback to source if dest doesn't have it (e.g. first run) + config_path = src_site / "config.json" + if not config_path.exists(): + print(f"Error: {config_path} not found.") + return - print("Refreshing site/ assets...") + print(f"Refreshing {dest_site} assets...") # 1. Refresh QR codes using site/config.json from voxvera.build import generate_qr - generate_qr(config_path, site_dir) + generate_qr(config_path, dest_site) # 2. Build the HTML from master template data = load_config(config_path) @@ -1026,11 +1058,12 @@ def build_site(): for key, value in data.items(): html = html.replace(f"{{{{{key}}}}}", str(value)) - with open(site_dir / "index.html", "w") as fh: + os.makedirs(dest_site, exist_ok=True) + with open(dest_site / "index.html", "w") as fh: fh.write(html) # 5. Create the download assets in site/download/ - download_dir = site_dir / "download" + download_dir = dest_site / "download" os.makedirs(download_dir, exist_ok=True) # Bundle source version @@ -1070,33 +1103,39 @@ def vendorize(): def batch_import_configs(): - files = sorted(glob.glob(str(ROOT / "imports" / "*.json"))) + files = sorted(glob.glob(str(DATA_DIR / "imports" / "*.json"))) if not files: print("No JSON files found in imports") return for json_file in files: print(f"Processing {json_file}") - dest_config = ROOT / "src" / "config.json" + # Use a temp config path in DATA_DIR instead of bundled ROOT + dest_config = DATA_DIR / "temp_config.json" shutil.copy(json_file, dest_config) folder_name = load_config(json_file)["folder_name"] - _clear_host_dir(ROOT / "host" / folder_name) - build_assets(dest_config) + _clear_host_dir(DATA_DIR / "host" / folder_name) + build_assets(str(dest_config)) + dest_config.unlink(missing_ok=True) def get_servers() -> list[str]: - # Check both bundled ROOT/host and local CWD/host - search_paths = [ROOT / "host", Path.cwd() / "host"] + # Check both bundled ROOT/host and local DATA_DIR/host + search_paths = [] + if ROOT != DATA_DIR: + search_paths.append(ROOT / "host") + search_paths.append(DATA_DIR / "host") + servers = set() for host_dir in search_paths: if host_dir.exists(): for d in host_dir.iterdir(): if d.is_dir() and (d / "config.json").exists(): - servers.append(d.name) if hasattr(servers, 'append') else servers.add(d.name) + servers.add(d.name) return sorted(list(servers)) def is_server_running(folder_name: str) -> bool: - pid_file = ROOT / "host" / folder_name / "server.pid" + pid_file = DATA_DIR / "host" / folder_name / "server.pid" if not pid_file.exists(): return False try: @@ -1135,7 +1174,7 @@ def is_server_running(folder_name: str) -> bool: def stop_server(folder_name: str): - pid_file = ROOT / "host" / folder_name / "server.pid" + pid_file = DATA_DIR / "host" / folder_name / "server.pid" if not pid_file.exists(): print(f"Server {folder_name} is not running.") return @@ -1157,7 +1196,7 @@ def stop_server(folder_name: str): def delete_server(folder_name: str): if is_server_running(folder_name): stop_server(folder_name) - dir_path = ROOT / "host" / folder_name + dir_path = DATA_DIR / "host" / folder_name if dir_path.exists(): shutil.rmtree(dir_path, ignore_errors=True) print(f"Deleted server {folder_name}") @@ -1179,7 +1218,7 @@ def manage_servers(): label = f"{status_text} {s}" if running: try: - site_data = load_config(ROOT / "host" / s / "config.json") + site_data = load_config(DATA_DIR / "host" / s / "config.json") url = site_data.get("tear_off_link") if url: label += f" ({url})" @@ -1204,7 +1243,7 @@ def manage_servers(): break if selected == "create_new": - config_path = ROOT / "src" / "config.json" + config_path = DATA_DIR / "config.json" # Reset config from template for a fresh start try: template_config = _template_res("voxvera", "config.json") @@ -1228,7 +1267,7 @@ def manage_servers(): results = [] for s in servers: if not is_server_running(s): - config_path = ROOT / "host" / s / "config.json" + config_path = DATA_DIR / "host" / s / "config.json" try: url = serve(str(config_path)) if url: @@ -1275,13 +1314,13 @@ def manage_servers(): if running: stop_server(selected) else: - config_path = ROOT / "host" / selected / "config.json" + config_path = DATA_DIR / "host" / selected / "config.json" try: serve(str(config_path)) except SystemExit: pass elif action == "edit": - config_path = ROOT / "host" / selected / "config.json" + config_path = DATA_DIR / "host" / selected / "config.json" try: interactive_update(str(config_path)) # Re-build assets after update @@ -1340,7 +1379,7 @@ def main(argv=None): parser = argparse.ArgumentParser(prog="voxvera") parser.add_argument( "--config", - default=str(ROOT / "src" / "config.json"), + default=str(DATA_DIR / "config.json"), help="Path to config.json", ) parser.add_argument( @@ -1453,7 +1492,6 @@ def main(argv=None): elif not args.non_interactive: interactive_update(config_path) build_assets(config_path) - serve(config_path) elif args.command == "build": build_assets(config_path, download_path=args.download) elif args.command == "export": @@ -1481,7 +1519,6 @@ def main(argv=None): sys.exit(1) interactive_update(config_path) build_assets(config_path) - serve(config_path) elif args.command == "check": check_deps() elif args.command == "vendorize":