Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/ui/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
245 changes: 245 additions & 0 deletions packages/ui/src/modules/wifi-hotspot/wifi-hotspot-drawer-or-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof DrawerPrimitive.Root>) {
const isMobile = useIsSmallMobile()
const Wrapper = isMobile ? Drawer : Dialog
return <Wrapper {...props} />
}

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 (
<DrawerOrDialogContent
header={
supportedQ.data ? (
<Switch
checked={enabled}
onCheckedChange={toggleEnabled}
disabled={isBusy || statusQ.isLoading || (!enabled && !canSave)}
/>
) : undefined
}
>
{supportedQ.isLoading || statusQ.isLoading ? (
<Message>
<Loading />
</Message>
) : !supportedQ.data ? (
<Message>{t('wifi-hotspot-unsupported')}</Message>
) : (
<form
className='flex flex-col gap-4'
onSubmit={(e) => {
e.preventDefault()
if (canSave) save()
}}
>
<Labeled label={t('wifi-hotspot-ssid')}>
<Input placeholder={t('wifi-hotspot-ssid-placeholder')} value={ssid} onValueChange={setSsid} />
</Labeled>

<PasswordInput
label={t('password')}
value={password}
onValueChange={setPassword}
error={password.length > 0 && !passwordValid ? t('wifi-hotspot-password-invalid') : undefined}
/>

{/* Advanced settings */}
<button
type='button'
className='flex items-center gap-1 text-13 font-medium text-white/60 transition-colors hover:text-white/90'
onClick={() => setShowAdvanced((v) => !v)}
>
{t('wifi-hotspot-advanced')}
<TbChevronDown className={cn('size-4 transition-transform', showAdvanced && 'rotate-180')} />
</button>

<AnimatePresence initial={false}>
{showAdvanced && (
<motion.div
className='overflow-hidden'
initial={{height: 0, opacity: 0}}
animate={{height: 'auto', opacity: 1}}
exit={{height: 0, opacity: 0}}
transition={{duration: 0.2, ease: 'easeInOut'}}
>
<div className='flex flex-col gap-4 pt-1'>
<Labeled label={t('wifi-hotspot-band')}>
<SegmentedControl
size='lg'
value={band}
onValueChange={setBand}
tabs={[
{id: '2.4ghz', label: t('wifi-hotspot-band-2ghz')},
{id: '5ghz', label: t('wifi-hotspot-band-5ghz')},
]}
/>
</Labeled>

<div className='grid grid-cols-2 gap-3'>
<Labeled label={t('wifi-hotspot-channel')}>
<Input
type='number'
min={0}
placeholder={t('wifi-hotspot-channel-auto')}
value={channel}
onValueChange={setChannel}
/>
</Labeled>
<Labeled label={t('wifi-hotspot-country')}>
<Input
placeholder='US'
maxLength={2}
value={countryCode}
onValueChange={(v) => setCountryCode(v.toUpperCase())}
/>
</Labeled>
</div>

<Row title={t('wifi-hotspot-hidden')} description={t('wifi-hotspot-hidden-description')}>
<Switch checked={hidden} onCheckedChange={setHidden} />
</Row>

<Row title={t('wifi-hotspot-bridge')} description={t('wifi-hotspot-bridge-description')}>
<Switch checked={bridgeToLan} onCheckedChange={setBridgeToLan} />
</Row>

{bridgeToLan && (
<div className='flex items-start gap-1.5 text-12 text-yellow-300'>
<TbAlertTriangle className='mt-0.5 size-4 shrink-0' />
<span>{t('wifi-hotspot-bridge-warning')}</span>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>

{errorMessage && <div className='text-13 text-destructive2-lightest'>{errorMessage}</div>}

<Button type='submit' variant='primary' size='dialog' disabled={!canSave}>
{enabled ? t('wifi-hotspot-update') : t('wifi-hotspot-enable')}
</Button>
</form>
)}
</DrawerOrDialogContent>
)
}

function Row({title, description, children}: {title: string; description?: string; children: ReactNode}) {
return (
<div className='flex items-center justify-between gap-4'>
<div className='space-y-0.5'>
<div className='text-14 font-medium leading-tight'>{title}</div>
{description && <div className='text-12 leading-tight text-white/40'>{description}</div>}
</div>
{children}
</div>
)
}

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 (
<Content className='mx-auto px-[20px] py-[30px] sm:max-w-[560px]'>
<Header className='flex flex-row items-center justify-between gap-4'>
<div className='space-y-0.5'>
<Title>{t('wifi-hotspot')}</Title>
<Description className='text-12 leading-tight'>{t('wifi-hotspot-description-long')}</Description>
</div>
{header}
</Header>
{children}
</Content>
)
}

function Message({children}: {children?: React.ReactNode}) {
return (
<div className='grid h-32 place-items-center rounded-12 bg-white/6 p-4'>
<div className='text-center text-14 font-medium -tracking-2 opacity-60'>{children}</div>
</div>
)
}
26 changes: 18 additions & 8 deletions packages/ui/src/routes/settings/_components/settings-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -166,6 +169,13 @@ export function SettingsContent() {
<Switch checked={false} onCheckedChange={() => navigate('wifi-unsupported')} />
</ListRow>
)}
{wifiHotspotSupportedQ.data && (
<ListRow title={t('wifi-hotspot')} description={t('wifi-hotspot-description')}>
<IconButton icon={TbWifi} onClick={() => navigate('wifi-hotspot')}>
{t('wifi-hotspot-configure')}
</IconButton>
</ListRow>
)}
<ListRow title={t('2fa')} description={t('2fa-description')} disabled={is2faEnabledQ.isLoading}>
<Switch checked={is2faEnabledQ.data} onCheckedChange={() => navigate('2fa')} />
</ListRow>
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/routes/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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})),
)
Expand Down Expand Up @@ -145,6 +146,7 @@ export function Settings() {
{isMobile && <Route path='/wallpaper' Component={WallpaperDrawer} />}
<Route path='/wifi' Component={Wifi} />
<Route path='/wifi-unsupported' Component={WifiUnsupported} />
<Route path='/wifi-hotspot' Component={WifiHotspot} />
{/* Backup: mobile drawer (/backups) opens first on mobile to give same options as desktop */}
{isMobile && <Route path='/backups' Component={BackupsMobileDrawer} />}
<Route path='/backups/*' Component={BackupsRestoreDialog} />
Expand Down
15 changes: 15 additions & 0 deletions packages/ui/src/routes/settings/wifi-hotspot.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<WifiHotspotDrawerOrDialog {...dialogProps}>
<WifiHotspotDrawerOrDialogContent />
</WifiHotspotDrawerOrDialog>
)
}
14 changes: 14 additions & 0 deletions packages/umbreld/source/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
setupPiCpuGovernor,
restoreHostname,
restoreWiFi,
restoreWifiHotspot,
restoreStaticIp,
waitForSystemTime,
reboot,
Expand Down Expand Up @@ -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<
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions packages/umbreld/source/modules/server/trpc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -26,6 +27,7 @@ const appRouter = router({
system,
systemNg,
wifi,
wifiHotspot,
user,
appStore,
apps,
Expand Down
Loading