feat: add setup wizard for first-time config initialization

When config.json does not exist, the WebSocket channel sends a
setup_required message to connected clients. The Android app navigates
to a multi-step setup wizard that creates the initial config via
POST /api/setup/init (unauthenticated, guarded by file-existence check)
and merges remaining settings via PUT /api/setup/complete (authenticated).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-28 11:01:18 +09:00
parent 38da89539e
commit 4a899b92c2
18 changed files with 1181 additions and 8 deletions

View file

@ -9,6 +9,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@ -19,12 +20,15 @@ import androidx.navigation.navArgument
import io.clawdroid.backend.config.ConfigSectionDetailScreen import io.clawdroid.backend.config.ConfigSectionDetailScreen
import io.clawdroid.backend.config.ConfigSectionListScreen import io.clawdroid.backend.config.ConfigSectionListScreen
import io.clawdroid.backend.config.ConfigViewModel import io.clawdroid.backend.config.ConfigViewModel
import io.clawdroid.core.data.remote.WebSocketClient
import io.clawdroid.core.ui.theme.ClawDroidTheme import io.clawdroid.core.ui.theme.ClawDroidTheme
import io.clawdroid.feature.chat.screen.ChatScreen import io.clawdroid.feature.chat.screen.ChatScreen
import io.clawdroid.feature.chat.screen.SettingsScreen import io.clawdroid.feature.chat.screen.SettingsScreen
import io.clawdroid.navigation.NavRoutes import io.clawdroid.navigation.NavRoutes
import io.clawdroid.settings.AppSettingsScreen import io.clawdroid.settings.AppSettingsScreen
import io.clawdroid.setup.SetupWizardScreen
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -39,6 +43,22 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
ClawDroidTheme { ClawDroidTheme {
val navController = rememberNavController() val navController = rememberNavController()
val wsClient: WebSocketClient = koinInject()
// Observe setup_required messages from server
LaunchedEffect(Unit) {
wsClient.incomingMessages.collect { msg ->
if (msg.type == "setup_required") {
val current = navController.currentDestination?.route
if (current != NavRoutes.SETUP) {
navController.navigate(NavRoutes.SETUP) {
launchSingleTop = true
}
}
}
}
}
NavHost(navController = navController, startDestination = NavRoutes.CHAT) { NavHost(navController = navController, startDestination = NavRoutes.CHAT) {
composable(NavRoutes.CHAT) { composable(NavRoutes.CHAT) {
ChatScreen( ChatScreen(
@ -89,6 +109,15 @@ class MainActivity : ComponentActivity() {
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
) )
} }
composable(NavRoutes.SETUP) {
SetupWizardScreen(
onSetupComplete = {
navController.navigate(NavRoutes.CHAT) {
popUpTo(NavRoutes.CHAT) { inclusive = true }
}
},
)
}
} }
} }
} }

View file

@ -33,6 +33,8 @@ import io.clawdroid.feature.chat.voice.CameraCaptureManager
import io.clawdroid.feature.chat.voice.VoiceModeManager import io.clawdroid.feature.chat.voice.VoiceModeManager
import io.clawdroid.settings.AppSettingsViewModel import io.clawdroid.settings.AppSettingsViewModel
import io.clawdroid.settings.GatewaySettingsStoreImpl import io.clawdroid.settings.GatewaySettingsStoreImpl
import io.clawdroid.setup.SetupApiClient
import io.clawdroid.setup.SetupViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -127,8 +129,12 @@ val appModule = module {
single { CameraCaptureManager(androidContext()) } single { CameraCaptureManager(androidContext()) }
single { VoiceModeManager(get(), get(), get(), get(), get(), get()) } single { VoiceModeManager(get(), get(), get(), get(), get(), get()) }
// Setup
single { SetupApiClient(get()) }
// ViewModel // ViewModel
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel(get(), get(), get()) } viewModel { SettingsViewModel(get(), get(), get()) }
viewModel { AppSettingsViewModel(get(), get()) } viewModel { AppSettingsViewModel(get(), get()) }
viewModel { SetupViewModel(get(), get()) }
} }

View file

@ -7,4 +7,5 @@ object NavRoutes {
const val BACKEND_SETTINGS_LIST = "backend_settings_list" const val BACKEND_SETTINGS_LIST = "backend_settings_list"
const val BACKEND_SETTINGS_SECTION = "backend_settings/{sectionKey}" const val BACKEND_SETTINGS_SECTION = "backend_settings/{sectionKey}"
const val APP_SETTINGS = "app_settings" const val APP_SETTINGS = "app_settings"
const val SETUP = "setup"
} }

