// Cthuloid Lights -- the app shell. THE UI IS the web UI: the same page cthuloid-web
// serves on stortron, served here by the same controller running in this app's
// worklet, shown in a WebView. Nothing about lamps lives in this file.
import React, { useEffect, useState, useCallback, useRef } from 'react'
import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable, AppState, DeviceEventEmitter, NativeModules } from 'react-native'
import { WebView } from 'react-native-webview'
import { startWorklet } from './worklet-host'
import * as updater from './updater'
// The lamp finder (LampFinderModule.kt, NsdManager) -> the worklet's discovery.
// Browses only while the app is on screen: in the background the worklet is
// suspended anyway, and on resume a fresh browse re-reports every lamp.
let finding = false
function startFinder (host) {
const finder = NativeModules.CthuloidLampFinder
if (!finder || finding) return
finding = true
DeviceEventEmitter.addListener('cthuloid-unit', (m) => host.send(m))
finder.start()
AppState.addEventListener('change', (state) => {
if (state === 'active') finder.start()
else finder.stop()
})
}
function Center ({ children }) {
return <View style={s.center}>{children}</View>
}
export default function App () {
return (
<SafeAreaView style={s.fill}>
<UpdateBanner />
<Main />
</SafeAreaView>
)
}
// Self-update (DESIGN-android.md §6): checks the forge on start, every 6 h and
// on tap. A slim line always shows the installed version and the last check;
// it turns into a blue Install strip when a newer complete release exists.
// Above every screen, so a build whose controller will not start can still
// update itself.
const CHECK_EVERY = 6 * 60 * 60 * 1000
const hhmm = (d) => String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
function UpdateBanner () {
const [u, setU] = useState(null) // { version, apkUrl, shaUrl }
const [phase, setPhase] = useState('available')
const [note, setNote] = useState('')
const [checked, setChecked] = useState('checking for updates…')
const busy = useRef(false)
const path = useRef(null) // checked APK on disk, for u.version only
const run = useCallback(() => {
if (busy.current) return
setChecked('checking for updates…')
updater.check()
.then((next) => {
setChecked('up to date · checked ' + hhmm(new Date()))
if (busy.current) return
setU((prev) => {
if (!prev || !next || prev.version !== next.version) { path.current = null; setPhase('available'); setNote('') }
return next
})
})
// A failed check is said as such, never shown as 'up to date'.
.catch((e) => setChecked('update check failed: ' + e.message))
}, [])
useEffect(() => {
run()
const t = setInterval(run, CHECK_EVERY)
return () => clearInterval(t)
}, [run])
const go = useCallback(async () => {
if (busy.current) return
busy.current = true
try {
if (!path.current) {
setPhase('downloading')
path.current = await updater.download(u, (f) => setNote(Math.round(f * 100) + ' %'))
}
const opened = await updater.install(path.current)
setPhase(opened ? 'installing' : 'permission')
setNote('')
} catch (e) {
path.current = null
// Cut off mid-download (screen locked, WiFi dropped): the bytes so far are
// kept, and the download continues when the app is back on screen.
if (e.paused) setPhase('paused')
else { setPhase('error'); setNote(e.message) }
} finally {
busy.current = false
}
}, [u])
const resumeRef = useRef(null)
resumeRef.current = phase === 'paused' ? go : null
useEffect(() => {
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active' && resumeRef.current) resumeRef.current()
})
return () => sub.remove()
}, [])
if (!u) {
return (
<Pressable style={s.version} onPress={run}>
<Text style={s.versionText}>Cthuloid Lights {updater.installedVersion || '?'} · {checked}</Text>
</Pressable>
)
}
const label = {
available: 'Update ' + u.version + ' available — Install',
downloading: 'Downloading ' + u.version + '… ' + note,
paused: 'Download paused at ' + (note || '0 %') + ' — tap to continue',
permission: 'Allow "Install unknown apps" for Cthuloid Lights, then tap here',
installing: 'Installing ' + u.version + ' — tap if the installer closed',
error: 'Update failed: ' + note + ' — tap to retry'
}[phase]
return (
<Pressable style={s.banner} onPress={go} disabled={phase === 'downloading'}>
<Text style={s.bannerText}>{label}</Text>
<Text style={s.bannerSub}>installed {updater.installedVersion}</Text>
</Pressable>
)
}
function Main () {
const [url, setUrl] = useState(null)
const [error, setError] = useState(null)
const [stage, setStage] = useState('starting…')
const boot = useCallback(async () => {
setError(null)
const host = await startWorklet() // memoised: a retry reuses the running worklet
let answered = false
host.onMessage((m) => {
if (m.t === 'log') setStage(m.msg)
else if (m.t === 'ready') { answered = true; setUrl(m.url); startFinder(host) }
else if (m.t === 'fatal') { answered = true; setError(m.error) }
})
// A worklet has no error or exit event: if it never speaks, say so rather
// than spin for ever (0.1.0 did exactly that).
setTimeout(() => {
if (!answered) setError('The light controller did not answer within 20 s.')
}, 20000)
}, [])
useEffect(() => { boot().catch((e) => setError(e.message)) }, [boot])
if (error) {
return (
<Center>
<Text style={s.err}>{error}</Text>
<Text style={s.dim}>{stage}</Text>
<Pressable onPress={() => boot().catch((e) => setError(e.message))}><Text style={s.retry}>try again</Text></Pressable>
</Center>
)
}
if (!url) return <Center><ActivityIndicator /><Text style={s.dim}>{stage}</Text></Center>
return <Page url={url} />
}
function Page ({ url }) {
const ref = useRef(null)
const [canBack, setCanBack] = useState(false)
const [failed, setFailed] = useState(null)
// Hardware BACK follows page history before leaving the app.
useEffect(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
if (canBack && ref.current) { ref.current.goBack(); return true }
return false
})
return () => sub.remove()
}, [canBack])
if (failed) {
return (
<Center>
<Text style={s.err}>could not load the page</Text>
<Text style={s.dim}>{failed}</Text>
<Pressable onPress={() => setFailed(null)}><Text style={s.retry}>retry</Text></Pressable>
</Center>
)
}
return (
<View style={s.fill}>
<WebView
ref={ref}
source={{ uri: url }}
onNavigationStateChange={(nav) => setCanBack(nav.canGoBack)}
// The page comes from this device only; navigating off loopback is a bug.
originWhitelist={['http://127.0.0.1:*']}
onError={(e) => setFailed(String((e.nativeEvent && e.nativeEvent.description) || 'load failed'))}
javaScriptEnabled
// localStorage keeps this phone's session id, name and swatches.
domStorageEnabled
style={s.fill}
/>
</View>
)
}
const s = StyleSheet.create({
fill: { flex: 1 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
dim: { opacity: 0.6, marginTop: 12, textAlign: 'center' },
err: { color: '#c33', textAlign: 'center' },
retry: { marginTop: 16, textDecorationLine: 'underline' },
banner: { backgroundColor: '#2d5fd0', paddingVertical: 10, paddingHorizontal: 16 },
bannerText: { color: '#fff', textAlign: 'center' },
bannerSub: { color: '#fff', opacity: 0.75, fontSize: 11, textAlign: 'center' },
version: { paddingVertical: 4, paddingHorizontal: 12 },
versionText: { fontSize: 11, opacity: 0.55, textAlign: 'center' }
})