fix: daemon gui tunnel state sync issues

This commit is contained in:
zaneschepke
2026-03-27 04:41:29 -04:00
parent 66a6cd6958
commit d19db57265
15 changed files with 191 additions and 182 deletions
@@ -18,6 +18,7 @@ import io.ktor.client.request.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.utils.io.* import io.ktor.utils.io.*
import io.ktor.websocket.* import io.ktor.websocket.*
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
@@ -131,7 +132,7 @@ class UdsBackendService(
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
delay(DAEMON_WS_RECONNECT_DELAY_MILLIS) delay(DAEMON_WS_RECONNECT_DELAY_MILLIS.milliseconds)
} }
} }
@@ -142,13 +143,10 @@ class UdsBackendService(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
override fun statusFlow(): Flow<BackendStatus> = override fun statusFlow(): Flow<BackendStatus> =
basicStatusFlow() basicStatusFlow()
.flatMapLatest { basic -> .transformLatest { basic ->
flow { while (true) {
emit(enrichWithActiveConfigs(basic)) emit(enrichWithActiveConfigs(basic))
while (true) { delay(ACTIVE_CONFIG_INTERVAL.milliseconds)
delay(ACTIVE_CONFIG_INTERVAL)
emit(enrichWithActiveConfigs(basic))
}
} }
} }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
@@ -109,7 +109,7 @@ val serviceModule = module {
} }
single<DaemonService> { UdsDaemonService(get(), get(), get()) } single<DaemonService> { UdsDaemonService(get(), get(), get()) }
single<TunnelService> { UdsTunnelService(get(), tunnelRepository = get()) } single<TunnelService> { UdsTunnelService(get(), tunnelRepository = get()) }
single<BackendService> { UdsBackendService(get(), get(), get(), get()) } single<BackendService> { UdsBackendService(get(), get(), get(), get(), get()) }
single<TunnelImportService> { DefaultTunnelImportService(get()) } single<TunnelImportService> { DefaultTunnelImportService(get()) }
} }
+6
View File
@@ -0,0 +1,6 @@
#Fri Mar 27 04:08:22 EDT 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
@@ -248,7 +248,7 @@ fun App(uiState: AppUiState, viewModel: AppViewModel, toaster: ToasterState) {
entryProvider = entryProvider =
entryProvider { entryProvider {
currentTab.startRoute currentTab.startRoute
entry<Route.Tunnels> { TunnelsScreen(viewModel) } entry<Route.Tunnels> { TunnelsScreen() }
entry<Route.Tunnel> { entry<Route.Tunnel> {
val viewModel: TunnelViewModel = val viewModel: TunnelViewModel =
koinViewModel(parameters = { parametersOf(it.id) }) koinViewModel(parameters = { parametersOf(it.id) })
@@ -30,7 +30,6 @@ import com.zaneschepke.wireguardautotunnel.desktop.ui.common.tooltip.CustomToolt
import com.zaneschepke.wireguardautotunnel.desktop.ui.screens.tunnels.components.TunnelList import com.zaneschepke.wireguardautotunnel.desktop.ui.screens.tunnels.components.TunnelList
import com.zaneschepke.wireguardautotunnel.desktop.ui.sideeffects.AppSideEffect import com.zaneschepke.wireguardautotunnel.desktop.ui.sideeffects.AppSideEffect
import com.zaneschepke.wireguardautotunnel.desktop.util.FileUtils import com.zaneschepke.wireguardautotunnel.desktop.util.FileUtils
import com.zaneschepke.wireguardautotunnel.desktop.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.desktop.viewmodel.TunnelsViewModel import com.zaneschepke.wireguardautotunnel.desktop.viewmodel.TunnelsViewModel
import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.PlatformFile
import io.github.vinceglb.filekit.dialogs.FileKitMode import io.github.vinceglb.filekit.dialogs.FileKitMode
@@ -48,10 +47,9 @@ import org.orbitmvi.orbit.compose.collectSideEffect
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TunnelsScreen(appViewModel: AppViewModel, viewModel: TunnelsViewModel = koinViewModel()) { fun TunnelsScreen(viewModel: TunnelsViewModel = koinViewModel()) {
val uiState by viewModel.collectAsState() val uiState by viewModel.collectAsState()
val appUiState by appViewModel.collectAsState()
var pendingDeleteIntent by remember { mutableStateOf<DeleteIntent?>(null) } var pendingDeleteIntent by remember { mutableStateOf<DeleteIntent?>(null) }
@@ -90,43 +88,48 @@ fun TunnelsScreen(appViewModel: AppViewModel, viewModel: TunnelsViewModel = koin
if (!uiState.isLoaded) return if (!uiState.isLoaded) return
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val pickerLauncher = rememberFilePickerLauncher( val pickerLauncher =
mode = FileKitMode.Single rememberFilePickerLauncher(mode = FileKitMode.Single) { platformFile: PlatformFile? ->
) { platformFile: PlatformFile? -> platformFile?.let { file ->
platformFile?.let { file -> val ext = file.extension.lowercase()
val ext = file.extension.lowercase()
scope.launch(Dispatchers.Main.immediate) { scope.launch(Dispatchers.Main.immediate) {
when (ext) { when (ext) {
FileUtils.CONF_FILE_EXTENSION -> { FileUtils.CONF_FILE_EXTENSION -> {
runCatching { runCatching {
val text = file.readString() val text = file.readString()
viewModel.onConfImport(text, file.nameWithoutExtension) viewModel.onConfImport(text, file.nameWithoutExtension)
}.onFailure { }
toaster.show(Toast(ToastType.Error, "Failed to read .conf file")) .onFailure {
toaster.show(
Toast(ToastType.Error, "Failed to read .conf file")
)
}
} }
} FileUtils.ZIP_FILE_EXTENSION -> {
FileUtils.ZIP_FILE_EXTENSION -> { runCatching {
runCatching { val bytes = file.readBytes()
val bytes = file.readBytes() val configMap = FileUtils.readConfigsFromZip(bytes)
val configMap = FileUtils.readConfigsFromZip(bytes) viewModel.onMultiConfImport(configMap)
viewModel.onMultiConfImport(configMap) }
}.onFailure { .onFailure {
toaster.show(Toast(ToastType.Error, "Failed to read .zip archive")) toaster.show(
Toast(ToastType.Error, "Failed to read .zip archive")
)
}
} }
} else -> {
else -> { toaster.show(
toaster.show( Toast(
Toast( type = ToastType.Warning,
type = ToastType.Warning, message = "Only '.conf' or '.zip' files supported",
message = "Only '.conf' or '.zip' files supported" )
) )
) }
} }
} }
} }
} }
}
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
@@ -184,7 +187,6 @@ fun TunnelsScreen(appViewModel: AppViewModel, viewModel: TunnelsViewModel = koin
) { ) {
TunnelList( TunnelList(
uiState = uiState, uiState = uiState,
tunnelStatuses = appUiState.tunnelStatuses,
startTunnel = viewModel::onStartTunnel, startTunnel = viewModel::onStartTunnel,
stopTunnel = viewModel::onStopTunnel, stopTunnel = viewModel::onStopTunnel,
viewModel::onItemsReordered, viewModel::onItemsReordered,
@@ -8,8 +8,7 @@ import com.zaneschepke.wireguardautotunnel.desktop.ui.theme.WarningAmber
fun TunnelState.asColor(): Color { fun TunnelState.asColor(): Color {
return when (this) { return when (this) {
TunnelState.DOWN, TunnelState.DOWN -> Color.Gray
TunnelState.UNKNOWN -> Color.Gray
TunnelState.HEALTHY -> HealthyGreen TunnelState.HEALTHY -> HealthyGreen
TunnelState.HANDSHAKE_FAILURE -> ErrorRed TunnelState.HANDSHAKE_FAILURE -> ErrorRed
TunnelState.RESOLVING_DNS, TunnelState.RESOLVING_DNS,
@@ -21,7 +20,6 @@ fun TunnelState.asColor(): Color {
fun TunnelState.asTooltipMessage(): String { fun TunnelState.asTooltipMessage(): String {
return when (this) { return when (this) {
TunnelState.DOWN, TunnelState.DOWN,
TunnelState.UNKNOWN -> "Unknown"
TunnelState.STARTING -> "Starting" TunnelState.STARTING -> "Starting"
TunnelState.STOPPING -> "Stopping" TunnelState.STOPPING -> "Stopping"
TunnelState.HEALTHY -> "Healthy" TunnelState.HEALTHY -> "Healthy"
@@ -27,8 +27,6 @@ import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import com.zaneschepke.wireguardautotunnel.client.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.client.domain.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.core.ipc.dto.TunnelState
import com.zaneschepke.wireguardautotunnel.core.ipc.dto.TunnelStatus
import com.zaneschepke.wireguardautotunnel.desktop.ui.common.LocalNavController import com.zaneschepke.wireguardautotunnel.desktop.ui.common.LocalNavController
import com.zaneschepke.wireguardautotunnel.desktop.ui.common.button.SurfaceRow import com.zaneschepke.wireguardautotunnel.desktop.ui.common.button.SurfaceRow
import com.zaneschepke.wireguardautotunnel.desktop.ui.common.button.SwitchWithDivider import com.zaneschepke.wireguardautotunnel.desktop.ui.common.button.SwitchWithDivider
@@ -48,7 +46,6 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
@Composable @Composable
fun TunnelList( fun TunnelList(
uiState: TunnelsUiState, uiState: TunnelsUiState,
tunnelStatuses: List<TunnelStatus>,
startTunnel: (id: Long) -> Unit, startTunnel: (id: Long) -> Unit,
stopTunnel: (id: Long) -> Unit, stopTunnel: (id: Long) -> Unit,
onReorder: (Int, Int) -> Unit, onReorder: (Int, Int) -> Unit,
@@ -68,18 +65,6 @@ fun TunnelList(
onReorder(from.index, to.index) onReorder(from.index, to.index)
} }
val tunnelIndicators by
remember(tunnelStatuses, uiState.tunnels) {
derivedStateOf {
uiState.tunnels.associate { tunnel ->
val state =
tunnelStatuses.firstOrNull { it.id == tunnel.id }?.state
?: TunnelState.UNKNOWN
tunnel.id to (state.asColor() to state.asTooltipMessage())
}
}
}
LaunchedEffect(reorderableState.isAnyItemDragging) { LaunchedEffect(reorderableState.isAnyItemDragging) {
if (!reorderableState.isAnyItemDragging) { if (!reorderableState.isAnyItemDragging) {
onReorderCompleted() onReorderCompleted()
@@ -99,7 +84,7 @@ fun TunnelList(
} }
.fillMaxSize(), .fillMaxSize(),
) { ) {
if (uiState.tunnels.isEmpty()) { if (uiState.tunnelItems.isEmpty()) {
item { item {
Box( Box(
modifier = Modifier.fillMaxSize().padding(top = 80.dp), modifier = Modifier.fillMaxSize().padding(top = 80.dp),
@@ -111,9 +96,9 @@ fun TunnelList(
return@LazyColumn return@LazyColumn
} }
items(uiState.tunnels, key = { it.id }) { tunnel -> items(uiState.tunnelItems, key = { it.config.id }) { item ->
val isSelected = uiState.selectedTunnels.contains(tunnel) val isSelected = uiState.selectedTunnels.contains(item.config)
ReorderableItem(reorderableState, key = tunnel.id) { isDragging -> ReorderableItem(reorderableState, key = item.config.id) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
ContextMenuArea( ContextMenuArea(
@@ -122,20 +107,20 @@ fun TunnelList(
if (!uiState.isSelectionMode) { if (!uiState.isSelectionMode) {
add( add(
ContextMenuItem("Details") { ContextMenuItem("Details") {
navController.push(Route.Tunnel(tunnel.id)) navController.push(Route.Tunnel(item.config.id))
} }
) )
add( add(
ContextMenuItem("Delete") { ContextMenuItem("Delete") {
onDelete(DeleteIntent.Tunnel(tunnel)) onDelete(DeleteIntent.Tunnel(item.config))
} }
) )
add( add(
ContextMenuItem("Export") { ContextMenuItem("Export") {
onExport(ExportIntent.Tunnel(tunnel)) onExport(ExportIntent.Tunnel(item.config))
} }
) )
add(ContextMenuItem("Select") { onSelected(tunnel) }) add(ContextMenuItem("Select") { onSelected(item.config) })
} else { } else {
add( add(
ContextMenuItem("Delete selected") { ContextMenuItem("Delete selected") {
@@ -155,7 +140,7 @@ fun TunnelList(
} }
) { ) {
SurfaceRow( SurfaceRow(
title = tunnel.name, title = item.config.name,
modifier = modifier =
Modifier.shadow(elevation) Modifier.shadow(elevation)
.animateItem() .animateItem()
@@ -167,23 +152,22 @@ fun TunnelList(
.then(if (isDragging) Modifier.zIndex(1f) else Modifier), .then(if (isDragging) Modifier.zIndex(1f) else Modifier),
onClick = { onClick = {
if (!uiState.isSelectionMode) { if (!uiState.isSelectionMode) {
navController.push(Route.Tunnel(tunnel.id)) navController.push(Route.Tunnel(item.config.id))
} }
}, },
leading = { leading = {
val tooltip = tunnelIndicators[tunnel.id]?.second val item = uiState.tunnelItems.first { it.config.id == item.config.id }
val indicatorColor = tunnelIndicators[tunnel.id]?.first
@Composable @Composable
fun icon() { fun icon() {
Icon( Icon(
Icons.Rounded.Circle, Icons.Rounded.Circle,
contentDescription = null, contentDescription = null,
tint = indicatorColor ?: TunnelState.UNKNOWN.asColor(), tint = item.stateColor,
modifier = Modifier.size(14.dp), modifier = Modifier.size(14.dp),
) )
} }
if (tooltip != null) { if (item.tooltipMessage.isNotBlank()) {
CustomTooltip(text = tooltip) { icon() } CustomTooltip(text = item.tooltipMessage) { icon() }
} else { } else {
icon() icon()
} }
@@ -192,9 +176,10 @@ fun TunnelList(
trailing = { trailing = {
if (!uiState.isSelectionMode) { if (!uiState.isSelectionMode) {
SwitchWithDivider( SwitchWithDivider(
checked = tunnel.active, checked = item.isRunning,
onClick = { onClick = {
if (it) startTunnel(tunnel.id) else stopTunnel(tunnel.id) if (it) startTunnel(item.config.id)
else stopTunnel(item.config.id)
}, },
) )
} }
@@ -15,19 +15,13 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class, FlowPreview::class) @OptIn(ExperimentalFoundationApi::class, FlowPreview::class)
@Composable @Composable
fun ConfigEditor( fun ConfigEditor(rawConfig: String, isEditable: Boolean, onConfigChange: (String) -> Unit) {
rawConfig: String,
isEditable: Boolean,
onConfigChange: (String) -> Unit
) {
var textFieldValue by remember { mutableStateOf(TextFieldValue(rawConfig)) } var textFieldValue by remember { mutableStateOf(TextFieldValue(rawConfig)) }
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
@@ -48,20 +42,22 @@ fun ConfigEditor(
.launchIn(this) .launchIn(this)
} }
val scrollbarStyle = defaultScrollbarStyle().copy( val scrollbarStyle =
thickness = 10.dp, defaultScrollbarStyle()
unhoverColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.18f), .copy(
hoverColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.65f), thickness = 10.dp,
) unhoverColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.18f),
hoverColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.65f),
)
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier.fillMaxSize()
.background( .background(
if (isEditable) MaterialTheme.colorScheme.surfaceContainerLowest if (isEditable) MaterialTheme.colorScheme.surfaceContainerLowest
else MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surface
) )
.clipToBounds() .clipToBounds()
) { ) {
BasicTextField( BasicTextField(
value = textFieldValue, value = textFieldValue,
@@ -70,17 +66,18 @@ fun ConfigEditor(
textFieldValue = newValue textFieldValue = newValue
} }
}, },
modifier = Modifier modifier =
.fillMaxSize() Modifier.fillMaxSize()
.verticalScroll(verticalScrollState) .verticalScroll(verticalScrollState)
.horizontalScroll(horizontalScrollState) .horizontalScroll(horizontalScrollState)
.padding(16.dp) .padding(16.dp)
.padding(end = 26.dp), .padding(end = 26.dp),
textStyle = TextStyle( textStyle =
fontSize = 14.sp, TextStyle(
color = MaterialTheme.colorScheme.onSurface, fontSize = 14.sp,
fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurface,
), fontFamily = FontFamily.Monospace,
),
readOnly = !isEditable, readOnly = !isEditable,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
visualTransformation = remember { ConfigVisualTransformation() }, visualTransformation = remember { ConfigVisualTransformation() },
@@ -98,4 +95,4 @@ fun ConfigEditor(
style = scrollbarStyle, style = scrollbarStyle,
) )
} }
} }
@@ -23,7 +23,7 @@ class ConfigVisualTransformation : VisualTransformation {
builder.addStyle( builder.addStyle(
SpanStyle(color = Color(0xFFBB86FC), fontWeight = FontWeight.Bold), SpanStyle(color = Color(0xFFBB86FC), fontWeight = FontWeight.Bold),
it.range.first, it.range.first,
it.range.last + 1 it.range.last + 1,
) )
} }
@@ -32,7 +32,7 @@ class ConfigVisualTransformation : VisualTransformation {
builder.addStyle( builder.addStyle(
SpanStyle(color = Color(0xFF03DAC5)), SpanStyle(color = Color(0xFF03DAC5)),
it.range.first, it.range.first,
it.range.last + 1 it.range.last + 1,
) )
} }
@@ -43,11 +43,11 @@ class ConfigVisualTransformation : VisualTransformation {
builder.addStyle( builder.addStyle(
SpanStyle(color = Color.Gray, fontStyle = FontStyle.Italic), SpanStyle(color = Color.Gray, fontStyle = FontStyle.Italic),
match.range.first, match.range.first,
match.range.first + trimmed.length match.range.first + trimmed.length,
) )
} }
} }
return TransformedText(builder.toAnnotatedString(), OffsetMapping.Identity) return TransformedText(builder.toAnnotatedString(), OffsetMapping.Identity)
} }
} }
@@ -1,10 +1,26 @@
package com.zaneschepke.wireguardautotunnel.desktop.ui.state package com.zaneschepke.wireguardautotunnel.desktop.ui.state
import androidx.compose.ui.graphics.Color
import com.zaneschepke.wireguardautotunnel.client.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.client.domain.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.core.ipc.dto.TunnelState
import com.zaneschepke.wireguardautotunnel.core.ipc.dto.TunnelStatus
import com.zaneschepke.wireguardautotunnel.desktop.ui.screens.tunnels.components.asColor
import com.zaneschepke.wireguardautotunnel.desktop.ui.screens.tunnels.components.asTooltipMessage
data class TunnelsUiState( data class TunnelsUiState(
val tunnels: List<TunnelConfig> = emptyList(), val tunnelItems: List<TunnelUiItem> = emptyList(),
val selectedTunnels: List<TunnelConfig> = emptyList(), val selectedTunnels: List<TunnelConfig> = emptyList(),
val isSelectionMode: Boolean = false, val isSelectionMode: Boolean = false,
val isLoaded: Boolean = false, val isLoaded: Boolean = false,
) )
data class TunnelUiItem(val config: TunnelConfig, val status: TunnelStatus? = null) {
val isRunning: Boolean
get() = status?.state != TunnelState.DOWN && status != null
val stateColor: Color
get() = status?.state?.asColor() ?: TunnelState.DOWN.asColor()
val tooltipMessage: String
get() = status?.state?.asTooltipMessage() ?: ""
}
@@ -5,11 +5,13 @@ import com.dokar.sonner.ToastType
import com.zaneschepke.wireguardautotunnel.client.domain.error.ClientException import com.zaneschepke.wireguardautotunnel.client.domain.error.ClientException
import com.zaneschepke.wireguardautotunnel.client.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.client.domain.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.client.domain.repository.TunnelRepository import com.zaneschepke.wireguardautotunnel.client.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.client.service.BackendService
import com.zaneschepke.wireguardautotunnel.client.service.TunnelImportService import com.zaneschepke.wireguardautotunnel.client.service.TunnelImportService
import com.zaneschepke.wireguardautotunnel.client.service.TunnelService import com.zaneschepke.wireguardautotunnel.client.service.TunnelService
import com.zaneschepke.wireguardautotunnel.desktop.ui.screens.tunnels.DeleteIntent import com.zaneschepke.wireguardautotunnel.desktop.ui.screens.tunnels.DeleteIntent
import com.zaneschepke.wireguardautotunnel.desktop.ui.screens.tunnels.ExportIntent import com.zaneschepke.wireguardautotunnel.desktop.ui.screens.tunnels.ExportIntent
import com.zaneschepke.wireguardautotunnel.desktop.ui.sideeffects.AppSideEffect import com.zaneschepke.wireguardautotunnel.desktop.ui.sideeffects.AppSideEffect
import com.zaneschepke.wireguardautotunnel.desktop.ui.state.TunnelUiItem
import com.zaneschepke.wireguardautotunnel.desktop.ui.state.TunnelsUiState import com.zaneschepke.wireguardautotunnel.desktop.ui.state.TunnelsUiState
import com.zaneschepke.wireguardautotunnel.desktop.util.FileUtils import com.zaneschepke.wireguardautotunnel.desktop.util.FileUtils
import com.zaneschepke.wireguardautotunnel.desktop.util.asUserMessage import com.zaneschepke.wireguardautotunnel.desktop.util.asUserMessage
@@ -17,8 +19,6 @@ import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.dialogs.openFileSaver import io.github.vinceglb.filekit.dialogs.openFileSaver
import io.github.vinceglb.filekit.name import io.github.vinceglb.filekit.name
import io.github.vinceglb.filekit.write import io.github.vinceglb.filekit.write
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.ContainerHost
import org.orbitmvi.orbit.viewmodel.container import org.orbitmvi.orbit.viewmodel.container
@@ -26,31 +26,53 @@ class TunnelsViewModel(
private val tunnelRepository: TunnelRepository, private val tunnelRepository: TunnelRepository,
private val tunnelService: TunnelService, private val tunnelService: TunnelService,
private val tunnelImportService: TunnelImportService, private val tunnelImportService: TunnelImportService,
private val backendService: BackendService,
) : ContainerHost<TunnelsUiState, AppSideEffect>, ViewModel() { ) : ContainerHost<TunnelsUiState, AppSideEffect>, ViewModel() {
override val container = override val container =
container<TunnelsUiState, AppSideEffect>( container<TunnelsUiState, AppSideEffect>(TunnelsUiState()) {
TunnelsUiState(),
buildSettings = { repeatOnSubscribedStopTimeout = 5_000L },
) {
intent { intent {
tunnelRepository.flow.collect { tunnels -> tunnelRepository.flow.collect { configs ->
reduce { state.copy(tunnels = tunnels, isLoaded = true) } reduce {
val currentItems = state.tunnelItems
val updatedItems =
configs.map { config ->
val existingStatus =
currentItems.firstOrNull { it.config.id == config.id }?.status
TunnelUiItem(config = config, status = existingStatus)
}
state.copy(tunnelItems = updatedItems, isLoaded = true)
}
}
}
intent {
backendService.statusFlow().collect { backendStatus ->
reduce {
val updatedItems =
state.tunnelItems.map { item ->
val newStatus =
backendStatus.activeTunnels.firstOrNull {
it.id == item.config.id
}
item.copy(status = newStatus)
}
state.copy(tunnelItems = updatedItems)
}
} }
} }
} }
fun onItemsReordered(fromIndex: Int, toIndex: Int) = intent { fun onItemsReordered(fromIndex: Int, toIndex: Int) = intent {
val list = state.tunnels.toMutableList() val list = state.tunnelItems.toMutableList()
val item = list.removeAt(fromIndex) val item = list.removeAt(fromIndex)
list.add(toIndex, item) list.add(toIndex, item)
reduce { state.copy(tunnelItems = list) }
reduce { state.copy(tunnels = list) }
} }
fun onPersistReorder() = intent { fun onPersistReorder() = intent {
val updatedTunnels = val updatedTunnels =
state.tunnels.mapIndexed { index, tunnel -> tunnel.copy(position = index) } state.tunnelItems.mapIndexed { index, item -> item.config.copy(position = index) }
tunnelRepository.updateAll(updatedTunnels) tunnelRepository.updateAll(updatedTunnels)
} }
@@ -128,7 +150,12 @@ class TunnelsViewModel(
} }
fun onSelectAll() = intent { fun onSelectAll() = intent {
reduce { state.copy(isSelectionMode = true, selectedTunnels = state.tunnels) } reduce {
state.copy(
isSelectionMode = true,
selectedTunnels = state.tunnelItems.map { it.config },
)
}
} }
fun onDelete(intent: DeleteIntent) = intent { fun onDelete(intent: DeleteIntent) = intent {
@@ -14,7 +14,6 @@ fun Tunnel.State.toDto(): TunnelState =
is Tunnel.State.Up.Healthy -> TunnelState.HEALTHY is Tunnel.State.Up.Healthy -> TunnelState.HEALTHY
is Tunnel.State.Up.HandshakeFailure -> TunnelState.HANDSHAKE_FAILURE is Tunnel.State.Up.HandshakeFailure -> TunnelState.HANDSHAKE_FAILURE
is Tunnel.State.Up.ResolvingDns -> TunnelState.RESOLVING_DNS is Tunnel.State.Up.ResolvingDns -> TunnelState.RESOLVING_DNS
is Tunnel.State.Up.Unknown -> TunnelState.UNKNOWN
is Tunnel.State.Stopping -> TunnelState.STOPPING is Tunnel.State.Stopping -> TunnelState.STOPPING
} }
@@ -10,5 +10,4 @@ enum class TunnelState {
HEALTHY, HEALTHY,
HANDSHAKE_FAILURE, HANDSHAKE_FAILURE,
RESOLVING_DNS, RESOLVING_DNS,
UNKNOWN,
} }
@@ -68,44 +68,6 @@ class AmneziaBackend : Backend {
override suspend fun start(tunnel: Tunnel, config: String): Result<Unit> = runCatching { override suspend fun start(tunnel: Tunnel, config: String): Result<Unit> = runCatching {
log.i { "Start request for tunnel: ${tunnel.id}" } log.i { "Start request for tunnel: ${tunnel.id}" }
val statusChannel = Channel<Int>(Channel.BUFFERED)
val statusCallback =
object : StatusCodeCallback {
override fun onTunnelStatusCode(handle: Int, statusCode: Int) {
log.v { "Native Callback - Handle: $handle, Code: $statusCode" }
statusChannel.trySend(statusCode)
}
}
statusCallbacks[tunnel.id] = statusCallback
val handle =
tunnelMutex.withLock {
if (tunnelHandles.containsKey(tunnel.id)) {
log.w { "Tunnel ${tunnel.id} already exists in handles map." }
throw BackendException.StateConflict("Tunnel ${tunnel.id} is already in use")
}
log.d { "Lock acquired. Invoking native turnOn..." }
val nativeHandle =
when (currentMode) {
Backend.Mode.Proxy -> tun.awgProxyTurnOn(config, statusCallback)
Backend.Mode.Userspace -> tun.awgTurnOn(config, statusCallback)
}
if (nativeHandle < 0) {
log.e { "Native turnOn failed: $nativeHandle" }
throw BackendException.InternalError(
"Tunnel failed with internal error code $nativeHandle"
)
}
tunnelHandles[tunnel.id] = nativeHandle
nativeHandle
}
log.i { "Tunnel ${tunnel.id} native initialization successful. Handle: $handle" }
tunnel.updateState(Tunnel.State.Starting) tunnel.updateState(Tunnel.State.Starting)
_status.update { _status.update {
it.copy( it.copy(
@@ -114,12 +76,43 @@ class AmneziaBackend : Backend {
) )
} }
val statusChannel = Channel<Int>(Channel.BUFFERED)
val statusCallback =
object : StatusCodeCallback {
override fun onTunnelStatusCode(handle: Int, statusCode: Int) {
log.v { "Native Callback - Handle: $handle, Code: $statusCode" }
statusChannel.trySend(statusCode)
}
}
statusCallbacks[tunnel.id] = statusCallback
val handle =
tunnelMutex.withLock {
if (tunnelHandles.containsKey(tunnel.id)) {
throw BackendException.StateConflict("Tunnel ${tunnel.id} is already in use")
}
val nativeHandle =
when (currentMode) {
Backend.Mode.Proxy -> tun.awgProxyTurnOn(config, statusCallback)
Backend.Mode.Userspace -> tun.awgTurnOn(config, statusCallback)
}
if (nativeHandle < 0) {
throw BackendException.InternalError(
"Tunnel failed with internal error code $nativeHandle"
)
}
tunnelHandles[tunnel.id] = nativeHandle
nativeHandle
}
log.i { "Tunnel ${tunnel.id} native initialization successful. Handle: $handle" }
tunnelJobs[tunnel.id] = tunnelJobs[tunnel.id] =
backendScope.launch { backendScope.launch {
try { try {
statusChannel.consumeAsFlow().collect { statusCode -> statusChannel.consumeAsFlow().collect { statusCode ->
val tunnelState = mapStatusCodeToState(statusCode) val tunnelState = mapStatusCodeToState(statusCode)
log.d { "Tunnel ${tunnel.id} status update: $statusCode -> $tunnelState" } log.d { "Tunnel ${tunnel.id} status update: $statusCode $tunnelState" }
_status.update { _status.update {
it.copy( it.copy(
@@ -130,10 +123,7 @@ class AmneziaBackend : Backend {
} }
tunnel.updateState(tunnelState) tunnel.updateState(tunnelState)
} }
} catch (e: Exception) {
log.e(e) { "Error in status flow for tunnel ${tunnel.id}" }
} finally { } finally {
log.i { "Status collector for tunnel ${tunnel.id} terminating." }
statusChannel.close() statusChannel.close()
cleanupTunnelState(tunnel.id) cleanupTunnelState(tunnel.id)
tunnel.updateState(Tunnel.State.Down) tunnel.updateState(Tunnel.State.Down)
@@ -146,12 +136,10 @@ class AmneziaBackend : Backend {
val handle = val handle =
tunnelHandles[id] tunnelHandles[id]
?: run { ?: return Result.failure(
log.w { "Stop requested for $id but no handle found." } BackendException.StateConflict("Tunnel $id is not active.")
return Result.failure( )
BackendException.StateConflict("Tunnel $id is not active.")
)
}
_status.update { current -> _status.update { current ->
val key = current.activeTunnels.keys.firstOrNull { it.id == id } val key = current.activeTunnels.keys.firstOrNull { it.id == id }
if (key != null) if (key != null)
@@ -160,15 +148,12 @@ class AmneziaBackend : Backend {
} }
tunnelMutex.withLock { tunnelMutex.withLock {
log.d { "Lock acquired for Stop. Calling native turnOff for handle: $handle" }
when (currentMode) { when (currentMode) {
Backend.Mode.Proxy -> tun.awgProxyTurnOff(handle) Backend.Mode.Proxy -> tun.awgProxyTurnOff(handle)
Backend.Mode.Userspace -> tun.awgTurnOff(handle) Backend.Mode.Userspace -> tun.awgTurnOff(handle)
} }
} }
tunnelJobs[id]?.cancel() tunnelJobs[id]?.cancel()
log.i { "Stop command sent and job cancelled for tunnel $id" } log.i { "Stop command sent and job cancelled for tunnel $id" }
} }
@@ -256,7 +241,6 @@ class AmneziaBackend : Backend {
0 -> Tunnel.State.Up.Healthy 0 -> Tunnel.State.Up.Healthy
1 -> Tunnel.State.Up.HandshakeFailure 1 -> Tunnel.State.Up.HandshakeFailure
2 -> Tunnel.State.Up.ResolvingDns 2 -> Tunnel.State.Up.ResolvingDns
3 -> Tunnel.State.Up.Unknown
else -> Tunnel.State.Down else -> Tunnel.State.Down
} }
} }
@@ -14,8 +14,6 @@ interface Tunnel {
data object ResolvingDns : Up() data object ResolvingDns : Up()
data object HandshakeFailure : Up() data object HandshakeFailure : Up()
data object Unknown : Up()
} }
data object Down : State data object Down : State