feat: add android control plane app

This commit is contained in:
Codex
2026-03-23 13:55:46 +08:00
parent 06a3d10c88
commit 0453c1b8ce
24 changed files with 2766 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "site.hyzq.bossandroid"
compileSdk = 34
defaultConfig {
applicationId = "site.hyzq.bossandroid"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.compose.foundation:foundation:1.7.5")
implementation("androidx.compose.material3:material3:1.3.1")
implementation("androidx.compose.material:material-icons-extended:1.7.5")
implementation("androidx.compose.ui:ui:1.7.5")
implementation("androidx.compose.ui:ui-tooling-preview:1.7.5")
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("com.google.android.material:material:1.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
debugImplementation("androidx.compose.ui:ui-tooling:1.7.5")
debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.5")
}

1
android-app/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1 @@
# Boss Android v1 does not need additional release rules yet.

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@drawable/ic_boss_badge"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.BossAndroid"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.BossAndroid">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,23 @@
package site.hyzq.bossandroid
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.lifecycle.viewmodel.compose.viewModel
import site.hyzq.bossandroid.ui.BossApp
import site.hyzq.bossandroid.ui.BossViewModel
import site.hyzq.bossandroid.ui.theme.BossAndroidTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
BossAndroidTheme {
val bossViewModel: BossViewModel = viewModel()
BossApp(viewModel = bossViewModel)
}
}
}
}

View File

@@ -0,0 +1,105 @@
package site.hyzq.bossandroid.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class AppStatePayload(
val sessions: List<Session> = emptyList(),
val messages: List<Message> = emptyList(),
val tasks: List<TaskItem> = emptyList(),
val workers: List<WorkerNode> = emptyList(),
val approvals: List<ApprovalRequest> = emptyList(),
val events: List<BossEvent> = emptyList(),
)
@Serializable
data class HealthPayload(
val status: String = "unknown",
val sessions: Int = 0,
val workers: Int = 0,
)
@Serializable
data class Session(
val id: String,
val title: String,
val status: String,
val activeObjective: String = "",
val lastPlannerSummary: String = "",
val createdAt: String,
val updatedAt: String,
)
@Serializable
data class Message(
val id: String,
val sessionId: String,
val role: String,
val channel: String,
val content: String,
val createdAt: String,
)
@Serializable
data class TaskItem(
val id: String,
val sessionId: String,
val parentTaskId: String? = null,
val title: String,
val description: String,
val kind: String,
val status: String,
val priority: String,
val requiredOs: String,
val requiredCapabilities: List<String> = emptyList(),
val dependencyIds: List<String> = emptyList(),
val assignedWorkerId: String? = null,
val approvalStatus: String = "not_required",
val progressPercent: Int = 0,
val summary: String = "",
val currentStep: String = "",
val nextStep: String = "",
val createdAt: String,
val updatedAt: String,
)
@Serializable
data class WorkerNode(
val id: String,
val name: String,
val os: String,
val capabilities: List<String> = emptyList(),
val status: String,
val currentTaskId: String? = null,
val load: Int = 0,
val lastSeenAt: String,
val createdAt: String,
val updatedAt: String,
)
@Serializable
data class ApprovalRequest(
val id: String,
val sessionId: String,
val taskId: String,
val kind: String,
val summary: String,
val riskLevel: String,
val status: String,
val requester: String,
val responder: String? = null,
val createdAt: String,
val updatedAt: String,
)
@Serializable
data class BossEvent(
val id: String,
val sessionId: String? = null,
val taskId: String? = null,
val source: String,
val type: String,
val timestamp: String,
val payload: Map<String, JsonElement> = emptyMap(),
)

View File

