diff --git a/scripts/wifi-captive.conf b/scripts/wifi-captive.conf new file mode 100644 index 0000000..129f5b2 --- /dev/null +++ b/scripts/wifi-captive.conf @@ -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 diff --git a/scripts/wifi.sh b/scripts/wifi.sh new file mode 100755 index 0000000..b23eb31 --- /dev/null +++ b/scripts/wifi.sh @@ -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 diff --git a/src/client/api.ts b/src/client/api.ts index 6b7143f..b8ce5c9 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -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 => fetch(`/api/apps/${name}/logs/dates`).then(r => r.json()) export const getLogsForDate = (name: string, date: string): Promise => 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' }) diff --git a/src/client/components/Dashboard.tsx b/src/client/components/Dashboard.tsx index c88e014..b1eb3eb 100644 --- a/src/client/components/Dashboard.tsx +++ b/src/client/components/Dashboard.tsx @@ -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 ( - {!isNarrow && } + {!isNarrow && !setupMode && } diff --git a/src/client/components/SettingsPage.tsx b/src/client/components/SettingsPage.tsx index 442d345..b9a9e4b 100644 --- a/src/client/components/SettingsPage.tsx +++ b/src/client/components/SettingsPage.tsx @@ -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 ( + + {[1, 2, 3, 4].map(i => ( + + ))} + + ) +} + +function NetworkList({ networks, onSelect }: { networks: WifiNetwork[], onSelect: (net: WifiNetwork) => void }) { + if (networks.length === 0) { + return
No networks found. Try scanning again.
+ } + + return ( +
+ {networks.map(net => ( +
onSelect(net)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '12px 16px', + cursor: 'pointer', + borderBottom: '1px solid var(--colors-border)', + }} + > + {net.ssid} + + {net.security && net.security !== '' && net.security !== '--' && 🔒} + {signalBars(net.signal)} + +
+ ))} +
+ ) +} + export function SettingsPage({ render }: { render: () => void }) { - const [network, setNetwork] = useState('') + const [step, setStep] = useState('status') + const [connected, setConnected] = useState(false) + const [currentSsid, setCurrentSsid] = useState('') + const [currentIp, setCurrentIp] = useState('') + const [networks, setNetworks] = useState([]) + const [selectedNetwork, setSelectedNetwork] = useState(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 (
- Settings - - - + {title} + {!setupMode && ( + + + + )}
WiFi -
- - Network - setNetwork((e.target as HTMLInputElement).value)} - placeholder="SSID" - /> - - - Password - setPassword((e.target as HTMLInputElement).value)} - placeholder="Password" - /> - - - {saved && Saved} - - -
-
-
- Install CLI - - curl -fsSL {location.origin}/install | bash - + + {/* Status display */} + {step === 'status' && ( +
+
+
+ Status + + {connected ? 'Connected' : 'Disconnected'} + +
+ {connected && currentSsid && ( +
+ Network + {currentSsid} +
+ )} + {connected && currentIp && ( +
+ IP + {currentIp} +
+ )} +
+ + + +
+ )} + + {/* Scanning spinner */} + {step === 'scanning' && ( +
+ Scanning for networks... +
+ )} + + {/* Network list */} + {step === 'networks' && ( +
+ + {error &&
{error}
} + + {!setupMode && } + + +
+ )} + + {/* Password entry */} + {step === 'password' && ( +
+ + Network +
{selectedNetwork?.ssid}
+
+
+ + Password + setPassword((e.target as HTMLInputElement).value)} + placeholder="Enter WiFi password" + autofocus + /> + + {error &&
{error}
} + + + + +
+
+ )} + + {/* Connecting spinner */} + {step === 'connecting' && ( +
+
+

Connecting to {selectedNetwork?.ssid}...

+ +
+ )} + + {/* Success */} + {step === 'success' && ( +
+
+

Connected!

+

+ Connected to {successSsid} + {successIp && ({successIp})} +

+ {setupMode ? ( +
+

Reconnect your device to {successSsid} and visit:

+ + http://toes.local + +
+ ) : ( + + + + )} +
+ )}
+ + {!setupMode && ( +
+ Install CLI + + curl -fsSL {location.origin}/install | bash + +
+ )}
) diff --git a/src/client/index.tsx b/src/client/index.tsx index 27070f3..ad9f832 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -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 diff --git a/src/client/state.ts b/src/client/state.ts index a01c574..255a19f 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -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 = 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' diff --git a/src/server/api/system.ts b/src/server/api/system.ts index ffbc1e6..4a98521 100644 --- a/src/server/api/system.ts +++ b/src/server/api/system.ts @@ -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 } -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() - 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') diff --git a/src/server/api/wifi.ts b/src/server/api/wifi.ts new file mode 100644 index 0000000..91f1a71 --- /dev/null +++ b/src/server/api/wifi.ts @@ -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 diff --git a/src/server/index.tsx b/src/server/index.tsx index ce5bbcb..f69d91b 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -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) { + 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) diff --git a/src/server/wifi.ts b/src/server/wifi.ts new file mode 100644 index 0000000..df662cc --- /dev/null +++ b/src/server/wifi.ts @@ -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 { + 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 { + try { + const output = await run(['status']) + return JSON.parse(output) + } catch { + return { connected: false, ssid: '', ip: '' } + } +} + +export async function scanNetworks(): Promise { + try { + const output = await run(['scan']) + return JSON.parse(output) + } catch { + return [] + } +} + +export async function connectToWifi(ssid: string, password?: string): Promise { + 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 { + 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 { + // 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() +}