View file

@ -0,0 +1,64 @@
package io.clawdroid.setup
import io.clawdroid.backend.api.GatewaySettingsStore
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.Closeable
import java.io.IOException
class SetupApiClient(private val settingsStore: GatewaySettingsStore) : Closeable {
private val json = Json { ignoreUnknownKeys = true }
private val client = HttpClient(OkHttp)
private val baseUrl: String get() = settingsStore.settings.value.httpBaseUrl
private val apiKey: String get() = settingsStore.settings.value.apiKey
suspend fun init(body: JsonObject) {
val response = client.post("$baseUrl/api/setup/init") {
contentType(ContentType.Application.Json)
setBody(body.toString())
}
if (!response.status.isSuccess()) {
val errorMsg = parseError(response.bodyAsText())
throw IOException("HTTP ${response.status.value}: $errorMsg")
}
}
suspend fun complete(body: JsonObject) {
val response = client.put("$baseUrl/api/setup/complete") {
contentType(ContentType.Application.Json)
setBody(body.toString())
if (apiKey.isNotEmpty()) header("Authorization", "Bearer $apiKey")
}
if (!response.status.isSuccess()) {
val errorMsg = parseError(response.bodyAsText())
throw IOException("HTTP ${response.status.value}: $errorMsg")
}
}
override fun close() {
client.close()
}
private fun parseError(responseBody: String): String {
return try {
json.parseToJsonElement(responseBody).jsonObject["error"]?.jsonPrimitive?.content
?: "request failed"
} catch (_: Exception) {
"request failed"
}
}
}

View file

@ -0,0 +1,84 @@
package io.clawdroid.setup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.clawdroid.core.ui.theme.DeepBlack
import io.clawdroid.core.ui.theme.NeonCyan
import io.clawdroid.core.ui.theme.TextPrimary
import io.clawdroid.core.ui.theme.TextSecondary
@Composable
fun SetupCompleteScreen(
viewModel: SetupViewModel,
onSetupComplete: () -> Unit,
) {
val uiState by viewModel.uiState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Step 5 of 5", style = MaterialTheme.typography.labelMedium, color = TextSecondary)
Spacer(Modifier.height(16.dp))
Text(
"Setup Complete!",
style = MaterialTheme.typography.headlineMedium,
color = TextPrimary,
)
Spacer(Modifier.height(12.dp))
Text(
"You can change these settings later from the Settings screen.",
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
uiState.error?.let { error ->
Spacer(Modifier.height(12.dp))
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Spacer(Modifier.height(32.dp))
if (uiState.loading) {
CircularProgressIndicator(color = NeonCyan)
} else {
Button(
onClick = { viewModel.submitComplete(onSetupComplete) },
colors = ButtonDefaults.buttonColors(
containerColor = NeonCyan,
contentColor = DeepBlack,
),
modifier = Modifier.fillMaxWidth(0.6f),
) {
Text("Done")
}
}
}
}

View file

@ -0,0 +1,19 @@
package io.clawdroid.setup
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import io.clawdroid.core.ui.theme.GlassBorder
import io.clawdroid.core.ui.theme.GlassWhite
import io.clawdroid.core.ui.theme.NeonCyan
import io.clawdroid.core.ui.theme.TextPrimary
@Composable
fun setupFieldColors() = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NeonCyan.copy(alpha = 0.5f),
unfocusedBorderColor = GlassBorder,
focusedContainerColor = GlassWhite,
unfocusedContainerColor = Color.Transparent,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
)

View file

