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.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)
|
||||||
|
|||||||
+1
-1
@@ -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()) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) })
|
||||||
|
|||||||
+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.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,
|
||||||
|
|||||||
+1
-3
@@ -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"
|
||||||
|
|||||||
+17
-32
@@ -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)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-31
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -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() ?: ""
|
||||||
|
}
|
||||||
|
|||||||
+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.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,
|
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-52
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user