Compare commits

..

8 Commits

14 changed files with 157 additions and 88 deletions

View File

@ -14,7 +14,7 @@ git push origin main
ssh "$HOST" DEST="$DEST" APPS_DIR="$APPS_DIR" bash <<'SCRIPT' ssh "$HOST" DEST="$DEST" APPS_DIR="$APPS_DIR" bash <<'SCRIPT'
set -e set -e
cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && bun run build cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && rm -rf dist && bun run build
echo "=> Syncing default apps..." echo "=> Syncing default apps..."
for app_dir in "$DEST"/apps/*/; do for app_dir in "$DEST"/apps/*/; do

View File

@ -19,6 +19,7 @@ echo ">> Updating system libraries"
quiet sudo apt-get update quiet sudo apt-get update
quiet sudo apt-get install -y libcap2-bin quiet sudo apt-get install -y libcap2-bin
quiet sudo apt-get install -y avahi-utils quiet sudo apt-get install -y avahi-utils
quiet sudo apt-get install -y dnsmasq
quiet sudo apt-get install -y fish quiet sudo apt-get install -y fish
echo ">> Setting fish as default shell for toes user" 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 for app in $BUNDLED_APPS; do
if [ -d "apps/$app" ]; then if [ -d "apps/$app" ]; then
echo " Installing $app..." echo " Installing $app..."
# Copy app to ~/apps
cp -r "apps/$app" ~/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) version_dir=$(ls -1 ~/apps/$app | grep -E '^[0-9]{8}-[0-9]{6}$' | sort -r | head -1)
if [ -n "$version_dir" ]; then if [ -n "$version_dir" ]; then
ln -sfn "$version_dir" ~/apps/$app/current ln -sfn "$version_dir" ~/apps/$app/current
# Install dependencies if ! (cd ~/apps/$app/current && bun install --frozen-lockfile) > /dev/null 2>&1; then
(cd ~/apps/$app/current && bun install --frozen-lockfile) > /dev/null 2>&1 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
fi fi
done done
@ -118,7 +121,6 @@ EOF
fi fi
echo ">> Done! Rebooting in 5 seconds..." 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 sleep 5
quiet sudo nohup reboot >/dev/null 2>&1 & sudo reboot
exit 0

View File

@ -13,7 +13,3 @@ no-resolv
# Don't read /etc/hosts # Don't read /etc/hosts
no-hosts no-hosts
# Log queries for debugging
log-queries
log-facility=/tmp/toes-dnsmasq.log

View File

@ -13,7 +13,7 @@ export const getLogDates = (name: string): Promise<string[]> =>
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 getWifiStatus = (): Promise<WifiStatus & { setupMode: boolean, url: string }> =>
fetch('/api/wifi/status').then(r => r.json()) fetch('/api/wifi/status').then(r => r.json())
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' }) export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })

View File