@ -0,0 +1,140 @@
package io.clawdroid.setup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import io.clawdroid.core.ui.theme.DeepBlack
import io.clawdroid.core.ui.theme.NeonCyan
import io.clawdroid.core.ui.theme.TextPrimary
import io.clawdroid.core.ui.theme.TextSecondary
@Composable
fun SetupStep1GatewayScreen(viewModel: SetupViewModel) {
val uiState by viewModel.uiState.collectAsState()
var apiKeyHidden by remember { mutableStateOf(true) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Spacer(Modifier.height(32.dp))
Text(
"Step 1 of 5",
style = MaterialTheme.typography.labelMedium,
color = TextSecondary,
)
Text(
"Gateway Connection",
style = MaterialTheme.typography.headlineMedium,
color = TextPrimary,
)
Text(
"Configure the HTTP gateway that connects this app to the backend.",
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = uiState.gatewayPort,
onValueChange = viewModel::onGatewayPortChange,
label = { Text("Port", color = TextSecondary) },
placeholder = { Text("18790", color = TextSecondary.copy(alpha = 0.5f)) },
singleLine = true,
isError = uiState.gatewayPortError != null,
supportingText = uiState.gatewayPortError?.let { err -> { Text(err) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
colors = setupFieldColors(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.gatewayApiKey,
onValueChange = viewModel::onGatewayApiKeyChange,
label = { Text("API Key", color = TextSecondary) },
singleLine = true,
visualTransformation = if (apiKeyHidden) PasswordVisualTransformation() else VisualTransformation.None,
trailingIcon = {
TextButton(onClick = { apiKeyHidden = !apiKeyHidden }) {
Text(
if (apiKeyHidden) "Show" else "Hide",
color = NeonCyan,
style = MaterialTheme.typography.labelSmall,
)
}
},
colors = setupFieldColors(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = viewModel::generateApiKey,
colors = ButtonDefaults.outlinedButtonColors(contentColor = NeonCyan),
) {
Text("Generate API Key")
}
uiState.error?.let { error ->
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Spacer(Modifier.weight(1f))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
if (uiState.loading) {
CircularProgressIndicator(color = NeonCyan, modifier = Modifier.padding(end = 16.dp))
}
Button(
onClick = viewModel::submitInit,
enabled = uiState.canProceedStep1 && !uiState.loading,
colors = ButtonDefaults.buttonColors(
containerColor = NeonCyan,
contentColor = DeepBlack,
),
) {
Text("Next")
}
}
}
}

View file

@ -0,0 +1,117 @@
package io.clawdroid.setup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import io.clawdroid.core.ui.theme.DeepBlack
import io.clawdroid.core.ui.theme.NeonCyan
import io.clawdroid.core.ui.theme.TextPrimary
import io.clawdroid.core.ui.theme.TextSecondary
@Composable
fun SetupStep2LlmScreen(viewModel: SetupViewModel) {
val uiState by viewModel.uiState.collectAsState()
var apiKeyHidden by remember { mutableStateOf(true) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Spacer(Modifier.height(32.dp))
Text("Step 2 of 5", style = MaterialTheme.typography.labelMedium, color = TextSecondary)
Text("LLM Settings", style = MaterialTheme.typography.headlineMedium, color = TextPrimary)
Text(
"Configure the language model used by the agent.",
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = uiState.llmModel,
onValueChange = viewModel::onLlmModelChange,
label = { Text("Model", color = TextSecondary) },
placeholder = { Text("e.g. openai/gpt-4o", color = TextSecondary.copy(alpha = 0.5f)) },
singleLine = true,
colors = setupFieldColors(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.llmApiKey,
onValueChange = viewModel::onLlmApiKeyChange,
label = { Text("API Key", color = TextSecondary) },
singleLine = true,
visualTransformation = if (apiKeyHidden) PasswordVisualTransformation() else VisualTransformation.None,
trailingIcon = {
TextButton(onClick = { apiKeyHidden = !apiKeyHidden }) {
Text(
if (apiKeyHidden) "Show" else "Hide",
color = NeonCyan,
style = MaterialTheme.typography.labelSmall,
)
}
},
colors = setupFieldColors(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.llmBaseUrl,
onValueChange = viewModel::onLlmBaseUrlChange,
label = { Text("Base URL", color = TextSecondary) },
placeholder = { Text("https://openrouter.ai/api/v1", color = TextSecondary.copy(alpha = 0.5f)) },
singleLine = true,
colors = setupFieldColors(),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.weight(1f))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextButton(onClick = { viewModel.skipStep(2) }) {
Text("Set up later", color = TextSecondary)
}
Button(
onClick = { viewModel.nextStep(2) },
colors = ButtonDefaults.buttonColors(
containerColor = NeonCyan,
contentColor = DeepBlack,
),
) {
Text("Next")
}
}
}
}

View file

@ -0,0 +1,92 @@
package io.clawdroid.setup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.clawdroid.core.ui.theme.DeepBlack
import io.clawdroid.core.ui.theme.NeonCyan
import io.clawdroid.core.ui.theme.TextPrimary
import io.clawdroid.core.ui.theme.TextSecondary
@Composable
fun SetupStep3WorkspaceScreen(viewModel: SetupViewModel) {
val uiState by viewModel.uiState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Spacer(Modifier.height(32.dp))
Text("Step 3 of 5", style = MaterialTheme.typography.labelMedium, color = TextSecondary)
Text("Workspace & Data", style = MaterialTheme.typography.headlineMedium, color = TextPrimary)
Text(
"Set the workspace and data directories used by the agent.",
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = uiState.workspace,
onValueChange = viewModel::onWorkspaceChange,
label = { Text("Workspace", color = TextSecondary) },
placeholder = { Text("~/.clawdroid/workspace", color = TextSecondary.copy(alpha = 0.5f)) },
singleLine = true,
colors = setupFieldColors(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.dataDir,
onValueChange = viewModel::onDataDirChange,
label = { Text("Data Directory", color = TextSecondary) },
placeholder = { Text("~/.clawdroid/data", color = TextSecondary.copy(alpha = 0.5f)) },
singleLine = true,
colors = setupFieldColors(),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.weight(1f))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextButton(onClick = { viewModel.skipStep(3) }) {
Text("Set up later", color = TextSecondary)
}
Button(
onClick = { viewModel.nextStep(3) },
colors = ButtonDefaults.buttonColors(
containerColor = NeonCyan,
contentColor = DeepBlack,
),
) {
Text("Next")
}
}
}
}

View file

@ -0,0 +1,175 @@
package io.clawdroid.setup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import io.clawdroid.core.ui.theme.DeepBlack
import io.clawdroid.core.ui.theme.NeonCyan
import io.clawdroid.core.ui.theme.TextPrimary
import io.clawdroid.core.ui.theme.TextSecondary
@Composable
fun SetupStep4ChatScreen(viewModel: SetupViewModel) {
val uiState by viewModel.uiState.collectAsState()
var wsApiKeyHidden by remember { mutableStateOf(true) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Spacer(Modifier.height(32.dp))
Text("Step 4 of 5", style = MaterialTheme.typography.labelMedium, color = TextSecondary)
Text("WebSocket & Agent", style = MaterialTheme.typography.headlineMedium, color = TextPrimary)
Text(
"Configure the WebSocket channel and agent parameters.",
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
Spacer(Modifier.height(4.dp))
// WebSocket section
Text("WebSocket", style = MaterialTheme.typography.titleSmall, color = NeonCyan)
OutlinedTextField(
value = uiState.wsHost,
onValueChange = viewModel::onWsHostChange,
label = { Text("Host", color = TextSecondary) },
singleLine = true,
colors = setupFieldColors(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.wsPort,
onValueChange = viewModel::onWsPortChange,
label = { Text("Port", color = TextSecondary) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
colors = setupFieldColors(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.wsPath,
onValueChange = viewModel::onWsPathChange,
label = { Text("Path", color = TextSecondary) },
singleLine = true,
colors = setupFieldColors(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.wsApiKey,
onValueChange = viewModel::onWsApiKeyChange,
label = { Text("WS API Key", color = TextSecondary) },
singleLine = true,
visualTransformation = if (wsApiKeyHidden) PasswordVisualTransformation() else VisualTransformation.None,
trailingIcon = {
TextButton(onClick = { wsApiKeyHidden = !wsApiKeyHidden }) {
Text(
if (wsApiKeyHidden) "Show" else "Hide",
color = NeonCyan,
style = MaterialTheme.typography.labelSmall,
)
}
},
colors = setupFieldColors(),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
// Agent section
Text("Agent Defaults", style = MaterialTheme.typography.titleSmall, color = NeonCyan)
OutlinedTextField(
value = uiState.maxTokens,
onValueChange = viewModel::onMaxTokensChange,
label = { Text("Max Tokens", color = TextSecondary) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
colors = setupFieldColors(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.contextWindow,
onValueChange = viewModel::onContextWindowChange,
label = { Text("Context Window", color = TextSecondary) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
colors = setupFieldColors(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.temperature,
onValueChange = viewModel::onTemperatureChange,
label = { Text("Temperature", color = TextSecondary) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
colors = setupFieldColors(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.maxToolIterations,
onValueChange = viewModel::onMaxToolIterationsChange,
label = { Text("Max Tool Iterations", color = TextSecondary) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
colors = setupFieldColors(),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.weight(1f))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextButton(onClick = { viewModel.skipStep(4) }) {
Text("Set up later", color = TextSecondary)
}
Button(
onClick = { viewModel.nextStep(4) },
colors = ButtonDefaults.buttonColors(
containerColor = NeonCyan,
contentColor = DeepBlack,
),
) {
Text("Next")
}
}
}
}

View file

@ -0,0 +1,206 @@
package io.clawdroid.setup
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.clawdroid.backend.api.GatewaySettings
import io.clawdroid.backend.api.GatewaySettingsStore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.util.UUID
data class SetupUiState(
val currentStep: Int = 0,
val loading: Boolean = false,
val error: String? = null,
// Step 1: Gateway
val gatewayPort: String = "18790",
val gatewayApiKey: String = "",
val step1Done: Boolean = false,
// Step 2: LLM
val llmModel: String = "",
val llmApiKey: String = "",
val llmBaseUrl: String = "",
val step2Skipped: Boolean = false,
// Step 3: Workspace
val workspace: String = "",
val dataDir: String = "",
val step3Skipped: Boolean = false,
// Step 4: WS + Agent
val wsHost: String = "127.0.0.1",
val wsPort: String = "18793",
val wsPath: String = "/ws",
val wsApiKey: String = "",
val maxTokens: String = "8192",
val contextWindow: String = "128000",
val temperature: String = "0",
val maxToolIterations: String = "10",
val step4Skipped: Boolean = false,
) {
val gatewayPortError: String?
get() {
if (gatewayPort.isEmpty()) return null
val port = gatewayPort.toIntOrNull() ?: return "Invalid number"
return if (port !in 1..65535) "1-65535" else null
}
val canProceedStep1: Boolean
get() = gatewayPort.isNotEmpty() && gatewayPortError == null && gatewayApiKey.isNotEmpty()
}
class SetupViewModel(
private val setupApiClient: SetupApiClient,
private val settingsStore: GatewaySettingsStore,
) : ViewModel() {
private val _uiState = MutableStateFlow(SetupUiState())
val uiState: StateFlow<SetupUiState> = _uiState.asStateFlow()
fun onGatewayPortChange(value: String) {
if (value.isEmpty() || value.toIntOrNull() != null) {
_uiState.update { it.copy(gatewayPort = value, error = null) }
}
}
fun onGatewayApiKeyChange(value: String) {
_uiState.update { it.copy(gatewayApiKey = value, error = null) }
}
fun generateApiKey() {
_uiState.update { it.copy(gatewayApiKey = UUID.randomUUID().toString(), error = null) }
}
fun onLlmModelChange(value: String) = _uiState.update { it.copy(llmModel = value) }
fun onLlmApiKeyChange(value: String) = _uiState.update { it.copy(llmApiKey = value) }
fun onLlmBaseUrlChange(value: String) = _uiState.update { it.copy(llmBaseUrl = value) }
fun onWorkspaceChange(value: String) = _uiState.update { it.copy(workspace = value) }
fun onDataDirChange(value: String) = _uiState.update { it.copy(dataDir = value) }
fun onWsHostChange(value: String) = _uiState.update { it.copy(wsHost = value) }
fun onWsPortChange(value: String) {
if (value.isEmpty() || value.toIntOrNull() != null) {
_uiState.update { it.copy(wsPort = value) }
}
}
fun onWsPathChange(value: String) = _uiState.update { it.copy(wsPath = value) }
fun onWsApiKeyChange(value: String) = _uiState.update { it.copy(wsApiKey = value) }
fun onMaxTokensChange(value: String) {
if (value.isEmpty() || value.toIntOrNull() != null) {
_uiState.update { it.copy(maxTokens = value) }
}
}
fun onContextWindowChange(value: String) {
if (value.isEmpty() || value.toIntOrNull() != null) {
_uiState.update { it.copy(contextWindow = value) }
}
}
fun onTemperatureChange(value: String) {
if (value.isEmpty() || value.toDoubleOrNull() != null) {
_uiState.update { it.copy(temperature = value) }
}
}
fun onMaxToolIterationsChange(value: String) {
if (value.isEmpty() || value.toIntOrNull() != null) {
_uiState.update { it.copy(maxToolIterations = value) }
}
}
fun submitInit() {
viewModelScope.launch {
val state = _uiState.value
if (!state.canProceedStep1 || state.loading) return@launch
_uiState.update { it.copy(loading = true, error = null) }
val port = state.gatewayPort.toIntOrNull() ?: 18790
val body = buildJsonObject {
put("gateway", buildJsonObject {
put("port", JsonPrimitive(port))
put("api_key", JsonPrimitive(state.gatewayApiKey))
})
}
try {
setupApiClient.init(body)
// Persist gateway settings locally
settingsStore.update(GatewaySettings(httpPort = port, apiKey = state.gatewayApiKey))
_uiState.update { it.copy(loading = false, step1Done = true, currentStep = 1) }
} catch (e: Exception) {
_uiState.update { it.copy(loading = false, error = e.message ?: "Init failed") }
}
}
}
fun skipStep(step: Int) {
_uiState.update {
when (step) {
2 -> it.copy(step2Skipped = true, currentStep = 2)
3 -> it.copy(step3Skipped = true, currentStep = 3)
4 -> it.copy(step4Skipped = true, currentStep = 4)
else -> it
}
}
}
fun nextStep(step: Int) {
_uiState.update { it.copy(currentStep = step) }
}
fun submitComplete(onComplete: () -> Unit) {
viewModelScope.launch {
val state = _uiState.value
if (state.loading) return@launch
_uiState.update { it.copy(loading = true, error = null) }
val body = buildJsonObject {
if (!state.step2Skipped) {
put("llm", buildJsonObject {
if (state.llmModel.isNotBlank()) put("model", JsonPrimitive(state.llmModel))
if (state.llmApiKey.isNotBlank()) put("api_key", JsonPrimitive(state.llmApiKey))
if (state.llmBaseUrl.isNotBlank()) put("base_url", JsonPrimitive(state.llmBaseUrl))
})
}
if (!state.step3Skipped) {
put("agents", buildJsonObject {
put("defaults", buildJsonObject {
if (state.workspace.isNotBlank()) put("workspace", JsonPrimitive(state.workspace))
if (state.dataDir.isNotBlank()) put("data_dir", JsonPrimitive(state.dataDir))
})
})
}
if (!state.step4Skipped) {
put("channels", buildJsonObject {
put("websocket", buildJsonObject {
if (state.wsHost.isNotBlank()) put("host", JsonPrimitive(state.wsHost))
state.wsPort.toIntOrNull()?.let { put("port", JsonPrimitive(it)) }
if (state.wsPath.isNotBlank()) put("path", JsonPrimitive(state.wsPath))
if (state.wsApiKey.isNotBlank()) put("api_key", JsonPrimitive(state.wsApiKey))
})
})
put("agents_extra", buildJsonObject {
put("defaults", buildJsonObject {
state.maxTokens.toIntOrNull()?.let { put("max_tokens", JsonPrimitive(it)) }
state.contextWindow.toIntOrNull()?.let { put("context_window", JsonPrimitive(it)) }
state.temperature.toDoubleOrNull()?.let { put("temperature", JsonPrimitive(it)) }
state.maxToolIterations.toIntOrNull()?.let { put("max_tool_iterations", JsonPrimitive(it)) }
})
})
}
}
try {
setupApiClient.complete(body)
_uiState.update { it.copy(loading = false) }
onComplete()
} catch (e: Exception) {
_uiState.update { it.copy(loading = false, error = e.message ?: "Complete failed") }
}
}
}
}

View file

@ -0,0 +1,73 @@
package io.clawdroid.setup
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import io.clawdroid.core.ui.theme.DeepBlack
import io.clawdroid.core.ui.theme.GradientCyan
import io.clawdroid.core.ui.theme.GradientPurple
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun SetupWizardScreen(
onSetupComplete: () -> Unit,
viewModel: SetupViewModel = koinViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
Box(
modifier = Modifier
.fillMaxSize()
.background(DeepBlack)
.drawBehind {
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
GradientCyan.copy(alpha = 0.07f),
Color.Transparent,
),
center = Offset(size.width * 0.15f, size.height * 0.1f),
radius = size.width * 0.8f,
),
)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
GradientPurple.copy(alpha = 0.07f),
Color.Transparent,
),
center = Offset(size.width * 0.85f, size.height * 0.9f),
radius = size.width * 0.7f,
),
)
},
) {
AnimatedContent(
targetState = uiState.currentStep,
transitionSpec = {
slideInHorizontally { it } togetherWith slideOutHorizontally { -it }
},
label = "setup_step",
) { step ->
when (step) {
0 -> SetupStep1GatewayScreen(viewModel)
1 -> SetupStep2LlmScreen(viewModel)
2 -> SetupStep3WorkspaceScreen(viewModel)
3 -> SetupStep4ChatScreen(viewModel)
4 -> SetupCompleteScreen(viewModel, onSetupComplete)
}
}
}
}

View file

@ -56,7 +56,7 @@ class ChatRepositoryImpl(
"status" -> _statusLabel.value = dto.content "status" -> _statusLabel.value = dto.content
"status_end" -> _statusLabel.value = null "status_end" -> _statusLabel.value = null
"tool_request" -> handleToolRequest(dto.content) "tool_request" -> handleToolRequest(dto.content)
"exit" -> { /* ignored in chat mode */ } "exit", "setup_required" -> { /* ignored in chat mode */ }
else -> { else -> {
_statusLabel.value = null _statusLabel.value = null
val entity = MessageMapper.toEntity(dto) val entity = MessageMapper.toEntity(dto)

View file

@ -523,7 +523,7 @@ func gatewayCmd() {
return tools.SilentResult(response) return tools.SilentResult(response)
}) })
channelManager, err := channels.NewManager(cfg, msgBus) channelManager, err := channels.NewManager(cfg, msgBus, configPath)
if err != nil { if err != nil {
fmt.Printf("Error creating channel manager: %v\n", err) fmt.Printf("Error creating channel manager: %v\n", err)
os.Exit(1) os.Exit(1)

View file

@ -21,6 +21,7 @@ type Manager struct {
channels map[string]Channel channels map[string]Channel
bus *bus.MessageBus bus *bus.MessageBus
config *config.Config config *config.Config
configPath string
dispatchTask *asyncTask dispatchTask *asyncTask
mu sync.RWMutex mu sync.RWMutex
} }
@ -29,11 +30,12 @@ type asyncTask struct {
cancel context.CancelFunc cancel context.CancelFunc
} }
func NewManager(cfg *config.Config, messageBus *bus.MessageBus) (*Manager, error) { func NewManager(cfg *config.Config, messageBus *bus.MessageBus, configPath string) (*Manager, error) {
m := &Manager{ m := &Manager{
channels: make(map[string]Channel), channels: make(map[string]Channel),
bus: messageBus, bus: messageBus,
config: cfg, config: cfg,
configPath: configPath,
} }
if err := m.initChannels(); err != nil { if err := m.initChannels(); err != nil {
@ -113,7 +115,7 @@ func (m *Manager) initChannels() error {
if m.config.Channels.WebSocket.Enabled { if m.config.Channels.WebSocket.Enabled {
logger.DebugC("channels", "Attempting to initialize WebSocket channel") logger.DebugC("channels", "Attempting to initialize WebSocket channel")
ws, err := NewWebSocketChannel(m.config.Channels.WebSocket, m.bus) ws, err := NewWebSocketChannel(m.config.Channels.WebSocket, m.bus, m.configPath)
if err != nil { if err != nil {
logger.ErrorCF("channels", "Failed to initialize WebSocket channel", map[string]interface{}{ logger.ErrorCF("channels", "Failed to initialize WebSocket channel", map[string]interface{}{
"error": err.Error(), "error": err.Error(),

View file

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os"
"sync" "sync"
"github.com/KarakuriAgent/clawdroid/pkg/broadcast" "github.com/KarakuriAgent/clawdroid/pkg/broadcast"
@ -38,6 +39,7 @@ type wsOutgoing struct {
type WebSocketChannel struct { type WebSocketChannel struct {
*BaseChannel *BaseChannel
config config.WebSocketConfig config config.WebSocketConfig
configPath string
server *http.Server server *http.Server
upgrader websocket.Upgrader upgrader websocket.Upgrader
clients map[*websocket.Conn]string // conn → clientID clients map[*websocket.Conn]string // conn → clientID
@ -48,12 +50,13 @@ type WebSocketChannel struct {
cancel context.CancelFunc cancel context.CancelFunc
} }
func NewWebSocketChannel(cfg config.WebSocketConfig, msgBus *bus.MessageBus) (*WebSocketChannel, error) { func NewWebSocketChannel(cfg config.WebSocketConfig, msgBus *bus.MessageBus, configPath string) (*WebSocketChannel, error) {
base := NewBaseChannel("websocket", cfg, msgBus, cfg.AllowFrom) base := NewBaseChannel("websocket", cfg, msgBus, cfg.AllowFrom)
return &WebSocketChannel{ return &WebSocketChannel{
BaseChannel: base, BaseChannel: base,
config: cfg, config: cfg,
configPath: configPath,
upgrader: websocket.Upgrader{ upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true }, CheckOrigin: func(r *http.Request) bool { return true },
}, },
@ -242,6 +245,24 @@ func (c *WebSocketChannel) handleWS(w http.ResponseWriter, r *http.Request) {
c.clientTypes[chatID] = clientType c.clientTypes[chatID] = clientType
c.mu.Unlock() c.mu.Unlock()
// Send setup_required if config.json does not exist
if c.configPath != "" {
if _, err := os.Stat(c.configPath); os.IsNotExist(err) {
setupMsg := wsOutgoing{
Content: "Configuration required",
Type: "setup_required",
}
if data, err := json.Marshal(setupMsg); err == nil {
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
logger.ErrorCF("websocket", "Failed to send setup_required", map[string]interface{}{
"client_id": clientID,
"error": err.Error(),
})
}
}
}
}
go c.readPump(conn, clientID, chatID, clientType) go c.readPump(conn, clientID, chatID, clientType)
} }

View file

@ -32,6 +32,8 @@ func (s *Server) Start() error {
mux.HandleFunc("GET /api/config/schema", s.authMiddleware(s.handleGetSchema)) mux.HandleFunc("GET /api/config/schema", s.authMiddleware(s.handleGetSchema))
mux.HandleFunc("GET /api/config", s.authMiddleware(s.handleGetConfig)) mux.HandleFunc("GET /api/config", s.authMiddleware(s.handleGetConfig))
mux.HandleFunc("PUT /api/config", s.authMiddleware(s.handlePutConfig)) mux.HandleFunc("PUT /api/config", s.authMiddleware(s.handlePutConfig))
mux.HandleFunc("POST /api/setup/init", s.handleSetupInit)
mux.HandleFunc("PUT /api/setup/complete", s.authMiddleware(s.handleSetupComplete))
addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Gateway.Port) addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Gateway.Port)
s.server = &http.Server{ s.server = &http.Server{

142
pkg/gateway/setup.go Normal file
View file

@ -0,0 +1,142 @@
package gateway
import (
"encoding/json"
"net/http"
"os"
"time"
"github.com/KarakuriAgent/clawdroid/pkg/config"
"github.com/KarakuriAgent/clawdroid/pkg/logger"
)
type gatewaySetupRequest struct {
Port int `json:"port"`
APIKey string `json:"api_key"`
}
type setupInitRequest struct {
Gateway gatewaySetupRequest `json:"gateway"`
}
// handleSetupInit creates config.json from scratch when it does not yet exist.
// POST /api/setup/init — no authentication required.
func (s *Server) handleSetupInit(w http.ResponseWriter, r *http.Request) {
// Only allowed when config.json does not exist
if _, err := os.Stat(s.configPath); err == nil {
writeJSONError(w, http.StatusConflict, "config already exists")
return
}
var req setupInitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
cfg := config.DefaultConfig()
if req.Gateway.Port > 0 {
cfg.Gateway.Port = req.Gateway.Port
}
cfg.Gateway.APIKey = req.Gateway.APIKey
if err := config.SaveConfig(s.configPath, cfg); err != nil {
logger.ErrorCF("gateway", "Failed to save initial config", map[string]interface{}{
"error": err.Error(),
})
writeJSONError(w, http.StatusInternalServerError, "failed to save config: "+err.Error())
return
}
logger.InfoC("gateway", "Initial config created via setup wizard")
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
if s.onRestart != nil {
go func() {
time.Sleep(100 * time.Millisecond)
s.onRestart()
}()
}
}
// handleSetupComplete merges additional settings into an existing config.json.
// PUT /api/setup/complete — authentication required.
func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
s.cfg.RLock()
currentData, err := json.Marshal(s.cfg)
s.cfg.RUnlock()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to read current config")
return
}
var incoming map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&incoming); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
// The "agents_extra" key allows patching agents.defaults without overwriting
// the entire agents block. Deep-merge it into the "agents" key so that
// e.g. agents_extra.defaults.max_tokens merges alongside agents.defaults.workspace.
if extra, ok := incoming["agents_extra"]; ok {
agents, _ := incoming["agents"].(map[string]interface{})
if agents == nil {
agents = make(map[string]interface{})
}
if extraMap, ok := extra.(map[string]interface{}); ok {
for k, v := range extraMap {
// If both sides have a map for this key, merge them deeply
existingMap, existingOk := agents[k].(map[string]interface{})
newMap, newOk := v.(map[string]interface{})
if existingOk && newOk {
for nk, nv := range newMap {
existingMap[nk] = nv
}
} else {
agents[k] = v
}
}
}
incoming["agents"] = agents
delete(incoming, "agents_extra")
}
mergedData, err := json.Marshal(incoming)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to prepare config")
return
}
var newCfg config.Config
if err := json.Unmarshal(currentData, &newCfg); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to copy config")
return
}
if err := json.Unmarshal(mergedData, &newCfg); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid config: "+err.Error())
return
}
s.cfg.Lock()
err = config.SaveConfigLocked(s.configPath, &newCfg)
if err == nil {
s.cfg.CopyFrom(&newCfg)
}
s.cfg.Unlock()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to save config: "+err.Error())
return
}
logger.InfoC("gateway", "Setup wizard completed, config updated")
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
if s.onRestart != nil {
go func() {
time.Sleep(100 * time.Millisecond)
s.onRestart()
}()
}
}