@@ -0,0 +1,215 @@
package site.hyzq.bossandroid.network
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.putJsonArray
import site.hyzq.bossandroid.model.AppStatePayload
import site.hyzq.bossandroid.model.ApprovalRequest
import site.hyzq.bossandroid.model.HealthPayload
import site.hyzq.bossandroid.model.Session
import site.hyzq.bossandroid.model.TaskItem
import site.hyzq.bossandroid.model.WorkerNode
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
class BossApi(
private val client: OkHttpClient = OkHttpClient(),
private val json: Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
},
) {
suspend fun getHealth(baseUrl: String): HealthPayload = get(baseUrl, "/api/health")
suspend fun getBootstrap(baseUrl: String): AppStatePayload = get(baseUrl, "/api/bootstrap")
suspend fun createSession(baseUrl: String, title: String): Session = post(
baseUrl = baseUrl,
path = "/api/sessions",
body = buildJsonObject {
put("title", JsonPrimitive(title))
},
)
suspend fun addMessage(baseUrl: String, sessionId: String, content: String): AppStatePayload {
post<UnitPayload>(
baseUrl = baseUrl,
path = "/api/sessions/$sessionId/messages",
body = buildJsonObject {
put("content", JsonPrimitive(content))
put("channel", JsonPrimitive("android"))
},
)
return getBootstrap(baseUrl)
}
suspend fun archiveSession(baseUrl: String, sessionId: String): AppStatePayload {
post<UnitPayload>(baseUrl, "/api/sessions/$sessionId/archive")
return getBootstrap(baseUrl)
}
suspend fun restoreSession(baseUrl: String, sessionId: String): AppStatePayload {
post<UnitPayload>(baseUrl, "/api/sessions/$sessionId/restore")
return getBootstrap(baseUrl)
}
suspend fun registerWorker(
baseUrl: String,
name: String,
os: String,
capabilities: List<String>,
): WorkerNode = post(
baseUrl = baseUrl,
path = "/api/workers/register",
body = buildJsonObject {
put("name", JsonPrimitive(name))
put("os", JsonPrimitive(os))
putJsonArray("capabilities") {
capabilities.forEach { add(JsonPrimitive(it)) }
}
},
)
suspend fun markWorkerOffline(baseUrl: String, workerId: String): AppStatePayload {
post<UnitPayload>(baseUrl, "/api/workers/$workerId/offline")
return getBootstrap(baseUrl)
}
suspend fun pauseTask(baseUrl: String, taskId: String): AppStatePayload {
post<TaskItem>(baseUrl, "/api/tasks/$taskId/pause")
return getBootstrap(baseUrl)
}
suspend fun cancelTask(baseUrl: String, taskId: String): AppStatePayload {
post<TaskItem>(baseUrl, "/api/tasks/$taskId/cancel")
return getBootstrap(baseUrl)
}
suspend fun resumeTask(baseUrl: String, taskId: String): AppStatePayload {
post<TaskItem>(baseUrl, "/api/tasks/$taskId/resume")
return getBootstrap(baseUrl)
}
suspend fun requeueTask(baseUrl: String, taskId: String): AppStatePayload {
post<TaskItem>(baseUrl, "/api/tasks/$taskId/requeue")
return getBootstrap(baseUrl)
}
suspend fun respondApproval(
baseUrl: String,
approvalId: String,
approved: Boolean,
): AppStatePayload {
post<ApprovalRequest>(
baseUrl = baseUrl,
path = "/api/approvals/$approvalId/respond",
body = buildJsonObject {
put("approved", JsonPrimitive(approved))
put("responder", JsonPrimitive("android-app"))
},
)
return getBootstrap(baseUrl)
}
suspend fun reconcile(baseUrl: String): AppStatePayload {
post<UnitPayload>(baseUrl, "/api/reconcile")
return getBootstrap(baseUrl)
}
private suspend inline fun <reified T> get(baseUrl: String, path: String): T = request(
method = "GET",
baseUrl = baseUrl,
path = path,
body = null,
)
private suspend inline fun <reified T> post(
baseUrl: String,
path: String,
body: JsonElement? = null,
): T = request(
method = "POST",
baseUrl = baseUrl,
path = path,
body = body,
)
private suspend inline fun <reified T> request(
method: String,
baseUrl: String,
path: String,
body: JsonElement?,
): T = withContext(Dispatchers.IO) {
val normalizedBaseUrl = normalizeBaseUrl(baseUrl)
val requestBuilder = Request.Builder()
.url("$normalizedBaseUrl$path")
if (method == "POST") {
val payload = json.encodeToString(body ?: JsonObject(emptyMap()))
requestBuilder.post(payload.toRequestBody(JSON_MEDIA_TYPE))
}
if (method == "GET") {
requestBuilder.get()
}
client.newCall(requestBuilder.build()).execute().use { response ->
val responseBody = response.body?.string().orEmpty()
if (!response.isSuccessful) {
throw IOException(parseErrorMessage(response.code, responseBody))
}
if (responseBody.isBlank()) {
return@withContext json.decodeFromString<T>("{}")
}
return@withContext json.decodeFromString(responseBody)
}
}
private fun normalizeBaseUrl(input: String): String {
val trimmed = input.trim().trimEnd('/')
if (trimmed.isBlank()) {
return DEFAULT_BASE_URL
}
return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
trimmed
} else {
"http://$trimmed"
}
}
private fun parseErrorMessage(status: Int, body: String): String {
return try {
val payload = json.decodeFromString<ErrorPayload>(body)
payload.message.ifBlank { "$status request failed" }
} catch (_: Exception) {
body.ifBlank { "$status request failed" }
}
}
@Serializable
private data class ErrorPayload(
val error: String = "",
val message: String = "",
)
@Serializable
private data class UnitPayload(
val ok: Boolean = true,
)
companion object {
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
const val DEFAULT_BASE_URL = "http://111.231.132.51/boss"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,471 @@
package site.hyzq.bossandroid.ui
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import site.hyzq.bossandroid.model.AppStatePayload
import site.hyzq.bossandroid.model.ApprovalRequest
import site.hyzq.bossandroid.model.BossEvent
import site.hyzq.bossandroid.model.HealthPayload
import site.hyzq.bossandroid.model.Message
import site.hyzq.bossandroid.model.Session
import site.hyzq.bossandroid.model.TaskItem
import site.hyzq.bossandroid.model.WorkerNode
import site.hyzq.bossandroid.network.BossApi
enum class MainSection {
CONVERSATIONS,
DEVICES,
SETTINGS,
}
enum class ConversationTab {
CHAT,
TASKS,
APPROVALS,
}
data class UiNotice(
val id: Long,
val message: String,
)
data class GeneratedWorkerCommand(
val workerName: String,
val shellLabel: String,
val command: String,
)
data class BossUiState(
val baseUrl: String = BossApi.DEFAULT_BASE_URL,
val section: MainSection = MainSection.CONVERSATIONS,
val conversationTab: ConversationTab = ConversationTab.CHAT,
val sessions: List<Session> = emptyList(),
val messages: List<Message> = emptyList(),
val tasks: List<TaskItem> = emptyList(),
val approvals: List<ApprovalRequest> = emptyList(),
val workers: List<WorkerNode> = emptyList(),
val events: List<BossEvent> = emptyList(),
val selectedSessionId: String? = null,
val selectedWorkerId: String? = null,
val health: HealthPayload? = null,
val isRefreshing: Boolean = false,
val generatedCommand: GeneratedWorkerCommand? = null,
val notice: UiNotice? = null,
val lastSyncedAt: Long? = null,
)
class BossViewModel(
application: Application,
) : AndroidViewModel(application) {
private val api = BossApi()
private val prefs = application.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val refreshMutex = Mutex()
private val _uiState = MutableStateFlow(
BossUiState(
baseUrl = sanitizeBaseUrl(prefs.getString(KEY_BASE_URL, BossApi.DEFAULT_BASE_URL).orEmpty()),
),
)
val uiState: StateFlow<BossUiState> = _uiState.asStateFlow()
private var pollingJob: Job? = null
init {
refresh(showSpinner = true)
startPolling()
}
fun selectSection(section: MainSection) {
_uiState.update { it.copy(section = section) }
}
fun selectConversationTab(tab: ConversationTab) {
_uiState.update { it.copy(conversationTab = tab) }
}
fun selectSession(sessionId: String) {
_uiState.update { current ->
current.copy(
selectedSessionId = sessionId,
section = MainSection.CONVERSATIONS,
)
}
}
fun selectWorker(workerId: String?) {
_uiState.update { current ->
current.copy(
selectedWorkerId = workerId,
section = if (workerId == null) current.section else MainSection.DEVICES,
)
}
}
fun saveBaseUrl(input: String) {
val sanitized = sanitizeBaseUrl(input)
prefs.edit().putString(KEY_BASE_URL, sanitized).apply()
_uiState.update { it.copy(baseUrl = sanitized) }
refresh(showSpinner = true)
}
fun dismissNotice() {
_uiState.update { it.copy(notice = null) }
}
fun clearGeneratedCommand() {
_uiState.update { it.copy(generatedCommand = null) }
}
fun refresh(showSpinner: Boolean = false) {
viewModelScope.launch {
refreshSnapshot(showSpinner = showSpinner)
}
}
fun createSession(title: String) {
runMutation(successMessage = "已创建新会话。") {
val session = api.createSession(currentBaseUrl(), title.trim().ifBlank { "未命名项目" })
val snapshot = api.getBootstrap(currentBaseUrl())
applySnapshot(snapshot, preferredSessionId = session.id)
}
}
fun sendMessage(content: String) {
val sessionId = uiState.value.selectedSessionId ?: return
val message = content.trim()
if (message.isBlank()) {
publishNotice("消息不能为空。")
return
}
runMutation(successMessage = "需求已发送给 Boss。") {
val snapshot = api.addMessage(currentBaseUrl(), sessionId, message)
applySnapshot(snapshot, preferredSessionId = sessionId)
}
}
fun archiveSelectedSession() {
val sessionId = uiState.value.selectedSessionId ?: return
runMutation(successMessage = "会话已归档。") {
val snapshot = api.archiveSession(currentBaseUrl(), sessionId)
applySnapshot(snapshot, preferredSessionId = sessionId)
}
}
fun restoreSelectedSession() {
val sessionId = uiState.value.selectedSessionId ?: return
runMutation(successMessage = "会话已恢复。") {
val snapshot = api.restoreSession(currentBaseUrl(), sessionId)
applySnapshot(snapshot, preferredSessionId = sessionId)
}
}
fun registerWorker(
name: String,
os: String,
capabilitiesInput: String,
executorType: String,
workspaceInput: String,
) {
val normalizedName = name.trim()
if (normalizedName.isBlank()) {
publishNotice("设备名称不能为空。")
return
}
val capabilities = capabilitiesInput
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
.ifEmpty { listOf("terminal") }
runMutation(successMessage = "设备已绑定到 Boss。") {
val worker = api.registerWorker(
baseUrl = currentBaseUrl(),
name = normalizedName,
os = os,
capabilities = capabilities,
)
val snapshot = api.getBootstrap(currentBaseUrl())
applySnapshot(snapshot, preferredWorkerId = worker.id)
_uiState.update { current ->
current.copy(
generatedCommand = buildWorkerCommand(
baseUrl = current.baseUrl,
worker = worker,
executorType = executorType,
workspaceInput = workspaceInput,
),
)
}
}
}
fun markWorkerOffline(workerId: String) {
runMutation(successMessage = "设备已下线。") {
val snapshot = api.markWorkerOffline(currentBaseUrl(), workerId)
applySnapshot(snapshot)
}
}
fun pauseTask(taskId: String) {
runTaskMutation(taskId, "任务已暂停。") {
api.pauseTask(currentBaseUrl(), taskId)
}
}
fun resumeTask(taskId: String) {
runTaskMutation(taskId, "任务已恢复并重新排队。") {
api.resumeTask(currentBaseUrl(), taskId)
}
}
fun cancelTask(taskId: String) {
runTaskMutation(taskId, "任务已取消。") {
api.cancelTask(currentBaseUrl(), taskId)
}
}
fun requeueTask(taskId: String) {
runTaskMutation(taskId, "任务已重新排队。") {
api.requeueTask(currentBaseUrl(), taskId)
}
}
fun respondApproval(approvalId: String, approved: Boolean) {
val feedback = if (approved) "审批已通过。" else "审批已拒绝。"
runMutation(successMessage = feedback) {
val snapshot = api.respondApproval(currentBaseUrl(), approvalId, approved)
applySnapshot(snapshot)
}
}
fun reconcile() {
runMutation(successMessage = "系统已触发一次重排。") {
val snapshot = api.reconcile(currentBaseUrl())
applySnapshot(snapshot)
}
}
private fun runTaskMutation(
taskId: String,
successMessage: String,
operation: suspend () -> AppStatePayload,
) {
val sessionId = uiState.value.tasks.firstOrNull { it.id == taskId }?.sessionId
runMutation(successMessage = successMessage) {
val snapshot = operation()
applySnapshot(snapshot, preferredSessionId = sessionId)
}
}
private fun runMutation(
successMessage: String,
operation: suspend () -> Unit,
) {
viewModelScope.launch {
try {
_uiState.update { it.copy(isRefreshing = true) }
operation()
publishNotice(successMessage)
} catch (error: Throwable) {
if (error is CancellationException) {
throw error
}
publishNotice(error.message ?: "请求失败。")
} finally {
_uiState.update { it.copy(isRefreshing = false) }
}
}
}
private suspend fun refreshSnapshot(showSpinner: Boolean) {
refreshMutex.withLock {
try {
if (showSpinner) {
_uiState.update { it.copy(isRefreshing = true) }
}
val snapshot = api.getBootstrap(currentBaseUrl())
applySnapshot(snapshot)
} catch (error: Throwable) {
if (error is CancellationException) {
throw error
}
if (showSpinner) {
publishNotice(error.message ?: "无法连接 Boss 控制面。")
}
} finally {
_uiState.update { it.copy(isRefreshing = false) }
}
}
}
private fun applySnapshot(
snapshot: AppStatePayload,
preferredSessionId: String? = uiState.value.selectedSessionId,
preferredWorkerId: String? = uiState.value.selectedWorkerId,
) {
val sessions = snapshot.sessions.sortedByDescending { it.updatedAt }
val messages = snapshot.messages.sortedBy { it.createdAt }
val tasks = snapshot.tasks.sortedByDescending { it.updatedAt }
val approvals = snapshot.approvals.sortedByDescending { it.updatedAt }
val workers = snapshot.workers.sortedBy { it.name.lowercase() }
val events = snapshot.events.sortedByDescending { it.timestamp }
val selectedSessionId = preferredSessionId
?.takeIf { candidate -> sessions.any { it.id == candidate } }
?: sessions.firstOrNull { it.status == "active" }?.id
?: sessions.firstOrNull()?.id
val selectedWorkerId = preferredWorkerId
?.takeIf { candidate -> workers.any { it.id == candidate } }
_uiState.update { current ->
current.copy(
sessions = sessions,
messages = messages,
tasks = tasks,
approvals = approvals,
workers = workers,
events = events,
health = HealthPayload(
status = "ok",
sessions = sessions.size,
workers = workers.size,
),
selectedSessionId = selectedSessionId,
selectedWorkerId = selectedWorkerId,
lastSyncedAt = System.currentTimeMillis(),
)
}
}
private fun publishNotice(message: String) {
_uiState.update { current ->
current.copy(
notice = UiNotice(
id = System.currentTimeMillis(),
message = message,
),
)
}
}
private fun currentBaseUrl(): String = uiState.value.baseUrl
private fun startPolling() {
pollingJob?.cancel()
pollingJob = viewModelScope.launch {
while (isActive) {
delay(POLL_INTERVAL_MS)
refreshSnapshot(showSpinner = false)
}
}
}
private fun sanitizeBaseUrl(input: String): String {
val trimmed = input.trim().trimEnd('/')
if (trimmed.isBlank()) {
return BossApi.DEFAULT_BASE_URL
}
return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
trimmed
} else {
"http://$trimmed"
}
}
private fun buildWorkerCommand(
baseUrl: String,
worker: WorkerNode,
executorType: String,
workspaceInput: String,
): GeneratedWorkerCommand {
val capabilityFlags = worker.capabilities.joinToString(" ") { capability ->
"--capability ${quoteForShell(capability, worker.os)}"
}
val workspace = workspaceInput.trim().ifBlank {
if (worker.os == "windows") {
"C:\\path\\to\\project"
} else {
"/path/to/project"
}
}
val normalizedBaseUrl = sanitizeBaseUrl(baseUrl)
return if (worker.os == "windows") {
val executor = when (executorType) {
"claude" -> "powershell -ExecutionPolicy Bypass -File .\\scripts\\claude_executor.ps1"
else -> "powershell -ExecutionPolicy Bypass -File .\\scripts\\codex_executor.ps1"
}
GeneratedWorkerCommand(
workerName = worker.name,
shellLabel = "PowerShell",
command = buildString {
append("npm run worker -- --name ")
append(quoteForShell(worker.name, worker.os))
append(" --os ")
append(worker.os)
append(' ')
append(capabilityFlags)
append(" --mode command --workspace ")
append(quoteForShell(workspace, worker.os))
append(" --executor ")
append(quoteForShell(executor, worker.os))
append(" --server ")
append(quoteForShell(normalizedBaseUrl, worker.os))
},
)
} else {
val executor = when (executorType) {
"claude" -> "./scripts/claude_executor.sh"
else -> "./scripts/codex_executor.sh"
}
GeneratedWorkerCommand(
workerName = worker.name,
shellLabel = "Terminal",
command = buildString {
append("npm run worker -- --name ")
append(quoteForShell(worker.name, worker.os))
append(" --os ")
append(worker.os)
append(' ')
append(capabilityFlags)
append(" --mode command --workspace ")
append(quoteForShell(workspace, worker.os))
append(" --executor ")
append(quoteForShell(executor, worker.os))
append(" --server ")
append(quoteForShell(normalizedBaseUrl, worker.os))
},
)
}
}
private fun quoteForShell(value: String, os: String): String {
return if (os == "windows") {
"\"${value.replace("\"", "`\"")}\""
} else {
"'${value.replace("'", "'\"'\"'")}'"
}
}
companion object {
private const val KEY_BASE_URL = "boss_base_url"
private const val POLL_INTERVAL_MS = 4_500L
private const val PREFS_NAME = "boss_android"
}
}

