mirror of
https://github.com/PR0M3TH3AN/VoxVera.git
synced 2026-10-27 17:50:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc64d1c3b5 | ||
|
|
b5ab447207 | ||
|
|
35069e24c5 | ||
|
|
51ab72239f | ||
|
|
4e571b62ea | ||
|
|
3bb8627d20 | ||
|
|
4e84466d38 | ||
|
|
f530e54f45 | ||
|
|
8be7d0761b | ||
|
|
6d747f719f | ||
|
|
dc83b76317 | ||
|
|
9c699eb36b | ||
|
|
3cba4c7e9f | ||
|
|
69ca9f6411 | ||
|
|
994744ee60 | ||
|
|
15f881e603 | ||
|
|
8662e2b16d | ||
|
|
71ae96c841 |
No files matched your search
@@ -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,6 +50,16 @@ jobs:
|
||||
run: |
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then SEP=";"; else SEP=":"; fi
|
||||
pyinstaller --onefile \
|
||||
--collect-all pkg_resources \
|
||||
--collect-all setuptools \
|
||||
--hidden-import pkg_resources \
|
||||
--hidden-import setuptools \
|
||||
--hidden-import jaraco.text \
|
||||
--hidden-import jaraco.functools \
|
||||
--hidden-import jaraco.context \
|
||||
--hidden-import more_itertools \
|
||||
--hidden-import packaging \
|
||||
--hidden-import platformdirs \
|
||||
--collect-all onionshare_cli \
|
||||
--collect-all flask_socketio \
|
||||
--collect-all engineio \
|
||||
|
||||
@@ -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
|
||||
|
||||
+22
-3
@@ -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
@@ -14,7 +14,7 @@ voxvera = ["templates/**", "src/**", "resources/**", "locales/**", "resources/bi
|
||||
|
||||
[project]
|
||||
name = "voxvera"
|
||||
version = "0.2.4"
|
||||
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 = [
|
||||
|
||||
@@ -7,4 +7,6 @@ htmlmin2
|
||||
pypdf
|
||||
wcwidth
|
||||
onionshare-cli
|
||||
flask<2.3.0
|
||||
werkzeug<3.0.0
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
import pkg_resources
|
||||
print("Success")
|
||||
@@ -0,0 +1,2 @@
|
||||
import pkg_resources
|
||||
print("Success")
|
||||
+93
-53
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -1,13 +1,12 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "0.2.4"
|
||||
__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))
|
||||
+335
-89
@@ -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,21 +412,6 @@ def interactive_update(config_path: str):
|
||||
"transformer": _len_transform(120),
|
||||
"validate": _len_validator(120),
|
||||
},
|
||||
|
||||
{
|
||||
"type": "input",
|
||||
"message": t("cli.binary_message_label"),
|
||||
"name": "binary_message",
|
||||
"default": data.get("binary_message", ""),
|
||||
"transformer": _len_transform(120),
|
||||
"validate": _len_validator(120),
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"message": t("cli.attachment_path_label"),
|
||||
"name": "attachment_path",
|
||||
"default": data.get("attachment_path", ""),
|
||||
},
|
||||
]
|
||||
data.update(prompt(link_qs))
|
||||
|
||||
@@ -412,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)
|
||||
|
||||
@@ -460,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:
|
||||
@@ -534,23 +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
|
||||
# Handle optional file attachment logic
|
||||
attachment_path = data.get("attachment_path")
|
||||
attachment_url = ""
|
||||
attachment_display = "none"
|
||||
if attachment_path and os.path.isfile(attachment_path):
|
||||
att_filename = os.path.basename(attachment_path)
|
||||
attachment_url = f"download/{att_filename}"
|
||||
attachment_display = "inline-block"
|
||||
|
||||
html = html.replace("{{attachment_url}}", attachment_url)
|
||||
html = html.replace("{{attachment_display}}", attachment_display)
|
||||
|
||||
# Only default to the local attachment path if no onion URL is set
|
||||
current_url = data.get("url", "")
|
||||
if not (current_url.startswith("http") and ".onion" in current_url):
|
||||
data["url"] = f"download/{att_filename}"
|
||||
save_config(data, config_path)
|
||||
html = html.replace("{{url}}", data["url"])
|
||||
|
||||
folder_name = data["folder_name"]
|
||||
dest = 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:
|
||||
@@ -571,12 +603,6 @@ def build_assets(
|
||||
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}")
|
||||
|
||||
|
||||
@@ -643,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)
|
||||
|
||||
|
||||
@@ -661,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)
|
||||
@@ -671,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",
|
||||
@@ -688,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:
|
||||
@@ -779,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:
|
||||
@@ -837,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:
|
||||
@@ -928,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)
|
||||
@@ -1026,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
|
||||
@@ -1070,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:
|
||||
@@ -1134,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
|
||||
@@ -1156,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()
|
||||
|
||||
@@ -1178,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})"
|
||||
@@ -1203,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")
|
||||
@@ -1227,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:
|
||||
@@ -1259,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"))
|
||||
@@ -1273,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":
|
||||
@@ -1326,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(
|
||||
@@ -1381,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)
|
||||
@@ -1402,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():
|
||||
@@ -1439,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":
|
||||
@@ -1456,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():
|
||||
@@ -1467,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":
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"bootstrapping_progress": "[{name}] تمهيد Tor: {progress}% ...",
|
||||
"no_urls_qr_skip": "لم يتم تكوين أي روابط بعد. سيتم إنشاء أكواد QR بعد 'voxvera serve'",
|
||||
"input_file_not_found": "ملف الإدخال {path} غير موجود.",
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)"
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
|
||||
"manage_action_edit": "Edit"
|
||||
},
|
||||
"web": {
|
||||
"tear_off_tor_browser": "استخدم متصفح Tor",
|
||||
|
||||
@@ -72,7 +72,8 @@
|
||||
"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.",
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)"
|
||||
"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",
|
||||
|
||||
@@ -65,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",
|
||||
|
||||
@@ -72,7 +72,8 @@
|
||||
"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.",
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)"
|
||||
"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",
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"bootstrapping_progress": "[{name}] راهاندازی Tor: {progress}% ...",
|
||||
"no_urls_qr_skip": "هنوز آدرسی پیکربندی نشده است. کدهای QR پس از 'voxvera serve' ایجاد میشوند",
|
||||
"input_file_not_found": "فایل ورودی {path} وجود ندارد.",
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)"
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
|
||||
"manage_action_edit": "Edit"
|
||||
},
|
||||
"web": {
|
||||
"tear_off_tor_browser": "از مرورگر Tor استفاده کنید",
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"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.",
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)"
|
||||
"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",
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"bootstrapping_progress": "[{name}] איתחול Tor: {progress}% ...",
|
||||
"no_urls_qr_skip": "עדיין לא הוגדרו כתובות URL. קודי QR ייווצרו לאחר 'voxvera serve'",
|
||||
"input_file_not_found": "קובץ הקלט {path} אינו קיים.",
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)"
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
|
||||
"manage_action_edit": "Edit"
|
||||
},
|
||||
"web": {
|
||||
"tear_off_tor_browser": "השתמש בדפדפן Tor",
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"bootstrapping_progress": "[{name}] Tor बूटस्ट्रैपिंग: {progress}% ...",
|
||||
"no_urls_qr_skip": "अभी तक कोई URL कॉन्फ़िगर नहीं किया गया है। QR कोड 'voxvera serve' के बाद जेनरेट किए जाएंगे",
|
||||
"input_file_not_found": "इनपुट फ़ाइल {path} मौजूद नहीं है।",
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)"
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
|
||||
"manage_action_edit": "Edit"
|
||||
},
|
||||
"web": {
|
||||
"tear_off_tor_browser": "Tor Browser का उपयोग करें",
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"bootstrapping_progress": "[{name}] Tor 起動中: {progress}% ...",
|
||||
"no_urls_qr_skip": "URLがまだ設定されていません。QRコードは 'voxvera serve' の後に生成されます",
|
||||
"input_file_not_found": "入力ファイル {path} が存在しません。",
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)"
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
|
||||
"manage_action_edit": "Edit"
|
||||
},
|
||||
"web": {
|
||||
"tear_off_tor_browser": "Tor Browserを使用してください",
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"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.",
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)"
|
||||
"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",
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"bootstrapping_progress": "[{name}] Загрузка Tor: {progress}% ...",
|
||||
"no_urls_qr_skip": "URL еще не настроены. QR-коды будут созданы после 'voxvera serve'",
|
||||
"input_file_not_found": "Входной файл {path} не существует.",
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)"
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
|
||||
"manage_action_edit": "Edit"
|
||||
},
|
||||
"web": {
|
||||
"tear_off_tor_browser": "Используйте Tor Browser",
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"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.",
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)"
|
||||
"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",
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"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.",
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)"
|
||||
"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",
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"bootstrapping_progress": "[{name}] Tor 引导中: {progress}% ...",
|
||||
"no_urls_qr_skip": "尚未配置 URL。QR 码将在 'voxvera serve' 后生成",
|
||||
"input_file_not_found": "输入文件 {path} 不存在。",
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)"
|
||||
"attachment_path_label": "Attachment file path (optional - will be served from Tor site)",
|
||||
"manage_action_edit": "Edit"
|
||||
},
|
||||
"web": {
|
||||
"tear_off_tor_browser": "使用 Tor 浏览器",
|
||||
|
||||
@@ -579,9 +579,6 @@
|
||||
<a id="download-link" href="download/voxvera-source.zip" download>
|
||||
<button class="download-button" data-i18n="web.download_button">{{t_web_download_button}}</button>
|
||||
</a>
|
||||
<a id="attachment-link" href="{{attachment_url}}" download style="display: {{attachment_display}};">
|
||||
<button class="download-button" data-i18n="web.download_attachment">{{t_web_download_attachment}}</button>
|
||||
</a>
|
||||
<select class="lang-select" id="lang-selector" onchange="setLanguage(this.value)"></select>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user