Compare commits

...
11 Commits
Author SHA1 Message Date
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
29 changed files with 343 additions and 177 deletions

No files matched your search

+12
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,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 \
+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
+6 -3
View File
@@ -137,15 +137,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
+3 -1
View File
@@ -14,7 +14,7 @@ voxvera = ["templates/**", "src/**", "resources/**", "locales/**", "resources/bi
[project]
name = "voxvera"
version = "0.2.4"
version = "0.2.13"
description = "VoxVera CLI utilities"
readme = "README.md"
authors = [{name = "VoxVera"}]
@@ -29,6 +29,8 @@ dependencies = [
"pypdf",
"wcwidth",
"onionshare-cli",
"flask<2.3.0",
"werkzeug<3.0.0",
]
[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")
+90 -50
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"
@@ -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
@@ -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()
@@ -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()
+42 -23
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)
@@ -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 -3
View File
@@ -1,13 +1,16 @@
import sys
from pathlib import Path
__version__ = "0.2.4"
__version__ = "0.2.13"
# Add the vendor directory to sys.path as a fallback.
# Add the vendor directory to sys.path. We prepend it to ensure our bundled
# dependencies take priority over potentially incompatible system packages
# (e.g. Flask/Werkzeug version mismatches on Debian).
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))
sys.path.insert(0, str(vendor_dir))
+131 -78
View File
@@ -10,6 +10,17 @@ 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
try:
import pkg_resources
import setuptools
import jaraco
import platformdirs
import packaging
except ImportError:
pass
from InquirerPy import prompt, inquirer
from rich.console import Console
from voxvera import __version__
@@ -17,9 +28,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.cwd()))
# Global locale state
CURRENT_LOCALE = {}
@@ -174,7 +191,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 +386,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 +409,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 +421,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 +473,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 +545,43 @@ 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)
# We don't replace the URL here if it's already an onion link (e.g. during serve rebuild)
if not data.get("url", "").startswith("http://") or ".onion" not in data.get("url", ""):
data["url"] = f"download/{att_filename}"
# Must write back to config so generate_qr uses the new URL
save_config(data, config_path)
# Also update the HTML we just replaced if we changed it
html = html.replace("{{url}}", data["url"])
folder_name = data["folder_name"]
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 +602,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}")
@@ -648,12 +673,15 @@ def _internal_onionshare():
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 is ['voxvera', '_internal_onionshare', '--website', ... ]
# We need to make it look like ['onionshare-cli', '--website', ... ]
sys.argv = ["onionshare-cli"] + sys.argv[2:]
sys.exit(main())
except ImportError:
print("Error: onionshare-cli not bundled or installed.", file=sys.stderr)
except ImportError as e:
import traceback
traceback.print_exc()
print(f"Error: onionshare-cli not bundled or installed correctly: {e}", file=sys.stderr)
print(f"DEBUG: sys.path = {sys.path}", file=sys.stderr)
sys.exit(1)
@@ -661,7 +689,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)
@@ -779,9 +807,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 +865,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 +956,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 +1060,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 +1105,39 @@ 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 get_servers() -> list[str]:
host_dir = ROOT / "host"
if not host_dir.exists():
return []
servers = []
for d in host_dir.iterdir():
if d.is_dir() and (d / "config.json").exists():
servers.append(d.name)
return sorted(servers)
# Check both bundled ROOT/host and local DATA_DIR/host
search_paths = []
if ROOT != DATA_DIR:
search_paths.append(ROOT / "host")
search_paths.append(DATA_DIR / "host")
servers = set()
for host_dir in search_paths:
if host_dir.exists():
for d in host_dir.iterdir():
if d.is_dir() and (d / "config.json").exists():
servers.add(d.name)
return sorted(list(servers))
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 +1176,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,7 +1198,7 @@ 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}")
@@ -1178,7 +1220,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 +1245,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 +1269,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 +1301,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 +1316,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 +1381,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(
@@ -1439,7 +1494,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":
@@ -1467,7 +1521,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":
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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 استفاده کنید",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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 का उपयोग करें",
+2 -1
View File
@@ -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を使用してください",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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 浏览器",
-3
View File
@@ -579,9 +579,6 @@
<a id="download-link" href="download/voxvera-source.zip" download>
<button class="download-button" data-i18n="web.download_button">{{t_web_download_button}}</button>
</a>
<a id="attachment-link" href="{{attachment_url}}" download style="display: {{attachment_display}};">
<button class="download-button" data-i18n="web.download_attachment">{{t_web_download_attachment}}</button>
</a>
<select class="lang-select" id="lang-selector" onchange="setLanguage(this.value)"></select>
</div>
</body>