| … | … | @@ -0,0 +1,15 @@ |
| 1 | package org.circusautomatic.cthuloid | |
| 2 | ||
| 3 | import com.facebook.react.ReactPackage | |
| 4 | import com.facebook.react.bridge.NativeModule | |
| 5 | import com.facebook.react.bridge.ReactApplicationContext | |
| 6 | import com.facebook.react.uimanager.ViewManager | |
| 7 | ||
| 8 | /** The app's own native modules. Autolinking cannot find these: they are not a node_modules package. */ | |
| 9 | class CthuloidPackage : ReactPackage { | |
| 10 | override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> = | |
| 11 | listOf(LampFinderModule(reactContext)) | |
| 12 | ||
| 13 | override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> = | |
| 14 | emptyList() | |
| 15 | } |
| … | … | @@ -0,0 +1,158 @@ |
| 1 | package org.circusautomatic.cthuloid | |
| 2 | ||
| 3 | import android.content.Context | |
| 4 | import android.net.nsd.NsdManager | |
| 5 | import android.net.nsd.NsdServiceInfo | |
| 6 | import android.os.Build | |
| 7 | import com.facebook.react.bridge.Arguments | |
| 8 | import com.facebook.react.bridge.ReactApplicationContext | |
| 9 | import com.facebook.react.bridge.ReactContextBaseJavaModule | |
| 10 | import com.facebook.react.bridge.ReactMethod | |
| 11 | import com.facebook.react.bridge.WritableMap | |
| 12 | import com.facebook.react.modules.core.DeviceEventManagerModule | |
| 13 | import java.net.Inet4Address | |
| 14 | import java.util.ArrayDeque | |
| 15 | import java.util.concurrent.Executors | |
| 16 | ||
| 17 | /** | |
| 18 | * Finds light units: Android's own mDNS (NsdManager) browsing _cthuloid._tcp, which every unit on | |
| 19 | * firmware proto >= 2 advertises on its command port with TXT proto=<n>. Results go to JS as | |
| 20 | * "cthuloid-unit" events shaped like the worklet's IPC messages (lib/ipc-discovery.js): | |
| 21 | * { t: 'unit-up', name, host, addr, port, proto } { t: 'unit-down', name } | |
| 22 | * { t: 'discovery', available, reason } | |
| 23 | * | |
| 24 | * No multicast lock: NsdManager does the multicast in the system's mDNS daemon, not in this app. | |
| 25 | * There is no DNS on Android for apps (favela memory, 2026-08-24), so units are reported by ADDRESS. | |
| 26 | * | |
| 27 | * Resolving differs by version. Android 14+ (API 34): registerServiceInfoCallback, which also reports | |
| 28 | * address changes and the unit going away. Below 34: resolveService, which fails with | |
| 29 | * FAILURE_ALREADY_ACTIVE if a second resolve starts before the first finishes -- so those are queued. | |
| 30 | * | |
| 31 | * A plain legacy module, as favela-surface-android's FavelaNodeModule: it works under the new | |
| 32 | * architecture's interop layer and needs no codegen spec. | |
| 33 | */ | |
| 34 | class LampFinderModule(private val ctx: ReactApplicationContext) : ReactContextBaseJavaModule(ctx) { | |
| 35 | ||
| 36 | companion object { | |
| 37 | const val NAME = "CthuloidLampFinder" | |
| 38 | const val EVENT = "cthuloid-unit" | |
| 39 | const val SERVICE = "_cthuloid._tcp" | |
| 40 | } | |
| 41 | ||
| 42 | override fun getName(): String = NAME | |
| 43 | ||
| 44 | private val nsd by lazy { ctx.getSystemService(Context.NSD_SERVICE) as NsdManager } | |
| 45 | private val executor = Executors.newSingleThreadExecutor() | |
| 46 | private var discovery: NsdManager.DiscoveryListener? = null | |
| 47 | private val callbacks = HashMap<String, NsdManager.ServiceInfoCallback>() | |
| 48 | private val resolveQueue = ArrayDeque<NsdServiceInfo>() | |
| 49 | private var resolving = false | |
| 50 | ||
| 51 | private fun emit(map: WritableMap) { | |
| 52 | ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java).emit(EVENT, map) | |
| 53 | } | |
| 54 | ||
| 55 | private fun available(ok: Boolean, reason: String = "") { | |
| 56 | emit(Arguments.createMap().apply { | |
| 57 | putString("t", "discovery"); putBoolean("available", ok); putString("reason", reason) | |
| 58 | }) | |
| 59 | } | |
| 60 | ||
| 61 | private fun up(info: NsdServiceInfo, addr: String) { | |
| 62 | val proto = info.attributes["proto"]?.let { String(it, Charsets.UTF_8).toIntOrNull() } ?: 0 | |
| 63 | emit(Arguments.createMap().apply { | |
| 64 | putString("t", "unit-up") | |
| 65 | putString("name", info.serviceName) | |
| 66 | putString("host", info.serviceName + ".local") | |
| 67 | putString("addr", addr) | |
| 68 | putInt("port", info.port) | |
| 69 | putInt("proto", proto) | |
| 70 | }) | |
| 71 | } | |
| 72 | ||
| 73 | private fun down(name: String) { | |
| 74 | emit(Arguments.createMap().apply { putString("t", "unit-down"); putString("name", name) }) | |
| 75 | } | |
| 76 | ||
| 77 | private fun ipv4Of(info: NsdServiceInfo): String? { | |
| 78 | val addrs = if (Build.VERSION.SDK_INT >= 34) info.hostAddresses else listOfNotNull(@Suppress("DEPRECATION") info.host) | |
| 79 | return (addrs.firstOrNull { it is Inet4Address } ?: addrs.firstOrNull())?.hostAddress | |
| 80 | } | |
| 81 | ||
| 82 | @ReactMethod | |
| 83 | fun start() { | |
| 84 | if (discovery != null) return | |
| 85 | val listener = object : NsdManager.DiscoveryListener { | |
| 86 | override fun onDiscoveryStarted(serviceType: String) = available(true) | |
| 87 | override fun onDiscoveryStopped(serviceType: String) {} | |
| 88 | override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { | |
| 89 | discovery = null | |
| 90 | available(false, "lamp discovery failed to start (NsdManager error $errorCode)") | |
| 91 | } | |
| 92 | override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {} | |
| 93 | override fun onServiceFound(info: NsdServiceInfo) = resolve(info) | |
| 94 | override fun onServiceLost(info: NsdServiceInfo) { | |
| 95 | if (Build.VERSION.SDK_INT < 34) down(info.serviceName) // 34+: the info callback says so | |
| 96 | } | |
| 97 | } | |
| 98 | discovery = listener | |
| 99 | nsd.discoverServices(SERVICE, NsdManager.PROTOCOL_DNS_SD, listener) | |
| 100 | } | |
| 101 | ||
| 102 | @ReactMethod | |
| 103 | fun stop() { | |
| 104 | discovery?.let { try { nsd.stopServiceDiscovery(it) } catch (_: IllegalArgumentException) {} } | |
| 105 | discovery = null | |
| 106 | if (Build.VERSION.SDK_INT >= 34) { | |
| 107 | for (cb in callbacks.values) try { nsd.unregisterServiceInfoCallback(cb) } catch (_: IllegalArgumentException) {} | |
| 108 | } | |
| 109 | callbacks.clear() | |
| 110 | synchronized(resolveQueue) { resolveQueue.clear() } | |
| 111 | } | |
| 112 | ||
| 113 | // Required by RN's event emitter contract when JS subscribes through NativeEventEmitter. | |
| 114 | @ReactMethod fun addListener(eventName: String) {} | |
| 115 | @ReactMethod fun removeListeners(count: Int) {} | |
| 116 | ||
| 117 | private fun resolve(info: NsdServiceInfo) { | |
| 118 | if (Build.VERSION.SDK_INT >= 34) { | |
| 119 | if (callbacks.containsKey(info.serviceName)) return | |
| 120 | val cb = object : NsdManager.ServiceInfoCallback { | |
| 121 | override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { | |
| 122 | callbacks.remove(info.serviceName) | |
| 123 | } | |
| 124 | override fun onServiceUpdated(updated: NsdServiceInfo) { | |
| 125 | ipv4Of(updated)?.let { up(updated, it) } | |
| 126 | } | |
| 127 | override fun onServiceLost() = down(info.serviceName) | |
| 128 | override fun onServiceInfoCallbackUnregistered() {} | |
| 129 | } | |
| 130 | callbacks[info.serviceName] = cb | |
| 131 | nsd.registerServiceInfoCallback(info, executor, cb) | |
| 132 | return | |
| 133 | } | |
| 134 | synchronized(resolveQueue) { resolveQueue.add(info) } | |
| 135 | nextResolve() | |
| 136 | } | |
| 137 | ||
| 138 | @Suppress("DEPRECATION") | |
| 139 | private fun nextResolve() { | |
| 140 | val info = synchronized(resolveQueue) { | |
| 141 | if (resolving) return | |
| 142 | val next = resolveQueue.poll() ?: return | |
| 143 | resolving = true | |
| 144 | next | |
| 145 | } | |
| 146 | nsd.resolveService(info, object : NsdManager.ResolveListener { | |
| 147 | override fun onResolveFailed(si: NsdServiceInfo, errorCode: Int) { | |
| 148 | synchronized(resolveQueue) { resolving = false } | |
| 149 | nextResolve() | |
| 150 | } | |
| 151 | override fun onServiceResolved(si: NsdServiceInfo) { | |
| 152 | ipv4Of(si)?.let { up(si, it) } | |
| 153 | synchronized(resolveQueue) { resolving = false } | |
| 154 | nextResolve() | |
| 155 | } | |
| 156 | }) | |
| 157 | } | |
| 158 | } |
| … | … | @@ -17,8 +17,9 @@ class MainApplication : Application(), ReactApplication { |
| 17 | 17 | |
| 18 | 18 | override val reactNativeHost: ReactNativeHost = |
| 19 | 19 | object : DefaultReactNativeHost(this) { |
| 20 | // react-native-bare-kit, -fs and -webview arrive by autolinking. | |
| 21 | override fun getPackages(): List<ReactPackage> = PackageList(this).packages | |
| 20 | // react-native-bare-kit, -fs and -webview arrive by autolinking; CthuloidPackage (the lamp | |
| 21 | // finder) is ours and lives here, so it is appended by hand. | |
| 22 | override fun getPackages(): List<ReactPackage> = PackageList(this).packages + CthuloidPackage() | |
| 22 | 23 | override fun getJSMainModuleName(): String = "app/index" |
| 23 | 24 | override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG |
| 24 | 25 | override val isHermesEnabled: Boolean = true |
| … | … | @@ -2,10 +2,26 @@ |
| 2 | 2 | // serves on stortron, served here by the same controller running in this app's |
| 3 | 3 | // worklet, shown in a WebView. Nothing about lamps lives in this file. |
| 4 | 4 | import React, { useEffect, useState, useCallback, useRef } from 'react' |
| 5 | import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable } from 'react-native' | |
| 5 | import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable, AppState, DeviceEventEmitter, NativeModules } from 'react-native' | |
| 6 | 6 | import { WebView } from 'react-native-webview' |
| 7 | 7 | import { startWorklet } from './worklet-host' |
| 8 | 8 | |
| 9 | // The lamp finder (LampFinderModule.kt, NsdManager) -> the worklet's discovery. | |
| 10 | // Browses only while the app is on screen: in the background the worklet is | |
| 11 | // suspended anyway, and on resume a fresh browse re-reports every lamp. | |
| 12 | let finding = false | |
| 13 | function startFinder (host) { | |
| 14 | const finder = NativeModules.CthuloidLampFinder | |
| 15 | if (!finder || finding) return | |
| 16 | finding = true | |
| 17 | DeviceEventEmitter.addListener('cthuloid-unit', (m) => host.send(m)) | |
| 18 | finder.start() | |
| 19 | AppState.addEventListener('change', (state) => { | |
| 20 | if (state === 'active') finder.start() | |
| 21 | else finder.stop() | |
| 22 | }) | |
| 23 | } | |
| 24 | ||
| 9 | 25 | function Center ({ children }) { |
| 10 | 26 | return <View style={s.center}>{children}</View> |
| 11 | 27 | } |
| … | … | @@ -21,7 +37,7 @@ export default function App () { |
| 21 | 37 | let answered = false |
| 22 | 38 | host.onMessage((m) => { |
| 23 | 39 | if (m.t === 'log') setStage(m.msg) |
| 24 | else if (m.t === 'ready') { answered = true; setUrl(m.url) } | |
| 40 | else if (m.t === 'ready') { answered = true; setUrl(m.url); startFinder(host) } | |
| 25 | 41 | else if (m.t === 'fatal') { answered = true; setError(m.error) } |
| 26 | 42 | }) |
| 27 | 43 | // A worklet has no error or exit event: if it never speaks, say so rather |
| … | … | @@ -0,0 +1,48 @@ |
| 1 | // Unit discovery fed by the app shell over IPC. On Android the shell's Kotlin | |
| 2 | // lamp finder (NsdManager, browsing _cthuloid._tcp) does the mDNS work and sends | |
| 3 | // { t: 'unit-up', name, host, addr, port, proto } | |
| 4 | // { t: 'unit-down', name } | |
| 5 | // { t: 'discovery', available, reason } | |
| 6 | // which this turns into the same events avahi produces on a desktop | |
| 7 | // (webui/lib/discovery.js), so the rig cannot tell them apart. | |
| 8 | import EventEmitter from 'bare-events' | |
| 9 | ||
| 10 | export class IpcDiscovery extends EventEmitter { | |
| 11 | constructor () { | |
| 12 | super() | |
| 13 | this.available = true | |
| 14 | this.reason = '' | |
| 15 | } | |
| 16 | ||
| 17 | start () {} | |
| 18 | stop () {} | |
| 19 | ||
| 20 | // Returns true if the message was a discovery message. | |
| 21 | handle (m) { | |
| 22 | if (!m || typeof m !== 'object') return false | |
| 23 | if (m.t === 'unit-up') { | |
| 24 | const port = Number(m.port) | |
| 25 | if (!m.name || !m.addr || !Number.isInteger(port)) return true | |
| 26 | this.emit('up', { | |
| 27 | name: String(m.name), | |
| 28 | host: String(m.host || m.name + '.local'), | |
| 29 | addr: String(m.addr), | |
| 30 | port, | |
| 31 | proto: Number(m.proto) || 0 | |
| 32 | }) | |
| 33 | return true | |
| 34 | } | |
| 35 | if (m.t === 'unit-down') { | |
| 36 | if (m.name) this.emit('down', { name: String(m.name) }) | |
| 37 | return true | |
| 38 | } | |
| 39 | if (m.t === 'discovery') { | |
| 40 | const was = this.available | |
| 41 | this.available = !!m.available | |
| 42 | this.reason = this.available ? '' : String(m.reason || 'lamp discovery is not running') | |
| 43 | if (was !== this.available) this.emit(this.available ? 'available' : 'unavailable', this.reason) | |
| 44 | return true | |
| 45 | } | |
| 46 | return false | |
| 47 | } | |
| 48 | } |
| … | … | @@ -3,11 +3,12 @@ |
| 3 | 3 | // packed bundle has no files on disk, and tells the React Native side where the |
| 4 | 4 | // page is. |
| 5 | 5 | // |
| 6 | // IPC is newline-delimited JSON over BareKit.IPC (bytes both ways). This side | |
| 7 | // only ever SENDS in v1: | |
| 6 | // IPC is newline-delimited JSON over BareKit.IPC (bytes both ways). | |
| 7 | // Worklet -> shell: | |
| 8 | 8 | // { t: 'log', msg } progress, also printed to the console (logcat) |
| 9 | 9 | // { t: 'ready', url } the page is being served -- point the WebView here |
| 10 | 10 | // { t: 'fatal', error } the controller could not start |
| 11 | // Shell -> worklet: the lamp finder's events (lib/ipc-discovery.js). | |
| 11 | 12 | // |
| 12 | 13 | // Storage arrives as the ONE argv entry. Nothing else is ever positional: in a |
| 13 | 14 | // later version the APK and the bundle can differ, and a new positional argument |
| … | … | @@ -19,9 +20,30 @@ import path from 'bare-path' |
| 19 | 20 | import { createApp } from 'cthuloid-web/lib/app.js' |
| 20 | 21 | import assets from 'cthuloid-web/lib/assets.generated.js' |
| 21 | 22 | import { storageFromArgv } from './argv.js' |
| 23 | import { IpcDiscovery } from './ipc-discovery.js' | |
| 22 | 24 | |
| 23 | 25 | const IPC = typeof BareKit !== 'undefined' ? BareKit.IPC : null |
| 24 | 26 | |
| 27 | // Lamps found by the shell's NsdManager arrive here over IPC. | |
| 28 | export const discovery = new IpcDiscovery() | |
| 29 | ||
| 30 | if (IPC) { | |
| 31 | let buf = Buffer.alloc(0) | |
| 32 | // 'data', not read(cb): BareKit.IPC is a streamx Duplex (favela-surface-android). | |
| 33 | // Split on the newline BYTE, then decode: a chunk can end mid-character. | |
| 34 | IPC.on('data', (data) => { | |
| 35 | buf = Buffer.concat([buf, Buffer.from(data)]) | |
| 36 | let i | |
| 37 | while ((i = buf.indexOf(10)) !== -1) { | |
| 38 | const line = buf.subarray(0, i).toString() | |
| 39 | buf = buf.subarray(i + 1) | |
| 40 | let m | |
| 41 | try { m = JSON.parse(line) } catch { continue } | |
| 42 | if (discovery.handle(m)) console.log('[cthuloid] ' + line) | |
| 43 | } | |
| 44 | }) | |
| 45 | } | |
| 46 | ||
| 25 | 47 | function send (msg) { |
| 26 | 48 | const line = JSON.stringify(msg) |
| 27 | 49 | console.log('[cthuloid] ' + line) |
| … | … | @@ -42,13 +64,16 @@ function lastPort (storage) { |
| 42 | 64 | |
| 43 | 65 | async function listenPreferring (storage, port) { |
| 44 | 66 | const configDir = path.join(storage, 'cthuloid-web') |
| 67 | let app = null | |
| 45 | 68 | try { |
| 46 | const app = createApp({ configDir, port, assets }) | |
| 69 | app = createApp({ configDir, port, assets, discovery }) | |
| 47 | 70 | return { app, ...(await app.listen()) } |
| 48 | 71 | } catch (err) { |
| 49 | 72 | if (port === 0) throw err |
| 73 | // Stop the half-started one, or its rig stays subscribed to discovery. | |
| 74 | try { await app.close() } catch {} | |
| 50 | 75 | send({ t: 'log', msg: 'port ' + port + ' busy (' + err.message + '), taking any' }) |
| 51 | const app = createApp({ configDir, port: 0, assets }) | |
| 76 | app = createApp({ configDir, port: 0, assets, discovery }) | |
| 52 | 77 | return { app, ...(await app.listen()) } |
| 53 | 78 | } |
| 54 | 79 | } |
| … | … | @@ -0,0 +1,37 @@ |
| 1 | // The shell's NSD messages become the rig's discovery events. | |
| 2 | import test from 'brittle' | |
| 3 | import { IpcDiscovery } from '../lib/ipc-discovery.js' | |
| 4 | ||
| 5 | test('unit-up / unit-down become up / down', (t) => { | |
| 6 | const d = new IpcDiscovery() | |
| 7 | const ev = [] | |
| 8 | d.on('up', (u) => ev.push(['up', u])) | |
| 9 | d.on('down', (u) => ev.push(['down', u])) | |
| 10 | t.ok(d.handle({ t: 'unit-up', name: 'studiolight', host: 'studiolight.local', addr: '192.168.50.89', port: 1337, proto: '3' })) | |
| 11 | t.ok(d.handle({ t: 'unit-down', name: 'studiolight' })) | |
| 12 | t.alike(ev, [ | |
| 13 | ['up', { name: 'studiolight', host: 'studiolight.local', addr: '192.168.50.89', port: 1337, proto: 3 }], | |
| 14 | ['down', { name: 'studiolight' }] | |
| 15 | ]) | |
| 16 | }) | |
| 17 | ||
| 18 | test('incomplete unit-up is ignored, unrelated messages are not ours', (t) => { | |
| 19 | const d = new IpcDiscovery() | |
| 20 | let n = 0 | |
| 21 | d.on('up', () => n++) | |
| 22 | t.ok(d.handle({ t: 'unit-up', name: 'x', port: 1337 }), 'consumed') | |
| 23 | t.is(n, 0, 'but no event without an address') | |
| 24 | t.absent(d.handle({ t: 'ping' })) | |
| 25 | }) | |
| 26 | ||
| 27 | test('availability changes are reported once', (t) => { | |
| 28 | const d = new IpcDiscovery() | |
| 29 | const ev = [] | |
| 30 | d.on('unavailable', (r) => ev.push('unavailable:' + r)) | |
| 31 | d.on('available', () => ev.push('available')) | |
| 32 | d.handle({ t: 'discovery', available: false, reason: 'wifi off' }) | |
| 33 | d.handle({ t: 'discovery', available: false, reason: 'wifi off' }) | |
| 34 | d.handle({ t: 'discovery', available: true }) | |
| 35 | t.alike(ev, ['unavailable:wifi off', 'available']) | |
| 36 | t.is(d.reason, '') | |
| 37 | }) |