Compare commits

...
3 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 4.6 4e571b62ea fix(cli): robust onionshare invocation, delete ghost sites, stabilize DATA_DIR (v0.2.16)
Major fixes:
- Use system onionshare-cli binary directly (subprocess/execvp) instead of
  importing it as a Python module, avoiding pkg_resources crashes in pipx venvs
- get_servers() now only searches DATA_DIR/host (single source of truth);
  bundled example sites are seeded on first manage run then fully user-owned
- delete_server() now permanently removes sites (no ghost re-appearance)
- DATA_DIR defaults to ~/  instead of cwd, preventing sites from scattering
  across directories
- Fix attachment URL logic that incorrectly overwrote valid onion URLs

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-16 09:29:02 -04:00
thePR0M3TH3ANandClaude Opus 4.6 3bb8627d20 fix(install): restrict vendor sys.path and PyInstaller imports to frozen builds only (v0.2.15)
The vendor directory was unconditionally prepended to sys.path, causing
pip/pipx installs to load incomplete vendored setuptools (missing data
files like Lorem ipsum.txt) instead of the system package. Now the
vendor path is only added for PyInstaller frozen builds, and the
force-import block in cli.py is similarly guarded behind sys.frozen.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-16 09:11:23 -04:00
thePR0M3TH3ANandClaude Opus 4.6 4e84466d38 fix(install): inject setuptools into pipx venv to fix pkg_resources import crash (v0.2.14)
pipx can omit setuptools at runtime even when listed as a dependency,
causing onionshare-cli to crash on `from pkg_resources import resource_filename`.
Add explicit `pipx inject` after install and ensure setuptools is in deps.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-16 08:41:53 -04:00
4 changed files with 81 additions and 56 deletions

No files matched your search

