Refactor WiFi types and styles to shared modules
This commit is contained in:
parent
ab3d379970
commit
3d40de5582
|
|
@ -7,7 +7,8 @@ Plug it in, turn it on, and forget about the cloud.
|
||||||
## quickstart
|
## quickstart
|
||||||
|
|
||||||
1. Plug in and turn on your Toes computer.
|
1. Plug in and turn on your Toes computer.
|
||||||
2. Tell Toes about your WiFi by <using dark @probablycorey magick>.
|
2. Connect to the **Toes Setup** WiFi network (password: **toessetup**).
|
||||||
|
A setup page will appear — choose your home WiFi and enter its password.
|
||||||
3. Visit https://toes.local to get started!
|
3. Visit https://toes.local to get started!
|
||||||
|
|
||||||
## features
|
## features
|
||||||
|
|
|
||||||
197
scripts/wifi.sh
197
scripts/wifi.sh
|
|
@ -1,197 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
##
|
|
||||||
# WiFi management for Toes appliance setup.
|
|
||||||
# Uses nmcli (NetworkManager) which is standard on Raspberry Pi OS Bookworm.
|
|
||||||
#
|
|
||||||
# Commands:
|
|
||||||
# status - Check WiFi connection state
|
|
||||||
# scan - List available WiFi networks
|
|
||||||
# connect - Connect to a network (SSID and password as args)
|
|
||||||
# hotspot-start - Start the setup hotspot + captive portal DNS
|
|
||||||
# hotspot-stop - Stop the hotspot + captive portal DNS
|
|
||||||
# has-wifi - Exit 0 if WiFi hardware exists, 1 if not
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
HOTSPOT_SSID="Toes Setup"
|
|
||||||
HOTSPOT_IFACE="wlan0"
|
|
||||||
HOTSPOT_CON="toes-hotspot"
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
CAPTIVE_CONF="$SCRIPT_DIR/wifi-captive.conf"
|
|
||||||
DNSMASQ_PID="/tmp/toes-dnsmasq.pid"
|
|
||||||
|
|
||||||
cmd="${1:-help}"
|
|
||||||
|
|
||||||
case "$cmd" in
|
|
||||||
|
|
||||||
status)
|
|
||||||
# Returns JSON: { connected, ssid, ip }
|
|
||||||
STATE=$(nmcli -t -f GENERAL.STATE device show "$HOTSPOT_IFACE" 2>/dev/null | cut -d: -f2 | xargs)
|
|
||||||
SSID=$(nmcli -t -f GENERAL.CONNECTION device show "$HOTSPOT_IFACE" 2>/dev/null | cut -d: -f2 | xargs)
|
|
||||||
IP=$(nmcli -t -f IP4.ADDRESS device show "$HOTSPOT_IFACE" 2>/dev/null | head -1 | cut -d: -f2 | cut -d/ -f1 | xargs)
|
|
||||||
|
|
||||||
CONNECTED="false"
|
|
||||||
if echo "$STATE" | grep -qi "connected" && [ "$SSID" != "$HOTSPOT_CON" ] && [ -n "$SSID" ] && [ "$SSID" != "--" ]; then
|
|
||||||
CONNECTED="true"
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '{"connected":%s,"ssid":"%s","ip":"%s"}\n' "$CONNECTED" "${SSID:-}" "${IP:-}"
|
|
||||||
;;
|
|
||||||
|
|
||||||
scan)
|
|
||||||
# Force a fresh scan then list networks as JSON array
|
|
||||||
nmcli device wifi rescan ifname "$HOTSPOT_IFACE" 2>/dev/null || true
|
|
||||||
sleep 1
|
|
||||||
nmcli -t -f SSID,SIGNAL,SECURITY device wifi list ifname "$HOTSPOT_IFACE" 2>/dev/null \
|
|
||||||
| awk -F: '
|
|
||||||
BEGIN { printf "[" }
|
|
||||||
NR > 1 { printf "," }
|
|
||||||
{
|
|
||||||
gsub(/"/, "\\\"", $1)
|
|
||||||
if ($1 != "" && $1 != "--") {
|
|
||||||
printf "{\"ssid\":\"%s\",\"signal\":%s,\"security\":\"%s\"}", $1, ($2 == "" ? "0" : $2), $3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
END { printf "]\n" }
|
|
||||||
' | python3 -c "
|
|
||||||
import sys, json
|
|
||||||
raw = json.load(sys.stdin)
|
|
||||||
# Deduplicate by SSID, keeping the strongest signal
|
|
||||||
seen = {}
|
|
||||||
for net in raw:
|
|
||||||
ssid = net.get('ssid', '')
|
|
||||||
if not ssid:
|
|
||||||
continue
|
|
||||||
if ssid not in seen or net.get('signal', 0) > seen[ssid].get('signal', 0):
|
|
||||||
seen[ssid] = net
|
|
||||||
result = sorted(seen.values(), key=lambda x: -x.get('signal', 0))
|
|
||||||
json.dump(result, sys.stdout)
|
|
||||||
print()
|
|
||||||
"
|
|
||||||
;;
|
|
||||||
|
|
||||||
connect)
|
|
||||||
SSID="${2:-}"
|
|
||||||
PASSWORD="${3:-}"
|
|
||||||
|
|
||||||
if [ -z "$SSID" ]; then
|
|
||||||
echo '{"ok":false,"error":"SSID is required"}' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Stop captive portal DNS
|
|
||||||
"$0" dns-stop 2>/dev/null || true
|
|
||||||
|
|
||||||
# Stop the hotspot first if it's running
|
|
||||||
nmcli connection down "$HOTSPOT_CON" 2>/dev/null || true
|
|
||||||
nmcli connection delete "$HOTSPOT_CON" 2>/dev/null || true
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# Connect to the network
|
|
||||||
if [ -n "$PASSWORD" ]; then
|
|
||||||
OUTPUT=$(nmcli device wifi connect "$SSID" password "$PASSWORD" ifname "$HOTSPOT_IFACE" 2>&1) || {
|
|
||||||
# Connection failed - restart hotspot so user can try again
|
|
||||||
"$0" hotspot-start 2>/dev/null || true
|
|
||||||
echo "{\"ok\":false,\"error\":\"$(echo "$OUTPUT" | tr '"' "'" | tr '\n' ' ')\"}"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
else
|
|
||||||
OUTPUT=$(nmcli device wifi connect "$SSID" ifname "$HOTSPOT_IFACE" 2>&1) || {
|
|
||||||
"$0" hotspot-start 2>/dev/null || true
|
|
||||||
echo "{\"ok\":false,\"error\":\"$(echo "$OUTPUT" | tr '"' "'" | tr '\n' ' ')\"}"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Wait for an IP
|
|
||||||
for i in $(seq 1 10); do
|
|
||||||
IP=$(nmcli -t -f IP4.ADDRESS device show "$HOTSPOT_IFACE" 2>/dev/null | head -1 | cut -d: -f2 | cut -d/ -f1 | xargs)
|
|
||||||
if [ -n "$IP" ] && [ "$IP" != "" ]; then
|
|
||||||
echo "{\"ok\":true,\"ip\":\"$IP\",\"ssid\":\"$SSID\"}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "{\"ok\":true,\"ip\":\"\",\"ssid\":\"$SSID\"}"
|
|
||||||
;;
|
|
||||||
|
|
||||||
hotspot-start)
|
|
||||||
# Delete any existing hotspot connection
|
|
||||||
nmcli connection delete "$HOTSPOT_CON" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Create the hotspot
|
|
||||||
nmcli connection add \
|
|
||||||
type wifi \
|
|
||||||
ifname "$HOTSPOT_IFACE" \
|
|
||||||
con-name "$HOTSPOT_CON" \
|
|
||||||
ssid "$HOTSPOT_SSID" \
|
|
||||||
autoconnect no \
|
|
||||||
wifi.mode ap \
|
|
||||||
wifi.band bg \
|
|
||||||
wifi-sec.key-mgmt wpa-psk \
|
|
||||||
wifi-sec.psk "toessetup" \
|
|
||||||
ipv4.method shared \
|
|
||||||
ipv4.addresses "10.42.0.1/24" \
|
|
||||||
2>/dev/null
|
|
||||||
|
|
||||||
nmcli connection up "$HOTSPOT_CON" 2>/dev/null
|
|
||||||
|
|
||||||
# Start captive portal DNS redirect
|
|
||||||
"$0" dns-start 2>/dev/null || true
|
|
||||||
|
|
||||||
echo '{"ok":true,"ssid":"'"$HOTSPOT_SSID"'","ip":"10.42.0.1"}'
|
|
||||||
;;
|
|
||||||
|
|
||||||
hotspot-stop)
|
|
||||||
"$0" dns-stop 2>/dev/null || true
|
|
||||||
nmcli connection down "$HOTSPOT_CON" 2>/dev/null || true
|
|
||||||
nmcli connection delete "$HOTSPOT_CON" 2>/dev/null || true
|
|
||||||
echo '{"ok":true}'
|
|
||||||
;;
|
|
||||||
|
|
||||||
dns-start)
|
|
||||||
# Start dnsmasq for captive portal DNS (resolves everything to 10.42.0.1)
|
|
||||||
# Kill any existing instance first
|
|
||||||
"$0" dns-stop 2>/dev/null || true
|
|
||||||
|
|
||||||
# NetworkManager runs its own dnsmasq on port 53, so we need to stop it
|
|
||||||
# for the hotspot interface and run our own
|
|
||||||
if [ -f "$CAPTIVE_CONF" ]; then
|
|
||||||
sudo dnsmasq --conf-file="$CAPTIVE_CONF" --pid-file="$DNSMASQ_PID" --port=53 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
|
|
||||||
dns-stop)
|
|
||||||
# Stop our captive portal dnsmasq
|
|
||||||
if [ -f "$DNSMASQ_PID" ]; then
|
|
||||||
sudo kill "$(cat "$DNSMASQ_PID")" 2>/dev/null || true
|
|
||||||
sudo rm -f "$DNSMASQ_PID"
|
|
||||||
fi
|
|
||||||
# Also kill by name in case PID file is stale
|
|
||||||
sudo pkill -f "dnsmasq.*wifi-captive.conf" 2>/dev/null || true
|
|
||||||
;;
|
|
||||||
|
|
||||||
has-wifi)
|
|
||||||
# Check if wlan0 exists
|
|
||||||
if nmcli device show "$HOTSPOT_IFACE" > /dev/null 2>&1; then
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
|
|
||||||
help|*)
|
|
||||||
echo "Usage: $0 {status|scan|connect|hotspot-start|hotspot-stop|has-wifi}"
|
|
||||||
echo ""
|
|
||||||
echo "Commands:"
|
|
||||||
echo " status Check WiFi connection state (JSON)"
|
|
||||||
echo " scan List available WiFi networks (JSON array)"
|
|
||||||
echo " connect SSID [PASSWORD] Connect to a WiFi network"
|
|
||||||
echo " hotspot-start Start the Toes Setup hotspot + captive portal"
|
|
||||||
echo " hotspot-stop Stop the hotspot + captive portal"
|
|
||||||
echo " has-wifi Exit 0 if WiFi hardware present"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
export const connectToWifi = (ssid: string, password?: string) =>
|
import type { ConnectResult, WifiNetwork, WifiStatus } from '../shared/types'
|
||||||
|
|
||||||
|
export const connectToWifi = (ssid: string, password?: string): Promise<ConnectResult> =>
|
||||||
fetch('/api/wifi/connect', {
|
fetch('/api/wifi/connect', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
@ -11,12 +13,12 @@ export const getLogDates = (name: string): Promise<string[]> =>
|
||||||
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
|
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
|
||||||
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
|
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
|
||||||
|
|
||||||
export const getWifiStatus = (): Promise<{ connected: boolean, ssid: string, ip: string, setupMode: boolean }> =>
|
export const getWifiStatus = (): Promise<WifiStatus & { setupMode: boolean }> =>
|
||||||
fetch('/api/wifi/status').then(r => r.json())
|
fetch('/api/wifi/status').then(r => r.json())
|
||||||
|
|
||||||
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
||||||
|
|
||||||
export const scanWifiNetworks = (): Promise<{ ssid: string, signal: number, security: string }[]> =>
|
export const scanWifiNetworks = (): Promise<WifiNetwork[]> =>
|
||||||
fetch('/api/wifi/scan').then(r => r.json())
|
fetch('/api/wifi/scan').then(r => r.json())
|
||||||
|
|
||||||
export const shareApp = (name: string) =>
|
export const shareApp = (name: string) =>
|
||||||
|
|
|
||||||
|
|
@ -4,69 +4,73 @@ import { setCurrentView, setupMode } from '../state'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DashboardInstallCmd,
|
DashboardInstallCmd,
|
||||||
|
ErrorBox,
|
||||||
FormActions,
|
FormActions,
|
||||||
FormField,
|
FormField,
|
||||||
FormInput,
|
FormInput,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
HeaderActions,
|
HeaderActions,
|
||||||
|
InfoLabel,
|
||||||
|
InfoRow,
|
||||||
|
InfoValue,
|
||||||
Main,
|
Main,
|
||||||
MainContent,
|
MainContent,
|
||||||
MainHeader,
|
MainHeader,
|
||||||
MainTitle,
|
MainTitle,
|
||||||
|
NetworkItem,
|
||||||
|
NetworkListWrap,
|
||||||
|
NetworkMeta,
|
||||||
|
NetworkName,
|
||||||
Section,
|
Section,
|
||||||
SectionTitle,
|
SectionTitle,
|
||||||
|
SignalBarSegment,
|
||||||
|
SignalBarsWrap,
|
||||||
|
Spinner,
|
||||||
|
SpinnerWrap,
|
||||||
|
SuccessCheck,
|
||||||
|
WifiColumn,
|
||||||
} from '../styles'
|
} from '../styles'
|
||||||
|
import { theme } from '../themes'
|
||||||
|
import type { WifiNetwork } from '../../shared/types'
|
||||||
|
|
||||||
type WifiNetwork = { ssid: string, signal: number, security: string }
|
|
||||||
type WifiStep = 'status' | 'scanning' | 'networks' | 'password' | 'connecting' | 'success' | 'error'
|
type WifiStep = 'status' | 'scanning' | 'networks' | 'password' | 'connecting' | 'success' | 'error'
|
||||||
|
|
||||||
function signalBars(signal: number) {
|
function signalBars(signal: number) {
|
||||||
const level = signal > 75 ? 4 : signal > 50 ? 3 : signal > 25 ? 2 : 1
|
const level = signal > 75 ? 4 : signal > 50 ? 3 : signal > 25 ? 2 : 1
|
||||||
return (
|
return (
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'flex-end', gap: 2, height: 14 }}>
|
<SignalBarsWrap>
|
||||||
{[1, 2, 3, 4].map(i => (
|
{[1, 2, 3, 4].map(i => (
|
||||||
<span
|
<SignalBarSegment
|
||||||
key={i}
|
key={i}
|
||||||
style={{
|
level={i <= level ? 'active' : 'inactive'}
|
||||||
width: 3,
|
style={{ height: 3 + i * 3 }}
|
||||||
height: 3 + i * 3,
|
|
||||||
borderRadius: 1,
|
|
||||||
background: i <= level ? '#22c55e' : '#333',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</span>
|
</SignalBarsWrap>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NetworkList({ networks, onSelect }: { networks: WifiNetwork[], onSelect: (net: WifiNetwork) => void }) {
|
function NetworkList({ networks, onSelect }: { networks: WifiNetwork[], onSelect: (net: WifiNetwork) => void }) {
|
||||||
if (networks.length === 0) {
|
if (networks.length === 0) {
|
||||||
return <div style={{ padding: '24px 16px', textAlign: 'center', color: '#888' }}>No networks found. Try scanning again.</div>
|
return (
|
||||||
|
<SpinnerWrap>
|
||||||
|
No networks found. Try scanning again.
|
||||||
|
</SpinnerWrap>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxHeight: 320, overflowY: 'auto', border: '1px solid var(--colors-border)', borderRadius: 'var(--radius-md)' }}>
|
<NetworkListWrap>
|
||||||
{networks.map(net => (
|
{networks.map(net => (
|
||||||
<div
|
<NetworkItem key={net.ssid} onClick={() => onSelect(net)}>
|
||||||
key={net.ssid}
|
<NetworkName>{net.ssid}</NetworkName>
|
||||||
onClick={() => onSelect(net)}
|
<NetworkMeta>
|
||||||
style={{
|
{net.security && net.security !== '' && net.security !== '--' && <span style={{ fontSize: 12 }}>🔒</span>}
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '12px 16px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderBottom: '1px solid var(--colors-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontWeight: 500 }}>{net.ssid}</span>
|
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#888', fontSize: 13 }}>
|
|
||||||
{net.security && net.security !== '' && net.security !== '--' && <span style={{ fontSize: 12 }}>🔒</span>}
|
|
||||||
{signalBars(net.signal)}
|
{signalBars(net.signal)}
|
||||||
</span>
|
</NetworkMeta>
|
||||||
</div>
|
</NetworkItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</NetworkListWrap>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,55 +175,57 @@ export function SettingsPage({ render }: { render: () => void }) {
|
||||||
|
|
||||||
{/* Status display */}
|
{/* Status display */}
|
||||||
{step === 'status' && (
|
{step === 'status' && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 400 }}>
|
<WifiColumn>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 14 }}>
|
<InfoRow>
|
||||||
<span style={{ color: '#888', width: 80 }}>Status</span>
|
<InfoLabel>Status</InfoLabel>
|
||||||
<span style={{ color: connected ? '#22c55e' : '#f87171', fontWeight: 500 }}>
|
<InfoValue style={{ color: connected ? theme('colors-statusRunning') : theme('colors-error'), fontWeight: 500 }}>
|
||||||
{connected ? 'Connected' : 'Disconnected'}
|
{connected ? 'Connected' : 'Disconnected'}
|
||||||
</span>
|
</InfoValue>
|
||||||
</div>
|
</InfoRow>
|
||||||
{connected && currentSsid && (
|
{connected && currentSsid && (
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 14 }}>
|
<InfoRow>
|
||||||
<span style={{ color: '#888', width: 80 }}>Network</span>
|
<InfoLabel>Network</InfoLabel>
|
||||||
<span>{currentSsid}</span>
|
<InfoValue>{currentSsid}</InfoValue>
|
||||||
</div>
|
</InfoRow>
|
||||||
)}
|
)}
|
||||||
{connected && currentIp && (
|
{connected && currentIp && (
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 14 }}>
|
<InfoRow>
|
||||||
<span style={{ color: '#888', width: 80 }}>IP</span>
|
<InfoLabel>IP</InfoLabel>
|
||||||
<span style={{ fontFamily: 'ui-monospace, monospace' }}>{currentIp}</span>
|
<InfoValue style={{ fontFamily: theme('fonts-mono') }}>{currentIp}</InfoValue>
|
||||||
</div>
|
</InfoRow>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<FormActions>
|
<FormActions>
|
||||||
<Button onClick={doScan}>Scan Networks</Button>
|
<Button onClick={doScan}>Scan Networks</Button>
|
||||||
</FormActions>
|
</FormActions>
|
||||||
</div>
|
</WifiColumn>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scanning spinner */}
|
{/* Scanning spinner */}
|
||||||
{step === 'scanning' && (
|
{step === 'scanning' && (
|
||||||
<div style={{ padding: '32px 0', textAlign: 'center', color: '#888' }}>
|
<SpinnerWrap>
|
||||||
Scanning for networks...
|
<Spinner />
|
||||||
</div>
|
<p style={{ color: theme('colors-textMuted') }}>Scanning for networks...</p>
|
||||||
|
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||||
|
</SpinnerWrap>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Network list */}
|
{/* Network list */}
|
||||||
{step === 'networks' && (
|
{step === 'networks' && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 400 }}>
|
<WifiColumn style={{ gap: 12 }}>
|
||||||
<NetworkList networks={networks} onSelect={handleSelectNetwork} />
|
<NetworkList networks={networks} onSelect={handleSelectNetwork} />
|
||||||
{error && <div style={{ padding: '10px 12px', background: '#2a1515', border: '1px solid #4a2020', borderRadius: 6, color: '#f87171', fontSize: 14 }}>{error}</div>}
|
{error && <ErrorBox>{error}</ErrorBox>}
|
||||||
<FormActions>
|
<FormActions>
|
||||||
{!setupMode && <Button onClick={() => setStep('status')}>Back</Button>}
|
{!setupMode && <Button onClick={() => setStep('status')}>Back</Button>}
|
||||||
<Button onClick={doScan}>Rescan</Button>
|
<Button onClick={doScan}>Rescan</Button>
|
||||||
</FormActions>
|
</FormActions>
|
||||||
</div>
|
</WifiColumn>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Password entry */}
|
{/* Password entry */}
|
||||||
{step === 'password' && (
|
{step === 'password' && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 400 }}>
|
<WifiColumn>
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel>Network</FormLabel>
|
<FormLabel>Network</FormLabel>
|
||||||
<div style={{ fontWeight: 500, fontSize: 16 }}>{selectedNetwork?.ssid}</div>
|
<div style={{ fontWeight: 500, fontSize: 16 }}>{selectedNetwork?.ssid}</div>
|
||||||
|
|
@ -235,50 +241,30 @@ export function SettingsPage({ render }: { render: () => void }) {
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
{error && <div style={{ padding: '10px 12px', background: '#2a1515', border: '1px solid #4a2020', borderRadius: 6, color: '#f87171', fontSize: 14 }}>{error}</div>}
|
{error && <ErrorBox>{error}</ErrorBox>}
|
||||||
<FormActions>
|
<FormActions>
|
||||||
<Button onClick={() => { setError(''); setStep('networks') }}>Back</Button>
|
<Button onClick={() => { setError(''); setStep('networks') }}>Back</Button>
|
||||||
<Button variant="primary" type="submit">Connect</Button>
|
<Button variant="primary" type="submit">Connect</Button>
|
||||||
</FormActions>
|
</FormActions>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</WifiColumn>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Connecting spinner */}
|
{/* Connecting spinner */}
|
||||||
{step === 'connecting' && (
|
{step === 'connecting' && (
|
||||||
<div style={{ padding: '32px 0', textAlign: 'center' }}>
|
<SpinnerWrap>
|
||||||
<div style={{
|
<Spinner />
|
||||||
width: 32,
|
<p style={{ color: theme('colors-textMuted') }}>Connecting to <strong>{selectedNetwork?.ssid}</strong>...</p>
|
||||||
height: 32,
|
|
||||||
border: '3px solid #333',
|
|
||||||
borderTopColor: '#22c55e',
|
|
||||||
borderRadius: '50%',
|
|
||||||
margin: '0 auto 16px',
|
|
||||||
animation: 'spin 0.8s linear infinite',
|
|
||||||
}} />
|
|
||||||
<p style={{ color: '#888' }}>Connecting to <strong>{selectedNetwork?.ssid}</strong>...</p>
|
|
||||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||||
</div>
|
</SpinnerWrap>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Success */}
|
{/* Success */}
|
||||||
{step === 'success' && (
|
{step === 'success' && (
|
||||||
<div style={{ textAlign: 'center', padding: '16px 0', maxWidth: 400 }}>
|
<WifiColumn style={{ textAlign: 'center' }}>
|
||||||
<div style={{
|
<SuccessCheck>✓</SuccessCheck>
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
background: '#22c55e',
|
|
||||||
color: '#0a0a0f',
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
borderRadius: '50%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
margin: '0 auto 12px',
|
|
||||||
}}>✓</div>
|
|
||||||
<h3 style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>Connected!</h3>
|
<h3 style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>Connected!</h3>
|
||||||
<p style={{ color: '#888', marginBottom: 16 }}>
|
<p style={{ color: theme('colors-textMuted'), marginBottom: 16 }}>
|
||||||
Connected to <strong>{successSsid}</strong>
|
Connected to <strong>{successSsid}</strong>
|
||||||
{successIp && <span> ({successIp})</span>}
|
{successIp && <span> ({successIp})</span>}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -293,7 +279,7 @@ export function SettingsPage({ render }: { render: () => void }) {
|
||||||
<p style={{ marginBottom: 8 }}>Reconnect your device to <strong>{successSsid}</strong> and visit:</p>
|
<p style={{ marginBottom: 8 }}>Reconnect your device to <strong>{successSsid}</strong> and visit:</p>
|
||||||
<a
|
<a
|
||||||
href="http://toes.local"
|
href="http://toes.local"
|
||||||
style={{ color: '#22c55e', fontSize: 18, fontWeight: 600, textDecoration: 'none' }}
|
style={{ color: theme('colors-statusRunning'), fontSize: 18, fontWeight: 600, textDecoration: 'none' }}
|
||||||
>
|
>
|
||||||
http://toes.local
|
http://toes.local
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -303,7 +289,7 @@ export function SettingsPage({ render }: { render: () => void }) {
|
||||||
<Button onClick={() => { fetchStatus(); setStep('status') }}>Done</Button>
|
<Button onClick={() => { fetchStatus(); setStep('status') }}>Done</Button>
|
||||||
</FormActions>
|
</FormActions>
|
||||||
)}
|
)}
|
||||||
</div>
|
</WifiColumn>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,3 +68,16 @@ export {
|
||||||
TabBar,
|
TabBar,
|
||||||
TabContent,
|
TabContent,
|
||||||
} from './misc'
|
} from './misc'
|
||||||
|
export {
|
||||||
|
ErrorBox,
|
||||||
|
NetworkItem,
|
||||||
|
NetworkListWrap,
|
||||||
|
NetworkMeta,
|
||||||
|
NetworkName,
|
||||||
|
SignalBarSegment,
|
||||||
|
SignalBarsWrap,
|
||||||
|
Spinner,
|
||||||
|
SpinnerWrap,
|
||||||
|
SuccessCheck,
|
||||||
|
WifiColumn,
|
||||||
|
} from './wifi'
|
||||||
|
|
|
||||||
97
src/client/styles/wifi.ts
Normal file
97
src/client/styles/wifi.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { define } from '@because/forge'
|
||||||
|
import { theme } from '../themes'
|
||||||
|
|
||||||
|
export const ErrorBox = define('ErrorBox', {
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: theme('colors-errorBg'),
|
||||||
|
border: `1px solid ${theme('colors-errorBorder')}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
color: theme('colors-error'),
|
||||||
|
fontSize: 14,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const NetworkItem = define('NetworkItem', {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '12px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
|
selectors: {
|
||||||
|
'&:hover': { background: theme('colors-bgHover') },
|
||||||
|
'&:last-child': { borderBottom: 'none' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const NetworkListWrap = define('NetworkListWrap', {
|
||||||
|
maxHeight: 320,
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const NetworkMeta = define('NetworkMeta', {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
fontSize: 13,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const NetworkName = define('NetworkName', {
|
||||||
|
fontWeight: 500,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SignalBarSegment = define('SignalBarSegment', {
|
||||||
|
width: 3,
|
||||||
|
borderRadius: 1,
|
||||||
|
variants: {
|
||||||
|
level: {
|
||||||
|
active: { background: theme('colors-statusRunning') },
|
||||||
|
inactive: { background: theme('colors-border') },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SignalBarsWrap = define('SignalBarsWrap', {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 2,
|
||||||
|
height: 14,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Spinner = define('Spinner', {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
border: `3px solid ${theme('colors-border')}`,
|
||||||
|
borderTopColor: theme('colors-statusRunning'),
|
||||||
|
borderRadius: '50%',
|
||||||
|
margin: '0 auto 16px',
|
||||||
|
animation: 'spin 0.8s linear infinite',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SpinnerWrap = define('SpinnerWrap', {
|
||||||
|
padding: '32px 0',
|
||||||
|
textAlign: 'center',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SuccessCheck = define('SuccessCheck', {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
background: theme('colors-statusRunning'),
|
||||||
|
color: theme('colors-bg'),
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
margin: '0 auto 12px',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const WifiColumn = define('WifiColumn', {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
maxWidth: 400,
|
||||||
|
})
|
||||||
|
|
@ -19,6 +19,8 @@ export default {
|
||||||
'colors-dangerBorder': '#7f1d1d',
|
'colors-dangerBorder': '#7f1d1d',
|
||||||
'colors-dangerText': '#fca5a5',
|
'colors-dangerText': '#fca5a5',
|
||||||
'colors-error': '#f87171',
|
'colors-error': '#f87171',
|
||||||
|
'colors-errorBg': '#2a1515',
|
||||||
|
'colors-errorBorder': '#4a2020',
|
||||||
|
|
||||||
'colors-statusRunning': '#22c55e',
|
'colors-statusRunning': '#22c55e',
|
||||||
'colors-statusStopped': '#666',
|
'colors-statusStopped': '#666',
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ export default {
|
||||||
'colors-dangerBorder': '#fecaca',
|
'colors-dangerBorder': '#fecaca',
|
||||||
'colors-dangerText': '#dc2626',
|
'colors-dangerText': '#dc2626',
|
||||||
'colors-error': '#dc2626',
|
'colors-error': '#dc2626',
|
||||||
|
'colors-errorBg': '#fef2f2',
|
||||||
|
'colors-errorBorder': '#fecaca',
|
||||||
|
|
||||||
'colors-statusRunning': '#16a34a',
|
'colors-statusRunning': '#16a34a',
|
||||||
'colors-statusStopped': '#9ca3af',
|
'colors-statusStopped': '#9ca3af',
|
||||||
|
|
|
||||||
171
src/server/wifi-nmcli.ts
Normal file
171
src/server/wifi-nmcli.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { resolve } from 'path'
|
||||||
|
import { hostLog } from './tui'
|
||||||
|
import type { ConnectResult, WifiNetwork, WifiStatus } from '@types'
|
||||||
|
|
||||||
|
const CAPTIVE_CONF = resolve(import.meta.dir, '../../scripts/wifi-captive.conf')
|
||||||
|
const DNSMASQ_PID = '/tmp/toes-dnsmasq.pid'
|
||||||
|
const HOTSPOT_CON = 'toes-hotspot'
|
||||||
|
const HOTSPOT_IFACE = 'wlan0'
|
||||||
|
const HOTSPOT_SSID = 'Toes Setup'
|
||||||
|
|
||||||
|
async function dnsStart(): Promise<void> {
|
||||||
|
await dnsStop()
|
||||||
|
await sudo(['dnsmasq', `--conf-file=${CAPTIVE_CONF}`, `--pid-file=${DNSMASQ_PID}`, '--port=53'])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dnsStop(): Promise<void> {
|
||||||
|
if (await Bun.file(DNSMASQ_PID).exists()) {
|
||||||
|
const pid = (await Bun.file(DNSMASQ_PID).text()).trim()
|
||||||
|
await sudo(['kill', pid]).catch(() => {})
|
||||||
|
await sudo(['rm', '-f', DNSMASQ_PID]).catch(() => {})
|
||||||
|
}
|
||||||
|
await sudo(['pkill', '-f', 'dnsmasq.*wifi-captive.conf']).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nmcli(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
|
const proc = Bun.spawn(['nmcli', ...args], { stdout: 'pipe', stderr: 'pipe' })
|
||||||
|
const [stdout, stderr] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
])
|
||||||
|
const exitCode = await proc.exited
|
||||||
|
if (exitCode !== 0 && stderr.trim()) {
|
||||||
|
hostLog(`nmcli error: ${stderr.trim()}`)
|
||||||
|
}
|
||||||
|
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
async function sudo(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
|
const proc = Bun.spawn(['sudo', ...args], { stdout: 'pipe', stderr: 'pipe' })
|
||||||
|
const [stdout, stderr] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
])
|
||||||
|
const exitCode = await proc.exited
|
||||||
|
if (exitCode !== 0 && stderr.trim()) {
|
||||||
|
hostLog(`sudo error: ${stderr.trim()}`)
|
||||||
|
}
|
||||||
|
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectToNetwork(ssid: string, password?: string): Promise<ConnectResult> {
|
||||||
|
// Stop hotspot and DNS first
|
||||||
|
await stopHotspot()
|
||||||
|
await sleep(1000)
|
||||||
|
|
||||||
|
// Connect
|
||||||
|
const args = ['device', 'wifi', 'connect', ssid]
|
||||||
|
if (password) args.push('password', password)
|
||||||
|
args.push('ifname', HOTSPOT_IFACE)
|
||||||
|
|
||||||
|
const result = await nmcli(args)
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
// Connection failed — restart hotspot so user can retry
|
||||||
|
await startHotspot()
|
||||||
|
const error = result.stderr || result.stdout || 'Connection failed'
|
||||||
|
return { ok: false, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for IP (up to 10 seconds)
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const ip = await getIp()
|
||||||
|
if (ip) return { ok: true, ip, ssid }
|
||||||
|
await sleep(1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, ip: '', ssid }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasHardware(): boolean {
|
||||||
|
const proc = Bun.spawnSync(['nmcli', 'device', 'show', HOTSPOT_IFACE])
|
||||||
|
return proc.exitCode === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanNetworks(): Promise<WifiNetwork[]> {
|
||||||
|
// Force rescan
|
||||||
|
await nmcli(['device', 'wifi', 'rescan', 'ifname', HOTSPOT_IFACE]).catch(() => {})
|
||||||
|
await sleep(1000)
|
||||||
|
|
||||||
|
const { stdout } = await nmcli(['-t', '-f', 'SSID,SIGNAL,SECURITY', 'device', 'wifi', 'list', 'ifname', HOTSPOT_IFACE])
|
||||||
|
if (!stdout) return []
|
||||||
|
|
||||||
|
// Parse colon-delimited output (handle escaped colons with \:)
|
||||||
|
const seen = new Map<string, WifiNetwork>()
|
||||||
|
|
||||||
|
for (const line of stdout.split('\n')) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
const parts = line.split(/(?<!\\):/)
|
||||||
|
if (parts.length < 3) continue
|
||||||
|
|
||||||
|
const ssid = (parts[0] ?? '').replace(/\\:/g, ':').trim()
|
||||||
|
const signal = parseInt(parts[1] ?? '0', 10) || 0
|
||||||
|
const security = (parts[2] ?? '').trim()
|
||||||
|
|
||||||
|
if (!ssid || ssid === '--') continue
|
||||||
|
|
||||||
|
const existing = seen.get(ssid)
|
||||||
|
if (!existing || signal > existing.signal) {
|
||||||
|
seen.set(ssid, { ssid, signal, security })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...seen.values()].sort((a, b) => b.signal - a.signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startHotspot(): Promise<void> {
|
||||||
|
// Delete any existing hotspot connection
|
||||||
|
await nmcli(['connection', 'delete', HOTSPOT_CON]).catch(() => {})
|
||||||
|
|
||||||
|
// Create the hotspot
|
||||||
|
await nmcli([
|
||||||
|
'connection', 'add',
|
||||||
|
'type', 'wifi',
|
||||||
|
'ifname', HOTSPOT_IFACE,
|
||||||
|
'con-name', HOTSPOT_CON,
|
||||||
|
'ssid', HOTSPOT_SSID,
|
||||||
|
'autoconnect', 'no',
|
||||||
|
'wifi.mode', 'ap',
|
||||||
|
'wifi.band', 'bg',
|
||||||
|
'wifi-sec.key-mgmt', 'wpa-psk',
|
||||||
|
'wifi-sec.psk', 'toessetup',
|
||||||
|
'ipv4.method', 'shared',
|
||||||
|
'ipv4.addresses', '10.42.0.1/24',
|
||||||
|
])
|
||||||
|
|
||||||
|
await nmcli(['connection', 'up', HOTSPOT_CON])
|
||||||
|
await dnsStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function status(): Promise<WifiStatus> {
|
||||||
|
const [stateResult, ssidResult, ipResult] = await Promise.all([
|
||||||
|
nmcli(['-t', '-f', 'GENERAL.STATE', 'device', 'show', HOTSPOT_IFACE]),
|
||||||
|
nmcli(['-t', '-f', 'GENERAL.CONNECTION', 'device', 'show', HOTSPOT_IFACE]),
|
||||||
|
nmcli(['-t', '-f', 'IP4.ADDRESS', 'device', 'show', HOTSPOT_IFACE]),
|
||||||
|
])
|
||||||
|
|
||||||
|
const state = stateResult.stdout.split(':')[1]?.trim() ?? ''
|
||||||
|
const ssid = ssidResult.stdout.split(':')[1]?.trim() ?? ''
|
||||||
|
const ip = (ipResult.stdout.split('\n')[0] ?? '').split(':')[1]?.split('/')[0]?.trim() ?? ''
|
||||||
|
|
||||||
|
const connected = state.toLowerCase().includes('connected')
|
||||||
|
&& ssid !== HOTSPOT_CON
|
||||||
|
&& ssid !== ''
|
||||||
|
&& ssid !== '--'
|
||||||
|
|
||||||
|
return { connected, ssid, ip }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopHotspot(): Promise<void> {
|
||||||
|
await dnsStop()
|
||||||
|
await nmcli(['connection', 'down', HOTSPOT_CON]).catch(() => {})
|
||||||
|
await nmcli(['connection', 'delete', HOTSPOT_CON]).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getIp(): Promise<string> {
|
||||||
|
const { stdout } = await nmcli(['-t', '-f', 'IP4.ADDRESS', 'device', 'show', HOTSPOT_IFACE])
|
||||||
|
const line = stdout.split('\n')[0] ?? ''
|
||||||
|
return line.split(':')[1]?.split('/')[0]?.trim() ?? ''
|
||||||
|
}
|
||||||
|
|
@ -1,29 +1,11 @@
|
||||||
import { resolve, join } from 'path'
|
import * as nmcli from './wifi-nmcli'
|
||||||
import { hostLog } from './tui'
|
import { hostLog } from './tui'
|
||||||
|
import type { ConnectResult, WifiNetwork, WifiStatus } from '@types'
|
||||||
|
|
||||||
export type WifiStatus = {
|
export type { ConnectResult, WifiNetwork, WifiStatus } from '@types'
|
||||||
connected: boolean
|
|
||||||
ssid: string
|
|
||||||
ip: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WifiNetwork = {
|
|
||||||
ssid: string
|
|
||||||
signal: number
|
|
||||||
security: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ConnectResult = {
|
|
||||||
ok: boolean
|
|
||||||
ip?: string
|
|
||||||
ssid?: string
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const WIFI_SCRIPT = resolve(join(import.meta.dir, '../../scripts/wifi.sh'))
|
|
||||||
|
|
||||||
let _setupMode = false
|
let _setupMode = false
|
||||||
let _listeners = new Set<(setupMode: boolean) => void>()
|
const _listeners = new Set<(setupMode: boolean) => void>()
|
||||||
|
|
||||||
export const isSetupMode = () => _setupMode
|
export const isSetupMode = () => _setupMode
|
||||||
|
|
||||||
|
|
@ -39,25 +21,9 @@ function setSetupMode(mode: boolean) {
|
||||||
for (const cb of _listeners) cb(mode)
|
for (const cb of _listeners) cb(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run(args: string[]): Promise<string> {
|
|
||||||
const proc = Bun.spawn(['bash', WIFI_SCRIPT, ...args], {
|
|
||||||
stdout: 'pipe',
|
|
||||||
stderr: 'pipe',
|
|
||||||
})
|
|
||||||
const stdout = await new Response(proc.stdout).text()
|
|
||||||
await proc.exited
|
|
||||||
return stdout.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasWifiHardware(): boolean {
|
|
||||||
const proc = Bun.spawnSync(['bash', WIFI_SCRIPT, 'has-wifi'])
|
|
||||||
return proc.exitCode === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWifiStatus(): Promise<WifiStatus> {
|
export async function getWifiStatus(): Promise<WifiStatus> {
|
||||||
try {
|
try {
|
||||||
const output = await run(['status'])
|
return await nmcli.status()
|
||||||
return JSON.parse(output)
|
|
||||||
} catch {
|
} catch {
|
||||||
return { connected: false, ssid: '', ip: '' }
|
return { connected: false, ssid: '', ip: '' }
|
||||||
}
|
}
|
||||||
|
|
@ -65,8 +31,7 @@ export async function getWifiStatus(): Promise<WifiStatus> {
|
||||||
|
|
||||||
export async function scanNetworks(): Promise<WifiNetwork[]> {
|
export async function scanNetworks(): Promise<WifiNetwork[]> {
|
||||||
try {
|
try {
|
||||||
const output = await run(['scan'])
|
return await nmcli.scanNetworks()
|
||||||
return JSON.parse(output)
|
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
@ -74,10 +39,7 @@ export async function scanNetworks(): Promise<WifiNetwork[]> {
|
||||||
|
|
||||||
export async function connectToWifi(ssid: string, password?: string): Promise<ConnectResult> {
|
export async function connectToWifi(ssid: string, password?: string): Promise<ConnectResult> {
|
||||||
try {
|
try {
|
||||||
const args = ['connect', ssid]
|
const result = await nmcli.connectToNetwork(ssid, password)
|
||||||
if (password) args.push(password)
|
|
||||||
const output = await run(args)
|
|
||||||
const result: ConnectResult = JSON.parse(output)
|
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
setSetupMode(false)
|
setSetupMode(false)
|
||||||
|
|
@ -90,15 +52,6 @@ export async function connectToWifi(ssid: string, password?: string): Promise<Co
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startHotspot(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await run(['hotspot-start'])
|
|
||||||
hostLog('WiFi hotspot started: "Toes Setup" (password: toessetup)')
|
|
||||||
} catch (e) {
|
|
||||||
hostLog(`Failed to start hotspot: ${e instanceof Error ? e.message : String(e)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initWifi(): Promise<void> {
|
export async function initWifi(): Promise<void> {
|
||||||
// Skip on non-Linux or when no WiFi hardware
|
// Skip on non-Linux or when no WiFi hardware
|
||||||
if (process.platform !== 'linux') {
|
if (process.platform !== 'linux') {
|
||||||
|
|
@ -106,7 +59,7 @@ export async function initWifi(): Promise<void> {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasWifiHardware()) {
|
if (!nmcli.hasHardware()) {
|
||||||
hostLog('WiFi setup: skipped (no WiFi hardware)')
|
hostLog('WiFi setup: skipped (no WiFi hardware)')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -121,5 +74,11 @@ export async function initWifi(): Promise<void> {
|
||||||
// No WiFi connection - enter setup mode
|
// No WiFi connection - enter setup mode
|
||||||
hostLog('WiFi: not connected, starting setup hotspot...')
|
hostLog('WiFi: not connected, starting setup hotspot...')
|
||||||
setSetupMode(true)
|
setSetupMode(true)
|
||||||
await startHotspot()
|
|
||||||
|
try {
|
||||||
|
await nmcli.startHotspot()
|
||||||
|
hostLog('WiFi hotspot started: "Toes Setup" (password: toessetup)')
|
||||||
|
} catch (e) {
|
||||||
|
hostLog(`Failed to start hotspot: ${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ export type LogLine = {
|
||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConnectResult = { ok: boolean; ip?: string; ssid?: string; error?: string }
|
||||||
|
export type WifiNetwork = { ssid: string; signal: number; security: string }
|
||||||
|
export type WifiStatus = { connected: boolean; ssid: string; ip: string }
|
||||||
|
|
||||||
export type App = {
|
export type App = {
|
||||||
name: string
|
name: string
|
||||||
state: AppState
|
state: AppState
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user