diff --git a/README.md b/README.md index 2dda393..e742c75 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ Plug it in, turn it on, and forget about the cloud. ## quickstart 1. Plug in and turn on your Toes computer. -2. Tell Toes about your WiFi by . +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! ## features diff --git a/scripts/wifi.sh b/scripts/wifi.sh deleted file mode 100755 index b23eb31..0000000 --- a/scripts/wifi.sh +++ /dev/null @@ -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 diff --git a/src/client/api.ts b/src/client/api.ts index b8ce5c9..dff249c 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -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 => fetch('/api/wifi/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -11,12 +13,12 @@ export const getLogDates = (name: string): Promise => export const getLogsForDate = (name: string, date: string): Promise => 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 => fetch('/api/wifi/status').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 }[]> => +export const scanWifiNetworks = (): Promise => fetch('/api/wifi/scan').then(r => r.json()) export const shareApp = (name: string) => diff --git a/src/client/components/SettingsPage.tsx b/src/client/components/SettingsPage.tsx index b9a9e4b..1995976 100644 --- a/src/client/components/SettingsPage.tsx +++ b/src/client/components/SettingsPage.tsx @@ -4,69 +4,73 @@ import { setCurrentView, setupMode } from '../state' import { Button, DashboardInstallCmd, + ErrorBox, FormActions, FormField, FormInput, FormLabel, HeaderActions, + InfoLabel, + InfoRow, + InfoValue, Main, MainContent, MainHeader, MainTitle, + NetworkItem, + NetworkListWrap, + NetworkMeta, + NetworkName, Section, SectionTitle, + SignalBarSegment, + SignalBarsWrap, + Spinner, + SpinnerWrap, + SuccessCheck, + WifiColumn, } 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' 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 ( + + 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 !== '--' && 🔒} + onSelect(net)}> + {net.ssid} + + {net.security && net.security !== '' && net.security !== '--' && 🔒} {signalBars(net.signal)} - -
+ + ))} -
+ ) } @@ -171,55 +175,57 @@ export function SettingsPage({ render }: { render: () => void }) { {/* Status display */} {step === 'status' && ( -
-
-
- Status - + +
+ + Status + {connected ? 'Connected' : 'Disconnected'} - -
+ + {connected && currentSsid && ( -
- Network - {currentSsid} -
+ + Network + {currentSsid} + )} {connected && currentIp && ( -
- IP - {currentIp} -
+ + IP + {currentIp} + )}
-
+ )} {/* Scanning spinner */} {step === 'scanning' && ( -
- Scanning for networks... -
+ + +

Scanning for networks...

+ +
)} {/* Network list */} {step === 'networks' && ( -
+ - {error &&
{error}
} + {error && {error}} {!setupMode && } -
+ )} {/* Password entry */} {step === 'password' && ( -
+ Network
{selectedNetwork?.ssid}
@@ -235,50 +241,30 @@ export function SettingsPage({ render }: { render: () => void }) { autofocus />
- {error &&
{error}
} + {error && {error}} -
+ )} {/* Connecting spinner */} {step === 'connecting' && ( -
-
-

Connecting to {selectedNetwork?.ssid}...

+ + +

Connecting to {selectedNetwork?.ssid}...

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

Connected!

-

+

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

@@ -293,7 +279,7 @@ export function SettingsPage({ render }: { render: () => void }) {

Reconnect your device to {successSsid} and visit:

http://toes.local @@ -303,7 +289,7 @@ export function SettingsPage({ render }: { render: () => void }) { )} -
+ )} diff --git a/src/client/styles/index.ts b/src/client/styles/index.ts index 402c6b7..f91af0a 100644 --- a/src/client/styles/index.ts +++ b/src/client/styles/index.ts @@ -68,3 +68,16 @@ export { TabBar, TabContent, } from './misc' +export { + ErrorBox, + NetworkItem, + NetworkListWrap, + NetworkMeta, + NetworkName, + SignalBarSegment, + SignalBarsWrap, + Spinner, + SpinnerWrap, + SuccessCheck, + WifiColumn, +} from './wifi' diff --git a/src/client/styles/wifi.ts b/src/client/styles/wifi.ts new file mode 100644 index 0000000..623fbab --- /dev/null +++ b/src/client/styles/wifi.ts @@ -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, +}) diff --git a/src/client/themes/dark.ts b/src/client/themes/dark.ts index 2438b49..bc5292f 100644 --- a/src/client/themes/dark.ts +++ b/src/client/themes/dark.ts @@ -19,6 +19,8 @@ export default { 'colors-dangerBorder': '#7f1d1d', 'colors-dangerText': '#fca5a5', 'colors-error': '#f87171', + 'colors-errorBg': '#2a1515', + 'colors-errorBorder': '#4a2020', 'colors-statusRunning': '#22c55e', 'colors-statusStopped': '#666', diff --git a/src/client/themes/light.ts b/src/client/themes/light.ts index b4d556d..82c0f96 100644 --- a/src/client/themes/light.ts +++ b/src/client/themes/light.ts @@ -19,6 +19,8 @@ export default { 'colors-dangerBorder': '#fecaca', 'colors-dangerText': '#dc2626', 'colors-error': '#dc2626', + 'colors-errorBg': '#fef2f2', + 'colors-errorBorder': '#fecaca', 'colors-statusRunning': '#16a34a', 'colors-statusStopped': '#9ca3af', diff --git a/src/server/wifi-nmcli.ts b/src/server/wifi-nmcli.ts new file mode 100644 index 0000000..8d55248 --- /dev/null +++ b/src/server/wifi-nmcli.ts @@ -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 { + await dnsStop() + await sudo(['dnsmasq', `--conf-file=${CAPTIVE_CONF}`, `--pid-file=${DNSMASQ_PID}`, '--port=53']) +} + +async function dnsStop(): Promise { + 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(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 { + // 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 { + // 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() + + for (const line of stdout.split('\n')) { + if (!line.trim()) continue + const parts = line.split(/(? existing.signal) { + seen.set(ssid, { ssid, signal, security }) + } + } + + return [...seen.values()].sort((a, b) => b.signal - a.signal) +} + +export async function startHotspot(): Promise { + // 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 { + 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 { + await dnsStop() + await nmcli(['connection', 'down', HOTSPOT_CON]).catch(() => {}) + await nmcli(['connection', 'delete', HOTSPOT_CON]).catch(() => {}) +} + +async function getIp(): Promise { + 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() ?? '' +} diff --git a/src/server/wifi.ts b/src/server/wifi.ts index df662cc..b9c3c92 100644 --- a/src/server/wifi.ts +++ b/src/server/wifi.ts @@ -1,29 +1,11 @@ -import { resolve, join } from 'path' +import * as nmcli from './wifi-nmcli' import { hostLog } from './tui' +import type { ConnectResult, WifiNetwork, WifiStatus } from '@types' -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')) +export type { ConnectResult, WifiNetwork, WifiStatus } from '@types' let _setupMode = false -let _listeners = new Set<(setupMode: boolean) => void>() +const _listeners = new Set<(setupMode: boolean) => void>() export const isSetupMode = () => _setupMode @@ -39,25 +21,9 @@ function setSetupMode(mode: boolean) { 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) + return await nmcli.status() } catch { return { connected: false, ssid: '', ip: '' } } @@ -65,8 +31,7 @@ export async function getWifiStatus(): Promise { export async function scanNetworks(): Promise { try { - const output = await run(['scan']) - return JSON.parse(output) + return await nmcli.scanNetworks() } catch { return [] } @@ -74,10 +39,7 @@ export async function scanNetworks(): Promise { 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) + const result = await nmcli.connectToNetwork(ssid, password) if (result.ok) { setSetupMode(false) @@ -90,15 +52,6 @@ export async function connectToWifi(ssid: string, password?: string): 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') { @@ -106,7 +59,7 @@ export async function initWifi(): Promise { return } - if (!hasWifiHardware()) { + if (!nmcli.hasHardware()) { hostLog('WiFi setup: skipped (no WiFi hardware)') return } @@ -121,5 +74,11 @@ export async function initWifi(): Promise { // No WiFi connection - enter setup mode hostLog('WiFi: not connected, starting setup hotspot...') 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)}`) + } } diff --git a/src/shared/types.ts b/src/shared/types.ts index c0fdd42..0ab6ac6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -18,6 +18,10 @@ export type LogLine = { 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 = { name: string state: AppState