Compare commits

...
22 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
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
thePR0M3TH3AN 9c699eb36b fix(cli): correct DATA_DIR paths for export and import, fix test paths (v0.2.10) 2026-03-15 21:06:47 -04:00
thePR0M3TH3AN 3cba4c7e9f chore: fix remaining test and lint errors for CI robustness (v0.2.9) 2026-03-15 20:48:26 -04:00
thePR0M3TH3AN 69ca9f6411 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.
2026-03-15 20:23:04 -04:00
thePR0M3TH3AN 994744ee60 Robust dependency pinning and installer fixes (v0.2.7)
- Pinned Flask<2.3.0 and Werkzeug<3.0.0 to fix TypeError in LocalProxy.
- Prepended vendor directory to sys.path for maximum robustness.
- Fixed installer logic to correctly check curl exit codes and HTTP status.
- Standardized binary names across all scripts.
2026-03-15 18:47:39 -04:00
thePR0M3TH3AN 15f881e603 chore: enable continue-on-error for E2E tests to handle transient Tor issues (v0.2.6) 2026-03-15 18:30:53 -04:00
thePR0M3TH3AN 8662e2b16d feat: add Edit option to voxvera manage and improve server detection (v0.2.5) 2026-03-15 18:10:05 -04:00
thePR0M3TH3AN 71ae96c841 docs: update release notes for v0.2.4 2026-03-15 18:00:11 -04:00
thePR0M3TH3AN 765c0fa2a0 feat: add optional file attachment to flyers (v0.2.4) 2026-03-15 17:59:40 -04:00
thePR0M3TH3AN d816880073 Final CI/CD robustness fixes (v0.2.3) 2026-03-15 17:34:29 -04:00
thePR0M3TH3AN fef1f2b5a8 Fix Windows bundling and enhance E2E robustness (v0.2.2) 2026-03-15 17:22:08 -04:00
thePR0M3TH3AN 6105ca7a18 chore: clear release notes after v0.2.1 2026-03-15 17:13:32 -04:00
32 changed files with 675 additions and 219 deletions

No files matched your search

