Compare commits

..

4 Commits

53 changed files with 950 additions and 768 deletions

View File

@ -4,28 +4,12 @@ Toes is a personal web server you run in your home.
Plug it in, turn it on, and forget about the cloud.
## setup
## quickstart
Toes runs on a Raspberry Pi. You'll need:
- A Raspberry Pi running Raspberry Pi OS
- A `toes` user with passwordless sudo
SSH into your Pi as the `toes` user and run:
```bash
curl -fsSL https://toes.dev/install | bash
```
This will:
1. Install system dependencies (git, fish shell, networking tools)
2. Install Bun and grant it network binding capabilities
3. Clone and build the toes server
4. Set up bundled apps (clock, code, cron, env, stats, versions)
5. Install and enable a systemd service for auto-start
Once complete, visit `http://<hostname>.local` on your local network.
1. Plug in and turn on your Toes computer.
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
- Hosts bun/hono/hype webapps - both SSR and SPA.

View File

@ -1,26 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "toes-install",
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.9.3",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.9", "https://npm.nose.space/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.3.2", "https://npm.nose.space/@types/node/-/node-25.3.2.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

View File

@ -1,123 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
##
# toes installer
# Usage: curl -sSL https://toes.dev/install | bash
# Must be run as the 'toes' user.
DEST=~/toes
REPO="https://git.nose.space/defunkt/toes"
quiet() { "$@" > /dev/null 2>&1; }
echo ""
echo " ╔══════════════════════════════════╗"
echo " ║ 🐾 toes - personal web appliance ║"
echo " ╚══════════════════════════════════╝"
echo ""
# Must be running as toes
if [ "$(whoami)" != "toes" ]; then
echo "ERROR: This script must be run as the 'toes' user."
echo "Create the user during Raspberry Pi OS setup."
exit 1
fi
# Must have passwordless sudo (can't prompt when piped from curl)
if ! sudo -n true 2>/dev/null; then
echo "ERROR: This script requires passwordless sudo."
echo "On Raspberry Pi OS, the default user has this already."
exit 1
fi
# -- System packages --
echo ">> Updating system packages"
quiet sudo apt-get update
quiet sudo apt-get install -y git libcap2-bin avahi-utils fish unzip
echo ">> Setting fish as default shell"
if [ "$(getent passwd toes | cut -d: -f7)" != "/usr/bin/fish" ]; then
quiet sudo chsh -s /usr/bin/fish toes
fi
# -- Bun --
BUN_REAL="$HOME/.bun/bin/bun"
BUN_SYMLINK="/usr/local/bin/bun"
if [ ! -x "$BUN_REAL" ]; then
echo ">> Installing bun"
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
if [ ! -x "$BUN_REAL" ]; then
echo "ERROR: bun installation failed"
exit 1
fi
fi
if [ ! -x "$BUN_SYMLINK" ]; then
sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
fi
echo ">> Setting CAP_NET_BIND_SERVICE on bun"
sudo setcap 'cap_net_bind_service=+ep' "$BUN_REAL"
# -- Clone --
if [ ! -d "$DEST" ]; then
echo ">> Cloning toes"
git clone "$REPO" "$DEST"
else
echo ">> Updating toes"
cd "$DEST" && git pull origin main
fi
# -- Directories --
mkdir -p ~/data ~/apps
# -- Dependencies & build --
echo ">> Installing dependencies"
cd "$DEST" && bun install
echo ">> Building client"
cd "$DEST" && bun run build
# -- Bundled apps --
echo ">> Installing bundled apps"
BUNDLED_APPS="clock code cron env stats versions"
for app in $BUNDLED_APPS; do
if [ -d "$DEST/apps/$app" ]; then
if [ -d ~/apps/"$app" ]; then
echo " $app (exists, skipping)"
continue
fi
echo " $app"
cp -r "$DEST/apps/$app" ~/apps/
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
(cd ~/apps/"$app"/current && bun install --frozen-lockfile) > /dev/null 2>&1 || true
fi
fi
done
# -- Systemd --
echo ">> Installing toes service"
sudo install -m 644 -o root -g root "$DEST/scripts/toes.service" /etc/systemd/system/toes.service
sudo systemctl daemon-reload
sudo systemctl enable toes
echo ">> Starting toes"
sudo systemctl restart toes
# -- Done --
echo ""
echo " toes is installed and running!"
echo " Visit: http://$(hostname).local"
echo ""

View File

@ -1,16 +0,0 @@
{
"name": "toes-install",
"version": "0.0.1",
"description": "install toes",
"module": "server.ts",
"type": "module",
"scripts": {
"start": "bun run server.ts"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.9.3"
}
}

View File

@ -1,17 +0,0 @@
import { resolve } from "path"
const script = await Bun.file(resolve(import.meta.dir, "install.sh")).text()
Bun.serve({
port: parseInt(process.env.PORT || "3000"),
fetch(req) {
if (new URL(req.url).pathname === "/install") {
return new Response(script, {
headers: { "content-type": "text/plain" },
})
}
return new Response("404 Not Found", { status: 404 })
},
})
console.log(`Serving /install on :${Bun.env.PORT || 3000}`)

