Compare commits
2 Commits
6b6d29ef38
...
95c384d3ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 95c384d3ad | |||
| d892af62ff |
|
|
@ -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 && rm -rf dist && bun run build
|
cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && 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
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ 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"
|
||||||
|
|
@ -63,16 +62,14 @@ 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
|
||||||
if ! (cd ~/apps/$app/current && bun install --frozen-lockfile) > /dev/null 2>&1; then
|
# Install dependencies
|
||||||
echo " WARNING: bun install failed for $app, trying without lockfile..."
|
(cd ~/apps/$app/current && bun install --frozen-lockfile) > /dev/null 2>&1
|
||||||
(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
|
||||||
|
|
@ -121,6 +118,7 @@ EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ">> Done! Rebooting in 5 seconds..."
|
echo ">> Done! Rebooting in 5 seconds..."
|
||||||
systemctl status "$SERVICE_NAME" --no-pager -l || true
|
quiet systemctl status "$SERVICE_NAME" --no-pager -l || true
|
||||||
sleep 5
|
sleep 5
|
||||||
sudo reboot
|
quiet sudo nohup reboot >/dev/null 2>&1 &
|
||||||
|
exit 0
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,7 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -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, url: string }> =>
|
export const getWifiStatus = (): Promise<WifiStatus & { setupMode: boolean }> =>
|
||||||
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' })
|
||||||
|
|
|
||||||
|
|
@ -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'
|
type WifiStep = 'status' | 'scanning' | 'networks' | 'password' | 'connecting' | 'success' | 'error'
|
||||||
|
|
||||||
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,14 +85,12 @@ 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(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,6 +207,7 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -256,6 +255,7 @@ 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={serverUrl}
|
href="http://toes.local"
|
||||||
style={{ color: theme('colors-statusRunning'), fontSize: 18, fontWeight: 600, textDecoration: 'none' }}
|
style={{ color: theme('colors-statusRunning'), fontSize: 18, fontWeight: 600, textDecoration: 'none' }}
|
||||||
>
|
>
|
||||||
{serverUrl}
|
http://toes.local
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,21 @@ 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)) {
|
||||||
|
|
|
||||||
|
|
@ -95,10 +95,3 @@ 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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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, freemem, platform, totalmem } from 'os'
|
import { cpus, platform, totalmem } from 'os'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { readFileSync, statfsSync } from 'fs'
|
import { readFileSync, statfsSync } from 'fs'
|
||||||
|
|
||||||
|
|
@ -63,23 +63,37 @@ 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') {
|
||||||
try {
|
for (const app of apps) {
|
||||||
const meminfo = readFileSync('/proc/meminfo', 'utf-8')
|
try {
|
||||||
const available = meminfo.match(/MemAvailable:\s+(\d+)/)
|
const status = readFileSync(`/proc/${app.proc!.pid}/status`, 'utf-8')
|
||||||
if (available) {
|
const match = status.match(/VmRSS:\s+(\d+)/)
|
||||||
const availableBytes = parseInt(available[1]!, 10) * 1024
|
if (match) used += parseInt(match[1]!, 10) * 1024
|
||||||
const used = total - availableBytes
|
} catch {}
|
||||||
return { used, total, percent: Math.round((used / total) * 100) }
|
}
|
||||||
}
|
} else {
|
||||||
} catch {}
|
// 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 {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// macOS fallback
|
return {
|
||||||
const free = freemem()
|
used,
|
||||||
const used = total - free
|
total,
|
||||||
return { used, total, percent: Math.round((used / total) * 100) }
|
percent: used > 0 ? Math.max(1, Math.round((used / total) * 100)) : 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDiskUsage(): { used: number, total: number, percent: number } {
|
function getDiskUsage(): { used: number, total: number, percent: number } {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import { TOES_URL } from '$apps'
|
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { connectToWifi, getWifiStatus, isSetupMode, scanNetworks } from '../wifi'
|
import { connectToWifi, getWifiStatus, isSetupMode, onSetupModeChange, 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(), url: TOES_URL })
|
return c.json({ ...status, setupMode: isSetupMode() })
|
||||||
})
|
})
|
||||||
|
|
||||||
// GET /api/wifi/scan - list available networks
|
// GET /api/wifi/scan - list available networks
|
||||||
|
|
@ -28,4 +27,11 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import type { Subprocess } from 'bun'
|
import type { Subprocess } from 'bun'
|
||||||
import { toSubdomain } from '@urls'
|
import { toSubdomain } from '@urls'
|
||||||
import { hostname, networkInterfaces } from 'os'
|
import { 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 {
|
||||||
|
|
@ -25,8 +24,7 @@ export function cleanupStalePublishers() {
|
||||||
if (!isEnabled) return
|
if (!isEnabled) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pattern = HOST_DOMAIN.replace(/\./g, '\\.')
|
const result = Bun.spawnSync(['pkill', '-f', 'avahi-publish.*toes\\.local'])
|
||||||
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')
|
||||||
}
|
}
|
||||||
|
|
@ -43,7 +41,7 @@ export function publishApp(name: string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostname = `${toSubdomain(name)}.${HOST_DOMAIN}`
|
const hostname = `${toSubdomain(name)}.toes.local`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(['avahi-publish', '-a', hostname, '-R', ip], {
|
const proc = Bun.spawn(['avahi-publish', '-a', hostname, '-R', ip], {
|
||||||
|
|
@ -70,7 +68,7 @@ export function unpublishApp(name: string) {
|
||||||
|
|
||||||
proc.kill()
|
proc.kill()
|
||||||
_publishers.delete(name)
|
_publishers.delete(name)
|
||||||
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${HOST_DOMAIN}`)
|
hostLog(`mDNS: unpublished ${toSubdomain(name)}.toes.local`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unpublishAll() {
|
export function unpublishAll() {
|
||||||
|
|
@ -78,7 +76,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)}.${HOST_DOMAIN}`)
|
hostLog(`mDNS: unpublished ${toSubdomain(name)}.toes.local`)
|
||||||
}
|
}
|
||||||
_publishers.clear()
|
_publishers.clear()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(e => hostLog(`dnsStop: failed to kill pid ${pid}: ${e}`))
|
await sudo(['kill', pid]).catch(() => {})
|
||||||
await sudo(['rm', '-f', DNSMASQ_PID]).catch(e => hostLog(`dnsStop: failed to remove pid file: ${e}`))
|
await sudo(['rm', '-f', DNSMASQ_PID]).catch(() => {})
|
||||||
}
|
}
|
||||||
await sudo(['pkill', '-f', 'dnsmasq.*wifi-captive.conf']).catch(() => {})
|
await sudo(['pkill', '-f', 'dnsmasq.*wifi-captive.conf']).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
@ -64,11 +64,7 @@ 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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,26 @@ 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 (e) {
|
} catch {
|
||||||
hostLog(`WiFi status check failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
||||||
return { connected: false, ssid: '', ip: '' }
|
return { connected: false, ssid: '', ip: '' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -26,8 +32,7 @@ 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 (e) {
|
} catch {
|
||||||
hostLog(`WiFi scan failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
import { afterEach, beforeEach, describe, expect, 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 { _resetGlobalCache, loadGitignore } from './gitignore'
|
import { 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 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -172,64 +171,4 @@ 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 }))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
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',
|
||||||
|
|
@ -27,46 +24,15 @@ 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, ...globalPatterns, ...parseGitignore(readFileSync(gitignorePath, 'utf-8'))]
|
? [...ALWAYS_EXCLUDE, ...parseGitignore(readFileSync(gitignorePath, 'utf-8'))]
|
||||||
: [...DEFAULT_PATTERNS, ...globalPatterns]
|
: DEFAULT_PATTERNS
|
||||||
|
|
||||||
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')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user