Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17dd2e02ba |
@@ -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")
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"
|
||||
tools:targetApi="s" />
|
||||
|
||||
<!-- BLE hardware — required=false so non-BT boxes still install. -->
|
||||
<uses-feature
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
@@ -23,8 +26,23 @@ import java.security.SecureRandom
|
||||
*/
|
||||
class ApiKeyManager(context: Context) {
|
||||
|
||||
private val prefs = context.applicationContext
|
||||
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
// Prefer Android-Keystore-backed EncryptedSharedPreferences for the API
|
||||
// key. If the keystore is unavailable (some OEM TV-box ROMs ship a broken
|
||||
// or absent keystore, or a key got corrupted), creation throws — fall back
|
||||
// to plain SharedPreferences so a keystore failure NEVER bricks the local
|
||||
// API key (which would 401 every LAN client). [encrypted] records which
|
||||
// path we took so we don't repeatedly attempt migration.
|
||||
private val encrypted: Boolean
|
||||
private val prefs: SharedPreferences
|
||||
|
||||
init {
|
||||
val (store, isEncrypted) = buildPrefs(appContext)
|
||||
prefs = store
|
||||
encrypted = isEncrypted
|
||||
if (isEncrypted) migrateLegacyKeyIfPresent()
|
||||
}
|
||||
|
||||
// Once we've materialised a key in this process, cache it so
|
||||
// subsequent reads don't hit prefs and don't risk re-checking
|
||||
@@ -60,6 +78,20 @@ class ApiKeyManager(context: Context) {
|
||||
cached = existing
|
||||
return existing
|
||||
}
|
||||
// Before minting a fresh key, fall back to any key still in the
|
||||
// legacy plain store (covers a failed/partial encrypted migration:
|
||||
// commit() can return false WITHOUT throwing, so migration may have
|
||||
// left the live key only in the legacy file). Rotating the
|
||||
// per-install key would 401 every already-paired client, so we
|
||||
// generate a brand-new key ONLY when no key exists anywhere.
|
||||
recoverLegacyKey()?.let { recovered ->
|
||||
// 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<SharedPreferences, Boolean> {
|
||||
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
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/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 <token>` 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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
// 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<HTMLCanvasElement, CanvasRenderingContext2D>();
|
||||
const _cssTestImageDataCache = new WeakMap<HTMLCanvasElement, { w: number; h: number; imageData: ImageData }>();
|
||||
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();
|
||||
|
||||
@@ -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<void> {
|
||||
_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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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 <input> 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 {
|
||||
<label class="wizard-form-label">${t('wizard.display.manual_index')}</label>
|
||||
<input id="wizard-display-index-manual" class="form-input" type="number"
|
||||
min="0" max="63" value="${state.displayIndex}"
|
||||
oninput="wizardSelectDisplay(parseInt(this.value)||0, 'Display '+this.value)">
|
||||
oninput="wizardSelectDisplay(parseInt(this.value)||0, 'Display '+this.value, true)">
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Калибровка сохранена",
|
||||
|
||||
@@ -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": "校准已保存",
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -56,10 +56,10 @@
|
||||
<div class="calibration-preview">
|
||||
<!-- Screen with direction toggle, total LEDs, and offset -->
|
||||
<div class="preview-screen">
|
||||
<button type="button" class="direction-toggle" onclick="toggleDirection()" title="Toggle direction">
|
||||
<button type="button" class="direction-toggle" onclick="toggleDirection()" data-i18n-title="calibration.direction.toggle" title="Toggle direction">
|
||||
<span id="direction-icon"><svg class="icon" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg></span> <span id="direction-label">CW</span>
|
||||
</button>
|
||||
<div class="preview-screen-total" onclick="toggleEdgeInputs()" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div>
|
||||
<div class="preview-screen-total" onclick="toggleEdgeInputs()" data-i18n-title="calibration.edge_inputs.toggle" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div>
|
||||
<div class="preview-screen-border-width">
|
||||
<label for="cal-border-width" data-i18n="calibration.border_width">Border (px):</label>
|
||||
<input type="number" id="cal-border-width" min="1" max="100" value="10">
|
||||
@@ -104,16 +104,16 @@
|
||||
</div>
|
||||
|
||||
<!-- Edge test toggle zones (outside container border, in tick area) -->
|
||||
<button type="button" class="edge-toggle toggle-top" onclick="toggleTestEdge('top')"></button>
|
||||
<button type="button" class="edge-toggle toggle-right" onclick="toggleTestEdge('right')"></button>
|
||||
<button type="button" class="edge-toggle toggle-bottom" onclick="toggleTestEdge('bottom')"></button>
|
||||
<button type="button" class="edge-toggle toggle-left" onclick="toggleTestEdge('left')"></button>
|
||||
<button type="button" class="edge-toggle toggle-top" onclick="toggleTestEdge('top')" data-i18n-aria-label="calibration.edge.top" aria-label="Toggle top edge test LEDs"></button>
|
||||
<button type="button" class="edge-toggle toggle-right" onclick="toggleTestEdge('right')" data-i18n-aria-label="calibration.edge.right" aria-label="Toggle right edge test LEDs"></button>
|
||||
<button type="button" class="edge-toggle toggle-bottom" onclick="toggleTestEdge('bottom')" data-i18n-aria-label="calibration.edge.bottom" aria-label="Toggle bottom edge test LEDs"></button>
|
||||
<button type="button" class="edge-toggle toggle-left" onclick="toggleTestEdge('left')" data-i18n-aria-label="calibration.edge.left" aria-label="Toggle left edge test LEDs"></button>
|
||||
|
||||
<!-- Corner start position buttons -->
|
||||
<button type="button" class="preview-corner corner-top-left" onclick="setStartPosition('top_left')">●</button>
|
||||
<button type="button" class="preview-corner corner-top-right" onclick="setStartPosition('top_right')">●</button>
|
||||
<button type="button" class="preview-corner corner-bottom-left" onclick="setStartPosition('bottom_left')">●</button>
|
||||
<button type="button" class="preview-corner corner-bottom-right" onclick="setStartPosition('bottom_right')">●</button>
|
||||
<button type="button" class="preview-corner corner-top-left" onclick="setStartPosition('top_left')" data-i18n-aria-label="calibration.corner.top_left" aria-label="Set start position: top left">●</button>
|
||||
<button type="button" class="preview-corner corner-top-right" onclick="setStartPosition('top_right')" data-i18n-aria-label="calibration.corner.top_right" aria-label="Set start position: top right">●</button>
|
||||
<button type="button" class="preview-corner corner-bottom-left" onclick="setStartPosition('bottom_left')" data-i18n-aria-label="calibration.corner.bottom_left" aria-label="Set start position: bottom left">●</button>
|
||||
<button type="button" class="preview-corner corner-bottom-right" onclick="setStartPosition('bottom_right')" data-i18n-aria-label="calibration.corner.bottom_right" aria-label="Set start position: bottom right">●</button>
|
||||
|
||||
<!-- Canvas overlay for ticks, arrows, start label -->
|
||||
<canvas id="calibration-preview-canvas"></canvas>
|
||||
@@ -144,7 +144,7 @@
|
||||
<span class="ds-section-index" aria-hidden="true">03</span>
|
||||
</div>
|
||||
<div class="ds-section-body">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px;">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-md);">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="cal-offset" data-i18n="calibration.offset">LED Offset:</label>
|
||||
@@ -170,13 +170,13 @@
|
||||
<input type="number" id="cal-skip-end" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 16px;">
|
||||
<div class="form-group" style="margin-top: var(--space-md);">
|
||||
<div class="label-row">
|
||||
<label data-i18n="calibration.roi">Capture region (%):</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="calibration.roi.hint">Sample only this sub-rectangle of the screen so a taskbar, HUD, or black bars don't pollute the border colours. Full frame = X/Y 0, Width/Height 100.</small>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 12px;">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: var(--space-md);">
|
||||
<div>
|
||||
<label for="cal-roi-x" class="time-range-label" data-i18n="calibration.roi.x">X (%)</label>
|
||||
<input type="number" id="cal-roi-x" min="0" max="100" value="0">
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user