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) <noreply@anthropic.com>
This commit is contained in:
exHuman 2026-03-31 15:52:12 +02:00
parent 3733e54aef
commit dee2d53f32
14 changed files with 1126 additions and 84 deletions

View file

@ -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: Package macOS app
- name: Sign .app bundle
if: env.APPLE_CERTIFICATE != ''
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
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"
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: Upload macOS artifact
- 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: |
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: 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` |
| 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` |
### Installation
### macOS 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)
**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)
**Windows:**
**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

View file

@ -0,0 +1,24 @@
<?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>
<!-- Allow outgoing network connections (mDNS discovery, OTA, HTTP API) -->
<key>com.apple.security.network.client</key>
<true/>
<!-- Allow incoming network connections (UDP beacon listener) -->
<key>com.apple.security.network.server</key>
<true/>
<!-- USB device access for ESP32 serial flashing/provisioning -->
<key>com.apple.security.device.usb</key>
<true/>
<!-- Serial port access for ESP32 communication -->
<key>com.apple.security.device.serial</key>
<true/>
<!-- Read/write access to user-selected files (firmware binaries) -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<!-- Bonjour/mDNS service browsing -->
<key>com.apple.security.network.bonjour</key>
<true/>
</dict>
</plist>

View file

@ -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<String>,
pub bssid: Option<String>,
pub channel: Option<u32>,
pub rssi: Option<i32>,
pub noise: Option<i32>,
pub tx_rate: Option<f64>,
pub security: Option<String>,
pub phy_mode: Option<String>,
}
/// macOS system info relevant for RuView diagnostics.
#[derive(Debug, Clone, Serialize)]
pub struct MacSystemInfo {
pub os_version: String,
pub arch: String,
pub model: Option<String>,
pub wifi_interface: Option<String>,
pub wifi_power: bool,
pub serial_drivers: Vec<String>,
}
/// 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<MacWifiInfo, 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("-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<Vec<MacWifiScanResult>, 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<MacSystemInfo, String> {
#[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<String> {
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<MacPermissions, String> {
#[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);
}
}

View file

@ -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;

View file

@ -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");
}

View file

@ -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();

View file

@ -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"
}
}
}

View file

@ -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 <Sensing />;
case "mesh": return <MeshView />;
case "settings": return <Settings />;
case "macos": return <MacOSDiagnostics />;
}
};

View file

@ -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; }

View file

