diff --git a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsBackendService.kt b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsBackendService.kt index 25a0fac..039a93d 100644 --- a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsBackendService.kt +++ b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/data/service/UdsBackendService.kt @@ -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 = 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) diff --git a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/di/serviceModule.kt b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/di/serviceModule.kt index 827e44c..0591fe6 100644 --- a/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/di/serviceModule.kt +++ b/client/src/commonMain/kotlin/com/zaneschepke/wireguardautotunnel/client/di/serviceModule.kt @@ -109,7 +109,7 @@ val serviceModule = module { } single { UdsDaemonService(get(), get(), get()) } single { UdsTunnelService(get(), tunnelRepository = get()) } - single { UdsBackendService(get(), get(), get(), get()) } + single { UdsBackendService(get(), get(), get(), get(), get()) } single { DefaultTunnelImportService(get()) } } diff --git a/composeApp/gradle/wrapper/gradle-wrapper.properties b/composeApp/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4d0f8f2 --- /dev/null +++ b/composeApp/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/App.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/App.kt index c686183..044b5aa 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/App.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/App.kt @@ -248,7 +248,7 @@ fun App(uiState: AppUiState, viewModel: AppViewModel, toaster: ToasterState) { entryProvider = entryProvider { currentTab.startRoute - entry { TunnelsScreen(viewModel) } + entry { TunnelsScreen() } entry { val viewModel: TunnelViewModel = koinViewModel(parameters = { parametersOf(it.id) }) diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/TunnelsScreen.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/TunnelsScreen.kt index 6a0ad8d..509900a 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/TunnelsScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/TunnelsScreen.kt @@ -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(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, diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/components/Extensions.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/components/Extensions.kt index 9dacaa7..c6b41a3 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/components/Extensions.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/components/Extensions.kt @@ -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" diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/components/TunnelList.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/components/TunnelList.kt index 6f50c9a..467fd61 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/components/TunnelList.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/components/TunnelList.kt @@ -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, 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) }, ) } diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/tunnel/components/ConfigEditor.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/tunnel/components/ConfigEditor.kt index 2c685e0..3503d50 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/tunnel/components/ConfigEditor.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/tunnel/components/ConfigEditor.kt @@ -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() }, @@ -98,4 +95,4 @@ fun ConfigEditor( style = scrollbarStyle, ) } -} \ No newline at end of file +} diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/tunnel/components/ConfigVisualTransformation.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/tunnel/components/ConfigVisualTransformation.kt index e56ced0..c6d6e09 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/tunnel/components/ConfigVisualTransformation.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/screens/tunnels/tunnel/components/ConfigVisualTransformation.kt @@ -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,11 +43,11 @@ 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, ) } } return TransformedText(builder.toAnnotatedString(), OffsetMapping.Identity) } -} \ No newline at end of file +} diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/state/TunnelsUiState.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/state/TunnelsUiState.kt index 7cbabb8..4abb869 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/state/TunnelsUiState.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/ui/state/TunnelsUiState.kt @@ -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 = emptyList(), + val tunnelItems: List = emptyList(), val selectedTunnels: List = 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() ?: "" +} diff --git a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/TunnelsViewModel.kt b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/TunnelsViewModel.kt index ab5716f..8de3817 100644 --- a/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/TunnelsViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/com/zaneschepke/wireguardautotunnel/desktop/viewmodel/TunnelsViewModel.kt @@ -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, ViewModel() { override val container = - container( - TunnelsUiState(), - buildSettings = { repeatOnSubscribedStopTimeout = 5_000L }, - ) { + container(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 { diff --git a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/dto/Extensions.kt b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/dto/Extensions.kt index 958988c..5f4ebab 100644 --- a/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/dto/Extensions.kt +++ b/daemon/src/main/kotlin/com/zaneschepke/wireguardautotunnel/daemon/dto/Extensions.kt @@ -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 } diff --git a/shared/src/main/java/com/zaneschepke/wireguardautotunnel/core/ipc/dto/TunnelState.kt b/shared/src/main/java/com/zaneschepke/wireguardautotunnel/core/ipc/dto/TunnelState.kt index 0d9ce68..8eedc71 100644 --- a/shared/src/main/java/com/zaneschepke/wireguardautotunnel/core/ipc/dto/TunnelState.kt +++ b/shared/src/main/java/com/zaneschepke/wireguardautotunnel/core/ipc/dto/TunnelState.kt @@ -10,5 +10,4 @@ enum class TunnelState { HEALTHY, HANDSHAKE_FAILURE, RESOLVING_DNS, - UNKNOWN, } diff --git a/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/AmneziaBackend.kt b/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/AmneziaBackend.kt index 566e479..4476e21 100644 --- a/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/AmneziaBackend.kt +++ b/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/AmneziaBackend.kt @@ -68,44 +68,6 @@ class AmneziaBackend : Backend { override suspend fun start(tunnel: Tunnel, config: String): Result = runCatching { log.i { "Start request for tunnel: ${tunnel.id}" } - val statusChannel = Channel(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(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 } } diff --git a/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/Tunnel.kt b/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/Tunnel.kt index 32aabf9..abe0965 100644 --- a/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/Tunnel.kt +++ b/tunnel/src/main/kotlin/com/zaneschepke/wireguardautotunnel/tunnel/Tunnel.kt @@ -14,8 +14,6 @@ interface Tunnel { data object ResolvingDns : Up() data object HandshakeFailure : Up() - - data object Unknown : Up() } data object Down : State