View File

@ -1,43 +0,0 @@
{
"exclude": ["apps", "templates"],
"compilerOptions": {
// Environment setup & latest features
"lib": [
"ESNext",
"DOM"
],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"baseUrl": ".",
"paths": {
"$*": [
"./src/server/*"
],
"@*": [
"./src/shared/*"
],
"%*": [
"./src/lib/*"
]
}
}
}

View File

@ -2,12 +2,8 @@
# It isn't enough to modify this yet.
# You also need to manually update the toes.service file.
TOES_USER="${TOES_USER:-toes}"
HOST="${HOST:-toes.local}"
SSH_HOST="$TOES_USER@$HOST"
URL="${URL:-http://$HOST}"
DEST="${DEST:-$HOME/toes}"
DATA_DIR="${DATA_DIR:-$HOME/data}"
APPS_DIR="${APPS_DIR:-$HOME/apps}"
mkdir -p "$DEST" "$DATA_DIR" "$APPS_DIR"
HOST="${HOST:-toes@toes.local}"
URL="${URL:-http://toes.local}"
DEST="${DEST:-~/toes}"
DATA_DIR="${DATA_DIR:-~/data}"
APPS_DIR="${APPS_DIR:-~/apps}"

View File

@ -11,7 +11,7 @@ source "$ROOT_DIR/scripts/config.sh"
git push origin main
# SSH to target: pull, build, sync apps, restart
ssh "$SSH_HOST" DEST="$DEST" APPS_DIR="$APPS_DIR" bash <<'SCRIPT'
ssh "$HOST" DEST="$DEST" APPS_DIR="$APPS_DIR" bash <<'SCRIPT'
set -e
cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && rm -rf dist && bun run build
@ -35,5 +35,5 @@ done
sudo systemctl restart toes.service
SCRIPT
echo "=> Deployed to $SSH_HOST"
echo "=> Deployed to $HOST"
echo "=> Visit $URL"

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

View File

@ -9,4 +9,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh"
# Run remote install on the target
ssh "$SSH_HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"
ssh "$HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"

View File

@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh"
ssh "$SSH_HOST" "journalctl -u toes -n 100"
ssh "$HOST" "journalctl -u toes -n 100"

View File

@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh"
ssh "$SSH_HOST" "sudo systemctl restart toes.service"
ssh "$HOST" "sudo systemctl restart toes.service"

View File

@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh"
ssh "$SSH_HOST" "sudo systemctl start toes.service"
ssh "$HOST" "sudo systemctl start toes.service"

View File

@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh"
ssh "$SSH_HOST" "sudo systemctl stop toes.service"
ssh "$HOST" "sudo systemctl stop toes.service"

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,4 +1,5 @@
import type { Manifest } from '@types'
const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local'
const normalizeUrl = (url: string) =>

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

