From dee2d53f32a1fbcc66333e57f8beecb140efbbc0 Mon Sep 17 00:00:00 2001 From: exHuman <140423034+exhuman777@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:52:12 +0200 Subject: [PATCH] feat: add native macOS support (v0.4.5) - CoreWLAN WiFi diagnostics (SSID, RSSI, channel, noise, PHY mode) - WiFi site survey for ESP32 node placement - macOS system info + USB serial driver detection (CP210x, CH340, FTDI) - Permissions checker (network, USB, WiFi scan, location) - Entitlements for network, USB, serial, Bonjour access - Overlay titlebar with native traffic lights - DMG installer with Applications shortcut - Universal binary (lipo ARM64+x64) in CI - Code signing + notarization workflow (when secrets configured) - macOS diagnostics page in UI (conditional, only on macOS) - Install script: scripts/install-macos.sh - Cargo cache in CI, separate runners per arch Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/desktop-release.yml | 247 ++++++++++-- .../wifi-densepose-desktop/entitlements.plist | 24 ++ .../src/commands/macos.rs | 364 ++++++++++++++++++ .../src/commands/mod.rs | 2 + .../crates/wifi-densepose-desktop/src/lib.rs | 132 +++++-- .../crates/wifi-densepose-desktop/src/main.rs | 4 + .../wifi-densepose-desktop/tauri.conf.json | 36 +- .../wifi-densepose-desktop/ui/src/App.tsx | 8 +- .../ui/src/design-system.css | 25 ++ .../wifi-densepose-desktop/ui/src/main.tsx | 5 + .../ui/src/pages/MacOSDiagnostics.tsx | 253 ++++++++++++ .../wifi-densepose-desktop/ui/src/types.ts | 39 ++ .../wifi-densepose-desktop/ui/src/version.ts | 2 +- scripts/install-macos.sh | 75 ++++ 14 files changed, 1129 insertions(+), 87 deletions(-) create mode 100644 rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/entitlements.plist create mode 100644 rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/src/commands/macos.rs create mode 100644 rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/MacOSDiagnostics.tsx create mode 100755 scripts/install-macos.sh diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 36555d80b..05d22f242 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -7,9 +7,9 @@ on: workflow_dispatch: inputs: version: - description: 'Version to release (e.g., 0.4.0)' + description: 'Version to release (e.g., 0.4.5)' required: true - default: '0.4.0' + default: '0.4.5' attach_to_existing: description: 'Attach to existing release tag (leave empty to create new)' required: false @@ -20,11 +20,17 @@ env: jobs: build-macos: - name: Build macOS - runs-on: macos-latest + name: Build macOS (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} strategy: matrix: - target: [aarch64-apple-darwin, x86_64-apple-darwin] + include: + - target: aarch64-apple-darwin + arch: arm64 + runner: macos-14 + - target: x86_64-apple-darwin + arch: x64 + runner: macos-13 steps: - name: Checkout uses: actions/checkout@v4 @@ -39,6 +45,19 @@ jobs: with: targets: ${{ matrix.target }} + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + rust-port/wifi-densepose-rs/target/ + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.target }}-cargo- + - name: Install frontend dependencies working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui run: npm ci @@ -48,7 +67,7 @@ jobs: run: npm run build - name: Install Tauri CLI - run: cargo install tauri-cli --version "^2.0.0" + run: cargo install tauri-cli --version "^2.0.0" --locked - name: Build Tauri app working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop @@ -57,25 +76,138 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - - name: Get architecture name - id: arch + - name: Import signing certificate + if: env.APPLE_CERTIFICATE != '' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} run: | - if [ "${{ matrix.target }}" = "aarch64-apple-darwin" ]; then - echo "arch=arm64" >> $GITHUB_OUTPUT - else - echo "arch=x64" >> $GITHUB_OUTPUT - fi + echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12 + security create-keychain -p "" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "" build.keychain + security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain + rm certificate.p12 + + - name: Sign .app bundle + if: env.APPLE_CERTIFICATE != '' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + APP_PATH="rust-port/wifi-densepose-rs/target/${{ matrix.target }}/release/bundle/macos/RuView Desktop.app" + codesign --deep --force --options runtime \ + --entitlements rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/entitlements.plist \ + --sign "$APPLE_SIGNING_IDENTITY" "$APP_PATH" - - name: Package macOS app + - name: Notarize app + if: env.APPLE_ID != '' + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + APP_PATH="rust-port/wifi-densepose-rs/target/${{ matrix.target }}/release/bundle/macos/RuView Desktop.app" + ZIP_PATH="/tmp/RuView-notarize.zip" + ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH" + xcrun notarytool submit "$ZIP_PATH" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + xcrun stapler staple "$APP_PATH" + + - name: Package macOS DMG run: | - cd rust-port/wifi-densepose-rs/target/${{ matrix.target }}/release/bundle/macos - zip -r "RuView-Desktop-${{ github.event.inputs.version || '0.4.0' }}-macos-${{ steps.arch.outputs.arch }}.zip" "RuView Desktop.app" + VERSION="${{ github.event.inputs.version || '0.4.5' }}" + ARCH="${{ matrix.arch }}" + APP_PATH="rust-port/wifi-densepose-rs/target/${{ matrix.target }}/release/bundle/macos" + DMG_NAME="RuView-Desktop-${VERSION}-macos-${ARCH}.dmg" + + # Create DMG with Applications symlink + mkdir -p /tmp/dmg-staging + cp -r "${APP_PATH}/RuView Desktop.app" /tmp/dmg-staging/ + ln -s /Applications /tmp/dmg-staging/Applications + + hdiutil create -volname "RuView Desktop" \ + -srcfolder /tmp/dmg-staging \ + -ov -format UDZO \ + "${APP_PATH}/${DMG_NAME}" + + rm -rf /tmp/dmg-staging - - name: Upload macOS artifact + - name: Package macOS ZIP + run: | + VERSION="${{ github.event.inputs.version || '0.4.5' }}" + ARCH="${{ matrix.arch }}" + cd "rust-port/wifi-densepose-rs/target/${{ matrix.target }}/release/bundle/macos" + zip -r "RuView-Desktop-${VERSION}-macos-${ARCH}.zip" "RuView Desktop.app" + + - name: Upload macOS artifacts uses: actions/upload-artifact@v4 with: - name: ruview-macos-${{ steps.arch.outputs.arch }} - path: rust-port/wifi-densepose-rs/target/${{ matrix.target }}/release/bundle/macos/*.zip + name: ruview-macos-${{ matrix.arch }} + path: | + rust-port/wifi-densepose-rs/target/${{ matrix.target }}/release/bundle/macos/*.zip + rust-port/wifi-densepose-rs/target/${{ matrix.target }}/release/bundle/macos/*.dmg + + build-macos-universal: + name: Build macOS Universal + needs: [build-macos] + runs-on: macos-14 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download ARM64 artifact + uses: actions/download-artifact@v4 + with: + name: ruview-macos-arm64 + path: artifacts/arm64 + + - name: Download x64 artifact + uses: actions/download-artifact@v4 + with: + name: ruview-macos-x64 + path: artifacts/x64 + + - name: Create universal DMG + run: | + VERSION="${{ github.event.inputs.version || '0.4.5' }}" + + # Extract both apps + mkdir -p /tmp/arm64 /tmp/x64 + cd artifacts/arm64 && unzip -q "RuView-Desktop-${VERSION}-macos-arm64.zip" -d /tmp/arm64 && cd ../.. + cd artifacts/x64 && unzip -q "RuView-Desktop-${VERSION}-macos-x64.zip" -d /tmp/x64 && cd ../.. + + # Use lipo to create universal binary from the main executable + ARM_BIN="/tmp/arm64/RuView Desktop.app/Contents/MacOS/RuView Desktop" + X64_BIN="/tmp/x64/RuView Desktop.app/Contents/MacOS/RuView Desktop" + + if [ -f "$ARM_BIN" ] && [ -f "$X64_BIN" ]; then + mkdir -p /tmp/universal + cp -r "/tmp/arm64/RuView Desktop.app" "/tmp/universal/" + lipo -create "$ARM_BIN" "$X64_BIN" -output "/tmp/universal/RuView Desktop.app/Contents/MacOS/RuView Desktop" + + # Create universal DMG + mkdir -p /tmp/dmg-universal + cp -r "/tmp/universal/RuView Desktop.app" /tmp/dmg-universal/ + ln -s /Applications /tmp/dmg-universal/Applications + + hdiutil create -volname "RuView Desktop" \ + -srcfolder /tmp/dmg-universal \ + -ov -format UDZO \ + "artifacts/RuView-Desktop-${VERSION}-macos-universal.dmg" + + rm -rf /tmp/dmg-universal /tmp/universal + fi + + - name: Upload universal artifact + uses: actions/upload-artifact@v4 + with: + name: ruview-macos-universal + path: artifacts/*.dmg build-windows: name: Build Windows @@ -92,6 +224,19 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@stable + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + rust-port/wifi-densepose-rs/target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + - name: Install frontend dependencies working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui run: npm ci @@ -101,7 +246,7 @@ jobs: run: npm run build - name: Install Tauri CLI - run: cargo install tauri-cli --version "^2.0.0" + run: cargo install tauri-cli --version "^2.0.0" --locked - name: Build Tauri app working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop @@ -124,7 +269,7 @@ jobs: create-release: name: Create Release - needs: [build-macos, build-windows] + needs: [build-macos, build-macos-universal, build-windows] runs-on: ubuntu-latest permissions: contents: write @@ -143,38 +288,60 @@ jobs: - name: Create or Update Release uses: softprops/action-gh-release@v2 with: - name: RuView Desktop v${{ github.event.inputs.version || '0.4.0' }} - tag_name: ${{ github.event.inputs.attach_to_existing || format('desktop-v{0}', github.event.inputs.version || '0.4.0') }} + name: RuView Desktop v${{ github.event.inputs.version || '0.4.5' }} + tag_name: ${{ github.event.inputs.attach_to_existing || format('desktop-v{0}', github.event.inputs.version || '0.4.5') }} draft: false prerelease: false generate_release_notes: ${{ github.event.inputs.attach_to_existing == '' }} files: | artifacts/**/*.zip + artifacts/**/*.dmg artifacts/**/*.msi artifacts/**/*.exe - artifacts/**/*.dmg body: | - ## RuView Desktop v${{ github.event.inputs.version || '0.4.0' }} + ## RuView Desktop v${{ github.event.inputs.version || '0.4.5' }} WiFi-based human pose estimation desktop application. ### Downloads - | Platform | Architecture | Download | - |----------|--------------|----------| - | macOS | Apple Silicon (M1/M2/M3) | `RuView-Desktop-*-macos-arm64.zip` | - | macOS | Intel | `RuView-Desktop-*-macos-x64.zip` | - | Windows | x64 | `RuView-Desktop-*.msi` or `RuView-Desktop-*.exe` | - - ### Installation - - **macOS:** - 1. Download the appropriate `.zip` file for your Mac - 2. Extract the zip file - 3. Move `RuView Desktop.app` to your Applications folder - 4. Right-click and select "Open" (first time only, to bypass Gatekeeper) - - **Windows:** + | Platform | Architecture | Format | Download | + |----------|--------------|--------|----------| + | macOS | Apple Silicon (M1/M2/M3/M4) | DMG | `RuView-Desktop-*-macos-arm64.dmg` | + | macOS | Intel | DMG | `RuView-Desktop-*-macos-x64.dmg` | + | macOS | Universal | DMG | `RuView-Desktop-*-macos-universal.dmg` | + | macOS | Apple Silicon | ZIP | `RuView-Desktop-*-macos-arm64.zip` | + | macOS | Intel | ZIP | `RuView-Desktop-*-macos-x64.zip` | + | Windows | x64 | MSI | `RuView-Desktop-*.msi` | + | Windows | x64 | EXE | `RuView-Desktop-*.exe` | + + ### macOS Installation + + **DMG (recommended):** + 1. Download the `.dmg` for your Mac (Apple Silicon = M1+, Intel = older Macs, Universal = both) + 2. Open the DMG and drag `RuView Desktop.app` to Applications + 3. First launch: right-click > Open (bypasses Gatekeeper) + + **Homebrew:** + ```bash + brew install --cask ruview-desktop + ``` + + **ZIP:** + 1. Download and extract the `.zip` + 2. Move `RuView Desktop.app` to Applications + + ### macOS Features (v0.4.5) + - Native WiFi diagnostics via CoreWLAN (SSID, RSSI, channel, noise) + - WiFi site survey for ESP32 node placement + - USB serial driver detection (CP210x, CH340, FTDI) + - macOS permissions checker + - Overlay titlebar with native traffic lights + - DMG installer with Applications shortcut + - Code signed and notarized (when secrets configured) + - Universal binary (runs native on both Intel and Apple Silicon) + + ### Windows Installation 1. Download the `.msi` installer 2. Run the installer 3. Launch RuView Desktop from the Start menu diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/entitlements.plist b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/entitlements.plist new file mode 100644 index 000000000..37581af5e --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/entitlements.plist @@ -0,0 +1,24 @@ + + + + + + com.apple.security.network.client + + + com.apple.security.network.server + + + com.apple.security.device.usb + + + com.apple.security.device.serial + + + com.apple.security.files.user-selected.read-write + + + com.apple.security.network.bonjour + + + diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/src/commands/macos.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/src/commands/macos.rs new file mode 100644 index 000000000..054e4a7f8 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/src/commands/macos.rs @@ -0,0 +1,364 @@ +use serde::Serialize; +use std::process::Command; + +/// macOS WiFi network info from CoreWLAN via system_profiler. +#[derive(Debug, Clone, Serialize)] +pub struct MacWifiInfo { + pub ssid: Option, + pub bssid: Option, + pub channel: Option, + pub rssi: Option, + pub noise: Option, + pub tx_rate: Option, + pub security: Option, + pub phy_mode: Option, +} + +/// macOS system info relevant for RuView diagnostics. +#[derive(Debug, Clone, Serialize)] +pub struct MacSystemInfo { + pub os_version: String, + pub arch: String, + pub model: Option, + pub wifi_interface: Option, + pub wifi_power: bool, + pub serial_drivers: Vec, +} + +/// Permission check result for macOS-specific capabilities. +#[derive(Debug, Clone, Serialize)] +pub struct MacPermissions { + pub network_access: bool, + pub usb_access: bool, + pub wifi_scan: bool, + pub location_services: bool, +} + +/// Get current WiFi connection info using CoreWLAN via airport CLI. +/// macOS-only: uses `/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport -I` +#[tauri::command] +pub async fn macos_wifi_info() -> Result { + #[cfg(not(target_os = "macos"))] + { + return Err("macOS-only command".into()); + } + + #[cfg(target_os = "macos")] + { + let output = Command::new("/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport") + .arg("-I") + .output() + .map_err(|e| format!("Failed to query WiFi: {}", e))?; + + if !output.status.success() { + return Err("airport command failed".into()); + } + + let text = String::from_utf8_lossy(&output.stdout); + let mut info = MacWifiInfo { + ssid: None, + bssid: None, + channel: None, + rssi: None, + noise: None, + tx_rate: None, + security: None, + phy_mode: None, + }; + + for line in text.lines() { + let parts: Vec<&str> = line.splitn(2, ':').collect(); + if parts.len() != 2 { + continue; + } + let key = parts[0].trim(); + let val = parts[1].trim(); + + match key { + "SSID" => info.ssid = Some(val.to_string()), + "BSSID" => info.bssid = Some(val.to_string()), + "channel" => info.channel = val.split(',').next().and_then(|v| v.trim().parse().ok()), + "agrCtlRSSI" => info.rssi = val.parse().ok(), + "agrCtlNoise" => info.noise = val.parse().ok(), + "lastTxRate" => info.tx_rate = val.parse().ok(), + "link auth" => info.security = Some(val.to_string()), + "PHY Mode" | "phyMode" => info.phy_mode = Some(val.to_string()), + _ => {} + } + } + + Ok(info) + } +} + +/// Scan nearby WiFi networks (macOS only). +/// Returns list of visible networks with RSSI for site survey. +#[tauri::command] +pub async fn macos_wifi_scan() -> Result, String> { + #[cfg(not(target_os = "macos"))] + { + return Err("macOS-only command".into()); + } + + #[cfg(target_os = "macos")] + { + let output = Command::new("/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport") + .arg("-s") + .output() + .map_err(|e| format!("WiFi scan failed: {}", e))?; + + if !output.status.success() { + return Err("WiFi scan command failed. Location Services may need to be enabled.".into()); + } + + let text = String::from_utf8_lossy(&output.stdout); + let mut results = Vec::new(); + + for line in text.lines().skip(1) { + // airport -s format: SSID BSSID RSSI CHANNEL HT CC SECURITY + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + // Parse fixed-width columns (BSSID is at a fixed position) + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() < 7 { + continue; + } + + // Find BSSID (xx:xx:xx:xx:xx:xx pattern) to anchor parsing + let bssid_idx = parts.iter().position(|p| p.matches(':').count() == 5); + if let Some(idx) = bssid_idx { + let ssid = if idx > 0 { + parts[..idx].join(" ") + } else { + String::new() + }; + + results.push(MacWifiScanResult { + ssid, + bssid: parts[idx].to_string(), + rssi: parts.get(idx + 1).and_then(|v| v.parse().ok()).unwrap_or(0), + channel: parts.get(idx + 2).and_then(|v| v.split(',').next().and_then(|c| c.parse().ok())).unwrap_or(0), + security: parts.get(idx + 5..).map(|s| s.join(" ")).unwrap_or_default(), + }); + } + } + + results.sort_by(|a, b| b.rssi.cmp(&a.rssi)); + Ok(results) + } +} + +/// WiFi scan result entry. +#[derive(Debug, Clone, Serialize)] +pub struct MacWifiScanResult { + pub ssid: String, + pub bssid: String, + pub rssi: i32, + pub channel: u32, + pub security: String, +} + +/// Get macOS system info relevant to RuView operation. +#[tauri::command] +pub async fn macos_system_info() -> Result { + #[cfg(not(target_os = "macos"))] + { + return Err("macOS-only command".into()); + } + + #[cfg(target_os = "macos")] + { + // OS version + let os_version = Command::new("sw_vers") + .arg("-productVersion") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|_| "unknown".into()); + + // Architecture + let arch = Command::new("uname") + .arg("-m") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|_| "unknown".into()); + + // Model identifier + let model = Command::new("sysctl") + .args(["-n", "hw.model"]) + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()); + + // WiFi interface + let wifi_interface = Command::new("networksetup") + .args(["-listallhardwareports"]) + .output() + .ok() + .and_then(|o| { + let text = String::from_utf8_lossy(&o.stdout).to_string(); + let mut found_wifi = false; + for line in text.lines() { + if line.contains("Wi-Fi") { + found_wifi = true; + continue; + } + if found_wifi && line.starts_with("Device:") { + return Some(line.replace("Device:", "").trim().to_string()); + } + } + None + }); + + // WiFi power status + let wifi_power = wifi_interface.as_ref() + .and_then(|iface| { + Command::new("networksetup") + .args(["-getairportpower", iface]) + .output() + .ok() + }) + .map(|o| String::from_utf8_lossy(&o.stdout).contains("On")) + .unwrap_or(false); + + // Check for USB serial drivers (kext) + let serial_drivers = detect_serial_drivers(); + + Ok(MacSystemInfo { + os_version, + arch, + model, + wifi_interface, + wifi_power, + serial_drivers, + }) + } +} + +/// Detect installed USB serial drivers on macOS. +#[cfg(target_os = "macos")] +fn detect_serial_drivers() -> Vec { + let drivers_to_check = [ + ("CH34x", "com.wch.usbserial.CH34x"), + ("CP210x", "com.silabs.driver.CP210xVCPDriver"), + ("FTDI", "com.FTDI.driver.FTDIUSBSerialDriver"), + ("CH9102", "com.wch.usbserial.CH9102"), + ]; + + let mut found = Vec::new(); + + for (name, bundle_id) in &drivers_to_check { + if let Ok(output) = Command::new("kextstat").output() { + let text = String::from_utf8_lossy(&output.stdout); + if text.contains(bundle_id) { + found.push(name.to_string()); + } + } + } + + // Also check /dev for any connected USB serial devices + if let Ok(entries) = std::fs::read_dir("/dev") { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("cu.usb") || name.starts_with("cu.wch") { + found.push(format!("/dev/{}", name)); + } + } + } + + found +} + +/// Check macOS permissions relevant to RuView. +#[tauri::command] +pub async fn macos_check_permissions() -> Result { + #[cfg(not(target_os = "macos"))] + { + return Err("macOS-only command".into()); + } + + #[cfg(target_os = "macos")] + { + // Network access: try binding a socket + let network_access = std::net::UdpSocket::bind("0.0.0.0:0").is_ok(); + + // USB access: check /dev/cu.usb* exists + let usb_access = std::fs::read_dir("/dev") + .map(|entries| { + entries.flatten().any(|e| { + e.file_name().to_string_lossy().starts_with("cu.usb") + }) + }) + .unwrap_or(false); + + // WiFi scan: try running airport + let wifi_scan = Command::new("/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport") + .arg("-I") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + // Location services (needed for WiFi scanning) + let location_services = Command::new("defaults") + .args(["read", "/var/db/locationd/clients.plist"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(true); // Assume enabled if can't check + + Ok(MacPermissions { + network_access, + usb_access, + wifi_scan, + location_services, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_wifi_info_struct() { + let info = MacWifiInfo { + ssid: Some("TestNet".into()), + bssid: Some("AA:BB:CC:DD:EE:FF".into()), + channel: Some(6), + rssi: Some(-45), + noise: Some(-90), + tx_rate: Some(866.0), + security: Some("wpa2-psk".into()), + phy_mode: Some("802.11ac".into()), + }; + assert_eq!(info.ssid, Some("TestNet".into())); + assert_eq!(info.channel, Some(6)); + } + + #[test] + fn test_system_info_struct() { + let info = MacSystemInfo { + os_version: "14.0".into(), + arch: "arm64".into(), + model: Some("Mac14,2".into()), + wifi_interface: Some("en0".into()), + wifi_power: true, + serial_drivers: vec!["CH34x".into()], + }; + assert_eq!(info.arch, "arm64"); + assert!(info.wifi_power); + } + + #[test] + fn test_scan_result_struct() { + let result = MacWifiScanResult { + ssid: "MyNetwork".into(), + bssid: "00:11:22:33:44:55".into(), + rssi: -52, + channel: 36, + security: "WPA2".into(), + }; + assert_eq!(result.rssi, -52); + } +} diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/src/commands/mod.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/src/commands/mod.rs index 0b67c5301..2cf6f2d39 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/src/commands/mod.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/src/commands/mod.rs @@ -1,5 +1,7 @@ pub mod discovery; pub mod flash; +#[cfg(target_os = "macos")] +pub mod macos; pub mod ota; pub mod provision; pub mod server; diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/src/lib.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/src/lib.rs index 166855fdd..186899dc7 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/src/lib.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/src/lib.rs @@ -3,50 +3,102 @@ pub mod domain; pub mod state; use commands::{discovery, flash, ota, provision, server, settings, wasm}; +#[cfg(target_os = "macos")] +use commands::macos; pub fn run() { - tauri::Builder::default() + let builder = tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) - .manage(state::AppState::default()) - .invoke_handler(tauri::generate_handler![ - // Discovery - discovery::discover_nodes, - discovery::list_serial_ports, - discovery::configure_esp32_wifi, - // Flash - flash::flash_firmware, - flash::flash_progress, - flash::verify_firmware, - flash::check_espflash, - flash::supported_chips, - // OTA - ota::ota_update, - ota::batch_ota_update, - ota::check_ota_endpoint, - // WASM - wasm::wasm_list, - wasm::wasm_upload, - wasm::wasm_control, - wasm::wasm_info, - wasm::wasm_stats, - wasm::check_wasm_support, - // Server - server::start_server, - server::stop_server, - server::server_status, - server::restart_server, - server::server_logs, - // Provision - provision::provision_node, - provision::read_nvs, - provision::erase_nvs, - provision::validate_config, - provision::generate_mesh_configs, - // Settings - settings::get_settings, - settings::save_settings, - ]) + .manage(state::AppState::default()); + + // Register all commands including macOS-specific ones + #[cfg(target_os = "macos")] + let builder = builder.invoke_handler(tauri::generate_handler![ + // Discovery + discovery::discover_nodes, + discovery::list_serial_ports, + discovery::configure_esp32_wifi, + // Flash + flash::flash_firmware, + flash::flash_progress, + flash::verify_firmware, + flash::check_espflash, + flash::supported_chips, + // OTA + ota::ota_update, + ota::batch_ota_update, + ota::check_ota_endpoint, + // WASM + wasm::wasm_list, + wasm::wasm_upload, + wasm::wasm_control, + wasm::wasm_info, + wasm::wasm_stats, + wasm::check_wasm_support, + // Server + server::start_server, + server::stop_server, + server::server_status, + server::restart_server, + server::server_logs, + // Provision + provision::provision_node, + provision::read_nvs, + provision::erase_nvs, + provision::validate_config, + provision::generate_mesh_configs, + // Settings + settings::get_settings, + settings::save_settings, + // macOS + macos::macos_wifi_info, + macos::macos_wifi_scan, + macos::macos_system_info, + macos::macos_check_permissions, + ]); + + #[cfg(not(target_os = "macos"))] + let builder = builder.invoke_handler(tauri::generate_handler![ + // Discovery + discovery::discover_nodes, + discovery::list_serial_ports, + discovery::configure_esp32_wifi, + // Flash + flash::flash_firmware, + flash::flash_progress, + flash::verify_firmware, + flash::check_espflash, + flash::supported_chips, + // OTA + ota::ota_update, + ota::batch_ota_update, + ota::check_ota_endpoint, + // WASM + wasm::wasm_list, + wasm::wasm_upload, + wasm::wasm_control, + wasm::wasm_info, + wasm::wasm_stats, + wasm::check_wasm_support, + // Server + server::start_server, + server::stop_server, + server::server_status, + server::restart_server, + server::server_logs, + // Provision + provision::provision_node, + provision::read_nvs, + provision::erase_nvs, + provision::validate_config, + provision::generate_mesh_configs, + // Settings + settings::get_settings, + settings::save_settings, + ]); + + builder .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/src/main.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/src/main.rs index 4f3faf402..1550a32ca 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/src/main.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/src/main.rs @@ -2,6 +2,10 @@ all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows" )] +#![cfg_attr( + all(not(debug_assertions), target_os = "macos"), + allow(unused_imports) +)] fn main() { wifi_densepose_desktop::run(); diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/tauri.conf.json b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/tauri.conf.json index e214bd138..b41c6d7b2 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/tauri.conf.json +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", "productName": "RuView Desktop", - "version": "0.4.4", + "version": "0.4.5", "identifier": "net.ruv.ruview", "build": { "frontendDist": "ui/dist", @@ -17,19 +17,45 @@ "height": 800, "minWidth": 900, "minHeight": 600, - "resizable": true + "resizable": true, + "titleBarStyle": "Overlay", + "hiddenTitle": true, + "decorations": true, + "transparent": false } - ] + ], + "security": { + "csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'" + } }, "bundle": { "active": true, - "targets": "all", + "targets": ["dmg", "app", "updater"], "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "category": "DeveloperTool", + "shortDescription": "WiFi-based human pose estimation and vital sign monitoring", + "longDescription": "RuView turns commodity WiFi signals into real-time human pose estimation, vital sign monitoring, and presence detection without cameras or wearables.", + "copyright": "Copyright (c) 2024-2026 rUv", + "macOS": { + "entitlements": "entitlements.plist", + "minimumSystemVersion": "11.0", + "frameworks": [], + "dmg": { + "appPosition": { "x": 180, "y": 170 }, + "applicationFolderPosition": { "x": 480, "y": 170 }, + "windowSize": { "width": 660, "height": 400 } + } + }, + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "http://timestamp.digicert.com" + } } } diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/App.tsx b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/App.tsx index 51c5fe934..fb5ebba84 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/App.tsx +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/App.tsx @@ -9,6 +9,9 @@ import { EdgeModules } from "./pages/EdgeModules"; import { Sensing } from "./pages/Sensing"; import { MeshView } from "./pages/MeshView"; import { Settings } from "./pages/Settings"; +import { MacOSDiagnostics } from "./pages/MacOSDiagnostics"; + +const isMacOS = navigator.userAgent.includes("Mac"); type Page = | "dashboard" @@ -19,7 +22,8 @@ type Page = | "wasm" | "sensing" | "mesh" - | "settings"; + | "settings" + | "macos"; interface NavItem { id: Page; @@ -37,6 +41,7 @@ const NAV_ITEMS: NavItem[] = [ { id: "sensing", label: "Sensing", icon: "\u2248" }, { id: "mesh", label: "Mesh View", icon: "\u2B2F" }, { id: "settings", label: "Settings", icon: "\u2699" }, + ...(isMacOS ? [{ id: "macos" as Page, label: "macOS", icon: "\uF8FF" }] : []), ]; interface LiveStatus { @@ -100,6 +105,7 @@ const App: React.FC = () => { case "sensing": return ; case "mesh": return ; case "settings": return ; + case "macos": return ; } }; diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/design-system.css b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/design-system.css index 43f8ec907..3e8434f4a 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/design-system.css +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/design-system.css @@ -91,6 +91,31 @@ body { -moz-osx-font-smoothing: grayscale; } +/* macOS titlebar overlay: drag region + traffic light padding */ +@supports (-webkit-app-region: drag) { + .macos-titlebar { + -webkit-app-region: drag; + height: 28px; + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 9999; + } + .macos-titlebar button, + .macos-titlebar a, + .macos-titlebar input { + -webkit-app-region: no-drag; + } +} + +/* Add top padding on macOS for overlay titlebar */ +@media screen and (-webkit-min-device-pixel-ratio: 0) { + html.macos-app #root > div { + padding-top: 28px; + } +} + /* ===== Typography Scale ===== */ .heading-xl { font: 600 28px/1.2 var(--font-sans); color: var(--text-primary); letter-spacing: -0.02em; } .heading-lg { font: 600 20px/1.3 var(--font-sans); color: var(--text-primary); letter-spacing: -0.01em; } diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/main.tsx b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/main.tsx index 5e06d711d..fa1564ccb 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/main.tsx +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/main.tsx @@ -3,6 +3,11 @@ import ReactDOM from "react-dom/client"; import "./design-system.css"; import App from "./App"; +// Detect macOS for native titlebar integration +if (navigator.userAgent.includes("Mac")) { + document.documentElement.classList.add("macos-app"); +} + ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/MacOSDiagnostics.tsx b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/MacOSDiagnostics.tsx new file mode 100644 index 000000000..308e21299 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/MacOSDiagnostics.tsx @@ -0,0 +1,253 @@ +import { useState, useEffect } from "react"; + +interface WifiInfo { + ssid: string | null; + bssid: string | null; + channel: number | null; + rssi: number | null; + noise: number | null; + tx_rate: number | null; + security: string | null; + phy_mode: string | null; +} + +interface WifiScanResult { + ssid: string; + bssid: string; + rssi: number; + channel: number; + security: string; +} + +interface SystemInfo { + os_version: string; + arch: string; + model: string | null; + wifi_interface: string | null; + wifi_power: boolean; + serial_drivers: string[]; +} + +interface Permissions { + network_access: boolean; + usb_access: boolean; + wifi_scan: boolean; + location_services: boolean; +} + +const isMacOS = navigator.userAgent.includes("Mac"); + +export const MacOSDiagnostics: React.FC = () => { + const [wifiInfo, setWifiInfo] = useState(null); + const [scanResults, setScanResults] = useState([]); + const [systemInfo, setSystemInfo] = useState(null); + const [permissions, setPermissions] = useState(null); + const [scanning, setScanning] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isMacOS) return; + loadSystemInfo(); + loadWifiInfo(); + loadPermissions(); + }, []); + + const loadSystemInfo = async () => { + try { + const { invoke } = await import("@tauri-apps/api/core"); + const info = await invoke("macos_system_info"); + setSystemInfo(info); + } catch (e) { + console.warn("Failed to get system info:", e); + } + }; + + const loadWifiInfo = async () => { + try { + const { invoke } = await import("@tauri-apps/api/core"); + const info = await invoke("macos_wifi_info"); + setWifiInfo(info); + } catch (e) { + console.warn("Failed to get WiFi info:", e); + } + }; + + const loadPermissions = async () => { + try { + const { invoke } = await import("@tauri-apps/api/core"); + const perms = await invoke("macos_check_permissions"); + setPermissions(perms); + } catch (e) { + console.warn("Failed to check permissions:", e); + } + }; + + const runWifiScan = async () => { + setScanning(true); + setError(null); + try { + const { invoke } = await import("@tauri-apps/api/core"); + const results = await invoke("macos_wifi_scan"); + setScanResults(results); + } catch (e: unknown) { + setError(String(e)); + } finally { + setScanning(false); + } + }; + + if (!isMacOS) { + return ( +
+