+3
View File
@@ -156,6 +156,9 @@ fi
if command_exists pipx; then
msg "Falling back to pipx installation (this may take a few minutes)..."
if pipx install --force 'voxvera@git+https://github.com/PR0M3TH3AN/VoxVera.git@main'; then
# Ensure setuptools (pkg_resources) is available in the venv — pipx can
# sometimes skip it if the resolver thinks the build-system copy suffices.
pipx inject voxvera setuptools 2>/dev/null || true
pipx ensurepath --force
msg "\nVoxVera installed/updated successfully via pipx."
msg "IMPORTANT: Please restart your terminal or run 'source ~/.bashrc' (or your shell config) to use 'voxvera'."
+2 -1
View File
@@ -14,7 +14,7 @@ voxvera = ["templates/**", "src/**", "resources/**", "locales/**", "resources/bi
[project]
name = "voxvera"
version = "0.2.13"
version = "0.2.16"
description = "VoxVera CLI utilities"
readme = "README.md"
authors = [{name = "VoxVera"}]
@@ -29,6 +29,7 @@ dependencies = [
"pypdf",
"wcwidth",
"onionshare-cli",
"setuptools",
"flask<2.3.0",
"werkzeug<3.0.0",
]
+5 -9
View File
@@ -1,16 +1,12 @@
import sys
from pathlib import Path
__version__ = "0.2.13"
__version__ = "0.2.16"
# Add the vendor directory to sys.path. We prepend it to ensure our bundled
# dependencies take priority over potentially incompatible system packages
# (e.g. Flask/Werkzeug version mismatches on Debian).
# Add the vendor directory to sys.path ONLY for PyInstaller (frozen) builds.
# For pip/pipx installs the system/venv packages are correct and the vendored
# copies are incomplete (missing data files), so we must not shadow them.
if getattr(sys, 'frozen', False):
vendor_dir = Path(sys._MEIPASS).joinpath("voxvera", "vendor")
else:
vendor_dir = Path(__file__).parent / "vendor"
if vendor_dir.exists():
if str(vendor_dir) not in sys.path:
if vendor_dir.exists() and str(vendor_dir) not in sys.path:
sys.path.insert(0, str(vendor_dir))
+71 -46
View File
@@ -11,15 +11,18 @@ from pathlib import Path
from importlib import resources
from importlib.resources.abc import Traversable
# Force PyInstaller to bundle these critical dependencies for onionshare-cli
try:
import pkg_resources
import setuptools
import jaraco
import platformdirs
import packaging
except ImportError:
pass
# Force PyInstaller to bundle these critical dependencies for onionshare-cli.
# Only needed at freeze-time analysis; skip for normal pip/pipx installs to
# avoid pulling in vendored copies that may be incomplete.
if getattr(sys, 'frozen', False):
try:
import pkg_resources
import setuptools
import jaraco
import platformdirs
import packaging
except Exception:
pass
from InquirerPy import prompt, inquirer
from rich.console import Console
@@ -35,7 +38,7 @@ else:
ROOT = Path(__file__).resolve().parent
# Persistent data directory (where host/ and projects live)
DATA_DIR = Path(os.environ.get("VOXVERA_DIR", Path.cwd()))
DATA_DIR = Path(os.environ.get("VOXVERA_DIR", Path.home()))
# Global locale state
CURRENT_LOCALE = {}
@@ -549,13 +552,11 @@ def build_assets(
attachment_path = data.get("attachment_path")
if attachment_path and os.path.isfile(attachment_path):
att_filename = os.path.basename(attachment_path)
# We don't replace the URL here if it's already an onion link (e.g. during serve rebuild)
if not data.get("url", "").startswith("http://") or ".onion" not in data.get("url", ""):
# Only default to the local attachment path if no onion URL is set
current_url = data.get("url", "")
if not (current_url.startswith("http") and ".onion" in current_url):
data["url"] = f"download/{att_filename}"
# Must write back to config so generate_qr uses the new URL
save_config(data, config_path)
# Also update the HTML we just replaced if we changed it
html = html.replace("{{url}}", data["url"])
folder_name = data["folder_name"]
@@ -668,20 +669,26 @@ def _internal_onionshare():
bundled_tor = tor_dir / ("tor.exe" if "windows" in system else "tor")
if bundled_tor.exists() and bundled_tor.stat().st_size > 100:
os.environ["PATH"] = str(tor_dir) + os.pathsep + os.environ.get("PATH", "")
# Robustness: explicitly tell OnionShare where Tor is
os.environ["TOR_BINARY"] = str(bundled_tor)
onionshare_args = sys.argv[2:] # strip 'voxvera _internal_onionshare'
# For non-frozen installs (pip/pipx), prefer the system onionshare-cli
# binary to avoid dependency-isolation issues with pkg_resources etc.
if not getattr(sys, 'frozen', False):
onionshare_bin = shutil.which("onionshare-cli") or shutil.which("onionshare")
if onionshare_bin:
os.execvp(onionshare_bin, [onionshare_bin] + onionshare_args)
# execvp replaces the process; if we reach here something went wrong
# Fall through to Python import as last resort
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.argv = ["onionshare-cli"] + onionshare_args
sys.exit(main())
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)
except Exception as e:
print(f"Error: onionshare-cli not available: {e}", file=sys.stderr)
print("Install onionshare-cli via your system package manager.", file=sys.stderr)
sys.exit(1)
@@ -700,11 +707,7 @@ 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 = [
sys.argv[0],
"_internal_onionshare",
onionshare_args = [
"--website",
"--public",
"--persistent",
@@ -716,10 +719,19 @@ def serve(config_path: str) -> str | None:
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:]
if getattr(sys, 'frozen', False):
# PyInstaller build: route through our hidden subcommand
cmd = [sys.argv[0], "_internal_onionshare"] + onionshare_args
else:
# pip/pipx install: call the system onionshare-cli binary directly
onionshare_bin = shutil.which("onionshare-cli") or shutil.which("onionshare")
if onionshare_bin:
cmd = [onionshare_bin] + onionshare_args
else:
# Last resort: route through our subcommand (will try Python import)
cmd = [sys.argv[0], "_internal_onionshare"] + onionshare_args
if sys.argv[0].endswith('.py'):
cmd = [sys.executable] + cmd
log_fh = open(logfile, "w")
proc = subprocess.Popen(cmd, stdout=log_fh, stderr=subprocess.STDOUT, env=env)
@@ -1120,20 +1132,30 @@ def batch_import_configs():
dest_config.unlink(missing_ok=True)
def _seed_bundled_sites():
"""Copy bundled example sites from ROOT/host/ to DATA_DIR/host/ if they
don't already exist there. This runs once — after that, only DATA_DIR is
the source of truth so deletes actually stick."""
bundled_host = ROOT / "host"
if not bundled_host.exists() or ROOT == DATA_DIR:
return
dest_host = DATA_DIR / "host"
for src in bundled_host.iterdir():
if src.is_dir() and (src / "config.json").exists():
dest = dest_host / src.name
if not dest.exists():
shutil.copytree(src, dest)
def get_servers() -> list[str]:
# 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.add(d.name)
return sorted(list(servers))
"""Return server names found in DATA_DIR/host only."""
host_dir = DATA_DIR / "host"
servers = []
if host_dir.exists():
for d in host_dir.iterdir():
if d.is_dir() and (d / "config.json").exists():
servers.append(d.name)
return sorted(servers)
def is_server_running(folder_name: str) -> bool:
@@ -1209,6 +1231,9 @@ def manage_servers():
from InquirerPy.base.control import Choice
console = Console()
# On first run, copy any bundled example sites so they're user-editable.
_seed_bundled_sites()
while True:
servers = get_servers()