@ -2,14 +2,13 @@ import { define } from '@because/forge'
import type { App } from '../../shared/types'
import { buildAppUrl } from '../../shared/urls'
import { restartApp, shareApp, startApp, stopApp, unshareApp } from '../api'
import { openDeleteAppModal, openRenameAppModal } from '../modals'
import { apps, getSelectedTab, isNarrow, setMobileSidebar } from '../state'
import { openAppSelectorModal, openDeleteAppModal, openRenameAppModal } from '../modals'
import { apps, getSelectedTab, isNarrow } from '../state'
import {
ActionBar,
AppSelectorChevron,
Button,
ClickableAppName,
HamburgerButton,
HamburgerLine,
HeaderActions,
InfoLabel,
InfoRow,
@ -53,15 +52,14 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
<Main>
<MainHeader>
<MainTitle>
{isNarrow && (
<HamburgerButton onClick={() => { setMobileSidebar(true); render() }} title="Show apps">
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
)}
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
&nbsp;
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
{isNarrow && (
<AppSelectorChevron onClick={() => openAppSelectorModal(render)}>
</AppSelectorChevron>
)}
</MainTitle>
<HeaderActions>
{!app.tool && (

View File

@ -2,6 +2,7 @@ import type { CSSProperties } from 'hono/jsx'
import {
apps,
selectedApp,
setSelectedApp,
setSidebarSection,
sidebarSection,
} from '../state'
@ -16,13 +17,19 @@ import {
interface AppSelectorProps {
render: () => void
onSelect?: () => void
onDashboard?: () => void
collapsed?: boolean
large?: boolean
switcherStyle?: CSSProperties
listStyle?: CSSProperties
}
export function AppSelector({ render, onSelect, collapsed, large, switcherStyle, listStyle }: AppSelectorProps) {
export function AppSelector({ render, onSelect, onDashboard, collapsed, switcherStyle, listStyle }: AppSelectorProps) {
const selectApp = (name: string) => {
setSelectedApp(name)
onSelect?.()
render()
}
const switchSection = (section: 'apps' | 'tools') => {
setSidebarSection(section)
render()
@ -36,18 +43,18 @@ export function AppSelector({ render, onSelect, collapsed, large, switcherStyle,
<>
{!collapsed && toolApps.length > 0 && (
<SectionSwitcher style={switcherStyle}>
<SectionTab active={sidebarSection === 'apps' ? true : undefined} large={large || undefined} onClick={() => switchSection('apps')}>
<SectionTab active={sidebarSection === 'apps' ? true : undefined} onClick={() => switchSection('apps')}>
Apps
</SectionTab>
<SectionTab active={sidebarSection === 'tools' ? true : undefined} large={large || undefined} onClick={() => switchSection('tools')}>
<SectionTab active={sidebarSection === 'tools' ? true : undefined} onClick={() => switchSection('tools')}>
Tools
</SectionTab>
</SectionSwitcher>
)}
<AppList style={listStyle}>
{collapsed && (
{collapsed && onDashboard && (
<AppItem
href="/"
onClick={onDashboard}
selected={!selectedApp ? true : undefined}
style={{ justifyContent: 'center', padding: '10px 12px' }}
title="Toes"
@ -58,9 +65,7 @@ export function AppSelector({ render, onSelect, collapsed, large, switcherStyle,
{activeApps.map(app => (
<AppItem
key={app.name}
href={`/app/${app.name}`}
onClick={onSelect}
large={large || undefined}
onClick={() => selectApp(app.name)}
selected={app.name === selectedApp ? true : undefined}
style={collapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined}
title={collapsed ? app.name : undefined}
@ -69,7 +74,7 @@ export function AppSelector({ render, onSelect, collapsed, large, switcherStyle,
<span style={{ fontSize: 18 }}>{app.icon}</span>
) : (
<>
<span style={{ fontSize: large ? 20 : 14 }}>{app.icon}</span>
<span style={{ fontSize: 14 }}>{app.icon}</span>
{app.name}
<StatusDot state={app.state} data-app={app.name} data-state={app.state} style={{ marginLeft: 'auto' }} />
</>

View File

@ -1,48 +1,13 @@
import { Styles } from '@because/forge'
import { openNewAppModal } from '../modals'
import { apps, currentView, isNarrow, mobileSidebar, selectedApp, setMobileSidebar } from '../state'
import {
HamburgerButton,
HamburgerLine,
Layout,
Main,
MainContent as MainContentContainer,
MainHeader,
MainTitle,
NewAppButton,
} from '../styles'
import { apps, currentView, isNarrow, selectedApp, setupMode } from '../state'
import { Layout } from '../styles'
import { AppDetail } from './AppDetail'
import { AppSelector } from './AppSelector'
import { DashboardLanding } from './DashboardLanding'
import { Modal } from './modal'
import { SettingsPage } from './SettingsPage'
import { Sidebar } from './Sidebar'
function MobileSidebar({ render }: { render: () => void }) {
return (
<Main>
<MainHeader>
<MainTitle>
<HamburgerButton onClick={() => { setMobileSidebar(false); render() }} title="Hide apps">
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
<a href="/" style={{ textDecoration: 'none', color: 'inherit' }}>🐾 Toes</a>
</MainTitle>
</MainHeader>
<MainContentContainer>
<AppSelector render={render} large />
<div style={{ padding: '12px 16px' }}>
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
</div>
</MainContentContainer>
</Main>
)
}
function MainContent({ render }: { render: () => void }) {
if (isNarrow && mobileSidebar) return <MobileSidebar render={render} />
const selected = apps.find(a => a.name === selectedApp)
if (selected) return <AppDetail app={selected} render={render} />
if (currentView === 'settings') return <SettingsPage render={render} />
@ -53,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,37 +1,34 @@
import { useEffect } from 'hono/jsx'
import { navigate } from '../router'
import { dashboardTab, isNarrow, setMobileSidebar } from '../state'
import { openAppSelectorModal } from '../modals'
import { apps, isNarrow, setCurrentView, setSelectedApp } from '../state'
import {
HamburgerButton,
HamburgerLine,
AppSelectorChevron,
DashboardContainer,
DashboardHeader,
DashboardTitle,
SettingsGear,
Tab,
TabBar,
TabContent,
StatusDot,
StatusDotLink,
StatusDotsRow,
} from '../styles'
import { UnifiedLogs, initUnifiedLogs, scrollLogsToBottom } from './UnifiedLogs'
import { Urls } from './Urls'
import { update } from '../update'
import { UnifiedLogs, initUnifiedLogs } from './UnifiedLogs'
import { Vitals, initVitals } from './Vitals'
let activeTooltip: string | null = null
export function DashboardLanding({ render }: { render: () => void }) {
useEffect(() => {
initUnifiedLogs()
initVitals()
if (dashboardTab === 'logs') scrollLogsToBottom()
}, [])
const narrow = isNarrow || undefined
const openSettings = () => {
navigate('/settings')
}
const switchTab = (tab: typeof dashboardTab) => {
navigate(tab === 'urls' ? '/' : `/${tab}`)
if (tab === 'logs') scrollLogsToBottom()
setSelectedApp(null)
setCurrentView('settings')
render()
}
return (
@ -43,40 +40,43 @@ export function DashboardLanding({ render }: { render: () => void }) {
>
</SettingsGear>
{isNarrow && (
<HamburgerButton
onClick={() => { setMobileSidebar(true); render() }}
title="Show apps"
style={{ position: 'absolute', top: 16, left: 16 }}
>
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
)}
<DashboardHeader>
<DashboardTitle narrow={narrow}>
🐾 Toes
{isNarrow && (
<AppSelectorChevron onClick={() => openAppSelectorModal(render)}>
</AppSelectorChevron>
)}
</DashboardTitle>
</DashboardHeader>
<TabBar centered>
<Tab active={dashboardTab === 'urls' || undefined} onClick={() => switchTab('urls')}>URLs</Tab>
<Tab active={dashboardTab === 'logs' || undefined} onClick={() => switchTab('logs')}>Logs</Tab>
<Tab active={dashboardTab === 'metrics' || undefined} onClick={() => switchTab('metrics')}>Metrics</Tab>
</TabBar>
<StatusDotsRow>
{[...apps.filter(a => !a.tool), ...apps.filter(a => a.tool)].map(app => (
<StatusDotLink
key={app.name}
data-tooltip={app.name}
tooltipVisible={activeTooltip === app.name || undefined}
onClick={(e: Event) => {
e.preventDefault()
if (isNarrow && activeTooltip !== app.name) {
activeTooltip = app.name
render()
return
}
activeTooltip = null
setSelectedApp(app.name)
update()
}}
>
<StatusDot state={app.state} data-app={app.name} />
</StatusDotLink>
))}
</StatusDotsRow>
<TabContent active={dashboardTab === 'urls' || undefined}>
<Urls render={render} />
</TabContent>
<Vitals />
<TabContent active={dashboardTab === 'logs' || undefined}>
<UnifiedLogs />
</TabContent>
<TabContent active={dashboardTab === 'metrics' || undefined}>
<Vitals />
</TabContent>
<UnifiedLogs />
</DashboardContainer>
)
}

View File

@ -1,6 +1,5 @@
import type { App } from '../../shared/types'
import { navigate } from '../router'
import { apps, getSelectedTab } from '../state'
import { apps, getSelectedTab, setSelectedTab } from '../state'
import { Tab, TabBar } from '../styles'
import { resetToolIframe } from '../tool-iframes'
@ -13,7 +12,8 @@ export function Nav({ app, render }: { app: App; render: () => void }) {
resetToolIframe(tab, app.name)
return
}
navigate(tab === 'overview' ? `/app/${app.name}` : `/app/${app.name}/${tab}`)
setSelectedTab(app.name, tab)
render()
}
// Find all tools

View File

@ -1,92 +1,306 @@
import { useEffect, useState } from 'hono/jsx'
import { getWifiConfig, saveWifiConfig } from '../api'
import { navigate } from '../router'
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 = () => {
navigate('/')
setCurrentView('dashboard')
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,5 +1,7 @@
import { openNewAppModal } from '../modals'
import {
setCurrentView,
setSelectedApp,
setSidebarCollapsed,
sidebarCollapsed,
} from '../state'
@ -15,6 +17,12 @@ import {
import { AppSelector } from './AppSelector'
export function Sidebar({ render }: { render: () => void }) {
const goToDashboard = () => {
setSelectedApp(null)
setCurrentView('dashboard')
render()
}
const toggleSidebar = () => {
setSidebarCollapsed(!sidebarCollapsed)
render()
@ -32,7 +40,7 @@ export function Sidebar({ render }: { render: () => void }) {
</div>
) : (
<Logo>
<LogoLink href="/" title="Go to dashboard">
<LogoLink onClick={goToDashboard} title="Go to dashboard">
🐾 Toes
</LogoLink>
<HamburgerButton onClick={toggleSidebar} title="Hide sidebar">
@ -42,7 +50,7 @@ export function Sidebar({ render }: { render: () => void }) {
</HamburgerButton>
</Logo>
)}
<AppSelector render={render} collapsed={sidebarCollapsed} />
<AppSelector render={render} collapsed={sidebarCollapsed} onDashboard={goToDashboard} />
{!sidebarCollapsed && (
<SidebarFooter>
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>

View File

@ -105,13 +105,6 @@ function renderLogs() {
})
}
export function scrollLogsToBottom() {
requestAnimationFrame(() => {
const el = document.getElementById('unified-logs-body')
if (el) el.scrollTop = el.scrollHeight
})
}
export function initUnifiedLogs() {
if (_source) return
_source = new EventSource('/api/system/logs/stream')

View File

@ -1,48 +0,0 @@
import { buildAppUrl } from '../../shared/urls'
import { navigate } from '../router'
import { apps, isNarrow } from '../state'
import {
EmptyState,
Tile,
TileGrid,
TileIcon,
TileName,
TileStatus,
} from '../styles'
export function Urls({ render }: { render: () => void }) {
const nonTools = apps.filter(a => !a.tool)
if (nonTools.length === 0) {
return <EmptyState>No apps installed</EmptyState>
}
return (
<TileGrid narrow={isNarrow || undefined}>
{nonTools.map(app => {
const url = app.tunnelUrl || buildAppUrl(app.name, location.origin)
const running = app.state === 'running'
const appPage = `/app/${app.name}`
const openAppPage = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
navigate(appPage)
}
return (
<Tile
key={app.name}
href={running ? url : appPage}
target={running ? '_blank' : undefined}
narrow={isNarrow || undefined}
>
<TileStatus state={app.state} onClick={openAppPage} />
<TileIcon>{app.icon}</TileIcon>
<TileName>{app.name}</TileName>
</Tile>
)
})}
</TileGrid>
)
}

View File

@ -1,8 +1,8 @@
import { render as renderApp } from 'hono/jsx/dom'
import { Dashboard } from './components'
import { getWifiStatus } from './api'
import { apps, getSelectedTab, selectedApp, setApps, setCurrentView, setIsNarrow, setSelectedApp, setSetupMode } from './state'
import { initModal } from './components/modal'
import { initRouter, navigate } from './router'
import { apps, getSelectedTab, selectedApp, setApps, setIsNarrow } from './state'
import { initToolIframes, updateToolIframes } from './tool-iframes'
import { initUpdate } from './update'
@ -42,16 +42,22 @@ narrowQuery.addEventListener('change', e => {
render()
})
// Initialize router (sets initial state from URL and renders)
initRouter(render)
// Check WiFi setup mode on load
getWifiStatus().then(status => {
if (status.setupMode) {
setSetupMode(true)
setCurrentView('settings')
render()
}
}).catch(() => {})
// SSE connection
// SSE connection for app state
const events = new EventSource('/api/apps/stream')
events.onmessage = e => {
setApps(JSON.parse(e.data))
if (selectedApp && !apps.some(a => a.name === selectedApp)) {
navigate('/')
setSelectedApp(null)
}
render()

View File

@ -0,0 +1,17 @@
import { closeModal, openModal } from '../components/modal'
import { AppSelector } from '../components/AppSelector'
let renderFn: () => void
export function openAppSelectorModal(render: () => void) {
renderFn = render
openModal('Select App', () => (
<AppSelector
render={renderFn}
onSelect={closeModal}
switcherStyle={{ padding: '0 0 12px', marginLeft: -20, marginRight: -20, paddingLeft: 20, paddingRight: 20, marginBottom: 8 }}
listStyle={{ maxHeight: 300, overflow: 'auto' }}
/>
))
}

View File

@ -1,7 +1,6 @@
import type { App } from '../../shared/types'
import { closeModal, openModal, rerenderModal } from '../components/modal'
import { navigate } from '../router'
import { selectedApp } from '../state'
import { selectedApp, setSelectedApp } from '../state'
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
import { theme } from '../themes'
@ -33,11 +32,11 @@ async function deleteApp(input: HTMLInputElement) {
throw new Error(`Failed to delete app: ${res.statusText}`)
}
// Success - close modal and navigate to dashboard
closeModal()
// Success - close modal and clear selection
if (selectedApp === deleteAppTarget.name) {
navigate('/')
setSelectedApp(null)
}
closeModal()
} catch (err) {
deleteAppError = err instanceof Error ? err.message : 'Failed to delete app'
deleteAppDeleting = false

View File

@ -1,6 +1,5 @@
import { closeModal, openModal, rerenderModal } from '../components/modal'
import { navigate } from '../router'
import { apps } from '../state'
import { apps, setSelectedApp } from '../state'
import { Button, Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from '../styles'
type TemplateType = 'ssr' | 'spa' | 'bare'
@ -49,9 +48,9 @@ async function createNewApp() {
throw new Error(data.error || 'Failed to create app')
}
// Success - close modal and navigate to the new app
// Success - close modal and select the new app
setSelectedApp(name)
closeModal()
navigate(`/app/${name}`)
} catch (err) {
newAppError = err instanceof Error ? err.message : 'Failed to create app'
newAppCreating = false

View File

@ -1,7 +1,6 @@
import type { App } from '../../shared/types'
import { closeModal, openModal, rerenderModal } from '../components/modal'
import { navigate } from '../router'
import { apps } from '../state'
import { apps, setSelectedApp } from '../state'
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
let renameAppError = ''
@ -59,9 +58,9 @@ async function doRenameApp(input: HTMLInputElement) {
throw new Error(data.error || 'Failed to rename app')
}
// Success - close modal and navigate to renamed app
// Success - update selection and close modal
setSelectedApp(data.name || newName)
closeModal()
navigate(`/app/${data.name || newName}`)
} catch (err) {
renameAppError = err instanceof Error ? err.message : 'Failed to rename app'
renameAppRenaming = false

View File

@ -1,3 +1,4 @@
export { openAppSelectorModal } from './AppSelector'
export { openDeleteAppModal } from './DeleteApp'
export { openNewAppModal } from './NewApp'
export { openRenameAppModal } from './RenameApp'

View File

@ -1,60 +0,0 @@
import { setCurrentView, setDashboardTab, setMobileSidebar, setSelectedApp, setSelectedTab } from './state'
let _render: () => void
export function navigate(href: string) {
history.pushState(null, '', href)
route()
}
export function initRouter(render: () => void) {
_render = render
// Intercept link clicks
document.addEventListener('click', e => {
const a = (e.target as Element).closest('a')
if (!a || !a.href || a.origin !== location.origin || a.target === '_blank') return
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
e.preventDefault()
history.pushState(null, '', a.href)
route()
})
// Handle back/forward
window.addEventListener('popstate', route)
// Initial route from URL
route()
}
function route() {
setMobileSidebar(false)
const path = location.pathname
if (path.startsWith('/app/')) {
const rest = decodeURIComponent(path.slice(5))
const slashIdx = rest.indexOf('/')
const name = slashIdx === -1 ? rest : rest.slice(0, slashIdx)
const tab = slashIdx === -1 ? 'overview' : rest.slice(slashIdx + 1)
setSelectedApp(name)
setSelectedTab(name, tab)
setCurrentView('dashboard')
} else if (path === '/settings') {
setSelectedApp(null)
setCurrentView('settings')
} else if (path === '/logs') {
setSelectedApp(null)
setDashboardTab('logs')
setCurrentView('dashboard')
} else if (path === '/metrics') {
setSelectedApp(null)
setDashboardTab('metrics')
setCurrentView('dashboard')
} else {
setSelectedApp(null)
setDashboardTab('urls')
setCurrentView('dashboard')
}
_render()
}

View File

@ -1,43 +1,37 @@
import type { App } from '../shared/types'
export type DashboardTab = 'urls' | 'logs' | 'metrics'
// UI state (survives re-renders)
export let currentView: 'dashboard' | 'settings' = 'dashboard'
export let isNarrow: boolean = window.matchMedia('(max-width: 768px)').matches
export let selectedApp: string | null = null
export let selectedApp: string | null = localStorage.getItem('selectedApp')
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
export let dashboardTab: DashboardTab = 'urls'
export let mobileSidebar: boolean = false
export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSection') as 'apps' | 'tools') || 'apps'
// Server state (from SSE)
export let apps: App[] = []
export let setupMode: boolean = false
// Tab state
export let appTabs: Record<string, string> = {}
export let appTabs: Record<string, string> = JSON.parse(localStorage.getItem('appTabs') || '{}')
// State setters
export function setDashboardTab(tab: DashboardTab) {
dashboardTab = tab
}
export function setCurrentView(view: 'dashboard' | 'settings') {
currentView = view
}
export function setSelectedApp(name: string | null) {
selectedApp = name
if (name) {
localStorage.setItem('selectedApp', name)
} else {
localStorage.removeItem('selectedApp')
}
}
export function setIsNarrow(narrow: boolean) {
isNarrow = narrow
}
export function setMobileSidebar(open: boolean) {
mobileSidebar = open
}
export function setSidebarCollapsed(collapsed: boolean) {
sidebarCollapsed = collapsed
localStorage.setItem('sidebarCollapsed', String(collapsed))
@ -52,10 +46,15 @@ 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'
export function setSelectedTab(appName: string | null, tab: string) {
if (!appName) return
appTabs[appName] = tab
localStorage.setItem('appTabs', JSON.stringify(appTabs))
}

View File

@ -65,6 +65,7 @@ export const GaugeValue = define('GaugeValue', {
// Unified Logs Section
export const LogsSection = define('LogsSection', {
width: '100%',
maxWidth: 800,
flex: 1,
minHeight: 0,
display: 'flex',
@ -201,86 +202,3 @@ export const LogStatus = define('LogStatus', {
warning: { color: '#f59e0b' },
},
})
// App Tiles Grid
export const TileGrid = define('TileGrid', {
width: '100%',
maxWidth: 900,
margin: '0 auto',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: 20,
variants: {
narrow: { gridTemplateColumns: '1fr' },
},
})
export const Tile = define('Tile', {
base: 'a',
position: 'relative',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 8,
padding: '28px 20px 24px',
background: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
textDecoration: 'none',
cursor: 'pointer',
selectors: {
'&:hover': {
background: theme('colors-bgHover'),
borderColor: theme('colors-textFaint'),
},
},
variants: {
narrow: {
flexDirection: 'row',
alignItems: 'center',
padding: '16px 20px',
gap: 16,
},
},
})
export const TileIcon = define('TileIcon', {
fontSize: 48,
lineHeight: 1,
userSelect: 'none',
})
export const TileName = define('TileName', {
fontSize: 15,
fontWeight: 600,
color: theme('colors-text'),
textAlign: 'center',
})
export const TilePort = define('TilePort', {
fontFamily: theme('fonts-mono'),
fontSize: 13,
color: theme('colors-textFaint'),
})
export const TileStatus = define('TileStatus', {
position: 'absolute',
top: 8,
right: 8,
width: 2,
height: 2,
borderRadius: '50%',
cursor: 'pointer',
padding: 4,
backgroundClip: 'content-box',
variants: {
state: {
error: { background: theme('colors-statusInvalid') },
invalid: { background: theme('colors-statusInvalid') },
stopped: { background: theme('colors-statusStopped') },
starting: { background: theme('colors-statusStarting') },
running: { background: theme('colors-statusRunning') },
stopping: { background: theme('colors-statusStarting') },
},
},
})

View File

@ -15,12 +15,6 @@ export {
LogStatus,
LogText,
LogTimestamp,
Tile,
TileGrid,
TileIcon,
TileName,
TilePort,
TileStatus,
VitalCard,
VitalLabel,
VitalsSection,
@ -29,6 +23,7 @@ export { Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel,
export {
AppItem,
AppList,
AppSelectorChevron,
ClickableAppName,
DashboardContainer,
DashboardHeader,
@ -73,3 +68,16 @@ export {
TabBar,
TabContent,
} from './misc'
export {
ErrorBox,
NetworkItem,
NetworkListWrap,
NetworkMeta,
NetworkName,
SignalBarSegment,
SignalBarsWrap,
Spinner,
SpinnerWrap,
SuccessCheck,
WifiColumn,
} from './wifi'

View File

@ -29,10 +29,7 @@ export const Logo = define('Logo', {
})
export const LogoLink = define('LogoLink', {
base: 'a',
cursor: 'pointer',
color: 'inherit',
textDecoration: 'none',
borderRadius: theme('radius-md'),
padding: '4px 8px',
margin: '-4px -8px',
@ -106,7 +103,6 @@ export const SectionTab = define('SectionTab', {
background: theme('colors-bgSelected'),
color: theme('colors-text'),
},
large: { fontSize: 14, padding: '8px 12px' },
},
})
@ -116,7 +112,6 @@ export const AppList = define('AppList', {
})
export const AppItem = define('AppItem', {
base: 'a',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
@ -130,7 +125,6 @@ export const AppItem = define('AppItem', {
'&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') },
},
variants: {
large: { fontSize: 18, padding: '12px 16px', gap: 12 },
selected: { background: theme('colors-bgSelected'), color: theme('colors-text'), fontWeight: 500 },
},
})
@ -172,6 +166,20 @@ export const MainTitle = define('MainTitle', {
margin: 0,
})
export const AppSelectorChevron = define('AppSelectorChevron', {
base: 'button',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px 8px',
marginLeft: 4,
fontSize: 14,
color: theme('colors-textMuted'),
selectors: {
'&:hover': { color: theme('colors-text') },
},
})
export const ClickableAppName = define('ClickableAppName', {
cursor: 'pointer',
borderRadius: theme('radius-md'),
@ -225,7 +233,6 @@ export const DashboardContainer = define('DashboardContainer', {
export const DashboardHeader = define('DashboardHeader', {
textAlign: 'center',
width: '100%',
})
export const DashboardTitle = define('DashboardTitle', {

View File

@ -127,12 +127,6 @@ export const TabBar = define('TabBar', {
display: 'flex',
gap: 24,
marginBottom: 20,
variants: {
centered: {
justifyContent: 'center',
marginBottom: 0,
},
},
})
export const Tab = define('Tab', {
@ -165,7 +159,6 @@ export const TabContent = define('TabContent', {
display: 'flex',
flexDirection: 'column',
flex: 1,
width: '100%',
}
}
})

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,4 +0,0 @@
import { hostname } from 'os'
export const HOSTNAME = hostname()
export const LOCAL_HOST = `${HOSTNAME}.local`

View File

@ -8,11 +8,7 @@ const router = Hype.router()
// individual events so apps can react to specific lifecycle changes.
router.sse('/stream', (send) => {
const unsub = onEvent(event => send(event))
const heartbeat = setInterval(() => send('', 'ping'), 60_000)
return () => {
clearInterval(heartbeat)
unsub()
}
return unsub
})
export default router

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,7 +4,7 @@ import type { Subprocess } from 'bun'
import { DEFAULT_EMOJI } from '@types'
import { buildAppUrl, toSubdomain } from '@urls'
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'fs'
import { LOCAL_HOST } from '%config'
import { hostname } from 'os'
import { join, resolve } from 'path'
import { loadAppEnv } from '../tools/env'
import { publishApp, unpublishAll, unpublishApp } from './mdns'
@ -16,7 +16,7 @@ export type { AppState } from '@types'
export const APPS_DIR = process.env.APPS_DIR ?? resolve(join(process.env.DATA_DIR ?? '.', 'apps'))
export const TOES_DIR = process.env.TOES_DIR ?? join(process.env.DATA_DIR ?? '.', 'toes')
const defaultHost = process.env.NODE_ENV === 'production' ? LOCAL_HOST : 'localhost'
const defaultHost = process.env.NODE_ENV === 'production' ? `${hostname()}.local` : 'localhost'
export const TOES_URL = process.env.TOES_URL ?? `http://${defaultHost}:${process.env.PORT || 3000}`
const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3

View File

@ -4,10 +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 { Shell } from './shell'
import { initWifi, isSetupMode } from './wifi'
import type { Server } from 'bun'
import type { WsData } from './proxy'
@ -17,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 => {
@ -90,7 +92,7 @@ async function buildBinary(name: string): Promise<boolean> {
return promise
}
// Install script: curl -fsSL http://<hostname>.local/install | bash
// Install script: curl -fsSL http://toes.local/install | bash
app.get('/install', c => {
if (!TOES_URL) return c.text('TOES_URL is not configured', 500)
const script = INSTALL_SCRIPT.replace('__TOES_URL__', TOES_URL)
@ -114,14 +116,20 @@ app.get('/dist/:file', async c => {
})
})
// SPA routes — serve the shell for all client-side paths
app.get('/app/:name/:tab', c => c.html(<Shell />))
app.get('/app/:name', c => c.html(<Shell />))
app.get('/logs', c => c.html(<Shell />))
app.get('/metrics', c => c.html(<Shell />))
app.get('/settings', c => c.html(<Shell />))
// 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
@ -130,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,11 +1,11 @@
import type { Subprocess } from 'bun'
import { toSubdomain } from '@urls'
import { LOCAL_HOST } from '%config'
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 {
@ -25,7 +25,8 @@ export function cleanupStalePublishers() {
if (!isEnabled) return
try {
const result = Bun.spawnSync(['pkill', '-f', `avahi-publish.*${LOCAL_HOST}`])
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')
}
@ -42,22 +43,22 @@ export function publishApp(name: string) {
return
}
const host = `${toSubdomain(name)}.${LOCAL_HOST}`
const hostname = `${toSubdomain(name)}.${HOST_DOMAIN}`
try {
const proc = Bun.spawn(['avahi-publish', '-a', host, '-R', ip], {
const proc = Bun.spawn(['avahi-publish', '-a', hostname, '-R', ip], {
stdout: 'ignore',
stderr: 'ignore',
})
_publishers.set(name, proc)
hostLog(`mDNS: published ${host} -> ${ip}`)
hostLog(`mDNS: published ${hostname} -> ${ip}`)
proc.exited.then(() => {
_publishers.delete(name)
})
} catch {
hostLog(`mDNS: failed to publish ${host}`)
hostLog(`mDNS: failed to publish ${hostname}`)
}
}
@ -69,7 +70,7 @@ export function unpublishApp(name: string) {
proc.kill()
_publishers.delete(name)
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${LOCAL_HOST}`)
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${HOST_DOMAIN}`)
}
export function unpublishAll() {
@ -77,7 +78,7 @@ export function unpublishAll() {
for (const [name, proc] of _publishers) {
proc.kill()
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${LOCAL_HOST}`)
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${HOST_DOMAIN}`)
}
_publishers.clear()
}

View File

@ -146,19 +146,18 @@ export function renameTunnelConfig(oldName: string, newName: string) {
saveConfig(config)
}
function cancelReconnect(appName: string, resetAttempts = true) {
function cancelReconnect(appName: string) {
const timer = _reconnectTimers.get(appName)
if (timer) {
clearTimeout(timer)
_reconnectTimers.delete(appName)
}
if (resetAttempts) _reconnectAttempts.delete(appName)
_reconnectAttempts.delete(appName)
}
function openTunnel(appName: string, port: number, subdomain?: string, isReconnect = false) {
function openTunnel(appName: string, port: number, subdomain?: string) {
// Cancel any pending reconnect timer to prevent duplicate loops
// but preserve attempts counter during reconnection so backoff works
cancelReconnect(appName, !isReconnect)
cancelReconnect(appName)
// Close existing tunnel if any
const existing = _tunnels.get(appName)
@ -233,7 +232,7 @@ function openTunnel(appName: string, port: number, subdomain?: string, isReconne
const config = loadConfig()
if (!config[appName]) return
hostLog(`Tunnel reconnecting: ${appName}`)
openTunnel(appName, port, config[appName]?.subdomain, true)
openTunnel(appName, port, config[appName]?.subdomain)
}, delay)
_reconnectTimers.set(appName, timer)

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

View File

@ -35,10 +35,8 @@ function ensureConnection() {
buf = lines.pop()!
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const payload = line.slice(6)
if (!payload) continue
try {
const event: ToesEvent = JSON.parse(payload)
const event: ToesEvent = JSON.parse(line.slice(6))
_listeners.forEach(l => {
if (l.types.includes(event.type)) l.callback(event)
})