Add WiFi setup mode with captive portal and network configuration UI
This commit is contained in:
parent
51e42dc538
commit
ab3d379970
19
scripts/wifi-captive.conf
Normal file
19
scripts/wifi-captive.conf
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# dnsmasq config for Toes WiFi setup captive portal
|
||||
# Redirect ALL DNS queries to the hotspot gateway IP
|
||||
|
||||
# Only listen on the hotspot interface
|
||||
interface=wlan0
|
||||
bind-interfaces
|
||||
|
||||
# Resolve everything to our IP (captive portal)
|
||||
address=/#/10.42.0.1
|
||||
|
||||
# Don't use /etc/resolv.conf
|
||||
no-resolv
|
||||
|
||||
# Don't read /etc/hosts
|
||||
no-hosts
|
||||
|
||||
# Log queries for debugging
|
||||
log-queries
|
||||
log-facility=/tmp/toes-dnsmasq.log
|
||||
197
scripts/wifi.sh
Executable file
197
scripts/wifi.sh
Executable file
|
|
@ -0,0 +1,197 @@
|
|||
#!/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,27 +1,30 @@
|
|||
export const connectToWifi = (ssid: string, password?: string) =>
|
||||
fetch('/api/wifi/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ssid, password }),
|
||||
}).then(r => r.json())
|
||||
|
||||
export const getLogDates = (name: string): Promise<string[]> =>
|
||||
fetch(`/api/apps/${name}/logs/dates`).then(r => r.json())
|
||||
|
||||
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
|
||||
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
|
||||
|
||||
export const getWifiConfig = (): Promise<{ network: string, password: string }> =>
|
||||
fetch('/api/system/wifi').then(r => r.json())
|
||||
export const getWifiStatus = (): Promise<{ connected: boolean, ssid: string, ip: string, setupMode: boolean }> =>
|
||||
fetch('/api/wifi/status').then(r => r.json())
|
||||
|
||||
export const saveWifiConfig = (config: { network: string, password: string }) =>
|
||||
fetch('/api/system/wifi', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
}).then(r => r.json())
|
||||
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
||||
|
||||
export const scanWifiNetworks = (): Promise<{ ssid: string, signal: number, security: string }[]> =>
|
||||
fetch('/api/wifi/scan').then(r => r.json())
|
||||
|
||||
export const shareApp = (name: string) =>
|
||||
fetch(`/api/apps/${name}/tunnel`, { method: 'POST' })
|
||||
|
||||
export const unshareApp = (name: string) =>
|
||||
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })
|
||||
|
||||
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
||||
|
||||
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
|
||||
|
||||
export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })
|
||||
|
||||
export const unshareApp = (name: string) =>
|
||||
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Styles } from '@because/forge'
|
||||
import { apps, currentView, isNarrow, selectedApp } from '../state'
|
||||
import { apps, currentView, isNarrow, selectedApp, setupMode } from '../state'
|
||||
import { Layout } from '../styles'
|
||||
import { AppDetail } from './AppDetail'
|
||||
import { DashboardLanding } from './DashboardLanding'
|
||||
|
|
@ -18,7 +18,7 @@ export function Dashboard({ render }: { render: () => void }) {
|
|||
return (
|
||||
<Layout>
|
||||
<Styles />
|
||||
{!isNarrow && <Sidebar render={render} />}
|
||||
{!isNarrow && !setupMode && <Sidebar render={render} />}
|
||||
<MainContent render={render} />
|
||||
<Modal />
|
||||
</Layout>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from 'hono/jsx'
|
||||
import { getWifiConfig, saveWifiConfig } from '../api'
|
||||
import { setCurrentView } from '../state'
|
||||
import { connectToWifi, getWifiStatus, scanWifiNetworks } from '../api'
|
||||
import { setCurrentView, setupMode } from '../state'
|
||||
import {
|
||||
Button,
|
||||
DashboardInstallCmd,
|
||||
|
|
@ -17,17 +17,82 @@ import {
|
|||
SectionTitle,
|
||||
} from '../styles'
|
||||
|
||||
type WifiNetwork = { ssid: string, signal: number, security: string }
|
||||
type WifiStep = 'status' | 'scanning' | 'networks' | 'password' | 'connecting' | 'success' | 'error'
|
||||
|
||||
function signalBars(signal: number) {
|
||||
const level = signal > 75 ? 4 : signal > 50 ? 3 : signal > 25 ? 2 : 1
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'flex-end', gap: 2, height: 14 }}>
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
width: 3,
|
||||
height: 3 + i * 3,
|
||||
borderRadius: 1,
|
||||
background: i <= level ? '#22c55e' : '#333',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function NetworkList({ networks, onSelect }: { networks: WifiNetwork[], onSelect: (net: WifiNetwork) => void }) {
|
||||
if (networks.length === 0) {
|
||||
return <div style={{ padding: '24px 16px', textAlign: 'center', color: '#888' }}>No networks found. Try scanning again.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxHeight: 320, overflowY: 'auto', border: '1px solid var(--colors-border)', borderRadius: 'var(--radius-md)' }}>
|
||||
{networks.map(net => (
|
||||
<div
|
||||
key={net.ssid}
|
||||
onClick={() => onSelect(net)}
|
||||
style={{
|
||||
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)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsPage({ render }: { render: () => void }) {
|
||||
const [network, setNetwork] = useState('')
|
||||
const [step, setStep] = useState<WifiStep>('status')
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [currentSsid, setCurrentSsid] = useState('')
|
||||
const [currentIp, setCurrentIp] = useState('')
|
||||
const [networks, setNetworks] = useState<WifiNetwork[]>([])
|
||||
const [selectedNetwork, setSelectedNetwork] = useState<WifiNetwork | null>(null)
|
||||
const [password, setPassword] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [successSsid, setSuccessSsid] = useState('')
|
||||
const [successIp, setSuccessIp] = useState('')
|
||||
|
||||
const fetchStatus = () => {
|
||||
getWifiStatus().then(status => {
|
||||
setConnected(status.connected)
|
||||
setCurrentSsid(status.ssid)
|
||||
setCurrentIp(status.ip)
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getWifiConfig().then(config => {
|
||||
setNetwork(config.network)
|
||||
setPassword(config.password)
|
||||
})
|
||||
fetchStatus()
|
||||
if (setupMode) doScan()
|
||||
}, [])
|
||||
|
||||
const goBack = () => {
|
||||
|
|
@ -35,59 +100,221 @@ export function SettingsPage({ render }: { render: () => void }) {
|
|||
render()
|
||||
}
|
||||
|
||||
const handleSave = async (e: Event) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setSaved(false)
|
||||
await saveWifiConfig({ network, password })
|
||||
setSaving(false)
|
||||
setSaved(true)
|
||||
const doScan = () => {
|
||||
setStep('scanning')
|
||||
setError('')
|
||||
scanWifiNetworks()
|
||||
.then(nets => {
|
||||
setNetworks(nets)
|
||||
setStep('networks')
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Failed to scan networks')
|
||||
setStep('networks')
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectNetwork = (net: WifiNetwork) => {
|
||||
setSelectedNetwork(net)
|
||||
setPassword('')
|
||||
setError('')
|
||||
const needsPassword = net.security && net.security !== '' && net.security !== '--'
|
||||
if (needsPassword) {
|
||||
setStep('password')
|
||||
} else {
|
||||
doConnect(net.ssid)
|
||||
}
|
||||
}
|
||||
|
||||
const doConnect = (ssid: string, pw?: string) => {
|
||||
setStep('connecting')
|
||||
setError('')
|
||||
connectToWifi(ssid, pw)
|
||||
.then(result => {
|
||||
if (result.ok) {
|
||||
setSuccessSsid(result.ssid || ssid)
|
||||
setSuccessIp(result.ip || '')
|
||||
setStep('success')
|
||||
fetchStatus()
|
||||
} else {
|
||||
setError(result.error || 'Connection failed. Check your password and try again.')
|
||||
setStep('password')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Connection failed. Please try again.')
|
||||
setStep('password')
|
||||
})
|
||||
}
|
||||
|
||||
const handleConnect = (e: Event) => {
|
||||
e.preventDefault()
|
||||
if (!selectedNetwork) return
|
||||
doConnect(selectedNetwork.ssid, password || undefined)
|
||||
}
|
||||
|
||||
const title = setupMode ? 'WiFi Setup' : 'Settings'
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<MainHeader centered>
|
||||
<MainTitle>Settings</MainTitle>
|
||||
<MainTitle>{title}</MainTitle>
|
||||
{!setupMode && (
|
||||
<HeaderActions>
|
||||
<Button onClick={goBack}>Back</Button>
|
||||
</HeaderActions>
|
||||
)}
|
||||
</MainHeader>
|
||||
<MainContent centered>
|
||||
<Section>
|
||||
<SectionTitle>WiFi</SectionTitle>
|
||||
<form onSubmit={handleSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 400 }}>
|
||||
|
||||
{/* Status display */}
|
||||
{step === 'status' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 400 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 14 }}>
|
||||
<span style={{ color: '#888', width: 80 }}>Status</span>
|
||||
<span style={{ color: connected ? '#22c55e' : '#f87171', fontWeight: 500 }}>
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
{connected && currentSsid && (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 14 }}>
|
||||
<span style={{ color: '#888', width: 80 }}>Network</span>
|
||||
<span>{currentSsid}</span>
|
||||
</div>
|
||||
)}
|
||||
{connected && currentIp && (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 14 }}>
|
||||
<span style={{ color: '#888', width: 80 }}>IP</span>
|
||||
<span style={{ fontFamily: 'ui-monospace, monospace' }}>{currentIp}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FormActions>
|
||||
<Button onClick={doScan}>Scan Networks</Button>
|
||||
</FormActions>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scanning spinner */}
|
||||
{step === 'scanning' && (
|
||||
<div style={{ padding: '32px 0', textAlign: 'center', color: '#888' }}>
|
||||
Scanning for networks...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Network list */}
|
||||
{step === 'networks' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 400 }}>
|
||||
<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>}
|
||||
<FormActions>
|
||||
{!setupMode && <Button onClick={() => setStep('status')}>Back</Button>}
|
||||
<Button onClick={doScan}>Rescan</Button>
|
||||
</FormActions>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password entry */}
|
||||
{step === 'password' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 400 }}>
|
||||
<FormField>
|
||||
<FormLabel>Network</FormLabel>
|
||||
<FormInput
|
||||
type="text"
|
||||
value={network}
|
||||
onInput={(e: Event) => setNetwork((e.target as HTMLInputElement).value)}
|
||||
placeholder="SSID"
|
||||
/>
|
||||
<div style={{ fontWeight: 500, fontSize: 16 }}>{selectedNetwork?.ssid}</div>
|
||||
</FormField>
|
||||
<form onSubmit={handleConnect} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<FormField>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormInput
|
||||
type="password"
|
||||
value={password}
|
||||
onInput={(e: Event) => setPassword((e.target as HTMLInputElement).value)}
|
||||
placeholder="Password"
|
||||
placeholder="Enter WiFi password"
|
||||
autofocus
|
||||
/>
|
||||
</FormField>
|
||||
{error && <div style={{ padding: '10px 12px', background: '#2a1515', border: '1px solid #4a2020', borderRadius: 6, color: '#f87171', fontSize: 14 }}>{error}</div>}
|
||||
<FormActions>
|
||||
{saved && <span style={{ fontSize: 13, color: '#888', alignSelf: 'center' }}>Saved</span>}
|
||||
<Button variant="primary" type="submit" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button onClick={() => { setError(''); setStep('networks') }}>Back</Button>
|
||||
<Button variant="primary" type="submit">Connect</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connecting spinner */}
|
||||
{step === 'connecting' && (
|
||||
<div style={{ padding: '32px 0', textAlign: 'center' }}>
|
||||
<div style={{
|
||||
width: 32,
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{step === 'success' && (
|
||||
<div style={{ textAlign: 'center', padding: '16px 0', maxWidth: 400 }}>
|
||||
<div style={{
|
||||
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>
|
||||
<p style={{ color: '#888', marginBottom: 16 }}>
|
||||
Connected to <strong>{successSsid}</strong>
|
||||
{successIp && <span> ({successIp})</span>}
|
||||
</p>
|
||||
{setupMode ? (
|
||||
<div style={{
|
||||
marginTop: 20,
|
||||
padding: 16,
|
||||
background: 'var(--colors-bgSubtle)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--colors-border)',
|
||||
}}>
|
||||
<p style={{ marginBottom: 8 }}>Reconnect your device to <strong>{successSsid}</strong> and visit:</p>
|
||||
<a
|
||||
href="http://toes.local"
|
||||
style={{ color: '#22c55e', fontSize: 18, fontWeight: 600, textDecoration: 'none' }}
|
||||
>
|
||||
http://toes.local
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<FormActions>
|
||||
<Button onClick={() => { fetchStatus(); setStep('status') }}>Done</Button>
|
||||
</FormActions>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{!setupMode && (
|
||||
<Section>
|
||||
<SectionTitle>Install CLI</SectionTitle>
|
||||
<DashboardInstallCmd>
|
||||
curl -fsSL {location.origin}/install | bash
|
||||
</DashboardInstallCmd>
|
||||
</Section>
|
||||
)}
|
||||
</MainContent>
|
||||
</Main>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { render as renderApp } from 'hono/jsx/dom'
|
||||
import { Dashboard } from './components'
|
||||
import { apps, getSelectedTab, selectedApp, setApps, setIsNarrow, setSelectedApp } from './state'
|
||||
import { getWifiStatus } from './api'
|
||||
import { apps, getSelectedTab, selectedApp, setApps, setCurrentView, setIsNarrow, setSelectedApp, setSetupMode } from './state'
|
||||
import { initModal } from './components/modal'
|
||||
import { initToolIframes, updateToolIframes } from './tool-iframes'
|
||||
import { initUpdate } from './update'
|
||||
|
|
@ -41,7 +42,27 @@ narrowQuery.addEventListener('change', e => {
|
|||
render()
|
||||
})
|
||||
|
||||
// SSE connection
|
||||
// Check WiFi setup mode on load
|
||||
getWifiStatus().then(status => {
|
||||
if (status.setupMode) {
|
||||
setSetupMode(true)
|
||||
setCurrentView('settings')
|
||||
render()
|
||||
}
|
||||
}).catch(() => {})
|
||||
|
||||
// SSE for WiFi setup mode changes
|
||||
const wifiEvents = new EventSource('/api/wifi/stream')
|
||||
wifiEvents.onmessage = e => {
|
||||
const data = JSON.parse(e.data)
|
||||
setSetupMode(data.setupMode)
|
||||
if (data.setupMode) {
|
||||
setCurrentView('settings')
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
// SSE connection for app state
|
||||
const events = new EventSource('/api/apps/stream')
|
||||
events.onmessage = e => {
|
||||
const prev = apps
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSect
|
|||
|
||||
// Server state (from SSE)
|
||||
export let apps: App[] = []
|
||||
export let setupMode: boolean = false
|
||||
|
||||
// Tab state
|
||||
export let appTabs: Record<string, string> = JSON.parse(localStorage.getItem('appTabs') || '{}')
|
||||
|
|
@ -45,6 +46,10 @@ export function setApps(newApps: App[]) {
|
|||
apps = newApps
|
||||
}
|
||||
|
||||
export function setSetupMode(mode: boolean) {
|
||||
setupMode = mode
|
||||
}
|
||||
|
||||
export const getSelectedTab = (appName: string | null) =>
|
||||
appName ? appTabs[appName] || 'overview' : 'overview'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { allApps, APPS_DIR, onChange, TOES_DIR } from '$apps'
|
||||
import { allApps, APPS_DIR, onChange } from '$apps'
|
||||
import { onHostLog } from '../tui'
|
||||
import { Hype } from '@because/hype'
|
||||
import { cpus, freemem, platform, totalmem } from 'os'
|
||||
import { join } from 'path'
|
||||
import { existsSync, mkdirSync, readFileSync, statfsSync, writeFileSync } from 'fs'
|
||||
import { readFileSync, statfsSync } from 'fs'
|
||||
|
||||
export interface AppMetrics {
|
||||
cpu: number
|
||||
|
|
@ -18,11 +18,6 @@ export interface SystemMetrics {
|
|||
apps: Record<string, AppMetrics>
|
||||
}
|
||||
|
||||
export interface WifiConfig {
|
||||
network: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface UnifiedLogLine {
|
||||
time: number
|
||||
app: string
|
||||
|
|
@ -190,38 +185,6 @@ router.sse('/metrics/stream', (send) => {
|
|||
return () => clearInterval(interval)
|
||||
})
|
||||
|
||||
// WiFi config
|
||||
const CONFIG_DIR = join(TOES_DIR, 'config')
|
||||
const WIFI_PATH = join(CONFIG_DIR, 'wifi.json')
|
||||
|
||||
function readWifiConfig(): WifiConfig {
|
||||
try {
|
||||
if (existsSync(WIFI_PATH)) {
|
||||
return JSON.parse(readFileSync(WIFI_PATH, 'utf-8'))
|
||||
}
|
||||
} catch {}
|
||||
return { network: '', password: '' }
|
||||
}
|
||||
|
||||
function writeWifiConfig(config: WifiConfig) {
|
||||
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true })
|
||||
writeFileSync(WIFI_PATH, JSON.stringify(config, null, 2))
|
||||
}
|
||||
|
||||
router.get('/wifi', c => {
|
||||
return c.json(readWifiConfig())
|
||||
})
|
||||
|
||||
router.put('/wifi', async c => {
|
||||
const body = await c.req.json<WifiConfig>()
|
||||
const config: WifiConfig = {
|
||||
network: String(body.network ?? ''),
|
||||
password: String(body.password ?? ''),
|
||||
}
|
||||
writeWifiConfig(config)
|
||||
return c.json(config)
|
||||
})
|
||||
|
||||
// Get recent unified logs
|
||||
router.get('/logs', c => {
|
||||
const tail = c.req.query('tail')
|
||||
|
|
|
|||
37
src/server/api/wifi.ts
Normal file
37
src/server/api/wifi.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Hype } from '@because/hype'
|
||||
import { connectToWifi, getWifiStatus, isSetupMode, onSetupModeChange, scanNetworks } from '../wifi'
|
||||
|
||||
const router = Hype.router()
|
||||
|
||||
// GET /api/wifi/status - current WiFi state + setup mode flag
|
||||
router.get('/status', async c => {
|
||||
const status = await getWifiStatus()
|
||||
return c.json({ ...status, setupMode: isSetupMode() })
|
||||
})
|
||||
|
||||
// GET /api/wifi/scan - list available networks
|
||||
router.get('/scan', async c => {
|
||||
const networks = await scanNetworks()
|
||||
return c.json(networks)
|
||||
})
|
||||
|
||||
// POST /api/wifi/connect - submit WiFi credentials
|
||||
router.post('/connect', async c => {
|
||||
const { ssid, password } = await c.req.json<{ ssid: string, password?: string }>()
|
||||
|
||||
if (!ssid) {
|
||||
return c.json({ ok: false, error: 'SSID is required' }, 400)
|
||||
}
|
||||
|
||||
const result = await connectToWifi(ssid, password)
|
||||
return c.json(result)
|
||||
})
|
||||
|
||||
// SSE stream for setup mode changes
|
||||
router.sse('/stream', (send, c) => {
|
||||
send({ setupMode: isSetupMode() })
|
||||
const unsub = onSetupModeChange(setupMode => send({ setupMode }))
|
||||
return () => unsub()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
@ -4,9 +4,11 @@ import appsRouter from './api/apps'
|
|||
import eventsRouter from './api/events'
|
||||
import syncRouter from './api/sync'
|
||||
import systemRouter from './api/system'
|
||||
import wifiRouter from './api/wifi'
|
||||
import { Hype } from '@because/hype'
|
||||
import { cleanupStalePublishers } from './mdns'
|
||||
import { extractSubdomain, proxySubdomain, proxyWebSocket, websocket } from './proxy'
|
||||
import { initWifi, isSetupMode } from './wifi'
|
||||
import type { Server } from 'bun'
|
||||
import type { WsData } from './proxy'
|
||||
|
||||
|
|
@ -16,6 +18,7 @@ app.route('/api/apps', appsRouter)
|
|||
app.route('/api/events', eventsRouter)
|
||||
app.route('/api/sync', syncRouter)
|
||||
app.route('/api/system', systemRouter)
|
||||
app.route('/api/wifi', wifiRouter)
|
||||
|
||||
// Tool URLs: /tool/code?app=todo&file=README.md -> redirect to tool subdomain
|
||||
app.get('/tool/:tool', c => {
|
||||
|
|
@ -113,7 +116,20 @@ app.get('/dist/:file', async c => {
|
|||
})
|
||||
})
|
||||
|
||||
// Captive portal detection paths (iOS, Android, Windows, macOS)
|
||||
const CAPTIVE_PORTAL_PATHS = new Set([
|
||||
'/hotspot-detect.html',
|
||||
'/library/test/success.html',
|
||||
'/generate_204',
|
||||
'/gen_204',
|
||||
'/connecttest.txt',
|
||||
'/ncsi.txt',
|
||||
'/canonical.html',
|
||||
'/success.txt',
|
||||
])
|
||||
|
||||
cleanupStalePublishers()
|
||||
await initWifi()
|
||||
await initApps()
|
||||
|
||||
const defaults = app.defaults
|
||||
|
|
@ -122,7 +138,32 @@ export default {
|
|||
...defaults,
|
||||
maxRequestBodySize: 1024 * 1024 * 50, // 50MB
|
||||
fetch(req: Request, server: Server<WsData>) {
|
||||
const url = new URL(req.url)
|
||||
const subdomain = extractSubdomain(req.headers.get('host') ?? '')
|
||||
|
||||
// In setup mode, restrict access to WiFi API + client assets
|
||||
if (isSetupMode() && !subdomain) {
|
||||
const path = url.pathname
|
||||
|
||||
// Allow through: WiFi API, app stream (for SSE), client assets, root page
|
||||
const allowed =
|
||||
path.startsWith('/api/wifi') ||
|
||||
path === '/api/apps/stream' ||
|
||||
path.startsWith('/client/') ||
|
||||
path === '/' ||
|
||||
path === ''
|
||||
|
||||
// Captive portal probes → redirect to root
|
||||
if (CAPTIVE_PORTAL_PATHS.has(path)) {
|
||||
return Response.redirect('/', 302)
|
||||
}
|
||||
|
||||
// Everything else → redirect to root
|
||||
if (!allowed) {
|
||||
return Response.redirect('/', 302)
|
||||
}
|
||||
}
|
||||
|
||||
if (subdomain) {
|
||||
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
||||
return proxyWebSocket(subdomain, req, server)
|
||||
|
|
|
|||
125
src/server/wifi.ts
Normal file
125
src/server/wifi.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { resolve, join } from 'path'
|
||||
import { hostLog } from './tui'
|
||||
|
||||
export type WifiStatus = {
|
||||
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 _listeners = new Set<(setupMode: boolean) => void>()
|
||||
|
||||
export const isSetupMode = () => _setupMode
|
||||
|
||||
export const onSetupModeChange = (cb: (setupMode: boolean) => void) => {
|
||||
_listeners.add(cb)
|
||||
return () => _listeners.delete(cb)
|
||||
}
|
||||
|
||||
function setSetupMode(mode: boolean) {
|
||||
if (_setupMode === mode) return
|
||||
_setupMode = mode
|
||||
hostLog(mode ? 'Entering WiFi setup mode' : 'Exiting WiFi setup 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> {
|
||||
try {
|
||||
const output = await run(['status'])
|
||||
return JSON.parse(output)
|
||||
} catch {
|
||||
return { connected: false, ssid: '', ip: '' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanNetworks(): Promise<WifiNetwork[]> {
|
||||
try {
|
||||
const output = await run(['scan'])
|
||||
return JSON.parse(output)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectToWifi(ssid: string, password?: string): Promise<ConnectResult> {
|
||||
try {
|
||||
const args = ['connect', ssid]
|
||||
if (password) args.push(password)
|
||||
const output = await run(args)
|
||||
const result: ConnectResult = JSON.parse(output)
|
||||
|
||||
if (result.ok) {
|
||||
setSetupMode(false)
|
||||
hostLog(`Connected to WiFi: ${ssid} (${result.ip})`)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
// Skip on non-Linux or when no WiFi hardware
|
||||
if (process.platform !== 'linux') {
|
||||
hostLog('WiFi setup: skipped (not Linux)')
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasWifiHardware()) {
|
||||
hostLog('WiFi setup: skipped (no WiFi hardware)')
|
||||
return
|
||||
}
|
||||
|
||||
const status = await getWifiStatus()
|
||||
|
||||
if (status.connected) {
|
||||
hostLog(`WiFi: connected to ${status.ssid} (${status.ip})`)
|
||||
return
|
||||
}
|
||||
|
||||
// No WiFi connection - enter setup mode
|
||||
hostLog('WiFi: not connected, starting setup hotspot...')
|
||||
setSetupMode(true)
|
||||
await startHotspot()
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user