From 92d20a7ecbb696dedd554994e1b09b0f0e5f1507 Mon Sep 17 00:00:00 2001 From: ptrinh Date: Sat, 27 Jun 2026 00:05:58 +0700 Subject: [PATCH] feat(network): add WiFi hotspot (access point) settings Adds a WiFi Hotspot feature so an Umbrel with a capable WiFi adapter can broadcast its own network for other devices to join. Built on NetworkManager (same stack as the existing WiFi client support) so no new system packages are required. Backend (umbreld): - system.ts: supportsWifiHotspot/startWifiHotspot/stopWifiHotspot/ getWifiHotspotStatus/restoreWifiHotspot via nmcli AP-mode connection - wifi-hotspot-routes.ts: supported/status/enable/disable tRPC procedures with rollback to the previous config on failure - persisted under settings.wifiHotspot; restored on boot/OTA UI: - Settings > Wi-Fi Hotspot page (enable toggle, SSID, password) with an Advanced section: band, channel, country code, hidden SSID, and an experimental 'bridge to LAN' option for seamless roaming - shown only when the device reports hotspot support Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ui/public/locales/en.json | 22 ++ .../wifi-hotspot-drawer-or-dialog.tsx | 245 ++++++++++++++++++ .../settings/_components/settings-content.tsx | 26 +- packages/ui/src/routes/settings/index.tsx | 2 + .../ui/src/routes/settings/wifi-hotspot.tsx | 15 ++ packages/umbreld/source/index.ts | 14 + .../source/modules/server/trpc/index.ts | 2 + .../umbreld/source/modules/system/system.ts | 178 +++++++++++++ .../modules/system/wifi-hotspot-routes.ts | 55 ++++ 9 files changed, 551 insertions(+), 8 deletions(-) create mode 100644 packages/ui/src/modules/wifi-hotspot/wifi-hotspot-drawer-or-dialog.tsx create mode 100644 packages/ui/src/routes/settings/wifi-hotspot.tsx create mode 100644 packages/umbreld/source/modules/system/wifi-hotspot-routes.ts diff --git a/packages/ui/public/locales/en.json b/packages/ui/public/locales/en.json index 585f73cad..58ac630f4 100644 --- a/packages/ui/public/locales/en.json +++ b/packages/ui/public/locales/en.json @@ -1251,6 +1251,28 @@ "wifi-dangerous-disable-confirmation-title": "Are you sure you want to disable Wi-Fi?", "wifi-description": "Connect your device to a Wi-Fi network", "wifi-description-long": "Your device stays connected to your chosen Wi-Fi, even if the Ethernet cable is removed, and automatically reconnects to Wi-Fi on startup.", + "wifi-hotspot": "Wi-Fi Hotspot", + "wifi-hotspot-description": "Share your connection as a Wi-Fi network", + "wifi-hotspot-description-long": "Turn your Umbrel into a Wi-Fi access point that other devices can connect to.", + "wifi-hotspot-configure": "Configure", + "wifi-hotspot-unsupported": "This device doesn’t have a Wi-Fi adapter that supports running a hotspot.", + "wifi-hotspot-ssid": "Network name (SSID)", + "wifi-hotspot-ssid-placeholder": "My Umbrel Hotspot", + "wifi-hotspot-password-invalid": "Password must be 8–63 characters", + "wifi-hotspot-advanced": "Advanced settings", + "wifi-hotspot-band": "Frequency band", + "wifi-hotspot-band-2ghz": "2.4 GHz", + "wifi-hotspot-band-5ghz": "5 GHz", + "wifi-hotspot-channel": "Channel", + "wifi-hotspot-channel-auto": "Auto", + "wifi-hotspot-country": "Country code", + "wifi-hotspot-hidden": "Hidden network", + "wifi-hotspot-hidden-description": "Don’t broadcast the network name", + "wifi-hotspot-bridge": "Bridge to local network", + "wifi-hotspot-bridge-description": "Put hotspot devices on your main network for seamless roaming", + "wifi-hotspot-bridge-warning": "Experimental: this reconfigures your wired connection and may briefly interrupt connectivity.", + "wifi-hotspot-enable": "Enable hotspot", + "wifi-hotspot-update": "Save changes", "wifi-no-networks-message": "No Wi-Fi networks found", "wifi-searching": "Searching for Wi-Fi networks...", "wifi-unsupported-device-description": "Wi-Fi is not supported on this device. This may be due to a missing or incompatible wireless adapter.", diff --git a/packages/ui/src/modules/wifi-hotspot/wifi-hotspot-drawer-or-dialog.tsx b/packages/ui/src/modules/wifi-hotspot/wifi-hotspot-drawer-or-dialog.tsx new file mode 100644 index 000000000..54ab89e87 --- /dev/null +++ b/packages/ui/src/modules/wifi-hotspot/wifi-hotspot-drawer-or-dialog.tsx @@ -0,0 +1,245 @@ +import {AnimatePresence, motion} from 'motion/react' +import {ReactNode, useEffect, useState} from 'react' +import {useTranslation} from 'react-i18next' +import {TbAlertTriangle, TbChevronDown} from 'react-icons/tb' +import {Drawer as DrawerPrimitive} from 'vaul' + +import {Button} from '@/components/ui/button' +import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog' +import {Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle} from '@/components/ui/drawer' +import {Input, Labeled, PasswordInput} from '@/components/ui/input' +import {Loading} from '@/components/ui/loading' +import {SegmentedControl} from '@/components/ui/segmented-control' +import {Switch} from '@/components/ui/switch' +import {useIsSmallMobile} from '@/hooks/use-is-mobile' +import {cn} from '@/lib/utils' +import {trpcReact} from '@/trpc/trpc' + +export function WifiHotspotDrawerOrDialog(props: React.ComponentProps) { + const isMobile = useIsSmallMobile() + const Wrapper = isMobile ? Drawer : Dialog + return +} + +export function WifiHotspotDrawerOrDialogContent() { + const {t} = useTranslation() + const utils = trpcReact.useUtils() + + const supportedQ = trpcReact.wifiHotspot.supported.useQuery() + const statusQ = trpcReact.wifiHotspot.status.useQuery() + + const invalidate = () => { + utils.wifiHotspot.status.invalidate() + utils.system.getIpAddresses.invalidate() + } + + const enableMut = trpcReact.wifiHotspot.enable.useMutation({onSettled: invalidate}) + const disableMut = trpcReact.wifiHotspot.disable.useMutation({onSettled: invalidate}) + + // Local form state, seeded from the saved config + const [ssid, setSsid] = useState('') + const [password, setPassword] = useState('') + const [band, setBand] = useState<'2.4ghz' | '5ghz'>('2.4ghz') + const [channel, setChannel] = useState('0') + const [countryCode, setCountryCode] = useState('') + const [hidden, setHidden] = useState(false) + const [bridgeToLan, setBridgeToLan] = useState(false) + const [showAdvanced, setShowAdvanced] = useState(false) + + const status = statusQ.data + useEffect(() => { + if (!status) return + setSsid(status.ssid) + setPassword(status.password) + setBand(status.band) + setChannel(String(status.channel ?? 0)) + setCountryCode(status.countryCode ?? '') + setHidden(status.hidden) + setBridgeToLan(status.bridgeToLan) + }, [status]) + + const enabled = status?.enabled ?? false + const isBusy = enableMut.isPending || disableMut.isPending + + const save = () => { + enableMut.mutate({ + ssid, + password, + band, + channel: Number(channel) || 0, + countryCode: countryCode ? countryCode.toUpperCase() : undefined, + hidden, + bridgeToLan, + }) + } + + const toggleEnabled = (next: boolean) => { + if (next) save() + else disableMut.mutate() + } + + const errorMessage = enableMut.error?.message + const passwordValid = password.length >= 8 && password.length <= 63 + const canSave = ssid.trim().length > 0 && passwordValid && !isBusy + + return ( + + ) : undefined + } + > + {supportedQ.isLoading || statusQ.isLoading ? ( + + + + ) : !supportedQ.data ? ( + {t('wifi-hotspot-unsupported')} + ) : ( +
{ + e.preventDefault() + if (canSave) save() + }} + > + + + + + 0 && !passwordValid ? t('wifi-hotspot-password-invalid') : undefined} + /> + + {/* Advanced settings */} + + + + {showAdvanced && ( + +
+ + + + +
+ + + + + setCountryCode(v.toUpperCase())} + /> + +
+ + + + + + + + + + {bridgeToLan && ( +
+ + {t('wifi-hotspot-bridge-warning')} +
+ )} +
+
+ )} +
+ + {errorMessage &&
{errorMessage}
} + + + + )} +
+ ) +} + +function Row({title, description, children}: {title: string; description?: string; children: ReactNode}) { + return ( +
+
+
{title}
+ {description &&
{description}
} +
+ {children} +
+ ) +} + +function DrawerOrDialogContent({header, children}: {header?: ReactNode; children: ReactNode}) { + const {t} = useTranslation() + const isMobile = useIsSmallMobile() + + const Content = isMobile ? DrawerContent : DialogContent + const Header = isMobile ? DrawerHeader : DialogHeader + const Title = isMobile ? DrawerTitle : DialogTitle + const Description = isMobile ? DrawerDescription : DialogDescription + + return ( + +
+
+ {t('wifi-hotspot')} + {t('wifi-hotspot-description-long')} +
+ {header} +
+ {children} +
+ ) +} + +function Message({children}: {children?: React.ReactNode}) { + return ( +
+
{children}
+
+ ) +} diff --git a/packages/ui/src/routes/settings/_components/settings-content.tsx b/packages/ui/src/routes/settings/_components/settings-content.tsx index a91a86389..b5b3b5b9e 100644 --- a/packages/ui/src/routes/settings/_components/settings-content.tsx +++ b/packages/ui/src/routes/settings/_components/settings-content.tsx @@ -53,14 +53,17 @@ export function SettingsContent() { const {isUmbrelPro} = useIsUmbrelPro() const {deviceName} = useIsHomeOrPro() - const [userQ, wifiSupportedQ, is2faEnabledQ, raidStatusQ, devicesQ] = trpcReact.useQueries((t) => [ - t.user.get(), - t.wifi.supported(), - t.user.is2faEnabled(), - // Storage queries only run on Umbrel Pro to avoid unnecessary API calls on other devices - t.hardware.raid.getStatus(undefined, {enabled: isUmbrelPro}), - t.hardware.internalStorage.getDevices(undefined, {enabled: isUmbrelPro}), - ]) + const [userQ, wifiSupportedQ, wifiHotspotSupportedQ, is2faEnabledQ, raidStatusQ, devicesQ] = trpcReact.useQueries( + (t) => [ + t.user.get(), + t.wifi.supported(), + t.wifiHotspot.supported(), + t.user.is2faEnabled(), + // Storage queries only run on Umbrel Pro to avoid unnecessary API calls on other devices + t.hardware.raid.getStatus(undefined, {enabled: isUmbrelPro}), + t.hardware.internalStorage.getDevices(undefined, {enabled: isUmbrelPro}), + ], + ) const {repositories: backupRepositories, isLoadingRepositories: isLoadingBackups} = useBackups() @@ -166,6 +169,13 @@ export function SettingsContent() { navigate('wifi-unsupported')} /> )} + {wifiHotspotSupportedQ.data && ( + + navigate('wifi-hotspot')}> + {t('wifi-hotspot-configure')} + + + )} navigate('2fa')} /> diff --git a/packages/ui/src/routes/settings/index.tsx b/packages/ui/src/routes/settings/index.tsx index 98298bf6a..7ac974a02 100644 --- a/packages/ui/src/routes/settings/index.tsx +++ b/packages/ui/src/routes/settings/index.tsx @@ -53,6 +53,7 @@ const StartMigrationDrawerOrDialog = React.lazy(() => ) const Wifi = React.lazy(() => import('@/routes/settings/wifi')) const WifiUnsupported = React.lazy(() => import('@/routes/settings/wifi-unsupported')) +const WifiHotspot = React.lazy(() => import('@/routes/settings/wifi-hotspot')) const AccountDrawer = React.lazy(() => import('@/routes/settings/mobile/account').then((m) => ({default: m.AccountDrawer})), ) @@ -145,6 +146,7 @@ export function Settings() { {isMobile && } + {/* Backup: mobile drawer (/backups) opens first on mobile to give same options as desktop */} {isMobile && } diff --git a/packages/ui/src/routes/settings/wifi-hotspot.tsx b/packages/ui/src/routes/settings/wifi-hotspot.tsx new file mode 100644 index 000000000..b32c85b05 --- /dev/null +++ b/packages/ui/src/routes/settings/wifi-hotspot.tsx @@ -0,0 +1,15 @@ +import { + WifiHotspotDrawerOrDialog, + WifiHotspotDrawerOrDialogContent, +} from '@/modules/wifi-hotspot/wifi-hotspot-drawer-or-dialog' +import {useSettingsDialogProps} from '@/routes/settings/_components/shared' + +export default function WifiHotspot() { + const dialogProps = useSettingsDialogProps() + + return ( + + + + ) +} diff --git a/packages/umbreld/source/index.ts b/packages/umbreld/source/index.ts index f08af8176..023c59eea 100644 --- a/packages/umbreld/source/index.ts +++ b/packages/umbreld/source/index.ts @@ -25,6 +25,7 @@ import { setupPiCpuGovernor, restoreHostname, restoreWiFi, + restoreWifiHotspot, restoreStaticIp, waitForSystemTime, reboot, @@ -57,6 +58,16 @@ type StoreSchema = { ssid: string password?: string } + wifiHotspot?: { + enabled: boolean + ssid: string + password: string + band?: '2.4ghz' | '5ghz' + channel?: number + countryCode?: string + hidden?: boolean + bridgeToLan?: boolean + } externalDns?: boolean hostname?: string staticIp?: Record< @@ -201,6 +212,9 @@ export default class Umbreld { // Restore WiFi connection after OTA update (non-blocking) restoreWiFi(this) + // Restore WiFi hotspot after boot/OTA update (non-blocking) + restoreWifiHotspot(this) + // Restore static IP settings (non-blocking) restoreStaticIp(this) diff --git a/packages/umbreld/source/modules/server/trpc/index.ts b/packages/umbreld/source/modules/server/trpc/index.ts index e5482336c..8f0c78717 100644 --- a/packages/umbreld/source/modules/server/trpc/index.ts +++ b/packages/umbreld/source/modules/server/trpc/index.ts @@ -8,6 +8,7 @@ import system from '../../system/routes.js' // Temporary name while migrating from the legacy system module. Will be renamed to "system" once migration is complete. import systemNg from '../../system-ng/routes.js' import wifi from '../../system/wifi-routes.js' +import wifiHotspot from '../../system/wifi-hotspot-routes.js' import user from '../../user/routes.js' import {appStore, apps} from '../../apps/routes.js' import widget from '../../widgets/routes.js' @@ -26,6 +27,7 @@ const appRouter = router({ system, systemNg, wifi, + wifiHotspot, user, appStore, apps, diff --git a/packages/umbreld/source/modules/system/system.ts b/packages/umbreld/source/modules/system/system.ts index 04800606d..3e50a5170 100644 --- a/packages/umbreld/source/modules/system/system.ts +++ b/packages/umbreld/source/modules/system/system.ts @@ -575,6 +575,184 @@ export async function restoreWiFi(umbreld: Umbreld): Promise { } } +// --------------------------------------------------------------------------- +// WiFi hotspot (Access Point) +// +// Turns the device's WiFi adapter into an access point so other devices can +// connect to it. Built on NetworkManager (the same tool used for WiFi client +// mode above), so no extra system packages are required. +// +// Two modes: +// - 'shared' (default): NetworkManager runs DHCP + NAT for clients on their +// own subnet. Safe and self-contained. +// - 'bridge' (advanced/experimental): the AP is bridged onto the wired LAN so +// clients get addresses from the main router and share one subnet. This is +// what makes roaming between the router and this AP seamless, but it +// reconfigures the wired connection and can briefly interrupt connectivity. +// --------------------------------------------------------------------------- + +const WIFI_HOTSPOT_CONNECTION = 'umbrel-hotspot' +const WIFI_HOTSPOT_BRIDGE = 'umbrel-hotspot-br0' + +export type WifiHotspotConfig = { + ssid: string + password: string + band?: '2.4ghz' | '5ghz' + channel?: number // 0 = auto + countryCode?: string + hidden?: boolean + bridgeToLan?: boolean +} + +// Return the name of the first WiFi device (e.g. 'wlan0', 'wlo1'), if any +export async function getWifiDevice(): Promise { + const {stdout} = await $`nmcli --terse --fields DEVICE,TYPE device status` + for (const line of stdout.split('\n')) { + const [device, type] = line.split(':') + if (type === 'wifi' && device) return device + } + return undefined +} + +// Whether this device can host a WiFi hotspot (has a WiFi adapter that supports AP mode) +export async function supportsWifiHotspot(): Promise { + const device = await getWifiDevice() + if (!device) return false + try { + // Check the adapter advertises AP mode under its supported interface modes + const {stdout} = await $`iw list` + return /Supported interface modes:[\s\S]*?\*\s*AP\b/.test(stdout) + } catch { + // `iw` may be unavailable; if we have a WiFi device assume AP is supported + // and let the actual start attempt surface any real incompatibility. + return true + } +} + +// Return the name of the primary connected ethernet device, if any +async function getPrimaryEthernetDevice(): Promise { + const {stdout} = await $`nmcli --terse --fields DEVICE,TYPE,STATE device status` + for (const line of stdout.split('\n')) { + const [device, type, state] = line.split(':') + if (type === 'ethernet' && state === 'connected' && device) return device + } + return undefined +} + +// Tear down the hotspot connection (and the experimental bridge) if present +export async function stopWifiHotspot(): Promise { + const {stdout} = await $`nmcli --terse --fields NAME connection` + const connections = stdout.split('\n') + for (const name of [WIFI_HOTSPOT_CONNECTION, `${WIFI_HOTSPOT_BRIDGE}-eth`, WIFI_HOTSPOT_BRIDGE]) { + if (!connections.includes(name)) continue + await $`nmcli connection down ${name}`.catch(() => {}) + await $`nmcli connection delete ${name}`.catch(() => {}) + } +} + +// EXPERIMENTAL: create a bridge that contains the primary ethernet so the +// hotspot can join the same L2 network as the rest of the LAN (single subnet, +// seamless roaming). Reconfigures the wired connection — can interrupt +// connectivity while the bridge comes up. +async function ensureLanBridge(): Promise { + const ethernet = await getPrimaryEthernetDevice() + if (!ethernet) throw new Error('Bridging to the local network requires a connected Ethernet cable') + + const {stdout} = await $`nmcli --terse --fields NAME connection` + const connections = stdout.split('\n') + + if (!connections.includes(WIFI_HOTSPOT_BRIDGE)) { + await $`nmcli connection add type bridge con-name ${WIFI_HOTSPOT_BRIDGE} ifname ${WIFI_HOTSPOT_BRIDGE} stp no ipv4.method auto ipv6.method ignore` + } + if (!connections.includes(`${WIFI_HOTSPOT_BRIDGE}-eth`)) { + await $`nmcli connection add type ethernet ifname ${ethernet} con-name ${WIFI_HOTSPOT_BRIDGE}-eth master ${WIFI_HOTSPOT_BRIDGE}` + } + await $`nmcli connection up ${WIFI_HOTSPOT_BRIDGE}` +} + +// Bring up the WiFi hotspot with the given configuration +export async function startWifiHotspot(config: WifiHotspotConfig): Promise { + const device = await getWifiDevice() + if (!device) throw new Error('No WiFi adapter found') + if (config.ssid.length === 0 || config.ssid.length > 32) throw new Error('SSID must be between 1 and 32 characters') + if (config.password.length < 8 || config.password.length > 63) + throw new Error('Password must be between 8 and 63 characters') + + // Best-effort: set the wireless regulatory domain so the requested band/channel is permitted + if (config.countryCode) await $`iw reg set ${config.countryCode}`.catch(() => {}) + + // Start from a clean slate + await stopWifiHotspot().catch(() => {}) + + const band = config.band === '5ghz' ? 'a' : 'bg' + + // Create the access point connection + await $`nmcli connection add type wifi ifname ${device} con-name ${WIFI_HOTSPOT_CONNECTION} autoconnect yes ssid ${config.ssid}` + + // Configure AP mode + security + await $`nmcli connection modify ${WIFI_HOTSPOT_CONNECTION} 802-11-wireless.mode ap 802-11-wireless.band ${band} 802-11-wireless.hidden ${config.hidden ? 'yes' : 'no'} wifi-sec.key-mgmt wpa-psk wifi-sec.psk ${config.password} ipv6.method ignore` + + if (config.channel && config.channel > 0) { + await $`nmcli connection modify ${WIFI_HOTSPOT_CONNECTION} 802-11-wireless.channel ${config.channel}` + } + + if (config.bridgeToLan) { + // Experimental: attach the AP to a LAN bridge so clients share the main subnet + await ensureLanBridge() + await $`nmcli connection modify ${WIFI_HOTSPOT_CONNECTION} connection.master ${WIFI_HOTSPOT_BRIDGE} connection.slave-type bridge` + } else { + // Default: NetworkManager provides DHCP + NAT for clients (shared mode) + await $`nmcli connection modify ${WIFI_HOTSPOT_CONNECTION} ipv4.method shared` + } + + await $`nmcli connection up ${WIFI_HOTSPOT_CONNECTION}` +} + +// Report the current hotspot configuration and whether it's live +export async function getWifiHotspotStatus(umbreld: Umbreld) { + const config = await umbreld.store.get('settings.wifiHotspot') + + let active = false + try { + const {stdout} = await $`nmcli --terse --fields NAME connection show --active` + active = stdout.split('\n').includes(WIFI_HOTSPOT_CONNECTION) + } catch { + // Ignore — treat as inactive + } + + return { + enabled: config?.enabled ?? false, + active, + ssid: config?.ssid ?? '', + // Password is the user's own AP credential and is needed to pre-fill the + // settings form; only ever returned over the authenticated RPC. + password: config?.password ?? '', + band: config?.band ?? ('2.4ghz' as const), + channel: config?.channel ?? 0, + countryCode: config?.countryCode ?? '', + hidden: config?.hidden ?? false, + bridgeToLan: config?.bridgeToLan ?? false, + } +} + +// Re-apply the saved hotspot after boot/OTA update (non-blocking, retries) +export async function restoreWifiHotspot(umbreld: Umbreld): Promise { + const config = await umbreld.store.get('settings.wifiHotspot') + if (!config?.enabled) return + + while (true) { + umbreld.logger.log(`Attempting to restore WiFi hotspot ${config.ssid}...`) + try { + await startWifiHotspot(config) + umbreld.logger.log(`WiFi hotspot restored!`) + break + } catch (error) { + umbreld.logger.error(`Failed to restore WiFi hotspot, retrying in 1 minute`, error) + await setTimeout(1000 * 60) + } + } +} + // Get IP addresses of the device export function getIpAddresses(): string[] { // Known good interfaces: diff --git a/packages/umbreld/source/modules/system/wifi-hotspot-routes.ts b/packages/umbreld/source/modules/system/wifi-hotspot-routes.ts new file mode 100644 index 000000000..063be24be --- /dev/null +++ b/packages/umbreld/source/modules/system/wifi-hotspot-routes.ts @@ -0,0 +1,55 @@ +import {z} from 'zod' + +import {privateProcedure, router} from '../server/trpc/trpc.js' +import {supportsWifiHotspot, startWifiHotspot, stopWifiHotspot, getWifiHotspotStatus} from './system.js' + +const configSchema = z.object({ + ssid: z.string().min(1).max(32), + password: z.string().min(8).max(63), + band: z.enum(['2.4ghz', '5ghz']).optional(), + channel: z.number().int().min(0).max(165).optional(), + countryCode: z + .string() + .length(2) + .regex(/^[A-Za-z]{2}$/) + .optional(), + hidden: z.boolean().optional(), + bridgeToLan: z.boolean().optional(), +}) + +export default router({ + // Whether this device has a WiFi adapter capable of running an access point + supported: privateProcedure.query(() => supportsWifiHotspot()), + + // Current hotspot configuration + whether it's currently live + status: privateProcedure.query(({ctx}) => getWifiHotspotStatus(ctx.umbreld)), + + // Enable (or update) the hotspot + enable: privateProcedure.input(configSchema).mutation(async ({ctx, input}) => { + const previous = await ctx.umbreld.store.get('settings.wifiHotspot') + try { + await startWifiHotspot(input) + await ctx.umbreld.store.set('settings.wifiHotspot', {enabled: true, ...input}) + return true + } catch (error) { + // Best effort: tear down the half-configured hotspot and restore the + // previous working config so a bad change can't leave the AP broken. + await stopWifiHotspot().catch(() => {}) + if (previous?.enabled) { + ctx.umbreld.logger.error(`Failed to enable WiFi hotspot, attempting to restore previous config...`) + startWifiHotspot(previous).catch((error) => { + ctx.umbreld.logger.error(`Failed to restore previous WiFi hotspot`, error) + }) + } + throw error + } + }), + + // Disable the hotspot (keeps the saved config so it can be re-enabled) + disable: privateProcedure.mutation(async ({ctx}) => { + await stopWifiHotspot() + const existing = await ctx.umbreld.store.get('settings.wifiHotspot') + if (existing) await ctx.umbreld.store.set('settings.wifiHotspot', {...existing, enabled: false}) + return true + }), +})