Compare commits
No commits in common. "95c384d3ad8debc6a0b68fcedb4e1d288f0d1025" and "520606ccb9f66aa6d38dc425704f77a3d6c90b23" have entirely different histories.
95c384d3ad
...
520606ccb9
|
|
@ -7,8 +7,7 @@ Plug it in, turn it on, and forget about the cloud.
|
||||||
## quickstart
|
## quickstart
|
||||||
|
|
||||||
1. Plug in and turn on your Toes computer.
|
1. Plug in and turn on your Toes computer.
|
||||||
2. Connect to the **Toes Setup** WiFi network (password: **toessetup**).
|
2. Tell Toes about your WiFi by <using dark @probablycorey magick>.
|
||||||
A setup page will appear — choose your home WiFi and enter its password.
|
|
||||||
3. Visit https://toes.local to get started!
|
3. Visit https://toes.local to get started!
|
||||||
|
|
||||||
## features
|
## features
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,32 +1,27 @@
|
||||||
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[]> =>
|
export const getLogDates = (name: string): Promise<string[]> =>
|
||||||
fetch(`/api/apps/${name}/logs/dates`).then(r => r.json())
|
fetch(`/api/apps/${name}/logs/dates`).then(r => r.json())
|
||||||
|
|
||||||
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
|
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
|
||||||
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
|
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
|
||||||
|
|
||||||
export const getWifiStatus = (): Promise<WifiStatus & { setupMode: boolean }> =>
|
export const getWifiConfig = (): Promise<{ network: string, password: string }> =>
|
||||||
fetch('/api/wifi/status').then(r => r.json())
|
fetch('/api/system/wifi').then(r => r.json())
|
||||||
|
|
||||||
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
export const saveWifiConfig = (config: { network: string, password: string }) =>
|
||||||
|
fetch('/api/system/wifi', {
|
||||||
export const scanWifiNetworks = (): Promise<WifiNetwork[]> =>
|
method: 'PUT',
|
||||||
fetch('/api/wifi/scan').then(r => r.json())
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
}).then(r => r.json())
|
||||||
|
|
||||||
export const shareApp = (name: string) =>
|
export const shareApp = (name: string) =>
|
||||||
fetch(`/api/apps/${name}/tunnel`, { method: 'POST' })
|
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 startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
|
||||||
|
|
||||||
export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { 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 { Styles } from '@because/forge'
|
||||||
import { apps, currentView, isNarrow, selectedApp, setupMode } from '../state'
|
import { apps, currentView, isNarrow, selectedApp } from '../state'
|
||||||
import { Layout } from '../styles'
|
import { Layout } from '../styles'
|
||||||
import { AppDetail } from './AppDetail'
|
import { AppDetail } from './AppDetail'
|
||||||
import { DashboardLanding } from './DashboardLanding'
|
import { DashboardLanding } from './DashboardLanding'
|
||||||
|
|
@ -18,7 +18,7 @@ export function Dashboard({ render }: { render: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Styles />
|
<Styles />
|
||||||
{!isNarrow && !setupMode && <Sidebar render={render} />}
|
{!isNarrow && <Sidebar render={render} />}
|
||||||
<MainContent render={render} />
|
<MainContent render={render} />
|
||||||
<Modal />
|
<Modal />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,33 @@
|
||||||
import { useEffect, useState } from 'hono/jsx'
|
import { useEffect, useState } from 'hono/jsx'
|
||||||
import { connectToWifi, getWifiStatus, scanWifiNetworks } from '../api'
|
import { getWifiConfig, saveWifiConfig } from '../api'
|
||||||
import { setCurrentView, setupMode } from '../state'
|
import { setCurrentView } from '../state'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DashboardInstallCmd,
|
DashboardInstallCmd,
|
||||||
ErrorBox,
|
|
||||||
FormActions,
|
FormActions,
|
||||||
FormField,
|
FormField,
|
||||||
FormInput,
|
FormInput,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
HeaderActions,
|
HeaderActions,
|
||||||
InfoLabel,
|
|
||||||
InfoRow,
|
|
||||||
InfoValue,
|
|
||||||
Main,
|
Main,
|
||||||
MainContent,
|
MainContent,
|
||||||
MainHeader,
|
MainHeader,
|
||||||
MainTitle,
|
MainTitle,
|
||||||
NetworkItem,
|
|
||||||
NetworkListWrap,
|
|
||||||
NetworkMeta,
|
|
||||||
NetworkName,
|
|
||||||
Section,
|
Section,
|
||||||
SectionTitle,
|
SectionTitle,
|
||||||
SignalBarSegment,
|
|
||||||
SignalBarsWrap,
|
|
||||||
Spinner,
|
|
||||||
SpinnerWrap,
|
|
||||||
SuccessCheck,
|
|
||||||
WifiColumn,
|
|
||||||
} from '../styles'
|
} from '../styles'
|
||||||
import { theme } from '../themes'
|
|
||||||
import type { WifiNetwork } from '../../shared/types'
|
|
||||||
|
|
||||||
type 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 (
|
|
||||||
<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 }) {
|
export function SettingsPage({ render }: { render: () => void }) {
|
||||||
const [step, setStep] = useState<WifiStep>('status')
|
const [network, setNetwork] = useState('')
|
||||||
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 [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [saving, setSaving] = useState(false)
|
||||||
const [successSsid, setSuccessSsid] = useState('')
|
const [saved, setSaved] = useState(false)
|
||||||
const [successIp, setSuccessIp] = useState('')
|
|
||||||
|
|
||||||
const fetchStatus = () => {
|
|
||||||
getWifiStatus().then(status => {
|
|
||||||
setConnected(status.connected)
|
|
||||||
setCurrentSsid(status.ssid)
|
|
||||||
setCurrentIp(status.ip)
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStatus()
|
getWifiConfig().then(config => {
|
||||||
if (setupMode) doScan()
|
setNetwork(config.network)
|
||||||
|
setPassword(config.password)
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
|
|
@ -104,203 +35,59 @@ export function SettingsPage({ render }: { render: () => void }) {
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
const doScan = () => {
|
const handleSave = async (e: Event) => {
|
||||||
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()
|
e.preventDefault()
|
||||||
if (!selectedNetwork) return
|
setSaving(true)
|
||||||
doConnect(selectedNetwork.ssid, password || undefined)
|
setSaved(false)
|
||||||
|
await saveWifiConfig({ network, password })
|
||||||
|
setSaving(false)
|
||||||
|
setSaved(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = setupMode ? 'WiFi Setup' : 'Settings'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Main>
|
<Main>
|
||||||
<MainHeader centered>
|
<MainHeader centered>
|
||||||
<MainTitle>{title}</MainTitle>
|
<MainTitle>Settings</MainTitle>
|
||||||
{!setupMode && (
|
|
||||||
<HeaderActions>
|
<HeaderActions>
|
||||||
<Button onClick={goBack}>Back</Button>
|
<Button onClick={goBack}>Back</Button>
|
||||||
</HeaderActions>
|
</HeaderActions>
|
||||||
)}
|
|
||||||
</MainHeader>
|
</MainHeader>
|
||||||
<MainContent centered>
|
<MainContent centered>
|
||||||
<Section>
|
<Section>
|
||||||
<SectionTitle>WiFi</SectionTitle>
|
<SectionTitle>WiFi</SectionTitle>
|
||||||
|
<form onSubmit={handleSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 400 }}>
|
||||||
{/* 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>
|
|
||||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
|
||||||
</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>
|
<FormField>
|
||||||
<FormLabel>Network</FormLabel>
|
<FormLabel>Network</FormLabel>
|
||||||
<div style={{ fontWeight: 500, fontSize: 16 }}>{selectedNetwork?.ssid}</div>
|
<FormInput
|
||||||
|
type="text"
|
||||||
|
value={network}
|
||||||
|
onInput={(e: Event) => setNetwork((e.target as HTMLInputElement).value)}
|
||||||
|
placeholder="SSID"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<form onSubmit={handleConnect} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel>Password</FormLabel>
|
<FormLabel>Password</FormLabel>
|
||||||
<FormInput
|
<FormInput
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onInput={(e: Event) => setPassword((e.target as HTMLInputElement).value)}
|
onInput={(e: Event) => setPassword((e.target as HTMLInputElement).value)}
|
||||||
placeholder="Enter WiFi password"
|
placeholder="Password"
|
||||||
autofocus
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
{error && <ErrorBox>{error}</ErrorBox>}
|
|
||||||
<FormActions>
|
<FormActions>
|
||||||
<Button onClick={() => { setError(''); setStep('networks') }}>Back</Button>
|
{saved && <span style={{ fontSize: 13, color: '#888', alignSelf: 'center' }}>Saved</span>}
|
||||||
<Button variant="primary" type="submit">Connect</Button>
|
<Button variant="primary" type="submit" disabled={saving}>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
</FormActions>
|
</FormActions>
|
||||||
</form>
|
</form>
|
||||||
</WifiColumn>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Connecting spinner */}
|
|
||||||
{step === 'connecting' && (
|
|
||||||
<SpinnerWrap>
|
|
||||||
<Spinner />
|
|
||||||
<p style={{ color: theme('colors-textMuted') }}>Connecting to <strong>{selectedNetwork?.ssid}</strong>...</p>
|
|
||||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
|
||||||
</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="http://toes.local"
|
|
||||||
style={{ color: theme('colors-statusRunning'), fontSize: 18, fontWeight: 600, textDecoration: 'none' }}
|
|
||||||
>
|
|
||||||
http://toes.local
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FormActions>
|
|
||||||
<Button onClick={() => { fetchStatus(); setStep('status') }}>Done</Button>
|
|
||||||
</FormActions>
|
|
||||||
)}
|
|
||||||
</WifiColumn>
|
|
||||||
)}
|
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{!setupMode && (
|
|
||||||
<Section>
|
<Section>
|
||||||
<SectionTitle>Install CLI</SectionTitle>
|
<SectionTitle>Install CLI</SectionTitle>
|
||||||
<DashboardInstallCmd>
|
<DashboardInstallCmd>
|
||||||
curl -fsSL {location.origin}/install | bash
|
curl -fsSL {location.origin}/install | bash
|
||||||
</DashboardInstallCmd>
|
</DashboardInstallCmd>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
|
||||||
</MainContent>
|
</MainContent>
|
||||||
</Main>
|
</Main>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { render as renderApp } from 'hono/jsx/dom'
|
import { render as renderApp } from 'hono/jsx/dom'
|
||||||
import { Dashboard } from './components'
|
import { Dashboard } from './components'
|
||||||
import { getWifiStatus } from './api'
|
import { apps, getSelectedTab, selectedApp, setApps, setIsNarrow, setSelectedApp } from './state'
|
||||||
import { apps, getSelectedTab, selectedApp, setApps, setCurrentView, setIsNarrow, setSelectedApp, setSetupMode } from './state'
|
|
||||||
import { initModal } from './components/modal'
|
import { initModal } from './components/modal'
|
||||||
import { initToolIframes, updateToolIframes } from './tool-iframes'
|
import { initToolIframes, updateToolIframes } from './tool-iframes'
|
||||||
import { initUpdate } from './update'
|
import { initUpdate } from './update'
|
||||||
|
|
@ -42,27 +41,7 @@ narrowQuery.addEventListener('change', e => {
|
||||||
render()
|
render()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check WiFi setup mode on load
|
// SSE connection
|
||||||
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')
|
const events = new EventSource('/api/apps/stream')
|
||||||
events.onmessage = e => {
|
events.onmessage = e => {
|
||||||
const prev = apps
|
const prev = apps
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSect
|
||||||
|
|
||||||
// Server state (from SSE)
|
// Server state (from SSE)
|
||||||
export let apps: App[] = []
|
export let apps: App[] = []
|
||||||
export let setupMode: boolean = false
|
|
||||||
|
|
||||||
// Tab state
|
// Tab state
|
||||||
export let appTabs: Record<string, string> = JSON.parse(localStorage.getItem('appTabs') || '{}')
|
export let appTabs: Record<string, string> = JSON.parse(localStorage.getItem('appTabs') || '{}')
|
||||||
|
|
@ -46,10 +45,6 @@ export function setApps(newApps: App[]) {
|
||||||
apps = newApps
|
apps = newApps
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSetupMode(mode: boolean) {
|
|
||||||
setupMode = mode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getSelectedTab = (appName: string | null) =>
|
export const getSelectedTab = (appName: string | null) =>
|
||||||
appName ? appTabs[appName] || 'overview' : 'overview'
|
appName ? appTabs[appName] || 'overview' : 'overview'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,16 +68,3 @@ export {
|
||||||
TabBar,
|
TabBar,
|
||||||
TabContent,
|
TabContent,
|
||||||
} from './misc'
|
} from './misc'
|
||||||
export {
|
|
||||||
ErrorBox,
|
|
||||||
NetworkItem,
|
|
||||||
NetworkListWrap,
|
|
||||||
NetworkMeta,
|
|
||||||
NetworkName,
|
|
||||||
SignalBarSegment,
|
|
||||||
SignalBarsWrap,
|
|
||||||
Spinner,
|
|
||||||
SpinnerWrap,
|
|
||||||
SuccessCheck,
|
|
||||||
WifiColumn,
|
|
||||||
} from './wifi'
|
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import { define } from '@because/forge'
|
|
||||||
import { theme } from '../themes'
|
|
||||||
|
|
||||||
export const ErrorBox = define('ErrorBox', {
|
|
||||||
padding: '10px 12px',
|
|
||||||
background: theme('colors-errorBg'),
|
|
||||||
border: `1px solid ${theme('colors-errorBorder')}`,
|
|
||||||
borderRadius: 6,
|
|
||||||
color: theme('colors-error'),
|
|
||||||
fontSize: 14,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const NetworkItem = define('NetworkItem', {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '12px 16px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderBottom: `1px solid ${theme('colors-border')}`,
|
|
||||||
selectors: {
|
|
||||||
'&:hover': { background: theme('colors-bgHover') },
|
|
||||||
'&:last-child': { borderBottom: 'none' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const NetworkListWrap = define('NetworkListWrap', {
|
|
||||||
maxHeight: 320,
|
|
||||||
overflowY: 'auto',
|
|
||||||
border: `1px solid ${theme('colors-border')}`,
|
|
||||||
borderRadius: theme('radius-md'),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const NetworkMeta = define('NetworkMeta', {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
color: theme('colors-textMuted'),
|
|
||||||
fontSize: 13,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const NetworkName = define('NetworkName', {
|
|
||||||
fontWeight: 500,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const SignalBarSegment = define('SignalBarSegment', {
|
|
||||||
width: 3,
|
|
||||||
borderRadius: 1,
|
|
||||||
variants: {
|
|
||||||
level: {
|
|
||||||
active: { background: theme('colors-statusRunning') },
|
|
||||||
inactive: { background: theme('colors-border') },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const SignalBarsWrap = define('SignalBarsWrap', {
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'flex-end',
|
|
||||||
gap: 2,
|
|
||||||
height: 14,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Spinner = define('Spinner', {
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
border: `3px solid ${theme('colors-border')}`,
|
|
||||||
borderTopColor: theme('colors-statusRunning'),
|
|
||||||
borderRadius: '50%',
|
|
||||||
margin: '0 auto 16px',
|
|
||||||
animation: 'spin 0.8s linear infinite',
|
|
||||||
})
|
|
||||||
|
|
||||||
export const SpinnerWrap = define('SpinnerWrap', {
|
|
||||||
padding: '32px 0',
|
|
||||||
textAlign: 'center',
|
|
||||||
})
|
|
||||||
|
|
||||||
export const SuccessCheck = define('SuccessCheck', {
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
background: theme('colors-statusRunning'),
|
|
||||||
color: theme('colors-bg'),
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
borderRadius: '50%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
margin: '0 auto 12px',
|
|
||||||
})
|
|
||||||
|
|
||||||
export const WifiColumn = define('WifiColumn', {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 16,
|
|
||||||
maxWidth: 400,
|
|
||||||
})
|
|
||||||
|
|
@ -19,8 +19,6 @@ export default {
|
||||||
'colors-dangerBorder': '#7f1d1d',
|
'colors-dangerBorder': '#7f1d1d',
|
||||||
'colors-dangerText': '#fca5a5',
|
'colors-dangerText': '#fca5a5',
|
||||||
'colors-error': '#f87171',
|
'colors-error': '#f87171',
|
||||||
'colors-errorBg': '#2a1515',
|
|
||||||
'colors-errorBorder': '#4a2020',
|
|
||||||
|
|
||||||
'colors-statusRunning': '#22c55e',
|
'colors-statusRunning': '#22c55e',
|
||||||
'colors-statusStopped': '#666',
|
'colors-statusStopped': '#666',
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,6 @@ export default {
|
||||||
'colors-dangerBorder': '#fecaca',
|
'colors-dangerBorder': '#fecaca',
|
||||||
'colors-dangerText': '#dc2626',
|
'colors-dangerText': '#dc2626',
|
||||||
'colors-error': '#dc2626',
|
'colors-error': '#dc2626',
|
||||||
'colors-errorBg': '#fef2f2',
|
|
||||||
'colors-errorBorder': '#fecaca',
|
|
||||||
|
|
||||||
'colors-statusRunning': '#16a34a',
|
'colors-statusRunning': '#16a34a',
|
||||||
'colors-statusStopped': '#9ca3af',
|
'colors-statusStopped': '#9ca3af',
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { allApps, APPS_DIR, onChange } from '$apps'
|
import { allApps, APPS_DIR, onChange, TOES_DIR } from '$apps'
|
||||||
import { onHostLog } from '../tui'
|
import { onHostLog } from '../tui'
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { cpus, platform, totalmem } from 'os'
|
import { cpus, platform, totalmem } from 'os'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { readFileSync, statfsSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, statfsSync, writeFileSync } from 'fs'
|
||||||
|
|
||||||
export interface AppMetrics {
|
export interface AppMetrics {
|
||||||
cpu: number
|
cpu: number
|
||||||
|
|
@ -18,6 +18,11 @@ export interface SystemMetrics {
|
||||||
apps: Record<string, AppMetrics>
|
apps: Record<string, AppMetrics>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WifiConfig {
|
||||||
|
network: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface UnifiedLogLine {
|
export interface UnifiedLogLine {
|
||||||
time: number
|
time: number
|
||||||
app: string
|
app: string
|
||||||
|
|
@ -199,6 +204,38 @@ router.sse('/metrics/stream', (send) => {
|
||||||
return () => clearInterval(interval)
|
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
|
// Get recent unified logs
|
||||||
router.get('/logs', c => {
|
router.get('/logs', c => {
|
||||||
const tail = c.req.query('tail')
|
const tail = c.req.query('tail')
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
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,11 +4,9 @@ import appsRouter from './api/apps'
|
||||||
import eventsRouter from './api/events'
|
import eventsRouter from './api/events'
|
||||||
import syncRouter from './api/sync'
|
import syncRouter from './api/sync'
|
||||||
import systemRouter from './api/system'
|
import systemRouter from './api/system'
|
||||||
import wifiRouter from './api/wifi'
|
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { cleanupStalePublishers } from './mdns'
|
import { cleanupStalePublishers } from './mdns'
|
||||||
import { extractSubdomain, proxySubdomain, proxyWebSocket, websocket } from './proxy'
|
import { extractSubdomain, proxySubdomain, proxyWebSocket, websocket } from './proxy'
|
||||||
import { initWifi, isSetupMode } from './wifi'
|
|
||||||
import type { Server } from 'bun'
|
import type { Server } from 'bun'
|
||||||
import type { WsData } from './proxy'
|
import type { WsData } from './proxy'
|
||||||
|
|
||||||
|
|
@ -18,7 +16,6 @@ app.route('/api/apps', appsRouter)
|
||||||
app.route('/api/events', eventsRouter)
|
app.route('/api/events', eventsRouter)
|
||||||
app.route('/api/sync', syncRouter)
|
app.route('/api/sync', syncRouter)
|
||||||
app.route('/api/system', systemRouter)
|
app.route('/api/system', systemRouter)
|
||||||
app.route('/api/wifi', wifiRouter)
|
|
||||||
|
|
||||||
// Tool URLs: /tool/code?app=todo&file=README.md -> redirect to tool subdomain
|
// Tool URLs: /tool/code?app=todo&file=README.md -> redirect to tool subdomain
|
||||||
app.get('/tool/:tool', c => {
|
app.get('/tool/:tool', c => {
|
||||||
|
|
@ -116,20 +113,7 @@ 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()
|
cleanupStalePublishers()
|
||||||
await initWifi()
|
|
||||||
await initApps()
|
await initApps()
|
||||||
|
|
||||||
const defaults = app.defaults
|
const defaults = app.defaults
|
||||||
|
|
@ -138,32 +122,7 @@ export default {
|
||||||
...defaults,
|
...defaults,
|
||||||
maxRequestBodySize: 1024 * 1024 * 50, // 50MB
|
maxRequestBodySize: 1024 * 1024 * 50, // 50MB
|
||||||
fetch(req: Request, server: Server<WsData>) {
|
fetch(req: Request, server: Server<WsData>) {
|
||||||
const url = new URL(req.url)
|
|
||||||
const subdomain = extractSubdomain(req.headers.get('host') ?? '')
|
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 (subdomain) {
|
||||||
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
||||||
return proxyWebSocket(subdomain, req, server)
|
return proxyWebSocket(subdomain, req, server)
|
||||||
|
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
import { resolve } from 'path'
|
|
||||||
import { hostLog } from './tui'
|
|
||||||
import type { ConnectResult, WifiNetwork, WifiStatus } from '@types'
|
|
||||||
|
|
||||||
const CAPTIVE_CONF = resolve(import.meta.dir, '../../scripts/wifi-captive.conf')
|
|
||||||
const DNSMASQ_PID = '/tmp/toes-dnsmasq.pid'
|
|
||||||
const HOTSPOT_CON = 'toes-hotspot'
|
|
||||||
const HOTSPOT_IFACE = 'wlan0'
|
|
||||||
const HOTSPOT_SSID = 'Toes Setup'
|
|
||||||
|
|
||||||
async function dnsStart(): Promise<void> {
|
|
||||||
await dnsStop()
|
|
||||||
await sudo(['dnsmasq', `--conf-file=${CAPTIVE_CONF}`, `--pid-file=${DNSMASQ_PID}`, '--port=53'])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dnsStop(): Promise<void> {
|
|
||||||
if (await Bun.file(DNSMASQ_PID).exists()) {
|
|
||||||
const pid = (await Bun.file(DNSMASQ_PID).text()).trim()
|
|
||||||
await sudo(['kill', pid]).catch(() => {})
|
|
||||||
await sudo(['rm', '-f', DNSMASQ_PID]).catch(() => {})
|
|
||||||
}
|
|
||||||
await sudo(['pkill', '-f', 'dnsmasq.*wifi-captive.conf']).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function nmcli(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
||||||
const proc = Bun.spawn(['nmcli', ...args], { stdout: 'pipe', stderr: 'pipe' })
|
|
||||||
const [stdout, stderr] = await Promise.all([
|
|
||||||
new Response(proc.stdout).text(),
|
|
||||||
new Response(proc.stderr).text(),
|
|
||||||
])
|
|
||||||
const exitCode = await proc.exited
|
|
||||||
if (exitCode !== 0 && stderr.trim()) {
|
|
||||||
hostLog(`nmcli error: ${stderr.trim()}`)
|
|
||||||
}
|
|
||||||
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
|
|
||||||
}
|
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
||||||
|
|
||||||
async function sudo(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
||||||
const proc = Bun.spawn(['sudo', ...args], { stdout: 'pipe', stderr: 'pipe' })
|
|
||||||
const [stdout, stderr] = await Promise.all([
|
|
||||||
new Response(proc.stdout).text(),
|
|
||||||
new Response(proc.stderr).text(),
|
|
||||||
])
|
|
||||||
const exitCode = await proc.exited
|
|
||||||
if (exitCode !== 0 && stderr.trim()) {
|
|
||||||
hostLog(`sudo error: ${stderr.trim()}`)
|
|
||||||
}
|
|
||||||
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function connectToNetwork(ssid: string, password?: string): Promise<ConnectResult> {
|
|
||||||
// Stop hotspot and DNS first
|
|
||||||
await stopHotspot()
|
|
||||||
await sleep(1000)
|
|
||||||
|
|
||||||
// Connect
|
|
||||||
const args = ['device', 'wifi', 'connect', ssid]
|
|
||||||
if (password) args.push('password', password)
|
|
||||||
args.push('ifname', HOTSPOT_IFACE)
|
|
||||||
|
|
||||||
const result = await nmcli(args)
|
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
|
||||||
// Connection failed — restart hotspot so user can retry
|
|
||||||
await startHotspot()
|
|
||||||
const error = result.stderr || result.stdout || 'Connection failed'
|
|
||||||
return { ok: false, error }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poll for IP (up to 10 seconds)
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const ip = await getIp()
|
|
||||||
if (ip) return { ok: true, ip, ssid }
|
|
||||||
await sleep(1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, ip: '', ssid }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasHardware(): boolean {
|
|
||||||
const proc = Bun.spawnSync(['nmcli', 'device', 'show', HOTSPOT_IFACE])
|
|
||||||
return proc.exitCode === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function scanNetworks(): Promise<WifiNetwork[]> {
|
|
||||||
// Force rescan
|
|
||||||
await nmcli(['device', 'wifi', 'rescan', 'ifname', HOTSPOT_IFACE]).catch(() => {})
|
|
||||||
await sleep(1000)
|
|
||||||
|
|
||||||
const { stdout } = await nmcli(['-t', '-f', 'SSID,SIGNAL,SECURITY', 'device', 'wifi', 'list', 'ifname', HOTSPOT_IFACE])
|
|
||||||
if (!stdout) return []
|
|
||||||
|
|
||||||
// Parse colon-delimited output (handle escaped colons with \:)
|
|
||||||
const seen = new Map<string, WifiNetwork>()
|
|
||||||
|
|
||||||
for (const line of stdout.split('\n')) {
|
|
||||||
if (!line.trim()) continue
|
|
||||||
const parts = line.split(/(?<!\\):/)
|
|
||||||
if (parts.length < 3) continue
|
|
||||||
|
|
||||||
const ssid = (parts[0] ?? '').replace(/\\:/g, ':').trim()
|
|
||||||
const signal = parseInt(parts[1] ?? '0', 10) || 0
|
|
||||||
const security = (parts[2] ?? '').trim()
|
|
||||||
|
|
||||||
if (!ssid || ssid === '--') continue
|
|
||||||
|
|
||||||
const existing = seen.get(ssid)
|
|
||||||
if (!existing || signal > existing.signal) {
|
|
||||||
seen.set(ssid, { ssid, signal, security })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...seen.values()].sort((a, b) => b.signal - a.signal)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startHotspot(): Promise<void> {
|
|
||||||
// Delete any existing hotspot connection
|
|
||||||
await nmcli(['connection', 'delete', HOTSPOT_CON]).catch(() => {})
|
|
||||||
|
|
||||||
// Create the hotspot
|
|
||||||
await nmcli([
|
|
||||||
'connection', 'add',
|
|
||||||
'type', 'wifi',
|
|
||||||
'ifname', HOTSPOT_IFACE,
|
|
||||||
'con-name', HOTSPOT_CON,
|
|
||||||
'ssid', HOTSPOT_SSID,
|
|
||||||
'autoconnect', 'no',
|
|
||||||
'wifi.mode', 'ap',
|
|
||||||
'wifi.band', 'bg',
|
|
||||||
'wifi-sec.key-mgmt', 'wpa-psk',
|
|
||||||
'wifi-sec.psk', 'toessetup',
|
|
||||||
'ipv4.method', 'shared',
|
|
||||||
'ipv4.addresses', '10.42.0.1/24',
|
|
||||||
])
|
|
||||||
|
|
||||||
await nmcli(['connection', 'up', HOTSPOT_CON])
|
|
||||||
await dnsStart()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function status(): Promise<WifiStatus> {
|
|
||||||
const [stateResult, ssidResult, ipResult] = await Promise.all([
|
|
||||||
nmcli(['-t', '-f', 'GENERAL.STATE', 'device', 'show', HOTSPOT_IFACE]),
|
|
||||||
nmcli(['-t', '-f', 'GENERAL.CONNECTION', 'device', 'show', HOTSPOT_IFACE]),
|
|
||||||
nmcli(['-t', '-f', 'IP4.ADDRESS', 'device', 'show', HOTSPOT_IFACE]),
|
|
||||||
])
|
|
||||||
|
|
||||||
const state = stateResult.stdout.split(':')[1]?.trim() ?? ''
|
|
||||||
const ssid = ssidResult.stdout.split(':')[1]?.trim() ?? ''
|
|
||||||
const ip = (ipResult.stdout.split('\n')[0] ?? '').split(':')[1]?.split('/')[0]?.trim() ?? ''
|
|
||||||
|
|
||||||
const connected = state.toLowerCase().includes('connected')
|
|
||||||
&& ssid !== HOTSPOT_CON
|
|
||||||
&& ssid !== ''
|
|
||||||
&& ssid !== '--'
|
|
||||||
|
|
||||||
return { connected, ssid, ip }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function stopHotspot(): Promise<void> {
|
|
||||||
await dnsStop()
|
|
||||||
await nmcli(['connection', 'down', HOTSPOT_CON]).catch(() => {})
|
|
||||||
await nmcli(['connection', 'delete', HOTSPOT_CON]).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getIp(): Promise<string> {
|
|
||||||
const { stdout } = await nmcli(['-t', '-f', 'IP4.ADDRESS', 'device', 'show', HOTSPOT_IFACE])
|
|
||||||
const line = stdout.split('\n')[0] ?? ''
|
|
||||||
return line.split(':')[1]?.split('/')[0]?.trim() ?? ''
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
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
|
|
||||||
const _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)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWifiStatus(): Promise<WifiStatus> {
|
|
||||||
try {
|
|
||||||
return await nmcli.status()
|
|
||||||
} catch {
|
|
||||||
return { connected: false, ssid: '', ip: '' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function scanNetworks(): Promise<WifiNetwork[]> {
|
|
||||||
try {
|
|
||||||
return await nmcli.scanNetworks()
|
|
||||||
} catch {
|
|
||||||
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)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,10 +18,6 @@ export type LogLine = {
|
||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectResult = { ok: boolean; ip?: string; ssid?: string; error?: string }
|
|
||||||
export type WifiNetwork = { ssid: string; signal: number; security: string }
|
|
||||||
export type WifiStatus = { connected: boolean; ssid: string; ip: string }
|
|
||||||
|
|
||||||
export type App = {
|
export type App = {
|
||||||
name: string
|
name: string
|
||||||
state: AppState
|
state: AppState
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user