Compare commits

...

4 Commits

19 changed files with 788 additions and 122 deletions

View File

@ -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 <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!
## features

View File

@ -19,6 +19,7 @@ echo ">> Updating system libraries"
quiet sudo apt-get update
quiet sudo apt-get install -y libcap2-bin
quiet sudo apt-get install -y avahi-utils
quiet sudo apt-get install -y dnsmasq
quiet sudo apt-get install -y fish
echo ">> Setting fish as default shell for toes user"
@ -62,14 +63,16 @@ BUNDLED_APPS="clock code cron env stats versions"
for app in $BUNDLED_APPS; do
if [ -d "apps/$app" ]; then
echo " Installing $app..."
# Copy app to ~/apps
cp -r "apps/$app" ~/apps/
# Find the version directory and create current symlink
version_dir=$(ls -1 ~/apps/$app | grep -E '^[0-9]{8}-[0-9]{6}$' | sort -r | head -1)
if [ -n "$version_dir" ]; then
ln -sfn "$version_dir" ~/apps/$app/current
# Install dependencies
(cd ~/apps/$app/current && bun install --frozen-lockfile) > /dev/null 2>&1
if ! (cd ~/apps/$app/current && bun install --frozen-lockfile) > /dev/null 2>&1; then
echo " WARNING: bun install failed for $app, trying without lockfile..."
(cd ~/apps/$app/current && bun install) > /dev/null 2>&1 || echo " ERROR: bun install failed for $app"
fi
else
echo " WARNING: no version directory found for $app, skipping"
fi
fi
done
@ -118,7 +121,6 @@ EOF
fi
echo ">> Done! Rebooting in 5 seconds..."
quiet systemctl status "$SERVICE_NAME" --no-pager -l || true
systemctl status "$SERVICE_NAME" --no-pager -l || true
sleep 5
quiet sudo nohup reboot >/dev/null 2>&1 &
exit 0
sudo reboot

15
scripts/wifi-captive.conf Normal file
View File

@ -0,0 +1,15 @@
# 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

View File

@ -1,27 +1,32 @@
import type { ConnectResult, WifiNetwork, WifiStatus } from '../shared/types'
export const connectToWifi = (ssid: string, password?: string): Promise<ConnectResult> =>
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<WifiStatus & { setupMode: boolean, url: string }> =>
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<WifiNetwork[]> =>
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' })

View File

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

View File

