Compare commits

...
7 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
thePR0M3TH3AN f530e54f45 fix(build): force collect-all for pkg_resources and setuptools to guarantee inclusion (v0.2.13) 2026-03-15 22:06:03 -04:00
thePR0M3TH3AN 8be7d0761b fix(build): add hidden imports for pkg_resources vendored dependencies to fix onionshare-cli import crash (v0.2.12) 2026-03-15 21:31:02 -04:00
thePR0M3TH3AN 6d747f719f chore: clear release notes after v0.2.11 2026-03-15 21:20:28 -04:00
thePR0M3TH3AN dc83b76317 fix(cli): resolve PyInstaller nano crash, pkg_resources import error, and streamline attachment vs URL logic (v0.2.11) 2026-03-15 21:20:26 -04:00
10 changed files with 124 additions and 78 deletions

No files matched your search

+11
View File
@@ -34,6 +34,7 @@ jobs:
- name: Install dependencies
run: |
pip install --upgrade setuptools pkg_resources || pip install --upgrade setuptools
pip install -r requirements.txt
pip install .
pip install pyinstaller pyinstaller-hooks-contrib
@@ -49,6 +50,16 @@ jobs:
run: |
if [ "$RUNNER_OS" == "Windows" ]; then SEP=";"; else SEP=":"; fi
pyinstaller --onefile \
--collect-all pkg_resources \
--collect-all setuptools \
--hidden-import pkg_resources \
--hidden-import setuptools \
--hidden-import jaraco.text \
--hidden-import jaraco.functools \
--hidden-import jaraco.context \
--hidden-import more_itertools \
--hidden-import packaging \
--hidden-import platformdirs \
--collect-all onionshare_cli \
--collect-all flask_socketio \
--collect-all engineio \
-7
View File
@@ -1,8 +1 @@
# VoxVera v0.2.4
## New Features
- **Optional File Attachment:** Users can now attach a file (e.g., a .torrent, image, or document) to their flyers. This file is packaged into the Tor-hosted site and made available via a "Download Attachment" button on the landing page.
- **Interactive Attachment Selection:** Added a prompt during `voxvera init` to easily specify the path to an attachment.
## Localization
- Added new tokens for attachment handling across all 14+ supported languages.
+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.10"
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",
]
+12
View File
@@ -0,0 +1,12 @@
import sys
import os
try:
import pkg_resources
import setuptools
print("pkg_resources available")
except ImportError as e:
print(f"Failed to import: {e}")
from voxvera import cli
cli._internal_onionshare()
+2
View File
@@ -0,0 +1,2 @@
import pkg_resources
print("Success")
+2
View File
@@ -0,0 +1,2 @@
import pkg_resources
print("Success")
+5 -9
View File
@@ -1,16 +1,12 @@
import sys
from pathlib import Path
__version__ = "0.2.10"
__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))
+87 -58
View File
@@ -10,6 +10,20 @@ import time
from pathlib import Path
from importlib import resources
from importlib.resources.abc import Traversable
# 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
from voxvera import __version__
@@ -24,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 = {}
@@ -180,7 +194,7 @@ def _open_editor_terminal(initial: str) -> str:
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(initial or "")
subprocess.call([editor, path])
subprocess.run([editor, path], stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr)
with open(path, "r", encoding="utf-8") as fh:
return fh.read()
except Exception as e:
@@ -375,11 +389,18 @@ def interactive_update(config_path: str):
console.rule(t("cli.init_links"))
link_qs = [
{
"type": "input",
"message": t("cli.attachment_path_label"),
"name": "attachment_path",
"default": data.get("attachment_path", ""),
},
{
"type": "input",
"message": t("cli.url_label"),
"name": "url",
"default": data.get("url", "https://voxvera.org/"),
"default": data.get("url", "https://voxvera.org/") if not data.get("url", "").startswith("download/") else "",
"when": lambda result: not result.get("attachment_path", "").strip(),
"transformer": _len_transform(200),
"validate": _len_validator(200),
},
@@ -391,21 +412,6 @@ def interactive_update(config_path: str):
"transformer": _len_transform(120),
"validate": _len_validator(120),
},
{
"type": "input",
"message": t("cli.binary_message_label"),
"name": "binary_message",
"default": data.get("binary_message", ""),
"transformer": _len_transform(120),
"validate": _len_validator(120),
},
{
"type": "input",
"message": t("cli.attachment_path_label"),
"name": "attachment_path",
"default": data.get("attachment_path", ""),
},
]
data.update(prompt(link_qs))
@@ -542,18 +548,17 @@ def build_assets(
val_str = re.sub(r"~~(.*?)~~", r'<span class="redacted">\1</span>', val_str)
html = html.replace(f"{{{{{key}}}}}", val_str)
# Handle optional file attachment
# Handle optional file attachment logic
attachment_path = data.get("attachment_path")
attachment_url = ""
attachment_display = "none"
if attachment_path and os.path.isfile(attachment_path):
att_filename = os.path.basename(attachment_path)
attachment_url = f"download/{att_filename}"
attachment_display = "inline-block"
html = html.replace("{{attachment_url}}", attachment_url)
html = html.replace("{{attachment_display}}", attachment_display)
# 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}"
save_config(data, config_path)
html = html.replace("{{url}}", data["url"])
folder_name = data["folder_name"]
dest = DATA_DIR / "host" / folder_name
os.makedirs(dest, exist_ok=True)
@@ -664,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)
@@ -696,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",
@@ -712,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)
@@ -1116,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:
@@ -1205,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()
-3
View File
@@ -579,9 +579,6 @@
<a id="download-link" href="download/voxvera-source.zip" download>
<button class="download-button" data-i18n="web.download_button">{{t_web_download_button}}</button>
</a>
<a id="attachment-link" href="{{attachment_url}}" download style="display: {{attachment_display}};">
<button class="download-button" data-i18n="web.download_attachment">{{t_web_download_attachment}}</button>
</a>
<select class="lang-select" id="lang-selector" onchange="setLanguage(this.value)"></select>
</div>
</body>