@ -33,7 +33,7 @@ import {
import { theme } from '../themes' import { theme } from '../themes'
import type { WifiNetwork } from '../../shared/types' import type { WifiNetwork } from '../../shared/types'
type WifiStep = 'status' | 'scanning' | 'networks' | 'password' | 'connecting' | 'success' | 'error' type WifiStep = 'status' | 'scanning' | 'networks' | 'password' | 'connecting' | 'success'
function signalBars(signal: number) { function signalBars(signal: number) {
const level = signal > 75 ? 4 : signal > 50 ? 3 : signal > 25 ? 2 : 1 const level = signal > 75 ? 4 : signal > 50 ? 3 : signal > 25 ? 2 : 1
@ -85,12 +85,14 @@ export function SettingsPage({ render }: { render: () => void }) {
const [error, setError] = useState('') const [error, setError] = useState('')
const [successSsid, setSuccessSsid] = useState('') const [successSsid, setSuccessSsid] = useState('')
const [successIp, setSuccessIp] = useState('') const [successIp, setSuccessIp] = useState('')
const [serverUrl, setServerUrl] = useState('')
const fetchStatus = () => { const fetchStatus = () => {
getWifiStatus().then(status => { getWifiStatus().then(status => {
setConnected(status.connected) setConnected(status.connected)
setCurrentSsid(status.ssid) setCurrentSsid(status.ssid)
setCurrentIp(status.ip) setCurrentIp(status.ip)
if (status.url) setServerUrl(status.url)
}).catch(() => {}) }).catch(() => {})
} }
@ -207,7 +209,6 @@ export function SettingsPage({ render }: { render: () => void }) {
<SpinnerWrap> <SpinnerWrap>
<Spinner /> <Spinner />
<p style={{ color: theme('colors-textMuted') }}>Scanning for networks...</p> <p style={{ color: theme('colors-textMuted') }}>Scanning for networks...</p>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</SpinnerWrap> </SpinnerWrap>
)} )}
@ -255,7 +256,6 @@ export function SettingsPage({ render }: { render: () => void }) {
<SpinnerWrap> <SpinnerWrap>
<Spinner /> <Spinner />
<p style={{ color: theme('colors-textMuted') }}>Connecting to <strong>{selectedNetwork?.ssid}</strong>...</p> <p style={{ color: theme('colors-textMuted') }}>Connecting to <strong>{selectedNetwork?.ssid}</strong>...</p>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</SpinnerWrap> </SpinnerWrap>
)} )}
@ -278,10 +278,10 @@ export function SettingsPage({ render }: { render: () => void }) {
}}> }}>
<p style={{ marginBottom: 8 }}>Reconnect your device to <strong>{successSsid}</strong> and visit:</p> <p style={{ marginBottom: 8 }}>Reconnect your device to <strong>{successSsid}</strong> and visit:</p>
<a <a
href="http://toes.local" href={serverUrl}
style={{ color: theme('colors-statusRunning'), fontSize: 18, fontWeight: 600, textDecoration: 'none' }} style={{ color: theme('colors-statusRunning'), fontSize: 18, fontWeight: 600, textDecoration: 'none' }}
> >
http://toes.local {serverUrl}
</a> </a>
</div> </div>
) : ( ) : (

View File

@ -51,21 +51,9 @@ getWifiStatus().then(status => {
} }
}).catch(() => {}) }).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 // 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
setApps(JSON.parse(e.data)) setApps(JSON.parse(e.data))
if (selectedApp && !apps.some(a => a.name === selectedApp)) { if (selectedApp && !apps.some(a => a.name === selectedApp)) {

View File

@ -95,3 +95,10 @@ export const WifiColumn = define('WifiColumn', {
gap: 16, gap: 16,
maxWidth: 400, 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

@ -1,7 +1,7 @@
import { allApps, APPS_DIR, onChange } from '$apps' import { allApps, APPS_DIR, onChange } 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, freemem, platform, totalmem } from 'os'
import { join } from 'path' import { join } from 'path'
import { readFileSync, statfsSync } from 'fs' import { readFileSync, statfsSync } from 'fs'
@ -63,37 +63,23 @@ function getCpuUsage(): number {
function getMemoryUsage(): { used: number, total: number, percent: number } { function getMemoryUsage(): { used: number, total: number, percent: number } {
const total = totalmem() const total = totalmem()
const apps = allApps().filter(a => a.proc?.pid)
let used = 0
if (platform() === 'linux') { if (platform() === 'linux') {
for (const app of apps) {
try { try {
const status = readFileSync(`/proc/${app.proc!.pid}/status`, 'utf-8') const meminfo = readFileSync('/proc/meminfo', 'utf-8')
const match = status.match(/VmRSS:\s+(\d+)/) const available = meminfo.match(/MemAvailable:\s+(\d+)/)
if (match) used += parseInt(match[1]!, 10) * 1024 if (available) {
} catch {} const availableBytes = parseInt(available[1]!, 10) * 1024
} const used = total - availableBytes
} else { return { used, total, percent: Math.round((used / total) * 100) }
// macOS: batch ps call for all pids
const pids = apps.map(a => a.proc!.pid).join(',')
if (pids) {
try {
const result = Bun.spawnSync(['ps', '-o', 'rss=', '-p', pids])
const output = result.stdout.toString()
for (const line of output.split('\n')) {
const kb = parseInt(line.trim(), 10)
if (kb) used += kb * 1024
} }
} catch {} } catch {}
} }
}
return { // macOS fallback
used, const free = freemem()
total, const used = total - free
percent: used > 0 ? Math.max(1, Math.round((used / total) * 100)) : 0, return { used, total, percent: Math.round((used / total) * 100) }
}
} }
function getDiskUsage(): { used: number, total: number, percent: number } { function getDiskUsage(): { used: number, total: number, percent: number } {

View File

@ -1,12 +1,13 @@
import { TOES_URL } from '$apps'
import { Hype } from '@because/hype' import { Hype } from '@because/hype'
import { connectToWifi, getWifiStatus, isSetupMode, onSetupModeChange, scanNetworks } from '../wifi' import { connectToWifi, getWifiStatus, isSetupMode, scanNetworks } from '../wifi'
const router = Hype.router() const router = Hype.router()
// GET /api/wifi/status - current WiFi state + setup mode flag // GET /api/wifi/status - current WiFi state + setup mode flag
router.get('/status', async c => { router.get('/status', async c => {
const status = await getWifiStatus() const status = await getWifiStatus()
return c.json({ ...status, setupMode: isSetupMode() }) return c.json({ ...status, setupMode: isSetupMode(), url: TOES_URL })
}) })
// GET /api/wifi/scan - list available networks // GET /api/wifi/scan - list available networks
@ -27,11 +28,4 @@ router.post('/connect', async c => {
return c.json(result) 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 export default router

View File

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

View File

@ -16,8 +16,8 @@ async function dnsStart(): Promise<void> {
async function dnsStop(): Promise<void> { async function dnsStop(): Promise<void> {
if (await Bun.file(DNSMASQ_PID).exists()) { if (await Bun.file(DNSMASQ_PID).exists()) {
const pid = (await Bun.file(DNSMASQ_PID).text()).trim() const pid = (await Bun.file(DNSMASQ_PID).text()).trim()
await sudo(['kill', pid]).catch(() => {}) await sudo(['kill', pid]).catch(e => hostLog(`dnsStop: failed to kill pid ${pid}: ${e}`))
await sudo(['rm', '-f', DNSMASQ_PID]).catch(() => {}) 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(() => {}) await sudo(['pkill', '-f', 'dnsmasq.*wifi-captive.conf']).catch(() => {})
} }
@ -64,7 +64,11 @@ export async function connectToNetwork(ssid: string, password?: string): Promise
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
// Connection failed — restart hotspot so user can retry // Connection failed — restart hotspot so user can retry
try {
await startHotspot() 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' const error = result.stderr || result.stdout || 'Connection failed'
return { ok: false, error } return { ok: false, error }
} }

View File

@ -5,26 +5,20 @@ import type { ConnectResult, WifiNetwork, WifiStatus } from '@types'
export type { ConnectResult, WifiNetwork, WifiStatus } from '@types' export type { ConnectResult, WifiNetwork, WifiStatus } from '@types'
let _setupMode = false let _setupMode = false
const _listeners = new Set<(setupMode: boolean) => void>()
export const isSetupMode = () => _setupMode export const isSetupMode = () => _setupMode
export const onSetupModeChange = (cb: (setupMode: boolean) => void) => {
_listeners.add(cb)
return () => _listeners.delete(cb)
}
function setSetupMode(mode: boolean) { function setSetupMode(mode: boolean) {
if (_setupMode === mode) return if (_setupMode === mode) return
_setupMode = mode _setupMode = mode
hostLog(mode ? 'Entering WiFi setup mode' : 'Exiting WiFi setup mode') hostLog(mode ? 'Entering WiFi setup mode' : 'Exiting WiFi setup mode')
for (const cb of _listeners) cb(mode)
} }
export async function getWifiStatus(): Promise<WifiStatus> { export async function getWifiStatus(): Promise<WifiStatus> {
try { try {
return await nmcli.status() return await nmcli.status()
} catch { } catch (e) {
hostLog(`WiFi status check failed: ${e instanceof Error ? e.message : String(e)}`)
return { connected: false, ssid: '', ip: '' } return { connected: false, ssid: '', ip: '' }
} }
} }
@ -32,7 +26,8 @@ export async function getWifiStatus(): Promise<WifiStatus> {
export async function scanNetworks(): Promise<WifiNetwork[]> { export async function scanNetworks(): Promise<WifiNetwork[]> {
try { try {
return await nmcli.scanNetworks() return await nmcli.scanNetworks()
} catch { } catch (e) {
hostLog(`WiFi scan failed: ${e instanceof Error ? e.message : String(e)}`)
return [] return []
} }
} }

View File

@ -1,11 +1,12 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { mkdirSync, rmSync, writeFileSync } from 'fs' import { mkdirSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path' import { join } from 'path'
import { loadGitignore } from './gitignore' import { _resetGlobalCache, loadGitignore } from './gitignore'
const TEST_DIR = '/tmp/toes-gitignore-test' const TEST_DIR = '/tmp/toes-gitignore-test'
beforeEach(() => { beforeEach(() => {
_resetGlobalCache()
mkdirSync(TEST_DIR, { recursive: true }) mkdirSync(TEST_DIR, { recursive: true })
}) })
@ -171,4 +172,64 @@ describe('loadGitignore', () => {
expect(checker.shouldExclude('build/output')).toBe(true) expect(checker.shouldExclude('build/output')).toBe(true)
}) })
}) })
describe('global gitignore', () => {
const GLOBAL_IGNORE = '/tmp/toes-global-gitignore-test'
beforeEach(() => {
mkdirSync(GLOBAL_IGNORE, { recursive: true })
})
afterEach(() => {
rmSync(GLOBAL_IGNORE, { recursive: true, force: true })
})
test('should include patterns from global gitignore', () => {
const globalIgnorePath = join(GLOBAL_IGNORE, 'ignore')
writeFileSync(globalIgnorePath, '*.bak\n.idea/')
const origSpawnSync = require('child_process').spawnSync
const spawnSyncMock = mock((cmd: string, args: string[], opts: any) => {
if (cmd === 'git' && args.includes('core.excludesFile')) {
return { status: 0, stdout: globalIgnorePath + '\n', stderr: '' }
}
return origSpawnSync(cmd, args, opts)
})
mock.module('child_process', () => ({ spawnSync: spawnSyncMock }))
_resetGlobalCache()
const checker = loadGitignore(TEST_DIR)
expect(checker.shouldExclude('backup.bak')).toBe(true)
expect(checker.shouldExclude('src/old.bak')).toBe(true)
expect(checker.shouldExclude('.idea')).toBe(true)
expect(checker.shouldExclude('src/index.ts')).toBe(false)
mock.module('child_process', () => ({ spawnSync: origSpawnSync }))
})
test('should merge global and local patterns', () => {
const globalIgnorePath = join(GLOBAL_IGNORE, 'ignore')
writeFileSync(globalIgnorePath, '*.bak')
writeFileSync(join(TEST_DIR, '.gitignore'), '*.log')
const origSpawnSync = require('child_process').spawnSync
const spawnSyncMock = mock((cmd: string, args: string[], opts: any) => {
if (cmd === 'git' && args.includes('core.excludesFile')) {
return { status: 0, stdout: globalIgnorePath + '\n', stderr: '' }
}
return origSpawnSync(cmd, args, opts)
})
mock.module('child_process', () => ({ spawnSync: spawnSyncMock }))
_resetGlobalCache()
const checker = loadGitignore(TEST_DIR)
expect(checker.shouldExclude('backup.bak')).toBe(true)
expect(checker.shouldExclude('debug.log')).toBe(true)
expect(checker.shouldExclude('src/index.ts')).toBe(false)
mock.module('child_process', () => ({ spawnSync: origSpawnSync }))
})
})
}) })

View File

@ -1,8 +1,11 @@
import { spawnSync } from 'child_process'
import { existsSync, readFileSync } from 'fs' import { existsSync, readFileSync } from 'fs'
import { homedir } from 'os'
import { join } from 'path' import { join } from 'path'
const ALWAYS_EXCLUDE = [ const ALWAYS_EXCLUDE = [
'node_modules', 'node_modules',
'.sandlot',
'.DS_Store', '.DS_Store',
'.git', '.git',
'.toes', '.toes',
@ -24,15 +27,46 @@ export interface GitignoreChecker {
export function loadGitignore(appPath: string): GitignoreChecker { export function loadGitignore(appPath: string): GitignoreChecker {
const gitignorePath = join(appPath, '.gitignore') const gitignorePath = join(appPath, '.gitignore')
const globalPatterns = loadGlobalPatterns()
const patterns = existsSync(gitignorePath) const patterns = existsSync(gitignorePath)
? [...ALWAYS_EXCLUDE, ...parseGitignore(readFileSync(gitignorePath, 'utf-8'))] ? [...ALWAYS_EXCLUDE, ...globalPatterns, ...parseGitignore(readFileSync(gitignorePath, 'utf-8'))]
: DEFAULT_PATTERNS : [...DEFAULT_PATTERNS, ...globalPatterns]
return { return {
shouldExclude: (path: string) => matchesPattern(path, patterns), shouldExclude: (path: string) => matchesPattern(path, patterns),
} }
} }
export const _resetGlobalCache = () => {
cachedGlobalPatterns = undefined
}
let cachedGlobalPatterns: string[] | undefined
const expandHome = (filepath: string) =>
filepath.startsWith('~/') ? join(homedir(), filepath.slice(2)) : filepath
function getGlobalExcludesPath(): string | null {
const result = spawnSync('git', ['config', '--global', 'core.excludesFile'], {
encoding: 'utf-8',
})
if (result.status === 0 && result.stdout.trim()) {
return expandHome(result.stdout.trim())
}
const xdgHome = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
const defaultPath = join(xdgHome, 'git', 'ignore')
if (existsSync(defaultPath)) return defaultPath
return null
}
function loadGlobalPatterns(): string[] {
if (cachedGlobalPatterns !== undefined) return cachedGlobalPatterns
const path = getGlobalExcludesPath()
cachedGlobalPatterns =
path && existsSync(path) ? parseGitignore(readFileSync(path, 'utf-8')) : []
return cachedGlobalPatterns
}
function parseGitignore(content: string): string[] { function parseGitignore(content: string): string[] {
return content return content
.split('\n') .split('\n')