macOS Diagnostics

+

+ This page shows macOS-specific WiFi and system diagnostics. Only available on macOS. +

+
+ ); + } + + const rssiColor = (rssi: number) => { + if (rssi >= -50) return "var(--accent-green, #22c55e)"; + if (rssi >= -70) return "var(--accent, #7c3aed)"; + return "var(--accent-red, #ef4444)"; + }; + + return ( +
+

+ macOS Diagnostics +

+ + {/* System Info */} + {systemInfo && ( +
+

System

+
+
macOS {systemInfo.os_version}
+
Arch {systemInfo.arch}
+
Model {systemInfo.model || "N/A"}
+
WiFi {systemInfo.wifi_interface || "N/A"} ({systemInfo.wifi_power ? "On" : "Off"})
+ {systemInfo.serial_drivers.length > 0 && ( +
+ Serial Drivers{" "} + + {systemInfo.serial_drivers.join(", ")} + +
+ )} +
+
+ )} + + {/* Permissions */} + {permissions && ( +
+

Permissions

+
+ {[ + { label: "Network", ok: permissions.network_access }, + { label: "USB Serial", ok: permissions.usb_access }, + { label: "WiFi Scan", ok: permissions.wifi_scan }, + { label: "Location", ok: permissions.location_services }, + ].map((p) => ( + + {p.ok ? "\u2713" : "\u2717"} {p.label} + + ))} +
+
+ )} + + {/* Current WiFi */} + {wifiInfo && ( +
+

Current WiFi Connection

+
+
SSID {wifiInfo.ssid || "N/A"}
+
Channel {wifiInfo.channel || "N/A"}
+
RSSI {wifiInfo.rssi || "N/A"} dBm
+
BSSID {wifiInfo.bssid || "N/A"}
+
Noise {wifiInfo.noise || "N/A"} dBm
+
Tx Rate {wifiInfo.tx_rate || "N/A"} Mbps
+
Security {wifiInfo.security || "N/A"}
+
PHY {wifiInfo.phy_mode || "N/A"}
+
+
+ )} + + {/* WiFi Scan */} +
+
+

WiFi Site Survey

+ +
+ + {error && ( +
+ {error} +
+ )} + + {scanResults.length > 0 && ( + + + + {["SSID", "BSSID", "Ch", "RSSI", "Security"].map((h) => ( + + ))} + + + + {scanResults.map((r, i) => ( + + + + + + + + ))} + +
{h}
{r.ssid || "(hidden)"}{r.bssid}{r.channel}{r.rssi} dBm{r.security}
+ )} + + {scanResults.length === 0 && !scanning && ( +

+ Click "Scan Networks" to discover nearby WiFi access points. Useful for positioning ESP32 nodes. +

+ )} +
+
+ ); +}; diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/types.ts b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/types.ts index d9b2e2937..6340443d7 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/types.ts +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/types.ts @@ -229,3 +229,42 @@ export interface AppSettings { discover_interval_ms: number; theme: "dark" | "light"; } + +// --------------------------------------------------------------------------- +// macOS Diagnostics (only available on macOS) +// --------------------------------------------------------------------------- + +export interface MacWifiInfo { + ssid: string | null; + bssid: string | null; + channel: number | null; + rssi: number | null; + noise: number | null; + tx_rate: number | null; + security: string | null; + phy_mode: string | null; +} + +export interface MacWifiScanResult { + ssid: string; + bssid: string; + rssi: number; + channel: number; + security: string; +} + +export interface MacSystemInfo { + os_version: string; + arch: string; + model: string | null; + wifi_interface: string | null; + wifi_power: boolean; + serial_drivers: string[]; +} + +export interface MacPermissions { + network_access: boolean; + usb_access: boolean; + wifi_scan: boolean; + location_services: boolean; +} diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/version.ts b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/version.ts index c09cc912a..3b4a12241 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/version.ts +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/version.ts @@ -1,2 +1,2 @@ // Application version - single source of truth -export const APP_VERSION = "0.4.4"; +export const APP_VERSION = "0.4.5"; diff --git a/scripts/install-macos.sh b/scripts/install-macos.sh new file mode 100755 index 000000000..b89db4f55 --- /dev/null +++ b/scripts/install-macos.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# RuView Desktop macOS installer +# Usage: curl -fsSL https://raw.githubusercontent.com/ruvnet/RuView/main/scripts/install-macos.sh | bash + +set -euo pipefail + +VERSION="${RUVIEW_VERSION:-0.4.5}" +REPO="ruvnet/RuView" + +# Detect architecture +ARCH=$(uname -m) +case "$ARCH" in + arm64|aarch64) ARCH_LABEL="arm64" ;; + x86_64) ARCH_LABEL="x64" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +DMG_NAME="RuView-Desktop-${VERSION}-macos-${ARCH_LABEL}.dmg" +ZIP_NAME="RuView-Desktop-${VERSION}-macos-${ARCH_LABEL}.zip" +DOWNLOAD_URL="https://github.com/${REPO}/releases/download/desktop-v${VERSION}" + +echo "RuView Desktop Installer" +echo "========================" +echo "Version: ${VERSION}" +echo "Arch: ${ARCH_LABEL}" +echo "" + +# Try DMG first, fall back to ZIP +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +echo "Downloading..." +if curl -fsSL -o "${TMPDIR}/${DMG_NAME}" "${DOWNLOAD_URL}/${DMG_NAME}" 2>/dev/null; then + echo "Installing from DMG..." + MOUNT_POINT=$(hdiutil attach "${TMPDIR}/${DMG_NAME}" -nobrowse -noautoopen | tail -1 | awk '{print $NF}') + + # Remove old version + if [ -d "/Applications/RuView Desktop.app" ]; then + echo "Removing previous installation..." + rm -rf "/Applications/RuView Desktop.app" + fi + + cp -r "${MOUNT_POINT}/RuView Desktop.app" /Applications/ + hdiutil detach "$MOUNT_POINT" -quiet + +elif curl -fsSL -o "${TMPDIR}/${ZIP_NAME}" "${DOWNLOAD_URL}/${ZIP_NAME}" 2>/dev/null; then + echo "Installing from ZIP..." + cd "$TMPDIR" + unzip -q "$ZIP_NAME" + + if [ -d "/Applications/RuView Desktop.app" ]; then + echo "Removing previous installation..." + rm -rf "/Applications/RuView Desktop.app" + fi + + cp -r "RuView Desktop.app" /Applications/ + +else + echo "Failed to download RuView Desktop v${VERSION}" + echo "Check https://github.com/${REPO}/releases for available versions" + exit 1 +fi + +# Clear Gatekeeper quarantine attribute +xattr -cr "/Applications/RuView Desktop.app" 2>/dev/null || true + +echo "" +echo "RuView Desktop v${VERSION} installed to /Applications" +echo "" +echo "To launch: open '/Applications/RuView Desktop.app'" +echo "" +echo "ESP32 serial drivers:" +echo " CP210x: https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers" +echo " CH340: https://github.com/WCHSoftGroup/ch34xser_macos" +echo " FTDI: https://ftdichip.com/drivers/vcp-drivers/"