View File

@@ -0,0 +1,55 @@
package site.hyzq.bossandroid.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val LightPalette = lightColorScheme(
primary = Color(0xFFB7791F),
onPrimary = Color(0xFFFFFBF5),
primaryContainer = Color(0xFFF4D9AA),
onPrimaryContainer = Color(0xFF2A1800),
secondary = Color(0xFF23575A),
onSecondary = Color(0xFFF2FFFE),
secondaryContainer = Color(0xFFC3ECEB),
onSecondaryContainer = Color(0xFF082022),
tertiary = Color(0xFF394B7A),
background = Color(0xFFF7F3EA),
surface = Color(0xFFFFFBF5),
surfaceVariant = Color(0xFFE9E0CF),
onSurface = Color(0xFF151515),
onSurfaceVariant = Color(0xFF4F4638),
error = Color(0xFFB3261E),
)
private val DarkPalette = darkColorScheme(
primary = Color(0xFFF4B740),
onPrimary = Color(0xFF3F2800),
primaryContainer = Color(0xFF6C4800),
onPrimaryContainer = Color(0xFFFFE2A8),
secondary = Color(0xFF8FD4D1),
onSecondary = Color(0xFF003738),
secondaryContainer = Color(0xFF145153),
onSecondaryContainer = Color(0xFFC4F1EE),
tertiary = Color(0xFFBBC7FF),
background = Color(0xFF111318),
surface = Color(0xFF171A20),
surfaceVariant = Color(0xFF2B313B),
onSurface = Color(0xFFEAE2D5),
onSurfaceVariant = Color(0xFFD1C6B4),
error = Color(0xFFFFB4AB),
)
@Composable
fun BossAndroidTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkPalette else LightPalette,
content = content,
)
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp"
android:height="96dp"
android:viewportWidth="96"
android:viewportHeight="96">
<path
android:fillColor="#111827"
android:pathData="M16,18c0,-6.627 5.373,-12 12,-12h40c6.627,0 12,5.373 12,12v60c0,6.627 -5.373,12 -12,12H28c-6.627,0 -12,-5.373 -12,-12z" />
<path
android:fillColor="#F4B740"
android:pathData="M28,26c0,-3.314 2.686,-6 6,-6h28c9.941,0 18,8.059 18,18s-8.059,18 -18,18H34c-3.314,0 -6,-2.686 -6,-6z" />
<path
android:fillColor="#FFF9EC"
android:pathData="M38,34h22c3.314,0 6,2.686 6,6s-2.686,6 -6,6H38z" />
<path
android:fillColor="#1F2937"
android:pathData="M38,60h18c7.732,0 14,6.268 14,14v2H38z" />
</vector>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Boss</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.BossAndroid" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>