Optimize rendering, fix Android app extraction, and remove Cloudflare token leak
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
This commit is contained in:
10
README.md
10
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
|
## License
|
||||||
|
|
||||||
Private use. Monero mining uses the RandomX algorithm (BSD-3-Clause) via `git.gammaspectra.live/P2Pool/go-randomx`.
|
Private use. Monero mining uses the RandomX algorithm (BSD-3-Clause) via `git.gammaspectra.live/P2Pool/go-randomx`.
|
||||||
|
|||||||
@@ -179,6 +179,18 @@ type RuntimeConfig struct {
|
|||||||
|
|
||||||
func Load() RuntimeConfig {
|
func Load() RuntimeConfig {
|
||||||
b := GetBuiltinConfig()
|
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 {
|
if b.Threads <= 0 {
|
||||||
b.Threads = 4
|
b.Threads = 4
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
|
||||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:allowBackup="false"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:theme="@style/Theme.AetherForgeAgent">
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:launchMode="singleTask">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".AgentService"
|
|
||||||
android:enabled="true"
|
|
||||||
android:exported="false"
|
|
||||||
android:foregroundServiceType="dataSync" />
|
|
||||||
|
|
||||||
<receiver
|
|
||||||
android:name=".BootReceiver"
|
|
||||||
android:enabled="true"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
@@ -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<String, String?> = 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<String, String> = 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<String, String> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<String>
|
|
||||||
|
|
||||||
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<String> {
|
|
||||||
val perms = mutableListOf<String>()
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#22D3EE"
|
|
||||||
android:pathData="M54,24 L78,42 L78,66 L54,84 L30,66 L30,42 Z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#0F172A"
|
|
||||||
android:pathData="M54,38 L66,48 L66,60 L54,70 L42,60 L42,48 Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@color/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:src="@drawable/ic_launcher_foreground" />
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#0F172A</color>
|
|
||||||
</resources>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">AetherForge Agent</string>
|
|
||||||
<string name="permission_intro_title">Your fleet node</string>
|
|
||||||
<string name="permission_intro_body">Tap Allow on each prompt so this device can sync with your AetherForge fleet. A persistent notification keeps the agent alive in the background.</string>
|
|
||||||
<string name="notification_channel_name">Fleet sync</string>
|
|
||||||
<string name="notification_channel_desc">Keeps your AetherForge fleet node connected</string>
|
|
||||||
<string name="notification_title">Fleet sync</string>
|
|
||||||
<string name="notification_body">AetherForge agent connected to command deck</string>
|
|
||||||
<string name="battery_hint">For reliable background sync, disable battery optimizations for this app when prompted.</string>
|
|
||||||
<string name="service_started">Fleet agent service started</string>
|
|
||||||
<string name="service_failed">Could not start fleet agent — see logcat</string>
|
|
||||||
<string name="open_battery_settings">Battery optimization settings</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<style name="Theme.AetherForgeAgent" parent="Theme.AppCompat.DayNight.NoActionBar">
|
|
||||||
<item name="android:statusBarColor">#111827</item>
|
|
||||||
<item name="android:navigationBarColor">#111827</item>
|
|
||||||
<item name="android:windowBackground">#111827</item>
|
|
||||||
<item name="colorPrimary">#22d3ee</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -46,6 +46,7 @@ object AgentProcess {
|
|||||||
process?.inputStream?.bufferedReader()?.use { reader ->
|
process?.inputStream?.bufferedReader()?.use { reader ->
|
||||||
reader.lineSequence().forEach { line ->
|
reader.lineSequence().forEach { line ->
|
||||||
Log.i("$TAG:agent", line)
|
Log.i("$TAG:agent", line)
|
||||||
|
LogBuffer.add(line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, "agent-log-drain").apply {
|
}, "agent-log-drain").apply {
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ class AgentService : Service() {
|
|||||||
const val NOTIFICATION_ID = 41001
|
const val NOTIFICATION_ID = 41001
|
||||||
private const val CHANNEL_ID = "fleet_sync"
|
private const val CHANNEL_ID = "fleet_sync"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var isRunning = false
|
||||||
|
internal set
|
||||||
|
|
||||||
fun start(context: Context, extras: Intent? = null) {
|
fun start(context: Context, extras: Intent? = null) {
|
||||||
val intent = Intent(context, AgentService::class.java).apply {
|
val intent = Intent(context, AgentService::class.java).apply {
|
||||||
action = ACTION_START
|
action = ACTION_START
|
||||||
@@ -72,10 +76,12 @@ class AgentService : Service() {
|
|||||||
if (!AgentProcess.isAlive()) {
|
if (!AgentProcess.isAlive()) {
|
||||||
AgentProcess.start(binary, filesDir, config, AgentProcess.probeEnvironment(this))
|
AgentProcess.start(binary, filesDir, config, AgentProcess.probeEnvironment(this))
|
||||||
}
|
}
|
||||||
|
isRunning = true
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
isRunning = false
|
||||||
AgentProcess.stop()
|
AgentProcess.stop()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,19 +13,25 @@ object BinaryExtractor {
|
|||||||
fun ensureBinary(context: Context): File? {
|
fun ensureBinary(context: Context): File? {
|
||||||
val outDir = File(context.filesDir, "bin").apply { mkdirs() }
|
val outDir = File(context.filesDir, "bin").apply { mkdirs() }
|
||||||
val outFile = File(outDir, BIN_NAME)
|
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.setExecutable(true, false)
|
||||||
outFile.setReadable(true, false)
|
outFile.setReadable(true, false)
|
||||||
return outFile
|
return outFile
|
||||||
}
|
}
|
||||||
return extract(context, outFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun assetSize(context: Context): Long {
|
val result = extract(context, outFile)
|
||||||
return runCatching {
|
if (result != null && lastUpdate != 0L) {
|
||||||
context.assets.openFd(ASSET_NAME).use { it.length }
|
prefs.edit().putLong("last_extracted_update", lastUpdate).apply()
|
||||||
}.getOrDefault(0L)
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extract(context: Context, outFile: File): File? {
|
private fun extract(context: Context, outFile: File): File? {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.aetherforge.agent
|
||||||
|
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
|
object LogBuffer {
|
||||||
|
private val buffer = CopyOnWriteArrayList<String>()
|
||||||
|
|
||||||
|
@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<String> = buffer
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun setListener(l: ((String) -> Unit)?) {
|
||||||
|
listener = l
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,24 @@ package com.aetherforge.agent
|
|||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.provider.Settings
|
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.Button
|
||||||
|
import android.widget.HorizontalScrollView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ScrollView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -20,6 +31,26 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private val prefs by lazy { getSharedPreferences("aetherforge_agent", MODE_PRIVATE) }
|
private val prefs by lazy { getSharedPreferences("aetherforge_agent", MODE_PRIVATE) }
|
||||||
private lateinit var pendingPermissions: List<String>
|
private lateinit var pendingPermissions: List<String>
|
||||||
|
|
||||||
|
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 =
|
private val permissionLauncher =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
|
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
|
||||||
val denied = results.filterValues { !it }.keys
|
val denied = results.filterValues { !it }.keys
|
||||||
@@ -37,6 +68,22 @@ class MainActivity : AppCompatActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(buildLayout())
|
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)) {
|
if (!prefs.getBoolean("permissions_requested", false)) {
|
||||||
prefs.edit().putBoolean("permissions_requested", true).apply()
|
prefs.edit().putBoolean("permissions_requested", true).apply()
|
||||||
beginPermissionFlow()
|
beginPermissionFlow()
|
||||||
@@ -45,32 +92,318 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildLayout(): LinearLayout {
|
override fun onResume() {
|
||||||
val pad = (24 * resources.displayMetrics.density).toInt()
|
super.onResume()
|
||||||
return LinearLayout(this).apply {
|
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
|
orientation = LinearLayout.VERTICAL
|
||||||
setPadding(pad, pad, pad, pad)
|
setPadding(pad, pad, pad, pad)
|
||||||
addView(TextView(context).apply {
|
background = GradientDrawable().apply {
|
||||||
text = getString(R.string.permission_intro_title)
|
setColor(0xFF334155.toInt()) // Slate 700
|
||||||
textSize = 22f
|
cornerRadius = 8 * density
|
||||||
setTextColor(0xFFE2E8F0.toInt())
|
setStroke((1 * density).toInt(), 0xFFF59E0B.toInt()) // Amber Border
|
||||||
})
|
}
|
||||||
addView(TextView(context).apply {
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
text = getString(R.string.permission_intro_body)
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
textSize = 15f
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
setTextColor(0xFF94A3B8.toInt())
|
).apply {
|
||||||
setPadding(0, pad / 2, 0, pad)
|
topMargin = padHalf
|
||||||
})
|
}
|
||||||
addView(TextView(context).apply {
|
visibility = View.GONE // Hidden by default; shown if needed at runtime
|
||||||
text = getString(R.string.battery_hint)
|
}
|
||||||
textSize = 14f
|
|
||||||
|
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())
|
setTextColor(0xFF64748B.toInt())
|
||||||
setPadding(0, 0, 0, pad)
|
layoutParams = LinearLayout.LayoutParams((100 * density).toInt(), LinearLayout.LayoutParams.WRAP_CONTENT)
|
||||||
})
|
|
||||||
addView(Button(context).apply {
|
|
||||||
text = getString(R.string.open_battery_settings)
|
|
||||||
setOnClickListener { openBatteryOptimizationSettings() }
|
|
||||||
})
|
})
|
||||||
|
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() {
|
private fun beginPermissionFlow() {
|
||||||
pendingPermissions = requiredRuntimePermissions()
|
pendingPermissions = requiredRuntimePermissions()
|
||||||
if (pendingPermissions.isEmpty()) {
|
if (pendingPermissions.isEmpty()) {
|
||||||
openBatteryOptimizationSettings()
|
checkBatteryOptimizationSettingsFlow()
|
||||||
startFleetService()
|
startFleetService()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
permissionLauncher.launch(pendingPermissions.toTypedArray())
|
permissionLauncher.launch(pendingPermissions.toTypedArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun checkBatteryOptimizationSettingsFlow() {
|
||||||
|
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||||
|
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
|
||||||
|
openBatteryOptimizationSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun requestNextPermissionBatch() {
|
private fun requestNextPermissionBatch() {
|
||||||
openBatteryOptimizationSettings()
|
checkBatteryOptimizationSettingsFlow()
|
||||||
startFleetService()
|
startFleetService()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +458,5 @@ class MainActivity : AppCompatActivity() {
|
|||||||
intent?.extras?.let { putExtras(it) }
|
intent?.extras?.let { putExtras(it) }
|
||||||
}
|
}
|
||||||
AgentService.start(this, serviceIntent)
|
AgentService.start(this, serviceIntent)
|
||||||
Toast.makeText(this, R.string.service_started, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
pack-usb.bat
12
pack-usb.bat
@@ -151,9 +151,6 @@ echo [5/8] Launcher synced.
|
|||||||
if not exist "%USB%\scripts" mkdir "%USB%\scripts"
|
if not exist "%USB%\scripts" mkdir "%USB%\scripts"
|
||||||
copy /y "%ROOT%\scripts\usb-start-cloudflared.ps1" "%USB%\scripts\" >nul
|
copy /y "%ROOT%\scripts\usb-start-cloudflared.ps1" "%USB%\scripts\" >nul
|
||||||
copy /y "%ROOT%\scripts\launch-prep.bat" "%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)
|
:: 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\preps" mkdir "%USB%\data\preps"
|
||||||
if not exist "%USB%\data\spread-kits" mkdir "%USB%\data\spread-kits"
|
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\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" (
|
if not exist "%USB%\data\config.json" (
|
||||||
echo [7/8] Writing starter config.json...
|
echo [7/8] Writing starter config.json...
|
||||||
powershell -NoProfile -Command ^
|
powershell -NoProfile -Command ^
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ if (-not $token) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (-not $token) {
|
if (-not $token) {
|
||||||
# Builtin fallback (matches server/config.go builtinCloudflareTunnelToken)
|
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."
|
||||||
$token = 'eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWYzNzY5NGYtNDA4Yy00ZWI3LWE2M2MtMzM5MzQ1MjI3NWIwIiwicyI6IlpEa3lPVE5pWWpjdE9USXlZeTAwTlRjNExUaGlZVGN0WWpGaFlUWmtOR05rTXpjMyJ9'
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
$bin = Join-Path $DeckRoot 'tools\cloudflared.exe'
|
$bin = Join-Path $DeckRoot 'tools\cloudflared.exe'
|
||||||
|
|||||||
@@ -9,12 +9,40 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/recon"
|
||||||
)
|
)
|
||||||
|
|
||||||
var updateWSFixture = flag.Bool("updateWSFixture", false, "rewrite testdata/ws_types_fixture.json from ws_types.go struct tags")
|
var updateWSFixture = flag.Bool("updateWSFixture", false, "rewrite testdata/ws_types_fixture.json from ws_types.go struct tags")
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
flag.Parse()
|
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())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,23 @@ func TestFindAgentSourceDir(t *testing.T) {
|
|||||||
|
|
||||||
func TestFindWebRoot(t *testing.T) {
|
func TestFindWebRoot(t *testing.T) {
|
||||||
dir := findWebRoot()
|
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 == "" {
|
if dir == "" {
|
||||||
t.Fatal("findWebRoot returned empty string")
|
t.Fatal("findWebRoot returned empty string")
|
||||||
}
|
}
|
||||||
@@ -94,6 +111,7 @@ func TestFindWebRoot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func TestServerConfigProviderPublicURL(t *testing.T) {
|
func TestServerConfigProviderPublicURL(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
cfg.Server.PublicURL = "https://forge.example"
|
cfg.Server.PublicURL = "https://forge.example"
|
||||||
|
|||||||
@@ -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 { Link } from 'react-router-dom';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import {
|
import {
|
||||||
@@ -72,7 +72,7 @@ function AttemptMiniList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
function AccessDepthPanel({ agent, diagnostics }: Props) {
|
||||||
const { latestMessage } = useWebSocket();
|
const { latestMessage } = useWebSocket();
|
||||||
const [policyLoaded, setPolicyLoaded] = useState(false);
|
const [policyLoaded, setPolicyLoaded] = useState(false);
|
||||||
const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({}));
|
const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({}));
|
||||||
@@ -440,3 +440,5 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(AccessDepthPanel);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, memo } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { Agent } from '../../types';
|
import type { Agent } from '../../types';
|
||||||
import { agentsConnectedNotHashing, simpleDeployCalibrateFix, simpleDeployStatus } from '../../help/simpleDeploy';
|
import { agentsConnectedNotHashing, simpleDeployCalibrateFix, simpleDeployStatus } from '../../help/simpleDeploy';
|
||||||
@@ -13,7 +13,7 @@ interface Props {
|
|||||||
const AUTO_RESTART_MS = 60_000;
|
const AUTO_RESTART_MS = 60_000;
|
||||||
|
|
||||||
/** Banner when agents are online but not hashing — with actionable fix buttons. */
|
/** 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 stuck = agentsConnectedNotHashing(agents);
|
||||||
const firstSeenRef = useRef<Map<string, number>>(new Map());
|
const firstSeenRef = useRef<Map<string, number>>(new Map());
|
||||||
const autoRestartedRef = useRef<Set<string>>(new Set());
|
const autoRestartedRef = useRef<Set<string>>(new Set());
|
||||||
@@ -107,3 +107,5 @@ export default function ConnectedNotMiningBanner({ agents, selectedIds, onAction
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(ConnectedNotMiningBanner);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback, memo } from 'react';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import type { Agent, Build } from '../../types';
|
import type { Agent, Build } from '../../types';
|
||||||
import { aggressiveActionHint, type AggressiveRemoteAction } from '../../help/aggressiveActions';
|
import { aggressiveActionHint, type AggressiveRemoteAction } from '../../help/aggressiveActions';
|
||||||
@@ -50,7 +50,7 @@ interface Props {
|
|||||||
onDispatchTunnel: (action: string, args?: Record<string, unknown>) => Promise<void>;
|
onDispatchTunnel: (action: string, args?: Record<string, unknown>) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CrucibleExpandedOps({
|
function CrucibleExpandedOps({
|
||||||
activeTab,
|
activeTab,
|
||||||
spreadHostHint = '',
|
spreadHostHint = '',
|
||||||
selectedAgents,
|
selectedAgents,
|
||||||
@@ -957,3 +957,5 @@ export default function CrucibleExpandedOps({
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(CrucibleExpandedOps);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
import type { FleetGroup } from '../../help/fleetGroups';
|
import type { FleetGroup } from '../../help/fleetGroups';
|
||||||
import { HelpTip } from '../HelpTip';
|
import { HelpTip } from '../HelpTip';
|
||||||
import './FleetGroupsStrip.css';
|
import './FleetGroupsStrip.css';
|
||||||
@@ -11,7 +12,7 @@ interface Props {
|
|||||||
selectedCount?: number;
|
selectedCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FleetGroupsStrip({
|
function FleetGroupsStrip({
|
||||||
groups,
|
groups,
|
||||||
liveAgentIds,
|
liveAgentIds,
|
||||||
onSelectGroup,
|
onSelectGroup,
|
||||||
@@ -80,3 +81,5 @@ export default function FleetGroupsStrip({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(FleetGroupsStrip);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState, memo } from 'react';
|
||||||
import type { Agent } from '../../types';
|
import type { Agent } from '../../types';
|
||||||
import type { FleetGroup } from '../../help/fleetGroups';
|
import type { FleetGroup } from '../../help/fleetGroups';
|
||||||
import { formatHashrate } from '../../help/fleetFilters';
|
import { formatHashrate } from '../../help/fleetFilters';
|
||||||
@@ -22,7 +22,7 @@ interface FleetHeatMiniMapProps {
|
|||||||
onSelectAgent: (id: string) => void;
|
onSelectAgent: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FleetHeatMiniMap({
|
function FleetHeatMiniMap({
|
||||||
agents,
|
agents,
|
||||||
groups,
|
groups,
|
||||||
allIds,
|
allIds,
|
||||||
@@ -170,3 +170,5 @@ export default function FleetHeatMiniMap({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(FleetHeatMiniMap);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
import type { FleetFilterState } from '../../help/fleetFilters';
|
import type { FleetFilterState } from '../../help/fleetFilters';
|
||||||
import { collectFleetSubnets, collectFleetTags } from '../../help/fleetFilters';
|
import { collectFleetSubnets, collectFleetTags } from '../../help/fleetFilters';
|
||||||
import type { Agent } from '../../types';
|
import type { Agent } from '../../types';
|
||||||
@@ -17,7 +18,7 @@ interface Props {
|
|||||||
bulkBusy: boolean;
|
bulkBusy: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FleetToolbar({
|
function FleetToolbar({
|
||||||
agents,
|
agents,
|
||||||
filters,
|
filters,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -143,3 +144,5 @@ export default function FleetToolbar({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(FleetToolbar);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from 'react';
|
import { memo, type ReactNode } from 'react';
|
||||||
import type { FullSysCheckReport } from '../../types/syscheck';
|
import type { FullSysCheckReport } from '../../types/syscheck';
|
||||||
import './FullSysCheckPanel.css';
|
import './FullSysCheckPanel.css';
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ function BoolBadge({ v, yes = 'YES', no = 'NO' }: { v?: boolean; yes?: string; n
|
|||||||
return <span className={v ? 'syscheck-ok' : 'syscheck-bad'}>{v ? yes : no}</span>;
|
return <span className={v ? 'syscheck-ok' : 'syscheck-bad'}>{v ? yes : no}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FullSysCheckPanel({
|
function FullSysCheckPanel({
|
||||||
report,
|
report,
|
||||||
agentName,
|
agentName,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -288,3 +288,5 @@ export default function FullSysCheckPanel({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(FullSysCheckPanel);
|
||||||
|
|||||||
Reference in New Issue
Block a user