diff --git a/.gitignore b/.gitignore
index ee4133c..662e079 100644
--- a/.gitignore
+++ b/.gitignore
@@ -85,3 +85,6 @@ pishrink.sh
# Temp file storage
frontends/web/temp_files/
rpi/config.json
+
+# Pre-built venv tarball (39MB - too large for git)
+rpi/stegasoo-venv-pi-arm64.tar.zst
diff --git a/PLAN-4.1.4.md b/PLAN-4.1.4.md
index c100cee..1bcf85d 100644
--- a/PLAN-4.1.4.md
+++ b/PLAN-4.1.4.md
@@ -1,17 +1,157 @@
# Stegasoo 4.1.4 Plan
## Build / Deploy
-- [ ] Pre-built Python 3.12 venv tarball for Pi (skip 20+ min compile)
+- [x] Pre-built Python 3.12 venv tarball for Pi (skip 20+ min compile) - see details below
- [x] Fixed partition sizing in flash script (16GB rootfs for faster imaging)
- [x] Rename `flash-pi.sh` → `flash-stock-img.sh` for clarity
- [x] pip-audit integration in release validation
+### Pi venv Tarball Approach
+1. Flash fresh Pi image, let it fully build (20+ min compile)
+2. Once running and working, SSH in and create optimized tarball:
+ ```bash
+ cd /opt/stegasoo
+ # Strip caches and tests (295MB → 208MB)
+ find venv/ -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null
+ find venv/ -type d -name 'tests' -exec rm -rf {} + 2>/dev/null
+ find venv/ -type d -name 'test' -exec rm -rf {} + 2>/dev/null
+ # Compress with zstd (208MB → 39MB)
+ tar -cf - venv/ | zstd -19 -T0 > /tmp/stegasoo-venv-pi-arm64.tar.zst
+ ```
+3. Pull tarball to host: `scp admin@pi:/tmp/stegasoo-venv-pi-arm64.tar.zst rpi/`
+4. setup.sh auto-detects and extracts tarball if present in rpi/
+5. Re-flash and test fresh build with pre-built venv (should be <2 min vs 20+)
+
## Features
-- [ ] QR channel key sharing (needs UI thought - avoid crowding encode/decode pages)
+- [x] QR channel key sharing (see detailed plan below)
- [ ] Role-based permissions: admin / mod / user
- [x] `stegasoo info` fastfetch-style command (version, service status, channel, CPU, temp, etc.)
- [ ] Better capacity estimates / pre-flight check before encode fails
+---
+
+## QR Channel Key Sharing - Implementation Plan
+
+### Current State
+- ✅ **CLI**: `stegasoo channel qr` generates ASCII/PNG QR for server channel key
+- ✅ **Web UI (about.html)**: Client-side QR generator exists - input key, generate/show QR, download PNG
+- ✅ **Account page**: Shows saved channel keys with fingerprint, rename, delete
+- ❌ No role restrictions on QR sharing
+- ❌ No QR button for saved keys on account page
+- ❌ No QR scanning to import keys
+
+### Design Decisions
+
+**UI Placement** (avoiding encode/decode page crowding):
+- Keep QR generator in **about.html** (already exists, logical place for tools)
+- Add QR button to **account.html** saved keys (small icon, doesn't crowd)
+- Both should be admin-only
+
+**Role Restriction** (per user request):
+- QR sharing = admin only (hide generator + saved key QR buttons from non-admins)
+- Prerequisite: Need role-based permissions feature first
+- Interim option: Just hide from non-admin users using existing `is_admin` flag
+
+### Implementation Steps
+
+#### Phase 1: Admin-only restriction (quick win)
+1. **about.html**: Wrap QR generator section in `{% if is_admin %}` block
+2. **Account route**: Pass `is_admin` to template (if not already)
+3. **account.html**: Add small QR icon button to saved keys row (admin only)
+ - Opens modal with QR canvas (reuse qrcode.js pattern from about.html)
+ - Download PNG button in modal
+
+#### Phase 2: QR Import (optional enhancement)
+1. Add "Import via QR" button to account.html key-add section
+2. Use device camera or file upload to scan QR
+3. Decode and populate channel_key input field
+4. Requires `pyzbar` on server OR client-side JS library like `jsQR`
+
+### Files to Modify
+
+```
+frontends/web/app.py
+ - about() route: Add missing vars: is_admin, channel_configured,
+ channel_fingerprint, channel_source (BUG: currently not passed!)
+ - account() route: ✅ Already passes is_admin
+
+frontends/web/templates/about.html
+ - Wrap channel key QR section in {% if is_admin %}
+
+frontends/web/templates/account.html
+ - Add QR button to saved keys (admin only)
+ - Add QR modal (copy pattern from about.html)
+ - Include qrcode.min.js CDN script
+```
+
+### Bug Found During Research
+The about.html template uses `channel_configured`, `channel_fingerprint`,
+`channel_source` but the route doesn't pass them - always shows "public mode".
+Fix this while implementing QR admin restriction.
+
+### Exact Code Changes
+
+**app.py - Fix about() route (around line 1564):**
+```python
+@app.route("/about")
+def about():
+ from stegasoo.channel import get_channel_status
+ channel_status = get_channel_status()
+
+ # Check if user is admin (for QR sharing)
+ current_user = get_current_user()
+ is_admin = current_user.is_admin if current_user else False
+
+ return render_template(
+ "about.html",
+ has_argon2=has_argon2(),
+ has_qrcode_read=HAS_QRCODE_READ,
+ # Channel info (bugfix)
+ channel_configured=channel_status["configured"],
+ channel_fingerprint=channel_status.get("fingerprint"),
+ channel_source=channel_status.get("source"),
+ # Admin check for QR sharing
+ is_admin=is_admin,
+ )
+```
+
+### Template Changes Preview
+
+**account.html - Add to saved key row:**
+```html
+{% if is_admin %}
+
+{% endif %}
+```
+
+**about.html - Wrap existing section:**
+```html
+{% if is_admin %}
+
+
+ ...existing QR generator...
+
+{% endif %}
+```
+
+### Testing Checklist (Phase 1 Implemented)
+- [ ] Non-admin users cannot see QR generator in about.html
+- [ ] Non-admin users cannot see QR buttons on account page
+- [ ] Admin users can generate QR for any saved key
+- [ ] QR downloads work correctly
+- [ ] QR scans correctly with phone camera
+
+### Implementation Status
+**Phase 1: COMPLETE** - Admin-only QR sharing implemented:
+- `app.py`: Fixed about() route to pass channel status + is_admin
+- `about.html`: QR generator wrapped in `{% if is_admin %}` with Admin badge
+- `account.html`: QR button added to saved keys (admin only), modal + JS for generation/download
+
+---
+
## Security
- [ ] Optional encryption for temp file storage (paranoid mode, config toggle)
diff --git a/frontends/web/app.py b/frontends/web/app.py
index 48700f1..1730d4c 100644
--- a/frontends/web/app.py
+++ b/frontends/web/app.py
@@ -1561,7 +1561,25 @@ def decode_download(file_id):
@app.route("/about")
def about():
- return render_template("about.html", has_argon2=has_argon2(), has_qrcode_read=HAS_QRCODE_READ)
+ from stegasoo.channel import get_channel_status
+
+ channel_status = get_channel_status()
+
+ # Check if user is admin (for QR sharing)
+ current_user = get_current_user()
+ is_admin = current_user.is_admin if current_user else False
+
+ return render_template(
+ "about.html",
+ has_argon2=has_argon2(),
+ has_qrcode_read=HAS_QRCODE_READ,
+ # Channel info (bugfix - was not being passed)
+ channel_configured=channel_status["configured"],
+ channel_fingerprint=channel_status.get("fingerprint"),
+ channel_source=channel_status.get("source"),
+ # Admin check for QR sharing
+ is_admin=is_admin,
+ )
# ============================================================================
diff --git a/frontends/web/templates/about.html b/frontends/web/templates/about.html
index 5420405..76fdef3 100644
--- a/frontends/web/templates/about.html
+++ b/frontends/web/templates/about.html
@@ -331,10 +331,12 @@
{% endif %}
-
+
+ {% if is_admin %}
Generate a QR code to share a channel key with others.
@@ -366,6 +368,7 @@
+ {% endif %}
diff --git a/frontends/web/templates/account.html b/frontends/web/templates/account.html
index f958455..881fdbd 100644
--- a/frontends/web/templates/account.html
+++ b/frontends/web/templates/account.html
@@ -140,6 +140,13 @@
{% endif %}
+ {% if is_admin %}
+
+ {% endif %}
+
+{% if is_admin %}
+
+
+{% endif %}
{% endblock %}
{% block scripts %}
+{% if is_admin %}
+
+{% endif %}
{% endblock %}
diff --git a/rpi/BUILD_IMAGE.md b/rpi/BUILD_IMAGE.md
index 5605261..7c7b89b 100644
--- a/rpi/BUILD_IMAGE.md
+++ b/rpi/BUILD_IMAGE.md
@@ -26,8 +26,8 @@ ssh admin@stegasoo.local
# Take ownership of /opt (for pyenv, jpegio builds)
sudo chown admin:admin /opt
-# Install git (not included in Lite image)
-sudo apt-get update && sudo apt-get install -y git
+# Install git and zstd (not included in Lite image)
+sudo apt-get update && sudo apt-get install -y git zstd jq
```
## Step 4: Clone & Run Setup
@@ -39,7 +39,22 @@ cd stegasoo
./rpi/setup.sh
```
-This takes ~15-20 minutes and installs:
+### Fast Build Option (with pre-built venv)
+
+If you have `stegasoo-venv-pi-arm64.tar.zst` from a previous build:
+
+```bash
+cd /opt
+git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
+
+# Copy pre-built venv (from your host machine)
+# On host: scp rpi/stegasoo-venv-pi-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
+
+cd stegasoo
+./rpi/setup.sh # Detects tarball, extracts instead of compiling (~2 min vs 20+)
+```
+
+**Standard build** takes ~15-20 minutes and installs:
- Python 3.12 via pyenv
- jpegio (patched for ARM)
- Stegasoo with web UI
diff --git a/rpi/flash-image.sh b/rpi/flash-image.sh
index fbfdea5..1dffea4 100755
--- a/rpi/flash-image.sh
+++ b/rpi/flash-image.sh
@@ -8,9 +8,14 @@
# Supports: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip (GitHub release format)
# If device is specified, skips auto-detection (useful for NVMe/large drives)
#
+# Optional: Place config.json in same directory for headless WiFi setup
+#
set -e
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+CONFIG_FILE="$SCRIPT_DIR/config.json"
+
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
@@ -18,6 +23,28 @@ BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m'
+# Load config if present (optional - for headless WiFi setup)
+HAS_CONFIG=false
+if [ -f "$CONFIG_FILE" ] && command -v jq &> /dev/null; then
+ WIFI_SSID=$(jq -r '.wifiSSID // empty' "$CONFIG_FILE")
+ WIFI_PASS=$(jq -r '.wifiPassword // empty' "$CONFIG_FILE")
+ WIFI_COUNTRY=$(jq -r '.wifiCountry // "US"' "$CONFIG_FILE")
+ PI_HOSTNAME=$(jq -r '.hostname // empty' "$CONFIG_FILE")
+ if [ -n "$WIFI_SSID" ] && [ -n "$WIFI_PASS" ]; then
+ HAS_CONFIG=true
+ echo -e "${GREEN}Found config.json - will configure WiFi after flash${NC}"
+ echo -e " WiFi: ${YELLOW}$WIFI_SSID${NC}"
+ if [ -n "$PI_HOSTNAME" ]; then
+ echo -e " Hostname: ${YELLOW}$PI_HOSTNAME${NC}"
+ fi
+ echo ""
+ fi
+elif [ -f "$CONFIG_FILE" ]; then
+ echo -e "${YELLOW}Note: config.json found but jq not installed (apt install jq)${NC}"
+ echo -e "${YELLOW} WiFi will need to be configured manually after boot${NC}"
+ echo ""
+fi
+
# Check for required tools
for cmd in dd lsblk; do
if ! command -v $cmd &> /dev/null; then
@@ -222,6 +249,17 @@ if [ -n "$MOUNTED" ]; then
done
fi
+# Ask about wiping
+echo
+read -p "Wipe partition table first? (recommended if having issues) [y/N] " wipe_confirm
+if [[ "$wipe_confirm" =~ ^[Yy]$ ]]; then
+ echo "Wiping partition table..."
+ sudo wipefs -a "$SELECTED"
+ sudo dd if=/dev/zero of="$SELECTED" bs=1M count=10 status=none
+ sync
+ echo " Wiped clean"
+fi
+
# Final confirmation
echo -e "${RED}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${RED}║ WARNING: ALL DATA ON THIS DEVICE WILL BE DESTROYED! ║${NC}"
@@ -284,6 +322,109 @@ echo ""
echo -e "${GREEN}Syncing...${NC}"
sync
+# Inject WiFi config if config.json was loaded
+if [ "$HAS_CONFIG" = true ]; then
+ echo ""
+ echo -e "${GREEN}Configuring WiFi from config.json...${NC}"
+
+ # Wait for partitions to appear
+ sleep 2
+ partprobe "$SELECTED" 2>/dev/null || true
+ sleep 1
+
+ # Determine boot partition
+ if [[ "$SELECTED" == *"nvme"* ]] || [[ "$SELECTED" == *"mmcblk"* ]]; then
+ BOOT_PART="${SELECTED}p1"
+ else
+ BOOT_PART="${SELECTED}1"
+ fi
+
+ if [ -b "$BOOT_PART" ]; then
+ MOUNT_DIR=$(mktemp -d)
+ if mount "$BOOT_PART" "$MOUNT_DIR" 2>/dev/null; then
+ # Create firstrun.sh for WiFi setup
+ cat > "$MOUNT_DIR/firstrun.sh" << 'EOFSCRIPT'
+#!/bin/bash
+set +e
+
+# Set hostname if provided
+if [ -n "PLACEHOLDER_HOSTNAME" ] && [ "PLACEHOLDER_HOSTNAME" != "" ]; then
+ CURRENT_HOSTNAME=$(cat /etc/hostname | tr -d " \t\n\r")
+ if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
+ /usr/lib/raspberrypi-sys-mods/imager_custom set_hostname PLACEHOLDER_HOSTNAME
+ else
+ echo PLACEHOLDER_HOSTNAME >/etc/hostname
+ sed -i "s/127.0.1.1.*$CURRENT_HOSTNAME/127.0.1.1\tPLACEHOLDER_HOSTNAME/g" /etc/hosts
+ fi
+fi
+
+# Configure WiFi
+if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
+ /usr/lib/raspberrypi-sys-mods/imager_custom set_wlan 'PLACEHOLDER_SSID' 'PLACEHOLDER_WIFIPASS' 'PLACEHOLDER_COUNTRY'
+else
+ # NetworkManager method (Trixie)
+ cat >/etc/NetworkManager/system-connections/preconfigured.nmconnection <<'NMEOF'
+[connection]
+id=preconfigured
+type=wifi
+autoconnect=true
+
+[wifi]
+mode=infrastructure
+ssid=PLACEHOLDER_SSID
+
+[wifi-security]
+auth-alg=open
+key-mgmt=wpa-psk
+psk=PLACEHOLDER_WIFIPASS
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=auto
+NMEOF
+ chmod 600 /etc/NetworkManager/system-connections/preconfigured.nmconnection
+ rfkill unblock wifi
+fi
+
+# Cleanup
+rm -f /boot/firstrun.sh
+rm -f /boot/firmware/firstrun.sh
+sed -i 's| systemd.run.*||g' /boot/cmdline.txt 2>/dev/null
+sed -i 's| systemd.run.*||g' /boot/firmware/cmdline.txt 2>/dev/null
+exit 0
+EOFSCRIPT
+
+ # Replace placeholders
+ sed -i "s/PLACEHOLDER_SSID/$WIFI_SSID/g" "$MOUNT_DIR/firstrun.sh"
+ sed -i "s/PLACEHOLDER_WIFIPASS/$WIFI_PASS/g" "$MOUNT_DIR/firstrun.sh"
+ sed -i "s/PLACEHOLDER_COUNTRY/$WIFI_COUNTRY/g" "$MOUNT_DIR/firstrun.sh"
+ if [ -n "$PI_HOSTNAME" ]; then
+ sed -i "s/PLACEHOLDER_HOSTNAME/$PI_HOSTNAME/g" "$MOUNT_DIR/firstrun.sh"
+ else
+ sed -i "s/PLACEHOLDER_HOSTNAME//g" "$MOUNT_DIR/firstrun.sh"
+ fi
+ chmod +x "$MOUNT_DIR/firstrun.sh"
+
+ # Update cmdline.txt to run firstrun.sh
+ CMDLINE="$MOUNT_DIR/cmdline.txt"
+ if [ -f "$CMDLINE" ]; then
+ CURRENT=$(cat "$CMDLINE" | tr -d '\n' | sed 's| systemd.run.*||g')
+ echo "$CURRENT systemd.run=/boot/firmware/firstrun.sh systemd.run_success_action=reboot systemd.unit=kernel-command-line.target" > "$CMDLINE"
+ fi
+
+ umount "$MOUNT_DIR"
+ echo -e " ${GREEN}✓${NC} WiFi configured for: $WIFI_SSID"
+ else
+ echo -e " ${YELLOW}⚠${NC} Could not mount boot partition"
+ fi
+ rmdir "$MOUNT_DIR" 2>/dev/null || true
+ else
+ echo -e " ${YELLOW}⚠${NC} Boot partition not found"
+ fi
+fi
+
echo ""
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Flash Complete! ║${NC}"
@@ -291,5 +432,11 @@ echo -e "${GREEN}╚════════════════════
echo ""
echo -e "You can now remove the SD card and boot your Raspberry Pi."
echo ""
-echo -e "${YELLOW}Tip:${NC} On first boot, SSH in and the setup wizard will run automatically."
+if [ "$HAS_CONFIG" = true ]; then
+ echo -e "${GREEN}WiFi pre-configured${NC} - Pi will connect to $WIFI_SSID on boot"
+ echo -e "SSH: ${YELLOW}ssh admin@${PI_HOSTNAME:-stegasoo}.local${NC} (password: stegasoo)"
+else
+ echo -e "${YELLOW}Tip:${NC} On first boot, the setup wizard will help configure WiFi."
+ echo -e "${YELLOW}Tip:${NC} Or place config.json in rpi/ for headless setup next time."
+fi
echo ""
diff --git a/rpi/pull-image.sh b/rpi/pull-image.sh
index 0eaa926..d22e326 100755
--- a/rpi/pull-image.sh
+++ b/rpi/pull-image.sh
@@ -168,12 +168,41 @@ fi
DEV_SIZE=$(blockdev --getsize64 "$SELECTED")
echo ""
-echo -e "${GREEN}[1/3]${NC} Copying image from $SELECTED..."
+echo -e "${GREEN}[1/4]${NC} Copying image from $SELECTED..."
dd if="$SELECTED" bs=4M status=none | pv -s "$DEV_SIZE" > "$IMG_FILE"
sync
echo ""
-echo -e "${GREEN}[2/3]${NC} Shrinking image..."
+echo -e "${GREEN}[2/4]${NC} Re-enabling auto-expand for distribution..."
+# Mount the image and restore auto-expand service (may have been disabled during build)
+LOOP_DEV=$(losetup -f --show -P "$IMG_FILE")
+if [ -n "$LOOP_DEV" ]; then
+ TEMP_MOUNT=$(mktemp -d)
+ if mount "${LOOP_DEV}p2" "$TEMP_MOUNT" 2>/dev/null; then
+ # Re-enable the resize service if the service file exists
+ SERVICE_FILE="$TEMP_MOUNT/lib/systemd/system/rpi-resizerootfs.service"
+ SERVICE_LINK="$TEMP_MOUNT/etc/systemd/system/multi-user.target.wants/rpi-resizerootfs.service"
+ if [ -f "$SERVICE_FILE" ] && [ ! -L "$SERVICE_LINK" ]; then
+ mkdir -p "$(dirname "$SERVICE_LINK")"
+ ln -sf /lib/systemd/system/rpi-resizerootfs.service "$SERVICE_LINK"
+ echo -e " ${GREEN}✓${NC} Auto-expand service re-enabled"
+ elif [ -L "$SERVICE_LINK" ]; then
+ echo -e " ${GREEN}✓${NC} Auto-expand already enabled"
+ else
+ echo -e " ${YELLOW}⚠${NC} Could not find resize service file"
+ fi
+ umount "$TEMP_MOUNT"
+ else
+ echo -e " ${YELLOW}⚠${NC} Could not mount rootfs, skipping auto-expand fix"
+ fi
+ rmdir "$TEMP_MOUNT" 2>/dev/null || true
+ losetup -d "$LOOP_DEV"
+else
+ echo -e " ${YELLOW}⚠${NC} Could not create loop device, skipping auto-expand fix"
+fi
+
+echo ""
+echo -e "${GREEN}[3/4]${NC} Shrinking image..."
if command -v pishrink.sh &> /dev/null; then
pishrink.sh "$IMG_FILE"
elif [ -f "./pishrink.sh" ]; then
@@ -187,11 +216,11 @@ fi
echo ""
if [ "$SKIP_COMPRESS" = true ]; then
- echo -e "${GREEN}[3/3]${NC} Skipping compression (.img output)"
+ echo -e "${GREEN}[4/4]${NC} Skipping compression (.img output)"
FINAL_SIZE=$(du -h "$IMG_FILE" | awk '{print $1}')
OUTPUT="$IMG_FILE"
else
- echo -e "${GREEN}[3/3]${NC} Compressing with zstd..."
+ echo -e "${GREEN}[4/4]${NC} Compressing with zstd..."
pv "$IMG_FILE" | zstd -19 -T0 -q > "$OUTPUT"
rm -f "$IMG_FILE"
FINAL_SIZE=$(du -h "$OUTPUT" | awk '{print $1}')
diff --git a/rpi/setup.sh b/rpi/setup.sh
index 454def7..d98601f 100755
--- a/rpi/setup.sh
+++ b/rpi/setup.sh
@@ -135,6 +135,7 @@ sudo apt-get install -y \
build-essential \
git \
curl \
+ zstd \
libssl-dev \
zlib1g-dev \
libbz2-dev \
@@ -218,50 +219,84 @@ else
cd "$INSTALL_DIR"
fi
-echo -e "${GREEN}[6/12]${NC} Creating Python virtual environment..."
+# Check for pre-built venv tarball (skips 20+ min compile time)
+PREBUILT_VENV="$INSTALL_DIR/rpi/stegasoo-venv-pi-arm64.tar.zst"
+PREBUILT_VENV_URL="${PREBUILT_VENV_URL:-}" # Optional: URL to download from
-# Create venv with pyenv Python (not system Python)
-# Use pyenv which to get actual path (handles 3.12 -> 3.12.12 mapping)
-PYENV_PYTHON=$(pyenv which python)
-echo " Using Python: $PYENV_PYTHON"
-if [ ! -d "venv" ]; then
- "$PYENV_PYTHON" -m venv venv
-fi
-source venv/bin/activate
+if [ -f "$PREBUILT_VENV" ] || [ -n "$PREBUILT_VENV_URL" ]; then
+ echo -e "${GREEN}[6/8]${NC} Installing pre-built Python environment..."
-# Verify we're using the right Python
-VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
-echo " venv Python: $VENV_PY"
+ # Download if URL provided and local file doesn't exist
+ if [ ! -f "$PREBUILT_VENV" ] && [ -n "$PREBUILT_VENV_URL" ]; then
+ echo " Downloading pre-built venv..."
+ curl -L -o "$PREBUILT_VENV" "$PREBUILT_VENV_URL"
+ fi
-echo -e "${GREEN}[7/12]${NC} Building jpegio for ARM..."
+ # Extract pre-built venv (zstd compressed)
+ echo " Extracting pre-built venv (this is much faster!)..."
+ zstd -d "$PREBUILT_VENV" --stdout | tar -xf - -C "$INSTALL_DIR"
-# Clone jpegio
-JPEGIO_DIR="/tmp/jpegio-build"
-rm -rf "$JPEGIO_DIR"
-git clone "$JPEGIO_REPO" "$JPEGIO_DIR"
+ # Activate and verify
+ source venv/bin/activate
+ VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
+ echo -e " ${GREEN}✓${NC} venv Python: $VENV_PY"
-# Apply ARM64 patch
-if [ -f "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
- bash "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
+ # Install stegasoo package in editable mode (quick, no compile)
+ echo -e "${GREEN}[7/8]${NC} Installing Stegasoo package..."
+ pip install -e "." --quiet
+
+ # Adjust step numbers for rest of script
+ STEP_OFFSET=-4
else
- echo " Applying inline ARM64 patch..."
- sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
+ echo -e "${GREEN}[6/12]${NC} Creating Python virtual environment..."
+ echo -e " ${YELLOW}Note: No pre-built venv found. Building from source (20+ min)${NC}"
+ echo -e " ${YELLOW}To speed up future installs, add stegasoo-venv-pi-arm64.tar.gz to rpi/${NC}"
+
+ # Create venv with pyenv Python (not system Python)
+ # Use pyenv which to get actual path (handles 3.12 -> 3.12.12 mapping)
+ PYENV_PYTHON=$(pyenv which python)
+ echo " Using Python: $PYENV_PYTHON"
+ if [ ! -d "venv" ]; then
+ "$PYENV_PYTHON" -m venv venv
+ fi
+ source venv/bin/activate
+
+ # Verify we're using the right Python
+ VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
+ echo " venv Python: $VENV_PY"
+
+ echo -e "${GREEN}[7/12]${NC} Building jpegio for ARM..."
+
+ # Clone jpegio
+ JPEGIO_DIR="/tmp/jpegio-build"
+ rm -rf "$JPEGIO_DIR"
+ git clone "$JPEGIO_REPO" "$JPEGIO_DIR"
+
+ # Apply ARM64 patch
+ if [ -f "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
+ bash "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
+ else
+ echo " Applying inline ARM64 patch..."
+ sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
+ fi
+
+ cd "$JPEGIO_DIR"
+
+ # Build jpegio into venv
+ pip install --upgrade pip setuptools wheel cython numpy
+ pip install .
+
+ cd "$INSTALL_DIR"
+ rm -rf "$JPEGIO_DIR"
+
+ echo -e "${GREEN}[8/12]${NC} Installing Stegasoo..."
+
+ # Install dependencies (jpegio already in venv, won't re-download)
+ pip install -e ".[web]"
+
+ STEP_OFFSET=0
fi
-cd "$JPEGIO_DIR"
-
-# Build jpegio into venv
-pip install --upgrade pip setuptools wheel cython numpy
-pip install .
-
-cd "$INSTALL_DIR"
-rm -rf "$JPEGIO_DIR"
-
-echo -e "${GREEN}[8/12]${NC} Installing Stegasoo..."
-
-# Install dependencies (jpegio already in venv, won't re-download)
-pip install -e ".[web]"
-
echo -e "${GREEN}[9/12]${NC} Creating systemd service..."
# Create systemd service file