@ -1,33 +1,104 @@
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,
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 WifiStep = 'status' | 'scanning' | 'networks' | 'password' | 'connecting' | 'success'
function signalBars(signal: number) {
const level = signal > 75 ? 4 : signal > 50 ? 3 : signal > 25 ? 2 : 1
return (
<SignalBarsWrap>
{[1, 2, 3, 4].map(i => (
<SignalBarSegment
key={i}
level={i <= level ? 'active' : 'inactive'}
style={{ height: 3 + i * 3 }}
/>
))}
</SignalBarsWrap>
)
}
function NetworkList({ networks, onSelect }: { networks: WifiNetwork[], onSelect: (net: WifiNetwork) => void }) {
if (networks.length === 0) {
return (
<SpinnerWrap>
No networks found. Try scanning again.
</SpinnerWrap>
)
}
return (
<NetworkListWrap>
{networks.map(net => (
<NetworkItem key={net.ssid} onClick={() => onSelect(net)}>
<NetworkName>{net.ssid}</NetworkName>
<NetworkMeta>
{net.security && net.security !== '' && net.security !== '--' && <span style={{ fontSize: 12 }}>🔒</span>}
{signalBars(net.signal)}
</NetworkMeta>
</NetworkItem>
))}
</NetworkListWrap>
)
}
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 [serverUrl, setServerUrl] = useState('')
const fetchStatus = () => {
getWifiStatus().then(status => {
setConnected(status.connected)
setCurrentSsid(status.ssid)
setCurrentIp(status.ip)
if (status.url) setServerUrl(status.url)
}).catch(() => {})
}
useEffect(() => {
getWifiConfig().then(config => {
setNetwork(config.network)
setPassword(config.password)
})
fetchStatus()
if (setupMode) doScan()
}, [])
const goBack = () => {
@ -35,59 +106,201 @@ 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>
<HeaderActions>
<Button onClick={goBack}>Back</Button>
</HeaderActions>
<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 }}>
<FormField>
<FormLabel>Network</FormLabel>
<FormInput
type="text"
value={network}
onInput={(e: Event) => setNetwork((e.target as HTMLInputElement).value)}
placeholder="SSID"
/>
</FormField>
<FormField>
<FormLabel>Password</FormLabel>
<FormInput
type="password"
value={password}
onInput={(e: Event) => setPassword((e.target as HTMLInputElement).value)}
placeholder="Password"
/>
</FormField>
<FormActions>
{saved && <span style={{ fontSize: 13, color: '#888', alignSelf: 'center' }}>Saved</span>}
<Button variant="primary" type="submit" disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</FormActions>
</form>
</Section>
<Section>
<SectionTitle>Install CLI</SectionTitle>
<DashboardInstallCmd>
curl -fsSL {location.origin}/install | bash
</DashboardInstallCmd>
{/* Status display */}
{step === 'status' && (
<WifiColumn>
<div>
<InfoRow>
<InfoLabel>Status</InfoLabel>
<InfoValue style={{ color: connected ? theme('colors-statusRunning') : theme('colors-error'), fontWeight: 500 }}>
{connected ? 'Connected' : 'Disconnected'}
</InfoValue>
</InfoRow>
{connected && currentSsid && (
<InfoRow>
<InfoLabel>Network</InfoLabel>
<InfoValue>{currentSsid}</InfoValue>
</InfoRow>
)}
{connected && currentIp && (
<InfoRow>
<InfoLabel>IP</InfoLabel>
<InfoValue style={{ fontFamily: theme('fonts-mono') }}>{currentIp}</InfoValue>
</InfoRow>
)}
</div>
<FormActions>
<Button onClick={doScan}>Scan Networks</Button>
</FormActions>
</WifiColumn>
)}
{/* Scanning spinner */}
{step === 'scanning' && (
<SpinnerWrap>
<Spinner />
<p style={{ color: theme('colors-textMuted') }}>Scanning for networks...</p>
</SpinnerWrap>
)}
{/* Network list */}
{step === 'networks' && (
<WifiColumn style={{ gap: 12 }}>
<NetworkList networks={networks} onSelect={handleSelectNetwork} />
{error && <ErrorBox>{error}</ErrorBox>}
<FormActions>
{!setupMode && <Button onClick={() => setStep('status')}>Back</Button>}
<Button onClick={doScan}>Rescan</Button>
</FormActions>
</WifiColumn>
)}
{/* Password entry */}
{step === 'password' && (
<WifiColumn>
<FormField>
<FormLabel>Network</FormLabel>
<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="Enter WiFi password"
autofocus
/>
</FormField>
{error && <ErrorBox>{error}</ErrorBox>}
<FormActions>
<Button onClick={() => { setError(''); setStep('networks') }}>Back</Button>
<Button variant="primary" type="submit">Connect</Button>
</FormActions>
</form>
</WifiColumn>
)}
{/* Connecting spinner */}
{step === 'connecting' && (
<SpinnerWrap>
<Spinner />
<p style={{ color: theme('colors-textMuted') }}>Connecting to <strong>{selectedNetwork?.ssid}</strong>...</p>
</SpinnerWrap>
)}
{/* Success */}
{step === 'success' && (
<WifiColumn style={{ textAlign: 'center' }}>
<SuccessCheck></SuccessCheck>
<h3 style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>Connected!</h3>
<p style={{ color: theme('colors-textMuted'), 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={serverUrl}
style={{ color: theme('colors-statusRunning'), fontSize: 18, fontWeight: 600, textDecoration: 'none' }}
>
{serverUrl}
</a>
</div>
) : (
<FormActions>
<Button onClick={() => { fetchStatus(); setStep('status') }}>Done</Button>
</FormActions>
)}
</WifiColumn>
)}
</Section>
{!setupMode && (
<Section>
<SectionTitle>Install CLI</SectionTitle>
<DashboardInstallCmd>
curl -fsSL {location.origin}/install | bash
</DashboardInstallCmd>
</Section>
)}
</MainContent>
</Main>
)

View File

@ -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,10 +42,18 @@ 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 connection for app state
const events = new EventSource('/api/apps/stream')
events.onmessage = e => {
const prev = apps
setApps(JSON.parse(e.data))
if (selectedApp && !apps.some(a => a.name === selectedApp)) {

View File

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

View File

@ -68,3 +68,16 @@ export {
TabBar,
TabContent,
} from './misc'
export {
ErrorBox,
NetworkItem,
NetworkListWrap,
NetworkMeta,
NetworkName,
SignalBarSegment,
SignalBarsWrap,
Spinner,
SpinnerWrap,
SuccessCheck,
WifiColumn,
} from './wifi'

104
src/client/styles/wifi.ts Normal file
View File

@ -0,0 +1,104 @@
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,
})
// Inject spin keyframes once
if (typeof document !== 'undefined') {
const style = document.createElement('style')
style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }'
document.head.appendChild(style)
}

