Compare commits

...
7 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 4.6 bc64d1c3b5 feat(cli): persistent servers, start-all/stop-all, autostart on boot (v0.3.0)
Sites now survive terminal close and can start automatically on boot:

- subprocess.Popen uses start_new_session=True (Unix) or
  CREATE_NEW_PROCESS_GROUP (Windows) so onionshare-cli processes
  are fully detached and immune to SIGHUP when the terminal exits
- New `voxvera start-all` / `stop-all` commands for non-interactive
  batch control of all configured sites
- New `voxvera autostart` installs platform-appropriate boot service:
  - Linux: systemd user service + loginctl enable-linger
  - macOS: launchd LaunchAgent
  - Windows: schtasks scheduled task on login

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-16 11:00:44 -04:00
thePR0M3TH3ANandClaude Opus 4.6 b5ab447207 fix(cli): set PYTHONNOUSERSITE=1 for onionshare-cli subprocess, robust cleanup (v0.2.19)
Root cause: user-local werkzeug 2.0.3 in ~/.local/lib/python3.11/
site-packages shadows the system werkzeug 2.2.2, breaking Flask's
LocalProxy.unbound_message for the system onionshare-cli.

The pip3 show-based cleanup was unreliable (shows system package,
skips user-local). Two-pronged fix:

1. Set PYTHONNOUSERSITE=1 in the env passed to onionshare-cli so it
   never loads packages from ~/.local — uses only system packages.
2. Install script now directly rm -rf's conflicting packages from
   ~/.local/lib/python3.*/site-packages/ instead of relying on pip3.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-16 10:14:39 -04:00
thePR0M3TH3ANandClaude Opus 4.6 35069e24c5 fix(install): remove flask/werkzeug pins, clean stale pip packages (v0.2.18)
Previous pip --user installs left werkzeug 2.0.3 in ~/.local which
shadows the system werkzeug and breaks the system onionshare-cli
(Flask 2.2.2 requires werkzeug >= 2.2 for LocalProxy.unbound_message).

Changes:
- Remove flask, werkzeug, and onionshare-cli from voxvera's own
  dependencies — they are onionshare's transitive deps, not ours
- Install script now cleans up stale user-local packages (werkzeug,
  flask, flask-socketio, onionshare-cli, voxvera) before installing
- Prevents pip --user packages from poisoning system Python environment

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-16 10:06:17 -04:00
thePR0M3TH3ANandClaude Opus 4.6 51ab72239f fix(cli): always use system onionshare-cli binary, even in PyInstaller builds (v0.2.17)
The previous fix only used the system binary for non-frozen (pip/pipx)
installs.  But the PyInstaller binary also needs this — it downloads
successfully but its bundled onionshare_cli import still crashes on
'No module named pkg_resources'.

Now both serve() and _internal_onionshare() unconditionally prefer the
system onionshare-cli/onionshare binary via shutil.which(), falling
back to the Python import only if no system binary exists.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-16 09:51:44 -04:00
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
6 changed files with 268 additions and 65 deletions

No files matched your search

