Compare commits

...
7 Commits
74 changed files with 3044 additions and 233 deletions

No files matched your search

+24
View File
@@ -70,10 +70,27 @@ jobs:
--add-data "voxvera/src${SEP}voxvera/src" \
--add-data "voxvera/templates${SEP}voxvera/templates" \
--add-data "voxvera/resources/tor${SEP}voxvera/resources/tor" \
--add-data "support-matrix.json${SEP}." \
--add-data "requirements.txt${SEP}." \
--name "${{ matrix.artifact_name }}" \
voxvera/cli.py
- name: Validate built binary contract surface (Linux/macOS)
if: matrix.os != 'windows-latest'
shell: bash
run: |
./dist/${{ matrix.artifact_name }} platform-status --json
./dist/${{ matrix.artifact_name }} doctor --json
./dist/${{ matrix.artifact_name }} autostart status --json
- name: Validate built binary contract surface (Windows)
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
& ".\dist\${{ matrix.artifact_name }}" platform-status --json
& ".\dist\${{ matrix.artifact_name }}" doctor --json
& ".\dist\${{ matrix.artifact_name }}" autostart status --json
- name: Build .deb (Linux)
if: matrix.os == 'ubuntu-22.04'
run: |
@@ -90,6 +107,13 @@ jobs:
chmod +x packaging/build_appimage.sh
./packaging/build_appimage.sh dist/${{ matrix.artifact_name }}
- name: Validate Linux release artifacts
if: matrix.os == 'ubuntu-22.04'
shell: bash
run: |
chmod +x scripts/validate-release-artifacts.sh
./scripts/validate-release-artifacts.sh
- name: Build Flatpak (Linux)
if: matrix.os == 'ubuntu-22.04'
run: |
+39
View File
@@ -16,10 +16,49 @@ jobs:
python-version: '3.12'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y tor
python -m pip install --upgrade pip
pip install flake8
pip install -e ".[test]"
pip install git+https://github.com/onionshare/onionshare.git#subdirectory=cli
- name: Lint
run: flake8 voxvera tests --exclude=voxvera/vendor --ignore=E501,W503,W293,E402,E306,E704,E261,F841,E302,F401,E401,F541
- name: Test
run: pytest tests/ -v
- name: Platform smoke
run: bash scripts/platform-smoke.sh linux-cli
platform-contract:
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
smoke_target: macos-cli
- os: windows-latest
smoke_target: windows-cli
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
shell: bash
run: |
python -m pip install --upgrade pip
pip install -e ".[test]"
- name: Verify smoke target listing
shell: bash
run: bash scripts/platform-smoke.sh list
- name: Validate platform-status JSON
shell: bash
run: python -m voxvera.cli platform-status --json
- name: Validate doctor JSON
shell: bash
run: python -m voxvera.cli doctor --json
- name: Validate autostart JSON
shell: bash
run: python -m voxvera.cli autostart status --json
+7 -5
View File
@@ -1,5 +1,8 @@
FROM python:3.11-slim
# Experimental container path: Linux systemd remains the supported
# persistent-host deployment target.
# Install system dependencies (only runtime)
RUN apt-get update && apt-get install -y \
tor onionshare-cli \
@@ -13,10 +16,9 @@ WORKDIR /opt/voxvera
RUN pip install .
# Prepare flyers volume
RUN mkdir /flyers && ln -s /flyers /opt/voxvera/host
RUN mkdir /flyers
ENV VOXVERA_DIR=/flyers
VOLUME /flyers
# VoxVera expects to run in /opt/voxvera for local assets
WORKDIR /opt/voxvera
CMD ["voxvera", "quickstart"]
# Seed a default config when the volume is empty, then retry startup periodically.
CMD ["sh", "-lc", "mkdir -p /flyers && if [ ! -f /flyers/config.json ]; then cp /opt/voxvera/voxvera/src/config.json /flyers/config.json; fi && voxvera quickstart --non-interactive && while true; do sleep 300; voxvera start-all || true; done"]
+10 -3
View File
@@ -10,11 +10,17 @@ Generate printable flyers with QR codes linking to Tor (.onion) hidden services.
VoxVera is designed for maximum portability and censorship resistance.
Linux with systemd is the supported persistent-host deployment target.
Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey remain experimental until their background Tor hosting and auto-recovery paths are validated end-to-end.
See [docs/platform-support-matrix.md](docs/platform-support-matrix.md) for the canonical platform status table and [docs/roadmap-platform-parity.md](docs/roadmap-platform-parity.md) for the parity plan.
For the supported Linux hosting path, use [docs/linux-hosting-validation.md](docs/linux-hosting-validation.md) as the operational validation runbook.
### 1. Standalone Binaries (Recommended)
Download the zero-dependency executable for your OS:
Download the executable for your OS:
- 🐧 [**Linux**](https://github.com/PR0M3TH3AN/VoxVera/releases/latest/download/voxvera-linux)
- 🪟 [**Windows**](https://github.com/PR0M3TH3AN/VoxVera/releases/latest/download/voxvera-windows.exe)
- 🍏 [**macOS**](https://github.com/PR0M3TH3AN/VoxVera/releases/latest/download/voxvera-macos)
- 🪟 [**Windows (Experimental)**](https://github.com/PR0M3TH3AN/VoxVera/releases/latest/download/voxvera-windows.exe)
- 🍏 [**macOS (Experimental)**](https://github.com/PR0M3TH3AN/VoxVera/releases/latest/download/voxvera-macos)
### 2. Universal Mirroring (The "Viral" Method)
If you found a physical VoxVera flyer, scan it and click **"Download"**. You will receive a `voxvera-portable.zip` containing the full tool, all dependencies, and all 14 languages—ready to run offline.
@@ -23,6 +29,7 @@ If you found a physical VoxVera flyer, scan it and click **"Download"**. You wil
```bash
curl -fsSL https://raw.githubusercontent.com/PR0M3TH3AN/VoxVera/main/install.sh | bash
```
On Linux, this now installs the recurring `systemd --user` recovery timer used to retry hidden-service startup after boot or offline periods. On macOS, the installer path is still experimental.
### 4. Developer / Python Install
```bash
+7 -1
View File
@@ -46,7 +46,9 @@ python3 -m voxvera.cli build-site
## 3. Build Distribution Binaries
Standardized binaries and AppImages must be generated for the current platform.
Linux CLI delivery is the supported deployment target today. Other package formats should be treated as experimental release artifacts until their Tor/OnionShare background-hosting behavior is validated end-to-end.
Capture platform evidence before promoting any support tier: run `bash scripts/platform-smoke.sh linux-cli` locally for the supported Linux path, and keep CI/workflow evidence aligned with the same command.
As other runtime targets mature, use `bash scripts/platform-smoke.sh macos-cli`, `bash scripts/platform-smoke.sh windows-cli`, or `bash scripts/platform-smoke.sh docker-cli` on their native environments before changing their support labels.
### Standard Binary
Generates an architecture-specific binary (e.g., `voxvera-linux-x86_64`) in `voxvera/resources/bin/`.
@@ -60,24 +62,28 @@ Wraps the standard binary into a self-contained AppImage.
```bash
./packaging/build_appimage.sh
```
Experimental package surface.
### Flatpak (Linux)
Generates a standalone `.flatpak` bundle. Requires `flatpak-builder`.
```bash
./packaging/build_flatpak.sh
```
Experimental package surface.
### Debian Package (Linux)
Generates a `.deb` package for Debian/Ubuntu systems.
```bash
./packaging/build_deb.sh
```
Experimental package surface unless its runtime dependencies and recovery behavior are explicitly validated for the release.
### DMG (macOS)
Generates a `.dmg` installer for macOS (must be run on macOS).
```bash
./packaging/build_dmg.sh
```
Experimental package surface.
## 4. Release Execution
+1
View File
@@ -0,0 +1 @@
# Upcoming Release Notes
+6
View File
@@ -26,6 +26,9 @@ fi
echo "VoxVera command: $voxvera_cmd"
echo "Checking platform contract before workflow..."
bash scripts/platform-smoke.sh linux-cli
# Generate demo flyer
echo "Initializing demo flyer..."
"$voxvera_cmd" --lang en init --template voxvera <<EOI
@@ -104,6 +107,9 @@ if [ "${VOXVERA_E2E_OFFLINE:-}" != "1" ]; then
exit 1
fi
echo "Checking platform contract with live Tor..."
VOXVERA_REQUIRE_TOR=1 bash scripts/platform-smoke.sh linux-cli
# Start OnionShare
echo "Starting OnionShare..."
# Explicitly set environment variables that OnionShare-cli respects
+6 -3
View File
@@ -1,5 +1,7 @@
# Docker Usage
Experimental: the container path is not yet a production-grade hidden-service deployment target. Linux with `systemd --user` remains the supported persistent-host environment.
A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published to
`ghcr.io/voxvera/voxvera`.
@@ -7,10 +9,11 @@ A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published t
# pull the image
docker pull ghcr.io/voxvera/voxvera:latest
# run voxvera quickstart with flyers stored in ./flyers
# run the experimental container with flyers stored in ./flyers
mkdir -p flyers
docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera
```
The container uses `/flyers` as the working directory and runs `voxvera quickstart`
by default. All generated flyer files will appear in the mounted `flyers` folder.
The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`.
Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end.
+15
View File
@@ -6,6 +6,10 @@ This page collects common issues encountered when hosting or accessing flyers.
- Ensure Tor is allowed through your firewall. On systems using `ufw` you may need to run `sudo ufw allow tor`.
- Some networks block Tor entirely. If you cannot reach onion services, try connecting over a different network or use a Tor bridge.
## Supported deployment target
- The supported persistent-host setup is Linux with `systemd --user`.
- Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and may not recover hosting automatically after reboot or network interruptions.
## Tor port detection
`voxvera serve` auto-detects Tor by probing common ports (9050/9150 for SOCKS, 9051/9151 for control). If detection fails -- for example when Tor runs on non-standard ports -- you can override with environment variables:
@@ -39,6 +43,17 @@ keypair (and URL) will be generated on the next `voxvera serve`. The
`voxvera import` command preserves this file automatically, but manual
deletion of the `host/` directory will destroy it.
## Hosting does not return after reboot or network loss
On Linux, install or verify the recovery timer:
```bash
voxvera autostart
systemctl --user status voxvera-start.timer
systemctl --user status voxvera-start.service
```
The supported recovery path is the recurring `systemd --user` timer, which reruns `voxvera start-all` every few minutes.
## QR codes not generated
If `voxvera build` reports "No URLs configured yet", this is expected before the first `voxvera serve`. QR codes for tear-off links are generated automatically once the .onion address is known. QR codes for the content link are generated if you provided a URL during `voxvera init`.
+6 -3
View File
@@ -1,5 +1,7 @@
# Docker Usage
Experimental: the container path is not yet a production-grade hidden-service deployment target. Linux with `systemd --user` remains the supported persistent-host environment.
A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published to
`ghcr.io/voxvera/voxvera`.
@@ -7,10 +9,11 @@ A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published t
# pull the image
docker pull ghcr.io/voxvera/voxvera:latest
# run voxvera quickstart with flyers stored in ./flyers
# run the experimental container with flyers stored in ./flyers
mkdir -p flyers
docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera
```
The container uses `/flyers` as the working directory and runs `voxvera quickstart`
by default. All generated flyer files will appear in the mounted `flyers` folder.
The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`.
Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end.
+15
View File
@@ -6,6 +6,10 @@ This page collects common issues encountered when hosting or accessing flyers.
- Ensure Tor is allowed through your firewall. On systems using `ufw` you may need to run `sudo ufw allow tor`.
- Some networks block Tor entirely. If you cannot reach onion services, try connecting over a different network or use a Tor bridge.
## Supported deployment target
- The supported persistent-host setup is Linux with `systemd --user`.
- Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and may not recover hosting automatically after reboot or network interruptions.
## Tor port detection
`voxvera serve` auto-detects Tor by probing common ports (9050/9150 for SOCKS, 9051/9151 for control). If detection fails -- for example when Tor runs on non-standard ports -- you can override with environment variables:
@@ -39,6 +43,17 @@ keypair (and URL) will be generated on the next `voxvera serve`. The
`voxvera import` command preserves this file automatically, but manual
deletion of the `host/` directory will destroy it.
## Hosting does not return after reboot or network loss
On Linux, install or verify the recovery timer:
```bash
voxvera autostart
systemctl --user status voxvera-start.timer
systemctl --user status voxvera-start.service
```
The supported recovery path is the recurring `systemd --user` timer, which reruns `voxvera start-all` every few minutes.
## QR codes not generated
If `voxvera build` reports "No URLs configured yet", this is expected before the first `voxvera serve`. QR codes for tear-off links are generated automatically once the .onion address is known. QR codes for the content link are generated if you provided a URL during `voxvera init`.
+6 -3
View File
@@ -1,5 +1,7 @@
# Docker Usage
Experimental: the container path is not yet a production-grade hidden-service deployment target. Linux with `systemd --user` remains the supported persistent-host environment.
A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published to
`ghcr.io/voxvera/voxvera`.
@@ -7,10 +9,11 @@ A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published t
# pull the image
docker pull ghcr.io/voxvera/voxvera:latest
# run voxvera quickstart with flyers stored in ./flyers
# run the experimental container with flyers stored in ./flyers
mkdir -p flyers
docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera
```
The container uses `/flyers` as the working directory and runs `voxvera quickstart`
by default. All generated flyer files will appear in the mounted `flyers` folder.
The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`.
Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end.
+15
View File
@@ -6,6 +6,10 @@ This page collects common issues encountered when hosting or accessing flyers.
- Ensure Tor is allowed through your firewall. On systems using `ufw` you may need to run `sudo ufw allow tor`.
- Some networks block Tor entirely. If you cannot reach onion services, try connecting over a different network or use a Tor bridge.
## Supported deployment target
- The supported persistent-host setup is Linux with `systemd --user`.
- Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and may not recover hosting automatically after reboot or network interruptions.
## Tor port detection
`voxvera serve` auto-detects Tor by probing common ports (9050/9150 for SOCKS, 9051/9151 for control). If detection fails -- for example when Tor runs on non-standard ports -- you can override with environment variables:
@@ -39,6 +43,17 @@ keypair (and URL) will be generated on the next `voxvera serve`. The
`voxvera import` command preserves this file automatically, but manual
deletion of the `host/` directory will destroy it.
## Hosting does not return after reboot or network loss
On Linux, install or verify the recovery timer:
```bash
voxvera autostart
systemctl --user status voxvera-start.timer
systemctl --user status voxvera-start.service
```
The supported recovery path is the recurring `systemd --user` timer, which reruns `voxvera start-all` every few minutes.
## QR codes not generated
If `voxvera build` reports "No URLs configured yet", this is expected before the first `voxvera serve`. QR codes for tear-off links are generated automatically once the .onion address is known. QR codes for the content link are generated if you provided a URL during `voxvera init`.
+15 -1
View File
@@ -6,18 +6,22 @@ This guide covers common CLI workflows. See `docs/docker.md` for Docker instruct
VoxVera is designed to be highly portable. You can run it directly from the source code on any platform with Python installed.
Linux with `systemd --user` is the supported persistent-host environment. Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and should not yet be treated as equally reliable hidden-service deployment targets.
### 1. Automated Install (Recommended)
The easiest way to install VoxVera is using our cross-platform installer script:
The easiest way to install VoxVera is using the Linux-first installer script:
**Linux / macOS:**
```bash
curl -fsSL https://raw.githubusercontent.com/PR0M3TH3AN/VoxVera/main/install.sh | bash
```
On Linux, this also installs the recurring recovery timer used to retry hidden-service startup after boot and after missed/offline periods.
**Windows (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/PR0M3TH3AN/VoxVera/main/install.ps1 | iex
```
The Windows installer is experimental and does not yet guarantee production-ready background hosting behavior.
### 2. Manual Source Install
If you have downloaded the source code ZIP:
@@ -50,6 +54,16 @@ If you wish to remove VoxVera from your system, you can use the provided uninsta
```
This automatically detects your Tor instance, starts OnionShare, and writes the generated .onion address into the flyer's tear-off links.
### Persistent Hosting On Linux
For a machine that should resume hosting automatically after boot or after being offline, install the autostart recovery timer:
```bash
voxvera autostart
```
This installs a `systemd --user` timer that reruns `voxvera start-all` every few minutes. The retry loop is the supported way to recover hidden-service hosting after suspend, reboot, or temporary network loss.
## Language Support
VoxVera is fully localized. You can change your language preference permanently using either the interactive selector or a direct shortcut:
+6 -3
View File
@@ -1,5 +1,7 @@
# Docker Usage
Experimental: the container path is not yet a production-grade hidden-service deployment target. Linux with `systemd --user` remains the supported persistent-host environment.
A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published to
`ghcr.io/voxvera/voxvera`.
@@ -7,10 +9,11 @@ A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published t
# pull the image
docker pull ghcr.io/voxvera/voxvera:latest
# run voxvera quickstart with flyers stored in ./flyers
# run the experimental container with flyers stored in ./flyers
mkdir -p flyers
docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera
```
The container uses `/flyers` as the working directory and runs `voxvera quickstart`
by default. All generated flyer files will appear in the mounted `flyers` folder.
The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`.
Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end.
+15
View File
@@ -6,6 +6,10 @@ This page collects common issues encountered when hosting or accessing flyers.
- Ensure Tor is allowed through your firewall. On systems using `ufw` you may need to run `sudo ufw allow tor`.
- Some networks block Tor entirely. If you cannot reach onion services, try connecting over a different network or use a Tor bridge.
## Supported deployment target
- The supported persistent-host setup is Linux with `systemd --user`.
- Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and may not recover hosting automatically after reboot or network interruptions.
## Tor port detection
`voxvera serve` auto-detects Tor by probing common ports (9050/9150 for SOCKS, 9051/9151 for control). If detection fails -- for example when Tor runs on non-standard ports -- you can override with environment variables:
@@ -39,6 +43,17 @@ keypair (and URL) will be generated on the next `voxvera serve`. The
`voxvera import` command preserves this file automatically, but manual
deletion of the `host/` directory will destroy it.
## Hosting does not return after reboot or network loss
On Linux, install or verify the recovery timer:
```bash
voxvera autostart
systemctl --user status voxvera-start.timer
systemctl --user status voxvera-start.service
```
The supported recovery path is the recurring `systemd --user` timer, which reruns `voxvera start-all` every few minutes.
## QR codes not generated
If `voxvera build` reports "No URLs configured yet", this is expected before the first `voxvera serve`. QR codes for tear-off links are generated automatically once the .onion address is known. QR codes for the content link are generated if you provided a URL during `voxvera init`.
+6 -3
View File
@@ -1,5 +1,7 @@
# Docker Usage
Experimental: the container path is not yet a production-grade hidden-service deployment target. Linux with `systemd --user` remains the supported persistent-host environment.
A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published to
`ghcr.io/voxvera/voxvera`.
@@ -7,10 +9,11 @@ A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published t
# pull the image
docker pull ghcr.io/voxvera/voxvera:latest
# run voxvera quickstart with flyers stored in ./flyers
# run the experimental container with flyers stored in ./flyers
mkdir -p flyers
docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera
```
The container uses `/flyers` as the working directory and runs `voxvera quickstart`
by default. All generated flyer files will appear in the mounted `flyers` folder.
The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`.
Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end.
+15
View File
@@ -6,6 +6,10 @@ This page collects common issues encountered when hosting or accessing flyers.
- Ensure Tor is allowed through your firewall. On systems using `ufw` you may need to run `sudo ufw allow tor`.
- Some networks block Tor entirely. If you cannot reach onion services, try connecting over a different network or use a Tor bridge.
## Supported deployment target
- The supported persistent-host setup is Linux with `systemd --user`.
- Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and may not recover hosting automatically after reboot or network interruptions.
## Tor port detection
`voxvera serve` auto-detects Tor by probing common ports (9050/9150 for SOCKS, 9051/9151 for control). If detection fails -- for example when Tor runs on non-standard ports -- you can override with environment variables:
@@ -39,6 +43,17 @@ keypair (and URL) will be generated on the next `voxvera serve`. The
`voxvera import` command preserves this file automatically, but manual
deletion of the `host/` directory will destroy it.
## Hosting does not return after reboot or network loss
On Linux, install or verify the recovery timer:
```bash
voxvera autostart
systemctl --user status voxvera-start.timer
systemctl --user status voxvera-start.service
```
The supported recovery path is the recurring `systemd --user` timer, which reruns `voxvera start-all` every few minutes.
## QR codes not generated
If `voxvera build` reports "No URLs configured yet", this is expected before the first `voxvera serve`. QR codes for tear-off links are generated automatically once the .onion address is known. QR codes for the content link are generated if you provided a URL during `voxvera init`.
+6 -3
View File
@@ -1,5 +1,7 @@
# Docker Usage
Experimental: the container path is not yet a production-grade hidden-service deployment target. Linux with `systemd --user` remains the supported persistent-host environment.
A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published to
`ghcr.io/voxvera/voxvera`.
@@ -7,10 +9,11 @@ A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published t
# pull the image
docker pull ghcr.io/voxvera/voxvera:latest
# run voxvera quickstart with flyers stored in ./flyers
# run the experimental container with flyers stored in ./flyers
mkdir -p flyers
docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera
```
The container uses `/flyers` as the working directory and runs `voxvera quickstart`
by default. All generated flyer files will appear in the mounted `flyers` folder.
The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`.
Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end.
+15
View File
@@ -6,6 +6,10 @@ This page collects common issues encountered when hosting or accessing flyers.
- Ensure Tor is allowed through your firewall. On systems using `ufw` you may need to run `sudo ufw allow tor`.
- Some networks block Tor entirely. If you cannot reach onion services, try connecting over a different network or use a Tor bridge.
## Supported deployment target
- The supported persistent-host setup is Linux with `systemd --user`.
- Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and may not recover hosting automatically after reboot or network interruptions.
## Tor port detection
`voxvera serve` auto-detects Tor by probing common ports (9050/9150 for SOCKS, 9051/9151 for control). If detection fails -- for example when Tor runs on non-standard ports -- you can override with environment variables:
@@ -39,6 +43,17 @@ keypair (and URL) will be generated on the next `voxvera serve`. The
`voxvera import` command preserves this file automatically, but manual
deletion of the `host/` directory will destroy it.
## Hosting does not return after reboot or network loss
On Linux, install or verify the recovery timer:
```bash
voxvera autostart
systemctl --user status voxvera-start.timer
systemctl --user status voxvera-start.service
```
The supported recovery path is the recurring `systemd --user` timer, which reruns `voxvera start-all` every few minutes.
## QR codes not generated
If `voxvera build` reports "No URLs configured yet", this is expected before the first `voxvera serve`. QR codes for tear-off links are generated automatically once the .onion address is known. QR codes for the content link are generated if you provided a URL during `voxvera init`.
+6 -3
View File
@@ -1,5 +1,7 @@
# Docker Usage
Experimental: the container path is not yet a production-grade hidden-service deployment target. Linux with `systemd --user` remains the supported persistent-host environment.
A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published to
`ghcr.io/voxvera/voxvera`.
@@ -7,10 +9,11 @@ A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published t
# pull the image
docker pull ghcr.io/voxvera/voxvera:latest
# run voxvera quickstart with flyers stored in ./flyers
# run the experimental container with flyers stored in ./flyers
mkdir -p flyers
docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera
```
The container uses `/flyers` as the working directory and runs `voxvera quickstart`
by default. All generated flyer files will appear in the mounted `flyers` folder.
The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`.
Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end.
+15
View File
@@ -6,6 +6,10 @@ This page collects common issues encountered when hosting or accessing flyers.
- Ensure Tor is allowed through your firewall. On systems using `ufw` you may need to run `sudo ufw allow tor`.
- Some networks block Tor entirely. If you cannot reach onion services, try connecting over a different network or use a Tor bridge.
## Supported deployment target
- The supported persistent-host setup is Linux with `systemd --user`.
- Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and may not recover hosting automatically after reboot or network interruptions.
## Tor port detection
`voxvera serve` auto-detects Tor by probing common ports (9050/9150 for SOCKS, 9051/9151 for control). If detection fails -- for example when Tor runs on non-standard ports -- you can override with environment variables:
@@ -39,6 +43,17 @@ keypair (and URL) will be generated on the next `voxvera serve`. The
`voxvera import` command preserves this file automatically, but manual
deletion of the `host/` directory will destroy it.
## Hosting does not return after reboot or network loss
On Linux, install or verify the recovery timer:
```bash
voxvera autostart
systemctl --user status voxvera-start.timer
systemctl --user status voxvera-start.service
```
The supported recovery path is the recurring `systemd --user` timer, which reruns `voxvera start-all` every few minutes.
## QR codes not generated
If `voxvera build` reports "No URLs configured yet", this is expected before the first `voxvera serve`. QR codes for tear-off links are generated automatically once the .onion address is known. QR codes for the content link are generated if you provided a URL during `voxvera init`.
+15 -1
View File
@@ -6,18 +6,22 @@ This guide covers common CLI workflows. See `docs/docker.md` for Docker instruct
VoxVera is designed to be highly portable. You can run it directly from the source code on any platform with Python installed.
Linux with `systemd --user` is the supported persistent-host environment. Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and should not yet be treated as equally reliable hidden-service deployment targets.
### 1. Automated Install (Recommended)
The easiest way to install VoxVera is using our cross-platform installer script:
The easiest way to install VoxVera is using the Linux-first installer script:
**Linux / macOS:**
```bash
curl -fsSL https://raw.githubusercontent.com/PR0M3TH3AN/VoxVera/main/install.sh | bash
```
On Linux, this also installs the recurring recovery timer used to retry hidden-service startup after boot and after missed/offline periods.
**Windows (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/PR0M3TH3AN/VoxVera/main/install.ps1 | iex
```
The Windows installer is experimental and does not yet guarantee production-ready background hosting behavior.
### 2. Manual Source Install
If you have downloaded the source code ZIP:
@@ -50,6 +54,16 @@ If you wish to remove VoxVera from your system, you can use the provided uninsta
```
This automatically detects your Tor instance, starts OnionShare, and writes the generated .onion address into the flyer's tear-off links.
### Persistent Hosting On Linux
For a machine that should resume hosting automatically after boot or after being offline, install the autostart recovery timer:
```bash
voxvera autostart
```
This installs a `systemd --user` timer that reruns `voxvera start-all` every few minutes. The retry loop is the supported way to recover hidden-service hosting after suspend, reboot, or temporary network loss.
## Language Support
VoxVera is fully localized. You can change your language preference permanently using either the interactive selector or a direct shortcut:
+6 -3
View File
@@ -1,5 +1,7 @@
# Docker Usage
Experimental: the container path is not yet a production-grade hidden-service deployment target. Linux with `systemd --user` remains the supported persistent-host environment.
A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published to
`ghcr.io/voxvera/voxvera`.
@@ -7,10 +9,11 @@ A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published t
# pull the image
docker pull ghcr.io/voxvera/voxvera:latest
# run voxvera quickstart with flyers stored in ./flyers
# run the experimental container with flyers stored in ./flyers
mkdir -p flyers
docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera
```
The container uses `/flyers` as the working directory and runs `voxvera quickstart`
by default. All generated flyer files will appear in the mounted `flyers` folder.
The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`.
Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end.
+15
View File
@@ -6,6 +6,10 @@ This page collects common issues encountered when hosting or accessing flyers.
- Ensure Tor is allowed through your firewall. On systems using `ufw` you may need to run `sudo ufw allow tor`.
- Some networks block Tor entirely. If you cannot reach onion services, try connecting over a different network or use a Tor bridge.
## Supported deployment target
- The supported persistent-host setup is Linux with `systemd --user`.
- Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and may not recover hosting automatically after reboot or network interruptions.
## Tor port detection
`voxvera serve` auto-detects Tor by probing common ports (9050/9150 for SOCKS, 9051/9151 for control). If detection fails -- for example when Tor runs on non-standard ports -- you can override with environment variables:
@@ -39,6 +43,17 @@ keypair (and URL) will be generated on the next `voxvera serve`. The
`voxvera import` command preserves this file automatically, but manual
deletion of the `host/` directory will destroy it.
## Hosting does not return after reboot or network loss
On Linux, install or verify the recovery timer:
```bash
voxvera autostart
systemctl --user status voxvera-start.timer
systemctl --user status voxvera-start.service
```
The supported recovery path is the recurring `systemd --user` timer, which reruns `voxvera start-all` every few minutes.
## QR codes not generated
If `voxvera build` reports "No URLs configured yet", this is expected before the first `voxvera serve`. QR codes for tear-off links are generated automatically once the .onion address is known. QR codes for the content link are generated if you provided a URL during `voxvera init`.
+6 -3
View File
@@ -1,5 +1,7 @@
# Docker Usage
Experimental: the container path is not yet a production-grade hidden-service deployment target. Linux with `systemd --user` remains the supported persistent-host environment.
A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published to
`ghcr.io/voxvera/voxvera`.
@@ -7,10 +9,11 @@ A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published t
# pull the image
docker pull ghcr.io/voxvera/voxvera:latest
# run voxvera quickstart with flyers stored in ./flyers
# run the experimental container with flyers stored in ./flyers
mkdir -p flyers
docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera
```
The container uses `/flyers` as the working directory and runs `voxvera quickstart`
by default. All generated flyer files will appear in the mounted `flyers` folder.
The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`.
Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end.
+15
View File
@@ -6,6 +6,10 @@ This page collects common issues encountered when hosting or accessing flyers.
- Ensure Tor is allowed through your firewall. On systems using `ufw` you may need to run `sudo ufw allow tor`.
- Some networks block Tor entirely. If you cannot reach onion services, try connecting over a different network or use a Tor bridge.
## Supported deployment target
- The supported persistent-host setup is Linux with `systemd --user`.
- Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and may not recover hosting automatically after reboot or network interruptions.
## Tor port detection
`voxvera serve` auto-detects Tor by probing common ports (9050/9150 for SOCKS, 9051/9151 for control). If detection fails -- for example when Tor runs on non-standard ports -- you can override with environment variables:
@@ -39,6 +43,17 @@ keypair (and URL) will be generated on the next `voxvera serve`. The
`voxvera import` command preserves this file automatically, but manual
deletion of the `host/` directory will destroy it.
## Hosting does not return after reboot or network loss
On Linux, install or verify the recovery timer:
```bash
voxvera autostart
systemctl --user status voxvera-start.timer
systemctl --user status voxvera-start.service
```
The supported recovery path is the recurring `systemd --user` timer, which reruns `voxvera start-all` every few minutes.
## QR codes not generated
If `voxvera build` reports "No URLs configured yet", this is expected before the first `voxvera serve`. QR codes for tear-off links are generated automatically once the .onion address is known. QR codes for the content link are generated if you provided a URL during `voxvera init`.
+178
View File
@@ -0,0 +1,178 @@
# Linux Hosting Validation
This runbook defines the operational validation standard for VoxVera's only supported hosting target:
- Linux CLI with `systemd --user`
Everything else remains below that bar until equivalent evidence exists.
## Prerequisites
- `voxvera` installed and on `PATH`
- `tor` installed
- `onionshare-cli` available
- a user account that can run `systemctl --user`
## Quick Verification
Use the helper script first:
```bash
bash scripts/linux-recovery-check.sh
```
This checks:
- `platform-status --json`
- `doctor --json`
- `autostart status --json`
- expected Linux support tier and platform id
- presence of `~/host`
- Tor and OnionShare discovery
- autostart artifacts and timer state
- per-site state for `.onionshare-session`, `onionshare.log`, and `server.pid`
## Core Commands
Install autostart:
```bash
voxvera autostart
```
Inspect autostart:
```bash
voxvera autostart status
systemctl --user status voxvera-start.timer
systemctl --user status voxvera-start.service
```
Remove autostart:
```bash
voxvera autostart uninstall
```
Inspect runtime diagnostics:
```bash
voxvera doctor
voxvera doctor --json
```
## Validation Checklist
### 1. Fresh install
Run the Linux installer or install through the supported CLI path, then confirm:
```bash
voxvera platform-status --json
voxvera doctor --json
voxvera autostart status --json
```
Expected:
- `platform_id` is `linux_cli_systemd`
- support tier is `supported`
- `voxvera_cli`, `onionshare_cli`, and `tor_binary` pass
### 2. Build and serve
Create or edit a site, then build and serve it:
```bash
voxvera build
voxvera serve
```
Expected:
- site output appears under `~/host/<folder_name>/`
- `index.html` and QR code images exist
- `onionshare.log` is written
- `.onionshare-session` appears after the hidden service is started
### 3. Identity persistence
Confirm the site keeps its identity across rebuilds:
```bash
ls -la ~/host/<folder_name>/
voxvera build --config ~/host/<folder_name>/config.json
```
Expected:
- `.onionshare-session` still exists after rebuild
### 4. Autostart persistence
Confirm the recovery timer exists and is enabled:
```bash
voxvera autostart status
systemctl --user is-enabled voxvera-start.timer
systemctl --user is-active voxvera-start.timer
```
Expected:
- timer artifacts exist under `~/.config/systemd/user/`
- timer is enabled
### 5. Reboot recovery
After a reboot, verify:
```bash
bash scripts/linux-recovery-check.sh
systemctl --user status voxvera-start.timer
journalctl --user -u voxvera-start.service --no-pager -n 50
```
Expected:
- timer remains enabled
- service has retried `start-all`
- configured sites are restorable without manual reconfiguration
### 6. Offline/reconnect recovery
Temporarily disconnect the host from the network, reconnect, then wait through at least one timer interval.
Verify:
```bash
systemctl --user status voxvera-start.timer
journalctl --user -u voxvera-start.service --no-pager -n 50
bash scripts/linux-recovery-check.sh
```
Expected:
- timer is still active
- `start-all` retries appear in the service logs
- Tor becomes reachable again
## Paths That Matter
- `~/.config/systemd/user/voxvera-start.service`
- `~/.config/systemd/user/voxvera-start.timer`
- `~/host/<folder_name>/config.json`
- `~/host/<folder_name>/.onionshare-session`
- `~/host/<folder_name>/onionshare.log`
- `~/host/<folder_name>/server.pid`
## Failure Interpretation
If `bash scripts/linux-recovery-check.sh` fails:
- missing `onionshare_cli` or `tor_binary` means the runtime is incomplete
- missing `host_root` means no sites are built yet
- missing autostart artifacts means recovery was never installed or was removed
- `tor_socks_reachable` warning means Tor is not currently reachable even if it is installed
Linux should remain the only supported target until this runbook is repeatable on real hosts without caveats.
+51
View File
@@ -0,0 +1,51 @@
# Platform Support Matrix
This file is the canonical human-readable summary of VoxVera's actual platform support posture.
The machine-readable companion file is [`support-matrix.json`](/home/user/Documents/GitHub/VoxVera/support-matrix.json).
## Support Tiers
- **Supported**: validated for persistent hidden-service hosting with documented recovery behavior.
- **Beta**: feature-complete enough for broader testing, but not yet validated for production-grade recovery claims.
- **Experimental**: may install or run, but is not yet validated for reliable background hidden-service hosting.
- **Unsupported**: not currently a target for reliable hosting.
## Current Matrix
| Platform | Tier | Canonical Runtime Model | Distribution Surfaces | Notes |
| :--- | :--- | :--- | :--- | :--- |
| Linux CLI with `systemd --user` | Supported | System Tor or Tor Browser plus `onionshare-cli`, with recurring `systemd --user` timer recovery | `install.sh`, `voxvera-install.sh`, `pip/pipx`, Linux binary | Current reference implementation |
| Docker CLI | Experimental | Containerized runtime with `VOXVERA_DIR=/flyers` and periodic `start-all` retries | Dockerfile, GHCR image | Needs end-to-end restart validation |
| macOS CLI | Experimental | CLI runtime with `launchd` autostart path and external Tor/OnionShare dependencies | `install.sh`, `pip/pipx`, macOS binary, DMG | No validated recovery claim yet |
| Windows CLI | Experimental | CLI runtime with Task Scheduler path and external Tor/OnionShare dependencies | `install.ps1`, Windows binary, Chocolatey | Binary naming and runtime model need cleanup |
| Electron GUI | Experimental | GUI frontend over the CLI runtime | `gui/electron` | Must converge on shared runtime behavior |
| Flatpak/AppImage/Homebrew/Chocolatey/Deb | Experimental | Distribution-only until runtime parity exists | packaging scripts/manifests | Package availability does not imply hosting parity |
## What "Supported" Means For VoxVera
To move a platform to **Supported**, VoxVera should have evidence for all of the following:
1. Fresh install works on a clean machine.
2. Manual `build` and `serve` work.
3. `.onionshare-session` survives rebuilds and restarts.
4. Hosting returns after reboot or login.
5. Hosting returns after a temporary offline period.
6. Logs and status are inspectable with documented commands.
7. The docs and release artifacts do not overstate capability.
## Promotion Rules
- Do not promote a package surface before the underlying runtime model is validated.
- Do not call a platform "recommended" unless it is also `supported`.
- If a platform lacks validated auto-recovery after reboot or network interruption, it must remain `experimental` or `beta`.
## Current Recommendation
If your goal is reliable Tor hidden-service hosting today, use the Linux CLI path and install the recurring recovery timer with:
```bash
voxvera autostart
```
That is the current reference deployment model the rest of the platform work should converge toward.
+6 -3
View File
@@ -1,5 +1,7 @@
# Docker Usage
Experimental: the container path is not yet a production-grade hidden-service deployment target. Linux with `systemd --user` remains the supported persistent-host environment.
A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published to
`ghcr.io/voxvera/voxvera`.
@@ -7,10 +9,11 @@ A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published t
# pull the image
docker pull ghcr.io/voxvera/voxvera:latest
# run voxvera quickstart with flyers stored in ./flyers
# run the experimental container with flyers stored in ./flyers
mkdir -p flyers
docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera
```
The container uses `/flyers` as the working directory and runs `voxvera quickstart`
by default. All generated flyer files will appear in the mounted `flyers` folder.
The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`.
Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end.
+15
View File
@@ -6,6 +6,10 @@ This page collects common issues encountered when hosting or accessing flyers.
- Ensure Tor is allowed through your firewall. On systems using `ufw` you may need to run `sudo ufw allow tor`.
- Some networks block Tor entirely. If you cannot reach onion services, try connecting over a different network or use a Tor bridge.
## Supported deployment target
- The supported persistent-host setup is Linux with `systemd --user`.
- Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and may not recover hosting automatically after reboot or network interruptions.
## Tor port detection
`voxvera serve` auto-detects Tor by probing common ports (9050/9150 for SOCKS, 9051/9151 for control). If detection fails -- for example when Tor runs on non-standard ports -- you can override with environment variables:
@@ -39,6 +43,17 @@ keypair (and URL) will be generated on the next `voxvera serve`. The
`voxvera import` command preserves this file automatically, but manual
deletion of the `host/` directory will destroy it.
## Hosting does not return after reboot or network loss
On Linux, install or verify the recovery timer:
```bash
voxvera autostart
systemctl --user status voxvera-start.timer
systemctl --user status voxvera-start.service
```
The supported recovery path is the recurring `systemd --user` timer, which reruns `voxvera start-all` every few minutes.
## QR codes not generated
If `voxvera build` reports "No URLs configured yet", this is expected before the first `voxvera serve`. QR codes for tear-off links are generated automatically once the .onion address is known. QR codes for the content link are generated if you provided a URL during `voxvera init`.
+270
View File
@@ -0,0 +1,270 @@
# Platform Parity Roadmap
This roadmap focuses on one question:
How do we expand VoxVera beyond Linux without overstating reliability, while still converging on real cross-platform parity for Tor hidden-service hosting?
## Strategic Principle
Parity is not a packaging milestone. It is a runtime-contract milestone.
Every platform must eventually implement the same hosting contract:
1. install VoxVera
2. ensure Tor and OnionShare are available
3. build site assets
4. serve the hidden service
5. preserve `.onionshare-session`
6. recover hosting after reboot, login, suspend, or offline periods
7. surface logs and status
Package formats should inherit that contract. They should not define independent behavior.
## Phase 0: Truth Alignment
Goal: bring repo claims, docs, and release posture into line with current reality.
Deliverables:
- canonical support matrix
- machine-readable support declaration
- installer/package warnings for experimental paths
- README and docs aligned to the actual supported target
Definition of done:
- no docs imply parity that tests do not support
- every platform surface has an explicit support tier
- release artifacts are not treated as proof of hosting reliability
## Phase 1: Harden Linux As Reference
Goal: make Linux CLI with `systemd --user` the clearly defensible model that other platforms copy.
Work items:
1. Add `voxvera doctor` for runtime validation.
- Check Tor reachability.
- Check OnionShare availability.
- Check `start-all` surfaces.
- Check autostart timer installation.
- Check host directories and session files.
2. Add `voxvera autostart status`.
- Show service and timer files.
- Show whether the timer is enabled.
- Show next/last run timestamps if available.
3. Normalize Linux log/status guidance.
- Document host log paths.
- Document timer/service status commands.
- Document expected recovery flow after reboot/network loss.
4. Validate restart behavior.
- fresh install
- first serve
- reboot
- suspend/resume if possible
- temporary offline period followed by reconnect
Definition of done:
- Linux remains the only `supported` path until the above evidence exists and is documented.
## Phase 2: Platform Adapter Layer
Goal: stop encoding platform logic in ad hoc installer scripts.
Recommended structure:
- `voxvera/platforms/base.py`
- `voxvera/platforms/linux.py`
- `voxvera/platforms/macos.py`
- `voxvera/platforms/windows.py`
- `voxvera/platforms/docker.py`
Each adapter should define:
- support tier
- Tor discovery strategy
- OnionShare discovery strategy
- state root
- autostart installation strategy
- autostart status strategy
- log/status surface
- known limitations
CLI surfaces to add:
- `voxvera doctor`
- `voxvera platform-status`
- `voxvera autostart`
- `voxvera autostart status`
- `voxvera autostart uninstall`
Definition of done:
- shell installers become thin wrappers over shared Python logic instead of owning runtime policy themselves
## Phase 3: Docker Beta Candidate
Goal: make Docker a coherent, testable deployment surface.
Decisions to settle:
- single-container runtime vs compose-based runtime
- host Tor vs container Tor
- log and state persistence model
Recommended direction:
- keep runtime state under one explicit volume root
- document restart policy clearly
- make health and recovery behavior observable
Validation checklist:
- container restart preserves onion identity
- host reboot with restart policy restores hosting
- state remains in mounted volume
- logs remain inspectable
Promotion target:
- `experimental` -> `beta`
## Phase 4: macOS CLI
Goal: establish one honest CLI-based macOS hosting model before packaging.
Work items:
1. define Tor discovery strategy
2. define OnionShare discovery/install strategy
3. validate manual `build` + `serve`
4. implement and validate `launchd` recovery
5. document known limitations
Promotion target:
- remain `experimental` until `launchd` recovery is validated
- move to `beta` only after login/reboot/offline recovery evidence exists
## Phase 5: Windows CLI
Goal: establish one canonical Windows runtime model before claiming package parity.
Work items:
1. unify binary naming across installer, Chocolatey, docs, and release assets
2. define Tor discovery strategy
3. define OnionShare discovery strategy
4. validate manual `build` + `serve`
5. implement Task Scheduler recovery path
6. add Windows-specific status/log guidance
Promotion target:
- remain `experimental` until scheduled recovery and state persistence are proven
- move to `beta` only after reboot/login/offline recovery evidence exists
## Phase 6: Electron GUI Convergence
Goal: make the GUI a frontend to the supported runtime, not a separate runtime.
Work items:
1. route the GUI through shared CLI/runtime surfaces
2. reduce duplicated Tor startup behavior
3. add visibility for:
- Tor status
- OnionShare status
- hosting status
- onion URL
- logs
4. make window-close behavior explicit and safe
Promotion target:
- GUI remains `experimental` until it stops diverging from the CLI runtime contract
## Phase 7: Package Surface Promotion
Goal: package formats should become trustworthy wrappers around validated runtimes.
Work items:
1. Debian package
- declare actual runtime dependencies
- validate install + host + recovery path
2. AppImage
- decide whether it is distribution-only or a real hosting target
3. Homebrew
- fix artifact URL/sha/version policy
- clarify whether it installs a hosting target or just a CLI
4. Chocolatey
- align artifact naming and installer semantics
5. Flatpak
- decide whether Tor/OnionShare can be supported sanely in-sandbox
Rule:
- no package surface should be promoted above the support tier of its underlying runtime
## Testing Program
### 1. Unit tests
- config/state logic
- autostart file generation
- platform adapter selection
### 2. Mocked integration tests
- `serve`
- `autostart`
- `doctor`
- `platform-status`
### 3. Platform smoke tests
- fresh install
- build
- serve
- persist identity
- restart
- recover
Current smoke target names:
- `linux-cli`
- `macos-cli`
- `windows-cli`
- `docker-cli`
These target names are contract-level validation entry points. Only `linux-cli` currently represents a supported hosting path; the others should be used to validate experimental posture and dependency discovery on their native platforms until their recovery behavior is proven.
### 4. Release checks
- support matrix is current
- docs match support tiers
- artifact names match scripts and package manifests
## Immediate Next Tasks
1. Add `voxvera doctor`.
2. Add `voxvera autostart status`.
3. Add the platform adapter scaffold.
4. Capture Linux reboot/offline recovery validation steps in docs and CI notes.
5. Pick Docker's canonical runtime model.
6. Normalize Windows binary naming before further Windows work.
## Non-Goals
- claiming parity before recovery behavior is validated
- treating package availability as support proof
- maintaining separate runtime semantics in GUI vs CLI
+6 -3
View File
@@ -1,5 +1,7 @@
# Docker Usage
Experimental: the container path is not yet a production-grade hidden-service deployment target. Linux with `systemd --user` remains the supported persistent-host environment.
A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published to
`ghcr.io/voxvera/voxvera`.
@@ -7,10 +9,11 @@ A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published t
# pull the image
docker pull ghcr.io/voxvera/voxvera:latest
# run voxvera quickstart with flyers stored in ./flyers
# run the experimental container with flyers stored in ./flyers
mkdir -p flyers
docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera
```
The container uses `/flyers` as the working directory and runs `voxvera quickstart`
by default. All generated flyer files will appear in the mounted `flyers` folder.
The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`.
Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end.
+15
View File
@@ -6,6 +6,10 @@ This page collects common issues encountered when hosting or accessing flyers.
- Ensure Tor is allowed through your firewall. On systems using `ufw` you may need to run `sudo ufw allow tor`.
- Some networks block Tor entirely. If you cannot reach onion services, try connecting over a different network or use a Tor bridge.
## Supported deployment target
- The supported persistent-host setup is Linux with `systemd --user`.
- Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and may not recover hosting automatically after reboot or network interruptions.
## Tor port detection
`voxvera serve` auto-detects Tor by probing common ports (9050/9150 for SOCKS, 9051/9151 for control). If detection fails -- for example when Tor runs on non-standard ports -- you can override with environment variables:
@@ -39,6 +43,17 @@ keypair (and URL) will be generated on the next `voxvera serve`. The
`voxvera import` command preserves this file automatically, but manual
deletion of the `host/` directory will destroy it.
## Hosting does not return after reboot or network loss
On Linux, install or verify the recovery timer:
```bash
voxvera autostart
systemctl --user status voxvera-start.timer
systemctl --user status voxvera-start.service
```
The supported recovery path is the recurring `systemd --user` timer, which reruns `voxvera start-all` every few minutes.
## QR codes not generated
If `voxvera build` reports "No URLs configured yet", this is expected before the first `voxvera serve`. QR codes for tear-off links are generated automatically once the .onion address is known. QR codes for the content link are generated if you provided a URL during `voxvera init`.
+6 -3
View File
@@ -1,5 +1,7 @@
# Docker Usage
Experimental: the container path is not yet a production-grade hidden-service deployment target. Linux with `systemd --user` remains the supported persistent-host environment.
A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published to
`ghcr.io/voxvera/voxvera`.
@@ -7,10 +9,11 @@ A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published t
# pull the image
docker pull ghcr.io/voxvera/voxvera:latest
# run voxvera quickstart with flyers stored in ./flyers
# run the experimental container with flyers stored in ./flyers
mkdir -p flyers
docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera
```
The container uses `/flyers` as the working directory and runs `voxvera quickstart`
by default. All generated flyer files will appear in the mounted `flyers` folder.
The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`.
Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end.
+15
View File
@@ -6,6 +6,10 @@ This page collects common issues encountered when hosting or accessing flyers.
- Ensure Tor is allowed through your firewall. On systems using `ufw` you may need to run `sudo ufw allow tor`.
- Some networks block Tor entirely. If you cannot reach onion services, try connecting over a different network or use a Tor bridge.
## Supported deployment target
- The supported persistent-host setup is Linux with `systemd --user`.
- Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and may not recover hosting automatically after reboot or network interruptions.
## Tor port detection
`voxvera serve` auto-detects Tor by probing common ports (9050/9150 for SOCKS, 9051/9151 for control). If detection fails -- for example when Tor runs on non-standard ports -- you can override with environment variables:
@@ -39,6 +43,17 @@ keypair (and URL) will be generated on the next `voxvera serve`. The
`voxvera import` command preserves this file automatically, but manual
deletion of the `host/` directory will destroy it.
## Hosting does not return after reboot or network loss
On Linux, install or verify the recovery timer:
```bash
voxvera autostart
systemctl --user status voxvera-start.timer
systemctl --user status voxvera-start.service
```
The supported recovery path is the recurring `systemd --user` timer, which reruns `voxvera start-all` every few minutes.
## QR codes not generated
If `voxvera build` reports "No URLs configured yet", this is expected before the first `voxvera serve`. QR codes for tear-off links are generated automatically once the .onion address is known. QR codes for the content link are generated if you provided a URL during `voxvera init`.
+6 -3
View File
@@ -1,5 +1,7 @@
# Docker Usage
Experimental: the container path is not yet a production-grade hidden-service deployment target. Linux with `systemd --user` remains the supported persistent-host environment.
A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published to
`ghcr.io/voxvera/voxvera`.
@@ -7,10 +9,11 @@ A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published t
# pull the image
docker pull ghcr.io/voxvera/voxvera:latest
# run voxvera quickstart with flyers stored in ./flyers
# run the experimental container with flyers stored in ./flyers
mkdir -p flyers
docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera
```
The container uses `/flyers` as the working directory and runs `voxvera quickstart`
by default. All generated flyer files will appear in the mounted `flyers` folder.
The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`.
Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end.
+15
View File
@@ -6,6 +6,10 @@ This page collects common issues encountered when hosting or accessing flyers.
- Ensure Tor is allowed through your firewall. On systems using `ufw` you may need to run `sudo ufw allow tor`.
- Some networks block Tor entirely. If you cannot reach onion services, try connecting over a different network or use a Tor bridge.
## Supported deployment target
- The supported persistent-host setup is Linux with `systemd --user`.
- Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and may not recover hosting automatically after reboot or network interruptions.
## Tor port detection
`voxvera serve` auto-detects Tor by probing common ports (9050/9150 for SOCKS, 9051/9151 for control). If detection fails -- for example when Tor runs on non-standard ports -- you can override with environment variables:
@@ -39,6 +43,17 @@ keypair (and URL) will be generated on the next `voxvera serve`. The
`voxvera import` command preserves this file automatically, but manual
deletion of the `host/` directory will destroy it.
## Hosting does not return after reboot or network loss
On Linux, install or verify the recovery timer:
```bash
voxvera autostart
systemctl --user status voxvera-start.timer
systemctl --user status voxvera-start.service
```
The supported recovery path is the recurring `systemd --user` timer, which reruns `voxvera start-all` every few minutes.
## QR codes not generated
If `voxvera build` reports "No URLs configured yet", this is expected before the first `voxvera serve`. QR codes for tear-off links are generated automatically once the .onion address is known. QR codes for the content link are generated if you provided a URL during `voxvera init`.
+15 -1
View File
@@ -6,18 +6,22 @@ This guide covers common CLI workflows. See `docs/docker.md` for Docker instruct
VoxVera is designed to be highly portable. You can run it directly from the source code on any platform with Python installed.
Linux with `systemd --user` is the supported persistent-host environment. Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and should not yet be treated as equally reliable hidden-service deployment targets.
### 1. Automated Install (Recommended)
The easiest way to install VoxVera is using our cross-platform installer script:
The easiest way to install VoxVera is using the Linux-first installer script:
**Linux / macOS:**
```bash
curl -fsSL https://raw.githubusercontent.com/PR0M3TH3AN/VoxVera/main/install.sh | bash
```
On Linux, this also installs the recurring recovery timer used to retry hidden-service startup after boot and after missed/offline periods.
**Windows (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/PR0M3TH3AN/VoxVera/main/install.ps1 | iex
```
The Windows installer is experimental and does not yet guarantee production-ready background hosting behavior.
### 2. Manual Source Install
If you have downloaded the source code ZIP:
@@ -50,6 +54,16 @@ If you wish to remove VoxVera from your system, you can use the provided uninsta
```
This automatically detects your Tor instance, starts OnionShare, and writes the generated .onion address into the flyer's tear-off links.
### Persistent Hosting On Linux
For a machine that should resume hosting automatically after boot or after being offline, install the autostart recovery timer:
```bash
voxvera autostart
```
This installs a `systemd --user` timer that reruns `voxvera start-all` every few minutes. The retry loop is the supported way to recover hidden-service hosting after suspend, reboot, or temporary network loss.
## Language Support
VoxVera is fully localized. You can change your language preference permanently using either the interactive selector or a direct shortcut:
+6 -3
View File
@@ -1,5 +1,7 @@
# Docker Usage
Experimental: the container path is not yet a production-grade hidden-service deployment target. Linux with `systemd --user` remains the supported persistent-host environment.
A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published to
`ghcr.io/voxvera/voxvera`.
@@ -7,10 +9,11 @@ A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published t
# pull the image
docker pull ghcr.io/voxvera/voxvera:latest
# run voxvera quickstart with flyers stored in ./flyers
# run the experimental container with flyers stored in ./flyers
mkdir -p flyers
docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera
```
The container uses `/flyers` as the working directory and runs `voxvera quickstart`
by default. All generated flyer files will appear in the mounted `flyers` folder.
The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`.
Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end.
+15
View File
@@ -6,6 +6,10 @@ This page collects common issues encountered when hosting or accessing flyers.
- Ensure Tor is allowed through your firewall. On systems using `ufw` you may need to run `sudo ufw allow tor`.
- Some networks block Tor entirely. If you cannot reach onion services, try connecting over a different network or use a Tor bridge.
## Supported deployment target
- The supported persistent-host setup is Linux with `systemd --user`.
- Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and may not recover hosting automatically after reboot or network interruptions.
## Tor port detection
`voxvera serve` auto-detects Tor by probing common ports (9050/9150 for SOCKS, 9051/9151 for control). If detection fails -- for example when Tor runs on non-standard ports -- you can override with environment variables:
@@ -39,6 +43,17 @@ keypair (and URL) will be generated on the next `voxvera serve`. The
`voxvera import` command preserves this file automatically, but manual
deletion of the `host/` directory will destroy it.
## Hosting does not return after reboot or network loss
On Linux, install or verify the recovery timer:
```bash
voxvera autostart
systemctl --user status voxvera-start.timer
systemctl --user status voxvera-start.service
```
The supported recovery path is the recurring `systemd --user` timer, which reruns `voxvera start-all` every few minutes.
## QR codes not generated
If `voxvera build` reports "No URLs configured yet", this is expected before the first `voxvera serve`. QR codes for tear-off links are generated automatically once the .onion address is known. QR codes for the content link are generated if you provided a URL during `voxvera init`.
+6 -3
View File
@@ -1,5 +1,7 @@
# Docker Usage
Experimental: the container path is not yet a production-grade hidden-service deployment target. Linux with `systemd --user` remains the supported persistent-host environment.
A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published to
`ghcr.io/voxvera/voxvera`.
@@ -7,10 +9,11 @@ A prebuilt image containing Tor, OnionShare and the `voxvera` CLI is published t
# pull the image
docker pull ghcr.io/voxvera/voxvera:latest
# run voxvera quickstart with flyers stored in ./flyers
# run the experimental container with flyers stored in ./flyers
mkdir -p flyers
docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera
```
The container uses `/flyers` as the working directory and runs `voxvera quickstart`
by default. All generated flyer files will appear in the mounted `flyers` folder.
The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`.
Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end.
+15
View File
@@ -6,6 +6,10 @@ This page collects common issues encountered when hosting or accessing flyers.
- Ensure Tor is allowed through your firewall. On systems using `ufw` you may need to run `sudo ufw allow tor`.
- Some networks block Tor entirely. If you cannot reach onion services, try connecting over a different network or use a Tor bridge.
## Supported deployment target
- The supported persistent-host setup is Linux with `systemd --user`.
- Windows, macOS, Docker, Flatpak, AppImage, Homebrew, and Chocolatey are currently experimental and may not recover hosting automatically after reboot or network interruptions.
## Tor port detection
`voxvera serve` auto-detects Tor by probing common ports (9050/9150 for SOCKS, 9051/9151 for control). If detection fails -- for example when Tor runs on non-standard ports -- you can override with environment variables:
@@ -39,6 +43,17 @@ keypair (and URL) will be generated on the next `voxvera serve`. The
`voxvera import` command preserves this file automatically, but manual
deletion of the `host/` directory will destroy it.
## Hosting does not return after reboot or network loss
On Linux, install or verify the recovery timer:
```bash
voxvera autostart
systemctl --user status voxvera-start.timer
systemctl --user status voxvera-start.service
```
The supported recovery path is the recurring `systemd --user` timer, which reruns `voxvera start-all` every few minutes.
## QR codes not generated
If `voxvera build` reports "No URLs configured yet", this is expected before the first `voxvera serve`. QR codes for tear-off links are generated automatically once the .onion address is known. QR codes for the content link are generated if you provided a URL during `voxvera init`.
+3
View File
@@ -4,6 +4,9 @@ This directory contains a minimal Electron wrapper around the `voxvera` CLI.
It exposes a simple "Quickstart" button so non-technical users can generate
flyers without touching the command line.
Status: experimental. The GUI is not the supported path for reliable background hidden-service hosting or automatic recovery after reboot/network loss.
The GUI now reads `voxvera platform-status --json`, `voxvera doctor --json`, and `voxvera autostart status --json` so its runtime messaging reflects the same platform contract as the CLI instead of inventing a separate compatibility story.
## Development
```
+82
View File
@@ -4,6 +4,36 @@
<meta charset="UTF-8">
<title>VoxVera GUI</title>
<style>
body {
font-family: sans-serif;
margin: 1rem;
}
#status {
border: 1px solid #ccc;
padding: 0.75rem;
margin-bottom: 1rem;
background: #fafafa;
}
.status-badge {
display: inline-block;
padding: 0.15rem 0.4rem;
border-radius: 999px;
font-size: 0.85rem;
font-weight: bold;
text-transform: uppercase;
}
.status-supported {
background: #dff3e4;
color: #14532d;
}
.status-experimental {
background: #fff4d6;
color: #854d0e;
}
.status-error {
background: #fee2e2;
color: #991b1b;
}
#log {
border: 1px solid #ccc;
padding: 5px;
@@ -16,6 +46,13 @@
</head>
<body>
<h1>VoxVera</h1>
<div id="status">
<strong>Runtime status</strong>
<div id="platform-tier"></div>
<div id="platform-model"></div>
<div id="autostart-state"></div>
<div id="doctor-summary"></div>
</div>
<form id="config-form">
<div><label>Name <input type="text" id="name"></label></div>
<div><label>Subdomain <input type="text" id="folder_name"></label></div>
@@ -32,12 +69,56 @@
<p id="onion-address"></p>
<pre id="log"></pre>
<script>
function setStatusBadge(targetId, label, tier) {
const el = document.getElementById(targetId);
const className = tier === 'supported' ? 'status-supported'
: tier === 'experimental' ? 'status-experimental'
: 'status-error';
el.innerHTML = `<span class="status-badge ${className}">${label}</span>`;
}
function summarizeDoctorChecks(doctor) {
const sections = doctor && doctor.sections ? Object.values(doctor.sections) : [];
if (sections.length > 0) {
const failedSections = sections.filter(section => section.checks && section.checks.length > 0 && !section.ok);
if (failedSections.length === 0) return 'All doctor sections passed.';
return `Doctor warnings in: ${failedSections.map(section => section.title).join(', ')}`;
}
const checks = doctor && Array.isArray(doctor.checks) ? doctor.checks : [];
if (checks.length === 0) return 'No diagnostics available.';
const failed = checks.filter(check => !check.ok);
if (failed.length === 0) return 'All doctor checks passed.';
return `Doctor reports ${failed.length} failing check(s): ${failed.map(check => check.name).join(', ')}`;
}
async function loadRuntimeStatus() {
const runtime = await window.voxvera.loadRuntimeStatus();
if (!runtime.ok) {
setStatusBadge('platform-tier', 'error', 'error');
document.getElementById('platform-model').textContent = runtime.error;
document.getElementById('autostart-state').textContent = '';
document.getElementById('doctor-summary').textContent = '';
return;
}
const { platformStatus, autostart, doctor } = runtime;
setStatusBadge('platform-tier', platformStatus.tier, platformStatus.tier);
document.getElementById('platform-model').textContent = platformStatus.hosting_model || '';
if (autostart && !autostart.error) {
document.getElementById('autostart-state').textContent = `Autostart: ${autostart.message}`;
} else if (autostart && autostart.error) {
document.getElementById('autostart-state').textContent = `Autostart status unavailable: ${autostart.error}`;
}
document.getElementById('doctor-summary').textContent = summarizeDoctorChecks(doctor);
}
async function load() {
const cfg = await window.voxvera.loadConfig();
for (const [k, v] of Object.entries(cfg)) {
const el = document.getElementById(k);
if (el) el.value = v;
}
await loadRuntimeStatus();
}
document.addEventListener('DOMContentLoaded', load);
@@ -49,6 +130,7 @@
if (el) cfg[id] = el.value;
});
await window.voxvera.quickstart(cfg);
await loadRuntimeStatus();
});
window.voxvera.onOnionUrl(url => {
+55 -67
View File
@@ -3,13 +3,10 @@ const { spawn } = require('child_process');
const path = require('path');
const which = require('which');
const fs = require('fs');
const { launchTor } = require('./tor.js');
const { extractOnionUrl } = require('./runtime-utils');
let mainWindow;
let onionProc;
// 🔧 merged conflicting changes from codex/start-onionshare-and-generate-static-site vs main
let restarting = false;
function createWindow() {
mainWindow = new BrowserWindow({
@@ -22,74 +19,42 @@ function createWindow() {
mainWindow.loadFile('index.html');
}
// 🔧 merged conflicting changes from codex/start-onionshare-and-generate-static-site vs main
function startOnionShare() {
const configPath = getConfigPath();
function getVoxveraPath() {
const voxveraPath = which.sync('voxvera', { nothrow: true });
if (!voxveraPath) {
dialog.showErrorBox(
'voxvera not found',
'Install the voxvera CLI and ensure it is in your PATH.'
);
return;
throw new Error('Install the voxvera CLI and ensure it is in your PATH.');
}
const build = spawn(voxveraPath, ['--config', configPath, 'build']);
build.on('error', err => {
dialog.showErrorBox('voxvera build error', err.message);
});
build.on('close', code => {
if (code !== 0) {
dialog.showErrorBox('voxvera build error', `build exited with code ${code}.`);
return;
}
runServe();
});
return voxveraPath;
}
async function runServe (retry = false) {
let torProc, socksPort, controlPort;
try {
({ torProc, socksPort, controlPort } = await launchTor());
} catch (err) {
dialog.showErrorBox('Tor error', err.message);
return;
}
const env = { ...process.env,
TOR_SOCKS_PORT: socksPort.toString(),
TOR_CONTROL_PORT: controlPort.toString() };
const configPath = getConfigPath();
const voxveraPath = which.sync('voxvera', { nothrow: true });
onionProc = spawn(voxveraPath, ['--config', configPath, 'serve'], { env });
let gotURL = false;
const softTimeout = setTimeout(() => {
if (!gotURL) {
mainWindow.webContents.send('log',
{ text: 'Tor timed out, retrying…', isError: true });
onionProc.kill(); torProc.kill();
runServe(true);
}
}, 90_000);
onionProc.stdout.on('data', buf => {
const line = buf.toString();
const onionUrl = extractOnionUrl(line);
if (onionUrl) {
gotURL = true;
clearTimeout(softTimeout);
mainWindow.webContents.send('onion-url', onionUrl);
}
mainWindow.webContents.send('log', { text: line });
function runJsonCommand(args) {
return new Promise((resolve, reject) => {
let stdout = '';
let stderr = '';
const proc = spawn(getVoxveraPath(), args);
proc.stdout.on('data', data => {
stdout += data.toString();
});
proc.stderr.on('data', data => {
stderr += data.toString();
});
proc.on('close', code => {
if (code !== 0) {
reject(new Error(stderr || `voxvera exited with code ${code}`));
return;
}
try {
resolve(JSON.parse(stdout));
} catch (err) {
reject(new Error(`Could not parse VoxVera JSON output: ${err.message}`));
}
});
proc.on('error', reject);
});
onionProc.on('exit', () => torProc.kill());
}
app.whenReady().then(() => {
createWindow();
startOnionShare();
});
function getConfigPath() {
@@ -118,17 +83,40 @@ ipcMain.handle('load-config', async () => {
return config;
});
ipcMain.handle('load-runtime-status', async () => {
try {
const platformStatus = await runJsonCommand(['platform-status', '--json']);
const doctor = await runJsonCommand(['doctor', '--json']);
let autostart = null;
try {
autostart = await runJsonCommand(['autostart', 'status', '--json']);
} catch (err) {
autostart = { error: err.message };
}
return {
ok: true,
platformStatus,
doctor,
autostart
};
} catch (err) {
return {
ok: false,
error: err.message
};
}
});
ipcMain.handle('run-quickstart', async (_, config) => {
const configPath = getConfigPath();
if (config && typeof config === 'object') {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
}
const voxveraPath = which.sync('voxvera', { nothrow: true });
if (!voxveraPath) {
dialog.showErrorBox(
'voxvera not found',
'Install the voxvera CLI and ensure it is in your PATH.'
);
let voxveraPath;
try {
voxveraPath = getVoxveraPath();
} catch (err) {
dialog.showErrorBox('voxvera not found', err.message);
return -1;
}
return new Promise((resolve) => {
+1
View File
@@ -2,6 +2,7 @@ const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('voxvera', {
loadConfig: () => ipcRenderer.invoke('load-config'),
loadRuntimeStatus: () => ipcRenderer.invoke('load-runtime-status'),
quickstart: (config) => ipcRenderer.invoke('run-quickstart', config),
onOnionUrl: (cb) => ipcRenderer.on('onion-url', (_, url) => cb(url)),
onLog: (cb) => ipcRenderer.on('log', (_, data) => cb(data.text, data.isError))
+41 -3
View File
@@ -1,6 +1,8 @@
$ErrorActionPreference = 'Stop'
$packages = @('tor', 'onionshare', 'jq', 'qrencode', 'imagemagick')
Write-Warning 'The Windows installer path is experimental. It is not yet validated for reliable background Tor hidden-service hosting or automatic recovery after connectivity changes.'
$packages = @('tor', 'onionshare')
function Install-Choco {
param([string]$Name)
@@ -76,6 +78,41 @@ function Check-LocalBin {
}
}
function Show-VoxVeraStatus {
if (-not (Get-Command voxvera -ErrorAction SilentlyContinue)) {
return
}
try {
$platform = voxvera platform-status --json | ConvertFrom-Json
Write-Host ("Platform tier: {0} ({1})" -f $platform.tier, $platform.label)
if ($platform.hosting_model) {
Write-Host ("Hosting model: {0}" -f $platform.hosting_model)
}
} catch {
Write-Warning "Could not read platform status from voxvera."
}
try {
$doctor = voxvera doctor --json | ConvertFrom-Json
$failed = @($doctor.checks | Where-Object { -not $_.ok } | ForEach-Object { $_.name })
if ($failed.Count -gt 0) {
Write-Host ("Doctor checks needing attention: {0}" -f ($failed -join ', '))
} else {
Write-Host 'Doctor checks: all passed'
}
} catch {
Write-Warning "Could not read doctor output from voxvera."
}
try {
$autostart = voxvera autostart status --json | ConvertFrom-Json
Write-Host ("Autostart: {0}" -f $autostart.message)
} catch {
Write-Warning "Could not read autostart status from voxvera."
}
}
if (Get-Command pipx -ErrorAction SilentlyContinue) {
try {
pipx install voxvera --force
@@ -84,7 +121,7 @@ if (Get-Command pipx -ErrorAction SilentlyContinue) {
$home = [Environment]::GetFolderPath('UserProfile')
$dest = Join-Path $home '.local\bin'
New-Item -ItemType Directory -Path $dest -Force | Out-Null
$url = 'https://github.com/PR0M3TH3AN/VoxVera/releases/latest/download/voxvera.exe'
$url = 'https://github.com/PR0M3TH3AN/VoxVera/releases/latest/download/voxvera-windows.exe'
$status = Download-Binary $url "$dest/voxvera.exe"
if ($status -eq 200) {
Check-LocalBin
@@ -100,7 +137,7 @@ if (Get-Command pipx -ErrorAction SilentlyContinue) {
$home = [Environment]::GetFolderPath('UserProfile')
$dest = Join-Path $home '.local\bin'
New-Item -ItemType Directory -Path $dest -Force | Out-Null
$url = 'https://github.com/PR0M3TH3AN/VoxVera/releases/latest/download/voxvera.exe'
$url = 'https://github.com/PR0M3TH3AN/VoxVera/releases/latest/download/voxvera-windows.exe'
$status = Download-Binary $url "$dest/voxvera.exe"
if ($status -eq 200) {
Check-LocalBin
@@ -114,3 +151,4 @@ if (Get-Command pipx -ErrorAction SilentlyContinue) {
}
Write-Host 'VoxVera installed successfully.'
Show-VoxVeraStatus
+57
View File
@@ -4,6 +4,7 @@ set -euo pipefail
REPO="PR0M3TH3AN/VoxVera"
REPO_URL="https://github.com/${REPO}"
BRANCH="main"
OS_NAME="$(uname -s | tr '[:upper:]' '[:lower:]')"
# --- Network resilience settings ---
# Kill git operations that stall below 1 KB/s for 30 seconds
@@ -49,6 +50,45 @@ msg() { echo -e "\e[32m$*\e[0m"; }
warn() { echo -e "\e[33m$*\e[0m" >&2; }
die() { echo -e "\e[31m$*\e[0m" >&2; exit 1; }
report_cli_status() {
command_exists voxvera || return 0
local platform_json doctor_json autostart_json
platform_json="$(voxvera platform-status --json 2>/dev/null || true)"
doctor_json="$(voxvera doctor --json 2>/dev/null || true)"
autostart_json="$(voxvera autostart status --json 2>/dev/null || true)"
if [ -n "$platform_json" ]; then
PLATFORM_JSON="$platform_json" python3 - <<'PY'
import json, os
data = json.loads(os.environ["PLATFORM_JSON"])
print(f"Platform tier: {data.get('tier', 'unknown')} ({data.get('label', 'unknown')})")
if data.get("hosting_model"):
print(f"Hosting model: {data['hosting_model']}")
PY
fi
if [ -n "$doctor_json" ]; then
DOCTOR_JSON="$doctor_json" python3 - <<'PY'
import json, os
data = json.loads(os.environ["DOCTOR_JSON"])
failed = [check["name"] for check in data.get("checks", []) if not check.get("ok")]
if failed:
print("Doctor checks needing attention: " + ", ".join(failed))
else:
print("Doctor checks: all passed")
PY
fi
if [ -n "$autostart_json" ]; then
AUTOSTART_JSON="$autostart_json" python3 - <<'PY'
import json, os
data = json.loads(os.environ["AUTOSTART_JSON"])
print("Autostart: " + data.get("message", "unknown"))
PY
fi
}
install_pkg() {
case "$PM" in
apt) $SUDO apt-get install -y "$@" ;;
@@ -108,6 +148,9 @@ get_latest_tag() {
# Ensure core dependencies are present
msg "Checking and updating system dependencies..."
if [[ "$OS_NAME" != linux* ]]; then
warn "This installer is optimized for Linux. Non-Linux paths are experimental and are not validated for reliable background hidden-service hosting."
fi
if [ "$PM" = "apt" ]; then
$SUDO apt-get update
SYSTEM_PKGS=(tor curl wget git python3-pip python3-venv)
@@ -386,9 +429,23 @@ for strategy in install_tarball install_shallow install_pipx install_pip_git ins
if command_exists voxvera; then
msg "Rebuilding existing sites with updated template..."
voxvera rebuild-all 2>/dev/null || true
if [[ "$OS_NAME" == linux* ]]; then
msg "Installing Linux autostart recovery timer..."
voxvera autostart 2>/dev/null || warn "Could not install the VoxVera autostart timer automatically. Run 'voxvera autostart' manually."
else
warn "Autostart was not configured automatically because this platform is experimental."
fi
msg "Inspecting installed runtime posture..."
report_cli_status
fi
msg "\nVoxVera installed/updated successfully. Run 'voxvera check' to verify."
if [[ "$OS_NAME" == linux* ]]; then
msg "Linux systemd user autostart should now retry hidden-service startup every few minutes."
msg "Inspect with: voxvera autostart status"
msg "Remove with: voxvera autostart uninstall"
msg "Validate with: bash scripts/linux-recovery-check.sh"
fi
# Detect if the parent shell will still have a stale hash
msg "If 'voxvera' is not found, run: hash -r (or open a new terminal)"
exit 0
+2
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env bash
set -e
echo "Warning: AppImage packaging is experimental and is not yet validated for reliable background hidden-service hosting."
# Ensure we are in project root
cd "$(dirname "$0")/.."
+2
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env bash
set -e
echo "Warning: Debian packaging is currently experimental until runtime dependencies and hidden-service recovery are validated end-to-end."
# Ensure we are in project root
cd "$(dirname "$0")/.."
+2
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env bash
set -e
echo "Warning: DMG packaging is experimental and is not yet validated for reliable background hidden-service hosting."
# Ensure we are in project root
cd "$(dirname "$0")/.."
@@ -1,3 +1,4 @@
# Experimental package surface: not yet validated for reliable background hidden-service hosting.
$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$url = 'https://github.com/PR0M3TH3AN/VoxVera/releases/latest/download/voxvera-windows.exe'
@@ -1,4 +1,5 @@
app-id: org.voxvera.VoxVera
# Experimental package surface: not yet validated for reliable background hidden-service hosting.
runtime: org.freedesktop.Platform
runtime-version: '23.08'
sdk: org.freedesktop.Sdk
+1 -1
View File
@@ -1,5 +1,5 @@
class Voxvera < Formula
desc "Generate QR-coded flyers"
desc "Experimental VoxVera formula"
homepage "https://github.com/PR0M3TH3AN/VoxVera"
url "https://github.com/PR0M3TH3AN/VoxVera/releases/latest/download/voxvera"
version "0.1.0"
+1 -1
View File
@@ -14,7 +14,7 @@ voxvera = ["templates/**", "src/**", "resources/**", "locales/**", "resources/bi
[project]
name = "voxvera"
version = "0.3.6"
version = "0.3.9"
description = "VoxVera CLI utilities"
readme = "README.md"
authors = [{name = "VoxVera"}]
+1
View File
@@ -45,6 +45,7 @@ $PY_CMD --onefile \
--add-data "voxvera/src:voxvera/src" \
--add-data "voxvera/templates:voxvera/templates" \
--add-data "voxvera/resources/tor:voxvera/resources/tor" \
--add-data "support-matrix.json:." \
--add-data "requirements.txt:." \
--name "$BINARY_NAME" \
--distpath "$ARTIFACT_DIR" \
+5
View File
@@ -18,6 +18,11 @@ echo "==> electron smoke tests"
echo "==> dependency check"
python3 -m voxvera.cli --lang en check
if [[ "$(uname -s)" == "Linux" ]]; then
echo "==> platform smoke"
bash scripts/platform-smoke.sh linux-cli
fi
echo "==> build docs"
python3 -m voxvera.cli build-docs
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
if [[ "$(uname -s)" != "Linux" ]]; then
echo "linux-recovery-check.sh is only for Linux hosts." >&2
exit 1
fi
command -v voxvera >/dev/null 2>&1 || {
echo "voxvera is not on PATH" >&2
exit 1
}
TMP_DIR="${VOXVERA_RECOVERY_TMPDIR:-$(mktemp -d /tmp/voxvera-linux-recovery-XXXXXX)}"
cleanup() {
if [[ -z "${VOXVERA_RECOVERY_TMPDIR:-}" ]]; then
rm -rf "$TMP_DIR"
fi
}
trap cleanup EXIT
PLATFORM_JSON="$TMP_DIR/platform-status.json"
DOCTOR_JSON="$TMP_DIR/doctor.json"
AUTOSTART_JSON="$TMP_DIR/autostart-status.json"
voxvera platform-status --json >"$PLATFORM_JSON"
voxvera doctor --json >"$DOCTOR_JSON"
voxvera autostart status --json >"$AUTOSTART_JSON"
PLATFORM_JSON="$PLATFORM_JSON" \
DOCTOR_JSON="$DOCTOR_JSON" \
AUTOSTART_JSON="$AUTOSTART_JSON" \
python3 - <<'PY'
import json
import os
import sys
from pathlib import Path
platform_status = json.loads(Path(os.environ["PLATFORM_JSON"]).read_text(encoding="utf-8"))
doctor = json.loads(Path(os.environ["DOCTOR_JSON"]).read_text(encoding="utf-8"))
autostart = json.loads(Path(os.environ["AUTOSTART_JSON"]).read_text(encoding="utf-8"))
errors = []
warnings = []
if platform_status.get("platform_id") != "linux_cli_systemd":
errors.append(f"expected linux_cli_systemd, got {platform_status.get('platform_id')}")
if platform_status.get("tier") != "supported":
errors.append(f"expected supported tier, got {platform_status.get('tier')}")
checks = {check["name"]: check for check in doctor.get("checks", [])}
required_ok = ["platform", "voxvera_cli", "onionshare_cli", "tor_binary", "host_root"]
for name in required_ok:
check = checks.get(name)
if not check:
errors.append(f"missing doctor check: {name}")
elif not check.get("ok"):
errors.append(f"{name} failed: {check.get('detail')}")
tor_check = checks.get("tor_socks_reachable")
if not tor_check:
errors.append("missing doctor check: tor_socks_reachable")
elif not tor_check.get("ok"):
warnings.append(f"tor_socks_reachable: {tor_check.get('detail')}")
timer_check = next((check for check in autostart.get("checks", []) if check["name"] == "systemd_timer_enabled"), None)
if not timer_check:
errors.append("missing autostart check: systemd_timer_enabled")
elif not timer_check.get("ok"):
warnings.append(f"systemd_timer_enabled: {timer_check.get('detail')}")
artifacts = [Path(path) for path in autostart.get("artifacts", [])]
missing = [str(path) for path in artifacts if not path.exists()]
if missing:
warnings.append("missing autostart artifacts: " + ", ".join(missing))
host_root = Path.home() / "host"
sites = []
if host_root.exists():
for site_dir in sorted(path for path in host_root.iterdir() if path.is_dir()):
config = site_dir / "config.json"
if not config.exists():
continue
session = site_dir / ".onionshare-session"
log = site_dir / "onionshare.log"
pid = site_dir / "server.pid"
sites.append(
{
"name": site_dir.name,
"session": session.exists(),
"log": log.exists(),
"pid": pid.exists(),
}
)
if errors:
print("Linux recovery check failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
sys.exit(1)
print(f"Platform: {platform_status['label']} [{platform_status['tier']}]")
print(f"Hosting model: {platform_status.get('hosting_model', '')}")
print(f"Autostart: {autostart.get('message', '')}")
if warnings:
print("Warnings:")
for warning in warnings:
print(f"- {warning}")
else:
print("Warnings: none")
if sites:
print("Site state:")
for site in sites:
print(
f"- {site['name']}: session={'yes' if site['session'] else 'no'}, "
f"log={'yes' if site['log'] else 'no'}, pid={'yes' if site['pid'] else 'no'}"
)
else:
print("Site state: no configured sites under ~/host")
PY
echo "Detailed JSON artifacts written to $TMP_DIR"
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
TARGET="${1:-linux-cli}"
TMP_DIR="${VOXVERA_SMOKE_TMPDIR:-$(mktemp -d /tmp/voxvera-platform-smoke-XXXXXX)}"
mkdir -p "$TMP_DIR"
cleanup() {
if [[ -z "${VOXVERA_SMOKE_TMPDIR:-}" ]]; then
rm -rf "$TMP_DIR"
fi
}
trap cleanup EXIT
command -v voxvera >/dev/null 2>&1 || {
echo "voxvera is not on PATH" >&2
exit 1
}
case "$TARGET" in
linux-cli)
EXPECTED_PLATFORM_ID="linux_cli_systemd"
EXPECTED_TIER="supported"
REQUIRE_ONIONSHARE="${VOXVERA_REQUIRE_ONIONSHARE:-1}"
;;
macos-cli)
EXPECTED_PLATFORM_ID="macos_cli"
EXPECTED_TIER="experimental"
REQUIRE_ONIONSHARE="${VOXVERA_REQUIRE_ONIONSHARE:-0}"
;;
windows-cli)
EXPECTED_PLATFORM_ID="windows_cli"
EXPECTED_TIER="experimental"
REQUIRE_ONIONSHARE="${VOXVERA_REQUIRE_ONIONSHARE:-0}"
;;
docker-cli)
EXPECTED_PLATFORM_ID="linux_cli_systemd"
EXPECTED_TIER="${VOXVERA_EXPECTED_TIER_OVERRIDE:-supported}"
REQUIRE_ONIONSHARE="${VOXVERA_REQUIRE_ONIONSHARE:-0}"
;;
list)
printf '%s\n' linux-cli macos-cli windows-cli docker-cli
exit 0
;;
*)
echo "Unsupported smoke target: $TARGET" >&2
exit 1
;;
esac
PLATFORM_JSON="$TMP_DIR/platform-status.json"
DOCTOR_JSON="$TMP_DIR/doctor.json"
AUTOSTART_JSON="$TMP_DIR/autostart-status.json"
voxvera platform-status --json >"$PLATFORM_JSON"
voxvera doctor --json >"$DOCTOR_JSON"
voxvera autostart status --json >"$AUTOSTART_JSON"
PLATFORM_JSON="$PLATFORM_JSON" \
DOCTOR_JSON="$DOCTOR_JSON" \
AUTOSTART_JSON="$AUTOSTART_JSON" \
EXPECTED_PLATFORM_ID="$EXPECTED_PLATFORM_ID" \
EXPECTED_TIER="$EXPECTED_TIER" \
VOXVERA_REQUIRE_TOR="${VOXVERA_REQUIRE_TOR:-0}" \
VOXVERA_REQUIRE_AUTOSTART_ARTIFACTS="${VOXVERA_REQUIRE_AUTOSTART_ARTIFACTS:-0}" \
VOXVERA_REQUIRE_ONIONSHARE="$REQUIRE_ONIONSHARE" \
python3 - <<'PY'
import json
import os
import sys
from pathlib import Path
platform_status = json.loads(Path(os.environ["PLATFORM_JSON"]).read_text(encoding="utf-8"))
doctor = json.loads(Path(os.environ["DOCTOR_JSON"]).read_text(encoding="utf-8"))
autostart = json.loads(Path(os.environ["AUTOSTART_JSON"]).read_text(encoding="utf-8"))
expected_platform_id = os.environ["EXPECTED_PLATFORM_ID"]
expected_tier = os.environ["EXPECTED_TIER"]
require_tor = os.environ.get("VOXVERA_REQUIRE_TOR") == "1"
require_autostart_artifacts = os.environ.get("VOXVERA_REQUIRE_AUTOSTART_ARTIFACTS") == "1"
require_onionshare = os.environ.get("VOXVERA_REQUIRE_ONIONSHARE") == "1"
errors = []
if platform_status.get("platform_id") != expected_platform_id:
errors.append(
f"platform-status platform_id mismatch: {platform_status.get('platform_id')} != {expected_platform_id}"
)
if platform_status.get("tier") != expected_tier:
errors.append(f"platform-status tier mismatch: {platform_status.get('tier')} != {expected_tier}")
if doctor.get("platform_id") != expected_platform_id:
errors.append(f"doctor platform_id mismatch: {doctor.get('platform_id')} != {expected_platform_id}")
if autostart.get("platform_id") != expected_platform_id:
errors.append(f"autostart platform_id mismatch: {autostart.get('platform_id')} != {expected_platform_id}")
check_map = {check["name"]: check for check in doctor.get("checks", [])}
for required_check in ("platform", "voxvera_cli"):
if required_check not in check_map:
errors.append(f"doctor missing required check: {required_check}")
elif not check_map[required_check].get("ok"):
errors.append(f"doctor check failed: {required_check} ({check_map[required_check].get('detail')})")
if require_onionshare:
required_check = "onionshare_cli"
if required_check not in check_map:
errors.append(f"doctor missing required check: {required_check}")
elif not check_map[required_check].get("ok"):
errors.append(f"doctor check failed: {required_check} ({check_map[required_check].get('detail')})")
if require_tor:
for required_check in ("tor_binary", "tor_socks_reachable"):
if required_check not in check_map:
errors.append(f"doctor missing Tor check: {required_check}")
elif not check_map[required_check].get("ok"):
errors.append(f"doctor Tor check failed: {required_check} ({check_map[required_check].get('detail')})")
if require_autostart_artifacts:
if not autostart.get("artifacts"):
errors.append("autostart status returned no artifacts")
missing_artifacts = [artifact for artifact in autostart.get("artifacts", []) if not Path(artifact).exists()]
if missing_artifacts:
errors.append("autostart artifacts missing: " + ", ".join(missing_artifacts))
if errors:
print("Platform smoke validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
sys.exit(1)
print(f"Platform smoke OK: {platform_status['label']} [{platform_status['tier']}]")
print(f"Hosting model: {platform_status.get('hosting_model', '')}")
failing_checks = [check["name"] for check in doctor.get("checks", []) if not check.get("ok")]
if failing_checks:
print("Doctor warnings: " + ", ".join(failing_checks))
else:
print("Doctor warnings: none")
print("Autostart message: " + autostart.get("message", ""))
PY
echo "Artifacts written to $TMP_DIR"
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
ARCH="${ARCH_OVERRIDE:-$(uname -m)}"
if [[ "$ARCH" == "x86_64" ]]; then
DEB_ARCH="amd64"
else
DEB_ARCH="$ARCH"
fi
DEB_PATH="${1:-voxvera/resources/bin/voxvera_$(python3 - <<'PY'
from voxvera import __version__
print(__version__)
PY
)_${DEB_ARCH}.deb}"
APPIMAGE_PATH="${2:-voxvera/resources/bin/VoxVera-${ARCH}.AppImage}"
tmpdir="$(mktemp -d)"
cleanup() {
rm -rf "$tmpdir"
}
trap cleanup EXIT
json_get() {
local file="$1"
local key="$2"
python3 - "$file" "$key" <<'PY'
import json
import sys
path = sys.argv[2].split(".")
data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
for part in path:
data = data[part]
if isinstance(data, list):
print("\n".join(str(item) for item in data))
else:
print(data)
PY
}
require_non_empty_json_field() {
local file="$1"
local key="$2"
local value
value="$(json_get "$file" "$key")"
if [[ -z "${value// }" ]]; then
echo "Expected non-empty JSON field '$key' in $file" >&2
exit 1
fi
}
echo "Validating Debian artifact: $DEB_PATH"
if [[ ! -f "$DEB_PATH" ]]; then
echo "Debian package not found: $DEB_PATH" >&2
exit 1
fi
mkdir -p "$tmpdir/deb-root" "$tmpdir/deb-data"
dpkg-deb -x "$DEB_PATH" "$tmpdir/deb-root"
if [[ ! -x "$tmpdir/deb-root/usr/bin/voxvera" ]]; then
echo "Packaged voxvera binary missing from .deb payload" >&2
exit 1
fi
VOXVERA_DIR="$tmpdir/deb-data" \
"$tmpdir/deb-root/usr/bin/voxvera" \
--config "$tmpdir/deb-data/config.json" \
init --non-interactive
if [[ ! -f "$tmpdir/deb-data/config.json" ]]; then
echo ".deb validation failed: config.json was not created" >&2
exit 1
fi
if [[ ! -f "$tmpdir/deb-data/host/voxvera/index.html" ]]; then
echo ".deb validation failed: built site missing index.html" >&2
exit 1
fi
echo "Validating AppImage artifact: $APPIMAGE_PATH"
if [[ ! -f "$APPIMAGE_PATH" ]]; then
echo "AppImage not found: $APPIMAGE_PATH" >&2
exit 1
fi
chmod +x "$APPIMAGE_PATH"
"$APPIMAGE_PATH" platform-status --json > "$tmpdir/platform-status.json"
require_non_empty_json_field "$tmpdir/platform-status.json" "tier_description"
require_non_empty_json_field "$tmpdir/platform-status.json" "hosting_model"
VOXVERA_DIR="$tmpdir/appimage-data" \
"$APPIMAGE_PATH" \
--config "$tmpdir/appimage-data/config.json" \
init --non-interactive
if [[ ! -f "$tmpdir/appimage-data/config.json" ]]; then
echo "AppImage validation failed: config.json was not created" >&2
exit 1
fi
if [[ ! -f "$tmpdir/appimage-data/host/voxvera/index.html" ]]; then
echo "AppImage validation failed: built site missing index.html" >&2
exit 1
fi
echo "Release artifact validation passed."
Binary file not shown.
+156
View File
@@ -0,0 +1,156 @@
{
"generated_at": "2026-03-19",
"default_supported_target": "linux_cli_systemd",
"support_tiers": {
"supported": "Validated for persistent hidden-service hosting with documented recovery behavior.",
"beta": "Feature-complete enough for broader testing, but not yet validated for production-grade recovery claims.",
"experimental": "May install or run, but is not yet validated for reliable background hidden-service hosting.",
"unsupported": "Not currently a target for reliable hosting."
},
"platforms": [
{
"id": "linux_cli_systemd",
"label": "Linux CLI with systemd --user",
"tier": "supported",
"hosting_model": "System Tor or Tor Browser plus onionshare-cli, with VoxVera-managed recurring systemd user timer recovery.",
"distribution_surfaces": [
"install.sh",
"voxvera-install.sh",
"pip/pipx",
"linux binary"
],
"required_capabilities": [
"manual_build_and_serve",
"persistent_onion_identity",
"background_recovery_after_boot",
"background_recovery_after_offline_periods",
"inspectable_logs"
],
"current_notes": [
"Canonical deployment target.",
"Systemd user timer retries start-all every few minutes."
],
"next_gaps": [
"Real-machine reboot and network-loss validation evidence should be captured in CI/docs."
]
},
{
"id": "docker_cli",
"label": "Docker CLI container",
"tier": "experimental",
"hosting_model": "Containerized VoxVera runtime with state under /flyers and periodic start-all retries.",
"distribution_surfaces": [
"Dockerfile",
"ghcr image"
],
"required_capabilities": [
"persistent_onion_identity",
"restart_policy_integration",
"validated_state_volume_layout"
],
"current_notes": [
"State now routes through VOXVERA_DIR=/flyers.",
"Not yet validated as a production-grade hidden-service host."
],
"next_gaps": [
"Decide whether the canonical model is single-container or compose-based.",
"Validate restart behavior with real container restart policies."
]
},
{
"id": "macos_cli",
"label": "macOS CLI",
"tier": "experimental",
"hosting_model": "CLI runtime with launchd autostart path and external Tor/OnionShare dependencies.",
"distribution_surfaces": [
"install.sh",
"pip/pipx",
"macOS binary",
"DMG"
],
"required_capabilities": [
"manual_build_and_serve",
"launchd_recovery",
"persistent_onion_identity"
],
"current_notes": [
"Installer and autostart warnings are explicit.",
"No validated background recovery claim."
],
"next_gaps": [
"Define the canonical Tor/OnionShare discovery model.",
"Validate launchd behavior after login, reboot, and offline periods."
]
},
{
"id": "windows_cli",
"label": "Windows CLI",
"tier": "experimental",
"hosting_model": "CLI runtime with Task Scheduler autostart path and external Tor/OnionShare dependencies.",
"distribution_surfaces": [
"install.ps1",
"windows binary",
"Chocolatey"
],
"required_capabilities": [
"manual_build_and_serve",
"task_scheduler_recovery",
"persistent_onion_identity",
"consistent_binary_naming"
],
"current_notes": [
"Installer warnings are explicit.",
"Packaging surfaces are not yet trustworthy for hosting claims."
],
"next_gaps": [
"Unify binary naming and installer expectations.",
"Define canonical Tor and OnionShare discovery on Windows.",
"Validate restart behavior after reboot and reconnect."
]
},
{
"id": "electron_gui",
"label": "Electron GUI",
"tier": "experimental",
"hosting_model": "GUI frontend over the CLI runtime.",
"distribution_surfaces": [
"gui/electron"
],
"required_capabilities": [
"shared_runtime_contract_with_cli",
"status_visibility",
"non-destructive_window_close_behavior"
],
"current_notes": [
"GUI is documented as experimental."
],
"next_gaps": [
"Remove duplicate runtime logic where possible.",
"Ensure GUI is a controller over supported runtime surfaces, not a separate hosting model."
]
},
{
"id": "flatpak_appimage_homebrew_chocolatey",
"label": "Package-only surfaces",
"tier": "experimental",
"hosting_model": "Artifact distribution only until runtime parity exists.",
"distribution_surfaces": [
"Flatpak",
"AppImage",
"Homebrew",
"Chocolatey",
"Debian package"
],
"required_capabilities": [
"runtime_dependency_model",
"support-tier-specific_release_checks"
],
"current_notes": [
"All are now explicitly marked experimental."
],
"next_gaps": [
"Do not promote any package surface until the underlying platform runtime is validated."
]
}
]
}
+359 -6
View File
@@ -5,9 +5,15 @@ import json
import zipfile
import time
import socket
import platform
from pathlib import Path
import pytest
from voxvera import cli
from voxvera.platforms import get_platform_adapter
from voxvera.platforms import base as platform_base
from voxvera.platforms import linux as platform_linux
from voxvera.platforms import macos as platform_macos
from voxvera.platforms import windows as platform_windows
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
@@ -25,6 +31,7 @@ def _setup_tmp(monkeypatch, tmp_path):
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")
shutil.copy(repo_root / "support-matrix.json", test_data_dir / "support-matrix.json")
monkeypatch.setattr(cli, "ROOT", res_root)
monkeypatch.setattr(cli, "DATA_DIR", test_data_dir)
@@ -44,6 +51,24 @@ def test_help(capsys):
assert "usage:" in captured.out
def test_get_platform_adapter_linux(monkeypatch):
monkeypatch.setattr(platform, "system", lambda: "Linux")
adapter = get_platform_adapter(cli_module=cli)
assert adapter.platform_id == "linux_cli_systemd"
def test_get_platform_adapter_macos(monkeypatch):
monkeypatch.setattr(platform, "system", lambda: "Darwin")
adapter = get_platform_adapter(cli_module=cli)
assert adapter.platform_id == "macos_cli"
def test_get_platform_adapter_windows(monkeypatch):
monkeypatch.setattr(platform, "system", lambda: "Windows")
adapter = get_platform_adapter(cli_module=cli)
assert adapter.platform_id == "windows_cli"
def test_init_template(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
test_data_dir = tmp_path / "data"
@@ -56,6 +81,84 @@ def test_init_template(tmp_path, monkeypatch):
assert (dest / "index.html").exists()
def test_init_seeds_missing_default_config(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
test_data_dir = tmp_path / "data"
config_path = test_data_dir / "fresh-config.json"
answers = iter([
"Fresh Site",
"freshsite",
"Fresh Title",
"Fresh Subtitle",
"Fresh Headline",
"https://example.com",
"Read more",
"Footer bits",
"",
])
monkeypatch.setattr(cli, "choose_language", lambda *a: "en")
monkeypatch.setattr(cli, "prompt", lambda questions: {question["name"]: next(answers) for question in questions})
monkeypatch.setattr(cli, "open_editor", lambda initial: "Fresh content")
class _Confirm:
def execute(self):
return False
monkeypatch.setattr(cli.inquirer, "confirm", lambda **kwargs: _Confirm())
cli.main(["--config", str(config_path), "init"])
assert config_path.exists()
saved = cli.load_config(config_path)
assert saved["folder_name"] == "freshsite"
assert saved["content"] == "Fresh content"
def test_create_from_template_persists_current_language(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
cli.load_locale("es")
class _Prompt:
def execute(self):
return cli.list_presets()[0]
monkeypatch.setattr(cli.inquirer, "select", lambda **kwargs: _Prompt())
monkeypatch.setattr(cli, "interactive_update", lambda path: None)
monkeypatch.setattr(cli, "resolve_new_site_folder", lambda path: cli.load_config(path)["folder_name"])
monkeypatch.setattr(cli, "build_assets", lambda path: None)
monkeypatch.setattr(cli, "serve", lambda path: None)
cli.create_from_template(None)
config = cli.load_config(tmp_path / "data" / "config.json")
assert config["lang"] == "es"
def test_resolve_new_site_folder_versions_existing_folder(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
test_data_dir = tmp_path / "data"
host_dir = test_data_dir / "host"
existing_dir = host_dir / "jesus"
existing_dir.mkdir(parents=True)
(existing_dir / "config.json").write_text(json.dumps({"folder_name": "jesus"}), encoding="utf-8")
config_path = test_data_dir / "config.json"
config_path.write_text(json.dumps({"folder_name": "jesus"}), encoding="utf-8")
class _Prompt:
def execute(self):
return "version"
monkeypatch.setattr(cli.inquirer, "select", lambda **kwargs: _Prompt())
resolved = cli.resolve_new_site_folder(str(config_path))
updated = cli.load_config(config_path)
assert resolved == "jesus_01"
assert updated["folder_name"] == "jesus_01"
def test_build(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
cli.main(["build"])
@@ -76,6 +179,26 @@ def test_build_with_download(tmp_path, monkeypatch):
assert dest.is_file()
def test_build_generates_tear_off_qr_from_url_when_onion_missing(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
test_data_dir = tmp_path / "data"
config_path = test_data_dir / "config.json"
config = cli.load_config(config_path)
config["url"] = "https://example.com/learn-more"
config["tear_off_link"] = ""
cli.save_config(config, config_path)
cli.build_assets(str(config_path))
dest = test_data_dir / "host" / config["folder_name"]
html = (dest / "index.html").read_text(encoding="utf-8")
assert (dest / "qrcode-content.png").exists()
assert (dest / "qrcode-tear-offs.png").exists()
assert 'class="container no-tear-offs"' not in html
assert "https://example.com/learn-more" in html
def test_import(tmp_path, monkeypatch):
repo = _setup_tmp(monkeypatch, tmp_path)
test_data_dir = tmp_path / "data"
@@ -174,6 +297,212 @@ def test_check_missing(capsys, monkeypatch):
assert "missing" in captured.out
def test_doctor_command_outputs_platform_report(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(shutil, "which", lambda cmd: f"/usr/bin/{cmd}" if cmd in {"voxvera", "tor", "onionshare-cli", "systemctl"} else None)
class FakeSocket:
def settimeout(self, value):
return None
def connect_ex(self, addr):
return 0
def close(self):
return None
def fake_check_output(args, stderr=None):
if "is-enabled" in args:
return b"enabled"
if "is-active" in args:
return b"active"
raise AssertionError(args)
monkeypatch.setattr(platform_linux.socket, "socket", lambda *args, **kwargs: FakeSocket())
monkeypatch.setattr(platform_base.subprocess, "check_output", fake_check_output)
cli.main(["doctor"])
captured = capsys.readouterr()
assert "VoxVera Doctor" in captured.out
assert "linux_cli_systemd" in captured.out
def test_doctor_json_output(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(shutil, "which", lambda cmd: f"/usr/bin/{cmd}" if cmd in {"voxvera", "tor", "onionshare-cli"} else None)
class FakeSocket:
def settimeout(self, value):
return None
def connect_ex(self, addr):
return 0
def close(self):
return None
monkeypatch.setattr(platform_linux.socket, "socket", lambda *args, **kwargs: FakeSocket())
monkeypatch.setattr(platform_base.subprocess, "check_output", lambda *args, **kwargs: b"disabled")
cli.main(["doctor", "--json"])
captured = capsys.readouterr()
report = json.loads(captured.out)
assert report["platform_id"] == "linux_cli_systemd"
assert "summary" in report
assert "sections" in report
assert "dependencies" in report["sections"]
assert "checks" in report
def test_platform_status_json_output(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
monkeypatch.setattr(platform, "system", lambda: "Linux")
cli.main(["platform-status", "--json"])
captured = capsys.readouterr()
report = json.loads(captured.out)
assert report["platform_id"] == "linux_cli_systemd"
assert report["hosting_model"]
assert "checks" not in report
def test_autostart_status_command_outputs_timer_state(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
fake_home = tmp_path / "home"
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(platform_base.Path, "home", lambda: fake_home)
systemd_dir = fake_home / ".config" / "systemd" / "user"
systemd_dir.mkdir(parents=True, exist_ok=True)
(systemd_dir / "voxvera-start.service").write_text("service", encoding="utf-8")
(systemd_dir / "voxvera-start.timer").write_text("timer", encoding="utf-8")
def fake_check_output(args, stderr=None):
if "is-enabled" in args:
return b"enabled"
if "is-active" in args:
return b"active"
raise AssertionError(args)
monkeypatch.setattr(shutil, "which", lambda cmd: f"/usr/bin/{cmd}" if cmd == "systemctl" else None)
monkeypatch.setattr(platform_base.subprocess, "check_output", fake_check_output)
cli.main(["autostart", "status"])
captured = capsys.readouterr()
assert "Autostart Status" in captured.out
assert "systemd_timer_enabled" in captured.out
def test_autostart_status_json_output(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
fake_home = tmp_path / "home"
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(platform_base.Path, "home", lambda: fake_home)
systemd_dir = fake_home / ".config" / "systemd" / "user"
systemd_dir.mkdir(parents=True, exist_ok=True)
(systemd_dir / "voxvera-start.service").write_text("service", encoding="utf-8")
(systemd_dir / "voxvera-start.timer").write_text("timer", encoding="utf-8")
monkeypatch.setattr(shutil, "which", lambda cmd: f"/usr/bin/{cmd}" if cmd == "systemctl" else None)
monkeypatch.setattr(platform_base.subprocess, "check_output", lambda *args, **kwargs: b"enabled")
cli.main(["autostart", "status", "--json"])
captured = capsys.readouterr()
status = json.loads(captured.out)
assert status["platform_id"] == "linux_cli_systemd"
assert len(status["artifacts"]) == 2
def test_linux_autostart_uninstall_removes_artifacts(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
fake_home = tmp_path / "home"
commands = []
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(platform_linux.Path, "home", lambda: fake_home)
monkeypatch.setattr(platform_linux.subprocess, "run", lambda args, **kwargs: commands.append(args))
systemd_dir = fake_home / ".config" / "systemd" / "user"
systemd_dir.mkdir(parents=True, exist_ok=True)
service = systemd_dir / "voxvera-start.service"
timer = systemd_dir / "voxvera-start.timer"
service.write_text("service", encoding="utf-8")
timer.write_text("timer", encoding="utf-8")
cli.main(["autostart", "uninstall"])
captured = capsys.readouterr()
assert "Removed systemd user service" in captured.out
assert not service.exists()
assert not timer.exists()
assert ["systemctl", "--user", "disable", "--now", "voxvera-start.timer"] in commands
def test_macos_autostart_status_and_render(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
fake_home = tmp_path / "home"
monkeypatch.setattr(platform, "system", lambda: "Darwin")
monkeypatch.setattr(platform_base.Path, "home", lambda: fake_home)
adapter = get_platform_adapter(cli_module=cli)
assert adapter.platform_id == "macos_cli"
plist = adapter.render_launchd_plist("/tmp/voxvera")
assert "org.voxvera.start-all" in plist
assert "<string>/tmp/voxvera</string>" in plist
assert "start-all" in plist
status = adapter.autostart_status()
assert status["platform_id"] == "macos_cli"
assert status["artifacts"] == [str(fake_home / "Library" / "LaunchAgents" / "org.voxvera.start-all.plist")]
assert any(check["name"] == "launchd_loaded" for check in status["checks"])
def test_macos_autostart_uninstall_removes_plist(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
fake_home = tmp_path / "home"
commands = []
monkeypatch.setattr(platform, "system", lambda: "Darwin")
monkeypatch.setattr(platform_base.Path, "home", lambda: fake_home)
monkeypatch.setattr(platform_base.subprocess, "check_output", lambda args, stderr=None: commands.append(args) or b"")
plist = fake_home / "Library" / "LaunchAgents" / "org.voxvera.start-all.plist"
plist.parent.mkdir(parents=True, exist_ok=True)
plist.write_text("plist", encoding="utf-8")
cli.main(["autostart", "uninstall"])
captured = capsys.readouterr()
assert "Removed LaunchAgent" in captured.out
assert not plist.exists()
assert ["launchctl", "unload", str(plist)] in commands
def test_windows_autostart_status_and_render(monkeypatch):
monkeypatch.setattr(platform, "system", lambda: "Windows")
monkeypatch.setattr(platform_base.subprocess, "check_output", lambda *args, **kwargs: b"TaskName: VoxVera Start All")
adapter = get_platform_adapter(cli_module=cli)
assert adapter.platform_id == "windows_cli"
command = adapter.render_schtasks_create_command("C:\\voxvera.exe")
assert command[:6] == ["schtasks", "/Create", "/F", "/SC", "ONLOGON", "/TN"]
assert "VoxVera Start All" in command
assert '"C:\\voxvera.exe" start-all' in command
status = adapter.autostart_status()
assert status["platform_id"] == "windows_cli"
assert any(check["name"] == "scheduled_task_present" for check in status["checks"])
def test_windows_autostart_uninstall_uses_schtasks(monkeypatch, capsys):
commands = []
monkeypatch.setattr(platform, "system", lambda: "Windows")
monkeypatch.setattr(platform_base.subprocess, "check_output", lambda args, stderr=None: commands.append(args) or b"SUCCESS")
cli.main(["autostart", "uninstall"])
captured = capsys.readouterr()
assert "removed" in captured.out.lower()
assert ["schtasks", "/Delete", "/TN", "VoxVera Start All", "/F"] in commands
def test_build_download_zip(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
res_root = tmp_path / "data" / "voxvera"
@@ -246,7 +575,7 @@ 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."""
"""Test quickstart builds and serves in one command."""
_setup_tmp(monkeypatch, tmp_path)
res_root = tmp_path / "data" / "voxvera"
test_data_dir = tmp_path / "data"
@@ -287,8 +616,6 @@ def test_quickstart_noninteractive(tmp_path, monkeypatch):
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 = test_data_dir / "host" / folder_name
assert (dir_path / "index.html").exists()
@@ -298,6 +625,30 @@ def test_quickstart_noninteractive(tmp_path, monkeypatch):
# tear_off_link should be set to onion address (for tear-off tabs)
assert updated["tear_off_link"] == onion_url
# url should be preserved (user-configured content link)
assert updated["url"] == config["url"]
def test_install_systemd_autostart_writes_recovery_timer(tmp_path, monkeypatch):
"""Test Linux autostart installs a recurring recovery service and timer."""
commands = []
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(platform_linux.Path, "home", lambda: tmp_path)
monkeypatch.setattr(cli, "_find_voxvera_bin", lambda: "/tmp/voxvera")
monkeypatch.setattr(platform_linux.subprocess, "run", lambda args, **kwargs: commands.append(args))
monkeypatch.setenv("USER", "tester")
cli._install_systemd_autostart()
service_dir = tmp_path / ".config" / "systemd" / "user"
service_text = (service_dir / "voxvera-start.service").read_text(encoding="utf-8")
timer_text = (service_dir / "voxvera-start.timer").read_text(encoding="utf-8")
assert "ExecStart=/tmp/voxvera start-all" in service_text
assert "OnUnitActiveSec=5min" in timer_text
assert "Persistent=true" in timer_text
assert any(args[:4] == ["systemctl", "--user", "enable", "--now"] and args[4] == "voxvera-start.timer" for args in commands)
assert any(args[:4] == ["systemctl", "--user", "start", "voxvera-start.service"] for args in commands)
def test_get_tor_ports_with_env_vars(monkeypatch):
@@ -387,7 +738,7 @@ def test_build_injects_localized_tor_browser_download_links(tmp_path, monkeypatc
def test_build_with_only_url(tmp_path, monkeypatch):
"""Test that QR codes work when only url (content link) is set."""
"""Test that the main URL also populates tear-off rendering before onion setup."""
_setup_tmp(monkeypatch, tmp_path)
# Set only url (content link)
@@ -407,9 +758,11 @@ def test_build_with_only_url(tmp_path, monkeypatch):
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)
html = (dest / "index.html").read_text(encoding="utf-8")
assert (dest / "qrcode-content.png").exists()
assert not (dest / "qrcode-tear-offs.png").exists()
assert (dest / "qrcode-tear-offs.png").exists()
assert 'class="container no-tear-offs"' not in html
assert "https://example.com/external-resource" in html
def test_build_with_only_tear_off_link(tmp_path, monkeypatch):
+68
View File
@@ -8,6 +8,74 @@ from voxvera import cli
# Helper to find project root
REPO_ROOT = Path(__file__).resolve().parent.parent
def test_support_matrix_exists_and_matches_docs():
support_matrix_path = REPO_ROOT / "support-matrix.json"
support_doc_path = REPO_ROOT / "docs" / "platform-support-matrix.md"
assert support_matrix_path.exists()
assert support_doc_path.exists()
support_data = json.loads(support_matrix_path.read_text(encoding="utf-8"))
assert support_data["default_supported_target"] == "linux_cli_systemd"
assert any(platform["tier"] == "supported" for platform in support_data["platforms"])
assert any(platform["id"] == "windows_cli" for platform in support_data["platforms"])
support_doc = support_doc_path.read_text(encoding="utf-8")
assert "Linux CLI with `systemd --user`" in support_doc
assert "Experimental" in support_doc
def test_platform_smoke_is_wired_into_validation_surfaces():
smoke_script = REPO_ROOT / "scripts" / "platform-smoke.sh"
ci_workflow = (REPO_ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
e2e_script = (REPO_ROOT / "ci" / "test-e2e.sh").read_text(encoding="utf-8")
binaries_workflow = (REPO_ROOT / ".github" / "workflows" / "binaries.yml").read_text(encoding="utf-8")
smoke_text = smoke_script.read_text(encoding="utf-8")
assert smoke_script.exists()
assert "scripts/platform-smoke.sh linux-cli" in ci_workflow
assert "scripts/platform-smoke.sh linux-cli" in e2e_script
assert "platform-status --json" in ci_workflow
assert "doctor --json" in ci_workflow
assert "autostart status --json" in ci_workflow
assert "platform-status --json" in binaries_workflow
assert "doctor --json" in binaries_workflow
assert "autostart status --json" in binaries_workflow
for target in ("linux-cli", "macos-cli", "windows-cli", "docker-cli"):
assert target in smoke_text
def test_linux_validation_runbook_and_helper_are_linked():
runbook = REPO_ROOT / "docs" / "linux-hosting-validation.md"
helper = REPO_ROOT / "scripts" / "linux-recovery-check.sh"
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
assert runbook.exists()
assert helper.exists()
assert "docs/linux-hosting-validation.md" in readme
assert "scripts/linux-recovery-check.sh" in runbook.read_text(encoding="utf-8")
def test_frozen_builds_bundle_support_matrix():
build_script = (REPO_ROOT / "scripts" / "build_binaries.sh").read_text(encoding="utf-8")
binaries_workflow = (REPO_ROOT / ".github" / "workflows" / "binaries.yml").read_text(encoding="utf-8")
assert '--add-data "support-matrix.json:."' in build_script
assert '--add-data "support-matrix.json${SEP}."' in binaries_workflow
def test_release_artifact_validation_is_wired_into_binaries_workflow():
validation_script = REPO_ROOT / "scripts" / "validate-release-artifacts.sh"
binaries_workflow = (REPO_ROOT / ".github" / "workflows" / "binaries.yml").read_text(encoding="utf-8")
script_text = validation_script.read_text(encoding="utf-8")
assert validation_script.exists()
assert "scripts/validate-release-artifacts.sh" in binaries_workflow
assert "dpkg-deb -x" in script_text
assert "platform-status --json" in script_text
assert "init --non-interactive" in script_text
def test_locale_completeness():
"""Verify that all non-English locales have the same keys as English."""
locales_dir = REPO_ROOT / "voxvera" / "locales"
+59
View File
@@ -4,6 +4,7 @@
# Installs Tor + OnionShare and VoxVera itself.
# --------------------------------------------------------------------
set -euo pipefail
OS_NAME="$(uname -s | tr '[:upper:]' '[:lower:]')"
## -------- helper functions -----------------------------------------
command_exists() { command -v "$1" &>/dev/null; }
@@ -12,6 +13,45 @@ msg() { printf "\e[32m%s\e[0m\n" "$*"; }
warn() { printf "\e[33m%s\e[0m\n" "$*" >&2; }
die() { printf "\e[31mERROR: %s\e[0m\n" "$*" >&2; exit 1; }
report_cli_status() {
command_exists voxvera || return 0
local platform_json doctor_json autostart_json
platform_json="$(voxvera platform-status --json 2>/dev/null || true)"
doctor_json="$(voxvera doctor --json 2>/dev/null || true)"
autostart_json="$(voxvera autostart status --json 2>/dev/null || true)"
if [ -n "$platform_json" ]; then
PLATFORM_JSON="$platform_json" python3 - <<'PY'
import json, os
data = json.loads(os.environ["PLATFORM_JSON"])
print(f"Platform tier: {data.get('tier', 'unknown')} ({data.get('label', 'unknown')})")
if data.get("hosting_model"):
print(f"Hosting model: {data['hosting_model']}")
PY
fi
if [ -n "$doctor_json" ]; then
DOCTOR_JSON="$doctor_json" python3 - <<'PY'
import json, os
data = json.loads(os.environ["DOCTOR_JSON"])
failed = [check["name"] for check in data.get("checks", []) if not check.get("ok")]
if failed:
print("Doctor checks needing attention: " + ", ".join(failed))
else:
print("Doctor checks: all passed")
PY
fi
if [ -n "$autostart_json" ]; then
AUTOSTART_JSON="$autostart_json" python3 - <<'PY'
import json, os
data = json.loads(os.environ["AUTOSTART_JSON"])
print("Autostart: " + data.get("message", "unknown"))
PY
fi
}
# Detect sudo/root
if command_exists sudo; then
SUDO="sudo"
@@ -31,6 +71,9 @@ else die "Unsupported distro – can't find apt/yum/dnf/pacman/apk."
fi
msg ">> Detected package manager: $PM"
if [[ "$OS_NAME" != linux* ]]; then
warn "This installer is experimental outside Linux and is not validated for reliable background hidden-service hosting."
fi
## -------- install system packages ----------------------------------
msg ">> Installing system dependencies..."
@@ -142,6 +185,16 @@ if command_exists systemctl; then
$SUDO systemctl restart tor || true
fi
if command_exists voxvera && [[ "$OS_NAME" == linux* ]]; then
msg ">> Installing Linux autostart recovery timer..."
voxvera autostart 2>/dev/null || warn "Could not install the VoxVera autostart timer automatically. Run 'voxvera autostart' manually."
fi
if command_exists voxvera; then
msg ">> Inspecting installed runtime posture..."
report_cli_status
fi
msg "------------------------------------------------------------------"
msg " VoxVera install finished!"
msg " Tor rc file : $TOR_DIR/torrc"
@@ -149,5 +202,11 @@ msg ""
msg " IMPORTANT: Please restart your terminal or run 'source ~/.bashrc'"
msg " (or your shell config) to use 'voxvera'."
msg ""
msg " Linux is the supported persistent-host target."
msg " Other package/install paths remain experimental."
msg ""
msg " Run 'voxvera check' to verify your setup after refreshing."
msg " Run 'voxvera autostart status' to inspect the recovery timer."
msg " Run 'voxvera autostart uninstall' to remove the recovery timer."
msg " Run 'bash scripts/linux-recovery-check.sh' from the repo for the Linux validation checklist."
msg "------------------------------------------------------------------"
+1 -1
View File
@@ -1,7 +1,7 @@
import sys
from pathlib import Path
__version__ = "0.3.6"
__version__ = "0.3.9"
# 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
+1 -1
View File
@@ -24,7 +24,7 @@ def generate_qr(config_path: Path, output_dir: Path) -> None:
config = json.load(f)
url = config.get("url", "")
tear = config.get("tear_off_link", "")
tear = config.get("tear_off_link", "") or url
# Skip QR generation if no URLs configured yet (will be generated after serve)
if not url and not tear:
+232 -96
View File
@@ -27,6 +27,7 @@ if getattr(sys, 'frozen', False):
from InquirerPy import prompt, inquirer
from rich.console import Console
from voxvera import __version__
from voxvera.platforms import get_platform_adapter
GITHUB_RECOMMENDED_BINARY_LIMIT = 95 * 1024 * 1024
LEGACY_CONFIG_KEYS = {
@@ -61,6 +62,7 @@ DATA_DIR = Path(os.environ.get("VOXVERA_DIR", Path.home()))
# Global locale state
CURRENT_LOCALE = {}
CURRENT_LANG_CODE = "en"
def tor_browser_download_url(lang_code: str) -> str:
@@ -80,10 +82,17 @@ def normalize_config(data: dict) -> dict:
return normalized
def get_effective_tear_off_link(data: dict) -> str:
"""Use the explicit tear-off link when present, otherwise fall back to the main URL."""
return (data.get("tear_off_link") or "").strip() or (data.get("url") or "").strip()
def build_render_context(data: dict) -> dict:
"""Add derived template values without mutating persisted config shape."""
render_data = dict(data)
render_data["tear_off_state_class"] = "" if render_data.get("tear_off_link", "").strip() else "no-tear-offs"
effective_tear_off_link = get_effective_tear_off_link(render_data)
render_data["tear_off_link"] = effective_tear_off_link
render_data["tear_off_state_class"] = "" if effective_tear_off_link else "no-tear-offs"
return render_data
@@ -137,7 +146,7 @@ def _locale_res(*parts) -> Traversable:
def load_locale(lang_code: str = "en"):
"""Load the specified locale JSON file."""
global CURRENT_LOCALE
global CURRENT_LOCALE, CURRENT_LANG_CODE
try:
res = _locale_res(f"{lang_code}.json")
if getattr(sys, 'frozen', False):
@@ -147,6 +156,7 @@ def load_locale(lang_code: str = "en"):
lang_code = "en"
locale_file = _locale_res("en.json")
CURRENT_LOCALE = json.loads(locale_file.read_text(encoding="utf-8"))
CURRENT_LANG_CODE = lang_code
else:
with resources.as_file(res) as locale_file:
if not locale_file.exists():
@@ -155,10 +165,12 @@ def load_locale(lang_code: str = "en"):
CURRENT_LOCALE = json.loads(fallback_file.read_text(encoding="utf-8"))
else:
CURRENT_LOCALE = json.loads(locale_file.read_text(encoding="utf-8"))
CURRENT_LANG_CODE = lang_code
except Exception as e:
print(f"Error loading locale {lang_code}: {e}")
# Final safety fallback to empty dict to avoid KeyErrors
CURRENT_LOCALE = {"cli": {}, "web": {}, "meta": {}}
CURRENT_LANG_CODE = "en"
def t(path: str, **kwargs) -> str:
@@ -172,6 +184,14 @@ def t(path: str, **kwargs) -> str:
return val.format(**kwargs)
def t_fallback(path: str, default: str, **kwargs) -> str:
"""Retrieve a localized string, but fall back to a provided default."""
value = t(path, **kwargs)
if value == path:
return default.format(**kwargs)
return value
def require_cmd(cmd: str):
if cmd == "onionshare-cli":
try:
@@ -233,6 +253,84 @@ def check_deps():
console.print(f"[red]{t('cli.all_deps_installed')}[/red]")
def print_doctor_report():
print_report("doctor")
def print_platform_status():
print_report("platform-status")
def print_report(report_type: str, json_output: bool = False):
adapter = get_platform_adapter(cli_module=sys.modules[__name__])
report = adapter.doctor_report() if report_type == "doctor" else adapter.platform_status()
if json_output:
print(json.dumps(report, indent=2))
return
console = Console()
if report_type == "doctor":
console.rule(f"VoxVera Doctor: {report['label']}")
console.print(f"Tier: {report['tier']}")
if report["hosting_model"]:
console.print(f"Hosting model: {report['hosting_model']}")
summary = report.get("summary", {})
if summary:
console.print(f"Overall: {'OK' if summary.get('ok') else 'WARN'}")
for section in report.get("sections", {}).values():
if not section.get("checks"):
continue
console.print(f"{section['title']}: {'OK' if section.get('ok') else 'WARN'}")
for check in report["checks"]:
style = "green" if check["ok"] else "red"
state = "OK" if check["ok"] else "FAIL"
console.print(f"[{style}]{state}[/{style}] {check['name']}: {check['detail']}")
return
console.rule(f"Platform Status: {report['label']}")
console.print(f"Tier: {report['tier']}")
if report["tier_description"]:
console.print(report["tier_description"])
if report["hosting_model"]:
console.print(f"Hosting model: {report['hosting_model']}")
if report["distribution_surfaces"]:
console.print("Distribution surfaces:")
for surface in report["distribution_surfaces"]:
console.print(f" - {surface}")
if report["required_capabilities"]:
console.print("Required capabilities:")
for capability in report["required_capabilities"]:
console.print(f" - {capability}")
if report["current_notes"]:
console.print("Current notes:")
for note in report["current_notes"]:
console.print(f" - {note}")
if report["next_gaps"]:
console.print("Next gaps:")
for gap in report["next_gaps"]:
console.print(f" - {gap}")
def print_autostart_status(json_output: bool = False):
adapter = get_platform_adapter(cli_module=sys.modules[__name__])
status = adapter.autostart_status()
if json_output:
print(json.dumps(status, indent=2))
return
console = Console()
console.rule(f"Autostart Status: {status['label']}")
console.print(f"Tier: {status['tier']}")
console.print(status["message"])
if status["artifacts"]:
console.print("Artifacts:")
for artifact in status["artifacts"]:
console.print(f" - {artifact}")
for check in status["checks"]:
style = "green" if check["ok"] else "red"
state = "OK" if check["ok"] else "FAIL"
console.print(f"[{style}]{state}[/{style}] {check['name']}: {check['detail']}")
def load_config(path: str) -> dict:
with open(path, "r") as fh:
return normalize_config(json.load(fh))
@@ -243,6 +341,18 @@ def save_config(data: dict, path: str):
json.dump(normalize_config(data), fh, indent=2)
def ensure_config_exists(path: str | Path) -> Path:
"""Seed a config file from the bundled defaults when missing."""
config_path = Path(path)
if config_path.exists():
return config_path
config_path.parent.mkdir(parents=True, exist_ok=True)
default_config = json.loads((_src_res("config.json")).read_text(encoding="utf-8"))
config_path.write_text(json.dumps(normalize_config(default_config), indent=2), encoding="utf-8")
return config_path
def _open_editor_terminal(initial: str) -> str:
"""Fallback to opening the user's $EDITOR in the terminal."""
import tempfile
@@ -357,12 +467,82 @@ def _len_validator(limit: int):
def _folder_name_validator(val: str):
if len(val) > 63:
return "Folder name must be at most 63 characters"
if not re.fullmatch(r"[a-z0-9-]+", val):
return "Use only lowercase letters, numbers and '-'"
if not re.fullmatch(r"[a-z0-9_-]+", val):
return "Use only lowercase letters, numbers, '-' and '_'"
return True
def _next_versioned_folder_name(folder_name: str) -> str:
"""Return the next available versioned folder name like jesus_01."""
host_dir = DATA_DIR / "host"
for index in range(1, 100):
candidate = f"{folder_name}_{index:02d}"
if not (host_dir / candidate).exists():
return candidate
raise RuntimeError(f"Could not allocate a versioned folder name for '{folder_name}'")
def resolve_new_site_folder(config_path: str, preserve_session_on_overwrite: bool = True) -> str | None:
"""Resolve collisions for a newly created site before the first build."""
data = load_config(config_path)
folder_name = data["folder_name"]
dest_dir = DATA_DIR / "host" / folder_name
if not dest_dir.exists():
return folder_name
suggested_name = _next_versioned_folder_name(folder_name)
action = inquirer.select(
message=t_fallback(
"cli.site_exists_action",
"Site '{name}' already exists. What do you want to do?",
name=folder_name,
),
choices=[
{
"name": t_fallback("cli.site_exists_overwrite", "Overwrite existing site"),
"value": "overwrite",
},
{
"name": t_fallback(
"cli.site_exists_version",
"Create new version ({suggested})",
suggested=suggested_name,
),
"value": "version",
},
{
"name": t_fallback("cli.site_exists_cancel", "Cancel"),
"value": "cancel",
},
],
default="version",
).execute()
if action in (None, "cancel"):
print(t_fallback("cli.site_exists_cancelled", "Site creation cancelled."))
return None
if action == "overwrite":
if preserve_session_on_overwrite:
_clear_host_dir(dest_dir)
else:
shutil.rmtree(dest_dir, ignore_errors=True)
return folder_name
data["folder_name"] = suggested_name
save_config(data, config_path)
print(
t_fallback(
"cli.site_exists_versioned",
"Using versioned folder name: {name}",
name=suggested_name,
)
)
return suggested_name
def interactive_update(config_path: str):
ensure_config_exists(config_path)
data = load_config(config_path)
console = Console()
@@ -1362,6 +1542,7 @@ def create_from_template(console):
# Load the preset JSON
preset_data = json.loads(Path(selected["file"]).read_text(encoding="utf-8"))
template_id = selected["id"]
preset_data["lang"] = CURRENT_LANG_CODE or preset_data.get("lang", "en")
# Override content fields with locale translations if available
for field in ["name", "title", "subtitle", "headline", "content", "url_message"]:
@@ -1375,6 +1556,8 @@ def create_from_template(console):
# Let user customize, build, and serve
interactive_update(str(config_path))
if resolve_new_site_folder(str(config_path)) is None:
return
build_assets(str(config_path))
serve(str(config_path))
@@ -1530,20 +1713,23 @@ def stop_all_servers():
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}. "
adapter = get_platform_adapter(cli_module=sys.modules[__name__])
try:
adapter.install_autostart()
except NotImplementedError:
print(f"Autostart not supported on {adapter.label}. "
"Run 'voxvera start-all' manually or add it to your init system.")
def uninstall_autostart():
"""Remove the platform-appropriate autostart mechanism."""
adapter = get_platform_adapter(cli_module=sys.modules[__name__])
try:
adapter.uninstall_autostart()
except NotImplementedError:
print(f"Autostart uninstall not supported on {adapter.label}.")
def _find_voxvera_bin() -> str:
"""Return the absolute path to the voxvera binary/script."""
voxvera_bin = shutil.which("voxvera")
@@ -1554,90 +1740,21 @@ def _find_voxvera_bin() -> str:
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")
"""Backward-compatible wrapper for Linux autostart installation."""
adapter = get_platform_adapter(cli_module=sys.modules[__name__])
adapter.install_autostart()
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}")
"""Backward-compatible wrapper for macOS autostart installation."""
adapter = get_platform_adapter(cli_module=sys.modules[__name__])
adapter.install_autostart()
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.")
"""Backward-compatible wrapper for Windows autostart installation."""
adapter = get_platform_adapter(cli_module=sys.modules[__name__])
adapter.install_autostart()
def manage_servers():
@@ -1696,6 +1813,8 @@ def manage_servers():
try:
interactive_update(str(config_path))
if resolve_new_site_folder(str(config_path)) is None:
continue
build_assets(str(config_path))
serve(str(config_path))
except (KeyboardInterrupt, Exception) as e:
@@ -1888,13 +2007,19 @@ def main(argv=None):
help="Skip interactive prompts and use existing config",
)
sub.add_parser("check", help="Check for required external dependencies")
p_doctor = sub.add_parser("doctor", help="Run platform-aware runtime diagnostics")
p_doctor.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
p_platform_status = sub.add_parser("platform-status", help="Show detected platform support tier and runtime posture")
p_platform_status.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
sub.add_parser("vendorize", help="Download all dependencies into voxvera/vendor/ for portability")
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")
p_autostart = sub.add_parser("autostart", help="Install or inspect platform autostart support")
p_autostart.add_argument("action", nargs="?", choices=["install", "status", "uninstall"], default="install")
p_autostart.add_argument("--json", action="store_true", help="Emit machine-readable JSON for status")
sub.add_parser("rebuild-all", help="Rebuild all existing sites from the current template (preserves keys and config)")
sub.add_parser("_internal_onionshare", help=argparse.SUPPRESS)
@@ -1951,7 +2076,8 @@ def main(argv=None):
if args.template:
copy_template(args.template)
return
elif not args.non_interactive:
ensure_config_exists(config_path)
if not args.non_interactive:
interactive_update(config_path)
build_assets(config_path)
elif args.command == "build":
@@ -1975,7 +2101,12 @@ def main(argv=None):
elif args.command == "stop-all":
stop_all_servers()
elif args.command == "autostart":
install_autostart()
if args.action == "status":
print_autostart_status(json_output=args.json)
elif args.action == "uninstall":
uninstall_autostart()
else:
install_autostart()
elif args.command == "quickstart":
if not args.non_interactive:
if not sys.stdin.isatty():
@@ -1987,8 +2118,13 @@ 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 == "doctor":
print_report("doctor", json_output=args.json)
elif args.command == "platform-status":
print_report("platform-status", json_output=args.json)
elif args.command == "vendorize":
vendorize()
elif args.command == "build-docs":
+17
View File
@@ -0,0 +1,17 @@
import platform
from .base import PlatformAdapter
from .linux import LinuxPlatformAdapter
from .macos import MacOSPlatformAdapter
from .windows import WindowsPlatformAdapter
def get_platform_adapter(cli_module=None) -> PlatformAdapter:
system = platform.system()
if system == "Linux":
return LinuxPlatformAdapter(cli_module=cli_module)
if system == "Darwin":
return MacOSPlatformAdapter(cli_module=cli_module)
if system == "Windows":
return WindowsPlatformAdapter(cli_module=cli_module)
return PlatformAdapter(cli_module=cli_module)
+163
View File
@@ -0,0 +1,163 @@
from __future__ import annotations
import json
import platform
import shutil
import subprocess
from pathlib import Path
class PlatformAdapter:
platform_id = "generic"
tier = "experimental"
label = "Generic platform"
def __init__(self, cli_module=None):
self.cli = cli_module
@property
def home_dir(self) -> Path:
return Path.home()
@property
def data_dir(self) -> Path:
if self.cli is not None:
return Path(self.cli.DATA_DIR)
return self.home_dir
@property
def repo_root(self) -> Path:
if self.cli is not None and hasattr(self.cli, "ROOT"):
return Path(self.cli.ROOT).parent
return Path(__file__).resolve().parents[2]
@property
def support_matrix_path(self) -> Path:
return self.repo_root / "support-matrix.json"
def _run_capture(self, args: list[str]) -> tuple[bool, str]:
try:
output = subprocess.check_output(args, stderr=subprocess.STDOUT).decode().strip()
return True, output
except Exception as exc:
return False, str(exc)
def load_support_matrix(self) -> dict:
if not self.support_matrix_path.exists():
return {"platforms": [], "support_tiers": {}}
return json.loads(self.support_matrix_path.read_text(encoding="utf-8"))
def support_entry(self) -> dict:
matrix = self.load_support_matrix()
for entry in matrix.get("platforms", []):
if entry.get("id") == self.platform_id:
return entry
return {
"id": self.platform_id,
"label": self.label,
"tier": self.tier,
"hosting_model": "",
"distribution_surfaces": [],
"required_capabilities": [],
"current_notes": [],
"next_gaps": [],
}
def autostart_status(self) -> dict:
return {
"platform_id": self.platform_id,
"label": self.label,
"tier": self.tier,
"supported": False,
"message": "Autostart status is not implemented for this platform.",
"artifacts": [],
"checks": [],
}
def install_autostart(self) -> None:
raise NotImplementedError("Autostart is not implemented for this platform.")
def uninstall_autostart(self) -> None:
raise NotImplementedError("Autostart uninstall is not implemented for this platform.")
def platform_status(self) -> dict:
entry = self.support_entry()
matrix = self.load_support_matrix()
return {
"platform_id": self.platform_id,
"label": entry.get("label", self.label),
"tier": entry.get("tier", self.tier),
"tier_description": matrix.get("support_tiers", {}).get(entry.get("tier", self.tier), ""),
"hosting_model": entry.get("hosting_model", ""),
"distribution_surfaces": entry.get("distribution_surfaces", []),
"required_capabilities": entry.get("required_capabilities", []),
"current_notes": entry.get("current_notes", []),
"next_gaps": entry.get("next_gaps", []),
}
def _doctor_section(self, title: str, check_names: list[str], checks: list[dict]) -> dict:
section_checks = [check for check in checks if check["name"] in check_names]
return {
"title": title,
"ok": all(check.get("ok") for check in section_checks) if section_checks else True,
"checks": section_checks,
}
def build_doctor_sections(self, checks: list[dict]) -> dict:
return {
"platform_contract": self._doctor_section("Platform contract", ["platform"], checks),
"dependencies": self._doctor_section("Dependencies", ["voxvera_cli", "onionshare_cli"], checks),
"network": self._doctor_section("Network", [], checks),
"autostart": self._doctor_section("Autostart", [], checks),
"content_state": self._doctor_section("Content state", [], checks),
}
def doctor_report(self) -> dict:
checks = []
voxvera_path = shutil.which("voxvera")
onionshare_path = shutil.which("onionshare-cli") or shutil.which("onionshare")
platform_status = self.platform_status()
checks.append(
{
"name": "platform",
"ok": True,
"detail": f"{platform.system()} ({self.platform_id}, tier={platform_status['tier']})",
}
)
checks.append(
{
"name": "voxvera_cli",
"ok": bool(voxvera_path),
"detail": voxvera_path or "voxvera not found on PATH",
}
)
checks.append(
{
"name": "onionshare_cli",
"ok": bool(onionshare_path),
"detail": onionshare_path or "onionshare-cli/onionshare not found on PATH",
}
)
sections = self.build_doctor_sections(checks)
return {
"platform_id": self.platform_id,
"label": platform_status["label"],
"tier": platform_status["tier"],
"tier_description": platform_status["tier_description"],
"hosting_model": platform_status["hosting_model"],
"distribution_surfaces": platform_status["distribution_surfaces"],
"required_capabilities": platform_status["required_capabilities"],
"current_notes": platform_status["current_notes"],
"next_gaps": platform_status["next_gaps"],
"summary": {
"ok": all(check.get("ok") for check in checks),
"failing_checks": [check["name"] for check in checks if not check.get("ok")],
},
"sections": sections,
"checks": checks,
}
def autostart_artifacts(self) -> list[str]:
return []
+246
View File
@@ -0,0 +1,246 @@
from __future__ import annotations
import os
import shutil
import socket
import subprocess
from pathlib import Path
from .base import PlatformAdapter
class LinuxPlatformAdapter(PlatformAdapter):
platform_id = "linux_cli_systemd"
tier = "supported"
label = "Linux CLI with systemd --user"
@property
def user_systemd_dir(self) -> Path:
return self.home_dir / ".config" / "systemd" / "user"
@property
def service_path(self) -> Path:
return self.user_systemd_dir / "voxvera-start.service"
@property
def timer_path(self) -> Path:
return self.user_systemd_dir / "voxvera-start.timer"
def autostart_artifacts(self) -> list[str]:
return [str(self.service_path), str(self.timer_path)]
def render_systemd_service(self, voxvera_bin: str) -> str:
return f"""\
[Unit]
Description=VoxVera hidden-service recovery runner
After=network-online.target tor.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart={voxvera_bin} start-all
"""
def render_systemd_timer(self) -> str:
return """\
[Unit]
Description=Retry VoxVera hidden-service startup
[Install]
WantedBy=timers.target
[Timer]
OnBootSec=2min
OnStartupSec=2min
OnUnitActiveSec=5min
Unit=voxvera-start.service
Persistent=true
"""
def autostart_status(self) -> dict:
checks = []
artifacts = self.autostart_artifacts()
for path in [self.service_path, self.timer_path]:
checks.append(
{
"name": path.name,
"ok": path.exists(),
"detail": str(path) if path.exists() else f"missing: {path}",
}
)
if shutil.which("systemctl"):
ok, output = self._run_capture(["systemctl", "--user", "is-enabled", "voxvera-start.timer"])
checks.append(
{
"name": "systemd_timer_enabled",
"ok": ok and output == "enabled",
"detail": output,
}
)
ok, output = self._run_capture(["systemctl", "--user", "is-active", "voxvera-start.timer"])
checks.append(
{
"name": "systemd_timer_active",
"ok": ok and output == "active",
"detail": output,
}
)
else:
checks.append(
{
"name": "systemctl",
"ok": False,
"detail": "systemctl not found",
}
)
supported = all(check["ok"] for check in checks if check["name"] in {"voxvera-start.service", "voxvera-start.timer"})
return {
"platform_id": self.platform_id,
"label": self.label,
"tier": self.tier,
"supported": supported,
"message": "Linux recovery timer is the supported persistent-host path.",
"artifacts": artifacts,
"checks": checks,
}
def install_autostart(self) -> None:
if self.cli is None:
raise RuntimeError("CLI module is required to install Linux autostart.")
self.user_systemd_dir.mkdir(parents=True, exist_ok=True)
voxvera_bin = self.cli._find_voxvera_bin()
self.service_path.write_text(self.render_systemd_service(voxvera_bin), encoding="utf-8")
self.timer_path.write_text(self.render_systemd_timer(), encoding="utf-8")
legacy_service = self.user_systemd_dir / "voxvera.service"
if legacy_service.exists():
legacy_service.unlink()
commands = [
["systemctl", "--user", "daemon-reload"],
["systemctl", "--user", "disable", "--now", "voxvera.service"],
["systemctl", "--user", "enable", "--now", "voxvera-start.timer"],
["systemctl", "--user", "start", "voxvera-start.service"],
]
for command in commands:
try:
subprocess.run(
command,
check=False,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=10,
)
except Exception:
pass
linger_output = ""
try:
result = subprocess.run(
["loginctl", "show-user", os.environ.get("USER", ""), "--property=Linger", "--value"],
check=False,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=5,
)
linger_output = (result.stdout or "").strip()
if linger_output == "no":
print("Note: user linger is not enabled. The recovery timer will run while you are logged in.")
print(" To enable boot-time user services, run: sudo loginctl enable-linger $USER")
except Exception:
pass
print(f"Systemd user service installed: {self.service_path}")
print(f"Systemd user timer installed: {self.timer_path}")
print("Sites will be retried automatically after boot and every few minutes.")
print(" Manage with: systemctl --user [start|stop|status] voxvera-start.service")
print(" Timer status: systemctl --user status voxvera-start.timer")
def uninstall_autostart(self) -> None:
commands = [
["systemctl", "--user", "disable", "--now", "voxvera-start.timer"],
["systemctl", "--user", "stop", "voxvera-start.service"],
["systemctl", "--user", "daemon-reload"],
]
for command in commands:
try:
subprocess.run(command, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
pass
self.service_path.unlink(missing_ok=True)
self.timer_path.unlink(missing_ok=True)
print(f"Removed systemd user service: {self.service_path}")
print(f"Removed systemd user timer: {self.timer_path}")
def doctor_report(self) -> dict:
report = super().doctor_report()
checks = report["checks"]
tor_found = bool(shutil.which("tor"))
checks.append(
{
"name": "tor_binary",
"ok": tor_found,
"detail": shutil.which("tor") or "tor not found on PATH",
}
)
socks_port = os.getenv("TOR_SOCKS_PORT")
if not socks_port:
for candidate in ("9050", "9150"):
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(0.25)
result = sock.connect_ex(("127.0.0.1", int(candidate)))
sock.close()
if result == 0:
socks_port = candidate
break
except Exception:
pass
checks.append(
{
"name": "tor_socks_reachable",
"ok": bool(socks_port),
"detail": f"reachable on {socks_port}" if socks_port else "no Tor SOCKS port detected",
}
)
status = self.autostart_status()
timer_check = next((check for check in status["checks"] if check["name"] == "systemd_timer_enabled"), None)
if timer_check is not None:
checks.append(
{
"name": "autostart_timer_enabled",
"ok": timer_check["ok"],
"detail": timer_check["detail"],
}
)
host_root = self.data_dir / "host"
checks.append(
{
"name": "host_root",
"ok": host_root.exists(),
"detail": str(host_root) if host_root.exists() else f"missing: {host_root}",
}
)
report["summary"] = {
"ok": all(check.get("ok") for check in checks),
"failing_checks": [check["name"] for check in checks if not check.get("ok")],
}
report["sections"] = {
"platform_contract": self._doctor_section("Platform contract", ["platform"], checks),
"dependencies": self._doctor_section("Dependencies", ["voxvera_cli", "onionshare_cli", "tor_binary"], checks),
"network": self._doctor_section("Network", ["tor_socks_reachable"], checks),
"autostart": self._doctor_section("Autostart", ["autostart_timer_enabled"], checks),
"content_state": self._doctor_section("Content state", ["host_root"], checks),
}
return report
+95
View File
@@ -0,0 +1,95 @@
from pathlib import Path
from .base import PlatformAdapter
class MacOSPlatformAdapter(PlatformAdapter):
platform_id = "macos_cli"
tier = "experimental"
label = "macOS CLI"
@property
def launch_agents_dir(self) -> Path:
return self.home_dir / "Library" / "LaunchAgents"
@property
def plist_path(self) -> Path:
return self.launch_agents_dir / "org.voxvera.start-all.plist"
def autostart_artifacts(self) -> list[str]:
return [str(self.plist_path)]
def render_launchd_plist(self, voxvera_bin: str) -> str:
home = self.home_dir
return 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>{home}/host/voxvera-autostart.log</string>
<key>StandardErrorPath</key>
<string>{home}/host/voxvera-autostart.log</string>
</dict>
</plist>
"""
def autostart_status(self) -> dict:
checks = [
{
"name": self.plist_path.name,
"ok": self.plist_path.exists(),
"detail": str(self.plist_path) if self.plist_path.exists() else f"missing: {self.plist_path}",
}
]
if self._run_capture(["launchctl", "list", "org.voxvera.start-all"])[0]:
checks.append(
{
"name": "launchd_loaded",
"ok": True,
"detail": "org.voxvera.start-all is loaded",
}
)
else:
checks.append(
{
"name": "launchd_loaded",
"ok": False,
"detail": "org.voxvera.start-all is not loaded",
}
)
return {
"platform_id": self.platform_id,
"label": self.label,
"tier": self.tier,
"supported": False,
"message": "macOS launchd autostart is experimental and not validated for reliable hidden-service recovery.",
"artifacts": self.autostart_artifacts(),
"checks": checks,
}
def install_autostart(self) -> None:
if self.cli is None:
raise RuntimeError("CLI module is required to install macOS autostart.")
self.launch_agents_dir.mkdir(parents=True, exist_ok=True)
voxvera_bin = self.cli._find_voxvera_bin()
self.plist_path.write_text(self.render_launchd_plist(voxvera_bin), encoding="utf-8")
self._run_capture(["launchctl", "load", "-w", str(self.plist_path)])
print(f"LaunchAgent installed: {self.plist_path}")
print("Sites will start automatically on login.")
print(f" Disable with: launchctl unload {self.plist_path}")
def uninstall_autostart(self) -> None:
self._run_capture(["launchctl", "unload", str(self.plist_path)])
self.plist_path.unlink(missing_ok=True)
print(f"Removed LaunchAgent: {self.plist_path}")
+61
View File
@@ -0,0 +1,61 @@
from .base import PlatformAdapter
class WindowsPlatformAdapter(PlatformAdapter):
platform_id = "windows_cli"
tier = "experimental"
label = "Windows CLI"
task_name = "VoxVera Start All"
def render_schtasks_create_command(self, voxvera_bin: str) -> list[str]:
return [
"schtasks",
"/Create",
"/F",
"/SC",
"ONLOGON",
"/TN",
self.task_name,
"/TR",
f'"{voxvera_bin}" start-all',
"/RL",
"HIGHEST",
]
def autostart_status(self) -> dict:
ok, output = self._run_capture(["schtasks", "/Query", "/TN", self.task_name])
checks = [
{
"name": "scheduled_task_present",
"ok": ok,
"detail": output if ok else f"missing or unreadable: {self.task_name}",
}
]
return {
"platform_id": self.platform_id,
"label": self.label,
"tier": self.tier,
"supported": False,
"message": "Windows Task Scheduler autostart is experimental and not validated for reliable hidden-service recovery.",
"artifacts": [],
"checks": checks,
}
def install_autostart(self) -> None:
if self.cli is None:
raise RuntimeError("CLI module is required to install Windows autostart.")
voxvera_bin = self.cli._find_voxvera_bin()
ok, _output = self._run_capture(self.render_schtasks_create_command(voxvera_bin))
if ok:
print(f"Windows scheduled task '{self.task_name}' created.")
print("Sites will start automatically on login.")
print(f' Remove with: schtasks /Delete /TN "{self.task_name}" /F')
return
print("Failed to create scheduled task. Try running as Administrator.")
def uninstall_autostart(self) -> None:
ok, _output = self._run_capture(["schtasks", "/Delete", "/TN", self.task_name, "/F"])
if ok:
print(f"Windows scheduled task '{self.task_name}' removed.")
return
print(f"Windows scheduled task '{self.task_name}' could not be removed.")