View File

@ -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',

View File

@ -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',

View File

@ -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')

31
src/server/api/wifi.ts Normal file
View File

@ -0,0 +1,31 @@
import { TOES_URL } from '$apps'
import { Hype } from '@because/hype'
import { connectToWifi, getWifiStatus, isSetupMode, 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(), url: TOES_URL })
})
// 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)
})
export default router

View File

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

View File

@ -1,10 +1,11 @@
import type { Subprocess } from 'bun'
import { toSubdomain } from '@urls'
import { networkInterfaces } from 'os'
import { hostname, networkInterfaces } from 'os'
import { hostLog } from './tui'
const _publishers = new Map<string, Subprocess>()
const HOST_DOMAIN = `${hostname()}.local`
const isEnabled = process.env.NODE_ENV === 'production' && process.platform === 'linux'
function getLocalIp(): string | null {
@ -24,7 +25,8 @@ export function cleanupStalePublishers() {
if (!isEnabled) return
try {
const result = Bun.spawnSync(['pkill', '-f', 'avahi-publish.*toes\\.local'])
const pattern = HOST_DOMAIN.replace(/\./g, '\\.')
const result = Bun.spawnSync(['pkill', '-f', `avahi-publish.*${pattern}`])
if (result.exitCode === 0) {
hostLog('mDNS: cleaned up stale avahi-publish processes')
}
@ -41,7 +43,7 @@ export function publishApp(name: string) {
return
}
const hostname = `${toSubdomain(name)}.toes.local`
const hostname = `${toSubdomain(name)}.${HOST_DOMAIN}`
try {
const proc = Bun.spawn(['avahi-publish', '-a', hostname, '-R', ip], {
@ -68,7 +70,7 @@ export function unpublishApp(name: string) {
proc.kill()
_publishers.delete(name)
hostLog(`mDNS: unpublished ${toSubdomain(name)}.toes.local`)
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${HOST_DOMAIN}`)
}
export function unpublishAll() {
@ -76,7 +78,7 @@ export function unpublishAll() {
for (const [name, proc] of _publishers) {
proc.kill()
hostLog(`mDNS: unpublished ${toSubdomain(name)}.toes.local`)
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${HOST_DOMAIN}`)
}
_publishers.clear()
}

175
src/server/wifi-nmcli.ts Normal file
View File

@ -0,0 +1,175 @@
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(e => hostLog(`dnsStop: failed to kill pid ${pid}: ${e}`))
await sudo(['rm', '-f', DNSMASQ_PID]).catch(e => hostLog(`dnsStop: failed to remove pid file: ${e}`))
}
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
try {
await startHotspot()
} catch (e) {
hostLog(`CRITICAL: failed to restart hotspot after connection failure: ${e instanceof Error ? e.message : String(e)}`)
}
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() ?? ''
}

79
src/server/wifi.ts Normal file
View File

@ -0,0 +1,79 @@
import * as nmcli from './wifi-nmcli'
import { hostLog } from './tui'
import type { ConnectResult, WifiNetwork, WifiStatus } from '@types'
export type { ConnectResult, WifiNetwork, WifiStatus } from '@types'
let _setupMode = false
export const isSetupMode = () => _setupMode
function setSetupMode(mode: boolean) {
if (_setupMode === mode) return
_setupMode = mode
hostLog(mode ? 'Entering WiFi setup mode' : 'Exiting WiFi setup mode')
}
export async function getWifiStatus(): Promise<WifiStatus> {
try {
return await nmcli.status()
} catch (e) {
hostLog(`WiFi status check failed: ${e instanceof Error ? e.message : String(e)}`)
return { connected: false, ssid: '', ip: '' }
}
}
export async function scanNetworks(): Promise<WifiNetwork[]> {
try {
return await nmcli.scanNetworks()
} catch (e) {
hostLog(`WiFi scan failed: ${e instanceof Error ? e.message : String(e)}`)
return []
}
}
export async function connectToWifi(ssid: string, password?: string): Promise<ConnectResult> {
try {
const result = await nmcli.connectToNetwork(ssid, password)
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) }
}
}
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 (!nmcli.hasHardware()) {
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)
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)}`)
}
}

View File

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