@ -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(
<React.StrictMode>
<App />

View file

@ -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<WifiInfo | null>(null);
const [scanResults, setScanResults] = useState<WifiScanResult[]>([]);
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
const [permissions, setPermissions] = useState<Permissions | null>(null);
const [scanning, setScanning] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isMacOS) return;
loadSystemInfo();
loadWifiInfo();
loadPermissions();
}, []);
const loadSystemInfo = async () => {
try {
const { invoke } = await import("@tauri-apps/api/core");
const info = await invoke<SystemInfo>("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<WifiInfo>("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<Permissions>("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<WifiScanResult[]>("macos_wifi_scan");
setScanResults(results);
} catch (e: unknown) {
setError(String(e));
} finally {
setScanning(false);
}
};
if (!isMacOS) {
return (
<div style={{ padding: 32 }}>
<h2 style={{ color: "var(--text-primary)", margin: 0 }}>macOS Diagnostics</h2>
<p style={{ color: "var(--text-muted)", marginTop: 12 }}>
This page shows macOS-specific WiFi and system diagnostics. Only available on macOS.
</p>
</div>
);
}
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 (
<div style={{ padding: 32, maxWidth: 960 }}>
<h2 style={{ color: "var(--text-primary)", margin: "0 0 24px 0", fontFamily: "var(--font-sans)" }}>
macOS Diagnostics
</h2>
{/* System Info */}
{systemInfo && (
<div className="card" style={{ marginBottom: 20, padding: 20 }}>
<h3 style={{ color: "var(--text-primary)", margin: "0 0 12px", fontSize: 14 }}>System</h3>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px 24px", fontSize: 13 }}>
<div><span style={{ color: "var(--text-muted)" }}>macOS</span> <span style={{ color: "var(--text-primary)" }}>{systemInfo.os_version}</span></div>
<div><span style={{ color: "var(--text-muted)" }}>Arch</span> <span style={{ color: "var(--text-primary)" }}>{systemInfo.arch}</span></div>
<div><span style={{ color: "var(--text-muted)" }}>Model</span> <span style={{ color: "var(--text-primary)" }}>{systemInfo.model || "N/A"}</span></div>
<div><span style={{ color: "var(--text-muted)" }}>WiFi</span> <span style={{ color: "var(--text-primary)" }}>{systemInfo.wifi_interface || "N/A"} ({systemInfo.wifi_power ? "On" : "Off"})</span></div>
{systemInfo.serial_drivers.length > 0 && (
<div style={{ gridColumn: "1 / -1" }}>
<span style={{ color: "var(--text-muted)" }}>Serial Drivers</span>{" "}
<span style={{ color: "var(--text-primary)", fontFamily: "var(--font-mono)" }}>
{systemInfo.serial_drivers.join(", ")}
</span>
</div>
)}
</div>
</div>
)}
{/* Permissions */}
{permissions && (
<div className="card" style={{ marginBottom: 20, padding: 20 }}>
<h3 style={{ color: "var(--text-primary)", margin: "0 0 12px", fontSize: 14 }}>Permissions</h3>
<div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
{[
{ 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) => (
<span
key={p.label}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "4px 10px",
borderRadius: 6,
fontSize: 12,
fontFamily: "var(--font-mono)",
background: p.ok ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)",
color: p.ok ? "#22c55e" : "#ef4444",
border: `1px solid ${p.ok ? "rgba(34,197,94,0.2)" : "rgba(239,68,68,0.2)"}`,
}}
>
{p.ok ? "\u2713" : "\u2717"} {p.label}
</span>
))}
</div>
</div>
)}
{/* Current WiFi */}
{wifiInfo && (
<div className="card" style={{ marginBottom: 20, padding: 20 }}>
<h3 style={{ color: "var(--text-primary)", margin: "0 0 12px", fontSize: 14 }}>Current WiFi Connection</h3>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "8px 24px", fontSize: 13 }}>
<div><span style={{ color: "var(--text-muted)" }}>SSID</span> <span style={{ color: "var(--text-primary)", fontWeight: 600 }}>{wifiInfo.ssid || "N/A"}</span></div>
<div><span style={{ color: "var(--text-muted)" }}>Channel</span> <span style={{ color: "var(--text-primary)" }}>{wifiInfo.channel || "N/A"}</span></div>
<div><span style={{ color: "var(--text-muted)" }}>RSSI</span> <span style={{ color: rssiColor(wifiInfo.rssi || -100), fontWeight: 600 }}>{wifiInfo.rssi || "N/A"} dBm</span></div>
<div><span style={{ color: "var(--text-muted)" }}>BSSID</span> <span style={{ color: "var(--text-primary)", fontFamily: "var(--font-mono)", fontSize: 11 }}>{wifiInfo.bssid || "N/A"}</span></div>
<div><span style={{ color: "var(--text-muted)" }}>Noise</span> <span style={{ color: "var(--text-primary)" }}>{wifiInfo.noise || "N/A"} dBm</span></div>
<div><span style={{ color: "var(--text-muted)" }}>Tx Rate</span> <span style={{ color: "var(--text-primary)" }}>{wifiInfo.tx_rate || "N/A"} Mbps</span></div>
<div><span style={{ color: "var(--text-muted)" }}>Security</span> <span style={{ color: "var(--text-primary)" }}>{wifiInfo.security || "N/A"}</span></div>
<div><span style={{ color: "var(--text-muted)" }}>PHY</span> <span style={{ color: "var(--text-primary)" }}>{wifiInfo.phy_mode || "N/A"}</span></div>
</div>
</div>
)}
{/* WiFi Scan */}
<div className="card" style={{ padding: 20 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
<h3 style={{ color: "var(--text-primary)", margin: 0, fontSize: 14 }}>WiFi Site Survey</h3>
<button
onClick={runWifiScan}
disabled={scanning}
style={{
padding: "6px 14px",
borderRadius: 6,
fontSize: 12,
fontWeight: 600,
background: "linear-gradient(135deg, var(--accent), #a855f7)",
color: "#fff",
border: "none",
cursor: scanning ? "wait" : "pointer",
opacity: scanning ? 0.7 : 1,
}}
>
{scanning ? "Scanning..." : "Scan Networks"}
</button>
</div>
{error && (
<div style={{ color: "#ef4444", fontSize: 12, marginBottom: 12, padding: "8px 12px", background: "rgba(239,68,68,0.1)", borderRadius: 6 }}>
{error}
</div>
)}
{scanResults.length > 0 && (
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>
<thead>
<tr style={{ borderBottom: "1px solid var(--border)" }}>
{["SSID", "BSSID", "Ch", "RSSI", "Security"].map((h) => (
<th key={h} style={{ padding: "6px 8px", textAlign: "left", color: "var(--text-muted)", fontWeight: 500 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{scanResults.map((r, i) => (
<tr key={i} style={{ borderBottom: "1px solid var(--border)" }}>
<td style={{ padding: "6px 8px", color: "var(--text-primary)", fontWeight: 500 }}>{r.ssid || "(hidden)"}</td>
<td style={{ padding: "6px 8px", color: "var(--text-muted)", fontFamily: "var(--font-mono)", fontSize: 10 }}>{r.bssid}</td>
<td style={{ padding: "6px 8px", color: "var(--text-primary)" }}>{r.channel}</td>
<td style={{ padding: "6px 8px", color: rssiColor(r.rssi), fontWeight: 600 }}>{r.rssi} dBm</td>
<td style={{ padding: "6px 8px", color: "var(--text-muted)", fontSize: 10 }}>{r.security}</td>
</tr>
))}
</tbody>
</table>
)}
{scanResults.length === 0 && !scanning && (
<p style={{ color: "var(--text-muted)", fontSize: 12, margin: 0 }}>
Click "Scan Networks" to discover nearby WiFi access points. Useful for positioning ESP32 nodes.
</p>
)}
</div>
</div>
);
};

View file

@ -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;
}

View file

@ -1,2 +1,2 @@
// Application version - single source of truth
export const APP_VERSION = "0.4.4";
export const APP_VERSION = "0.4.5";

75
scripts/install-macos.sh Executable file
View file

@ -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/"