mirror of
https://github.com/wgtunnel/desktop.git
synced 2026-06-02 00:29:09 +02:00
fix: daemon gui tunnel state sync issues
This commit is contained in:
+5
-7
@@ -18,6 +18,7 @@ import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.utils.io.*
|
||||
import io.ktor.websocket.*
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
@@ -131,7 +132,7 @@ class UdsBackendService(
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
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)
|
||||
override fun statusFlow(): Flow<BackendStatus> =
|
||||
basicStatusFlow()
|
||||
.flatMapLatest { basic ->
|
||||
flow {
|
||||
.transformLatest { basic ->
|
||||
while (true) {
|
||||
emit(enrichWithActiveConfigs(basic))
|
||||
while (true) {
|
||||
delay(ACTIVE_CONFIG_INTERVAL)
|
||||
emit(enrichWithActiveConfigs(basic))
|
||||
}
|
||||
delay(ACTIVE_CONFIG_INTERVAL.milliseconds)
|
||||
}
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ val serviceModule = module {
|
||||
}
|
||||
single<DaemonService> { UdsDaemonService(get(), get(), 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()) }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
currentTab.startRoute
|
||||
entry<Route.Tunnels> { TunnelsScreen(viewModel) }
|
||||
entry<Route.Tunnels> { TunnelsScreen() }
|
||||
entry<Route.Tunnel> {
|
||||
val viewModel: TunnelViewModel =
|
||||
koinViewModel(parameters = { parametersOf(it.id) })
|
||||
|
||||
+35
-33
@@ -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.sideeffects.AppSideEffect
|
||||
import com.zaneschepke.wireguardautotunnel.desktop.util.FileUtils
|
||||
import com.zaneschepke.wireguardautotunnel.desktop.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.desktop.viewmodel.TunnelsViewModel
|
||||
import io.github.vinceglb.filekit.PlatformFile
|
||||
import io.github.vinceglb.filekit.dialogs.FileKitMode
|
||||
@@ -48,10 +47,9 @@ import org.orbitmvi.orbit.compose.collectSideEffect
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TunnelsScreen(appViewModel: AppViewModel, viewModel: TunnelsViewModel = koinViewModel()) {
|
||||
fun TunnelsScreen(viewModel: TunnelsViewModel = koinViewModel()) {
|
||||
|
||||
val uiState by viewModel.collectAsState()
|
||||
val appUiState by appViewModel.collectAsState()
|
||||
|
||||
var pendingDeleteIntent by remember { mutableStateOf<DeleteIntent?>(null) }
|
||||
|
||||
@@ -90,43 +88,48 @@ fun TunnelsScreen(appViewModel: AppViewModel, viewModel: TunnelsViewModel = koin
|
||||
if (!uiState.isLoaded) return
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val pickerLauncher = rememberFilePickerLauncher(
|
||||
mode = FileKitMode.Single
|
||||
) { platformFile: PlatformFile? ->
|
||||
platformFile?.let { file ->
|
||||
val ext = file.extension.lowercase()
|
||||
val pickerLauncher =
|
||||
rememberFilePickerLauncher(mode = FileKitMode.Single) { platformFile: PlatformFile? ->
|
||||
platformFile?.let { file ->
|
||||
val ext = file.extension.lowercase()
|
||||
|
||||
scope.launch(Dispatchers.Main.immediate) {
|
||||
when (ext) {
|
||||
FileUtils.CONF_FILE_EXTENSION -> {
|
||||
runCatching {
|
||||
val text = file.readString()
|
||||
viewModel.onConfImport(text, file.nameWithoutExtension)
|
||||
}.onFailure {
|
||||
toaster.show(Toast(ToastType.Error, "Failed to read .conf file"))
|
||||
scope.launch(Dispatchers.Main.immediate) {
|
||||
when (ext) {
|
||||
FileUtils.CONF_FILE_EXTENSION -> {
|
||||
runCatching {
|
||||
val text = file.readString()
|
||||
viewModel.onConfImport(text, file.nameWithoutExtension)
|
||||
}
|
||||
.onFailure {
|
||||
toaster.show(
|
||||
Toast(ToastType.Error, "Failed to read .conf file")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
FileUtils.ZIP_FILE_EXTENSION -> {
|
||||
runCatching {
|
||||
val bytes = file.readBytes()
|
||||
val configMap = FileUtils.readConfigsFromZip(bytes)
|
||||
viewModel.onMultiConfImport(configMap)
|
||||
}.onFailure {
|
||||
toaster.show(Toast(ToastType.Error, "Failed to read .zip archive"))
|
||||
FileUtils.ZIP_FILE_EXTENSION -> {
|
||||
runCatching {
|
||||
val bytes = file.readBytes()
|
||||
val configMap = FileUtils.readConfigsFromZip(bytes)
|
||||
viewModel.onMultiConfImport(configMap)
|
||||
}
|
||||
.onFailure {
|
||||
toaster.show(
|
||||
Toast(ToastType.Error, "Failed to read .zip archive")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
toaster.show(
|
||||
Toast(
|
||||
type = ToastType.Warning,
|
||||
message = "Only '.conf' or '.zip' files supported"
|
||||
else -> {
|
||||
toaster.show(
|
||||
Toast(
|
||||
type = ToastType.Warning,
|
||||
message = "Only '.conf' or '.zip' files supported",
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
|
||||
@@ -184,7 +187,6 @@ fun TunnelsScreen(appViewModel: AppViewModel, viewModel: TunnelsViewModel = koin
|
||||
) {
|
||||
TunnelList(
|
||||
uiState = uiState,
|
||||
tunnelStatuses = appUiState.tunnelStatuses,
|
||||
startTunnel = viewModel::onStartTunnel,
|
||||
stopTunnel = viewModel::onStopTunnel,
|
||||
viewModel::onItemsReordered,
|
||||
|
||||
+1
-3
@@ -8,8 +8,7 @@ import com.zaneschepke.wireguardautotunnel.desktop.ui.theme.WarningAmber
|
||||
|
||||
fun TunnelState.asColor(): Color {
|
||||
return when (this) {
|
||||
TunnelState.DOWN,
|
||||
TunnelState.UNKNOWN -> Color.Gray
|
||||
TunnelState.DOWN -> Color.Gray
|
||||
TunnelState.HEALTHY -> HealthyGreen
|
||||
TunnelState.HANDSHAKE_FAILURE -> ErrorRed
|
||||
TunnelState.RESOLVING_DNS,
|
||||
@@ -21,7 +20,6 @@ fun TunnelState.asColor(): Color {
|
||||
fun TunnelState.asTooltipMessage(): String {
|
||||
return when (this) {
|
||||
TunnelState.DOWN,
|
||||
TunnelState.UNKNOWN -> "Unknown"
|
||||
TunnelState.STARTING -> "Starting"
|
||||
TunnelState.STOPPING -> "Stopping"
|
||||
TunnelState.HEALTHY -> "Healthy"
|
||||
|
||||
+17
-32
@@ -27,8 +27,6 @@ import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
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.button.SurfaceRow
|
||||
import com.zaneschepke.wireguardautotunnel.desktop.ui.common.button.SwitchWithDivider
|
||||
@@ -48,7 +46,6 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
@Composable
|
||||
fun TunnelList(
|
||||
uiState: TunnelsUiState,
|
||||
tunnelStatuses: List<TunnelStatus>,
|
||||
startTunnel: (id: Long) -> Unit,
|
||||
stopTunnel: (id: Long) -> Unit,
|
||||
onReorder: (Int, Int) -> Unit,
|
||||
@@ -68,18 +65,6 @@ fun TunnelList(
|
||||
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) {
|
||||
if (!reorderableState.isAnyItemDragging) {
|
||||
onReorderCompleted()
|
||||
@@ -99,7 +84,7 @@ fun TunnelList(
|
||||
}
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
if (uiState.tunnels.isEmpty()) {
|
||||
if (uiState.tunnelItems.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(top = 80.dp),
|
||||
@@ -111,9 +96,9 @@ fun TunnelList(
|
||||
return@LazyColumn
|
||||
}
|
||||
|
||||
items(uiState.tunnels, key = { it.id }) { tunnel ->
|
||||
val isSelected = uiState.selectedTunnels.contains(tunnel)
|
||||
ReorderableItem(reorderableState, key = tunnel.id) { isDragging ->
|
||||
items(uiState.tunnelItems, key = { it.config.id }) { item ->
|
||||
val isSelected = uiState.selectedTunnels.contains(item.config)
|
||||
ReorderableItem(reorderableState, key = item.config.id) { isDragging ->
|
||||
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
|
||||
|
||||
ContextMenuArea(
|
||||
@@ -122,20 +107,20 @@ fun TunnelList(
|
||||
if (!uiState.isSelectionMode) {
|
||||
add(
|
||||
ContextMenuItem("Details") {
|
||||
navController.push(Route.Tunnel(tunnel.id))
|
||||
navController.push(Route.Tunnel(item.config.id))
|
||||
}
|
||||
)
|
||||
add(
|
||||
ContextMenuItem("Delete") {
|
||||
onDelete(DeleteIntent.Tunnel(tunnel))
|
||||
onDelete(DeleteIntent.Tunnel(item.config))
|
||||
}
|
||||
)
|
||||
add(
|
||||
ContextMenuItem("Export") {
|
||||
onExport(ExportIntent.Tunnel(tunnel))
|
||||
onExport(ExportIntent.Tunnel(item.config))
|
||||
}
|
||||
)
|
||||
add(ContextMenuItem("Select") { onSelected(tunnel) })
|
||||
add(ContextMenuItem("Select") { onSelected(item.config) })
|
||||
} else {
|
||||
add(
|
||||
ContextMenuItem("Delete selected") {
|
||||
@@ -155,7 +140,7 @@ fun TunnelList(
|
||||
}
|
||||
) {
|
||||
SurfaceRow(
|
||||
title = tunnel.name,
|
||||
title = item.config.name,
|
||||
modifier =
|
||||
Modifier.shadow(elevation)
|
||||
.animateItem()
|
||||
@@ -167,23 +152,22 @@ fun TunnelList(
|
||||
.then(if (isDragging) Modifier.zIndex(1f) else Modifier),
|
||||
onClick = {
|
||||
if (!uiState.isSelectionMode) {
|
||||
navController.push(Route.Tunnel(tunnel.id))
|
||||
navController.push(Route.Tunnel(item.config.id))
|
||||
}
|
||||
},
|
||||
leading = {
|
||||
val tooltip = tunnelIndicators[tunnel.id]?.second
|
||||
val indicatorColor = tunnelIndicators[tunnel.id]?.first
|
||||
val item = uiState.tunnelItems.first { it.config.id == item.config.id }
|
||||
@Composable
|
||||
fun icon() {
|
||||
Icon(
|
||||
Icons.Rounded.Circle,
|
||||
contentDescription = null,
|
||||
tint = indicatorColor ?: TunnelState.UNKNOWN.asColor(),
|
||||
tint = item.stateColor,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
if (tooltip != null) {
|
||||
CustomTooltip(text = tooltip) { icon() }
|
||||
if (item.tooltipMessage.isNotBlank()) {
|
||||
CustomTooltip(text = item.tooltipMessage) { icon() }
|
||||
} else {
|
||||
icon()
|
||||
}
|
||||
@@ -192,9 +176,10 @@ fun TunnelList(
|
||||
trailing = {
|
||||
if (!uiState.isSelectionMode) {
|
||||
SwitchWithDivider(
|
||||
checked = tunnel.active,
|
||||
checked = item.isRunning,
|
||||
onClick = {
|
||||
if (it) startTunnel(tunnel.id) else stopTunnel(tunnel.id)
|
||||
if (it) startTunnel(item.config.id)
|
||||
else stopTunnel(item.config.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+27
-30
@@ -15,19 +15,13 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, FlowPreview::class)
|
||||
@Composable
|
||||
fun ConfigEditor(
|
||||
rawConfig: String,
|
||||
isEditable: Boolean,
|
||||
onConfigChange: (String) -> Unit
|
||||
) {
|
||||
fun ConfigEditor(rawConfig: String, isEditable: Boolean, onConfigChange: (String) -> Unit) {
|
||||
var textFieldValue by remember { mutableStateOf(TextFieldValue(rawConfig)) }
|
||||
|
||||
val verticalScrollState = rememberScrollState()
|
||||
@@ -48,20 +42,22 @@ fun ConfigEditor(
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
val scrollbarStyle = defaultScrollbarStyle().copy(
|
||||
thickness = 10.dp,
|
||||
unhoverColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.18f),
|
||||
hoverColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.65f),
|
||||
)
|
||||
val scrollbarStyle =
|
||||
defaultScrollbarStyle()
|
||||
.copy(
|
||||
thickness = 10.dp,
|
||||
unhoverColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.18f),
|
||||
hoverColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.65f),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
if (isEditable) MaterialTheme.colorScheme.surfaceContainerLowest
|
||||
else MaterialTheme.colorScheme.surface
|
||||
)
|
||||
.clipToBounds()
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.background(
|
||||
if (isEditable) MaterialTheme.colorScheme.surfaceContainerLowest
|
||||
else MaterialTheme.colorScheme.surface
|
||||
)
|
||||
.clipToBounds()
|
||||
) {
|
||||
BasicTextField(
|
||||
value = textFieldValue,
|
||||
@@ -70,17 +66,18 @@ fun ConfigEditor(
|
||||
textFieldValue = newValue
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(verticalScrollState)
|
||||
.horizontalScroll(horizontalScrollState)
|
||||
.padding(16.dp)
|
||||
.padding(end = 26.dp),
|
||||
textStyle = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.verticalScroll(verticalScrollState)
|
||||
.horizontalScroll(horizontalScrollState)
|
||||
.padding(16.dp)
|
||||
.padding(end = 26.dp),
|
||||
textStyle =
|
||||
TextStyle(
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
readOnly = !isEditable,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
visualTransformation = remember { ConfigVisualTransformation() },
|
||||
|
||||
+3
-3
@@ -23,7 +23,7 @@ class ConfigVisualTransformation : VisualTransformation {
|
||||
builder.addStyle(
|
||||
SpanStyle(color = Color(0xFFBB86FC), fontWeight = FontWeight.Bold),
|
||||
it.range.first,
|
||||
it.range.last + 1
|
||||
it.range.last + 1,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ class ConfigVisualTransformation : VisualTransformation {
|
||||
builder.addStyle(
|
||||
SpanStyle(color = Color(0xFF03DAC5)),
|
||||
it.range.first,
|
||||
it.range.last + 1
|
||||
it.range.last + 1,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class ConfigVisualTransformation : VisualTransformation {
|
||||
builder.addStyle(
|
||||
SpanStyle(color = Color.Gray, fontStyle = FontStyle.Italic),
|
||||
match.range.first,
|
||||
match.range.first + trimmed.length
|
||||
match.range.first + trimmed.length,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -1,10 +1,26 @@
|
||||
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.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(
|
||||
val tunnels: List<TunnelConfig> = emptyList(),
|
||||
val tunnelItems: List<TunnelUiItem> = emptyList(),
|
||||
val selectedTunnels: List<TunnelConfig> = emptyList(),
|
||||
val isSelectionMode: 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() ?: ""
|
||||
}
|
||||
|
||||
+40
-13
@@ -5,11 +5,13 @@ import com.dokar.sonner.ToastType
|
||||
import com.zaneschepke.wireguardautotunnel.client.domain.error.ClientException
|
||||
import com.zaneschepke.wireguardautotunnel.client.domain.model.TunnelConfig
|
||||
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.TunnelService
|
||||
import com.zaneschepke.wireguardautotunnel.desktop.ui.screens.tunnels.DeleteIntent
|
||||
import com.zaneschepke.wireguardautotunnel.desktop.ui.screens.tunnels.ExportIntent
|
||||
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.util.FileUtils
|
||||
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.name
|
||||
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.viewmodel.container
|
||||
|
||||
@@ -26,31 +26,53 @@ class TunnelsViewModel(
|
||||
private val tunnelRepository: TunnelRepository,
|
||||
private val tunnelService: TunnelService,
|
||||
private val tunnelImportService: TunnelImportService,
|
||||
private val backendService: BackendService,
|
||||
) : ContainerHost<TunnelsUiState, AppSideEffect>, ViewModel() {
|
||||
|
||||
override val container =
|
||||
container<TunnelsUiState, AppSideEffect>(
|
||||
TunnelsUiState(),
|
||||
buildSettings = { repeatOnSubscribedStopTimeout = 5_000L },
|
||||
) {
|
||||
container<TunnelsUiState, AppSideEffect>(TunnelsUiState()) {
|
||||
intent {
|
||||
tunnelRepository.flow.collect { tunnels ->
|
||||
reduce { state.copy(tunnels = tunnels, isLoaded = true) }
|
||||
tunnelRepository.flow.collect { configs ->
|
||||
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 {
|
||||
val list = state.tunnels.toMutableList()
|
||||
val list = state.tunnelItems.toMutableList()
|
||||
val item = list.removeAt(fromIndex)
|
||||
list.add(toIndex, item)
|
||||
|
||||
reduce { state.copy(tunnels = list) }
|
||||
reduce { state.copy(tunnelItems = list) }
|
||||
}
|
||||
|
||||
fun onPersistReorder() = intent {
|
||||
val updatedTunnels =
|
||||
state.tunnels.mapIndexed { index, tunnel -> tunnel.copy(position = index) }
|
||||
state.tunnelItems.mapIndexed { index, item -> item.config.copy(position = index) }
|
||||
tunnelRepository.updateAll(updatedTunnels)
|
||||
}
|
||||
|
||||
@@ -128,7 +150,12 @@ class TunnelsViewModel(
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -14,7 +14,6 @@ fun Tunnel.State.toDto(): TunnelState =
|
||||
is Tunnel.State.Up.Healthy -> TunnelState.HEALTHY
|
||||
is Tunnel.State.Up.HandshakeFailure -> TunnelState.HANDSHAKE_FAILURE
|
||||
is Tunnel.State.Up.ResolvingDns -> TunnelState.RESOLVING_DNS
|
||||
is Tunnel.State.Up.Unknown -> TunnelState.UNKNOWN
|
||||
is Tunnel.State.Stopping -> TunnelState.STOPPING
|
||||
}
|
||||
|
||||
|
||||
@@ -10,5 +10,4 @@ enum class TunnelState {
|
||||
HEALTHY,
|
||||
HANDSHAKE_FAILURE,
|
||||
RESOLVING_DNS,
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
+36
-52
@@ -68,44 +68,6 @@ class AmneziaBackend : Backend {
|
||||
override suspend fun start(tunnel: Tunnel, config: String): Result<Unit> = runCatching {
|
||||
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)
|
||||
_status.update {
|
||||
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] =
|
||||
backendScope.launch {
|
||||
try {
|
||||
statusChannel.consumeAsFlow().collect { 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 {
|
||||
it.copy(
|
||||
@@ -130,10 +123,7 @@ class AmneziaBackend : Backend {
|
||||
}
|
||||
tunnel.updateState(tunnelState)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.e(e) { "Error in status flow for tunnel ${tunnel.id}" }
|
||||
} finally {
|
||||
log.i { "Status collector for tunnel ${tunnel.id} terminating." }
|
||||
statusChannel.close()
|
||||
cleanupTunnelState(tunnel.id)
|
||||
tunnel.updateState(Tunnel.State.Down)
|
||||
@@ -146,12 +136,10 @@ class AmneziaBackend : Backend {
|
||||
|
||||
val handle =
|
||||
tunnelHandles[id]
|
||||
?: run {
|
||||
log.w { "Stop requested for $id but no handle found." }
|
||||
return Result.failure(
|
||||
BackendException.StateConflict("Tunnel $id is not active.")
|
||||
)
|
||||
}
|
||||
?: return Result.failure(
|
||||
BackendException.StateConflict("Tunnel $id is not active.")
|
||||
)
|
||||
|
||||
_status.update { current ->
|
||||
val key = current.activeTunnels.keys.firstOrNull { it.id == id }
|
||||
if (key != null)
|
||||
@@ -160,15 +148,12 @@ class AmneziaBackend : Backend {
|
||||
}
|
||||
|
||||
tunnelMutex.withLock {
|
||||
log.d { "Lock acquired for Stop. Calling native turnOff for handle: $handle" }
|
||||
when (currentMode) {
|
||||
Backend.Mode.Proxy -> tun.awgProxyTurnOff(handle)
|
||||
Backend.Mode.Userspace -> tun.awgTurnOff(handle)
|
||||
}
|
||||
}
|
||||
|
||||
tunnelJobs[id]?.cancel()
|
||||
|
||||
log.i { "Stop command sent and job cancelled for tunnel $id" }
|
||||
}
|
||||
|
||||
@@ -256,7 +241,6 @@ class AmneziaBackend : Backend {
|
||||
0 -> Tunnel.State.Up.Healthy
|
||||
1 -> Tunnel.State.Up.HandshakeFailure
|
||||
2 -> Tunnel.State.Up.ResolvingDns
|
||||
3 -> Tunnel.State.Up.Unknown
|
||||
else -> Tunnel.State.Down
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ interface Tunnel {
|
||||
data object ResolvingDns : Up()
|
||||
|
||||
data object HandshakeFailure : Up()
|
||||
|
||||
data object Unknown : Up()
|
||||
}
|
||||
|
||||
data object Down : State
|
||||
|
||||
Reference in New Issue
Block a user