+16
View File
@@ -96,6 +96,19 @@ else
msg "onionshare-cli found (system package), skipping pipx install. Run 'pipx install --force ...' if you want the latest git version."
fi
# Clean up stale user-local packages from prior pip --user installs of voxvera.
# These shadow system packages (e.g. Werkzeug) and break onionshare-cli.
msg "Cleaning up stale user-local packages that may conflict with system onionshare-cli..."
for pydir in "$HOME"/.local/lib/python3.*/site-packages; do
[ -d "$pydir" ] || continue
for pkg in werkzeug flask flask_socketio onionshare_cli voxvera; do
if [ -d "$pydir/$pkg" ]; then
warn "Removing stale $pkg from $pydir"
rm -rf "$pydir/$pkg" "$pydir/${pkg}-"*.dist-info 2>/dev/null || true
fi
done
done
download_binary() {
local url=$1
local dest=$2
@@ -156,6 +169,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 -4
View File
@@ -14,7 +14,7 @@ voxvera = ["templates/**", "src/**", "resources/**", "locales/**", "resources/bi
[project]
name = "voxvera"
version = "0.2.13"
version = "0.3.0"
description = "VoxVera CLI utilities"
readme = "README.md"
authors = [{name = "VoxVera"}]
@@ -28,9 +28,7 @@ dependencies = [
"htmlmin2",
"pypdf",
"wcwidth",
"onionshare-cli",
"flask<2.3.0",
"werkzeug<3.0.0",
"setuptools",
]
[project.optional-dependencies]
test = [
+3 -3
View File
@@ -194,7 +194,7 @@ def test_serve_updates_url(tmp_path, monkeypatch):
onion_url = "http://test123.onion"
class FakePopen:
def __init__(self, cmd, stdout=None, stderr=None, env=None):
def __init__(self, cmd, stdout=None, stderr=None, env=None, **kwargs):
self.cmd = cmd
self.stdout = stdout
self.stderr = stderr
@@ -244,7 +244,7 @@ def test_quickstart_noninteractive(tmp_path, monkeypatch):
onion_url = "http://quick.onion"
class FakePopen:
def __init__(self, cmd, stdout=None, stderr=None, env=None):
def __init__(self, cmd, stdout=None, stderr=None, env=None, **kwargs):
self.cmd = cmd
self.stdout = stdout
self.stderr = stderr
@@ -401,7 +401,7 @@ def test_serve_passes_tor_ports_to_env(monkeypatch, tmp_path):
captured_env = {}
class FakePopen:
def __init__(self, cmd, stdout=None, stderr=None, env=None):
def __init__(self, cmd, stdout=None, stderr=None, env=None, **kwargs):
self.cmd = cmd
self.stdout = stdout
self.stderr = stderr
+1 -1
View File
@@ -153,7 +153,7 @@ class TestFullWorkflow:
onion_url = "http://test123abc.onion"
class MockPopen:
def __init__(self, cmd, stdout=None, stderr=None, env=None):
def __init__(self, cmd, stdout=None, stderr=None, env=None, **kwargs):
self.cmd = cmd
self.stdout = stdout
self.stderr = stderr
+5 -9
View File
@@ -1,16 +1,12 @@
import sys
from pathlib import Path
__version__ = "0.2.13"
__version__ = "0.3.0"
# 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))
+241 -48
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,29 @@ 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'
# Always prefer the system onionshare-cli binary. This avoids
# pkg_resources / setuptools isolation issues in both PyInstaller
# builds and pipx venvs.
# Prevent user-local pip packages from shadowing system packages
os.environ["PYTHONNOUSERSITE"] = "1"
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; unreachable unless execvp itself fails
# Last resort: try to import onionshare_cli as a Python module
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)
@@ -699,12 +709,12 @@ def serve(config_path: str) -> str | None:
env = os.environ.copy()
env["TOR_SOCKS_PORT"] = socks
env["TOR_CONTROL_PORT"] = ctl
# Prevent user-local pip packages (~/.local) from shadowing system packages.
# onionshare-cli depends on specific Flask/Werkzeug versions from the system;
# stale user-local copies break it.
env["PYTHONNOUSERSITE"] = "1"
# 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,13 +726,28 @@ 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:]
# Always prefer the system onionshare-cli binary to avoid pkg_resources
# and dependency-isolation issues inside both PyInstaller and pipx.
onionshare_bin = shutil.which("onionshare-cli") or shutil.which("onionshare")
if onionshare_bin:
cmd = [onionshare_bin] + onionshare_args
else:
# Fallback: route through our hidden subcommand
cmd = [sys.argv[0], "_internal_onionshare"] + onionshare_args
if not getattr(sys, 'frozen', False) and 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)
# Detach the child so it survives when the parent terminal closes.
# Unix: start_new_session prevents SIGHUP propagation.
# Windows: CREATE_NEW_PROCESS_GROUP detaches from the console.
import platform as _platform
popen_kwargs = dict(stdout=log_fh, stderr=subprocess.STDOUT, env=env)
if _platform.system() == "Windows":
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
popen_kwargs["start_new_session"] = True
proc = subprocess.Popen(cmd, **popen_kwargs)
pid_file = dir_path / "server.pid"
with open(pid_file, "w") as f:
@@ -1120,20 +1145,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:
@@ -1204,11 +1239,160 @@ def delete_server(folder_name: str):
print(f"Deleted server {folder_name}")
def start_all_servers():
"""Non-interactive: start every configured site that isn't already running."""
_seed_bundled_sites()
servers = get_servers()
if not servers:
print("No sites configured.")
return
for s in servers:
if is_server_running(s):
print(f"[SKIP] {s} already running")
continue
config_path = DATA_DIR / "host" / s / "config.json"
try:
url = serve(str(config_path))
if url:
print(f"[OK] {s}: {url}")
else:
print(f"[FAIL] {s}: could not obtain onion URL")
except Exception as e:
print(f"[FAIL] {s}: {e}")
def stop_all_servers():
"""Non-interactive: stop every running site."""
servers = get_servers()
stopped = False
for s in servers:
if is_server_running(s):
stop_server(s)
stopped = True
if not stopped:
print("No running sites.")
def install_autostart():
"""Install a platform-appropriate mechanism to start sites on boot."""
import platform
system = platform.system()
if system == "Linux":
_install_systemd_autostart()
elif system == "Darwin":
_install_launchd_autostart()
elif system == "Windows":
_install_windows_autostart()
else:
print(f"Autostart not supported on {system}. "
"Run 'voxvera start-all' manually or add it to your init system.")
def _find_voxvera_bin() -> str:
"""Return the absolute path to the voxvera binary/script."""
voxvera_bin = shutil.which("voxvera")
if voxvera_bin:
return str(Path(voxvera_bin).resolve())
# Fallback: current executable
return str(Path(sys.argv[0]).resolve())
def _install_systemd_autostart():
"""Install a systemd user service for Linux."""
service_dir = Path.home() / ".config" / "systemd" / "user"
service_dir.mkdir(parents=True, exist_ok=True)
service_file = service_dir / "voxvera.service"
voxvera_bin = _find_voxvera_bin()
unit = f"""\
[Unit]
Description=VoxVera Onion Sites
After=network-online.target tor.service
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart={voxvera_bin} start-all
ExecStop={voxvera_bin} stop-all
[Install]
WantedBy=default.target
"""
service_file.write_text(unit)
os.system("systemctl --user daemon-reload")
os.system("systemctl --user enable voxvera.service")
os.system("systemctl --user start voxvera.service")
# loginctl enable-linger ensures user services start at boot even without login
os.system(f"loginctl enable-linger {os.environ.get('USER', '')}")
print(f"Systemd user service installed: {service_file}")
print("Sites will start automatically on boot.")
print(" Manage with: systemctl --user [start|stop|status|disable] voxvera")
def _install_launchd_autostart():
"""Install a launchd plist for macOS."""
agents_dir = Path.home() / "Library" / "LaunchAgents"
agents_dir.mkdir(parents=True, exist_ok=True)
plist_file = agents_dir / "org.voxvera.start-all.plist"
voxvera_bin = _find_voxvera_bin()
plist = f"""\
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>org.voxvera.start-all</string>
<key>ProgramArguments</key>
<array>
<string>{voxvera_bin}</string>
<string>start-all</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>{Path.home()}/host/voxvera-autostart.log</string>
<key>StandardErrorPath</key>
<string>{Path.home()}/host/voxvera-autostart.log</string>
</dict>
</plist>
"""
plist_file.write_text(plist)
os.system(f"launchctl load -w {plist_file}")
print(f"LaunchAgent installed: {plist_file}")
print("Sites will start automatically on login.")
print(f" Disable with: launchctl unload {plist_file}")
def _install_windows_autostart():
"""Add a startup shortcut or scheduled task on Windows."""
voxvera_bin = _find_voxvera_bin()
# Use Task Scheduler for a reliable boot-time trigger
task_name = "VoxVera Start All"
cmd = (
f'schtasks /Create /F /SC ONLOGON /TN "{task_name}" '
f'/TR "\"{voxvera_bin}\" start-all" /RL HIGHEST'
)
ret = os.system(cmd)
if ret == 0:
print(f"Windows scheduled task '{task_name}' created.")
print("Sites will start automatically on login.")
print(f' Remove with: schtasks /Delete /TN "{task_name}" /F')
else:
print("Failed to create scheduled task. Try running as Administrator.")
def manage_servers():
from InquirerPy import inquirer
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()
@@ -1436,6 +1620,9 @@ 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("start-all", help="Start all configured sites (non-interactive)")
sub.add_parser("stop-all", help="Stop all running sites (non-interactive)")
sub.add_parser("autostart", help="Install/remove a systemd user service to start sites on boot")
sub.add_parser("_internal_onionshare", help=argparse.SUPPRESS)
args, unknown_args = parser.parse_known_args(argv)
@@ -1457,7 +1644,7 @@ def main(argv=None):
except Exception:
pass
if not current_lang and args.command in ["init", "quickstart", "manage"]:
if not current_lang and args.command in ("init", "quickstart", "manage"):
current_lang = choose_language()
# Save choice to config if possible
if config_path.exists():
@@ -1510,6 +1697,12 @@ def main(argv=None):
batch_import_configs()
elif args.command == "manage":
manage_servers()
elif args.command == "start-all":
start_all_servers()
elif args.command == "stop-all":
stop_all_servers()
elif args.command == "autostart":
install_autostart()
elif args.command == "quickstart":
if not args.non_interactive:
if not sys.stdin.isatty():