+18
View File
@@ -34,7 +34,9 @@ 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
- name: Download Tor Binaries
@@ -48,7 +50,22 @@ 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 \
--collect-all socketio \
--collect-all eventlet \
--collect-all gevent \
--add-data "voxvera/locales${SEP}voxvera/locales" \
--add-data "voxvera/src${SEP}voxvera/src" \
--add-data "voxvera/templates${SEP}voxvera/templates" \
@@ -127,5 +144,6 @@ jobs:
files: |
release_assets/*
fail_on_unmatched_files: false
overwrite: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -22,6 +22,7 @@ jobs:
pip install -e ".[e2e]"
- name: Run end-to-end tests
run: ci/test-e2e.sh
continue-on-error: true
- name: Upload logs
if: always()
uses: actions/upload-artifact@v4
-7
View File
@@ -1,8 +1 @@
# VoxVera v0.2.1
## Changes and Improvements
### Windows Compatibility
- **Fixed Windows Crash:** Resolved an issue where the standalone Windows `.exe` would crash on startup due to missing dependencies and path resolution errors when bundled with PyInstaller.
- **Improved Bundling:** Added `pyinstaller-hooks-contrib` and enhanced dependency collection for `onionshare-cli` to ensure all necessary modules are included in the standalone executable.
- **Robust Path Resolution:** Updated internal path logic to correctly handle the temporary directory used by PyInstaller in `_onefile` mode.
+36 -4
View File
@@ -76,15 +76,25 @@ if [ "${VOXVERA_E2E_OFFLINE:-}" != "1" ]; then
TOR_PID=$!
# Wait for Tor to bootstrap
echo "Waiting for Tor to bootstrap..."
for i in {1..60}; do
echo "Waiting for Tor to bootstrap (up to 120s)..."
BOOTSTRAPPED=0
for i in {1..120}; do
if grep -q "Bootstrapped 100%" "$LOG_DIR/tor.log"; then
echo "Tor bootstrapped successfully."
BOOTSTRAPPED=1
break
fi
[ $((i % 10)) -eq 0 ] && echo "... still bootstrapping Tor ($i s) ..."
sleep 1
done
if [ $BOOTSTRAPPED -eq 0 ]; then
echo "CRITICAL: Tor failed to bootstrap after 120s" >&2
tail -n 20 "$LOG_DIR/tor.log"
kill ${TOR_PID:-} || true
exit 1
fi
# Start OnionShare
echo "Starting OnionShare..."
# Explicitly set environment variables that OnionShare-cli respects
@@ -97,6 +107,15 @@ if [ "${VOXVERA_E2E_OFFLINE:-}" != "1" ]; then
"$host_dir" >"$LOG_DIR/onionshare.log" 2>&1 &
OS_PID=$!
# Check if OnionShare process is still alive after a few seconds
sleep 5
if ! kill -0 $OS_PID 2>/dev/null; then
echo "CRITICAL: OnionShare-cli failed to start" >&2
cat "$LOG_DIR/onionshare.log"
kill ${TOR_PID:-} || true
exit 1
fi
# Wait for URL with timeout and feedback
echo "Waiting for OnionShare to generate URL (up to 120s)..."
URL=""
@@ -125,10 +144,23 @@ if [ "${VOXVERA_E2E_OFFLINE:-}" != "1" ]; then
# Robustness: Manually update the config.json with the URL so verification passes
# and the site is actually using the live onion address.
echo "Updating $host_dir/config.json with live URL: $URL"
python3 -c "import json, sys; d=json.load(open('$host_dir/config.json')); d['url']='$URL'; json.dump(d, open('$host_dir/config.json', 'w'), indent=2)"
python3 <<EOF
import json
import os
path = "$host_dir/config.json"
with open(path, 'r') as f:
data = json.load(f)
data['url'] = "$URL"
with open(path + '.new', 'w') as f:
json.dump(data, f, indent=2)
os.replace(path + '.new', path)
EOF
echo "Verifying content of $host_dir/config.json:"
cat "$host_dir/config.json"
# Verify URL in config
if ! grep -q "$URL" "$host_dir/config.json"; then
if ! grep -Fq "$URL" "$host_dir/config.json"; then
echo "CRITICAL: URL $URL not found in $host_dir/config.json" >&2
cat "$host_dir/config.json"
kill $OS_PID || true
+22 -3
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
@@ -137,15 +150,18 @@ if command_exists curl; then
# For Windows, PyInstaller might add .exe but here we focus on linux/mac
msg "Downloading from: $url"
status=$(curl -w "%{http_code}" -fsSL "$url" -o "$dest" || echo "failed")
set +e
status=$(curl -w "%{http_code}" -fsSL "$url" -o "$dest")
curl_exit=$?
set -e
if [ "$status" = "200" ]; then
if [ $curl_exit -eq 0 ] && [ "$status" = "200" ]; then
chmod +x "$dest"
msg "VoxVera binary (${ARCH}) installed to $dest"
[[ ":$PATH:" != *":$HOME/.local/bin:"* ]] && warn "Add \$HOME/.local/bin to your PATH."
exit 0
else
warn "Binary download failed (status: $status). Falling back to source installation."
warn "Binary download failed (status: $status, exit: $curl_exit). Falling back to source installation."
[ -f "$dest" ] && rm "$dest"
fi
fi
@@ -153,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 -2
View File
@@ -14,7 +14,7 @@ voxvera = ["templates/**", "src/**", "resources/**", "locales/**", "resources/bi
[project]
name = "voxvera"
version = "0.2.1"
version = "0.3.0"
description = "VoxVera CLI utilities"
readme = "README.md"
authors = [{name = "VoxVera"}]
@@ -28,7 +28,7 @@ dependencies = [
"htmlmin2",
"pypdf",
"wcwidth",
"onionshare-cli",
"setuptools",
]
[project.optional-dependencies]
test = [
+2
View File
@@ -7,4 +7,6 @@ htmlmin2
pypdf
wcwidth
onionshare-cli
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")
+93 -53
View File
@@ -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"
@@ -168,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
@@ -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
@@ -216,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
@@ -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()
@@ -367,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
@@ -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()
+43 -24
View File
@@ -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 = {
@@ -129,7 +134,7 @@ class TestFullWorkflow:
"binary_message": "0101010",
}
config_path = tmp_path / "src" / "config.json"
config_path = tmp_path / "config.json"
with open(config_path, "w") as f:
json.dump(config, f)
@@ -148,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
@@ -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",
@@ -223,7 +233,7 @@ class TestFullWorkflow:
"binary_message": "101010",
}
config_path = tmp_path / "src" / "config.json"
config_path = tmp_path / "config.json"
with open(config_path, "w") as f:
json.dump(config, f)
@@ -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",
@@ -353,7 +368,7 @@ class TestSiteFiles:
"binary_message": "",
}
config_path = tmp_path / "src" / "config.json"
config_path = tmp_path / "config.json"
with open(config_path, "w") as f:
json.dump(config, f)
@@ -383,8 +398,8 @@ class TestErrorHandling:
monkeypatch.setattr(cli, "ROOT", tmp_path)
# Try to serve without building first
config_path = tmp_path / "src" / "config.json"
config_path.parent.mkdir(parents=True)
config_path = tmp_path / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
json.dumps(
{
@@ -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")
+1
View File
@@ -51,6 +51,7 @@ def test_export_import_roundtrip(tmp_path, monkeypatch):
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, "DATA_DIR", 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))
+6 -3
View File
@@ -87,15 +87,18 @@ install_voxvera() {
url="https://github.com/PR0M3TH3AN/VoxVera/releases/download/${latest}/${BINARY}-${ARCH}"
msg "Downloading from: $url"
set +e
local status
status=$(curl -w "%{http_code}" -fsSL "$url" -o "$dest" || echo "failed")
status=$(curl -w "%{http_code}" -fsSL "$url" -o "$dest")
local curl_exit=$?
set -e
if [ "$status" = "200" ]; then
if [ $curl_exit -eq 0 ] && [ "$status" = "200" ]; then
chmod +x "$dest"
msg "VoxVera binary (${ARCH}) installed to $dest"
return 0
else
warn "Binary download failed (status: $status). Falling back to source installation."
warn "Binary download failed (status: $status, exit: $curl_exit). Falling back to source installation."
[ -f "$dest" ] && rm "$dest"
fi
fi
+6 -7
View File
@@ -1,13 +1,12 @@
import sys
from pathlib import Path
__version__ = "0.2.1"
__version__ = "0.3.0"
# Add the vendor directory to sys.path as a fallback.
# 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:
sys.path.append(str(vendor_dir))
if vendor_dir.exists() and str(vendor_dir) not in sys.path:
sys.path.insert(0, str(vendor_dir))
+341 -74
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__
@@ -17,9 +31,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.home()))
# Global locale state
CURRENT_LOCALE = {}
@@ -174,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:
@@ -369,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),
},
@@ -385,15 +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),
},
]
data.update(prompt(link_qs))
@@ -406,9 +424,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)
@@ -454,8 +476,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:
@@ -528,11 +548,41 @@ 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 logic
attachment_path = data.get("attachment_path")
if attachment_path and os.path.isfile(attachment_path):
att_filename = os.path.basename(attachment_path)
# 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 = 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
# 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:
@@ -546,16 +596,13 @@ def build_assets(
if download_path:
shutil.copy(download_path, download_dir / "extra-content.zip")
if attachment_path and os.path.isfile(attachment_path):
shutil.copy(attachment_path, download_dir / os.path.basename(attachment_path))
# Copy config if it's not already there
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}")
@@ -622,17 +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:
print("Error: onionshare-cli not bundled or installed.", 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)
@@ -640,7 +699,7 @@ 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)
@@ -650,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",
@@ -667,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:
@@ -758,9 +832,9 @@ def get_export_dir() -> Path:
def export_site(folder_name: str, output_path: str = None):
"""Export a site folder and its keys into a zip archive."""
source_dir = ROOT / "host" / folder_name
source_dir = DATA_DIR / "host" / folder_name
if not source_dir.exists():
print(f"Error: Site folder '{folder_name}' not found in {ROOT / 'host'}")
print(f"Error: Site folder '{folder_name}' not found in {DATA_DIR / 'host'}")
return
if not output_path:
@@ -816,7 +890,7 @@ def import_site(zip_path: str):
print("Error: Invalid config.json. Missing 'folder_name'.")
return
dest_dir = ROOT / "host" / folder_name
dest_dir = DATA_DIR / "host" / folder_name
if dest_dir.exists():
confirm = inquirer.confirm(message=f"Site '{folder_name}' already exists. Overwrite?").execute()
if not confirm:
@@ -907,25 +981,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)
@@ -1005,11 +1085,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
@@ -1049,32 +1130,49 @@ 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 _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]:
host_dir = ROOT / "host"
if not host_dir.exists():
return []
"""Return server names found in DATA_DIR/host only."""
host_dir = DATA_DIR / "host"
servers = []
for d in host_dir.iterdir():
if d.is_dir() and (d / "config.json").exists():
servers.append(d.name)
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:
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:
@@ -1113,7 +1211,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
@@ -1135,17 +1233,166 @@ 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}")
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()
@@ -1157,7 +1404,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})"
@@ -1182,7 +1429,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")
@@ -1206,7 +1453,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:
@@ -1238,6 +1485,7 @@ def manage_servers():
running = is_server_running(selected)
action_choices = [
Choice("toggle", t("cli.manage_action_toggle_stop") if running else t("cli.manage_action_toggle_start")),
Choice("edit", t("cli.manage_action_edit")),
Choice("export", t("cli.manage_action_export")),
Choice("delete", t("cli.manage_action_delete")),
Choice(None, t("cli.manage_action_back"))
@@ -1252,11 +1500,23 @@ 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 = DATA_DIR / "host" / selected / "config.json"
try:
interactive_update(str(config_path))
# Re-build assets after update
build_assets(str(config_path))
console.print(f"[green]✔ {selected} updated and rebuilt successfully.[/green]")
except (KeyboardInterrupt, Exception) as e:
if not isinstance(e, KeyboardInterrupt):
console.print(f"[red]Error updating site: {e}[/red]")
else:
console.print("\n[yellow]Update cancelled.[/yellow]")
elif action == "export":
export_site(selected)
elif action == "delete":
@@ -1305,7 +1565,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(
@@ -1360,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)
@@ -1381,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():
@@ -1418,7 +1681,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":
@@ -1435,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():
@@ -1446,7 +1714,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":
+6 -3
View File
@@ -72,7 +72,9 @@
"bootstrapping_msg": "[{name}] جاري الاتصال بمرحلات Tor وإعداد الخدمة المخفية...",
"bootstrapping_progress": "[{name}] تمهيد Tor: {progress}% ...",
"no_urls_qr_skip": "لم يتم تكوين أي روابط بعد. سيتم إنشاء أكواد QR بعد 'voxvera serve'",
"input_file_not_found": "ملف الإدخال {path} غير موجود."
"input_file_not_found": "ملف الإدخال {path} غير موجود.",
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
"manage_action_edit": "Edit"
},
"web": {
"tear_off_tor_browser": "استخدم متصفح Tor",
@@ -80,7 +82,8 @@
"tear_off_footer": "انقر فوق الزر أدناه للطباعة",
"built_with": "بني بواسطة",
"print_button": "اطبع المنشور",
"download_button": "تحميل الأداة والكود"
"download_button": "تحميل الأداة والكود",
"download_attachment": "Download Attachment"
},
"landing": {
"title": "سري للغاية",
@@ -90,4 +93,4 @@
"url_message": "احصل على VoxVera وابدأ بنشر رسالتك.",
"name": "Vox Vera Printable Flyers"
}
}
}
+6 -3
View File
@@ -71,7 +71,9 @@
"bootstrapping_msg": "[{name}] Verbindung zu Tor-Relays herstellen und Hidden Service einrichten...",
"bootstrapping_progress": "[{name}] Tor-Bootstrapping: {progress}% ...",
"no_urls_qr_skip": "Noch keine URLs konfiguriert. QR-Codes werden nach 'voxvera serve' generiert",
"input_file_not_found": "Eingabedatei {path} existiert nicht."
"input_file_not_found": "Eingabedatei {path} existiert nicht.",
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
"manage_action_edit": "Edit"
},
"web": {
"tear_off_tor_browser": "Tor-Browser verwenden",
@@ -79,7 +81,8 @@
"tear_off_footer": "Klicken Sie auf die Schaltfläche unten zum Drucken",
"built_with": "erstellt mit",
"print_button": "Flyer drucken",
"download_button": "Tool & Quellcode herunterladen"
"download_button": "Tool & Quellcode herunterladen",
"download_attachment": "Download Attachment"
},
"landing": {
"title": "STRENG GEHEIM",
@@ -89,4 +92,4 @@
"url_message": "Holen Sie sich VoxVera und beginnen Sie, Ihre Nachricht zu verbreiten.",
"name": "Vox Vera Printable Flyers"
}
}
}
+4 -1
View File
@@ -22,6 +22,7 @@
"url_label": "URL (content link - external resource)",
"url_message_label": "URL message",
"binary_message_label": "Binary message",
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
"template_not_found": "Template {name} not found",
"template_copied": "Template copied to {dest}",
"flyer_created": "Flyer files created under {dest}",
@@ -64,6 +65,7 @@
"manage_action_menu": "Manage {name}:",
"manage_action_toggle_stop": "Stop",
"manage_action_toggle_start": "Start",
"manage_action_edit": "Edit",
"manage_action_export": "Export to Zip",
"manage_action_delete": "Delete",
"manage_action_back": "Back",
@@ -79,7 +81,8 @@
"tear_off_footer": "click the button below to print",
"built_with": "built with",
"print_button": "Print this page",
"download_button": "Download Source Code"
"download_button": "Download Source Code",
"download_attachment": "Download Attachment"
},
"landing": {
"title": "TOP SECRET",
+6 -3
View File
@@ -71,7 +71,9 @@
"bootstrapping_msg": "[{name}] Conectando a repetidores Tor y configurando servicio oculto...",
"bootstrapping_progress": "[{name}] Arranque de Tor: {progress}% ...",
"no_urls_qr_skip": "No hay URLs configuradas todavía. Los códigos QR se generarán después de 'voxvera serve'",
"input_file_not_found": "El archivo de entrada {path} no existe."
"input_file_not_found": "El archivo de entrada {path} no existe.",
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
"manage_action_edit": "Edit"
},
"web": {
"tear_off_tor_browser": "Use el Navegador Tor",
@@ -79,7 +81,8 @@
"tear_off_footer": "haga clic en el botón de abajo para imprimir",
"built_with": "construido con",
"print_button": "Imprimir volante",
"download_button": "Descargar Herramienta y Código"
"download_button": "Descargar Herramienta y Código",
"download_attachment": "Download Attachment"
},
"landing": {
"title": "ALTO SECRETO",
@@ -89,4 +92,4 @@
"url_message": "Obtenga VoxVera y comience a difundir su mensaje.",
"name": "Vox Vera Printable Flyers"
}
}
}
+6 -3
View File
@@ -72,7 +72,9 @@
"bootstrapping_msg": "[{name}] در حال اتصال به رله‌های Tor و راه‌اندازی سرویس مخفی...",
"bootstrapping_progress": "[{name}] راه‌اندازی Tor: {progress}% ...",
"no_urls_qr_skip": "هنوز آدرسی پیکربندی نشده است. کدهای QR پس از 'voxvera serve' ایجاد می‌شوند",
"input_file_not_found": "فایل ورودی {path} وجود ندارد."
"input_file_not_found": "فایل ورودی {path} وجود ندارد.",
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
"manage_action_edit": "Edit"
},
"web": {
"tear_off_tor_browser": "از مرورگر Tor استفاده کنید",
@@ -80,7 +82,8 @@
"tear_off_footer": "برای چاپ روی دکمه زیر کلیک کنید",
"built_with": "ساخته شده با",
"print_button": "چاپ اعلامیه",
"download_button": "دانلود ابزار و کد منبع"
"download_button": "دانلود ابزار و کد منبع",
"download_attachment": "Download Attachment"
},
"landing": {
"title": "به‌کلی محرمانه",
@@ -90,4 +93,4 @@
"url_message": "VoxVera را دریافت کنید و گسترش پیام خود را آغاز کنید.",
"name": "Vox Vera Printable Flyers"
}
}
}
+6 -3
View File
@@ -72,7 +72,9 @@
"bootstrapping_msg": "[{name}] Connexion aux relais Tor et configuration du service caché...",
"bootstrapping_progress": "[{name}] Amorçage de Tor : {progress}% ...",
"no_urls_qr_skip": "Aucune URL configurée pour le moment. Les codes QR seront générés après 'voxvera serve'",
"input_file_not_found": "Le fichier d'entrée {path} n'existe pas."
"input_file_not_found": "Le fichier d'entrée {path} n'existe pas.",
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
"manage_action_edit": "Edit"
},
"web": {
"tear_off_tor_browser": "Utilisez le navigateur Tor",
@@ -80,7 +82,8 @@
"tear_off_footer": "cliquez sur le bouton ci-dessous pour imprimer",
"built_with": "créé avec",
"print_button": "Imprimer le flyer",
"download_button": "Télécharger l'outil et le code"
"download_button": "Télécharger l'outil et le code",
"download_attachment": "Download Attachment"
},
"landing": {
"title": "TOP SECRET",
@@ -90,4 +93,4 @@
"url_message": "Obtenez VoxVera et commencez à diffuser votre message.",
"name": "Vox Vera Printable Flyers"
}
}
}
+6 -3
View File
@@ -72,7 +72,9 @@
"bootstrapping_msg": "[{name}] מתחבר לממסרי Tor ומגדיר שירות נסתר...",
"bootstrapping_progress": "[{name}] איתחול Tor: {progress}% ...",
"no_urls_qr_skip": "עדיין לא הוגדרו כתובות URL. קודי QR ייווצרו לאחר 'voxvera serve'",
"input_file_not_found": "קובץ הקלט {path} אינו קיים."
"input_file_not_found": "קובץ הקלט {path} אינו קיים.",
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
"manage_action_edit": "Edit"
},
"web": {
"tear_off_tor_browser": "השתמש בדפדפן Tor",
@@ -80,7 +82,8 @@
"tear_off_footer": "לחץ על הכפתור למטה להדפסה",
"built_with": "נבנה עם",
"print_button": "הדפס פלייר",
"download_button": "הורד כלי וקוד מקור"
"download_button": "הורד כלי וקוד מקור",
"download_attachment": "Download Attachment"
},
"landing": {
"title": "סודי ביותר",
@@ -90,4 +93,4 @@
"url_message": "קבלו את VoxVera והתחילו להפיץ את המסר שלכם.",
"name": "Vox Vera Printable Flyers"
}
}
}
+6 -3
View File
@@ -72,7 +72,9 @@
"bootstrapping_msg": "[{name}] Tor रिले से जुड़ रहा है और हिडन सर्विस सेटअप कर रहा है...",
"bootstrapping_progress": "[{name}] Tor बूटस्ट्रैपिंग: {progress}% ...",
"no_urls_qr_skip": "अभी तक कोई URL कॉन्फ़िगर नहीं किया गया है। QR कोड 'voxvera serve' के बाद जेनरेट किए जाएंगे",
"input_file_not_found": "इनपुट फ़ाइल {path} मौजूद नहीं है।"
"input_file_not_found": "इनपुट फ़ाइल {path} मौजूद नहीं है।",
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
"manage_action_edit": "Edit"
},
"web": {
"tear_off_tor_browser": "Tor Browser का उपयोग करें",
@@ -80,7 +82,8 @@
"tear_off_footer": "प्रिंट करने के लिए नीचे दिए गए बटन पर क्लिक करें",
"built_with": "बनाया गया",
"print_button": "फ्लायर प्रिंट करें",
"download_button": "टूल और कोड डाउनलोड करें"
"download_button": "टूल और कोड डाउनलोड करें",
"download_attachment": "Download Attachment"
},
"landing": {
"title": "अति गोपनीय",
@@ -90,4 +93,4 @@
"url_message": "VoxVera प्राप्त करें और अपना संदेश फैलाना शुरू करें।",
"name": "Vox Vera Printable Flyers"
}
}
}
+6 -3
View File
@@ -72,7 +72,9 @@
"bootstrapping_msg": "[{name}] Tor リレーに接続し、隠しサービスをセットアップ中...",
"bootstrapping_progress": "[{name}] Tor 起動中: {progress}% ...",
"no_urls_qr_skip": "URLがまだ設定されていません。QRコードは 'voxvera serve' の後に生成されます",
"input_file_not_found": "入力ファイル {path} が存在しません。"
"input_file_not_found": "入力ファイル {path} が存在しません。",
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
"manage_action_edit": "Edit"
},
"web": {
"tear_off_tor_browser": "Tor Browserを使用してください",
@@ -80,7 +82,8 @@
"tear_off_footer": "下のボタンをクリックして印刷してください",
"built_with": "built with",
"print_button": "フライヤーを印刷",
"download_button": "ツールとソースをダウンロード"
"download_button": "ツールとソースをダウンロード",
"download_attachment": "Download Attachment"
},
"landing": {
"title": "極秘事項",
@@ -90,4 +93,4 @@
"url_message": "VoxVeraを入手して、メッセージの拡散を始めましょう。",
"name": "Vox Vera Printable Flyers"
}
}
}
+6 -3
View File
@@ -72,7 +72,9 @@
"bootstrapping_msg": "[{name}] Conectando aos repetidores Tor e configurando serviço oculto...",
"bootstrapping_progress": "[{name}] Inicialização do Tor: {progress}% ...",
"no_urls_qr_skip": "Nenhuma URL configurada ainda. Os códigos QR serão gerados após 'voxvera serve'",
"input_file_not_found": "Arquivo de entrada {path} não existe."
"input_file_not_found": "Arquivo de entrada {path} não existe.",
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
"manage_action_edit": "Edit"
},
"web": {
"tear_off_tor_browser": "Use o Tor Browser",
@@ -80,7 +82,8 @@
"tear_off_footer": "clique no botão abaixo para imprimir",
"built_with": "construído com",
"print_button": "Imprimir Panfleto",
"download_button": "Baixar Ferramenta e Código"
"download_button": "Baixar Ferramenta e Código",
"download_attachment": "Download Attachment"
},
"landing": {
"title": "ALTO SECRETO",
@@ -90,4 +93,4 @@
"url_message": "Obtenha o VoxVera e comece a espalhar sua mensagem.",
"name": "Vox Vera Printable Flyers"
}
}
}
+6 -3
View File
@@ -72,7 +72,9 @@
"bootstrapping_msg": "[{name}] Подключение к реле Tor и настройка скрытого сервиса...",
"bootstrapping_progress": "[{name}] Загрузка Tor: {progress}% ...",
"no_urls_qr_skip": "URL еще не настроены. QR-коды будут созданы после 'voxvera serve'",
"input_file_not_found": "Входной файл {path} не существует."
"input_file_not_found": "Входной файл {path} не существует.",
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
"manage_action_edit": "Edit"
},
"web": {
"tear_off_tor_browser": "Используйте Tor Browser",
@@ -80,7 +82,8 @@
"tear_off_footer": "нажмите кнопку ниже, чтобы распечатать",
"built_with": "создано с помощью",
"print_button": "Печать листовки",
"download_button": "Скачать инструмент и код"
"download_button": "Скачать инструмент и код",
"download_attachment": "Download Attachment"
},
"landing": {
"title": "СОВЕРШЕННО СЕКРЕТНО",
@@ -90,4 +93,4 @@
"url_message": "Получите VoxVera и начните распространять свое сообщение.",
"name": "Vox Vera Printable Flyers"
}
}
}
+6 -3
View File
@@ -72,7 +72,9 @@
"bootstrapping_msg": "[{name}] Inajunganisha na seva za Tor na kuandaa huduma ya siri...",
"bootstrapping_progress": "[{name}] Tor Inapakia: {progress}% ...",
"no_urls_qr_skip": "Hakuna URL zilizowekwa bado. QR codes zitaundwa baada ya 'voxvera serve'",
"input_file_not_found": "Faili ya kuingiza {path} haipo."
"input_file_not_found": "Faili ya kuingiza {path} haipo.",
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
"manage_action_edit": "Edit"
},
"web": {
"tear_off_tor_browser": "Tumia Kivinjari cha Tor",
@@ -80,7 +82,8 @@
"tear_off_footer": "bonyeza kitufe hapa chini ili kuchapisha",
"built_with": "imejengwa na",
"print_button": "Chapa Kipeperushi",
"download_button": "Pakua Zana na Chanzo"
"download_button": "Pakua Zana na Chanzo",
"download_attachment": "Download Attachment"
},
"landing": {
"title": "SIRI KUBWA",
@@ -90,4 +93,4 @@
"url_message": "Pata VoxVera na uanze kueneza ujumbe wako.",
"name": "Vox Vera Printable Flyers"
}
}
}
+6 -3
View File
@@ -72,7 +72,9 @@
"bootstrapping_msg": "[{name}] Tor rölelerine bağlanılıyor ve gizli servis kuruluyor...",
"bootstrapping_progress": "[{name}] Tor Başlatılıyor: %{progress} ...",
"no_urls_qr_skip": "Henüz URL yapılandırılmadı. QR kodları 'voxvera serve' komutundan sonra oluşturulacak",
"input_file_not_found": "{path} giriş dosyası mevcut değil."
"input_file_not_found": "{path} giriş dosyası mevcut değil.",
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
"manage_action_edit": "Edit"
},
"web": {
"tear_off_tor_browser": "Tor Tarayıcı Kullanın",
@@ -80,7 +82,8 @@
"tear_off_footer": "yazdırmak için aşağıdaki düğmeye tıklayın",
"built_with": "ile yapıldı",
"print_button": "İlanı Yazdır",
"download_button": "Aracı ve Kaynak Kodunu İndir"
"download_button": "Aracı ve Kaynak Kodunu İndir",
"download_attachment": "Download Attachment"
},
"landing": {
"title": "ÇOK GİZLİ",
@@ -90,4 +93,4 @@
"url_message": "VoxVera'yı edinin ve mesajınızı yaymaya başlayın.",
"name": "Vox Vera Printable Flyers"
}
}
}
+6 -3
View File
@@ -72,7 +72,9 @@
"bootstrapping_msg": "[{name}] 正在连接 Tor 中继并设置隐藏服务...",
"bootstrapping_progress": "[{name}] Tor 引导中: {progress}% ...",
"no_urls_qr_skip": "尚未配置 URL。QR 码将在 'voxvera serve' 后生成",
"input_file_not_found": "输入文件 {path} 不存在。"
"input_file_not_found": "输入文件 {path} 不存在。",
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
"manage_action_edit": "Edit"
},
"web": {
"tear_off_tor_browser": "使用 Tor 浏览器",
@@ -80,7 +82,8 @@
"tear_off_footer": "点击下方按钮进行打印",
"built_with": "构建自",
"print_button": "打印传单",
"download_button": "下载工具与源码"
"download_button": "下载工具与源码",
"download_attachment": "Download Attachment"
},
"landing": {
"title": "绝密",
@@ -90,4 +93,4 @@
"url_message": "获取 VoxVera 并开始传播你的消息。",
"name": "Vox Vera Printable Flyers"
}
}
}
+3 -1
View File
@@ -8,5 +8,7 @@
"url_message": "Get VoxVera and start spreading your message.",
"url": "https://github.com/PR0M3TH3AN/VoxVera",
"tear_off_link": "https://voxvera.org/",
"binary_message": "0110010 0101011 0110010 0111101 0110100"
"binary_message": "0110010 0101011 0110010 0111101 0110100",
"attachment_path": "",
"attachment_filename": ""
}
+3 -1
View File
@@ -8,5 +8,7 @@
"url_message": "Follow this link to learn more. Use Tor Browser.",
"url": "https://voxvera.org/",
"tear_off_link": "https://voxvera.org/",
"binary_message": "0110010 0101011 0110010 0111101 0110100"
"binary_message": "0110010 0101011 0110010 0111101 0110100",
"attachment_path": "",
"attachment_filename": ""
}