From 8d84980daa4da1a5d2321e8b7a6c2aa1dee4bbe0 Mon Sep 17 00:00:00 2001 From: ophirhan Date: Mon, 5 Oct 2026 21:31:36 +0300 Subject: [PATCH 1/8] feat(sideload): optional BYO FCM wake for offline proactive Sideload-only Firebase Messaging with runtime FirebaseOptions from Settings (no google-services.json bake-in). Phone registers token via POST /push/token; relay stores tokens and sends high-priority data wakes when proactive queues offline. Service account stays on the Mac (RELAY_FCM_SERVICE_ACCOUNT_JSON). googlePlay flavor remains a no-op controller. --- app/build.gradle.kts | 6 + .../relay/push/FcmPushControllerFactory.kt | 14 + .../hermesandroid/relay/data/FcmPushConfig.kt | 189 +++++++++++ .../relay/network/relay/RelayHttpClient.kt | 79 +++++ .../relay/push/FcmPushController.kt | 33 ++ .../ui/screens/ProactiveSettingsScreen.kt | 160 +++++++++ .../relay/viewmodel/ConnectionViewModel.kt | 88 +++++ app/src/main/res/values-b+pt+BR/strings.xml | 18 + app/src/main/res/values-b+zh+Hans/strings.xml | 18 + app/src/main/res/values-de/strings.xml | 18 + app/src/main/res/values-es/strings.xml | 18 + app/src/main/res/values-ja/strings.xml | 18 + app/src/main/res/values-ru/strings.xml | 18 + app/src/main/res/values/strings.xml | 17 + app/src/sideload/AndroidManifest.xml | 12 + .../relay/push/FcmPushControllerFactory.kt | 90 +++++ .../relay/push/HermesFcmMessagingService.kt | 166 ++++++++++ .../relay/data/FcmClientConfigParserTest.kt | 62 ++++ gradle/libs.versions.toml | 6 + plugin/relay/channels/proactive.py | 24 ++ plugin/relay/fcm_sender.py | 309 ++++++++++++++++++ plugin/relay/server.py | 106 ++++++ plugin/tests/test_fcm_sender.py | 124 +++++++ plugin/tests/test_push_token.py | 66 ++++ 24 files changed, 1659 insertions(+) create mode 100644 app/src/googlePlay/kotlin/com/hermesandroid/relay/push/FcmPushControllerFactory.kt create mode 100644 app/src/main/kotlin/com/hermesandroid/relay/data/FcmPushConfig.kt create mode 100644 app/src/main/kotlin/com/hermesandroid/relay/push/FcmPushController.kt create mode 100644 app/src/sideload/kotlin/com/hermesandroid/relay/push/FcmPushControllerFactory.kt create mode 100644 app/src/sideload/kotlin/com/hermesandroid/relay/push/HermesFcmMessagingService.kt create mode 100644 app/src/test/kotlin/com/hermesandroid/relay/data/FcmClientConfigParserTest.kt create mode 100644 plugin/relay/fcm_sender.py create mode 100644 plugin/tests/test_fcm_sender.py create mode 100644 plugin/tests/test_push_token.py diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9ce88aa3c..d699bee4f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -393,6 +393,12 @@ dependencies { "googlePlayImplementation"(libs.play.app.update) "googlePlayImplementation"(libs.play.app.update.ktx) + // BYO FCM — sideload only. Runtime FirebaseOptions (no google-services + // plugin). googlePlay stays free of messaging in this theme; Device + // Control remains sideload-only as before. + "sideloadImplementation"(platform(libs.firebase.bom)) + "sideloadImplementation"(libs.firebase.messaging) + // Markdown rendering implementation(libs.markdown.renderer.m3) implementation(libs.markdown.renderer.code) diff --git a/app/src/googlePlay/kotlin/com/hermesandroid/relay/push/FcmPushControllerFactory.kt b/app/src/googlePlay/kotlin/com/hermesandroid/relay/push/FcmPushControllerFactory.kt new file mode 100644 index 000000000..0ddc13200 --- /dev/null +++ b/app/src/googlePlay/kotlin/com/hermesandroid/relay/push/FcmPushControllerFactory.kt @@ -0,0 +1,14 @@ +package com.hermesandroid.relay.push + +import android.content.Context +import com.hermesandroid.relay.data.FcmClientConfig + +/** googlePlay: BYO FCM is sideload-only in this theme. */ +fun createFcmPushController(context: Context): FcmPushController = + object : FcmPushController { + override val isSupported: Boolean = false + override suspend fun applyConfig(config: FcmClientConfig, enabled: Boolean): String = + "FCM is available on the sideload build only" + override suspend fun currentToken(): String = "" + override suspend fun clear() = Unit + } diff --git a/app/src/main/kotlin/com/hermesandroid/relay/data/FcmPushConfig.kt b/app/src/main/kotlin/com/hermesandroid/relay/data/FcmPushConfig.kt new file mode 100644 index 000000000..8c063f2ad --- /dev/null +++ b/app/src/main/kotlin/com/hermesandroid/relay/data/FcmPushConfig.kt @@ -0,0 +1,189 @@ +package com.hermesandroid.relay.data + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Client-side Firebase options for BYO FCM (phone half only). + * + * The Mac holds the service-account JSON used to *send*. Never store a + * service account here. + */ +data class FcmClientConfig( + val projectId: String = "", + val applicationId: String = "", + val apiKey: String = "", + val gcmSenderId: String = "", +) { + val isComplete: Boolean + get() = listOf(projectId, applicationId, apiKey, gcmSenderId) + .all { it.isNotBlank() } +} + +private val KEY_FCM_ENABLED = booleanPreferencesKey("fcm_push_enabled") +private val KEY_FCM_PROJECT_ID = stringPreferencesKey("fcm_project_id") +private val KEY_FCM_APPLICATION_ID = stringPreferencesKey("fcm_application_id") +private val KEY_FCM_API_KEY = stringPreferencesKey("fcm_api_key") +private val KEY_FCM_GCM_SENDER_ID = stringPreferencesKey("fcm_gcm_sender_id") +private val KEY_FCM_LAST_TOKEN = stringPreferencesKey("fcm_last_token") +private val KEY_FCM_LAST_ERROR = stringPreferencesKey("fcm_last_error") + +data class FcmPushPreferences( + val enabled: Boolean = false, + val config: FcmClientConfig = FcmClientConfig(), + val lastToken: String = "", + val lastError: String = "", +) + +fun Context.fcmPushPreferencesFlow(): Flow = + relayDataStore.data.map { prefs -> + FcmPushPreferences( + enabled = prefs[KEY_FCM_ENABLED] ?: false, + config = FcmClientConfig( + projectId = prefs[KEY_FCM_PROJECT_ID].orEmpty(), + applicationId = prefs[KEY_FCM_APPLICATION_ID].orEmpty(), + apiKey = prefs[KEY_FCM_API_KEY].orEmpty(), + gcmSenderId = prefs[KEY_FCM_GCM_SENDER_ID].orEmpty(), + ), + lastToken = prefs[KEY_FCM_LAST_TOKEN].orEmpty(), + lastError = prefs[KEY_FCM_LAST_ERROR].orEmpty(), + ) + } + +suspend fun Context.setFcmPushEnabled(enabled: Boolean) { + relayDataStore.edit { it[KEY_FCM_ENABLED] = enabled } +} + +suspend fun Context.setFcmClientConfig(config: FcmClientConfig) { + relayDataStore.edit { + it[KEY_FCM_PROJECT_ID] = config.projectId.trim() + it[KEY_FCM_APPLICATION_ID] = config.applicationId.trim() + it[KEY_FCM_API_KEY] = config.apiKey.trim() + it[KEY_FCM_GCM_SENDER_ID] = config.gcmSenderId.trim() + } +} + +suspend fun Context.setFcmLastToken(token: String) { + relayDataStore.edit { + it[KEY_FCM_LAST_TOKEN] = token + if (token.isNotBlank()) it[KEY_FCM_LAST_ERROR] = "" + } +} + +suspend fun Context.setFcmLastError(message: String) { + relayDataStore.edit { it[KEY_FCM_LAST_ERROR] = message } +} + +suspend fun Context.clearFcmClientConfig() { + relayDataStore.edit { + it.remove(KEY_FCM_PROJECT_ID) + it.remove(KEY_FCM_APPLICATION_ID) + it.remove(KEY_FCM_API_KEY) + it.remove(KEY_FCM_GCM_SENDER_ID) + it.remove(KEY_FCM_LAST_TOKEN) + it.remove(KEY_FCM_LAST_ERROR) + it[KEY_FCM_ENABLED] = false + } +} + +/** + * Parse Firebase Android client fields from pasted `google-services.json` + * text or a compact JSON object with the same keys. + */ +object FcmClientConfigParser { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun parse(raw: String): Result { + val text = raw.trim() + if (text.isEmpty()) { + return Result.failure(IllegalArgumentException("Empty Firebase config")) + } + return try { + val root = json.parseToJsonElement(text).jsonObject + val fromClient = root["client"] + ?.let { el -> + // google-services.json uses an array under "client" + runCatching { el.jsonObject }.getOrNull() + ?: el.toString().let { null } + } + // Prefer first client entry when present + val clientObj: JsonObject? = when (val client = root["client"]) { + is kotlinx.serialization.json.JsonArray -> + client.firstOrNull()?.jsonObject + is JsonObject -> client + else -> null + } + val projectInfo = root["project_info"]?.jsonObject + val clientInfo = clientObj?.get("client_info")?.jsonObject + val androidClientInfo = clientInfo?.get("android_client_info")?.jsonObject + val apiKeyObj = clientObj + ?.get("api_key") + ?.let { keyEl -> + when (keyEl) { + is kotlinx.serialization.json.JsonArray -> + keyEl.firstOrNull()?.jsonObject + is JsonObject -> keyEl + else -> null + } + } + + val projectId = listOfNotNull( + root["project_id"]?.jsonPrimitive?.contentOrNull, + projectInfo?.get("project_id")?.jsonPrimitive?.contentOrNull, + ).firstOrNull().orEmpty() + + val applicationId = listOfNotNull( + root["application_id"]?.jsonPrimitive?.contentOrNull, + root["mobilesdk_app_id"]?.jsonPrimitive?.contentOrNull, + clientInfo?.get("mobilesdk_app_id")?.jsonPrimitive?.contentOrNull, + ).firstOrNull().orEmpty() + + val apiKey = listOfNotNull( + root["api_key"]?.jsonPrimitive?.contentOrNull, + root["apiKey"]?.jsonPrimitive?.contentOrNull, + apiKeyObj?.get("current_key")?.jsonPrimitive?.contentOrNull, + ).firstOrNull().orEmpty() + + val gcmSenderId = listOfNotNull( + root["gcm_sender_id"]?.jsonPrimitive?.contentOrNull, + root["messaging_sender_id"]?.jsonPrimitive?.contentOrNull, + root["messagingSenderId"]?.jsonPrimitive?.contentOrNull, + projectInfo?.get("project_number")?.jsonPrimitive?.contentOrNull, + ).firstOrNull().orEmpty() + + // package_name is informational; Firebase still needs the app + // registered under the install applicationId. + val config = FcmClientConfig( + projectId = projectId, + applicationId = applicationId, + apiKey = apiKey, + gcmSenderId = gcmSenderId, + ) + if (!config.isComplete) { + Result.failure( + IllegalArgumentException( + "Missing fields — need project_id, mobilesdk_app_id, api_key, gcm_sender_id", + ), + ) + } else { + Result.success(config) + } + } catch (e: Exception) { + Result.failure(IllegalArgumentException("Invalid JSON: ${e.message}", e)) + } + } +} diff --git a/app/src/main/kotlin/com/hermesandroid/relay/network/relay/RelayHttpClient.kt b/app/src/main/kotlin/com/hermesandroid/relay/network/relay/RelayHttpClient.kt index 5ea07531f..75b165f6e 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/network/relay/RelayHttpClient.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/network/relay/RelayHttpClient.kt @@ -1099,6 +1099,85 @@ class RelayHttpClient( } } + /** + * Register (or clear) a BYO FCM device token on the relay. + * + * POST /push/token — empty [token] clears the registration for this device. + */ + suspend fun registerPushToken( + token: String, + platform: String = "android", + projectId: String = "", + ): Result = withContext(Dispatchers.IO) { + val relayUrl = relayUrlProvider()?.trim().orEmpty() + if (relayUrl.isEmpty()) { + return@withContext Result.failure(IllegalStateException("Relay URL not configured")) + } + val sessionToken = sessionTokenProvider() + if (sessionToken.isNullOrBlank()) { + return@withContext Result.failure( + IllegalStateException("Relay not paired — session token missing"), + ) + } + val httpBase = relayHttpBaseOrNull(relayUrl) + ?: return@withContext Result.failure(IOException("Invalid relay URL")) + val url = try { + "$httpBase/push/token".toHttpUrl() + } catch (e: IllegalArgumentException) { + return@withContext Result.failure(IOException("Invalid relay URL: ${e.message}")) + } + val bodyJson = buildString { + append('{') + append("\"token\":").append(jsonString(token)) + append(",\"platform\":").append(jsonString(platform)) + if (projectId.isNotBlank()) { + append(",\"project_id\":").append(jsonString(projectId)) + } + append('}') + } + val request = Request.Builder() + .url(url) + .post(bodyJson.toRequestBody("application/json; charset=utf-8".toMediaType())) + .relaySessionCredential(sessionToken, isDashboardRelayIngressUrl(relayUrl)) + .header("Accept", "application/json") + .build() + try { + callClient(relayUrl).newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val reason = when (response.code) { + 401, 403 -> "Unauthorized — re-pair with the relay" + in 500..599 -> "Relay error (HTTP ${response.code})" + else -> "HTTP ${response.code}: ${response.message.ifBlank { "request failed" }}" + } + return@withContext Result.failure(IOException(reason)) + } + Result.success(Unit) + } + } catch (e: IOException) { + Log.w(TAG, "registerPushToken failed: ${e.message}") + Result.failure(e) + } catch (e: Exception) { + Log.w(TAG, "registerPushToken unexpected error: ${e.message}") + Result.failure(e) + } + } + + private fun jsonString(value: String): String = + buildString { + append('"') + value.forEach { c -> + when (c) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> if (c.code < 0x20) append("\\u%04x".format(c.code)) else append(c) + } + } + append('"') + } + /** * Reduce this bearer session's TTL or grants. Omitted grants are preserved; * any lifetime or grant expansion requires fresh operator-approved pairing. diff --git a/app/src/main/kotlin/com/hermesandroid/relay/push/FcmPushController.kt b/app/src/main/kotlin/com/hermesandroid/relay/push/FcmPushController.kt new file mode 100644 index 000000000..1c79882e0 --- /dev/null +++ b/app/src/main/kotlin/com/hermesandroid/relay/push/FcmPushController.kt @@ -0,0 +1,33 @@ +package com.hermesandroid.relay.push + +import android.content.Context +import com.hermesandroid.relay.data.FcmClientConfig + +/** + * Flavor-split BYO FCM controller. + * + * - **sideload**: runtime [FirebaseApp] init + token fetch/upload hooks. + * - **googlePlay**: no-op (this theme keeps messaging off the Play track). + * + * Factories live in each flavor as `createFcmPushController` with the same + * package + signature (same pattern as update availability). + */ +interface FcmPushController { + /** True when this build can actually talk to FCM (sideload + GMS). */ + val isSupported: Boolean + + /** + * Apply client config and optionally enable. Returns a short status + * string for the Settings UI (ok / error). Never throws. + */ + suspend fun applyConfig(config: FcmClientConfig, enabled: Boolean): String + + /** + * Fetch the current FCM registration token after a successful init. + * Empty string when unavailable. + */ + suspend fun currentToken(): String + + /** Tear down the named Firebase app instance (best-effort). */ + suspend fun clear() +} diff --git a/app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ProactiveSettingsScreen.kt b/app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ProactiveSettingsScreen.kt index e128912f5..daba0bae8 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ProactiveSettingsScreen.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ProactiveSettingsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Message +import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -27,14 +28,19 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -43,6 +49,8 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import com.hermesandroid.relay.R import com.hermesandroid.relay.auth.AuthState +import com.hermesandroid.relay.data.FcmClientConfig +import com.hermesandroid.relay.data.FcmClientConfigParser import com.hermesandroid.relay.viewmodel.ConnectionViewModel /** @@ -179,7 +187,159 @@ fun ProactiveSettingsScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + + if (connectionViewModel.fcmSupported) { + FcmByoSection(connectionViewModel = connectionViewModel, paired = paired) + } + } + } +} + +@Composable +private fun FcmByoSection( + connectionViewModel: ConnectionViewModel, + paired: Boolean, +) { + val prefs by connectionViewModel.fcmPushPreferences.collectAsState() + val status by connectionViewModel.fcmStatusMessage.collectAsState() + var projectId by remember(prefs.config.projectId) { mutableStateOf(prefs.config.projectId) } + var applicationId by remember(prefs.config.applicationId) { + mutableStateOf(prefs.config.applicationId) + } + var apiKey by remember(prefs.config.apiKey) { mutableStateOf(prefs.config.apiKey) } + var gcmSenderId by remember(prefs.config.gcmSenderId) { + mutableStateOf(prefs.config.gcmSenderId) + } + var pasteJson by remember { mutableStateOf("") } + var enabled by remember(prefs.enabled) { mutableStateOf(prefs.enabled) } + + ProactiveSectionCard(title = stringResource(R.string.proactive_fcm_title)) { + Text( + text = stringResource(R.string.proactive_fcm_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.proactive_fcm_enable), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + ) + Switch(checked = enabled, onCheckedChange = { enabled = it }) + } + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = pasteJson, + onValueChange = { pasteJson = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.proactive_fcm_paste_json)) }, + minLines = 3, + maxLines = 8, + ) + Spacer(Modifier.height(8.dp)) + FilledTonalButton( + onClick = { + val parsed = FcmClientConfigParser.parse(pasteJson) + parsed.getOrNull()?.let { cfg -> + projectId = cfg.projectId + applicationId = cfg.applicationId + apiKey = cfg.apiKey + gcmSenderId = cfg.gcmSenderId + } + }, + enabled = pasteJson.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.proactive_fcm_import_json)) + } + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = projectId, + onValueChange = { projectId = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.proactive_fcm_project_id)) }, + singleLine = true, + ) + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = applicationId, + onValueChange = { applicationId = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.proactive_fcm_application_id)) }, + singleLine = true, + ) + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = apiKey, + onValueChange = { apiKey = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.proactive_fcm_api_key)) }, + singleLine = true, + ) + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = gcmSenderId, + onValueChange = { gcmSenderId = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.proactive_fcm_sender_id)) }, + singleLine = true, + ) + Spacer(Modifier.height(12.dp)) + Button( + onClick = { + connectionViewModel.saveFcmClientConfig( + FcmClientConfig( + projectId = projectId, + applicationId = applicationId, + apiKey = apiKey, + gcmSenderId = gcmSenderId, + ), + enabled = enabled, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.proactive_fcm_save)) + } + TextButton( + onClick = { connectionViewModel.clearFcmClientConfig() }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.proactive_fcm_clear)) + } + if (prefs.lastToken.isNotBlank()) { + Text( + text = stringResource( + R.string.proactive_fcm_token_status, + prefs.lastToken.take(12) + "…", + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (status.isNotBlank()) { + Text( + text = status, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + if (enabled && !paired) { + Text( + text = stringResource(R.string.proactive_fcm_not_paired), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) } + Text( + text = stringResource(R.string.proactive_fcm_mac_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } diff --git a/app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ConnectionViewModel.kt b/app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ConnectionViewModel.kt index 9de374eda..dedb0db52 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ConnectionViewModel.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ConnectionViewModel.kt @@ -79,6 +79,14 @@ import com.hermesandroid.relay.data.SessionTransport import com.hermesandroid.relay.data.relayDataStore import com.hermesandroid.relay.data.proactiveEnabledFlow import com.hermesandroid.relay.data.setProactiveEnabled +import com.hermesandroid.relay.data.fcmPushPreferencesFlow +import com.hermesandroid.relay.data.setFcmClientConfig +import com.hermesandroid.relay.data.setFcmPushEnabled +import com.hermesandroid.relay.data.setFcmLastToken +import com.hermesandroid.relay.data.clearFcmClientConfig +import com.hermesandroid.relay.data.FcmClientConfig +import com.hermesandroid.relay.data.FcmPushPreferences +import com.hermesandroid.relay.data.FcmClientConfigParser import com.hermesandroid.relay.data.DEFAULT_HIDDEN_SOURCES import com.hermesandroid.relay.data.ProactiveInboxEntry import com.hermesandroid.relay.data.ProactiveInboxRepository @@ -3550,6 +3558,84 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati } } + private val fcmPushController by lazy { + com.hermesandroid.relay.push.createFcmPushController(getApplication()) + } + + val fcmPushPreferences: StateFlow = + getApplication().fcmPushPreferencesFlow() + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + com.hermesandroid.relay.data.FcmPushPreferences(), + ) + + val fcmSupported: Boolean get() = fcmPushController.isSupported + + private val _fcmStatusMessage = MutableStateFlow("") + val fcmStatusMessage: StateFlow = _fcmStatusMessage.asStateFlow() + + /** + * Persist BYO Firebase client fields, optionally enable FCM, init runtime + * Firebase, and upload the device token when paired. + */ + fun saveFcmClientConfig( + config: com.hermesandroid.relay.data.FcmClientConfig, + enabled: Boolean, + ) { + viewModelScope.launch { + val app = getApplication() + app.setFcmClientConfig(config) + app.setFcmPushEnabled(enabled) + val status = fcmPushController.applyConfig(config, enabled) + _fcmStatusMessage.value = status + if (!enabled) { + relayHttpClient.registerPushToken(token = "").onFailure { } + app.setFcmLastToken("") + return@launch + } + val token = fcmPushController.currentToken() + if (token.isNotBlank()) { + app.setFcmLastToken(token) + val result = relayHttpClient.registerPushToken( + token = token, + projectId = config.projectId, + ) + _fcmStatusMessage.value = if (result.isSuccess) { + "FCM ready — token registered with relay" + } else { + "FCM ready locally; relay register failed: ${result.exceptionOrNull()?.message}" + } + } else if (status == "FCM ready") { + _fcmStatusMessage.value = "FCM ready — waiting for device token" + } + } + } + + fun clearFcmClientConfig() { + viewModelScope.launch { + fcmPushController.clear() + getApplication().clearFcmClientConfig() + relayHttpClient.registerPushToken(token = "") + _fcmStatusMessage.value = "FCM cleared" + } + } + + /** Re-upload token after pair / token refresh. */ + fun syncFcmTokenIfNeeded() { + viewModelScope.launch { + val prefs = fcmPushPreferences.value + if (!prefs.enabled || !prefs.config.isComplete || !fcmPushController.isSupported) { + return@launch + } + fcmPushController.applyConfig(prefs.config, enabled = true) + val token = fcmPushController.currentToken().ifBlank { prefs.lastToken } + if (token.isBlank()) return@launch + getApplication().setFcmLastToken(token) + relayHttpClient.registerPushToken(token = token, projectId = prefs.config.projectId) + } + } + /** Clear the Hermes inbox of agent-initiated messages. */ fun clearProactiveInbox() { viewModelScope.launch { proactiveInbox.clear() } @@ -4589,6 +4675,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati refreshPhoneThreadChatIds() // Check whether the relay's plugin is behind the latest release. refreshRelayUpdateInfo() + // Re-register BYO FCM token after pair / reconnect. + syncFcmTokenIfNeeded() } } // React to the toggle flipping while already connected. drop(1) skips diff --git a/app/src/main/res/values-b+pt+BR/strings.xml b/app/src/main/res/values-b+pt+BR/strings.xml index efff5fd2d..e19482b30 100644 --- a/app/src/main/res/values-b+pt+BR/strings.xml +++ b/app/src/main/res/values-b+pt+BR/strings.xml @@ -1391,6 +1391,24 @@ Sobre Quando ativado, seu celular informa ao relay que aceita mensagens iniciadas pelo agente. O agente as entrega pela mesma conexão pareada já usada pelo relay — sem novas permissões além das notificações. Seu servidor também precisa ativar a plataforma do celular (defina PHONE_ENABLED no servidor). Enquanto os dois lados não estiverem ativados e o celular não estiver pareado, nada será enviado. + + Despertar push (BYO FCM) + Opcional. Use seu próprio projeto Firebase para o Hermes acordar este telefone quando a conexão ao vivo cair. O texto completo ainda chega pelo Relay após reconectar. Somente sideload; precisa do Google Play Services. + Ativar despertar FCM + Colar google-services.json + Importar campos JSON + ID do projeto + ID do app Mobile SDK + Chave de API + ID do remetente GCM / messaging + Salvar configurações FCM + Limpar configurações FCM + Token do dispositivo: %1$s + Pareie o Relay para registrar o token do dispositivo no host. + No Mac, defina RELAY_FCM_SERVICE_ACCOUNT_JSON com o caminho (ou JSON) da service account do Firebase. Nunca coloque a service account no app. + Hermes + O Hermes tem uma mensagem — abra para reconectar e entregar. + Despertar push Voltar Atualizar tudo diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index dd5532fb2..68425986c 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -1446,6 +1446,24 @@ 开启后,您的手机会告知 Relay 它愿意接收代理主动发起的消息。代理会通过 Relay 已使用的同一配对连接投递消息——除通知外无需新权限。 您的服务器也必须启用手机平台(在服务器上设置 PHONE_ENABLED)。在两侧均开启且手机已配对之前,不会推送任何内容。 + 推送唤醒(BYO FCM) + 可选。使用你自己的 Firebase 项目,以便在实时连接断开时由 Hermes 唤醒本机。完整正文仍在重连后通过 Relay 送达。仅 sideload;需要 Google Play 服务。 + 启用 FCM 唤醒 + 粘贴 google-services.json + 导入 JSON 字段 + 项目 ID + Mobile SDK 应用 ID + API 密钥 + GCM / messaging 发送方 ID + 保存 FCM 设置 + 清除 FCM 设置 + 设备令牌:%1$s + 配对 Relay 以向主机注册设备令牌。 + 在 Mac 上将 RELAY_FCM_SERVICE_ACCOUNT_JSON 设为 Firebase 服务账号路径(或 JSON)。切勿把服务账号放进应用。 + Hermes + Hermes 有一条消息 — 打开以重连并投递。 + 推送唤醒 + 返回 刷新全部 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b935f8418..8bbaae79f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1452,6 +1452,24 @@ Wenn aktiviert, teilt dein Smartphone dem Relay mit, dass es für vom Agenten gestartete Nachrichten bereit ist. Der Agent liefert sie über dieselbe gekoppelte Relay-Verbindung — zusätzliche Berechtigungen außer Benachrichtigungen sind nicht nötig. Auf deinem Server muss auch die Smartphone-Plattform aktiviert sein (PHONE_ENABLED auf dem Server setzen). Solange nicht beide Seiten aktiv und das Smartphone gekoppelt sind, wird nichts zugestellt. + Push-Wake (BYO FCM) + Optional. Nutze dein eigenes Firebase-Projekt, damit Hermes dieses Telefon wecken kann, wenn die Live-Verbindung weg ist. Der volle Text kommt weiter über Relay nach dem Reconnect. Nur Sideload; braucht Google Play Services. + FCM-Wake aktivieren + google-services.json einfügen + JSON-Felder importieren + Projekt-ID + Mobile-SDK-App-ID + API-Schlüssel + GCM- / Messaging-Sender-ID + FCM-Einstellungen speichern + FCM-Einstellungen löschen + Gerätetoken: %1$s + Kopple Relay, um das Gerätetoken beim Host zu registrieren. + Auf dem Mac RELAY_FCM_SERVICE_ACCOUNT_JSON auf den Firebase-Service-Account-Pfad (oder JSON) setzen. Niemals den Service Account in die App legen. + Hermes + Hermes hat eine Nachricht — öffnen zum Reconnect und Zustellen. + Push-Wake + Zurück Alles aktualisieren diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b70e466c7..a1f35b5fd 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1334,6 +1334,24 @@ Acerca de Cuando está activado, su teléfono le dice al relay que está abierto a mensajes iniciados por el agente. El agente los entrega a través de la misma conexión emparejada que el relay ya utiliza, sin nuevos permisos más allá de las notificaciones. Su servidor también debe habilitar la plataforma telefónica (configure PHONE_ENABLED en el servidor). Hasta que ambos lados estén encendidos y el teléfono esté emparejado, no se presiona nada. + + Despertar push (BYO FCM) + Opcional. Usa tu propio proyecto Firebase para que Hermes despierte este teléfono cuando la conexión en vivo esté caída. El texto completo sigue llegando por Relay tras reconectar. Solo sideload; necesita Google Play Services. + Activar despertar FCM + Pegar google-services.json + Importar campos JSON + ID del proyecto + ID de app Mobile SDK + Clave API + ID de remitente GCM / messaging + Guardar ajustes FCM + Borrar ajustes FCM + Token del dispositivo: %1$s + Empareja Relay para registrar el token del dispositivo en el host. + En el Mac, define RELAY_FCM_SERVICE_ACCOUNT_JSON con la ruta (o JSON) de la cuenta de servicio de Firebase. Nunca pongas la cuenta de servicio en la app. + Hermes + Hermes tiene un mensaje: abre para reconectar y entregar. + Despertar push Atrás Actualizar todo Inspeccionar %1$s diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 356621389..190053068 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1462,6 +1462,24 @@ オンにすると、電話機はエージェントが開始したメッセージに対してオープンであることをRelayに伝えます。エージェントは、Relayがすでに使用しているのと同じペア接続を介してそれらを配信します。通知以外に新しい権限はありません。 サーバーでは電話プラットフォームも有効にする必要があります (サーバーで PHONE_ENABLED を設定)。両側の電源が入って電話機がペアリングされるまでは、何も押されません。 + プッシュ起床 (BYO FCM) + 任意。独自の Firebase プロジェクトを使い、ライブ接続が切れたときに Hermes がこの端末を起床できます。本文は再接続後に Relay 経由で届きます。サイドロードのみ。Google Play 開発者サービスが必要です。 + FCM 起床を有効化 + google-services.json を貼り付け + JSON フィールドをインポート + プロジェクト ID + Mobile SDK アプリ ID + API キー + GCM / messaging センダー ID + FCM 設定を保存 + FCM 設定をクリア + デバイストークン: %1$s + ホストにデバイストークンを登録するには Relay をペアリングしてください。 + Mac では RELAY_FCM_SERVICE_ACCOUNT_JSON に Firebase サービスアカウントのパス(または JSON)を設定します。サービスアカウントをアプリに入れないでください。 + Hermes + Hermes にメッセージがあります — 開いて再接続し配信します。 + プッシュ起床 + 戻る すべて更新 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 8b5b7ae53..dbf083a44 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1467,6 +1467,24 @@ О программе Когда включено, ваш телефон сообщает Relay, что он открыт для сообщений, инициированных агентом. Агент доставляет их через то же сопряженное соединение, которое Relay уже использует — никаких новых разрешений, кроме уведомлений. Ваш сервер также должен включить платформу телефона (установить PHONE_ENABLED на сервере). Пока обе стороны не включены и телефон не сопряжен, ничего не отправляется. + + Push-пробуждение (BYO FCM) + Необязательно. Используйте свой проект Firebase, чтобы Hermes мог разбудить телефон, когда живое соединение недоступно. Полный текст по-прежнему приходит через Relay после переподключения. Только sideload; нужны Google Play Services. + Включить FCM-пробуждение + Вставить google-services.json + Импортировать поля JSON + ID проекта + ID приложения Mobile SDK + API-ключ + GCM / messaging sender ID + Сохранить настройки FCM + Очистить настройки FCM + Токен устройства: %1$s + Сопрягите Relay, чтобы зарегистрировать токен устройства на хосте. + На Mac задайте RELAY_FCM_SERVICE_ACCOUNT_JSON — путь к service account Firebase (или JSON). Никогда не кладите service account в приложение. + Hermes + У Hermes есть сообщение — откройте, чтобы переподключиться и доставить. + Push-пробуждение Назад Обновить все Инспектировать %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f1233cb9..f50ef9c66 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1728,6 +1728,23 @@ About When on, your phone tells the relay it\'s open to agent-initiated messages. The agent delivers them over the same paired connection the relay already uses — no new permissions beyond notifications. Your server must also enable the phone platform (set PHONE_ENABLED on the server). Until both sides are on and the phone is paired, nothing is pushed. + Push wake (BYO FCM) + Optional. Use your own Firebase project so Hermes can wake this phone when the live connection is down. Full message text still arrives over Relay after reconnect. Sideload only; needs Google Play Services. + Enable FCM wake + Paste google-services.json + Import JSON fields + Project ID + Mobile SDK app ID + API key + GCM / messaging sender ID + Save FCM settings + Clear FCM settings + Device token: %1$s + Pair Relay to register the device token with the host. + On the Mac, set RELAY_FCM_SERVICE_ACCOUNT_JSON to your Firebase service-account path (or JSON). Never put the service account in the app. + Hermes + Hermes has a message — open to reconnect and deliver. + Push wake %1$d message arrived while you were away %1$d messages arrived while you were away diff --git a/app/src/sideload/AndroidManifest.xml b/app/src/sideload/AndroidManifest.xml index a08e8afc1..0589548d0 100644 --- a/app/src/sideload/AndroidManifest.xml +++ b/app/src/sideload/AndroidManifest.xml @@ -84,6 +84,10 @@ WAKE_LOCK covers both lock levels. --> + + + + + + + + diff --git a/app/src/sideload/kotlin/com/hermesandroid/relay/push/FcmPushControllerFactory.kt b/app/src/sideload/kotlin/com/hermesandroid/relay/push/FcmPushControllerFactory.kt new file mode 100644 index 000000000..09873c195 --- /dev/null +++ b/app/src/sideload/kotlin/com/hermesandroid/relay/push/FcmPushControllerFactory.kt @@ -0,0 +1,90 @@ +package com.hermesandroid.relay.push + +import android.content.Context +import android.util.Log +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.messaging.FirebaseMessaging +import com.hermesandroid.relay.data.FcmClientConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext + +private const val TAG = "FcmPushController" + +/** + * sideload: runtime FirebaseOptions on the **default** FirebaseApp so + * [HermesFcmMessagingService] receives tokens/messages without a named-app + * split. No google-services.json is baked into the APK. + */ +fun createFcmPushController(context: Context): FcmPushController = + SideloadFcmPushController(context.applicationContext) + +private class SideloadFcmPushController( + private val appContext: Context, +) : FcmPushController { + override val isSupported: Boolean = true + + override suspend fun applyConfig(config: FcmClientConfig, enabled: Boolean): String = + withContext(Dispatchers.IO) { + if (!enabled) { + clearLocked() + return@withContext "FCM disabled" + } + if (!config.isComplete) { + return@withContext "Incomplete Firebase client config" + } + try { + val options = FirebaseOptions.Builder() + .setProjectId(config.projectId) + .setApplicationId(config.applicationId) + .setApiKey(config.apiKey) + .setGcmSenderId(config.gcmSenderId) + .build() + + val default = FirebaseApp.getApps(appContext) + .firstOrNull { it.name == FirebaseApp.DEFAULT_APP_NAME } + if (default != null) { + // Options are immutable after init — recreate when changed. + val same = + default.options.projectId == options.projectId && + default.options.applicationId == options.applicationId && + default.options.apiKey == options.apiKey && + default.options.gcmSenderId == options.gcmSenderId + if (!same) { + default.delete() + FirebaseApp.initializeApp(appContext, options) + } + } else { + FirebaseApp.initializeApp(appContext, options) + } + FirebaseMessaging.getInstance().token.await() + "FCM ready" + } catch (t: Throwable) { + Log.w(TAG, "applyConfig failed", t) + "FCM init failed: ${t.message ?: t.javaClass.simpleName}" + } + } + + override suspend fun currentToken(): String = withContext(Dispatchers.IO) { + try { + if (FirebaseApp.getApps(appContext).isEmpty()) return@withContext "" + FirebaseMessaging.getInstance().token.await().orEmpty() + } catch (t: Throwable) { + Log.w(TAG, "currentToken failed", t) + "" + } + } + + override suspend fun clear() = withContext(Dispatchers.IO) { clearLocked() } + + private fun clearLocked() { + try { + FirebaseApp.getApps(appContext) + .firstOrNull { it.name == FirebaseApp.DEFAULT_APP_NAME } + ?.delete() + } catch (t: Throwable) { + Log.w(TAG, "clear failed", t) + } + } +} diff --git a/app/src/sideload/kotlin/com/hermesandroid/relay/push/HermesFcmMessagingService.kt b/app/src/sideload/kotlin/com/hermesandroid/relay/push/HermesFcmMessagingService.kt new file mode 100644 index 000000000..5d3a19d9f --- /dev/null +++ b/app/src/sideload/kotlin/com/hermesandroid/relay/push/HermesFcmMessagingService.kt @@ -0,0 +1,166 @@ +package com.hermesandroid.relay.push + +import android.Manifest +import android.annotation.SuppressLint +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.hermesandroid.relay.MainActivity +import com.hermesandroid.relay.R +import com.hermesandroid.relay.data.setFcmLastToken +import com.hermesandroid.relay.network.upstream.GatewayKeepAliveService +import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * High-priority data messages wake the process so proactive WSS can reconnect + * and drain the 24h buffer. Full message text is NOT carried on FCM. + * + * Uses the default [FirebaseApp] initialized by [SideloadFcmPushController]. + */ +class HermesFcmMessagingService : FirebaseMessagingService() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val mainHandler = Handler(Looper.getMainLooper()) + + override fun onNewToken(token: String) { + Log.i(TAG, "onNewToken len=${token.length}") + scope.launch { + try { + applicationContext.setFcmLastToken(token) + } catch (t: Throwable) { + Log.w(TAG, "persist token failed", t) + } + } + } + + override fun onMessageReceived(message: RemoteMessage) { + val data = message.data + val type = data["type"].orEmpty() + val action = data["action"].orEmpty() + val messageId = data["message_id"].orEmpty() + val title = data["title"]?.takeIf { it.isNotBlank() } + Log.i( + TAG, + "onMessageReceived type=$type action=$action message_id=$messageId keys=${data.keys}", + ) + val isWake = + type == "hermes_wake" || + type == "proactive.wake" || + action == "wake" || + data.isNotEmpty() + if (!isWake) return + + postWakeNotification(title = title, messageId = messageId) + // Bring UI up so ConnectionViewModel reconnects + proactive.subscribe. + kickMainActivity() + // Best-effort keep-alive nudge when the user already opted into persistent + // connection; GatewayKeepAliveService.update is main-thread only. + mainHandler.post { + try { + GatewayKeepAliveService.update( + applicationContext, + persistent = true, + activeTurns = ActiveTurnKeepAliveRegistry.Snapshot(), + appForeground = true, + ) + } catch (t: Throwable) { + Log.w(TAG, "keep-alive nudge failed", t) + } + } + } + + private fun kickMainActivity() { + try { + val intent = Intent(applicationContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(MainActivity.EXTRA_NAV_ROUTE, "chat") + putExtra(EXTRA_FCM_WAKE, true) + } + applicationContext.startActivity(intent) + } catch (t: Throwable) { + // Background activity starts are restricted on newer Android; the + // heads-up notification remains the reliable user-visible path. + Log.i(TAG, "startActivity after FCM wake deferred: ${t.message}") + } + } + + @SuppressLint("MissingPermission", "NotificationPermission") + private fun postWakeNotification(title: String?, messageId: String) { + if (!hasPostNotificationsPermission()) { + Log.i(TAG, "POST_NOTIFICATIONS not granted — skip wake notification") + return + } + ensureChannel() + val tapIntent = Intent(applicationContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(MainActivity.EXTRA_NAV_ROUTE, "chat") + putExtra(EXTRA_FCM_WAKE, true) + } + val requestCode = (messageId.ifBlank { "wake" }.hashCode() and 0x7fffffff) + val pending = PendingIntent.getActivity( + applicationContext, + requestCode, + tapIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val resolvedTitle = title?.takeIf { it.isNotBlank() } + ?: getString(R.string.proactive_fcm_wake_title) + val body = getString(R.string.proactive_fcm_wake_body) + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(resolvedTitle) + .setContentText(body) + .setContentIntent(pending) + .setAutoCancel(true) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .build() + runCatching { + NotificationManagerCompat.from(this).notify(NOTIFICATION_ID_BASE + (requestCode % 1000), notification) + }.onFailure { Log.w(TAG, "wake notify failed", it) } + } + + private fun ensureChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = getSystemService(NotificationManager::class.java) ?: return + val existing = manager.getNotificationChannel(CHANNEL_ID) + if (existing != null) return + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + getString(R.string.proactive_fcm_wake_channel), + NotificationManager.IMPORTANCE_HIGH, + ), + ) + } + + private fun hasPostNotificationsPermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true + return ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + } + + companion object { + private const val TAG = "HermesFcmMsg" + private const val CHANNEL_ID = "hermes_fcm_wake" + private const val NOTIFICATION_ID_BASE = 0x46434D00 // "FCM" + const val EXTRA_FCM_WAKE = "com.hermesandroid.relay.push.EXTRA_FCM_WAKE" + } +} diff --git a/app/src/test/kotlin/com/hermesandroid/relay/data/FcmClientConfigParserTest.kt b/app/src/test/kotlin/com/hermesandroid/relay/data/FcmClientConfigParserTest.kt new file mode 100644 index 000000000..a5731bf45 --- /dev/null +++ b/app/src/test/kotlin/com/hermesandroid/relay/data/FcmClientConfigParserTest.kt @@ -0,0 +1,62 @@ +package com.hermesandroid.relay.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FcmClientConfigParserTest { + @Test + fun parse_googleServicesJson_extractsFields() { + val raw = """ + { + "project_info": { + "project_number": "123456789012", + "project_id": "my-demo", + "storage_bucket": "my-demo.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:123456789012:android:abcdef", + "android_client_info": { + "package_name": "com.axiomlabs.hermesrelay.sideload" + } + }, + "api_key": [ + { "current_key": "AIzaSyDemoKey" } + ] + } + ] + } + """.trimIndent() + val cfg = FcmClientConfigParser.parse(raw).getOrThrow() + assertEquals("my-demo", cfg.projectId) + assertEquals("1:123456789012:android:abcdef", cfg.applicationId) + assertEquals("AIzaSyDemoKey", cfg.apiKey) + assertEquals("123456789012", cfg.gcmSenderId) + assertTrue(cfg.isComplete) + } + + @Test + fun parse_compactObject_works() { + val raw = """ + { + "project_id": "p", + "application_id": "1:1:android:x", + "api_key": "k", + "gcm_sender_id": "99" + } + """.trimIndent() + val cfg = FcmClientConfigParser.parse(raw).getOrThrow() + assertEquals("p", cfg.projectId) + assertEquals("99", cfg.gcmSenderId) + } + + @Test + fun parse_incomplete_fails() { + val result = FcmClientConfigParser.parse("""{"project_id":"only"}""") + assertTrue(result.isFailure) + assertFalse(FcmClientConfig(projectId = "only").isComplete) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7e3d87e84..35b7e9084 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,7 @@ sherpaOnnx = "v1.13.4" # dependabot #706 breaks check-android-native-compat (JNI symbol version). onnxRuntime = "1.27.0" play-app-update = "2.1.0" +firebase-bom = "34.4.0" [libraries] # Compose BOM @@ -115,6 +116,11 @@ onnxruntime-android = { group = "com.microsoft.onnxruntime", name = "onnxruntime play-app-update = { group = "com.google.android.play", name = "app-update", version.ref = "play-app-update" } play-app-update-ktx = { group = "com.google.android.play", name = "app-update-ktx", version.ref = "play-app-update" } +# Firebase Cloud Messaging — sideload flavor ONLY (BYO project via runtime +# FirebaseOptions). googlePlay stays free of messaging deps in this track. +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase-bom" } +firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } + # Serialization kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } diff --git a/plugin/relay/channels/proactive.py b/plugin/relay/channels/proactive.py index 464d479c0..c28693aa7 100644 --- a/plugin/relay/channels/proactive.py +++ b/plugin/relay/channels/proactive.py @@ -175,6 +175,9 @@ def __init__(self) -> None: # Bounded + drop-oldest; stale entries pruned on flush. self._outbound: deque[dict[str, Any]] = deque(maxlen=MAX_BUFFERED_OUTBOUND) self.queued_count: int = 0 + # Optional BYO FCM (or other) wake when a message is queued offline. + # Callable may be sync or async; signature (message_id, title) -> None. + self.on_queued_wake: Any | None = None # ── Envelope dispatch (inbound from phone) ─────────────────────────── @@ -310,6 +313,10 @@ async def push(self, payload: dict[str, Any]) -> dict[str, Any]: out_payload["chat_id"], len(self._outbound), ) + await self._maybe_wake_offline_phone( + message_id=message_id, + title=out_payload.get("title"), + ) return { "delivered": False, "queued": True, @@ -500,6 +507,23 @@ def buffered_outbound_count(self) -> int: """Number of agent→phone messages queued for the next subscribe.""" return len(self._outbound) + async def _maybe_wake_offline_phone( + self, + *, + message_id: str, + title: Any = None, + ) -> None: + """Best-effort offline wake (BYO FCM). Never raises into push().""" + hook = self.on_queued_wake + if hook is None: + return + try: + result = hook(message_id, title if isinstance(title, str) else None) + if asyncio.iscoroutine(result): + await result + except Exception as exc: # pragma: no cover - defensive + logger.warning("proactive: on_queued_wake failed: %s", exc) + # ── Lifecycle ──────────────────────────────────────────────────────── def is_phone_subscribed(self) -> bool: diff --git a/plugin/relay/fcm_sender.py b/plugin/relay/fcm_sender.py new file mode 100644 index 000000000..3bab04dfa --- /dev/null +++ b/plugin/relay/fcm_sender.py @@ -0,0 +1,309 @@ +"""BYO Firebase Cloud Messaging sender (Mac / relay half). + +Service-account JSON stays on the host — never in the APK. Phone registers an +FCM device token via ``POST /push/token``; when proactive has no live WSS +subscriber the relay optionally sends a high-priority *data* wake so the app +can reconnect and drain the 24h buffer. + +Env (either prefix accepted; RELAY_ preferred): + RELAY_FCM_SERVICE_ACCOUNT_JSON / FCM_SERVICE_ACCOUNT_JSON + path to SA JSON, or the JSON body itself + RELAY_FCM_ENABLED / FCM_ENABLED + default on when SA is present; set 0 to disable +""" + +from __future__ import annotations + +import json +import logging +import os +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +_FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging" +_TOKEN_URL = "https://oauth2.googleapis.com/token" +_SEND_URL = "https://fcm.googleapis.com/v1/projects/{project_id}/messages:send" + + +@dataclass +class FcmSendResult: + ok: bool + status: int = 0 + body: str = "" + error: str = "" + + +def _env_get(env: Any, *keys: str) -> str: + for key in keys: + raw = env.get(key) + if raw is None: + continue + text = str(raw).strip() + if text: + return text + return "" + + +def load_service_account_from_env( + env: Any | None = None, +) -> dict[str, Any] | None: + """Return parsed service-account dict, or None if unset/invalid.""" + e = env if env is not None else os.environ + raw = _env_get(e, "RELAY_FCM_SERVICE_ACCOUNT_JSON", "FCM_SERVICE_ACCOUNT_JSON") + if not raw: + return None + if raw.startswith("{"): + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + logger.warning("RELAY_FCM_SERVICE_ACCOUNT_JSON is not valid JSON: %s", exc) + return None + else: + path = Path(raw).expanduser() + if not path.is_file(): + logger.warning("RELAY_FCM_SERVICE_ACCOUNT_JSON path not found: %s", path) + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Failed reading FCM service account %s: %s", path, exc) + return None + if not isinstance(data, dict): + return None + if not data.get("client_email") or not data.get("private_key"): + logger.warning("FCM service account missing client_email/private_key") + return None + return data + + +def fcm_enabled(env: Any | None = None) -> bool: + e = env if env is not None else os.environ + flag = _env_get(e, "RELAY_FCM_ENABLED", "FCM_ENABLED").lower() + if flag in ("0", "false", "no", "off"): + return False + if flag in ("1", "true", "yes", "on"): + return True + return load_service_account_from_env(e) is not None + + +def _b64url(data: bytes) -> str: + import base64 + + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def mint_access_token( + service_account: dict[str, Any], + *, + now: float | None = None, + opener: Any = None, +) -> str: + """JWT bearer grant → OAuth access token (stdlib only).""" + import hashlib + import hmac + + # Prefer cryptography/PyJWT if present; otherwise pure-stdlib RS256 via + # openssl is heavy — use google-auth style with hmac only for tests of + # payload shape is insufficient for real RSA. Real path uses urllib + + # the `jwt` algorithm via the `cryptography` package if available, else + # falls back to google.oauth2 when installed. + try: + from google.auth.transport.requests import Request + from google.oauth2 import service_account as ga_sa + + creds = ga_sa.Credentials.from_service_account_info( + service_account, + scopes=[_FCM_SCOPE], + ) + creds.refresh(Request()) + return creds.token + except Exception: + pass + + try: + import jwt # PyJWT + except ImportError as exc: # pragma: no cover + raise RuntimeError( + "FCM send requires google-auth or PyJWT+cryptography on the host" + ) from exc + + now_ts = int(now if now is not None else time.time()) + payload = { + "iss": service_account["client_email"], + "sub": service_account["client_email"], + "aud": _TOKEN_URL, + "iat": now_ts, + "exp": now_ts + 3600, + "scope": _FCM_SCOPE, + } + assertion = jwt.encode( + payload, + service_account["private_key"], + algorithm="RS256", + ) + if isinstance(assertion, bytes): + assertion = assertion.decode("ascii") + + body = ( + "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer" + f"&assertion={assertion}" + ).encode("utf-8") + req = urllib.request.Request( + _TOKEN_URL, + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + open_fn = opener or urllib.request.urlopen + with open_fn(req, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + token = data.get("access_token") + if not token: + raise RuntimeError(f"token endpoint returned no access_token: {data!r}") + return str(token) + + +def build_wake_message( + *, + device_token: str, + message_id: str, + title: str | None = None, +) -> dict[str, Any]: + """High-priority data-only FCM payload (no notification body with secrets).""" + data = { + "type": "hermes_wake", + "action": "wake", + "message_id": str(message_id), + } + if title: + data["title"] = str(title)[:120] + return { + "message": { + "token": device_token, + "data": data, + "android": { + "priority": "HIGH", + "ttl": "120s", + }, + } + } + + +def send_wake( + *, + device_token: str, + message_id: str, + service_account: dict[str, Any], + title: str | None = None, + access_token: str | None = None, + opener: Any = None, +) -> FcmSendResult: + project_id = service_account.get("project_id") + if not project_id: + return FcmSendResult(ok=False, error="service account missing project_id") + if not device_token: + return FcmSendResult(ok=False, error="empty device token") + + try: + token = access_token or mint_access_token(service_account, opener=opener) + except Exception as exc: + logger.warning("FCM mint_access_token failed: %s", exc) + return FcmSendResult(ok=False, error=f"auth failed: {exc}") + + url = _SEND_URL.format(project_id=project_id) + payload = build_wake_message( + device_token=device_token, + message_id=message_id, + title=title, + ) + raw = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url, + data=raw, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json; charset=UTF-8", + }, + method="POST", + ) + open_fn = opener or urllib.request.urlopen + try: + with open_fn(req, timeout=15) as resp: + body = resp.read().decode("utf-8", errors="replace") + status = getattr(resp, "status", 200) or 200 + return FcmSendResult(ok=200 <= int(status) < 300, status=int(status), body=body) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") if exc.fp else "" + return FcmSendResult(ok=False, status=int(exc.code), body=body, error=str(exc)) + except Exception as exc: + return FcmSendResult(ok=False, error=str(exc)) + + +class FcmTokenStore: + """Persist device_id → FCM registration token under HERMES_HOME.""" + + def __init__(self, path: Path | str) -> None: + self.path = Path(path) + self._data: dict[str, dict[str, Any]] = {} + self._load() + + def _load(self) -> None: + if not self.path.is_file(): + self._data = {} + return + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + if isinstance(raw, dict): + self._data = { + str(k): v for k, v in raw.items() if isinstance(v, dict) + } + else: + self._data = {} + except (OSError, json.JSONDecodeError): + self._data = {} + + def _save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix(".tmp") + tmp.write_text(json.dumps(self._data, indent=2, sort_keys=True), encoding="utf-8") + tmp.replace(self.path) + + def upsert( + self, + *, + device_id: str, + token: str, + platform: str = "android", + project_id: str = "", + ) -> None: + if not device_id or not token: + raise ValueError("device_id and token required") + self._data[device_id] = { + "token": token, + "platform": platform or "android", + "project_id": project_id or "", + "updated_at": time.time(), + } + self._save() + + def remove(self, device_id: str) -> None: + if device_id in self._data: + del self._data[device_id] + self._save() + + def tokens(self) -> list[str]: + out: list[str] = [] + for entry in self._data.values(): + t = entry.get("token") + if isinstance(t, str) and t: + out.append(t) + return out + + def all_entries(self) -> dict[str, dict[str, Any]]: + return dict(self._data) diff --git a/plugin/relay/server.py b/plugin/relay/server.py index 1cdf53af1..1feeb29e4 100644 --- a/plugin/relay/server.py +++ b/plugin/relay/server.py @@ -67,6 +67,12 @@ from .channels.terminal import TerminalHandler from .channels.tui import TuiHandler from .config import RelayConfig +from .fcm_sender import ( + FcmTokenStore, + fcm_enabled, + load_service_account_from_env, + send_wake, +) from .secure_proxy import ( SECURE_LINK_CAPABILITIES, SECURE_LINK_DESCRIPTION, @@ -289,6 +295,13 @@ def __init__(self, config: RelayConfig) -> None: # never reached the phone (voice session died before resume) is pushed # as a proactive phone message — buffered while the phone is offline. self.realtime_agent.proactive_push = self.proactive.push + # BYO FCM token store + offline wake hook (service account on host). + hermes_home = Path( + os.environ.get("HERMES_HOME") + or Path(config.hermes_config_path).expanduser().parent + ).expanduser() + self.fcm_tokens = FcmTokenStore(hermes_home / "hermes-relay-fcm-tokens.json") + self.proactive.on_queued_wake = self._fcm_wake_offline_phones # Desktop CLI awareness channel — stashes workspace + active-editor # hints per session (ephemeral; no persistence). Wired in alpha.6 # as the keystone for future prompt-injection plugin hooks. @@ -309,6 +322,39 @@ def __init__(self, config: RelayConfig) -> None: def client_count(self) -> int: return len(self._clients) + async def _fcm_wake_offline_phones( + self, + message_id: str, + title: str | None = None, + ) -> None: + """Send high-priority FCM data wakes to registered device tokens.""" + if not fcm_enabled(): + return + sa = load_service_account_from_env() + if sa is None: + return + tokens = self.fcm_tokens.tokens() + if not tokens: + return + + async def _one(token: str) -> None: + result = await asyncio.to_thread( + send_wake, + device_token=token, + message_id=message_id, + service_account=sa, + title=title, + ) + if not result.ok: + logger.info( + "FCM wake failed message_id=%s status=%s err=%s", + message_id, + result.status, + result.error or result.body[:200], + ) + + await asyncio.gather(*[_one(t) for t in tokens], return_exceptions=True) + async def close(self) -> None: """Shut down all channel handlers and close client connections.""" # Cancel all in-flight tasks @@ -4060,6 +4106,64 @@ async def handle_phone_message(request: web.Request) -> web.Response: return web.json_response(result, status=200) +async def handle_push_token(request: web.Request) -> web.Response: + """Register or clear a BYO FCM device token for the paired session. + + POST /push/token Authorization: Bearer + {\"token\": \"...\", \"platform\": \"android\", \"project_id\": \"...\"} + → 200 {\"ok\": true} + empty token clears the registration for this device_id + """ + try: + server, session = _require_bearer_session(request) + except web.HTTPUnauthorized as exc: + return web.json_response({"ok": False, "error": exc.text}, status=401) + + try: + payload = await request.json() + except (json.JSONDecodeError, ValueError, aiohttp.ContentTypeError): + return web.json_response({"ok": False, "error": "invalid JSON body"}, status=400) + if not isinstance(payload, dict): + return web.json_response( + {"ok": False, "error": "body must be a JSON object"}, status=400 + ) + + token = payload.get("token") + if token is None: + token = "" + if not isinstance(token, str): + return web.json_response({"ok": False, "error": "token must be a string"}, status=400) + + device_id = session.device_id or session.token[:16] + if not token.strip(): + server.fcm_tokens.remove(device_id) + return web.json_response({"ok": True, "cleared": True}) + + platform = payload.get("platform") or "android" + project_id = payload.get("project_id") or "" + if not isinstance(platform, str): + platform = "android" + if not isinstance(project_id, str): + project_id = "" + try: + server.fcm_tokens.upsert( + device_id=device_id, + token=token.strip(), + platform=platform.strip() or "android", + project_id=project_id.strip(), + ) + except ValueError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + + logger.info( + "FCM token registered device_id=%s platform=%s len=%d", + device_id, + platform, + len(token), + ) + return web.json_response({"ok": True, "device_id": device_id}) + + async def handle_phone_outbound(request: web.Request) -> web.Response: """Inspect or cancel the queued agent→phone outbound messages. @@ -5143,6 +5247,8 @@ async def _voice_realtime_agent_ws(request: web.Request) -> web.StreamResponse: # Proactive push: agent → phone. Loopback-only (the phone platform # adapter POSTs here). Forwards over the phone WSS via ProactiveChannel. app.router.add_post("/phone/message", handle_phone_message) + # BYO FCM device token registration (paired phone → relay). + app.router.add_post("/push/token", handle_push_token) # Inbound reply leg (Phase 2c) — the gateway adapter long-polls here to # drain ``proactive.reply`` envelopes buffered by the ProactiveChannel. app.router.add_get("/phone/replies", handle_phone_replies) diff --git a/plugin/tests/test_fcm_sender.py b/plugin/tests/test_fcm_sender.py new file mode 100644 index 000000000..1ea309172 --- /dev/null +++ b/plugin/tests/test_fcm_sender.py @@ -0,0 +1,124 @@ +"""Unit tests for BYO FCM sender + token store (no network).""" + +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from plugin.relay.fcm_sender import ( + FcmTokenStore, + build_wake_message, + fcm_enabled, + load_service_account_from_env, + send_wake, +) + + +class FcmSenderUnitTests(unittest.TestCase): + def test_load_sa_from_inline_json(self) -> None: + sa = { + "type": "service_account", + "project_id": "demo", + "client_email": "a@b.iam.gserviceaccount.com", + "private_key": "-----BEGIN PRIVATE KEY-----\nX\n-----END PRIVATE KEY-----\n", + } + env = {"RELAY_FCM_SERVICE_ACCOUNT_JSON": json.dumps(sa)} + loaded = load_service_account_from_env(env) + assert loaded is not None + self.assertEqual(loaded["project_id"], "demo") + + def test_load_sa_from_path(self) -> None: + sa = { + "project_id": "p", + "client_email": "c@x", + "private_key": "k", + } + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "sa.json" + path.write_text(json.dumps(sa), encoding="utf-8") + loaded = load_service_account_from_env( + {"FCM_SERVICE_ACCOUNT_JSON": str(path)} + ) + assert loaded is not None + self.assertEqual(loaded["client_email"], "c@x") + + def test_fcm_enabled_respects_flag(self) -> None: + self.assertFalse( + fcm_enabled( + { + "RELAY_FCM_ENABLED": "0", + "RELAY_FCM_SERVICE_ACCOUNT_JSON": "{}", + } + ) + ) + sa = json.dumps( + { + "project_id": "p", + "client_email": "c@x", + "private_key": "k", + } + ) + self.assertTrue(fcm_enabled({"RELAY_FCM_SERVICE_ACCOUNT_JSON": sa})) + + def test_build_wake_message_data_only(self) -> None: + msg = build_wake_message(device_token="tok", message_id="m1", title="Hi") + body = msg["message"] + self.assertEqual(body["token"], "tok") + self.assertEqual(body["data"]["type"], "hermes_wake") + self.assertEqual(body["android"]["priority"], "HIGH") + self.assertNotIn("notification", body) + + def test_token_store_roundtrip(self) -> None: + with tempfile.TemporaryDirectory() as td: + store = FcmTokenStore(Path(td) / "tokens.json") + store.upsert(device_id="dev1", token="abc", project_id="p") + store2 = FcmTokenStore(Path(td) / "tokens.json") + self.assertEqual(store2.tokens(), ["abc"]) + store2.remove("dev1") + self.assertEqual(store2.tokens(), []) + + def test_send_wake_uses_http_v1(self) -> None: + sa = { + "project_id": "demo-proj", + "client_email": "a@b", + "private_key": "k", + } + captured: dict = {} + + class FakeResp: + status = 200 + + def read(self) -> bytes: + return b'{"name":"projects/demo-proj/messages/1"}' + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def fake_open(req, timeout=15): + captured["url"] = req.full_url + captured["auth"] = req.headers.get("Authorization") + captured["body"] = req.data + return FakeResp() + + result = send_wake( + device_token="device-token", + message_id="mid", + service_account=sa, + access_token="ya29.test", + opener=fake_open, + ) + self.assertTrue(result.ok) + self.assertIn("demo-proj", captured["url"]) + self.assertTrue(captured["auth"].startswith("Bearer ya29")) + payload = json.loads(captured["body"].decode("utf-8")) + self.assertEqual(payload["message"]["token"], "device-token") + + +if __name__ == "__main__": + unittest.main() diff --git a/plugin/tests/test_push_token.py b/plugin/tests/test_push_token.py new file mode 100644 index 000000000..fe33ce1d1 --- /dev/null +++ b/plugin/tests/test_push_token.py @@ -0,0 +1,66 @@ +"""HTTP tests for POST /push/token.""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from unittest import mock + +from aiohttp import web +from aiohttp.test_utils import AioHTTPTestCase + +from plugin.relay.config import RelayConfig +from plugin.relay.server import create_app + + +class PushTokenHttpTests(AioHTTPTestCase): + async def asyncSetUp(self) -> None: + self._hermes_home = tempfile.TemporaryDirectory() + self._env_patch = mock.patch.dict( + os.environ, + {"HERMES_HOME": self._hermes_home.name}, + ) + self._env_patch.start() + await super().asyncSetUp() + + async def asyncTearDown(self) -> None: + await super().asyncTearDown() + self._env_patch.stop() + self._hermes_home.cleanup() + + async def get_application(self) -> web.Application: + return create_app(RelayConfig(profile_discovery_enabled=False)) + + def _session(self): + return self.app["server"].sessions.create_session("fcm-phone", "device-fcm-1") + + async def test_anonymous_rejected(self) -> None: + resp = await self.client.post("/push/token", json={"token": "abc"}) + self.assertEqual(resp.status, 401) + + async def test_register_and_clear(self) -> None: + session = self._session() + headers = {"Authorization": f"Bearer {session.token}"} + ok = await self.client.post( + "/push/token", + json={"token": "fcm-device-token", "platform": "android", "project_id": "p"}, + headers=headers, + ) + self.assertEqual(ok.status, 200) + body = await ok.json() + self.assertTrue(body.get("ok")) + tokens = self.app["server"].fcm_tokens.tokens() + self.assertEqual(tokens, ["fcm-device-token"]) + + cleared = await self.client.post( + "/push/token", + json={"token": ""}, + headers=headers, + ) + self.assertEqual(cleared.status, 200) + self.assertEqual(self.app["server"].fcm_tokens.tokens(), []) + + +if __name__ == "__main__": + unittest.main() From 857ed9e69843d4a16e6dbe5baa9a4e3d32ae27fd Mon Sep 17 00:00:00 2001 From: ophirhan Date: Tue, 6 Oct 2026 10:19:21 +0300 Subject: [PATCH 2/8] feat(sideload): easier BYO FCM host setup + preview/picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host: auto-discover ~/.hermes/secrets/fcm-sa.json, Dashboard Settings → Push wake upload/enable/clear, CLI install|status on fcm_sender, and .env merge for status. Phone: message preview default + hide toggle, google-services file picker, clearer Mac-vs-phone copy. Secure Link /relay/* reverse-proxy + transport allowlist for push/token so register works behind pinned TLS. --- .../hermesandroid/relay/data/FcmPushConfig.kt | 23 + .../relay/network/relay/RelayHttpClient.kt | 4 + .../notifications/TurnCompleteNotifier.kt | 18 +- .../ui/screens/ProactiveSettingsScreen.kt | 137 ++++- .../relay/viewmodel/ConnectionViewModel.kt | 35 +- app/src/main/res/values-b+pt+BR/strings.xml | 2 +- app/src/main/res/values-b+zh+Hans/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values/strings.xml | 17 +- .../relay/push/HermesFcmMessagingService.kt | 21 +- plugin/dashboard/dist/index.js | 14 +- plugin/dashboard/plugin_api.py | 69 ++- plugin/dashboard/src/lib/api.js | 22 + plugin/dashboard/src/tabs/PushConsole.jsx | 241 ++++++-- plugin/dashboard/src/tabs/RelayManagement.jsx | 6 + plugin/dashboard/test_plugin_api.py | 38 +- plugin/relay/channels/proactive.py | 8 +- plugin/relay/fcm_sender.py | 545 +++++++++++++++++- plugin/relay/secure_proxy.py | 30 + plugin/relay/server.py | 28 +- plugin/tests/test_fcm_sender.py | 90 ++- plugin/tests/test_push_token.py | 17 + plugin/tests/test_secure_proxy.py | 27 +- 26 files changed, 1255 insertions(+), 147 deletions(-) diff --git a/app/src/main/kotlin/com/hermesandroid/relay/data/FcmPushConfig.kt b/app/src/main/kotlin/com/hermesandroid/relay/data/FcmPushConfig.kt index 8c063f2ad..07946ebb6 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/data/FcmPushConfig.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/data/FcmPushConfig.kt @@ -5,7 +5,9 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -38,12 +40,17 @@ private val KEY_FCM_API_KEY = stringPreferencesKey("fcm_api_key") private val KEY_FCM_GCM_SENDER_ID = stringPreferencesKey("fcm_gcm_sender_id") private val KEY_FCM_LAST_TOKEN = stringPreferencesKey("fcm_last_token") private val KEY_FCM_LAST_ERROR = stringPreferencesKey("fcm_last_error") +/** When true, notifications use generic copy (no message preview). Default false = show preview. */ +private val KEY_FCM_HIDE_NOTIFICATION_CONTENT = + booleanPreferencesKey("fcm_hide_notification_content") data class FcmPushPreferences( val enabled: Boolean = false, val config: FcmClientConfig = FcmClientConfig(), val lastToken: String = "", val lastError: String = "", + /** Default false: show message preview on wake/reply notifications. */ + val hideNotificationContent: Boolean = false, ) fun Context.fcmPushPreferencesFlow(): Flow = @@ -58,6 +65,7 @@ fun Context.fcmPushPreferencesFlow(): Flow = ), lastToken = prefs[KEY_FCM_LAST_TOKEN].orEmpty(), lastError = prefs[KEY_FCM_LAST_ERROR].orEmpty(), + hideNotificationContent = prefs[KEY_FCM_HIDE_NOTIFICATION_CONTENT] ?: false, ) } @@ -65,6 +73,10 @@ suspend fun Context.setFcmPushEnabled(enabled: Boolean) { relayDataStore.edit { it[KEY_FCM_ENABLED] = enabled } } +suspend fun Context.setFcmHideNotificationContent(hide: Boolean) { + relayDataStore.edit { it[KEY_FCM_HIDE_NOTIFICATION_CONTENT] = hide } +} + suspend fun Context.setFcmClientConfig(config: FcmClientConfig) { relayDataStore.edit { it[KEY_FCM_PROJECT_ID] = config.projectId.trim() @@ -93,10 +105,21 @@ suspend fun Context.clearFcmClientConfig() { it.remove(KEY_FCM_GCM_SENDER_ID) it.remove(KEY_FCM_LAST_TOKEN) it.remove(KEY_FCM_LAST_ERROR) + it.remove(KEY_FCM_HIDE_NOTIFICATION_CONTENT) it[KEY_FCM_ENABLED] = false } } +/** + * Sync read for notification paths (FCM service / turn-complete). Default false = + * show preview. Brief [runBlocking] is acceptable on those background threads. + */ +fun Context.hideNotificationContentBlocking(): Boolean = + runBlocking { + runCatching { fcmPushPreferencesFlow().first().hideNotificationContent } + .getOrDefault(false) + } + /** * Parse Firebase Android client fields from pasted `google-services.json` * text or a compact JSON object with the same keys. diff --git a/app/src/main/kotlin/com/hermesandroid/relay/network/relay/RelayHttpClient.kt b/app/src/main/kotlin/com/hermesandroid/relay/network/relay/RelayHttpClient.kt index 75b165f6e..aabab290b 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/network/relay/RelayHttpClient.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/network/relay/RelayHttpClient.kt @@ -1108,6 +1108,7 @@ class RelayHttpClient( token: String, platform: String = "android", projectId: String = "", + includePreview: Boolean = true, ): Result = withContext(Dispatchers.IO) { val relayUrl = relayUrlProvider()?.trim().orEmpty() if (relayUrl.isEmpty()) { @@ -1133,6 +1134,9 @@ class RelayHttpClient( if (projectId.isNotBlank()) { append(",\"project_id\":").append(jsonString(projectId)) } + if (token.isNotBlank()) { + append(",\"include_preview\":").append(if (includePreview) "true" else "false") + } append('}') } val request = Request.Builder() diff --git a/app/src/main/kotlin/com/hermesandroid/relay/notifications/TurnCompleteNotifier.kt b/app/src/main/kotlin/com/hermesandroid/relay/notifications/TurnCompleteNotifier.kt index e9e3c52b1..f2cab50f0 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/notifications/TurnCompleteNotifier.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/notifications/TurnCompleteNotifier.kt @@ -15,6 +15,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import com.hermesandroid.relay.MainActivity import com.hermesandroid.relay.R +import com.hermesandroid.relay.data.hideNotificationContentBlocking /** One reply slot per durable conversation, routed to its exact owner. */ object TurnCompleteNotifier { @@ -67,8 +68,17 @@ object TurnCompleteNotifier { val conversation = identity?.conversationTitle?.takeIf { it.isNotBlank() } ?: "…${target.sessionId.takeLast(12)}" val title = "$agent · ${conversation.take(100)}" - val collapsed = responseText.take(120) - val expanded = responseText.take(400) + val hideContent = context.hideNotificationContentBlocking() + val collapsed = if (hideContent) { + context.getString(R.string.notification_content_hidden) + } else { + responseText.take(120) + } + val expanded = if (hideContent) { + context.getString(R.string.notification_content_hidden) + } else { + responseText.take(400) + } val publicVersion = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.mipmap.ic_launcher) @@ -77,8 +87,8 @@ object TurnCompleteNotifier { .build() val builder = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.mipmap.ic_launcher) - .setContentTitle(title) - .setLargeIcon(avatar) + .setContentTitle(if (hideContent) "Hermes replied" else title) + .setLargeIcon(if (hideContent) null else avatar) .setContentText(collapsed) .setStyle(NotificationCompat.BigTextStyle().bigText(expanded)) .setContentIntent(tapPending) diff --git a/app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ProactiveSettingsScreen.kt b/app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ProactiveSettingsScreen.kt index daba0bae8..0013cb791 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ProactiveSettingsScreen.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ProactiveSettingsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Message +import androidx.compose.material.icons.filled.Folder import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -52,6 +53,7 @@ import com.hermesandroid.relay.auth.AuthState import com.hermesandroid.relay.data.FcmClientConfig import com.hermesandroid.relay.data.FcmClientConfigParser import com.hermesandroid.relay.viewmodel.ConnectionViewModel +import java.nio.charset.Charset /** * "Threads" — the opt-in surface that lets the agent proactively @@ -200,6 +202,7 @@ private fun FcmByoSection( connectionViewModel: ConnectionViewModel, paired: Boolean, ) { + val context = LocalContext.current val prefs by connectionViewModel.fcmPushPreferences.collectAsState() val status by connectionViewModel.fcmStatusMessage.collectAsState() var projectId by remember(prefs.config.projectId) { mutableStateOf(prefs.config.projectId) } @@ -212,6 +215,54 @@ private fun FcmByoSection( } var pasteJson by remember { mutableStateOf("") } var enabled by remember(prefs.enabled) { mutableStateOf(prefs.enabled) } + var importError by remember { mutableStateOf(null) } + var importNote by remember { mutableStateOf(null) } + + fun applyFirebaseJson(raw: String, sourceLabel: String) { + val parsed = FcmClientConfigParser.parse(raw) + parsed.fold( + onSuccess = { cfg -> + projectId = cfg.projectId + applicationId = cfg.applicationId + apiKey = cfg.apiKey + gcmSenderId = cfg.gcmSenderId + // Keep a short marker in the paste box so the user sees something loaded; + // full multi-KB JSON is awkward to scroll on phone. + pasteJson = raw.trim().let { if (it.length <= 400) it else it.take(400) + "\n…" } + importError = null + importNote = context.getString(R.string.proactive_fcm_loaded_from, sourceLabel) + }, + onFailure = { err -> + importNote = null + importError = err.message + ?: context.getString(R.string.proactive_fcm_parse_failed) + }, + ) + } + + val pickJsonLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + val raw = runCatching { + context.contentResolver.openInputStream(uri)?.use { stream -> + stream.bufferedReader(Charset.forName("UTF-8")).readText() + } + }.getOrElse { err -> + importNote = null + importError = err.message + ?: context.getString(R.string.proactive_fcm_read_failed) + return@rememberLauncherForActivityResult + } + if (raw.isNullOrBlank()) { + importNote = null + importError = context.getString(R.string.proactive_fcm_read_failed) + return@rememberLauncherForActivityResult + } + val label = uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() } + ?: "file" + applyFirebaseJson(raw, label) + } ProactiveSectionCard(title = stringResource(R.string.proactive_fcm_title)) { Text( @@ -231,10 +282,68 @@ private fun FcmByoSection( ) Switch(checked = enabled, onCheckedChange = { enabled = it }) } + Spacer(Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.proactive_fcm_hide_content), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.proactive_fcm_hide_content_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = prefs.hideNotificationContent, + onCheckedChange = { connectionViewModel.setFcmHideNotificationContent(it) }, + ) + } + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.proactive_fcm_config_heading), + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringResource(R.string.proactive_fcm_load_file_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Button( + onClick = { + // JSON + broad fallbacks: Downloads often report octet-stream / plain text. + pickJsonLauncher.launch( + arrayOf( + "application/json", + "application/octet-stream", + "text/plain", + "text/*", + "*/*", + ), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Filled.Folder, + contentDescription = null, + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.proactive_fcm_load_file)) + } Spacer(Modifier.height(8.dp)) OutlinedTextField( value = pasteJson, - onValueChange = { pasteJson = it }, + onValueChange = { + pasteJson = it + importError = null + importNote = null + }, modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.proactive_fcm_paste_json)) }, minLines = 3, @@ -242,20 +351,28 @@ private fun FcmByoSection( ) Spacer(Modifier.height(8.dp)) FilledTonalButton( - onClick = { - val parsed = FcmClientConfigParser.parse(pasteJson) - parsed.getOrNull()?.let { cfg -> - projectId = cfg.projectId - applicationId = cfg.applicationId - apiKey = cfg.apiKey - gcmSenderId = cfg.gcmSenderId - } - }, + onClick = { applyFirebaseJson(pasteJson, "paste") }, enabled = pasteJson.isNotBlank(), modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.proactive_fcm_import_json)) } + importNote?.let { note -> + Spacer(Modifier.height(6.dp)) + Text( + text = note, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + importError?.let { err -> + Spacer(Modifier.height(6.dp)) + Text( + text = err, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } Spacer(Modifier.height(8.dp)) OutlinedTextField( value = projectId, diff --git a/app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ConnectionViewModel.kt b/app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ConnectionViewModel.kt index dedb0db52..968bd879f 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ConnectionViewModel.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ConnectionViewModel.kt @@ -82,6 +82,7 @@ import com.hermesandroid.relay.data.setProactiveEnabled import com.hermesandroid.relay.data.fcmPushPreferencesFlow import com.hermesandroid.relay.data.setFcmClientConfig import com.hermesandroid.relay.data.setFcmPushEnabled +import com.hermesandroid.relay.data.setFcmHideNotificationContent import com.hermesandroid.relay.data.setFcmLastToken import com.hermesandroid.relay.data.clearFcmClientConfig import com.hermesandroid.relay.data.FcmClientConfig @@ -3600,6 +3601,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati val result = relayHttpClient.registerPushToken( token = token, projectId = config.projectId, + includePreview = !fcmPushPreferences.value.hideNotificationContent, ) _fcmStatusMessage.value = if (result.isSuccess) { "FCM ready — token registered with relay" @@ -3621,6 +3623,33 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati } } + /** + * Hide message content in notifications (FCM wake + chat replies). Default + * is show preview. When toggled, re-registers the device token so the Mac + * omits or includes ``preview`` on the FCM wire. + */ + fun setFcmHideNotificationContent(hide: Boolean) { + viewModelScope.launch { + val app = getApplication() + app.setFcmHideNotificationContent(hide) + val prefs = fcmPushPreferences.value + if (!prefs.enabled || !prefs.config.isComplete) return@launch + val token = fcmPushController.currentToken().ifBlank { prefs.lastToken } + if (token.isBlank()) return@launch + relayHttpClient.registerPushToken( + token = token, + projectId = prefs.config.projectId, + includePreview = !hide, + ).onSuccess { + _fcmStatusMessage.value = if (hide) { + "Notifications will hide message content" + } else { + "Notifications will show message preview" + } + } + } + } + /** Re-upload token after pair / token refresh. */ fun syncFcmTokenIfNeeded() { viewModelScope.launch { @@ -3632,7 +3661,11 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati val token = fcmPushController.currentToken().ifBlank { prefs.lastToken } if (token.isBlank()) return@launch getApplication().setFcmLastToken(token) - relayHttpClient.registerPushToken(token = token, projectId = prefs.config.projectId) + relayHttpClient.registerPushToken( + token = token, + projectId = prefs.config.projectId, + includePreview = !prefs.hideNotificationContent, + ) } } diff --git a/app/src/main/res/values-b+pt+BR/strings.xml b/app/src/main/res/values-b+pt+BR/strings.xml index e19482b30..aa6e4efce 100644 --- a/app/src/main/res/values-b+pt+BR/strings.xml +++ b/app/src/main/res/values-b+pt+BR/strings.xml @@ -1405,7 +1405,7 @@ Limpar configurações FCM Token do dispositivo: %1$s Pareie o Relay para registrar o token do dispositivo no host. - No Mac, defina RELAY_FCM_SERVICE_ACCOUNT_JSON com o caminho (ou JSON) da service account do Firebase. Nunca coloque a service account no app. + Neste host Hermes: Dashboard → Hermes-Relay → Settings → Push wake — escolha o JSON da service account Admin do Firebase (ou: python -m plugin.relay.fcm_sender install ./sa.json). Nunca coloque a service account no app; o telefone só precisa do google-services.json de cliente. Hermes O Hermes tem uma mensagem — abra para reconectar e entregar. Despertar push diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 68425986c..97ac8cff1 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -1459,7 +1459,7 @@ 清除 FCM 设置 设备令牌:%1$s 配对 Relay 以向主机注册设备令牌。 - 在 Mac 上将 RELAY_FCM_SERVICE_ACCOUNT_JSON 设为 Firebase 服务账号路径(或 JSON)。切勿把服务账号放进应用。 + 在此 Hermes 主机:Dashboard → Hermes-Relay → Settings → Push wake — 选择 Firebase Admin 服务账号 JSON(或运行:python -m plugin.relay.fcm_sender install ./sa.json)。切勿把服务账号放进应用;手机只需客户端 google-services.json。 Hermes Hermes 有一条消息 — 打开以重连并投递。 推送唤醒 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8bbaae79f..686f1b4c0 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1465,7 +1465,7 @@ FCM-Einstellungen löschen Gerätetoken: %1$s Kopple Relay, um das Gerätetoken beim Host zu registrieren. - Auf dem Mac RELAY_FCM_SERVICE_ACCOUNT_JSON auf den Firebase-Service-Account-Pfad (oder JSON) setzen. Niemals den Service Account in die App legen. + Auf diesem Hermes-Host: Dashboard → Hermes-Relay → Settings → Push wake — Firebase-Admin-Service-Account-JSON wählen (oder: python -m plugin.relay.fcm_sender install ./sa.json). Niemals den Service Account in die App legen; das Telefon braucht nur die Client-google-services.json. Hermes Hermes hat eine Nachricht — öffnen zum Reconnect und Zustellen. Push-Wake diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a1f35b5fd..bbd27b713 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1348,7 +1348,7 @@ Borrar ajustes FCM Token del dispositivo: %1$s Empareja Relay para registrar el token del dispositivo en el host. - En el Mac, define RELAY_FCM_SERVICE_ACCOUNT_JSON con la ruta (o JSON) de la cuenta de servicio de Firebase. Nunca pongas la cuenta de servicio en la app. + En este host Hermes: Dashboard → Hermes-Relay → Settings → Push wake — elige el JSON de la cuenta de servicio Admin de Firebase (o: python -m plugin.relay.fcm_sender install ./sa.json). Nunca pongas la cuenta de servicio en la app; el teléfono solo necesita el google-services.json de cliente. Hermes Hermes tiene un mensaje: abre para reconectar y entregar. Despertar push diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 190053068..cc9c7a5d0 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1475,7 +1475,7 @@ FCM 設定をクリア デバイストークン: %1$s ホストにデバイストークンを登録するには Relay をペアリングしてください。 - Mac では RELAY_FCM_SERVICE_ACCOUNT_JSON に Firebase サービスアカウントのパス(または JSON)を設定します。サービスアカウントをアプリに入れないでください。 + この Hermes ホスト: Dashboard → Hermes-Relay → Settings → Push wake で Firebase Admin サービスアカウント JSON を選択(または python -m plugin.relay.fcm_sender install ./sa.json)。サービスアカウントをアプリに入れないでください。端末にはクライアント google-services.json のみ。 Hermes Hermes にメッセージがあります — 開いて再接続し配信します。 プッシュ起床 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index dbf083a44..d302a7776 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1481,7 +1481,7 @@ Очистить настройки FCM Токен устройства: %1$s Сопрягите Relay, чтобы зарегистрировать токен устройства на хосте. - На Mac задайте RELAY_FCM_SERVICE_ACCOUNT_JSON — путь к service account Firebase (или JSON). Никогда не кладите service account в приложение. + На этом хосте Hermes: Dashboard → Hermes-Relay → Settings → Push wake — выберите JSON сервисного аккаунта Firebase Admin (или: python -m plugin.relay.fcm_sender install ./sa.json). Никогда не кладите service account в приложение; телефону нужен только клиентский google-services.json. Hermes У Hermes есть сообщение — откройте, чтобы переподключиться и доставить. Push-пробуждение diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f50ef9c66..6f0ee9029 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1729,10 +1729,18 @@ When on, your phone tells the relay it\'s open to agent-initiated messages. The agent delivers them over the same paired connection the relay already uses — no new permissions beyond notifications. Your server must also enable the phone platform (set PHONE_ENABLED on the server). Until both sides are on and the phone is paired, nothing is pushed. Push wake (BYO FCM) - Optional. Use your own Firebase project so Hermes can wake this phone when the live connection is down. Full message text still arrives over Relay after reconnect. Sideload only; needs Google Play Services. + Optional. Use your own Firebase project so Hermes can wake this phone when the live connection is down. Message preview is shown on the wake notification by default; full text still arrives over Relay after reconnect. Sideload only; needs Google Play Services. Enable FCM wake - Paste google-services.json - Import JSON fields + Hide message content in notifications + Off by default. When on, wake and reply notifications use generic text only (and the host omits preview from FCM). + Firebase client config + Load google-services.json + Pick the file from Downloads / Files (no need to open or copy it). Paste still works if you prefer. + Loaded fields from %1$s — review below, then Save. + Could not read that file. + Could not parse Firebase JSON. + Or paste google-services.json + Import from paste Project ID Mobile SDK app ID API key @@ -1741,10 +1749,11 @@ Clear FCM settings Device token: %1$s Pair Relay to register the device token with the host. - On the Mac, set RELAY_FCM_SERVICE_ACCOUNT_JSON to your Firebase service-account path (or JSON). Never put the service account in the app. + On this Hermes host: Dashboard → Hermes-Relay → Settings → Push wake — choose the Firebase Admin service-account JSON (or run: python -m plugin.relay.fcm_sender install ./sa.json). Never put the service account in the app; phone only needs client google-services.json. Hermes Hermes has a message — open to reconnect and deliver. Push wake + Open Hermes to view the message. %1$d message arrived while you were away %1$d messages arrived while you were away diff --git a/app/src/sideload/kotlin/com/hermesandroid/relay/push/HermesFcmMessagingService.kt b/app/src/sideload/kotlin/com/hermesandroid/relay/push/HermesFcmMessagingService.kt index 5d3a19d9f..f7fe416e6 100644 --- a/app/src/sideload/kotlin/com/hermesandroid/relay/push/HermesFcmMessagingService.kt +++ b/app/src/sideload/kotlin/com/hermesandroid/relay/push/HermesFcmMessagingService.kt @@ -18,6 +18,7 @@ import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.hermesandroid.relay.MainActivity import com.hermesandroid.relay.R +import com.hermesandroid.relay.data.hideNotificationContentBlocking import com.hermesandroid.relay.data.setFcmLastToken import com.hermesandroid.relay.network.upstream.GatewayKeepAliveService import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry @@ -28,7 +29,8 @@ import kotlinx.coroutines.launch /** * High-priority data messages wake the process so proactive WSS can reconnect - * and drain the 24h buffer. Full message text is NOT carried on FCM. + * and drain the 24h buffer. Optional short ``preview`` may be shown (default); + * hide-content preference keeps the generic wake body. * * Uses the default [FirebaseApp] initialized by [SideloadFcmPushController]. */ @@ -53,6 +55,7 @@ class HermesFcmMessagingService : FirebaseMessagingService() { val action = data["action"].orEmpty() val messageId = data["message_id"].orEmpty() val title = data["title"]?.takeIf { it.isNotBlank() } + val preview = data["preview"]?.trim().orEmpty() Log.i( TAG, "onMessageReceived type=$type action=$action message_id=$messageId keys=${data.keys}", @@ -64,7 +67,7 @@ class HermesFcmMessagingService : FirebaseMessagingService() { data.isNotEmpty() if (!isWake) return - postWakeNotification(title = title, messageId = messageId) + postWakeNotification(title = title, messageId = messageId, preview = preview) // Bring UI up so ConnectionViewModel reconnects + proactive.subscribe. kickMainActivity() // Best-effort keep-alive nudge when the user already opted into persistent @@ -99,7 +102,7 @@ class HermesFcmMessagingService : FirebaseMessagingService() { } @SuppressLint("MissingPermission", "NotificationPermission") - private fun postWakeNotification(title: String?, messageId: String) { + private fun postWakeNotification(title: String?, messageId: String, preview: String = "") { if (!hasPostNotificationsPermission()) { Log.i(TAG, "POST_NOTIFICATIONS not granted — skip wake notification") return @@ -119,16 +122,26 @@ class HermesFcmMessagingService : FirebaseMessagingService() { ) val resolvedTitle = title?.takeIf { it.isNotBlank() } ?: getString(R.string.proactive_fcm_wake_title) - val body = getString(R.string.proactive_fcm_wake_body) + val hideContent = hideNotificationContentBlocking() + val genericBody = getString(R.string.proactive_fcm_wake_body) + val body = if (hideContent || preview.isBlank()) genericBody else preview + val publicVersion = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(getString(R.string.proactive_fcm_wake_title)) + .setContentText(genericBody) + .build() val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle(resolvedTitle) .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) .setContentIntent(pending) .setAutoCancel(true) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setPriority(NotificationCompat.PRIORITY_HIGH) .setDefaults(NotificationCompat.DEFAULT_ALL) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setPublicVersion(publicVersion) .build() runCatching { NotificationManagerCompat.from(this).notify(NOTIFICATION_ID_BASE + (requestCode % 1000), notification) diff --git a/plugin/dashboard/dist/index.js b/plugin/dashboard/dist/index.js index 7e4be6d37..e40d3dbbd 100644 --- a/plugin/dashboard/dist/index.js +++ b/plugin/dashboard/dist/index.js @@ -1,9 +1,9 @@ -(()=>{var Zi=Object.create;var fn=Object.defineProperty;var Ri=Object.getOwnPropertyDescriptor;var eo=Object.getOwnPropertyNames;var to=Object.getPrototypeOf,ro=Object.prototype.hasOwnProperty;var W=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var no=(e,t,r,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let s of eo(t))!ro.call(e,s)&&s!==r&&fn(e,s,{get:()=>t[s],enumerable:!(n=Ri(t,s))||n.enumerable});return e};var ir=(e,t,r)=>(r=e!=null?Zi(to(e)):{},no(t||!e||!e.__esModule?fn(r,"default",{value:e,enumerable:!0}):r,e));var qn=W((Fl,Fn)=>{Fn.exports=function(){return typeof Promise=="function"&&Promise.prototype&&Promise.prototype.then}});var $e=W(Fe=>{var mr,uo=[0,26,44,70,100,134,172,196,242,292,346,404,466,532,581,655,733,815,901,991,1085,1156,1258,1364,1474,1588,1706,1828,1921,2051,2185,2323,2465,2611,2761,2876,3034,3196,3362,3532,3706];Fe.getSymbolSize=function(t){if(!t)throw new Error('"version" cannot be null or undefined');if(t<1||t>40)throw new Error('"version" should be in range from 1 to 40');return t*4+17};Fe.getSymbolTotalCodewords=function(t){return uo[t]};Fe.getBCHDigit=function(e){let t=0;for(;e!==0;)t++,e>>>=1;return t};Fe.setToSJISFunction=function(t){if(typeof t!="function")throw new Error('"toSJISFunc" is not a valid function.');mr=t};Fe.isKanjiModeEnabled=function(){return typeof mr<"u"};Fe.toSJIS=function(t){return mr(t)}});var zt=W(Ce=>{Ce.L={bit:1};Ce.M={bit:0};Ce.Q={bit:3};Ce.H={bit:2};function co(e){if(typeof e!="string")throw new Error("Param is not a string");switch(e.toLowerCase()){case"l":case"low":return Ce.L;case"m":case"medium":return Ce.M;case"q":case"quartile":return Ce.Q;case"h":case"high":return Ce.H;default:throw new Error("Unknown EC Level: "+e)}}Ce.isValid=function(t){return t&&typeof t.bit<"u"&&t.bit>=0&&t.bit<4};Ce.from=function(t,r){if(Ce.isValid(t))return t;try{return co(t)}catch{return r}}});var jn=W((Kl,Kn)=>{function Gn(){this.buffer=[],this.length=0}Gn.prototype={get:function(e){let t=Math.floor(e/8);return(this.buffer[t]>>>7-e%8&1)===1},put:function(e,t){for(let r=0;r>>t-r-1&1)===1)},getLengthInBits:function(){return this.length},putBit:function(e){let t=Math.floor(this.length/8);this.buffer.length<=t&&this.buffer.push(0),e&&(this.buffer[t]|=128>>>this.length%8),this.length++}};Kn.exports=Gn});var Jn=W((jl,Vn)=>{function ft(e){if(!e||e<1)throw new Error("BitMatrix size must be defined and greater than 0");this.size=e,this.data=new Uint8Array(e*e),this.reservedBit=new Uint8Array(e*e)}ft.prototype.set=function(e,t,r,n){let s=e*this.size+t;this.data[s]=r,n&&(this.reservedBit[s]=!0)};ft.prototype.get=function(e,t){return this.data[e*this.size+t]};ft.prototype.xor=function(e,t,r){this.data[e*this.size+t]^=r};ft.prototype.isReserved=function(e,t){return this.reservedBit[e*this.size+t]};Vn.exports=ft});var Wn=W(Ot=>{var mo=$e().getSymbolSize;Ot.getRowColCoords=function(t){if(t===1)return[];let r=Math.floor(t/7)+2,n=mo(t),s=n===145?26:Math.ceil((n-13)/(2*r-2))*2,i=[n-7];for(let o=1;o{var po=$e().getSymbolSize,Qn=7;Yn.getPositions=function(t){let r=po(t);return[[0,0],[r-Qn,0],[0,r-Qn]]}});var Zn=W(Y=>{Y.Patterns={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7};var qe={N1:3,N2:3,N3:40,N4:10};Y.isValid=function(t){return t!=null&&t!==""&&!isNaN(t)&&t>=0&&t<=7};Y.from=function(t){return Y.isValid(t)?parseInt(t,10):void 0};Y.getPenaltyN1=function(t){let r=t.size,n=0,s=0,i=0,o=null,a=null;for(let u=0;u=5&&(n+=qe.N1+(s-5)),o=f,s=1),f=t.get(c,u),f===a?i++:(i>=5&&(n+=qe.N1+(i-5)),a=f,i=1)}s>=5&&(n+=qe.N1+(s-5)),i>=5&&(n+=qe.N1+(i-5))}return n};Y.getPenaltyN2=function(t){let r=t.size,n=0;for(let s=0;s=10&&(s===1488||s===93)&&n++,i=i<<1&2047|t.get(a,o),a>=10&&(i===1488||i===93)&&n++}return n*qe.N3};Y.getPenaltyN4=function(t){let r=0,n=t.data.length;for(let i=0;i{var Ue=zt(),Ft=[1,1,1,1,1,1,1,1,1,1,2,2,1,2,2,4,1,2,4,4,2,4,4,4,2,4,6,5,2,4,6,6,2,5,8,8,4,5,8,8,4,5,8,11,4,8,10,11,4,9,12,16,4,9,16,16,6,10,12,18,6,10,17,16,6,11,16,19,6,13,18,21,7,14,21,25,8,16,20,25,8,17,23,25,9,17,23,34,9,18,25,30,10,20,27,32,12,21,29,35,12,23,34,37,12,25,34,40,13,26,35,42,14,28,38,45,15,29,40,48,16,31,43,51,17,33,45,54,18,35,48,57,19,37,51,60,19,38,53,63,20,40,56,66,21,43,59,70,22,45,62,74,24,47,65,77,25,49,68,81],qt=[7,10,13,17,10,16,22,28,15,26,36,44,20,36,52,64,26,48,72,88,36,64,96,112,40,72,108,130,48,88,132,156,60,110,160,192,72,130,192,224,80,150,224,264,96,176,260,308,104,198,288,352,120,216,320,384,132,240,360,432,144,280,408,480,168,308,448,532,180,338,504,588,196,364,546,650,224,416,600,700,224,442,644,750,252,476,690,816,270,504,750,900,300,560,810,960,312,588,870,1050,336,644,952,1110,360,700,1020,1200,390,728,1050,1260,420,784,1140,1350,450,812,1200,1440,480,868,1290,1530,510,924,1350,1620,540,980,1440,1710,570,1036,1530,1800,570,1064,1590,1890,600,1120,1680,1980,630,1204,1770,2100,660,1260,1860,2220,720,1316,1950,2310,750,1372,2040,2430];pr.getBlocksCount=function(t,r){switch(r){case Ue.L:return Ft[(t-1)*4+0];case Ue.M:return Ft[(t-1)*4+1];case Ue.Q:return Ft[(t-1)*4+2];case Ue.H:return Ft[(t-1)*4+3];default:return}};pr.getTotalCodewordsCount=function(t,r){switch(r){case Ue.L:return qt[(t-1)*4+0];case Ue.M:return qt[(t-1)*4+1];case Ue.Q:return qt[(t-1)*4+2];case Ue.H:return qt[(t-1)*4+3];default:return}}});var Rn=W(Kt=>{var ht=new Uint8Array(512),Gt=new Uint8Array(256);(function(){let t=1;for(let r=0;r<255;r++)ht[r]=t,Gt[t]=r,t<<=1,t&256&&(t^=285);for(let r=255;r<512;r++)ht[r]=ht[r-255]})();Kt.log=function(t){if(t<1)throw new Error("log("+t+")");return Gt[t]};Kt.exp=function(t){return ht[t]};Kt.mul=function(t,r){return t===0||r===0?0:ht[Gt[t]+Gt[r]]}});var es=W(gt=>{var hr=Rn();gt.mul=function(t,r){let n=new Uint8Array(t.length+r.length-1);for(let s=0;s=0;){let s=n[0];for(let o=0;o{var ts=es();function gr(e){this.genPoly=void 0,this.degree=e,this.degree&&this.initialize(this.degree)}gr.prototype.initialize=function(t){this.degree=t,this.genPoly=ts.generateECPolynomial(this.degree)};gr.prototype.encode=function(t){if(!this.genPoly)throw new Error("Encoder not initialized");let r=new Uint8Array(t.length+this.degree);r.set(t);let n=ts.mod(r,this.genPoly),s=this.degree-n.length;if(s>0){let i=new Uint8Array(this.degree);return i.set(n,s),i}return n};rs.exports=gr});var vr=W(ss=>{ss.isValid=function(t){return!isNaN(t)&&t>=1&&t<=40}});var br=W(Le=>{var is="[0-9]+",ho="[A-Z $%*+\\-./:]+",vt="(?:[u3000-u303F]|[u3040-u309F]|[u30A0-u30FF]|[uFF00-uFFEF]|[u4E00-u9FAF]|[u2605-u2606]|[u2190-u2195]|u203B|[u2010u2015u2018u2019u2025u2026u201Cu201Du2225u2260]|[u0391-u0451]|[u00A7u00A8u00B1u00B4u00D7u00F7])+";vt=vt.replace(/u/g,"\\u");var go="(?:(?![A-Z0-9 $%*+\\-./:]|"+vt+`)(?:.|[\r -]))+`;Le.KANJI=new RegExp(vt,"g");Le.BYTE_KANJI=new RegExp("[^A-Z0-9 $%*+\\-./:]+","g");Le.BYTE=new RegExp(go,"g");Le.NUMERIC=new RegExp(is,"g");Le.ALPHANUMERIC=new RegExp(ho,"g");var vo=new RegExp("^"+vt+"$"),bo=new RegExp("^"+is+"$"),yo=new RegExp("^[A-Z0-9 $%*+\\-./:]+$");Le.testKanji=function(t){return vo.test(t)};Le.testNumeric=function(t){return bo.test(t)};Le.testAlphanumeric=function(t){return yo.test(t)}});var ze=W(re=>{var xo=vr(),yr=br();re.NUMERIC={id:"Numeric",bit:1,ccBits:[10,12,14]};re.ALPHANUMERIC={id:"Alphanumeric",bit:2,ccBits:[9,11,13]};re.BYTE={id:"Byte",bit:4,ccBits:[8,16,16]};re.KANJI={id:"Kanji",bit:8,ccBits:[8,10,12]};re.MIXED={bit:-1};re.getCharCountIndicator=function(t,r){if(!t.ccBits)throw new Error("Invalid mode: "+t);if(!xo.isValid(r))throw new Error("Invalid version: "+r);return r>=1&&r<10?t.ccBits[0]:r<27?t.ccBits[1]:t.ccBits[2]};re.getBestModeForData=function(t){return yr.testNumeric(t)?re.NUMERIC:yr.testAlphanumeric(t)?re.ALPHANUMERIC:yr.testKanji(t)?re.KANJI:re.BYTE};re.toString=function(t){if(t&&t.id)return t.id;throw new Error("Invalid mode")};re.isValid=function(t){return t&&t.bit&&t.ccBits};function wo(e){if(typeof e!="string")throw new Error("Param is not a string");switch(e.toLowerCase()){case"numeric":return re.NUMERIC;case"alphanumeric":return re.ALPHANUMERIC;case"kanji":return re.KANJI;case"byte":return re.BYTE;default:throw new Error("Unknown mode: "+e)}}re.from=function(t,r){if(re.isValid(t))return t;try{return wo(t)}catch{return r}}});var us=W(Ge=>{var jt=$e(),Co=fr(),os=zt(),Oe=ze(),xr=vr(),ls=7973,as=jt.getBCHDigit(ls);function No(e,t,r){for(let n=1;n<=40;n++)if(t<=Ge.getCapacity(n,r,e))return n}function ds(e,t){return Oe.getCharCountIndicator(e,t)+4}function So(e,t){let r=0;return e.forEach(function(n){let s=ds(n.mode,t);r+=s+n.getBitsLength()}),r}function To(e,t){for(let r=1;r<=40;r++)if(So(e,r)<=Ge.getCapacity(r,t,Oe.MIXED))return r}Ge.from=function(t,r){return xr.isValid(t)?parseInt(t,10):r};Ge.getCapacity=function(t,r,n){if(!xr.isValid(t))throw new Error("Invalid QR Code version");typeof n>"u"&&(n=Oe.BYTE);let s=jt.getSymbolTotalCodewords(t),i=Co.getTotalCodewordsCount(t,r),o=(s-i)*8;if(n===Oe.MIXED)return o;let a=o-ds(n,t);switch(n){case Oe.NUMERIC:return Math.floor(a/10*3);case Oe.ALPHANUMERIC:return Math.floor(a/11*2);case Oe.KANJI:return Math.floor(a/13);case Oe.BYTE:default:return Math.floor(a/8)}};Ge.getBestVersionForData=function(t,r){let n,s=os.from(r,os.M);if(Array.isArray(t)){if(t.length>1)return To(t,s);if(t.length===0)return 1;n=t[0]}else n=t;return No(n.mode,n.getLength(),s)};Ge.getEncodedBits=function(t){if(!xr.isValid(t)||t<7)throw new Error("Invalid QR Code version");let r=t<<12;for(;jt.getBCHDigit(r)-as>=0;)r^=ls<{var wr=$e(),ms=1335,ko=21522,cs=wr.getBCHDigit(ms);ps.getEncodedBits=function(t,r){let n=t.bit<<3|r,s=n<<10;for(;wr.getBCHDigit(s)-cs>=0;)s^=ms<{var _o=ze();function tt(e){this.mode=_o.NUMERIC,this.data=e.toString()}tt.getBitsLength=function(t){return 10*Math.floor(t/3)+(t%3?t%3*3+1:0)};tt.prototype.getLength=function(){return this.data.length};tt.prototype.getBitsLength=function(){return tt.getBitsLength(this.data.length)};tt.prototype.write=function(t){let r,n,s;for(r=0;r+3<=this.data.length;r+=3)n=this.data.substr(r,3),s=parseInt(n,10),t.put(s,10);let i=this.data.length-r;i>0&&(n=this.data.substr(r),s=parseInt(n,10),t.put(s,i*3+1))};hs.exports=tt});var bs=W((id,vs)=>{var Ao=ze(),Cr=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"," ","$","%","*","+","-",".","/",":"];function rt(e){this.mode=Ao.ALPHANUMERIC,this.data=e}rt.getBitsLength=function(t){return 11*Math.floor(t/2)+6*(t%2)};rt.prototype.getLength=function(){return this.data.length};rt.prototype.getBitsLength=function(){return rt.getBitsLength(this.data.length)};rt.prototype.write=function(t){let r;for(r=0;r+2<=this.data.length;r+=2){let n=Cr.indexOf(this.data[r])*45;n+=Cr.indexOf(this.data[r+1]),t.put(n,11)}this.data.length%2&&t.put(Cr.indexOf(this.data[r]),6)};vs.exports=rt});var xs=W((od,ys)=>{var Eo=ze();function nt(e){this.mode=Eo.BYTE,typeof e=="string"?this.data=new TextEncoder().encode(e):this.data=new Uint8Array(e)}nt.getBitsLength=function(t){return t*8};nt.prototype.getLength=function(){return this.data.length};nt.prototype.getBitsLength=function(){return nt.getBitsLength(this.data.length)};nt.prototype.write=function(e){for(let t=0,r=this.data.length;t{var Bo=ze(),Po=$e();function st(e){this.mode=Bo.KANJI,this.data=e}st.getBitsLength=function(t){return t*13};st.prototype.getLength=function(){return this.data.length};st.prototype.getBitsLength=function(){return st.getBitsLength(this.data.length)};st.prototype.write=function(e){let t;for(t=0;t=33088&&r<=40956)r-=33088;else if(r>=57408&&r<=60351)r-=49472;else throw new Error("Invalid SJIS character: "+this.data[t]+` -Make sure your charset is UTF-8`);r=(r>>>8&255)*192+(r&255),e.put(r,13)}};ws.exports=st});var Ns=W((ld,Nr)=>{"use strict";var bt={single_source_shortest_paths:function(e,t,r){var n={},s={};s[t]=0;var i=bt.PriorityQueue.make();i.push(t,0);for(var o,a,u,c,f,m,w,h,C;!i.empty();){o=i.pop(),a=o.value,c=o.cost,f=e[a]||{};for(u in f)f.hasOwnProperty(u)&&(m=f[u],w=c+m,h=s[u],C=typeof s[u]>"u",(C||h>w)&&(s[u]=w,i.push(u,w),n[u]=a))}if(typeof r<"u"&&typeof s[r]>"u"){var y=["Could not find a path from ",t," to ",r,"."].join("");throw new Error(y)}return n},extract_shortest_path_from_predecessor_list:function(e,t){for(var r=[],n=t,s;n;)r.push(n),s=e[n],n=e[n];return r.reverse(),r},find_path:function(e,t,r){var n=bt.single_source_shortest_paths(e,t,r);return bt.extract_shortest_path_from_predecessor_list(n,r)},PriorityQueue:{make:function(e){var t=bt.PriorityQueue,r={},n;e=e||{};for(n in t)t.hasOwnProperty(n)&&(r[n]=t[n]);return r.queue=[],r.sorter=e.sorter||t.default_sorter,r},default_sorter:function(e,t){return e.cost-t.cost},push:function(e,t){var r={value:e,cost:t};this.queue.push(r),this.queue.sort(this.sorter)},pop:function(){return this.queue.shift()},empty:function(){return this.queue.length===0}}};typeof Nr<"u"&&(Nr.exports=bt)});var Ps=W(it=>{var K=ze(),ks=gs(),_s=bs(),As=xs(),Es=Cs(),yt=br(),Vt=$e(),Lo=Ns();function Ss(e){return unescape(encodeURIComponent(e)).length}function xt(e,t,r){let n=[],s;for(;(s=e.exec(r))!==null;)n.push({data:s[0],index:s.index,mode:t,length:s[0].length});return n}function Bs(e){let t=xt(yt.NUMERIC,K.NUMERIC,e),r=xt(yt.ALPHANUMERIC,K.ALPHANUMERIC,e),n,s;return Vt.isKanjiModeEnabled()?(n=xt(yt.BYTE,K.BYTE,e),s=xt(yt.KANJI,K.KANJI,e)):(n=xt(yt.BYTE_KANJI,K.BYTE,e),s=[]),t.concat(r,n,s).sort(function(o,a){return o.index-a.index}).map(function(o){return{data:o.data,mode:o.mode,length:o.length}})}function Sr(e,t){switch(t){case K.NUMERIC:return ks.getBitsLength(e);case K.ALPHANUMERIC:return _s.getBitsLength(e);case K.KANJI:return Es.getBitsLength(e);case K.BYTE:return As.getBitsLength(e)}}function Do(e){return e.reduce(function(t,r){let n=t.length-1>=0?t[t.length-1]:null;return n&&n.mode===r.mode?(t[t.length-1].data+=r.data,t):(t.push(r),t)},[])}function Io(e){let t=[];for(let r=0;r{var Wt=$e(),Tr=zt(),Ho=jn(),$o=Jn(),Uo=Wn(),zo=Xn(),Ar=Zn(),Er=fr(),Oo=ns(),Jt=us(),Fo=fs(),qo=ze(),kr=Ps();function Go(e,t){let r=e.size,n=zo.getPositions(t);for(let s=0;s=0&&a<=6&&(u===0||u===6)||u>=0&&u<=6&&(a===0||a===6)||a>=2&&a<=4&&u>=2&&u<=4?e.set(i+a,o+u,!0,!0):e.set(i+a,o+u,!1,!0))}}function Ko(e){let t=e.size;for(let r=8;r>a&1)===1,e.set(s,i,o,!0),e.set(i,s,o,!0)}function _r(e,t,r){let n=e.size,s=Fo.getEncodedBits(t,r),i,o;for(i=0;i<15;i++)o=(s>>i&1)===1,i<6?e.set(i,8,o,!0):i<8?e.set(i+1,8,o,!0):e.set(n-15+i,8,o,!0),i<8?e.set(8,n-i-1,o,!0):i<9?e.set(8,15-i-1+1,o,!0):e.set(8,15-i-1,o,!0);e.set(n-8,8,1,!0)}function Jo(e,t){let r=e.size,n=-1,s=r-1,i=7,o=0;for(let a=r-1;a>0;a-=2)for(a===6&&a--;;){for(let u=0;u<2;u++)if(!e.isReserved(s,a-u)){let c=!1;o>>i&1)===1),e.set(s,a-u,c),i--,i===-1&&(o++,i=7)}if(s+=n,s<0||r<=s){s-=n,n=-n;break}}}function Wo(e,t,r){let n=new Ho;r.forEach(function(u){n.put(u.mode.bit,4),n.put(u.getLength(),qo.getCharCountIndicator(u.mode,e)),u.write(n)});let s=Wt.getSymbolTotalCodewords(e),i=Er.getTotalCodewordsCount(e,t),o=(s-i)*8;for(n.getLengthInBits()+4<=o&&n.put(0,4);n.getLengthInBits()%8!==0;)n.putBit(0);let a=(o-n.getLengthInBits())/8;for(let u=0;u{var uo=Object.create;var xn=Object.defineProperty;var co=Object.getOwnPropertyDescriptor;var mo=Object.getOwnPropertyNames;var po=Object.getPrototypeOf,fo=Object.prototype.hasOwnProperty;var Q=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var ho=(e,t,r,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let s of mo(t))!fo.call(e,s)&&s!==r&&xn(e,s,{get:()=>t[s],enumerable:!(n=co(t,s))||n.enumerable});return e};var ur=(e,t,r)=>(r=e!=null?uo(po(e)):{},ho(t||!e||!e.__esModule?xn(r,"default",{value:e,enumerable:!0}):r,e));var Zn=Q((od,Xn)=>{Xn.exports=function(){return typeof Promise=="function"&&Promise.prototype&&Promise.prototype.then}});var Ue=Q(je=>{var vr,wo=[0,26,44,70,100,134,172,196,242,292,346,404,466,532,581,655,733,815,901,991,1085,1156,1258,1364,1474,1588,1706,1828,1921,2051,2185,2323,2465,2611,2761,2876,3034,3196,3362,3532,3706];je.getSymbolSize=function(t){if(!t)throw new Error('"version" cannot be null or undefined');if(t<1||t>40)throw new Error('"version" should be in range from 1 to 40');return t*4+17};je.getSymbolTotalCodewords=function(t){return wo[t]};je.getBCHDigit=function(e){let t=0;for(;e!==0;)t++,e>>>=1;return t};je.setToSJISFunction=function(t){if(typeof t!="function")throw new Error('"toSJISFunc" is not a valid function.');vr=t};je.isKanjiModeEnabled=function(){return typeof vr<"u"};je.toSJIS=function(t){return vr(t)}});var Ft=Q(Ne=>{Ne.L={bit:1};Ne.M={bit:0};Ne.Q={bit:3};Ne.H={bit:2};function Co(e){if(typeof e!="string")throw new Error("Param is not a string");switch(e.toLowerCase()){case"l":case"low":return Ne.L;case"m":case"medium":return Ne.M;case"q":case"quartile":return Ne.Q;case"h":case"high":return Ne.H;default:throw new Error("Unknown EC Level: "+e)}}Ne.isValid=function(t){return t&&typeof t.bit<"u"&&t.bit>=0&&t.bit<4};Ne.from=function(t,r){if(Ne.isValid(t))return t;try{return Co(t)}catch{return r}}});var ts=Q((dd,es)=>{function Rn(){this.buffer=[],this.length=0}Rn.prototype={get:function(e){let t=Math.floor(e/8);return(this.buffer[t]>>>7-e%8&1)===1},put:function(e,t){for(let r=0;r>>t-r-1&1)===1)},getLengthInBits:function(){return this.length},putBit:function(e){let t=Math.floor(this.length/8);this.buffer.length<=t&&this.buffer.push(0),e&&(this.buffer[t]|=128>>>this.length%8),this.length++}};es.exports=Rn});var ns=Q((ud,rs)=>{function ht(e){if(!e||e<1)throw new Error("BitMatrix size must be defined and greater than 0");this.size=e,this.data=new Uint8Array(e*e),this.reservedBit=new Uint8Array(e*e)}ht.prototype.set=function(e,t,r,n){let s=e*this.size+t;this.data[s]=r,n&&(this.reservedBit[s]=!0)};ht.prototype.get=function(e,t){return this.data[e*this.size+t]};ht.prototype.xor=function(e,t,r){this.data[e*this.size+t]^=r};ht.prototype.isReserved=function(e,t){return this.reservedBit[e*this.size+t]};rs.exports=ht});var ss=Q(Ot=>{var No=Ue().getSymbolSize;Ot.getRowColCoords=function(t){if(t===1)return[];let r=Math.floor(t/7)+2,n=No(t),s=n===145?26:Math.ceil((n-13)/(2*r-2))*2,i=[n-7];for(let o=1;o{var So=Ue().getSymbolSize,is=7;os.getPositions=function(t){let r=So(t);return[[0,0],[r-is,0],[0,r-is]]}});var ls=Q(Z=>{Z.Patterns={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7};var Ge={N1:3,N2:3,N3:40,N4:10};Z.isValid=function(t){return t!=null&&t!==""&&!isNaN(t)&&t>=0&&t<=7};Z.from=function(t){return Z.isValid(t)?parseInt(t,10):void 0};Z.getPenaltyN1=function(t){let r=t.size,n=0,s=0,i=0,o=null,a=null;for(let d=0;d=5&&(n+=Ge.N1+(s-5)),o=f,s=1),f=t.get(m,d),f===a?i++:(i>=5&&(n+=Ge.N1+(i-5)),a=f,i=1)}s>=5&&(n+=Ge.N1+(s-5)),i>=5&&(n+=Ge.N1+(i-5))}return n};Z.getPenaltyN2=function(t){let r=t.size,n=0;for(let s=0;s=10&&(s===1488||s===93)&&n++,i=i<<1&2047|t.get(a,o),a>=10&&(i===1488||i===93)&&n++}return n*Ge.N3};Z.getPenaltyN4=function(t){let r=0,n=t.data.length;for(let i=0;i{var ze=Ft(),qt=[1,1,1,1,1,1,1,1,1,1,2,2,1,2,2,4,1,2,4,4,2,4,4,4,2,4,6,5,2,4,6,6,2,5,8,8,4,5,8,8,4,5,8,11,4,8,10,11,4,9,12,16,4,9,16,16,6,10,12,18,6,10,17,16,6,11,16,19,6,13,18,21,7,14,21,25,8,16,20,25,8,17,23,25,9,17,23,34,9,18,25,30,10,20,27,32,12,21,29,35,12,23,34,37,12,25,34,40,13,26,35,42,14,28,38,45,15,29,40,48,16,31,43,51,17,33,45,54,18,35,48,57,19,37,51,60,19,38,53,63,20,40,56,66,21,43,59,70,22,45,62,74,24,47,65,77,25,49,68,81],jt=[7,10,13,17,10,16,22,28,15,26,36,44,20,36,52,64,26,48,72,88,36,64,96,112,40,72,108,130,48,88,132,156,60,110,160,192,72,130,192,224,80,150,224,264,96,176,260,308,104,198,288,352,120,216,320,384,132,240,360,432,144,280,408,480,168,308,448,532,180,338,504,588,196,364,546,650,224,416,600,700,224,442,644,750,252,476,690,816,270,504,750,900,300,560,810,960,312,588,870,1050,336,644,952,1110,360,700,1020,1200,390,728,1050,1260,420,784,1140,1350,450,812,1200,1440,480,868,1290,1530,510,924,1350,1620,540,980,1440,1710,570,1036,1530,1800,570,1064,1590,1890,600,1120,1680,1980,630,1204,1770,2100,660,1260,1860,2220,720,1316,1950,2310,750,1372,2040,2430];br.getBlocksCount=function(t,r){switch(r){case ze.L:return qt[(t-1)*4+0];case ze.M:return qt[(t-1)*4+1];case ze.Q:return qt[(t-1)*4+2];case ze.H:return qt[(t-1)*4+3];default:return}};br.getTotalCodewordsCount=function(t,r){switch(r){case ze.L:return jt[(t-1)*4+0];case ze.M:return jt[(t-1)*4+1];case ze.Q:return jt[(t-1)*4+2];case ze.H:return jt[(t-1)*4+3];default:return}}});var ds=Q(Kt=>{var gt=new Uint8Array(512),Gt=new Uint8Array(256);(function(){let t=1;for(let r=0;r<255;r++)gt[r]=t,Gt[t]=r,t<<=1,t&256&&(t^=285);for(let r=255;r<512;r++)gt[r]=gt[r-255]})();Kt.log=function(t){if(t<1)throw new Error("log("+t+")");return Gt[t]};Kt.exp=function(t){return gt[t]};Kt.mul=function(t,r){return t===0||r===0?0:gt[Gt[t]+Gt[r]]}});var us=Q(vt=>{var xr=ds();vt.mul=function(t,r){let n=new Uint8Array(t.length+r.length-1);for(let s=0;s=0;){let s=n[0];for(let o=0;o{var cs=us();function wr(e){this.genPoly=void 0,this.degree=e,this.degree&&this.initialize(this.degree)}wr.prototype.initialize=function(t){this.degree=t,this.genPoly=cs.generateECPolynomial(this.degree)};wr.prototype.encode=function(t){if(!this.genPoly)throw new Error("Encoder not initialized");let r=new Uint8Array(t.length+this.degree);r.set(t);let n=cs.mod(r,this.genPoly),s=this.degree-n.length;if(s>0){let i=new Uint8Array(this.degree);return i.set(n,s),i}return n};ms.exports=wr});var Cr=Q(fs=>{fs.isValid=function(t){return!isNaN(t)&&t>=1&&t<=40}});var Nr=Q(De=>{var hs="[0-9]+",To="[A-Z $%*+\\-./:]+",bt="(?:[u3000-u303F]|[u3040-u309F]|[u30A0-u30FF]|[uFF00-uFFEF]|[u4E00-u9FAF]|[u2605-u2606]|[u2190-u2195]|u203B|[u2010u2015u2018u2019u2025u2026u201Cu201Du2225u2260]|[u0391-u0451]|[u00A7u00A8u00B1u00B4u00D7u00F7])+";bt=bt.replace(/u/g,"\\u");var _o="(?:(?![A-Z0-9 $%*+\\-./:]|"+bt+`)(?:.|[\r +]))+`;De.KANJI=new RegExp(bt,"g");De.BYTE_KANJI=new RegExp("[^A-Z0-9 $%*+\\-./:]+","g");De.BYTE=new RegExp(_o,"g");De.NUMERIC=new RegExp(hs,"g");De.ALPHANUMERIC=new RegExp(To,"g");var Ao=new RegExp("^"+bt+"$"),Eo=new RegExp("^"+hs+"$"),Bo=new RegExp("^[A-Z0-9 $%*+\\-./:]+$");De.testKanji=function(t){return Ao.test(t)};De.testNumeric=function(t){return Eo.test(t)};De.testAlphanumeric=function(t){return Bo.test(t)}});var Fe=Q(oe=>{var Po=Cr(),Sr=Nr();oe.NUMERIC={id:"Numeric",bit:1,ccBits:[10,12,14]};oe.ALPHANUMERIC={id:"Alphanumeric",bit:2,ccBits:[9,11,13]};oe.BYTE={id:"Byte",bit:4,ccBits:[8,16,16]};oe.KANJI={id:"Kanji",bit:8,ccBits:[8,10,12]};oe.MIXED={bit:-1};oe.getCharCountIndicator=function(t,r){if(!t.ccBits)throw new Error("Invalid mode: "+t);if(!Po.isValid(r))throw new Error("Invalid version: "+r);return r>=1&&r<10?t.ccBits[0]:r<27?t.ccBits[1]:t.ccBits[2]};oe.getBestModeForData=function(t){return Sr.testNumeric(t)?oe.NUMERIC:Sr.testAlphanumeric(t)?oe.ALPHANUMERIC:Sr.testKanji(t)?oe.KANJI:oe.BYTE};oe.toString=function(t){if(t&&t.id)return t.id;throw new Error("Invalid mode")};oe.isValid=function(t){return t&&t.bit&&t.ccBits};function Lo(e){if(typeof e!="string")throw new Error("Param is not a string");switch(e.toLowerCase()){case"numeric":return oe.NUMERIC;case"alphanumeric":return oe.ALPHANUMERIC;case"kanji":return oe.KANJI;case"byte":return oe.BYTE;default:throw new Error("Unknown mode: "+e)}}oe.from=function(t,r){if(oe.isValid(t))return t;try{return Lo(t)}catch{return r}}});var xs=Q(Ke=>{var Vt=Ue(),Do=yr(),gs=Ft(),Oe=Fe(),kr=Cr(),bs=7973,vs=Vt.getBCHDigit(bs);function Io(e,t,r){for(let n=1;n<=40;n++)if(t<=Ke.getCapacity(n,r,e))return n}function ys(e,t){return Oe.getCharCountIndicator(e,t)+4}function Mo(e,t){let r=0;return e.forEach(function(n){let s=ys(n.mode,t);r+=s+n.getBitsLength()}),r}function Ho(e,t){for(let r=1;r<=40;r++)if(Mo(e,r)<=Ke.getCapacity(r,t,Oe.MIXED))return r}Ke.from=function(t,r){return kr.isValid(t)?parseInt(t,10):r};Ke.getCapacity=function(t,r,n){if(!kr.isValid(t))throw new Error("Invalid QR Code version");typeof n>"u"&&(n=Oe.BYTE);let s=Vt.getSymbolTotalCodewords(t),i=Do.getTotalCodewordsCount(t,r),o=(s-i)*8;if(n===Oe.MIXED)return o;let a=o-ys(n,t);switch(n){case Oe.NUMERIC:return Math.floor(a/10*3);case Oe.ALPHANUMERIC:return Math.floor(a/11*2);case Oe.KANJI:return Math.floor(a/13);case Oe.BYTE:default:return Math.floor(a/8)}};Ke.getBestVersionForData=function(t,r){let n,s=gs.from(r,gs.M);if(Array.isArray(t)){if(t.length>1)return Ho(t,s);if(t.length===0)return 1;n=t[0]}else n=t;return Io(n.mode,n.getLength(),s)};Ke.getEncodedBits=function(t){if(!kr.isValid(t)||t<7)throw new Error("Invalid QR Code version");let r=t<<12;for(;Vt.getBCHDigit(r)-vs>=0;)r^=bs<{var Tr=Ue(),Cs=1335,$o=21522,ws=Tr.getBCHDigit(Cs);Ns.getEncodedBits=function(t,r){let n=t.bit<<3|r,s=n<<10;for(;Tr.getBCHDigit(s)-ws>=0;)s^=Cs<{var Uo=Fe();function nt(e){this.mode=Uo.NUMERIC,this.data=e.toString()}nt.getBitsLength=function(t){return 10*Math.floor(t/3)+(t%3?t%3*3+1:0)};nt.prototype.getLength=function(){return this.data.length};nt.prototype.getBitsLength=function(){return nt.getBitsLength(this.data.length)};nt.prototype.write=function(t){let r,n,s;for(r=0;r+3<=this.data.length;r+=3)n=this.data.substr(r,3),s=parseInt(n,10),t.put(s,10);let i=this.data.length-r;i>0&&(n=this.data.substr(r),s=parseInt(n,10),t.put(s,i*3+1))};ks.exports=nt});var As=Q((Sd,_s)=>{var zo=Fe(),_r=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"," ","$","%","*","+","-",".","/",":"];function st(e){this.mode=zo.ALPHANUMERIC,this.data=e}st.getBitsLength=function(t){return 11*Math.floor(t/2)+6*(t%2)};st.prototype.getLength=function(){return this.data.length};st.prototype.getBitsLength=function(){return st.getBitsLength(this.data.length)};st.prototype.write=function(t){let r;for(r=0;r+2<=this.data.length;r+=2){let n=_r.indexOf(this.data[r])*45;n+=_r.indexOf(this.data[r+1]),t.put(n,11)}this.data.length%2&&t.put(_r.indexOf(this.data[r]),6)};_s.exports=st});var Bs=Q((kd,Es)=>{var Fo=Fe();function it(e){this.mode=Fo.BYTE,typeof e=="string"?this.data=new TextEncoder().encode(e):this.data=new Uint8Array(e)}it.getBitsLength=function(t){return t*8};it.prototype.getLength=function(){return this.data.length};it.prototype.getBitsLength=function(){return it.getBitsLength(this.data.length)};it.prototype.write=function(e){for(let t=0,r=this.data.length;t{var Oo=Fe(),qo=Ue();function ot(e){this.mode=Oo.KANJI,this.data=e}ot.getBitsLength=function(t){return t*13};ot.prototype.getLength=function(){return this.data.length};ot.prototype.getBitsLength=function(){return ot.getBitsLength(this.data.length)};ot.prototype.write=function(e){let t;for(t=0;t=33088&&r<=40956)r-=33088;else if(r>=57408&&r<=60351)r-=49472;else throw new Error("Invalid SJIS character: "+this.data[t]+` +Make sure your charset is UTF-8`);r=(r>>>8&255)*192+(r&255),e.put(r,13)}};Ps.exports=ot});var Ds=Q((_d,Ar)=>{"use strict";var yt={single_source_shortest_paths:function(e,t,r){var n={},s={};s[t]=0;var i=yt.PriorityQueue.make();i.push(t,0);for(var o,a,d,m,f,c,y,h,w;!i.empty();){o=i.pop(),a=o.value,m=o.cost,f=e[a]||{};for(d in f)f.hasOwnProperty(d)&&(c=f[d],y=m+c,h=s[d],w=typeof s[d]>"u",(w||h>y)&&(s[d]=y,i.push(d,y),n[d]=a))}if(typeof r<"u"&&typeof s[r]>"u"){var x=["Could not find a path from ",t," to ",r,"."].join("");throw new Error(x)}return n},extract_shortest_path_from_predecessor_list:function(e,t){for(var r=[],n=t,s;n;)r.push(n),s=e[n],n=e[n];return r.reverse(),r},find_path:function(e,t,r){var n=yt.single_source_shortest_paths(e,t,r);return yt.extract_shortest_path_from_predecessor_list(n,r)},PriorityQueue:{make:function(e){var t=yt.PriorityQueue,r={},n;e=e||{};for(n in t)t.hasOwnProperty(n)&&(r[n]=t[n]);return r.queue=[],r.sorter=e.sorter||t.default_sorter,r},default_sorter:function(e,t){return e.cost-t.cost},push:function(e,t){var r={value:e,cost:t};this.queue.push(r),this.queue.sort(this.sorter)},pop:function(){return this.queue.shift()},empty:function(){return this.queue.length===0}}};typeof Ar<"u"&&(Ar.exports=yt)});var Os=Q(at=>{var K=Fe(),Hs=Ts(),$s=As(),Us=Bs(),zs=Ls(),xt=Nr(),Jt=Ue(),jo=Ds();function Is(e){return unescape(encodeURIComponent(e)).length}function wt(e,t,r){let n=[],s;for(;(s=e.exec(r))!==null;)n.push({data:s[0],index:s.index,mode:t,length:s[0].length});return n}function Fs(e){let t=wt(xt.NUMERIC,K.NUMERIC,e),r=wt(xt.ALPHANUMERIC,K.ALPHANUMERIC,e),n,s;return Jt.isKanjiModeEnabled()?(n=wt(xt.BYTE,K.BYTE,e),s=wt(xt.KANJI,K.KANJI,e)):(n=wt(xt.BYTE_KANJI,K.BYTE,e),s=[]),t.concat(r,n,s).sort(function(o,a){return o.index-a.index}).map(function(o){return{data:o.data,mode:o.mode,length:o.length}})}function Er(e,t){switch(t){case K.NUMERIC:return Hs.getBitsLength(e);case K.ALPHANUMERIC:return $s.getBitsLength(e);case K.KANJI:return zs.getBitsLength(e);case K.BYTE:return Us.getBitsLength(e)}}function Go(e){return e.reduce(function(t,r){let n=t.length-1>=0?t[t.length-1]:null;return n&&n.mode===r.mode?(t[t.length-1].data+=r.data,t):(t.push(r),t)},[])}function Ko(e){let t=[];for(let r=0;r{var Qt=Ue(),Br=Ft(),Jo=ts(),Wo=ns(),Qo=ss(),Yo=as(),Dr=ls(),Ir=yr(),Xo=ps(),Wt=xs(),Zo=Ss(),Ro=Fe(),Pr=Os();function ea(e,t){let r=e.size,n=Yo.getPositions(t);for(let s=0;s=0&&a<=6&&(d===0||d===6)||d>=0&&d<=6&&(a===0||a===6)||a>=2&&a<=4&&d>=2&&d<=4?e.set(i+a,o+d,!0,!0):e.set(i+a,o+d,!1,!0))}}function ta(e){let t=e.size;for(let r=8;r>a&1)===1,e.set(s,i,o,!0),e.set(i,s,o,!0)}function Lr(e,t,r){let n=e.size,s=Zo.getEncodedBits(t,r),i,o;for(i=0;i<15;i++)o=(s>>i&1)===1,i<6?e.set(i,8,o,!0):i<8?e.set(i+1,8,o,!0):e.set(n-15+i,8,o,!0),i<8?e.set(8,n-i-1,o,!0):i<9?e.set(8,15-i-1+1,o,!0):e.set(8,15-i-1,o,!0);e.set(n-8,8,1,!0)}function sa(e,t){let r=e.size,n=-1,s=r-1,i=7,o=0;for(let a=r-1;a>0;a-=2)for(a===6&&a--;;){for(let d=0;d<2;d++)if(!e.isReserved(s,a-d)){let m=!1;o>>i&1)===1),e.set(s,a-d,m),i--,i===-1&&(o++,i=7)}if(s+=n,s<0||r<=s){s-=n,n=-n;break}}}function ia(e,t,r){let n=new Jo;r.forEach(function(d){n.put(d.mode.bit,4),n.put(d.getLength(),Ro.getCharCountIndicator(d.mode,e)),d.write(n)});let s=Qt.getSymbolTotalCodewords(e),i=Ir.getTotalCodewordsCount(e,t),o=(s-i)*8;for(n.getLengthInBits()+4<=o&&n.put(0,4);n.getLengthInBits()%8!==0;)n.putBit(0);let a=(o-n.getLengthInBits())/8;for(let d=0;d=7&&Vo(u,t),Jo(u,o),isNaN(n)&&(n=Ar.getBestMask(u,_r.bind(null,u,r))),Ar.applyMask(n,u),_r(u,r,n),{modules:u,version:t,errorCorrectionLevel:r,maskPattern:n,segments:s}}Ls.create=function(t,r){if(typeof t>"u"||t==="")throw new Error("No input text");let n=Tr.M,s,i;return typeof r<"u"&&(n=Tr.from(r.errorCorrectionLevel,Tr.M),s=Jt.from(r.version),i=Ar.from(r.maskPattern),r.toSJISFunc&&Wt.setToSJISFunction(r.toSJISFunc)),Yo(t,s,n,i)}});var Br=W(Ke=>{function Is(e){if(typeof e=="number"&&(e=e.toString()),typeof e!="string")throw new Error("Color should be defined as hex string");let t=e.slice().replace("#","").split("");if(t.length<3||t.length===5||t.length>8)throw new Error("Invalid hex color: "+e);(t.length===3||t.length===4)&&(t=Array.prototype.concat.apply([],t.map(function(n){return[n,n]}))),t.length===6&&t.push("F","F");let r=parseInt(t.join(""),16);return{r:r>>24&255,g:r>>16&255,b:r>>8&255,a:r&255,hex:"#"+t.slice(0,6).join("")}}Ke.getOptions=function(t){t||(t={}),t.color||(t.color={});let r=typeof t.margin>"u"||t.margin===null||t.margin<0?4:t.margin,n=t.width&&t.width>=21?t.width:void 0,s=t.scale||4;return{width:n,scale:n?4:s,margin:r,color:{dark:Is(t.color.dark||"#000000ff"),light:Is(t.color.light||"#ffffffff")},type:t.type,rendererOpts:t.rendererOpts||{}}};Ke.getScale=function(t,r){return r.width&&r.width>=t+r.margin*2?r.width/(t+r.margin*2):r.scale};Ke.getImageWidth=function(t,r){let n=Ke.getScale(t,r);return Math.floor((t+r.margin*2)*n)};Ke.qrToImageData=function(t,r,n){let s=r.modules.size,i=r.modules.data,o=Ke.getScale(s,n),a=Math.floor((s+n.margin*2)*o),u=n.margin*o,c=[n.color.light,n.color.dark];for(let f=0;f=u&&m>=u&&f{var Pr=Br();function Xo(e,t,r){e.clearRect(0,0,t.width,t.height),t.style||(t.style={}),t.height=r,t.width=r,t.style.height=r+"px",t.style.width=r+"px"}function Zo(){try{return document.createElement("canvas")}catch{throw new Error("You need to specify a canvas element")}}Qt.render=function(t,r,n){let s=n,i=r;typeof s>"u"&&(!r||!r.getContext)&&(s=r,r=void 0),r||(i=Zo()),s=Pr.getOptions(s);let o=Pr.getImageWidth(t.modules.size,s),a=i.getContext("2d"),u=a.createImageData(o,o);return Pr.qrToImageData(u.data,t,s),Xo(a,i,o),a.putImageData(u,0,0),i};Qt.renderToDataURL=function(t,r,n){let s=n;typeof s>"u"&&(!r||!r.getContext)&&(s=r,r=void 0),s||(s={});let i=Qt.render(t,r,s),o=s.type||"image/png",a=s.rendererOpts||{};return i.toDataURL(o,a.quality)}});var Us=W($s=>{var Ro=Br();function Hs(e,t){let r=e.a/255,n=t+'="'+e.hex+'"';return r<1?n+" "+t+'-opacity="'+r.toFixed(2).slice(1)+'"':n}function Lr(e,t,r){let n=e+t;return typeof r<"u"&&(n+=" "+r),n}function ea(e,t,r){let n="",s=0,i=!1,o=0;for(let a=0;a0&&u>0&&e[a-1]||(n+=i?Lr("M",u+r,.5+c+r):Lr("m",s,0),s=0,i=!1),u+1':"",c="',f='viewBox="0 0 '+a+" "+a+'"',w=''+u+c+` -`;return typeof n=="function"&&n(null,w),w}});var Yt=W(wt=>{var ta=qn(),Dr=Ds(),zs=Ms(),ra=Us();function Ir(e,t,r,n,s){let i=[].slice.call(arguments,1),o=i.length,a=typeof i[o-1]=="function";if(!a&&!ta())throw new Error("Callback required as last argument");if(a){if(o<2)throw new Error("Too few arguments provided");o===2?(s=r,r=t,t=n=void 0):o===3&&(t.getContext&&typeof s>"u"?(s=n,n=void 0):(s=n,n=r,r=t,t=void 0))}else{if(o<1)throw new Error("Too few arguments provided");return o===1?(r=t,t=n=void 0):o===2&&!t.getContext&&(n=r,r=t,t=void 0),new Promise(function(u,c){try{let f=Dr.create(r,n);u(e(f,t,n))}catch(f){c(f)}})}try{let u=Dr.create(r,n);s(null,e(u,t,n))}catch(u){s(u)}}wt.create=Dr.create;wt.toCanvas=Ir.bind(null,zs.render);wt.toDataURL=Ir.bind(null,zs.renderToDataURL);wt.toString=Ir.bind(null,function(e,t,r){return ra.render(e,r)})});var Re=typeof window<"u"?window.__HERMES_PLUGIN_SDK__:null,so="/api/plugins/hermes-relay";function Z(e,t){return!Re||typeof Re.fetchJSON!="function"?Promise.reject(new Error("Hermes plugin SDK unavailable")):Re.fetchJSON(`${so}${e}`,t)}function io(e,t){return!Re||typeof Re.fetchJSON!="function"?Promise.reject(new Error("Hermes plugin SDK unavailable")):Re.fetchJSON(e,t)}function It(){return Z("/overview")}function or(){return Z("/sessions")}function Mt(e=100){let t=Number.isFinite(e)?`?limit=${e}`:"";return Z(`/bridge-activity${t}`)}function hn({includeExpired:e=!1}={}){return Z(`/media${e?"?include_expired=true":""}`)}function gn(){return Z("/agent-context")}function vn(){return Z("/phone/config")}function ar({refresh:e=!1}={}){return Z(`/update-check${e?"?refresh=true":""}`)}function lr(e,t){return io("/api/env",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({key:e,value:String(t)})})}function bn(e){return Z(`/sessions/${encodeURIComponent(e)}`,{method:"DELETE"})}function Ht(){return Z("/remote-access/status")}function yn({host:e,port:t}={}){let r=new URLSearchParams;return e!==void 0&&r.set("host",e),t!==void 0&&r.set("port",t),Z(`/remote-access/secure-link/preflight?${r}`)}function xn(e){return Z("/remote-access/tailscale/enable",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e!==void 0?{port:e}:{stack:!0})})}function wn(e){return Z("/remote-access/tailscale/disable",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e!==void 0?{port:e}:{stack:!0})})}function Cn(e,{legacyDirectRelay:t=!1}={}){return Z("/remote-access/public-url",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:e==null?null:String(e),legacy_direct_relay:!!t})})}async function pt(e){let t=Array.isArray(e)?e:[],r=t.filter(i=>i.requires_paired_client).map(i=>({...i,reachable:null,status:null,latency_ms:null,error:null})),n=t.filter(i=>!i.requires_paired_client);if(!n.length)return{results:r};let s=await Z("/remote-access/probe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({candidates:n})});return{...s,results:[...s.results||[],...r]}}function $t({mode:e,publicUrl:t,prefer:r,...n}={}){let s={...n};return e&&(s.mode=e),t!==void 0&&(s.public_url=t),r&&(s.prefer=r),Z("/pairing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)})}function Nn(){return Z("/git/repos")}function dr(e){return Z(`/git/status?repo=${encodeURIComponent(e)}`)}function ur(e){return Z(`/git/branches?repo=${encodeURIComponent(e)}`)}function Sn(e,t,r="unstaged"){return Z(`/git/diff?repo=${encodeURIComponent(e)}&path=${encodeURIComponent(t)}&kind=${encodeURIComponent(r)}`)}function Tn(e,t){return Z(`/git/file?repo=${encodeURIComponent(e)}&path=${encodeURIComponent(t)}`)}function Ne(e,t){return Z(e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})}function kn(e,t){return Ne("/git/stage",{repo:e,paths:t})}function _n(e,t){return Ne("/git/unstage",{repo:e,paths:t})}function An(e,t,r,n=!1){return Ne("/git/discard",{repo:e,paths:t,confirmation:r,delete_untracked:n})}function En(e,t){return Ne("/git/commit",{repo:e,message:t})}function Bn(e,t="origin"){return Ne("/git/fetch",{repo:e,remote:t})}function Pn(e,t="origin",r=""){return Ne("/git/pull",{repo:e,remote:t,branch:r})}function Ln(e,t,r="origin",n=""){return Ne("/git/push",{repo:e,remote:r,branch:n,confirmation:t})}function Dn(e,t,r={}){let n={repo:e,ref:t,confirmation:r.confirmation,new_branch:r.newBranch||"",track:!!r.track};return Ne("/git/checkout",n)}function In(e){return Ne("/git/commit_message",{repo:e})}function Mn(e,t){return Ne("/git/commit_message_selected",{repo:e,paths:t})}function Hn(e,t,r={}){let n={repo:e,ref:t,new_branch:r.newBranch||"",track:!!r.track};return Ne("/git/stash_checkout",n)}function ye(e){if(e==null||e==="")return"\u2014";let t;if(typeof e=="number")t=e<1e12?e*1e3:e;else{let c=Date.parse(e);if(Number.isNaN(c))return"\u2014";t=c}let r=Date.now()-t;if(r<0)return"just now";let n=Math.floor(r/1e3);if(n<5)return"just now";if(n<60)return`${n}s ago`;let s=Math.floor(n/60);if(s<60)return`${s}m ago`;let i=Math.floor(s/60);if(i<24)return`${i}h ago`;let o=Math.floor(i/24);if(o<30)return`${o}d ago`;let a=Math.floor(o/30);return a<12?`${a}mo ago`:`${Math.floor(o/365)}y ago`}function $n(e){if(e==null||Number.isNaN(e))return"\u2014";let t=Number(e);if(t<1024)return`${t} B`;let r=["KB","MB","GB","TB"],n=t/1024,s=0;for(;n>=1024&&s65535)return null;let r=String(e.host).includes(":")&&!String(e.host).startsWith("[")?`[${e.host}]`:String(e.host);return Nt(`${e.tls?"https":"http"}://${r}:${t}`,Xt)}function na(e,t){let r=t.filter(n=>n.url.startsWith("https://")||n.url.startsWith("wss://")).length;return String(e).toLowerCase()==="tailscale"&&r===0?"Tailnet encrypted \xB7 no application TLS":r===t.length&&r>0?"Application TLS":r>0?"Mixed application TLS":"No application TLS"}function sa(e,t,r,n){let s=String(e&&e.role?e.role:"default"),i=Number.isInteger(e&&e.priority)?e.priority:t,o=n<=1,a=[],u=e?.proxy,c=u?Nt(u.url,new Set(["https:"])):null,f=typeof u?.pin_sha256=="string"&&/^sha256\/[A-Za-z0-9+/]{43}=$/.test(u.pin_sha256),m=c&&/^https:\/\/[^/?#]+\/?$/i.test(u.url.trim())&&f&&typeof u.cert_der=="string"&&u.cert_der.length>0;u&&!m&&a.push(`${s}: Secure Link needs a safe HTTPS origin, paired pin, and certificate`);let w=new Set(m&&Array.isArray(u.surfaces)?u.surfaces:[]),h=w.has("dashboard")?`${c}/dashboard`:null,C=w.has("relay")?`${c.replace(/^https:/,"wss:")}/relay/ws`:null,y=w.has("api")?`${c}/api`:null,k=e&&e.dashboard&&e.dashboard.url?e.dashboard.url:h||(o?r.dashboard_url:null),E=e&&e.relay&&e.relay.url?e.relay.url:C||(o&&r.relay?r.relay.url:null),_=Nt(k,Xt),b=Nt(E,Ks),T=e?.api?Gs(e.api):y||Gs(o?r:null),P=[_?{surface:"dashboard",label:"Dashboard",url:_}:null,b?{surface:"relay",label:"Relay",url:b}:null,T?{surface:"api",label:"Direct API",url:T}:null].filter(Boolean).map(U=>({...U,...m&&[h,C,y].includes(U.url)?{requiresPairedClient:!0}:{}}));k&&!_&&a.push(`${s}: invalid Dashboard URL`),E&&!b&&a.push(`${s}: invalid Relay URL`),e&&e.api&&!T&&a.push(`${s}: invalid API endpoint`);let x=s.toLowerCase();return x==="public"?(_||a.push("public: missing Dashboard origin"),b||a.push("public: missing Relay route"),_&&!_.startsWith("https://")&&a.push("public: Dashboard must use HTTPS"),b&&!b.startsWith("wss://")&&a.push("public: Relay must use WSS"),T&&!T.startsWith("https://")&&a.push("public: Direct API must use HTTPS")):x==="public_legacy"&&(b||a.push("public_legacy: missing Relay route"),b&&!b.startsWith("wss://")&&a.push("public_legacy: Relay must use WSS")),{role:s,priority:i,surfaces:P,protection:P.length&&P.every(U=>U.requiresPairedClient)?"Secure Link \xB7 paired TLS":na(s,P),issues:a}}function Zt(e){if(!e)return{routes:[],blockingIssues:[]};try{let t=typeof e=="string"?JSON.parse(e):e;if(!t||typeof t!="object")return{routes:[],blockingIssues:["Pairing payload is not an object"]};let r=Array.isArray(t.endpoints)&&t.endpoints.length>0?t.endpoints:[{role:"default",priority:0,api:t.host&&t.port?{host:t.host,port:t.port,tls:!!t.tls}:null,relay:t.relay,dashboard:t.dashboard_url?{url:t.dashboard_url}:null}],n=r.map((s,i)=>sa(s,i,t,r.length));return{routes:n,blockingIssues:n.flatMap(s=>s.issues)}}catch{return{routes:[],blockingIssues:["Pairing payload is not valid JSON"]}}}function Rt(e){return(e&&Array.isArray(e.routes)?e.routes:[]).flatMap(t=>t.surfaces.map(r=>({role:t.role,priority:t.priority,surface:r.surface,url:r.url,...r.requiresPairedClient?{requires_paired_client:!0}:{}})))}function js(e){return!e||e.blockingIssues.length?[]:e.routes.map(t=>({...t,surfaces:t.surfaces.filter(r=>r.surface==="relay"&&!Js(r.url))})).filter(t=>t.surfaces.length>0)}function Vs(e){try{let t=new URL(e);if(!["http:","https:","ws:","wss:"].includes(t.protocol))return"";let r=t.port||(["https:","wss:"].includes(t.protocol)?"443":"80");return`${t.protocol.slice(0,-1).toUpperCase()} \xB7 port ${r}`}catch{return""}}function Ve(e){return[e.role,e.priority,e.surface,e.url].join("|")}function er(e){return e?e.requires_paired_client?{healthy:null,label:"Verify after pairing"}:e.surface==="relay"&&(e.status===401||e.status===403)&&Js(e.url)?{healthy:!0,label:"Auth required"}:e.reachable===!0?{healthy:!0,label:`Ready${e.latency_ms!=null?` \xB7 ${e.latency_ms}ms`:""}`}:e.status!=null?{healthy:!1,label:`HTTP ${e.status}`}:{healthy:!1,label:e.error||"Unreachable"}:{healthy:null,label:"Not checked"}}function Js(e){if(typeof e!="string"||!e.trim())return!1;try{let t=new URL(e.trim());if(!Xt.has(t.protocol)&&!Ks.has(t.protocol)||t.username||t.password||t.search||t.hash)return!1;let r="/api/plugins/hermes-relay/transport",n=t.pathname.replace(/\/+$/,""),s=n.lastIndexOf(r);if(s<0)return!1;let i=n.slice(s+r.length);return i===""||i==="/ws"||i==="/health"}catch{return!1}}function Ws(e,t=10443){let r=!!(e&&e.active===!0),n=Array.isArray(e&&e.listen_ports)?[...new Set(e.listen_ports.filter(i=>Number.isInteger(i)))].sort((i,o)=>i-o):[],s=Number.isInteger(t)&&t>0&&t<=65535?t:10443;return{active:r,listenPorts:n,recommendedPort:s,recommendedActive:r&&n.includes(s),migration443:r&&s!==443&&n.includes(443),migration9119:r&&s!==9119&&n.includes(9119)}}function Mr(e){if(typeof e!="string"||!e.trim())return{kind:"empty",url:null};let t=Nt(e,Xt);if(!t)return{kind:"invalid",url:null};let r=new URL(t),n=r.pathname.replace(/\/+$/,"");return{kind:r.port==="8767"||/\/(?:api\/plugins\/hermes-relay\/transport|relay(?:\/ws)?|ws)$/i.test(n)?"legacy-relay-path":"dashboard-origin",url:t}}var Xs=window.__HERMES_PLUGIN_SDK__,{React:me}=Xs,xe=Xs.components||{},Qs=xe.Button,Ys=xe.Badge;function ia(e){switch(e){case"outline":return{outlined:!0};case"ghost":return{ghost:!0};case"secondary":return{invert:!0};case"destructive":return{destructive:!0};case"default":case void 0:case null:default:return{}}}var S=Qs?(({variant:e,size:t,className:r="",children:n,...s})=>{let i=ia(e);return me.createElement(Qs,{size:t==="icon"||t==="xs"||t==="sm"?t:"default",className:r,...i,...s},n)}):(({variant:e,size:t,className:r="",children:n,...s})=>me.createElement("button",{type:"button",className:`hr-btn inline-flex items-center justify-center gap-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${e==="ghost"?"hr-btn-ghost":e==="outline"?"hr-btn-outline":e==="destructive"?"hr-btn-destructive":"hr-btn-solid"} ${r}`,...s},n)),oa={default:"secondary",secondary:"secondary",outline:"outline",destructive:"destructive",success:"success",warning:"warning"},$=Ys?(({variant:e,className:t="",children:r,...n})=>me.createElement(Ys,{tone:oa[e]||"secondary",className:t,...n},r)):(({variant:e,className:t="",children:r,...n})=>me.createElement("span",{className:`hr-badge ${e==="destructive"?"hr-badge-destructive":e==="outline"?"hr-badge-outline":"hr-badge-secondary"} ${t}`,...n},r)),se=xe.Alert||(({children:e,variant:t,className:r=""})=>me.createElement("div",{className:`rounded-md border p-3 ${t==="destructive"?"border-destructive/50 text-destructive bg-destructive/10":"border-border bg-muted/30"} ${r}`},e)),ie=xe.AlertTitle||(({children:e,className:t=""})=>me.createElement("div",{className:`font-semibold mb-1 ${t}`},e)),oe=xe.AlertDescription||(({children:e,className:t=""})=>me.createElement("div",{className:`text-sm ${t}`},e)),X=xe.CardDescription||(({children:e,className:t=""})=>me.createElement("p",{className:`text-sm text-muted-foreground ${t}`},e)),ot=xe.Switch||(({checked:e,onCheckedChange:t,id:r,disabled:n,...s})=>me.createElement("button",{id:r,type:"button",role:"switch","aria-checked":!!e,disabled:!!n,className:`hr-switch ${e?"checked":""}`,onClick:()=>t&&t(!e),...s},me.createElement("span",{className:"hr-switch-thumb","aria-hidden":"true"}))),at=xe.Table||(({children:e,className:t=""})=>me.createElement("div",{className:"w-full overflow-auto"},me.createElement("table",{className:`w-full text-sm caption-bottom ${t}`},e))),lt=xe.TableHeader||(({children:e})=>me.createElement("thead",{className:"border-b bg-muted/50"},e)),dt=xe.TableBody||(({children:e})=>me.createElement("tbody",{className:"[&_tr:last-child]:border-0"},e)),Be=xe.TableRow||(({children:e,className:t="",...r})=>me.createElement("tr",{className:`border-b hover:bg-muted/30 transition-colors ${t}`,...r},e)),ne=xe.TableHead||(({children:e,className:t=""})=>me.createElement("th",{className:`h-10 px-2 text-left align-middle font-medium text-muted-foreground ${t}`},e)),R=xe.TableCell||(({children:e,className:t="",...r})=>me.createElement("td",{className:`p-2 align-middle ${t}`,...r},e));var $r=window.__HERMES_PLUGIN_SDK__,{React:p}=$r,{useState:De,useEffect:kt,useRef:Zs,useCallback:St,useMemo:tr}=$r.hooks,{Input:Rs,Label:Tt,Dialog:aa,DialogContent:la,DialogDescription:da,DialogFooter:ua,DialogHeader:ca,DialogTitle:ma}=$r.components,ei="hermes-relay-pair-mode",ti="hermes-relay-pair-prefer",ri="hermes-relay-pair-host",ni="hermes-relay-pair-port",si="hermes-relay-pair-tls",ii="hermes-relay-pair-client",pa=[{value:"auto",label:"Auto (all reachable candidates)"},{value:"lan",label:"LAN only"},{value:"tailscale",label:"Tailscale only"},{value:"public",label:"Public Dashboard only"}],fa=[{value:"",label:"Natural order (Tailscale \u2192 Public \u2192 LAN)"},{value:"lan",label:"LAN \u2192 priority 0"},{value:"tailscale",label:"Tailscale \u2192 priority 0"},{value:"public",label:"Public \u2192 priority 0"}],ha={plugin_proxy:"Secure Link",public_legacy:"External",legacy_direct:"LAN"};function ut(e,t){try{return window.localStorage.getItem(e)??t}catch{return t}}function ct(e,t){try{window.localStorage.setItem(e,t)}catch{}}function ga(){let e=parseInt(ut(ni,""),10);return{client:ut(ii,"android")==="desktop"?"desktop":"android",mode:ut(ei,"auto")||"auto",prefer:ut(ti,"")||"",host:ut(ri,"")||"",port:Number.isFinite(e)&&e>0&&e<=65535?e:8642,tls:ut(si,"")==="true"}}function va(e){ct(ii,e.client||"android"),ct(ei,e.mode||"auto"),ct(ti,e.prefer||""),ct(ri,e.host||""),ct(ni,String(e.port||"")),ct(si,e.tls?"true":"false")}function ba(e){let[t,r]=De(()=>Math.floor(Date.now()/1e3));if(kt(()=>{if(!e)return;r(Math.floor(Date.now()/1e3));let o=setInterval(()=>r(Math.floor(Date.now()/1e3)),1e3);return()=>clearInterval(o)},[e]),!e)return null;let n=Math.max(0,e-t);if(n<=0)return"expired";let s=Math.floor(n/60),i=n%60;return s>0?`${s}m ${i}s`:`${i}s`}function ya(e){if(!e)return!1;let t=e.toLowerCase().trim();return/^\d+\.\d+\.\d+\.\d+$/.test(t)||t==="localhost"||t.endsWith(".local")||t.endsWith(".ts.net")?!1:t.includes(".")&&!t.endsWith(".lan")&&!t.endsWith(".home.arpa")}function rr({open:e,onClose:t}){let[r,n]=De(ga),[s,i]=De(!1),[o,a]=De({status:"idle"}),[u,c]=De({status:"idle",results:[]}),[f,m]=De(""),[w,h]=De({status:"idle"}),[C,y]=De(!1),[k,E]=De(!1),_=Zs(null),b=Zs(0),T=ba(o.data?o.data.expires_at:null),P=r.client==="desktop",x=T==="expired",U=tr(()=>Zt(o.data?o.data.qr_payload:null),[o.data]),D=tr(()=>js(U),[U]),V=P?D:U.routes,Q=tr(()=>Rt({routes:V}),[V]),ue=tr(()=>{let N=new Map;return(u.results||[]).forEach(M=>N.set(Ve(M),M)),N},[u.results]),q=r.host&&ya(r.host),ge=St(async N=>{let M=N||r,te=++b.current;a({status:"loading"});try{let ae=je(window.location);if(!ae)throw new Error("This Dashboard does not have a valid HTTP(S) origin for pairing.");let Ye={};M.host&&M.host.trim()&&(Ye.host=M.host.trim(),Ye.port=Number(M.port)||8642,Ye.tls=!!M.tls);let Xe=await $t({mode:M.mode||"auto",prefer:M.prefer||void 0,dashboard_url:ae,legacy_direct_relay:M.client==="desktop"?!0:void 0,...Ye});if(te!==b.current)return;m(""),y(!1),a({status:"ok",data:Xe})}catch(ae){if(te!==b.current)return;a({status:"error",error:ae&&ae.message?ae.message:String(ae)})}},[r]);kt(()=>{if(o.status!=="ok"||U.blockingIssues.length>0||Q.length===0){c({status:"idle",results:[]});return}let N=!1;return c({status:"loading",results:[]}),pt(Q).then(M=>{N||c({status:"done",results:M&&Array.isArray(M.results)?M.results:[]})}).catch(M=>{N||c({status:"error",results:[],error:M&&M.message?M.message:String(M)})}),()=>{N=!0}},[o.status,U,Q]),kt(()=>{!e||o.status!=="idle"||q&&!k||ge()},[e,o.status,ge,q,k]),kt(()=>{if(!e||P||x||o.status!=="ok"||!_.current){h({status:"idle"});return}let N=!1;return h({status:"loading"}),Promise.resolve().then(()=>{if(N||!_.current)return;let M=Hr.default.create(o.data.qr_payload,Ct()),te=_.current.closest(".hr-pair-qr-column"),ae=(te?te.clientWidth:280)-26;return Hr.default.toCanvas(_.current,o.data.qr_payload,Ct({modules:M.modules.size,availableWidth:ae}))}).then(()=>{N||h({status:"ok"})}).catch(M=>{N||h({status:"error",error:qs(M)})}),()=>{N=!0}},[e,P,x,o.status,o.data]),kt(()=>{e||(b.current+=1,a({status:"idle"}),m(""),y(!1),c({status:"idle",results:[]}),i(!1),E(!1))},[e]);let ee=St((N,M=!0)=>{n(te=>{let ae={...te,...N};return va(ae),ae}),Object.prototype.hasOwnProperty.call(N,"host")&&E(!1),M&&(b.current+=1,a({status:"idle"}))},[]),ce=St(()=>{b.current+=1,a({status:"idle"})},[]),z=St(()=>{E(!0),a({status:"idle"})},[]),O=St(async()=>{let N=o.data&&(o.data.pairing_url||o.data.qr_payload);if(!N||o.data.expires_at<=Math.floor(Date.now()/1e3))return;let M=b.current;try{if(await Fs(N),M!==b.current)return;m("Invite copied"),y(!1)}catch{if(M!==b.current)return;m("Copy failed. Select the invite below and copy it manually."),y(!0)}},[o.data]);if(!e)return null;let G=s&&q,we=q&&!k,Qe=o.status==="ok"&&U.blockingIssues.length>0;return p.createElement(aa,{open:e,onOpenChange:N=>{N||t()}},p.createElement(la,{className:"hermes-relay-plugin hr-pair-dialog"},p.createElement(ca,null,p.createElement("div",{className:"flex flex-wrap items-center gap-2"},p.createElement(ma,null,"Pair new device"),p.createElement($,{variant:"outline",className:"text-xs"},"Hermes-Relay Plugin")),p.createElement(da,null,"Scan with Android or copy a CLI+UI invite for another computer.")),p.createElement("div",{className:"hr-pair-body"},p.createElement("section",{className:"hr-pair-qr-column","aria-label":"Hermes-Relay pairing code"},p.createElement("div",{className:"hr-pair-panel"},p.createElement(Tt,{htmlFor:"pair-client"},"Pairing client"),p.createElement("select",{id:"pair-client",className:"h-9 w-full rounded-md border border-border bg-background px-2 text-sm",value:r.client,onChange:N=>ee({client:N.target.value})},p.createElement("option",{value:"android"},"Hermes-Relay Android"),p.createElement("option",{value:"desktop"},"Hermes-Relay CLI+UI"))),we?p.createElement("div",{className:"rounded-md border border-amber-500/60 bg-amber-500/15 p-3 text-sm space-y-2"},p.createElement("div",{className:"font-medium"},"Proxy-fronted host detected"),p.createElement("p",{className:"text-xs"},p.createElement("span",{className:"font-mono"},r.host)," appears to require browser authentication that Hermes-Relay Android cannot present to the API route."),p.createElement("div",{className:"flex flex-wrap gap-2"},p.createElement(S,{size:"sm",onClick:z},"Mint anyway"),p.createElement(S,{size:"sm",variant:"outline",onClick:()=>ee({host:"",port:8642,tls:!1})},"Clear override"))):Qe?p.createElement("div",{className:"rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive space-y-2"},p.createElement("div",{className:"font-medium"},"Unsafe pairing route blocked"),p.createElement("p",{className:"text-xs"},"The invite was not shown or copied because its public route is incomplete or insecure."),p.createElement("ul",{className:"list-disc pl-4 text-xs space-y-1"},U.blockingIssues.map(N=>p.createElement("li",{key:N},N))),p.createElement(S,{size:"sm",variant:"outline",onClick:ce},"Mint a corrected route")):o.status==="loading"?p.createElement("div",{className:"hr-pair-loading text-sm text-muted-foreground"},"Minting a secure code\u2026"):o.status==="error"?p.createElement("div",{className:"rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"},p.createElement("div",{className:"font-medium mb-1"},"Minting failed"),p.createElement("div",{className:"break-words"},o.error),p.createElement(S,{className:"mt-2",size:"sm",variant:"outline",onClick:ce},"Retry")):o.status==="ok"?p.createElement(p.Fragment,null,x?p.createElement("div",{className:"hr-pair-panel",role:"status"},p.createElement("div",{className:"hr-pair-panel-title"},"Invite expired"),p.createElement("p",{className:"text-sm text-muted-foreground"},"Create a new invite to continue pairing."),p.createElement(S,{size:"sm",onClick:ce},"New invite")):P?p.createElement("div",{className:"hr-pair-panel"},p.createElement("div",{className:"hr-pair-panel-title"},"Connect this computer"),p.createElement("p",{className:"text-sm"},"Copy the invite, then open CLI+UI \u2192 Pair host \u2192 Paste invite."),p.createElement("p",{className:"text-xs text-muted-foreground"},"The invite includes Relay addresses and Secure Link certificate trust. Hermes sign-in is not required on the computer.")):p.createElement(p.Fragment,null,w.status==="error"?p.createElement("div",{className:"hr-pair-panel",role:"status"},p.createElement("div",{className:"hr-pair-panel-title"},"Use the pairing invite"),p.createElement("p",{className:"text-sm text-muted-foreground"},w.error)):null,p.createElement("div",{className:"hr-qr-frame hr-pairing-qr",hidden:w.status==="error"},p.createElement("canvas",{ref:_,className:"block","aria-label":"Hermes-Relay pairing QR code"}))),p.createElement("div",{className:"hr-pair-code-row"},p.createElement("div",null,p.createElement("div",{className:"text-xs uppercase tracking-wider text-muted-foreground"},"Pairing code"),p.createElement("div",{className:"font-mono text-2xl tracking-widest"},o.data.code)),p.createElement("div",{className:"text-right"},p.createElement("div",{className:"text-xs uppercase tracking-wider text-muted-foreground"},"Expires in"),p.createElement($,{variant:T==="expired"?"destructive":"outline"},T||"\u2014"))),o.data.pairing_url?p.createElement("div",{className:"space-y-2"},p.createElement(S,{className:"w-full",size:"sm",onClick:O,disabled:x||P&&D.length===0},"Copy invite"),!x&&f?p.createElement("div",{className:"text-center text-xs text-muted-foreground",role:"status"},f):null,!x&&C?p.createElement("textarea",{className:"hr-pair-invite-text","aria-label":"Pairing invite",readOnly:!0,value:o.data.pairing_url,onFocus:N=>N.target.select()}):null):null):null),p.createElement("section",{className:"hr-pair-options-column"},!P&&p.createElement("div",{className:"hr-pair-panel"},p.createElement("div",{className:"hr-pair-panel-title"},"What this adds"),p.createElement("div",{className:"hr-grant-list"},["Terminal","Bridge","Media","Voice"].map(N=>p.createElement($,{key:N,variant:"outline",className:"text-xs"},N))),p.createElement("p",{className:"text-xs text-muted-foreground"},"Extends an existing Hermes Dashboard connection with Hermes-Relay capabilities.")),p.createElement("div",{className:"hr-pair-panel"},p.createElement("div",{className:"hr-pair-connection-header"},p.createElement("div",null,p.createElement("div",{className:"hr-pair-panel-title"},P?"Relay routes":"Connection"),p.createElement("div",{className:"text-xs text-muted-foreground"},P?"Addresses this computer can use":"Best available route")),p.createElement("select",{id:"pair-mode","aria-label":"Connection mode",className:"h-9 rounded-md border border-border bg-background px-2 text-sm",value:r.mode,onChange:N=>ee({mode:N.target.value})},pa.map(N=>p.createElement("option",{key:N.value,value:N.value},P&&N.value==="public"?"Public routes":N.label)))),V.length>0?p.createElement("div",{className:"hr-endpoint-list"},V.map(N=>p.createElement("div",{key:`${N.role}-${N.priority}`,className:"hr-endpoint-route"},p.createElement("div",{className:"hr-endpoint-row"},p.createElement($,{variant:"outline",className:"text-xs capitalize"},ha[N.role]||N.role),p.createElement("span",{className:"text-xs text-muted-foreground hr-endpoint-address"},N.protection),p.createElement("span",{className:"text-xs text-muted-foreground"},"p",N.priority)),p.createElement("div",{className:"hr-endpoint-surfaces"},N.surfaces.map(M=>{let te=ue.get(Ve({role:N.role,priority:N.priority,surface:M.surface,url:M.url})),ae=u.status==="loading"&&!te?"Checking\u2026":er(te).label;return p.createElement("div",{key:`${M.surface}-${M.url}`,className:"hr-endpoint-surface"},p.createElement("span",{className:"text-xs font-medium"},M.label),p.createElement("div",{className:"min-w-0"},p.createElement("span",{className:`block font-mono text-xs ${P?"hr-relay-address":"hr-endpoint-address"}`,title:M.url},M.url),p.createElement("div",{className:"text-xs text-muted-foreground"},Vs(M.url))),p.createElement($,{variant:"outline",className:"text-xs"},ae))}))))):p.createElement("div",{className:"text-xs text-muted-foreground"},P&&o.status==="ok"?"No compatible Relay route is available. Configure a reachable direct Relay or Secure Link address before pairing.":"Automatic route selection will use server configuration."),p.createElement("p",{className:"text-xs text-muted-foreground"},P?"The CLI verifies the selected route while pairing. A Dashboard address alone cannot connect this computer to Relay.":p.createElement(p.Fragment,null,"Tailscale is preferred because it keeps the route private and ACL-controlled. A raw HTTP/WS tailnet URL has no application TLS, but Tailscale still encrypts device-to-device traffic. Public routes must use HTTPS/WSS.")),u.status==="error"?p.createElement("div",{className:"text-xs text-destructive"},"Surface probes failed: ",u.error):null),p.createElement("details",{className:"hr-pair-advanced",open:s},p.createElement("summary",{onClick:N=>{N.preventDefault(),i(M=>!M)}},"Advanced connection options"),s?p.createElement("div",{className:"hr-pair-advanced-content space-y-3"},p.createElement("div",{className:"space-y-1"},p.createElement(Tt,{htmlFor:"pair-prefer"},"Prefer role"),p.createElement("select",{id:"pair-prefer",className:"h-9 w-full rounded-md border border-border bg-background px-2 text-sm",value:r.prefer,onChange:N=>ee({prefer:N.target.value})},fa.map(N=>p.createElement("option",{key:N.value,value:N.value},N.label)))),p.createElement("div",{className:"space-y-1"},p.createElement(Tt,{htmlFor:"pair-host"},"API host override"),p.createElement(Rs,{id:"pair-host",value:r.host,placeholder:"Use server configuration",onChange:N=>ee({host:N.target.value},!1)})),p.createElement("div",{className:"grid grid-cols-2 gap-2"},p.createElement("div",{className:"space-y-1"},p.createElement(Tt,{htmlFor:"pair-port"},"API port"),p.createElement(Rs,{id:"pair-port",type:"number",min:"1",max:"65535",value:r.port,onChange:N=>ee({port:parseInt(N.target.value,10)||8642},!1)})),p.createElement("div",{className:"space-y-1"},p.createElement(Tt,{htmlFor:"pair-tls"},"Scheme"),p.createElement("label",{className:"hr-pair-checkbox text-sm",htmlFor:"pair-tls"},p.createElement("input",{id:"pair-tls",type:"checkbox",className:"h-4 w-4",checked:!!r.tls,onChange:N=>ee({tls:N.target.checked},!1)}),"https://"))),G?p.createElement("div",{className:"rounded-md border border-amber-500/50 bg-amber-500/10 p-2 text-xs"},"This host appears proxy-fronted and may reject API requests from Hermes-Relay Android."):null,p.createElement("div",{className:"flex flex-wrap gap-2"},p.createElement(S,{size:"sm",onClick:ce},"Apply and mint"),p.createElement(S,{size:"sm",variant:"ghost",onClick:()=>ee({host:"",port:8642,tls:!1})},"Clear override"))):null))),p.createElement(ua,null,p.createElement(S,{size:"sm",variant:"outline",onClick:ce,disabled:o.status==="loading"},"New code"),p.createElement(S,{size:"sm",onClick:t},"Done"))))}var nr=window.__HERMES_PLUGIN_SDK__,{React:l}=nr,{useState:j,useEffect:mt,useCallback:fe}=nr.hooks,{Card:Te,CardHeader:ke,CardTitle:_e,CardContent:Ae,Input:xa,Label:mi,ConfirmDialog:wa,Toast:pi}=nr.components,fi=nr.hooks.useToast,Ur="RELAY_AGENT_CONTEXT_ENABLED",zr="RELAY_CONTEXT_MEDIA_SENSITIVITY",Ca=new Set(["1","true","yes","on"]);function oi(e,t){return e==null?t:Ca.has(String(e).trim().toLowerCase())}function Na(e){return e==null?"":Array.isArray(e)?e.join(" "):typeof e=="object"?Object.keys(e).join(" "):String(e)}var ai={chat:0,bridge:10,terminal:20,tui:30,"voice:config":40,"voice:stt":41,"voice:tts":42};function li(e){let t=String(e||"").toLowerCase();return Object.prototype.hasOwnProperty.call(ai,t)?ai[t]:100}function di(e){switch(String(e||"").toLowerCase()){case"chat":return"Chat";case"bridge":return"Bridge";case"terminal":return"Terminal";case"tui":return"TUI";case"voice:config":return"Voice config";case"voice:stt":return"Voice STT";case"voice:tts":return"Voice TTS";default:return String(e||"")}}function ui(e){return e.sort((t,r)=>li(t.name)-li(r.name)||String(t.name).localeCompare(String(r.name)))}function Sa(e){let t=e&&e.grants;if(Array.isArray(t)){let r=t.map(n=>{if(typeof n=="string")return{name:n,detail:""};if(!n||typeof n!="object")return null;let s=n.name||n.channel||n.grant||n.scope;return s?{name:String(s),detail:n.expires_at?et(n.expires_at):Or(n.ttl_seconds??n.ttl??n.seconds)}:null}).filter(Boolean);return ui(r)}return t&&typeof t=="object"?ui(Object.entries(t).map(([r,n])=>({name:r,detail:n&&typeof n=="object"?n.expires_at||n.expiresAt||n.until?et(n.expires_at||n.expiresAt||n.until):Or(n.ttl_seconds??n.ttl??n.seconds):Or(n)}))):[]}function Or(e){if(e==null||e===""||e===!0)return"";let t=Number(e);return!Number.isFinite(t)||t<=0?"":t>1e9?et(t):Ta(t)}function Ta(e){if(e==null||e===""||e===!0)return"";let t=Number(e);if(!Number.isFinite(t)||t<=0)return"";if(t<60)return`${Math.floor(t)}s`;let r=Math.floor(t/60);if(r<60)return`${r}m`;let n=Math.floor(r/60),s=r%60;if(n<24)return s?`${n}h ${s}m`:`${n}h`;let i=Math.floor(n/24),o=n%24;return o?`${i}d ${o}h`:`${i}d`}function ka(e,t){let r=[e.device_type,e.client_type,e.platform,e.device_platform,e.device_model,e.client_surface,e.device_form_factor,e.device_name,e.device_label,e.client_name,e.label,e.transport,e.transport_hint,e.channel,Na(e.capabilities),t.map(n=>n.name).join(" ")].filter(Boolean).join(" ").toLowerCase();return/\bandroid\b|\bmobile\b|\bphone\b|hermes-relay-android/.test(r)?"Android":/\btui\b|terminal-ui|textual/.test(r)?"Desktop TUI":/\bcli\b|terminal|shell|desktop|tool|powershell|bash|cmd\.exe/.test(r)?"Desktop CLI":/\bweb\b|\bbrowser\b|\bdashboard\b/.test(r)?"Dashboard":"Client"}function _a(e){return e.transport_hint||e.transport||e.channel||e.connection||e.protocol||""}function hi(e){let t=e.token||e.session_token||"";return e.token_prefix||e.prefix||e.tokenPrefix||e.session_prefix||(t?String(t).slice(0,12):"")}function Fr({label:e,value:t,hint:r}){return l.createElement(Te,null,l.createElement(ke,{className:"pb-2"},l.createElement(X,null,e),l.createElement(_e,{className:"text-2xl"},t)),r?l.createElement(Ae,{className:"pt-0 text-xs text-muted-foreground"},r):null)}function ci({id:e,title:t,description:r,checked:n,disabled:s,onChange:i}){return l.createElement("div",{className:"flex items-start justify-between gap-3 rounded-md border border-border/70 p-3"},l.createElement("div",{className:"space-y-1"},l.createElement(mi,{htmlFor:e,className:"text-sm font-medium"},t),r?l.createElement("div",{className:"text-xs text-muted-foreground"},r):null),l.createElement(ot,{id:e,checked:n,disabled:s,onCheckedChange:i}))}function Aa({data:e,saving:t,onToggle:r}){let n=e&&e.settings||{},s=e&&e.injected||{},i=Array.isArray(s.blocks)?s.blocks:[],o=oi(n[Ur],!0),a=oi(n[zr],!0);return l.createElement(Te,null,l.createElement(ke,null,l.createElement(_e,null,"Agent context"),l.createElement(X,null,"On by default for Hermes-Relay installs \u2014 injects an instruction into the agent's system prompt (server-side) so it can mark sensitive media. Turn off to opt out; removable by uninstalling the relay plugin.")),l.createElement(Ae,{className:"space-y-3"},l.createElement(ci,{id:"relay-agent-context-enabled",title:"Enable Agent context injection",description:"Master toggle for Hermes-Relay-owned server-side prompt blocks.",checked:o,disabled:t===Ur,onChange:u=>r(Ur,u)}),l.createElement(ci,{id:"relay-context-media-sensitivity",title:"Media sensitivity block",description:"Ask the agent to mark private, NSFW, or spoiler media with client-visible sensitivity markers.",checked:a,disabled:t===zr,onChange:u=>r(zr,u)}),l.createElement("details",{className:"hr-audit-details"},l.createElement("summary",null,l.createElement("span",null,l.createElement("span",{className:"text-sm font-medium"},"Server-side audit"),l.createElement("span",{className:"block text-xs text-muted-foreground"},s.enabled?"Context injection enabled.":"Context injection disabled.")),l.createElement($,{variant:s.enabled?"success":"outline",className:"text-xs"},i.length," ",i.length===1?"block":"blocks"," active")),i.length===0?l.createElement("div",{className:"hr-audit-empty text-xs text-muted-foreground"},"No blocks would be injected on the next turn."):l.createElement("div",{className:"hr-audit-blocks space-y-2"},i.map(u=>l.createElement("div",{key:u.name,className:"rounded-md bg-muted/40 p-2"},l.createElement("div",{className:"text-xs font-medium"},u.name),l.createElement("pre",{className:"mt-1 whitespace-pre-wrap text-xs text-muted-foreground"},u.text)))))))}function Ea({config:e,onSaved:t}){let[r,n]=j(e&&e.home_channel_name||"Phone"),[s,i]=j(!1),[o,a]=j(null),[u,c]=j(null),f=e&&e.home_channel_id||"phone",m=e&&e.name_env_key||"PHONE_HOME_CHANNEL_NAME",w=fe(async()=>{let h=r.trim();if(!h){c("Display name cannot be empty.");return}c(null),i(!0);try{await lr(m,h),a(Date.now()),t&&await t()}catch(C){c(C&&C.message?C.message:String(C))}finally{i(!1)}},[r,m,t]);return l.createElement(Te,null,l.createElement(ke,null,l.createElement("div",{className:"flex flex-wrap items-center gap-2"},l.createElement(_e,null,"Home channel"),l.createElement($,{variant:"warning",className:"text-xs"},"Restart required")),l.createElement(X,null,"Where Hermes delivers proactive pushes, cron results, and cross-platform messages when no specific Thread is named. The phone is a single paired device, so this is auto-configured \u2014 you only set a display name.")),l.createElement(Ae,{className:"space-y-3"},l.createElement("div",{className:"space-y-1"},l.createElement(mi,{htmlFor:"phone-home-name"},"Display name"),l.createElement(xa,{id:"phone-home-name",value:r,placeholder:"Phone",onChange:h=>n(h.target.value)}),l.createElement("p",{className:"text-xs text-muted-foreground"},"Notification title and Thread label. Applies after the next gateway restart.")),l.createElement("div",{className:"text-xs text-muted-foreground"},"Channel id ",l.createElement("code",{className:"font-mono"},f)," \u2014 fixed; changing it would orphan existing Threads."),u?l.createElement("div",{className:"rounded-md border border-destructive/50 bg-destructive/10 p-2 text-xs text-destructive"},u):null,l.createElement("div",{className:"flex flex-wrap items-center gap-2"},l.createElement(S,{size:"sm",onClick:w,disabled:s||!r.trim()},s?"Saving\u2026":"Save"),o?l.createElement("span",{className:"text-xs text-muted-foreground"},"Saved ",ye(o)):null)))}function Ba({info:e,onRefresh:t}){let[r,n]=j(!1),[s,i]=j(!1),o=fe(async()=>{n(!0);try{await t(!0)}finally{n(!1)}},[t]),a=e&&e.update_command,u=fe(async()=>{if(a)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(a):window.prompt("Copy update command",a),i(!0),window.setTimeout(()=>i(!1),1500)}catch{window.prompt("Copy update command",a)}},[a]);if(!e)return null;let c=e.current||"\u2014",f=!!e.update_available,m=f?`Update available \u2014 you're on ${c}.`:e.error?`On ${c}. Couldn't reach GitHub to check.`:`On ${c}${e.latest?` \u2014 latest is ${e.latest}.`:"."}`;return l.createElement(Te,null,l.createElement(ke,{className:"flex flex-row items-center justify-between space-y-0"},l.createElement("div",null,l.createElement(_e,null,"Plugin version"),l.createElement(X,null,m)),l.createElement(S,{size:"sm",variant:"outline",onClick:o,disabled:r},r?"Checking\u2026":"Check")),f?l.createElement(Ae,{className:"space-y-2"},l.createElement($,{variant:"secondary",className:"w-fit text-xs"},c," \u2192 ",e.latest),l.createElement("div",{className:"flex items-center justify-between gap-2 rounded-md border border-border/70 bg-muted/30 p-2 font-mono text-xs"},l.createElement("span",{className:"truncate"},a),l.createElement(S,{size:"sm",variant:"ghost",onClick:u},s?"Copied":"Copy")),l.createElement("p",{className:"text-xs text-muted-foreground"},"Run it on your Hermes host, then restart the gateway to load the new plugin.")):e.error?l.createElement(Ae,{className:"pt-0 text-xs text-muted-foreground"},e.error):null)}function gi(e){return Array.isArray(e)?e:e&&e.sessions||[]}function Se(e){return e&&e.message?e.message:String(e)}function vi({session:e,compact:t=!1,copied:r,revoking:n,onCopy:s,onRevoke:i}){let o=hi(e),a=e.device_name||e.device_label||e.client_name||e.label||Ut(o)||"Unnamed device",u=e.last_seen||e.last_activity||e.last_seen_at||e.last_activity_at||e.updated_at||e.paired_at,c=e.expires_at??e.expiresAt??e.expires,f=zn(c),m=Sa(e),w=ka(e,m),h=_a(e),C=cr(e),y=[e.device_model,e.device_platform].filter(k=>k&&k!=="unknown").join(" \xB7 ");return l.createElement("div",{className:`hr-device-card ${t?"hr-device-card-compact":""}`},l.createElement("div",{className:"hr-device-main"},l.createElement("div",{className:"hr-device-title-row"},l.createElement("span",{className:"hr-status-dot hr-status-dot-success","aria-label":"Paired"}),l.createElement("span",{className:"font-medium"},a),l.createElement($,{variant:"outline",className:"text-xs"},w),C?l.createElement($,{variant:"secondary",className:"text-xs"},"Supervised"):null),l.createElement("div",{className:"hr-device-meta text-xs text-muted-foreground"},y?l.createElement("span",null,y):null,h?l.createElement("span",null,h):null,l.createElement("span",null,"Seen ",ye(u)),l.createElement($,{variant:f.expired?"destructive":"secondary",className:"text-xs",title:f.exact?`Expires ${f.exact}`:void 0},f.label)),C&&C.profileLabel?l.createElement("div",{className:"text-xs text-muted-foreground"},"Pinned profile: ",C.profileLabel):null,t?null:l.createElement("div",{className:"hr-grant-list"},m.length===0?l.createElement("span",{className:"text-xs text-muted-foreground"},"No grants reported"):m.map(k=>l.createElement($,{key:`${k.name}:${k.detail}`,variant:"secondary",className:"text-xs"},k.detail?`${di(k.name)} ${k.detail}`:di(k.name))))),t?null:l.createElement("div",{className:"hr-device-actions"},l.createElement(S,{size:"sm",variant:"outline",disabled:!o,onClick:()=>s(o)},r===o?"Copied":"Copy prefix"),l.createElement(S,{size:"sm",variant:"destructive",disabled:n===o||!o,onClick:()=>i({prefix:o,label:a})},n===o?"Revoking\u2026":"Revoke")))}function Pa(e){if(!e)return{value:"\u2014",hint:"Checking routes"};let t=e.tailscale&&e.tailscale.serve_services||{};return t.dashboard&&t.dashboard.active===!0?{value:"Tailscale",hint:"Private Dashboard route active"}:t.legacy_relay&&t.legacy_relay.active===!0?{value:"Tailscale",hint:"Legacy direct Relay route active"}:e.secure_link&&e.secure_link.enabled?{value:"Secure Link",hint:"Pinned TLS route active"}:e.public&&e.public.url?{value:"Public HTTPS",hint:"Pinned Dashboard origin"}:{value:"LAN only",hint:"No remote route configured"}}function La(e){let t=String(e||"pending").toLowerCase();return t==="executed"||t==="confirmed"?"success":t==="blocked"||t==="error"?"destructive":t==="timeout"?"warning":"outline"}function Da({rows:e,error:t,onOpenActivity:r}){return l.createElement(Te,null,l.createElement(ke,{className:"flex flex-row items-center justify-between space-y-0"},l.createElement("div",null,l.createElement(_e,null,"Recent Bridge activity"),l.createElement(X,null,"Latest commands routed through Hermes-Relay.")),l.createElement(S,{size:"sm",variant:"ghost",onClick:r},"View all")),l.createElement(Ae,null,t&&e.length===0?l.createElement("div",{className:"text-xs text-destructive"},t):e.length===0?l.createElement("div",{className:"text-sm text-muted-foreground"},"No Bridge activity recorded yet."):l.createElement("div",{className:"hr-activity-list"},e.slice(0,4).map((n,s)=>l.createElement("div",{className:"hr-activity-row",key:n.request_id||`${n.sent_at}-${s}`},l.createElement("div",{className:"min-w-0"},l.createElement("div",{className:"font-mono text-xs hr-activity-method"},n.method||"COMMAND"," ",n.path||""),l.createElement("div",{className:"text-xs text-muted-foreground"},ye(n.sent_at))),l.createElement($,{variant:La(n.decision),className:"text-xs capitalize"},n.decision||"pending"))))))}function bi({autoRefresh:e,onConnectMobile:t,onNavigate:r}){let[n,s]=j(null),[i,o]=j(null),[a,u]=j([]),[c,f]=j(null),[m,w]=j(null),[h,C]=j(null),[y,k]=j([]),[E,_]=j(null),[b,T]=j(null),[P,x]=j(!1),U=fe(async()=>{try{s(await It()),o(null)}catch(G){o(Se(G))}},[]),D=fe(async()=>{try{u(gi(await or())),f(null)}catch(G){f(Se(G))}},[]),V=fe(async()=>{try{w(await Ht()),C(null)}catch(G){C(Se(G))}},[]),Q=fe(async()=>{try{let G=await Mt(5);k(Array.isArray(G)?G:G&&G.activity||[]),_(null)}catch(G){_(Se(G))}},[]),ue=fe(async(G=!1)=>{try{T(await ar({refresh:G}))}catch(we){T({error:Se(we)})}},[]),q=fe(()=>{U(),D(),V(),Q()},[U,D,V,Q]);mt(()=>{q(),ue(!1)},[q,ue]),mt(()=>{if(!e)return;let G=setInterval(q,1e4);return()=>clearInterval(G)},[e,q]);let ge=n||{},ee=Pa(m),ce=y[0],z=i?n?"Stale":"Unavailable":n?ge.health||"Healthy":"Checking";return l.createElement("div",{className:"space-y-4"},l.createElement(Te,{className:"hr-service-card"},l.createElement(ke,{className:"hr-service-header"},l.createElement("div",null,l.createElement("div",{className:"flex flex-wrap items-center gap-2"},l.createElement(_e,null,"Service status"),l.createElement($,{variant:i?"warning":n?"success":"outline",className:"text-xs"},z)),l.createElement(X,null,"Hermes-Relay service health and installed plugin version.")),l.createElement(S,{size:"sm",variant:"outline",onClick:()=>ue(!0)},"Check for updates")),l.createElement(Ae,{className:"hr-service-details"},l.createElement("div",null,l.createElement("span",null,"Version"),l.createElement("strong",null,ge.version||"\u2014")),l.createElement("div",null,l.createElement("span",null,"Uptime"),l.createElement("strong",null,Un(ge.uptime_seconds))),l.createElement("div",null,l.createElement("span",null,"Refresh"),l.createElement("strong",null,e?"Live":"Paused")),b&&b.update_available?l.createElement($,{variant:"warning",className:"text-xs"},b.latest," available"):null),i?l.createElement("div",{className:"hr-inline-error text-xs text-destructive"},i):null),l.createElement("div",{className:"grid gap-3 sm:grid-cols-2 lg:grid-cols-3"},l.createElement(Fr,{label:"Paired devices",value:n?ge.paired_device_count??ge.session_count??a.length:"\u2014",hint:c||"Hermes-Relay sessions"}),l.createElement(Fr,{label:"Remote access",value:ee.value,hint:h||ee.hint}),l.createElement(Fr,{label:"Last Bridge event",value:ce?ye(ce.sent_at):"\u2014",hint:E||(ce?ce.decision||"Recorded":"No recent activity")})),l.createElement("div",{className:"hr-overview-grid"},l.createElement(Te,null,l.createElement(ke,null,l.createElement(_e,null,"Quick actions"),l.createElement(X,null,"Connect the standard Dashboard first, then add Hermes-Relay capabilities.")),l.createElement(Ae,{className:"space-y-3"},l.createElement(S,{className:"w-full",onClick:()=>x(!0)},"Pair new device"),l.createElement(S,{className:"w-full",variant:"outline",onClick:t},"Connect mobile app"),a.length>0?l.createElement("div",{className:"hr-overview-device"},l.createElement("div",{className:"text-xs uppercase tracking-wider text-muted-foreground"},"Most recently paired"),l.createElement(vi,{session:a[0],compact:!0})):null,l.createElement(S,{size:"sm",variant:"ghost",onClick:()=>r("devices")},"Manage devices"))),l.createElement(Da,{rows:y,error:E,onOpenActivity:()=>r("activity")})),e?null:l.createElement(S,{size:"sm",variant:"outline",onClick:q},"Refresh overview"),l.createElement(rr,{open:P,onClose:()=>{x(!1),D(),U()}}))}function qr({autoRefresh:e,onConnectMobile:t}){let[r,n]=j([]),[s,i]=j(!0),[o,a]=j(null),[u,c]=j(!1),[f,m]=j(null),[w,h]=j(null),[C,y]=j(null),{toast:k,showToast:E}=fi(),_=fe(async()=>{try{n(gi(await or())),a(null)}catch(x){a(Se(x))}finally{i(!1)}},[]);mt(()=>{_()},[_]),mt(()=>{if(!e)return;let x=setInterval(_,1e4);return()=>clearInterval(x)},[e,_]);let b=fe(async x=>{if(x)try{await navigator.clipboard.writeText(x),h(x),E("Token prefix copied","success"),window.setTimeout(()=>h(null),1500)}catch{E("Could not copy token prefix","error")}},[E]),T=fe(async()=>{if(C){m(C.prefix);try{await bn(C.prefix),E(`${C.label} revoked`,"success"),y(null),await _()}catch(x){E(`Revoke failed: ${Se(x)}`,"error")}finally{m(null)}}},[C,_,E]),P=r.some(x=>cr(x));return l.createElement("div",{className:"space-y-4"},l.createElement(pi,{toast:k}),l.createElement("div",{className:"hr-connection-choice-grid"},l.createElement(Te,null,l.createElement(ke,null,l.createElement(_e,null,"Connect mobile app"),l.createElement(X,null,"Standard Hermes Dashboard connection for Chat, Manage, sessions, and voice.")),l.createElement(Ae,null,l.createElement(S,{size:"sm",variant:"outline",onClick:t},"Show setup QR"))),l.createElement(Te,null,l.createElement(ke,null,l.createElement(_e,null,"Pair with Hermes-Relay"),l.createElement(X,null,"Add Terminal, Bridge, media, remote-access, and extended voice grants.")),l.createElement(Ae,null,l.createElement(S,{size:"sm",onClick:()=>c(!0)},"Pair new device")))),l.createElement(Te,null,l.createElement(ke,{className:"flex flex-row items-center justify-between space-y-0"},l.createElement("div",null,l.createElement(_e,null,"Paired devices"),l.createElement(X,null,"Clients currently authorized against Hermes-Relay.")),e?null:l.createElement(S,{size:"sm",variant:"outline",onClick:_},"Refresh")),l.createElement(Ae,null,P?l.createElement("div",{className:"mb-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground"},"Supervised Mode is reported and enforced by Hermes-Relay Android, not by the plugin service."):null,o&&r.length===0?l.createElement(se,{variant:"destructive"},l.createElement(ie,null,"Paired devices unavailable"),l.createElement(oe,null,o)):s?l.createElement("div",{className:"text-sm text-muted-foreground"},"Loading paired devices\u2026"):r.length===0?l.createElement("div",{className:"hr-empty-state"},l.createElement("div",{className:"font-medium"},"No Hermes-Relay devices paired"),l.createElement("div",{className:"text-sm text-muted-foreground"},"Pair a device to enable Hermes-Relay capabilities.")):l.createElement("div",{className:"hr-device-list"},r.map((x,U)=>l.createElement(vi,{key:hi(x)||U,session:x,copied:w,revoking:f,onCopy:b,onRevoke:y}))),o&&r.length>0?l.createElement("div",{className:"mt-3 text-xs text-destructive"},"Refresh failed: ",o):null)),l.createElement(wa,{open:!!C,title:"Revoke Hermes-Relay device",description:C?`${C.label} will lose Hermes-Relay access and must pair again.`:"This device will lose Hermes-Relay access.",confirmLabel:"Revoke",destructive:!0,loading:!!f,onCancel:()=>y(null),onConfirm:T}),l.createElement(rr,{open:u,onClose:()=>{c(!1),_()}}))}function yi({autoRefresh:e}){let[t,r]=j("general"),[n,s]=j(null),[i,o]=j(null),[a,u]=j(null),[c,f]=j(null),[m,w]=j(null),[h,C]=j(null),{toast:y,showToast:k}=fi(),E=fe(async()=>{try{s(await gn()),o(null)}catch(x){o(Se(x))}},[]),_=fe(async()=>{try{f(await vn()),w(null)}catch(x){w(Se(x))}},[]),b=fe(async(x=!1)=>{try{C(await ar({refresh:x}))}catch(U){C({error:Se(U)})}},[]);mt(()=>{E(),_(),b(!1)},[E,_,b]),mt(()=>{if(!e)return;let x=setInterval(()=>{E(),_()},15e3);return()=>clearInterval(x)},[e,E,_]);let T=fe(async(x,U)=>{u(x);try{await lr(x,U?"1":"0"),await E(),k("Hermes-Relay Agent Context updated","success")}catch(D){k(`Agent Context update failed: ${Se(D)}`,"error")}finally{u(null)}},[E,k]);return l.createElement("div",{className:"hr-settings-layout"},l.createElement(pi,{toast:y}),l.createElement("aside",{className:"hr-settings-nav","aria-label":"Hermes-Relay settings sections"},l.createElement("div",{className:"hr-settings-nav-label"},"Settings"),[{key:"general",label:"General"},{key:"context",label:"Agent Context"},{key:"maintenance",label:"Maintenance"}].map(x=>l.createElement("button",{key:x.key,type:"button",className:`hr-settings-nav-item ${t===x.key?"active":""}`,"aria-current":t===x.key?"page":void 0,onClick:()=>r(x.key)},x.label))),l.createElement("div",{className:"hr-settings-content"},t==="general"?m&&!c?l.createElement(se,{variant:"destructive"},l.createElement(ie,null,"General settings unavailable"),l.createElement(oe,null,m)):c&&c.enabled?l.createElement(Ea,{key:c.home_channel_name||"phone",config:c,onSaved:_}):l.createElement(Te,null,l.createElement(ke,null,l.createElement(_e,null,"General"),l.createElement(X,null,"Phone Home Channel is not enabled for this Hermes-Relay installation."))):null,t==="context"?i&&!n?l.createElement(se,{variant:"destructive"},l.createElement(ie,null,"Agent Context unavailable"),l.createElement(oe,null,i)):n?l.createElement(Aa,{data:n,saving:a,onToggle:T}):l.createElement("div",{className:"text-sm text-muted-foreground"},"Loading Agent Context\u2026"):null,t==="maintenance"?h?l.createElement(Ba,{info:h,onRefresh:b}):l.createElement("div",{className:"text-sm text-muted-foreground"},"Checking Hermes-Relay version\u2026"):null))}var Gr=window.__HERMES_PLUGIN_SDK__,{React:L}=Gr,{useState:_t,useEffect:xi,useCallback:Ia,useMemo:Ma}=Gr.hooks,{Card:Ha,CardHeader:$a,CardTitle:Ua,CardContent:za}=Gr.components,Oa=["All","Executed","Blocked","Confirmed","Timeout","Error"],Fa={executed:"default",blocked:"destructive",confirmed:"default",timeout:"secondary",error:"destructive",pending:"outline"};function qa({decision:e}){let t=(e||"pending").toLowerCase(),r=Fa[t]||"outline";return L.createElement($,{variant:r,className:"text-xs capitalize"},t)}function Ga(e){if(!e||typeof e!="object")return"\u2014";try{return JSON.stringify(e,null,2)}catch{return String(e)}}function Kr({autoRefresh:e}){let[t,r]=_t(null),[n,s]=_t(!0),[i,o]=_t(null),[a,u]=_t("All"),[c,f]=_t({}),m=Ia(async()=>{o(null);try{let h=await Mt(100),C=Array.isArray(h)?h:h&&h.activity||[];r(C)}catch(h){o(h&&h.message?h.message:String(h))}finally{s(!1)}},[]);xi(()=>{m()},[m]),xi(()=>{if(!e)return;let h=setInterval(m,5e3);return()=>clearInterval(h)},[e,m]);let w=Ma(()=>{if(!t)return[];if(a==="All")return t;let h=a.toLowerCase();return t.filter(C=>(C.decision||"").toLowerCase()===h)},[t,a]);return n?L.createElement("div",{className:"text-sm text-muted-foreground"},"Loading bridge activity\u2026"):i?L.createElement(se,{variant:"destructive"},L.createElement(ie,null,"Hermes-Relay unreachable"),L.createElement(oe,null,L.createElement("pre",{className:"whitespace-pre-wrap text-xs"},i),e?null:L.createElement(S,{className:"mt-2",size:"sm",variant:"outline",onClick:m},"Retry"))):L.createElement(Ha,null,L.createElement($a,null,L.createElement(Ua,null,"Bridge activity"),L.createElement(X,null,"Most recent Bridge commands routed through Hermes-Relay. Newest first; capped at 100.")),L.createElement(za,null,L.createElement("div",{className:"mb-3 flex flex-wrap items-center gap-2"},Oa.map(h=>L.createElement(S,{key:h,size:"sm",variant:a===h?"default":"outline",onClick:()=>u(h)},h)),e?null:L.createElement(S,{size:"sm",variant:"ghost",className:"ml-auto",onClick:m},"Refresh")),w.length===0?L.createElement("div",{className:"text-sm text-muted-foreground"},t&&t.length===0?"No bridge commands recorded yet.":`No entries match "${a}".`):L.createElement(at,null,L.createElement(lt,null,L.createElement(Be,null,L.createElement(ne,null,"Sent"),L.createElement(ne,null,"Method"),L.createElement(ne,null,"Path"),L.createElement(ne,null,"Decision"),L.createElement(ne,null,"Status"),L.createElement(ne,null,"Error"))),L.createElement(dt,null,w.map((h,C)=>{let y=h.request_id||`${h.sent_at}-${C}`,k=!!c[y];return L.createElement(L.Fragment,{key:y},L.createElement(Be,{className:"cursor-pointer",onClick:()=>f(E=>({...E,[y]:!E[y]}))},L.createElement(R,null,ye(h.sent_at)),L.createElement(R,{className:"font-mono text-xs"},h.method||"\u2014"),L.createElement(R,{className:"font-mono text-xs"},h.path||"\u2014"),L.createElement(R,null,L.createElement(qa,{decision:h.decision})),L.createElement(R,null,h.response_status??"\u2014"),L.createElement(R,{className:"max-w-xs truncate text-xs text-destructive"},h.error||"")),k?L.createElement(Be,null,L.createElement(R,{colSpan:6,className:"bg-muted/30"},L.createElement("div",{className:"space-y-2 py-2 text-xs"},L.createElement("div",null,L.createElement("span",{className:"font-semibold"},"request_id:")," ",L.createElement("span",{className:"font-mono"},h.request_id||"\u2014")),L.createElement("div",null,L.createElement("span",{className:"font-semibold"},"params (redacted):"),L.createElement("pre",{className:"mt-1 whitespace-pre-wrap rounded bg-background p-2 font-mono"},Ga(h.params))),h.result_summary?L.createElement("div",null,L.createElement("span",{className:"font-semibold"},"result:")," ",L.createElement("span",null,h.result_summary)):null))):null)})))))}var Vr=window.__HERMES_PLUGIN_SDK__,{React:F}=Vr,{useState:At,useEffect:jr,useCallback:Ka}=Vr.hooks,{Card:ja,CardHeader:Va,CardTitle:Ja,CardContent:Wa,Label:Qa}=Vr.components;function Jr({autoRefresh:e}){let[t,r]=At(null),[n,s]=At(!0),[i,o]=At(null),[a,u]=At(!1),[c,f]=At(Date.now()),m=Ka(async()=>{o(null);try{let h=await hn({includeExpired:a}),C=Array.isArray(h)?h:h&&h.media||[];r(C)}catch(h){o(h&&h.message?h.message:String(h))}finally{s(!1)}},[a]);if(jr(()=>{m()},[m]),jr(()=>{if(!e)return;let h=setInterval(m,15e3);return()=>clearInterval(h)},[e,m]),jr(()=>{let h=setInterval(()=>f(Date.now()),1e3);return()=>clearInterval(h)},[]),n&&t===null)return F.createElement("div",{className:"text-sm text-muted-foreground"},"Loading media registry\u2026");if(i)return F.createElement(se,{variant:"destructive"},F.createElement(ie,null,"Hermes-Relay unreachable"),F.createElement(oe,null,F.createElement("pre",{className:"whitespace-pre-wrap text-xs"},i),e?null:F.createElement(S,{className:"mt-2",size:"sm",variant:"outline",onClick:m},"Retry")));let w=t||[];return F.createElement(ja,null,F.createElement(Va,null,F.createElement(Ja,null,"Media tokens"),F.createElement(X,null,"Diagnostic view of token-backed Hermes-Relay media. Bare-path deliveries are not included.")),F.createElement(Wa,null,F.createElement("div",{className:"mb-3 flex items-center gap-3"},F.createElement("div",{className:"flex items-center gap-2"},F.createElement(ot,{id:"include-expired",checked:a,onCheckedChange:u}),F.createElement(Qa,{htmlFor:"include-expired"},"Include expired")),e?null:F.createElement(S,{size:"sm",variant:"ghost",className:"ml-auto",onClick:m},"Refresh")),w.length===0?F.createElement("div",{className:"text-sm text-muted-foreground"},a?"No media tokens registered.":"No active media tokens. Toggle 'Include expired' to see recently expired entries."):F.createElement(at,null,F.createElement(lt,null,F.createElement(Be,null,F.createElement(ne,null,"File"),F.createElement(ne,null,"Type"),F.createElement(ne,null,"Size"),F.createElement(ne,null,"Created"),F.createElement(ne,null,"TTL"),F.createElement(ne,null,"Token"))),F.createElement(dt,null,w.map(h=>{let C=!!h.is_expired,y=C?"expired":et(h.expires_at,c);return F.createElement(Be,{key:h.token||`${h.file_name}-${h.created_at}`},F.createElement(R,{className:"max-w-xs truncate font-mono text-xs"},h.file_name||"\u2014"),F.createElement(R,{className:"text-xs"},h.content_type||"\u2014"),F.createElement(R,{className:"text-xs"},$n(h.size)),F.createElement(R,{className:"text-xs"},ye(h.created_at)),F.createElement(R,null,F.createElement($,{variant:C?"secondary":"outline",className:"text-xs"},y)),F.createElement(R,{className:"font-mono text-xs text-muted-foreground"},Ut(h.token,8)))})))))}var wi=window.__HERMES_PLUGIN_SDK__,{React:Je}=wi,{Tabs:Ya,TabsList:Xa,TabsTrigger:Za}=wi.components,Ra=[{key:"bridge",label:"Bridge activity"},{key:"media",label:"Media tokens"}];function Wr({autoRefresh:e}){return Je.createElement(Ya,{defaultValue:"bridge",className:"hr-activity-tabs"},(t,r)=>Je.createElement(Je.Fragment,null,Je.createElement(Xa,{className:"hr-activity-tablist","aria-label":"Hermes-Relay activity views"},Ra.map(n=>Je.createElement(Za,{key:n.key,active:t===n.key,value:n.key,onClick:()=>r(n.key)},n.label))),t==="bridge"?Je.createElement(Kr,{autoRefresh:e}):Je.createElement(Jr,{autoRefresh:e})))}var Si=ir(Yt());var Qr=window.__HERMES_PLUGIN_SDK__,{React:B}=Qr,{useState:We,useRef:el,useEffect:tl}=Qr.hooks,{Input:Ci,Label:Ni}=Qr.components;function Yr({status:e,onPair:t,onInvalidateInvite:r,pairingBusy:n=!1}){let s=(()=>{try{return new URL(e.url)}catch{return null}})(),[i,o]=We(!1),[a,u]=We(s?.hostname||""),[c,f]=We(s?.port||"9443"),[m,w]=We(null),[h,C]=We(""),[y,k]=We(!1),[E,_]=We(!1),b=el(0);tl(()=>{b.current+=1,w(null),k(!1),r?.()},[e.url]);let T=(D,V)=>{b.current+=1,D(V),w(null),C(""),_(!1),k(!1),r?.()},P=async()=>{let D=++b.current;o(!0),k(!0),C(""),_(!1),r?.();try{let V=await yn({host:a.trim()||void 0,port:c});if(D!==b.current)return;if(V?.schema_version!==1||!Array.isArray(V.checks))throw new Error("Relay returned an unsupported setup report.");w(V),!a.trim()&&V.host&&u(V.host)}catch(V){if(D!==b.current)return;w(null),C(`Setup checks unavailable. Confirm Relay is running and supports Secure Link setup checks. ${V.message||""}`)}finally{D===b.current&&k(!1)}},x=Object.entries(m?.environment||{}).map(([D,V])=>`${D}=${V}`).join(` -`),U=async()=>{try{await navigator.clipboard.writeText(x),_(!0)}catch{C("Clipboard access is unavailable. Select and copy the settings below.")}};return B.createElement("div",{className:"space-y-3"},i?B.createElement("div",{className:"space-y-4 border-t border-border pt-3"},B.createElement("div",{className:"space-y-2"},B.createElement("h4",{className:"text-sm font-semibold"},"1. Check this server"),B.createElement("p",{className:"text-xs text-muted-foreground"},"Checks are read-only. They do not change settings, create keys, or restart services."),B.createElement("div",{className:"grid gap-3 sm:grid-cols-2"},B.createElement("div",{className:"space-y-1"},B.createElement(Ni,{htmlFor:"secure-link-host"},"Address the phone will use"),B.createElement(Ci,{id:"secure-link-host",value:a,placeholder:"192.168.1.20 or relay.example",onChange:D=>T(u,D.target.value)})),B.createElement("div",{className:"space-y-1"},B.createElement(Ni,{htmlFor:"secure-link-port"},"HTTPS port"),B.createElement(Ci,{id:"secure-link-port",value:c,inputMode:"numeric",onChange:D=>T(f,D.target.value)}))),B.createElement(S,{size:"sm",variant:"outline",disabled:y,onClick:P},y?"Checking\u2026":"Check again")),h?B.createElement(se,{variant:"destructive"},B.createElement(ie,null,"Check needed"),B.createElement(oe,null,h)):null,m?B.createElement(B.Fragment,null,B.createElement("div",{role:"list",className:"space-y-2","aria-live":"polite"},m.checks.map(D=>B.createElement("div",{role:"listitem",key:D.id,className:"rounded-md border border-border p-3 space-y-1"},B.createElement("div",{className:"flex items-center justify-between gap-2 text-sm"},B.createElement("span",null,D.label),B.createElement($,{variant:D.status==="blocked"?"destructive":"outline"},D.status==="ok"?"Checked":D.status==="warning"?"Optional / unavailable":"Needs attention")),B.createElement("p",{className:"text-xs text-muted-foreground"},D.detail)))),m.ready_to_enable&&!m.pairing_ready?B.createElement("div",{className:"space-y-2"},B.createElement("h4",{className:"text-sm font-semibold"},"2. Review and enable"),B.createElement("p",{className:"font-mono text-xs break-all"},m.url),m.requires_repair?B.createElement("p",{className:"text-xs text-muted-foreground"},"This changes the paired address or port. Existing clients must re-pair after activation; no certificate is replaced by this check."):null,B.createElement("p",{className:"text-xs text-muted-foreground"},"Add these settings to the existing Relay environment. Restart its owner, then choose Check again. Do not start a second Relay."),B.createElement("p",{className:"text-xs text-muted-foreground"},m.connected_clients," Relay client(s) connected. ",m.restart_notice),B.createElement("p",{className:"text-xs text-muted-foreground"},m.configuration_note),B.createElement("pre",{className:"rounded-md bg-muted/20 p-3 text-xs whitespace-pre-wrap break-all select-text"},x),B.createElement(S,{size:"sm",variant:"outline",onClick:U},E?"Copied":"Copy settings"),B.createElement("details",{className:"text-xs text-muted-foreground"},B.createElement("summary",{className:"cursor-pointer"},"Foreground / CLI startup"),B.createElement("p",{className:"mt-2"},"Add to your existing ",B.createElement("code",null,"hermes relay start")," command, preserving its other arguments:"),B.createElement("pre",{className:"mt-2 whitespace-pre-wrap break-all select-text"},m.start_arguments.join(" ")))):null,m.pairing_ready?B.createElement("div",{className:"space-y-2"},B.createElement("h4",{className:"text-sm font-semibold"},"3. Pair a device"),B.createElement("p",{className:"font-mono text-xs break-all"},m.current_url),B.createElement("p",{className:"text-xs text-muted-foreground"},"Secure Link is listening. Create a fresh signed QR below, scan it in Android, then sign into Dashboard. Chat becomes ready only after Gateway connects. Existing devices must re-pair to import this certificate and pin."),B.createElement(S,{disabled:n||y,onClick:()=>t(m.current_url)},n?"Creating invite\u2026":"Create pairing QR")):null,B.createElement("details",{className:"text-xs text-muted-foreground"},B.createElement("summary",{className:"cursor-pointer"},"Disable or recover"),B.createElement("p",{className:"mt-2"},"Set ",B.createElement("code",null,"RELAY_SECURE_LINK_ENABLED=0")," (or replace the startup flag with ",B.createElement("code",null,"--no-secure-link"),"), then restart Relay using its existing manager. Keep the certificate and key if you intend to re-enable the same route. Address or certificate changes require explicit re-pairing."),B.createElement("p",{className:"mt-2"},"This flow does not restart services or open firewall ports. If activation fails, restore the previous Relay settings and check its health before retrying."))):null):B.createElement(S,{variant:"outline",onClick:P},e.enabled?"Check Secure Link":"Set up Secure Link"))}var Zr=window.__HERMES_PLUGIN_SDK__,{React:d}=Zr,{useState:he,useEffect:Et,useCallback:Ie,useMemo:sr,useRef:Xr}=Zr.hooks,{Card:Bt,CardHeader:Pt,CardTitle:Lt,CardContent:Dt,Input:rl,Label:Ti}=Zr.components;function Me({tone:e="muted",title:t}){let r={ok:"bg-emerald-500",warn:"bg-amber-500",bad:"bg-destructive",muted:"bg-muted-foreground/40"},n=r[e]||r.muted;return d.createElement("span",{title:t||"",className:`inline-block h-2.5 w-2.5 rounded-full ${n}`})}function ki(e){return e===!0?"ok":e===!1?"bad":"muted"}function nl({status:e,onEnable:t,onDisable:r,busy:n,resultMessage:s}){let i=!!(e&&e.available),o=e&&e.serve_ports||[],a=e&&e.serve_services||{},u=a.dashboard||{},c=Number.isInteger(e&&e.recommended_listener_port)?e.recommended_listener_port:10443,f=Ws(u,c),m=a.api||{},w=a.legacy_relay||{},h=f.active,C=f.recommendedActive,y=f.migration443,k=f.migration9119,E=m.active===!0,_=w.active===!0,b=C,T=e&&e.hostname||null,P=e&&e.tailscale_ip||null,x=e&&e.reason,U=D=>{let V=Array.isArray(D&&D.listen_ports)?D.listen_ports.filter(Q=>Number.isInteger(Q)):[];return V.length>0?`tailnet ${V.map(Q=>`:${Q}`).join(", ")}`:"tailnet"};return d.createElement(Bt,null,d.createElement(Pt,null,d.createElement("div",{className:"flex flex-wrap items-center gap-2"},d.createElement(Lt,null,"Tailscale Serve"),d.createElement($,{className:"text-xs"},"Recommended"),d.createElement($,{variant:"outline",className:"text-xs"},"Remote access")),d.createElement(X,null,"The easiest supported way to reach this self-hosted Hermes installation away from home. Tailscale supplies private routing, WireGuard encryption, and ACLs. Raw tailnet HTTP/WS has no application TLS, but traffic remains encrypted between tailnet devices; Hermes authentication still applies. Recommended setup maps dedicated tailnet HTTPS :",f.recommendedPort,"to local Dashboard :9119, avoiding conflicts with Traefik, Caddy, nginx, or another service that already owns :443.")),d.createElement(Dt,{className:"space-y-3"},d.createElement("div",{className:"flex flex-wrap items-center gap-3 text-sm"},d.createElement("div",{className:"flex items-center gap-2"},d.createElement(Me,{tone:i?"ok":"muted",title:i?"CLI present":"CLI absent"}),d.createElement("span",null,"CLI: ",i?"installed":"not installed")),d.createElement("div",{className:"flex items-center gap-2"},d.createElement(Me,{tone:C?"ok":i?"warn":"muted"}),d.createElement("span",null,"Dashboard \u2192 host :9119: ",C?`HTTPS :${f.recommendedPort} active \xB7 recommended`:y||k?`HTTPS :${f.recommendedPort} off \xB7 old listeners do not satisfy recommended setup`:"off")),y?d.createElement("div",{className:"flex items-center gap-2"},d.createElement(Me,{tone:"warn"}),d.createElement("span",null,"Shared HTTPS listener :443: active \xB7 migration or explicit route")):null,k?d.createElement("div",{className:"flex items-center gap-2"},d.createElement(Me,{tone:"warn"}),d.createElement("span",null,"Old Dashboard listener :9119: active \xB7 migration compatibility")):null,d.createElement("div",{className:"flex items-center gap-2"},d.createElement(Me,{tone:E?"ok":"muted"}),d.createElement("span",null,"Direct API \u2192 host :8642: ",E?`active on ${U(m)} \xB7 optional`:"off")),d.createElement("div",{className:"flex items-center gap-2"},d.createElement(Me,{tone:_?"warn":"muted"}),d.createElement("span",null,"Direct Relay \u2192 host :8767: ",_?`active on ${U(w)} \xB7 legacy compatibility`:"off"))),T?d.createElement("div",{className:"rounded-md border border-border bg-muted/20 px-3 py-2 text-xs space-y-1"},d.createElement("div",null,d.createElement("span",{className:"uppercase tracking-wider text-muted-foreground"},"Hostname")," ",d.createElement("span",{className:"font-mono"},T)),P?d.createElement("div",null,d.createElement("span",{className:"uppercase tracking-wider text-muted-foreground"},"Tailscale IP")," ",d.createElement("span",{className:"font-mono"},P)):null,o.length>0?d.createElement("div",{className:"flex flex-wrap gap-1 items-center"},d.createElement("span",{className:"uppercase tracking-wider text-muted-foreground"},"Serving"),o.map(D=>d.createElement($,{key:D,variant:"outline",className:"text-xs"},D))):null):x?d.createElement("div",{className:"rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground"},x):null,d.createElement("div",{className:"flex flex-wrap gap-2"},d.createElement(S,{size:"sm",disabled:n||!i||b,onClick:()=>t()},n==="enable"?"Enabling\u2026":`Enable HTTPS :${f.recommendedPort} ingress`),C&&!E?d.createElement(S,{size:"sm",variant:"outline",disabled:n||!i,onClick:()=>t(8642)},"Enable optional API :8642"):null,d.createElement(S,{size:"sm",variant:"outline",disabled:n||!i||!h&&!E&&!_,onClick:()=>r()},n==="disable"?"Disabling\u2026":"Disable managed routes"),_?d.createElement(S,{size:"sm",variant:"outline",disabled:n||!i,onClick:()=>r(8767)},"Disable legacy :8767 after re-pairing"):null,y?d.createElement(S,{size:"sm",variant:"outline",disabled:n||!i,onClick:()=>r(443)},"Disable old :443 listener after re-pairing"):null,k?d.createElement(S,{size:"sm",variant:"outline",disabled:n||!i,onClick:()=>r(9119)},"Disable old :9119 listener after re-pairing"):null),d.createElement("p",{className:"text-xs text-muted-foreground"},"New pairing invites use the classified tailnet listener above \u2014 normally HTTPS :",f.recommendedPort," \u2014 which proxies local Dashboard :9119 and its same-origin Relay path. Keep old :443, :9119, and :8767 listeners only until affected devices have re-paired."),s?d.createElement("div",{className:"text-xs text-muted-foreground whitespace-pre-wrap"},s):null))}function sl({status:e,onPair:t,onInvalidateInvite:r,pairingBusy:n}){let s=!!(e&&e.enabled),i=e&&e.url,o=Array.isArray(e&&e.surfaces)?e.surfaces:[],a=e&&e.reason;return d.createElement(Bt,null,d.createElement(Pt,null,d.createElement("div",{className:"flex flex-wrap items-center gap-2"},d.createElement(Lt,null,"Hermes Secure Link"),d.createElement($,{variant:"outline",className:"text-xs"},"Secure ingress")),d.createElement(X,null,"Pairing-pinned TLS for Hermes services. Secure Link authenticates the route but does not create internet reachability or perform NAT traversal; use LAN, Tailscale, a VPN, or a public route to reach this host.")),d.createElement(Dt,{className:"space-y-3"},d.createElement("div",{className:"flex flex-wrap items-center gap-2 text-sm"},d.createElement(Me,{tone:s?"ok":"muted"}),d.createElement("span",null,s?"Enabled":e?.state==="unavailable"?"Needs attention":e?.state==="unknown"?"Not checked":"Not enabled"),s?d.createElement($,{variant:"outline"},"Pinned TLS"):null),i?d.createElement("div",{className:"rounded-md border border-border bg-muted/20 px-3 py-2 text-xs space-y-1"},d.createElement("div",null,d.createElement("span",{className:"uppercase tracking-wider text-muted-foreground"},"Advertised route")," ",d.createElement("span",{className:"font-mono break-all"},i)),o.length>0?d.createElement("div",{className:"flex flex-wrap gap-1 items-center"},d.createElement("span",{className:"uppercase tracking-wider text-muted-foreground"},"Services"),o.map(u=>d.createElement($,{key:u,variant:"outline",className:"text-xs capitalize"},u))):null):a?d.createElement("div",{className:"rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground"},a):null,d.createElement("p",{className:"text-xs text-muted-foreground"},"When enabled, new pairing invites include Secure Link alongside other reachable candidates. Existing devices must re-pair to trust its certificate pin."),d.createElement(Yr,{status:e||{},onPair:t,onInvalidateInvite:r,pairingBusy:n})))}function il({status:e}){let t=e&&e.reach;return!t||!t.enabled&&t.state==="disabled"?null:d.createElement("details",{className:"rounded-lg border border-dashed border-border bg-muted/10 px-4 py-3"},d.createElement("summary",{className:"cursor-pointer list-none flex flex-wrap items-center gap-2 text-sm font-medium"},d.createElement("span",null,"Hermes Reach"),d.createElement($,{variant:"outline",className:"text-xs"},"Experimental"),d.createElement("span",{className:"ml-auto text-xs text-muted-foreground"},t.state||"disabled")),d.createElement("p",{className:"mt-3 text-xs text-muted-foreground"},"Advanced brokered fallback for evaluation. Reach is disabled by default, tried after supported routes, and not recommended instead of Tailscale."),t.last_error?d.createElement("pre",{className:"mt-2 whitespace-pre-wrap text-xs text-destructive"},t.last_error):null)}function ol({initialUrl:e,onSaved:t}){let[r,n]=he(e||""),[s,i]=he(!1),[o,a]=he(null),[u,c]=he(null),[f,m]=he({reachable:null,status:null,latency_ms:null,error:null,at:null}),[w,h]=he(!1),C=sr(()=>Mr(r),[r]),y=C.kind==="invalid"?"Enter a complete HTTP(S) URL without credentials, query parameters, or a fragment.":C.url&&!C.url.startsWith("https://")?"Public routes must use HTTPS.":null;Et(()=>{n(e||"")},[e]);let k=Ie(async()=>{a(null),i(!0);try{let _=r.trim();if(_&&y){a(y);return}let T=await Cn(_===""?null:_,{legacyDirectRelay:C.kind==="legacy-relay-path"});c(Date.now()),t&&t(T&&T.url?T.url:null)}catch(_){a(_&&_.message?_.message:String(_))}finally{i(!1)}},[C.kind,r,y,t]),E=Ie(async()=>{let _=r.trim();if(!(!_||y)){h(!0);try{let b=await pt([{role:"public",priority:0,surface:C.kind==="legacy-relay-path"?"relay":"dashboard",url:_}]),T=b&&Array.isArray(b.results)&&b.results[0]||{};m({reachable:T.reachable==null?null:!!T.reachable,status:T.status??null,latency_ms:T.latency_ms??null,error:T.error||null,at:Date.now()})}catch(b){m({reachable:!1,status:null,latency_ms:null,error:b&&b.message?b.message:String(b),at:Date.now()})}finally{h(!1)}}},[C.kind,r,y]);return d.createElement(Bt,null,d.createElement(Pt,null,d.createElement("div",{className:"flex flex-wrap items-center gap-2"},d.createElement(Lt,null,"Public Dashboard origin"),C.kind==="legacy-relay-path"?d.createElement($,{variant:"outline",className:"text-xs"},"Legacy Relay proxy path"):null),d.createElement(X,null,"HTTPS origin for the Dashboard, Gateway, and same-origin Hermes-Relay transport (for example a reverse proxy or Cloudflare Tunnel). This becomes the next pairing QR's ",d.createElement("code",{className:"font-mono"},"role=public")," route.")),d.createElement(Dt,{className:"space-y-3"},d.createElement("div",{className:"space-y-1"},d.createElement(Ti,{htmlFor:"public-url"},"Dashboard origin"),d.createElement(rl,{id:"public-url",value:r,placeholder:"https://hermes.example.com",onChange:_=>n(_.target.value)}),d.createElement("p",{className:"text-xs text-muted-foreground"},"Leave empty to clear. Use an HTTPS Dashboard origin for new setups. An explicit path ending in the Relay transport is recognized as legacy reverse-proxy compatibility, not as a Dashboard origin. Direct port",d.createElement("code",{className:"font-mono"}," :8767")," is not advertised for new pairing.")),y?d.createElement("div",{className:"rounded-md border border-destructive/50 bg-destructive/10 p-2 text-xs text-destructive"},y):null,o?d.createElement("div",{className:"rounded-md border border-destructive/50 bg-destructive/10 p-2 text-xs text-destructive"},o):null,d.createElement("div",{className:"flex flex-wrap items-center gap-2"},d.createElement(S,{size:"sm",onClick:k,disabled:s||!!y},s?"Saving\u2026":"Save"),d.createElement(S,{size:"sm",variant:"outline",onClick:E,disabled:w||!r.trim()||!!y},w?"Probing\u2026":"Probe route"),u?d.createElement("span",{className:"text-xs text-muted-foreground"},"Saved ",ye(u)):null),f.at!=null?d.createElement("div",{className:"flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs"},d.createElement(Me,{tone:ki(f.reachable)}),d.createElement("span",null,f.reachable===!0?"reachable":f.reachable===!1?"unreachable":"unknown"),f.status!=null?d.createElement($,{variant:"outline",className:"text-xs"},"HTTP ",f.status):null,f.latency_ms!=null?d.createElement("span",{className:"text-muted-foreground"},f.latency_ms,"ms"):null,d.createElement("span",{className:"text-muted-foreground ml-auto"},"checked ",ye(f.at)),f.error?d.createElement("div",{className:"basis-full text-destructive"},f.error):null):null))}function al({endpoints:e,reachability:t,onProbe:r,onRegenerate:n,busy:s,qrPayload:i,pairingUrl:o,preferRole:a,onPreferChange:u,blockingIssues:c}){let f=Xr(null),[m,w]=he("");Et(()=>{!i||!f.current||Si.default.toCanvas(f.current,i,Ct()).catch(()=>{})},[i]);let h=sr(()=>{let y=new Map;return(t||[]).forEach(k=>{y.set(Ve(k),k)}),y},[t]),C=Ie(async()=>{let y=o||i;if(y)try{await navigator.clipboard.writeText(y),w("Copied invite URL")}catch{w("Copy failed; select the URL manually")}},[o,i]);return d.createElement(Bt,null,d.createElement(Pt,null,d.createElement(Lt,null,"Endpoint preview"),d.createElement(X,null,"Candidates the next ",d.createElement("code",{className:"font-mono"},"mode=auto")," QR would embed. Lower priority = higher preference on the phone.")),d.createElement(Dt,{className:"space-y-3"},c&&c.length>0?d.createElement(se,{variant:"destructive"},d.createElement(ie,null,"Unsafe pairing route blocked"),d.createElement(oe,null,d.createElement("ul",{className:"list-disc pl-4 text-xs space-y-1"},c.map(y=>d.createElement("li",{key:y},y))))):null,e.length===0?d.createElement("div",{className:"text-sm text-muted-foreground"},"No candidates detected. Enable Tailscale and/or pin a public URL above."):d.createElement(at,null,d.createElement(lt,null,d.createElement(Be,null,d.createElement(ne,null,"Role"),d.createElement(ne,null,"Surface"),d.createElement(ne,null,"Resolved URL"),d.createElement(ne,null,"Priority"),d.createElement(ne,null,"Reachable"))),d.createElement(dt,null,e.map(y=>{let k=h.get(Ve(y)),E=er(k);return d.createElement(Be,{key:Ve(y)},d.createElement(R,null,d.createElement($,{variant:"outline",className:"text-xs capitalize"},y.role)),d.createElement(R,{className:"text-xs capitalize"},y.surface),d.createElement(R,{className:"font-mono text-xs"},y.url),d.createElement(R,{className:"text-xs"},y.priority??"\u2014"),d.createElement(R,null,d.createElement("div",{className:"flex items-center gap-1"},d.createElement(Me,{tone:ki(E.healthy)}),d.createElement("span",{className:"text-xs"},k?E.label:"\u2014"))))}))),d.createElement("div",{className:"flex flex-wrap gap-2"},d.createElement(S,{size:"sm",variant:"outline",onClick:r,disabled:s||e.length===0||c&&c.length>0},s==="probe"?"Probing\u2026":"Probe all"),d.createElement(S,{size:"sm",onClick:n,disabled:s},s==="mint"?"Regenerating\u2026":"Regenerate QR")),d.createElement("div",{className:"flex items-center gap-2 text-xs text-muted-foreground"},d.createElement(Ti,{htmlFor:"prefer-role",className:"whitespace-nowrap"},"Prefer role:"),d.createElement("select",{id:"prefer-role",className:"h-8 rounded-md border border-border bg-background px-2 text-xs",value:a||"",onChange:y=>u&&u(y.target.value||null)},d.createElement("option",{value:""},"(natural order \u2014 Tailscale \u2192 Public \u2192 LAN)"),d.createElement("option",{value:"lan"},"lan \u2192 priority 0"),d.createElement("option",{value:"tailscale"},"tailscale \u2192 priority 0"),d.createElement("option",{value:"public"},"public \u2192 priority 0")),d.createElement("span",{className:"whitespace-nowrap"},'Applies on the next "Regenerate QR".')),i&&(!c||c.length===0)?d.createElement("div",{className:"hr-qr-frame hr-pairing-qr flex flex-col items-center gap-2"},d.createElement("canvas",{ref:f,className:"block"}),d.createElement("p",{className:"text-xs text-muted-foreground"},"Scan from the Hermes-Relay Android app. Fresh payload, signed with the host's QR secret \u2014 embedding one-shot pairing code.")):null,o&&(!c||c.length===0)?d.createElement("div",{className:"rounded-md border border-border bg-muted/20 px-3 py-2 text-xs space-y-2"},d.createElement("div",{className:"uppercase tracking-wider text-muted-foreground"},"Copy/paste invite"),d.createElement("div",{className:"font-mono break-all"},o),d.createElement("div",{className:"flex items-center gap-2"},d.createElement(S,{size:"sm",variant:"outline",onClick:C},"Copy invite URL"),m?d.createElement("span",{className:"text-muted-foreground"},m):null)):null))}function Rr({autoRefresh:e}){let[t,r]=he(null),[n,s]=he(!0),[i,o]=he(null),[a,u]=he(null),[c,f]=he(null),[m,w]=he(null),[h,C]=he([]),[y,k]=he(null),[E,_]=he(null),b=Xr(0),T=Xr(null);Et(()=>{m?.qr_payload&&(T.current?.scrollIntoView({block:"start"}),T.current?.focus({preventScroll:!0}))},[m]);let P=Ie(()=>{b.current+=1,w(null),u(z=>z==="mint"?null:z)},[]),x=Ie(async()=>{o(null);try{let z=await Ht();r(z||{});let O=z&&z.public&&z.public.url||null;k(O)}catch(z){o(z&&z.message?z.message:String(z))}finally{s(!1)}},[]);Et(()=>{x()},[x]),Et(()=>{if(!e)return;let z=setInterval(x,15e3);return()=>clearInterval(z)},[e,x]);let U=Ie(async z=>{u("enable"),f(null);try{let O=await xn(z);f(`${O&&O.ok?"Enabled":"Failed"}: ${O&&O.message?O.message:"(no message)"}`),await x()}catch(O){f(`Error: ${O&&O.message?O.message:O}`)}finally{u(null)}},[x]),D=Ie(async z=>{u("disable"),f(null);try{let O=await wn(z);f(`${O&&O.ok?"Disabled":"Failed"}: ${O&&O.message?O.message:"(no message)"}`),await x()}catch(O){f(`Error: ${O&&O.message?O.message:O}`)}finally{u(null)}},[x]),V=Ie(async(z=null)=>{let O=++b.current;u("mint"),w(null);try{let G=typeof z=="string",we=G?`${z}/dashboard`:je(window.location);if(!we)throw new Error("This Dashboard does not have a valid HTTP(S) origin for pairing.");let Qe=await $t({mode:"auto",prefer:G?"plugin_proxy":E||void 0,dashboard_url:we,legacy_direct_relay:Mr(y||"").kind==="legacy-relay-path"});if(O!==b.current)return;if(G&&!JSON.parse(Qe.qr_payload).endpoints?.some(N=>N.proxy?.url===z))throw new Error("Secure Link changed. Check it again before pairing.");w(Qe||null)}catch(G){O===b.current&&w({error:G&&G.message?G.message:String(G)})}finally{O===b.current&&u(null)}},[E,y]),Q=sr(()=>{if(!m||!m.qr_payload)return{routes:[],blockingIssues:[]};try{let z=JSON.parse(m.qr_payload);return Zt(z)}catch{return{routes:[],blockingIssues:["Pairing payload is not valid JSON"]}}},[m]),ue=sr(()=>Q&&Q.routes?Rt(Q):[],[Q]),q=Ie(async()=>{if(ue.length!==0){u("probe");try{let z=await pt(ue);C(z&&z.results||[])}catch(z){C(ue.map(O=>({url:O.url,reachable:!1,status:null,latency_ms:null,error:z&&z.message?z.message:String(z)})))}finally{u(null)}}},[ue]);if(n)return d.createElement("div",{className:"text-sm text-muted-foreground"},"Loading remote access\u2026");if(i)return d.createElement(se,{variant:"destructive"},d.createElement(ie,null,"Dashboard backend unreachable"),d.createElement(oe,null,d.createElement("pre",{className:"whitespace-pre-wrap text-xs"},i),e?null:d.createElement(S,{className:"mt-2",size:"sm",variant:"outline",onClick:x},"Retry")));let ge=t&&t.tailscale||{},ee=t&&t.secure_link||{},ce=!!(t&&t.upstream_canonical);return d.createElement("div",{className:"space-y-4"},d.createElement(se,null,d.createElement(ie,null,"Recommended remote-access setup"),d.createElement(oe,null,"Start with ",d.createElement("strong",null,"Tailscale")," for private remote reachability. Operators with a domain or reachable WAN address can instead use a public TLS reverse proxy or ",d.createElement("strong",null,"Hermes Secure Link"),". Experimental broker routes are kept out of the normal setup path.")),ce?d.createElement(se,null,d.createElement(ie,null,"Upstream helper detected"),d.createElement(oe,null,"Upstream hermes-agent now ships ",d.createElement("code",{className:"font-mono"},"hermes gateway run --tailscale"),". The helper in this plugin is now redundant and will be removed in a future release.")):null,d.createElement(nl,{status:ge,onEnable:U,onDisable:D,busy:a==="enable"||a==="disable"?a:null,resultMessage:c}),d.createElement(sl,{status:ee,onPair:V,onInvalidateInvite:P,pairingBusy:a==="mint"}),d.createElement(il,{status:ee}),d.createElement(ol,{initialUrl:y,onSaved:z=>{k(z),x()}}),d.createElement("div",{ref:T,role:"region","aria-label":"Pairing invite",tabIndex:-1},d.createElement(al,{endpoints:ue,reachability:h,onProbe:q,onRegenerate:V,busy:a==="mint"||a==="probe"?a:null,qrPayload:m&&m.qr_payload?m.qr_payload:null,pairingUrl:m&&m.pairing_url?m.pairing_url:null,preferRole:E,onPreferChange:_,blockingIssues:Q&&Q.blockingIssues?Q.blockingIssues:[]})),m&&m.error?d.createElement(se,{variant:"destructive"},d.createElement(ie,null,"QR regeneration failed"),d.createElement(oe,null,d.createElement("pre",{className:"whitespace-pre-wrap text-xs"},m.error))):null,m&&m.code?d.createElement(Bt,null,d.createElement(Pt,null,d.createElement(Lt,null,"Pairing code"),d.createElement(X,null,"One-shot code \u2014 expires in 10 minutes (or sooner, per session TTL).")),d.createElement(Dt,{className:"flex items-center justify-between gap-2"},d.createElement("div",{className:"font-mono text-2xl tracking-widest"},m.code),m.expires_at?d.createElement($,{variant:"outline"},"expires ",ye(m.expires_at)):null)):null)}function ll(e){let t=e&&typeof e=="object"?e:{};return{counts:{staged:Number(t.counts&&t.counts.staged)||0,modified:Number(t.counts&&t.counts.modified)||0,untracked:Number(t.counts&&t.counts.untracked)||0},staged:Array.isArray(t.staged)?t.staged:[],modified:Array.isArray(t.modified)?t.modified:[],untracked:Array.isArray(t.untracked)?t.untracked:[],truncated:!!t.truncated}}function dl(e){return(Array.isArray(e)?e:e&&e.branches||[]).filter(r=>r&&typeof r.name=="string")}var en={discard:"discard",push:"push",dirtyCheckout:"checkout-dirty"},ul=new Set(["discard","push","dirty-checkout"]);function tn(e){return ul.has(e)}function _i(e){return tn(e)?e==="discard"?en.discard:e==="push"?en.push:en.dirtyCheckout:null}function cl(e){let t=e&&typeof e=="object"?e:{};return{head:typeof t.head=="string"?t.head:"",status:ll(t.status),branches:dl(t.branches)}}function Ai(e){let t=e&&typeof e=="object"?e:{};return{message:typeof t.message=="string"?t.message:"",notice:typeof t.notice=="string"?t.notice:""}}function Ei(e){return!!(e&&e.message&&e.message.trim())}function ve(e,t,r,n){return e===r&&t===n}function Bi(e,t){return e===!0&&t===!0}function Pi(e){let t=e&&typeof e=="object"?e:{};return{...cl(e),stashed:!!t.stashed,stashMessage:typeof t.stash_message=="string"?t.stash_message:""}}var sn=window.__HERMES_PLUGIN_SDK__,{React:v}=sn,{useState:pe,useEffect:Li,useCallback:Ee,useRef:rn}=sn.hooks,{Card:Di,CardHeader:Ii,CardTitle:Mi,CardContent:Hi,Label:pu}=sn.components;function nn({truncated:e}){return e?v.createElement("div",{className:"rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-600"},"Results truncated \u2014 showing the first entries only."):null}function ml({status:e}){if(!e)return null;let t=e.counts||{};return v.createElement("div",{className:"space-y-2"},v.createElement("div",{className:"flex flex-wrap gap-2"},v.createElement($,{variant:"outline",className:"text-xs"},t.staged||0," staged"),v.createElement($,{variant:"outline",className:"text-xs"},t.modified||0," modified"),v.createElement($,{variant:"outline",className:"text-xs"},t.untracked||0," untracked")),v.createElement(nn,{truncated:e.truncated}),["staged","modified","untracked"].map(r=>{let n=e[r]||[];return n.length===0?null:v.createElement("div",{key:r},v.createElement("div",{className:"text-xs font-medium uppercase tracking-wide text-muted-foreground"},r),v.createElement("ul",{className:"mt-1 space-y-0.5"},n.map(s=>v.createElement("li",{key:`${r}-${s.path}`,className:"font-mono text-xs"},s.path))))}))}function pl({branches:e}){return!e||e.length===0?null:v.createElement("div",{className:"space-y-1"},e.map(t=>v.createElement("div",{key:t.name,className:"flex items-center gap-2 text-xs"},v.createElement("span",{className:"font-mono"},t.name),t.is_current?v.createElement($,{className:"text-xs"},"current"):null,t.upstream?v.createElement("span",{className:"text-muted-foreground"},"\u2192 ",t.upstream,t.ahead>0||t.behind>0?` (ahead ${t.ahead}, behind ${t.behind})`:""):null)))}function fl({status:e,selected:t,commitMessage:r,onCommitMessageChange:n,generatingMessage:s,onGenerateMessage:i,commitNotice:o,newBranch:a,onNewBranchChange:u,branchRef:c,onBranchRefChange:f,mutating:m,pushAfterCommit:w,onPushAfterCommitChange:h,onStageAll:C,onUnstageAll:y,onDiscardAll:k,onStage:E,onUnstage:_,onDiscard:b,onCommit:T,onFetch:P,onPull:x,onPush:U,onCheckout:D,onStashCheckout:V}){let Q=(e&&e.staged||[]).map(q=>q.path),ue=(e&&e.modified||[]).map(q=>q.path);return v.createElement("div",{className:"space-y-3 rounded-md border p-3"},v.createElement("div",{className:"text-xs font-medium uppercase tracking-wide text-muted-foreground"},"Write controls"),v.createElement("div",{className:"flex flex-wrap gap-2"},v.createElement(S,{size:"sm",variant:"outline",disabled:m||ue.length===0,onClick:C},"Stage modified"),v.createElement(S,{size:"sm",variant:"outline",disabled:m||Q.length===0,onClick:y},"Unstage staged"),v.createElement(S,{size:"sm",variant:"outline",disabled:m||Q.length===0,onClick:k},"Discard staged")),v.createElement("div",{className:"space-y-2"},v.createElement("div",{className:"flex items-center gap-2"},v.createElement("input",{type:"text",value:r,onChange:q=>n(q.target.value),placeholder:"Commit message",className:"w-full rounded-md border px-2 py-1 text-xs"}),v.createElement(S,{size:"sm",variant:"outline",disabled:m||s||Q.length===0,onClick:i,title:"Generate a commit message from the staged diff"},s?"Generating\u2026":"Generate")),o?v.createElement("div",{className:"text-xs text-amber-600"},o):null,v.createElement("div",{className:"flex items-center gap-2"},v.createElement("label",{className:"flex items-center gap-2 text-xs"},v.createElement("input",{type:"checkbox",checked:w,onChange:q=>h(q.target.checked)}),"Push after commit"),v.createElement(S,{size:"sm",disabled:m||!r.trim(),onClick:T},"Commit"))),v.createElement("div",{className:"flex flex-wrap items-center gap-2"},v.createElement("input",{type:"text",value:c,onChange:q=>f(q.target.value),placeholder:"Branch to switch to",className:"w-40 rounded-md border px-2 py-1 text-xs"}),v.createElement("input",{type:"text",value:a,onChange:q=>u(q.target.value),placeholder:"New branch name",className:"w-40 rounded-md border px-2 py-1 text-xs"}),v.createElement(S,{size:"sm",variant:"outline",disabled:m||!c.trim(),onClick:D},"Checkout"),v.createElement(S,{size:"sm",variant:"outline",disabled:m||!c.trim(),onClick:V,title:"Switch branches, auto-stashing a dirty tree first"},"Stash-checkout")),v.createElement("div",{className:"flex flex-wrap gap-2"},v.createElement(S,{size:"sm",variant:"outline",disabled:m,onClick:P},"Fetch"),v.createElement(S,{size:"sm",variant:"outline",disabled:m,onClick:x},"Pull"),v.createElement(S,{size:"sm",variant:"destructive",disabled:m,onClick:U},"Push")))}function on({autoRefresh:e}){let[t,r]=pe(null),[n,s]=pe(null),[i,o]=pe(null),[a,u]=pe(null),[c,f]=pe(null),[m,w]=pe(null),[h,C]=pe(!0),[y,k]=pe(null),[E,_]=pe(null),b=rn(null),T=rn(0),P=rn(!1),x=Ee(async()=>{k(null);try{let g=await Nn(),H=g&&g.repos||[];r(H),_(g&&g.notice||null);let A=b.current;A&&!H.some(I=>I.id===A)&&(b.current=null,T.current+=1,s(null),o(null),u(null),f(null),w(null),N(!1),te(null))}catch(g){k(g&&g.message?g.message:String(g))}finally{C(!1)}},[]);Li(()=>{x()},[x]),Li(()=>{if(!e)return;let g=setInterval(x,15e3);return()=>clearInterval(g)},[e,x]);let U=Ee(async g=>{let H=T.current+1;T.current=H,b.current=g,s(g),o(null),u(null),f(null),w(null),N(!1),te(null),k(null);try{let[A,I]=await Promise.all([dr(g),ur(g)]);if(!ve(b.current,T.current,g,H))return;o(A),u(I&&I.branches)}catch(A){if(!ve(b.current,T.current,g,H))return;k(A&&A.message?A.message:String(A))}},[]),D=Ee(async(g,H)=>{let A=b.current;if(!A)return;let I=T.current;w(null),k(null);try{let le=await Sn(A,g,H);if(!ve(b.current,T.current,A,I))return;f(le)}catch(le){if(!ve(b.current,T.current,A,I))return;k(le&&le.message?le.message:String(le))}},[]),V=Ee(async g=>{let H=b.current;if(!H)return;let A=T.current;f(null),k(null);try{let I=await Tn(H,g);if(!ve(b.current,T.current,H,A))return;w(I)}catch(I){if(!ve(b.current,T.current,H,A))return;k(I&&I.message?I.message:String(I))}},[]),[Q,ue]=pe(""),[q,ge]=pe(""),[ee,ce]=pe(""),[z,O]=pe(!1),[G,we]=pe(null),[Qe,N]=pe(!1),[M,te]=pe(null),[ae,Ye]=pe(!1),Xe=Ee(async(g,H)=>{let[A,I]=await Promise.all([dr(g),ur(g)]);ve(b.current,T.current,g,H)&&(o(A),u(I&&I.branches))},[]),Pe=Ee(async(g,H,A)=>{let I=b.current,le=T.current;if(!I||P.current)return!1;P.current=!0,we(null),O(!0);try{if(g==="stage")await kn(I,H);else if(g==="unstage")await _n(I,H);else if(g==="fetch")await Bn(I,A?.remote||"origin");else if(g==="pull")await Pn(I,A?.remote||"origin",A?.branch||"");else if(g==="commit")await En(I,A?.message);else if(g==="discard")await An(I,H,A?.confirmation,A?.deleteUntracked);else if(g==="push")await Ln(I,A?.confirmation,A?.remote||"origin",A?.branch||"");else if(g==="checkout"||g==="dirty-checkout")await Dn(I,A.ref,{confirmation:A.confirmation,newBranch:A.newBranch,track:A.track});else throw new Error(`Unknown Git operation: ${g}`);return ve(b.current,T.current,I,le)?(await Xe(I,le),!0):!1}catch(Ze){return ve(b.current,T.current,I,le)&&we(Ze&&Ze.message?Ze.message:String(Ze)),!1}finally{P.current=!1,O(!1)}},[Xe]),Wi=Ee(async()=>{let g=b.current;if(!g)return;let H=T.current;N(!0),te(null);try{let A=(i&&i.staged||[]).map(Ze=>Ze.path),I=A.length>0&&!i?.truncated?await Mn(g,A):await In(g);if(!ve(b.current,T.current,g,H))return;let le=Ai(I);Ei(le)?(ue(le.message),te(le.notice||null)):te(le.notice||"Nothing staged to generate a message from.")}catch(A){if(!ve(b.current,T.current,g,H))return;te(A&&A.message?A.message:String(A))}finally{ve(b.current,T.current,g,H)&&N(!1)}},[i]),Qi=Ee(async()=>{let g=ee.trim(),H=b.current,A=T.current;if(!(!g||!H||P.current)){P.current=!0,we(null),te(null),O(!0);try{let I=await Hn(H,g,{newBranch:q.trim(),track:!1});if(!ve(b.current,T.current,H,A))return;let le=Pi(I);le.stashed&&te(`Stashed changes on ${g} as \u201C${le.stashMessage}\u201D. Use \u201Cgit stash pop\u201D to restore them.`),await Xe(H,A)}catch(I){ve(b.current,T.current,H,A)&&we(I&&I.message?I.message:String(I))}finally{P.current=!1,O(!1),ce(""),ge("")}}},[ee,q,Xe]),be=Ee((g,H)=>{if(tn(g)){let A=g==="discard"?"Discard local changes? This cannot be undone.":g==="push"?"Push local commits to the remote repository?":"Working tree has uncommitted changes. Switch branches anyway?";if(!window.confirm(A))return;let I=_i(g);g==="discard"?Pe("discard",H?.paths,{confirmation:I,deleteUntracked:H?.deleteUntracked}):g==="push"?Pe("push",[],{confirmation:I,remote:H?.remote,branch:H?.branch}):g==="dirty-checkout"&&Pe("dirty-checkout",[],{...H,confirmation:I});return}Pe(g,H?.paths,H)},[Pe]),Yi=Ee(async()=>{let g=Q.trim();if(!g){we("Commit message must not be empty.");return}let H=await Pe("commit",[],{message:g});H&&(ue(""),Bi(H,ae)&&be("push",{}))},[Q,i,Pe,ae,be]),Xi=Ee(async()=>{let g=ee.trim();if(!g)return;let H=i&&i.counts.modified+i.counts.staged+i.counts.untracked>0,A={ref:g,newBranch:q.trim(),track:!1};if(H&&!A.newBranch){be("dirty-checkout",A),ce(""),ge("");return}await Pe("checkout",[],A),ce(""),ge("")},[ee,q,i,Pe,be]);if(h&&t===null)return v.createElement("div",{className:"text-sm text-muted-foreground"},"Loading repositories\u2026");if(y)return v.createElement(se,{variant:"destructive"},v.createElement(ie,null,"Git state unavailable"),v.createElement(oe,null,v.createElement("pre",{className:"whitespace-pre-wrap text-xs"},y),v.createElement(S,{className:"mt-2",size:"sm",variant:"outline",onClick:x},"Retry")));let pn=t||[];return v.createElement("div",{className:"space-y-4"},v.createElement(Di,null,v.createElement(Ii,null,v.createElement(Mi,null,"Repositories"),v.createElement(X,null,"Repositories scanned from the configured Git base path.")),v.createElement(Hi,null,E?v.createElement("div",{className:"mb-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-600"},E):null,pn.length===0?v.createElement("div",{className:"text-sm text-muted-foreground"},"No repositories found."):v.createElement("div",{className:"flex flex-wrap gap-2"},pn.map(g=>v.createElement(S,{key:g.id,size:"sm",variant:n===g.id?"default":"outline",onClick:()=>U(g.id)},g.name,g.dirty?" \u2022":""))))),n?v.createElement(Di,null,v.createElement(Ii,null,v.createElement(Mi,null,n),v.createElement(X,null,"Working tree and branches. Tap a changed file to view its diff or content.")),v.createElement(Hi,{className:"space-y-4"},G?v.createElement(se,{variant:"destructive"},v.createElement(ie,null,"Git mutation failed"),v.createElement(oe,null,v.createElement("pre",{className:"whitespace-pre-wrap text-xs"},G))):null,v.createElement(ml,{status:i}),v.createElement(pl,{branches:a}),v.createElement(fl,{status:i,branches:a,selected:n,commitMessage:Q,onCommitMessageChange:ue,generatingMessage:Qe,onGenerateMessage:Wi,commitNotice:M,newBranch:q,onNewBranchChange:ge,branchRef:ee,onBranchRefChange:ce,mutating:z,pushAfterCommit:ae,onPushAfterCommitChange:Ye,onStageAll:()=>be("stage",{paths:(i&&i.modified||[]).map(g=>g.path)}),onUnstageAll:()=>be("unstage",{paths:(i&&i.staged||[]).map(g=>g.path)}),onDiscardAll:()=>be("discard",{paths:(i&&i.staged||[]).map(g=>g.path),deleteUntracked:!1}),onStage:g=>be("stage",{paths:[g]}),onUnstage:g=>be("unstage",{paths:[g]}),onDiscard:g=>be("discard",{paths:[g],deleteUntracked:!1}),onCommit:Yi,onFetch:()=>be("fetch",{}),onPull:()=>be("pull",{}),onPush:()=>be("push",{}),onCheckout:Xi,onStashCheckout:Qi}),i&&(i.staged||[]).length>0?v.createElement("div",null,v.createElement("div",{className:"text-xs font-medium uppercase tracking-wide text-muted-foreground"},"Staged diffs"),v.createElement("ul",{className:"mt-1 space-y-0.5"},(i.staged||[]).map(g=>v.createElement("li",{key:`sd-${g.path}`},v.createElement("button",{type:"button",className:"font-mono text-xs text-primary underline",onClick:()=>D(g.path,"staged")},g.path))))):null,i&&(i.modified||[]).length>0?v.createElement("div",null,v.createElement("div",{className:"text-xs font-medium uppercase tracking-wide text-muted-foreground"},"Unstaged diffs"),v.createElement("ul",{className:"mt-1 space-y-0.5"},(i.modified||[]).map(g=>v.createElement("li",{key:`ud-${g.path}`},v.createElement("button",{type:"button",className:"font-mono text-xs text-primary underline",onClick:()=>D(g.path,"unstaged")},g.path))))):null,i&&(i.untracked||[]).length>0?v.createElement("div",null,v.createElement("div",{className:"text-xs font-medium uppercase tracking-wide text-muted-foreground"},"Untracked files"),v.createElement("ul",{className:"mt-1 space-y-0.5"},(i.untracked||[]).map(g=>v.createElement("li",{key:`uf-${g.path}`},v.createElement("button",{type:"button",className:"font-mono text-xs text-primary underline",onClick:()=>V(g.path)},g.path))))):null,c?v.createElement("div",null,v.createElement("div",{className:"flex items-center justify-between"},v.createElement("div",{className:"text-xs font-medium uppercase tracking-wide text-muted-foreground"},"Diff \u2014 ",c.path," (",c.kind,")"),v.createElement(nn,{truncated:c.truncated})),v.createElement("pre",{className:"mt-1 max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-2 font-mono text-xs"},c.diff||"(no changes)")):null,m?v.createElement("div",null,v.createElement("div",{className:"flex items-center justify-between"},v.createElement("div",{className:"text-xs font-medium uppercase tracking-wide text-muted-foreground"},"File \u2014 ",m.path),v.createElement(nn,{truncated:m.truncated})),v.createElement("pre",{className:"mt-1 max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-2 font-mono text-xs"},m.content)):null)):null)}var an=window.__HERMES_PLUGIN_SDK__,{React:hl}=an,{useState:gl,useEffect:vl,useCallback:bl,useRef:yl}=an.hooks,{Badge:xl}=an.components,wl=15e3,$i={loading:{label:"Hermes-Relay \xB7 \u2026",tone:"secondary"},offline:{label:"Hermes-Relay \xB7 offline",tone:"warning"},unpaired:{label:"Hermes-Relay \xB7 unpaired",tone:"secondary"},connected:{label:"Hermes-Relay \xB7 connected",tone:"success"}};function Cl(e){if(!e||typeof e!="object")return"offline";let t=e.paired_device_count??e.session_count??0;return Number(t)>0?"connected":"unpaired"}function ln(){let[e,t]=gl("loading"),r=yl(!0),n=bl(async()=>{try{let o=await It();r.current&&t(Cl(o))}catch{r.current&&t("offline")}},[]);vl(()=>{r.current=!0,n();let o=setInterval(n,wl);return()=>{r.current=!1,clearInterval(o)}},[n]);let{label:s,tone:i}=$i[e]||$i.offline;return hl.createElement(xl,{tone:i,className:"whitespace-nowrap text-xs",title:"Hermes-Relay status"},s)}var Gi=ir(Yt());var Nl=["a[href]","button:not([disabled])","input:not([disabled])","select:not([disabled])","textarea:not([disabled])",'[contenteditable="true"]','[tabindex]:not([tabindex="-1"])'].join(",");function Sl(e){return!e.hidden&&e.getAttribute("aria-hidden")!=="true"}function Ui(e){return Array.from(e.querySelectorAll(Nl)).filter(Sl)}function Tl(e,t,r){if(e.key!=="Tab")return;let n=Ui(t);if(n.length===0){e.preventDefault(),t.focus();return}let s=r.activeElement,i=n.indexOf(s),o=e.shiftKey&&i<=0,a=!e.shiftKey&&(i===-1||i===n.length-1);(o||a)&&(e.preventDefault(),(o?n[n.length-1]:n[0]).focus())}function kl(e,t){let r=[],n=e;for(;n&&n.parentElement&&n.parentElement!==t.documentElement;){let s=n.parentElement;for(let i of s.children)i!==n&&(r.push({element:i,inert:i.inert,inertAttribute:i.getAttribute("inert"),ariaHidden:i.getAttribute("aria-hidden")}),i.inert=!0,i.setAttribute("inert",""),i.setAttribute("aria-hidden","true"));n=s}return()=>{for(let s of r.reverse()){let{element:i}=s;s.inertAttribute===null?i.removeAttribute("inert"):i.setAttribute("inert",s.inertAttribute),i.inert=s.inert,s.ariaHidden===null?i.removeAttribute("aria-hidden"):i.setAttribute("aria-hidden",s.ariaHidden)}}}function zi({dialog:e,documentRef:t,onClose:r}){let n=t.activeElement,s=kl(e,t),i=e.querySelector("[data-modal-initial-focus]")||Ui(e)[0]||e,o=a=>{if(a.key==="Escape"){a.preventDefault(),a.stopPropagation(),r();return}Tl(a,e,t)};return t.addEventListener("keydown",o,!0),i.focus(),()=>{t.removeEventListener("keydown",o,!0),s(),n&&n.isConnected!==!1&&typeof n.focus=="function"&&n.focus()}}var qi=window.__HERMES_PLUGIN_SDK__,{React:J}=qi,{useState:_l,useEffect:dn,useMemo:Oi,useRef:Fi,useCallback:Al}=qi.hooks;function un({open:e,onClose:t}){let r=Fi(null),n=Fi(null),[s,i]=_l(""),o=Oi(()=>je(window.location),[e]),a=Oi(()=>o?Os(o):null,[o]);dn(()=>{!e||!a||!n.current||Gi.default.toCanvas(n.current,a,{width:280,margin:2,errorCorrectionLevel:"M"}).catch(()=>{})},[e,a]),dn(()=>{e&&i("")},[e]),dn(()=>{if(!(!e||!r.current))return zi({dialog:r.current,documentRef:window.document,onClose:t})},[e,t]);let u=Al(async()=>{if(o)try{await navigator.clipboard.writeText(o),i("Copied dashboard address")}catch{i("Copy failed; select the address below")}},[o]);return e?J.createElement("div",{className:"hermes-relay-plugin hr-modal-backdrop"},J.createElement("div",{ref:r,className:"hr-modal-card",role:"dialog","aria-modal":"true","aria-labelledby":"hr-mobile-connect-title","aria-describedby":"hr-mobile-connect-description",tabIndex:-1},J.createElement("div",{className:"hr-modal-header"},J.createElement("div",null,J.createElement("h2",{id:"hr-mobile-connect-title",className:"hr-modal-title"},"Connect mobile app"),J.createElement("p",{id:"hr-mobile-connect-description",className:"text-sm text-muted-foreground mt-1"},"Scan with Hermes-Relay Android to add this Dashboard.")),J.createElement(S,{variant:"ghost",size:"sm",className:"hr-modal-close",onClick:t,"data-modal-initial-focus":!0},"Close")),J.createElement("div",{className:"hr-modal-body space-y-3"},a?J.createElement(J.Fragment,null,J.createElement("div",{className:"hr-qr-frame hr-mobile-setup-qr"},J.createElement("canvas",{ref:n,className:"block","aria-label":"Dashboard setup QR code"})),J.createElement("div",{className:"rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2"},J.createElement("div",{className:"flex items-center justify-between gap-2"},J.createElement("span",{className:"text-xs text-muted-foreground"},"Dashboard address"),J.createElement($,{variant:"outline"},"No credentials included")),J.createElement("div",{className:"font-mono text-xs break-all hr-selectable"},o),J.createElement("div",{className:"flex flex-wrap items-center gap-2"},J.createElement(S,{size:"sm",variant:"outline",onClick:u},"Copy address"),s?J.createElement("span",{className:"text-xs text-muted-foreground",role:"status"},s):null)),J.createElement("div",{className:"rounded-md border border-border bg-muted/20 p-3 text-sm space-y-2"},J.createElement("div",{className:"font-medium"},"Standard Hermes connection"),J.createElement("p",{className:"text-xs text-muted-foreground"},"The app will verify this Dashboard, then use its advertised sign-in flow for Chat, Manage, sessions, and standard voice."),J.createElement("p",{className:"text-xs text-muted-foreground"},"This QR does not pair Hermes-Relay or enable Terminal, Bridge, device tools, or Hermes-Relay sessions. Use ",J.createElement("strong",null,"Pair new device")," separately when a reachable Hermes-Relay service is available.")),J.createElement("p",{className:"text-xs text-muted-foreground"},"The Dashboard address must be reachable from the phone. Sign-in still happens in the app; this QR contains no token, cookie, API key, or pairing code."),J.createElement("div",{className:"flex justify-end pt-1"},J.createElement(S,{size:"sm",onClick:t},"Done"))):J.createElement("div",{className:"rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"},"This page does not have a valid HTTP(S) Dashboard origin. Enter the Dashboard address manually in the mobile app.")))):null}var mn=window.__HERMES_PLUGIN_SDK__,{React:de}=mn,{useState:Ki,useEffect:El,useCallback:cn}=mn.hooks,{Label:Bl,Tabs:Pl,TabsList:Ll,TabsTrigger:Dl}=mn.components,Ji="hermes-relay-autorefresh",Il=[{key:"overview",label:"Overview"},{key:"devices",label:"Devices"},{key:"activity",label:"Activity"},{key:"remote",label:"Remote Access"},{key:"git",label:"Git"},{key:"settings",label:"Settings"}];function ji(){try{let e=window.localStorage.getItem(Ji);return e===null?!0:e==="true"}catch{return!0}}function Vi(e){try{window.localStorage.setItem(Ji,e?"true":"false")}catch{}}function Ml(){let[e,t]=Ki(!1),[r,n]=Ki(ji),s=cn(a=>{let u=typeof a=="function"?a(ji()):!!a;n(u),Vi(u)},[]);El(()=>{Vi(r)},[r]);let i=cn(()=>t(!0),[]),o=cn(()=>t(!1),[]);return de.createElement("div",{className:"hermes-relay-plugin p-4"},de.createElement(Pl,{defaultValue:"overview",className:"hr-plugin-tabs"},(a,u)=>de.createElement(de.Fragment,null,de.createElement("div",{className:"hr-plugin-toolbar"},de.createElement(Ll,{className:"hr-plugin-tablist"},Il.map(c=>de.createElement(Dl,{key:c.key,active:a===c.key,value:c.key,onClick:()=>u(c.key)},c.label))),de.createElement("div",{className:"hr-live-control"},de.createElement(ot,{id:"auto-refresh",checked:r,onCheckedChange:s}),de.createElement(Bl,{htmlFor:"auto-refresh"},"Live"))),de.createElement("div",{className:"hr-tab-content"},a==="overview"&&de.createElement(bi,{autoRefresh:r,onConnectMobile:i,onNavigate:u}),a==="devices"&&de.createElement(qr,{autoRefresh:r,onConnectMobile:i}),a==="activity"&&de.createElement(Wr,{autoRefresh:r}),a==="remote"&&de.createElement(Rr,{autoRefresh:r}),a==="git"&&de.createElement(on,{autoRefresh:r}),a==="settings"&&de.createElement(yi,{autoRefresh:r})))),de.createElement(un,{open:e,onClose:o}))}if(typeof window<"u"){let e=window.__HERMES_PLUGINS__;e&&typeof e.register=="function"?(e.register("hermes-relay",Ml),typeof e.registerSlot=="function"&&e.registerSlot("hermes-relay","header-right",ln)):console.error("[hermes-relay] window.__HERMES_PLUGINS__.register unavailable \u2014 dashboard shell did not initialize.")}})(); +`);let o=ia(t,r,s),a=Qt.getSymbolSize(t),d=new Wo(a);return ea(d,t),ta(d),ra(d,t),Lr(d,r,0),t>=7&&na(d,t),sa(d,o),isNaN(n)&&(n=Dr.getBestMask(d,Lr.bind(null,d,r))),Dr.applyMask(n,d),Lr(d,r,n),{modules:d,version:t,errorCorrectionLevel:r,maskPattern:n,segments:s}}qs.create=function(t,r){if(typeof t>"u"||t==="")throw new Error("No input text");let n=Br.M,s,i;return typeof r<"u"&&(n=Br.from(r.errorCorrectionLevel,Br.M),s=Wt.from(r.version),i=Dr.from(r.maskPattern),r.toSJISFunc&&Qt.setToSJISFunction(r.toSJISFunc)),aa(t,s,n,i)}});var Mr=Q(Ve=>{function Gs(e){if(typeof e=="number"&&(e=e.toString()),typeof e!="string")throw new Error("Color should be defined as hex string");let t=e.slice().replace("#","").split("");if(t.length<3||t.length===5||t.length>8)throw new Error("Invalid hex color: "+e);(t.length===3||t.length===4)&&(t=Array.prototype.concat.apply([],t.map(function(n){return[n,n]}))),t.length===6&&t.push("F","F");let r=parseInt(t.join(""),16);return{r:r>>24&255,g:r>>16&255,b:r>>8&255,a:r&255,hex:"#"+t.slice(0,6).join("")}}Ve.getOptions=function(t){t||(t={}),t.color||(t.color={});let r=typeof t.margin>"u"||t.margin===null||t.margin<0?4:t.margin,n=t.width&&t.width>=21?t.width:void 0,s=t.scale||4;return{width:n,scale:n?4:s,margin:r,color:{dark:Gs(t.color.dark||"#000000ff"),light:Gs(t.color.light||"#ffffffff")},type:t.type,rendererOpts:t.rendererOpts||{}}};Ve.getScale=function(t,r){return r.width&&r.width>=t+r.margin*2?r.width/(t+r.margin*2):r.scale};Ve.getImageWidth=function(t,r){let n=Ve.getScale(t,r);return Math.floor((t+r.margin*2)*n)};Ve.qrToImageData=function(t,r,n){let s=r.modules.size,i=r.modules.data,o=Ve.getScale(s,n),a=Math.floor((s+n.margin*2)*o),d=n.margin*o,m=[n.color.light,n.color.dark];for(let f=0;f=d&&c>=d&&f{var Hr=Mr();function la(e,t,r){e.clearRect(0,0,t.width,t.height),t.style||(t.style={}),t.height=r,t.width=r,t.style.height=r+"px",t.style.width=r+"px"}function da(){try{return document.createElement("canvas")}catch{throw new Error("You need to specify a canvas element")}}Yt.render=function(t,r,n){let s=n,i=r;typeof s>"u"&&(!r||!r.getContext)&&(s=r,r=void 0),r||(i=da()),s=Hr.getOptions(s);let o=Hr.getImageWidth(t.modules.size,s),a=i.getContext("2d"),d=a.createImageData(o,o);return Hr.qrToImageData(d.data,t,s),la(a,i,o),a.putImageData(d,0,0),i};Yt.renderToDataURL=function(t,r,n){let s=n;typeof s>"u"&&(!r||!r.getContext)&&(s=r,r=void 0),s||(s={});let i=Yt.render(t,r,s),o=s.type||"image/png",a=s.rendererOpts||{};return i.toDataURL(o,a.quality)}});var Ws=Q(Js=>{var ua=Mr();function Vs(e,t){let r=e.a/255,n=t+'="'+e.hex+'"';return r<1?n+" "+t+'-opacity="'+r.toFixed(2).slice(1)+'"':n}function $r(e,t,r){let n=e+t;return typeof r<"u"&&(n+=" "+r),n}function ca(e,t,r){let n="",s=0,i=!1,o=0;for(let a=0;a0&&d>0&&e[a-1]||(n+=i?$r("M",d+r,.5+m+r):$r("m",s,0),s=0,i=!1),d+1':"",m="',f='viewBox="0 0 '+a+" "+a+'"',y=''+d+m+` +`;return typeof n=="function"&&n(null,y),y}});var Xt=Q(Ct=>{var ma=Zn(),Ur=js(),Qs=Ks(),pa=Ws();function zr(e,t,r,n,s){let i=[].slice.call(arguments,1),o=i.length,a=typeof i[o-1]=="function";if(!a&&!ma())throw new Error("Callback required as last argument");if(a){if(o<2)throw new Error("Too few arguments provided");o===2?(s=r,r=t,t=n=void 0):o===3&&(t.getContext&&typeof s>"u"?(s=n,n=void 0):(s=n,n=r,r=t,t=void 0))}else{if(o<1)throw new Error("Too few arguments provided");return o===1?(r=t,t=n=void 0):o===2&&!t.getContext&&(n=r,r=t,t=void 0),new Promise(function(d,m){try{let f=Ur.create(r,n);d(e(f,t,n))}catch(f){m(f)}})}try{let d=Ur.create(r,n);s(null,e(d,t,n))}catch(d){s(d)}}Ct.create=Ur.create;Ct.toCanvas=zr.bind(null,Qs.render);Ct.toDataURL=zr.bind(null,Qs.renderToDataURL);Ct.toString=zr.bind(null,function(e,t,r){return pa.render(e,r)})});var tt=typeof window<"u"?window.__HERMES_PLUGIN_SDK__:null,go="/api/plugins/hermes-relay";function R(e,t){return!tt||typeof tt.fetchJSON!="function"?Promise.reject(new Error("Hermes plugin SDK unavailable")):tt.fetchJSON(`${go}${e}`,t)}function vo(e,t){return!tt||typeof tt.fetchJSON!="function"?Promise.reject(new Error("Hermes plugin SDK unavailable")):tt.fetchJSON(e,t)}function Mt(){return R("/overview")}function cr(){return R("/sessions")}function Ht(e=100){let t=Number.isFinite(e)?`?limit=${e}`:"";return R(`/bridge-activity${t}`)}function wn({includeExpired:e=!1}={}){return R(`/media${e?"?include_expired=true":""}`)}function Cn(){return R("/push")}function Nn(e){return R("/push/service-account",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e||{})})}function Sn(){return R("/push/service-account",{method:"DELETE"})}function kn(e){return R("/push/enabled",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!e})})}function Tn(){return R("/agent-context")}function _n(){return R("/phone/config")}function mr({refresh:e=!1}={}){return R(`/update-check${e?"?refresh=true":""}`)}function pr(e,t){return vo("/api/env",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({key:e,value:String(t)})})}function An(e){return R(`/sessions/${encodeURIComponent(e)}`,{method:"DELETE"})}function $t(){return R("/remote-access/status")}function En({host:e,port:t}={}){let r=new URLSearchParams;return e!==void 0&&r.set("host",e),t!==void 0&&r.set("port",t),R(`/remote-access/secure-link/preflight?${r}`)}function Bn(e){return R("/remote-access/tailscale/enable",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e!==void 0?{port:e}:{stack:!0})})}function Pn(e){return R("/remote-access/tailscale/disable",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e!==void 0?{port:e}:{stack:!0})})}function Ln(e,{legacyDirectRelay:t=!1}={}){return R("/remote-access/public-url",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:e==null?null:String(e),legacy_direct_relay:!!t})})}async function ft(e){let t=Array.isArray(e)?e:[],r=t.filter(i=>i.requires_paired_client).map(i=>({...i,reachable:null,status:null,latency_ms:null,error:null})),n=t.filter(i=>!i.requires_paired_client);if(!n.length)return{results:r};let s=await R("/remote-access/probe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({candidates:n})});return{...s,results:[...s.results||[],...r]}}function Ut({mode:e,publicUrl:t,prefer:r,...n}={}){let s={...n};return e&&(s.mode=e),t!==void 0&&(s.public_url=t),r&&(s.prefer=r),R("/pairing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)})}function Dn(){return R("/git/repos")}function fr(e){return R(`/git/status?repo=${encodeURIComponent(e)}`)}function hr(e){return R(`/git/branches?repo=${encodeURIComponent(e)}`)}function In(e,t,r="unstaged"){return R(`/git/diff?repo=${encodeURIComponent(e)}&path=${encodeURIComponent(t)}&kind=${encodeURIComponent(r)}`)}function Mn(e,t){return R(`/git/file?repo=${encodeURIComponent(e)}&path=${encodeURIComponent(t)}`)}function Se(e,t){return R(e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})}function Hn(e,t){return Se("/git/stage",{repo:e,paths:t})}function $n(e,t){return Se("/git/unstage",{repo:e,paths:t})}function Un(e,t,r,n=!1){return Se("/git/discard",{repo:e,paths:t,confirmation:r,delete_untracked:n})}function zn(e,t){return Se("/git/commit",{repo:e,message:t})}function Fn(e,t="origin"){return Se("/git/fetch",{repo:e,remote:t})}function On(e,t="origin",r=""){return Se("/git/pull",{repo:e,remote:t,branch:r})}function qn(e,t,r="origin",n=""){return Se("/git/push",{repo:e,remote:r,branch:n,confirmation:t})}function jn(e,t,r={}){let n={repo:e,ref:t,confirmation:r.confirmation,new_branch:r.newBranch||"",track:!!r.track};return Se("/git/checkout",n)}function Gn(e){return Se("/git/commit_message",{repo:e})}function Kn(e,t){return Se("/git/commit_message_selected",{repo:e,paths:t})}function Vn(e,t,r={}){let n={repo:e,ref:t,new_branch:r.newBranch||"",track:!!r.track};return Se("/git/stash_checkout",n)}function xe(e){if(e==null||e==="")return"\u2014";let t;if(typeof e=="number")t=e<1e12?e*1e3:e;else{let m=Date.parse(e);if(Number.isNaN(m))return"\u2014";t=m}let r=Date.now()-t;if(r<0)return"just now";let n=Math.floor(r/1e3);if(n<5)return"just now";if(n<60)return`${n}s ago`;let s=Math.floor(n/60);if(s<60)return`${s}m ago`;let i=Math.floor(s/60);if(i<24)return`${i}h ago`;let o=Math.floor(i/24);if(o<30)return`${o}d ago`;let a=Math.floor(o/30);return a<12?`${a}mo ago`:`${Math.floor(o/365)}y ago`}function Jn(e){if(e==null||Number.isNaN(e))return"\u2014";let t=Number(e);if(t<1024)return`${t} B`;let r=["KB","MB","GB","TB"],n=t/1024,s=0;for(;n>=1024&&s65535)return null;let r=String(e.host).includes(":")&&!String(e.host).startsWith("[")?`[${e.host}]`:String(e.host);return St(`${e.tls?"https":"http"}://${r}:${t}`,Zt)}function fa(e,t){let r=t.filter(n=>n.url.startsWith("https://")||n.url.startsWith("wss://")).length;return String(e).toLowerCase()==="tailscale"&&r===0?"Tailnet encrypted \xB7 no application TLS":r===t.length&&r>0?"Application TLS":r>0?"Mixed application TLS":"No application TLS"}function ha(e,t,r,n){let s=String(e&&e.role?e.role:"default"),i=Number.isInteger(e&&e.priority)?e.priority:t,o=n<=1,a=[],d=e?.proxy,m=d?St(d.url,new Set(["https:"])):null,f=typeof d?.pin_sha256=="string"&&/^sha256\/[A-Za-z0-9+/]{43}=$/.test(d.pin_sha256),c=m&&/^https:\/\/[^/?#]+\/?$/i.test(d.url.trim())&&f&&typeof d.cert_der=="string"&&d.cert_der.length>0;d&&!c&&a.push(`${s}: Secure Link needs a safe HTTPS origin, paired pin, and certificate`);let y=new Set(c&&Array.isArray(d.surfaces)?d.surfaces:[]),h=y.has("dashboard")?`${m}/dashboard`:null,w=y.has("relay")?`${m.replace(/^https:/,"wss:")}/relay/ws`:null,x=y.has("api")?`${m}/api`:null,T=e&&e.dashboard&&e.dashboard.url?e.dashboard.url:h||(o?r.dashboard_url:null),A=e&&e.relay&&e.relay.url?e.relay.url:w||(o&&r.relay?r.relay.url:null),N=St(T,Zt),b=St(A,ei),_=e?.api?Rs(e.api):x||Rs(o?r:null),E=[N?{surface:"dashboard",label:"Dashboard",url:N}:null,b?{surface:"relay",label:"Relay",url:b}:null,_?{surface:"api",label:"Direct API",url:_}:null].filter(Boolean).map(z=>({...z,...c&&[h,w,x].includes(z.url)?{requiresPairedClient:!0}:{}}));T&&!N&&a.push(`${s}: invalid Dashboard URL`),A&&!b&&a.push(`${s}: invalid Relay URL`),e&&e.api&&!_&&a.push(`${s}: invalid API endpoint`);let C=s.toLowerCase();return C==="public"?(N||a.push("public: missing Dashboard origin"),b||a.push("public: missing Relay route"),N&&!N.startsWith("https://")&&a.push("public: Dashboard must use HTTPS"),b&&!b.startsWith("wss://")&&a.push("public: Relay must use WSS"),_&&!_.startsWith("https://")&&a.push("public: Direct API must use HTTPS")):C==="public_legacy"&&(b||a.push("public_legacy: missing Relay route"),b&&!b.startsWith("wss://")&&a.push("public_legacy: Relay must use WSS")),{role:s,priority:i,surfaces:E,protection:E.length&&E.every(z=>z.requiresPairedClient)?"Secure Link \xB7 paired TLS":fa(s,E),issues:a}}function Rt(e){if(!e)return{routes:[],blockingIssues:[]};try{let t=typeof e=="string"?JSON.parse(e):e;if(!t||typeof t!="object")return{routes:[],blockingIssues:["Pairing payload is not an object"]};let r=Array.isArray(t.endpoints)&&t.endpoints.length>0?t.endpoints:[{role:"default",priority:0,api:t.host&&t.port?{host:t.host,port:t.port,tls:!!t.tls}:null,relay:t.relay,dashboard:t.dashboard_url?{url:t.dashboard_url}:null}],n=r.map((s,i)=>ha(s,i,t,r.length));return{routes:n,blockingIssues:n.flatMap(s=>s.issues)}}catch{return{routes:[],blockingIssues:["Pairing payload is not valid JSON"]}}}function er(e){return(e&&Array.isArray(e.routes)?e.routes:[]).flatMap(t=>t.surfaces.map(r=>({role:t.role,priority:t.priority,surface:r.surface,url:r.url,...r.requiresPairedClient?{requires_paired_client:!0}:{}})))}function ti(e){return!e||e.blockingIssues.length?[]:e.routes.map(t=>({...t,surfaces:t.surfaces.filter(r=>r.surface==="relay"&&!ni(r.url))})).filter(t=>t.surfaces.length>0)}function ri(e){try{let t=new URL(e);if(!["http:","https:","ws:","wss:"].includes(t.protocol))return"";let r=t.port||(["https:","wss:"].includes(t.protocol)?"443":"80");return`${t.protocol.slice(0,-1).toUpperCase()} \xB7 port ${r}`}catch{return""}}function We(e){return[e.role,e.priority,e.surface,e.url].join("|")}function tr(e){return e?e.requires_paired_client?{healthy:null,label:"Verify after pairing"}:e.surface==="relay"&&(e.status===401||e.status===403)&&ni(e.url)?{healthy:!0,label:"Auth required"}:e.reachable===!0?{healthy:!0,label:`Ready${e.latency_ms!=null?` \xB7 ${e.latency_ms}ms`:""}`}:e.status!=null?{healthy:!1,label:`HTTP ${e.status}`}:{healthy:!1,label:e.error||"Unreachable"}:{healthy:null,label:"Not checked"}}function ni(e){if(typeof e!="string"||!e.trim())return!1;try{let t=new URL(e.trim());if(!Zt.has(t.protocol)&&!ei.has(t.protocol)||t.username||t.password||t.search||t.hash)return!1;let r="/api/plugins/hermes-relay/transport",n=t.pathname.replace(/\/+$/,""),s=n.lastIndexOf(r);if(s<0)return!1;let i=n.slice(s+r.length);return i===""||i==="/ws"||i==="/health"}catch{return!1}}function si(e,t=10443){let r=!!(e&&e.active===!0),n=Array.isArray(e&&e.listen_ports)?[...new Set(e.listen_ports.filter(i=>Number.isInteger(i)))].sort((i,o)=>i-o):[],s=Number.isInteger(t)&&t>0&&t<=65535?t:10443;return{active:r,listenPorts:n,recommendedPort:s,recommendedActive:r&&n.includes(s),migration443:r&&s!==443&&n.includes(443),migration9119:r&&s!==9119&&n.includes(9119)}}function Fr(e){if(typeof e!="string"||!e.trim())return{kind:"empty",url:null};let t=St(e,Zt);if(!t)return{kind:"invalid",url:null};let r=new URL(t),n=r.pathname.replace(/\/+$/,"");return{kind:r.port==="8767"||/\/(?:api\/plugins\/hermes-relay\/transport|relay(?:\/ws)?|ws)$/i.test(n)?"legacy-relay-path":"dashboard-origin",url:t}}var ai=window.__HERMES_PLUGIN_SDK__,{React:pe}=ai,we=ai.components||{},ii=we.Button,oi=we.Badge;function ga(e){switch(e){case"outline":return{outlined:!0};case"ghost":return{ghost:!0};case"secondary":return{invert:!0};case"destructive":return{destructive:!0};case"default":case void 0:case null:default:return{}}}var S=ii?(({variant:e,size:t,className:r="",children:n,...s})=>{let i=ga(e);return pe.createElement(ii,{size:t==="icon"||t==="xs"||t==="sm"?t:"default",className:r,...i,...s},n)}):(({variant:e,size:t,className:r="",children:n,...s})=>pe.createElement("button",{type:"button",className:`hr-btn inline-flex items-center justify-center gap-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${e==="ghost"?"hr-btn-ghost":e==="outline"?"hr-btn-outline":e==="destructive"?"hr-btn-destructive":"hr-btn-solid"} ${r}`,...s},n)),va={default:"secondary",secondary:"secondary",outline:"outline",destructive:"destructive",success:"success",warning:"warning"},M=oi?(({variant:e,className:t="",children:r,...n})=>pe.createElement(oi,{tone:va[e]||"secondary",className:t,...n},r)):(({variant:e,className:t="",children:r,...n})=>pe.createElement("span",{className:`hr-badge ${e==="destructive"?"hr-badge-destructive":e==="outline"?"hr-badge-outline":"hr-badge-secondary"} ${t}`,...n},r)),ee=we.Alert||(({children:e,variant:t,className:r=""})=>pe.createElement("div",{className:`rounded-md border p-3 ${t==="destructive"?"border-destructive/50 text-destructive bg-destructive/10":"border-border bg-muted/30"} ${r}`},e)),te=we.AlertTitle||(({children:e,className:t=""})=>pe.createElement("div",{className:`font-semibold mb-1 ${t}`},e)),re=we.AlertDescription||(({children:e,className:t=""})=>pe.createElement("div",{className:`text-sm ${t}`},e)),Y=we.CardDescription||(({children:e,className:t=""})=>pe.createElement("p",{className:`text-sm text-muted-foreground ${t}`},e)),qe=we.Switch||(({checked:e,onCheckedChange:t,id:r,disabled:n,...s})=>pe.createElement("button",{id:r,type:"button",role:"switch","aria-checked":!!e,disabled:!!n,className:`hr-switch ${e?"checked":""}`,onClick:()=>t&&t(!e),...s},pe.createElement("span",{className:"hr-switch-thumb","aria-hidden":"true"}))),lt=we.Table||(({children:e,className:t=""})=>pe.createElement("div",{className:"w-full overflow-auto"},pe.createElement("table",{className:`w-full text-sm caption-bottom ${t}`},e))),dt=we.TableHeader||(({children:e})=>pe.createElement("thead",{className:"border-b bg-muted/50"},e)),ut=we.TableBody||(({children:e})=>pe.createElement("tbody",{className:"[&_tr:last-child]:border-0"},e)),Pe=we.TableRow||(({children:e,className:t="",...r})=>pe.createElement("tr",{className:`border-b hover:bg-muted/30 transition-colors ${t}`,...r},e)),ae=we.TableHead||(({children:e,className:t=""})=>pe.createElement("th",{className:`h-10 px-2 text-left align-middle font-medium text-muted-foreground ${t}`},e)),ne=we.TableCell||(({children:e,className:t="",...r})=>pe.createElement("td",{className:`p-2 align-middle ${t}`,...r},e));var qr=window.__HERMES_PLUGIN_SDK__,{React:p}=qr,{useState:Ie,useEffect:_t,useRef:li,useCallback:kt,useMemo:rr}=qr.hooks,{Input:di,Label:Tt,Dialog:ba,DialogContent:ya,DialogDescription:xa,DialogFooter:wa,DialogHeader:Ca,DialogTitle:Na}=qr.components,ui="hermes-relay-pair-mode",ci="hermes-relay-pair-prefer",mi="hermes-relay-pair-host",pi="hermes-relay-pair-port",fi="hermes-relay-pair-tls",hi="hermes-relay-pair-client",Sa=[{value:"auto",label:"Auto (all reachable candidates)"},{value:"lan",label:"LAN only"},{value:"tailscale",label:"Tailscale only"},{value:"public",label:"Public Dashboard only"}],ka=[{value:"",label:"Natural order (Tailscale \u2192 Public \u2192 LAN)"},{value:"lan",label:"LAN \u2192 priority 0"},{value:"tailscale",label:"Tailscale \u2192 priority 0"},{value:"public",label:"Public \u2192 priority 0"}],Ta={plugin_proxy:"Secure Link",public_legacy:"External",legacy_direct:"LAN"};function ct(e,t){try{return window.localStorage.getItem(e)??t}catch{return t}}function mt(e,t){try{window.localStorage.setItem(e,t)}catch{}}function _a(){let e=parseInt(ct(pi,""),10);return{client:ct(hi,"android")==="desktop"?"desktop":"android",mode:ct(ui,"auto")||"auto",prefer:ct(ci,"")||"",host:ct(mi,"")||"",port:Number.isFinite(e)&&e>0&&e<=65535?e:8642,tls:ct(fi,"")==="true"}}function Aa(e){mt(hi,e.client||"android"),mt(ui,e.mode||"auto"),mt(ci,e.prefer||""),mt(mi,e.host||""),mt(pi,String(e.port||"")),mt(fi,e.tls?"true":"false")}function Ea(e){let[t,r]=Ie(()=>Math.floor(Date.now()/1e3));if(_t(()=>{if(!e)return;r(Math.floor(Date.now()/1e3));let o=setInterval(()=>r(Math.floor(Date.now()/1e3)),1e3);return()=>clearInterval(o)},[e]),!e)return null;let n=Math.max(0,e-t);if(n<=0)return"expired";let s=Math.floor(n/60),i=n%60;return s>0?`${s}m ${i}s`:`${i}s`}function Ba(e){if(!e)return!1;let t=e.toLowerCase().trim();return/^\d+\.\d+\.\d+\.\d+$/.test(t)||t==="localhost"||t.endsWith(".local")||t.endsWith(".ts.net")?!1:t.includes(".")&&!t.endsWith(".lan")&&!t.endsWith(".home.arpa")}function nr({open:e,onClose:t}){let[r,n]=Ie(_a),[s,i]=Ie(!1),[o,a]=Ie({status:"idle"}),[d,m]=Ie({status:"idle",results:[]}),[f,c]=Ie(""),[y,h]=Ie({status:"idle"}),[w,x]=Ie(!1),[T,A]=Ie(!1),N=li(null),b=li(0),_=Ea(o.data?o.data.expires_at:null),E=r.client==="desktop",C=_==="expired",z=rr(()=>Rt(o.data?o.data.qr_payload:null),[o.data]),D=rr(()=>ti(z),[z]),J=E?D:z.routes,X=rr(()=>er({routes:J}),[J]),ce=rr(()=>{let k=new Map;return(d.results||[]).forEach($=>k.set(We($),$)),k},[d.results]),j=r.host&&Ba(r.host),ve=kt(async k=>{let $=k||r,ie=++b.current;a({status:"loading"});try{let le=Je(window.location);if(!le)throw new Error("This Dashboard does not have a valid HTTP(S) origin for pairing.");let Ze={};$.host&&$.host.trim()&&(Ze.host=$.host.trim(),Ze.port=Number($.port)||8642,Ze.tls=!!$.tls);let Re=await Ut({mode:$.mode||"auto",prefer:$.prefer||void 0,dashboard_url:le,legacy_direct_relay:$.client==="desktop"?!0:void 0,...Ze});if(ie!==b.current)return;c(""),x(!1),a({status:"ok",data:Re})}catch(le){if(ie!==b.current)return;a({status:"error",error:le&&le.message?le.message:String(le)})}},[r]);_t(()=>{if(o.status!=="ok"||z.blockingIssues.length>0||X.length===0){m({status:"idle",results:[]});return}let k=!1;return m({status:"loading",results:[]}),ft(X).then($=>{k||m({status:"done",results:$&&Array.isArray($.results)?$.results:[]})}).catch($=>{k||m({status:"error",results:[],error:$&&$.message?$.message:String($)})}),()=>{k=!0}},[o.status,z,X]),_t(()=>{!e||o.status!=="idle"||j&&!T||ve()},[e,o.status,ve,j,T]),_t(()=>{if(!e||E||C||o.status!=="ok"||!N.current){h({status:"idle"});return}let k=!1;return h({status:"loading"}),Promise.resolve().then(()=>{if(k||!N.current)return;let $=Or.default.create(o.data.qr_payload,Nt()),ie=N.current.closest(".hr-pair-qr-column"),le=(ie?ie.clientWidth:280)-26;return Or.default.toCanvas(N.current,o.data.qr_payload,Nt({modules:$.modules.size,availableWidth:le}))}).then(()=>{k||h({status:"ok"})}).catch($=>{k||h({status:"error",error:Zs($)})}),()=>{k=!0}},[e,E,C,o.status,o.data]),_t(()=>{e||(b.current+=1,a({status:"idle"}),c(""),x(!1),m({status:"idle",results:[]}),i(!1),A(!1))},[e]);let se=kt((k,$=!0)=>{n(ie=>{let le={...ie,...k};return Aa(le),le}),Object.prototype.hasOwnProperty.call(k,"host")&&A(!1),$&&(b.current+=1,a({status:"idle"}))},[]),me=kt(()=>{b.current+=1,a({status:"idle"})},[]),F=kt(()=>{A(!0),a({status:"idle"})},[]),O=kt(async()=>{let k=o.data&&(o.data.pairing_url||o.data.qr_payload);if(!k||o.data.expires_at<=Math.floor(Date.now()/1e3))return;let $=b.current;try{if(await Xs(k),$!==b.current)return;c("Invite copied"),x(!1)}catch{if($!==b.current)return;c("Copy failed. Select the invite below and copy it manually."),x(!0)}},[o.data]);if(!e)return null;let G=s&&j,Ce=j&&!T,Xe=o.status==="ok"&&z.blockingIssues.length>0;return p.createElement(ba,{open:e,onOpenChange:k=>{k||t()}},p.createElement(ya,{className:"hermes-relay-plugin hr-pair-dialog"},p.createElement(Ca,null,p.createElement("div",{className:"flex flex-wrap items-center gap-2"},p.createElement(Na,null,"Pair new device"),p.createElement(M,{variant:"outline",className:"text-xs"},"Hermes-Relay Plugin")),p.createElement(xa,null,"Scan with Android or copy a CLI+UI invite for another computer.")),p.createElement("div",{className:"hr-pair-body"},p.createElement("section",{className:"hr-pair-qr-column","aria-label":"Hermes-Relay pairing code"},p.createElement("div",{className:"hr-pair-panel"},p.createElement(Tt,{htmlFor:"pair-client"},"Pairing client"),p.createElement("select",{id:"pair-client",className:"h-9 w-full rounded-md border border-border bg-background px-2 text-sm",value:r.client,onChange:k=>se({client:k.target.value})},p.createElement("option",{value:"android"},"Hermes-Relay Android"),p.createElement("option",{value:"desktop"},"Hermes-Relay CLI+UI"))),Ce?p.createElement("div",{className:"rounded-md border border-amber-500/60 bg-amber-500/15 p-3 text-sm space-y-2"},p.createElement("div",{className:"font-medium"},"Proxy-fronted host detected"),p.createElement("p",{className:"text-xs"},p.createElement("span",{className:"font-mono"},r.host)," appears to require browser authentication that Hermes-Relay Android cannot present to the API route."),p.createElement("div",{className:"flex flex-wrap gap-2"},p.createElement(S,{size:"sm",onClick:F},"Mint anyway"),p.createElement(S,{size:"sm",variant:"outline",onClick:()=>se({host:"",port:8642,tls:!1})},"Clear override"))):Xe?p.createElement("div",{className:"rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive space-y-2"},p.createElement("div",{className:"font-medium"},"Unsafe pairing route blocked"),p.createElement("p",{className:"text-xs"},"The invite was not shown or copied because its public route is incomplete or insecure."),p.createElement("ul",{className:"list-disc pl-4 text-xs space-y-1"},z.blockingIssues.map(k=>p.createElement("li",{key:k},k))),p.createElement(S,{size:"sm",variant:"outline",onClick:me},"Mint a corrected route")):o.status==="loading"?p.createElement("div",{className:"hr-pair-loading text-sm text-muted-foreground"},"Minting a secure code\u2026"):o.status==="error"?p.createElement("div",{className:"rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"},p.createElement("div",{className:"font-medium mb-1"},"Minting failed"),p.createElement("div",{className:"break-words"},o.error),p.createElement(S,{className:"mt-2",size:"sm",variant:"outline",onClick:me},"Retry")):o.status==="ok"?p.createElement(p.Fragment,null,C?p.createElement("div",{className:"hr-pair-panel",role:"status"},p.createElement("div",{className:"hr-pair-panel-title"},"Invite expired"),p.createElement("p",{className:"text-sm text-muted-foreground"},"Create a new invite to continue pairing."),p.createElement(S,{size:"sm",onClick:me},"New invite")):E?p.createElement("div",{className:"hr-pair-panel"},p.createElement("div",{className:"hr-pair-panel-title"},"Connect this computer"),p.createElement("p",{className:"text-sm"},"Copy the invite, then open CLI+UI \u2192 Pair host \u2192 Paste invite."),p.createElement("p",{className:"text-xs text-muted-foreground"},"The invite includes Relay addresses and Secure Link certificate trust. Hermes sign-in is not required on the computer.")):p.createElement(p.Fragment,null,y.status==="error"?p.createElement("div",{className:"hr-pair-panel",role:"status"},p.createElement("div",{className:"hr-pair-panel-title"},"Use the pairing invite"),p.createElement("p",{className:"text-sm text-muted-foreground"},y.error)):null,p.createElement("div",{className:"hr-qr-frame hr-pairing-qr",hidden:y.status==="error"},p.createElement("canvas",{ref:N,className:"block","aria-label":"Hermes-Relay pairing QR code"}))),p.createElement("div",{className:"hr-pair-code-row"},p.createElement("div",null,p.createElement("div",{className:"text-xs uppercase tracking-wider text-muted-foreground"},"Pairing code"),p.createElement("div",{className:"font-mono text-2xl tracking-widest"},o.data.code)),p.createElement("div",{className:"text-right"},p.createElement("div",{className:"text-xs uppercase tracking-wider text-muted-foreground"},"Expires in"),p.createElement(M,{variant:_==="expired"?"destructive":"outline"},_||"\u2014"))),o.data.pairing_url?p.createElement("div",{className:"space-y-2"},p.createElement(S,{className:"w-full",size:"sm",onClick:O,disabled:C||E&&D.length===0},"Copy invite"),!C&&f?p.createElement("div",{className:"text-center text-xs text-muted-foreground",role:"status"},f):null,!C&&w?p.createElement("textarea",{className:"hr-pair-invite-text","aria-label":"Pairing invite",readOnly:!0,value:o.data.pairing_url,onFocus:k=>k.target.select()}):null):null):null),p.createElement("section",{className:"hr-pair-options-column"},!E&&p.createElement("div",{className:"hr-pair-panel"},p.createElement("div",{className:"hr-pair-panel-title"},"What this adds"),p.createElement("div",{className:"hr-grant-list"},["Terminal","Bridge","Media","Voice"].map(k=>p.createElement(M,{key:k,variant:"outline",className:"text-xs"},k))),p.createElement("p",{className:"text-xs text-muted-foreground"},"Extends an existing Hermes Dashboard connection with Hermes-Relay capabilities.")),p.createElement("div",{className:"hr-pair-panel"},p.createElement("div",{className:"hr-pair-connection-header"},p.createElement("div",null,p.createElement("div",{className:"hr-pair-panel-title"},E?"Relay routes":"Connection"),p.createElement("div",{className:"text-xs text-muted-foreground"},E?"Addresses this computer can use":"Best available route")),p.createElement("select",{id:"pair-mode","aria-label":"Connection mode",className:"h-9 rounded-md border border-border bg-background px-2 text-sm",value:r.mode,onChange:k=>se({mode:k.target.value})},Sa.map(k=>p.createElement("option",{key:k.value,value:k.value},E&&k.value==="public"?"Public routes":k.label)))),J.length>0?p.createElement("div",{className:"hr-endpoint-list"},J.map(k=>p.createElement("div",{key:`${k.role}-${k.priority}`,className:"hr-endpoint-route"},p.createElement("div",{className:"hr-endpoint-row"},p.createElement(M,{variant:"outline",className:"text-xs capitalize"},Ta[k.role]||k.role),p.createElement("span",{className:"text-xs text-muted-foreground hr-endpoint-address"},k.protection),p.createElement("span",{className:"text-xs text-muted-foreground"},"p",k.priority)),p.createElement("div",{className:"hr-endpoint-surfaces"},k.surfaces.map($=>{let ie=ce.get(We({role:k.role,priority:k.priority,surface:$.surface,url:$.url})),le=d.status==="loading"&&!ie?"Checking\u2026":tr(ie).label;return p.createElement("div",{key:`${$.surface}-${$.url}`,className:"hr-endpoint-surface"},p.createElement("span",{className:"text-xs font-medium"},$.label),p.createElement("div",{className:"min-w-0"},p.createElement("span",{className:`block font-mono text-xs ${E?"hr-relay-address":"hr-endpoint-address"}`,title:$.url},$.url),p.createElement("div",{className:"text-xs text-muted-foreground"},ri($.url))),p.createElement(M,{variant:"outline",className:"text-xs"},le))}))))):p.createElement("div",{className:"text-xs text-muted-foreground"},E&&o.status==="ok"?"No compatible Relay route is available. Configure a reachable direct Relay or Secure Link address before pairing.":"Automatic route selection will use server configuration."),p.createElement("p",{className:"text-xs text-muted-foreground"},E?"The CLI verifies the selected route while pairing. A Dashboard address alone cannot connect this computer to Relay.":p.createElement(p.Fragment,null,"Tailscale is preferred because it keeps the route private and ACL-controlled. A raw HTTP/WS tailnet URL has no application TLS, but Tailscale still encrypts device-to-device traffic. Public routes must use HTTPS/WSS.")),d.status==="error"?p.createElement("div",{className:"text-xs text-destructive"},"Surface probes failed: ",d.error):null),p.createElement("details",{className:"hr-pair-advanced",open:s},p.createElement("summary",{onClick:k=>{k.preventDefault(),i($=>!$)}},"Advanced connection options"),s?p.createElement("div",{className:"hr-pair-advanced-content space-y-3"},p.createElement("div",{className:"space-y-1"},p.createElement(Tt,{htmlFor:"pair-prefer"},"Prefer role"),p.createElement("select",{id:"pair-prefer",className:"h-9 w-full rounded-md border border-border bg-background px-2 text-sm",value:r.prefer,onChange:k=>se({prefer:k.target.value})},ka.map(k=>p.createElement("option",{key:k.value,value:k.value},k.label)))),p.createElement("div",{className:"space-y-1"},p.createElement(Tt,{htmlFor:"pair-host"},"API host override"),p.createElement(di,{id:"pair-host",value:r.host,placeholder:"Use server configuration",onChange:k=>se({host:k.target.value},!1)})),p.createElement("div",{className:"grid grid-cols-2 gap-2"},p.createElement("div",{className:"space-y-1"},p.createElement(Tt,{htmlFor:"pair-port"},"API port"),p.createElement(di,{id:"pair-port",type:"number",min:"1",max:"65535",value:r.port,onChange:k=>se({port:parseInt(k.target.value,10)||8642},!1)})),p.createElement("div",{className:"space-y-1"},p.createElement(Tt,{htmlFor:"pair-tls"},"Scheme"),p.createElement("label",{className:"hr-pair-checkbox text-sm",htmlFor:"pair-tls"},p.createElement("input",{id:"pair-tls",type:"checkbox",className:"h-4 w-4",checked:!!r.tls,onChange:k=>se({tls:k.target.checked},!1)}),"https://"))),G?p.createElement("div",{className:"rounded-md border border-amber-500/50 bg-amber-500/10 p-2 text-xs"},"This host appears proxy-fronted and may reject API requests from Hermes-Relay Android."):null,p.createElement("div",{className:"flex flex-wrap gap-2"},p.createElement(S,{size:"sm",onClick:me},"Apply and mint"),p.createElement(S,{size:"sm",variant:"ghost",onClick:()=>se({host:"",port:8642,tls:!1})},"Clear override"))):null))),p.createElement(wa,null,p.createElement(S,{size:"sm",variant:"outline",onClick:me,disabled:o.status==="loading"},"New code"),p.createElement(S,{size:"sm",onClick:t},"Done"))))}var ar=window.__HERMES_PLUGIN_SDK__,{React:I}=ar,{useState:sr,useEffect:gi,useCallback:ir,useRef:Pa}=ar.hooks,{Card:La,CardHeader:Da,CardTitle:Ia,CardContent:Ma,Label:Ha,Toast:$a}=ar.components,Ua=ar.hooks.useToast;function or(e){return e?typeof e=="string"?e:e.message||e.detail||String(e):"Unknown error"}function jr({autoRefresh:e=!1}={}){let[t,r]=sr(null),[n,s]=sr(null),[i,o]=sr(!0),[a,d]=sr(null),m=Pa(null),{toast:f,showToast:c}=Ua(),y=ir(async()=>{try{let N=await Cn();r(N||null),s(null)}catch(N){s(or(N))}finally{o(!1)}},[]);gi(()=>{y()},[y]),gi(()=>{if(!e)return;let N=setInterval(y,15e3);return()=>clearInterval(N)},[e,y]);let h=ir(async N=>{let b=N.target.files&&N.target.files[0];if(N.target.value="",!!b){d("install");try{let _=await b.text(),E=await Nn({json_text:_});r(E),c(E.configured?`Service account installed (${E.project_id||"ok"})`:"Install finished but not configured",E.configured?"success":"error")}catch(_){c(`Install failed: ${or(_)}`,"error")}finally{d(null)}}},[c]),w=ir(async N=>{d("enabled");try{let b=await kn(!!N);r(b),c(N?"FCM wake enabled":"FCM wake disabled","success")}catch(b){c(`Update failed: ${or(b)}`,"error")}finally{d(null)}},[c]),x=ir(async()=>{if(window.confirm("Remove the Firebase service account from this host?")){d("clear");try{let N=await Sn();r(N),c("Service account cleared","success")}catch(N){c(`Clear failed: ${or(N)}`,"error")}finally{d(null)}}},[c]),T=!!(t&&t.configured),A=!!(t&&t.enabled);return I.createElement(La,null,I.createElement($a,{toast:f}),I.createElement(Da,null,I.createElement(Ia,null,"Push wake (BYO FCM)"),I.createElement(Y,null,"Install the Firebase ",I.createElement("strong",null,"Admin service account")," on this Hermes host so the relay can wake your phone when the live socket is down. The service account never goes on the phone \u2014 the app only needs client"," ",I.createElement("code",null,"google-services.json"),".")),I.createElement(Ma,{className:"space-y-4"},i?I.createElement("div",{className:"text-sm text-muted-foreground"},"Checking host FCM status\u2026"):n?I.createElement(ee,{variant:"destructive"},I.createElement(te,null,"Status unavailable"),I.createElement(re,null,I.createElement("pre",{className:"whitespace-pre-wrap text-xs"},n))):null,t?I.createElement("div",{className:"rounded border border-border bg-muted/30 p-3 space-y-2 text-sm"},I.createElement("div",{className:"flex flex-wrap items-center gap-2"},I.createElement("span",{className:"font-semibold"},"Host status"),I.createElement(M,{variant:T&&A?"default":"outline"},T&&A?"Ready":T?"Installed \xB7 disabled":"Not configured")),I.createElement("div",{className:"text-xs text-muted-foreground space-y-1"},t.project_id?I.createElement("div",null,"project: ",I.createElement("code",null,t.project_id)):null,t.client_email?I.createElement("div",null,"account: ",I.createElement("code",null,t.client_email)):null,t.path?I.createElement("div",null,"file: ",I.createElement("code",{className:"break-all"},t.path)):I.createElement("div",null,"default path: ",I.createElement("code",{className:"break-all"},t.default_path)),I.createElement("div",null,"registered devices: ",t.device_tokens??0),t.reason?I.createElement("div",null,t.reason):null)):null,I.createElement("div",{className:"space-y-2"},I.createElement("h4",{className:"text-sm font-semibold"},"1. Install service account on this Mac"),I.createElement("p",{className:"text-xs text-muted-foreground"},"Firebase Console \u2192 Project settings \u2192 Service accounts \u2192 Generate new private key. Choose that JSON here (or CLI:"," ",I.createElement("code",null,"python ~/.hermes/plugins/hermes-relay/relay/fcm_sender.py install ./sa.json"),")."),I.createElement("input",{ref:m,type:"file",accept:"application/json,.json,text/plain",className:"hidden",onChange:h}),I.createElement("div",{className:"flex flex-wrap gap-2"},I.createElement(S,{size:"sm",disabled:a==="install",onClick:()=>m.current&&m.current.click()},a==="install"?"Installing\u2026":T?"Replace service account\u2026":"Choose service-account JSON\u2026"),I.createElement(S,{size:"sm",variant:"outline",disabled:!T||a==="clear",onClick:x},a==="clear"?"Clearing\u2026":"Remove"),I.createElement(S,{size:"sm",variant:"outline",onClick:y,disabled:!!a},"Refresh"))),I.createElement("div",{className:"flex items-center justify-between gap-3 rounded border border-border p-3"},I.createElement("div",{className:"space-y-1"},I.createElement(Ha,{htmlFor:"fcm-enabled"},"Enable FCM wake"),I.createElement("p",{className:"text-xs text-muted-foreground"},"Off keeps the file but stops sending wakes.")),I.createElement(qe,{id:"fcm-enabled",checked:A,disabled:!T||a==="enabled",onCheckedChange:w})),I.createElement(ee,null,I.createElement(te,null,"2. Phone (client config only)"),I.createElement(re,null,t?.phone_setup||"Sideload app \u2192 Settings \u2192 Power tools \u2192 Threads \u2192 Push wake (BYO FCM). Load google-services.json (client), enable, Save. Never paste the Admin service account into the app."))))}var lr=window.__HERMES_PLUGIN_SDK__,{React:l}=lr,{useState:V,useEffect:pt,useCallback:he}=lr.hooks,{Card:Te,CardHeader:_e,CardTitle:Ae,CardContent:Ee,Input:za,Label:Ni,ConfirmDialog:Fa,Toast:Si}=lr.components,ki=lr.hooks.useToast,Gr="RELAY_AGENT_CONTEXT_ENABLED",Kr="RELAY_CONTEXT_MEDIA_SENSITIVITY",Oa=new Set(["1","true","yes","on"]);function vi(e,t){return e==null?t:Oa.has(String(e).trim().toLowerCase())}function qa(e){return e==null?"":Array.isArray(e)?e.join(" "):typeof e=="object"?Object.keys(e).join(" "):String(e)}var bi={chat:0,bridge:10,terminal:20,tui:30,"voice:config":40,"voice:stt":41,"voice:tts":42};function yi(e){let t=String(e||"").toLowerCase();return Object.prototype.hasOwnProperty.call(bi,t)?bi[t]:100}function xi(e){switch(String(e||"").toLowerCase()){case"chat":return"Chat";case"bridge":return"Bridge";case"terminal":return"Terminal";case"tui":return"TUI";case"voice:config":return"Voice config";case"voice:stt":return"Voice STT";case"voice:tts":return"Voice TTS";default:return String(e||"")}}function wi(e){return e.sort((t,r)=>yi(t.name)-yi(r.name)||String(t.name).localeCompare(String(r.name)))}function ja(e){let t=e&&e.grants;if(Array.isArray(t)){let r=t.map(n=>{if(typeof n=="string")return{name:n,detail:""};if(!n||typeof n!="object")return null;let s=n.name||n.channel||n.grant||n.scope;return s?{name:String(s),detail:n.expires_at?rt(n.expires_at):Vr(n.ttl_seconds??n.ttl??n.seconds)}:null}).filter(Boolean);return wi(r)}return t&&typeof t=="object"?wi(Object.entries(t).map(([r,n])=>({name:r,detail:n&&typeof n=="object"?n.expires_at||n.expiresAt||n.until?rt(n.expires_at||n.expiresAt||n.until):Vr(n.ttl_seconds??n.ttl??n.seconds):Vr(n)}))):[]}function Vr(e){if(e==null||e===""||e===!0)return"";let t=Number(e);return!Number.isFinite(t)||t<=0?"":t>1e9?rt(t):Ga(t)}function Ga(e){if(e==null||e===""||e===!0)return"";let t=Number(e);if(!Number.isFinite(t)||t<=0)return"";if(t<60)return`${Math.floor(t)}s`;let r=Math.floor(t/60);if(r<60)return`${r}m`;let n=Math.floor(r/60),s=r%60;if(n<24)return s?`${n}h ${s}m`:`${n}h`;let i=Math.floor(n/24),o=n%24;return o?`${i}d ${o}h`:`${i}d`}function Ka(e,t){let r=[e.device_type,e.client_type,e.platform,e.device_platform,e.device_model,e.client_surface,e.device_form_factor,e.device_name,e.device_label,e.client_name,e.label,e.transport,e.transport_hint,e.channel,qa(e.capabilities),t.map(n=>n.name).join(" ")].filter(Boolean).join(" ").toLowerCase();return/\bandroid\b|\bmobile\b|\bphone\b|hermes-relay-android/.test(r)?"Android":/\btui\b|terminal-ui|textual/.test(r)?"Desktop TUI":/\bcli\b|terminal|shell|desktop|tool|powershell|bash|cmd\.exe/.test(r)?"Desktop CLI":/\bweb\b|\bbrowser\b|\bdashboard\b/.test(r)?"Dashboard":"Client"}function Va(e){return e.transport_hint||e.transport||e.channel||e.connection||e.protocol||""}function Ti(e){let t=e.token||e.session_token||"";return e.token_prefix||e.prefix||e.tokenPrefix||e.session_prefix||(t?String(t).slice(0,12):"")}function Jr({label:e,value:t,hint:r}){return l.createElement(Te,null,l.createElement(_e,{className:"pb-2"},l.createElement(Y,null,e),l.createElement(Ae,{className:"text-2xl"},t)),r?l.createElement(Ee,{className:"pt-0 text-xs text-muted-foreground"},r):null)}function Ci({id:e,title:t,description:r,checked:n,disabled:s,onChange:i}){return l.createElement("div",{className:"flex items-start justify-between gap-3 rounded-md border border-border/70 p-3"},l.createElement("div",{className:"space-y-1"},l.createElement(Ni,{htmlFor:e,className:"text-sm font-medium"},t),r?l.createElement("div",{className:"text-xs text-muted-foreground"},r):null),l.createElement(qe,{id:e,checked:n,disabled:s,onCheckedChange:i}))}function Ja({data:e,saving:t,onToggle:r}){let n=e&&e.settings||{},s=e&&e.injected||{},i=Array.isArray(s.blocks)?s.blocks:[],o=vi(n[Gr],!0),a=vi(n[Kr],!0);return l.createElement(Te,null,l.createElement(_e,null,l.createElement(Ae,null,"Agent context"),l.createElement(Y,null,"On by default for Hermes-Relay installs \u2014 injects an instruction into the agent's system prompt (server-side) so it can mark sensitive media. Turn off to opt out; removable by uninstalling the relay plugin.")),l.createElement(Ee,{className:"space-y-3"},l.createElement(Ci,{id:"relay-agent-context-enabled",title:"Enable Agent context injection",description:"Master toggle for Hermes-Relay-owned server-side prompt blocks.",checked:o,disabled:t===Gr,onChange:d=>r(Gr,d)}),l.createElement(Ci,{id:"relay-context-media-sensitivity",title:"Media sensitivity block",description:"Ask the agent to mark private, NSFW, or spoiler media with client-visible sensitivity markers.",checked:a,disabled:t===Kr,onChange:d=>r(Kr,d)}),l.createElement("details",{className:"hr-audit-details"},l.createElement("summary",null,l.createElement("span",null,l.createElement("span",{className:"text-sm font-medium"},"Server-side audit"),l.createElement("span",{className:"block text-xs text-muted-foreground"},s.enabled?"Context injection enabled.":"Context injection disabled.")),l.createElement(M,{variant:s.enabled?"success":"outline",className:"text-xs"},i.length," ",i.length===1?"block":"blocks"," active")),i.length===0?l.createElement("div",{className:"hr-audit-empty text-xs text-muted-foreground"},"No blocks would be injected on the next turn."):l.createElement("div",{className:"hr-audit-blocks space-y-2"},i.map(d=>l.createElement("div",{key:d.name,className:"rounded-md bg-muted/40 p-2"},l.createElement("div",{className:"text-xs font-medium"},d.name),l.createElement("pre",{className:"mt-1 whitespace-pre-wrap text-xs text-muted-foreground"},d.text)))))))}function Wa({config:e,onSaved:t}){let[r,n]=V(e&&e.home_channel_name||"Phone"),[s,i]=V(!1),[o,a]=V(null),[d,m]=V(null),f=e&&e.home_channel_id||"phone",c=e&&e.name_env_key||"PHONE_HOME_CHANNEL_NAME",y=he(async()=>{let h=r.trim();if(!h){m("Display name cannot be empty.");return}m(null),i(!0);try{await pr(c,h),a(Date.now()),t&&await t()}catch(w){m(w&&w.message?w.message:String(w))}finally{i(!1)}},[r,c,t]);return l.createElement(Te,null,l.createElement(_e,null,l.createElement("div",{className:"flex flex-wrap items-center gap-2"},l.createElement(Ae,null,"Home channel"),l.createElement(M,{variant:"warning",className:"text-xs"},"Restart required")),l.createElement(Y,null,"Where Hermes delivers proactive pushes, cron results, and cross-platform messages when no specific Thread is named. The phone is a single paired device, so this is auto-configured \u2014 you only set a display name.")),l.createElement(Ee,{className:"space-y-3"},l.createElement("div",{className:"space-y-1"},l.createElement(Ni,{htmlFor:"phone-home-name"},"Display name"),l.createElement(za,{id:"phone-home-name",value:r,placeholder:"Phone",onChange:h=>n(h.target.value)}),l.createElement("p",{className:"text-xs text-muted-foreground"},"Notification title and Thread label. Applies after the next gateway restart.")),l.createElement("div",{className:"text-xs text-muted-foreground"},"Channel id ",l.createElement("code",{className:"font-mono"},f)," \u2014 fixed; changing it would orphan existing Threads."),d?l.createElement("div",{className:"rounded-md border border-destructive/50 bg-destructive/10 p-2 text-xs text-destructive"},d):null,l.createElement("div",{className:"flex flex-wrap items-center gap-2"},l.createElement(S,{size:"sm",onClick:y,disabled:s||!r.trim()},s?"Saving\u2026":"Save"),o?l.createElement("span",{className:"text-xs text-muted-foreground"},"Saved ",xe(o)):null)))}function Qa({info:e,onRefresh:t}){let[r,n]=V(!1),[s,i]=V(!1),o=he(async()=>{n(!0);try{await t(!0)}finally{n(!1)}},[t]),a=e&&e.update_command,d=he(async()=>{if(a)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(a):window.prompt("Copy update command",a),i(!0),window.setTimeout(()=>i(!1),1500)}catch{window.prompt("Copy update command",a)}},[a]);if(!e)return null;let m=e.current||"\u2014",f=!!e.update_available,c=f?`Update available \u2014 you're on ${m}.`:e.error?`On ${m}. Couldn't reach GitHub to check.`:`On ${m}${e.latest?` \u2014 latest is ${e.latest}.`:"."}`;return l.createElement(Te,null,l.createElement(_e,{className:"flex flex-row items-center justify-between space-y-0"},l.createElement("div",null,l.createElement(Ae,null,"Plugin version"),l.createElement(Y,null,c)),l.createElement(S,{size:"sm",variant:"outline",onClick:o,disabled:r},r?"Checking\u2026":"Check")),f?l.createElement(Ee,{className:"space-y-2"},l.createElement(M,{variant:"secondary",className:"w-fit text-xs"},m," \u2192 ",e.latest),l.createElement("div",{className:"flex items-center justify-between gap-2 rounded-md border border-border/70 bg-muted/30 p-2 font-mono text-xs"},l.createElement("span",{className:"truncate"},a),l.createElement(S,{size:"sm",variant:"ghost",onClick:d},s?"Copied":"Copy")),l.createElement("p",{className:"text-xs text-muted-foreground"},"Run it on your Hermes host, then restart the gateway to load the new plugin.")):e.error?l.createElement(Ee,{className:"pt-0 text-xs text-muted-foreground"},e.error):null)}function _i(e){return Array.isArray(e)?e:e&&e.sessions||[]}function ke(e){return e&&e.message?e.message:String(e)}function Ai({session:e,compact:t=!1,copied:r,revoking:n,onCopy:s,onRevoke:i}){let o=Ti(e),a=e.device_name||e.device_label||e.client_name||e.label||zt(o)||"Unnamed device",d=e.last_seen||e.last_activity||e.last_seen_at||e.last_activity_at||e.updated_at||e.paired_at,m=e.expires_at??e.expiresAt??e.expires,f=Qn(m),c=ja(e),y=Ka(e,c),h=Va(e),w=gr(e),x=[e.device_model,e.device_platform].filter(T=>T&&T!=="unknown").join(" \xB7 ");return l.createElement("div",{className:`hr-device-card ${t?"hr-device-card-compact":""}`},l.createElement("div",{className:"hr-device-main"},l.createElement("div",{className:"hr-device-title-row"},l.createElement("span",{className:"hr-status-dot hr-status-dot-success","aria-label":"Paired"}),l.createElement("span",{className:"font-medium"},a),l.createElement(M,{variant:"outline",className:"text-xs"},y),w?l.createElement(M,{variant:"secondary",className:"text-xs"},"Supervised"):null),l.createElement("div",{className:"hr-device-meta text-xs text-muted-foreground"},x?l.createElement("span",null,x):null,h?l.createElement("span",null,h):null,l.createElement("span",null,"Seen ",xe(d)),l.createElement(M,{variant:f.expired?"destructive":"secondary",className:"text-xs",title:f.exact?`Expires ${f.exact}`:void 0},f.label)),w&&w.profileLabel?l.createElement("div",{className:"text-xs text-muted-foreground"},"Pinned profile: ",w.profileLabel):null,t?null:l.createElement("div",{className:"hr-grant-list"},c.length===0?l.createElement("span",{className:"text-xs text-muted-foreground"},"No grants reported"):c.map(T=>l.createElement(M,{key:`${T.name}:${T.detail}`,variant:"secondary",className:"text-xs"},T.detail?`${xi(T.name)} ${T.detail}`:xi(T.name))))),t?null:l.createElement("div",{className:"hr-device-actions"},l.createElement(S,{size:"sm",variant:"outline",disabled:!o,onClick:()=>s(o)},r===o?"Copied":"Copy prefix"),l.createElement(S,{size:"sm",variant:"destructive",disabled:n===o||!o,onClick:()=>i({prefix:o,label:a})},n===o?"Revoking\u2026":"Revoke")))}function Ya(e){if(!e)return{value:"\u2014",hint:"Checking routes"};let t=e.tailscale&&e.tailscale.serve_services||{};return t.dashboard&&t.dashboard.active===!0?{value:"Tailscale",hint:"Private Dashboard route active"}:t.legacy_relay&&t.legacy_relay.active===!0?{value:"Tailscale",hint:"Legacy direct Relay route active"}:e.secure_link&&e.secure_link.enabled?{value:"Secure Link",hint:"Pinned TLS route active"}:e.public&&e.public.url?{value:"Public HTTPS",hint:"Pinned Dashboard origin"}:{value:"LAN only",hint:"No remote route configured"}}function Xa(e){let t=String(e||"pending").toLowerCase();return t==="executed"||t==="confirmed"?"success":t==="blocked"||t==="error"?"destructive":t==="timeout"?"warning":"outline"}function Za({rows:e,error:t,onOpenActivity:r}){return l.createElement(Te,null,l.createElement(_e,{className:"flex flex-row items-center justify-between space-y-0"},l.createElement("div",null,l.createElement(Ae,null,"Recent Bridge activity"),l.createElement(Y,null,"Latest commands routed through Hermes-Relay.")),l.createElement(S,{size:"sm",variant:"ghost",onClick:r},"View all")),l.createElement(Ee,null,t&&e.length===0?l.createElement("div",{className:"text-xs text-destructive"},t):e.length===0?l.createElement("div",{className:"text-sm text-muted-foreground"},"No Bridge activity recorded yet."):l.createElement("div",{className:"hr-activity-list"},e.slice(0,4).map((n,s)=>l.createElement("div",{className:"hr-activity-row",key:n.request_id||`${n.sent_at}-${s}`},l.createElement("div",{className:"min-w-0"},l.createElement("div",{className:"font-mono text-xs hr-activity-method"},n.method||"COMMAND"," ",n.path||""),l.createElement("div",{className:"text-xs text-muted-foreground"},xe(n.sent_at))),l.createElement(M,{variant:Xa(n.decision),className:"text-xs capitalize"},n.decision||"pending"))))))}function Ei({autoRefresh:e,onConnectMobile:t,onNavigate:r}){let[n,s]=V(null),[i,o]=V(null),[a,d]=V([]),[m,f]=V(null),[c,y]=V(null),[h,w]=V(null),[x,T]=V([]),[A,N]=V(null),[b,_]=V(null),[E,C]=V(!1),z=he(async()=>{try{s(await Mt()),o(null)}catch(G){o(ke(G))}},[]),D=he(async()=>{try{d(_i(await cr())),f(null)}catch(G){f(ke(G))}},[]),J=he(async()=>{try{y(await $t()),w(null)}catch(G){w(ke(G))}},[]),X=he(async()=>{try{let G=await Ht(5);T(Array.isArray(G)?G:G&&G.activity||[]),N(null)}catch(G){N(ke(G))}},[]),ce=he(async(G=!1)=>{try{_(await mr({refresh:G}))}catch(Ce){_({error:ke(Ce)})}},[]),j=he(()=>{z(),D(),J(),X()},[z,D,J,X]);pt(()=>{j(),ce(!1)},[j,ce]),pt(()=>{if(!e)return;let G=setInterval(j,1e4);return()=>clearInterval(G)},[e,j]);let ve=n||{},se=Ya(c),me=x[0],F=i?n?"Stale":"Unavailable":n?ve.health||"Healthy":"Checking";return l.createElement("div",{className:"space-y-4"},l.createElement(Te,{className:"hr-service-card"},l.createElement(_e,{className:"hr-service-header"},l.createElement("div",null,l.createElement("div",{className:"flex flex-wrap items-center gap-2"},l.createElement(Ae,null,"Service status"),l.createElement(M,{variant:i?"warning":n?"success":"outline",className:"text-xs"},F)),l.createElement(Y,null,"Hermes-Relay service health and installed plugin version.")),l.createElement(S,{size:"sm",variant:"outline",onClick:()=>ce(!0)},"Check for updates")),l.createElement(Ee,{className:"hr-service-details"},l.createElement("div",null,l.createElement("span",null,"Version"),l.createElement("strong",null,ve.version||"\u2014")),l.createElement("div",null,l.createElement("span",null,"Uptime"),l.createElement("strong",null,Wn(ve.uptime_seconds))),l.createElement("div",null,l.createElement("span",null,"Refresh"),l.createElement("strong",null,e?"Live":"Paused")),b&&b.update_available?l.createElement(M,{variant:"warning",className:"text-xs"},b.latest," available"):null),i?l.createElement("div",{className:"hr-inline-error text-xs text-destructive"},i):null),l.createElement("div",{className:"grid gap-3 sm:grid-cols-2 lg:grid-cols-3"},l.createElement(Jr,{label:"Paired devices",value:n?ve.paired_device_count??ve.session_count??a.length:"\u2014",hint:m||"Hermes-Relay sessions"}),l.createElement(Jr,{label:"Remote access",value:se.value,hint:h||se.hint}),l.createElement(Jr,{label:"Last Bridge event",value:me?xe(me.sent_at):"\u2014",hint:A||(me?me.decision||"Recorded":"No recent activity")})),l.createElement("div",{className:"hr-overview-grid"},l.createElement(Te,null,l.createElement(_e,null,l.createElement(Ae,null,"Quick actions"),l.createElement(Y,null,"Connect the standard Dashboard first, then add Hermes-Relay capabilities.")),l.createElement(Ee,{className:"space-y-3"},l.createElement(S,{className:"w-full",onClick:()=>C(!0)},"Pair new device"),l.createElement(S,{className:"w-full",variant:"outline",onClick:t},"Connect mobile app"),a.length>0?l.createElement("div",{className:"hr-overview-device"},l.createElement("div",{className:"text-xs uppercase tracking-wider text-muted-foreground"},"Most recently paired"),l.createElement(Ai,{session:a[0],compact:!0})):null,l.createElement(S,{size:"sm",variant:"ghost",onClick:()=>r("devices")},"Manage devices"))),l.createElement(Za,{rows:x,error:A,onOpenActivity:()=>r("activity")})),e?null:l.createElement(S,{size:"sm",variant:"outline",onClick:j},"Refresh overview"),l.createElement(nr,{open:E,onClose:()=>{C(!1),D(),z()}}))}function Wr({autoRefresh:e,onConnectMobile:t}){let[r,n]=V([]),[s,i]=V(!0),[o,a]=V(null),[d,m]=V(!1),[f,c]=V(null),[y,h]=V(null),[w,x]=V(null),{toast:T,showToast:A}=ki(),N=he(async()=>{try{n(_i(await cr())),a(null)}catch(C){a(ke(C))}finally{i(!1)}},[]);pt(()=>{N()},[N]),pt(()=>{if(!e)return;let C=setInterval(N,1e4);return()=>clearInterval(C)},[e,N]);let b=he(async C=>{if(C)try{await navigator.clipboard.writeText(C),h(C),A("Token prefix copied","success"),window.setTimeout(()=>h(null),1500)}catch{A("Could not copy token prefix","error")}},[A]),_=he(async()=>{if(w){c(w.prefix);try{await An(w.prefix),A(`${w.label} revoked`,"success"),x(null),await N()}catch(C){A(`Revoke failed: ${ke(C)}`,"error")}finally{c(null)}}},[w,N,A]),E=r.some(C=>gr(C));return l.createElement("div",{className:"space-y-4"},l.createElement(Si,{toast:T}),l.createElement("div",{className:"hr-connection-choice-grid"},l.createElement(Te,null,l.createElement(_e,null,l.createElement(Ae,null,"Connect mobile app"),l.createElement(Y,null,"Standard Hermes Dashboard connection for Chat, Manage, sessions, and voice.")),l.createElement(Ee,null,l.createElement(S,{size:"sm",variant:"outline",onClick:t},"Show setup QR"))),l.createElement(Te,null,l.createElement(_e,null,l.createElement(Ae,null,"Pair with Hermes-Relay"),l.createElement(Y,null,"Add Terminal, Bridge, media, remote-access, and extended voice grants.")),l.createElement(Ee,null,l.createElement(S,{size:"sm",onClick:()=>m(!0)},"Pair new device")))),l.createElement(Te,null,l.createElement(_e,{className:"flex flex-row items-center justify-between space-y-0"},l.createElement("div",null,l.createElement(Ae,null,"Paired devices"),l.createElement(Y,null,"Clients currently authorized against Hermes-Relay.")),e?null:l.createElement(S,{size:"sm",variant:"outline",onClick:N},"Refresh")),l.createElement(Ee,null,E?l.createElement("div",{className:"mb-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground"},"Supervised Mode is reported and enforced by Hermes-Relay Android, not by the plugin service."):null,o&&r.length===0?l.createElement(ee,{variant:"destructive"},l.createElement(te,null,"Paired devices unavailable"),l.createElement(re,null,o)):s?l.createElement("div",{className:"text-sm text-muted-foreground"},"Loading paired devices\u2026"):r.length===0?l.createElement("div",{className:"hr-empty-state"},l.createElement("div",{className:"font-medium"},"No Hermes-Relay devices paired"),l.createElement("div",{className:"text-sm text-muted-foreground"},"Pair a device to enable Hermes-Relay capabilities.")):l.createElement("div",{className:"hr-device-list"},r.map((C,z)=>l.createElement(Ai,{key:Ti(C)||z,session:C,copied:y,revoking:f,onCopy:b,onRevoke:x}))),o&&r.length>0?l.createElement("div",{className:"mt-3 text-xs text-destructive"},"Refresh failed: ",o):null)),l.createElement(Fa,{open:!!w,title:"Revoke Hermes-Relay device",description:w?`${w.label} will lose Hermes-Relay access and must pair again.`:"This device will lose Hermes-Relay access.",confirmLabel:"Revoke",destructive:!0,loading:!!f,onCancel:()=>x(null),onConfirm:_}),l.createElement(nr,{open:d,onClose:()=>{m(!1),N()}}))}function Bi({autoRefresh:e}){let[t,r]=V("general"),[n,s]=V(null),[i,o]=V(null),[a,d]=V(null),[m,f]=V(null),[c,y]=V(null),[h,w]=V(null),{toast:x,showToast:T}=ki(),A=he(async()=>{try{s(await Tn()),o(null)}catch(C){o(ke(C))}},[]),N=he(async()=>{try{f(await _n()),y(null)}catch(C){y(ke(C))}},[]),b=he(async(C=!1)=>{try{w(await mr({refresh:C}))}catch(z){w({error:ke(z)})}},[]);pt(()=>{A(),N(),b(!1)},[A,N,b]),pt(()=>{if(!e)return;let C=setInterval(()=>{A(),N()},15e3);return()=>clearInterval(C)},[e,A,N]);let _=he(async(C,z)=>{d(C);try{await pr(C,z?"1":"0"),await A(),T("Hermes-Relay Agent Context updated","success")}catch(D){T(`Agent Context update failed: ${ke(D)}`,"error")}finally{d(null)}},[A,T]);return l.createElement("div",{className:"hr-settings-layout"},l.createElement(Si,{toast:x}),l.createElement("aside",{className:"hr-settings-nav","aria-label":"Hermes-Relay settings sections"},l.createElement("div",{className:"hr-settings-nav-label"},"Settings"),[{key:"general",label:"General"},{key:"push",label:"Push wake"},{key:"context",label:"Agent Context"},{key:"maintenance",label:"Maintenance"}].map(C=>l.createElement("button",{key:C.key,type:"button",className:`hr-settings-nav-item ${t===C.key?"active":""}`,"aria-current":t===C.key?"page":void 0,onClick:()=>r(C.key)},C.label))),l.createElement("div",{className:"hr-settings-content"},t==="general"?c&&!m?l.createElement(ee,{variant:"destructive"},l.createElement(te,null,"General settings unavailable"),l.createElement(re,null,c)):m&&m.enabled?l.createElement(Wa,{key:m.home_channel_name||"phone",config:m,onSaved:N}):l.createElement(Te,null,l.createElement(_e,null,l.createElement(Ae,null,"General"),l.createElement(Y,null,"Phone Home Channel is not enabled for this Hermes-Relay installation."))):null,t==="push"?l.createElement(jr,{autoRefresh:e}):null,t==="context"?i&&!n?l.createElement(ee,{variant:"destructive"},l.createElement(te,null,"Agent Context unavailable"),l.createElement(re,null,i)):n?l.createElement(Ja,{data:n,saving:a,onToggle:_}):l.createElement("div",{className:"text-sm text-muted-foreground"},"Loading Agent Context\u2026"):null,t==="maintenance"?h?l.createElement(Qa,{info:h,onRefresh:b}):l.createElement("div",{className:"text-sm text-muted-foreground"},"Checking Hermes-Relay version\u2026"):null))}var Qr=window.__HERMES_PLUGIN_SDK__,{React:L}=Qr,{useState:At,useEffect:Pi,useCallback:Ra,useMemo:el}=Qr.hooks,{Card:tl,CardHeader:rl,CardTitle:nl,CardContent:sl}=Qr.components,il=["All","Executed","Blocked","Confirmed","Timeout","Error"],ol={executed:"default",blocked:"destructive",confirmed:"default",timeout:"secondary",error:"destructive",pending:"outline"};function al({decision:e}){let t=(e||"pending").toLowerCase(),r=ol[t]||"outline";return L.createElement(M,{variant:r,className:"text-xs capitalize"},t)}function ll(e){if(!e||typeof e!="object")return"\u2014";try{return JSON.stringify(e,null,2)}catch{return String(e)}}function Yr({autoRefresh:e}){let[t,r]=At(null),[n,s]=At(!0),[i,o]=At(null),[a,d]=At("All"),[m,f]=At({}),c=Ra(async()=>{o(null);try{let h=await Ht(100),w=Array.isArray(h)?h:h&&h.activity||[];r(w)}catch(h){o(h&&h.message?h.message:String(h))}finally{s(!1)}},[]);Pi(()=>{c()},[c]),Pi(()=>{if(!e)return;let h=setInterval(c,5e3);return()=>clearInterval(h)},[e,c]);let y=el(()=>{if(!t)return[];if(a==="All")return t;let h=a.toLowerCase();return t.filter(w=>(w.decision||"").toLowerCase()===h)},[t,a]);return n?L.createElement("div",{className:"text-sm text-muted-foreground"},"Loading bridge activity\u2026"):i?L.createElement(ee,{variant:"destructive"},L.createElement(te,null,"Hermes-Relay unreachable"),L.createElement(re,null,L.createElement("pre",{className:"whitespace-pre-wrap text-xs"},i),e?null:L.createElement(S,{className:"mt-2",size:"sm",variant:"outline",onClick:c},"Retry"))):L.createElement(tl,null,L.createElement(rl,null,L.createElement(nl,null,"Bridge activity"),L.createElement(Y,null,"Most recent Bridge commands routed through Hermes-Relay. Newest first; capped at 100.")),L.createElement(sl,null,L.createElement("div",{className:"mb-3 flex flex-wrap items-center gap-2"},il.map(h=>L.createElement(S,{key:h,size:"sm",variant:a===h?"default":"outline",onClick:()=>d(h)},h)),e?null:L.createElement(S,{size:"sm",variant:"ghost",className:"ml-auto",onClick:c},"Refresh")),y.length===0?L.createElement("div",{className:"text-sm text-muted-foreground"},t&&t.length===0?"No bridge commands recorded yet.":`No entries match "${a}".`):L.createElement(lt,null,L.createElement(dt,null,L.createElement(Pe,null,L.createElement(ae,null,"Sent"),L.createElement(ae,null,"Method"),L.createElement(ae,null,"Path"),L.createElement(ae,null,"Decision"),L.createElement(ae,null,"Status"),L.createElement(ae,null,"Error"))),L.createElement(ut,null,y.map((h,w)=>{let x=h.request_id||`${h.sent_at}-${w}`,T=!!m[x];return L.createElement(L.Fragment,{key:x},L.createElement(Pe,{className:"cursor-pointer",onClick:()=>f(A=>({...A,[x]:!A[x]}))},L.createElement(ne,null,xe(h.sent_at)),L.createElement(ne,{className:"font-mono text-xs"},h.method||"\u2014"),L.createElement(ne,{className:"font-mono text-xs"},h.path||"\u2014"),L.createElement(ne,null,L.createElement(al,{decision:h.decision})),L.createElement(ne,null,h.response_status??"\u2014"),L.createElement(ne,{className:"max-w-xs truncate text-xs text-destructive"},h.error||"")),T?L.createElement(Pe,null,L.createElement(ne,{colSpan:6,className:"bg-muted/30"},L.createElement("div",{className:"space-y-2 py-2 text-xs"},L.createElement("div",null,L.createElement("span",{className:"font-semibold"},"request_id:")," ",L.createElement("span",{className:"font-mono"},h.request_id||"\u2014")),L.createElement("div",null,L.createElement("span",{className:"font-semibold"},"params (redacted):"),L.createElement("pre",{className:"mt-1 whitespace-pre-wrap rounded bg-background p-2 font-mono"},ll(h.params))),h.result_summary?L.createElement("div",null,L.createElement("span",{className:"font-semibold"},"result:")," ",L.createElement("span",null,h.result_summary)):null))):null)})))))}var Zr=window.__HERMES_PLUGIN_SDK__,{React:q}=Zr,{useState:Et,useEffect:Xr,useCallback:dl}=Zr.hooks,{Card:ul,CardHeader:cl,CardTitle:ml,CardContent:pl,Label:fl}=Zr.components;function Rr({autoRefresh:e}){let[t,r]=Et(null),[n,s]=Et(!0),[i,o]=Et(null),[a,d]=Et(!1),[m,f]=Et(Date.now()),c=dl(async()=>{o(null);try{let h=await wn({includeExpired:a}),w=Array.isArray(h)?h:h&&h.media||[];r(w)}catch(h){o(h&&h.message?h.message:String(h))}finally{s(!1)}},[a]);if(Xr(()=>{c()},[c]),Xr(()=>{if(!e)return;let h=setInterval(c,15e3);return()=>clearInterval(h)},[e,c]),Xr(()=>{let h=setInterval(()=>f(Date.now()),1e3);return()=>clearInterval(h)},[]),n&&t===null)return q.createElement("div",{className:"text-sm text-muted-foreground"},"Loading media registry\u2026");if(i)return q.createElement(ee,{variant:"destructive"},q.createElement(te,null,"Hermes-Relay unreachable"),q.createElement(re,null,q.createElement("pre",{className:"whitespace-pre-wrap text-xs"},i),e?null:q.createElement(S,{className:"mt-2",size:"sm",variant:"outline",onClick:c},"Retry")));let y=t||[];return q.createElement(ul,null,q.createElement(cl,null,q.createElement(ml,null,"Media tokens"),q.createElement(Y,null,"Diagnostic view of token-backed Hermes-Relay media. Bare-path deliveries are not included.")),q.createElement(pl,null,q.createElement("div",{className:"mb-3 flex items-center gap-3"},q.createElement("div",{className:"flex items-center gap-2"},q.createElement(qe,{id:"include-expired",checked:a,onCheckedChange:d}),q.createElement(fl,{htmlFor:"include-expired"},"Include expired")),e?null:q.createElement(S,{size:"sm",variant:"ghost",className:"ml-auto",onClick:c},"Refresh")),y.length===0?q.createElement("div",{className:"text-sm text-muted-foreground"},a?"No media tokens registered.":"No active media tokens. Toggle 'Include expired' to see recently expired entries."):q.createElement(lt,null,q.createElement(dt,null,q.createElement(Pe,null,q.createElement(ae,null,"File"),q.createElement(ae,null,"Type"),q.createElement(ae,null,"Size"),q.createElement(ae,null,"Created"),q.createElement(ae,null,"TTL"),q.createElement(ae,null,"Token"))),q.createElement(ut,null,y.map(h=>{let w=!!h.is_expired,x=w?"expired":rt(h.expires_at,m);return q.createElement(Pe,{key:h.token||`${h.file_name}-${h.created_at}`},q.createElement(ne,{className:"max-w-xs truncate font-mono text-xs"},h.file_name||"\u2014"),q.createElement(ne,{className:"text-xs"},h.content_type||"\u2014"),q.createElement(ne,{className:"text-xs"},Jn(h.size)),q.createElement(ne,{className:"text-xs"},xe(h.created_at)),q.createElement(ne,null,q.createElement(M,{variant:w?"secondary":"outline",className:"text-xs"},x)),q.createElement(ne,{className:"font-mono text-xs text-muted-foreground"},zt(h.token,8)))})))))}var Li=window.__HERMES_PLUGIN_SDK__,{React:Qe}=Li,{Tabs:hl,TabsList:gl,TabsTrigger:vl}=Li.components,bl=[{key:"bridge",label:"Bridge activity"},{key:"media",label:"Media tokens"}];function en({autoRefresh:e}){return Qe.createElement(hl,{defaultValue:"bridge",className:"hr-activity-tabs"},(t,r)=>Qe.createElement(Qe.Fragment,null,Qe.createElement(gl,{className:"hr-activity-tablist","aria-label":"Hermes-Relay activity views"},bl.map(n=>Qe.createElement(vl,{key:n.key,active:t===n.key,value:n.key,onClick:()=>r(n.key)},n.label))),t==="bridge"?Qe.createElement(Yr,{autoRefresh:e}):Qe.createElement(Rr,{autoRefresh:e})))}var Mi=ur(Xt());var tn=window.__HERMES_PLUGIN_SDK__,{React:P}=tn,{useState:Ye,useRef:yl,useEffect:xl}=tn.hooks,{Input:Di,Label:Ii}=tn.components;function rn({status:e,onPair:t,onInvalidateInvite:r,pairingBusy:n=!1}){let s=(()=>{try{return new URL(e.url)}catch{return null}})(),[i,o]=Ye(!1),[a,d]=Ye(s?.hostname||""),[m,f]=Ye(s?.port||"9443"),[c,y]=Ye(null),[h,w]=Ye(""),[x,T]=Ye(!1),[A,N]=Ye(!1),b=yl(0);xl(()=>{b.current+=1,y(null),T(!1),r?.()},[e.url]);let _=(D,J)=>{b.current+=1,D(J),y(null),w(""),N(!1),T(!1),r?.()},E=async()=>{let D=++b.current;o(!0),T(!0),w(""),N(!1),r?.();try{let J=await En({host:a.trim()||void 0,port:m});if(D!==b.current)return;if(J?.schema_version!==1||!Array.isArray(J.checks))throw new Error("Relay returned an unsupported setup report.");y(J),!a.trim()&&J.host&&d(J.host)}catch(J){if(D!==b.current)return;y(null),w(`Setup checks unavailable. Confirm Relay is running and supports Secure Link setup checks. ${J.message||""}`)}finally{D===b.current&&T(!1)}},C=Object.entries(c?.environment||{}).map(([D,J])=>`${D}=${J}`).join(` +`),z=async()=>{try{await navigator.clipboard.writeText(C),N(!0)}catch{w("Clipboard access is unavailable. Select and copy the settings below.")}};return P.createElement("div",{className:"space-y-3"},i?P.createElement("div",{className:"space-y-4 border-t border-border pt-3"},P.createElement("div",{className:"space-y-2"},P.createElement("h4",{className:"text-sm font-semibold"},"1. Check this server"),P.createElement("p",{className:"text-xs text-muted-foreground"},"Checks are read-only. They do not change settings, create keys, or restart services."),P.createElement("div",{className:"grid gap-3 sm:grid-cols-2"},P.createElement("div",{className:"space-y-1"},P.createElement(Ii,{htmlFor:"secure-link-host"},"Address the phone will use"),P.createElement(Di,{id:"secure-link-host",value:a,placeholder:"192.168.1.20 or relay.example",onChange:D=>_(d,D.target.value)})),P.createElement("div",{className:"space-y-1"},P.createElement(Ii,{htmlFor:"secure-link-port"},"HTTPS port"),P.createElement(Di,{id:"secure-link-port",value:m,inputMode:"numeric",onChange:D=>_(f,D.target.value)}))),P.createElement(S,{size:"sm",variant:"outline",disabled:x,onClick:E},x?"Checking\u2026":"Check again")),h?P.createElement(ee,{variant:"destructive"},P.createElement(te,null,"Check needed"),P.createElement(re,null,h)):null,c?P.createElement(P.Fragment,null,P.createElement("div",{role:"list",className:"space-y-2","aria-live":"polite"},c.checks.map(D=>P.createElement("div",{role:"listitem",key:D.id,className:"rounded-md border border-border p-3 space-y-1"},P.createElement("div",{className:"flex items-center justify-between gap-2 text-sm"},P.createElement("span",null,D.label),P.createElement(M,{variant:D.status==="blocked"?"destructive":"outline"},D.status==="ok"?"Checked":D.status==="warning"?"Optional / unavailable":"Needs attention")),P.createElement("p",{className:"text-xs text-muted-foreground"},D.detail)))),c.ready_to_enable&&!c.pairing_ready?P.createElement("div",{className:"space-y-2"},P.createElement("h4",{className:"text-sm font-semibold"},"2. Review and enable"),P.createElement("p",{className:"font-mono text-xs break-all"},c.url),c.requires_repair?P.createElement("p",{className:"text-xs text-muted-foreground"},"This changes the paired address or port. Existing clients must re-pair after activation; no certificate is replaced by this check."):null,P.createElement("p",{className:"text-xs text-muted-foreground"},"Add these settings to the existing Relay environment. Restart its owner, then choose Check again. Do not start a second Relay."),P.createElement("p",{className:"text-xs text-muted-foreground"},c.connected_clients," Relay client(s) connected. ",c.restart_notice),P.createElement("p",{className:"text-xs text-muted-foreground"},c.configuration_note),P.createElement("pre",{className:"rounded-md bg-muted/20 p-3 text-xs whitespace-pre-wrap break-all select-text"},C),P.createElement(S,{size:"sm",variant:"outline",onClick:z},A?"Copied":"Copy settings"),P.createElement("details",{className:"text-xs text-muted-foreground"},P.createElement("summary",{className:"cursor-pointer"},"Foreground / CLI startup"),P.createElement("p",{className:"mt-2"},"Add to your existing ",P.createElement("code",null,"hermes relay start")," command, preserving its other arguments:"),P.createElement("pre",{className:"mt-2 whitespace-pre-wrap break-all select-text"},c.start_arguments.join(" ")))):null,c.pairing_ready?P.createElement("div",{className:"space-y-2"},P.createElement("h4",{className:"text-sm font-semibold"},"3. Pair a device"),P.createElement("p",{className:"font-mono text-xs break-all"},c.current_url),P.createElement("p",{className:"text-xs text-muted-foreground"},"Secure Link is listening. Create a fresh signed QR below, scan it in Android, then sign into Dashboard. Chat becomes ready only after Gateway connects. Existing devices must re-pair to import this certificate and pin."),P.createElement(S,{disabled:n||x,onClick:()=>t(c.current_url)},n?"Creating invite\u2026":"Create pairing QR")):null,P.createElement("details",{className:"text-xs text-muted-foreground"},P.createElement("summary",{className:"cursor-pointer"},"Disable or recover"),P.createElement("p",{className:"mt-2"},"Set ",P.createElement("code",null,"RELAY_SECURE_LINK_ENABLED=0")," (or replace the startup flag with ",P.createElement("code",null,"--no-secure-link"),"), then restart Relay using its existing manager. Keep the certificate and key if you intend to re-enable the same route. Address or certificate changes require explicit re-pairing."),P.createElement("p",{className:"mt-2"},"This flow does not restart services or open firewall ports. If activation fails, restore the previous Relay settings and check its health before retrying."))):null):P.createElement(S,{variant:"outline",onClick:E},e.enabled?"Check Secure Link":"Set up Secure Link"))}var sn=window.__HERMES_PLUGIN_SDK__,{React:u}=sn,{useState:ge,useEffect:Bt,useCallback:Me,useMemo:dr,useRef:nn}=sn.hooks,{Card:Pt,CardHeader:Lt,CardTitle:Dt,CardContent:It,Input:wl,Label:Hi}=sn.components;function He({tone:e="muted",title:t}){let r={ok:"bg-emerald-500",warn:"bg-amber-500",bad:"bg-destructive",muted:"bg-muted-foreground/40"},n=r[e]||r.muted;return u.createElement("span",{title:t||"",className:`inline-block h-2.5 w-2.5 rounded-full ${n}`})}function $i(e){return e===!0?"ok":e===!1?"bad":"muted"}function Cl({status:e,onEnable:t,onDisable:r,busy:n,resultMessage:s}){let i=!!(e&&e.available),o=e&&e.serve_ports||[],a=e&&e.serve_services||{},d=a.dashboard||{},m=Number.isInteger(e&&e.recommended_listener_port)?e.recommended_listener_port:10443,f=si(d,m),c=a.api||{},y=a.legacy_relay||{},h=f.active,w=f.recommendedActive,x=f.migration443,T=f.migration9119,A=c.active===!0,N=y.active===!0,b=w,_=e&&e.hostname||null,E=e&&e.tailscale_ip||null,C=e&&e.reason,z=D=>{let J=Array.isArray(D&&D.listen_ports)?D.listen_ports.filter(X=>Number.isInteger(X)):[];return J.length>0?`tailnet ${J.map(X=>`:${X}`).join(", ")}`:"tailnet"};return u.createElement(Pt,null,u.createElement(Lt,null,u.createElement("div",{className:"flex flex-wrap items-center gap-2"},u.createElement(Dt,null,"Tailscale Serve"),u.createElement(M,{className:"text-xs"},"Recommended"),u.createElement(M,{variant:"outline",className:"text-xs"},"Remote access")),u.createElement(Y,null,"The easiest supported way to reach this self-hosted Hermes installation away from home. Tailscale supplies private routing, WireGuard encryption, and ACLs. Raw tailnet HTTP/WS has no application TLS, but traffic remains encrypted between tailnet devices; Hermes authentication still applies. Recommended setup maps dedicated tailnet HTTPS :",f.recommendedPort,"to local Dashboard :9119, avoiding conflicts with Traefik, Caddy, nginx, or another service that already owns :443.")),u.createElement(It,{className:"space-y-3"},u.createElement("div",{className:"flex flex-wrap items-center gap-3 text-sm"},u.createElement("div",{className:"flex items-center gap-2"},u.createElement(He,{tone:i?"ok":"muted",title:i?"CLI present":"CLI absent"}),u.createElement("span",null,"CLI: ",i?"installed":"not installed")),u.createElement("div",{className:"flex items-center gap-2"},u.createElement(He,{tone:w?"ok":i?"warn":"muted"}),u.createElement("span",null,"Dashboard \u2192 host :9119: ",w?`HTTPS :${f.recommendedPort} active \xB7 recommended`:x||T?`HTTPS :${f.recommendedPort} off \xB7 old listeners do not satisfy recommended setup`:"off")),x?u.createElement("div",{className:"flex items-center gap-2"},u.createElement(He,{tone:"warn"}),u.createElement("span",null,"Shared HTTPS listener :443: active \xB7 migration or explicit route")):null,T?u.createElement("div",{className:"flex items-center gap-2"},u.createElement(He,{tone:"warn"}),u.createElement("span",null,"Old Dashboard listener :9119: active \xB7 migration compatibility")):null,u.createElement("div",{className:"flex items-center gap-2"},u.createElement(He,{tone:A?"ok":"muted"}),u.createElement("span",null,"Direct API \u2192 host :8642: ",A?`active on ${z(c)} \xB7 optional`:"off")),u.createElement("div",{className:"flex items-center gap-2"},u.createElement(He,{tone:N?"warn":"muted"}),u.createElement("span",null,"Direct Relay \u2192 host :8767: ",N?`active on ${z(y)} \xB7 legacy compatibility`:"off"))),_?u.createElement("div",{className:"rounded-md border border-border bg-muted/20 px-3 py-2 text-xs space-y-1"},u.createElement("div",null,u.createElement("span",{className:"uppercase tracking-wider text-muted-foreground"},"Hostname")," ",u.createElement("span",{className:"font-mono"},_)),E?u.createElement("div",null,u.createElement("span",{className:"uppercase tracking-wider text-muted-foreground"},"Tailscale IP")," ",u.createElement("span",{className:"font-mono"},E)):null,o.length>0?u.createElement("div",{className:"flex flex-wrap gap-1 items-center"},u.createElement("span",{className:"uppercase tracking-wider text-muted-foreground"},"Serving"),o.map(D=>u.createElement(M,{key:D,variant:"outline",className:"text-xs"},D))):null):C?u.createElement("div",{className:"rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground"},C):null,u.createElement("div",{className:"flex flex-wrap gap-2"},u.createElement(S,{size:"sm",disabled:n||!i||b,onClick:()=>t()},n==="enable"?"Enabling\u2026":`Enable HTTPS :${f.recommendedPort} ingress`),w&&!A?u.createElement(S,{size:"sm",variant:"outline",disabled:n||!i,onClick:()=>t(8642)},"Enable optional API :8642"):null,u.createElement(S,{size:"sm",variant:"outline",disabled:n||!i||!h&&!A&&!N,onClick:()=>r()},n==="disable"?"Disabling\u2026":"Disable managed routes"),N?u.createElement(S,{size:"sm",variant:"outline",disabled:n||!i,onClick:()=>r(8767)},"Disable legacy :8767 after re-pairing"):null,x?u.createElement(S,{size:"sm",variant:"outline",disabled:n||!i,onClick:()=>r(443)},"Disable old :443 listener after re-pairing"):null,T?u.createElement(S,{size:"sm",variant:"outline",disabled:n||!i,onClick:()=>r(9119)},"Disable old :9119 listener after re-pairing"):null),u.createElement("p",{className:"text-xs text-muted-foreground"},"New pairing invites use the classified tailnet listener above \u2014 normally HTTPS :",f.recommendedPort," \u2014 which proxies local Dashboard :9119 and its same-origin Relay path. Keep old :443, :9119, and :8767 listeners only until affected devices have re-paired."),s?u.createElement("div",{className:"text-xs text-muted-foreground whitespace-pre-wrap"},s):null))}function Nl({status:e,onPair:t,onInvalidateInvite:r,pairingBusy:n}){let s=!!(e&&e.enabled),i=e&&e.url,o=Array.isArray(e&&e.surfaces)?e.surfaces:[],a=e&&e.reason;return u.createElement(Pt,null,u.createElement(Lt,null,u.createElement("div",{className:"flex flex-wrap items-center gap-2"},u.createElement(Dt,null,"Hermes Secure Link"),u.createElement(M,{variant:"outline",className:"text-xs"},"Secure ingress")),u.createElement(Y,null,"Pairing-pinned TLS for Hermes services. Secure Link authenticates the route but does not create internet reachability or perform NAT traversal; use LAN, Tailscale, a VPN, or a public route to reach this host.")),u.createElement(It,{className:"space-y-3"},u.createElement("div",{className:"flex flex-wrap items-center gap-2 text-sm"},u.createElement(He,{tone:s?"ok":"muted"}),u.createElement("span",null,s?"Enabled":e?.state==="unavailable"?"Needs attention":e?.state==="unknown"?"Not checked":"Not enabled"),s?u.createElement(M,{variant:"outline"},"Pinned TLS"):null),i?u.createElement("div",{className:"rounded-md border border-border bg-muted/20 px-3 py-2 text-xs space-y-1"},u.createElement("div",null,u.createElement("span",{className:"uppercase tracking-wider text-muted-foreground"},"Advertised route")," ",u.createElement("span",{className:"font-mono break-all"},i)),o.length>0?u.createElement("div",{className:"flex flex-wrap gap-1 items-center"},u.createElement("span",{className:"uppercase tracking-wider text-muted-foreground"},"Services"),o.map(d=>u.createElement(M,{key:d,variant:"outline",className:"text-xs capitalize"},d))):null):a?u.createElement("div",{className:"rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground"},a):null,u.createElement("p",{className:"text-xs text-muted-foreground"},"When enabled, new pairing invites include Secure Link alongside other reachable candidates. Existing devices must re-pair to trust its certificate pin."),u.createElement(rn,{status:e||{},onPair:t,onInvalidateInvite:r,pairingBusy:n})))}function Sl({status:e}){let t=e&&e.reach;return!t||!t.enabled&&t.state==="disabled"?null:u.createElement("details",{className:"rounded-lg border border-dashed border-border bg-muted/10 px-4 py-3"},u.createElement("summary",{className:"cursor-pointer list-none flex flex-wrap items-center gap-2 text-sm font-medium"},u.createElement("span",null,"Hermes Reach"),u.createElement(M,{variant:"outline",className:"text-xs"},"Experimental"),u.createElement("span",{className:"ml-auto text-xs text-muted-foreground"},t.state||"disabled")),u.createElement("p",{className:"mt-3 text-xs text-muted-foreground"},"Advanced brokered fallback for evaluation. Reach is disabled by default, tried after supported routes, and not recommended instead of Tailscale."),t.last_error?u.createElement("pre",{className:"mt-2 whitespace-pre-wrap text-xs text-destructive"},t.last_error):null)}function kl({initialUrl:e,onSaved:t}){let[r,n]=ge(e||""),[s,i]=ge(!1),[o,a]=ge(null),[d,m]=ge(null),[f,c]=ge({reachable:null,status:null,latency_ms:null,error:null,at:null}),[y,h]=ge(!1),w=dr(()=>Fr(r),[r]),x=w.kind==="invalid"?"Enter a complete HTTP(S) URL without credentials, query parameters, or a fragment.":w.url&&!w.url.startsWith("https://")?"Public routes must use HTTPS.":null;Bt(()=>{n(e||"")},[e]);let T=Me(async()=>{a(null),i(!0);try{let N=r.trim();if(N&&x){a(x);return}let _=await Ln(N===""?null:N,{legacyDirectRelay:w.kind==="legacy-relay-path"});m(Date.now()),t&&t(_&&_.url?_.url:null)}catch(N){a(N&&N.message?N.message:String(N))}finally{i(!1)}},[w.kind,r,x,t]),A=Me(async()=>{let N=r.trim();if(!(!N||x)){h(!0);try{let b=await ft([{role:"public",priority:0,surface:w.kind==="legacy-relay-path"?"relay":"dashboard",url:N}]),_=b&&Array.isArray(b.results)&&b.results[0]||{};c({reachable:_.reachable==null?null:!!_.reachable,status:_.status??null,latency_ms:_.latency_ms??null,error:_.error||null,at:Date.now()})}catch(b){c({reachable:!1,status:null,latency_ms:null,error:b&&b.message?b.message:String(b),at:Date.now()})}finally{h(!1)}}},[w.kind,r,x]);return u.createElement(Pt,null,u.createElement(Lt,null,u.createElement("div",{className:"flex flex-wrap items-center gap-2"},u.createElement(Dt,null,"Public Dashboard origin"),w.kind==="legacy-relay-path"?u.createElement(M,{variant:"outline",className:"text-xs"},"Legacy Relay proxy path"):null),u.createElement(Y,null,"HTTPS origin for the Dashboard, Gateway, and same-origin Hermes-Relay transport (for example a reverse proxy or Cloudflare Tunnel). This becomes the next pairing QR's ",u.createElement("code",{className:"font-mono"},"role=public")," route.")),u.createElement(It,{className:"space-y-3"},u.createElement("div",{className:"space-y-1"},u.createElement(Hi,{htmlFor:"public-url"},"Dashboard origin"),u.createElement(wl,{id:"public-url",value:r,placeholder:"https://hermes.example.com",onChange:N=>n(N.target.value)}),u.createElement("p",{className:"text-xs text-muted-foreground"},"Leave empty to clear. Use an HTTPS Dashboard origin for new setups. An explicit path ending in the Relay transport is recognized as legacy reverse-proxy compatibility, not as a Dashboard origin. Direct port",u.createElement("code",{className:"font-mono"}," :8767")," is not advertised for new pairing.")),x?u.createElement("div",{className:"rounded-md border border-destructive/50 bg-destructive/10 p-2 text-xs text-destructive"},x):null,o?u.createElement("div",{className:"rounded-md border border-destructive/50 bg-destructive/10 p-2 text-xs text-destructive"},o):null,u.createElement("div",{className:"flex flex-wrap items-center gap-2"},u.createElement(S,{size:"sm",onClick:T,disabled:s||!!x},s?"Saving\u2026":"Save"),u.createElement(S,{size:"sm",variant:"outline",onClick:A,disabled:y||!r.trim()||!!x},y?"Probing\u2026":"Probe route"),d?u.createElement("span",{className:"text-xs text-muted-foreground"},"Saved ",xe(d)):null),f.at!=null?u.createElement("div",{className:"flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs"},u.createElement(He,{tone:$i(f.reachable)}),u.createElement("span",null,f.reachable===!0?"reachable":f.reachable===!1?"unreachable":"unknown"),f.status!=null?u.createElement(M,{variant:"outline",className:"text-xs"},"HTTP ",f.status):null,f.latency_ms!=null?u.createElement("span",{className:"text-muted-foreground"},f.latency_ms,"ms"):null,u.createElement("span",{className:"text-muted-foreground ml-auto"},"checked ",xe(f.at)),f.error?u.createElement("div",{className:"basis-full text-destructive"},f.error):null):null))}function Tl({endpoints:e,reachability:t,onProbe:r,onRegenerate:n,busy:s,qrPayload:i,pairingUrl:o,preferRole:a,onPreferChange:d,blockingIssues:m}){let f=nn(null),[c,y]=ge("");Bt(()=>{!i||!f.current||Mi.default.toCanvas(f.current,i,Nt()).catch(()=>{})},[i]);let h=dr(()=>{let x=new Map;return(t||[]).forEach(T=>{x.set(We(T),T)}),x},[t]),w=Me(async()=>{let x=o||i;if(x)try{await navigator.clipboard.writeText(x),y("Copied invite URL")}catch{y("Copy failed; select the URL manually")}},[o,i]);return u.createElement(Pt,null,u.createElement(Lt,null,u.createElement(Dt,null,"Endpoint preview"),u.createElement(Y,null,"Candidates the next ",u.createElement("code",{className:"font-mono"},"mode=auto")," QR would embed. Lower priority = higher preference on the phone.")),u.createElement(It,{className:"space-y-3"},m&&m.length>0?u.createElement(ee,{variant:"destructive"},u.createElement(te,null,"Unsafe pairing route blocked"),u.createElement(re,null,u.createElement("ul",{className:"list-disc pl-4 text-xs space-y-1"},m.map(x=>u.createElement("li",{key:x},x))))):null,e.length===0?u.createElement("div",{className:"text-sm text-muted-foreground"},"No candidates detected. Enable Tailscale and/or pin a public URL above."):u.createElement(lt,null,u.createElement(dt,null,u.createElement(Pe,null,u.createElement(ae,null,"Role"),u.createElement(ae,null,"Surface"),u.createElement(ae,null,"Resolved URL"),u.createElement(ae,null,"Priority"),u.createElement(ae,null,"Reachable"))),u.createElement(ut,null,e.map(x=>{let T=h.get(We(x)),A=tr(T);return u.createElement(Pe,{key:We(x)},u.createElement(ne,null,u.createElement(M,{variant:"outline",className:"text-xs capitalize"},x.role)),u.createElement(ne,{className:"text-xs capitalize"},x.surface),u.createElement(ne,{className:"font-mono text-xs"},x.url),u.createElement(ne,{className:"text-xs"},x.priority??"\u2014"),u.createElement(ne,null,u.createElement("div",{className:"flex items-center gap-1"},u.createElement(He,{tone:$i(A.healthy)}),u.createElement("span",{className:"text-xs"},T?A.label:"\u2014"))))}))),u.createElement("div",{className:"flex flex-wrap gap-2"},u.createElement(S,{size:"sm",variant:"outline",onClick:r,disabled:s||e.length===0||m&&m.length>0},s==="probe"?"Probing\u2026":"Probe all"),u.createElement(S,{size:"sm",onClick:n,disabled:s},s==="mint"?"Regenerating\u2026":"Regenerate QR")),u.createElement("div",{className:"flex items-center gap-2 text-xs text-muted-foreground"},u.createElement(Hi,{htmlFor:"prefer-role",className:"whitespace-nowrap"},"Prefer role:"),u.createElement("select",{id:"prefer-role",className:"h-8 rounded-md border border-border bg-background px-2 text-xs",value:a||"",onChange:x=>d&&d(x.target.value||null)},u.createElement("option",{value:""},"(natural order \u2014 Tailscale \u2192 Public \u2192 LAN)"),u.createElement("option",{value:"lan"},"lan \u2192 priority 0"),u.createElement("option",{value:"tailscale"},"tailscale \u2192 priority 0"),u.createElement("option",{value:"public"},"public \u2192 priority 0")),u.createElement("span",{className:"whitespace-nowrap"},'Applies on the next "Regenerate QR".')),i&&(!m||m.length===0)?u.createElement("div",{className:"hr-qr-frame hr-pairing-qr flex flex-col items-center gap-2"},u.createElement("canvas",{ref:f,className:"block"}),u.createElement("p",{className:"text-xs text-muted-foreground"},"Scan from the Hermes-Relay Android app. Fresh payload, signed with the host's QR secret \u2014 embedding one-shot pairing code.")):null,o&&(!m||m.length===0)?u.createElement("div",{className:"rounded-md border border-border bg-muted/20 px-3 py-2 text-xs space-y-2"},u.createElement("div",{className:"uppercase tracking-wider text-muted-foreground"},"Copy/paste invite"),u.createElement("div",{className:"font-mono break-all"},o),u.createElement("div",{className:"flex items-center gap-2"},u.createElement(S,{size:"sm",variant:"outline",onClick:w},"Copy invite URL"),c?u.createElement("span",{className:"text-muted-foreground"},c):null)):null))}function on({autoRefresh:e}){let[t,r]=ge(null),[n,s]=ge(!0),[i,o]=ge(null),[a,d]=ge(null),[m,f]=ge(null),[c,y]=ge(null),[h,w]=ge([]),[x,T]=ge(null),[A,N]=ge(null),b=nn(0),_=nn(null);Bt(()=>{c?.qr_payload&&(_.current?.scrollIntoView({block:"start"}),_.current?.focus({preventScroll:!0}))},[c]);let E=Me(()=>{b.current+=1,y(null),d(F=>F==="mint"?null:F)},[]),C=Me(async()=>{o(null);try{let F=await $t();r(F||{});let O=F&&F.public&&F.public.url||null;T(O)}catch(F){o(F&&F.message?F.message:String(F))}finally{s(!1)}},[]);Bt(()=>{C()},[C]),Bt(()=>{if(!e)return;let F=setInterval(C,15e3);return()=>clearInterval(F)},[e,C]);let z=Me(async F=>{d("enable"),f(null);try{let O=await Bn(F);f(`${O&&O.ok?"Enabled":"Failed"}: ${O&&O.message?O.message:"(no message)"}`),await C()}catch(O){f(`Error: ${O&&O.message?O.message:O}`)}finally{d(null)}},[C]),D=Me(async F=>{d("disable"),f(null);try{let O=await Pn(F);f(`${O&&O.ok?"Disabled":"Failed"}: ${O&&O.message?O.message:"(no message)"}`),await C()}catch(O){f(`Error: ${O&&O.message?O.message:O}`)}finally{d(null)}},[C]),J=Me(async(F=null)=>{let O=++b.current;d("mint"),y(null);try{let G=typeof F=="string",Ce=G?`${F}/dashboard`:Je(window.location);if(!Ce)throw new Error("This Dashboard does not have a valid HTTP(S) origin for pairing.");let Xe=await Ut({mode:"auto",prefer:G?"plugin_proxy":A||void 0,dashboard_url:Ce,legacy_direct_relay:Fr(x||"").kind==="legacy-relay-path"});if(O!==b.current)return;if(G&&!JSON.parse(Xe.qr_payload).endpoints?.some(k=>k.proxy?.url===F))throw new Error("Secure Link changed. Check it again before pairing.");y(Xe||null)}catch(G){O===b.current&&y({error:G&&G.message?G.message:String(G)})}finally{O===b.current&&d(null)}},[A,x]),X=dr(()=>{if(!c||!c.qr_payload)return{routes:[],blockingIssues:[]};try{let F=JSON.parse(c.qr_payload);return Rt(F)}catch{return{routes:[],blockingIssues:["Pairing payload is not valid JSON"]}}},[c]),ce=dr(()=>X&&X.routes?er(X):[],[X]),j=Me(async()=>{if(ce.length!==0){d("probe");try{let F=await ft(ce);w(F&&F.results||[])}catch(F){w(ce.map(O=>({url:O.url,reachable:!1,status:null,latency_ms:null,error:F&&F.message?F.message:String(F)})))}finally{d(null)}}},[ce]);if(n)return u.createElement("div",{className:"text-sm text-muted-foreground"},"Loading remote access\u2026");if(i)return u.createElement(ee,{variant:"destructive"},u.createElement(te,null,"Dashboard backend unreachable"),u.createElement(re,null,u.createElement("pre",{className:"whitespace-pre-wrap text-xs"},i),e?null:u.createElement(S,{className:"mt-2",size:"sm",variant:"outline",onClick:C},"Retry")));let ve=t&&t.tailscale||{},se=t&&t.secure_link||{},me=!!(t&&t.upstream_canonical);return u.createElement("div",{className:"space-y-4"},u.createElement(ee,null,u.createElement(te,null,"Recommended remote-access setup"),u.createElement(re,null,"Start with ",u.createElement("strong",null,"Tailscale")," for private remote reachability. Operators with a domain or reachable WAN address can instead use a public TLS reverse proxy or ",u.createElement("strong",null,"Hermes Secure Link"),". Experimental broker routes are kept out of the normal setup path.")),me?u.createElement(ee,null,u.createElement(te,null,"Upstream helper detected"),u.createElement(re,null,"Upstream hermes-agent now ships ",u.createElement("code",{className:"font-mono"},"hermes gateway run --tailscale"),". The helper in this plugin is now redundant and will be removed in a future release.")):null,u.createElement(Cl,{status:ve,onEnable:z,onDisable:D,busy:a==="enable"||a==="disable"?a:null,resultMessage:m}),u.createElement(Nl,{status:se,onPair:J,onInvalidateInvite:E,pairingBusy:a==="mint"}),u.createElement(Sl,{status:se}),u.createElement(kl,{initialUrl:x,onSaved:F=>{T(F),C()}}),u.createElement("div",{ref:_,role:"region","aria-label":"Pairing invite",tabIndex:-1},u.createElement(Tl,{endpoints:ce,reachability:h,onProbe:j,onRegenerate:J,busy:a==="mint"||a==="probe"?a:null,qrPayload:c&&c.qr_payload?c.qr_payload:null,pairingUrl:c&&c.pairing_url?c.pairing_url:null,preferRole:A,onPreferChange:N,blockingIssues:X&&X.blockingIssues?X.blockingIssues:[]})),c&&c.error?u.createElement(ee,{variant:"destructive"},u.createElement(te,null,"QR regeneration failed"),u.createElement(re,null,u.createElement("pre",{className:"whitespace-pre-wrap text-xs"},c.error))):null,c&&c.code?u.createElement(Pt,null,u.createElement(Lt,null,u.createElement(Dt,null,"Pairing code"),u.createElement(Y,null,"One-shot code \u2014 expires in 10 minutes (or sooner, per session TTL).")),u.createElement(It,{className:"flex items-center justify-between gap-2"},u.createElement("div",{className:"font-mono text-2xl tracking-widest"},c.code),c.expires_at?u.createElement(M,{variant:"outline"},"expires ",xe(c.expires_at)):null)):null)}function _l(e){let t=e&&typeof e=="object"?e:{};return{counts:{staged:Number(t.counts&&t.counts.staged)||0,modified:Number(t.counts&&t.counts.modified)||0,untracked:Number(t.counts&&t.counts.untracked)||0},staged:Array.isArray(t.staged)?t.staged:[],modified:Array.isArray(t.modified)?t.modified:[],untracked:Array.isArray(t.untracked)?t.untracked:[],truncated:!!t.truncated}}function Al(e){return(Array.isArray(e)?e:e&&e.branches||[]).filter(r=>r&&typeof r.name=="string")}var an={discard:"discard",push:"push",dirtyCheckout:"checkout-dirty"},El=new Set(["discard","push","dirty-checkout"]);function ln(e){return El.has(e)}function Ui(e){return ln(e)?e==="discard"?an.discard:e==="push"?an.push:an.dirtyCheckout:null}function Bl(e){let t=e&&typeof e=="object"?e:{};return{head:typeof t.head=="string"?t.head:"",status:_l(t.status),branches:Al(t.branches)}}function zi(e){let t=e&&typeof e=="object"?e:{};return{message:typeof t.message=="string"?t.message:"",notice:typeof t.notice=="string"?t.notice:""}}function Fi(e){return!!(e&&e.message&&e.message.trim())}function be(e,t,r,n){return e===r&&t===n}function Oi(e,t){return e===!0&&t===!0}function qi(e){let t=e&&typeof e=="object"?e:{};return{...Bl(e),stashed:!!t.stashed,stashMessage:typeof t.stash_message=="string"?t.stash_message:""}}var cn=window.__HERMES_PLUGIN_SDK__,{React:v}=cn,{useState:fe,useEffect:ji,useCallback:Be,useRef:dn}=cn.hooks,{Card:Gi,CardHeader:Ki,CardTitle:Vi,CardContent:Ji,Label:Hu}=cn.components;function un({truncated:e}){return e?v.createElement("div",{className:"rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-600"},"Results truncated \u2014 showing the first entries only."):null}function Pl({status:e}){if(!e)return null;let t=e.counts||{};return v.createElement("div",{className:"space-y-2"},v.createElement("div",{className:"flex flex-wrap gap-2"},v.createElement(M,{variant:"outline",className:"text-xs"},t.staged||0," staged"),v.createElement(M,{variant:"outline",className:"text-xs"},t.modified||0," modified"),v.createElement(M,{variant:"outline",className:"text-xs"},t.untracked||0," untracked")),v.createElement(un,{truncated:e.truncated}),["staged","modified","untracked"].map(r=>{let n=e[r]||[];return n.length===0?null:v.createElement("div",{key:r},v.createElement("div",{className:"text-xs font-medium uppercase tracking-wide text-muted-foreground"},r),v.createElement("ul",{className:"mt-1 space-y-0.5"},n.map(s=>v.createElement("li",{key:`${r}-${s.path}`,className:"font-mono text-xs"},s.path))))}))}function Ll({branches:e}){return!e||e.length===0?null:v.createElement("div",{className:"space-y-1"},e.map(t=>v.createElement("div",{key:t.name,className:"flex items-center gap-2 text-xs"},v.createElement("span",{className:"font-mono"},t.name),t.is_current?v.createElement(M,{className:"text-xs"},"current"):null,t.upstream?v.createElement("span",{className:"text-muted-foreground"},"\u2192 ",t.upstream,t.ahead>0||t.behind>0?` (ahead ${t.ahead}, behind ${t.behind})`:""):null)))}function Dl({status:e,selected:t,commitMessage:r,onCommitMessageChange:n,generatingMessage:s,onGenerateMessage:i,commitNotice:o,newBranch:a,onNewBranchChange:d,branchRef:m,onBranchRefChange:f,mutating:c,pushAfterCommit:y,onPushAfterCommitChange:h,onStageAll:w,onUnstageAll:x,onDiscardAll:T,onStage:A,onUnstage:N,onDiscard:b,onCommit:_,onFetch:E,onPull:C,onPush:z,onCheckout:D,onStashCheckout:J}){let X=(e&&e.staged||[]).map(j=>j.path),ce=(e&&e.modified||[]).map(j=>j.path);return v.createElement("div",{className:"space-y-3 rounded-md border p-3"},v.createElement("div",{className:"text-xs font-medium uppercase tracking-wide text-muted-foreground"},"Write controls"),v.createElement("div",{className:"flex flex-wrap gap-2"},v.createElement(S,{size:"sm",variant:"outline",disabled:c||ce.length===0,onClick:w},"Stage modified"),v.createElement(S,{size:"sm",variant:"outline",disabled:c||X.length===0,onClick:x},"Unstage staged"),v.createElement(S,{size:"sm",variant:"outline",disabled:c||X.length===0,onClick:T},"Discard staged")),v.createElement("div",{className:"space-y-2"},v.createElement("div",{className:"flex items-center gap-2"},v.createElement("input",{type:"text",value:r,onChange:j=>n(j.target.value),placeholder:"Commit message",className:"w-full rounded-md border px-2 py-1 text-xs"}),v.createElement(S,{size:"sm",variant:"outline",disabled:c||s||X.length===0,onClick:i,title:"Generate a commit message from the staged diff"},s?"Generating\u2026":"Generate")),o?v.createElement("div",{className:"text-xs text-amber-600"},o):null,v.createElement("div",{className:"flex items-center gap-2"},v.createElement("label",{className:"flex items-center gap-2 text-xs"},v.createElement("input",{type:"checkbox",checked:y,onChange:j=>h(j.target.checked)}),"Push after commit"),v.createElement(S,{size:"sm",disabled:c||!r.trim(),onClick:_},"Commit"))),v.createElement("div",{className:"flex flex-wrap items-center gap-2"},v.createElement("input",{type:"text",value:m,onChange:j=>f(j.target.value),placeholder:"Branch to switch to",className:"w-40 rounded-md border px-2 py-1 text-xs"}),v.createElement("input",{type:"text",value:a,onChange:j=>d(j.target.value),placeholder:"New branch name",className:"w-40 rounded-md border px-2 py-1 text-xs"}),v.createElement(S,{size:"sm",variant:"outline",disabled:c||!m.trim(),onClick:D},"Checkout"),v.createElement(S,{size:"sm",variant:"outline",disabled:c||!m.trim(),onClick:J,title:"Switch branches, auto-stashing a dirty tree first"},"Stash-checkout")),v.createElement("div",{className:"flex flex-wrap gap-2"},v.createElement(S,{size:"sm",variant:"outline",disabled:c,onClick:E},"Fetch"),v.createElement(S,{size:"sm",variant:"outline",disabled:c,onClick:C},"Pull"),v.createElement(S,{size:"sm",variant:"destructive",disabled:c,onClick:z},"Push")))}function mn({autoRefresh:e}){let[t,r]=fe(null),[n,s]=fe(null),[i,o]=fe(null),[a,d]=fe(null),[m,f]=fe(null),[c,y]=fe(null),[h,w]=fe(!0),[x,T]=fe(null),[A,N]=fe(null),b=dn(null),_=dn(0),E=dn(!1),C=Be(async()=>{T(null);try{let g=await Dn(),U=g&&g.repos||[];r(U),N(g&&g.notice||null);let B=b.current;B&&!U.some(H=>H.id===B)&&(b.current=null,_.current+=1,s(null),o(null),d(null),f(null),y(null),k(!1),ie(null))}catch(g){T(g&&g.message?g.message:String(g))}finally{w(!1)}},[]);ji(()=>{C()},[C]),ji(()=>{if(!e)return;let g=setInterval(C,15e3);return()=>clearInterval(g)},[e,C]);let z=Be(async g=>{let U=_.current+1;_.current=U,b.current=g,s(g),o(null),d(null),f(null),y(null),k(!1),ie(null),T(null);try{let[B,H]=await Promise.all([fr(g),hr(g)]);if(!be(b.current,_.current,g,U))return;o(B),d(H&&H.branches)}catch(B){if(!be(b.current,_.current,g,U))return;T(B&&B.message?B.message:String(B))}},[]),D=Be(async(g,U)=>{let B=b.current;if(!B)return;let H=_.current;y(null),T(null);try{let de=await In(B,g,U);if(!be(b.current,_.current,B,H))return;f(de)}catch(de){if(!be(b.current,_.current,B,H))return;T(de&&de.message?de.message:String(de))}},[]),J=Be(async g=>{let U=b.current;if(!U)return;let B=_.current;f(null),T(null);try{let H=await Mn(U,g);if(!be(b.current,_.current,U,B))return;y(H)}catch(H){if(!be(b.current,_.current,U,B))return;T(H&&H.message?H.message:String(H))}},[]),[X,ce]=fe(""),[j,ve]=fe(""),[se,me]=fe(""),[F,O]=fe(!1),[G,Ce]=fe(null),[Xe,k]=fe(!1),[$,ie]=fe(null),[le,Ze]=fe(!1),Re=Be(async(g,U)=>{let[B,H]=await Promise.all([fr(g),hr(g)]);be(b.current,_.current,g,U)&&(o(B),d(H&&H.branches))},[]),Le=Be(async(g,U,B)=>{let H=b.current,de=_.current;if(!H||E.current)return!1;E.current=!0,Ce(null),O(!0);try{if(g==="stage")await Hn(H,U);else if(g==="unstage")await $n(H,U);else if(g==="fetch")await Fn(H,B?.remote||"origin");else if(g==="pull")await On(H,B?.remote||"origin",B?.branch||"");else if(g==="commit")await zn(H,B?.message);else if(g==="discard")await Un(H,U,B?.confirmation,B?.deleteUntracked);else if(g==="push")await qn(H,B?.confirmation,B?.remote||"origin",B?.branch||"");else if(g==="checkout"||g==="dirty-checkout")await jn(H,B.ref,{confirmation:B.confirmation,newBranch:B.newBranch,track:B.track});else throw new Error(`Unknown Git operation: ${g}`);return be(b.current,_.current,H,de)?(await Re(H,de),!0):!1}catch(et){return be(b.current,_.current,H,de)&&Ce(et&&et.message?et.message:String(et)),!1}finally{E.current=!1,O(!1)}},[Re]),io=Be(async()=>{let g=b.current;if(!g)return;let U=_.current;k(!0),ie(null);try{let B=(i&&i.staged||[]).map(et=>et.path),H=B.length>0&&!i?.truncated?await Kn(g,B):await Gn(g);if(!be(b.current,_.current,g,U))return;let de=zi(H);Fi(de)?(ce(de.message),ie(de.notice||null)):ie(de.notice||"Nothing staged to generate a message from.")}catch(B){if(!be(b.current,_.current,g,U))return;ie(B&&B.message?B.message:String(B))}finally{be(b.current,_.current,g,U)&&k(!1)}},[i]),oo=Be(async()=>{let g=se.trim(),U=b.current,B=_.current;if(!(!g||!U||E.current)){E.current=!0,Ce(null),ie(null),O(!0);try{let H=await Vn(U,g,{newBranch:j.trim(),track:!1});if(!be(b.current,_.current,U,B))return;let de=qi(H);de.stashed&&ie(`Stashed changes on ${g} as \u201C${de.stashMessage}\u201D. Use \u201Cgit stash pop\u201D to restore them.`),await Re(U,B)}catch(H){be(b.current,_.current,U,B)&&Ce(H&&H.message?H.message:String(H))}finally{E.current=!1,O(!1),me(""),ve("")}}},[se,j,Re]),ye=Be((g,U)=>{if(ln(g)){let B=g==="discard"?"Discard local changes? This cannot be undone.":g==="push"?"Push local commits to the remote repository?":"Working tree has uncommitted changes. Switch branches anyway?";if(!window.confirm(B))return;let H=Ui(g);g==="discard"?Le("discard",U?.paths,{confirmation:H,deleteUntracked:U?.deleteUntracked}):g==="push"?Le("push",[],{confirmation:H,remote:U?.remote,branch:U?.branch}):g==="dirty-checkout"&&Le("dirty-checkout",[],{...U,confirmation:H});return}Le(g,U?.paths,U)},[Le]),ao=Be(async()=>{let g=X.trim();if(!g){Ce("Commit message must not be empty.");return}let U=await Le("commit",[],{message:g});U&&(ce(""),Oi(U,le)&&ye("push",{}))},[X,i,Le,le,ye]),lo=Be(async()=>{let g=se.trim();if(!g)return;let U=i&&i.counts.modified+i.counts.staged+i.counts.untracked>0,B={ref:g,newBranch:j.trim(),track:!1};if(U&&!B.newBranch){ye("dirty-checkout",B),me(""),ve("");return}await Le("checkout",[],B),me(""),ve("")},[se,j,i,Le,ye]);if(h&&t===null)return v.createElement("div",{className:"text-sm text-muted-foreground"},"Loading repositories\u2026");if(x)return v.createElement(ee,{variant:"destructive"},v.createElement(te,null,"Git state unavailable"),v.createElement(re,null,v.createElement("pre",{className:"whitespace-pre-wrap text-xs"},x),v.createElement(S,{className:"mt-2",size:"sm",variant:"outline",onClick:C},"Retry")));let yn=t||[];return v.createElement("div",{className:"space-y-4"},v.createElement(Gi,null,v.createElement(Ki,null,v.createElement(Vi,null,"Repositories"),v.createElement(Y,null,"Repositories scanned from the configured Git base path.")),v.createElement(Ji,null,A?v.createElement("div",{className:"mb-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-600"},A):null,yn.length===0?v.createElement("div",{className:"text-sm text-muted-foreground"},"No repositories found."):v.createElement("div",{className:"flex flex-wrap gap-2"},yn.map(g=>v.createElement(S,{key:g.id,size:"sm",variant:n===g.id?"default":"outline",onClick:()=>z(g.id)},g.name,g.dirty?" \u2022":""))))),n?v.createElement(Gi,null,v.createElement(Ki,null,v.createElement(Vi,null,n),v.createElement(Y,null,"Working tree and branches. Tap a changed file to view its diff or content.")),v.createElement(Ji,{className:"space-y-4"},G?v.createElement(ee,{variant:"destructive"},v.createElement(te,null,"Git mutation failed"),v.createElement(re,null,v.createElement("pre",{className:"whitespace-pre-wrap text-xs"},G))):null,v.createElement(Pl,{status:i}),v.createElement(Ll,{branches:a}),v.createElement(Dl,{status:i,branches:a,selected:n,commitMessage:X,onCommitMessageChange:ce,generatingMessage:Xe,onGenerateMessage:io,commitNotice:$,newBranch:j,onNewBranchChange:ve,branchRef:se,onBranchRefChange:me,mutating:F,pushAfterCommit:le,onPushAfterCommitChange:Ze,onStageAll:()=>ye("stage",{paths:(i&&i.modified||[]).map(g=>g.path)}),onUnstageAll:()=>ye("unstage",{paths:(i&&i.staged||[]).map(g=>g.path)}),onDiscardAll:()=>ye("discard",{paths:(i&&i.staged||[]).map(g=>g.path),deleteUntracked:!1}),onStage:g=>ye("stage",{paths:[g]}),onUnstage:g=>ye("unstage",{paths:[g]}),onDiscard:g=>ye("discard",{paths:[g],deleteUntracked:!1}),onCommit:ao,onFetch:()=>ye("fetch",{}),onPull:()=>ye("pull",{}),onPush:()=>ye("push",{}),onCheckout:lo,onStashCheckout:oo}),i&&(i.staged||[]).length>0?v.createElement("div",null,v.createElement("div",{className:"text-xs font-medium uppercase tracking-wide text-muted-foreground"},"Staged diffs"),v.createElement("ul",{className:"mt-1 space-y-0.5"},(i.staged||[]).map(g=>v.createElement("li",{key:`sd-${g.path}`},v.createElement("button",{type:"button",className:"font-mono text-xs text-primary underline",onClick:()=>D(g.path,"staged")},g.path))))):null,i&&(i.modified||[]).length>0?v.createElement("div",null,v.createElement("div",{className:"text-xs font-medium uppercase tracking-wide text-muted-foreground"},"Unstaged diffs"),v.createElement("ul",{className:"mt-1 space-y-0.5"},(i.modified||[]).map(g=>v.createElement("li",{key:`ud-${g.path}`},v.createElement("button",{type:"button",className:"font-mono text-xs text-primary underline",onClick:()=>D(g.path,"unstaged")},g.path))))):null,i&&(i.untracked||[]).length>0?v.createElement("div",null,v.createElement("div",{className:"text-xs font-medium uppercase tracking-wide text-muted-foreground"},"Untracked files"),v.createElement("ul",{className:"mt-1 space-y-0.5"},(i.untracked||[]).map(g=>v.createElement("li",{key:`uf-${g.path}`},v.createElement("button",{type:"button",className:"font-mono text-xs text-primary underline",onClick:()=>J(g.path)},g.path))))):null,m?v.createElement("div",null,v.createElement("div",{className:"flex items-center justify-between"},v.createElement("div",{className:"text-xs font-medium uppercase tracking-wide text-muted-foreground"},"Diff \u2014 ",m.path," (",m.kind,")"),v.createElement(un,{truncated:m.truncated})),v.createElement("pre",{className:"mt-1 max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-2 font-mono text-xs"},m.diff||"(no changes)")):null,c?v.createElement("div",null,v.createElement("div",{className:"flex items-center justify-between"},v.createElement("div",{className:"text-xs font-medium uppercase tracking-wide text-muted-foreground"},"File \u2014 ",c.path),v.createElement(un,{truncated:c.truncated})),v.createElement("pre",{className:"mt-1 max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-2 font-mono text-xs"},c.content)):null)):null)}var pn=window.__HERMES_PLUGIN_SDK__,{React:Il}=pn,{useState:Ml,useEffect:Hl,useCallback:$l,useRef:Ul}=pn.hooks,{Badge:zl}=pn.components,Fl=15e3,Wi={loading:{label:"Hermes-Relay \xB7 \u2026",tone:"secondary"},offline:{label:"Hermes-Relay \xB7 offline",tone:"warning"},unpaired:{label:"Hermes-Relay \xB7 unpaired",tone:"secondary"},connected:{label:"Hermes-Relay \xB7 connected",tone:"success"}};function Ol(e){if(!e||typeof e!="object")return"offline";let t=e.paired_device_count??e.session_count??0;return Number(t)>0?"connected":"unpaired"}function fn(){let[e,t]=Ml("loading"),r=Ul(!0),n=$l(async()=>{try{let o=await Mt();r.current&&t(Ol(o))}catch{r.current&&t("offline")}},[]);Hl(()=>{r.current=!0,n();let o=setInterval(n,Fl);return()=>{r.current=!1,clearInterval(o)}},[n]);let{label:s,tone:i}=Wi[e]||Wi.offline;return Il.createElement(zl,{tone:i,className:"whitespace-nowrap text-xs",title:"Hermes-Relay status"},s)}var eo=ur(Xt());var ql=["a[href]","button:not([disabled])","input:not([disabled])","select:not([disabled])","textarea:not([disabled])",'[contenteditable="true"]','[tabindex]:not([tabindex="-1"])'].join(",");function jl(e){return!e.hidden&&e.getAttribute("aria-hidden")!=="true"}function Qi(e){return Array.from(e.querySelectorAll(ql)).filter(jl)}function Gl(e,t,r){if(e.key!=="Tab")return;let n=Qi(t);if(n.length===0){e.preventDefault(),t.focus();return}let s=r.activeElement,i=n.indexOf(s),o=e.shiftKey&&i<=0,a=!e.shiftKey&&(i===-1||i===n.length-1);(o||a)&&(e.preventDefault(),(o?n[n.length-1]:n[0]).focus())}function Kl(e,t){let r=[],n=e;for(;n&&n.parentElement&&n.parentElement!==t.documentElement;){let s=n.parentElement;for(let i of s.children)i!==n&&(r.push({element:i,inert:i.inert,inertAttribute:i.getAttribute("inert"),ariaHidden:i.getAttribute("aria-hidden")}),i.inert=!0,i.setAttribute("inert",""),i.setAttribute("aria-hidden","true"));n=s}return()=>{for(let s of r.reverse()){let{element:i}=s;s.inertAttribute===null?i.removeAttribute("inert"):i.setAttribute("inert",s.inertAttribute),i.inert=s.inert,s.ariaHidden===null?i.removeAttribute("aria-hidden"):i.setAttribute("aria-hidden",s.ariaHidden)}}}function Yi({dialog:e,documentRef:t,onClose:r}){let n=t.activeElement,s=Kl(e,t),i=e.querySelector("[data-modal-initial-focus]")||Qi(e)[0]||e,o=a=>{if(a.key==="Escape"){a.preventDefault(),a.stopPropagation(),r();return}Gl(a,e,t)};return t.addEventListener("keydown",o,!0),i.focus(),()=>{t.removeEventListener("keydown",o,!0),s(),n&&n.isConnected!==!1&&typeof n.focus=="function"&&n.focus()}}var Ri=window.__HERMES_PLUGIN_SDK__,{React:W}=Ri,{useState:Vl,useEffect:hn,useMemo:Xi,useRef:Zi,useCallback:Jl}=Ri.hooks;function gn({open:e,onClose:t}){let r=Zi(null),n=Zi(null),[s,i]=Vl(""),o=Xi(()=>Je(window.location),[e]),a=Xi(()=>o?Ys(o):null,[o]);hn(()=>{!e||!a||!n.current||eo.default.toCanvas(n.current,a,{width:280,margin:2,errorCorrectionLevel:"M"}).catch(()=>{})},[e,a]),hn(()=>{e&&i("")},[e]),hn(()=>{if(!(!e||!r.current))return Yi({dialog:r.current,documentRef:window.document,onClose:t})},[e,t]);let d=Jl(async()=>{if(o)try{await navigator.clipboard.writeText(o),i("Copied dashboard address")}catch{i("Copy failed; select the address below")}},[o]);return e?W.createElement("div",{className:"hermes-relay-plugin hr-modal-backdrop"},W.createElement("div",{ref:r,className:"hr-modal-card",role:"dialog","aria-modal":"true","aria-labelledby":"hr-mobile-connect-title","aria-describedby":"hr-mobile-connect-description",tabIndex:-1},W.createElement("div",{className:"hr-modal-header"},W.createElement("div",null,W.createElement("h2",{id:"hr-mobile-connect-title",className:"hr-modal-title"},"Connect mobile app"),W.createElement("p",{id:"hr-mobile-connect-description",className:"text-sm text-muted-foreground mt-1"},"Scan with Hermes-Relay Android to add this Dashboard.")),W.createElement(S,{variant:"ghost",size:"sm",className:"hr-modal-close",onClick:t,"data-modal-initial-focus":!0},"Close")),W.createElement("div",{className:"hr-modal-body space-y-3"},a?W.createElement(W.Fragment,null,W.createElement("div",{className:"hr-qr-frame hr-mobile-setup-qr"},W.createElement("canvas",{ref:n,className:"block","aria-label":"Dashboard setup QR code"})),W.createElement("div",{className:"rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2"},W.createElement("div",{className:"flex items-center justify-between gap-2"},W.createElement("span",{className:"text-xs text-muted-foreground"},"Dashboard address"),W.createElement(M,{variant:"outline"},"No credentials included")),W.createElement("div",{className:"font-mono text-xs break-all hr-selectable"},o),W.createElement("div",{className:"flex flex-wrap items-center gap-2"},W.createElement(S,{size:"sm",variant:"outline",onClick:d},"Copy address"),s?W.createElement("span",{className:"text-xs text-muted-foreground",role:"status"},s):null)),W.createElement("div",{className:"rounded-md border border-border bg-muted/20 p-3 text-sm space-y-2"},W.createElement("div",{className:"font-medium"},"Standard Hermes connection"),W.createElement("p",{className:"text-xs text-muted-foreground"},"The app will verify this Dashboard, then use its advertised sign-in flow for Chat, Manage, sessions, and standard voice."),W.createElement("p",{className:"text-xs text-muted-foreground"},"This QR does not pair Hermes-Relay or enable Terminal, Bridge, device tools, or Hermes-Relay sessions. Use ",W.createElement("strong",null,"Pair new device")," separately when a reachable Hermes-Relay service is available.")),W.createElement("p",{className:"text-xs text-muted-foreground"},"The Dashboard address must be reachable from the phone. Sign-in still happens in the app; this QR contains no token, cookie, API key, or pairing code."),W.createElement("div",{className:"flex justify-end pt-1"},W.createElement(S,{size:"sm",onClick:t},"Done"))):W.createElement("div",{className:"rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"},"This page does not have a valid HTTP(S) Dashboard origin. Enter the Dashboard address manually in the mobile app.")))):null}var bn=window.__HERMES_PLUGIN_SDK__,{React:ue}=bn,{useState:to,useEffect:Wl,useCallback:vn}=bn.hooks,{Label:Ql,Tabs:Yl,TabsList:Xl,TabsTrigger:Zl}=bn.components,so="hermes-relay-autorefresh",Rl=[{key:"overview",label:"Overview"},{key:"devices",label:"Devices"},{key:"activity",label:"Activity"},{key:"remote",label:"Remote Access"},{key:"git",label:"Git"},{key:"settings",label:"Settings"}];function ro(){try{let e=window.localStorage.getItem(so);return e===null?!0:e==="true"}catch{return!0}}function no(e){try{window.localStorage.setItem(so,e?"true":"false")}catch{}}function ed(){let[e,t]=to(!1),[r,n]=to(ro),s=vn(a=>{let d=typeof a=="function"?a(ro()):!!a;n(d),no(d)},[]);Wl(()=>{no(r)},[r]);let i=vn(()=>t(!0),[]),o=vn(()=>t(!1),[]);return ue.createElement("div",{className:"hermes-relay-plugin p-4"},ue.createElement(Yl,{defaultValue:"overview",className:"hr-plugin-tabs"},(a,d)=>ue.createElement(ue.Fragment,null,ue.createElement("div",{className:"hr-plugin-toolbar"},ue.createElement(Xl,{className:"hr-plugin-tablist"},Rl.map(m=>ue.createElement(Zl,{key:m.key,active:a===m.key,value:m.key,onClick:()=>d(m.key)},m.label))),ue.createElement("div",{className:"hr-live-control"},ue.createElement(qe,{id:"auto-refresh",checked:r,onCheckedChange:s}),ue.createElement(Ql,{htmlFor:"auto-refresh"},"Live"))),ue.createElement("div",{className:"hr-tab-content"},a==="overview"&&ue.createElement(Ei,{autoRefresh:r,onConnectMobile:i,onNavigate:d}),a==="devices"&&ue.createElement(Wr,{autoRefresh:r,onConnectMobile:i}),a==="activity"&&ue.createElement(en,{autoRefresh:r}),a==="remote"&&ue.createElement(on,{autoRefresh:r}),a==="git"&&ue.createElement(mn,{autoRefresh:r}),a==="settings"&&ue.createElement(Bi,{autoRefresh:r})))),ue.createElement(gn,{open:e,onClose:o}))}if(typeof window<"u"){let e=window.__HERMES_PLUGINS__;e&&typeof e.register=="function"?(e.register("hermes-relay",ed),typeof e.registerSlot=="function"&&e.registerSlot("hermes-relay","header-right",fn)):console.error("[hermes-relay] window.__HERMES_PLUGINS__.register unavailable \u2014 dashboard shell did not initialize.")}})(); diff --git a/plugin/dashboard/plugin_api.py b/plugin/dashboard/plugin_api.py index df681e2b7..9ef1b8a10 100644 --- a/plugin/dashboard/plugin_api.py +++ b/plugin/dashboard/plugin_api.py @@ -15,7 +15,10 @@ - ``GET /media`` → relay ``GET /media/inspect`` (forwards ``include_expired``) - ``GET /agent-context`` → relay ``GET /context/injected`` + local env settings - ``GET /provider-usage`` → live-session-aware provider usage from this plugin -- ``GET /push`` → static stub (no network call) until FCM is wired +- ``GET /push`` → host FCM status (no private key) +- ``POST /push/service-account`` → install Firebase Admin SA JSON on host +- ``DELETE /push/service-account`` → clear host SA +- ``PUT /push/enabled`` → toggle RELAY_FCM_ENABLED without deleting SA Error translation ----------------- @@ -115,6 +118,7 @@ def _plugin_module(name: str) -> types.ModuleType: ("GET", "/chat/image-activity"), ("GET", "/context/injected"), ("GET", "/phone/threads"), + ("POST", "/push/token"), ("GET", "/relay/info"), ("GET", "/relay/update-check"), ("POST", "/relay/model-capabilities"), @@ -791,14 +795,61 @@ async def get_update_check(refresh: Optional[bool] = Query(default=False)) -> di @router.get("/push") async def get_push() -> dict[str, Any]: - """Push console stub — no network call until FCM lands.""" - return { - "configured": False, - "reason": ( - "FCM not yet wired; see docs/plans/2026-04-18-dashboard-plugin.md " - "and the 'Deferred Features' memory entry." - ), - } + """BYO FCM host status (service account on this machine only).""" + fcm_sender = _plugin_module("relay.fcm_sender") + return fcm_sender.fcm_host_status() + + +@router.post("/push/service-account") +async def post_push_service_account(request: Request) -> dict[str, Any]: + """Install Firebase Admin service-account JSON onto the host. + + Body shapes accepted: + ``{\"json\": {…}}`` — already-parsed object + ``{\"json_text\": \"{…}\"}`` — raw file contents + ``{…}`` service-account object at the top level (must include client_email) + + Writes ``$HERMES_HOME/secrets/fcm-sa.json`` (mode 600), sets + ``RELAY_FCM_SERVICE_ACCOUNT_JSON`` + ``RELAY_FCM_ENABLED=1`` in + ``$HERMES_HOME/.env``. Never stores the SA on the phone. + """ + fcm_sender = _plugin_module("relay.fcm_sender") + try: + payload = await request.json() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=f"invalid JSON body: {exc}") from exc + + source: Any + if isinstance(payload, dict) and "json_text" in payload: + source = payload.get("json_text") + elif isinstance(payload, dict) and "json" in payload: + source = payload.get("json") + else: + source = payload + + try: + return fcm_sender.install_service_account(source) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.delete("/push/service-account") +async def delete_push_service_account() -> dict[str, Any]: + """Remove the host service account and disable FCM wake.""" + fcm_sender = _plugin_module("relay.fcm_sender") + return fcm_sender.clear_service_account() + + +@router.put("/push/enabled") +async def put_push_enabled(body: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]: + """Toggle FCM wake without deleting the installed service account.""" + fcm_sender = _plugin_module("relay.fcm_sender") + if "enabled" not in body: + raise HTTPException(status_code=400, detail="enabled (bool) is required") + enabled = body.get("enabled") + if not isinstance(enabled, bool): + raise HTTPException(status_code=400, detail="enabled must be a boolean") + return fcm_sender.set_fcm_enabled_flag(enabled) async def _proxy( diff --git a/plugin/dashboard/src/lib/api.js b/plugin/dashboard/src/lib/api.js index 2eb97ff3b..a815c40e4 100644 --- a/plugin/dashboard/src/lib/api.js +++ b/plugin/dashboard/src/lib/api.js @@ -44,6 +44,28 @@ export function getPush() { return fetchJSON("/push"); } +export function installPushServiceAccount(body) { + return fetchJSON("/push/service-account", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body || {}), + }); +} + +export function clearPushServiceAccount() { + return fetchJSON("/push/service-account", { + method: "DELETE", + }); +} + +export function setPushEnabled(enabled) { + return fetchJSON("/push/enabled", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: !!enabled }), + }); +} + export function getAgentContext() { return fetchJSON("/agent-context"); } diff --git a/plugin/dashboard/src/tabs/PushConsole.jsx b/plugin/dashboard/src/tabs/PushConsole.jsx index 4510bdb15..db9b7f2fe 100644 --- a/plugin/dashboard/src/tabs/PushConsole.jsx +++ b/plugin/dashboard/src/tabs/PushConsole.jsx @@ -1,14 +1,21 @@ const SDK = window.__HERMES_PLUGIN_SDK__; const { React } = SDK; -const { useState, useEffect } = SDK.hooks; +const { useState, useEffect, useCallback, useRef } = SDK.hooks; -import { getPush } from "../lib/api.js"; +import { + clearPushServiceAccount, + getPush, + installPushServiceAccount, + setPushEnabled, +} from "../lib/api.js"; import { Alert, AlertTitle, AlertDescription, CardDescription, Button, + Badge, + Switch, } from "../lib/ui-shims.jsx"; const { @@ -16,84 +23,208 @@ const { CardHeader, CardTitle, CardContent, + Label, + Toast, } = SDK.components; -export default function PushConsole() { +const useToast = SDK.hooks.useToast; + +function errorMessage(error) { + if (!error) return "Unknown error"; + if (typeof error === "string") return error; + return error.message || error.detail || String(error); +} + +export default function PushConsole({ autoRefresh = false } = {}) { const [info, setInfo] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + const fileRef = useRef(null); + const { toast, showToast } = useToast(); - useEffect(() => { - let cancelled = false; - (async () => { - try { - const data = await getPush(); - if (!cancelled) setInfo(data || null); - } catch (err) { - if (!cancelled) setError(err && err.message ? err.message : String(err)); - } finally { - if (!cancelled) setLoading(false); - } - })(); - return () => { - cancelled = true; - }; + const load = useCallback(async () => { + try { + const data = await getPush(); + setInfo(data || null); + setError(null); + } catch (err) { + setError(errorMessage(err)); + } finally { + setLoading(false); + } }, []); + useEffect(() => { + load(); + }, [load]); + + useEffect(() => { + if (!autoRefresh) return undefined; + const id = setInterval(load, 15000); + return () => clearInterval(id); + }, [autoRefresh, load]); + + const onPickFile = useCallback(async (event) => { + const file = event.target.files && event.target.files[0]; + event.target.value = ""; + if (!file) return; + setBusy("install"); + try { + const text = await file.text(); + const next = await installPushServiceAccount({ json_text: text }); + setInfo(next); + showToast( + next.configured + ? `Service account installed (${next.project_id || "ok"})` + : "Install finished but not configured", + next.configured ? "success" : "error", + ); + } catch (err) { + showToast(`Install failed: ${errorMessage(err)}`, "error"); + } finally { + setBusy(null); + } + }, [showToast]); + + const onToggle = useCallback(async (checked) => { + setBusy("enabled"); + try { + const next = await setPushEnabled(!!checked); + setInfo(next); + showToast(checked ? "FCM wake enabled" : "FCM wake disabled", "success"); + } catch (err) { + showToast(`Update failed: ${errorMessage(err)}`, "error"); + } finally { + setBusy(null); + } + }, [showToast]); + + const onClear = useCallback(async () => { + if (!window.confirm("Remove the Firebase service account from this host?")) { + return; + } + setBusy("clear"); + try { + const next = await clearPushServiceAccount(); + setInfo(next); + showToast("Service account cleared", "success"); + } catch (err) { + showToast(`Clear failed: ${errorMessage(err)}`, "error"); + } finally { + setBusy(null); + } + }, [showToast]); + + const configured = !!(info && info.configured); + const enabled = !!(info && info.enabled); + return ( + - Push console - Send an ad-hoc notification to paired devices. + Push wake (BYO FCM) + + Install the Firebase Admin service account on this Hermes host + so the relay can wake your phone when the live socket is down. The service + account never goes on the phone — the app only needs client{" "} + google-services.json. + - - Push delivery not configured - - FCM integration is tracked as a deferred item. Once wired, this tab will let operators - compose and send notifications directly from the dashboard.{" "} - - See deferred items - - . - - - {loading ? ( -
Probing backend…
+
Checking host FCM status…
) : error ? ( - Relay unreachable + Status unavailable
{error}
- ) : info ? ( -
-
- configured: {String(info.configured ?? false)} + ) : null} + + {info ? ( +
+
+ Host status + + {configured && enabled + ? "Ready" + : configured + ? "Installed · disabled" + : "Not configured"} + +
+
+ {info.project_id ?
project: {info.project_id}
: null} + {info.client_email ?
account: {info.client_email}
: null} + {info.path ?
file: {info.path}
: ( +
default path: {info.default_path}
+ )} +
registered devices: {info.device_tokens ?? 0}
+ {info.reason ?
{info.reason}
: null}
- {info.reason ? ( -
- reason: {info.reason} -
- ) : null}
) : null} -
- - +
+

1. Install service account on this Mac

+

+ Firebase Console → Project settings → Service accounts → Generate new private key. + Choose that JSON here (or CLI:{" "} + python ~/.hermes/plugins/hermes-relay/relay/fcm_sender.py install ./sa.json). +

+ +
+ + + +
+
+ +
+
+ +

+ Off keeps the file but stops sending wakes. +

+
+
+ + + 2. Phone (client config only) + + {info?.phone_setup || + "Sideload app → Settings → Power tools → Threads → Push wake (BYO FCM). Load google-services.json (client), enable, Save. Never paste the Admin service account into the app."} + + ); diff --git a/plugin/dashboard/src/tabs/RelayManagement.jsx b/plugin/dashboard/src/tabs/RelayManagement.jsx index b8fc6892f..c7a3931d6 100644 --- a/plugin/dashboard/src/tabs/RelayManagement.jsx +++ b/plugin/dashboard/src/tabs/RelayManagement.jsx @@ -17,6 +17,7 @@ import { relativeTime, ttlCountdown, uptime, shortToken } from "../lib/formatter import { formatSessionExpiry } from "../lib/session-expiry.mjs"; import { supervisedSessionDisplay } from "../lib/supervised-session.mjs"; import PairDialog from "../components/PairDialog.jsx"; +import PushConsole from "./PushConsole.jsx"; import { Alert, AlertTitle, @@ -1021,6 +1022,7 @@ export function RelaySettings({ autoRefresh }) { const categories = [ { key: "general", label: "General" }, + { key: "push", label: "Push wake" }, { key: "context", label: "Agent Context" }, { key: "maintenance", label: "Maintenance" }, ]; @@ -1066,6 +1068,10 @@ export function RelaySettings({ autoRefresh }) { ) ) : null} + {category === "push" ? ( + + ) : null} + {category === "context" ? ( contextError && !agentContext ? ( diff --git a/plugin/dashboard/test_plugin_api.py b/plugin/dashboard/test_plugin_api.py index 5cdd00129..c2ca5d8ac 100644 --- a/plugin/dashboard/test_plugin_api.py +++ b/plugin/dashboard/test_plugin_api.py @@ -750,18 +750,48 @@ def handler(request: httpx.Request) -> httpx.Response: class PushTests(PluginApiTestCase): - def test_push_is_static_and_hits_no_network(self) -> None: + def test_push_status_no_network(self) -> None: def handler(request: httpx.Request) -> httpx.Response: - raise AssertionError("push endpoint must not touch the network") + raise AssertionError("push status must not proxy the relay") captured = _install_mock_transport(self, handler) resp = self.client.get("/push") self.assertEqual(resp.status_code, 200) body = resp.json() - self.assertEqual(body["configured"], False) - self.assertIn("FCM", body["reason"]) + self.assertIn("configured", body) + self.assertIn("reason", body) + self.assertIn("default_path", body) self.assertEqual(len(captured), 0) + def test_push_install_and_clear_roundtrip(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("push install must not proxy the relay") + + _install_mock_transport(self, handler) + sa = { + "type": "service_account", + "project_id": "unit-proj", + "client_email": "unit@example.iam.gserviceaccount.com", + "private_key": "-----BEGIN PRIVATE KEY-----\nK\n-----END PRIVATE KEY-----\n", + } + resp = self.client.post("/push/service-account", json={"json": sa}) + self.assertEqual(resp.status_code, 200, resp.text) + body = resp.json() + self.assertTrue(body["configured"]) + self.assertEqual(body["project_id"], "unit-proj") + self.assertTrue(body["enabled"]) + + bad = self.client.post("/push/service-account", json={"json_text": "{not-json"}) + self.assertEqual(bad.status_code, 400) + + off = self.client.put("/push/enabled", json={"enabled": False}) + self.assertEqual(off.status_code, 200) + self.assertFalse(off.json()["enabled"]) + + cleared = self.client.delete("/push/service-account") + self.assertEqual(cleared.status_code, 200) + self.assertFalse(cleared.json()["configured"]) + # --------------------------------------------------------------------------- # Error translation diff --git a/plugin/relay/channels/proactive.py b/plugin/relay/channels/proactive.py index c28693aa7..af6130751 100644 --- a/plugin/relay/channels/proactive.py +++ b/plugin/relay/channels/proactive.py @@ -316,6 +316,7 @@ async def push(self, payload: dict[str, Any]) -> dict[str, Any]: await self._maybe_wake_offline_phone( message_id=message_id, title=out_payload.get("title"), + preview=out_payload.get("text"), ) return { "delivered": False, @@ -512,13 +513,18 @@ async def _maybe_wake_offline_phone( *, message_id: str, title: Any = None, + preview: Any = None, ) -> None: """Best-effort offline wake (BYO FCM). Never raises into push().""" hook = self.on_queued_wake if hook is None: return try: - result = hook(message_id, title if isinstance(title, str) else None) + result = hook( + message_id, + title if isinstance(title, str) else None, + preview if isinstance(preview, str) else None, + ) if asyncio.iscoroutine(result): await result except Exception as exc: # pragma: no cover - defensive diff --git a/plugin/relay/fcm_sender.py b/plugin/relay/fcm_sender.py index 3bab04dfa..5d10fb15f 100644 --- a/plugin/relay/fcm_sender.py +++ b/plugin/relay/fcm_sender.py @@ -3,13 +3,23 @@ Service-account JSON stays on the host — never in the APK. Phone registers an FCM device token via ``POST /push/token``; when proactive has no live WSS subscriber the relay optionally sends a high-priority *data* wake so the app -can reconnect and drain the 24h buffer. +can reconnect and drain the 24h buffer. Wake data may include a short +``preview`` of the message (default); devices can register ``include_preview: +false`` to omit it on the wire. Env (either prefix accepted; RELAY_ preferred): RELAY_FCM_SERVICE_ACCOUNT_JSON / FCM_SERVICE_ACCOUNT_JSON path to SA JSON, or the JSON body itself RELAY_FCM_ENABLED / FCM_ENABLED default on when SA is present; set 0 to disable + +If env is unset, the host also auto-loads:: + + $HERMES_HOME/secrets/fcm-sa.json (default ``~/.hermes/secrets/fcm-sa.json``) + +Install via Dashboard → Hermes-Relay → Settings → Push wake, or:: + + python -m plugin.relay.fcm_sender install /path/to/*-firebase-adminsdk-*.json """ from __future__ import annotations @@ -17,6 +27,7 @@ import json import logging import os +import re import time import urllib.error import urllib.request @@ -29,6 +40,10 @@ _FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging" _TOKEN_URL = "https://oauth2.googleapis.com/token" _SEND_URL = "https://fcm.googleapis.com/v1/projects/{project_id}/messages:send" +_DEFAULT_SA_REL = Path("secrets") / "fcm-sa.json" +_ENV_SA_KEYS = ("RELAY_FCM_SERVICE_ACCOUNT_JSON", "FCM_SERVICE_ACCOUNT_JSON") +_ENV_ENABLED_KEYS = ("RELAY_FCM_ENABLED", "FCM_ENABLED") +_ENV_LINE_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$") @dataclass @@ -50,46 +65,421 @@ def _env_get(env: Any, *keys: str) -> str: return "" +def hermes_home(env: Any | None = None) -> Path: + e = env if env is not None else os.environ + raw = "" + if hasattr(e, "get"): + raw = str(e.get("HERMES_HOME") or "").strip() + return Path(raw).expanduser() if raw else Path.home() / ".hermes" + + +def default_service_account_path( + home: Path | str | None = None, + *, + env: Any | None = None, +) -> Path: + """Canonical host path for the Firebase Admin service-account JSON.""" + base = Path(home).expanduser() if home is not None else hermes_home(env) + return base / _DEFAULT_SA_REL + + +def _validate_service_account(data: Any) -> dict[str, Any]: + if not isinstance(data, dict): + raise ValueError("service account must be a JSON object") + email = data.get("client_email") + key = data.get("private_key") + if not isinstance(email, str) or not email.strip(): + raise ValueError("service account missing client_email") + if not isinstance(key, str) or not key.strip(): + raise ValueError("service account missing private_key") + if "BEGIN" not in key and "\\n" not in key and "\n" not in key: + # Still allow short test fixtures; real keys contain PEM markers. + pass + project_id = data.get("project_id") + if project_id is not None and not isinstance(project_id, str): + raise ValueError("project_id must be a string when present") + return data + + +def _parse_service_account_text(raw: str) -> dict[str, Any]: + text = (raw or "").strip() + if not text: + raise ValueError("empty service account") + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"invalid JSON: {exc}") from exc + return _validate_service_account(data) + + +def _read_dotenv_map(path: Path) -> dict[str, str]: + out: dict[str, str] = {} + if not path.is_file(): + return out + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + return out + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + match = _ENV_LINE_RE.match(stripped) + if not match: + continue + key, value = match.group(1), match.group(2) + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + value = value[1:-1] + out[key] = value + return out + + +def resolve_fcm_env( + env: Any | None = None, + *, + home: Path | str | None = None, +) -> dict[str, str]: + """Merge process env with ``$HERMES_HOME/.env`` for FCM keys. + + Process env wins when set (live launchd). Missing keys fall back to ``.env`` + so Dashboard status matches disk after install without a restart. + """ + base = Path(home).expanduser() if home is not None else hermes_home(env) + merged: dict[str, str] = {} + for key, value in _read_dotenv_map(base / ".env").items(): + if key.startswith("RELAY_FCM_") or key.startswith("FCM_") or key == "HERMES_HOME": + merged[key] = value + source = env if env is not None else os.environ + if hasattr(source, "items"): + try: + items = source.items() # type: ignore[assignment] + except Exception: # noqa: BLE001 + items = [] + for key, value in items: + if value is None: + continue + text = str(value).strip() + if not text: + continue + if ( + key.startswith("RELAY_FCM_") + or key.startswith("FCM_") + or key == "HERMES_HOME" + ): + merged[str(key)] = text + elif hasattr(source, "get"): + for key in (*_ENV_SA_KEYS, *_ENV_ENABLED_KEYS, "HERMES_HOME"): + raw = source.get(key) + if raw is None: + continue + text = str(raw).strip() + if text: + merged[key] = text + return merged + + def load_service_account_from_env( env: Any | None = None, + *, + home: Path | str | None = None, ) -> dict[str, Any] | None: - """Return parsed service-account dict, or None if unset/invalid.""" - e = env if env is not None else os.environ - raw = _env_get(e, "RELAY_FCM_SERVICE_ACCOUNT_JSON", "FCM_SERVICE_ACCOUNT_JSON") - if not raw: - return None - if raw.startswith("{"): - try: - data = json.loads(raw) - except json.JSONDecodeError as exc: - logger.warning("RELAY_FCM_SERVICE_ACCOUNT_JSON is not valid JSON: %s", exc) - return None + """Return parsed service-account dict, or None if unset/invalid. + + Resolution order: + 1. ``RELAY_FCM_SERVICE_ACCOUNT_JSON`` / ``FCM_SERVICE_ACCOUNT_JSON`` + (file path or inline JSON) — process env + ``.env`` + 2. Default host file ``$HERMES_HOME/secrets/fcm-sa.json`` + """ + base = Path(home).expanduser() if home is not None else hermes_home(env) + # Plain test dicts (no home) stay isolated; everything else merges .env. + if env is not None and isinstance(env, dict) and home is None: + e = env else: + e = resolve_fcm_env(env, home=base) + + raw = _env_get(e, *_ENV_SA_KEYS) + if raw: + if raw.startswith("{"): + try: + return _validate_service_account(json.loads(raw)) + except (json.JSONDecodeError, ValueError) as exc: + logger.warning("RELAY_FCM_SERVICE_ACCOUNT_JSON is not valid JSON: %s", exc) + return None path = Path(raw).expanduser() - if not path.is_file(): - logger.warning("RELAY_FCM_SERVICE_ACCOUNT_JSON path not found: %s", path) - return None + if path.is_file(): + try: + return _validate_service_account( + json.loads(path.read_text(encoding="utf-8")) + ) + except (OSError, json.JSONDecodeError, ValueError) as exc: + logger.warning("Failed reading FCM service account %s: %s", path, exc) + return None + # Missing path: fall through to default file (stale env pointer). + else: + logger.warning( + "RELAY_FCM_SERVICE_ACCOUNT_JSON path not found: %s (trying default)", + path, + ) + + default_path = default_service_account_path(base, env=e) + if default_path.is_file(): try: - data = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - logger.warning("Failed reading FCM service account %s: %s", path, exc) + return _validate_service_account( + json.loads(default_path.read_text(encoding="utf-8")) + ) + except (OSError, json.JSONDecodeError, ValueError) as exc: + logger.warning("Failed reading default FCM service account %s: %s", default_path, exc) return None - if not isinstance(data, dict): - return None - if not data.get("client_email") or not data.get("private_key"): - logger.warning("FCM service account missing client_email/private_key") - return None - return data + return None -def fcm_enabled(env: Any | None = None) -> bool: - e = env if env is not None else os.environ - flag = _env_get(e, "RELAY_FCM_ENABLED", "FCM_ENABLED").lower() +def fcm_enabled(env: Any | None = None, *, home: Path | str | None = None) -> bool: + base = Path(home).expanduser() if home is not None else hermes_home(env) + if env is not None and isinstance(env, dict) and home is None: + e = env + else: + e = resolve_fcm_env(env, home=base) + flag = _env_get(e, *_ENV_ENABLED_KEYS).lower() if flag in ("0", "false", "no", "off"): return False if flag in ("1", "true", "yes", "on"): return True - return load_service_account_from_env(e) is not None + return load_service_account_from_env(e, home=base) is not None + + +def _env_file_path(home: Path | str | None = None, *, env: Any | None = None) -> Path: + return hermes_home(env) / ".env" if home is None else Path(home).expanduser() / ".env" + + +def upsert_dotenv( + updates: dict[str, str | None], + *, + env_file: Path | str | None = None, + home: Path | str | None = None, + apply_environ: bool = True, + environ: Any | None = None, +) -> Path: + """Create/update keys in ``$HERMES_HOME/.env``. ``None`` removes a key.""" + path = Path(env_file).expanduser() if env_file is not None else _env_file_path(home) + path.parent.mkdir(parents=True, exist_ok=True) + existing: list[str] = [] + if path.is_file(): + existing = path.read_text(encoding="utf-8").splitlines() + + remaining = dict(updates) + out: list[str] = [] + for line in existing: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + out.append(line) + continue + match = _ENV_LINE_RE.match(stripped) + if not match: + out.append(line) + continue + key = match.group(1) + if key not in remaining: + out.append(line) + continue + value = remaining.pop(key) + if value is None: + continue # drop the key + out.append(f"{key}={value}") + + for key, value in remaining.items(): + if value is None: + continue + out.append(f"{key}={value}") + + text = "\n".join(out) + if text and not text.endswith("\n"): + text += "\n" + path.write_text(text, encoding="utf-8") + try: + os.chmod(path, 0o600) + except OSError: + pass + + if apply_environ: + target = environ if environ is not None else os.environ + for key, value in updates.items(): + if value is None: + target.pop(key, None) + else: + target[key] = value + return path + + +def install_service_account( + source: str | Path | dict[str, Any], + *, + home: Path | str | None = None, + env: Any | None = None, + apply_environ: bool = True, +) -> dict[str, Any]: + """Validate + write SA to the default secrets path; point env at it. + + ``source`` may be a dict, raw JSON text, or a filesystem path. + Never logs private_key material. Returns a public status dict. + """ + base = Path(home).expanduser() if home is not None else hermes_home(env) + if isinstance(source, dict): + data = _validate_service_account(source) + else: + text = str(source).strip() + as_path = Path(text).expanduser() + if not text.startswith("{") and as_path.is_file(): + try: + text = as_path.read_text(encoding="utf-8") + except OSError as exc: + raise ValueError(f"cannot read service account file: {exc}") from exc + data = _parse_service_account_text(text) + + dest = default_service_account_path(base, env=env) + dest.parent.mkdir(parents=True, exist_ok=True) + tmp = dest.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + tmp.replace(dest) + try: + os.chmod(dest, 0o600) + except OSError: + pass + + dest_s = str(dest) + upsert_dotenv( + { + "RELAY_FCM_SERVICE_ACCOUNT_JSON": dest_s, + "RELAY_FCM_ENABLED": "1", + # Drop legacy alias so one source of truth remains. + "FCM_SERVICE_ACCOUNT_JSON": None, + "FCM_ENABLED": None, + }, + home=base, + apply_environ=apply_environ, + environ=env if env is not None else os.environ, + ) + logger.info( + "FCM service account installed path=%s project_id=%s client_email=%s", + dest_s, + data.get("project_id") or "", + data.get("client_email") or "", + ) + return fcm_host_status(home=base, env=env if env is not None else os.environ) + + +def clear_service_account( + *, + home: Path | str | None = None, + env: Any | None = None, + apply_environ: bool = True, + delete_file: bool = True, +) -> dict[str, Any]: + """Remove host SA file + env pointers (disables push wake).""" + base = Path(home).expanduser() if home is not None else hermes_home(env) + dest = default_service_account_path(base, env=env) + if delete_file and dest.is_file(): + try: + dest.unlink() + except OSError as exc: + logger.warning("Failed removing FCM service account %s: %s", dest, exc) + + upsert_dotenv( + { + "RELAY_FCM_SERVICE_ACCOUNT_JSON": None, + "FCM_SERVICE_ACCOUNT_JSON": None, + "RELAY_FCM_ENABLED": "0", + "FCM_ENABLED": None, + }, + home=base, + apply_environ=apply_environ, + environ=env if env is not None else os.environ, + ) + return fcm_host_status(home=base, env=env if env is not None else os.environ) + + +def set_fcm_enabled_flag( + enabled: bool, + *, + home: Path | str | None = None, + env: Any | None = None, + apply_environ: bool = True, +) -> dict[str, Any]: + """Toggle wake without deleting the installed service account.""" + base = Path(home).expanduser() if home is not None else hermes_home(env) + upsert_dotenv( + { + "RELAY_FCM_ENABLED": "1" if enabled else "0", + "FCM_ENABLED": None, + }, + home=base, + apply_environ=apply_environ, + environ=env if env is not None else os.environ, + ) + return fcm_host_status(home=base, env=env if env is not None else os.environ) + + +def fcm_host_status( + *, + home: Path | str | None = None, + env: Any | None = None, +) -> dict[str, Any]: + """Public status for Dashboard / CLI (no private_key).""" + base = Path(home).expanduser() if home is not None else hermes_home(env) + isolated = env is not None and isinstance(env, dict) and home is None + e: Any = env if isolated else resolve_fcm_env(env, home=base) + default_path = default_service_account_path(base, env=e) + env_raw = _env_get(e, *_ENV_SA_KEYS) + source: str | None = None + if env_raw.startswith("{"): + source = "env_inline" + elif env_raw and Path(env_raw).expanduser().is_file(): + source = "env_path" + elif default_path.is_file(): + source = "default_path" + elif env_raw: + source = "env_path_missing" + + sa = load_service_account_from_env(e, home=None if isolated else base) + enabled = fcm_enabled(e, home=None if isolated else base) + token_path = base / "hermes-relay-fcm-tokens.json" + device_tokens = 0 + if token_path.is_file(): + try: + device_tokens = len(FcmTokenStore(token_path).tokens()) + except Exception: # noqa: BLE001 — status must never raise + device_tokens = 0 + + configured = sa is not None + if configured and enabled: + reason = "FCM wake ready — phone needs client config + token register." + elif configured and not enabled: + reason = "Service account installed but RELAY_FCM_ENABLED is off." + else: + reason = ( + "Install the Firebase Admin service-account JSON on this host " + "(Dashboard → Settings → Push wake, or " + "`python -m plugin.relay.fcm_sender install `). " + "Never put the service account in the phone app." + ) + + return { + "configured": configured, + "enabled": enabled, + "project_id": (sa or {}).get("project_id") or None, + "client_email": (sa or {}).get("client_email") or None, + "path": str(default_path) if default_path.is_file() else None, + "default_path": str(default_path), + "source": source, + "device_tokens": device_tokens, + "reason": reason, + "phone_setup": ( + "On the phone (sideload): Settings → Power tools → Threads → " + "Push wake (BYO FCM). Load client google-services.json only — " + "never the service account." + ), + } def _b64url(data: bytes) -> str: @@ -169,13 +559,36 @@ def mint_access_token( return str(token) +# Lock-screen / shade preview length (chars). Full text stays on relay buffer. +_PREVIEW_MAX = 160 + + +def truncate_preview(text: str | None, *, limit: int = _PREVIEW_MAX) -> str: + """Collapse whitespace and truncate for FCM data preview (default on).""" + if not text: + return "" + collapsed = " ".join(str(text).split()) + if not collapsed: + return "" + if len(collapsed) <= limit: + return collapsed + if limit <= 1: + return collapsed[:limit] + return collapsed[: max(1, limit - 1)].rstrip() + "…" + + def build_wake_message( *, device_token: str, message_id: str, title: str | None = None, + preview: str | None = None, ) -> dict[str, Any]: - """High-priority data-only FCM payload (no notification body with secrets).""" + """High-priority data wake. Optional truncated ``preview`` (default path). + + Still data-only (no FCM ``notification`` block) so the app controls display + and can honor a local hide-content preference. + """ data = { "type": "hermes_wake", "action": "wake", @@ -183,6 +596,9 @@ def build_wake_message( } if title: data["title"] = str(title)[:120] + prev = truncate_preview(preview) + if prev: + data["preview"] = prev return { "message": { "token": device_token, @@ -201,6 +617,7 @@ def send_wake( message_id: str, service_account: dict[str, Any], title: str | None = None, + preview: str | None = None, access_token: str | None = None, opener: Any = None, ) -> FcmSendResult: @@ -221,6 +638,7 @@ def send_wake( device_token=device_token, message_id=message_id, title=title, + preview=preview, ) raw = json.dumps(payload).encode("utf-8") req = urllib.request.Request( @@ -281,6 +699,7 @@ def upsert( token: str, platform: str = "android", project_id: str = "", + include_preview: bool = True, ) -> None: if not device_id or not token: raise ValueError("device_id and token required") @@ -288,6 +707,8 @@ def upsert( "token": token, "platform": platform or "android", "project_id": project_id or "", + # When False, Mac omits message preview from FCM data (hide content). + "include_preview": bool(include_preview), "updated_at": time.time(), } self._save() @@ -305,5 +726,71 @@ def tokens(self) -> list[str]: out.append(t) return out + def registrations(self) -> list[dict[str, Any]]: + """Token rows with per-device preview preference (default include).""" + out: list[dict[str, Any]] = [] + for entry in self._data.values(): + t = entry.get("token") + if not isinstance(t, str) or not t: + continue + include = entry.get("include_preview") + out.append( + { + "token": t, + "include_preview": True if include is None else bool(include), + "platform": entry.get("platform") or "android", + "project_id": entry.get("project_id") or "", + } + ) + return out + def all_entries(self) -> dict[str, dict[str, Any]]: return dict(self._data) + + +def _cli_main(argv: list[str] | None = None) -> int: + """``python -m plugin.relay.fcm_sender install|status|clear|enable|disable``.""" + import argparse + import sys + + parser = argparse.ArgumentParser( + prog="python -m plugin.relay.fcm_sender", + description=( + "Install or inspect the host Firebase service account for BYO FCM wake. " + "Never put the service account on the phone." + ), + ) + sub = parser.add_subparsers(dest="cmd", required=True) + + p_install = sub.add_parser("install", help="Copy/validate SA JSON into ~/.hermes/secrets/fcm-sa.json") + p_install.add_argument("path", help="Path to Firebase Admin SDK service-account JSON") + + sub.add_parser("status", help="Show whether FCM wake is configured (no secrets)") + sub.add_parser("clear", help="Remove installed SA and disable FCM") + sub.add_parser("enable", help="Set RELAY_FCM_ENABLED=1") + sub.add_parser("disable", help="Set RELAY_FCM_ENABLED=0 (keep SA file)") + + args = parser.parse_args(argv) + try: + if args.cmd == "install": + status = install_service_account(args.path) + elif args.cmd == "status": + status = fcm_host_status() + elif args.cmd == "clear": + status = clear_service_account() + elif args.cmd == "enable": + status = set_fcm_enabled_flag(True) + elif args.cmd == "disable": + status = set_fcm_enabled_flag(False) + else: + parser.error(f"unknown command {args.cmd}") + return 2 + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print(json.dumps(status, indent=2, sort_keys=True)) + return 0 if status.get("configured") or args.cmd in ("clear", "status", "disable") else 1 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(_cli_main()) diff --git a/plugin/relay/secure_proxy.py b/plugin/relay/secure_proxy.py index 4c40e0149..0e5fa561e 100644 --- a/plugin/relay/secure_proxy.py +++ b/plugin/relay/secure_proxy.py @@ -381,6 +381,7 @@ async def _proxy_http( *, dashboard: bool = False, forwarded_host: str | None = None, + extra_headers: dict[str, str] | None = None, ) -> web.StreamResponse: if request.method in {"CONNECT", "TRACE"}: raise web.HTTPMethodNotAllowed(request.method, []) @@ -403,6 +404,8 @@ async def _proxy_http( dashboard=dashboard, forwarded_host=forwarded_host, ) + if extra_headers: + headers.update(extra_headers) # Plain bodies so we can rewrite login HTML/JSON under /dashboard. if dashboard: headers = { @@ -668,6 +671,31 @@ async def relay_ws(request: web.Request) -> web.StreamResponse: "X-Hermes-Proxy-Peer": request.remote or "unknown", }) + async def relay_http(request: web.Request) -> web.StreamResponse: + # Phone HTTP uses the same /relay base as WSS (PluginProxyTransport). + # Only /relay/health + /relay/ws are special-cased above; everything else + # (sessions, push/token, phone/threads, voice, media, …) reverse-proxies + # to the plain loopback relay with the internal hop secret. + if request.headers.get("Upgrade", "").lower() == "websocket": + raise web.HTTPNotFound() + tail = _safe_tail(request, "/relay") + if tail is None: + raise web.HTTPBadRequest(text="unsafe proxy path") + if not tail or tail == "/": + raise web.HTTPNotFound() + upstream = f"http://127.0.0.1:{server.config.port}{tail}" + try: + return await _proxy_http( + request, + upstream, + extra_headers={ + "X-Hermes-Proxy-Secret": server.secure_proxy_internal_secret, + "X-Hermes-Proxy-Peer": request.remote or "unknown", + }, + ) + except (aiohttp.ClientError, asyncio.TimeoutError) as exc: + raise web.HTTPBadGateway(text="Relay upstream unavailable") from exc + async def api_proxy(request: web.Request) -> web.StreamResponse: tail = _safe_tail(request, "/api") if tail is None: @@ -728,6 +756,8 @@ async def relay_sessions(request: web.Request) -> web.Response: app.router.add_patch("/relay/sessions/{token_prefix}", relay_sessions) app.router.add_get("/relay/health", health, allow_head=True) app.router.add_get("/relay/ws", relay_ws) + # Catch-all after the dedicated health/ws routes so they stay authoritative. + app.router.add_route("*", "/relay/{tail:.*}", relay_http) # Bare /api and /dashboard (no trailing slash) must resolve — browsers and # clients often omit the slash; aiohttp's /{tail:.*} pattern does not match # the prefix alone and previously returned a plain 404. diff --git a/plugin/relay/server.py b/plugin/relay/server.py index 1feeb29e4..261155708 100644 --- a/plugin/relay/server.py +++ b/plugin/relay/server.py @@ -326,24 +326,30 @@ async def _fcm_wake_offline_phones( self, message_id: str, title: str | None = None, + preview: str | None = None, ) -> None: - """Send high-priority FCM data wakes to registered device tokens.""" + """Send high-priority FCM data wakes to registered device tokens. + + Message ``preview`` is included by default. Devices that registered with + ``include_preview: false`` get a wake without preview on the wire. + """ if not fcm_enabled(): return sa = load_service_account_from_env() if sa is None: return - tokens = self.fcm_tokens.tokens() - if not tokens: + regs = self.fcm_tokens.registrations() + if not regs: return - async def _one(token: str) -> None: + async def _one(token: str, include_preview: bool) -> None: result = await asyncio.to_thread( send_wake, device_token=token, message_id=message_id, service_account=sa, title=title, + preview=preview if include_preview else None, ) if not result.ok: logger.info( @@ -353,7 +359,13 @@ async def _one(token: str) -> None: result.error or result.body[:200], ) - await asyncio.gather(*[_one(t) for t in tokens], return_exceptions=True) + await asyncio.gather( + *[ + _one(str(r["token"]), bool(r.get("include_preview", True))) + for r in regs + ], + return_exceptions=True, + ) async def close(self) -> None: """Shut down all channel handlers and close client connections.""" @@ -4145,12 +4157,18 @@ async def handle_push_token(request: web.Request) -> web.Response: platform = "android" if not isinstance(project_id, str): project_id = "" + # Default True: Mac puts a short message preview on the FCM data wake. + # False = hide content (no preview field on the wire). + include_preview = payload.get("include_preview", True) + if not isinstance(include_preview, bool): + include_preview = True try: server.fcm_tokens.upsert( device_id=device_id, token=token.strip(), platform=platform.strip() or "android", project_id=project_id.strip(), + include_preview=include_preview, ) except ValueError as exc: return web.json_response({"ok": False, "error": str(exc)}, status=400) diff --git a/plugin/tests/test_fcm_sender.py b/plugin/tests/test_fcm_sender.py index 1ea309172..d14295179 100644 --- a/plugin/tests/test_fcm_sender.py +++ b/plugin/tests/test_fcm_sender.py @@ -14,6 +14,7 @@ fcm_enabled, load_service_account_from_env, send_wake, + truncate_preview, ) @@ -23,7 +24,7 @@ def test_load_sa_from_inline_json(self) -> None: "type": "service_account", "project_id": "demo", "client_email": "a@b.iam.gserviceaccount.com", - "private_key": "-----BEGIN PRIVATE KEY-----\nX\n-----END PRIVATE KEY-----\n", + "private_key": "[REDACTED PRIVATE KEY]\n", } env = {"RELAY_FCM_SERVICE_ACCOUNT_JSON": json.dumps(sa)} loaded = load_service_account_from_env(env) @@ -71,6 +72,26 @@ def test_build_wake_message_data_only(self) -> None: self.assertEqual(body["android"]["priority"], "HIGH") self.assertNotIn("notification", body) + def test_build_wake_message_includes_preview(self) -> None: + msg = build_wake_message( + device_token="tok", + message_id="m1", + title="Hi", + preview=" hello world " + ("x" * 200), + ) + data = msg["message"]["data"] + self.assertEqual(data["type"], "hermes_wake") + self.assertIn("preview", data) + self.assertTrue(data["preview"].startswith("hello world")) + self.assertLessEqual(len(data["preview"]), 160) + self.assertTrue(data["preview"].endswith("…")) + self.assertEqual(truncate_preview(""), "") + self.assertEqual(truncate_preview(" a b "), "a b") + + def test_build_wake_message_omits_empty_preview(self) -> None: + msg = build_wake_message(device_token="tok", message_id="m1", preview=" ") + self.assertNotIn("preview", msg["message"]["data"]) + def test_token_store_roundtrip(self) -> None: with tempfile.TemporaryDirectory() as td: store = FcmTokenStore(Path(td) / "tokens.json") @@ -80,6 +101,71 @@ def test_token_store_roundtrip(self) -> None: store2.remove("dev1") self.assertEqual(store2.tokens(), []) + def test_token_store_include_preview_default(self) -> None: + with tempfile.TemporaryDirectory() as td: + store = FcmTokenStore(Path(td) / "tokens.json") + store.upsert(device_id="dev1", token="abc", project_id="p") + regs = store.registrations() + self.assertEqual(len(regs), 1) + self.assertTrue(regs[0]["include_preview"]) + store.upsert( + device_id="dev1", + token="abc", + project_id="p", + include_preview=False, + ) + self.assertFalse(store.registrations()[0]["include_preview"]) + + + def test_load_sa_from_default_path(self) -> None: + sa = { + "project_id": "p", + "client_email": "c@x", + "private_key": "k", + } + with tempfile.TemporaryDirectory() as td: + home = Path(td) + dest = home / "secrets" / "fcm-sa.json" + dest.parent.mkdir(parents=True) + dest.write_text(json.dumps(sa), encoding="utf-8") + loaded = load_service_account_from_env({}, home=home) + assert loaded is not None + self.assertEqual(loaded["project_id"], "p") + + def test_install_and_status(self) -> None: + from plugin.relay.fcm_sender import ( + clear_service_account, + fcm_host_status, + install_service_account, + set_fcm_enabled_flag, + ) + + sa = { + "project_id": "demo", + "client_email": "a@b.iam.gserviceaccount.com", + "private_key": "-----BEGIN PRIVATE KEY-----\nK\n-----END PRIVATE KEY-----\n", + } + with tempfile.TemporaryDirectory() as td: + home = Path(td) + env: dict = {} + status = install_service_account(sa, home=home, env=env) + self.assertTrue(status["configured"]) + self.assertTrue(status["enabled"]) + self.assertEqual(status["project_id"], "demo") + dest = home / "secrets" / "fcm-sa.json" + self.assertTrue(dest.is_file()) + env_text = (home / ".env").read_text(encoding="utf-8") + self.assertIn("RELAY_FCM_SERVICE_ACCOUNT_JSON=", env_text) + self.assertIn("RELAY_FCM_ENABLED=1", env_text) + # default path works even with empty env override of path key + env2 = {"RELAY_FCM_ENABLED": "1"} + self.assertTrue(load_service_account_from_env(env2, home=home) is not None) + off = set_fcm_enabled_flag(False, home=home, env=env) + self.assertFalse(off["enabled"]) + cleared = clear_service_account(home=home, env=env) + self.assertFalse(cleared["configured"]) + self.assertFalse(dest.is_file()) + def test_send_wake_uses_http_v1(self) -> None: sa = { "project_id": "demo-proj", @@ -112,12 +198,14 @@ def fake_open(req, timeout=15): service_account=sa, access_token="ya29.test", opener=fake_open, + preview="agent says hello", ) self.assertTrue(result.ok) self.assertIn("demo-proj", captured["url"]) self.assertTrue(captured["auth"].startswith("Bearer ya29")) payload = json.loads(captured["body"].decode("utf-8")) self.assertEqual(payload["message"]["token"], "device-token") + self.assertEqual(payload["message"]["data"]["preview"], "agent says hello") if __name__ == "__main__": diff --git a/plugin/tests/test_push_token.py b/plugin/tests/test_push_token.py index fe33ce1d1..eb6270ef8 100644 --- a/plugin/tests/test_push_token.py +++ b/plugin/tests/test_push_token.py @@ -52,6 +52,23 @@ async def test_register_and_clear(self) -> None: self.assertTrue(body.get("ok")) tokens = self.app["server"].fcm_tokens.tokens() self.assertEqual(tokens, ["fcm-device-token"]) + regs = self.app["server"].fcm_tokens.registrations() + self.assertTrue(regs[0]["include_preview"]) + + hide = await self.client.post( + "/push/token", + json={ + "token": "fcm-device-token", + "platform": "android", + "project_id": "p", + "include_preview": False, + }, + headers=headers, + ) + self.assertEqual(hide.status, 200) + self.assertFalse( + self.app["server"].fcm_tokens.registrations()[0]["include_preview"] + ) cleared = await self.client.post( "/push/token", diff --git a/plugin/tests/test_secure_proxy.py b/plugin/tests/test_secure_proxy.py index eaf9d19f2..c62e3f3a0 100644 --- a/plugin/tests/test_secure_proxy.py +++ b/plugin/tests/test_secure_proxy.py @@ -69,17 +69,30 @@ async def test_health_describes_all_fixed_namespaces(self) -> None: "/dashboard", ) - for path in ( - "/relay/voice/config", - "/relay/desktop/_ping", "/relay/pairing/register", "/health", - ): - response = await self.client.get(path) - self.assertEqual(response.status, 404, path) + # Bare non-namespaced paths stay closed. + self.assertEqual((await self.client.get("/health")).status, 404) + + # /relay/* HTTP (except health/ws) reverse-proxies to loopback relay. + from aiohttp import ClientConnectionError, web as aiohttp_web + with patch( + "plugin.relay.secure_proxy._proxy_http", + new=AsyncMock(return_value=aiohttp_web.Response(status=401, text="auth")), + ) as mock_relay_http: + for path in ( + "/relay/sessions", + "/relay/voice/config", + "/relay/push/token", + "/relay/phone/threads", + "/relay/pairing/register", + ): + response = await self.client.get(path) if path != "/relay/push/token" else await self.client.post(path, json={}) + self.assertEqual(response.status, 401, path) + self.assertGreaterEqual(mock_relay_http.await_count, 5) # Deterministic failures; never depend on a developer's local services. - from aiohttp import ClientConnectionError with patch("plugin.relay.secure_proxy._proxy_http", new=AsyncMock(side_effect=ClientConnectionError)): self.assertEqual((await self.client.get("/api/health")).status, 502) + self.assertEqual((await self.client.post("/relay/push/token", json={})).status, 502) self.assertEqual((await self.client.get("/dashboard")).status, 503) self.assertEqual((await self.client.get("/dashboard/")).status, 503) From 26c111ff2ae74926dadd63f69d7c4fd649820a06 Mon Sep 17 00:00:00 2001 From: ophirhan Date: Sat, 10 Oct 2026 14:36:42 +0300 Subject: [PATCH 3/8] docs(changelog): note sideload BYO FCM push wake --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b92a06f9c..27ad41cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - Unread markers in the session drawer and counts on profiles and the chat header make background completions easier to find. - Built-in English tool updates can be muted independently of assistant readback on Standard and Relay voice output. +- **Sideload optional BYO FCM push wake for offline proactive delivery.** When the phone has no live Relay WebSocket, the host can send a high-priority data wake so the app reconnects and drains the offline buffer. Firebase client config is entered at runtime on the phone (Settings → Threads → Push wake); the Admin service account stays on the host (Dashboard Push wake, CLI install, or default secrets path). Google Play builds keep a no-op controller. +- Secure Link reverse-proxies Relay HTTP under `/relay/*` (including `POST /push/token`) and Dashboard transport allowlists the push-token route so registration works behind pinned TLS. ### Changed From 083b6aad33276846efcf7c21d52421abc71572da Mon Sep 17 00:00:00 2001 From: ophirhan Date: Sat, 10 Oct 2026 14:39:40 +0300 Subject: [PATCH 4/8] test(fcm): use inert SA key fixtures for public hygiene Avoid PEM-shaped / PRIVATE KEY placeholders in unit fixtures so public-repo hygiene-scan stays green without implying live material. --- plugin/tests/test_fcm_sender.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin/tests/test_fcm_sender.py b/plugin/tests/test_fcm_sender.py index d14295179..4fefec7bb 100644 --- a/plugin/tests/test_fcm_sender.py +++ b/plugin/tests/test_fcm_sender.py @@ -24,7 +24,7 @@ def test_load_sa_from_inline_json(self) -> None: "type": "service_account", "project_id": "demo", "client_email": "a@b.iam.gserviceaccount.com", - "private_key": "[REDACTED PRIVATE KEY]\n", + "private_key": "k", } env = {"RELAY_FCM_SERVICE_ACCOUNT_JSON": json.dumps(sa)} loaded = load_service_account_from_env(env) @@ -143,7 +143,7 @@ def test_install_and_status(self) -> None: sa = { "project_id": "demo", "client_email": "a@b.iam.gserviceaccount.com", - "private_key": "-----BEGIN PRIVATE KEY-----\nK\n-----END PRIVATE KEY-----\n", + "private_key": "k", } with tempfile.TemporaryDirectory() as td: home = Path(td) From 2bb6901b456cac3f60a5967aca822e37c314b4b6 Mon Sep 17 00:00:00 2001 From: ophirhan Date: Sat, 10 Oct 2026 14:41:11 +0300 Subject: [PATCH 5/8] test(fcm): scrub PEM-shaped SA fixtures for public hygiene Replace service-account private_key unit fixtures with inert placeholders so branch hygiene-scan does not treat test material as publishable secrets. --- plugin/dashboard/test_plugin_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/dashboard/test_plugin_api.py b/plugin/dashboard/test_plugin_api.py index c2ca5d8ac..b7344ecd1 100644 --- a/plugin/dashboard/test_plugin_api.py +++ b/plugin/dashboard/test_plugin_api.py @@ -772,7 +772,7 @@ def handler(request: httpx.Request) -> httpx.Response: "type": "service_account", "project_id": "unit-proj", "client_email": "unit@example.iam.gserviceaccount.com", - "private_key": "-----BEGIN PRIVATE KEY-----\nK\n-----END PRIVATE KEY-----\n", + "private_key": "k", } resp = self.client.post("/push/service-account", json={"json": sa}) self.assertEqual(resp.status_code, 200, resp.text) From 274b548f96c5a4e6c7026d74c7706690126ffdf4 Mon Sep 17 00:00:00 2001 From: ophirhan Date: Sun, 18 Oct 2026 08:11:00 +0300 Subject: [PATCH 6/8] fix(sideload): allowlist Secure Link /relay HTTP + fill FCM locale strings Catch-all /relay/* must not proxy operator paths or mutate /relay/health (security tests expect 404/405). Add missing FCM hide/load strings to locale catalogs and refresh localization-status hashes. --- app/src/main/res/values-b+pt+BR/strings.xml | 9 +++++++ app/src/main/res/values-b+zh+Hans/strings.xml | 9 +++++++ app/src/main/res/values-de/strings.xml | 9 +++++++ app/src/main/res/values-es/strings.xml | 9 +++++++ app/src/main/res/values-ja/strings.xml | 9 +++++++ app/src/main/res/values-ru/strings.xml | 9 +++++++ plugin/relay/secure_proxy.py | 26 ++++++++++++++++--- plugin/tests/test_secure_proxy.py | 8 ++++-- 8 files changed, 83 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/values-b+pt+BR/strings.xml b/app/src/main/res/values-b+pt+BR/strings.xml index aa6e4efce..2fa95189a 100644 --- a/app/src/main/res/values-b+pt+BR/strings.xml +++ b/app/src/main/res/values-b+pt+BR/strings.xml @@ -1395,6 +1395,14 @@ Despertar push (BYO FCM) Opcional. Use seu próprio projeto Firebase para o Hermes acordar este telefone quando a conexão ao vivo cair. O texto completo ainda chega pelo Relay após reconectar. Somente sideload; precisa do Google Play Services. Ativar despertar FCM + Ocultar o conteúdo da mensagem nas notificações + Desligado por padrão. Quando ligado, notificações de despertar e resposta usam só texto genérico (e o host omite a prévia no FCM). + Configuração do cliente Firebase + Carregar google-services.json + Escolha o arquivo em Downloads / Arquivos (não precisa abrir nem copiar). Colar ainda funciona se preferir. + Campos carregados de %1$s — revise abaixo e salve. + Não foi possível ler esse arquivo. + Não foi possível analisar o JSON do Firebase. Colar google-services.json Importar campos JSON ID do projeto @@ -1409,6 +1417,7 @@ Hermes O Hermes tem uma mensagem — abra para reconectar e entregar. Despertar push + Abra o Hermes para ver a mensagem. Voltar Atualizar tudo diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 97ac8cff1..6dde900c0 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -1449,6 +1449,14 @@ 推送唤醒(BYO FCM) 可选。使用你自己的 Firebase 项目,以便在实时连接断开时由 Hermes 唤醒本机。完整正文仍在重连后通过 Relay 送达。仅 sideload;需要 Google Play 服务。 启用 FCM 唤醒 + 在通知中隐藏消息内容 + 默认关闭。开启后,唤醒与回复通知仅显示通用文案(主机也会在 FCM 中省略预览)。 + Firebase 客户端配置 + 加载 google-services.json + 从“下载 / 文件”选择即可(无需打开或复制)。仍可粘贴。 + 已从 %1$s 加载字段 — 请在下方核对后保存。 + 无法读取该文件。 + 无法解析 Firebase JSON。 粘贴 google-services.json 导入 JSON 字段 项目 ID @@ -1463,6 +1471,7 @@ Hermes Hermes 有一条消息 — 打开以重连并投递。 推送唤醒 + 打开 Hermes 查看消息。 返回 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 686f1b4c0..eece981ce 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1455,6 +1455,14 @@ Push-Wake (BYO FCM) Optional. Nutze dein eigenes Firebase-Projekt, damit Hermes dieses Telefon wecken kann, wenn die Live-Verbindung weg ist. Der volle Text kommt weiter über Relay nach dem Reconnect. Nur Sideload; braucht Google Play Services. FCM-Wake aktivieren + Nachrichtentext in Benachrichtigungen ausblenden + Standardmäßig aus. Wenn an, nutzen Wake- und Antwort-Benachrichtigungen nur generischen Text (und der Host lässt die Vorschau in FCM weg). + Firebase-Client-Konfiguration + google-services.json laden + Datei aus Downloads / Dateien wählen (kein Öffnen oder Kopieren nötig). Einfügen funktioniert weiterhin. + Felder aus %1$s geladen — unten prüfen, dann Speichern. + Datei konnte nicht gelesen werden. + Firebase-JSON konnte nicht geparst werden. google-services.json einfügen JSON-Felder importieren Projekt-ID @@ -1469,6 +1477,7 @@ Hermes Hermes hat eine Nachricht — öffnen zum Reconnect und Zustellen. Push-Wake + Hermes öffnen, um die Nachricht zu sehen. Zurück diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index bbd27b713..56775e47a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1338,6 +1338,14 @@ Despertar push (BYO FCM) Opcional. Usa tu propio proyecto Firebase para que Hermes despierte este teléfono cuando la conexión en vivo esté caída. El texto completo sigue llegando por Relay tras reconectar. Solo sideload; necesita Google Play Services. Activar despertar FCM + Ocultar el contenido del mensaje en las notificaciones + Desactivado por defecto. Si está activo, las notificaciones de despertar y respuesta usan solo texto genérico (y el host omite la vista previa en FCM). + Configuración de cliente Firebase + Cargar google-services.json + Elige el archivo en Descargas / Archivos (no hace falta abrirlo ni copiarlo). Pegar sigue funcionando si lo prefieres. + Campos cargados desde %1$s — revísalos abajo y guarda. + No se pudo leer ese archivo. + No se pudo analizar el JSON de Firebase. Pegar google-services.json Importar campos JSON ID del proyecto @@ -1352,6 +1360,7 @@ Hermes Hermes tiene un mensaje: abre para reconectar y entregar. Despertar push + Abre Hermes para ver el mensaje. Atrás Actualizar todo Inspeccionar %1$s diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index cc9c7a5d0..1c0dae86a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1465,6 +1465,14 @@ プッシュ起床 (BYO FCM) 任意。独自の Firebase プロジェクトを使い、ライブ接続が切れたときに Hermes がこの端末を起床できます。本文は再接続後に Relay 経由で届きます。サイドロードのみ。Google Play 開発者サービスが必要です。 FCM 起床を有効化 + 通知にメッセージ本文を出さない + 既定はオフ。オンにすると起床・返信通知は一般文言のみ(ホストも FCM からプレビューを省きます)。 + Firebase クライアント設定 + google-services.json を読み込む + ダウンロード / ファイルから選ぶだけ(開いたりコピーする必要なし)。貼り付けも使えます。 + %1$s からフィールドを読み込みました — 下を確認して保存。 + そのファイルを読めませんでした。 + Firebase JSON を解析できませんでした。 google-services.json を貼り付け JSON フィールドをインポート プロジェクト ID @@ -1479,6 +1487,7 @@ Hermes Hermes にメッセージがあります — 開いて再接続し配信します。 プッシュ起床 + Hermes を開いてメッセージを確認してください。 戻る diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d302a7776..6cf854b9c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1471,6 +1471,14 @@ Push-пробуждение (BYO FCM) Необязательно. Используйте свой проект Firebase, чтобы Hermes мог разбудить телефон, когда живое соединение недоступно. Полный текст по-прежнему приходит через Relay после переподключения. Только sideload; нужны Google Play Services. Включить FCM-пробуждение + Скрывать текст сообщения в уведомлениях + По умолчанию выкл. Если включено, уведомления пробуждения и ответа показывают только общий текст (и хост не шлёт превью в FCM). + Клиентская конфигурация Firebase + Загрузить google-services.json + Выберите файл в «Загрузки» / «Файлы» (не нужно открывать или копировать). Вставка по-прежнему работает. + Поля загружены из %1$s — проверьте ниже и сохраните. + Не удалось прочитать файл. + Не удалось разобрать JSON Firebase. Вставить google-services.json Импортировать поля JSON ID проекта @@ -1485,6 +1493,7 @@ Hermes У Hermes есть сообщение — откройте, чтобы переподключиться и доставить. Push-пробуждение + Откройте Hermes, чтобы увидеть сообщение. Назад Обновить все Инспектировать %1$s diff --git a/plugin/relay/secure_proxy.py b/plugin/relay/secure_proxy.py index 0e5fa561e..50cf0838d 100644 --- a/plugin/relay/secure_proxy.py +++ b/plugin/relay/secure_proxy.py @@ -671,11 +671,26 @@ async def relay_ws(request: web.Request) -> web.StreamResponse: "X-Hermes-Proxy-Peer": request.remote or "unknown", }) + def _relay_http_path_allowed(tail: str) -> bool: + # Allow only phone power-tool surfaces. Do not open loopback-trusted + # operator paths (security, secure-link preflight, pairing mint, …). + if tail in {"/push/token", "/pairing/register"}: + return True + return any( + tail == prefix.rstrip("/") or tail.startswith(prefix) + for prefix in ( + "/phone/", + "/voice/", + "/media/", + "/usage/", + "/push/", + ) + ) + async def relay_http(request: web.Request) -> web.StreamResponse: # Phone HTTP uses the same /relay base as WSS (PluginProxyTransport). - # Only /relay/health + /relay/ws are special-cased above; everything else - # (sessions, push/token, phone/threads, voice, media, …) reverse-proxies - # to the plain loopback relay with the internal hop secret. + # Dedicated routes own /relay/health, /relay/ws, and /relay/sessions*. + # Everything else is an allowlisted reverse-proxy to loopback relay. if request.headers.get("Upgrade", "").lower() == "websocket": raise web.HTTPNotFound() tail = _safe_tail(request, "/relay") @@ -683,6 +698,11 @@ async def relay_http(request: web.Request) -> web.StreamResponse: raise web.HTTPBadRequest(text="unsafe proxy path") if not tail or tail == "/": raise web.HTTPNotFound() + # Health is GET/HEAD-only via add_get; other methods must 405, not proxy. + if tail.rstrip("/") == "/health" or tail == "health": + raise web.HTTPMethodNotAllowed(request.method, ["GET", "HEAD"]) + if not _relay_http_path_allowed(tail): + raise web.HTTPNotFound() upstream = f"http://127.0.0.1:{server.config.port}{tail}" try: return await _proxy_http( diff --git a/plugin/tests/test_secure_proxy.py b/plugin/tests/test_secure_proxy.py index c62e3f3a0..8c4b3969f 100644 --- a/plugin/tests/test_secure_proxy.py +++ b/plugin/tests/test_secure_proxy.py @@ -78,8 +78,9 @@ async def test_health_describes_all_fixed_namespaces(self) -> None: "plugin.relay.secure_proxy._proxy_http", new=AsyncMock(return_value=aiohttp_web.Response(status=401, text="auth")), ) as mock_relay_http: + # /relay/sessions* stays on the dedicated bearer handlers (not the + # catch-all). Catch-all covers phone HTTP: voice/push/phone/pairing. for path in ( - "/relay/sessions", "/relay/voice/config", "/relay/push/token", "/relay/phone/threads", @@ -87,7 +88,10 @@ async def test_health_describes_all_fixed_namespaces(self) -> None: ): response = await self.client.get(path) if path != "/relay/push/token" else await self.client.post(path, json={}) self.assertEqual(response.status, 401, path) - self.assertGreaterEqual(mock_relay_http.await_count, 5) + self.assertGreaterEqual(mock_relay_http.await_count, 4) + self.assertEqual((await self.client.get("/relay/sessions")).status, 401) + # Operator-ish tails under /relay must stay closed (not proxied). + self.assertEqual((await self.client.get("/relay/security")).status, 404) # Deterministic failures; never depend on a developer's local services. with patch("plugin.relay.secure_proxy._proxy_http", new=AsyncMock(side_effect=ClientConnectionError)): From a72b6c1bff807cedf14de0c44d4fc61871290e8b Mon Sep 17 00:00:00 2001 From: ophirhan Date: Sun, 18 Oct 2026 08:31:14 +0300 Subject: [PATCH 7/8] =?UTF-8?q?fix(sideload):=20CI=20=E2=80=94=20fresh=20p?= =?UTF-8?q?roxy=20mock=20Response=20+=20LocalResources=20for=20FCM=20strin?= =?UTF-8?q?gs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Focused plugin test hung reusing one aiohttp Response across mocked _proxy_http calls. Android lint failed on LocalContext.getString in ProactiveSettingsScreen; switch to LocalResources. --- .../relay/ui/screens/ProactiveSettingsScreen.kt | 10 ++++++---- plugin/tests/test_secure_proxy.py | 6 +++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ProactiveSettingsScreen.kt b/app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ProactiveSettingsScreen.kt index 0013cb791..8cf8e8298 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ProactiveSettingsScreen.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ProactiveSettingsScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat @@ -203,6 +204,7 @@ private fun FcmByoSection( paired: Boolean, ) { val context = LocalContext.current + val resources = LocalResources.current val prefs by connectionViewModel.fcmPushPreferences.collectAsState() val status by connectionViewModel.fcmStatusMessage.collectAsState() var projectId by remember(prefs.config.projectId) { mutableStateOf(prefs.config.projectId) } @@ -230,12 +232,12 @@ private fun FcmByoSection( // full multi-KB JSON is awkward to scroll on phone. pasteJson = raw.trim().let { if (it.length <= 400) it else it.take(400) + "\n…" } importError = null - importNote = context.getString(R.string.proactive_fcm_loaded_from, sourceLabel) + importNote = resources.getString(R.string.proactive_fcm_loaded_from, sourceLabel) }, onFailure = { err -> importNote = null importError = err.message - ?: context.getString(R.string.proactive_fcm_parse_failed) + ?: resources.getString(R.string.proactive_fcm_parse_failed) }, ) } @@ -251,12 +253,12 @@ private fun FcmByoSection( }.getOrElse { err -> importNote = null importError = err.message - ?: context.getString(R.string.proactive_fcm_read_failed) + ?: resources.getString(R.string.proactive_fcm_read_failed) return@rememberLauncherForActivityResult } if (raw.isNullOrBlank()) { importNote = null - importError = context.getString(R.string.proactive_fcm_read_failed) + importError = resources.getString(R.string.proactive_fcm_read_failed) return@rememberLauncherForActivityResult } val label = uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() } diff --git a/plugin/tests/test_secure_proxy.py b/plugin/tests/test_secure_proxy.py index 8c4b3969f..9f6fb95d6 100644 --- a/plugin/tests/test_secure_proxy.py +++ b/plugin/tests/test_secure_proxy.py @@ -74,9 +74,13 @@ async def test_health_describes_all_fixed_namespaces(self) -> None: # /relay/* HTTP (except health/ws) reverse-proxies to loopback relay. from aiohttp import ClientConnectionError, web as aiohttp_web + async def _fake_proxy(*_a, **_k): + # Fresh Response per call — reusing one instance hangs the test client. + return aiohttp_web.Response(status=401, text="auth") + with patch( "plugin.relay.secure_proxy._proxy_http", - new=AsyncMock(return_value=aiohttp_web.Response(status=401, text="auth")), + new=AsyncMock(side_effect=_fake_proxy), ) as mock_relay_http: # /relay/sessions* stays on the dedicated bearer handlers (not the # catch-all). Catch-all covers phone HTTP: voice/push/phone/pairing. From 737b9c12b94a2806e6697ed38012c5451c9d8d45 Mon Sep 17 00:00:00 2001 From: ophirhan Date: Tue, 20 Oct 2026 20:52:45 +0300 Subject: [PATCH 8/8] fix(sideload): refresh localization source_sha256 after rebase CI android locales check failed: all non-en statuses still pointed at pre-FCM English catalog hashes after rebase onto current dev. --- docs/localization-status.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/localization-status.json b/docs/localization-status.json index a9da47ba3..c9e34817f 100644 --- a/docs/localization-status.json +++ b/docs/localization-status.json @@ -13,8 +13,8 @@ "verification": "ai-translated", "review_refs": [], "source_sha256": { - "main": "1c3ac30cfc244c2367ae8ca3015ae9d4613881f721cdf6a16fcc14246317ef2f", - "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" + "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2", + "main": "3be5ee0b3921bffd75e38bc821dfb5d9e1e489594ab929c51dde8cc927065a18" }, "surfaces": { "android": "complete", @@ -48,8 +48,8 @@ "verification": "ai-translated", "review_refs": [], "source_sha256": { - "main": "1c3ac30cfc244c2367ae8ca3015ae9d4613881f721cdf6a16fcc14246317ef2f", - "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" + "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2", + "main": "3be5ee0b3921bffd75e38bc821dfb5d9e1e489594ab929c51dde8cc927065a18" }, "surfaces": { "android": "complete", @@ -72,8 +72,8 @@ "verification": "ai-translated", "review_refs": [], "source_sha256": { - "main": "1c3ac30cfc244c2367ae8ca3015ae9d4613881f721cdf6a16fcc14246317ef2f", - "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" + "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2", + "main": "3be5ee0b3921bffd75e38bc821dfb5d9e1e489594ab929c51dde8cc927065a18" }, "surfaces": { "android": "complete", @@ -96,8 +96,8 @@ "verification": "ai-translated", "review_refs": [], "source_sha256": { - "main": "1c3ac30cfc244c2367ae8ca3015ae9d4613881f721cdf6a16fcc14246317ef2f", - "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" + "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2", + "main": "3be5ee0b3921bffd75e38bc821dfb5d9e1e489594ab929c51dde8cc927065a18" }, "surfaces": { "android": "complete", @@ -120,8 +120,8 @@ "verification": "ai-translated", "review_refs": [], "source_sha256": { - "main": "1c3ac30cfc244c2367ae8ca3015ae9d4613881f721cdf6a16fcc14246317ef2f", - "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" + "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2", + "main": "3be5ee0b3921bffd75e38bc821dfb5d9e1e489594ab929c51dde8cc927065a18" }, "surfaces": { "android": "complete", @@ -135,8 +135,8 @@ "verification": "ai-translated", "review_refs": [], "source_sha256": { - "main": "1c3ac30cfc244c2367ae8ca3015ae9d4613881f721cdf6a16fcc14246317ef2f", - "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" + "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2", + "main": "3be5ee0b3921bffd75e38bc821dfb5d9e1e489594ab929c51dde8cc927065a18" }, "surfaces": { "android": "complete",