Compare commits

...
5 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
6 changed files with 248 additions and 48 deletions

No files matched your search

+13
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
+1 -4
View File
@@ -14,7 +14,7 @@ voxvera = ["templates/**", "src/**", "resources/**", "locales/**", "resources/bi
[project]
name = "voxvera"
version = "0.2.15"
version = "0.3.0"
description = "VoxVera CLI utilities"
readme = "README.md"
authors = [{name = "VoxVera"}]
@@ -28,10 +28,7 @@ dependencies = [
"htmlmin2",
"pypdf",
"wcwidth",
"onionshare-cli",
"setuptools",
"flask<2.3.0",
"werkzeug<3.0.0",
]
[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
+1 -1
View File
@@ -1,7 +1,7 @@
import sys
from pathlib import Path
__version__ = "0.2.15"
__version__ = "0.3.0"
# 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
+229 -39
View File
@@ -38,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 = {}
@@ -552,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"]
@@ -671,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)
@@ -702,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",
@@ -719,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:
@@ -1123,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:
@@ -1207,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()
@@ -1439,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)
@@ -1460,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():
@@ -1513,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():