diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6f920a5..4327d64 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -210,6 +210,10 @@ dependencies { implementation("androidx.core:core-splashscreen:1.0.1") // QR code generation for displaying server URL on TV implementation("com.google.zxing:core:3.5.3") + // EncryptedSharedPreferences (Android Keystore-backed) for the per-install + // server API key (see ApiKeyManager). Falls back to plain SharedPreferences + // when the keystore is unavailable. + implementation("androidx.security:security-crypto:1.1.0-alpha06") // USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for // driving Adalight/AmbiLED controllers plugged into Android TV boxes. implementation("com.github.mik3y:usb-serial-for-android:3.8.1") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0417e90..249c233 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -14,7 +14,8 @@ - + + // Best-effort persist into the encrypted store; cache regardless + // so we still return the recovered key if the write keeps failing. + runCatching { prefs.edit().putString(KEY_API_KEY, recovered).commit() } + cached = recovered + Log.i(TAG, "Recovered existing API key from legacy storage") + return recovered + } val generated = generateKey() // commit() (synchronous disk write) on the FIRST write so // the key is durable before MainActivity encodes it into a @@ -74,6 +106,88 @@ class ApiKeyManager(context: Context) { } } + /** + * Build the backing store, preferring EncryptedSharedPreferences. Returns + * (store, isEncrypted). Any keystore failure falls back to the plain prefs + * file so the local API key is never lost on a broken-keystore device. + */ + private fun buildPrefs(context: Context): Pair { + return try { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + val store = EncryptedSharedPreferences.create( + context, + ENCRYPTED_PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + store to true + } catch (e: Exception) { + // Keystore unavailable/corrupt — degrade to plain prefs rather + // than crashing. Worst case the key is stored unencrypted on a + // single-user TV box, which is the pre-existing behaviour. + Log.w(TAG, "EncryptedSharedPreferences unavailable, using plain prefs: ${e.message}") + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false + } + } + + /** + * One-time migration: if a key exists in the legacy plain-text prefs file + * (from before encrypted storage), copy it into the encrypted store and + * remove the plain copy. Preserves the existing key so already-scanned QR + * clients keep working — generating a fresh key here would silently 401 + * every LAN client (see the Data Migration Policy in CLAUDE.md). + */ + private fun migrateLegacyKeyIfPresent() { + // Don't migrate if the encrypted store already holds a key. + if (!prefs.getString(KEY_API_KEY, null).isNullOrEmpty()) return + runCatching { + val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val legacyKey = legacy.getString(KEY_API_KEY, null) + if (legacyKey != null && legacyKey.length >= MIN_KEY_LENGTH) { + // commit() returns false on write failure WITHOUT throwing, so the + // runCatching wrapper alone does NOT protect this path. Verify the + // encrypted store both committed AND reads back the identical value + // before touching the legacy copy — otherwise a silent write + // failure could delete the only surviving copy of the key and + // rotate it on next launch (401s every paired client — the exact + // silent-data-loss the Data Migration Policy forbids). + val ok = prefs.edit().putString(KEY_API_KEY, legacyKey).commit() + if (ok && prefs.getString(KEY_API_KEY, null) == legacyKey) { + // Keep the value as a .migrated backup (don't hard-delete) per + // the migration policy; remove only the live legacy key so the + // plaintext copy no longer answers reads. + legacy.edit() + .putString(KEY_API_KEY_MIGRATED, legacyKey) + .remove(KEY_API_KEY) + .apply() + Log.i(TAG, "Migrated API key from plain to encrypted storage") + } else { + // Leave the legacy key untouched; getOrCreateKey() will recover + // it via recoverLegacyKey() rather than minting a fresh one. + Log.w(TAG, "Encrypted key write unverified — keeping legacy key, not migrating") + } + } + }.onFailure { Log.w(TAG, "Legacy API key migration failed: ${it.message}") } + } + + /** + * Recover a still-present key from the legacy plain store — either the live + * key (failed/never-run migration) or the `.migrated` backup. Returns null + * when on the plain-prefs path (no legacy/encrypted split) or no valid key + * survives. Guarantees [getOrCreateKey] never rotates an existing key as long + * as the legacy file survives. + */ + private fun recoverLegacyKey(): String? { + if (!encrypted) return null + val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val candidate = legacy.getString(KEY_API_KEY, null) + ?: legacy.getString(KEY_API_KEY_MIGRATED, null) + return candidate?.takeIf { it.length >= MIN_KEY_LENGTH } + } + private fun generateKey(): String { val bytes = ByteArray(KEY_BYTES) SecureRandom().nextBytes(bytes) @@ -88,7 +202,11 @@ class ApiKeyManager(context: Context) { companion object { private const val TAG = "ApiKeyManager" private const val PREFS_NAME = "ledgrab_auth" + private const val ENCRYPTED_PREFS_NAME = "ledgrab_auth_enc" private const val KEY_API_KEY = "api_key" + // Backup of a migrated legacy key, kept in the plain store per the + // Data Migration Policy (never hard-delete user data on rename/move). + private const val KEY_API_KEY_MIGRATED = "api_key_migrated" private const val KEY_BYTES = 32 private const val MIN_KEY_LENGTH = 32 diff --git a/android/app/src/main/java/com/ledgrab/android/BleBridge.kt b/android/app/src/main/java/com/ledgrab/android/BleBridge.kt index 71aa5de..06f81cb 100644 --- a/android/app/src/main/java/com/ledgrab/android/BleBridge.kt +++ b/android/app/src/main/java/com/ledgrab/android/BleBridge.kt @@ -103,12 +103,32 @@ object BleBridge { } try { - bleHandler.post { scanner.startScan(callback) } + // startScan runs on the BLE handler thread; a denied + // BLUETOOTH_SCAN throws SecurityException there, which would + // crash the whole process (an uncaught exception on a handler + // thread is fatal). Catch it inside the posted body and report. + bleHandler.post { + try { + scanner.startScan(callback) + } catch (e: SecurityException) { + Log.w(TAG, "BLUETOOTH_SCAN permission denied — scan skipped", e) + } catch (e: Exception) { + Log.w(TAG, "BLE startScan failed: ${e.message}") + } + } Thread.sleep(timeoutMs) } catch (_: InterruptedException) { Thread.currentThread().interrupt() } finally { - try { bleHandler.post { scanner.stopScan(callback) } } catch (_: SecurityException) {} + bleHandler.post { + try { + scanner.stopScan(callback) + } catch (e: SecurityException) { + Log.w(TAG, "BLUETOOTH_SCAN permission denied — stopScan skipped", e) + } catch (e: Exception) { + Log.w(TAG, "BLE stopScan failed: ${e.message}") + } + } } return seen.values.toList() } @@ -136,7 +156,18 @@ object BleBridge { newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS -> { Log.d(TAG, "GATT connected to $address, discovering services") - gatt.discoverServices() + // Runs on the BLE handler thread; a denied + // BLUETOOTH_CONNECT throws SecurityException here, which + // would crash the process. Catch and fail the connect. + try { + gatt.discoverServices() + } catch (e: SecurityException) { + Log.e(TAG, "BLUETOOTH_CONNECT denied during discoverServices", e) + readyDeferred.complete(false) + } catch (e: Exception) { + Log.w(TAG, "discoverServices failed: ${e.message}") + readyDeferred.complete(false) + } } newState == BluetoothProfile.STATE_DISCONNECTED -> { Log.w(TAG, "GATT disconnected from $address (status=$status)") diff --git a/android/app/src/main/java/com/ledgrab/android/CaptureService.kt b/android/app/src/main/java/com/ledgrab/android/CaptureService.kt index a6e0c07..0252f32 100644 --- a/android/app/src/main/java/com/ledgrab/android/CaptureService.kt +++ b/android/app/src/main/java/com/ledgrab/android/CaptureService.kt @@ -105,12 +105,48 @@ class CaptureService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false - // CRITICAL: startForeground must be called IMMEDIATELY — before - // any other work, especially before getMediaProjection(). The - // service type must match the work; pass it explicitly via - // ServiceCompat so we stay compatible back to API 24. + // CRITICAL (Android 14+): for the MediaProjection path, validate the + // projection token BEFORE promoting to a foreground service with the + // mediaProjection FGS type. On service recreation (system redelivery + // or a stale relaunch) the consent token is gone — promoting first and + // then discovering the dead token causes a spurious foreground-service + // start + immediate stop, which on strict OEMs flickers the + // notification or trips a stopSelf loop. Bail out cleanly here, before + // startForeground, when the MediaProjection consent data is missing. val localIp = NetworkUtils.getLocalIpAddress(this) ?: "—" val url = "http://$localIp:$SERVER_PORT" + + val mediaProjectionResultData: Intent? = + if (!useRoot) extractProjectionResultData(intent) else null + if (!useRoot && (intent == null || mediaProjectionResultData == null)) { + // MediaProjection mode can't recover from a redelivery — + // the consent token in the original intent is single-use. + // + // We were launched via startForegroundService(), so the OS REQUIRES + // a startForeground() within ~5s even on this immediate-stop path, + // or it raises the fatal ForegroundServiceDidNotStartInTimeException. + // Promote with a benign SPECIAL_USE type (NOT mediaProjection — we + // have no valid consent token, and requesting that type without an + // active projection is exactly what we're avoiding) just long enough + // to satisfy the contract, then stop. + Log.w(TAG, "MediaProjection start without a valid consent token — stopping") + runCatching { + val bailType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + } else { + 0 + } + ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(url), bailType) + }.onFailure { Log.w(TAG, "Bail-path startForeground failed: ${it.message}") } + stopSelf() + return START_NOT_STICKY + } + + // startForeground must be called IMMEDIATELY after the token check — + // before any heavier work like getMediaProjection(). The service type + // must match the work; pass it explicitly via ServiceCompat so we stay + // compatible back to API 24. The MEDIA_PROJECTION type is only used + // here once resultData is confirmed non-null (checked above). try { val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { var t = if (useRoot) { @@ -152,20 +188,13 @@ class CaptureService : Service() { // otherwise `isRunning=true` sticks forever when startForeground throws. isRunning = true - if (intent == null && !useRoot) { - // MediaProjection mode can't recover from a redelivery — - // the consent token in the original intent is single-use. - Log.w(TAG, "Service restarted without intent (MediaProjection mode) — stopping") - isRunning = false - stopSelf() - return START_NOT_STICKY - } - try { if (useRoot) { startRootCapture(url) } else { - startMediaProjectionCapture(intent!!, url) + // mediaProjectionResultData is guaranteed non-null here — the + // token was validated before startForeground above. + startMediaProjectionCapture(intent!!, mediaProjectionResultData!!, url) } } catch (e: Exception) { Log.e(TAG, "Failed to start capture", e) @@ -294,20 +323,24 @@ class CaptureService : Service() { } } - private fun startMediaProjectionCapture(intent: Intent, url: String) { - val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0) + /** + * Extract the single-use MediaProjection consent token from the start + * intent, or null if the intent is missing/redelivered without it. + * Called BEFORE startForeground so the mediaProjection FGS type is only + * ever requested when a valid token is present (see onStartCommand). + */ + private fun extractProjectionResultData(intent: Intent?): Intent? { + if (intent == null) return null @Suppress("DEPRECATION") - val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java) } else { intent.getParcelableExtra(EXTRA_RESULT_DATA) } + } - if (resultData == null) { - Log.e(TAG, "No MediaProjection result data") - stopSelf() - return - } + private fun startMediaProjectionCapture(intent: Intent, resultData: Intent, url: String) { + val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0) val projectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager diff --git a/android/app/src/main/java/com/ledgrab/android/LedGrabNotificationListener.kt b/android/app/src/main/java/com/ledgrab/android/LedGrabNotificationListener.kt index ea79608..14f92bb 100644 --- a/android/app/src/main/java/com/ledgrab/android/LedGrabNotificationListener.kt +++ b/android/app/src/main/java/com/ledgrab/android/LedGrabNotificationListener.kt @@ -6,7 +6,9 @@ import android.service.notification.StatusBarNotification import android.util.Log import com.chaquo.python.Python import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException /** * Captures posted OS notifications and forwards the posting app's display @@ -25,7 +27,20 @@ class LedGrabNotificationListener : NotificationListenerService() { // Serial executor: the Python receiver does a (non-concurrency-safe) history // disk write and may play a sound, so pushes must not overlap. Off the main // looper to keep the system service responsive. - private val pushExecutor = Executors.newSingleThreadExecutor() + // + // Tied to the listener-connection lifecycle (onListenerConnected / + // onListenerDisconnected), NOT onDestroy: this is a system-rebindable + // service, so it can be connected/disconnected multiple times across a + // single onCreate..onDestroy span. Managing the executor here — combined + // with the runCatching guard at the submit site — keeps a notification + // that races teardown from triggering RejectedExecutionException on a + // shut-down executor. @Volatile so the connect/disconnect callbacks (which + // may run on a different thread than onNotificationPosted) publish safely. + @Volatile private var pushExecutor: ExecutorService? = null + + // Guards executor creation so the lazy submit-site fallback and + // onListenerConnected can't race two executors into existence. + private val executorLock = Any() // packageName -> resolved human-readable label. Matches the app_name the // Windows/Linux backends pass, so per-app colors/filters keep working. @@ -51,17 +66,34 @@ class LedGrabNotificationListener : NotificationListenerService() { val label = resolveAppLabel(notification.packageName) - pushExecutor.execute { - try { - Python.getInstance() - .getModule(PY_MODULE) - .callAttr("push_notification", label) - } catch (t: Throwable) { - // Never crash a system-bound service. Python.getInstance() throws - // IllegalStateException if Python.start() hasn't run (e.g. the - // service was bound at boot before the app process initialized). - // Log at debug — the label is potentially sensitive on a shared TV. - Log.d(TAG, "push_notification failed: ${t.message}") + // Obtain (creating if needed) the executor. onListenerConnected normally + // creates it, but that callback is not reliably invoked on every + // OEM/version (re)bind, and a notification can arrive before it fires — + // lazily creating here keeps a missing/late onListenerConnected from + // permanently disabling notification forwarding. A late submit onto an + // executor that onListenerDisconnected is shutting down throws + // RejectedExecutionException — guard with runCatching so a notification + // racing teardown can never crash this system-bound service. + val executor = ensureExecutor() + runCatching { + executor.execute { + try { + Python.getInstance() + .getModule(PY_MODULE) + .callAttr("push_notification", label) + } catch (t: Throwable) { + // Never crash a system-bound service. Python.getInstance() throws + // IllegalStateException if Python.start() hasn't run (e.g. the + // service was bound at boot before the app process initialized). + // Log at debug — the label is potentially sensitive on a shared TV. + Log.d(TAG, "push_notification failed: ${t.message}") + } + } + }.onFailure { e -> + if (e is RejectedExecutionException) { + Log.d(TAG, "push rejected — listener disconnecting") + } else { + throw e } } } @@ -69,24 +101,59 @@ class LedGrabNotificationListener : NotificationListenerService() { /** Resolve (and cache) a package's human-readable label; fall back to the package name. */ private fun resolveAppLabel(pkg: String): String { labelCache[pkg]?.let { return it } + // Only cache SUCCESSFUL resolutions. Caching the package-name fallback + // would permanently pin a wrong label if the PackageManager lookup + // failed transiently (e.g. the app was mid-install / still updating). val resolved = runCatching { val info = packageManager.getApplicationInfo(pkg, 0) packageManager.getApplicationLabel(info).toString() - }.getOrDefault(pkg) - labelCache[pkg] = resolved - return resolved + }.getOrNull() + if (resolved != null) { + labelCache[pkg] = resolved + return resolved + } + return pkg + } + + /** + * Return the push executor, creating it under [executorLock] if absent. + * Safe against a concurrent onListenerConnected/onNotificationPosted race + * (single executor) and against a missing onListenerConnected callback. + */ + private fun ensureExecutor(): ExecutorService { + pushExecutor?.let { return it } + synchronized(executorLock) { + return pushExecutor ?: Executors.newSingleThreadExecutor().also { pushExecutor = it } + } } override fun onListenerConnected() { Log.i(TAG, "Notification listener connected") + // Spin up the push executor on connect. The system can disconnect and + // later reconnect this service without destroying it, so own the + // executor here rather than in onCreate/onDestroy. onNotificationPosted + // also lazily creates it (via ensureExecutor) in case this callback is + // late or skipped on some ROMs. + ensureExecutor() } override fun onListenerDisconnected() { Log.i(TAG, "Notification listener disconnected") + // Tear the executor down on disconnect; a fresh one is created on the + // next onListenerConnected. Null out first so any in-flight + // onNotificationPosted snapshots see null (skips submit) rather than + // racing a shutdown executor. + pushExecutor?.let { exec -> + pushExecutor = null + exec.shutdown() + } } override fun onDestroy() { - pushExecutor.shutdown() + // Defensive: onListenerDisconnected normally clears this first, but + // shut down here too in case onDestroy fires without a prior disconnect. + pushExecutor?.shutdown() + pushExecutor = null super.onDestroy() } diff --git a/android/app/src/main/java/com/ledgrab/android/MainActivity.kt b/android/app/src/main/java/com/ledgrab/android/MainActivity.kt index d6a3ff3..b28831c 100644 --- a/android/app/src/main/java/com/ledgrab/android/MainActivity.kt +++ b/android/app/src/main/java/com/ledgrab/android/MainActivity.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext /** @@ -56,6 +57,7 @@ class MainActivity : Activity() { private const val REQUEST_POST_NOTIFICATIONS = 1002 private const val REQUEST_RECORD_AUDIO = 1003 private const val REQUEST_CAMERA = 1004 + private const val REQUEST_BLUETOOTH = 1005 private const val QR_SIZE_PX = 560 private const val NOTIF_PREFS = "ledgrab_notif" private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted" @@ -189,7 +191,13 @@ class MainActivity : Activity() { toggleButton.text = getString(R.string.btn_starting) statusText.text = getString(R.string.status_checking_root) uiScope.launch(Dispatchers.IO) { - val rooted = Root.requestGrant() + // runInterruptible so a config change (rotation) during the + // up-to-10s `su` probe cancels the coroutine AND interrupts the + // blocking probe thread — Root.requestGrant honours the interrupt, + // destroys the su child, and rethrows, so we don't leak the + // process + drain thread. Without this, IO-dispatcher cancellation + // would not interrupt the blocking waitFor(). + val rooted = runInterruptible { Root.requestGrant() } withContext(Dispatchers.Main) { toggleButton.isEnabled = true toggleButton.text = originalText @@ -214,6 +222,7 @@ class MainActivity : Activity() { ensureNotificationPermission() ensureNotificationListenerAccess() ensureCameraPermission() + ensureBluetoothPermissions() ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this)) updateUI() } @@ -236,6 +245,7 @@ class MainActivity : Activity() { ensureNotificationListenerAccess() ensureAudioPermission() ensureCameraPermission() + ensureBluetoothPermissions() val intent = CaptureService.createIntent(this, resultCode, resultData) ContextCompat.startForegroundService(this, intent) updateUI() @@ -536,6 +546,30 @@ class MainActivity : Activity() { } } + /** + * Request BLUETOOTH_SCAN + BLUETOOTH_CONNECT (API 31+) so the embedded + * server can discover and drive BLE LED controllers (SP110E / Triones / + * Zengge). On API < 31 these are install-time legacy permissions + * (BLUETOOTH / BLUETOOTH_ADMIN / ACCESS_FINE_LOCATION, maxSdk=30) and + * need no runtime grant — so this is a no-op there. Fire-and-forget, + * like [ensureAudioPermission]: screen capture works without BLE, and + * BleBridge degrades gracefully (empty scan / failed connect) when the + * grant is denied, so we don't block on the result. If first granted + * here, BLE devices become reachable on the next scan/connect. + */ + private fun ensureBluetoothPermissions() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + val needed = listOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + ).filter { + checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED + } + if (needed.isEmpty()) return + @Suppress("DEPRECATION") + requestPermissions(needed.toTypedArray(), REQUEST_BLUETOOTH) + } + /** Whether the user has granted notification-listener access to this app. */ private fun isNotificationAccessGranted(): Boolean = NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName) diff --git a/android/app/src/main/java/com/ledgrab/android/Root.kt b/android/app/src/main/java/com/ledgrab/android/Root.kt index 2576bc7..e3ab186 100644 --- a/android/app/src/main/java/com/ledgrab/android/Root.kt +++ b/android/app/src/main/java/com/ledgrab/android/Root.kt @@ -20,6 +20,11 @@ import java.util.concurrent.TimeUnit object Root { private const val TAG = "Root" + // Slice length for the cancellation-aware su probe wait loop. Short + // enough that coroutine cancellation is honoured promptly, long enough + // to avoid busy-spinning while Magisk's grant dialog is up. + private const val POLL_SLICE_MS = 100L + private val SU_PATHS = listOf( "/system/bin/su", "/system/xbin/su", @@ -49,17 +54,19 @@ object Root { return false } + var process: Process? = null val granted = try { // redirectErrorStream merges stderr into stdout so a single // drain thread is enough — avoids the classic pipe-buffer // deadlock where waitFor() blocks because stderr filled up. - val process = ProcessBuilder("su", "-c", "id") + val proc = ProcessBuilder("su", "-c", "id") .redirectErrorStream(true) .start() + process = proc val outputBuilder = StringBuilder() val drain = Thread({ try { - BufferedReader(InputStreamReader(process.inputStream)).use { r -> + BufferedReader(InputStreamReader(proc.inputStream)).use { r -> val buf = CharArray(512) while (true) { val n = r.read(buf) @@ -72,17 +79,35 @@ object Root { } }, "Root-su-drain").apply { isDaemon = true; start() } - val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS) + // Cancellation-aware wait: callers run this on a coroutine + // (MainActivity wraps it in runInterruptible), so a config change + // mid-probe cancels the coroutine and interrupts this thread. + // Poll waitFor() in short slices and honour interruption so we + // don't leak the `su` child + its drain thread for up to 10s. + // The catch(InterruptedException) below destroys the process; we + // re-arm the interrupt and rethrow so coroutine cancellation + // propagates cleanly. + val deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds) + var finished = false + while (System.nanoTime() < deadlineNanos) { + if (proc.waitFor(POLL_SLICE_MS, TimeUnit.MILLISECONDS)) { + finished = true + break + } + // Throws InterruptedException if the thread was interrupted + // by coroutine cancellation — handled below to tear down. + if (Thread.interrupted()) throw InterruptedException("su probe cancelled") + } if (!finished) { - process.destroyForcibly() + proc.destroyForcibly() drain.join(500) Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s") false } else { drain.join(500) val output = synchronized(outputBuilder) { outputBuilder.toString() } - if (process.exitValue() != 0) { - Log.w(TAG, "su -c id exited with ${process.exitValue()} output='${output.trim()}'") + if (proc.exitValue() != 0) { + Log.w(TAG, "su -c id exited with ${proc.exitValue()} output='${output.trim()}'") false } else { val rooted = output.contains("uid=0") @@ -90,8 +115,17 @@ object Root { rooted } } + } catch (e: InterruptedException) { + // Coroutine cancelled mid-probe (e.g. config change). Kill the + // su child so it doesn't outlive the cancelled work, re-arm the + // interrupt flag, and rethrow so the coroutine cancels cleanly. + // Do NOT cache a result — the probe never completed. + runCatching { process?.destroyForcibly() } + Thread.currentThread().interrupt() + throw e } catch (e: Exception) { Log.w(TAG, "su invocation failed: ${e.message}") + runCatching { process?.destroyForcibly() } false } diff --git a/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt b/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt index 93d9534..8547940 100644 --- a/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt +++ b/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt @@ -89,14 +89,15 @@ class RootScreenrecord( running = true try { - imageReader = buildImageReader() - decoder = buildDecoder(imageReader!!) - process = spawnScreenrecord() ?: run { + val reader = buildImageReader().also { imageReader = it } + val codec = buildDecoder(reader).also { decoder = it } + val proc = spawnScreenrecord() ?: run { stop() return false } - startInputPump(process!!.inputStream, decoder!!) - startOutputDrain(decoder!!) + process = proc + startInputPump(proc.inputStream, codec) + startOutputDrain(codec) Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)") return true } catch (e: Exception) { @@ -178,6 +179,14 @@ class RootScreenrecord( buffer.get(frameBuffer, row * rowBytes, rowBytes) } } + // CONTRACT: frameBuffer is REUSED across frames (single-threaded + // reader callback — no copy here). Safety depends on the Python + // receiver copying the bytes before this callback returns and + // overwrites the buffer for the next frame. It does: + // PythonBridge.pushRootFrame → root_screenrecord_engine.push_frame + // (server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py) + // does `rgba[:, :, :3].copy()`, so the queued frame owns its + // pixels independently of this buffer. Do NOT remove that copy. bridge.pushRootFrame(frameBuffer, width, height) framesDeliveredCounter.incrementAndGet() } catch (e: Exception) { diff --git a/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt b/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt index 2ce7c52..53ed275 100644 --- a/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt +++ b/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt @@ -147,6 +147,14 @@ class ScreenCapture( } } + // CONTRACT: frameBuffer is REUSED across frames (single-threaded + // capture handler — no copy here). Safety depends on the Python + // receiver copying the bytes before this callback returns and + // overwrites the buffer for the next frame. It does: + // PythonBridge.pushFrame → mediaprojection_engine.push_frame + // (server/src/ledgrab/core/capture_engines/mediaprojection_engine.py) + // does `rgba[:, :, :3].copy()`, so the queued frame owns its + // pixels independently of this buffer. Do NOT remove that copy. bridge.pushFrame(frameBuffer, captureWidth, captureHeight) // Advance the pacing accumulator. If we fell badly behind diff --git a/server/src/ledgrab/api/routes/game_integration.py b/server/src/ledgrab/api/routes/game_integration.py index b07f7e5..40b06ea 100644 --- a/server/src/ledgrab/api/routes/game_integration.py +++ b/server/src/ledgrab/api/routes/game_integration.py @@ -6,6 +6,7 @@ adapter metadata, and diagnostics. import threading import time +from collections import defaultdict from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request @@ -57,6 +58,73 @@ _prev_states: dict[str, dict[str, Any]] = {} _integration_stats: dict[str, dict[str, Any]] = {} +# ── Failed-auth rate limiter (brute-force defence on the ingest route) ───── +# +# The ingest route is high-frequency (games push at 16-64 Hz), so we do NOT +# rate-limit every event — that would throttle legitimate gameplay traffic. +# Instead we throttle only FAILED-auth attempts per source IP (the only thing +# an attacker without the token can produce). This mirrors the IP-based +# limiter in routes/webhooks.py (~30/min) but scopes it to failures so a +# brute-forcer is locked out after _AUTH_FAIL_LIMIT bad tokens per minute +# while authenticated high-rate ingestion is completely unaffected. +_AUTH_FAIL_LIMIT = 30 +_AUTH_FAIL_WINDOW = 60.0 # seconds +_AUTH_FAIL_HITS_HARD_CAP = 1024 +_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) +_auth_fail_hits: dict[str, list[float]] = defaultdict(list) +_auth_fail_lock = threading.Lock() + + +def _rate_limit_key(request: Request) -> str: + """Pick a stable client identifier for rate-limiting. + + When the immediate peer is loopback (assumed reverse-proxy), use the + first ``X-Forwarded-For`` entry; otherwise use the peer's IP. + """ + peer = request.client.host if request.client else "unknown" + if peer in _LOOPBACK_HOSTS: + xff = request.headers.get("x-forwarded-for", "") + if xff: + return xff.split(",", 1)[0].strip() or peer + return peer + + +def _check_auth_fail_rate_limit(client_ip: str) -> None: + """Raise 429 if *client_ip* exceeded the failed-auth attempt limit.""" + now = time.time() + window_start = now - _AUTH_FAIL_WINDOW + with _auth_fail_lock: + timestamps = [t for t in _auth_fail_hits[client_ip] if t > window_start] + _auth_fail_hits[client_ip] = timestamps + if len(timestamps) >= _AUTH_FAIL_LIMIT: + raise HTTPException( + status_code=429, + detail="Too many failed authentication attempts. Try again later.", + ) + + +def _record_auth_failure(client_ip: str) -> None: + """Record a failed-auth attempt for *client_ip* (bounded memory).""" + now = time.time() + window_start = now - _AUTH_FAIL_WINDOW + with _auth_fail_lock: + _auth_fail_hits[client_ip].append(now) + # Periodic cleanup of stale IPs to prevent unbounded growth. + if len(_auth_fail_hits) > 100: + stale = [ip for ip, ts in _auth_fail_hits.items() if not ts or ts[-1] < window_start] + for ip in stale: + del _auth_fail_hits[ip] + # Hard cap against an attacker spraying many distinct X-Forwarded-For + # values; drop the oldest-touched IPs. + if len(_auth_fail_hits) > _AUTH_FAIL_HITS_HARD_CAP: + ordered = sorted( + _auth_fail_hits.items(), + key=lambda kv: kv[1][-1] if kv[1] else 0.0, + ) + for ip, _ in ordered[: len(ordered) - _AUTH_FAIL_HITS_HARD_CAP]: + _auth_fail_hits.pop(ip, None) + + def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]: """Convert a JSON Schema object into a flat list of field descriptors. @@ -387,7 +455,16 @@ async def ingest_event( called before standard API auth. No AuthRequired dependency — adapter-level auth is used instead. + + Rate limiting is scoped to FAILED-auth attempts per source IP (see + ``_check_auth_fail_rate_limit``) so legitimate high-rate ingestion is + never throttled, but a brute-forcer is locked out after the threshold. """ + client_ip = _rate_limit_key(request) + # Block IPs that have already burned through the failed-auth budget, + # before doing any work (cheap brute-force lockout). + _check_auth_fail_rate_limit(client_ip) + try: config = store.get_integration(integration_id) except EntityNotFoundError: @@ -405,6 +482,7 @@ async def ingest_event( # Adapter-level auth check headers = dict(request.headers) if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config): + _record_auth_failure(client_ip) raise HTTPException(status_code=403, detail="Adapter authentication failed") # Parse payload through adapter diff --git a/server/src/ledgrab/api/routes/home_assistant.py b/server/src/ledgrab/api/routes/home_assistant.py index d982831..0ffeceb 100644 --- a/server/src/ledgrab/api/routes/home_assistant.py +++ b/server/src/ledgrab/api/routes/home_assistant.py @@ -2,6 +2,7 @@ import asyncio import json +from urllib.parse import urlparse from fastapi import APIRouter, Depends, HTTPException, Query @@ -28,6 +29,7 @@ from ledgrab.storage.base_store import EntityNotFoundError from ledgrab.storage.home_assistant_source import HomeAssistantSource from ledgrab.storage.home_assistant_store import HomeAssistantStore from ledgrab.utils import get_logger +from ledgrab.utils.net_classify import validate_lan_host logger = get_logger(__name__) @@ -37,6 +39,23 @@ router = APIRouter() _REDACTED_TOKEN = "***" +def _validate_ha_host(host: str | None) -> None: + """Reject literal public/link-local/metadata IPs for a HA source host. + + HA sources are LAN-by-design (loopback + private ranges allowed), so we + gate the user-supplied ``host`` with the same shared classifier the LED + device providers use (``validate_lan_host``). The HA host is stored as + ``host:port`` (e.g. ``192.168.1.100:8123``), so strip the port first via + ``urlparse`` — which also handles bracketed IPv6 literals. Hostnames / + mDNS labels pass through (classified UNPARSEABLE). Raises ``ValueError`` + on a literal public IP, which the callers translate to HTTP 400. + """ + if not host: + return + bare_host = urlparse(f"//{host.strip()}").hostname or host.strip() + validate_lan_host(bare_host) + + def _to_response( source: HomeAssistantSource, manager: HomeAssistantManager, @@ -99,6 +118,7 @@ async def create_ha_source( manager: HomeAssistantManager = Depends(get_ha_manager), ): try: + _validate_ha_host(data.host) source = store.create_source( name=data.name, host=data.host, @@ -153,6 +173,7 @@ async def update_ha_source( manager: HomeAssistantManager = Depends(get_ha_manager), ): try: + _validate_ha_host(data.host) source = store.update_source( source_id, name=data.name, diff --git a/server/src/ledgrab/core/capture/calibration.py b/server/src/ledgrab/core/capture/calibration.py index 2d3af4c..b29c112 100644 --- a/server/src/ledgrab/core/capture/calibration.py +++ b/server/src/ledgrab/core/capture/calibration.py @@ -824,6 +824,30 @@ def create_default_calibration( right_count = max(1, right_count) left_count = max(1, left_count) + # The max(1, ...) floors above can push the total above led_count for + # small counts (e.g. led_count=5 -> top=2,right=1,bottom=2,left=1 = 6). + # Trim the largest edge that stays >= 1 until the total matches exactly. + edge_order = ["bottom", "top", "right", "left"] + counts = { + "bottom": bottom_count, + "top": top_count, + "right": right_count, + "left": left_count, + } + overshoot = sum(counts.values()) - led_count + while overshoot > 0: + # Pick the largest edge that can still be reduced (stays >= 1). + trimmable = [e for e in edge_order if counts[e] > 1] + if not trimmable: + break + target_edge = max(trimmable, key=lambda e: counts[e]) + counts[target_edge] -= 1 + overshoot -= 1 + bottom_count = counts["bottom"] + top_count = counts["top"] + right_count = counts["right"] + left_count = counts["left"] + config = CalibrationConfig( layout="clockwise", start_position="bottom_left", diff --git a/server/src/ledgrab/core/devices/ddp_client.py b/server/src/ledgrab/core/devices/ddp_client.py index 7c3ac41..d93e6af 100644 --- a/server/src/ledgrab/core/devices/ddp_client.py +++ b/server/src/ledgrab/core/devices/ddp_client.py @@ -154,6 +154,15 @@ class DDPClient: all buses (observed in multi-bus setups). We reorder pixel channels here so the hardware receives the correct byte order directly. + TODO(ddp-multibus): currently UNUSED — ``send_pixels_numpy`` (the hot + send path) does NOT call this, so the per-bus color-order config + captured by ``set_buses`` is never applied to outgoing pixels. This + is intentionally left in place (not deleted) because it encodes real + multi-bus handling: if a multi-bus WLED setup needs per-bus byte + reordering, wire this into ``send_pixels_numpy`` before the payload + view is built (note it allocates a copy, so only call it when + ``self._buses`` actually requires reordering). + Args: pixel_array: (N, 3) uint8 numpy array in RGB order diff --git a/server/src/ledgrab/core/game_integration/adapters/cs2_adapter.py b/server/src/ledgrab/core/game_integration/adapters/cs2_adapter.py index 4355b38..a63f54e 100644 --- a/server/src/ledgrab/core/game_integration/adapters/cs2_adapter.py +++ b/server/src/ledgrab/core/game_integration/adapters/cs2_adapter.py @@ -260,7 +260,10 @@ class CS2Adapter(GameAdapter): auth_section = payload.get("auth", {}) actual_token = auth_section.get("token", "") - return bool(actual_token and actual_token == expected_token) + if not actual_token: + return False + # Constant-time comparison to avoid a timing oracle. + return secrets.compare_digest(actual_token, expected_token) @classmethod def get_config_schema(cls) -> dict[str, Any]: diff --git a/server/src/ledgrab/core/game_integration/adapters/dota2_adapter.py b/server/src/ledgrab/core/game_integration/adapters/dota2_adapter.py index 0c1a1ba..35a170e 100644 --- a/server/src/ledgrab/core/game_integration/adapters/dota2_adapter.py +++ b/server/src/ledgrab/core/game_integration/adapters/dota2_adapter.py @@ -177,7 +177,10 @@ class Dota2Adapter(GameAdapter): auth_section = payload.get("auth", {}) actual_token = auth_section.get("token", "") - return bool(actual_token and actual_token == expected_token) + if not actual_token: + return False + # Constant-time comparison to avoid a timing oracle. + return secrets.compare_digest(actual_token, expected_token) @classmethod def get_config_schema(cls) -> dict[str, Any]: diff --git a/server/src/ledgrab/core/game_integration/adapters/generic_webhook_adapter.py b/server/src/ledgrab/core/game_integration/adapters/generic_webhook_adapter.py index c699d3e..f875773 100644 --- a/server/src/ledgrab/core/game_integration/adapters/generic_webhook_adapter.py +++ b/server/src/ledgrab/core/game_integration/adapters/generic_webhook_adapter.py @@ -4,6 +4,7 @@ Allows users to define custom JSON path mappings via the adapter_config rather than a YAML file. Delegates all parsing logic to MappingAdapter. """ +import secrets from typing import Any, ClassVar from ledgrab.core.game_integration.base_adapter import GameAdapter @@ -54,11 +55,18 @@ class GenericWebhookAdapter(GameAdapter): payload: dict[str, Any], adapter_config: dict[str, Any], ) -> bool: - """Validate auth using a configurable header token.""" + """Validate auth using a configurable header token. + + Secure-by-default: this adapter is explicitly network-facing + (it accepts unauthenticated HTTP POSTs from anywhere on the LAN), + so a missing/empty ``auth_token`` REJECTS the request rather than + accepting it. A token must be configured for ingestion to work. + """ expected_token = adapter_config.get("auth_token") if not expected_token: - # No auth configured - return True + # No token configured — reject (secure-by-default for a + # network-facing adapter; an open webhook is a LAN attack surface). + return False auth_header = adapter_config.get("auth_header", "Authorization") actual_value = headers.get(auth_header, "") @@ -67,18 +75,27 @@ class GenericWebhookAdapter(GameAdapter): if actual_value.startswith("Bearer "): actual_value = actual_value[7:] - return bool(actual_value and actual_value == expected_token) + if not actual_value: + return False + + # Constant-time comparison to avoid a token-length/timing oracle. + return secrets.compare_digest(actual_value, expected_token) @classmethod def get_config_schema(cls) -> dict[str, Any]: """Return generic webhook config schema.""" return { "type": "object", + "required": ["auth_token"], "properties": { "auth_token": { "type": "string", "title": "Auth Token", - "description": "Optional token for authenticating incoming webhooks.", + "description": ( + "Required token for authenticating incoming webhooks. " + "Without it, ingestion is rejected (this adapter is " + "network-facing and secure-by-default)." + ), }, "auth_header": { "type": "string", @@ -136,15 +153,17 @@ class GenericWebhookAdapter(GameAdapter): "HTTP POST requests with JSON payloads.\n\n" "**Steps:**\n" "1. Configure your event mappings above — map JSON paths to standard events\n" - "2. Set an auth token (optional but recommended)\n" + "2. Set an auth token (REQUIRED — without it incoming webhooks are rejected)\n" "3. Point your game/application to:\n" " `POST http://:8080/api/v1/game-integrations//event`\n\n" "**Mapping example:**\n" "- Source path: `player.stats.health` → Event: `health` (min: 0, max: 100)\n" "- Source path: `events.kill_count` → Event: `kill` (trigger: on_increase)\n\n" - "**Auth:**\n" + "**Auth (required):**\n" "- Set `Authorization: Bearer ` header in your webhook sender\n" "- Or configure a custom auth header name in the adapter config\n" + "- A token is mandatory: this endpoint is reachable from the LAN, so\n" + " an unconfigured token rejects all incoming events.\n" ) diff --git a/server/src/ledgrab/core/game_integration/mapping_adapter.py b/server/src/ledgrab/core/game_integration/mapping_adapter.py index d59a2e6..808daaa 100644 --- a/server/src/ledgrab/core/game_integration/mapping_adapter.py +++ b/server/src/ledgrab/core/game_integration/mapping_adapter.py @@ -10,6 +10,7 @@ The MappingAdapter class is a concrete GameAdapter whose behavior is entirely driven by the parsed YAML definition. """ +import secrets import time from pathlib import Path from typing import Any @@ -241,7 +242,10 @@ class MappingAdapter(GameAdapter): expected_key = "auth_token" expected_value = adapter_config.get(expected_key, "") actual_value = headers.get(header_name, "") - return bool(expected_value and actual_value == expected_value) + if not (expected_value and actual_value): + return False + # Constant-time comparison to avoid a timing oracle. + return secrets.compare_digest(actual_value, expected_value) logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'") return False diff --git a/server/src/ledgrab/core/processing/composite_stream.py b/server/src/ledgrab/core/processing/composite_stream.py index e3a5c9d..1f53b6d 100644 --- a/server/src/ledgrab/core/processing/composite_stream.py +++ b/server/src/ledgrab/core/processing/composite_stream.py @@ -69,6 +69,9 @@ class CompositeColorStripStream(ColorStripStream): # (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing self._resize_cache: Dict[tuple, tuple] = {} + # (src_len, target_n) -> (src_x, dst_x) cache for full-strip resizing + # (output reuses the preallocated self._resize_buf from _ensure_pool) + self._resize_linspace_cache: Dict[tuple, tuple] = {} # layer_index -> (source_id, consumer_id, stream) self._sub_streams: Dict[int, tuple] = {} # layer_index -> (vs_id, value_stream) @@ -314,8 +317,14 @@ class CompositeColorStripStream(ColorStripStream): n_src = len(colors) if n_src == target_n: return colors - src_x = np.linspace(0, 1, n_src) - dst_x = np.linspace(0, 1, target_n) + # Cache the (src_x, dst_x) linspace arrays keyed by (n_src, target_n) + # exactly like the zone path, so they are not reallocated every frame. + lkey = (n_src, target_n) + linspaces = self._resize_linspace_cache.get(lkey) + if linspaces is None: + linspaces = (np.linspace(0, 1, n_src), np.linspace(0, 1, target_n)) + self._resize_linspace_cache[lkey] = linspaces + src_x, dst_x = linspaces buf = self._resize_buf for ch in range(3): np.copyto( diff --git a/server/src/ledgrab/core/processing/device_test_mode.py b/server/src/ledgrab/core/processing/device_test_mode.py index 7a8227f..aa15226 100644 --- a/server/src/ledgrab/core/processing/device_test_mode.py +++ b/server/src/ledgrab/core/processing/device_test_mode.py @@ -137,16 +137,34 @@ class DeviceTestModeMixin: await self._send_pixels_to_device(device_id, pixels) async def _send_clear_pixels(self, device_id: str) -> None: - """Send all-black pixels to clear LED output.""" + """Send all-black pixels to clear LED output. + + This is the explicit teardown path — unlike the per-frame + ``_send_pixels_to_device`` swallow, a clear that must actually take + effect retries once before giving up, so a single transient send + error doesn't leave the device lit after a session ends. + """ ds = self._devices[device_id] pixels = [(0, 0, 0)] * ds.led_count - await self._send_pixels_to_device(device_id, pixels) + try: + client = await self._get_idle_client(device_id) + await client.send_pixels(pixels) + except Exception as e: + logger.warning(f"Clear send to {device_id} failed, retrying once: {e}") + client = await self._get_idle_client(device_id) + await client.send_pixels(pixels) async def _send_pixels_to_device(self, device_id: str, pixels) -> None: """Send pixels to a device via cached idle client. Reuses a cached connection to avoid repeated serial reconnections (which trigger Arduino bootloader reset on Adalight devices). + + Send failures are logged and swallowed (best-effort per-frame send). + Callers that need a *guaranteed* clear — e.g. session teardown that + must "never leave the device dark" — must NOT rely on this returning + cleanly; use ``_send_clear_pixels`` (which retries once) and treat a + propagated exception as a failed clear. """ try: client = await self._get_idle_client(device_id) diff --git a/server/src/ledgrab/core/processing/effect_stream.py b/server/src/ledgrab/core/processing/effect_stream.py index 4b98069..e63b61c 100644 --- a/server/src/ledgrab/core/processing/effect_stream.py +++ b/server/src/ledgrab/core/processing/effect_stream.py @@ -973,13 +973,18 @@ class EffectColorStripStream(ColorStripStream): # Use noise at very low frequency for blob movement np.multiply(self._s_arange, scale * 0.03, out=self._s_f32_a) - # Two blob layers at different speeds for organic movement + # Two blob layers at different speeds for organic movement. + # fbm() returns a shared internal buffer that the next fbm() call + # overwrites, so each layer must be copied out — write into the + # preallocated scratch buffers instead of allocating per frame. self._s_f32_a += t * speed * 0.1 - layer1 = self._noise.fbm(self._s_f32_a, octaves=3).copy() + layer1 = self._s_layer1 + np.copyto(layer1, self._noise.fbm(self._s_f32_a, octaves=3)) np.multiply(self._s_arange, scale * 0.05, out=self._s_f32_a) self._s_f32_a += t * speed * 0.07 + 100.0 - layer2 = self._noise.fbm(self._s_f32_a, octaves=2).copy() + layer2 = self._s_layer2 + np.copyto(layer2, self._noise.fbm(self._s_f32_a, octaves=2)) # Combine: create blob-like shapes with soft edges combined = self._s_f32_a diff --git a/server/src/ledgrab/core/processing/wled_target_processor.py b/server/src/ledgrab/core/processing/wled_target_processor.py index b820522..6bd0a59 100644 --- a/server/src/ledgrab/core/processing/wled_target_processor.py +++ b/server/src/ledgrab/core/processing/wled_target_processor.py @@ -739,10 +739,19 @@ class WledTargetProcessor(TargetProcessor): self._last_preview_data = data - async def _send_safe(ws): + # Bound each per-client send to roughly one frame interval so a slow or + # backpressured preview WebSocket can never throttle the device send + # cadence. Clients that time out or error are dropped from the set. + eff_fps = self._effective_fps if self._effective_fps > 0 else 30 + send_timeout = 1.0 / eff_fps + + async def _send_safe(ws) -> bool: try: - await ws.send_bytes(data) + await asyncio.wait_for(ws.send_bytes(data), timeout=send_timeout) return True + except asyncio.TimeoutError: + logger.debug("LED preview broadcast WS send timed out (slow client dropped)") + return False except Exception as e: logger.debug("LED preview broadcast WS send failed: %s", e) return False diff --git a/server/src/ledgrab/core/scenes/playlist_engine.py b/server/src/ledgrab/core/scenes/playlist_engine.py index 71a0763..1a401ef 100644 --- a/server/src/ledgrab/core/scenes/playlist_engine.py +++ b/server/src/ledgrab/core/scenes/playlist_engine.py @@ -138,22 +138,42 @@ class PlaylistEngine: """Stop the playlist only if ``playlist_id`` is the one running. Used when a playlist is deleted or edited so a stale snapshot can't keep - cycling. + cycling. The read-compare and the stop happen atomically under the + lifecycle lock so a concurrent natural-end / start can't slip a + different playlist in between the check and the stop. """ - if self._state is not None and self._state.playlist_id == playlist_id: - await self.stop() + async with self._lifecycle_lock: + state = self._state + if state is None or state.playlist_id != playlist_id: + return + was_running = self._task is not None + await self._cancel_task() + stopped_id = self._state.playlist_id if self._state else playlist_id + self._state = None + if was_running: + self._fire_event("stopped", playlist_id=stopped_id) + logger.info("Playlist stopped") # ===== Query API (used by routes) ===== def is_running(self) -> bool: - return self._task is not None and not self._task.done() + # Snapshot the task ref so a concurrent clear (set to None at an await + # boundary) can't turn the deref into an attribute error mid-read. + task = self._task + return task is not None and not task.done() def get_running_playlist_id(self) -> str | None: - return self._state.playlist_id if self._state else None + state = self._state + return state.playlist_id if state else None def get_state(self) -> dict: - if self._state is not None and self.is_running(): - return self._state.to_dict() + # Snapshot both refs once: the event loop won't preempt this sync method + # between the reads, but snapshotting also guards against ever returning + # a half-cleared state (running True while _state is already None). + state = self._state + task = self._task + if state is not None and task is not None and not task.done(): + return state.to_dict() return dict(_IDLE_STATE) # ===== Internal ===== @@ -201,13 +221,25 @@ class PlaylistEngine: break # Natural end (non-loop or guard). Clear state without recursing - # through stop() (which would try to cancel this very task). Guard - # against a concurrent start_playlist having already replaced us: - # only clear if we are still the engine's current task. - if self._task is asyncio.current_task(): - self._task = None - ended_id = self._state.playlist_id if self._state else None - self._state = None + # through stop() (which would try to cancel this very task). Take + # the lifecycle lock so the clear + 'stopped' event are atomic with + # respect to a concurrent start/stop/stop_if_running — otherwise the + # two could interleave and emit duplicate or contradictory terminal + # events. _run never calls _cancel_task and never otherwise holds + # this lock, so acquiring it here cannot deadlock; if a canceller + # holding the lock cancels us while we wait to acquire it, the + # acquire raises CancelledError and we fall through to the handler. + ended_id = None + should_fire = False + async with self._lifecycle_lock: + # Re-check under the lock: a concurrent start_playlist may have + # replaced us while we waited. Only clear if we're still current. + if self._task is asyncio.current_task(): + self._task = None + ended_id = self._state.playlist_id if self._state else None + self._state = None + should_fire = True + if should_fire: self._fire_event("stopped", playlist_id=ended_id) logger.info("Playlist '%s' finished", playlist_id) except asyncio.CancelledError: diff --git a/server/src/ledgrab/main.py b/server/src/ledgrab/main.py index a28402b..2a1b08c 100644 --- a/server/src/ledgrab/main.py +++ b/server/src/ledgrab/main.py @@ -453,6 +453,13 @@ async def lifespan(app: FastAPI): # Stop the playlist engine so its cycling task can't apply scenes mid-shutdown. await _bounded("playlist_engine.stop", playlist_engine.stop(), timeout=1.0) + # Tear down any active calibration session BEFORE stop_all so the device + # isn't left stuck in the white-chase and its prior target is restored. + # stop() is a no-op when no session is active. + from ledgrab.core.capture.calibration_session import get_calibration_session + + await _bounded("calibration_session.stop", get_calibration_session().stop(), timeout=1.0) + # Stop discovery watcher and OS notification listener so they stop # firing events into a shutting-down processor manager. if discovery_watcher is not None: diff --git a/server/src/ledgrab/static/css/calibration.css b/server/src/ledgrab/static/css/calibration.css index b4c5b3c..6cf8f99 100644 --- a/server/src/ledgrab/static/css/calibration.css +++ b/server/src/ledgrab/static/css/calibration.css @@ -79,6 +79,12 @@ opacity: 1; } +.preview-screen-total:focus-visible { + opacity: 1; + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + .preview-screen-total.mismatch { color: #FFC107; } @@ -123,6 +129,12 @@ background: rgba(128, 128, 128, 0.25); } +.edge-toggle:focus-visible { + background: rgba(128, 128, 128, 0.25); + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + .preview-edge.edge-disabled { opacity: 0.25; pointer-events: none; @@ -374,6 +386,13 @@ color: rgba(76, 175, 80, 0.6); } +.preview-corner:focus-visible { + color: rgba(76, 175, 80, 0.6); + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: 4px; +} + .preview-corner.active:hover { transform: none; } @@ -412,6 +431,12 @@ background: rgba(255, 255, 255, 0.25); } +.direction-toggle:focus-visible { + background: rgba(255, 255, 255, 0.25); + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + .direction-toggle #direction-icon { font-size: 14px; } diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css index 2f390dc..d1f8bb5 100644 --- a/server/src/ledgrab/static/css/components.css +++ b/server/src/ledgrab/static/css/components.css @@ -328,7 +328,7 @@ select.field-invalid { display: block; border: 1px solid var(--border-color); border-radius: var(--radius-sm); - background: var(--surface-2, color-mix(in srgb, var(--text-color) 6%, var(--bg-color))); + background: var(--lux-bg-2); overflow: hidden; transition: border-color 0.15s ease, box-shadow 0.15s ease; } @@ -396,10 +396,10 @@ select.field-invalid { /* Token palette — restrained, three accents plus muted operators. */ .jinja-hl .tok-str { color: var(--success-color); } -.jinja-hl .tok-num { color: #d19a66; } +.jinja-hl .tok-num { color: var(--ch-amber); } .jinja-hl .tok-fn { color: var(--primary-color); font-weight: 600; } -.jinja-hl .tok-raw { color: #c678dd; font-style: italic; } -.jinja-hl .tok-var { color: #61afef; } +.jinja-hl .tok-raw { color: var(--ch-violet); font-style: italic; } +.jinja-hl .tok-var { color: var(--ch-cyan); } .jinja-hl .tok-op { color: var(--text-muted); } /* ── Template-input rows ─────────────────────────────────────── */ @@ -496,8 +496,8 @@ select.field-invalid { font-size: 0.76rem; padding: 1px 6px; border-radius: var(--radius-pill); - background: color-mix(in srgb, #61afef 16%, transparent); - color: #61afef; + background: color-mix(in srgb, var(--ch-cyan) 16%, transparent); + color: var(--ch-cyan); } .jinja-hints-examples { margin: 4px 0 0; padding-left: 0; list-style: none; } @@ -918,7 +918,12 @@ input:-webkit-autofill:focus { .discovery-item:focus-visible, .tag-chip-remove:focus-visible, .cs-filter-reset:focus-visible, -.graph-filter-clear:focus-visible { +.graph-filter-clear:focus-visible, +.wizard-discovery-item:focus-visible, +.wizard-display-item:focus-visible, +.autocal-corner-btn:focus-visible, +.autocal-direction-btn:focus-visible, +.scene-target-add-slot:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; } @@ -1118,7 +1123,8 @@ textarea:focus-visible { font-weight: 700; line-height: 1; } -.scene-target-add-slot:hover:not(:disabled) { +.scene-target-add-slot:hover:not(:disabled), +.scene-target-add-slot:focus-visible:not(:disabled) { background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--st-ch) 12%, transparent) 0 6px, @@ -1625,14 +1631,14 @@ textarea:focus-visible { padding: 6px 12px; border: 1px solid var(--border-color, #333); border-radius: 6px; - background: var(--surface-2, #1e1e2e); + background: var(--lux-bg-2); color: var(--text-secondary, #999); font-size: 0.8rem; cursor: pointer; transition: background 0.15s, color 0.15s, border-color 0.15s; } .type-picker-tab:hover { - background: var(--surface-3, #2a2a3e); + background: var(--lux-bg-3); color: var(--text-primary, #e0e0e0); } .type-picker-tab.active { @@ -2326,7 +2332,7 @@ textarea:focus-visible { .autocal-step-desc { font-size: 0.85rem; - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); line-height: 1.5; margin: 0; } @@ -2355,7 +2361,8 @@ textarea:focus-visible { font-weight: 500; text-align: center; } -.autocal-corner-btn:hover { +.autocal-corner-btn:hover, +.autocal-corner-btn:focus-visible { border-color: var(--primary-color); background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg)); color: var(--primary-color); @@ -2424,7 +2431,8 @@ textarea:focus-visible { font-weight: 500; text-align: center; } -.autocal-direction-btn:hover { +.autocal-direction-btn:hover, +.autocal-direction-btn:focus-visible { border-color: var(--primary-color); background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg)); color: var(--primary-color); @@ -2442,7 +2450,7 @@ textarea:focus-visible { border: 1px solid color-mix(in srgb, var(--primary-color) 20%, var(--border-color)); border-radius: var(--radius-sm, 6px); font-size: 0.8rem; - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); } .autocal-led-dot { @@ -2489,7 +2497,7 @@ textarea:focus-visible { justify-content: center; font-size: 0.7rem; font-weight: 700; - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); transition: border-color 0.2s, background 0.2s, color 0.2s; flex-shrink: 0; } @@ -2618,7 +2626,7 @@ textarea:focus-visible { } .autocal-solved-key { - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); flex-shrink: 0; min-width: 68px; } @@ -2720,7 +2728,7 @@ textarea:focus-visible { font-size: 0.7rem; font-weight: 700; background: var(--bg-secondary, var(--bg-2, #2a2a2a)); - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); border: 1.5px solid var(--border-color); transition: background 0.2s, color 0.2s, border-color 0.2s; } @@ -2775,7 +2783,7 @@ textarea:focus-visible { } .wizard-step-desc { font-size: 0.85rem; - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); line-height: 1.5; margin: 0; } @@ -2829,7 +2837,7 @@ textarea:focus-visible { font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); padding-bottom: 4px; } .wizard-section-label--scan { @@ -2860,7 +2868,7 @@ textarea:focus-visible { gap: 10px; padding: 14px 12px; font-size: 0.85rem; - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); background: var(--card-bg); border: 1px solid var(--border-color); border-radius: var(--radius-md, 8px); @@ -2868,7 +2876,7 @@ textarea:focus-visible { .wizard-discovery-empty { padding: 14px 12px; font-size: 0.85rem; - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); background: var(--card-bg); border: 1px solid var(--border-color); border-radius: var(--radius-md, 8px); @@ -2891,7 +2899,8 @@ textarea:focus-visible { width: 100%; transition: border-color 0.15s, background 0.15s; } -.wizard-discovery-item:hover { +.wizard-discovery-item:hover, +.wizard-discovery-item:focus-visible { border-color: var(--primary-color); background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg)); } @@ -2899,7 +2908,7 @@ textarea:focus-visible { .wizard-discovery-icon .icon { width: 20px; height: 20px; } .wizard-discovery-details { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; } .wizard-discovery-name { font-size: 0.88rem; font-weight: 600; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.wizard-discovery-url { font-size: 0.78rem; color: var(--text-muted, var(--secondary-text-color)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.wizard-discovery-url { font-size: 0.78rem; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .wizard-discovery-badge { font-size: 0.68rem; font-weight: 700; @@ -2926,7 +2935,8 @@ textarea:focus-visible { width: 100%; transition: border-color 0.15s, background 0.15s; } -.wizard-display-item:hover { +.wizard-display-item:hover, +.wizard-display-item:focus-visible { border-color: var(--primary-color); background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg)); } @@ -2938,7 +2948,7 @@ textarea:focus-visible { .wizard-display-icon .icon { width: 20px; height: 20px; } .wizard-display-details { display: flex; flex-direction: column; gap: 2px; flex: 1; } .wizard-display-name { font-size: 0.88rem; font-weight: 600; color: var(--text-color); } -.wizard-display-dims { font-size: 0.78rem; color: var(--text-muted, var(--secondary-text-color)); } +.wizard-display-dims { font-size: 0.78rem; color: var(--text-muted); } .wizard-display-check { color: var(--primary-color); } .wizard-display-check .icon { width: 16px; height: 16px; } .wizard-display-fallback { display: flex; flex-direction: column; gap: 12px; } @@ -2953,7 +2963,7 @@ textarea:focus-visible { border: 1px solid var(--border-color); border-radius: var(--radius-md, 8px); } -.wizard-scaffold-label { font-size: 0.88rem; color: var(--text-muted, var(--secondary-text-color)); } +.wizard-scaffold-label { font-size: 0.88rem; color: var(--text-muted); } /* Calibrate container */ .wizard-calibrate-container { @@ -2990,7 +3000,7 @@ textarea:focus-visible { padding: 12px 16px; } .wizard-done-item { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; gap: 12px; } -.wizard-done-label { color: var(--text-muted, var(--secondary-text-color)); } +.wizard-done-label { color: var(--text-muted); } .wizard-done-value { font-weight: 600; color: var(--text-color); text-align: right; } /* Wizard form rows */ diff --git a/server/src/ledgrab/static/css/modal.css b/server/src/ledgrab/static/css/modal.css index 8ca6cd2..718b500 100644 --- a/server/src/ledgrab/static/css/modal.css +++ b/server/src/ledgrab/static/css/modal.css @@ -2512,7 +2512,11 @@ padding-top: 8px; } -.error-message { +/* `.modal-error` is the convention recommended in contexts/frontend.md and is + used by several modals; it aliases `.error-message` so both render the same + inline error banner. */ +.error-message, +.modal-error { background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */ border: 1px solid var(--danger-color); color: var(--danger-color); diff --git a/server/src/ledgrab/static/js/features/auto-calibration.ts b/server/src/ledgrab/static/js/features/auto-calibration.ts index ca583c8..d64a908 100644 --- a/server/src/ledgrab/static/js/features/auto-calibration.ts +++ b/server/src/ledgrab/static/js/features/auto-calibration.ts @@ -98,6 +98,9 @@ export interface AutoCalOptions { let _state: AutoCalState | null = null; let _opts: AutoCalOptions | null = null; let _deviceEntitySelect: EntitySelect | null = null; +/** First render after mount: let the containing Modal's autofocus win; only + * steal focus on subsequent step transitions (for D-pad / TV navigation). */ +let _firstRender = true; // ── Public API ───────────────────────────────────────────────────────────── @@ -119,6 +122,7 @@ let _deviceEntitySelect: EntitySelect | null = null; */ export async function mountAutoCalibration(opts: AutoCalOptions): Promise { await unmountAutoCalibration(); + _firstRender = true; _opts = opts; let cssSourceType = 'picture'; @@ -177,6 +181,27 @@ function _render(): void { case 'corners': _renderCorners(); break; case 'preview': _renderPreview(); break; } + _focusFirstControl(); +} + +/** + * Move focus to the step's first focusable control after a re-render so D-pad / + * TV WebView users don't land on a node that was just removed. Skipped on the + * very first render so it doesn't fight the Modal's own initial autofocus. + */ +function _focusFirstControl(): void { + if (_firstRender) { + _firstRender = false; + return; + } + const container = _opts?.container; + if (!container) return; + requestAnimationFrame(() => { + const el = container.querySelector( + 'button:not([disabled]),[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled])' + ) as HTMLElement | null; + if (el && el.offsetParent !== null) el.focus(); + }); } // ── Step 1: Device picker ────────────────────────────────────────────────── @@ -309,10 +334,15 @@ export async function autoCalSetCorner(position: StartPosition): Promise { // LED is at index 0; advance to ~5% to show movement direction await _setPosition(0); await _delay(350); + // User may have cancelled during the delay (unmount sets _state = null). + if (!_state) return; const advance = Math.max(4, Math.round(_state.ledCount * 0.04)); await _setPosition(advance); + if (!_state) return; _state.busy = false; } catch (err: unknown) { + // _state may have been torn down mid-await by a cancel. + if (!_state) return; _state.busy = false; _state.errorMsg = _errMsg(err); _state.step = 'corner'; // revert on error diff --git a/server/src/ledgrab/static/js/features/calibration.ts b/server/src/ledgrab/static/js/features/calibration.ts index 63545d3..d9d578c 100644 --- a/server/src/ledgrab/static/js/features/calibration.ts +++ b/server/src/ledgrab/static/js/features/calibration.ts @@ -64,6 +64,14 @@ class CalibrationModal extends Modal { if (testGroup) testGroup.style.display = 'none'; const testSection = document.getElementById('calibration-test-setup-section'); if (testSection) testSection.style.display = 'none'; + // Destroy the test-device EntitySelect; otherwise the instance leaks and + // leaves a stale enhanced trigger button in the DOM until the next open. + if (_calTestDeviceEntitySelect) { + _calTestDeviceEntitySelect.destroy(); + _calTestDeviceEntitySelect = null; + } + const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement | null; + if (testDeviceSelect) testDeviceSelect.onchange = null; } else { const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value; if (deviceId) clearTestMode(deviceId); diff --git a/server/src/ledgrab/static/js/features/color-strips/test.ts b/server/src/ledgrab/static/js/features/color-strips/test.ts index aca20c0..23529b0 100644 --- a/server/src/ledgrab/static/js/features/color-strips/test.ts +++ b/server/src/ledgrab/static/js/features/color-strips/test.ts @@ -151,6 +151,15 @@ let _cssTestWs: WebSocket | null = null; let _cssTestRaf: number | null = null; let _cssTestLatestRgb: Uint8Array | null = null; let _cssTestMeta: any = null; +// Set true when a WS frame updates the latest RGB / layer data; the render loop +// only repaints when a new frame has arrived, then clears it. Avoids re-rendering +// the same frame every RAF tick on low-powered devices (e.g. Android TV WebView). +let _cssTestFrameDirty: boolean = false; +// Per-canvas render caches: reuse the 2d context and a single ImageData buffer +// instead of calling getContext()/createImageData() and resetting width/height +// every frame. ImageData is rebuilt only when the canvas LED count changes. +const _cssTestCtxCache = new WeakMap(); +const _cssTestImageDataCache = new WeakMap(); let _cssTestSourceId: string | null = null; let _cssTestIsComposite: boolean = false; let _cssTestLayerData: any = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array } @@ -234,6 +243,7 @@ function _testKeyColorsSource(sourceId: string) { _cssTestLatestRgb = null; _cssTestMeta = null; _cssTestLayerData = null; + _cssTestFrameDirty = false; const modal = document.getElementById('test-css-source-modal') as HTMLElement | null; if (!modal) return; @@ -414,6 +424,7 @@ function _openTestModal(sourceId: string) { _cssTestIsComposite = false; _cssTestIsKeyColors = false; _cssTestLayerData = null; + _cssTestFrameDirty = false; const modal = document.getElementById('test-css-source-modal') as HTMLElement | null; if (!modal) return; @@ -636,6 +647,8 @@ function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) { // Standard format: raw RGB _cssTestLatestRgb = raw; } + // Mark a new frame so the render loop repaints on the next RAF tick. + _cssTestFrameDirty = true; // Track FPS for api_input sources if (_cssTestIsApiInput) { @@ -756,6 +769,7 @@ export function applyCssTestSettings() { _cssTestLatestRgb = null; _cssTestMeta = null; _cssTestLayerData = null; + _cssTestFrameDirty = false; // Read selected input source from selector (both CSS and CSPT modes) const inputSel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement | null; @@ -774,6 +788,9 @@ export function applyCssTestSettings() { function _cssTestRenderLoop() { _cssTestRaf = requestAnimationFrame(_cssTestRenderLoop); if (!_cssTestMeta) return; + // Skip repaint when no new WS frame has arrived since the last render. + if (!_cssTestFrameDirty) return; + _cssTestFrameDirty = false; const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0; @@ -786,14 +803,40 @@ function _cssTestRenderLoop() { } } +// Returns a cached 2d context and a reusable ImageData for the canvas, only +// resizing the canvas / rebuilding the ImageData when the requested dimensions +// differ from the cached ones. Returns null if a 2d context is unavailable. +function _cssTestAcquireCanvas( + canvas: HTMLCanvasElement, + w: number, + h: number, +): { ctx: CanvasRenderingContext2D; imageData: ImageData } | null { + let ctx = _cssTestCtxCache.get(canvas); + if (!ctx) { + const got = canvas.getContext('2d'); + if (!got) return null; + ctx = got; + _cssTestCtxCache.set(canvas, ctx); + } + // Only touch width/height when they actually change (resetting them clears + // the backing store and is expensive). + if (canvas.width !== w) canvas.width = w; + if (canvas.height !== h) canvas.height = h; + let cached = _cssTestImageDataCache.get(canvas); + if (!cached || cached.w !== w || cached.h !== h) { + cached = { w, h, imageData: ctx.createImageData(w, h) }; + _cssTestImageDataCache.set(canvas, cached); + } + return { ctx, imageData: cached.imageData }; +} + function _cssTestRenderStrip(rgbBytes: Uint8Array) { const canvas = document.getElementById('css-test-strip-canvas') as HTMLCanvasElement | null; if (!canvas) return; const ledCount = rgbBytes.length / 3; - canvas.width = ledCount; - canvas.height = 1; - const ctx = canvas.getContext('2d')!; - const imageData = ctx.createImageData(ledCount, 1); + const acquired = _cssTestAcquireCanvas(canvas, ledCount, 1); + if (!acquired) return; + const { ctx, imageData } = acquired; const data = imageData.data; for (let i = 0; i < ledCount; i++) { const si = i * 3; @@ -824,10 +867,9 @@ function _cssTestRenderLayers(data: any) { function _cssTestRenderStripCanvas(canvas: HTMLCanvasElement, rgbBytes: Uint8Array) { const ledCount = rgbBytes.length / 3; if (ledCount <= 0) return; - canvas.width = ledCount; - canvas.height = 1; - const ctx = canvas.getContext('2d')!; - const imageData = ctx.createImageData(ledCount, 1); + const acquired = _cssTestAcquireCanvas(canvas, ledCount, 1); + if (!acquired) return; + const { ctx, imageData } = acquired; const data = imageData.data; for (let i = 0; i < ledCount; i++) { const si = i * 3; @@ -852,13 +894,17 @@ function _cssTestRenderRect(rgbBytes: Uint8Array, edges: any[]) { const canvas = document.getElementById(`css-test-edge-${edge}`) as HTMLCanvasElement | null; if (!canvas) continue; const count = indices.length; - if (count === 0) { canvas.width = 0; continue; } + if (count === 0) { + if (canvas.width !== 0) canvas.width = 0; + continue; + } const isH = edge === 'top' || edge === 'bottom'; - canvas.width = isH ? count : 1; - canvas.height = isH ? 1 : count; - const ctx = canvas.getContext('2d')!; - const imageData = ctx.createImageData(canvas.width, canvas.height); + const w = isH ? count : 1; + const h = isH ? 1 : count; + const acquired = _cssTestAcquireCanvas(canvas, w, h); + if (!acquired) continue; + const { ctx, imageData } = acquired; const px = imageData.data; for (let i = 0; i < count; i++) { const si = indices[i] * 3; @@ -1185,6 +1231,7 @@ export function closeTestCssSourceModal() { _cssTestIsComposite = false; _cssTestIsKeyColors = false; _cssTestLayerData = null; + _cssTestFrameDirty = false; _cssTestNotificationIds = []; _cssTestIsApiInput = false; _cssTestStopFpsSampling(); diff --git a/server/src/ledgrab/static/js/features/setup-wizard.ts b/server/src/ledgrab/static/js/features/setup-wizard.ts index a218390..3cc05ea 100644 --- a/server/src/ledgrab/static/js/features/setup-wizard.ts +++ b/server/src/ledgrab/static/js/features/setup-wizard.ts @@ -76,6 +76,9 @@ interface WizardState { let _state: WizardState | null = null; let _modal: SetupWizardModal | null = null; +/** First render after open: let the Modal's own autofocus win; only steal focus + * on subsequent step transitions (for D-pad / TV navigation). */ +let _firstRender = true; const STEPS: WizardStep[] = ['welcome', 'device', 'display', 'scaffold', 'calibrate', 'start', 'done']; @@ -95,6 +98,7 @@ class SetupWizardModal extends Modal { /** Open the wizard (first-run or on-demand). */ export function openSetupWizard(): void { if (!_modal) _modal = new SetupWizardModal(); + _firstRender = true; _state = { step: 'welcome', deviceId: '', @@ -201,8 +205,18 @@ export async function wizardNext(): Promise { _renderStep(); await _runScaffold(); } else if (step === 'calibrate') { - // "Skip calibration" path — move to start - void unmountAutoCalibration(); + // "Skip calibration" path — move to start. + // Mark busy BEFORE the await so a fast second tap on Skip/Next can't + // re-enter wizardNext (gated at the top) and fire a second teardown + + // start during the up-to-10s unmount await. _startOutput() manages + // busy itself afterward. + _state.busy = true; + _renderStep(); + // Await teardown first: unmountAutoCalibration() POSTs session/stop, which + // restores the device's prior target. Starting output before that completes + // would let the stop clobber the just-started target. + await unmountAutoCalibration(); + if (!_state) return; _state.step = 'start'; _renderStep(); await _startOutput(); @@ -361,12 +375,15 @@ async function _loadDisplays(): Promise { } } -export function wizardSelectDisplay(index: number, displayName: string): void { +export function wizardSelectDisplay(index: number, displayName: string, skipRender = false): void { if (!_state) return; _state.displayIndex = index; _state.displayName = displayName; _state.errorMsg = ''; - _renderStep(); + // The manual display-index fallback passes skipRender=true: a full + // _renderStep() rebuilds the container via innerHTML, destroying the input + // and losing focus/caret on every keystroke (unusable on Android TV WebView). + if (!skipRender) _renderStep(); } // ───────────────────────────────────────────────────────────────────────────── @@ -469,6 +486,25 @@ function _renderStep(): void { const html = _buildStepHtml(_state); container.innerHTML = html; _attachStepListeners(_state.step); + _focusFirstControl(container); +} + +/** + * Move focus to the step's first focusable control after a re-render so D-pad / + * TV WebView users don't land on a node that was just removed. Skipped on the + * very first render so it doesn't fight the Modal's own initial autofocus. + */ +function _focusFirstControl(container: HTMLElement): void { + if (_firstRender) { + _firstRender = false; + return; + } + requestAnimationFrame(() => { + const el = container.querySelector( + 'button:not([disabled]),[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled])' + ) as HTMLElement | null; + if (el && el.offsetParent !== null) el.focus(); + }); } function _renderProgressBar(): void { @@ -654,7 +690,7 @@ function _buildDisplayStep(state: WizardState): string { + oninput="wizardSelectDisplay(parseInt(this.value)||0, 'Display '+this.value, true)"> `; } else { diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 33d6e27..a36cfec 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -645,6 +645,16 @@ "calibration.roi.width": "Width (%)", "calibration.roi.height": "Height (%)", "calibration.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)", + "calibration.edge.top": "Toggle top edge test LEDs", + "calibration.edge.right": "Toggle right edge test LEDs", + "calibration.edge.bottom": "Toggle bottom edge test LEDs", + "calibration.edge.left": "Toggle left edge test LEDs", + "calibration.corner.top_left": "Set start position: top left", + "calibration.corner.top_right": "Set start position: top right", + "calibration.corner.bottom_right": "Set start position: bottom right", + "calibration.corner.bottom_left": "Set start position: bottom left", + "calibration.direction.toggle": "Toggle direction", + "calibration.edge_inputs.toggle": "Toggle edge LED inputs", "calibration.button.cancel": "Cancel", "calibration.button.save": "Save", "calibration.saved": "Calibration saved", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 4f5047c..f907246 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -702,6 +702,16 @@ "calibration.roi.width": "Ширина (%)", "calibration.roi.height": "Высота (%)", "calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)", + "calibration.edge.top": "Переключить тестовые светодиоды верхнего края", + "calibration.edge.right": "Переключить тестовые светодиоды правого края", + "calibration.edge.bottom": "Переключить тестовые светодиоды нижнего края", + "calibration.edge.left": "Переключить тестовые светодиоды левого края", + "calibration.corner.top_left": "Задать начальную позицию: верхний левый угол", + "calibration.corner.top_right": "Задать начальную позицию: верхний правый угол", + "calibration.corner.bottom_right": "Задать начальную позицию: нижний правый угол", + "calibration.corner.bottom_left": "Задать начальную позицию: нижний левый угол", + "calibration.direction.toggle": "Переключить направление", + "calibration.edge_inputs.toggle": "Переключить поля ввода светодиодов по краям", "calibration.button.cancel": "Отмена", "calibration.button.save": "Сохранить", "calibration.saved": "Калибровка сохранена", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 66097cb..2058c38 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -698,6 +698,16 @@ "calibration.roi.width": "宽度 (%)", "calibration.roi.height": "高度 (%)", "calibration.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100)", + "calibration.edge.top": "切换顶部边缘测试 LED", + "calibration.edge.right": "切换右侧边缘测试 LED", + "calibration.edge.bottom": "切换底部边缘测试 LED", + "calibration.edge.left": "切换左侧边缘测试 LED", + "calibration.corner.top_left": "设置起始位置:左上角", + "calibration.corner.top_right": "设置起始位置:右上角", + "calibration.corner.bottom_right": "设置起始位置:右下角", + "calibration.corner.bottom_left": "设置起始位置:左下角", + "calibration.direction.toggle": "切换方向", + "calibration.edge_inputs.toggle": "切换边缘 LED 输入", "calibration.button.cancel": "取消", "calibration.button.save": "保存", "calibration.saved": "校准已保存", diff --git a/server/src/ledgrab/storage/game_integration.py b/server/src/ledgrab/storage/game_integration.py index 080b4d9..3669e2b 100644 --- a/server/src/ledgrab/storage/game_integration.py +++ b/server/src/ledgrab/storage/game_integration.py @@ -10,6 +10,48 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any, List +from ledgrab.utils import secret_box + +# Keys inside ``adapter_config`` that hold secrets and must be encrypted at +# rest (and decrypted on load). Mirrors the secret_box pattern used by +# storage/http_endpoint.py for its ``auth_token`` field. +_SECRET_CONFIG_KEYS = ("auth_token",) + + +def _encrypt_adapter_config(adapter_config: dict[str, Any]) -> dict[str, Any]: + """Return a copy of *adapter_config* with secret values encrypted. + + ``secret_box.encrypt`` is a no-op on already-encrypted envelopes, so this + is safe to call repeatedly and on configs that were never plaintext. + """ + result = dict(adapter_config) + for key in _SECRET_CONFIG_KEYS: + value = result.get(key) + if isinstance(value, str) and value: + result[key] = secret_box.encrypt(value) + return result + + +def _decrypt_adapter_config(adapter_config: dict[str, Any]) -> dict[str, Any]: + """Return a copy of *adapter_config* with secret values decrypted. + + Migration-safe: ``secret_box.decrypt`` returns the input unchanged when it + is not an encryption envelope, so legacy plaintext rows still load. On a + corrupt/undecryptable envelope, fall back to dropping the value rather than + crashing the whole store load. + """ + result = dict(adapter_config) + for key in _SECRET_CONFIG_KEYS: + value = result.get(key) + if isinstance(value, str) and value: + if secret_box.is_encrypted(value): + try: + result[key] = secret_box.decrypt(value) + except Exception: + result[key] = "" + # else: legacy plaintext — leave as-is (migration-safe read path). + return result + @dataclass class EventMapping: @@ -89,7 +131,8 @@ class GameIntegrationConfig: "name": self.name, "adapter_type": self.adapter_type, "enabled": self.enabled, - "adapter_config": dict(self.adapter_config), + # Encrypt secret values (e.g. webhook auth_token) at rest. + "adapter_config": _encrypt_adapter_config(self.adapter_config), "event_mappings": [m.to_dict() for m in self.event_mappings], "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), @@ -110,7 +153,8 @@ class GameIntegrationConfig: name=data["name"], adapter_type=data["adapter_type"], enabled=data.get("enabled", True), - adapter_config=data.get("adapter_config", {}), + # Decrypt secret values; tolerant of legacy-plaintext rows. + adapter_config=_decrypt_adapter_config(data.get("adapter_config", {})), event_mappings=mappings, created_at=( datetime.fromisoformat(data["created_at"]) diff --git a/server/src/ledgrab/templates/modals/calibration.html b/server/src/ledgrab/templates/modals/calibration.html index 2e45d5d..0f2b5ab 100644 --- a/server/src/ledgrab/templates/modals/calibration.html +++ b/server/src/ledgrab/templates/modals/calibration.html @@ -56,10 +56,10 @@
- -
0 / 0
+
0 / 0
@@ -104,16 +104,16 @@
- - - - + + + + - - - - + + + + @@ -144,7 +144,7 @@
-
+
@@ -170,13 +170,13 @@
-
+
-
+
diff --git a/server/tests/api/routes/test_game_integration_routes.py b/server/tests/api/routes/test_game_integration_routes.py index 542a2e8..7b293ad 100644 --- a/server/tests/api/routes/test_game_integration_routes.py +++ b/server/tests/api/routes/test_game_integration_routes.py @@ -341,6 +341,64 @@ class TestEventIngestion: assert len(recent) == 1 +# --------------------------------------------------------------------------- +# Failed-auth rate limiting (brute-force defence on the ingest route) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def _reset_auth_fail_limiter(): + """Clear the module-level failed-auth hit map before and after each test. + + The limiter keeps per-IP state in a process-global dict, so without this + reset, attempts from earlier tests would bleed into later ones. + """ + from ledgrab.api.routes import game_integration as gi + + gi._auth_fail_hits.clear() + yield + gi._auth_fail_hits.clear() + + +class TestIngestRateLimiting: + def test_failed_auth_attempts_are_rate_limited(self, client, _reset_auth_fail_limiter): + from ledgrab.api.routes import game_integration as gi + + created = _create_integration( + client, + adapter_config={"auth_token": "correct_token"}, + ) + integration_id = created["id"] + url = f"/api/v1/game-integrations/{integration_id}/event" + bad = {"x-auth-token": "wrong_token"} + + # Burn through the failed-auth budget — each returns 403. + for _ in range(gi._AUTH_FAIL_LIMIT): + resp = client.post(url, json={"data": {"health": 1}}, headers=bad) + assert resp.status_code == 403 + + # The next attempt from the same IP is throttled with 429. + resp = client.post(url, json={"data": {"health": 1}}, headers=bad) + assert resp.status_code == 429 + + def test_successful_ingest_not_rate_limited(self, client, event_bus, _reset_auth_fail_limiter): + from ledgrab.api.routes import game_integration as gi + + created = _create_integration( + client, + adapter_config={"auth_token": "correct_token"}, + ) + integration_id = created["id"] + url = f"/api/v1/game-integrations/{integration_id}/event" + good = {"x-auth-token": "correct_token"} + + # High-rate legitimate ingestion well past the failed-auth threshold + # must NOT be throttled — only failures count toward the limit. + for _ in range(gi._AUTH_FAIL_LIMIT + 10): + resp = client.post(url, json={"data": {"health": 50}}, headers=good) + assert resp.status_code == 204 + + # --------------------------------------------------------------------------- # Status / diagnostics tests # --------------------------------------------------------------------------- diff --git a/server/tests/core/test_generic_webhook_adapter.py b/server/tests/core/test_generic_webhook_adapter.py index 5e3544a..aaec6e4 100644 --- a/server/tests/core/test_generic_webhook_adapter.py +++ b/server/tests/core/test_generic_webhook_adapter.py @@ -102,9 +102,20 @@ class TestGenericWebhookParsing: class TestGenericWebhookAuth: - def test_no_auth_configured(self) -> None: + def test_no_auth_configured_rejects(self) -> None: + # Secure-by-default: a network-facing webhook adapter with no token + # configured must REJECT, not accept (otherwise it is open to the LAN). result = GenericWebhookAdapter.validate_auth({}, {}, {}) - assert result is True + assert result is False + + def test_empty_token_rejects(self) -> None: + # An explicitly empty token is still "no token" → reject. + result = GenericWebhookAdapter.validate_auth( + {"Authorization": "Bearer "}, + {}, + {"auth_token": ""}, + ) + assert result is False def test_bearer_auth_valid(self) -> None: result = GenericWebhookAdapter.validate_auth( @@ -156,6 +167,8 @@ class TestGenericWebhookMetadata: assert "auth_token" in schema["properties"] assert "mappings" in schema["properties"] assert "auth_header" in schema["properties"] + # auth_token is mandatory (secure-by-default). + assert "auth_token" in schema.get("required", []) def test_setup_instructions(self) -> None: instructions = GenericWebhookAdapter.get_setup_instructions() diff --git a/server/tests/core/test_playlist_engine.py b/server/tests/core/test_playlist_engine.py index 6c9c039..e0b8bb7 100644 --- a/server/tests/core/test_playlist_engine.py +++ b/server/tests/core/test_playlist_engine.py @@ -315,3 +315,175 @@ class TestEvents: async def test_stop_when_idle_fires_nothing(self, engine): await engine.stop() assert _fired_actions(engine) == [] + + +# --------------------------------------------------------------------------- +# Concurrency — delete-mid-play race (H5). The engine reads/mutates _task and +# _state across await boundaries; a deletion firing stop_if_running() while the +# _run loop is between its get_playlist re-read and the natural-end clear must +# still produce exactly ONE terminal 'stopped' transition (no duplicate / +# contradictory WS events), and get_state() must never raise nor return a +# half-cleared snapshot during the window. +# --------------------------------------------------------------------------- + + +class _DeletableStore: + """Wraps a real playlist store; ``delete_playlist`` records a tombstone so + a later ``get_playlist`` re-read raises (like the real store after a delete) + and the engine's ``_run`` loop breaks out at the cycle boundary. + """ + + def __init__(self, real): + self._real = real + self._deleted: set[str] = set() + + def get_playlist(self, playlist_id): + if playlist_id in self._deleted: + raise KeyError(playlist_id) + return self._real.get_playlist(playlist_id) + + def delete_playlist(self, playlist_id): + self._deleted.add(playlist_id) + + +class TestConcurrencyDeleteRace: + async def test_delete_and_stop_if_running_emit_single_stopped( + self, engine, playlist_store, applied + ): + # A looping playlist so _run re-reads get_playlist every cycle, giving + # us a deterministic parking point to interleave the delete + stop. + pl = _make_playlist(playlist_store, "Racy", [("scene_a", 50), ("scene_b", 50)], loop=True) + + gated = _DeletableStore(playlist_store) + engine._playlist_store = gated + + # The dwell sleep is the engine's only await between the get_playlist + # re-read and the natural-end clear. We gate the FIRST dwell: when _run + # parks there we set `entered`, then await `release`. While _run is + # suspended the test deletes the playlist and kicks stop_if_running, so + # they race _run's resumed natural-end clear under the lifecycle lock. + entered = asyncio.Event() + release = asyncio.Event() + first_dwell = {"done": False} + + async def _interleaving_sleep(_duration): + if not first_dwell["done"]: + first_dwell["done"] = True + entered.set() + await release.wait() + await _REAL_SLEEP(0.005) + + with patch("asyncio.sleep", _interleaving_sleep): + await engine.start_playlist(pl.id) + + # Wait until _run is parked inside the first dwell (mid-cycle). + await asyncio.wait_for(entered.wait(), timeout=2.0) + + # get_state during the window must not raise and must be coherent: + # either fully running or fully idle — never half-cleared. + snap = engine.get_state() + assert isinstance(snap, dict) + assert "is_running" in snap + + # Concurrently: delete from the store AND call stop_if_running for + # the same id. Kick stop_if_running first as a task so it contends + # for the lifecycle lock with the (soon to resume) _run natural-end. + gated.delete_playlist(pl.id) + stop_task = asyncio.create_task(engine.stop_if_running(pl.id)) + + # Release _run: it finishes the dwell, re-reads (KeyError -> break), + # and races stop_task into the natural-end clear under the lock. + release.set() + + await asyncio.wait_for(stop_task, timeout=2.0) + # Drain whatever remains of the run task. + run_task = engine._task + if run_task is not None: + try: + await asyncio.wait_for(asyncio.shield(run_task), timeout=2.0) + except (asyncio.CancelledError, KeyError): + pass + + # Exactly one terminal 'stopped' transition (no duplicate/contradictory). + stopped = [e for e in _fired_events(engine) if e.get("action") == "stopped"] + assert len(stopped) == 1, f"expected exactly one stopped, got {len(stopped)}: {stopped}" + assert stopped[0]["playlist_id"] == pl.id + + # Engine fully idle and get_state coherent afterwards. + assert engine.is_running() is False + final = engine.get_state() + assert final["is_running"] is False + assert final["playlist_id"] is None + + async def test_natural_end_after_delete_emits_single_stopped( + self, engine, playlist_store, applied + ): + # Drive the OTHER ordering: the delete causes _run to break and run its + # (now lock-wrapped) natural-end clear+'stopped', while a stop_if_running + # for the same id is fired AFTER the dwell is released but races into the + # same lock. Whoever wins, exactly one 'stopped' must surface and the + # late stop_if_running must be a clean no-op (state already cleared). + pl = _make_playlist(playlist_store, "Ending", [("scene_a", 50)], loop=False) + + gated = _DeletableStore(playlist_store) + engine._playlist_store = gated + + entered = asyncio.Event() + release = asyncio.Event() + first_dwell = {"done": False} + + async def _interleaving_sleep(_duration): + if not first_dwell["done"]: + first_dwell["done"] = True + entered.set() + await release.wait() + await _REAL_SLEEP(0.005) + + with patch("asyncio.sleep", _interleaving_sleep): + await engine.start_playlist(pl.id) + await asyncio.wait_for(entered.wait(), timeout=2.0) + + # Delete and release so _run resumes -> non-loop break -> natural end. + gated.delete_playlist(pl.id) + release.set() + + # Let the run task drive its natural-end clear to completion. + run_task = engine._task + if run_task is not None: + await asyncio.wait_for(asyncio.shield(run_task), timeout=2.0) + + # A late stop_if_running for the same id is now a clean no-op. + await engine.stop_if_running(pl.id) + + stopped = [e for e in _fired_events(engine) if e.get("action") == "stopped"] + assert len(stopped) == 1, f"expected exactly one stopped, got {len(stopped)}: {stopped}" + assert stopped[0]["playlist_id"] == pl.id + assert engine.is_running() is False + assert engine.get_state()["playlist_id"] is None + + async def test_get_state_never_half_cleared_under_concurrent_stop(self, engine, playlist_store): + # Hammer get_state() while start/stop churn the lifecycle, asserting it + # never raises and never reports running with a None playlist_id. + pl = _make_playlist(playlist_store, "Churn", [("scene_a", 50)], loop=True) + + async def _churn(): + for _ in range(20): + await engine.start_playlist(pl.id) + await engine.stop() + + async def _poll(): + for _ in range(500): + s = engine.get_state() + # Coherence invariant: running implies a concrete playlist_id. + if s["is_running"]: + assert s["playlist_id"] is not None + await _REAL_SLEEP(0) + + async def _fast(_duration): + await _REAL_SLEEP(0) + + with patch("asyncio.sleep", _fast): + await asyncio.gather(_churn(), _poll()) + + await engine.stop() + assert engine.is_running() is False diff --git a/server/tests/storage/test_game_integration_store.py b/server/tests/storage/test_game_integration_store.py index e686647..66afc8f 100644 --- a/server/tests/storage/test_game_integration_store.py +++ b/server/tests/storage/test_game_integration_store.py @@ -5,6 +5,7 @@ import pytest from ledgrab.storage.base_store import EntityNotFoundError from ledgrab.storage.game_integration import EventMapping, GameIntegrationConfig from ledgrab.storage.game_integration_store import GameIntegrationStore +from ledgrab.utils import secret_box @pytest.fixture @@ -272,3 +273,83 @@ class TestGetReferences: config = store.create_integration(name="Test", adapter_type="webhook") refs = store.get_references(config.id) assert refs == [] + + +# --------------------------------------------------------------------------- +# Secret encryption (adapter_config.auth_token) tests +# --------------------------------------------------------------------------- + + +class TestAdapterConfigSecretEncryption: + def test_auth_token_round_trips(self): + config = GameIntegrationConfig.create_from_kwargs( + name="Webhook", + adapter_type="generic_webhook", + adapter_config={"auth_token": "super-secret", "auth_header": "X-Token"}, + ) + restored = GameIntegrationConfig.from_dict(config.to_dict()) + # Decrypted at runtime — plaintext is recovered. + assert restored.adapter_config["auth_token"] == "super-secret" + # Non-secret fields are untouched. + assert restored.adapter_config["auth_header"] == "X-Token" + + def test_stored_token_is_not_plaintext(self): + config = GameIntegrationConfig.create_from_kwargs( + name="Webhook", + adapter_type="generic_webhook", + adapter_config={"auth_token": "plaintext-secret"}, + ) + stored = config.to_dict() + raw_token = stored["adapter_config"]["auth_token"] + assert raw_token != "plaintext-secret" + assert secret_box.is_encrypted(raw_token) + + def test_legacy_plaintext_row_still_decrypts(self): + # Simulate a row written before encryption was added: auth_token is + # bare plaintext (not an ENC: envelope). It must still load. + legacy = { + "id": "gi_legacy01", + "name": "Legacy Webhook", + "adapter_type": "generic_webhook", + "enabled": True, + "adapter_config": {"auth_token": "legacy-plaintext"}, + "event_mappings": [], + "created_at": "2024-01-01T00:00:00+00:00", + "updated_at": "2024-01-01T00:00:00+00:00", + } + restored = GameIntegrationConfig.from_dict(legacy) + assert restored.adapter_config["auth_token"] == "legacy-plaintext" + + def test_no_auth_token_key_is_safe(self): + config = GameIntegrationConfig.create_from_kwargs( + name="NoSecret", + adapter_type="cs2", + adapter_config={"max_gold": 99999}, + ) + restored = GameIntegrationConfig.from_dict(config.to_dict()) + assert restored.adapter_config == {"max_gold": 99999} + + def test_db_stores_token_encrypted(self, tmp_db): + store = GameIntegrationStore(tmp_db) + store.create_integration( + name="Webhook", + adapter_type="generic_webhook", + adapter_config={"auth_token": "db-secret"}, + ) + rows = tmp_db.load_all("game_integrations") + assert len(rows) == 1 + raw_token = rows[0]["adapter_config"]["auth_token"] + assert raw_token != "db-secret" + assert secret_box.is_encrypted(raw_token) + + def test_db_round_trip_after_reload(self, tmp_db): + store1 = GameIntegrationStore(tmp_db) + created = store1.create_integration( + name="Webhook", + adapter_type="generic_webhook", + adapter_config={"auth_token": "reload-secret"}, + ) + # New store instance reads from disk and must decrypt. + store2 = GameIntegrationStore(tmp_db) + fetched = store2.get_integration(created.id) + assert fetched.adapter_config["auth_token"] == "reload-secret" diff --git a/server/tests/test_calibration.py b/server/tests/test_calibration.py index 7170e0e..dfd0c95 100644 --- a/server/tests/test_calibration.py +++ b/server/tests/test_calibration.py @@ -291,6 +291,22 @@ def test_create_default_calibration_small_count(): assert config.get_total_leds() == 4 +@pytest.mark.parametrize("led_count", [4, 5, 6, 7, 9, 11, 13, 60, 144, 300]) +def test_create_default_calibration_total_matches_count(led_count): + """Total LED count must equal led_count exactly, with every edge >= 1. + + Regression for small odd counts where the per-edge max(1, ...) floor used + to push the total above led_count (e.g. led_count=5 produced a total of 6). + """ + config = create_default_calibration(led_count) + + assert config.get_total_leds() == led_count + assert config.leds_top >= 1 + assert config.leds_right >= 1 + assert config.leds_bottom >= 1 + assert config.leds_left >= 1 + + def test_create_default_calibration_invalid(): """Test default calibration with invalid LED count.""" with pytest.raises(ValueError): diff --git a/server/tests/test_home_assistant_host_validation.py b/server/tests/test_home_assistant_host_validation.py new file mode 100644 index 0000000..c0028b2 --- /dev/null +++ b/server/tests/test_home_assistant_host_validation.py @@ -0,0 +1,52 @@ +"""Tests for Home Assistant source host classification. + +The HA source host is user-supplied and stored as ``host:port`` (e.g. +``192.168.1.100:8123``). Before the WebSocket runtime connects to it, the +route layer gates the host with the shared LAN classifier so an +(authenticated or default-anonymous) caller cannot weaponise it into a +public network-scan oracle — mirroring the LED device providers. +""" + +import pytest + +from ledgrab.api.routes.home_assistant import _validate_ha_host + + +@pytest.mark.parametrize( + "host", + [ + "127.0.0.1:8123", + "10.0.0.5:8123", + "192.168.1.100:8123", + "172.16.0.10", # no port + "homeassistant.local:8123", # mDNS label + "hass", # bare hostname + "[fe80::1]:8123", # bracketed IPv6 link-local + port + "[fc00::1]:8123", # bracketed IPv6 ULA + port + "169.254.169.254:80", # link-local — allowed by the shared LAN policy + "", # empty passes (upstream schema requires non-empty) + None, # update path may pass None (host unchanged) + ], +) +def test_validate_ha_host_accepts_lan(host) -> None: + """Loopback / private / link-local / hostnames must not raise. + + Link-local (169.254/16, fe80::/10) is part of the shared + ``validate_lan_host`` LAN policy used by every LED device provider, so + HA reuses it unchanged rather than hand-rolling a stricter variant. + """ + _validate_ha_host(host) + + +@pytest.mark.parametrize( + "host", + [ + "1.1.1.1:8123", # public IPv4 + port + "8.8.8.8", # public IPv4, no port + "[2606:4700:4700::1111]:8123", # public IPv6 + port + ], +) +def test_validate_ha_host_rejects_public(host) -> None: + """Genuinely-public IPs (the network-scan-oracle risk) are rejected.""" + with pytest.raises(ValueError, match="LedGrab is LAN-only"): + _validate_ha_host(host)