diff --git a/app/src/common/util/ProxyAutoStartHelper.kt b/app/src/common/util/ProxyAutoStartHelper.kt index 4ffc30385..190ab88bb 100644 --- a/app/src/common/util/ProxyAutoStartHelper.kt +++ b/app/src/common/util/ProxyAutoStartHelper.kt @@ -27,9 +27,8 @@ import android.net.VpnService import com.github.yumeyucca.yumebox.core.model.RunMode import com.github.yumeyucca.yumebox.core.util.AutoStartSessionGate import com.github.yumeyucca.yumebox.runtime.api.Profile -import com.github.yumeyucca.yumebox.runtime.service.StatusProvider +import com.github.yumeyucca.yumebox.runtime.service.RuntimeCoordinator import com.github.yumeyucca.yumebox.runtime.service.util.AutoStartExecutionGate -import com.github.yumeyucca.yumebox.runtime.service.util.AutoStartUpdatePolicy import kotlinx.coroutines.CancellationException import timber.log.Timber @@ -75,7 +74,7 @@ object ProxyAutoStartHelper { return } - if (deps.proxyFacade.runtimeSnapshot.value.running || StatusProvider.serviceRunning) { + if (deps.proxyFacade.runtimeSnapshot.value.running || RuntimeCoordinator.state.value.active) { return } @@ -104,35 +103,12 @@ object ProxyAutoStartHelper { context(deps: AutoStartDependencies) @Suppress("TooGenericExceptionCaught") private suspend fun tryUpdateActiveProfileOnStart(activeProfile: Profile?) { - when ( - AutoStartUpdatePolicy.decide( - autoUpdateEnabled = deps.appSettingsStorage.autoUpdateCurrentProfileOnStart.value, - activeProfile = activeProfile, - skipForPostUpdateColdStart = false, - ) - ) { - AutoStartUpdatePolicy.Decision.Proceed -> Unit - AutoStartUpdatePolicy.Decision.AutoUpdateDisabled -> return - AutoStartUpdatePolicy.Decision.SkipPostUpdateColdStart -> { - Timber.tag(TAG).d("Skip auto update: post-update cold-start marker consumed") - return - } - - AutoStartUpdatePolicy.Decision.NoActiveProfile -> { - Timber.tag(TAG).d("Skip auto update: no active profile") - return - } - - AutoStartUpdatePolicy.Decision.UnsupportedProfileType -> { - Timber.tag(TAG) - .d("Skip auto update: unsupported profile type=${activeProfile?.type}") - return - } - - AutoStartUpdatePolicy.Decision.SkipColdStartReason -> return - } - + if (!deps.appSettingsStorage.autoUpdateCurrentProfileOnStart.value) return val target = activeProfile ?: return + if (target.type != Profile.Type.Url) { + Timber.tag(TAG).d("Skip auto update: unsupported profile type=${target.type}") + return + } try { deps.profilesRepository.updateProfile(target.uuid) Timber.tag(TAG).i("Auto update on start ok: ${target.uuid}") diff --git a/app/src/di/CoreDiModules.kt b/app/src/di/CoreDiModules.kt index 0b1f74be9..ff5a308f4 100644 --- a/app/src/di/CoreDiModules.kt +++ b/app/src/di/CoreDiModules.kt @@ -27,9 +27,11 @@ import com.github.yumeyucca.yumebox.data.network.NetworkInfoService import com.github.yumeyucca.yumebox.data.store.* import com.github.yumeyucca.yumebox.data.store.room.createTrafficStatisticsDao import com.github.yumeyucca.yumebox.domain.model.TrafficData +import com.github.yumeyucca.yumebox.runtime.api.RuntimeOwner import com.github.yumeyucca.yumebox.runtime.client.ProfilesRepository import com.github.yumeyucca.yumebox.runtime.client.ProxyFacade import com.github.yumeyucca.yumebox.runtime.client.RuntimeStateMapper +import com.github.yumeyucca.yumebox.runtime.client.access.RuntimeAccess import com.tencent.mmkv.MMKV import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -89,8 +91,8 @@ val appDataRuntimeModule = module { resolveActiveMode = { proxyFacade.runtimeSnapshot.value.let { snapshot -> snapshot.runMode.takeIf { - snapshot.owner != com.github.yumeyucca.yumebox.runtime.api.RuntimeOwner.None && - snapshot.owner != com.github.yumeyucca.yumebox.runtime.api.RuntimeOwner.RemoteController + snapshot.owner != RuntimeOwner.None && + snapshot.owner != RuntimeOwner.RemoteController } } }, @@ -110,14 +112,12 @@ val appDataRuntimeModule = module { ProvidersController( context = appContext, queryProvidersAction = { - com.github.yumeyucca.yumebox.runtime.client.access.RuntimeAccess.connect(appContext) - com.github.yumeyucca.yumebox.runtime.client.access.RuntimeAccess.core() - .queryProviders() + RuntimeAccess.connect(appContext) + RuntimeAccess.core().queryProviders() }, updateProviderAction = { type, name -> - com.github.yumeyucca.yumebox.runtime.client.access.RuntimeAccess.connect(appContext) - com.github.yumeyucca.yumebox.runtime.client.access.RuntimeAccess.core() - .updateProvider(type, name) + RuntimeAccess.connect(appContext) + RuntimeAccess.core().updateProvider(type, name) }, ) } @@ -129,12 +129,7 @@ val appDataRuntimeModule = module { single { get() } single { OverrideResolver(get(), get()) } - single { - val appContext = androidContext() - OverrideService(appContext, get()) { - // Override-change reload hook (RootTun removed; VPN reloads via the normal path). - } - } + single { OverrideService(androidContext(), get()) } single { val profilesRepository = get() ActiveProfileOverrideReloader( diff --git a/app/src/screen/settings/NetworkSettingsViewModel.kt b/app/src/screen/settings/NetworkSettingsViewModel.kt index 8b17e1a4e..1dc6ecc2b 100644 --- a/app/src/screen/settings/NetworkSettingsViewModel.kt +++ b/app/src/screen/settings/NetworkSettingsViewModel.kt @@ -203,8 +203,7 @@ class NetworkSettingsViewModel( _activeKernelId.value = KernelManager.activeKernelId(getApplication()) viewModelScope.launch { refreshInstalledKernelCommits() - val root = RootAccessSupport.evaluateAsync(getApplication()).canStartRoot - _rootAvailable.value = root + _rootAvailable.value = RootAccessSupport.isGranted() refreshEbpfAvailability() } } diff --git a/core/src/core/model/UiConfiguration.kt b/core/src/core/model/UiConfiguration.kt deleted file mode 100644 index 42d3dcb01..000000000 --- a/core/src/core/model/UiConfiguration.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.core.model - -import android.os.Parcel -import android.os.Parcelable -import com.github.yumeyucca.yumebox.core.util.Parcelizer -import kotlinx.serialization.Serializable - -@Serializable -class UiConfiguration : Parcelable { - override fun writeToParcel(parcel: Parcel, flags: Int) { - Parcelizer.encodeToParcel(serializer(), parcel, this) - } - - override fun describeContents(): Int = 0 - - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): UiConfiguration = - Parcelizer.decodeFromParcel(serializer(), parcel) - - override fun newArray(size: Int): Array = arrayOfNulls(size) - } -} diff --git a/data/src/data/controller/OverrideService.kt b/data/src/data/controller/OverrideService.kt index 537a353a7..da163f0ad 100644 --- a/data/src/data/controller/OverrideService.kt +++ b/data/src/data/controller/OverrideService.kt @@ -29,7 +29,6 @@ import timber.log.Timber class OverrideService( context: Context, private val resolver: OverrideResolver, - private val onRuntimeOverrideChanged: () -> Unit = {}, ) { private val appContext = context.appContextOrSelf @@ -74,6 +73,5 @@ class OverrideService( Intent(Intents.actionOverrideChanged(appContext.packageName)) .setPackage(appContext.packageName) ) - onRuntimeOverrideChanged() } } diff --git a/runtime/api/src/runtime/api/CoreApi.kt b/runtime/api/src/runtime/api/CoreApi.kt index aa5891018..fdc376a5a 100644 --- a/runtime/api/src/runtime/api/CoreApi.kt +++ b/runtime/api/src/runtime/api/CoreApi.kt @@ -20,38 +20,35 @@ package com.github.yumeyucca.yumebox.runtime.api -import com.github.yumeyucca.yumebox.core.model.* +import com.github.yumeyucca.yumebox.core.model.ConnectionSnapshot +import com.github.yumeyucca.yumebox.core.model.Provider +import com.github.yumeyucca.yumebox.core.model.ProviderList +import com.github.yumeyucca.yumebox.core.model.ProxyGroup +import com.github.yumeyucca.yumebox.core.model.ProxySort +import com.github.yumeyucca.yumebox.core.model.RuntimeRule interface CoreApi { - fun queryTunnelState(): TunnelState + suspend fun queryTrafficNow(): Long - fun queryTrafficNow(): Long + suspend fun queryTrafficTotal(): Long - fun queryTrafficTotal(): Long + suspend fun queryConnections(): ConnectionSnapshot - fun queryConnections(): ConnectionSnapshot + suspend fun queryAllProxyGroups(excludeNotSelectable: Boolean): List - fun queryAllProxyGroups(excludeNotSelectable: Boolean): List + suspend fun queryProxyGroup(name: String, proxySort: ProxySort): ProxyGroup - fun queryProxyGroupNames(excludeNotSelectable: Boolean): List - - fun queryProxyGroup(name: String, proxySort: ProxySort): ProxyGroup - - fun queryConfiguration(): UiConfiguration - - fun queryProviders(): ProviderList + suspend fun queryProviders(): ProviderList /** Live rules from `GET /rules` (runtime rule list, not custom-routing editor). */ - fun queryRules(): List + suspend fun queryRules(): List /** Temporarily toggle [rule], then return the controller-confirmed runtime rule list. */ suspend fun setRuleDisabled(rule: RuntimeRule, disabled: Boolean): List - fun patchSelector(group: String, name: String): Boolean + suspend fun patchSelector(group: String, name: String): Boolean - fun closeConnection(id: String): Boolean - - fun closeAllConnections() + suspend fun closeConnection(id: String): Boolean /** Tests every member of [group] and returns its directly measured delays by proxy name. */ suspend fun healthCheck(group: String): Map @@ -60,7 +57,5 @@ interface CoreApi { suspend fun updateProvider(type: Provider.Type, name: String) - fun requestStop() - fun subscribeLogs(observer: LogObserver): LogSubscription } diff --git a/runtime/api/src/runtime/api/CoreAsyncQueries.kt b/runtime/api/src/runtime/api/CoreAsyncQueries.kt deleted file mode 100644 index ccda119b7..000000000 --- a/runtime/api/src/runtime/api/CoreAsyncQueries.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.api - -import com.github.yumeyucca.yumebox.core.model.* - -/** - * Coroutine-native controller queries. Prefer this over [CoreApi] sync methods to avoid nested - * runBlocking. [CoreApi] remains for legacy call sites and is implemented as a thin bridge. - */ -interface CoreAsyncQueries { - suspend fun queryTunnelStateAsync(): TunnelState - - suspend fun queryTrafficNowAsync(): Long - - suspend fun queryTrafficTotalAsync(): Long - - suspend fun queryConnectionsAsync(): ConnectionSnapshot - - suspend fun queryAllProxyGroupsAsync(excludeNotSelectable: Boolean): List - - suspend fun queryProxyGroupNamesAsync(excludeNotSelectable: Boolean): List - - suspend fun queryProxyGroupAsync(name: String, proxySort: ProxySort): ProxyGroup - - suspend fun queryConfigurationAsync(): UiConfiguration - - suspend fun queryProvidersAsync(): ProviderList - - suspend fun queryRulesAsync(): List - - suspend fun patchSelectorAsync(group: String, name: String): Boolean - - suspend fun closeConnectionAsync(id: String): Boolean - - suspend fun closeAllConnectionsAsync() -} diff --git a/runtime/api/src/runtime/api/Intents.kt b/runtime/api/src/runtime/api/Intents.kt index c518ecb28..18c8ef694 100644 --- a/runtime/api/src/runtime/api/Intents.kt +++ b/runtime/api/src/runtime/api/Intents.kt @@ -18,67 +18,24 @@ * */ -@file:Suppress("UnusedSymbol") - package com.github.yumeyucca.yumebox.runtime.api - object Intents { private fun intentAction(packageName: String, actionName: String): String = "$packageName.intent.action.$actionName" - fun actionStartRuntime(packageName: String): String = "$packageName.action.START_CLASH" - - fun actionStopRuntime(packageName: String): String = "$packageName.action.STOP_CLASH" - - fun actionServiceRecreated(packageName: String): String = - intentAction(packageName, "CLASH_RECREATED") - - fun actionRuntimeStarted(packageName: String): String = - intentAction(packageName, "CLASH_STARTED") - - fun actionRuntimeStopped(packageName: String): String = - intentAction(packageName, "CLASH_STOPPED") - - fun actionRuntimeRequestStop(packageName: String): String = - intentAction(packageName, "CLASH_REQUEST_STOP") - fun actionProfileChanged(packageName: String): String = intentAction(packageName, "PROFILE_CHANGED") - fun actionProfileLoaded(packageName: String): String = - intentAction(packageName, "PROFILE_LOADED") - fun actionOverrideChanged(packageName: String): String = intentAction(packageName, "OVERRIDE_CHANGED") - fun actionRootRuntimeFailed(packageName: String): String = - intentAction(packageName, "ROOT_RUNTIME_FAILED") - - val ACTION_SERVICE_RECREATED: String - get() = actionServiceRecreated(packageName) - - val ACTION_RUNTIME_STARTED: String - get() = actionRuntimeStarted(packageName) - - val ACTION_RUNTIME_STOPPED: String - get() = actionRuntimeStopped(packageName) - - val ACTION_RUNTIME_REQUEST_STOP: String - get() = actionRuntimeRequestStop(packageName) - val ACTION_PROFILE_CHANGED: String get() = actionProfileChanged(packageName) - val ACTION_PROFILE_LOADED: String - get() = actionProfileLoaded(packageName) - val ACTION_OVERRIDE_CHANGED: String get() = actionOverrideChanged(packageName) - const val EXTRA_STOP_REASON = "stop_reason" - const val EXTRA_RESTART = "restart" - const val EXTRA_RUNTIME_MODE = "runtime_mode" const val EXTRA_UUID = "uuid" const val EXTRA_AFFECTS_RUNTIME = "affects_runtime" -} +} \ No newline at end of file diff --git a/runtime/api/src/runtime/api/PlatformSeams.kt b/runtime/api/src/runtime/api/PlatformSeams.kt index 04d608065..fee7f7f95 100644 --- a/runtime/api/src/runtime/api/PlatformSeams.kt +++ b/runtime/api/src/runtime/api/PlatformSeams.kt @@ -18,77 +18,56 @@ * */ -@file:Suppress("UnusedSymbol") - package com.github.yumeyucca.yumebox.runtime.api - import com.github.yumeyucca.yumebox.core.model.RunMode +import kotlinx.coroutines.flow.StateFlow /** - * Platform-neutral core process control. Android wires - * `com.github.yumeyucca.yumebox.runtime.service.core.CoreProcess`; desktop can implement the same - * contract without VpnService/libsu. + * The one authoritative lifecycle state of the local core. [owner] is [RuntimeOwner.VpnService] + * or [RuntimeOwner.RootDaemon] while anything is starting, running or stopping; [mode] is the + * concrete run mode of that owner. [lastError] survives into Idle/Failed so callers can show why + * the last session ended. [generation] increases on every published transition. */ -interface ProcessController { - /** Active controller endpoint for the running core, or null when stopped. */ - fun currentEndpoint(): CoreEndpointRef? - - fun stop() - - fun stopRoot() - - fun isRootDaemonAlive(): Boolean - - /** Persisted root mode, allowing the owner to distinguish Tun from the native eBPF listener. */ - fun rootDaemonMode(): RunMode? - - fun reconnectRoot(): String? +data class RuntimeState( + val owner: RuntimeOwner = RuntimeOwner.None, + val mode: RunMode? = null, + val phase: RuntimePhase = RuntimePhase.Idle, + val startedAt: Long? = null, + val lastError: String? = null, + val reloading: Boolean = false, + val generation: Long = 0L, +) { + val active: Boolean + get() = phase == RuntimePhase.Starting || phase == RuntimePhase.Running } -/** Sock path + bearer secret for the mihomo controller. */ -data class CoreEndpointRef(val sock: String, val secret: String) - -/** - * Cross-process / cross-host runtime phase store. Android: ContentProvider-backed StatusProvider. - */ -interface RuntimeStatusStore { - fun isRuntimeActive(runModeName: String): Boolean - - fun queryRuntimePhase(runModeName: String): RuntimePhase - - fun queryRuntimeStartedAt(runModeName: String): Long? - - fun queryRuntimeLastError(runModeName: String): String? - - fun markRuntimeIdle(runModeName: String) - - fun reconcilePersistedRuntimeState() - - /** Platform-local service/process liveness for a configured run mode name. */ - fun isLocalRuntimeServiceAlive(runModeName: String): Boolean - - fun clearLegacyStateFiles() +enum class RuntimeStartSource { + Ui, + Tile, + AutoRestart, + AutoRestartBoot, + AutoRestartReplaced, + WifiAutomation, + System, } /** - * Platform-specific start/stop of the local runtime host (VPN service, root daemon, etc.). Desktop - * can implement without Android Service APIs. + * Single entry point for starting, stopping and reloading the local core. Every call is + * serialized; [start] returns once the core answers on its controller socket and throws (after + * publishing Failed) when it cannot get there. Starting a different owner stops the current one + * first. */ -interface RuntimeLauncher { - suspend fun start(owner: RuntimeOwner, mode: RunMode) +interface RuntimeControl { + val state: StateFlow - suspend fun stop(owner: RuntimeOwner) -} + suspend fun start(mode: RunMode, source: RuntimeStartSource) -/** - * Resolves the live core controller endpoint (local unix path + secret, or remote backend - * metadata). - */ -interface CoreEndpointSource { - fun localSocketPath(): String? + suspend fun stop(reason: String? = null) - fun localSecret(): String? + /** Recompile the active profile into the running owner; no-op when nothing runs. */ + suspend fun reload() - fun isRemoteActive(): Boolean + /** Re-check that the published owner still has a live core; drops to Idle if it vanished. */ + suspend fun verify() } diff --git a/runtime/api/src/runtime/api/ProfileUpdateReport.kt b/runtime/api/src/runtime/api/ProfileUpdateReport.kt index be298fffa..d010937eb 100644 --- a/runtime/api/src/runtime/api/ProfileUpdateReport.kt +++ b/runtime/api/src/runtime/api/ProfileUpdateReport.kt @@ -31,10 +31,7 @@ data class ProviderPrefetchReport( val discoveryAnomaly: Boolean = false, /** Text-scan / incomplete header parse path; download may miss custom headers. */ val headerDegraded: Boolean = false, -) { - val hasSoftWarning: Boolean - get() = failedNames.isNotEmpty() || discoveryAnomaly -} +) /** Result of a successful profile update (main config committed). */ data class ProfileUpdateReport( diff --git a/runtime/api/src/runtime/api/RuntimeSnapshot.kt b/runtime/api/src/runtime/api/RuntimeSnapshot.kt index 1acdeb92f..7981dbe8a 100644 --- a/runtime/api/src/runtime/api/RuntimeSnapshot.kt +++ b/runtime/api/src/runtime/api/RuntimeSnapshot.kt @@ -51,14 +51,8 @@ enum class RuntimePhase { val running: Boolean get() = this == Running - val isNotIdle: Boolean - get() = this != Idle - val isActiveOrStopping: Boolean get() = this == Starting || this == Running || this == Stopping - - val isRecovering: Boolean - get() = this == Starting || this == Stopping } @Serializable @@ -69,17 +63,12 @@ data class RuntimeSnapshot( val profileReady: Boolean = false, val groupsReady: Boolean = false, val trafficReady: Boolean = false, - val configReady: Boolean = false, - val transportReady: Boolean = false, - val logReady: Boolean = false, val profileUuid: String? = null, val profileName: String? = null, val lastError: String? = null, val startedAt: Long? = null, - val effectiveFingerprint: String? = null, val generation: Long = 0L, - val running: Boolean = phase.running, ) { - val payloadReady: Boolean - get() = profileReady && groupsReady && trafficReady + val running: Boolean + get() = phase.running } diff --git a/runtime/client/src/runtime/client/CoreQueries.kt b/runtime/client/src/runtime/client/CoreQueries.kt deleted file mode 100644 index 45aeee1d8..000000000 --- a/runtime/client/src/runtime/client/CoreQueries.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.client - -import com.github.yumeyucca.yumebox.core.model.* -import com.github.yumeyucca.yumebox.runtime.api.CoreApi -import com.github.yumeyucca.yumebox.runtime.api.CoreAsyncQueries -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** - * Suspend entry for controller queries. Uses [CoreAsyncQueries] when available so the hot path - * never nests runBlocking; falls back to [CoreApi] on IO for legacy implementations. - */ -object CoreQueries { - private suspend fun query( - api: CoreApi, - async: suspend CoreAsyncQueries.() -> T, - sync: CoreApi.() -> T, - ): T { - val asyncApi = api as? CoreAsyncQueries - return if (asyncApi != null) { - asyncApi.async() - } else { - withContext(Dispatchers.IO) { api.sync() } - } - } - - suspend fun queryTunnelState(api: CoreApi): TunnelState = - query(api, { queryTunnelStateAsync() }, { queryTunnelState() }) - - suspend fun queryTrafficNow(api: CoreApi): Long = - query(api, { queryTrafficNowAsync() }, { queryTrafficNow() }) - - suspend fun queryTrafficTotal(api: CoreApi): Long = - query(api, { queryTrafficTotalAsync() }, { queryTrafficTotal() }) - - suspend fun queryConnections(api: CoreApi): ConnectionSnapshot = - query(api, { queryConnectionsAsync() }, { queryConnections() }) - - suspend fun queryAllProxyGroups(api: CoreApi, excludeNotSelectable: Boolean): List = - query( - api, - { queryAllProxyGroupsAsync(excludeNotSelectable) }, - { queryAllProxyGroups(excludeNotSelectable) }, - ) - - suspend fun queryProxyGroup(api: CoreApi, name: String, sort: ProxySort): ProxyGroup = - query(api, { queryProxyGroupAsync(name, sort) }, { queryProxyGroup(name, sort) }) - - suspend fun queryConfiguration(api: CoreApi): UiConfiguration = - query(api, { queryConfigurationAsync() }, { queryConfiguration() }) - - suspend fun queryProviders(api: CoreApi): ProviderList = - query(api, { queryProvidersAsync() }, { queryProviders() }) - - suspend fun queryRules(api: CoreApi): List = - query(api, { queryRulesAsync() }, { queryRules() }) - - suspend fun patchSelector(api: CoreApi, group: String, name: String): Boolean = - query(api, { patchSelectorAsync(group, name) }, { patchSelector(group, name) }) - - suspend fun closeConnection(api: CoreApi, id: String): Boolean = - query(api, { closeConnectionAsync(id) }, { closeConnection(id) }) - - suspend fun closeAllConnections(api: CoreApi) = - query(api, { closeAllConnectionsAsync() }, { closeAllConnections() }) - - suspend fun healthCheck(api: CoreApi, group: String): Map = api.healthCheck(group) - - suspend fun healthCheckProxy(api: CoreApi, group: String, proxyName: String): Int = - api.healthCheckProxy(group, proxyName) - - suspend fun updateProvider(api: CoreApi, type: Provider.Type, name: String) = - api.updateProvider(type, name) -} diff --git a/runtime/client/src/runtime/client/ProxyFacade.kt b/runtime/client/src/runtime/client/ProxyFacade.kt index bd0803e7c..4c0cea376 100644 --- a/runtime/client/src/runtime/client/ProxyFacade.kt +++ b/runtime/client/src/runtime/client/ProxyFacade.kt @@ -31,6 +31,7 @@ import com.github.yumeyucca.yumebox.data.store.NetworkSettingsStore import com.github.yumeyucca.yumebox.data.store.RemoteControllerStore import com.github.yumeyucca.yumebox.domain.model.ProxyDelayTestProgressCallback import com.github.yumeyucca.yumebox.domain.model.ProxyGroupInfo +import com.github.yumeyucca.yumebox.domain.model.toInfo import com.github.yumeyucca.yumebox.runtime.api.* import com.github.yumeyucca.yumebox.runtime.client.access.RuntimeAccess import com.github.yumeyucca.yumebox.runtime.client.session.RuntimeCoreOps @@ -183,18 +184,9 @@ class ProxyFacade( } suspend fun awaitProxyGroupWarmUp() { - previewWarmupJob?.let { existing -> - when { - existing.isActive -> { - existing.join() - return - } - - existing.isCompleted -> return - } - } - val job = launchPreviewWarmup() - previewWarmupJob = job + val existing = previewWarmupJob + if (existing?.isCompleted == true) return + val job = existing?.takeIf { it.isActive } ?: launchPreviewWarmup().also { previewWarmupJob = it } job.join() } @@ -215,12 +207,7 @@ class ProxyFacade( } suspend fun startProxy(mode: RunMode = networkSettingsStorage.runMode.value) = - startProxy( - RuntimeStartRequest( - owner = session.ownership.ownerForMode(mode), - mode = mode, - ) - ) + startProxy(RuntimeStartRequest(mode = mode)) suspend fun stopProxy(request: RuntimeStopRequest) { try { @@ -230,8 +217,7 @@ class ProxyFacade( } } - suspend fun stopProxy(mode: RunMode? = null) = - stopProxy(RuntimeStopRequest(targetMode = mode ?: networkSettingsStorage.runMode.value)) + suspend fun stopProxy(reason: String? = null) = stopProxy(RuntimeStopRequest(reason = reason)) suspend fun selectProxy(group: String, proxyName: String): Boolean = if (nodeSession.value.source == NodeDataSource.Active) { @@ -298,14 +284,11 @@ class ProxyFacade( suspend fun refreshCurrentProfile() { if (isRemoteControllerActive()) { session.setCurrentProfile(null) - session.updateProfileReady(null) return } runCatching { session.connectBackend() - val profile = RuntimeAccess.profile().queryActive() - session.setCurrentProfile(profile) - session.updateProfileReady(profile) + session.setCurrentProfile(RuntimeAccess.profile().queryActive()) } .onFailure { error -> if (error is CancellationException) throw error @@ -316,13 +299,8 @@ class ProxyFacade( suspend fun refreshAll() { refreshCurrentProfile() refreshProxyGroups() - if (session.snapshotValue().phase == RuntimePhase.Running) { - queryTrafficNow() - queryTrafficTotal() - } else { - session.setTrafficNow(0L) - session.setTrafficTotal(0L) - } + queryTrafficNow() + queryTrafficTotal() } private fun launchPreviewWarmup(): Job = scope.launch { @@ -334,13 +312,7 @@ class ProxyFacade( } private suspend fun refreshAllSafely() { - val snapshot = session.snapshotValue() - if ( - snapshot.phase != RuntimePhase.Running && - snapshot.owner != RuntimeOwner.RemoteController - ) { - return - } + if (!session.snapshotValue().servesNodes) return runCatching { refreshAll() } .onFailure { error -> if (error is CancellationException) throw error @@ -378,7 +350,7 @@ class ProxyFacade( AppVisibilityTracker.isForeground .collect { isForeground -> if (isForeground) { - session.reconcileAndRefresh() + session.reconcile() } else { session.stopTrafficPolling() } @@ -423,16 +395,12 @@ class ProxyFacade( NodeInputs(profile != null, snapshot, activeGroups, previewState.groups, previewState.ready) } .collect { input -> - val activeAvailable = - input.activeGroups.isNotEmpty() && - (input.snapshot.phase == RuntimePhase.Running || - input.snapshot.owner == RuntimeOwner.RemoteController) - val previewGroups = input.previewGroups.map(::toProxyGroupInfo) + val previewGroups = input.previewGroups.map(ProxyGroup::toInfo) when { - activeAvailable -> - publishNodeSession(NodeDataSource.Active, input.activeGroups, available = true) + input.activeGroups.isNotEmpty() && input.snapshot.servesNodes -> + publishNodeSession(NodeDataSource.Active, input.activeGroups) input.previewReady && previewGroups.isNotEmpty() -> - publishNodeSession(NodeDataSource.Preview, previewGroups, available = true) + publishNodeSession(NodeDataSource.Preview, previewGroups) !input.hasProfile && input.snapshot.owner != RuntimeOwner.RemoteController -> _nodeSession.value = NodeSessionState() _nodeSession.value.everReady -> @@ -443,12 +411,7 @@ class ProxyFacade( } private suspend fun resumePreviewWhenEligible() { - if ( - AppVisibilityTracker.isForeground.value && - !session.snapshotValue().phase.isActiveOrStopping && - !session.isRemoteControllerActive() && - preview.hasActiveProfile() - ) { + if (AppVisibilityTracker.isForeground.value && shouldUsePreviewRuntime()) { preview.ensureRunning() } } @@ -459,30 +422,12 @@ class ProxyFacade( !session.isRemoteControllerActive() && preview.hasActiveProfile() - private fun publishNodeSession( - source: NodeDataSource, - nodeGroups: List, - available: Boolean, - ) { + /** Callers only publish non-empty groups. */ + private fun publishNodeSession(source: NodeDataSource, nodeGroups: List) { _nodeSession.value = - NodeSessionState( - source = source, - groups = nodeGroups, - available = available, - everReady = nodeGroups.isNotEmpty() || _nodeSession.value.everReady, - ) + NodeSessionState(source = source, groups = nodeGroups, available = true, everReady = true) } - private fun toProxyGroupInfo(group: ProxyGroup): ProxyGroupInfo = - ProxyGroupInfo( - name = group.name, - type = group.type, - proxies = group.proxies, - now = group.now.trim(), - icon = group.icon, - hidden = group.hidden, - ) - private data class NodeInputs( val hasProfile: Boolean, val snapshot: RuntimeSnapshot, @@ -496,14 +441,7 @@ class ProxyFacade( requests: Map, isForeground: Boolean, ): ProxyGroupSyncPriority { - if ( - !isForeground || - snapshot.phase != RuntimePhase.Running && - snapshot.owner != RuntimeOwner.RemoteController - ) { - return ProxyGroupSyncPriority.OFF - } + if (!isForeground || !snapshot.servesNodes) return ProxyGroupSyncPriority.OFF return requests.values.maxByOrNull { it.ordinal } ?: ProxyGroupSyncPriority.OFF } - } diff --git a/runtime/client/src/runtime/client/RuntimeRequests.kt b/runtime/client/src/runtime/client/RuntimeRequests.kt index 24441ebcd..6996fdb9b 100644 --- a/runtime/client/src/runtime/client/RuntimeRequests.kt +++ b/runtime/client/src/runtime/client/RuntimeRequests.kt @@ -22,19 +22,12 @@ package com.github.yumeyucca.yumebox.runtime.client import com.github.yumeyucca.yumebox.core.model.RunMode import com.github.yumeyucca.yumebox.runtime.api.Profile -import com.github.yumeyucca.yumebox.runtime.api.RuntimeOwner -/** Start request carried as one object instead of long argument lists. */ data class RuntimeStartRequest( - val owner: RuntimeOwner, val mode: RunMode, val profile: Profile? = null, ) -/** Stop request carried as one object instead of long argument lists. */ data class RuntimeStopRequest( - val owner: RuntimeOwner = RuntimeOwner.None, - val targetMode: RunMode, - val completeImmediately: Boolean = false, val reason: String? = null, ) diff --git a/runtime/client/src/runtime/client/RuntimeStateMapper.kt b/runtime/client/src/runtime/client/RuntimeStateMapper.kt index 9fd5525c6..24651b1e6 100644 --- a/runtime/client/src/runtime/client/RuntimeStateMapper.kt +++ b/runtime/client/src/runtime/client/RuntimeStateMapper.kt @@ -32,18 +32,6 @@ object RuntimeStateMapper { fun isActuallyRunning(snapshot: RuntimeSnapshot): Boolean = snapshot.phase == RuntimePhase.Running - /** - * VpnService maps 1:1 to [RunMode.VpnService]. RootDaemon has two concrete modes and is - * resolved from the persisted root process record by [RuntimeOwnership]. - */ - fun modeForOwner(owner: RuntimeOwner): RunMode? = - when (owner) { - RuntimeOwner.VpnService -> RunMode.VpnService - RuntimeOwner.RootDaemon, - RuntimeOwner.RemoteController, - RuntimeOwner.None -> null - } - fun resolveDisplayMode(snapshot: RuntimeSnapshot, configuredMode: RunMode): RunMode = if (isRunningOrStarting(snapshot)) snapshot.runMode else configuredMode @@ -60,3 +48,7 @@ object RuntimeStateMapper { generation = generation, ) } + +/** A running local core or the remote controller answers node queries. */ +internal val RuntimeSnapshot.servesNodes: Boolean + get() = phase == RuntimePhase.Running || owner == RuntimeOwner.RemoteController diff --git a/runtime/client/src/runtime/client/access/CoreRouter.kt b/runtime/client/src/runtime/client/access/CoreRouter.kt index 9170355ef..3ad194be0 100644 --- a/runtime/client/src/runtime/client/access/CoreRouter.kt +++ b/runtime/client/src/runtime/client/access/CoreRouter.kt @@ -18,23 +18,31 @@ * */ -@file:Suppress("ConvertLongToDuration") - package com.github.yumeyucca.yumebox.runtime.client.access -import com.github.yumeyucca.yumebox.core.model.* +import com.github.yumeyucca.yumebox.core.model.ConnectionSnapshot +import com.github.yumeyucca.yumebox.core.model.Provider +import com.github.yumeyucca.yumebox.core.model.ProviderList +import com.github.yumeyucca.yumebox.core.model.ProxyGroup +import com.github.yumeyucca.yumebox.core.model.ProxySort +import com.github.yumeyucca.yumebox.core.model.RuntimeRule import com.github.yumeyucca.yumebox.runtime.api.CoreApi -import com.github.yumeyucca.yumebox.runtime.api.CoreAsyncQueries import com.github.yumeyucca.yumebox.runtime.api.LogObserver import com.github.yumeyucca.yumebox.runtime.api.LogSubscription -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch /** Routes [CoreApi] to remote controller when active, otherwise the local controller. */ class CoreRouter( private val local: CoreApi, private val remote: CoreApi, private val isRemoteControllerActive: () -> Boolean, -) : CoreApi, CoreAsyncQueries { +) : CoreApi { private val logScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var logRoutingJob: Job? = null private var activeLogToken: Any? = null @@ -42,94 +50,29 @@ class CoreRouter( private fun pick(): CoreApi = if (isRemoteControllerActive()) remote else local - private suspend fun routeAsync( - block: suspend CoreAsyncQueries.() -> T, - sync: CoreApi.() -> T, - ): T { - val target = pick() - val async = target as? CoreAsyncQueries - return if (async != null) async.block() else sync(target) - } - - override suspend fun queryTunnelStateAsync(): TunnelState = - routeAsync({ queryTunnelStateAsync() }, { queryTunnelState() }) - - override suspend fun queryTrafficNowAsync(): Long = - routeAsync({ queryTrafficNowAsync() }, { queryTrafficNow() }) - - override suspend fun queryTrafficTotalAsync(): Long = - routeAsync({ queryTrafficTotalAsync() }, { queryTrafficTotal() }) - - override suspend fun queryConnectionsAsync(): ConnectionSnapshot = - routeAsync({ queryConnectionsAsync() }, { queryConnections() }) - - override suspend fun queryAllProxyGroupsAsync(excludeNotSelectable: Boolean): List = - routeAsync( - { queryAllProxyGroupsAsync(excludeNotSelectable) }, - { queryAllProxyGroups(excludeNotSelectable) }, - ) - - override suspend fun queryProxyGroupNamesAsync(excludeNotSelectable: Boolean): List = - routeAsync( - { queryProxyGroupNamesAsync(excludeNotSelectable) }, - { queryProxyGroupNames(excludeNotSelectable) }, - ) - - override suspend fun queryProxyGroupAsync(name: String, proxySort: ProxySort): ProxyGroup = - routeAsync({ queryProxyGroupAsync(name, proxySort) }, { queryProxyGroup(name, proxySort) }) - - override suspend fun queryConfigurationAsync(): UiConfiguration = - routeAsync({ queryConfigurationAsync() }, { queryConfiguration() }) - - override suspend fun queryProvidersAsync(): ProviderList = - routeAsync({ queryProvidersAsync() }, { queryProviders() }) + override suspend fun queryTrafficNow(): Long = pick().queryTrafficNow() - override suspend fun queryRulesAsync(): List = - routeAsync({ queryRulesAsync() }, { queryRules() }) + override suspend fun queryTrafficTotal(): Long = pick().queryTrafficTotal() - override suspend fun patchSelectorAsync(group: String, name: String): Boolean = - routeAsync({ patchSelectorAsync(group, name) }, { patchSelector(group, name) }) + override suspend fun queryConnections(): ConnectionSnapshot = pick().queryConnections() - override suspend fun closeConnectionAsync(id: String): Boolean = - routeAsync({ closeConnectionAsync(id) }, { closeConnection(id) }) - - override suspend fun closeAllConnectionsAsync() = - routeAsync({ closeAllConnectionsAsync() }, { closeAllConnections() }) - - override fun queryTunnelState(): TunnelState = pick().queryTunnelState() - - override fun queryTrafficNow(): Long = pick().queryTrafficNow() - - override fun queryTrafficTotal(): Long = pick().queryTrafficTotal() - - override fun queryConnections(): ConnectionSnapshot = pick().queryConnections() - - override fun queryAllProxyGroups(excludeNotSelectable: Boolean): List = + override suspend fun queryAllProxyGroups(excludeNotSelectable: Boolean): List = pick().queryAllProxyGroups(excludeNotSelectable) - override fun queryProxyGroupNames(excludeNotSelectable: Boolean): List = - pick().queryProxyGroupNames(excludeNotSelectable) - - override fun queryProxyGroup(name: String, proxySort: ProxySort): ProxyGroup = + override suspend fun queryProxyGroup(name: String, proxySort: ProxySort): ProxyGroup = pick().queryProxyGroup(name, proxySort) - override fun queryConfiguration(): UiConfiguration = pick().queryConfiguration() - - override fun queryProviders(): ProviderList = pick().queryProviders() + override suspend fun queryProviders(): ProviderList = pick().queryProviders() - override fun queryRules(): List = pick().queryRules() + override suspend fun queryRules(): List = pick().queryRules() - override suspend fun setRuleDisabled(rule: RuntimeRule, disabled: Boolean): List { - val target = pick() - return target.setRuleDisabled(rule, disabled) - } + override suspend fun setRuleDisabled(rule: RuntimeRule, disabled: Boolean): List = + pick().setRuleDisabled(rule, disabled) - override fun patchSelector(group: String, name: String): Boolean = + override suspend fun patchSelector(group: String, name: String): Boolean = pick().patchSelector(group, name) - override fun closeConnection(id: String): Boolean = pick().closeConnection(id) - - override fun closeAllConnections() = pick().closeAllConnections() + override suspend fun closeConnection(id: String): Boolean = pick().closeConnection(id) override suspend fun healthCheck(group: String): Map = pick().healthCheck(group) @@ -139,8 +82,6 @@ class CoreRouter( override suspend fun updateProvider(type: Provider.Type, name: String) = pick().updateProvider(type, name) - override fun requestStop() = pick().requestStop() - @Synchronized override fun subscribeLogs(observer: LogObserver): LogSubscription { clearActiveLogSubscription() diff --git a/runtime/client/src/runtime/client/access/RuntimeAccess.kt b/runtime/client/src/runtime/client/access/RuntimeAccess.kt index 47274ac73..22d101654 100644 --- a/runtime/client/src/runtime/client/access/RuntimeAccess.kt +++ b/runtime/client/src/runtime/client/access/RuntimeAccess.kt @@ -18,8 +18,6 @@ * */ -@file:Suppress("UnusedSymbol", "RedundantSuspendModifier") - package com.github.yumeyucca.yumebox.runtime.client.access import android.content.Context @@ -30,6 +28,7 @@ import com.github.yumeyucca.yumebox.runtime.api.ProfileApi import com.github.yumeyucca.yumebox.runtime.api.appContextOrSelf import com.github.yumeyucca.yumebox.runtime.api.initializeServiceGlobal import com.github.yumeyucca.yumebox.runtime.service.controller.CoreController +import com.github.yumeyucca.yumebox.runtime.service.core.CoreProcess import com.github.yumeyucca.yumebox.runtime.service.profile.ProfileService import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -40,56 +39,37 @@ import timber.log.Timber object RuntimeAccess { private val mutex = Mutex() - private var initialized = false private var coreApi: CoreApi? = null private var profileApi: ProfileApi? = null - private var remoteStore: RemoteControllerStore? = null private var remoteApi: CoreController? = null - private fun ensureRemoteController(): CoreController { - val store = - remoteStore - ?: RemoteControllerStore(MMKVProvider().getMMKV(RemoteControllerStore.MMKV_ID)).also { - remoteStore = it - } - return remoteApi - ?: CoreController(backendProvider = { store.activeBackend() }).also { remoteApi = it } - } + private fun ensureRemoteController(): CoreController = + remoteApi + ?: RemoteControllerStore(MMKVProvider().getMMKV(RemoteControllerStore.MMKV_ID)) + .let { store -> CoreController(backendProvider = { store.activeBackend() }) } + .also { remoteApi = it } - @Suppress("TooGenericExceptionCaught") suspend fun connect(ctx: Context) { withContext(Dispatchers.IO) { mutex.withLock { + if (coreApi != null && profileApi != null) return@withLock val appContext = ctx.appContextOrSelf - if (initialized && coreApi != null && profileApi != null) { - return@withLock - } - - val startedAt = System.currentTimeMillis() - try { + runCatching { initializeServiceGlobal(appContext) - val remote = ensureRemoteController() coreApi = CoreRouter( - local = - com.github.yumeyucca.yumebox.runtime.service.core.CoreProcess - .controller(appContext), - remote = remote, + local = CoreProcess.controller(appContext), + remote = ensureRemoteController(), isRemoteControllerActive = { RemoteControllerStore.isActive() }, ) profileApi = ProfileService(appContext) - initialized = true - Timber.d( - "RuntimeAccess ready pid=${android.os.Process.myPid()} cost=${System.currentTimeMillis() - startedAt}ms" - ) - } catch (error: Exception) { - if (error is CancellationException) throw error - initialized = false - coreApi = null - profileApi = null - Timber.e(error, "RuntimeAccess init failed") - throw error } + .onFailure { error -> + coreApi = null + profileApi = null + if (error !is CancellationException) Timber.e(error, "RuntimeAccess init failed") + } + .getOrThrow() } } } @@ -104,29 +84,7 @@ object RuntimeAccess { remote.probe() } - suspend fun disconnect() { - withContext(Dispatchers.IO) { - mutex.withLock { - coreApi = null - profileApi = null - initialized = false - } - } - } - - fun core(): CoreApi = - coreApi ?: throw IllegalStateException("RuntimeAccess not connected") - - suspend fun profile(): ProfileApi = - profileApi ?: throw IllegalStateException("RuntimeAccess not connected") + fun core(): CoreApi = checkNotNull(coreApi) { "RuntimeAccess not connected" } - fun isConnected(): Boolean = initialized && coreApi != null && profileApi != null - - /** - * Drop cached controller bindings and reconnect (remote backend / process endpoint changes). - */ - suspend fun reconnect(ctx: Context) { - disconnect() - connect(ctx) - } + fun profile(): ProfileApi = checkNotNull(profileApi) { "RuntimeAccess not connected" } } diff --git a/runtime/client/src/runtime/client/session/RuntimeCoreOps.kt b/runtime/client/src/runtime/client/session/RuntimeCoreOps.kt index adcc9769a..1b3f38419 100644 --- a/runtime/client/src/runtime/client/session/RuntimeCoreOps.kt +++ b/runtime/client/src/runtime/client/session/RuntimeCoreOps.kt @@ -24,33 +24,32 @@ import com.github.yumeyucca.yumebox.core.model.ConnectionSnapshot import com.github.yumeyucca.yumebox.core.model.ProxyGroup import com.github.yumeyucca.yumebox.core.model.ProxySort import com.github.yumeyucca.yumebox.runtime.api.CoreApi -import com.github.yumeyucca.yumebox.runtime.client.CoreQueries import com.github.yumeyucca.yumebox.runtime.client.access.RuntimeAccess -/** Shared CoreApi resolve + query helpers used by the runtime client facade. */ +/** Connects the backend on demand, then queries the routed controller. */ internal class RuntimeCoreOps(private val connect: suspend () -> Unit = {}) { - suspend fun api(): CoreApi { + private suspend fun api(): CoreApi { connect() return RuntimeAccess.core() } - suspend fun queryTrafficNow(): Long = CoreQueries.queryTrafficNow(api()) + suspend fun queryTrafficNow(): Long = api().queryTrafficNow() - suspend fun queryTrafficTotal(): Long = CoreQueries.queryTrafficTotal(api()) + suspend fun queryTrafficTotal(): Long = api().queryTrafficTotal() - suspend fun queryConnections(): ConnectionSnapshot = CoreQueries.queryConnections(api()) + suspend fun queryConnections(): ConnectionSnapshot = api().queryConnections() suspend fun queryAllProxyGroups(excludeNotSelectable: Boolean): List = - CoreQueries.queryAllProxyGroups(api(), excludeNotSelectable) + api().queryAllProxyGroups(excludeNotSelectable) suspend fun queryProxyGroup(name: String, sort: ProxySort): ProxyGroup = - CoreQueries.queryProxyGroup(api(), name, sort) + api().queryProxyGroup(name, sort) suspend fun patchSelector(group: String, name: String): Boolean = - CoreQueries.patchSelector(api(), group, name) + api().patchSelector(group, name) - suspend fun healthCheck(group: String): Map = CoreQueries.healthCheck(api(), group) + suspend fun healthCheck(group: String): Map = api().healthCheck(group) suspend fun healthCheckProxy(group: String, proxyName: String): Int = - CoreQueries.healthCheckProxy(api(), group, proxyName) + api().healthCheckProxy(group, proxyName) } diff --git a/runtime/client/src/runtime/client/session/RuntimeEventBridge.kt b/runtime/client/src/runtime/client/session/RuntimeEventBridge.kt index 6a7ca5911..6a63dc948 100644 --- a/runtime/client/src/runtime/client/session/RuntimeEventBridge.kt +++ b/runtime/client/src/runtime/client/session/RuntimeEventBridge.kt @@ -30,42 +30,23 @@ import com.github.yumeyucca.yumebox.runtime.api.appContextOrSelf import timber.log.Timber /** - * Registers runtime service broadcasts and dispatches them to facade callbacks. Keeps IntentFilter - * / BroadcastReceiver out of the main facade body. + * Forwards profile / override edits to the session. Runtime lifecycle arrives through the + * control's state flow, not broadcasts. */ internal class RuntimeEventBridge( context: Context, - private val isConfigReloading: () -> Boolean, - private val onRuntimeStarted: () -> Unit, - private val onRuntimeStopped: (reason: String?) -> Unit, private val onConfigChanged: () -> Unit, - private val onReconcile: () -> Unit, - private val onRootFailed: (error: String?) -> Unit, ) { private val appContext = context.appContextOrSelf private val packageName = appContext.packageName - private val actionRuntimeStarted = Intents.actionRuntimeStarted(packageName) - private val actionRuntimeStopped = Intents.actionRuntimeStopped(packageName) private val actionProfileChanged = Intents.actionProfileChanged(packageName) - private val actionProfileLoaded = Intents.actionProfileLoaded(packageName) private val actionOverrideChanged = Intents.actionOverrideChanged(packageName) - private val actionServiceRecreated = Intents.actionServiceRecreated(packageName) - private val actionRootRuntimeFailed = Intents.actionRootRuntimeFailed(packageName) private val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action ?: return) { - actionRuntimeStarted -> onRuntimeStarted() - actionRuntimeStopped -> { - if (isConfigReloading()) { - Timber.d("Ignoring stale runtime-stopped event during config reload") - } else { - onRuntimeStopped(intent.getStringExtra(Intents.EXTRA_STOP_REASON)) - } - } - actionProfileChanged -> { if (intent.getBooleanExtra(Intents.EXTRA_AFFECTS_RUNTIME, true)) { onConfigChanged() @@ -73,14 +54,6 @@ internal class RuntimeEventBridge( } actionOverrideChanged -> onConfigChanged() - actionProfileLoaded, - actionServiceRecreated -> onReconcile() - - actionRootRuntimeFailed -> { - val error = intent.getStringExtra("error") - Timber.w("Root runtime failed: $error") - onRootFailed(error) - } } } } @@ -88,13 +61,8 @@ internal class RuntimeEventBridge( fun register() { val filter = IntentFilter().apply { - addAction(actionRuntimeStarted) - addAction(actionRuntimeStopped) addAction(actionProfileChanged) - addAction(actionProfileLoaded) addAction(actionOverrideChanged) - addAction(actionServiceRecreated) - addAction(actionRootRuntimeFailed) } runCatching { ContextCompat.registerReceiver( diff --git a/runtime/client/src/runtime/client/session/RuntimeGroupHub.kt b/runtime/client/src/runtime/client/session/RuntimeGroupHub.kt index befc4cd7f..f3f34104f 100644 --- a/runtime/client/src/runtime/client/session/RuntimeGroupHub.kt +++ b/runtime/client/src/runtime/client/session/RuntimeGroupHub.kt @@ -27,9 +27,8 @@ import com.github.yumeyucca.yumebox.domain.model.ProxyDelayPublishCoalescer import com.github.yumeyucca.yumebox.domain.model.ProxyDelayTestProgressCallback import com.github.yumeyucca.yumebox.domain.model.ProxyGroupInfo import com.github.yumeyucca.yumebox.domain.model.runProxyGroupDelayTests -import com.github.yumeyucca.yumebox.runtime.api.RuntimeOwner -import com.github.yumeyucca.yumebox.runtime.api.RuntimePhase import com.github.yumeyucca.yumebox.runtime.client.access.RuntimeAccess +import com.github.yumeyucca.yumebox.runtime.client.servesNodes import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -49,7 +48,7 @@ internal class RuntimeGroupHub( private val groupStore = ProxyGroupStore( - isRuntimeRunning = { session.snapshotValue().phase == RuntimePhase.Running }, + isRuntimeRunning = { session.snapshotValue().running }, onGroupsReady = { ready -> session.updateGroupsReady(ready) }, ) private val refreshMutex = Mutex() @@ -111,46 +110,14 @@ internal class RuntimeGroupHub( } } - suspend fun healthCheck( - group: String, - onProgress: ProxyDelayTestProgressCallback? = null, - ) { + suspend fun healthCheck(group: String, onProgress: ProxyDelayTestProgressCallback? = null) { Timber.d("Health check request: group=%s", group) - val context = delayTestMutex.withLock { currentDelayTestContext() } ?: return - val success = - delayPublish.session( - flush = { delays -> publishDirectDelaysImmediate(context.epoch, delays) }, - ) { - runGroupDelayTests(context, listOf(group), onProgress) - } - if (success && isCurrentEpoch(context.epoch)) { - Timber.d("Health check completed: group=%s", group) - scheduleGroupsRefresh( - delayMillis = PollingTimerSpecs.ProxyHealthcheckRefresh.intervalMillis, - epoch = context.epoch, - ) - } + runDelayTests(onProgress) { listOf(group) } } suspend fun healthCheckAll(onProgress: ProxyDelayTestProgressCallback? = null) { Timber.d("Health check all request") - val context = delayTestMutex.withLock { currentDelayTestContext() } ?: return - val success = - delayPublish.session( - flush = { delays -> publishDirectDelaysImmediate(context.epoch, delays) }, - ) { - runGroupDelayTests( - context, - context.groups.map(ProxyGroupInfo::name), - onProgress, - ) - } - if (success && isCurrentEpoch(context.epoch)) { - scheduleGroupsRefresh( - delayMillis = PollingTimerSpecs.ProxyHealthcheckRefresh.intervalMillis, - epoch = context.epoch, - ) - } + runDelayTests(onProgress) { groups -> groups.map(ProxyGroupInfo::name) } } suspend fun healthCheckProxy(group: String, proxyName: String): Int { @@ -163,12 +130,7 @@ internal class RuntimeGroupHub( result } Timber.d("Health check proxy done: group=%s proxy=%s delay=%s", group, proxyName, delay) - if (isCurrentEpoch(epoch)) { - scheduleGroupsRefresh( - delayMillis = PollingTimerSpecs.ProxyHealthcheckRefresh.intervalMillis, - epoch = epoch, - ) - } + scheduleHealthcheckRefresh(epoch) return delay } @@ -184,14 +146,12 @@ internal class RuntimeGroupHub( if (!snapshot.running) { return@runCatching queryPreviewProxyGroups() } - coreOps - .queryAllProxyGroups(excludeNotSelectable = false) - .map(groupStore::toInfo) + queryGroups() } .getOrElse { error -> if (error is CancellationException) throw error Timber.e(error, "Failed to refresh proxy groups") - missingLocalRuntime = session.isMissingLocalRuntime(snapshot) + missingLocalRuntime = session.verifyLocalRuntime(snapshot) null } } @@ -200,7 +160,6 @@ internal class RuntimeGroupHub( if (groups != null) { groupStore.publish(groupStore.mergeReportedDelays(groups)) } else if (missingLocalRuntime) { - session.handleMissingLocalRuntime(snapshot, "runtime backend unavailable") runCatching { queryPreviewProxyGroups() } .onSuccess { preview -> if (isCurrentEpoch(epoch)) { @@ -244,14 +203,7 @@ internal class RuntimeGroupHub( } suspend fun refreshSafely() { - if (delayPublish.isActive) return - val snapshot = session.snapshotValue() - if ( - snapshot.phase != RuntimePhase.Running && - snapshot.owner != RuntimeOwner.RemoteController - ) { - return - } + if (delayPublish.isActive || !session.snapshotValue().servesNodes) return runCatching { refreshProxyGroups() } .onFailure { error -> if (error is CancellationException) throw error @@ -286,30 +238,39 @@ internal class RuntimeGroupHub( } } - private suspend fun runGroupDelayTests( - context: DelayTestContext, - groupNames: List, + private suspend fun runDelayTests( onProgress: ProxyDelayTestProgressCallback?, - ): Boolean = - runProxyGroupDelayTests( - groupNames = groupNames, - proxiesByGroup = context.proxiesByGroup, - isActive = { isCurrentEpoch(context.epoch) }, - measureProxy = { groupName, proxyName -> coreOps.healthCheckProxy(groupName, proxyName) }, - measureGroup = { groupName -> coreOps.healthCheck(groupName) }, - publish = { delays -> publishDirectDelays(context.epoch, delays) }, - onProgress = onProgress, - ) + groupNames: (groups: List) -> List, + ) { + val context = delayTestMutex.withLock { currentDelayTestContext() } ?: return + val success = + delayPublish.session(flush = { delays -> publishDirectDelaysImmediate(context.epoch, delays) }) { + runProxyGroupDelayTests( + groupNames = groupNames(context.groups), + proxiesByGroup = context.proxiesByGroup, + isActive = { isCurrentEpoch(context.epoch) }, + measureProxy = { groupName, proxyName -> coreOps.healthCheckProxy(groupName, proxyName) }, + measureGroup = { groupName -> coreOps.healthCheck(groupName) }, + publish = { delays -> publishDirectDelays(context.epoch, delays) }, + onProgress = onProgress, + ) + } + if (success) scheduleHealthcheckRefresh(context.epoch) + } + + private fun scheduleHealthcheckRefresh(epoch: GroupSessionEpoch) { + if (!isCurrentEpoch(epoch)) return + scheduleGroupsRefresh(PollingTimerSpecs.ProxyHealthcheckRefresh.intervalMillis, epoch) + } + + private suspend fun queryGroups(): List = + coreOps.queryAllProxyGroups(excludeNotSelectable = false).map(groupStore::toInfo) private suspend fun currentDelayTestContext(): DelayTestContext? { val epoch = currentEpoch() val freshGroups = withContext(Dispatchers.IO) { - runCatching { - coreOps - .queryAllProxyGroups(excludeNotSelectable = false) - .map(groupStore::toInfo) - } + runCatching { queryGroups() } .onFailure { error -> if (error is CancellationException) throw error Timber.d(error, "Delay-test group snapshot unavailable") @@ -379,18 +340,12 @@ internal class RuntimeGroupHub( } private suspend fun queryPreviewProxyGroups(): List { - if (isRemoteControllerActive()) { - return coreOps.queryAllProxyGroups(excludeNotSelectable = false).map(groupStore::toInfo) - } - session.connectBackend() - val activeProfile = - RuntimeAccess.profile().queryActive().also { - session.setCurrentProfile(it) - session.updateProfileReady(it) - } - if (activeProfile == null) { - return emptyList() + if (!isRemoteControllerActive()) { + session.connectBackend() + val activeProfile = RuntimeAccess.profile().queryActive() + session.setCurrentProfile(activeProfile) + if (activeProfile == null) return emptyList() } - return coreOps.queryAllProxyGroups(excludeNotSelectable = false).map(groupStore::toInfo) + return queryGroups() } } diff --git a/runtime/client/src/runtime/client/session/RuntimeOwnership.kt b/runtime/client/src/runtime/client/session/RuntimeOwnership.kt deleted file mode 100644 index d415f0b85..000000000 --- a/runtime/client/src/runtime/client/session/RuntimeOwnership.kt +++ /dev/null @@ -1,185 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -@file:Suppress("UnusedSymbol") - -package com.github.yumeyucca.yumebox.runtime.client.session - - -import com.github.yumeyucca.yumebox.core.model.RunMode -import com.github.yumeyucca.yumebox.runtime.api.* -import com.github.yumeyucca.yumebox.runtime.client.RuntimeStateMapper - -/** - * Ownership / liveness / snapshot transitions for the cohesive runtime session. Uses platform seams - * ([RuntimeStatusStore], [ProcessController]) so desktop can swap bindings. - */ -internal class RuntimeOwnership( - private val statusStore: RuntimeStatusStore, - private val processController: ProcessController, - private val currentSnapshot: () -> RuntimeSnapshot, -) { - fun detectOwner(): RuntimeOwner = - when { - isVpnSessionActive() -> RuntimeOwner.VpnService - isRootDaemonActive() -> RuntimeOwner.RootDaemon - else -> RuntimeOwner.None - } - - fun detectActiveOwner(): RuntimeOwner { - statusStore.reconcilePersistedRuntimeState() - return detectOwner() - } - - fun isVpnSessionActive(): Boolean = statusStore.isRuntimeActive(RunMode.VpnService.name) - - fun isRootDaemonActive(): Boolean = processController.isRootDaemonAlive() - - fun localRuntimePhaseForOwner(owner: RuntimeOwner): RuntimePhase = - when (owner) { - RuntimeOwner.VpnService -> statusStore.queryRuntimePhase(RunMode.VpnService.name) - RuntimeOwner.RootDaemon -> - processController.rootDaemonMode() - ?.let { statusStore.queryRuntimePhase(it.name) } - ?.takeIf { isRootDaemonActive() } - ?: RuntimePhase.Idle - - RuntimeOwner.RemoteController, - RuntimeOwner.None -> RuntimePhase.Idle - } - - fun localRuntimeStartedAtForOwner(owner: RuntimeOwner): Long? { - val snapshot = currentSnapshot() - return when (owner) { - RuntimeOwner.VpnService -> - statusStore.queryRuntimeStartedAt(RunMode.VpnService.name) - ?: snapshot.startedAt?.takeIf { snapshot.owner == owner } - - RuntimeOwner.RootDaemon -> - processController.rootDaemonMode() - ?.let { statusStore.queryRuntimeStartedAt(it.name) } - ?: snapshot.startedAt?.takeIf { snapshot.owner == owner } - - RuntimeOwner.RemoteController, - RuntimeOwner.None -> null - } - } - - fun localModeForOwner(owner: RuntimeOwner): RunMode? = - when (owner) { - RuntimeOwner.RootDaemon -> processController.rootDaemonMode() - else -> RuntimeStateMapper.modeForOwner(owner) - } - - fun ownerForMode(mode: RunMode): RuntimeOwner = - when (mode) { - RunMode.VpnService -> RuntimeOwner.VpnService - RunMode.Tun -> RuntimeOwner.RootDaemon - RunMode.Ebpf -> RuntimeOwner.RootDaemon - } - - fun startingSnapshot( - owner: RuntimeOwner, - runMode: RunMode, - profile: Profile, - generation: Long, - ): RuntimeSnapshot = - RuntimeSnapshot( - owner = owner, - phase = RuntimePhase.Starting, - runMode = runMode, - profileReady = true, - profileUuid = profile.uuid.toString(), - profileName = profile.name, - startedAt = System.currentTimeMillis(), - generation = generation, - ) - - fun activeSnapshot( - owner: RuntimeOwner, - runMode: RunMode, - localPhase: RuntimePhase = RuntimePhase.Idle, - localStartedAt: Long? = null, - ): RuntimeSnapshot = - RuntimeSnapshot( - owner = owner, - phase = - when (owner) { - RuntimeOwner.VpnService, - RuntimeOwner.RootDaemon -> localPhase - - RuntimeOwner.RemoteController -> RuntimePhase.Running - RuntimeOwner.None -> RuntimePhase.Idle - }, - runMode = runMode, - startedAt = - when (owner) { - RuntimeOwner.VpnService, - RuntimeOwner.RootDaemon -> localStartedAt - - RuntimeOwner.RemoteController, - RuntimeOwner.None -> null - }, - ) - - /** The root daemon is still the runtime. Used when a replacement start fails before the swap. */ - fun liveRootSnapshot(profile: Profile, generation: Long, lastError: String?): RuntimeSnapshot? { - if (!isRootDaemonActive()) return null - val mode = localModeForOwner(RuntimeOwner.RootDaemon) ?: return null - return activeSnapshot( - owner = RuntimeOwner.RootDaemon, - runMode = mode, - localPhase = localRuntimePhaseForOwner(RuntimeOwner.RootDaemon), - localStartedAt = localRuntimeStartedAtForOwner(RuntimeOwner.RootDaemon), - ).copy( - profileReady = true, - profileUuid = profile.uuid.toString(), - profileName = profile.name, - generation = generation, - lastError = lastError, - ) - } - - fun startedSnapshot( - current: RuntimeSnapshot, - owner: RuntimeOwner, - runMode: RunMode, - ): RuntimeSnapshot = - current.copy( - owner = owner, - phase = RuntimePhase.Running, - runMode = runMode, - lastError = null, - ) - - fun remoteRunningSnapshot( - runMode: RunMode, - generation: Long, - startedAt: Long = System.currentTimeMillis(), - ): RuntimeSnapshot = - RuntimeSnapshot( - owner = RuntimeOwner.RemoteController, - phase = RuntimePhase.Running, - runMode = runMode, - generation = generation, - startedAt = startedAt, - ) - -} diff --git a/runtime/client/src/runtime/client/session/RuntimePolling.kt b/runtime/client/src/runtime/client/session/RuntimePolling.kt index 65138ee7e..c68eedca3 100644 --- a/runtime/client/src/runtime/client/session/RuntimePolling.kt +++ b/runtime/client/src/runtime/client/session/RuntimePolling.kt @@ -18,11 +18,8 @@ * */ -@file:Suppress("UnusedSymbol") - package com.github.yumeyucca.yumebox.runtime.client.session - import com.github.yumeyucca.yumebox.core.util.PollingTimerSpecs import com.github.yumeyucca.yumebox.core.util.PollingTimers import com.github.yumeyucca.yumebox.runtime.client.ProxyGroupSyncPriority @@ -44,12 +41,6 @@ internal class RuntimePolling( private var groupJob: Job? = null private var activeGroupPriority: ProxyGroupSyncPriority = ProxyGroupSyncPriority.OFF - val isTrafficActive: Boolean - get() = trafficJob?.isActive == true - - val isGroupActive: Boolean - get() = groupJob?.isActive == true - fun startTraffic() { if (trafficJob?.isActive == true) return trafficJob = scope.launch { @@ -72,10 +63,8 @@ internal class RuntimePolling( return } activeGroupPriority = priority - stopGroups(clearPriority = false) - if (priority == ProxyGroupSyncPriority.OFF) { - return - } + groupJob?.cancel() + groupJob = null val timerSpec = when (priority) { ProxyGroupSyncPriority.FAST -> PollingTimerSpecs.RuntimeProxyGroupSyncFast @@ -86,17 +75,4 @@ internal class RuntimePolling( PollingTimers.ticks(timerSpec).collect { onGroupTick() } } } - - fun stopGroups(clearPriority: Boolean = true) { - groupJob?.cancel() - groupJob = null - if (clearPriority) { - activeGroupPriority = ProxyGroupSyncPriority.OFF - } - } - - fun stopAll() { - stopTraffic() - stopGroups() - } } diff --git a/runtime/client/src/runtime/client/session/RuntimeRemoteSwitch.kt b/runtime/client/src/runtime/client/session/RuntimeRemoteSwitch.kt index bf1bce5f7..ffc6083b8 100644 --- a/runtime/client/src/runtime/client/session/RuntimeRemoteSwitch.kt +++ b/runtime/client/src/runtime/client/session/RuntimeRemoteSwitch.kt @@ -42,33 +42,19 @@ import timber.log.Timber */ internal class RuntimeRemoteSwitch( private val deps: RuntimeSessionDeps, - private val ownership: RuntimeOwnership, private val operationMutex: Mutex, private val snapshot: () -> RuntimeSnapshot, private val publishRemoteRunning: () -> Unit, private val reconcile: suspend () -> Unit, - private val startLocal: suspend (RuntimeOwner, RunMode) -> Unit, + private val startLocal: suspend (RunMode) -> Unit, private val startTrafficPolling: () -> Unit, private val stopTrafficPolling: () -> Unit, private val connectBackend: suspend () -> Unit, ) { - private val scope - get() = deps.scope - - private val store - get() = deps.remoteControllerStore - - private val launcher - get() = deps.launcher - - private val statusStore - get() = deps.statusStore - - private val probe - get() = deps.probeRemote - - private fun configuredMode(): RunMode = deps.networkSettingsStorage.runMode.value - + private val scope = deps.scope + private val store = deps.remoteControllerStore + private val control = deps.control + private val probe = deps.probeRemote private val mutex = Mutex() private var probeJob: Job? = null private var refreshJob: Job? = null @@ -111,9 +97,7 @@ internal class RuntimeRemoteSwitch( attach(refresh) return } - val holding = - store.controllerAttached.value || snapshot().owner == RuntimeOwner.RemoteController - if (!holding) return + if (!isHolding()) return unreachableStreak += 1 if (unreachableStreak < DETACH_AFTER_MISSES) return unreachableStreak = 0 @@ -164,12 +148,11 @@ internal class RuntimeRemoteSwitch( } } + private fun isHolding(): Boolean = + store.controllerAttached.value || snapshot().owner == RuntimeOwner.RemoteController + private suspend fun detachIfHolding() { - if (store.controllerAttached.value || - snapshot().owner == RuntimeOwner.RemoteController - ) { - detach() - } + if (isHolding()) detach() } private suspend fun detach() { @@ -177,11 +160,12 @@ internal class RuntimeRemoteSwitch( refreshJob = null store.controllerAttached.set(false) stopTrafficPolling() - val paused = store.takePausedLocal()?.toTypedOrNull() + val paused = store.takePausedLocal() + val pausedMode = paused?.resumableMode() if (snapshot().owner == RuntimeOwner.RemoteController) { reconcile() } - if (paused == null) { + if (pausedMode == null) { refreshJob = scope.launch { if (snapshot().phase == RuntimePhase.Running) { deps.onAfterRunning() @@ -192,9 +176,9 @@ internal class RuntimeRemoteSwitch( return } Timber.i( - "Controller fallback: resuming local runtime owner=${paused.owner} mode=${paused.mode}" + "Controller fallback: resuming local runtime owner=${paused?.ownerName} mode=$pausedMode" ) - runCatching { startLocal(paused.owner, paused.mode) } + runCatching { startLocal(pausedMode) } .onFailure { error -> if (error is CancellationException) throw error Timber.w(error, "Failed to resume local runtime after controller fallback") @@ -203,14 +187,18 @@ internal class RuntimeRemoteSwitch( private suspend fun pauseLocalIfRunning() { runCatching { - val owner = ownership.detectActiveOwner() - if (owner != RuntimeOwner.VpnService && owner != RuntimeOwner.RootDaemon) return - val mode = ownership.localModeForOwner(owner) ?: configuredMode() + val state = control.state.value + val owner = state.owner + if (!state.active || + (owner != RuntimeOwner.VpnService && owner != RuntimeOwner.RootDaemon) + ) { + return + } + val mode = state.mode ?: deps.networkSettingsStorage.runMode.value store.rememberPausedLocal(owner.name, mode.name) Timber.i("Controller switch: pausing local runtime owner=$owner mode=$mode") - launcher.stop(owner) + control.stop() stopTrafficPolling() - statusStore.reconcilePersistedRuntimeState() } .onFailure { error -> if (error is CancellationException) throw error @@ -218,20 +206,11 @@ internal class RuntimeRemoteSwitch( } } - private data class TypedPausedLocal( - val owner: RuntimeOwner, - val mode: RunMode, - ) - - private fun PausedLocalRuntime.toTypedOrNull(): TypedPausedLocal? { - val owner = enumByNameOrNull(ownerName) ?: return null - val mode = enumByNameOrNull(modeName) ?: return null - return when (owner) { - RuntimeOwner.VpnService, - RuntimeOwner.RootDaemon -> TypedPausedLocal(owner, mode) - RuntimeOwner.RemoteController, - RuntimeOwner.None -> null - } + /** Only a paused local core (VPN or root) is resumed. */ + private fun PausedLocalRuntime.resumableMode(): RunMode? { + val owner = enumByNameOrNull(ownerName) + if (owner != RuntimeOwner.VpnService && owner != RuntimeOwner.RootDaemon) return null + return enumByNameOrNull(modeName) } private companion object { diff --git a/runtime/client/src/runtime/client/session/RuntimeSession.kt b/runtime/client/src/runtime/client/session/RuntimeSession.kt index 2af5ae40c..59b948b13 100644 --- a/runtime/client/src/runtime/client/session/RuntimeSession.kt +++ b/runtime/client/src/runtime/client/session/RuntimeSession.kt @@ -18,14 +18,17 @@ * */ -@file:Suppress("DuplicatedCode", "UnusedSymbol", "RedundantSuspendModifier") - package com.github.yumeyucca.yumebox.runtime.client.session -import android.net.VpnService import com.github.yumeyucca.yumebox.core.model.RunMode import com.github.yumeyucca.yumebox.core.util.AppVisibilityTracker -import com.github.yumeyucca.yumebox.runtime.api.* +import com.github.yumeyucca.yumebox.runtime.api.Profile +import com.github.yumeyucca.yumebox.runtime.api.RuntimeOwner +import com.github.yumeyucca.yumebox.runtime.api.RuntimePhase +import com.github.yumeyucca.yumebox.runtime.api.RuntimeSnapshot +import com.github.yumeyucca.yumebox.runtime.api.RuntimeStartSource +import com.github.yumeyucca.yumebox.runtime.api.RuntimeState +import com.github.yumeyucca.yumebox.runtime.api.appContextOrSelf import com.github.yumeyucca.yumebox.runtime.client.ProxyGroupSyncPriority import com.github.yumeyucca.yumebox.runtime.client.RuntimeStartRequest import com.github.yumeyucca.yumebox.runtime.client.RuntimeStateMapper @@ -41,61 +44,30 @@ import kotlinx.coroutines.sync.withLock import timber.log.Timber /** - * High-cohesion runtime host: snapshot/traffic/profile state, lifecycle, event bridge and traffic - * polling. Remote-controller takeover lives in [RuntimeRemoteSwitch]. Platform differences go - * through seams. + * UI-side view of the runtime: mirrors [RuntimeSessionDeps.control] into a [RuntimeSnapshot] + * enriched with profile / group / traffic readiness, and drives payload refreshes and traffic + * polling. Lifecycle decisions stay in the control; remote-controller takeover lives in + * [RuntimeRemoteSwitch]. */ internal class RuntimeSession(private val deps: RuntimeSessionDeps) { - private val context - get() = deps.context - - private val scope - get() = deps.scope - - private val networkSettingsStorage - get() = deps.networkSettingsStorage - - private val remoteControllerStore - get() = deps.remoteControllerStore - - private val statusStore - get() = deps.statusStore - - private val processController - get() = deps.processController - - private val launcher - get() = deps.launcher - - private val queryTrafficNowAction - get() = deps.queryTrafficNowAction - - private val queryTrafficTotalAction - get() = deps.queryTrafficTotalAction - - private val onAfterRunning - get() = deps.onAfterRunning - - private val onAfterIdle - get() = deps.onAfterIdle - - private val onGroupTick - get() = deps.onGroupTick - - private val onTrafficTickExtra - get() = deps.onTrafficTickExtra - - private val onClearGroups - get() = deps.onClearGroups + private val scope = deps.scope + private val control = deps.control + private val networkSettingsStorage = deps.networkSettingsStorage + private val remoteControllerStore = deps.remoteControllerStore private companion object { const val TRAFFIC_TOTAL_POLL_TICKS = 10 } - private val appContext = context.appContextOrSelf + private val appContext = deps.context.appContextOrSelf + + /** Serializes snapshot publication between the local state mirror and the remote switch. */ private val operationMutex = Mutex() private var generationCounter = 0L + /** Local-state generation whose payload refresh already ran. */ + private var refreshedGeneration = -1L + private val _runtimeSnapshot = MutableStateFlow(RuntimeStateMapper.idleSnapshot(networkSettingsStorage.runMode.value)) val runtimeSnapshot: StateFlow = _runtimeSnapshot.asStateFlow() @@ -115,68 +87,50 @@ internal class RuntimeSession(private val deps: RuntimeSessionDeps) { private val _trafficTotal = MutableStateFlow(0L) val trafficTotal: StateFlow = _trafficTotal.asStateFlow() - val ownership = - RuntimeOwnership( - statusStore = statusStore, - processController = processController, - currentSnapshot = { _runtimeSnapshot.value }, - ) - private val polling = RuntimePolling( scope = scope, isRunning = { _runtimeSnapshot.value.running }, onTrafficTick = { tick -> runCatching { - queryTrafficNow(queryTrafficNowAction) + queryTrafficNow(deps.queryTrafficNowAction) if (tick % TRAFFIC_TOTAL_POLL_TICKS == 0) { - queryTrafficTotal(queryTrafficTotalAction) + queryTrafficTotal(deps.queryTrafficTotalAction) } } .onFailure { error -> if (error is CancellationException) throw error Timber.d(error, "Traffic polling skipped") } - onTrafficTickExtra(tick) + deps.onTrafficTickExtra(tick) }, - onGroupTick = { onGroupTick() }, + onGroupTick = { deps.onGroupTick() }, ) private val eventBridge = RuntimeEventBridge( context = appContext, - isConfigReloading = { _isConfigReloading.value }, - onRuntimeStarted = { - scope.launch { - try { - reconcileAndRefresh() - } finally { - _isConfigReloading.value = false - } - } - }, - onRuntimeStopped = { reason -> scope.launch { handleStopped(reason) } }, onConfigChanged = { scope.launch { onConfigChanged() } }, - onReconcile = { scope.launch { reconcileAndRefresh() } }, - onRootFailed = { error -> scope.launch { handleFailure(error) } }, ) private val remoteSwitch = RuntimeRemoteSwitch( deps = deps, - ownership = ownership, operationMutex = operationMutex, snapshot = { _runtimeSnapshot.value }, publishRemoteRunning = { publishSnapshot( - ownership.remoteRunningSnapshot( + RuntimeSnapshot( + owner = RuntimeOwner.RemoteController, + phase = RuntimePhase.Running, runMode = networkSettingsStorage.runMode.value, generation = nextGeneration(), + startedAt = System.currentTimeMillis(), ) ) }, reconcile = { reconcile(refreshPayload = false) }, - startLocal = { owner, mode -> start(RuntimeStartRequest(owner = owner, mode = mode)) }, + startLocal = { mode -> start(RuntimeStartRequest(mode = mode)) }, startTrafficPolling = { startTrafficPolling() }, stopTrafficPolling = { stopTrafficPolling() }, connectBackend = { connectBackend() }, @@ -184,8 +138,9 @@ internal class RuntimeSession(private val deps: RuntimeSessionDeps) { fun bootstrap() { eventBridge.register() + if (remoteControllerStore.isWanted()) applyRemoteControllerState() + scope.launch { control.state.collect { state -> mirrorLocal(state) } } scope.launch { - operationMutex.withLock { initializeSnapshot() } remoteControllerStore.controllerEnabled.state.collect { applyRemoteControllerState() } } } @@ -194,16 +149,12 @@ internal class RuntimeSession(private val deps: RuntimeSessionDeps) { fun snapshotValue(): RuntimeSnapshot = _runtimeSnapshot.value - fun publishSnapshot(snapshot: RuntimeSnapshot) { - val normalized = snapshot.copy(running = snapshot.phase.running) - _runtimeSnapshot.value = normalized - _isRunning.value = normalized.running + private fun publishSnapshot(snapshot: RuntimeSnapshot) { + _runtimeSnapshot.value = snapshot + _isRunning.value = snapshot.running } - fun nextGeneration(): Long { - generationCounter += 1L - return generationCounter - } + private fun nextGeneration(): Long = ++generationCounter fun startTrafficPolling() { if (AppVisibilityTracker.isForeground.value) { @@ -215,20 +166,15 @@ internal class RuntimeSession(private val deps: RuntimeSessionDeps) { fun startGroupPolling(priority: ProxyGroupSyncPriority) = polling.startGroups(priority) - fun stopGroupPolling() = polling.stopGroups() - - fun setConfigReloading(value: Boolean) { - _isConfigReloading.value = value - } - - fun clearRuntimePayload(resetGroups: Boolean = true) { + private fun clearRuntimePayload() { _currentProfile.value = null - onClearGroups(resetGroups) + deps.onClearGroups(false) _trafficNow.value = 0L _trafficTotal.value = 0L } - fun updateProfileReady(profile: Profile?) { + fun setCurrentProfile(profile: Profile?) { + _currentProfile.value = profile val snapshot = _runtimeSnapshot.value publishSnapshot( snapshot.copy( @@ -243,7 +189,7 @@ internal class RuntimeSession(private val deps: RuntimeSessionDeps) { publishSnapshot(_runtimeSnapshot.value.copy(groupsReady = ready)) } - fun updateTrafficReady() { + private fun updateTrafficReady() { if (!_runtimeSnapshot.value.trafficReady) { publishSnapshot(_runtimeSnapshot.value.copy(trafficReady = true)) } @@ -251,76 +197,104 @@ internal class RuntimeSession(private val deps: RuntimeSessionDeps) { fun applyRemoteControllerState() = remoteSwitch.apply() + private suspend fun mirrorLocal(state: RuntimeState) { + operationMutex.withLock { applyLocalLocked(state, forceRefresh = false) } + } + + /** + * Publishes [state] unless the remote controller holds the snapshot, and refreshes the + * payload once per settled state: each Running generation (outside a reload) and each idle + * one. + */ + private fun applyLocalLocked(state: RuntimeState, forceRefresh: Boolean) { + _isConfigReloading.value = state.reloading + if (isRemoteControllerActive() || + _runtimeSnapshot.value.owner == RuntimeOwner.RemoteController + ) { + return + } + val previous = _runtimeSnapshot.value + val next = mapLocal(state, previous) + if (previous.running && !next.running && !state.reloading) clearRuntimePayload() + publishSnapshot(next) + + val settled = + (state.phase == RuntimePhase.Running && !state.reloading) || + state.phase == RuntimePhase.Idle || + state.phase == RuntimePhase.Failed + if (next.running) startTrafficPolling() else stopTrafficPolling() + if (!settled || (!forceRefresh && state.generation == refreshedGeneration)) return + refreshedGeneration = state.generation + scope.launch { if (next.running) deps.onAfterRunning() else deps.onAfterIdle() } + } + + private fun mapLocal(state: RuntimeState, previous: RuntimeSnapshot): RuntimeSnapshot { + val configuredMode = networkSettingsStorage.runMode.value + if (state.owner == RuntimeOwner.None) { + return RuntimeStateMapper.idleSnapshot( + configuredMode = configuredMode, + generation = nextGeneration(), + lastError = state.lastError, + ) + } + // Readiness survives only while the same session keeps running (reload, error notes). + val keep = + previous.owner == state.owner && + previous.phase == RuntimePhase.Running && + state.phase == RuntimePhase.Running + val profile = _currentProfile.value + return RuntimeSnapshot( + owner = state.owner, + phase = state.phase, + runMode = state.mode ?: configuredMode, + profileReady = keep && previous.profileReady, + groupsReady = keep && previous.groupsReady, + trafficReady = keep && previous.trafficReady, + profileUuid = profile?.uuid?.toString() ?: previous.profileUuid, + profileName = profile?.name ?: previous.profileName, + lastError = state.lastError, + startedAt = state.startedAt, + generation = nextGeneration(), + ) + } + + /** Re-checks core liveness, republishes the local state and optionally refreshes payload. */ suspend fun reconcile(refreshPayload: Boolean = true) { if (isRemoteControllerActive()) { applyRemoteControllerState() return } + control.verify() operationMutex.withLock { - val configuredMode = networkSettingsStorage.runMode.value - statusStore.reconcilePersistedRuntimeState() - val owner = ownership.detectOwner() - - if (owner == RuntimeOwner.None) { - stopTrafficPolling() - clearRuntimePayload(resetGroups = false) + if (_runtimeSnapshot.value.owner == RuntimeOwner.RemoteController) { publishSnapshot( RuntimeStateMapper.idleSnapshot( - configuredMode, - lastError = statusStore.queryRuntimeLastError(configuredMode.name), + networkSettingsStorage.runMode.value, + generation = nextGeneration(), ) ) - if (refreshPayload) onAfterIdle() - return - } - - publishSnapshot( - ownership.activeSnapshot( - owner = owner, - runMode = ownership.localModeForOwner(owner) ?: configuredMode, - localPhase = ownership.localRuntimePhaseForOwner(owner), - localStartedAt = ownership.localRuntimeStartedAtForOwner(owner), - ) - ) - - if (_runtimeSnapshot.value.phase.running) { - startTrafficPolling() - if (refreshPayload) onAfterRunning() - } else { - stopTrafficPolling() - if (refreshPayload) onAfterIdle() } + applyLocalLocked(control.state.value, forceRefresh = refreshPayload) } } - suspend fun reconcileAndRefresh() { - reconcile() - if (_runtimeSnapshot.value.phase == RuntimePhase.Running) { - onAfterRunning() - } else { - onAfterIdle() - } - } - + /** Idle config edits change what the preview shows; running ones arrive as a reload. */ private suspend fun onConfigChanged() { - if ( - !isRemoteControllerActive() && ownership.detectActiveOwner() == RuntimeOwner.RootDaemon - ) { - runCatching { reload(networkSettingsStorage.runMode.value) } - .onFailure { error -> Timber.w(error, "Root daemon config reload failed") } - } else { - reconcileAndRefresh() - } + if (isRemoteControllerActive() || control.state.value.active) return + deps.onAfterIdle() } - suspend fun reload(mode: RunMode = networkSettingsStorage.runMode.value) { + /** + * Applies changed settings to an active runtime: a reload in the same [mode], an owner switch + * otherwise. An idle runtime stays idle. + */ + suspend fun reload(mode: RunMode) { if (isRemoteControllerActive()) return - _isConfigReloading.value = true - try { - start(RuntimeStartRequest(owner = ownership.ownerForMode(mode), mode = mode)) - } catch (error: Throwable) { - _isConfigReloading.value = false - throw error + val state = control.state.value + when { + !state.active -> Unit + state.mode == mode -> control.reload() + else -> control.start(mode, RuntimeStartSource.Ui) } } @@ -329,82 +303,12 @@ internal class RuntimeSession(private val deps: RuntimeSessionDeps) { Timber.i("Ignoring startProxy: remote controller mode active") return } - val mode = request.mode - Timber.i("Start proxy: mode=$mode") + Timber.i("Start proxy: mode=${request.mode}") RuntimeAccess.connect(appContext) - val activeProfile = request.profile ?: RuntimeAccess.profile().queryActive() check(activeProfile != null) { "No profile selected" } - - if (mode == RunMode.VpnService) { - val vpnIntent = VpnService.prepare(context) - if (vpnIntent != null) { - throw VpnPermissionRequired(vpnIntent) - } - } - - operationMutex.withLock { - if (isRemoteControllerActive()) { - Timber.i("Ignoring startProxy: remote controller mode active") - return - } - val targetOwner = - request.owner.takeIf { it != RuntimeOwner.None } ?: ownership.ownerForMode(mode) - val currentOwner = - ownership.detectActiveOwner().takeIf { it != RuntimeOwner.None } - ?: _runtimeSnapshot.value.owner - // Root reload keeps the live daemon through config compile. The launcher swaps it - // under the core lifecycle lock. VPN and cross-mode starts still stop first: the - // service owns the tun and will not apply a second start while it is running. - val replacingSameRoot = - currentOwner == RuntimeOwner.RootDaemon && targetOwner == RuntimeOwner.RootDaemon - if (currentOwner != RuntimeOwner.None && !replacingSameRoot) { - stopInternal( - RuntimeStopRequest( - owner = currentOwner, - targetMode = mode, - completeImmediately = true, - ) - ) - } - - val generation = nextGeneration() - clearRuntimePayload(resetGroups = false) - _currentProfile.value = activeProfile - publishSnapshot( - ownership.startingSnapshot( - owner = targetOwner, - runMode = mode, - profile = activeProfile, - generation = generation, - ) - ) - - runCatching { launcher.start(targetOwner, mode) } - .onFailure { error -> - val liveRoot = - if (replacingSameRoot) { - ownership.liveRootSnapshot(activeProfile, generation, error.message) - } else { - null - } - if (liveRoot != null) { - publishSnapshot(liveRoot) - } else { - clearRuntimePayload(resetGroups = false) - publishSnapshot( - RuntimeStateMapper.idleSnapshot( - configuredMode = mode, - generation = generation, - lastError = error.message, - ) - ) - stopTrafficPolling() - scope.launch { onAfterIdle() } - } - throw error - } - } + _currentProfile.value = activeProfile + control.start(request.mode, RuntimeStartSource.Ui) } suspend fun stop(request: RuntimeStopRequest) { @@ -412,192 +316,37 @@ internal class RuntimeSession(private val deps: RuntimeSessionDeps) { Timber.i("Ignoring stopProxy: remote controller mode active") return } - operationMutex.withLock { stopInternal(request) } - } - - private suspend fun stopInternal(request: RuntimeStopRequest) { - val owner = - request.owner.takeIf { it != RuntimeOwner.None } - ?: ownership.detectActiveOwner().takeIf { it != RuntimeOwner.None } - ?: _runtimeSnapshot.value.owner - val generation = nextGeneration() - val targetMode = request.targetMode - - if (owner == RuntimeOwner.None) { - clearRuntimePayload(resetGroups = false) - publishSnapshot(RuntimeStateMapper.idleSnapshot(targetMode, generation = generation)) - stopTrafficPolling() - scope.launch { onAfterIdle() } - return - } - - val previousSnapshot = _runtimeSnapshot.value - publishSnapshot( - previousSnapshot.copy( - owner = owner, - phase = RuntimePhase.Stopping, - runMode = targetMode, - profileReady = false, - groupsReady = false, - trafficReady = false, - lastError = request.reason, - generation = generation, - ) - ) - - runCatching { launcher.stop(owner) } - .onFailure { - publishSnapshot(previousSnapshot) - throw it - } - - stopTrafficPolling() - if (!request.completeImmediately) { - return - } - - clearRuntimePayload(resetGroups = false) - publishSnapshot(RuntimeStateMapper.idleSnapshot(targetMode, generation = generation)) - scope.launch { onAfterIdle() } - } - - private fun initializeSnapshot() { - if (remoteControllerStore.isWanted()) { - applyRemoteControllerState() - } - if (isRemoteControllerActive()) { - return - } - val configuredMode = networkSettingsStorage.runMode.value - statusStore.clearLegacyStateFiles() - statusStore.reconcilePersistedRuntimeState() - // Root daemon survives app death; re-attach controller endpoint before ownership probe. - runCatching { processController.reconnectRoot() } - val owner = ownership.detectOwner() - - if (owner == RuntimeOwner.None) { - clearRuntimePayload(resetGroups = false) - publishSnapshot( - RuntimeStateMapper.idleSnapshot( - configuredMode, - lastError = statusStore.queryRuntimeLastError(configuredMode.name), - ) - ) - scope.launch { onAfterIdle() } - return - } - - publishSnapshot( - ownership.activeSnapshot( - owner = owner, - runMode = ownership.localModeForOwner(owner) ?: configuredMode, - localPhase = ownership.localRuntimePhaseForOwner(owner), - localStartedAt = ownership.localRuntimeStartedAtForOwner(owner), - ) - ) - if (_runtimeSnapshot.value.phase.running) { - startTrafficPolling() - scope.launch { onAfterRunning() } - } else { - stopTrafficPolling() - scope.launch { onAfterIdle() } - } + control.stop(request.reason) } - private fun handleStopped(reason: String?) { - if (isRemoteControllerActive()) { - applyRemoteControllerState() - return - } - val generation = nextGeneration() - clearRuntimePayload(resetGroups = false) - publishSnapshot( - RuntimeStateMapper.idleSnapshot( - configuredMode = networkSettingsStorage.runMode.value, - generation = generation, - lastError = reason, - ) - ) - stopTrafficPolling() - scope.launch { onAfterIdle() } - } - - private fun handleFailure(error: String?) { - if (isRemoteControllerActive()) { - applyRemoteControllerState() - return - } - val generation = nextGeneration() - clearRuntimePayload(resetGroups = false) - publishSnapshot( - RuntimeStateMapper.idleSnapshot( - configuredMode = networkSettingsStorage.runMode.value, - generation = generation, - lastError = error ?: "root runtime failed", - ) - ) - stopTrafficPolling() - scope.launch { onAfterIdle() } - } - - suspend fun handleMissingLocalRuntime(snapshot: RuntimeSnapshot, reason: String?) { - val mode = ownership.localModeForOwner(snapshot.owner) ?: return - statusStore.markRuntimeIdle(mode.name) - clearRuntimePayload(resetGroups = false) - publishSnapshot( - RuntimeStateMapper.idleSnapshot( - configuredMode = networkSettingsStorage.runMode.value, - generation = nextGeneration(), - lastError = reason, - ) - ) - stopTrafficPolling() - } - - fun isMissingLocalRuntime(snapshot: RuntimeSnapshot): Boolean { - if ( - snapshot.owner == RuntimeOwner.None || snapshot.owner == RuntimeOwner.RemoteController - ) { + /** + * The controller did not answer: returns true when that is because the local core itself is + * gone (the control then already dropped to Idle). + */ + suspend fun verifyLocalRuntime(snapshot: RuntimeSnapshot): Boolean { + if (snapshot.owner != RuntimeOwner.VpnService && snapshot.owner != RuntimeOwner.RootDaemon) { return false } - val mode = ownership.localModeForOwner(snapshot.owner) ?: return false - return !statusStore.isLocalRuntimeServiceAlive(mode.name) + control.verify() + return !control.state.value.active } - suspend fun queryTrafficTotal(query: suspend () -> Long): Long { - if (!_runtimeSnapshot.value.running) { - _trafficTotal.value = 0L - return 0L - } - val traffic = query() - _trafficTotal.value = traffic - updateTrafficReady() - return traffic - } + suspend fun queryTrafficTotal(query: suspend () -> Long): Long = queryTraffic(_trafficTotal, query) + + suspend fun queryTrafficNow(query: suspend () -> Long): Long = queryTraffic(_trafficNow, query) - suspend fun queryTrafficNow(query: suspend () -> Long): Long { + /** Reads 0 without touching the core while nothing is running. */ + private suspend fun queryTraffic(target: MutableStateFlow, query: suspend () -> Long): Long { if (!_runtimeSnapshot.value.running) { - _trafficNow.value = 0L + target.value = 0L return 0L } val traffic = query() - _trafficNow.value = traffic + target.value = traffic updateTrafficReady() return traffic } - fun setCurrentProfile(profile: Profile?) { - _currentProfile.value = profile - } - - fun setTrafficNow(value: Long) { - _trafficNow.value = value - } - - fun setTrafficTotal(value: Long) { - _trafficTotal.value = value - } - suspend fun connectBackend() { RuntimeAccess.connect(appContext) } diff --git a/runtime/client/src/runtime/client/session/RuntimeSessionDeps.kt b/runtime/client/src/runtime/client/session/RuntimeSessionDeps.kt index b45cb44b6..fa5e61ab7 100644 --- a/runtime/client/src/runtime/client/session/RuntimeSessionDeps.kt +++ b/runtime/client/src/runtime/client/session/RuntimeSessionDeps.kt @@ -23,12 +23,8 @@ package com.github.yumeyucca.yumebox.runtime.client.session import android.content.Context import com.github.yumeyucca.yumebox.data.store.NetworkSettingsStore import com.github.yumeyucca.yumebox.data.store.RemoteControllerStore -import com.github.yumeyucca.yumebox.runtime.api.ProcessController -import com.github.yumeyucca.yumebox.runtime.api.RuntimeLauncher -import com.github.yumeyucca.yumebox.runtime.api.RuntimeStatusStore -import com.github.yumeyucca.yumebox.runtime.service.AndroidRuntimeStatusStore -import com.github.yumeyucca.yumebox.runtime.service.core.AndroidProcessController -import com.github.yumeyucca.yumebox.runtime.service.session.AndroidRuntimeLauncher +import com.github.yumeyucca.yumebox.runtime.api.RuntimeControl +import com.github.yumeyucca.yumebox.runtime.service.RuntimeCoordinator import kotlinx.coroutines.CoroutineScope /** Construction bag for [RuntimeSession]. Keeps the session entry free of long argument lists. */ @@ -37,9 +33,7 @@ internal data class RuntimeSessionDeps( val scope: CoroutineScope, val networkSettingsStorage: NetworkSettingsStore, val remoteControllerStore: RemoteControllerStore, - val statusStore: RuntimeStatusStore = AndroidRuntimeStatusStore, - val processController: ProcessController = AndroidProcessController(context), - val launcher: RuntimeLauncher = AndroidRuntimeLauncher(context), + val control: RuntimeControl = RuntimeCoordinator, val queryTrafficNowAction: suspend () -> Long = { 0L }, val queryTrafficTotalAction: suspend () -> Long = { 0L }, val onAfterRunning: suspend () -> Unit = {}, diff --git a/runtime/service/src/runtime/service/AndroidRuntimeStatusStore.kt b/runtime/service/src/runtime/service/AndroidRuntimeStatusStore.kt deleted file mode 100644 index 1cff9d8f4..000000000 --- a/runtime/service/src/runtime/service/AndroidRuntimeStatusStore.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.service - -import com.github.yumeyucca.yumebox.core.model.RunMode -import com.github.yumeyucca.yumebox.core.util.enumByNameOrNull -import com.github.yumeyucca.yumebox.runtime.api.RuntimePhase -import com.github.yumeyucca.yumebox.runtime.api.RuntimeStatusStore - -/** Android [RuntimeStatusStore] backed by [StatusProvider]. */ -object AndroidRuntimeStatusStore : RuntimeStatusStore { - override fun isRuntimeActive(runModeName: String): Boolean { - val mode = enumByNameOrNull(runModeName) ?: return false - return StatusProvider.isRuntimeActive(mode) - } - - override fun queryRuntimePhase(runModeName: String): RuntimePhase { - val mode = enumByNameOrNull(runModeName) ?: return RuntimePhase.Idle - return StatusProvider.queryRuntimePhase(mode) - } - - override fun queryRuntimeStartedAt(runModeName: String): Long? { - val mode = enumByNameOrNull(runModeName) ?: return null - return StatusProvider.queryRuntimeStartedAt(mode) - } - - override fun queryRuntimeLastError(runModeName: String): String? { - val mode = enumByNameOrNull(runModeName) ?: return null - return StatusProvider.queryRuntimeLastError(mode) - } - - override fun markRuntimeIdle(runModeName: String) { - val mode = enumByNameOrNull(runModeName) ?: return - StatusProvider.markRuntimeIdle(mode) - } - - override fun reconcilePersistedRuntimeState() { - StatusProvider.reconcilePersistedRuntimeState() - } - - override fun isLocalRuntimeServiceAlive(runModeName: String): Boolean { - val mode = enumByNameOrNull(runModeName) ?: return false - return StatusProvider.isLocalRuntimeServiceAlive(mode) - } - - override fun clearLegacyStateFiles() { - StatusProvider.clearLegacyStateFiles() - } -} diff --git a/runtime/service/src/runtime/service/AutoRestartService.kt b/runtime/service/src/runtime/service/AutoRestartService.kt index 704a7c266..052e80d4f 100644 --- a/runtime/service/src/runtime/service/AutoRestartService.kt +++ b/runtime/service/src/runtime/service/AutoRestartService.kt @@ -35,13 +35,21 @@ import com.github.yumeyucca.yumebox.core.util.AutoStartSessionGate import com.github.yumeyucca.yumebox.core.util.StartupTaskCoordinator import com.github.yumeyucca.yumebox.data.model.RunMode import com.github.yumeyucca.yumebox.data.store.* -import com.github.yumeyucca.yumebox.runtime.api.Profile +import com.github.yumeyucca.yumebox.runtime.api.RuntimeOwner +import com.github.yumeyucca.yumebox.runtime.api.RuntimePhase +import com.github.yumeyucca.yumebox.runtime.api.RuntimeStartSource import com.github.yumeyucca.yumebox.runtime.service.log.RuntimeLog import com.github.yumeyucca.yumebox.runtime.service.profile.ProfileService -import com.github.yumeyucca.yumebox.runtime.service.session.RootSessionLauncher -import com.github.yumeyucca.yumebox.runtime.service.session.RuntimeServiceLauncher import com.github.yumeyucca.yumebox.runtime.service.util.* -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import timber.log.Timber import java.util.concurrent.atomic.AtomicBoolean @@ -53,6 +61,7 @@ class AutoRestartService : Service() { const val EXTRA_REASON = "auto_restart_reason" const val REASON_BOOT_COMPLETED = "boot_completed" const val REASON_PACKAGE_REPLACED = "package_replaced" + private const val ACTIVATION_TIMEOUT_MS = 60_000L } private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -65,7 +74,6 @@ class AutoRestartService : Service() { private val serviceCache by lazy { mmkvProvider.getMMKV("service_cache") } private val profileManager by lazy { ProfileService(applicationContext) } private val foregroundStarted = AtomicBoolean(false) - private val activationAwaiter = RuntimeActivationAwaiter() private var autoStartJob: Job? = null // Duplicate triggers (boot + replaced racing) are merged into one job; the finally block @@ -111,28 +119,24 @@ class AutoRestartService : Service() { if (!foregroundStarted.compareAndSet(false, true)) return createNotificationChannel() - val notification = createNotification() - val foregroundFlags = - when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> - ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE - - else -> 0 + val type = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + } else { + 0 } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground(NOTIFICATION_ID, notification, foregroundFlags) - } else { - startForeground(NOTIFICATION_ID, notification) - } + ServiceCompat.startForeground(this, NOTIFICATION_ID, createNotification(), type) } private suspend fun checkAndAutoStart(reason: String) { // An APK replacement kills app-owned services but not the detached root daemon. Tear it // down before checking the restart preference so no process keeps running replaced code. - if (reason == REASON_PACKAGE_REPLACED) { - withContext(Dispatchers.IO) { - RootSessionLauncher.stop(this@AutoRestartService) - } + // verify() also reattaches a surviving root daemon before anything below reads state. + RuntimeCoordinator.verify() + if (reason == REASON_PACKAGE_REPLACED && + RuntimeCoordinator.state.value.owner == RuntimeOwner.RootDaemon + ) { + RuntimeCoordinator.stop() } if (!appSettingsStorage.automaticRestart.value) return if (RemoteControllerStore.isActive()) { @@ -144,7 +148,9 @@ class AutoRestartService : Service() { return } StartupTaskCoordinator.awaitWarmup() - val skipUpdateOnPostUpdateColdStart = featureStore.consumePostUpdateColdStartPending() + // This background start is the post-update cold start; the next UI launch must not skip. + // Boot and replace starts never auto-update the profile: the network may not be up yet. + featureStore.consumePostUpdateColdStartPending() val activeProfile = profileManager.queryActive() if (activeProfile == null) { @@ -152,149 +158,40 @@ class AutoRestartService : Service() { return } - tryUpdateActiveProfileOnStart( - activeProfile = activeProfile, - reason = reason, - skipForPostUpdateColdStart = skipUpdateOnPostUpdateColdStart, - ) - val runMode = networkSettingsStorage.runMode.value - // Mirrors ProxyAutoStartHelper: a restart against an already-active runtime would - // re-mark Starting and tear down the live core underneath the existing service. - if (StatusProvider.isRuntimeActive(runMode)) { + // A restart against an already-active runtime would tear down the live core. + if (RuntimeCoordinator.state.value.active) { Timber.tag(TAG).i("Skip auto start: ${runMode.name} runtime already active") return } - - val startupSource = - when (reason) { - REASON_BOOT_COMPLETED -> RuntimeServiceLauncher.SOURCE_AUTO_RESTART_BOOT - REASON_PACKAGE_REPLACED -> RuntimeServiceLauncher.SOURCE_AUTO_RESTART_REPLACED - else -> RuntimeServiceLauncher.SOURCE_AUTO_RESTART - } - val activationMode = runMode - when (runMode) { - RunMode.VpnService -> { - if (VpnService.prepare(this) != null) { - Timber.tag(TAG).i("Skip auto start: VPN permission is missing") - return - } - RuntimeServiceLauncher.start(this, RunMode.VpnService, startupSource) - } - - RunMode.Tun -> - withContext(Dispatchers.IO) { - RootSessionLauncher.start(this@AutoRestartService, runMode) - } - - RunMode.Ebpf -> - withContext(Dispatchers.IO) { - RootSessionLauncher.start(this@AutoRestartService, RunMode.Ebpf) - } - } - - val activationResult = runCatching { - awaitRuntimeActivation(activationMode) - } - .getOrElse { error -> - cleanupIncompleteRuntime(activationMode) - throw error - } - val log = RuntimeLog.writer(this, activationMode) - when (activationResult) { - is RuntimeActivationResult.Running -> { - log.i( - RuntimeLog.Type.AutoStart, - "success: running reason=$reason mode=$activationMode", - ) - Timber.tag(TAG) - .i( - "Auto start active: reason=$reason profile=${activeProfile.name}, " + - "mode=$activationMode" - ) - } - - is RuntimeActivationResult.Failed -> { - cleanupIncompleteRuntime(activationMode) - val message = activationResult.error ?: "runtime entered Failed" - log.e(RuntimeLog.Type.AutoStart, "failed reason=$reason error=$message") - error(message) - } - - is RuntimeActivationResult.TimedOut -> { - cleanupIncompleteRuntime(activationMode) - val message = - "runtime activation timed out in ${activationResult.lastState.phase}" + - activationResult.lastState.error?.let { ": $it" }.orEmpty() - log.e( - RuntimeLog.Type.AutoStart, - "timeout reason=$reason phase=${activationResult.lastState.phase} " + - "error=${activationResult.lastState.error}", - ) - error(message) - } - } - } - - private suspend fun awaitRuntimeActivation(mode: RunMode): RuntimeActivationResult = - activationAwaiter.await(mode) { - RuntimeActivationState( - phase = StatusProvider.queryRuntimePhase(mode), - error = StatusProvider.queryRuntimeLastError(mode), - ) - } - - private suspend fun cleanupIncompleteRuntime(mode: RunMode) { - if (mode == RunMode.VpnService) { - RuntimeServiceLauncher.stop(this, mode) - } else { - RootSessionLauncher.stop(this) + if (runMode == RunMode.VpnService && VpnService.prepare(this) != null) { + Timber.tag(TAG).i("Skip auto start: VPN permission is missing") + return } - } - @Suppress("TooGenericExceptionCaught") - private suspend fun tryUpdateActiveProfileOnStart( - activeProfile: Profile, - reason: String, - skipForPostUpdateColdStart: Boolean, - ) { - when ( - AutoStartUpdatePolicy.decide( - autoUpdateEnabled = appSettingsStorage.autoUpdateCurrentProfileOnStart.value, - activeProfile = activeProfile, - skipForPostUpdateColdStart = skipForPostUpdateColdStart, - startupReason = reason, - coldStartReasons = setOf(REASON_BOOT_COMPLETED, REASON_PACKAGE_REPLACED), - ) - ) { - AutoStartUpdatePolicy.Decision.Proceed -> Unit - AutoStartUpdatePolicy.Decision.AutoUpdateDisabled -> return - AutoStartUpdatePolicy.Decision.SkipPostUpdateColdStart -> { - Timber.tag(TAG).d("Skip auto update: post-update cold-start marker consumed") - return - } - - AutoStartUpdatePolicy.Decision.SkipColdStartReason -> { - Timber.tag(TAG).d("Skip auto update on cold-start reason=$reason") - return - } - - AutoStartUpdatePolicy.Decision.UnsupportedProfileType -> { - Timber.tag(TAG) - .d("Skip boot update: unsupported profile type=${activeProfile.type}") - return + val source = + when (reason) { + REASON_BOOT_COMPLETED -> RuntimeStartSource.AutoRestartBoot + REASON_PACKAGE_REPLACED -> RuntimeStartSource.AutoRestartReplaced + else -> RuntimeStartSource.AutoRestart } - - AutoStartUpdatePolicy.Decision.NoActiveProfile -> return - } - + val log = RuntimeLog.writer(this, runMode) try { - profileManager.update(activeProfile.uuid, null) - Timber.tag(TAG).i("Boot update ok: ${activeProfile.uuid}") - } catch (error: Exception) { - // fault barrier: best-effort boot update goes through the core fetch bridge; any - // failure must not block the auto restart itself. - Timber.tag(TAG).w(error, "Boot update failed") + withTimeout(ACTIVATION_TIMEOUT_MS) { RuntimeCoordinator.start(runMode, source) } + } catch (error: TimeoutCancellationException) { + RuntimeCoordinator.stop() + log.e(RuntimeLog.Type.AutoStart, "timeout reason=$reason mode=$runMode") + throw IllegalStateException("runtime activation timed out", error) + } catch (error: CancellationException) { + throw error + } catch (@Suppress("TooGenericExceptionCaught") error: Exception) { + log.e(RuntimeLog.Type.AutoStart, "failed reason=$reason error=${error.message}") + throw error + } + if (RuntimeCoordinator.state.value.phase == RuntimePhase.Running) { + log.i(RuntimeLog.Type.AutoStart, "success: running reason=$reason mode=$runMode") + Timber.tag(TAG) + .i("Auto start active: reason=$reason profile=${activeProfile.name}, mode=$runMode") } } diff --git a/runtime/service/src/runtime/service/BaseService.kt b/runtime/service/src/runtime/service/BaseService.kt deleted file mode 100644 index 83a015be0..000000000 --- a/runtime/service/src/runtime/service/BaseService.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.service - -import android.app.Service -import com.github.yumeyucca.yumebox.runtime.api.appContextOrSelf -import com.github.yumeyucca.yumebox.runtime.api.initializeServiceGlobal -import com.github.yumeyucca.yumebox.runtime.service.util.cancelAndJoinBlocking -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers - -abstract class BaseService : Service(), CoroutineScope by CoroutineScope(Dispatchers.Default) { - override fun onCreate() { - super.onCreate() - - initializeServiceGlobal(appContextOrSelf) - } - - override fun onDestroy() { - super.onDestroy() - - cancelAndJoinBlocking() - } -} diff --git a/runtime/service/src/runtime/service/ProxyTileService.kt b/runtime/service/src/runtime/service/ProxyTileService.kt index b189f2316..5171a1a90 100644 --- a/runtime/service/src/runtime/service/ProxyTileService.kt +++ b/runtime/service/src/runtime/service/ProxyTileService.kt @@ -18,33 +18,33 @@ * */ -@file:Suppress("KotlinConstantConditions") - package com.github.yumeyucca.yumebox.runtime.service import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Intent import android.graphics.drawable.Icon -import android.net.VpnService import android.os.Build import android.service.quicksettings.Tile import android.service.quicksettings.TileService import com.github.yumeyucca.yumebox.core.util.AutoStartSessionGate -import com.github.yumeyucca.yumebox.core.util.PollingTimerSpecs -import com.github.yumeyucca.yumebox.core.util.PollingTimers -import com.github.yumeyucca.yumebox.data.model.RunMode import com.github.yumeyucca.yumebox.data.store.MMKVProvider import com.github.yumeyucca.yumebox.data.store.NetworkSettingsStore import com.github.yumeyucca.yumebox.data.store.RemoteControllerStore -import com.github.yumeyucca.yumebox.runtime.api.* -import com.github.yumeyucca.yumebox.runtime.service.core.CoreProcess +import com.github.yumeyucca.yumebox.runtime.api.Components +import com.github.yumeyucca.yumebox.runtime.api.RuntimePhase +import com.github.yumeyucca.yumebox.runtime.api.RuntimeStartSource +import com.github.yumeyucca.yumebox.runtime.api.RuntimeState +import com.github.yumeyucca.yumebox.runtime.api.VpnPermissionRequired import com.github.yumeyucca.yumebox.runtime.service.profile.ProfileService -import com.github.yumeyucca.yumebox.runtime.service.session.RootSessionLauncher -import com.github.yumeyucca.yumebox.runtime.service.session.RuntimeServiceLauncher import com.github.yumeyucca.yumebox.runtime.service.util.ServiceLogoIcons -import com.github.yumeyucca.yumebox.runtime.service.util.sendBroadcastSelf -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import tf.gal.yumebox.locale.YumeTxt import timber.log.Timber @@ -70,16 +70,7 @@ class ProxyTileService : TileService() { override fun onStartListening() { super.onStartListening() updateJob?.cancel() - updateJob = scope.launch { - // Refresh once up front: requestListeningState() (fired on every start/stop) only opens - // a - // brief listening window, so we must update immediately rather than wait for the first - // tick. - updateTileStateFromRuntime() - PollingTimers.ticks(PollingTimerSpecs.ProxyTileRefresh).collect { - updateTileStateFromRuntime() - } - } + updateJob = scope.launch { RuntimeCoordinator.state.collect(::render) } } override fun onStopListening() { @@ -87,200 +78,72 @@ class ProxyTileService : TileService() { updateJob?.cancel() } - @Suppress("TooGenericExceptionCaught") override fun onClick() { super.onClick() - if (toggleJob?.isActive == true) return + val state = RuntimeCoordinator.state.value + // A second tap while a start is still coming up cancels it. + if (toggleJob?.isActive == true && state.phase != RuntimePhase.Starting) return toggleJob = scope.launch { if (RemoteControllerStore.isActive()) { - updateTileState(true) + showTile(active = true, YumeTxt.Service.Tile.ClickToStopProxy) return@launch } - val snapshot = withContext(Dispatchers.IO) { currentSnapshot() } - val isActive = snapshot.phase.isActiveOrStopping - val currentMode = effectiveMode(snapshot) - - // If the tile visual state is stale vs the actual runtime state, sync it - // immediately but still perform the user's requested action — the user's tap - // is their intent to toggle, not just to reconcile state. - val tileState = qsTile?.state - val tileStaleInactive = isActive && tileState == Tile.STATE_INACTIVE - val tileStaleActive = !isActive && tileState == Tile.STATE_ACTIVE - if (tileStaleInactive || tileStaleActive) { - updateTileState(isActive) - } - try { - if (isActive) { + if (state.phase.isActiveOrStopping) { AutoStartSessionGate.markManualPaused() - updateTilePendingState(isStarting = false) - withContext(Dispatchers.IO) { stopLocalRuntime() } + showTile(active = true, YumeTxt.Service.Tile.Disconnecting) + RuntimeCoordinator.stop() } else { - val activeProfile = withContext(Dispatchers.IO) { profileManager.queryActive() } - if (activeProfile == null) { - updateTileInactiveState(subtitle = YumeTxt.Service.Tile.ClickToOpen) - - val intent = - Intent(Intent.ACTION_MAIN).apply { - component = Components.MAIN_ACTIVITY - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - startActivityAndCollapseCompat(intent, requestCode = 1001) - return@launch - } - - updateTilePendingState(isStarting = true) - when (currentMode) { - RunMode.VpnService -> { - val vpnIntent = VpnService.prepare(this@ProxyTileService) - if (vpnIntent != null) { - updateTileInactiveState(subtitle = YumeTxt.Service.Tile.ClickToOpen) - vpnIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivityAndCollapseCompat(vpnIntent, requestCode = 1002) - return@launch - } - - RuntimeServiceLauncher.start( - this@ProxyTileService, - RunMode.VpnService, - RuntimeServiceLauncher.SOURCE_TILE, - ) - } - - RunMode.Tun -> { - withContext(Dispatchers.IO) { - RootSessionLauncher.start(this@ProxyTileService, currentMode) - } - } - - RunMode.Ebpf -> { - withContext(Dispatchers.IO) { - RootSessionLauncher.start(this@ProxyTileService, RunMode.Ebpf) - } - } - } + startFromTile() } - } catch (error: Exception) { - // fault barrier: toggle spans root bridge / service start; the tile must recover - // to the real runtime state instead of crashing the SystemUI-bound service. + } catch (error: VpnPermissionRequired) { + showTile(active = false, YumeTxt.Service.Tile.ClickToOpen) + error.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivityAndCollapseCompat(error.intent, requestCode = 1002) + } catch (error: CancellationException) { + throw error + } catch (@Suppress("TooGenericExceptionCaught") error: Exception) { + // fault barrier: the tile must fall back to the real state instead of crashing + // the SystemUI-bound service. Timber.e(error, "Error toggling proxy from tile") } finally { - PollingTimers.awaitTick( - PollingTimerSpecs.dynamic( - name = "proxy_tile_toggle_state_sync", - intervalMillis = 300L, - initialDelayMillis = 300L, - ) - ) - updateTileStateFromRuntime() + render(RuntimeCoordinator.state.value) } } } - private suspend fun updateTileStateFromRuntime() { - updateTileState(withContext(Dispatchers.IO) { currentSnapshot() }.phase.isActiveOrStopping) - } - - private fun currentSnapshot(): RuntimeSnapshot { - val configuredMode = networkSettingsStorage.runMode.value - val vpnPhase = StatusProvider.queryRuntimePhase(RunMode.VpnService) - val owner = - when { - vpnPhase != RuntimePhase.Idle -> RuntimeOwner.VpnService - CoreProcess.isRootDaemonAlive() -> RuntimeOwner.RootDaemon - else -> RuntimeOwner.None - } - - return if (owner == RuntimeOwner.None) { - RuntimeSnapshot( - owner = RuntimeOwner.None, - phase = RuntimePhase.Idle, - runMode = configuredMode, - ) - } else { - RuntimeSnapshot( - owner = owner, - phase = - when (owner) { - RuntimeOwner.VpnService -> vpnPhase - RuntimeOwner.RootDaemon -> RuntimePhase.Running - RuntimeOwner.None -> error("unreachable: None handled above") - else -> RuntimePhase.Running - }, - // VpnService owner is always the VPN mode. A root daemon can outlive a settings - // change, so use its persisted mode instead of the current selection. - runMode = - if (owner == RuntimeOwner.VpnService) RunMode.VpnService - else CoreProcess.rootDaemonMode() ?: configuredMode, - ) - } - } - - // Mirrors the home-screen stop path: broadcast a stop, tear down the VPN service, and — since - // the - // root daemon isn't a service — explicitly stop it (this is a deliberate user stop). - private suspend fun stopLocalRuntime() { - runCatching { sendBroadcastSelf(Intent(Intents.ACTION_RUNTIME_REQUEST_STOP)) } - runCatching { - applicationContext.stopService(Intent(applicationContext, TunService::class.java)) - } - try { - RootSessionLauncher.stop(applicationContext) - } catch (error: Exception) { - Timber.w(error, "Failed to stop root runtime from tile") - } - } - - private fun effectiveMode(snapshot: RuntimeSnapshot): RunMode = snapshot.runMode - - private fun updateTileState(isRunning: Boolean) { - val tile = qsTile ?: return - tile.state = if (isRunning) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE - - tile.label = tileLabelText - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - tile.subtitle = - if (isRunning) { - YumeTxt.Service.Tile.ClickToStopProxy - } else { - YumeTxt.Service.Tile.ClickToStartProxy + private suspend fun startFromTile() { + val activeProfile = withContext(Dispatchers.IO) { profileManager.queryActive() } + if (activeProfile == null) { + showTile(active = false, YumeTxt.Service.Tile.ClickToOpen) + val intent = + Intent(Intent.ACTION_MAIN).apply { + component = Components.MAIN_ACTIVITY + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + startActivityAndCollapseCompat(intent, requestCode = 1001) + return } - - tile.icon = Icon.createWithResource(this, ServiceLogoIcons.resId()) - - tile.updateTile() + showTile(active = false, YumeTxt.Service.Tile.Connecting) + RuntimeCoordinator.start(networkSettingsStorage.runMode.value, RuntimeStartSource.Tile) } - private fun updateTilePendingState(isStarting: Boolean) { - val tile = qsTile ?: return - tile.state = if (isStarting) Tile.STATE_INACTIVE else Tile.STATE_ACTIVE - tile.label = tileLabelText - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - tile.subtitle = - if (isStarting) { - YumeTxt.Service.Tile.Connecting - } else { - YumeTxt.Service.Tile.Disconnecting - } + private fun render(state: RuntimeState) { + when (state.phase) { + RuntimePhase.Starting -> showTile(active = false, YumeTxt.Service.Tile.Connecting) + RuntimePhase.Stopping -> showTile(active = true, YumeTxt.Service.Tile.Disconnecting) + RuntimePhase.Running -> showTile(active = true, YumeTxt.Service.Tile.ClickToStopProxy) + RuntimePhase.Idle, + RuntimePhase.Failed -> showTile(active = false, YumeTxt.Service.Tile.ClickToStartProxy) } - - tile.icon = Icon.createWithResource(this, ServiceLogoIcons.resId()) - tile.updateTile() } - private fun updateTileInactiveState(subtitle: String) { + private fun showTile(active: Boolean, subtitle: String) { val tile = qsTile ?: return - tile.state = Tile.STATE_INACTIVE + tile.state = if (active) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE tile.label = tileLabelText - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - tile.subtitle = subtitle - } - + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = subtitle tile.icon = Icon.createWithResource(this, ServiceLogoIcons.resId()) tile.updateTile() } @@ -296,7 +159,7 @@ class ProxyTileService : TileService() { } @Suppress("DEPRECATION") - @android.annotation.SuppressLint("StartActivityAndCollapseDeprecated") + @SuppressLint("StartActivityAndCollapseDeprecated") startActivityAndCollapse(intent) } } diff --git a/runtime/service/src/runtime/service/RestartReceiver.kt b/runtime/service/src/runtime/service/RestartReceiver.kt index 6a7e08e06..963514513 100644 --- a/runtime/service/src/runtime/service/RestartReceiver.kt +++ b/runtime/service/src/runtime/service/RestartReceiver.kt @@ -27,23 +27,16 @@ import timber.log.Timber class RestartReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - Intent.ACTION_BOOT_COMPLETED, - Intent.ACTION_MY_PACKAGE_REPLACED -> { - val reason = - when (intent.action) { - Intent.ACTION_BOOT_COMPLETED -> AutoRestartService.REASON_BOOT_COMPLETED - Intent.ACTION_MY_PACKAGE_REPLACED -> - AutoRestartService.REASON_PACKAGE_REPLACED - - else -> "unknown" - } - val serviceIntent = - Intent(context, AutoRestartService::class.java) - .putExtra(AutoRestartService.EXTRA_REASON, reason) - runCatching { context.startForegroundService(serviceIntent) } - .onFailure { error -> Timber.e(error, "Start auto-restart service failed") } + val reason = + when (intent.action) { + Intent.ACTION_BOOT_COMPLETED -> AutoRestartService.REASON_BOOT_COMPLETED + Intent.ACTION_MY_PACKAGE_REPLACED -> AutoRestartService.REASON_PACKAGE_REPLACED + else -> return } - } + val serviceIntent = + Intent(context, AutoRestartService::class.java) + .putExtra(AutoRestartService.EXTRA_REASON, reason) + runCatching { context.startForegroundService(serviceIntent) } + .onFailure { error -> Timber.e(error, "Start auto-restart service failed") } } } diff --git a/runtime/service/src/runtime/service/RootForegroundService.kt b/runtime/service/src/runtime/service/RootForegroundService.kt index 6a992ad79..a73182462 100644 --- a/runtime/service/src/runtime/service/RootForegroundService.kt +++ b/runtime/service/src/runtime/service/RootForegroundService.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch /** * Foreground notification host for the detached root Tun daemon. The core itself remains a diff --git a/runtime/service/src/runtime/service/RuntimeCoordinator.kt b/runtime/service/src/runtime/service/RuntimeCoordinator.kt new file mode 100644 index 000000000..8a8061067 --- /dev/null +++ b/runtime/service/src/runtime/service/RuntimeCoordinator.kt @@ -0,0 +1,574 @@ +/* + * This file is part of YumeBox. + * + * YumeBox is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * Copyright (c) YumeYucca 2025 - Present + * + */ + +package com.github.yumeyucca.yumebox.runtime.service + +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.VpnService +import android.os.SystemClock +import android.service.quicksettings.TileService +import androidx.core.content.ContextCompat +import com.github.yumeyucca.yumebox.core.Global +import com.github.yumeyucca.yumebox.core.model.RunMode +import com.github.yumeyucca.yumebox.core.util.requireBuiltinGeoAssets +import com.github.yumeyucca.yumebox.data.store.RemoteControllerStore +import com.github.yumeyucca.yumebox.runtime.api.Intents +import com.github.yumeyucca.yumebox.runtime.api.RuntimeControl +import com.github.yumeyucca.yumebox.runtime.api.RuntimeOwner +import com.github.yumeyucca.yumebox.runtime.api.RuntimePhase +import com.github.yumeyucca.yumebox.runtime.api.RuntimeStartSource +import com.github.yumeyucca.yumebox.runtime.api.RuntimeState +import com.github.yumeyucca.yumebox.runtime.api.VpnPermissionRequired +import com.github.yumeyucca.yumebox.runtime.service.config.ServiceStore +import com.github.yumeyucca.yumebox.runtime.service.core.CoreProcess +import com.github.yumeyucca.yumebox.runtime.service.core.RootDaemonProbe +import com.github.yumeyucca.yumebox.runtime.service.core.RootDaemonState +import com.github.yumeyucca.yumebox.runtime.service.log.RuntimeLog +import com.github.yumeyucca.yumebox.runtime.service.session.ConnectionTracker +import com.github.yumeyucca.yumebox.runtime.service.session.ReloadRejectedException +import com.github.yumeyucca.yumebox.runtime.service.session.RootRuntime +import com.github.yumeyucca.yumebox.runtime.service.session.SessionRuntimeSpecFactory +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import timber.log.Timber +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean + +/** + * The single owner of the local core lifecycle, in-process for every caller (UI, tile, boot, + * Wi-Fi automation, config changes). Operations run one at a time under [mutex] on [scope], so a + * caller that goes away never leaves a half-started core behind, and [state] is the only source + * of truth — nothing is inferred from service liveness or persisted phase slots. + */ +object RuntimeCoordinator : RuntimeControl { + private const val TAG = "RuntimeCoordinator" + private const val VPN_ATTACH_TIMEOUT_MS = 10_000L + private const val VPN_DESTROY_TIMEOUT_MS = 3_000L + private const val CORE_WATCH_INTERVAL_MS = 500L + private const val RELOAD_DEBOUNCE_MS = 150L + private const val BACKEND_UNAVAILABLE = "runtime backend unavailable" + + private val context: Context + get() = Global.application + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mutex = Mutex() + private val bootstrapRequested = AtomicBoolean(false) + private var bootstrapped = false + private val _state = MutableStateFlow(RuntimeState()) + + /** The started TunService instance, from onStartCommand until onDestroy. */ + @Volatile private var vpnService: TunService? = null + /** The instance whose session currently carries the VPN core. */ + @Volatile private var activeVpn: TunService? = null + @Volatile private var vpnAttach: CompletableDeferred? = null + @Volatile private var vpnDestroy: CompletableDeferred? = null + @Volatile private var startJob: Job? = null + private var reloadJob: Job? = null + private var watchJob: Job? = null + /** elapsedRealtime right before the running owner last read its spec. */ + @Volatile private var specReadAt = 0L + private val connectionTracker by lazy { ConnectionTracker(context, scope) } + private val publicState = _state.asStateFlow() + + /** First access reattaches a surviving root daemon or restores the last failure. */ + override val state: StateFlow + get() { + if (bootstrapRequested.compareAndSet(false, true)) { + scope.launch { runOp {} } + } + return publicState + } + + override suspend fun start(mode: RunMode, source: RuntimeStartSource) { + try { + runOp { startLocked(mode, source) } + } catch (error: CancellationException) { + // A stop cancels the start it supersedes; only the caller's own cancellation escapes. + currentCoroutineContext().ensureActive() + } + } + + override suspend fun stop(reason: String?) { + startJob?.cancel() + runOp { stopLocked(reason) } + } + + override suspend fun reload() = runOp { reloadLocked(changedAt = null) } + + override suspend fun verify() = runOp { verifyLocked() } + + private suspend fun runOp(block: suspend () -> T): T = + scope + .async { + mutex.withLock { + bootstrapLocked() + block() + } + } + .await() + + // ---- TunService callbacks (main thread) ---- + + internal fun onVpnServiceStarted(service: TunService) { + vpnService = service + val attach = vpnAttach + if (attach != null) { + attach.complete(service) + } else if (activeVpn !== service) { + scope.launch { adoptVpnService(service) } + } + } + + internal fun onVpnRevoked(service: TunService) { + if (activeVpn !== service) return + scope.launch { stop("VPN connection revoked by system") } + } + + internal fun onVpnServiceDestroyed(service: TunService) { + if (vpnService === service) vpnService = null + vpnDestroy?.complete(Unit) + if (activeVpn === service) { + scope.launch { + runOp { + if (activeVpn !== service) return@runOp + Timber.tag(TAG).w("TunService destroyed while its session was active") + teardownLocked(RuntimeOwner.VpnService) + publish(RuntimeOwner.None, _state.value.mode, RuntimePhase.Idle) + } + } + } + } + + /** A TunService the system recreated on its own (START_REDELIVER_INTENT) becomes a start. */ + private suspend fun adoptVpnService(service: TunService) { + runCatching { start(RunMode.VpnService, RuntimeStartSource.System) } + .onFailure { error -> Timber.tag(TAG).w(error, "Recreated TunService not adopted") } + runOp { + if (activeVpn !== service && vpnService === service) { + withContext(Dispatchers.Main) { service.finish() } + } + } + } + + // ---- operations (called with mutex held) ---- + + private suspend fun bootstrapLocked() { + if (bootstrapped) return + bootstrapped = true + bootstrapRequested.set(true) + registerConfigReceiver() + val rootMode = + withContext(Dispatchers.IO) { + runCatching { CoreProcess.reconnectRoot(context) }.getOrNull() + } + ?.let(RunMode::fromCoreArg) + if (rootMode != null) { + val launchedAt = RootDaemonState.load()?.launchedAt?.takeIf { it > 0L } + publish(RuntimeOwner.RootDaemon, rootMode, RuntimePhase.Running, startedAt = launchedAt) + runCatching { RootForegroundService.start(context) } + onRunning(RuntimeOwner.RootDaemon) + return + } + RuntimeFailureRecord.load()?.let { failure -> + publish( + RuntimeOwner.None, + failure.mode, + RuntimePhase.Failed, + lastError = failure.error, + ) + } + } + + private suspend fun startLocked(mode: RunMode, source: RuntimeStartSource) { + val previous = _state.value + if (previous.phase == RuntimePhase.Running && previous.mode == mode) return + if (RemoteControllerStore.isActive()) { + RuntimeLog.writer(context, mode) + .i(RuntimeLog.Type.Launcher, "skipped: remote controller active source=$source") + return + } + if (mode == RunMode.VpnService) { + VpnService.prepare(context)?.let { throw VpnPermissionRequired(it) } + } + context.requireBuiltinGeoAssets() + + val owner = ownerOf(mode) + val swapsRoot = owner == RuntimeOwner.RootDaemon && previous.owner == RuntimeOwner.RootDaemon + if (previous.owner != RuntimeOwner.None && !swapsRoot) { + // Straight from Stopping to Starting: an Idle in between would wake idle-only work. + publish(previous.owner, previous.mode, RuntimePhase.Stopping, startedAt = previous.startedAt) + withContext(NonCancellable) { teardownLocked(previous.owner) } + } + launchLocked(owner, mode, source, previous, swapsRoot) + } + + /** [swapsRoot]: [previous] is a root daemon that keeps serving until the new exec. */ + private suspend fun launchLocked( + owner: RuntimeOwner, + mode: RunMode, + source: RuntimeStartSource, + previous: RuntimeState, + swapsRoot: Boolean, + reloading: Boolean = false, + ) { + RuntimeFailureRecord.clear() + startJob = currentCoroutineContext()[Job] + val startedAt = System.currentTimeMillis() + specReadAt = SystemClock.elapsedRealtime() + publish( + RuntimeState( + owner = owner, + mode = mode, + phase = RuntimePhase.Starting, + startedAt = startedAt, + reloading = reloading, + ) + ) + try { + when (owner) { + RuntimeOwner.VpnService -> startVpnLocked(source) + else -> { + RootForegroundService.start(context) + withContext(Dispatchers.IO) { RootRuntime.start(context, mode) } + } + } + publish(owner, mode, RuntimePhase.Running, startedAt = startedAt) + onRunning(owner) + } catch (error: CancellationException) { + withContext(NonCancellable) { + if (!keepServingRoot(swapsRoot, previous, lastError = null)) { + teardownLocked(owner) + publish(RuntimeOwner.None, mode, RuntimePhase.Idle) + } + } + throw error + } catch (@Suppress("TooGenericExceptionCaught") error: Exception) { + val message = error.message?.takeIf(String::isNotBlank) ?: "runtime start failed" + if (!keepServingRoot(swapsRoot, previous, lastError = message)) { + teardownLocked(owner) + fail(mode, message) + } + throw error + } finally { + startJob = null + } + } + + /** A root swap that failed before its exec leaves the previous daemon serving. */ + private suspend fun keepServingRoot( + swapsRoot: Boolean, + previous: RuntimeState, + lastError: String?, + ): Boolean { + if (!swapsRoot || !withContext(Dispatchers.IO) { RootDaemonProbe.trackedAlive() }) { + return false + } + publish( + RuntimeOwner.RootDaemon, + previous.mode, + RuntimePhase.Running, + startedAt = previous.startedAt, + lastError = lastError, + ) + onRunning(RuntimeOwner.RootDaemon) + return true + } + + private suspend fun startVpnLocked(source: RuntimeStartSource) { + RuntimeLog.writer(context, RuntimeLog.Source.LocalTun) + .beginSession(RuntimeLog.Type.Launcher, "request start source=$source mode=VpnService") + val service = vpnService ?: awaitVpnService() + activeVpn = service + val spec = withContext(Dispatchers.IO) { SessionRuntimeSpecFactory(context).createVpnSpec() } + service.session.start(spec) + StatusProvider.currentProfile = spec.profileName + } + + private suspend fun awaitVpnService(): TunService { + val attach = CompletableDeferred() + vpnAttach = attach + try { + ContextCompat.startForegroundService(context, Intent(context, TunService::class.java)) + return withTimeout(VPN_ATTACH_TIMEOUT_MS) { attach.await() } + } finally { + vpnAttach = null + } + } + + private suspend fun stopLocked(reason: String?) { + val current = _state.value + if (current.owner == RuntimeOwner.None) { + if (reason != null) publish(RuntimeOwner.None, current.mode, RuntimePhase.Idle, lastError = reason) + return + } + RuntimeFailureRecord.clear() + publish(current.owner, current.mode, RuntimePhase.Stopping, startedAt = current.startedAt) + withContext(NonCancellable) { teardownLocked(current.owner) } + publish(RuntimeOwner.None, current.mode, RuntimePhase.Idle, lastError = reason) + } + + /** [changedAt]: when the triggering config change arrived; null for an explicit reload. */ + private suspend fun reloadLocked(changedAt: Long?) { + val current = _state.value + if (current.phase != RuntimePhase.Running) return + val mode = current.mode ?: return + // The running spec was read after that change arrived, so it already carries it. + if (changedAt != null && changedAt < specReadAt) return + when (current.owner) { + RuntimeOwner.VpnService -> reloadVpnLocked(current, mode) + RuntimeOwner.RootDaemon -> + try { + launchLocked( + RuntimeOwner.RootDaemon, + mode, + RuntimeStartSource.System, + current, + swapsRoot = true, + reloading = true, + ) + } catch (error: CancellationException) { + throw error + } catch (@Suppress("TooGenericExceptionCaught") error: Exception) { + Timber.tag(TAG).w(error, "Root reload failed") + } + else -> Unit + } + } + + private suspend fun reloadVpnLocked(current: RuntimeState, mode: RunMode) { + val service = activeVpn ?: return + publish(current.copy(reloading = true)) + specReadAt = SystemClock.elapsedRealtime() + try { + val spec = + withContext(Dispatchers.IO) { SessionRuntimeSpecFactory(context).createVpnSpec() } + service.session.reload(spec) + StatusProvider.currentProfile = spec.profileName + publish(current.copy(reloading = false, lastError = null)) + } catch (error: ReloadRejectedException) { + ServiceStore().activeProfile = UUID.fromString(error.restored.profileUuid) + StatusProvider.currentProfile = error.restored.profileName + publish(current.copy(reloading = false, lastError = error.message)) + } catch (error: CancellationException) { + throw error + } catch (@Suppress("TooGenericExceptionCaught") error: Exception) { + Timber.tag(TAG).e(error, "VPN reload failed and nothing is serving") + withContext(NonCancellable) { teardownLocked(RuntimeOwner.VpnService) } + fail(mode, error.message ?: "runtime reload failed") + } + } + + private suspend fun verifyLocked() { + val current = _state.value + if (current.phase != RuntimePhase.Running) return + val alive = + when (current.owner) { + RuntimeOwner.VpnService -> CoreProcess.isLocalCoreAlive() + RuntimeOwner.RootDaemon -> + withContext(Dispatchers.IO) { RootDaemonProbe.isAlive() } + else -> true + } + if (alive) return + Timber.tag(TAG).w("%s core vanished", current.owner) + withContext(NonCancellable) { teardownLocked(current.owner) } + publish(RuntimeOwner.None, current.mode, RuntimePhase.Idle, lastError = BACKEND_UNAVAILABLE) + } + + private fun onRunning(owner: RuntimeOwner) { + connectionTracker.start() + watchJob?.cancel() + watchJob = if (owner == RuntimeOwner.VpnService) watchVpnCore() else null + } + + /** The VPN child has no other exit signal; a dead pid turns the session into Failed. */ + private fun watchVpnCore(): Job = + scope.launch(Dispatchers.IO) { + while (isActive) { + delay(CORE_WATCH_INTERVAL_MS) + if (CoreProcess.isLocalCoreAlive()) continue + val reason = + "core process exited unexpectedly" + + (CoreProcess.coreLogTail(context)?.let { ": $it" }.orEmpty()) + scope.launch { runOp { failIfVpnCoreDead(reason) } } + return@launch + } + } + + /** Re-checked under the lock: a reload has no child for a moment by design. */ + private suspend fun failIfVpnCoreDead(reason: String) { + val current = _state.value + if (current.owner != RuntimeOwner.VpnService || current.phase != RuntimePhase.Running) { + return + } + if (CoreProcess.isLocalCoreAlive()) { + watchJob = watchVpnCore() + return + } + teardownLocked(RuntimeOwner.VpnService) + fail(RunMode.VpnService, reason) + } + + private suspend fun teardownLocked(owner: RuntimeOwner) { + watchJob?.cancel() + watchJob = null + connectionTracker.stop() + when (owner) { + RuntimeOwner.VpnService -> stopVpnLocked() + RuntimeOwner.RootDaemon -> { + withContext(Dispatchers.IO) { + runCatching { CoreProcess.stopRoot() } + .onFailure { error -> Timber.tag(TAG).e(error, "Root stop failed") } + } + RootForegroundService.stop(context) + } + else -> Unit + } + } + + /** Returns only after the child core is gone and the TunService instance is destroyed. */ + private suspend fun stopVpnLocked() { + val service = activeVpn ?: vpnService + activeVpn = null + if (service != null) { + withContext(Dispatchers.IO) { service.session.stop() } + if (vpnService === service) { + val destroyed = CompletableDeferred() + vpnDestroy = destroyed + withContext(Dispatchers.Main) { service.finish() } + if (withTimeoutOrNull(VPN_DESTROY_TIMEOUT_MS) { destroyed.await() } == null) { + Timber.tag(TAG).w("TunService did not reach onDestroy in time") + } + vpnDestroy = null + } + } + if (CoreProcess.isLocalCoreAlive()) { + withContext(Dispatchers.IO) { CoreProcess.killRunning() } + } + } + + private fun fail(mode: RunMode, message: String) { + Timber.tag(TAG).e("%s runtime failed: %s", mode, message) + RuntimeFailureRecord.save(mode, message) + publish(RuntimeOwner.None, mode, RuntimePhase.Failed, lastError = message) + } + + private fun publish( + owner: RuntimeOwner, + mode: RunMode?, + phase: RuntimePhase, + startedAt: Long? = null, + lastError: String? = null, + ) { + publish( + RuntimeState( + owner = owner, + mode = mode, + phase = phase, + startedAt = startedAt, + lastError = lastError, + ) + ) + } + + private fun publish(next: RuntimeState) { + val previous = _state.value + _state.value = next.copy(generation = previous.generation + 1) + if (previous.phase != next.phase || previous.owner != next.owner) { + Timber.tag(TAG).i("%s %s -> %s %s", previous.owner, previous.phase, next.owner, next.phase) + requestTileRefresh() + } + } + + private fun ownerOf(mode: RunMode): RuntimeOwner = + when (mode) { + RunMode.VpnService -> RuntimeOwner.VpnService + RunMode.Tun, + RunMode.Ebpf -> RuntimeOwner.RootDaemon + } + + // ---- config changes ---- + + private fun registerConfigReceiver() { + val filter = + IntentFilter().apply { + addAction(Intents.ACTION_PROFILE_CHANGED) + addAction(Intents.ACTION_OVERRIDE_CHANGED) + } + ContextCompat.registerReceiver( + context, + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val affectsRuntime = + intent?.action != Intents.ACTION_PROFILE_CHANGED || + intent.getBooleanExtra(Intents.EXTRA_AFFECTS_RUNTIME, true) + if (affectsRuntime) scheduleReload(SystemClock.elapsedRealtime()) + } + }, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } + + /** Main thread. Bursts of changes collapse into one reload. */ + private fun scheduleReload(changedAt: Long) { + if (!_state.value.active) return + reloadJob?.cancel() + reloadJob = + scope.launch { + delay(RELOAD_DEBOUNCE_MS) + runCatching { runOp { reloadLocked(changedAt) } } + .onFailure { error -> Timber.tag(TAG).w(error, "Config reload failed") } + } + } + + /** The QS tile only re-reads state while listening; ask the system to bind it now. */ + private fun requestTileRefresh() { + runCatching { + TileService.requestListeningState( + context, + ComponentName(context, ProxyTileService::class.java), + ) + } + } +} diff --git a/runtime/service/src/runtime/service/RuntimeFailureRecord.kt b/runtime/service/src/runtime/service/RuntimeFailureRecord.kt new file mode 100644 index 000000000..ff67b0f67 --- /dev/null +++ b/runtime/service/src/runtime/service/RuntimeFailureRecord.kt @@ -0,0 +1,77 @@ +/* + * This file is part of YumeBox. + * + * YumeBox is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * Copyright (c) YumeYucca 2025 - Present + * + */ + +package com.github.yumeyucca.yumebox.runtime.service + +import com.github.yumeyucca.yumebox.core.model.RunMode +import com.github.yumeyucca.yumebox.core.util.enumByNameOrNull +import com.github.yumeyucca.yumebox.runtime.api.RuntimePhase +import com.tencent.mmkv.MMKV + +/** + * The last startup failure, kept for ten minutes so a restarted app can still say why the core + * is down. Only a Failed record is meaningful; anything else found under these keys is a + * leftover of the old persisted phase slot and is dropped on read. + */ +internal object RuntimeFailureRecord { + data class Failure(val mode: RunMode, val error: String) + + fun load(): Failure? { + val cache = cache() + LEGACY_KEYS.forEach(cache::removeValueForKey) + val phase = cache.decodeString(KEY_PHASE) + val mode = enumByNameOrNull(cache.decodeString(KEY_MODE)) + val error = cache.decodeString(KEY_LAST_ERROR)?.takeIf(String::isNotBlank) + val age = System.currentTimeMillis() - cache.decodeLong(KEY_AT, 0L) + if (phase == RuntimePhase.Failed.name && mode != null && error != null && age in 0..RETENTION_MS) { + return Failure(mode, error) + } + clear() + return null + } + + fun save(mode: RunMode, error: String) { + cache().apply { + encode(KEY_MODE, mode.name) + encode(KEY_PHASE, RuntimePhase.Failed.name) + encode(KEY_AT, System.currentTimeMillis()) + encode(KEY_LAST_ERROR, error) + } + } + + fun clear() { + cache().apply { + removeValueForKey(KEY_MODE) + removeValueForKey(KEY_PHASE) + removeValueForKey(KEY_AT) + removeValueForKey(KEY_LAST_ERROR) + } + } + + private fun cache(): MMKV = MMKV.mmkvWithID(SERVICE_CACHE_ID, MMKV.MULTI_PROCESS_MODE) + + private const val SERVICE_CACHE_ID = "service_cache" + private const val KEY_MODE = "local_runtime_mode" + private const val KEY_PHASE = "local_runtime_phase" + private const val KEY_AT = "local_runtime_started_at" + private const val KEY_LAST_ERROR = "local_runtime_last_error" + private val LEGACY_KEYS = listOf("local_runtime_session", "local_tun_starting") + private const val RETENTION_MS = 10 * 60_000L +} diff --git a/runtime/service/src/runtime/service/RuntimeForegroundController.kt b/runtime/service/src/runtime/service/RuntimeForegroundController.kt deleted file mode 100644 index c0708b96a..000000000 --- a/runtime/service/src/runtime/service/RuntimeForegroundController.kt +++ /dev/null @@ -1,423 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.service - -import android.app.Service -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import androidx.core.app.ServiceCompat -import androidx.core.content.ContextCompat -import com.github.yumeyucca.yumebox.core.model.LogMessage -import com.github.yumeyucca.yumebox.data.model.RunMode -import com.github.yumeyucca.yumebox.data.store.RemoteControllerStore -import com.github.yumeyucca.yumebox.runtime.api.Intents -import com.github.yumeyucca.yumebox.runtime.api.RuntimeSnapshot -import com.github.yumeyucca.yumebox.runtime.service.config.ServiceStore -import com.github.yumeyucca.yumebox.runtime.service.log.RuntimeLog -import com.github.yumeyucca.yumebox.runtime.service.notification.ServiceNotificationManager -import com.github.yumeyucca.yumebox.runtime.service.session.RuntimeHost -import com.github.yumeyucca.yumebox.runtime.service.session.RuntimeSpec -import com.github.yumeyucca.yumebox.runtime.service.session.RuntimeTransport -import com.github.yumeyucca.yumebox.runtime.service.session.SessionRuntime -import com.github.yumeyucca.yumebox.runtime.service.util.sendProfileLoaded -import com.github.yumeyucca.yumebox.runtime.service.util.sendRuntimeStarted -import com.github.yumeyucca.yumebox.runtime.service.util.sendRuntimeStopped -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import timber.log.Timber -import java.util.* -import kotlin.concurrent.thread - -/** - * Shared lifecycle logic for the foreground runtime services ([TunService] and root host). - * - * The two services cannot share a superclass — the TUN path is mandated by Android to extend - * [android.net.VpnService] while the HTTP path extends a plain [Service] — so the identical - * onCreate/onStartCommand/onDestroy/receiver/reload behavior is delegated to this controller - * instead. The hosting service keeps only its required parent class, transport, notification - * config, startup scope, and any service-specific global init/teardown. - */ -class RuntimeForegroundController( - private val service: Service, - private val scope: CoroutineScope, - private val mode: RunMode, - private val label: String, - private val notificationConfig: ServiceNotificationManager.Config, - private val logSource: RuntimeLog.Source, - private val createTransport: () -> RuntimeTransport, - private val createSpec: () -> RuntimeSpec, -) { - private var reason: String? = null - private val notificationManager by lazy { - ServiceNotificationManager(service, notificationConfig) - } - private val runtimeLog by lazy { RuntimeLog.writer(service, logSource) } - private var notificationJob: Job? = null - private var runtime: SessionRuntime? = null - private var reloadJob: Job? = null - @Volatile private var stopRequested = false - - /** Write token for the persisted phase slot; stale writers are dropped by StatusProvider. */ - private var sessionToken: String = "" - - /** Most recent startId delivered to onStartCommand; pins stopSelf to what we've seen. */ - @Volatile private var lastStartId = -1 - - /** startId observed when the stop was requested; a later command means a raced start. */ - @Volatile private var stopCommandStartId = -1 - - /** A stale session asks this instance to recreate itself only after onDestroy releases it. */ - @Volatile private var restartAfterStop = false - - /** Once destroyed, async stop work must not touch the (possibly replacement) service. */ - @Volatile private var destroyed = false - - /** - * Set when onCreate sees an attached remote controller. The system still delivers onStartCommand - * after that, and this instance must not start a runtime from there. - */ - private var remoteStartRejected = false - - private val runtimeEventsReceiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action ?: return) { - Intents.ACTION_PROFILE_CHANGED -> { - if (intent.getBooleanExtra(Intents.EXTRA_AFFECTS_RUNTIME, true)) { - scheduleReload() - } - } - - Intents.ACTION_OVERRIDE_CHANGED -> scheduleReload() - - Intents.ACTION_RUNTIME_REQUEST_STOP -> { - val targetMode = intent.getStringExtra(Intents.EXTRA_RUNTIME_MODE) - if (targetMode == null || targetMode == mode.name) { - requestStop( - stopReason = intent.getStringExtra(Intents.EXTRA_STOP_REASON), - restart = intent.getBooleanExtra(Intents.EXTRA_RESTART, false), - ) - } - } - } - } - } - - fun onCreate() { - // Mark this runtime service alive for StatusProvider's in-process liveness check (both - // services share this process). Set first so it holds even if the start below fails. - StatusProvider.setServiceAlive(mode, true) - runCatching { - runtimeLog.i(RuntimeLog.Type.Service, "onCreate begin mode=${mode.name}") - - notificationManager.createChannel() - service.startForeground( - notificationConfig.notificationId, - notificationManager.createInitialNotification(), - ) - runtimeLog.i(RuntimeLog.Type.Service, "startForeground done") - if (RemoteControllerStore.isActive()) { - remoteStartRejected = true - StatusProvider.setServiceAlive(mode, false) - StatusProvider.markRuntimeIdle(mode) - runtimeLog.i(RuntimeLog.Type.Service, "skip: remote controller active") - service.stopSelf() - return@runCatching - } - - StatusProvider.clearLegacyStateFiles() - sessionToken = StatusProvider.adoptOrBeginRuntimeSession(mode) - runtime = - SessionRuntime( - host = - object : RuntimeHost { - override val context = service - override val mode: RunMode = this@RuntimeForegroundController.mode - - override fun onStarting(spec: RuntimeSpec) = Unit - - override fun onStarted(spec: RuntimeSpec) { - StatusProvider.markRuntimeRunning( - this@RuntimeForegroundController.mode, - sessionToken, - ) - service.sendRuntimeStarted() - } - - override fun onStopped(reason: String?) { - this@RuntimeForegroundController.reason = reason - StatusProvider.markRuntimeIdle( - this@RuntimeForegroundController.mode, - sessionToken, - ) - service.sendRuntimeStopped(reason) - } - - override fun onProfileLoaded(profileUuid: String) { - service.sendProfileLoaded(UUID.fromString(profileUuid)) - } - - override fun restoreActiveProfile( - profileUuid: String, - profileName: String, - ) { - ServiceStore().activeProfile = UUID.fromString(profileUuid) - StatusProvider.currentProfile = profileName - } - - override fun onSnapshotChanged(snapshot: RuntimeSnapshot) = Unit - - override fun onLogReady(ready: Boolean) = Unit - - override fun onLogItem(log: LogMessage) = Unit - - override fun reportFailure(error: String) { - reason = error - runtimeLog.e( - RuntimeLog.Type.Session, - "runtime reported failure: $error", - ) - markFailed(error) - service.sendRuntimeStopped(error) - Timber.e("$label runtime failed: $error") - service.stopSelf() - } - }, - transport = createTransport(), - scope = scope, - ) - - registerRuntimeReceiver() - runtimeLog.i(RuntimeLog.Type.Service, "receiver registered") - scope.launch { - runCatching { - runtimeLog.i(RuntimeLog.Type.Spec, "create begin") - val spec = createSpec() - runtimeLog.i( - RuntimeLog.Type.Spec, - "create done profile=${spec.profileUuid} " + - "overrides=${spec.overrideSpecs.size}", - ) - val result = runtime!!.start(spec) - check(result.success) { - result.error ?: "${label.lowercase()} runtime start failed" - } - } - .onFailure { error -> failStartup(startFailureMessage(error), error) } - } - } - .onFailure { error -> failStartup(startFailureMessage(error), error) } - } - - private fun startFailureMessage(error: Throwable): String = - error.message?.takeIf(String::isNotBlank) ?: "${label.lowercase()} runtime start failed" - - /** [error] carries the cause chain the persisted status message alone would lose. */ - private fun failStartup(message: String, error: Throwable? = null) { - reason = message - runtimeLog.e(RuntimeLog.Type.Service, "startup failed: $message", error) - markFailed(message) - service.sendRuntimeStopped(message) - service.stopSelf() - } - - private fun markFailed(message: String) { - // The token is empty only when onCreate failed before the session was claimed; the - // slot then still holds the launcher's Starting record, which the force write clears - // (a lingering Starting would read as "killed while starting" and confuse recovery). - if (sessionToken.isNotEmpty()) { - StatusProvider.markRuntimeFailed(mode, sessionToken, message) - } else { - StatusProvider.markRuntimeFailed(mode, message) - } - } - - fun onStartCommand(startId: Int): Int { - lastStartId = startId - if (remoteStartRejected) { - if (!RemoteControllerStore.isActive()) restartAfterStop = true - service.stopSelf(startId) - return Service.START_NOT_STICKY - } - if (notificationJob?.isActive != true) { - notificationJob = notificationManager.startTrafficUpdate(scope) - } - // The launcher marks Starting unconditionally before every start request; on a - // re-entrant command against an already-running session this is the only place - // that can flip the persisted phase back, otherwise it is stuck at Starting. - if (runtime?.snapshot()?.running == true) { - StatusProvider.markRuntimeRunning(mode, sessionToken) - } - // Let Android recreate a killed started service with its original command instead of - // relying on an app-managed polling alarm. - return Service.START_REDELIVER_INTENT - } - - fun onVpnRevoked() { - requestStop("VPN connection revoked by system") - } - - fun onDestroy() { - destroyed = true - StatusProvider.setServiceAlive(mode, false) - runCatching { service.unregisterReceiver(runtimeEventsReceiver) } - reloadJob?.cancel() - reloadJob = null - notificationJob?.cancel() - notificationJob = null - stopForegroundService() - - runtime?.let { rt -> - rt.requestStop(reason) - if (!stopRequested) { - // Teardown contends with an in-flight native compile on the session lock; - // waiting for it here would block the main thread past the ANR window. - thread(name = "session-runtime-destroy") { rt.destroy() } - } - } - - // Token-guarded: a no-op when the launcher already claimed the slot for a - // replacement session, and when this session recorded a Failed phase (the failure - // must stay readable after the service is gone). - StatusProvider.markRuntimeIdle(mode, sessionToken) - service.sendRuntimeStopped(reason) - runtimeLog.i(RuntimeLog.Type.Service, "destroyed reason=${reason ?: "normal stop"}") - Timber.i("${service.javaClass.simpleName} destroyed: ${reason ?: "successfully"}") - - // A start command can land on a dying instance, and a stale session may explicitly - // request a handoff. Both cases must wait until this instance releases its token. - if ( - !RemoteControllerStore.isActive() && - (restartAfterStop || (stopRequested && lastStartId != stopCommandStartId)) - ) { - runtimeLog.i(RuntimeLog.Type.Service, "relaunching after stop") - runCatching { service.startForegroundService(Intent(service, service.javaClass)) } - .onFailure { error -> - runtimeLog.e(RuntimeLog.Type.Service, "relaunch failed", error) - } - } - } - - private fun stopForegroundService() { - // Silence the traffic updater first so a late notify() can't re-post the ongoing - // notification - // after we remove it, then drop the foreground notification. - notificationManager.release() - ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE) - } - - fun onTrimMemory() { - // The core owns its heap in its own process now; nothing to GC in-process. - } - - private fun requestStop(stopReason: String?, restart: Boolean = false) { - if (stopRequested) return - stopRequested = true - restartAfterStop = restart - stopCommandStartId = lastStartId - reason = stopReason - reloadJob?.cancel() - reloadJob = null - StatusProvider.markRuntimeStopping(mode, sessionToken) - notificationJob?.cancel() - notificationJob = null - // The session thread below performs the data-plane stop. It sends the VPN child SIGTERM - // first so mihomo can persist selector state; AndroidRuntimeLauncher retains a timeout - // based SIGKILL fallback if the service becomes stuck. - stopForegroundService() - val initialStopStartId = lastStartId - service.stopSelf(initialStopStartId) - thread(name = "session-runtime-stop") { - val stopResult = runtime?.stop(reason) - if (stopResult?.success == false) { - val error = stopResult.error ?: "${label.lowercase()} runtime stop failed" - this@RuntimeForegroundController.reason = error - markFailed(error) - service.sendRuntimeStopped(error) - Timber.e("$label runtime stop failed: $error") - } - // Once destroyed, stopSelf/stopForeground resolve through the service token and - // can put down a relaunched replacement instance instead of this dead one. - // stopSelf is pinned to a startId (never the unconditional overload) so AMS - // refuses the stop when a start command raced past the pinned id; retrying with - // the newer id still stops this instance, and onDestroy then relaunches a fresh - // one for the raced command instead of silently swallowing it. - if (!destroyed) { - var pinnedStartId = initialStopStartId - while (!destroyed && pinnedStartId != lastStartId) { - pinnedStartId = lastStartId - service.stopSelf(pinnedStartId) - } - } - } - } - - private fun registerRuntimeReceiver() { - val filter = - IntentFilter().apply { - addAction(Intents.ACTION_PROFILE_CHANGED) - addAction(Intents.ACTION_OVERRIDE_CHANGED) - addAction(Intents.ACTION_RUNTIME_REQUEST_STOP) - } - ContextCompat.registerReceiver( - service, - runtimeEventsReceiver, - filter, - ContextCompat.RECEIVER_NOT_EXPORTED, - ) - } - - private fun scheduleReload() { - reloadJob?.cancel() - reloadJob = scope.launch { - runtimeLog.i(RuntimeLog.Type.Reload, "spec create begin") - val spec = runCatching { - createSpec() - } - .getOrElse { error -> - reason = error.message - runtimeLog.e(RuntimeLog.Type.Reload, "spec refresh failed", error) - Timber.w("$label runtime spec refresh failed: ${error.message}") - return@launch - } - runtimeLog.i( - RuntimeLog.Type.Reload, - "spec create done profile=${spec.profileUuid} " + - "overrides=${spec.overrideSpecs.size}", - ) - - val result = runtime!!.reload(spec) - if (result.success) { - runtimeLog.i(RuntimeLog.Type.Reload, "success profile=${spec.profileUuid}") - } else { - reason = result.error - runtimeLog.e( - RuntimeLog.Type.Reload, - "failed: ${result.error ?: "${label.lowercase()} runtime reload failed"}", - ) - Timber.w("$label runtime reload failed: ${result.error}") - } - } - } -} diff --git a/runtime/service/src/runtime/service/StatusProvider.kt b/runtime/service/src/runtime/service/StatusProvider.kt index f9e3f17f2..5276f66e3 100644 --- a/runtime/service/src/runtime/service/StatusProvider.kt +++ b/runtime/service/src/runtime/service/StatusProvider.kt @@ -18,32 +18,31 @@ * */ -@file:Suppress("RedundantNullableReturnType", "UnusedSymbol") - package com.github.yumeyucca.yumebox.runtime.service - +import android.app.Application import android.content.ContentProvider import android.content.ContentValues import android.database.Cursor import android.net.Uri import android.os.Bundle -import com.github.yumeyucca.yumebox.core.Global -import com.github.yumeyucca.yumebox.core.util.enumByNameOrNull -import com.github.yumeyucca.yumebox.data.model.RunMode import com.github.yumeyucca.yumebox.runtime.api.RuntimePhase import com.github.yumeyucca.yumebox.runtime.api.initializeServiceGlobal -import com.github.yumeyucca.yumebox.runtime.service.core.CoreProcess import com.tencent.mmkv.MMKV -import java.util.* +/** + * Runs before `Application.onCreate`: initializes MMKV and pre-creates the stores the root daemon + * shares with the app. Runtime state itself lives in [RuntimeCoordinator]. + */ class StatusProvider : ContentProvider() { override fun call(method: String, arg: String?, extras: Bundle?): Bundle? = when (method) { - METHOD_CURRENT_PROFILE -> { - syncCachedRuntimeState() - if (serviceRunning) Bundle().apply { putString("name", currentProfile) } else null - } + METHOD_CURRENT_PROFILE -> + if (RuntimeCoordinator.state.value.phase == RuntimePhase.Running) { + Bundle().apply { putString("name", currentProfile) } + } else { + null + } else -> super.call(method, arg, extras) } @@ -73,17 +72,16 @@ class StatusProvider : ContentProvider() { override fun onCreate(): Boolean { runCatching { - val app = context?.applicationContext as? android.app.Application ?: return@runCatching + val app = context?.applicationContext as? Application ?: return@runCatching initializeServiceGlobal(app) - // MMKV 必须在使用前初始化,ContentProvider 在 Application.onCreate 之前执行 MMKV.initialize(app) - // Pre-create every store the root process touches: a file first created by the - // root uid would be root-owned and unreadable/unwritable for the app afterwards. + // A store first created by the root uid would be root-owned and unusable by the app. listOf("service", "network_settings", "profiles", "root_tun_state").forEach { id -> runCatching { MMKV.mmkvWithID(id, MMKV.MULTI_PROCESS_MODE) } } - runCatching { serviceCache().removeValueForKey(KEY_TUN_STARTING_LEGACY) } - syncCachedRuntimeState() + legacyRuntimeFiles.forEach { name -> + runCatching { app.filesDir.resolve(name).delete() } + } } return true } @@ -93,371 +91,8 @@ class StatusProvider : ContentProvider() { private val legacyRuntimeFiles = listOf("service_running.lock", "service_autostart.lock", "service_running_mode.txt") - private const val SERVICE_CACHE_ID = "service_cache" - private const val STARTING_GRACE_MS = 60_000L - private const val STARTING_DISPATCH_GRACE_MS = 2_000L - private const val FAILED_RETENTION_MS = 10 * 60_000L - private const val KEY_TUN_STARTING_LEGACY = "local_tun_starting" - private const val KEY_RUNTIME_MODE = "local_runtime_mode" - private const val KEY_RUNTIME_PHASE = "local_runtime_phase" - private const val KEY_RUNTIME_STARTED_AT = "local_runtime_started_at" - private const val KEY_RUNTIME_SESSION = "local_runtime_session" - private const val KEY_RUNTIME_LAST_ERROR = "local_runtime_last_error" - - @Volatile - var serviceRunning: Boolean = false - private set - - @Volatile - var runningMode: RunMode? = null - private set - - @Volatile - var localRuntimePhase: RuntimePhase = RuntimePhase.Idle - private set @Volatile var currentProfile: String? = null - - // ---- session-token lifecycle (VpnService runtime) ---- - // The phase store has a single mode slot shared by every writer. VpnService writers carry a - // token so a late write from an outgoing instance can't stomp its replacement (stale-token - // writes are dropped); the root daemon uses the token-less force writes below (own single - // writer). - - /** Claims the phase slot for a new local session and returns its write token. */ - @Synchronized - fun beginRuntimeSession(mode: RunMode): String { - val token = UUID.randomUUID().toString() - persistRuntimeState( - mode = mode, - phase = RuntimePhase.Starting, - startedAt = System.currentTimeMillis(), - token = token, - ) - updateInMemoryRuntimeState(mode, RuntimePhase.Starting) - return token - } - - /** Adopts the token the launcher created for this start, or claims a fresh session. */ - @Synchronized - fun adoptOrBeginRuntimeSession(mode: RunMode): String { - val (persistedMode, persistedPhase) = readPersistedRuntimeState() - val token = currentSessionToken() - if (persistedMode == mode && persistedPhase == RuntimePhase.Starting && token != null) { - updateInMemoryRuntimeState(mode, RuntimePhase.Starting) - return token - } - return beginRuntimeSession(mode) - } - - @Synchronized - fun markRuntimeRunning(mode: RunMode, token: String) { - markRuntimePhaseOwned(mode, RuntimePhase.Running, token) - } - - @Synchronized - fun markRuntimeStopping(mode: RunMode, token: String) { - markRuntimePhaseOwned(mode, RuntimePhase.Stopping, token) - } - - @Synchronized - fun markRuntimeFailed(mode: RunMode, token: String, error: String?) { - markRuntimePhaseOwned(mode, RuntimePhase.Failed, token, error) - } - - @Synchronized - fun markRuntimeIdle(mode: RunMode, token: String) { - markRuntimePhaseOwned(mode, RuntimePhase.Idle, token) - } - - fun queryRuntimeLastError(mode: RunMode): String? { - val (persistedMode, persistedPhase) = readPersistedRuntimeState() - if (persistedMode != mode || persistedPhase != RuntimePhase.Failed) return null - return serviceCache().decodeString(KEY_RUNTIME_LAST_ERROR)?.takeIf { it.isNotBlank() } - } - - fun isRuntimeStartingWithinGrace(mode: RunMode): Boolean { - val (persistedMode, persistedPhase) = readPersistedRuntimeState() - // The liveness check keeps a Starting record orphaned by a process death from - // blocking the user's next start for the rest of the grace window. - return persistedMode == mode && - persistedPhase == RuntimePhase.Starting && - isStartingWithinGrace() && - isLocalRuntimeServiceAlive(mode) - } - - // ---- token-less force writes (root daemon, explicit user stops, cleanup) ---- - - @Synchronized - fun markRuntimeStarting(mode: RunMode) { - markRuntimePhase(mode, RuntimePhase.Starting) - } - - @Synchronized - fun markRuntimeRunning(mode: RunMode) { - markRuntimePhase(mode, RuntimePhase.Running) - } - - @Synchronized - fun markRuntimeStopping(mode: RunMode) { - markRuntimePhase(mode, RuntimePhase.Stopping) - } - - @Synchronized - fun markRuntimeFailed(mode: RunMode, error: String? = null) { - markRuntimePhase(mode, RuntimePhase.Failed, error) - } - - @Synchronized - fun markRuntimeIdle(mode: RunMode) { - markRuntimePhase(mode, RuntimePhase.Idle) - } - - // Failed is a terminal record kept for diagnosis, not an engaged runtime. - fun isRuntimeActive(mode: RunMode): Boolean = queryRuntimePhase(mode).isActiveOrStopping - - fun queryRuntimePhase(mode: RunMode): RuntimePhase { - reconcilePersistedRuntimeState() - val (persistedMode, persistedPhase) = readPersistedRuntimeState() - updateInMemoryRuntimeState(persistedMode, persistedPhase) - return if (persistedMode == mode) persistedPhase else RuntimePhase.Idle - } - - @Synchronized - fun queryRuntimeStartedAt(mode: RunMode): Long? { - reconcilePersistedRuntimeState() - val (persistedMode, persistedPhase) = readPersistedRuntimeState() - var startedAt = readPersistedRuntimeStartedAt() - if (persistedMode == mode && persistedPhase.isNotIdle && startedAt == null) { - startedAt = System.currentTimeMillis() - // Read-repair only; keep the session token and error intact. - persistRuntimeState( - mode = persistedMode, - phase = persistedPhase, - startedAt = startedAt, - token = currentSessionToken(), - error = serviceCache().decodeString(KEY_RUNTIME_LAST_ERROR), - ) - } - updateInMemoryRuntimeState(persistedMode, persistedPhase) - return startedAt.takeIf { persistedMode == mode && persistedPhase.isNotIdle } - } - - @Synchronized - fun reconcilePersistedRuntimeState() { - val (persistedMode, persistedPhase) = readPersistedRuntimeState() - if (persistedMode == null || !persistedPhase.isNotIdle) { - updateInMemoryRuntimeState(persistedMode, persistedPhase) - return - } - - // Failed is a terminal record (service expected dead), so it must not be liveness-reset - // — - // that erased failures before they were read. It decays after retention or on the next - // session. - if (persistedPhase == RuntimePhase.Failed) { - val failedAt = readPersistedRuntimeStartedAt() - if ( - failedAt == null || - System.currentTimeMillis() - failedAt !in 0..FAILED_RETENTION_MS - ) { - persistRuntimeState(mode = null, phase = RuntimePhase.Idle) - updateInMemoryRuntimeState(mode = null, phase = RuntimePhase.Idle) - } else { - updateInMemoryRuntimeState(persistedMode, persistedPhase) - } - return - } - - // Allow only the short startForegroundService dispatch window without a lifecycle - // signal. Beyond it, Starting must prove service/core liveness like Running does. - if ( - persistedPhase == RuntimePhase.Starting && - startingElapsedMs()?.let { it <= STARTING_DISPATCH_GRACE_MS } == true - ) { - updateInMemoryRuntimeState(persistedMode, persistedPhase) - return - } - - if (isLocalRuntimeServiceAlive(persistedMode)) { - updateInMemoryRuntimeState(persistedMode, persistedPhase) - return - } - - persistRuntimeState(mode = null, phase = RuntimePhase.Idle) - updateInMemoryRuntimeState(mode = null, phase = RuntimePhase.Idle) - currentProfile = null - } - - // VpnService is in-process, so a flag driven from its lifecycle is an O(1) liveness signal - // (it replaced an ActivityManager.getRunningServices binder scan that stalled the UI). The - // root - // daemon is out-of-process and survives app death, so its liveness is a pid probe, not a - // flag. - @Volatile private var vpnServiceAlive = false - - /** Set from [RuntimeForegroundController.onCreate]/onDestroy for the VpnService. */ - fun setServiceAlive(mode: RunMode, alive: Boolean) { - if (mode == RunMode.VpnService) vpnServiceAlive = alive - } - - fun isLocalRuntimeServiceAlive(mode: RunMode): Boolean = - when (mode) { - RunMode.VpnService -> vpnServiceAlive - RunMode.Tun -> CoreProcess.isRootDaemonAlive() - RunMode.Ebpf -> CoreProcess.isRootDaemonAlive() - } - - fun clearLegacyStateFiles() { - val filesDir = Global.application.filesDir - legacyRuntimeFiles.forEach { name -> runCatching { filesDir.resolve(name).delete() } } - } - - private fun serviceCache(): MMKV = - MMKV.mmkvWithID(SERVICE_CACHE_ID, MMKV.MULTI_PROCESS_MODE) - - private fun markRuntimePhaseOwned( - mode: RunMode, - phase: RuntimePhase, - token: String, - error: String? = null, - ) { - if (token.isEmpty() || currentSessionToken() != token) return - when (phase) { - RuntimePhase.Idle -> { - persistRuntimeState(mode = null, phase = RuntimePhase.Idle) - updateInMemoryRuntimeState(mode = null, phase = RuntimePhase.Idle) - } - - RuntimePhase.Failed -> { - // Terminal record: keep the failure readable (mode + error + timestamp), - // drop the token so any later stale write from this session is a no-op. - persistRuntimeState( - mode = mode, - phase = RuntimePhase.Failed, - startedAt = System.currentTimeMillis(), - error = error, - ) - updateInMemoryRuntimeState(mode, RuntimePhase.Failed) - } - - else -> { - persistRuntimeState( - mode = mode, - phase = phase, - startedAt = resolveRuntimeStartedAt(mode = mode, phase = phase), - token = token, - ) - updateInMemoryRuntimeState(mode, phase) - } - } - } - - private fun markRuntimePhase(mode: RunMode, phase: RuntimePhase, error: String? = null) { - if (phase == RuntimePhase.Idle) { - // Force-idle only releases the slot the caller owns; another mode's live - // session (e.g. the root daemon racing a VpnService start) must not be wiped. - val (persistedMode, _) = readPersistedRuntimeState() - if (persistedMode != null && persistedMode != mode) return - } - val activeMode = mode.takeIf { phase.isNotIdle } - val startedAt = resolveRuntimeStartedAt(mode = activeMode, phase = phase) - persistRuntimeState( - mode = activeMode, - phase = phase, - startedAt = startedAt, - error = error.takeIf { phase == RuntimePhase.Failed }, - ) - updateInMemoryRuntimeState(mode = activeMode, phase = phase) - } - - private fun currentSessionToken(): String? = - serviceCache().decodeString(KEY_RUNTIME_SESSION)?.takeIf { it.isNotEmpty() } - - private fun persistRuntimeState( - mode: RunMode?, - phase: RuntimePhase, - startedAt: Long? = null, - token: String? = null, - error: String? = null, - ) { - val cache = serviceCache() - if (phase == RuntimePhase.Idle || mode == null) { - cache.removeValueForKey(KEY_RUNTIME_MODE) - cache.removeValueForKey(KEY_RUNTIME_PHASE) - cache.removeValueForKey(KEY_RUNTIME_STARTED_AT) - cache.removeValueForKey(KEY_RUNTIME_SESSION) - cache.removeValueForKey(KEY_RUNTIME_LAST_ERROR) - return - } - cache.encode(KEY_RUNTIME_MODE, mode.name) - cache.encode(KEY_RUNTIME_PHASE, phase.name) - if (startedAt != null) { - cache.encode(KEY_RUNTIME_STARTED_AT, startedAt) - } else { - cache.removeValueForKey(KEY_RUNTIME_STARTED_AT) - } - if (token != null) { - cache.encode(KEY_RUNTIME_SESSION, token) - } else { - cache.removeValueForKey(KEY_RUNTIME_SESSION) - } - if (error != null) { - cache.encode(KEY_RUNTIME_LAST_ERROR, error) - } else { - cache.removeValueForKey(KEY_RUNTIME_LAST_ERROR) - } - } - - private fun readPersistedRuntimeState(): Pair { - val cache = serviceCache() - val phase = - enumByNameOrNull(cache.decodeString(KEY_RUNTIME_PHASE)) - ?: RuntimePhase.Idle - val mode = - enumByNameOrNull(cache.decodeString(KEY_RUNTIME_MODE))?.takeIf { - phase.isNotIdle - } - return mode to phase - } - - private fun readPersistedRuntimeStartedAt(): Long? = - serviceCache().decodeLong(KEY_RUNTIME_STARTED_AT, 0L).takeIf { - it > 0L - } - - private fun isStartingWithinGrace(): Boolean = - startingElapsedMs()?.let { it in 0..STARTING_GRACE_MS } == true - - private fun startingElapsedMs(): Long? { - val startedAt = readPersistedRuntimeStartedAt() ?: return null - return (System.currentTimeMillis() - startedAt).takeIf { it >= 0L } - } - - private fun resolveRuntimeStartedAt(mode: RunMode?, phase: RuntimePhase): Long? { - if (!phase.isNotIdle || mode == null) { - return null - } - if (phase == RuntimePhase.Starting) { - return System.currentTimeMillis() - } - - val (persistedMode, persistedPhase) = readPersistedRuntimeState() - val persistedStartedAt = readPersistedRuntimeStartedAt() - return persistedStartedAt.takeIf { persistedMode == mode && persistedPhase.isNotIdle } - ?: System.currentTimeMillis() - } - - private fun updateInMemoryRuntimeState(mode: RunMode?, phase: RuntimePhase) { - runningMode = mode.takeIf { phase == RuntimePhase.Running } - localRuntimePhase = phase - serviceRunning = phase == RuntimePhase.Running - } - - private fun syncCachedRuntimeState() { - reconcilePersistedRuntimeState() - val (mode, phase) = readPersistedRuntimeState() - updateInMemoryRuntimeState(mode, phase) - } } } + diff --git a/runtime/service/src/runtime/service/TunService.kt b/runtime/service/src/runtime/service/TunService.kt index 5d38c9ecb..dfe42a114 100644 --- a/runtime/service/src/runtime/service/TunService.kt +++ b/runtime/service/src/runtime/service/TunService.kt @@ -22,54 +22,67 @@ package com.github.yumeyucca.yumebox.runtime.service import android.content.Intent import android.net.VpnService -import com.github.yumeyucca.yumebox.data.model.RunMode +import androidx.core.app.ServiceCompat import com.github.yumeyucca.yumebox.runtime.api.appContextOrSelf import com.github.yumeyucca.yumebox.runtime.api.initializeServiceGlobal -import com.github.yumeyucca.yumebox.runtime.service.log.RuntimeLog import com.github.yumeyucca.yumebox.runtime.service.notification.ServiceNotificationManager -import com.github.yumeyucca.yumebox.runtime.service.session.SessionRuntimeSpecFactory -import com.github.yumeyucca.yumebox.runtime.service.session.VpnTunTransport +import com.github.yumeyucca.yumebox.runtime.service.session.VpnSession import com.github.yumeyucca.yumebox.runtime.service.util.cancelAndJoinBlocking import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +/** + * Android host of the VPN core. It only owns what Android requires of a VpnService (the + * foreground notification, revoke and destroy callbacks); [RuntimeCoordinator] decides when the + * [session] starts, reloads and stops. + */ class TunService : VpnService(), CoroutineScope by CoroutineScope(Dispatchers.Default) { - private val controller = - RuntimeForegroundController( - service = this, - scope = this, - mode = RunMode.VpnService, - label = "Tun", - notificationConfig = ServiceNotificationManager.vpnConfig, - logSource = RuntimeLog.Source.LocalTun, - createTransport = { VpnTunTransport(this) }, - createSpec = { SessionRuntimeSpecFactory(appContextOrSelf).createVpnSpec() }, - ) + internal val session by lazy { VpnSession(this) } + private val notificationManager by lazy { + ServiceNotificationManager(this, ServiceNotificationManager.vpnConfig) + } + private var notificationJob: Job? = null override fun onCreate() { super.onCreate() initializeServiceGlobal(appContextOrSelf) - controller.onCreate() + notificationManager.createChannel() + startForeground( + ServiceNotificationManager.vpnConfig.notificationId, + notificationManager.createInitialNotification(), + ) } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = - controller.onStartCommand(startId) + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (notificationJob?.isActive != true) { + notificationJob = notificationManager.startTrafficUpdate(this) + } + RuntimeCoordinator.onVpnServiceStarted(this) + // A killed service is recreated with its command; the coordinator adopts it as a start. + return START_REDELIVER_INTENT + } - override fun onDestroy() { - // RuntimeForegroundController delegates the normal stop to SessionRuntime, whose VPN - // transport gives mihomo time to flush selector state before falling back to SIGKILL. - controller.onDestroy() - super.onDestroy() - cancelAndJoinBlocking() + /** Drops the notification and ends this instance; the session is already stopped. */ + internal fun finish() { + notificationJob?.cancel() + notificationJob = null + notificationManager.release() + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf() } override fun onRevoke() { - controller.onVpnRevoked() + RuntimeCoordinator.onVpnRevoked(this) super.onRevoke() } - override fun onTrimMemory(level: Int) { - super.onTrimMemory(level) - controller.onTrimMemory() + override fun onDestroy() { + notificationJob?.cancel() + notificationJob = null + notificationManager.release() + RuntimeCoordinator.onVpnServiceDestroyed(this) + super.onDestroy() + cancelAndJoinBlocking() } } diff --git a/runtime/service/src/runtime/service/WifiAutomationService.kt b/runtime/service/src/runtime/service/WifiAutomationService.kt index fb6350de5..fc1d2987e 100644 --- a/runtime/service/src/runtime/service/WifiAutomationService.kt +++ b/runtime/service/src/runtime/service/WifiAutomationService.kt @@ -16,7 +16,6 @@ import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.net.VpnService -import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat @@ -26,12 +25,20 @@ import com.github.yumeyucca.yumebox.data.model.WifiAutomationFallbackAction import com.github.yumeyucca.yumebox.data.store.MMKVProvider import com.github.yumeyucca.yumebox.data.store.NetworkSettingsStore import com.github.yumeyucca.yumebox.data.store.RemoteControllerStore +import com.github.yumeyucca.yumebox.runtime.api.RuntimeOwner +import com.github.yumeyucca.yumebox.runtime.api.RuntimeStartSource import com.github.yumeyucca.yumebox.runtime.api.appContextOrSelf import com.github.yumeyucca.yumebox.runtime.service.profile.ProfileService -import com.github.yumeyucca.yumebox.runtime.service.session.RuntimeServiceLauncher import com.github.yumeyucca.yumebox.runtime.service.session.WifiSsidObservation import com.github.yumeyucca.yumebox.runtime.service.session.WifiSsidObserver -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import timber.log.Timber import java.util.UUID @@ -144,38 +151,34 @@ class WifiAutomationService : Service() { } } - private fun startVpnIfPossible() { - if (StatusProvider.isRuntimeActive(RunMode.VpnService)) return + private suspend fun startVpnIfPossible() { + if (RuntimeCoordinator.state.value.active) return if (VpnService.prepare(this) != null) { Timber.i("Wi-Fi automation skipped start: VPN permission missing") return } - runCatching { - RuntimeServiceLauncher.start( - this, - RunMode.VpnService, - RuntimeServiceLauncher.SOURCE_WIFI_AUTOMATION, - ) + try { + RuntimeCoordinator.start(RunMode.VpnService, RuntimeStartSource.WifiAutomation) + } catch (error: CancellationException) { + throw error + } catch (@Suppress("TooGenericExceptionCaught") error: Exception) { + Timber.w(error, "Wi-Fi automation start failed") } - .onFailure { error -> Timber.w(error, "Wi-Fi automation start failed") } } - private fun stopVpn() { - runCatching { RuntimeServiceLauncher.stop(this, RunMode.VpnService) } - .onFailure { error -> Timber.w(error, "Wi-Fi automation stop failed") } + private suspend fun stopVpn() { + if (RuntimeCoordinator.state.value.owner != RuntimeOwner.VpnService) return + RuntimeCoordinator.stop() } private fun startForegroundNotification() { - val manager = getSystemService(NotificationManager::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - manager.createNotificationChannel( - NotificationChannel( - CHANNEL_ID, - getString(R.string.wifi_automation_channel_name), - NotificationManager.IMPORTANCE_LOW, - ) + getSystemService(NotificationManager::class.java).createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + getString(R.string.wifi_automation_channel_name), + NotificationManager.IMPORTANCE_LOW, ) - } + ) val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_logo_service) @@ -183,15 +186,12 @@ class WifiAutomationService : Service() { .setContentText(getString(R.string.wifi_automation_notification_text)) .setOngoing(true) .build() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION, - ) - } else { - startForeground(NOTIFICATION_ID, notification) - } + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION, + ) } companion object { @@ -212,6 +212,5 @@ class WifiAutomationService : Service() { val appContext = context.appContextOrSelf appContext.stopService(Intent(appContext, WifiAutomationService::class.java)) } - } } diff --git a/runtime/service/src/runtime/service/controller/CoreController.kt b/runtime/service/src/runtime/service/controller/CoreController.kt index f460c8158..b2f41e482 100644 --- a/runtime/service/src/runtime/service/controller/CoreController.kt +++ b/runtime/service/src/runtime/service/controller/CoreController.kt @@ -27,7 +27,6 @@ import com.github.yumeyucca.yumebox.core.model.* import com.github.yumeyucca.yumebox.core.util.encodeTrafficValue import com.github.yumeyucca.yumebox.data.model.RemoteBackend import com.github.yumeyucca.yumebox.runtime.api.CoreApi -import com.github.yumeyucca.yumebox.runtime.api.CoreAsyncQueries import com.github.yumeyucca.yumebox.runtime.api.LogObserver import com.github.yumeyucca.yumebox.runtime.api.LogSubscription import io.ktor.client.* @@ -50,7 +49,7 @@ import java.time.Instant class CoreController( private val local: Local? = null, private val backendProvider: () -> RemoteBackend? = { null }, -) : CoreApi, CoreAsyncQueries { +) : CoreApi { /** Local controller endpoint: fixed socket path, secret read per request. */ class Local(val socketPath: String, val secret: () -> String) @@ -110,7 +109,7 @@ class CoreController( suspend fun probe(): Boolean { if (local != null) return false return try { - queryTunnelStateAsync() + queryTunnelState() true } catch (error: CancellationException) { throw error @@ -180,30 +179,22 @@ class CoreController( private inline fun jsonBody(value: T): TextContent = TextContent(json.encodeToString(value), ContentType.Application.Json) - override suspend fun queryTunnelStateAsync(): TunnelState { + /** `GET /configs`: answers as soon as the controller listens. */ + internal suspend fun queryTunnelState(): TunnelState { val raw = request(HttpMethod.Get, "configs").bodyAsText() - val configs = json.decodeFromString(raw) - return TunnelState(configs.mode) + return TunnelState(json.decodeFromString(raw).mode) } - override fun queryTunnelState(): TunnelState = - runBlocking(Dispatchers.IO) { queryTunnelStateAsync() } - - override suspend fun queryTrafficNowAsync(): Long { + override suspend fun queryTrafficNow(): Long { val sample = readTrafficSample() ?: return 0L return (encodeTrafficValue(sample.up) shl 32) or encodeTrafficValue(sample.down) } - override fun queryTrafficNow(): Long = runBlocking(Dispatchers.IO) { queryTrafficNowAsync() } - - override suspend fun queryTrafficTotalAsync(): Long { + override suspend fun queryTrafficTotal(): Long { val sample = readTrafficSample() ?: return 0L return (encodeTrafficValue(sample.upTotal) shl 32) or encodeTrafficValue(sample.downTotal) } - override fun queryTrafficTotal(): Long = - runBlocking(Dispatchers.IO) { queryTrafficTotalAsync() } - /** Reuse one stream sample for the adjacent now/total reads performed by the UI. */ private suspend fun readTrafficSample(): RawTraffic? { val now = System.nanoTime() @@ -224,12 +215,7 @@ class CoreController( } } - override suspend fun queryConnectionsAsync(): ConnectionSnapshot = fetchConnections() - - override fun queryConnections(): ConnectionSnapshot = - runBlocking(Dispatchers.IO) { queryConnectionsAsync() } - - private suspend fun fetchConnections(): ConnectionSnapshot = + override suspend fun queryConnections(): ConnectionSnapshot = client .prepareGet(buildUrl("connections", query = CONNECTIONS_QUERY)) { applyAuth() @@ -241,24 +227,12 @@ class CoreController( json.decodeFromString(line) } - override suspend fun queryAllProxyGroupsAsync(excludeNotSelectable: Boolean): List = - withGroupQueryTimeout { - val nodes = fetchProxies() - val groups = orderGroups(fetchGroups(), nodes) - groups - .filter { !excludeNotSelectable || it.type in Proxy.Type.manuallySelectable } - .map { buildGroup(it, nodes, ProxySort.Default) } - } - - override fun queryAllProxyGroups(excludeNotSelectable: Boolean): List = - runBlocking(Dispatchers.IO) { queryAllProxyGroupsAsync(excludeNotSelectable) } - - override suspend fun queryProxyGroupNamesAsync(excludeNotSelectable: Boolean): List = + override suspend fun queryAllProxyGroups(excludeNotSelectable: Boolean): List = withGroupQueryTimeout { val nodes = fetchProxies() orderGroups(fetchGroups(), nodes) .filter { !excludeNotSelectable || it.type in Proxy.Type.manuallySelectable } - .map { it.name } + .map { buildGroup(it, nodes, ProxySort.Default) } } private suspend fun withGroupQueryTimeout(block: suspend () -> T): T = @@ -268,9 +242,6 @@ class CoreController( block() } - override fun queryProxyGroupNames(excludeNotSelectable: Boolean): List = - runBlocking(Dispatchers.IO) { queryProxyGroupNamesAsync(excludeNotSelectable) } - /** Prefer GLOBAL.all order, then name. */ private fun orderGroups(groups: List, nodes: Map): List { val canonical = nodes["GLOBAL"]?.all ?: emptyList() @@ -278,7 +249,7 @@ class CoreController( return groups.sortedWith(compareBy({ indexOf[it.name] ?: Int.MAX_VALUE }, { it.name })) } - override suspend fun queryProxyGroupAsync(name: String, proxySort: ProxySort): ProxyGroup { + override suspend fun queryProxyGroup(name: String, proxySort: ProxySort): ProxyGroup { val nodes = fetchProxies() val group = nodes[name] @@ -291,9 +262,6 @@ class CoreController( return buildGroup(group, nodes, proxySort) } - override fun queryProxyGroup(name: String, proxySort: ProxySort): ProxyGroup = - runBlocking(Dispatchers.IO) { queryProxyGroupAsync(name, proxySort) } - /** * Every node the UI can render, keyed by name. * @@ -383,48 +351,30 @@ class CoreController( return ProviderSnapshot(nodes = nodes, owners = owners) } + /** Startup readiness: `/group` alone, without the `/proxies` + provider join. */ + internal suspend fun queryGroupCount(): Int = fetchGroups().size + private suspend fun fetchGroups(): List { val raw = request(HttpMethod.Get, "group").bodyAsText() return json.decodeFromString(raw).proxies } @Suppress("TooGenericExceptionCaught") - override suspend fun patchSelectorAsync(group: String, name: String): Boolean = + override suspend fun patchSelector(group: String, name: String): Boolean = try { - val response = - request( - HttpMethod.Put, - "proxies", - group, - body = SelectBody(name), - ) - response.status.isSuccess() + request(HttpMethod.Put, "proxies", group, body = SelectBody(name)).status.isSuccess() } catch (_: Throwable) { // fault barrier: remote REST call must degrade to "not selected" false } - override fun patchSelector(group: String, name: String): Boolean = - runBlocking(Dispatchers.IO) { patchSelectorAsync(group, name) } - @Suppress("TooGenericExceptionCaught") - override suspend fun closeConnectionAsync(id: String): Boolean = + override suspend fun closeConnection(id: String): Boolean = try { request(HttpMethod.Delete, "connections", id).status.isSuccess() } catch (_: Throwable) { // fault barrier: remote REST call must degrade to "not closed" false } - override fun closeConnection(id: String): Boolean = - runBlocking(Dispatchers.IO) { closeConnectionAsync(id) } - - override suspend fun closeAllConnectionsAsync() { - runCatching { request(HttpMethod.Delete, "connections") } - } - - override fun closeAllConnections() { - runBlocking(Dispatchers.IO) { closeAllConnectionsAsync() } - } - override suspend fun healthCheck(group: String): Map = runCatching { request( @@ -472,7 +422,7 @@ class CoreController( } /** Proxy + rule providers; built-in Compatible entries are omitted. */ - override suspend fun queryProvidersAsync(): ProviderList { + override suspend fun queryProviders(): ProviderList { val proxies = runCatching { fetchProviders(category = "proxies", type = Provider.Type.Proxy) } @@ -487,9 +437,6 @@ class CoreController( return ProviderList(proxies.getOrDefault(emptyList()) + rules.getOrDefault(emptyList())) } - override fun queryProviders(): ProviderList = - runBlocking(Dispatchers.IO) { queryProvidersAsync() } - private suspend fun fetchProvidersResponse(category: String): RawProvidersResponse { val raw = request(HttpMethod.Get, "providers", category).bodyAsText() return json.decodeFromString(raw) @@ -538,13 +485,10 @@ class CoreController( request(HttpMethod.Put, "providers", category, name) } - override suspend fun queryConfigurationAsync(): UiConfiguration = UiConfiguration() - - override fun queryConfiguration(): UiConfiguration = UiConfiguration() - - override suspend fun queryRulesAsync(): List = fetchRules() - - override fun queryRules(): List = runBlocking(Dispatchers.IO) { queryRulesAsync() } + override suspend fun queryRules(): List { + val raw = request(HttpMethod.Get, "rules").bodyAsText() + return json.decodeFromString(raw).rules.map { it.toRuntimeRule() } + } /** Toggle a rule, then re-fetch `/rules`. */ override suspend fun setRuleDisabled( @@ -557,16 +501,7 @@ class CoreController( "disable", body = jsonBody(mapOf(rule.index.toString() to disabled)), ) - return fetchRules() - } - - private suspend fun fetchRules(): List { - val raw = request(HttpMethod.Get, "rules").bodyAsText() - return json.decodeFromString(raw).rules.map { it.toRuntimeRule() } - } - - override fun requestStop() { - // No-op: we don't own the remote core, so there is nothing to stop. + return queryRules() } override fun subscribeLogs(observer: LogObserver): LogSubscription = logStream.subscribe(observer) diff --git a/runtime/service/src/runtime/service/core/AndroidProcessController.kt b/runtime/service/src/runtime/service/core/AndroidProcessController.kt deleted file mode 100644 index b5055389b..000000000 --- a/runtime/service/src/runtime/service/core/AndroidProcessController.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.service.core - -import android.content.Context -import com.github.yumeyucca.yumebox.core.model.RunMode -import com.github.yumeyucca.yumebox.runtime.api.CoreEndpointRef -import com.github.yumeyucca.yumebox.runtime.api.ProcessController -import com.github.yumeyucca.yumebox.runtime.api.appContextOrSelf -import com.github.yumeyucca.yumebox.runtime.service.session.RuntimeServiceLauncher - -/** Android binding of [ProcessController] over [CoreProcess] static ownership. */ -class AndroidProcessController(context: Context) : ProcessController { - private val appContext = context.appContextOrSelf - - override fun currentEndpoint(): CoreEndpointRef? = - CoreProcess.current?.let { CoreEndpointRef(sock = it.sock, secret = it.secret) } - - override fun stop() { - // Stop the service instead of killing its child directly: VpnTunTransport gives mihomo a - // bounded SIGTERM window to persist selector state before escalating to SIGKILL. - RuntimeServiceLauncher.stop(appContext) - } - - override fun stopRoot() { - CoreProcess.stopRoot() - } - - override fun isRootDaemonAlive(): Boolean = CoreProcess.isRootDaemonAlive() - - override fun rootDaemonMode(): RunMode? = CoreProcess.rootDaemonMode() - - override fun reconnectRoot(): String? = CoreProcess.reconnectRoot(appContext) -} diff --git a/runtime/service/src/runtime/service/core/CoreProcess.kt b/runtime/service/src/runtime/service/core/CoreProcess.kt index 9e41efc4e..eb6fe2103 100644 --- a/runtime/service/src/runtime/service/core/CoreProcess.kt +++ b/runtime/service/src/runtime/service/core/CoreProcess.kt @@ -31,7 +31,6 @@ import android.os.SystemClock import android.system.Os import com.github.yumeyucca.yumebox.core.bridge.Channel import com.github.yumeyucca.yumebox.core.bridge.NativeProcess -import com.github.yumeyucca.yumebox.core.model.RunMode import com.github.yumeyucca.yumebox.core.util.runtimeHomeDir import com.github.yumeyucca.yumebox.runtime.api.CoreApi import com.github.yumeyucca.yumebox.runtime.service.controller.CoreController @@ -62,9 +61,7 @@ data class VpnTunnel(val fd: Int, val gateway: String, val dns: String) */ class CoreProcess(private val context: Context) { - private var process: NativeProcess? = null private var ownerChannel: Channel? = null - private var ownerQueryThread: Thread? = null /** * Reap any live core, then open the tun, then fork. The tun is created inside the lifecycle @@ -117,12 +114,10 @@ class CoreProcess(private val context: Context) { ) Timber.tag(TAG).i("launch core, tunFd=%d", tunFd) val proc = spawn(home, args) - process = proc running = proc // Stream config over the socketpair (in memory), then the TUN fd as a terminating - // SCM_RIGHTS - // message. The core dups the fd; the app closes its own copy. + // SCM_RIGHTS message. The core dups the fd; the app closes its own copy. val handoff = runCatching { val channel = Channel(proc.channelFd) @@ -149,7 +144,6 @@ class CoreProcess(private val context: Context) { runCatching { proc.kill() } stopOwnerQueryLoop() if (running === proc) running = null - if (process === proc) process = null current = null throw IllegalStateException("config/fd handoff failed", error) } @@ -160,45 +154,39 @@ class CoreProcess(private val context: Context) { private fun startOwnerQueryLoop(channel: Channel) { val resolver = SocketOwnerResolver(context) ownerChannel = channel - ownerQueryThread = - Thread { - val buffer = ByteArray(OWNER_QUERY_BUFFER_SIZE) - try { - while (true) { - val result = channel.readMessage(buffer, 0, buffer.size) - if (result.count <= 0) break - val request = buffer.decodeToString(0, result.count) - val response = - if (result.fd >= 0) { - if (request == PROTECT_SOCKET_REQUEST) { - protectCoreSocket(result.fd) - } else { - closeReceivedFd(result.fd) - PROTECT_SOCKET_DENIED - } - } else { - resolveOwnerQuery(resolver, request) + Thread { + val buffer = ByteArray(OWNER_QUERY_BUFFER_SIZE) + try { + while (true) { + val result = channel.readMessage(buffer, 0, buffer.size) + if (result.count <= 0) break + val request = buffer.decodeToString(0, result.count) + val response = + when { + result.fd < 0 -> resolveOwnerQuery(resolver, request) + request == PROTECT_SOCKET_REQUEST -> protectCoreSocket(result.fd) + else -> { + closeReceivedFd(result.fd) + PROTECT_SOCKET_DENIED } - val responseBytes = response.toByteArray(Charsets.UTF_8) - channel.writeMessage(responseBytes, 0, responseBytes.size) - } - } catch (error: Throwable) { - if (ownerChannel === channel && isLocalCoreAlive()) { - Timber.tag(TAG).w(error, "socket owner RPC stopped unexpectedly") - } - } finally { - if (ownerChannel === channel) { - ownerChannel = null - ownerQueryThread = null - } - runCatching { channel.close() } + } + val responseBytes = response.toByteArray(Charsets.UTF_8) + channel.writeMessage(responseBytes, 0, responseBytes.size) } + } catch (error: Throwable) { + if (ownerChannel === channel && isLocalCoreAlive()) { + Timber.tag(TAG).w(error, "socket owner RPC stopped unexpectedly") + } + } finally { + if (ownerChannel === channel) ownerChannel = null + runCatching { channel.close() } } - .apply { - name = "Core-SocketOwner" - isDaemon = true - start() - } + } + .apply { + name = "Core-SocketOwner" + isDaemon = true + start() + } } /** Protect only the child core's socket; regular app traffic must remain inside the VPN. */ @@ -262,8 +250,8 @@ class CoreProcess(private val context: Context) { // A detached `su` daemon can't inherit the config socketpair the VPN core streams over, so // hand the compiled config (proxy secrets) through a named pipe instead of a file: the core // reads it once via --config and nothing is ever written to disk — the same - // no-plaintext-at- - // rest posture as VPN. Drop any legacy plaintext run.yaml an older build left behind. + // no-plaintext-at-rest posture as VPN. Drop any legacy plaintext run.yaml an older build + // left behind. File(home, LEGACY_ROOT_CONFIG).delete() val fifo = File(home, ROOT_CONFIG_PIPE).apply { delete() } Os.mkfifo(fifo.absolutePath, ROOT_PIPE_MODE) @@ -288,8 +276,7 @@ class CoreProcess(private val context: Context) { } // Feed the config into the pipe; the core's ReadFile blocks until we open+write. Run it on - // a - // daemon thread with a timeout so a core that died on launch (no reader) can't block the + // a daemon thread with a timeout so a core that died on launch (no reader) can't block the // caller forever; then unlink the pipe node (it holds nothing at rest either way). val writer = Thread { runCatching { @@ -306,9 +293,7 @@ class CoreProcess(private val context: Context) { writer.join(FIFO_WRITE_TIMEOUT_MS) if (writer.isAlive) { // No reader turned up: open one ourselves to release the blocked writer thread. The - // dead - // daemon then surfaces via the launcher's startup probe (core.log shows the read - // failure). + // dead daemon then surfaces via the startup probe (core.log shows the read failure). Timber.tag(TAG).w("root config handoff timed out; core likely died on launch") runCatching { FileInputStream(fifo).use { it.readBytes() } } } @@ -320,6 +305,7 @@ class CoreProcess(private val context: Context) { secret = secret, mode = mode, startTimeTicks = RootDaemonProbe.startTimeTicks(pid) ?: 0L, + launchedAt = System.currentTimeMillis(), ) RootDaemonProbe.commit(record) Timber.tag(TAG).i("root core launched, pid=%d mode=%s", pid, mode) @@ -364,12 +350,11 @@ class CoreProcess(private val context: Context) { } fun stop() = withLifecycleLock { - val stoppedProcess = process ?: running - if (stoppedProcess != null) { - stopVpnProcess(stoppedProcess) - if (!isVpnProcessAlive(stoppedProcess.pid)) { - if (running === stoppedProcess) running = null - process = null + val child = running + if (child != null) { + stopVpnProcess(child) + if (!isVpnProcessAlive(child.pid)) { + if (running === child) running = null current = null } } @@ -391,7 +376,6 @@ class CoreProcess(private val context: Context) { } if (running === child) running = null } - process = null reapRootDaemon() } @@ -413,7 +397,6 @@ class CoreProcess(private val context: Context) { val channel = ownerChannel ownerChannel = null runCatching { channel?.close() } - ownerQueryThread = null } /** @@ -517,33 +500,11 @@ class CoreProcess(private val context: Context) { } } - /** One `kill -0`. Identity stays on reattach, where a recycled pid matters. */ - fun isTrackedRootProcessAlive(): Boolean = RootDaemonProbe.trackedAlive() - - /** Status and tile polling. The probe caches the combined pid, exe and start-time check. */ - fun isRootDaemonAlive(): Boolean = RootDaemonProbe.isAlive() - /** True when a VPN child or a live root record already owns tun, routes and the socket. */ internal fun realCoreReserved(): Boolean = isLocalCoreAlive() || RootDaemonProbe.trackedAlive() - /** - * True if the non-root VPN child core is still alive. Used by LOCAL_TUN startup verify so a - * dead process fails immediately instead of spinning on a missing clash.sock. - */ - fun isLocalCoreAlive(): Boolean { - val pid = running?.pid ?: return false - return runCatching { - Os.kill(pid, 0) - true - } - .getOrDefault(false) - } - - /** - * The run mode of the persisted root daemon ("tun"/"ebpf" → [RunMode]), or null when - * none. - */ - fun rootDaemonMode(): RunMode? = RunMode.fromCoreArg(RootDaemonState.load()?.mode) + /** True while the non-root VPN child core is alive. */ + fun isLocalCoreAlive(): Boolean = running?.pid?.let(::isVpnProcessAlive) == true /** Last non-blank line of `/core.log`. */ fun coreLogTail(context: Context): String? = runCatching { @@ -637,11 +598,15 @@ class CoreProcess(private val context: Context) { /** Shared local-core controller client (unix socket path fixed, secret from [current]). */ fun controller(context: Context): CoreApi = sharedController(context) - /** Suspendable startup probe so launch deadlines are not hidden by the synchronous API. */ + /** Root startup probe: `/configs` answers as soon as the controller listens. */ internal suspend fun probeController(context: Context) { - sharedController(context).queryTunnelStateAsync() + sharedController(context).queryTunnelState() } + /** VPN startup probe: number of groups the loaded config exposes. */ + internal suspend fun probeGroupCount(context: Context): Int = + sharedController(context).queryGroupCount() + private fun sharedController(context: Context): CoreController = controller ?: CoreController( diff --git a/runtime/service/src/runtime/service/core/PreviewCoreProcess.kt b/runtime/service/src/runtime/service/core/PreviewCoreProcess.kt index f13a9e06c..385ca0ae6 100644 --- a/runtime/service/src/runtime/service/core/PreviewCoreProcess.kt +++ b/runtime/service/src/runtime/service/core/PreviewCoreProcess.kt @@ -23,10 +23,9 @@ import timber.log.Timber */ class PreviewCoreProcess(private val context: Context) { private var process: NativeProcess? = null - private var endpoint: CoreEndpoint? = null private var controller: CoreController? = null - fun start(config: String): CoreEndpoint = CoreProcess.withLifecycleLock { + fun start(config: String): Unit = CoreProcess.withLifecycleLock { synchronized(gate) { if (CoreProcess.realCoreReserved()) { error("preview refused while a runtime core is active") @@ -69,10 +68,8 @@ class PreviewCoreProcess(private val context: Context) { } process = proc active = this - endpoint = nextEndpoint controller = CoreController(local = CoreController.Local(nextEndpoint.sock) { nextEndpoint.secret }) Timber.tag(TAG).i("preview core launched, pid=%d", proc.pid) - nextEndpoint } } @@ -84,7 +81,6 @@ class PreviewCoreProcess(private val context: Context) { if (active === this) active = null val previous = process process = null - endpoint = null controller = null if (previous != null) { reap(previous) @@ -103,8 +99,6 @@ class PreviewCoreProcess(private val context: Context) { checkNotNull(controller) { "Preview core is not running" } } - fun endpoint(): CoreEndpoint? = synchronized(gate) { endpoint } - private fun spawn(home: File, workdir: File, args: Array): NativeProcess { val launchArgs = CoreArtifacts.previewArguments(context, args) return try { diff --git a/runtime/service/src/runtime/service/core/RootDaemonState.kt b/runtime/service/src/runtime/service/core/RootDaemonState.kt index dd09b733f..1e5b12367 100644 --- a/runtime/service/src/runtime/service/core/RootDaemonState.kt +++ b/runtime/service/src/runtime/service/core/RootDaemonState.kt @@ -35,12 +35,16 @@ object RootDaemonState { private const val KEY_SECRET = "root_daemon_secret" private const val KEY_MODE = "root_daemon_mode" private const val KEY_START_TIME_TICKS = "root_daemon_start_time_ticks" + private const val KEY_LAUNCHED_AT = "root_daemon_launched_at" + /** [mode] is the launch `--mode` value: "tun" or "ebpf". */ data class Record( val pid: Int, val secret: String, val mode: String, val startTimeTicks: Long = 0L, + /** Wall-clock launch time, so a reattached session keeps its real uptime. */ + val launchedAt: Long = 0L, ) private fun store() = MMKVProvider().getMMKV(ID) @@ -51,6 +55,7 @@ object RootDaemonState { encode(KEY_SECRET, record.secret) encode(KEY_MODE, record.mode) encode(KEY_START_TIME_TICKS, record.startTimeTicks) + encode(KEY_LAUNCHED_AT, record.launchedAt) } } @@ -66,6 +71,7 @@ object RootDaemonState { secret = mmkv.decodeString(KEY_SECRET).orEmpty(), mode = mode, startTimeTicks = mmkv.decodeLong(KEY_START_TIME_TICKS, 0L), + launchedAt = mmkv.decodeLong(KEY_LAUNCHED_AT, 0L), ) } @@ -75,6 +81,7 @@ object RootDaemonState { removeValueForKey(KEY_SECRET) removeValueForKey(KEY_MODE) removeValueForKey(KEY_START_TIME_TICKS) + removeValueForKey(KEY_LAUNCHED_AT) // Remove state written by versions that launched a separate eBPF bridge. removeValueForKey("root_bridge_pid") removeValueForKey("root_bridge_start_time_ticks") diff --git a/runtime/service/src/runtime/service/notification/ServiceNotificationManager.kt b/runtime/service/src/runtime/service/notification/ServiceNotificationManager.kt index 5e1785f5e..0558fb796 100644 --- a/runtime/service/src/runtime/service/notification/ServiceNotificationManager.kt +++ b/runtime/service/src/runtime/service/notification/ServiceNotificationManager.kt @@ -38,6 +38,7 @@ import com.github.yumeyucca.yumebox.runtime.api.Components import com.github.yumeyucca.yumebox.runtime.api.CoreApi import com.github.yumeyucca.yumebox.runtime.service.R import com.github.yumeyucca.yumebox.runtime.service.config.ServiceStore +import com.github.yumeyucca.yumebox.runtime.service.core.CoreProcess import com.github.yumeyucca.yumebox.runtime.service.profile.Imported import com.github.yumeyucca.yumebox.runtime.service.profile.ImportedDao import com.github.yumeyucca.yumebox.runtime.service.shizuku.ShizukuManager @@ -131,7 +132,7 @@ class ServiceNotificationManager( data class Island(val running: NotificationPresentation.Running) : NoticeFrame } - private fun loadFrame(): NoticeFrame { + private suspend fun loadFrame(): NoticeFrame { // Reading the profile deserializes the whole stored list, so resolve it only once. val profile = resolveProfile() val profileName = @@ -149,11 +150,11 @@ class ServiceNotificationManager( return if (isIslandEnabled()) NoticeFrame.Island(running) else NoticeFrame.Plain(running) } - private fun loadRunning( + private suspend fun loadRunning( profile: Imported?, profileName: String, ): NotificationPresentation.Running { - val core = com.github.yumeyucca.yumebox.runtime.service.core.CoreProcess.controller(service) + val core = CoreProcess.controller(service) val now = runCatching { core.queryTrafficNow() }.getOrDefault(0L) return NotificationPresentationFactory.createRunning( profileName = profileName, @@ -268,7 +269,7 @@ class ServiceNotificationManager( private fun resolveProfile(): Imported? = serviceStore.activeProfile?.let { ImportedDao.queryByUUID(it) } - private fun resolveCurrentNode(core: CoreApi): String? { + private suspend fun resolveCurrentNode(core: CoreApi): String? { val now = SystemClock.elapsedRealtime() if (now - currentNodeUpdatedAt < CURRENT_NODE_REFRESH_MS) { return currentNode @@ -281,19 +282,16 @@ class ServiceNotificationManager( return currentNode } - private fun shouldShowTrafficNotification(): Boolean { - val settings = settingsStore - if (settings.containsKey("showTrafficNotification")) { - return settings.decodeBool("showTrafficNotification", true) + private fun shouldShowTrafficNotification(): Boolean = + if (settingsStore.containsKey("showTrafficNotification")) { + settingsStore.decodeBool("showTrafficNotification", true) + } else { + serviceStore.showTrafficNotification } - return serviceStore.showTrafficNotification - } - - private fun isSuperIslandEnabled(): Boolean = appSettings.superIslandEnabled.value private fun isIslandEnabled(): Boolean = !RemoteControllerStore.isActive() && - isSuperIslandEnabled() && + appSettings.superIslandEnabled.value && HyperOsIsland.isSupported() companion object { diff --git a/runtime/service/src/runtime/service/preview/PreviewRuntimeManager.kt b/runtime/service/src/runtime/service/preview/PreviewRuntimeManager.kt index 9bcf53b14..63058ced7 100644 --- a/runtime/service/src/runtime/service/preview/PreviewRuntimeManager.kt +++ b/runtime/service/src/runtime/service/preview/PreviewRuntimeManager.kt @@ -12,16 +12,15 @@ import com.github.yumeyucca.yumebox.core.model.ProxyGroup import com.github.yumeyucca.yumebox.core.model.ProxySort import com.github.yumeyucca.yumebox.domain.model.ProxyDelayPublishCoalescer import com.github.yumeyucca.yumebox.domain.model.ProxyDelayTestProgressCallback -import com.github.yumeyucca.yumebox.domain.model.ProxyGroupInfo import com.github.yumeyucca.yumebox.domain.model.ProxyGroupOverlay import com.github.yumeyucca.yumebox.domain.model.membersByGroup import com.github.yumeyucca.yumebox.domain.model.runProxyGroupDelayTests -import com.github.yumeyucca.yumebox.runtime.service.controller.CoreController -import com.github.yumeyucca.yumebox.runtime.service.core.PreviewCoreProcess import com.github.yumeyucca.yumebox.runtime.service.config.ServiceStore +import com.github.yumeyucca.yumebox.runtime.service.core.PreviewCoreProcess import com.github.yumeyucca.yumebox.runtime.service.profile.ImportedDao import com.github.yumeyucca.yumebox.runtime.service.session.CompiledConfigPipeline import com.github.yumeyucca.yumebox.runtime.service.session.SessionRuntimeSpecFactory +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -66,7 +65,7 @@ class PreviewRuntimeManager(context: Context) { if (generation.get() != requestGeneration) return@withLock // Startup, the home page, and the node page can request the first snapshot together. // Compile under the same transaction as launch/readiness so they reuse one result. - val compiled = pipeline.compileDetailed(factory.createPreviewSpec()) + val compiled = pipeline.compile(factory.createPreviewSpec()) if (generation.get() != requestGeneration) return@withLock // Several UI surfaces ask for the initial node snapshot at once. Keep the launch and // first controller read in one transaction: otherwise a second caller can replace a @@ -94,45 +93,16 @@ class PreviewRuntimeManager(context: Context) { } fun reset() { - generation.incrementAndGet() - delayOverlay.clear() - process.stop() + stop() _state.value = PreviewNodeState() } /** Preview is read-only for selection, but mihomo's delay probes are safe and useful here. */ - suspend fun healthCheck( - group: String, - onProgress: ProxyDelayTestProgressCallback? = null, - ) { - val previewGeneration = generation.get() - val snapshot = prepareHealthCheckSnapshot(previewGeneration) ?: return - delayPublish.session( - flush = { delays -> applyDirectDelays(previewGeneration, delays) }, - ) { - runPreviewDelayTests( - previewGeneration = previewGeneration, - snapshot = snapshot, - groupNames = listOf(group), - onProgress = onProgress, - ) - } - } + suspend fun healthCheck(group: String, onProgress: ProxyDelayTestProgressCallback? = null) = + runDelayTests(onProgress) { listOf(group) } - suspend fun healthCheckAll(onProgress: ProxyDelayTestProgressCallback? = null) { - val previewGeneration = generation.get() - val snapshot = prepareHealthCheckSnapshot(previewGeneration) ?: return - delayPublish.session( - flush = { delays -> applyDirectDelays(previewGeneration, delays) }, - ) { - runPreviewDelayTests( - previewGeneration = previewGeneration, - snapshot = snapshot, - groupNames = snapshot.groupInfos.map(ProxyGroupInfo::name), - onProgress = onProgress, - ) - } - } + suspend fun healthCheckAll(onProgress: ProxyDelayTestProgressCallback? = null) = + runDelayTests(onProgress) { proxiesByGroup -> proxiesByGroup.keys.toList() } suspend fun healthCheckProxy(group: String, proxyName: String): Int { val previewGeneration = generation.get() @@ -149,7 +119,7 @@ class PreviewRuntimeManager(context: Context) { mutex.withLock { if (!isCurrentGeneration(previewGeneration)) return@withLock val refreshed = - mergeReportedDelays(listOf(process.controller().queryProxyGroupAsync(name, sort))).first() + mergeReportedDelays(listOf(process.controller().queryProxyGroup(name, sort))).first() if (!isCurrentGeneration(previewGeneration)) return@withLock val previous = _state.value val merged = @@ -162,7 +132,7 @@ class PreviewRuntimeManager(context: Context) { } private suspend fun refreshGroups(previewGeneration: Long) { - val incoming = process.controller().queryAllProxyGroupsAsync(false) + val incoming = process.controller().queryAllProxyGroups(false) if (!isCurrentGeneration(previewGeneration)) { return } @@ -198,52 +168,31 @@ class PreviewRuntimeManager(context: Context) { }.let(delayOverlay::paintProxyGroups) } - private data class PreviewHealthCheckSnapshot( - val controller: CoreController, - val groupInfos: List, - val proxiesByGroup: Map>, - ) - - private suspend fun prepareHealthCheckSnapshot(previewGeneration: Long): PreviewHealthCheckSnapshot? = - mutex.withLock { - if (!isCurrentGeneration(previewGeneration)) return@withLock null - refreshGroups(previewGeneration) - if (!isCurrentGeneration(previewGeneration)) return@withLock null - val groups = _state.value.groups - PreviewHealthCheckSnapshot( - controller = process.controller(), - groupInfos = - groups.map { group -> - ProxyGroupInfo( - name = group.name, - type = group.type, - proxies = group.proxies, - now = group.now, - icon = group.icon, - hidden = group.hidden, - ) - }, - proxiesByGroup = groups.associate { group -> group.name to group.proxies }, + /** Refreshes the groups under the lock, then probes outside it so selection reads stay live. */ + private suspend fun runDelayTests( + onProgress: ProxyDelayTestProgressCallback?, + groupNames: (proxiesByGroup: Map>) -> List, + ) { + val previewGeneration = generation.get() + val (controller, proxiesByGroup) = + mutex.withLock { + if (!isCurrentGeneration(previewGeneration)) return + refreshGroups(previewGeneration) + if (!isCurrentGeneration(previewGeneration)) return + process.controller() to _state.value.groups.associate { it.name to it.proxies } + } + delayPublish.session(flush = { delays -> applyDirectDelays(previewGeneration, delays) }) { + runProxyGroupDelayTests( + groupNames = groupNames(proxiesByGroup), + proxiesByGroup = proxiesByGroup, + isActive = { isCurrentGeneration(previewGeneration) }, + measureProxy = { groupName, proxyName -> controller.healthCheckProxy(groupName, proxyName) }, + measureGroup = { groupName -> controller.healthCheck(groupName) }, + publish = { delays -> publishDirectDelays(previewGeneration, delays) }, + onProgress = onProgress, ) } - - private suspend fun runPreviewDelayTests( - previewGeneration: Long, - snapshot: PreviewHealthCheckSnapshot, - groupNames: List, - onProgress: ProxyDelayTestProgressCallback?, - ): Boolean = - runProxyGroupDelayTests( - groupNames = groupNames, - proxiesByGroup = snapshot.proxiesByGroup, - isActive = { isCurrentGeneration(previewGeneration) }, - measureProxy = { groupName, proxyName -> - snapshot.controller.healthCheckProxy(groupName, proxyName) - }, - measureGroup = { groupName -> snapshot.controller.healthCheck(groupName) }, - publish = { delays -> publishDirectDelays(previewGeneration, delays) }, - onProgress = onProgress, - ) + } private suspend fun publishDirectDelays( previewGeneration: Long, @@ -286,21 +235,15 @@ class PreviewRuntimeManager(context: Context) { private suspend fun awaitGroups(requestGeneration: Long): List = withTimeout(CONTROLLER_READY_TIMEOUT_MS) { - while (true) { + var groups: List? = null + while (groups == null) { if (generation.get() != requestGeneration || !process.isAlive()) { - throw kotlinx.coroutines.CancellationException("preview handoff requested") - } - try { - return@withTimeout process.controller().queryAllProxyGroupsAsync(false) - } catch (_: Throwable) { - if (generation.get() != requestGeneration || !process.isAlive()) { - throw kotlinx.coroutines.CancellationException("preview handoff requested") - } - delay(CONTROLLER_RETRY_MS) + throw CancellationException("preview handoff requested") } + groups = runCatching { process.controller().queryAllProxyGroups(false) }.getOrNull() + if (groups == null) delay(CONTROLLER_RETRY_MS) } - @Suppress("UNREACHABLE_CODE") - error("preview controller retry loop ended unexpectedly") + groups } private companion object { diff --git a/runtime/service/src/runtime/service/profile/ProfileStore.kt b/runtime/service/src/runtime/service/profile/ProfileStore.kt index 69af12d63..81704215b 100644 --- a/runtime/service/src/runtime/service/profile/ProfileStore.kt +++ b/runtime/service/src/runtime/service/profile/ProfileStore.kt @@ -20,11 +20,8 @@ @file:UseSerializers(UUIDSerializer::class) -@file:Suppress("UnusedSymbol") - package com.github.yumeyucca.yumebox.runtime.service.profile - import com.github.yumeyucca.yumebox.runtime.api.UUIDSerializer import com.tencent.mmkv.MMKV import kotlinx.serialization.SerializationException @@ -46,7 +43,7 @@ object ProfileStore { fun saveImported(list: List) { val jsonString = json.encodeToString(ListSerializer(Imported.serializer()), list) - mmkv.encode("imported", jsonString) + mmkv.encode(IMPORTED_KEY, jsonString) } fun loadImported(): List { @@ -62,7 +59,7 @@ object ProfileStore { fun saveProfileOrder(order: List) { val jsonString = json.encodeToString(ListSerializer(UUIDSerializer()), order) - mmkv.encode("profile_order", jsonString) + mmkv.encode(PROFILE_ORDER_KEY, jsonString) } fun loadProfileOrder(): List { @@ -75,11 +72,4 @@ object ProfileStore { emptyList() } } - - fun countStoredKeys(): Int { - var count = 0 - if (mmkv.decodeString(IMPORTED_KEY) != null) count++ - if (mmkv.decodeString(PROFILE_ORDER_KEY) != null) count++ - return count - } } diff --git a/runtime/service/src/runtime/service/root/RootAccessSupport.kt b/runtime/service/src/runtime/service/root/RootAccessSupport.kt index 4a00ea69c..ffbe4493e 100644 --- a/runtime/service/src/runtime/service/root/RootAccessSupport.kt +++ b/runtime/service/src/runtime/service/root/RootAccessSupport.kt @@ -18,38 +18,21 @@ * */ -@file:Suppress("UnusedSymbol") - package com.github.yumeyucca.yumebox.runtime.service.root - -import android.content.Context import com.topjohnwu.superuser.Shell import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.suspendCancellableCoroutine import tf.gal.yumebox.locale.YumeTxt -data class RootAccessStatus(val rootAccessGranted: Boolean) { - val canStartRoot: Boolean - get() = rootAccessGranted - - fun rootBlockedMessage(): String = YumeTxt.NetworkSettings.Error.RootRequired -} - object RootAccessSupport { - fun evaluate(@Suppress("UNUSED_PARAMETER") context: Context): RootAccessStatus { - val rootAccessGranted = RootPackageShell.hasRootAccess() - return RootAccessStatus(rootAccessGranted = rootAccessGranted) - } - // tryResume/completeResume (internal API): the libsu shell callback may fire after the // caller was cancelled, so the resume must be race-safe rather than throw. @OptIn(InternalCoroutinesApi::class) - suspend fun evaluateAsync(@Suppress("UNUSED_PARAMETER") context: Context): RootAccessStatus = + suspend fun isGranted(): Boolean = suspendCancellableCoroutine { continuation -> - fun resume(rootAccessGranted: Boolean) { - val status = RootAccessStatus(rootAccessGranted = rootAccessGranted) - val resumeToken = continuation.tryResume(status) ?: return + fun resume(granted: Boolean) { + val resumeToken = continuation.tryResume(granted) ?: return continuation.completeResume(resumeToken) } @@ -61,8 +44,7 @@ object RootAccessSupport { .onFailure { resume(false) } } - suspend fun requireRootAccess(context: Context): RootAccessStatus = - evaluateAsync(context).also { status -> - check(status.canStartRoot) { status.rootBlockedMessage() } - } + suspend fun require() { + check(isGranted()) { YumeTxt.NetworkSettings.Error.RootRequired } + } } diff --git a/runtime/service/src/runtime/service/root/RootPackageShell.kt b/runtime/service/src/runtime/service/root/RootPackageShell.kt index 149d3a5b0..bf8b33d03 100644 --- a/runtime/service/src/runtime/service/root/RootPackageShell.kt +++ b/runtime/service/src/runtime/service/root/RootPackageShell.kt @@ -18,147 +18,59 @@ * */ -@file:Suppress("UnusedSymbol", "CanConvertToMultiDollarString") - package com.github.yumeyucca.yumebox.runtime.service.root import com.topjohnwu.superuser.Shell +/** Package → uid lookups through the root shell; one full listing serves every query. */ object RootPackageShell { - private data class CacheEntry( - val value: T, - val cachedAt: Long, - ) { - fun isFresh(now: Long): Boolean = now - cachedAt <= CACHE_TTL_MS - } + private class CacheEntry(val uids: Map, val cachedAt: Long) private val packageUidRegex = Regex("""^package:(\S+).*(?:uid|userId):(\d+)\b""") - private val packageNameRegex = Regex("""^package:(\S+)""") - - @Volatile private var packageUidCache: CacheEntry>? = null - @Volatile private var packageNameCache: CacheEntry>? = null + @Volatile private var cache: CacheEntry? = null fun hasRootAccess(): Boolean = runCatching { Shell.getShell().isRoot }.getOrDefault(false) + /** + * Uids of [packages] (every package when null or empty), or null without root. A package + * missing from the cached listing forces a fresh one, so new installs resolve immediately. + */ fun queryPackageUidMap(packages: Set? = null): Map? { - if (!hasRootAccess()) return null - - val now = System.currentTimeMillis() - val normalizedPackages = - packages - ?.asSequence() - ?.map(String::trim) - ?.filter(String::isNotEmpty) - ?.toSortedSet() - ?.takeIf { it.isNotEmpty() } + val wanted = + packages?.map(String::trim)?.filter(String::isNotEmpty)?.toSet()?.takeIf { it.isNotEmpty() } + val cached = freshCache() + val uids = + if (cached != null && (wanted == null || wanted.all(cached::containsKey))) { + cached + } else { + listPackageUids() ?: return null + } + return if (wanted == null) uids else uids.filterKeys(wanted::contains).ifEmpty { null } + } - if (normalizedPackages == null) { - packageUidCache - ?.takeIf { it.isFresh(now) } - ?.value - ?.takeIf { it.isNotEmpty() } - ?.let { - return it - } - } else { - packageUidCache - ?.takeIf { it.isFresh(now) } - ?.value - ?.takeIf { cached -> normalizedPackages.all(cached::containsKey) } - ?.let { cached -> - return normalizedPackages.associateWith { cached.getValue(it) } - } - } + fun queryInstalledPackageNames(): Set? = (freshCache() ?: listPackageUids())?.keys - val stdout = mutableListOf() - val result = Shell.cmd(buildUidQueryCommand(normalizedPackages)).to(stdout).exec() + private fun freshCache(): Map? = + cache?.takeIf { System.currentTimeMillis() - it.cachedAt <= CACHE_TTL_MS }?.uids + private fun listPackageUids(): Map? { + if (!hasRootAccess()) return null + val result = Shell.cmd("cmd package list packages -U || pm list packages -U").exec() if (!result.isSuccess) return null - - val resolved = - stdout + val uids = + result.out .asSequence() .mapNotNull { line -> val match = packageUidRegex.find(line.trim()) ?: return@mapNotNull null - val packageName = match.groupValues[1] val uid = match.groupValues[2].toIntOrNull() ?: return@mapNotNull null - packageName to uid - } - .filter { (packageName, _) -> - normalizedPackages == null || packageName in normalizedPackages + match.groupValues[1] to uid } .toMap() - .ifEmpty { null } - - resolved?.let { value -> - val cacheEntry = CacheEntry(value = value, cachedAt = now) - if (normalizedPackages == null) { - packageUidCache = cacheEntry - packageNameCache = CacheEntry(value = value.keys, cachedAt = now) - } else { - val merged = (packageUidCache?.value.orEmpty() + value) - packageUidCache = CacheEntry(value = merged, cachedAt = now) - } - } - - return resolved + .ifEmpty { return null } + cache = CacheEntry(uids, System.currentTimeMillis()) + return uids } - fun queryInstalledPackageNames(): Set? { - if (!hasRootAccess()) return null - - val now = System.currentTimeMillis() - packageUidCache - ?.takeIf { it.isFresh(now) } - ?.value - ?.keys - ?.takeIf { it.isNotEmpty() } - ?.let { - return it - } - - packageNameCache - ?.takeIf { it.isFresh(now) } - ?.value - ?.takeIf { it.isNotEmpty() } - ?.let { - return it - } - - val stdout = mutableListOf() - val result = Shell.cmd("cmd package list packages || pm list packages").to(stdout).exec() - - if (!result.isSuccess) return null - - return stdout - .asSequence() - .mapNotNull { line -> packageNameRegex.find(line.trim())?.groupValues?.getOrNull(1) } - .toSet() - .ifEmpty { null } - ?.also { packageNameCache = CacheEntry(it, now) } - } - - fun invalidateCaches() { - packageUidCache = null - packageNameCache = null - } - - private fun buildUidQueryCommand(packages: Set?): String { - if (packages.isNullOrEmpty()) { - return "cmd package list packages -U || pm list packages -U" - } - - if (packages.size == 1) { - val filter = escapeShellArg(packages.first()) - return "cmd package list packages -U $filter || pm list packages -U $filter" - } - - val filters = packages.joinToString(" ") { escapeShellArg(it) } - return "for pkg in $filters; do cmd package list packages -U \"\$pkg\" || pm list packages -U \"\$pkg\"; done" - } - - private fun escapeShellArg(value: String): String = "'" + value.replace("'", "'\\''") + "'" - private const val CACHE_TTL_MS = 5 * 60 * 1000L } diff --git a/runtime/service/src/runtime/service/session/AndroidRuntimeLauncher.kt b/runtime/service/src/runtime/service/session/AndroidRuntimeLauncher.kt deleted file mode 100644 index e8f80ac6b..000000000 --- a/runtime/service/src/runtime/service/session/AndroidRuntimeLauncher.kt +++ /dev/null @@ -1,92 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.service.session - -import android.content.Context -import android.content.Intent -import android.os.SystemClock -import com.github.yumeyucca.yumebox.core.model.RunMode -import com.github.yumeyucca.yumebox.runtime.api.RuntimeLauncher -import com.github.yumeyucca.yumebox.runtime.api.RuntimeOwner -import com.github.yumeyucca.yumebox.runtime.api.appContextOrSelf -import com.github.yumeyucca.yumebox.runtime.service.StatusProvider -import com.github.yumeyucca.yumebox.runtime.service.TunService -import com.github.yumeyucca.yumebox.runtime.service.core.CoreProcess -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext - -/** - * Android [RuntimeLauncher]: VpnService foreground host + root daemon host. Desktop can ship a - * different [RuntimeLauncher] without Android Service APIs. - */ -class AndroidRuntimeLauncher(context: Context) : RuntimeLauncher { - private val appContext = context.appContextOrSelf - - override suspend fun start(owner: RuntimeOwner, mode: RunMode) { - when (owner) { - RuntimeOwner.VpnService -> - RuntimeServiceLauncher.start( - context = appContext, - mode = mode, - source = RuntimeServiceLauncher.SOURCE_UI, - ) - - RuntimeOwner.RootDaemon -> RootSessionLauncher.start(appContext, mode) - RuntimeOwner.RemoteController, - RuntimeOwner.None -> Unit - } - } - - override suspend fun stop(owner: RuntimeOwner) { - when (owner) { - RuntimeOwner.VpnService -> stopVpnRuntime() - RuntimeOwner.RootDaemon -> - withContext(Dispatchers.IO) { RootSessionLauncher.stop(appContext) } - - RuntimeOwner.RemoteController, - RuntimeOwner.None -> Unit - } - } - - private suspend fun stopVpnRuntime() { - withContext(Dispatchers.IO) { - appContext.stopService(Intent(appContext, TunService::class.java)) - val deadline = SystemClock.elapsedRealtime() + VPN_STOP_TIMEOUT_MS - while ( - StatusProvider.isLocalRuntimeServiceAlive(RunMode.VpnService) && - SystemClock.elapsedRealtime() < deadline - ) { - delay(VPN_STOP_POLL_MS) - } - if (StatusProvider.isLocalRuntimeServiceAlive(RunMode.VpnService)) { - // CoreProcess.stop() gives the child a bounded SIGTERM window so mihomo can - // persist selector state. Do not preempt that graceful shutdown here. - CoreProcess.killRunning() - } - } - } - - private companion object { - const val VPN_STOP_TIMEOUT_MS = 1_000L - const val VPN_STOP_POLL_MS = 25L - } -} diff --git a/runtime/service/src/runtime/service/session/CompiledConfigPipeline.kt b/runtime/service/src/runtime/service/session/CompiledConfigPipeline.kt index 5874a3171..345f9de78 100644 --- a/runtime/service/src/runtime/service/session/CompiledConfigPipeline.kt +++ b/runtime/service/src/runtime/service/session/CompiledConfigPipeline.kt @@ -18,228 +18,73 @@ * */ -@file:Suppress("UnusedSymbol", "RedundantSuspendModifier") - package com.github.yumeyucca.yumebox.runtime.service.session import android.content.Context -import android.util.Log import com.github.yumeyucca.yumebox.core.bridge.Compiler -import com.github.yumeyucca.yumebox.core.model.* +import com.github.yumeyucca.yumebox.core.model.CompileRequest +import com.github.yumeyucca.yumebox.core.model.CompileResult +import com.github.yumeyucca.yumebox.core.model.OverrideSpec import com.github.yumeyucca.yumebox.core.util.YamlCodec -import com.github.yumeyucca.yumebox.core.util.runtimeHomeDir import com.github.yumeyucca.yumebox.data.model.BuiltInOverrideCatalog import com.github.yumeyucca.yumebox.data.store.BuiltInOverrideFileStore import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import java.io.File import java.security.MessageDigest class CompiledConfigPipeline(private val context: Context) { private val builtInOverrideFiles = BuiltInOverrideFileStore(context) - fun resolveOverrideSpecs(profileUuid: String): List = - resolveOverrideBundle(profileUuid, logger = null).overrides - - fun resolveOverrideSpecs(profileUuid: String, logger: ((String) -> Unit)?): List = - resolveOverrideBundle(profileUuid, logger).overrides - fun resolveOverrideBundle(profileUuid: String): ResolvedOverrideBundle = - resolveOverrideBundle(profileUuid, logger = null) - - fun resolveOverrideBundle( - profileUuid: String, - logger: ((String) -> Unit)?, - ): ResolvedOverrideBundle { + /** The profile's user override chain, then its runtime-internal override if one exists. */ + fun resolveOverrideSpecs(profileUuid: String): List { val overridesDir = context.filesDir.resolve("overrides") - val metadataFile = overridesDir.resolve("metadata.yaml") - val metadata = loadMetadataIndex(metadataFile, logger) - - val binding = metadata.profileChains[profileUuid] - logger?.invoke( - "override resolve: profile=$profileUuid overrideIds=${binding?.overrideIds.orEmpty()}" - ) - - val userOverrides = mutableListOf() - val overrides = mutableListOf() - binding - ?.overrideIds - .orEmpty() - .filterNot(::isLegacyPresetId) - .filterNot(::isInternalRuntimeId) - .distinct() - .forEach { overrideId -> - val file = - resolveUserOverrideFile(overridesDir, overrideId, metadata) - ?: error( - "Override config not found for profile=$profileUuid id=$overrideId" - ) - val spec = file.toOverrideSpec() - logger?.invoke(describeOverrideFile(file, overrideId)) - userOverrides += spec - overrides += spec - } - - val runtimeInternalOverride = - resolveRuntimeInternalOverrideFile(overridesDir, profileUuid) - ?.also { file -> - logger?.invoke(describeOverrideFile(file, INTERNAL_RUNTIME_PREFIX)) - } - ?.toOverrideSpec() - - runtimeInternalOverride?.let(overrides::add) - - logger?.invoke( - "override resolve: profile=$profileUuid resolved=${overrides.size} " + - overrides.joinToString(prefix = "[", postfix = "]") { spec -> - "${spec.ext}:${spec.path.safeLogHash()}" + val metadata = loadMetadataIndex(overridesDir.resolve("metadata.yaml")) + val userOverrides = + metadata.profileChains[profileUuid] + ?.overrideIds + .orEmpty() + .filterNot { it.startsWith(LEGACY_PRESET_PREFIX) || it.startsWith(INTERNAL_RUNTIME_PREFIX) } + .distinct() + .map { overrideId -> + val file = + resolveUserOverrideFile(overridesDir, overrideId, metadata) + ?: error("Override config not found for profile=$profileUuid id=$overrideId") + file.toOverrideSpec() } - ) - - return ResolvedOverrideBundle( - profileUuid = profileUuid, - userOverrides = userOverrides, - runtimeInternalOverride = runtimeInternalOverride, - overrides = overrides, - ) + val internal = resolveRuntimeInternalOverrideFile(overridesDir, profileUuid)?.toOverrideSpec() + return userOverrides + listOfNotNull(internal) } /** - * Compiles the profile + override chain to the final mihomo config (liboverride) and returns - * it. Used by the out-of-process path: the caller streams this to the core over the socketpair, - * so it is never written to disk. The core reads and applies it itself. - */ - data class CompiledRuntimeConfig( - val finalYaml: String, - val proxyGroupNames: List, - val warnings: List = emptyList(), - val fingerprint: String = "", - ) - - suspend fun compile(spec: RuntimeSpec): String = compileDetailed(spec).finalYaml - - /** - * Compile once and extract proxy-group names so startup readiness does not need a second - * nativeCompile just to know which groups to expect. + * Compiles the profile + override chain to the final mihomo config (liboverride). The caller + * streams [CompileResult.finalYaml] to the core over the socketpair; it is never written to disk. */ - suspend fun compileDetailed(spec: RuntimeSpec): CompiledRuntimeConfig = + suspend fun compile(spec: RuntimeSpec): CompileResult = withContext(Dispatchers.Default) { - if (spec.compiledFinalYaml.isNotBlank()) { - return@withContext CompiledRuntimeConfig( - finalYaml = spec.compiledFinalYaml, - proxyGroupNames = - spec.expectedProxyGroupNames.ifEmpty { - extractProxyGroupNames(spec.compiledFinalYaml) - }, - ) - } - val request = buildRequest(spec) val result = compilerJson.decodeFromString( CompileResult.serializer(), Compiler.nativeCompile( - compilerJson.encodeToString(CompileRequest.serializer(), request) + compilerJson.encodeToString(CompileRequest.serializer(), buildRequest(spec)) ), ) check(result.success) { result.error ?: "override compile failed" } - CompiledRuntimeConfig( - finalYaml = result.finalYaml, - proxyGroupNames = extractProxyGroupNames(result.finalYaml), - warnings = result.warnings, - fingerprint = result.fingerprint, - ) + result } fun extractProxyGroupNames(finalYaml: String): List = runCatching { YamlCodec.decode(CompiledGroupConfig.serializer(), finalYaml) .proxyGroups - .asSequence() .map { it.name.trim() } .filter { it.isNotEmpty() } - .toList() } .getOrDefault(emptyList()) - /** - * Deletes any leftover runtime.yaml before loading a profile. runtime.yaml is no longer - * produced by any code path, but historical builds may have left one on disk; clearing it for - * every profile keeps the invariant "no runtime.yaml ever exists". A missing file is the normal - * case and returns silently; only a failed delete of an existing file is treated as an error. - */ - private fun removeStaleRuntimeYaml(spec: RuntimeSpec, logger: ((String) -> Unit)?) { - val runtimeFile = File(spec.runtimeConfigPath) - if (!runtimeFile.exists()) { - return - } - if (!runtimeFile.delete()) { - error("Stale runtime.yaml cleanup failed") - } - logger?.invoke( - "runtime native: removed stale runtime.yaml output=${runtimeFile.safeLogHash()}" - ) - } - - /** - * Authoritative group list straight from the compiled rawConfig. The list retains declaration - * order; live state is overlaid from the running core by [RuntimeProxyGroupResolver]. - */ - fun previewGroups(spec: RuntimeSpec, excludeNotSelectable: Boolean): List { - val request = buildRequest(spec) - val result = - compilerJson.decodeFromString( - CompileResult.serializer(), - Compiler.nativeCompile(compilerJson.encodeToString(CompileRequest.serializer(), request)), - ) - check(result.success) { result.error ?: "override group preview failed" } - val rawConfig = YamlCodec.decode(CompiledGroupConfig.serializer(), result.finalYaml) - return rawConfig.proxyGroups - .asSequence() - .filter { it.name.isNotBlank() } - .map(CompiledProxyGroup::toProxyGroup) - .filter { !excludeNotSelectable || it.isSelectable } - .toList() - } - - /** - * Returns the compiled YAML for non-encrypted profiles (the user-initiated "view compiled - * config" export). This is the ONLY path that materialises plaintext finalYaml in Kotlin, and - * it is intentionally retained: it is out of scope for the runtime.yaml elimination because it - * is an explicit user export, not a runtime/load path. `Clash.compilePreview` returns the YAML - * in memory and does NOT write `outputPath` to disk. Throws for encrypted profiles since full - * YAML must not reach the Kotlin heap. - */ - suspend fun previewCompiledYaml( - profileUuid: String, - profileDir: File, - overrideSpecs: List = resolveOverrideBundle(profileUuid).overrides, - ageSecretKey: String? = null, - ): CompileResult = - withContext(Dispatchers.Default) { - require(ageSecretKey == null) { - "previewCompiledYaml is not supported for encrypted profiles" - } - val request = - CompileRequest( - profileUuid = profileUuid, - profileDir = profileDir.absolutePath, - profilePath = profileDir.resolve("config.yaml").absolutePath, - overrides = overrideSpecs, - outputPath = profileDir.resolve("runtime.yaml").absolutePath, - ) - val result = - compilerJson.decodeFromString( - CompileResult.serializer(), - Compiler.nativeCompile( - compilerJson.encodeToString(CompileRequest.serializer(), request) - ), - ) - check(result.success) { result.error ?: "override preview failed" } - validateCompiledProviderPaths(result.finalYaml, profileDir) - result - } - private fun buildRequest(spec: RuntimeSpec): CompileRequest { val profileDir = File(spec.profileDir) return CompileRequest( @@ -247,8 +92,7 @@ class CompiledConfigPipeline(private val context: Context) { profileDir = profileDir.absolutePath, profilePath = profileDir.resolve("config.yaml").absolutePath, overrides = spec.overrideSpecs, - outputPath = - spec.runtimeConfigPath.ifBlank { profileDir.resolve("runtime.yaml").absolutePath }, + outputPath = profileDir.resolve("runtime.yaml").absolutePath, ageSecretKey = spec.ageSecretKey, runMode = spec.runMode, skipRuntimePatches = spec.skipRuntimePatches, @@ -256,96 +100,10 @@ class CompiledConfigPipeline(private val context: Context) { ) } - /** - * Publishes the compiled `tun.include-package` / `tun.exclude-package` lists for - * [VpnTunTransport]. A running VPN session does not re-establish the TUN device on profile - * reload, so a mid-session change only takes effect on the next VPN (re)start — log it so the - * limitation is diagnosable. - */ - private fun publishCompiledTunPackages( - summary: CompileRawSummary, - logger: ((String) -> Unit)?, - ) { - val changed = - CompiledTunPackages.update(summary.tunIncludePackage, summary.tunExcludePackage) - if (changed) { - logger?.invoke( - "runtime native: tun package lists changed include=${summary.tunIncludePackage.size}" + - " exclude=${summary.tunExcludePackage.size}; applied at next TUN establish" + - " (a running VPN session is not re-established on reload)" - ) - } - } - - private fun logRawCompileWarnings(summary: CompileRawSummary, logger: ((String) -> Unit)?) { - if (logger == null) { - return - } - if (!summary.success) { - logger("runtime native: warning summary failed=${summary.error.safeNativeDiagnostic()}") - return - } - summary.warnings.forEachIndexed { index, warning -> - logger("runtime native: warning index=$index detail=${warning.safeNativeDiagnostic()}") - } - } - - private fun validateCompiledProviderPaths(finalYaml: String, profileDir: File) { - val invalidPaths = mutableListOf() - val runtimeHomeDir = context.runtimeHomeDir - val expectedRuleBase = profileDir.resolve("providers/rules").canonicalFile - val expectedProxyBase = profileDir.resolve("providers/proxies").canonicalFile - pathPattern.findAll(finalYaml).forEach { match -> - val pathValue = match.groupValues[1].replace('\\', '/').trim() - if ( - !pathValue.endsWith(".yaml") && - !pathValue.endsWith(".yml") && - !pathValue.endsWith(".mrs") - ) { - return@forEach - } - val isLegacyPath = - pathValue.startsWith("./ruleset/") || - pathValue.startsWith("ruleset/") || - pathValue.contains("/clash/") - val resolvedPath = runtimeHomeDir.resolve(pathValue).canonicalFile - val inProfileProviders = - resolvedPath.toPath().startsWith(expectedRuleBase.toPath()) || - resolvedPath.toPath().startsWith(expectedProxyBase.toPath()) - if (isLegacyPath || File(pathValue).isAbsolute || !inProfileProviders) { - invalidPaths += pathValue - } - } - if (invalidPaths.isNotEmpty()) { - invalidPaths.forEachIndexed { index, invalidPath -> - Log.e( - TAG, - "Compiled provider path invalid index=$index path=${invalidPath.safeLogHash()}", - ) - } - error("Compiled provider path escaped profile scope") - } - if (pathPattern.containsMatchIn(finalYaml)) { - Log.i(TAG, "Compiled provider paths validated") - } - } - - private fun loadMetadataIndex( - metadataFile: File, - logger: ((String) -> Unit)?, - ): MetadataIndexPayload { - if (!metadataFile.exists()) { - return MetadataIndexPayload() - } - val metadataRaw = metadataFile.readText() - return runCatching { YamlCodec.decode(MetadataIndexPayload.serializer(), metadataRaw) } - .getOrElse { - logger?.invoke( - "override resolve: metadata decode failed file=${metadataFile.safeLogHash()} " + - "size=${metadataRaw.length} sha=${metadataRaw.sha256Short()}" - ) - MetadataIndexPayload() - } + private fun loadMetadataIndex(metadataFile: File): MetadataIndexPayload { + if (!metadataFile.exists()) return MetadataIndexPayload() + return runCatching { YamlCodec.decode(MetadataIndexPayload.serializer(), metadataFile.readText()) } + .getOrDefault(MetadataIndexPayload()) } private fun resolveUserOverrideFile( @@ -353,42 +111,32 @@ class CompiledConfigPipeline(private val context: Context) { overrideId: String, metadataIndex: MetadataIndexPayload, ): File? { - materializeBuiltInOverride(overrideId)?.let { - return it + if (BuiltInOverrideCatalog.find(overrideId) != null) { + builtInOverrideFiles.sync(overrideId)?.let { return it } } - - val expectedExtension = - metadataIndex.configs[overrideId]?.contentType?.toOverrideExtension() + val expectedExtension = metadataIndex.configs[overrideId]?.contentType?.toOverrideExtension() if (expectedExtension != null) { val expectedFile = overridesDir.resolve("configs/$overrideId.$expectedExtension") - if (expectedFile.exists()) { - return expectedFile - } + if (expectedFile.exists()) return expectedFile } - return userOverrideExtensions .asSequence() .map { extension -> overridesDir.resolve("configs/$overrideId.$extension") } .firstOrNull(File::exists) } - private fun materializeBuiltInOverride(overrideId: String): File? { - if (BuiltInOverrideCatalog.find(overrideId) == null) return null - return builtInOverrideFiles.sync(overrideId) - } - + /** A blank internal override is stale: it is deleted and skipped. */ private fun resolveRuntimeInternalOverrideFile(overridesDir: File, profileUuid: String): File? { - val file = - overridesDir.resolve("configs/${INTERNAL_RUNTIME_PREFIX}-profile-$profileUuid.yaml") + val file = overridesDir.resolve("configs/$INTERNAL_RUNTIME_PREFIX-profile-$profileUuid.yaml") if (!file.exists()) return null - val content = runCatching { - file.readText() - } - .getOrElse { - error( - "Runtime override file unreadable id=${profileUuid.safeLogHash()} reason=${it.message.safeNativeDiagnostic()}" - ) - } + val content = + runCatching { file.readText() } + .getOrElse { + error( + "Runtime override file unreadable id=${profileUuid.sha256Short()} " + + "reason=${it.message.safeNativeDiagnostic()}" + ) + } if (content.isBlank()) { runCatching { file.delete() } return null @@ -396,43 +144,11 @@ class CompiledConfigPipeline(private val context: Context) { return file } - private fun isInternalRuntimeId(overrideId: String): Boolean = - overrideId.startsWith(INTERNAL_RUNTIME_PREFIX) - - private fun isLegacyPresetId(overrideId: String): Boolean = - overrideId.startsWith(LEGACY_PRESET_PREFIX) - - private fun describeOverrideFile(file: File, overrideId: String): String { - // Avoid reading full override bodies just for diagnostics on the start hot path. - return buildString { - append("override resolve: file id=") - append(overrideId) - append(" file=") - append(file.safeLogHash()) - append(" exists=") - append(file.exists()) - append(" size=") - append(if (file.exists()) file.length() else -1L) - append(" mtime=") - append(if (file.exists()) file.lastModified() else -1L) - } - } - - fun previewGroupNames(spec: RuntimeSpec, excludeNotSelectable: Boolean): List = - previewGroups(spec, excludeNotSelectable).map(ProxyGroup::name).filter(String::isNotBlank) - private fun File.toOverrideSpec(): OverrideSpec { - val extension = - extension.lowercase().ifBlank { - error("Override file missing extension") - } + val extension = extension.lowercase().ifBlank { error("Override file missing extension") } return OverrideSpec(path = absolutePath, ext = extension) } - private fun File.safeLogHash(): String = absolutePath.safeLogHash() - - private fun String.safeLogHash(): String = sha256Short() - private fun String?.safeNativeDiagnostic(): String { val raw = this?.takeIf(String::isNotBlank) ?: return "unknown" return "len=${raw.length} sha=${raw.sha256Short()}" @@ -444,47 +160,12 @@ class CompiledConfigPipeline(private val context: Context) { return digest.take(8).joinToString("") { "%02x".format(it) } } - data class ResolvedOverrideBundle( - val profileUuid: String, - val userOverrides: List, - val runtimeInternalOverride: OverrideSpec?, - val overrides: List, - ) - @Serializable private data class CompiledGroupConfig( @SerialName("proxy-groups") val proxyGroups: List = emptyList() ) - @Serializable - private data class CompiledProxyGroup( - val name: String = "", - val type: String = "", - val proxies: List = emptyList(), - val icon: String? = null, - val hidden: Boolean = false, - ) { - fun toProxyGroup(): ProxyGroup { - val runtimeType = type.toRuntimeProxyType() - return ProxyGroup( - name = name, - type = runtimeType, - proxies = - proxies.map { proxyName -> - Proxy( - name = proxyName, - title = proxyName, - subtitle = "", - type = Proxy.Type.Unknown, - delay = 0, - ) - }, - now = "", - icon = icon, - hidden = hidden, - ) - } - } + @Serializable private data class CompiledProxyGroup(val name: String = "") @Serializable private data class MetadataIndexPayload( @@ -498,38 +179,23 @@ class CompiledConfigPipeline(private val context: Context) { private data class ProfileChainPayload(val overrideIds: List = emptyList()) private companion object { - private const val TAG = "CompiledConfigPipeline" private val compilerJson = - kotlinx.serialization.json.Json { + Json { ignoreUnknownKeys = true encodeDefaults = true coerceInputValues = true } - private val pathPattern = Regex("""(?m)path:\s*["']?([^"'\n]+)["']?""") private val userOverrideExtensions = listOf("yaml", "yml", "js") const val INTERNAL_RUNTIME_PREFIX = "__runtime__" const val LEGACY_PRESET_PREFIX = "preset-" } } -private fun String.toRuntimeProxyType(): String = - when (lowercase()) { - "select" -> Proxy.Type.Selector - "url-test" -> Proxy.Type.URLTest - "fallback" -> Proxy.Type.Fallback - "load-balance" -> Proxy.Type.LoadBalance - "relay" -> Proxy.Type.Relay - "smart" -> Proxy.Type.Smart - else -> Proxy.Type.Unknown - } - private fun String.toOverrideExtension(): String? = when (lowercase()) { "yaml", "yml" -> "yaml" - "js", "javascript" -> "js" - else -> null } diff --git a/runtime/service/src/runtime/service/session/CompiledTunPackages.kt b/runtime/service/src/runtime/service/session/CompiledTunPackages.kt deleted file mode 100644 index e27b229d5..000000000 --- a/runtime/service/src/runtime/service/session/CompiledTunPackages.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.service.session - -/** - * Service-process holder for the compiled config's `tun.include-package` / `tun.exclude-package` - * lists. - * - * Single writer: [CompiledConfigPipeline] publishes via [update] after a successful native - * compile-and-load, and [SessionRuntime] clears via [clear] on core teardown so a stale profile's - * lists never leak into the next session. Single reader: [VpnTunTransport] reads the lists at - * `VpnService.Builder.establish()` time to apply per-app routing. - * - * This state is deliberately kept in-process (service process only) and NOT in the multi-process - * ServiceStore MMKV: the compiled config is loaded and consumed in the same process, and the lists - * must always mirror the currently loaded config, not a persisted snapshot. - */ -object CompiledTunPackages { - @Volatile - var includePackages: List = emptyList() - private set - - @Volatile - var excludePackages: List = emptyList() - private set - - /** Publishes the compiled lists; returns true when either list changed. */ - @Synchronized - fun update(include: List, exclude: List): Boolean { - val changed = include != includePackages || exclude != excludePackages - includePackages = include - excludePackages = exclude - return changed - } - - @Synchronized - fun clear() { - includePackages = emptyList() - excludePackages = emptyList() - } -} diff --git a/runtime/service/src/runtime/service/session/ConnectionTracker.kt b/runtime/service/src/runtime/service/session/ConnectionTracker.kt new file mode 100644 index 000000000..6d5012836 --- /dev/null +++ b/runtime/service/src/runtime/service/session/ConnectionTracker.kt @@ -0,0 +1,65 @@ +/* + * This file is part of YumeBox. + * + * YumeBox is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * Copyright (c) YumeYucca 2025 - Present + * + */ + +package com.github.yumeyucca.yumebox.runtime.service.session + +import android.content.Context +import com.github.yumeyucca.yumebox.core.domain.ConnectionHistoryManager +import com.github.yumeyucca.yumebox.core.util.AppVisibilityTracker +import com.github.yumeyucca.yumebox.core.util.PollingTimerSpecs +import com.github.yumeyucca.yumebox.core.util.PollingTimers +import com.github.yumeyucca.yumebox.runtime.service.core.CoreProcess +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * Feeds [ConnectionHistoryManager] from `/connections`, but only while an activity is visible: + * nothing reads the history in the background, so polling there only wakes the core. + */ +internal class ConnectionTracker( + private val context: Context, + private val scope: CoroutineScope, +) { + private var job: Job? = null + + fun start() { + stop() + job = + scope.launch(Dispatchers.IO) { + AppVisibilityTracker.isForeground.collectLatest { foreground -> + if (!foreground) return@collectLatest + PollingTimers.ticks(PollingTimerSpecs.SessionConnectionTracking).collect { + runCatching { + val snapshot = CoreProcess.controller(context).queryConnections() + ConnectionHistoryManager.updateConnections(snapshot.connections) + } + } + } + } + } + + fun stop() { + job?.cancel() + job = null + } +} diff --git a/runtime/service/src/runtime/service/session/ControllerReadiness.kt b/runtime/service/src/runtime/service/session/ControllerReadiness.kt new file mode 100644 index 000000000..bf8a8a301 --- /dev/null +++ b/runtime/service/src/runtime/service/session/ControllerReadiness.kt @@ -0,0 +1,72 @@ +/* + * This file is part of YumeBox. + * + * YumeBox is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * Copyright (c) YumeYucca 2025 - Present + * + */ + +package com.github.yumeyucca.yumebox.runtime.service.session + +import android.os.SystemClock +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout + +/** + * Startup wait shared by the VPN child and the root daemon: the core usually answers within a few + * tens of milliseconds, so the retry pause starts at 25ms and backs off to 100ms inside a 2s + * budget. Cancellation of the caller aborts the wait immediately. + */ +internal object ControllerReadiness { + private val BACKOFF_MS = longArrayOf(25L, 50L, 100L) + private const val TIMEOUT_MS = 2_000L + + /** + * Returns once [probe] reports ready. [ensureAlive] runs before every attempt and throws when + * the core process is already gone; [onTimeout] builds the failure from the last probe error. + */ + suspend fun await( + ensureAlive: () -> Unit, + probe: suspend () -> Boolean, + onTimeout: (lastError: Throwable?) -> Nothing, + ) { + val deadline = SystemClock.elapsedRealtime() + TIMEOUT_MS + var lastError: Throwable? = null + var attempt = 0 + while (true) { + ensureAlive() + val remaining = deadline - SystemClock.elapsedRealtime() + if (remaining <= 0L) onTimeout(lastError) + try { + if (withTimeout(remaining) { probe() }) return + } catch (error: TimeoutCancellationException) { + lastError = error + } catch (error: CancellationException) { + throw error + } catch (@Suppress("TooGenericExceptionCaught") error: Exception) { + lastError = error + } + val pause = + minOf( + BACKOFF_MS[minOf(attempt, BACKOFF_MS.lastIndex)], + deadline - SystemClock.elapsedRealtime(), + ) + attempt++ + if (pause > 0L) delay(pause) + } + } +} diff --git a/runtime/service/src/runtime/service/session/RootRuntime.kt b/runtime/service/src/runtime/service/session/RootRuntime.kt new file mode 100644 index 000000000..f0642cca6 --- /dev/null +++ b/runtime/service/src/runtime/service/session/RootRuntime.kt @@ -0,0 +1,121 @@ +/* + * This file is part of YumeBox. + * + * YumeBox is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * Copyright (c) YumeYucca 2025 - Present + * + */ + +package com.github.yumeyucca.yumebox.runtime.service.session + +import android.content.Context +import android.os.SystemClock +import com.github.yumeyucca.yumebox.core.model.RunMode +import com.github.yumeyucca.yumebox.core.util.StartupTaskCoordinator +import com.github.yumeyucca.yumebox.runtime.service.core.CoreProcess +import com.github.yumeyucca.yumebox.runtime.service.core.KernelManager +import com.github.yumeyucca.yumebox.runtime.service.core.RootDaemonProbe +import com.github.yumeyucca.yumebox.runtime.service.core.RootDaemonState +import com.github.yumeyucca.yumebox.runtime.service.log.RuntimeLog +import com.github.yumeyucca.yumebox.runtime.service.root.RootAccessSupport +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext + +/** + * Compiles the active profile for a root [RunMode.Tun] / [RunMode.Ebpf] daemon and swaps it in. + * The daemon that is already serving keeps serving through the compile; [CoreProcess.startRoot] + * reaps it under the lifecycle lock right before the new exec. + */ +internal object RootRuntime { + /** + * On failure the daemon this call launched is reaped again, so afterwards + * [RootDaemonProbe.trackedAlive] tells whether the previous daemon still serves. + */ + suspend fun start(context: Context, mode: RunMode) { + require(mode == RunMode.Tun || mode == RunMode.Ebpf) { + "RootRuntime handles root modes only, got $mode" + } + val log = RuntimeLog.writer(context, mode) + log.beginSession(RuntimeLog.Type.Launcher, "root start mode=${mode.name}") + val previousPid = RootDaemonState.load()?.pid + try { + withContext(Dispatchers.IO) { + RootAccessSupport.require() + if (mode == RunMode.Ebpf) { + check(KernelManager.isEbpfKernelActive(context)) { + "eBPF mode requires an active kernel with the ebpf capability" + } + } + } + StartupTaskCoordinator.awaitWarmup() + val spec = withContext(Dispatchers.IO) { + SessionRuntimeSpecFactory(context).createRootSpec(mode) + } + log.i( + RuntimeLog.Type.Launcher, + "profile=${spec.profileName} overrides=${spec.overrideSpecs.size}", + ) + val config = CompiledConfigPipeline(context).compile(spec).finalYaml + log.i(RuntimeLog.Type.Launcher, "compiled") + withContext(Dispatchers.IO) { CoreProcess(context).startRoot(mode.coreArg, config) } + awaitReady(context) + log.i(RuntimeLog.Type.Launcher, "success: root daemon running mode=${mode.name}") + } catch (error: Throwable) { + withContext(NonCancellable + Dispatchers.IO) { + if (RootDaemonState.load()?.pid != previousPid) { + runCatching { CoreProcess.stopRoot() } + } + } + if (error !is CancellationException) { + log.e(RuntimeLog.Type.Launcher, "root start failed", error) + } + throw error + } + } + + private suspend fun awaitReady(context: Context) { + var nextLivenessCheckAt = 0L + ControllerReadiness.await( + ensureAlive = { + // The socket probe is the ready signal; the shell check only fails fast once the + // process has exited, so it is rate-limited. + val now = SystemClock.elapsedRealtime() + if (now >= nextLivenessCheckAt) { + nextLivenessCheckAt = now + LIVENESS_INTERVAL_MS + if (!RootDaemonProbe.trackedAlive()) { + error(CoreProcess.coreLogTail(context) ?: "root core exited during startup") + } + } + }, + probe = { + CoreProcess.probeController(context) + true + }, + onTimeout = { lastError -> + val tail = CoreProcess.coreLogTail(context) + throw IllegalStateException( + "root core controller unavailable: " + + (lastError?.message ?: "startup timed out") + + (tail?.let { " ($it)" }.orEmpty()), + lastError, + ) + }, + ) + } + + private const val LIVENESS_INTERVAL_MS = 300L +} diff --git a/runtime/service/src/runtime/service/session/RootSessionLauncher.kt b/runtime/service/src/runtime/service/session/RootSessionLauncher.kt deleted file mode 100644 index 1b17632b5..000000000 --- a/runtime/service/src/runtime/service/session/RootSessionLauncher.kt +++ /dev/null @@ -1,204 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.service.session - -import android.content.Context -import android.content.Intent -import android.os.SystemClock -import com.github.yumeyucca.yumebox.core.model.RunMode -import com.github.yumeyucca.yumebox.data.store.RemoteControllerStore -import com.github.yumeyucca.yumebox.core.util.PollingTimerSpecs -import com.github.yumeyucca.yumebox.core.util.PollingTimers -import com.github.yumeyucca.yumebox.core.util.StartupTaskCoordinator -import com.github.yumeyucca.yumebox.core.util.requireBuiltinGeoAssets -import com.github.yumeyucca.yumebox.runtime.api.Intents -import com.github.yumeyucca.yumebox.runtime.api.appContextOrSelf -import com.github.yumeyucca.yumebox.runtime.service.RootForegroundService -import com.github.yumeyucca.yumebox.runtime.service.StatusProvider -import com.github.yumeyucca.yumebox.runtime.service.core.CoreProcess -import com.github.yumeyucca.yumebox.runtime.service.core.KernelManager -import com.github.yumeyucca.yumebox.runtime.service.log.RuntimeLog -import com.github.yumeyucca.yumebox.runtime.service.root.RootAccessSupport -import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -/** - * Launches a root [RunMode.Tun] or [RunMode.Ebpf] daemon. A foreground notification host tracks - * the detached `su` daemon, which remains independently driven over the REST socket - * ([CoreProcess.reconnectRoot]). Only an explicit [CoreProcess.stopRoot] kills the core. - */ -object RootSessionLauncher { - private val lifecycleMutex = Mutex() - - /** Compile the active profile for [mode] and launch the detached root daemon. */ - suspend fun start(context: Context, mode: RunMode) = - lifecycleMutex.withLock { - startLocked(context, mode) - } - - private suspend fun startLocked(context: Context, mode: RunMode) { - require(mode == RunMode.Tun || mode == RunMode.Ebpf) { - "RootSessionLauncher handles root modes only, got $mode" - } - val appContext = context.appContextOrSelf - if (RemoteControllerStore.isActive()) { - RuntimeLog.writer(appContext, mode) - .i(RuntimeLog.Type.Launcher, "skipped: remote controller active") - return - } - RootAccessSupport.requireRootAccess(appContext) - if (mode == RunMode.Ebpf) { - check(KernelManager.isEbpfKernelActive(appContext)) { - "eBPF mode requires an active kernel with the ebpf capability" - } - } - appContext.requireBuiltinGeoAssets() - val log = RuntimeLog.writer(appContext, mode) - log.beginSession(RuntimeLog.Type.Launcher, "root start mode=${mode.name}") - // Compile against the daemon that is still serving. startRoot reaps it under the - // lifecycle lock immediately before the new exec, so the gap is the swap, not the compile. - val servingMode = CoreProcess.rootDaemonMode()?.takeIf { CoreProcess.isTrackedRootProcessAlive() } - - RootForegroundService.start(appContext) - var launched = false - try { - StatusProvider.markRuntimeStarting(mode) - StartupTaskCoordinator.awaitWarmup() - val specFactory = SessionRuntimeSpecFactory(appContext) - val spec = specFactory.createRootSpec(mode) - log.i( - RuntimeLog.Type.Launcher, - "profile=${spec.profileName} overrides=${spec.overrideSpecs.size}", - ) - val compiled = CompiledConfigPipeline(appContext).compileDetailed(spec) - log.i(RuntimeLog.Type.Launcher, "compiled groups=${compiled.proxyGroupNames.size}") - CoreProcess(appContext).startRoot(mode.coreArg, compiled.finalYaml) - launched = true - awaitControllerReady(appContext) - log.i(RuntimeLog.Type.Launcher, "controller ready") - - StatusProvider.markRuntimeRunning(mode) - broadcast(appContext, Intents.actionRuntimeStarted(appContext.packageName)) - log.i(RuntimeLog.Type.Launcher, "success: root daemon running mode=${mode.name}") - } catch (error: Throwable) { - val stillServing = - servingMode != null && !launched && CoreProcess.isTrackedRootProcessAlive() - if (stillServing) { - StatusProvider.markRuntimeRunning(servingMode) - } else { - runCatching { CoreProcess.stopRoot() } - StatusProvider.markRuntimeFailed(mode, error.message) - RootForegroundService.stop(appContext) - } - log.e(RuntimeLog.Type.Launcher, "root start failed", error) - throw error - } - } - - /** - * VPN launch reaps a root daemon inside [CoreProcess]. The notification host follows the - * record: once that record is gone, this is the same idle transition as an explicit stop. - */ - fun releaseReapedHost(context: Context, modeBeforeLaunch: RunMode?) { - if (modeBeforeLaunch == null || CoreProcess.rootDaemonMode() != null) return - StatusProvider.markRuntimeIdle(modeBeforeLaunch) - RootForegroundService.stop(context.appContextOrSelf) - } - - /** Explicitly stop the daemon and release its status slot. */ - suspend fun stop(context: Context) { - lifecycleMutex.withLock { - stopLocked(context.appContextOrSelf, broadcastStopped = true) - } - } - - private fun stopLocked(context: Context, broadcastStopped: Boolean) { - val mode = CoreProcess.rootDaemonMode() - runCatching { CoreProcess.stopRoot() } - mode?.let { StatusProvider.markRuntimeIdle(it) } - RootForegroundService.stop(context) - if (broadcastStopped) { - broadcast(context, Intents.actionRuntimeStopped(context.packageName)) - } - } - - private fun broadcast(context: Context, action: String) { - runCatching { - context.sendBroadcast(Intent(action).setPackage(context.packageName)) - } - } - - private suspend fun awaitControllerReady(context: Context) { - val deadline = SystemClock.elapsedRealtime() + STARTUP_PROBE_TIMEOUT_MS - var lastError: Throwable? = null - var nextLivenessCheckAt = 0L - while (true) { - val remainingMillis = deadline - SystemClock.elapsedRealtime() - if (remainingMillis <= 0L) break - val result = - runCatching { - withTimeout(remainingMillis) { - CoreProcess.probeController(context) - } - } - if (result.isSuccess) return - lastError = result.exceptionOrNull() - - val now = SystemClock.elapsedRealtime() - if (now >= nextLivenessCheckAt) { - nextLivenessCheckAt = now + STARTUP_LIVENESS_INTERVAL_MS - // The socket probe is the ready signal. A shell liveness check is only there to - // fail fast once the process has actually exited. - if (!CoreProcess.isTrackedRootProcessAlive()) { - val reason = CoreProcess.coreLogTail(context) ?: "root core exited during startup" - error(reason) - } - } - - val retryDelay = - minOf( - STARTUP_PROBE_INTERVAL_MS, - deadline - SystemClock.elapsedRealtime(), - ) - if (retryDelay <= 0L) break - PollingTimers.awaitTick( - PollingTimerSpecs.dynamic( - name = "root_core_startup_probe", - intervalMillis = retryDelay, - initialDelayMillis = retryDelay, - ) - ) - } - - val tail = CoreProcess.coreLogTail(context) - throw IllegalStateException( - "root core controller unavailable: " + - (lastError?.message ?: lastError?.let { it::class.simpleName } ?: "startup timed out") + - (tail?.let { " ($it)" } ?: ""), - lastError, - ) - } - - private const val STARTUP_PROBE_INTERVAL_MS = 75L - private const val STARTUP_LIVENESS_INTERVAL_MS = 300L - private const val STARTUP_PROBE_TIMEOUT_MS = 2_000L -} diff --git a/runtime/service/src/runtime/service/session/RuntimeHost.kt b/runtime/service/src/runtime/service/session/RuntimeHost.kt deleted file mode 100644 index 7be828a96..000000000 --- a/runtime/service/src/runtime/service/session/RuntimeHost.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.service.session - -import android.content.Context -import com.github.yumeyucca.yumebox.core.model.LogMessage -import com.github.yumeyucca.yumebox.data.model.RunMode -import com.github.yumeyucca.yumebox.runtime.api.RuntimeSnapshot - -interface RuntimeHost { - val context: Context - val mode: RunMode - - fun onStarting(spec: RuntimeSpec) - - fun onStarted(spec: RuntimeSpec) - - fun onStopped(reason: String?) - - fun onProfileLoaded(profileUuid: String) - - fun restoreActiveProfile(profileUuid: String, profileName: String) - - fun onSnapshotChanged(snapshot: RuntimeSnapshot) - - fun onLogReady(ready: Boolean) - - fun onLogItem(log: LogMessage) {} - - fun reportFailure(error: String) -} diff --git a/runtime/service/src/runtime/service/session/RuntimeProxyGroupResolver.kt b/runtime/service/src/runtime/service/session/RuntimeProxyGroupResolver.kt deleted file mode 100644 index a519aca33..000000000 --- a/runtime/service/src/runtime/service/session/RuntimeProxyGroupResolver.kt +++ /dev/null @@ -1,262 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -@file:Suppress("RedundantSuspendModifier") - -package com.github.yumeyucca.yumebox.runtime.service.session - -import com.github.yumeyucca.yumebox.core.model.Proxy -import com.github.yumeyucca.yumebox.core.model.ProxyGroup -import com.github.yumeyucca.yumebox.core.model.ProxySort -import java.security.MessageDigest - -/** - * Resolves the proxy-group list shown in the UI. - * - * When a session is running the live core itself is the single source of truth for group **order + - * membership + live state**: the mihomo fork preserves the `proxy-groups:` declaration order - * (`config/config.go`), and native `QueryProxyGroupNames` walks the GLOBAL provider in that exact - * declaration order. Since the rust override path now loads the rawConfig correctly, the running - * core already yields the correct order with real-time `now` / `proxies`, so a canonical recompile - * is redundant while running. - * - * [canonicalGroups] (compiled rawConfig, `proxy-groups:` declaration order) is kept only as the - * preview source and as the fallback for the transient window before the core has loaded. - */ -class RuntimeProxyGroupResolver( - private val compiledConfigPipeline: CompiledConfigPipeline, - private val context: android.content.Context, -) { - private val rest - get() = com.github.yumeyucca.yumebox.runtime.service.core.CoreProcess.controller(context) - - private val expectedNameCacheLock = Any() - private var expectedNameCache: ExpectedGroupCache? = null - - private val canonicalCacheLock = Any() - private var canonicalCache: CanonicalGroupCache? = null - - /** - * Authoritative ordered group list straight from the compiled rawConfig (`proxy-groups:`). - * Always rebuilt from a fresh compile via [CompiledConfigPipeline.previewGroups] so it can - * never read a stale on-disk runtime.yaml; cached by the [CanonicalGroupKey] so the recompile - * only happens when the config/override set actually changes (fingerprint flip), not on every - * refresh. - * - * An empty result is NOT cached: a transient empty compile during the start window must not - * poison the cache for the rest of the session. - */ - fun canonicalGroups( - spec: RuntimeSpec, - excludeNotSelectable: Boolean, - ): List { - val cacheKey = - CanonicalGroupKey( - profileUuid = spec.profileUuid, - effectiveFingerprint = spec.effectiveFingerprint, - excludeNotSelectable = excludeNotSelectable, - ageSecretKeyFingerprint = sha256Short(spec.ageSecretKey), - ) - synchronized(canonicalCacheLock) { - canonicalCache - ?.takeIf { it.key == cacheKey } - ?.let { - return it.groups - } - } - - val groups = runCatching { - compiledConfigPipeline.previewGroups(spec, excludeNotSelectable) - }.getOrDefault(emptyList()).filter { it.name.isNotBlank() } - - if (groups.isNotEmpty()) { - synchronized(canonicalCacheLock) { - canonicalCache = CanonicalGroupCache(cacheKey, groups) - } - } - return groups - } - - fun expectedGroupNames(spec: RuntimeSpec, excludeNotSelectable: Boolean): List { - val cacheKey = - ExpectedGroupKey( - profileUuid = spec.profileUuid, - effectiveFingerprint = spec.effectiveFingerprint, - excludeNotSelectable = excludeNotSelectable, - ageSecretKeyFingerprint = sha256Short(spec.ageSecretKey), - ) - synchronized(expectedNameCacheLock) { - expectedNameCache - ?.takeIf { it.key == cacheKey } - ?.let { - return it.names - } - } - - // Every profile (encrypted and non-encrypted) goes through the native in-memory compile, so - // expected names never depend on an on-disk runtime.yaml. - val names = compiledConfigPipeline.previewGroupNames(spec, excludeNotSelectable) - - // Never cache an empty result: a transient empty compile during the start window must not - // pin the whole session to an empty expected-name set. - if (names.isNotEmpty()) { - synchronized(expectedNameCacheLock) { - expectedNameCache = ExpectedGroupCache(cacheKey, names) - } - } - return names - } - - fun runtimeGroupNames(excludeNotSelectable: Boolean): List = - rest.queryProxyGroupNames(excludeNotSelectable) - - fun resolvedGroupNames( - spec: RuntimeSpec?, - excludeNotSelectable: Boolean, - ): List { - // Running core is the source of truth: GLOBAL provider order == proxy-groups declaration - // order. - val coreNames = runtimeGroupNames(excludeNotSelectable).filter(String::isNotBlank) - if (coreNames.isNotEmpty()) { - return coreNames - } - // Core not loaded yet (preview / transient start window): fall back to the compiled - // rawConfig. - return spec - ?.let { canonicalGroups(it, excludeNotSelectable) } - .orEmpty() - .map(ProxyGroup::name) - .filter(String::isNotBlank) - } - - /** - * The ordered, complete group list. - * - * @param enrichLive when true (running session) the list is taken straight from the live core - * ([coreGroups]): it already carries the `proxy-groups:` declaration order plus real-time - * `now` / `proxies`, so no canonical recompile is needed. When the core is not loaded yet - * (transient start window) or when [enrichLive] is false (preview / not running), the - * compiled rawConfig ([canonicalGroups]) is used instead. - */ - fun resolvedGroups( - spec: RuntimeSpec?, - excludeNotSelectable: Boolean, - enrichLive: Boolean = true, - ): List { - if (enrichLive) { - val coreGroups = coreGroups(excludeNotSelectable) - if (coreGroups.isNotEmpty()) { - return coreGroups - } - } - // Preview / core not loaded yet: use the compiled rawConfig (no running core to read from). - return spec?.let { canonicalGroups(it, excludeNotSelectable) }.orEmpty() - } - - /** - * Groups straight from the live core, in GLOBAL-provider order (== `proxy-groups:` declaration - * order in the mihomo fork). Each name is resolved to a usable live group; names that cannot be - * resolved are skipped (they are not part of the live runtime). Returns empty when no core is - * loaded, which signals the caller to fall back to [canonicalGroups]. - */ - private fun coreGroups(excludeNotSelectable: Boolean): List { - val coreNamesByTrimmed = buildCoreNamesByTrimmed(excludeNotSelectable) - return runtimeGroupNames(excludeNotSelectable).mapNotNull { name -> - queryUsableGroup(name, coreNamesByTrimmed) - } - } - - private fun queryUsableGroup( - name: String, - coreNamesByTrimmed: Map, - ): ProxyGroup? { - rest.queryProxyGroup(name, ProxySort.Default).takeIf(::isUsable)?.let { - return it - } - - // The expected name was trimmed in the native compile but the core registers the raw key. - // Retry against the core's actual (untrimmed) key for this trimmed name. - val actualKey = coreNamesByTrimmed[name.trim()] - if (actualKey != null && actualKey != name) { - rest.queryProxyGroup(actualKey, ProxySort.Default).takeIf(::isUsable)?.let { - return it - } - } - return null - } - - private fun buildCoreNamesByTrimmed(excludeNotSelectable: Boolean): Map { - val coreNames = runtimeGroupNames(excludeNotSelectable) - val lookup = HashMap(coreNames.size) - coreNames.forEach { coreName -> - val key = coreName.trim() - if (key.isEmpty()) return@forEach - // Prefer an exact (already-trimmed) registration; only fill from an untrimmed twin when - // no exact key has been recorded for this trimmed name. - val existing = lookup[key] - if (existing == null || (existing != key && coreName == key)) { - lookup[key] = coreName - } - } - return lookup - } - - fun isUsable(group: ProxyGroup): Boolean = - group.name.isNotBlank() && - (group.type != Proxy.Type.Unknown || - group.proxies.isNotEmpty() || - group.now.isNotBlank() || - !group.icon.isNullOrBlank()) - - /** - * Short SHA-256 hash of the age secret key. The raw key is NEVER stored in the cache key; only - * this hash is. Returns the literal "none" when the profile is not age-encrypted so a - * null/non-null transition still invalidates the caches. - */ - private fun sha256Short(value: String?): String { - if (value == null) return "none" - val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray()) - return digest.take(8).joinToString("") { "%02x".format(it) } - } - - private data class ExpectedGroupKey( - val profileUuid: String, - val effectiveFingerprint: String, - val excludeNotSelectable: Boolean, - val ageSecretKeyFingerprint: String, - ) - - private data class ExpectedGroupCache( - val key: ExpectedGroupKey, - val names: List, - ) - - private data class CanonicalGroupKey( - val profileUuid: String, - val effectiveFingerprint: String, - val excludeNotSelectable: Boolean, - val ageSecretKeyFingerprint: String, - ) - - private data class CanonicalGroupCache( - val key: CanonicalGroupKey, - val groups: List, - ) -} diff --git a/runtime/service/src/runtime/service/session/RuntimeServiceLauncher.kt b/runtime/service/src/runtime/service/session/RuntimeServiceLauncher.kt deleted file mode 100644 index b7b00e01b..000000000 --- a/runtime/service/src/runtime/service/session/RuntimeServiceLauncher.kt +++ /dev/null @@ -1,126 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.service.session - -import android.content.Context -import android.content.Intent -import com.github.yumeyucca.yumebox.data.model.RunMode -import com.github.yumeyucca.yumebox.data.store.RemoteControllerStore -import com.github.yumeyucca.yumebox.core.util.requireBuiltinGeoAssets -import com.github.yumeyucca.yumebox.runtime.api.Intents -import com.github.yumeyucca.yumebox.runtime.api.RuntimePhase -import com.github.yumeyucca.yumebox.runtime.api.appContextOrSelf -import com.github.yumeyucca.yumebox.runtime.service.StatusProvider -import com.github.yumeyucca.yumebox.runtime.service.TunService -import com.github.yumeyucca.yumebox.runtime.service.log.RuntimeLog - -object RuntimeServiceLauncher { - const val EXTRA_REQUEST_SOURCE = "runtime_request_source" - - const val SOURCE_UI = "ui" - const val SOURCE_TILE = "tile" - const val SOURCE_AUTO_RESTART = "auto_restart" - const val SOURCE_AUTO_RESTART_BOOT = "auto_restart_boot" - const val SOURCE_AUTO_RESTART_REPLACED = "auto_restart_replaced" - const val SOURCE_WIFI_AUTOMATION = "wifi_automation" - const val SOURCE_UNKNOWN = "unknown" - - // Only [RunMode.VpnService] is service-hosted; the root Tun daemon launches via - // CoreProcess.startRoot (through RuntimeLauncher), not this launcher. - @Synchronized - fun start( - context: Context, - mode: RunMode = RunMode.VpnService, - source: String = SOURCE_UNKNOWN, - ) { - val appContext = context.appContextOrSelf - val log = RuntimeLog.writer(appContext, mode) - - if (RemoteControllerStore.isActive()) { - log.i(RuntimeLog.Type.Launcher, "skipped: remote controller active") - return - } - - // A redundant start against an already-running service would re-mark the persisted - // phase as Starting with nothing to flip it back to Running afterwards. - val currentPhase = StatusProvider.queryRuntimePhase(mode) - if (currentPhase == RuntimePhase.Running) { - log.i(RuntimeLog.Type.Launcher, "skipped: already running") - return - } - - if (StatusProvider.isRuntimeStartingWithinGrace(mode)) { - log.i(RuntimeLog.Type.Launcher, "skipped: already starting") - return - } - - // A Starting phase past the grace window with a live-but-stuck instance would swallow a new - // command - // (onCreate never re-runs). Ask it to recreate itself after releasing its old session, - // don't re-token. - if ( - StatusProvider.queryRuntimePhase(mode) == RuntimePhase.Starting && - StatusProvider.isLocalRuntimeServiceAlive(mode) - ) { - log.w(RuntimeLog.Type.Launcher, "stopping stale ${mode.name} runtime before restart") - appContext.sendBroadcast( - Intent(Intents.ACTION_RUNTIME_REQUEST_STOP) - .setPackage(appContext.packageName) - .putExtra(Intents.EXTRA_RESTART, true) - .putExtra(Intents.EXTRA_RUNTIME_MODE, mode.name) - ) - return - } - - appContext.requireBuiltinGeoAssets() - log.beginSession( - RuntimeLog.Type.Launcher, - "request start source=$source mode=${mode.name}", - ) - val sessionToken = StatusProvider.beginRuntimeSession(mode) - - val intent = - Intent(appContext, TunService::class.java).putExtra(EXTRA_REQUEST_SOURCE, source) - - runCatching { appContext.startForegroundService(intent) } - .onSuccess { log.i(RuntimeLog.Type.Launcher, "service start requested") } - .onFailure { error -> - StatusProvider.markRuntimeIdle(mode, sessionToken) - log.e(RuntimeLog.Type.Launcher, "service start request failed", error) - throw error - } - } - - @Synchronized - fun stop(context: Context, mode: RunMode = RunMode.VpnService) { - val appContext = context.appContextOrSelf - if (mode == RunMode.VpnService && StatusProvider.isLocalRuntimeServiceAlive(mode)) { - appContext.sendBroadcast( - Intent(Intents.ACTION_RUNTIME_REQUEST_STOP) - .setPackage(appContext.packageName) - .putExtra(Intents.EXTRA_RUNTIME_MODE, mode.name) - ) - return - } - runCatching { appContext.stopService(Intent(appContext, TunService::class.java)) } - StatusProvider.markRuntimeIdle(mode) - } -} diff --git a/runtime/service/src/runtime/service/session/RuntimeSpec.kt b/runtime/service/src/runtime/service/session/RuntimeSpec.kt index dcbc27977..2f515b1bc 100644 --- a/runtime/service/src/runtime/service/session/RuntimeSpec.kt +++ b/runtime/service/src/runtime/service/session/RuntimeSpec.kt @@ -22,45 +22,21 @@ package com.github.yumeyucca.yumebox.runtime.service.session import com.github.yumeyucca.yumebox.core.model.OverrideSpec import com.github.yumeyucca.yumebox.core.model.RunMode -import com.github.yumeyucca.yumebox.core.model.TunConfig -import com.github.yumeyucca.yumebox.runtime.api.RuntimeOwner -import kotlinx.serialization.Serializable -@Serializable data class RuntimeSpec( - val owner: RuntimeOwner, val profileUuid: String, val profileName: String, val profileDir: String, - val runtimeConfigPath: String = "", - val ageSecretKey: String? = null, - val overrideSpecs: List = emptyList(), - val runMode: RunMode = RunMode.VpnService, - val skipRuntimePatches: Boolean = false, + val ageSecretKey: String?, + val overrideSpecs: List, + val runMode: RunMode, + val skipRuntimePatches: Boolean, /** Compiles an inspect-only core configuration; never expose this as a user run mode. */ - val preview: Boolean = false, - val tunConfig: TunConfig? = null, - val staticPlanFingerprint: String = "", - val transportFingerprint: String = "", - val effectiveFingerprint: String = "", - val profileFingerprint: String = "", - /** - * Precompiled final mihomo YAML for this start/reload. When non-blank, transports and - * readiness checks must reuse it instead of calling nativeCompile again. - */ - val compiledFinalYaml: String = "", - /** Proxy-group names extracted from [compiledFinalYaml] (declaration order). */ - val expectedProxyGroupNames: List = emptyList(), + val preview: Boolean, ) -@Serializable -data class RuntimeOperationResult( - val success: Boolean, - val error: String? = null, -) - -@Serializable -data class RuntimeLogChunk( - val nextSeq: Long = 0L, - val items: List = emptyList(), +/** A spec together with its one compile result; everything downstream reuses this YAML. */ +class LoadedRuntime( + val spec: RuntimeSpec, + val config: String, ) diff --git a/runtime/service/src/runtime/service/session/RuntimeTransport.kt b/runtime/service/src/runtime/service/session/RuntimeTransport.kt deleted file mode 100644 index 95b57a5ee..000000000 --- a/runtime/service/src/runtime/service/session/RuntimeTransport.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.service.session - -interface RuntimeTransport { - fun prepare(spec: RuntimeSpec) {} - - fun start(spec: RuntimeSpec) - - fun stop() - - fun onNetworkChanged() {} -} diff --git a/runtime/service/src/runtime/service/session/ServiceNetworkObserver.kt b/runtime/service/src/runtime/service/session/ServiceNetworkObserver.kt deleted file mode 100644 index d3f2bd544..000000000 --- a/runtime/service/src/runtime/service/session/ServiceNetworkObserver.kt +++ /dev/null @@ -1,130 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.service.session - -import android.content.Context -import android.net.* -import android.os.Build -import com.github.yumeyucca.yumebox.runtime.service.util.asSocketAddressText -import java.net.InetAddress -import java.util.concurrent.ConcurrentHashMap - -private data class NetworkInfo( - @Volatile var losingMs: Long = 0, - @Volatile var dnsList: List = emptyList(), -) { - fun isAvailable(): Boolean = losingMs < System.currentTimeMillis() -} - -class ServiceNetworkObserver( - context: Context, - private val onNetworkChanged: (Network?) -> Unit = {}, -) { - private val networkInfos = ConcurrentHashMap() - private val connectivity = context.getSystemService(ConnectivityManager::class.java) - private var lastDnsList = emptyList() - - private val request = - NetworkRequest.Builder() - .apply { - addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) - addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - addCapability(NetworkCapabilities.NET_CAPABILITY_FOREGROUND) - } - addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) - } - .build() - - private val callback = - object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - networkInfos[network] = NetworkInfo() - updateDns() - onNetworkChanged(selectNetwork()) - } - - override fun onLosing(network: Network, maxMsToLive: Int) { - networkInfos[network]?.losingMs = System.currentTimeMillis() + maxMsToLive - updateDns() - onNetworkChanged(selectNetwork()) - } - - override fun onLost(network: Network) { - networkInfos.remove(network) - updateDns() - onNetworkChanged(selectNetwork()) - } - - override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) { - networkInfos[network]?.dnsList = linkProperties.dnsServers - updateDns() - onNetworkChanged(selectNetwork()) - } - } - - fun start() { - updateDns() - onNetworkChanged(selectNetwork()) - connectivity?.registerNetworkCallback(request, callback) - } - - fun stop() { - runCatching { connectivity?.unregisterNetworkCallback(callback) } - networkInfos.clear() - updateDns() - onNetworkChanged(null) - } - - private fun updateDns() { - val dnsList = selectDns().map { it.asSocketAddressText(53) } - if (dnsList == lastDnsList) return - lastDnsList = dnsList - // The out-of-process core resolves DNS from its own config; no in-process push. - } - - private fun selectDns(): List { - val entry = networkInfos.asSequence().minByOrNull { networkToScore(it) } - return entry?.value?.dnsList ?: emptyList() - } - - private fun selectNetwork(): Network? = - networkInfos.asSequence().minByOrNull { networkToScore(it) }?.key - - private fun networkToScore(entry: Map.Entry): Int { - val capabilities = connectivity?.getNetworkCapabilities(entry.key) - return when { - capabilities == null -> 100 - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> 90 - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 0 - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 1 - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_USB) -> 2 - - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) -> 3 - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 4 - Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_SATELLITE) -> 5 - - else -> 20 - } + if (entry.value.isAvailable()) 0 else 10 - } -} diff --git a/runtime/service/src/runtime/service/session/SessionRuntime.kt b/runtime/service/src/runtime/service/session/SessionRuntime.kt deleted file mode 100644 index 07044d897..000000000 --- a/runtime/service/src/runtime/service/session/SessionRuntime.kt +++ /dev/null @@ -1,739 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.service.session - -import android.os.SystemClock -import com.github.yumeyucca.yumebox.core.model.ProxyGroup -import com.github.yumeyucca.yumebox.core.model.UiConfiguration -import com.github.yumeyucca.yumebox.runtime.api.RuntimeOwner -import com.github.yumeyucca.yumebox.runtime.api.RuntimePhase -import com.github.yumeyucca.yumebox.runtime.api.RuntimeSnapshot -import com.github.yumeyucca.yumebox.runtime.api.appContextOrSelf -import com.github.yumeyucca.yumebox.runtime.service.core.CoreProcess -import com.github.yumeyucca.yumebox.runtime.service.log.RuntimeLog -import kotlinx.coroutines.* -import java.util.concurrent.Semaphore -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.math.min - -class SessionRuntime( - private val host: RuntimeHost, - private val transport: RuntimeTransport, - private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), -) { - private val compiledConfigPipeline = CompiledConfigPipeline(host.context.appContextOrSelf) - - private val rest - get() = - CoreProcess.controller( - host.context.appContextOrSelf - ) - - private val proxyGroupResolver = - RuntimeProxyGroupResolver(compiledConfigPipeline, host.context.appContextOrSelf) - private val lock = Any() - private val interruptSignal = Semaphore(0) - private val snapshotLock = Any() - - @Volatile private var interruptReason: String? = null - @Volatile private var currentSpec: RuntimeSpec? = null - @Volatile private var currentSnapshot: RuntimeSnapshot = RuntimeSnapshot(runMode = host.mode) - private val observers = SessionRuntimeObservers(host.context, transport) - @Volatile private var snapshotRefreshJob: Job? = null - @Volatile private var coreWatchJob: Job? = null - private val failureReported = AtomicBoolean(false) - private val queryCache = SessionRuntimeQueryCache() - private val telemetry = - SessionRuntimeTelemetry(host = host, scope = scope) { ready -> - updateSnapshot { it.copy(logReady = ready) } - } - - fun start(spec: RuntimeSpec): RuntimeOperationResult = startFresh(spec, name = "start") - - fun reload(spec: RuntimeSpec): RuntimeOperationResult { - val previous = currentSpec - return runGuarded( - spec, - name = "reload", - onFailure = { message, error -> - previous?.let { - host.restoreActiveProfile(it.profileUuid, it.profileName) - host.onProfileLoaded(it.profileUuid) - } - startupError(spec, "reload failed: $message", error) - }, - ) { - startupLog(spec, "reload begin") - reloadInternal(spec) - } - } - - fun restart(spec: RuntimeSpec): RuntimeOperationResult = startFresh(spec, name = "restart") - - private fun startFresh(spec: RuntimeSpec, name: String): RuntimeOperationResult = - runGuarded( - spec, - name = name, - onFailure = { message, error -> rollback(spec, message, error, name) }, - ) { - failureReported.set(false) - stopInternal(reason = null, notifyHost = false) - startupLog(spec, "$name begin") - startInternal(spec) - } - - /** - * Shared interrupt-aware wrapper for the lifecycle entry points: serializes on [lock], treats - * [RuntimeInterruptedException] as a successful no-op, and routes any other failure through - * [onFailure] before surfacing it in the result. - * - * [onFailure] receives the raw [Throwable] as well as the flattened message: the message alone - * is routinely a generic wrapper, while the cause chain holds the reason the log needs. - */ - private fun runGuarded( - spec: RuntimeSpec, - name: String, - onFailure: (String, Throwable) -> Unit, - body: () -> Unit, - ): RuntimeOperationResult = - synchronized(lock) { - clearInterruptRequest() - runCatching { - body() - RuntimeOperationResult(success = true) - } - .getOrElse { error -> - if (error is RuntimeInterruptedException) { - startupLog(spec, "$name interrupted reason=${error.message}") - RuntimeOperationResult(success = true) - } else { - val message = error.message?.takeIf(String::isNotBlank) - ?: "$name runtime failed" - onFailure(message, error) - RuntimeOperationResult(success = false, error = message) - } - } - } - - fun stop(reason: String? = null): RuntimeOperationResult { - requestStop(reason) - return synchronized(lock) { - runCatching { - stopInternal(reason = reason, notifyHost = true) - RuntimeOperationResult(success = true) - } - .getOrElse { error -> - RuntimeOperationResult( - success = false, - error = error.message ?: "stop runtime failed", - ) - } - } - } - - fun requestStop(reason: String? = null) { - interruptReason = reason ?: "runtime stop requested" - stopCoreWatch() - currentSpec?.let { startupLog(it, "stop requested") } - snapshotRefreshJob?.cancel() - interruptSignal.release() - } - - fun destroy() { - requestStop("runtime destroyed") - synchronized(lock) { - runCatching { stopInternal(reason = "runtime destroyed", notifyHost = false) } - } - scope.cancel() - } - - fun snapshot(): RuntimeSnapshot = currentSnapshot - - - private fun startInternal(spec: RuntimeSpec) { - val startedAt = System.currentTimeMillis() - val startedAtNanos = SystemClock.elapsedRealtimeNanos() - val prepared = prepareCompiledSpec(spec) - startupLog(prepared, "compiled elapsedMs=${elapsedMillis(startedAtNanos)}") - currentSpec = prepared - publishSnapshot( - RuntimeSnapshot( - owner = prepared.owner, - phase = RuntimePhase.Starting, - runMode = host.mode, - profileUuid = prepared.profileUuid, - profileName = prepared.profileName, - profileReady = true, - startedAt = startedAt, - effectiveFingerprint = prepared.effectiveFingerprint, - ) - ) - host.onStarting(prepared) - ensureNotInterrupted(prepared) - - claimCoreAndTeardownPrevious() - ensureNotInterrupted(prepared) - startObservers() - startConnectionTracking() - ensureNotInterrupted(prepared) - - transport.prepare(prepared) - transport.start(prepared) - startCoreWatch(prepared) - startupLog( - prepared, - "core launched elapsedMs=${elapsedMillis(startedAtNanos)} " + - "profile=${prepared.profileName} overrides=${prepared.overrideSpecs.size}", - ) - val initialGroups = awaitProxyGroupsReady(prepared) - startupLog( - prepared, - "controller ready elapsedMs=${elapsedMillis(startedAtNanos)} groups=${initialGroups.size}", - ) - ensureNotInterrupted(prepared) - queryCache.replaceProxyGroups(initialGroups) - startLogStream() - - updateSnapshot { - it.copy( - phase = RuntimePhase.Running, - profileReady = true, - groupsReady = initialGroups.isNotEmpty(), - trafficReady = false, - configReady = true, - transportReady = true, - logReady = telemetry.isLogStreaming(), - startedAt = startedAt, - effectiveFingerprint = prepared.effectiveFingerprint, - ) - } - host.onProfileLoaded(prepared.profileUuid) - host.onStarted(prepared) - startupLog(prepared, "success: started elapsedMs=${elapsedMillis(startedAtNanos)}") - scheduleRuntimeSnapshotRefresh(prepared) - } - - private fun reloadInternal(spec: RuntimeSpec) { - val previous = checkNotNull(currentSpec) { "runtime not started" } - val previousSnapshot = currentSnapshot - failureReported.set(false) - val prepared = prepareCompiledSpec(spec) - updateSnapshot { - it.copy( - phase = RuntimePhase.Starting, - profileUuid = prepared.profileUuid, - profileName = prepared.profileName, - effectiveFingerprint = prepared.effectiveFingerprint, - groupsReady = false, - trafficReady = false, - ) - } - - stopCoreWatch() - stopLogStream() - runCatching { transport.stop() } - try { - currentSpec = prepared - transport.start(prepared) - startCoreWatch(prepared) - val initialGroups = awaitProxyGroupsReady(prepared) - ensureNotInterrupted(prepared) - queryCache.replaceProxyGroups(initialGroups) - startLogStream() - updateSnapshot { - it.copy( - phase = RuntimePhase.Running, - profileReady = true, - groupsReady = initialGroups.isNotEmpty(), - trafficReady = false, - configReady = true, - transportReady = true, - logReady = telemetry.isLogStreaming(), - effectiveFingerprint = prepared.effectiveFingerprint, - lastError = null, - ) - } - host.onProfileLoaded(prepared.profileUuid) - startupLog(prepared, "success: reload done") - scheduleRuntimeSnapshotRefresh(prepared) - } catch (reloadError: Throwable) { - if (reloadError is CancellationException || interruptReason != null) throw reloadError - restorePreviousAfterReloadFailure(previous, previousSnapshot, reloadError) - throw reloadError - } - } - - private fun restorePreviousAfterReloadFailure( - previous: RuntimeSpec, - previousSnapshot: RuntimeSnapshot, - reloadError: Throwable, - ) { - stopCoreWatch() - runCatching { transport.stop() } - val restoreResult = - runCatching { - currentSpec = previous - transport.start(previous) - startCoreWatch(previous) - val groups = awaitProxyGroupsReady(previous) - ensureNotInterrupted(previous) - queryCache.replaceProxyGroups(groups) - startLogStream() - publishSnapshot( - previousSnapshot.copy( - phase = RuntimePhase.Running, - groupsReady = groups.isNotEmpty(), - trafficReady = false, - transportReady = true, - logReady = telemetry.isLogStreaming(), - ) - ) - scheduleRuntimeSnapshotRefresh(previous) - } - restoreResult.onSuccess { - startupError(previous, "reload rejected; previous config restored", reloadError) - } - restoreResult.exceptionOrNull()?.let { restoreError -> - val reason = - "reload failed and previous runtime restore failed: " + - (restoreError.message ?: restoreError::class.java.simpleName) - rollback(previous, reason, restoreError, "reload") - throw IllegalStateException(reason, reloadError) - } - } - - private fun stopInternal(reason: String?, notifyHost: Boolean) { - stopCoreWatch() - snapshotRefreshJob?.cancel() - snapshotRefreshJob = null - if (currentSnapshot.phase == RuntimePhase.Idle && currentSpec == null) { - clearInterruptRequest() - return - } - - updateSnapshot { - it.copy( - phase = - if (it.phase == RuntimePhase.Idle) { - RuntimePhase.Idle - } else { - RuntimePhase.Stopping - }, - transportReady = false, - groupsReady = false, - trafficReady = false, - configReady = false, - logReady = false, - lastError = reason, - ) - } - stopLogStream() - stopConnectionTracking() - stopObservers() - teardownTransportAndCore() - currentSpec = null - queryCache.clear() - publishSnapshot( - RuntimeSnapshot( - owner = RuntimeOwner.None, - phase = RuntimePhase.Idle, - runMode = host.mode, - lastError = reason, - ) - ) - if (notifyHost) { - host.onStopped(reason) - } - clearInterruptRequest() - } - - private fun rollback( - spec: RuntimeSpec, - reason: String, - error: Throwable? = null, - operation: String, - ) { - stopCoreWatch() - stopLogStream() - stopConnectionTracking() - stopObservers() - teardownTransportAndCore() - currentSpec = null - queryCache.clear() - publishSnapshot( - RuntimeSnapshot( - owner = spec.owner, - phase = RuntimePhase.Failed, - runMode = host.mode, - profileUuid = spec.profileUuid, - profileName = spec.profileName, - profileReady = false, - lastError = reason, - effectiveFingerprint = spec.effectiveFingerprint, - ) - ) - startupError(spec, "$operation failed: $reason", error) - if (failureReported.compareAndSet(false, true)) { - host.reportFailure(reason) - } - } - - private fun startCoreWatch(spec: RuntimeSpec) { - stopCoreWatch() - if (spec.owner != RuntimeOwner.VpnService) return - coreWatchJob = - scope.launch(Dispatchers.IO) { - while (isActive) { - delay(CORE_WATCH_INTERVAL_MS) - if (currentSpec !== spec || interruptReason != null) return@launch - if ( - currentSnapshot.phase == RuntimePhase.Idle || - currentSnapshot.phase == RuntimePhase.Stopping || - currentSnapshot.phase == RuntimePhase.Failed - ) { - return@launch - } - if (CoreProcess.isLocalCoreAlive()) { - continue - } - - val coreTail = CoreProcess.coreLogTail(host.context.appContextOrSelf) - val reason = - "core process exited unexpectedly" + - (coreTail?.let { ": $it" } ?: "") - if (!failureReported.compareAndSet(false, true)) return@launch - - interruptReason = reason - interruptSignal.release() - publishSnapshot( - RuntimeSnapshot( - owner = spec.owner, - phase = RuntimePhase.Failed, - runMode = host.mode, - profileUuid = spec.profileUuid, - profileName = spec.profileName, - lastError = reason, - effectiveFingerprint = spec.effectiveFingerprint, - ) - ) - startupError(spec, reason) - host.reportFailure(reason) - return@launch - } - } - } - - private fun stopCoreWatch() { - coreWatchJob?.cancel() - coreWatchJob = null - } - - private fun awaitProxyGroupsReady(spec: RuntimeSpec): List { - ensureNotInterrupted(spec) - val expectedGroups = readExpectedGroupNames(spec) - verifyLog( - spec, - "expectedGroups=${expectedGroups.size}" + - expectedGroups - .takeIf { it.isNotEmpty() } - ?.let { " sample=${it.take(5)}" } - .orEmpty(), - ) - - var lastControllerError: String? = null - repeat(PROXY_GROUP_READY_RETRY_COUNT) { attempt -> - ensureNotInterrupted(spec) - - if ( - spec.owner == RuntimeOwner.VpnService && - !CoreProcess.isLocalCoreAlive() - ) { - val coreTail = - CoreProcess.coreLogTail( - host.context.appContextOrSelf - ) - error( - "core process exited before controller became ready" + - (coreTail?.let { " ($it)" } ?: "") - ) - } - - val groups = runCatching { - rest.queryAllProxyGroups(false) - } - .onFailure { error -> - lastControllerError = error.message ?: error::class.simpleName - } - .getOrDefault(emptyList()) - val names = groups.map(ProxyGroup::name).filter(String::isNotBlank) - if (names.isNotEmpty()) { - verifyLog(spec, "success: actualGroups=${names.size} sample=${names.take(5)}") - return groups - } - val tunnelOk = runCatching { - rest.queryTunnelState() - true - } - .onFailure { error -> - lastControllerError = error.message ?: error::class.simpleName - } - .getOrDefault(false) - if (tunnelOk && expectedGroups.isEmpty()) { - verifyLog(spec, "success: controller ok with 0 expected groups") - return emptyList() - } - if (attempt == 0 || attempt == PROXY_GROUP_READY_RETRY_COUNT - 1) { - verifyWarn( - spec, - "actualGroups=0 controller=${if (tunnelOk) "ok" else "down"}" + - (lastControllerError?.let { " err=$it" } ?: "") + - " attempt=${attempt + 1}/$PROXY_GROUP_READY_RETRY_COUNT", - ) - } - if (attempt < PROXY_GROUP_READY_RETRY_COUNT - 1) { - waitForRetryOrInterrupt(PROXY_GROUP_READY_RETRY_DELAY_MS) - } - } - - ensureNotInterrupted(spec) - val coreTail = - CoreProcess.coreLogTail( - host.context.appContextOrSelf - ) - if (expectedGroups.isNotEmpty()) { - error( - "runtime loaded but exposed 0 proxy groups; expected=${expectedGroups.size} " + - "sample=${expectedGroups.take(min(5, expectedGroups.size))}" + - (coreTail?.let { " core=$it" } ?: "") - ) - } - error( - "core controller unavailable or exposed 0 proxy groups during startup" + - (lastControllerError?.let { ": $it" } ?: "") + - (coreTail?.let { " ($it)" } ?: "") - ) - } - - private fun scheduleRuntimeSnapshotRefresh(spec: RuntimeSpec) { - snapshotRefreshJob?.cancel() - snapshotRefreshJob = - scope.launch(Dispatchers.IO) { - refreshRuntimeSnapshot(spec) - } - } - - private fun readExpectedGroupNames(spec: RuntimeSpec): List { - if (spec.expectedProxyGroupNames.isNotEmpty()) { - return spec.expectedProxyGroupNames - } - if (spec.compiledFinalYaml.isNotBlank()) { - return compiledConfigPipeline.extractProxyGroupNames(spec.compiledFinalYaml) - } - return runCatching { proxyGroupResolver.expectedGroupNames(spec, false) } - .getOrElse { error -> - verifyWarn(spec, "expected group inspect failed: ${error.message}") - emptyList() - } - } - - /** - * Ensures [RuntimeSpec.compiledFinalYaml] / [RuntimeSpec.expectedProxyGroupNames] are populated - * with a single nativeCompile so transport handoff and readiness share the same result. - */ - private fun prepareCompiledSpec(spec: RuntimeSpec): RuntimeSpec { - if (spec.compiledFinalYaml.isNotBlank()) { - if (spec.expectedProxyGroupNames.isNotEmpty()) { - return spec - } - val names = compiledConfigPipeline.extractProxyGroupNames(spec.compiledFinalYaml) - return spec.copy(expectedProxyGroupNames = names) - } - val compiled = - kotlinx.coroutines.runBlocking { compiledConfigPipeline.compileDetailed(spec) } - startupLog( - spec, - "compiled once groups=${compiled.proxyGroupNames.size} warnings=${compiled.warnings.size}", - ) - return spec.copy( - compiledFinalYaml = compiled.finalYaml, - expectedProxyGroupNames = compiled.proxyGroupNames, - ) - } - - private fun waitForRetryOrInterrupt(delayMs: Long) { - if (interruptReason == null) { - interruptSignal.tryAcquire(delayMs, TimeUnit.MILLISECONDS) - } - } - - private fun startObservers() = observers.start() - - private fun stopObservers() = observers.stop() - - private fun claimCoreAndTeardownPrevious() { - coreOwner = this - teardownCore() - } - - private fun teardownTransportAndCore() { - runCatching { transport.stop() } - teardownCore() - } - - private fun teardownCore() { - val owner = coreOwner - if (owner !== this && owner != null) return - coreOwner = null - CompiledTunPackages.clear() - } - - private fun refreshRuntimeSnapshot(expectedSpec: RuntimeSpec? = currentSpec) { - if ( - currentSnapshot.phase != RuntimePhase.Running && - currentSnapshot.phase != RuntimePhase.Starting - ) { - queryCache.clear() - return - } - - val configuration = runCatching { - rest.queryConfiguration() - }.getOrDefault(UiConfiguration()) - val providers = runCatching { rest.queryProviders() }.getOrDefault(emptyList()) - val proxyGroups = runCatching { - resolveRuntimeProxyGroups(excludeNotSelectable = false) - }.getOrDefault(emptyList()) - val trafficNow = runCatching { rest.queryTrafficNow() }.getOrDefault(0L) - val trafficTotal = runCatching { rest.queryTrafficTotal() }.getOrDefault(0L) - if (currentSpec !== expectedSpec || currentSnapshot.phase != RuntimePhase.Running) { - return - } - queryCache.replace( - configuration = configuration, - providers = providers, - proxyGroups = proxyGroups, - trafficNow = trafficNow, - trafficTotal = trafficTotal, - ) - } - - private fun resolveRuntimeProxyGroups(excludeNotSelectable: Boolean): List = - proxyGroupResolver.resolvedGroups(currentSpec, excludeNotSelectable, enrichLive = true) - - private fun ensureRuntimeSnapshot(): SessionRuntimeQuerySnapshot { - val snapshot = queryCache.snapshot() - if (snapshot.proxyGroups.isNotEmpty()) { - return snapshot - } - refreshRuntimeSnapshot() - return queryCache.snapshot() - } - - private fun startLogStream() { - telemetry.startLogStream(rest::subscribeLogs) - } - - private fun stopLogStream() { - telemetry.stopLogStream() - } - - private fun startConnectionTracking() { - telemetry.startConnectionTracking() - } - - private fun stopConnectionTracking() { - telemetry.stopConnectionTracking() - } - - private fun publishSnapshot(snapshot: RuntimeSnapshot) { - synchronized(snapshotLock) { - currentSnapshot = snapshot.copy(running = snapshot.phase.running) - host.onSnapshotChanged(currentSnapshot) - } - } - - private inline fun updateSnapshot(transform: (RuntimeSnapshot) -> RuntimeSnapshot) { - synchronized(snapshotLock) { - val next = transform(currentSnapshot) - currentSnapshot = next.copy(running = next.phase.running) - host.onSnapshotChanged(currentSnapshot) - } - } - - /** Null for owners that run no local session of their own (remote controller / idle). */ - private fun logWriter(spec: RuntimeSpec): RuntimeLog.Writer? { - val source = - when (spec.owner) { - RuntimeOwner.VpnService -> RuntimeLog.Source.LocalTun - RuntimeOwner.RootDaemon -> RuntimeLog.Source.forMode(spec.runMode) - RuntimeOwner.RemoteController, - RuntimeOwner.None -> return null - } - return RuntimeLog.writer(host.context.appContextOrSelf, source) - } - - private fun startupLog(spec: RuntimeSpec, message: String) { - logWriter(spec)?.i(RuntimeLog.Type.Session, message) - } - - private fun startupError(spec: RuntimeSpec, message: String, error: Throwable? = null) { - logWriter(spec)?.e(RuntimeLog.Type.Session, message, error) - } - - private fun verifyLog(spec: RuntimeSpec, message: String) { - logWriter(spec)?.i(RuntimeLog.Type.Verify, message) - } - - private fun verifyWarn(spec: RuntimeSpec, message: String) { - logWriter(spec)?.w(RuntimeLog.Type.Verify, message) - } - - private fun elapsedMillis(startedAtNanos: Long): Long = - (SystemClock.elapsedRealtimeNanos() - startedAtNanos) / 1_000_000L - - private fun ensureNotInterrupted(spec: RuntimeSpec) { - val reason = interruptReason ?: return - startupLog(spec, "session: interrupted reason=$reason") - if (failureReported.get()) { - throw RuntimeFailureException(reason) - } - throw RuntimeInterruptedException(reason) - } - - private fun clearInterruptRequest() { - interruptReason = null - interruptSignal.drainPermits() - } - - private companion object { - private const val PROXY_GROUP_READY_RETRY_COUNT = 10 - private const val PROXY_GROUP_READY_RETRY_DELAY_MS = 200L - private const val CORE_WATCH_INTERVAL_MS = 500L - - /** The session instance currently owning the process-wide Go core. */ - @Volatile private var coreOwner: SessionRuntime? = null - } - - private class RuntimeInterruptedException(message: String) : CancellationException(message) - - private class RuntimeFailureException(message: String) : IllegalStateException(message) -} diff --git a/runtime/service/src/runtime/service/session/SessionRuntimeObservers.kt b/runtime/service/src/runtime/service/session/SessionRuntimeObservers.kt deleted file mode 100644 index 46f1d772a..000000000 --- a/runtime/service/src/runtime/service/session/SessionRuntimeObservers.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * This file is part of YumeBox. - * - * Copyright (c) YumeYucca 2025 - Present - */ - -package com.github.yumeyucca.yumebox.runtime.service.session - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import androidx.core.content.ContextCompat -import com.github.yumeyucca.yumebox.runtime.api.appContextOrSelf - -internal class SessionRuntimeObservers( - context: Context, - private val transport: RuntimeTransport, -) { - private val appContext = context.appContextOrSelf - private var networkObserver: ServiceNetworkObserver? = null - private var timeZoneReceiver: BroadcastReceiver? = null - - fun start() { - if (networkObserver == null) { - networkObserver = - ServiceNetworkObserver(appContext) { transport.onNetworkChanged() }.also { it.start() } - } - if (timeZoneReceiver == null) { - val receiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) = Unit - } - ContextCompat.registerReceiver( - appContext, - receiver, - IntentFilter(Intent.ACTION_TIMEZONE_CHANGED), - ContextCompat.RECEIVER_NOT_EXPORTED, - ) - timeZoneReceiver = receiver - } - } - - fun stop() { - runCatching { networkObserver?.stop() } - networkObserver = null - timeZoneReceiver?.let { receiver -> runCatching { appContext.unregisterReceiver(receiver) } } - timeZoneReceiver = null - } -} diff --git a/runtime/service/src/runtime/service/session/SessionRuntimeQueryCache.kt b/runtime/service/src/runtime/service/session/SessionRuntimeQueryCache.kt deleted file mode 100644 index d62bf56ee..000000000 --- a/runtime/service/src/runtime/service/session/SessionRuntimeQueryCache.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.service.session - -import com.github.yumeyucca.yumebox.core.model.Provider -import com.github.yumeyucca.yumebox.core.model.ProxyGroup -import com.github.yumeyucca.yumebox.core.model.UiConfiguration - -internal class SessionRuntimeQueryCache { - private var snapshot = SessionRuntimeQuerySnapshot() - - fun clear() { - snapshot = SessionRuntimeQuerySnapshot() - } - - fun snapshot(): SessionRuntimeQuerySnapshot = snapshot - - fun replace( - configuration: UiConfiguration, - providers: List, - proxyGroups: List, - trafficNow: Long, - trafficTotal: Long, - ) { - snapshot = - SessionRuntimeQuerySnapshot( - configuration = configuration, - providers = providers, - proxyGroups = proxyGroups, - trafficNow = trafficNow, - trafficTotal = trafficTotal, - ) - } - - fun updateTrafficNow(trafficNow: Long) { - snapshot = snapshot.copy(trafficNow = trafficNow) - } - - fun updateTrafficTotal(trafficTotal: Long) { - snapshot = snapshot.copy(trafficTotal = trafficTotal) - } - - fun replaceProxyGroups(proxyGroups: List) { - snapshot = snapshot.copy(proxyGroups = proxyGroups) - } - - fun upsertProxyGroup(name: String, proxyGroup: ProxyGroup) { - val currentGroups = snapshot.proxyGroups - snapshot = - snapshot.copy( - proxyGroups = - if (currentGroups.any { it.name == name }) { - currentGroups.map { if (it.name == name) proxyGroup else it } - } else { - currentGroups + proxyGroup - } - ) - } -} - -internal data class SessionRuntimeQuerySnapshot( - val proxyGroups: List = emptyList(), - val configuration: UiConfiguration = UiConfiguration(), - val providers: List = emptyList(), - val trafficNow: Long = 0L, - val trafficTotal: Long = 0L, -) diff --git a/runtime/service/src/runtime/service/session/SessionRuntimeSpecFactory.kt b/runtime/service/src/runtime/service/session/SessionRuntimeSpecFactory.kt index 353432116..c00e32d89 100644 --- a/runtime/service/src/runtime/service/session/SessionRuntimeSpecFactory.kt +++ b/runtime/service/src/runtime/service/session/SessionRuntimeSpecFactory.kt @@ -21,21 +21,17 @@ package com.github.yumeyucca.yumebox.runtime.service.session import android.content.Context -import com.github.yumeyucca.yumebox.core.model.OverrideSpec import com.github.yumeyucca.yumebox.core.model.RunMode import com.github.yumeyucca.yumebox.core.model.TunConfig import com.github.yumeyucca.yumebox.data.store.MMKVProvider import com.github.yumeyucca.yumebox.data.store.NetworkSettingsStore -import com.github.yumeyucca.yumebox.runtime.api.RuntimeOwner import com.github.yumeyucca.yumebox.runtime.api.appContextOrSelf import com.github.yumeyucca.yumebox.runtime.service.config.AccessControlMode import com.github.yumeyucca.yumebox.runtime.service.config.ServiceStore +import com.github.yumeyucca.yumebox.runtime.service.profile.Imported import com.github.yumeyucca.yumebox.runtime.service.profile.ImportedDao import com.github.yumeyucca.yumebox.runtime.service.root.RootPackageShell -import com.github.yumeyucca.yumebox.runtime.service.util.directoryLastModified import com.github.yumeyucca.yumebox.runtime.service.util.importedDir -import java.io.File -import java.security.MessageDigest class SessionRuntimeSpecFactory( context: Context, @@ -47,18 +43,14 @@ class SessionRuntimeSpecFactory( NetworkSettingsStore(MMKVProvider().getMMKV("network_settings")) } - fun createVpnSpec(): RuntimeSpec = createSpec(RuntimeOwner.VpnService, RunMode.VpnService) + fun createVpnSpec(): RuntimeSpec = createSpec(RunMode.VpnService) - fun createRootSpec(runMode: RunMode): RuntimeSpec = createSpec(RuntimeOwner.RootDaemon, runMode) + fun createRootSpec(runMode: RunMode): RuntimeSpec = createSpec(runMode) /** A local, no-TUN core used only to materialize proxy-group state while the app is foregrounded. */ - fun createPreviewSpec(): RuntimeSpec = createSpec(RuntimeOwner.VpnService, RunMode.VpnService, preview = true) + fun createPreviewSpec(): RuntimeSpec = createSpec(RunMode.VpnService, preview = true) - private fun createSpec( - owner: RuntimeOwner, - runMode: RunMode, - preview: Boolean = false, - ): RuntimeSpec { + private fun createSpec(runMode: RunMode, preview: Boolean = false): RuntimeSpec { val profile = requireActiveProfile() val profileDir = context.importedDir.resolve(profile.uuid.toString()) val disableAllUserOverrides = networkSettings.disableAllOverride.value @@ -91,29 +83,16 @@ class SessionRuntimeSpecFactory( val overrideSpecs = if (runMode == RunMode.Ebpf) modeOverrides else modeOverrides + GlobalUaOverride.materialize(profileDir) - val ageSecretKey = normalizeAgeSecretKey(profile.ageSecretKey) return RuntimeSpec( - owner = owner, profileUuid = profile.uuid.toString(), profileName = profile.name, profileDir = profileDir.absolutePath, - runtimeConfigPath = profileDir.resolve("runtime.yaml").absolutePath, - ageSecretKey = ageSecretKey, + ageSecretKey = profile.ageSecretKey?.trim()?.takeIf { it.isNotEmpty() }, overrideSpecs = overrideSpecs, runMode = runMode, // eBPF keeps the profile authoritative; Root Tun only skips patches for disable-all. skipRuntimePatches = skipRuntimePatches, preview = preview, - tunConfig = tunConfig, - effectiveFingerprint = - buildEffectiveFingerprint( - profile.uuid.toString(), - overrideSpecs, - ageSecretKey, - skipModePatches, - preview, - ), - profileFingerprint = buildProfileFingerprint(profile.uuid.toString()), ) } @@ -194,81 +173,9 @@ class SessionRuntimeSpecFactory( private val LEGACY_INCLUDE_ANDROID_USERS = listOf(0, 10) } - private fun requireActiveProfile(): - com.github.yumeyucca.yumebox.runtime.service.profile.Imported { + private fun requireActiveProfile(): Imported { val profileId = store.activeProfile ?: error("No active profile selected") return ImportedDao.queryByUUID(profileId) ?: error("Active profile metadata not found: $profileId") } - - private fun buildProfileFingerprint(profileUuid: String): String { - val dir = context.importedDir.resolve(profileUuid) - return sha256 { - update(profileUuid.toByteArray()) - updateFile(dir.resolve("config.yaml")) - update((dir.directoryLastModified ?: -1L).toString().toByteArray()) - } - } - - private fun buildEffectiveFingerprint( - profileUuid: String, - overrideSpecs: List, - ageSecretKey: String?, - skipRuntimePatches: Boolean, - preview: Boolean, - ): String { - val profileDir = context.importedDir.resolve(profileUuid) - val metadataFile = context.filesDir.resolve("overrides/metadata.yaml") - return sha256 { - update(profileUuid.toByteArray()) - updateAgeSecretKeyDigest(ageSecretKey) - update("skip-runtime-patches:$skipRuntimePatches".toByteArray()) - update("preview:$preview".toByteArray()) - updateFile(profileDir.resolve("config.yaml")) - updateFile(metadataFile) - overrideSpecs.forEach { overrideSpec -> - update(overrideSpec.path.toByteArray()) - update(overrideSpec.ext.toByteArray()) - updateFile(File(overrideSpec.path)) - } - } - } - - private fun MessageDigest.updateAgeSecretKeyDigest(ageSecretKey: String?) { - update("age-secret-key:".toByteArray()) - update((ageSecretKey?.let(::sha256String) ?: "none").toByteArray()) - } - - private inline fun sha256(block: MessageDigest.() -> Unit): String { - val digest = MessageDigest.getInstance("SHA-256") - digest.block() - return digest.digest().joinToString("") { "%02x".format(it) } - } - - private fun MessageDigest.updateFile(file: File) { - if (!file.exists()) { - update("missing:${file.absolutePath}".toByteArray()) - return - } - // Stream path + size + mtime + content hash without loading the whole file into a byte[]. - update(file.absolutePath.toByteArray()) - update(file.length().toString().toByteArray()) - update(file.lastModified().toString().toByteArray()) - file.inputStream().use { input -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - while (true) { - val read = input.read(buffer) - if (read < 0) break - update(buffer, 0, read) - } - } - } - - private fun normalizeAgeSecretKey(value: String?): String? = - value?.trim()?.takeIf { it.isNotEmpty() } - - private fun sha256String(value: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray()) - return digest.joinToString("") { "%02x".format(it) } - } } diff --git a/runtime/service/src/runtime/service/session/SessionRuntimeTelemetry.kt b/runtime/service/src/runtime/service/session/SessionRuntimeTelemetry.kt deleted file mode 100644 index 4bd00a99b..000000000 --- a/runtime/service/src/runtime/service/session/SessionRuntimeTelemetry.kt +++ /dev/null @@ -1,135 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.service.session - -import com.github.yumeyucca.yumebox.core.domain.ConnectionHistoryManager -import com.github.yumeyucca.yumebox.core.model.LogMessage -import com.github.yumeyucca.yumebox.core.util.PollingTimerSpecs -import com.github.yumeyucca.yumebox.core.util.PollingTimers -import com.github.yumeyucca.yumebox.runtime.api.LogObserver -import com.github.yumeyucca.yumebox.runtime.api.LogSubscription -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import timber.log.Timber -import java.util.concurrent.atomic.AtomicLong - -internal class SessionRuntimeTelemetry( - private val host: RuntimeHost, - private val scope: CoroutineScope, - private val onLogReadyChanged: (Boolean) -> Unit, -) { - private var logSubscription: LogSubscription? = null - @Volatile private var logStreaming = false - private var connectionTrackingJob: Job? = null - private val logSeq = AtomicLong(0L) - private val recentLogs = ArrayDeque>() - @Volatile private var localLogObserver: ((LogMessage) -> Unit)? = null - - fun setLogObserver(observer: ((LogMessage) -> Unit)?) { - localLogObserver = observer - } - - fun queryRecentLogsJson(sinceSeq: Long): RuntimeLogChunk { - synchronized(recentLogs) { - val items = recentLogs.filter { it.first > sinceSeq }.map { it.second } - return RuntimeLogChunk(nextSeq = logSeq.get(), items = items) - } - } - - fun isLogStreaming(): Boolean = logStreaming - - fun startLogStream(subscribe: (LogObserver) -> LogSubscription) { - stopLogStream() - setLogReady(false) - logSubscription = - subscribe( - object : LogObserver { - override fun onConnected() { - setLogReady(true) - } - - override fun onError(error: Throwable) { - setLogReady(false) - } - - override fun newItem(log: LogMessage) { - runCatching { localLogObserver?.invoke(log) } - .onFailure { Timber.w(it, "Local log observer rejected an item") } - runCatching { host.onLogItem(log) } - .onFailure { Timber.w(it, "Runtime host rejected a log item") } - val encoded = Json.encodeToString(LogMessage.serializer(), log) - val seq = logSeq.incrementAndGet() - synchronized(recentLogs) { - recentLogs.addLast(seq to encoded) - while (recentLogs.size > MAX_BUFFERED_LOGS) { - recentLogs.removeFirst() - } - } - } - } - ) - } - - fun stopLogStream() { - runCatching { logSubscription?.close() } - .onFailure { Timber.w(it, "Failed to close runtime log subscription") } - logSubscription = null - synchronized(recentLogs) { recentLogs.clear() } - setLogReady(false) - } - - private fun setLogReady(ready: Boolean) { - logStreaming = ready - runCatching { host.onLogReady(ready) } - .onFailure { Timber.w(it, "Runtime host rejected log readiness") } - runCatching { onLogReadyChanged(ready) } - .onFailure { Timber.w(it, "Runtime snapshot rejected log readiness") } - } - - fun startConnectionTracking() { - stopConnectionTracking() - connectionTrackingJob = - scope.launch(Dispatchers.IO) { - PollingTimers.ticks(PollingTimerSpecs.SessionConnectionTracking).collect { - runCatching { - val core = - com.github.yumeyucca.yumebox.runtime.service.core.CoreProcess.controller( - host.context - ) - val snapshot = core.queryConnections() - ConnectionHistoryManager.updateConnections(snapshot.connections) - } - } - } - } - - fun stopConnectionTracking() { - connectionTrackingJob?.cancel() - connectionTrackingJob = null - } - - private companion object { - private const val MAX_BUFFERED_LOGS = 256 - } -} diff --git a/runtime/service/src/runtime/service/session/VpnSession.kt b/runtime/service/src/runtime/service/session/VpnSession.kt new file mode 100644 index 000000000..22a8b7055 --- /dev/null +++ b/runtime/service/src/runtime/service/session/VpnSession.kt @@ -0,0 +1,130 @@ +/* + * This file is part of YumeBox. + * + * YumeBox is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * Copyright (c) YumeYucca 2025 - Present + * + */ + +package com.github.yumeyucca.yumebox.runtime.service.session + +import android.net.VpnService +import android.os.SystemClock +import com.github.yumeyucca.yumebox.runtime.api.appContextOrSelf +import com.github.yumeyucca.yumebox.runtime.service.core.CoreProcess +import com.github.yumeyucca.yumebox.runtime.service.log.RuntimeLog +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * The VPN data plane of one `TunService` instance: + * one compile per start/reload, one fork, then a `/group` readiness probe. Callers serialize all + * calls; cancellation aborts between the blocking steps. + */ +internal class VpnSession(service: VpnService) { + private val context = service.appContextOrSelf + private val pipeline = CompiledConfigPipeline(context) + private val transport = VpnTunTransport(service) + private val log = RuntimeLog.writer(context, RuntimeLog.Source.LocalTun) + + @Volatile private var loaded: LoadedRuntime? = null + + suspend fun start(spec: RuntimeSpec) { + val startedAt = SystemClock.elapsedRealtime() + log.i(RuntimeLog.Type.Session, "start begin profile=${spec.profileName}") + val runtime = LoadedRuntime(spec, pipeline.compile(spec).finalYaml) + log.i(RuntimeLog.Type.Session, "compiled elapsedMs=${elapsedSince(startedAt)}") + launch(runtime) + log.i(RuntimeLog.Type.Session, "success: started elapsedMs=${elapsedSince(startedAt)}") + } + + /** + * Swaps the running config. If the new one does not come up, the previous one is relaunched + * and [ReloadRejectedException] reports the rejected config; any other throw means nothing is + * serving any more. + */ + suspend fun reload(spec: RuntimeSpec) { + val previous = checkNotNull(loaded) { "runtime not started" } + log.i(RuntimeLog.Type.Reload, "begin profile=${spec.profileName}") + val runtime = LoadedRuntime(spec, pipeline.compile(spec).finalYaml) + try { + launch(runtime) + } catch (error: CancellationException) { + throw error + } catch (@Suppress("TooGenericExceptionCaught") error: Exception) { + log.e(RuntimeLog.Type.Reload, "rejected; restoring previous config", error) + launch(previous) + throw ReloadRejectedException(previous.spec, error) + } + log.i(RuntimeLog.Type.Reload, "success profile=${spec.profileName}") + } + + /** Blocking: returns after the child core has exited. */ + fun stop() { + loaded = null + runCatching { transport.stop() } + .onFailure { error -> log.e(RuntimeLog.Type.Session, "stop failed", error) } + } + + private suspend fun launch(runtime: LoadedRuntime) { + withContext(Dispatchers.IO) { transport.start(runtime) } + loaded = runtime + awaitReady(runtime) + } + + private suspend fun awaitReady(runtime: LoadedRuntime) { + // Parsed only when the core reports zero groups: a normal start never decodes the YAML. + val expectedGroups by lazy { pipeline.extractProxyGroupNames(runtime.config) } + var controllerAnswered = false + ControllerReadiness.await( + ensureAlive = { + if (!CoreProcess.isLocalCoreAlive()) { + error( + "core process exited before controller became ready" + + coreTailSuffix() + ) + } + }, + probe = { + val groups = CoreProcess.probeGroupCount(context) + controllerAnswered = true + groups > 0 || expectedGroups.isEmpty() + }, + onTimeout = { lastError -> + val detail = + if (controllerAnswered) { + "runtime loaded but exposed 0 proxy groups; " + + "expected=${expectedGroups.size} sample=${expectedGroups.take(5)}" + } else { + "core controller unavailable during startup" + + (lastError?.message?.let { ": $it" }.orEmpty()) + } + log.w(RuntimeLog.Type.Verify, detail) + throw IllegalStateException(detail + coreTailSuffix(), lastError) + }, + ) + log.i(RuntimeLog.Type.Verify, "success: controller ready") + } + + private fun coreTailSuffix(): String = + CoreProcess.coreLogTail(context)?.let { " ($it)" }.orEmpty() + + private fun elapsedSince(startedAt: Long): Long = SystemClock.elapsedRealtime() - startedAt +} + +/** The new config failed; [restored] is serving again. */ +internal class ReloadRejectedException(val restored: RuntimeSpec, cause: Throwable) : + IllegalStateException(cause.message ?: "reload rejected", cause) diff --git a/runtime/service/src/runtime/service/session/VpnTunTransport.kt b/runtime/service/src/runtime/service/session/VpnTunTransport.kt index 8533e3aa8..4574f382c 100644 --- a/runtime/service/src/runtime/service/session/VpnTunTransport.kt +++ b/runtime/service/src/runtime/service/session/VpnTunTransport.kt @@ -18,13 +18,16 @@ * */ -@file:Suppress("UnusedSymbol") - package com.github.yumeyucca.yumebox.runtime.service.session import android.app.PendingIntent import android.content.Intent +import android.net.ConnectivityManager import android.net.IpPrefix +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest import android.net.ProxyInfo import android.net.VpnService import android.os.Build @@ -37,173 +40,118 @@ import com.github.yumeyucca.yumebox.runtime.service.core.VpnTunnel import com.github.yumeyucca.yumebox.runtime.service.log.RuntimeLog import com.github.yumeyucca.yumebox.runtime.service.util.buildIncludedRoutesFromExcludedCidrs import com.github.yumeyucca.yumebox.runtime.service.util.parseCIDR -import kotlinx.coroutines.runBlocking import java.net.InetAddress class VpnTunTransport( private val vpnService: VpnService, private val store: ServiceStore = ServiceStore(), -) : RuntimeTransport { +) { private val log = RuntimeLog.writer(vpnService, RuntimeLog.Source.LocalTun) - private val pipeline = CompiledConfigPipeline(vpnService) private val core = CoreProcess(vpnService) + private val connectivity = vpnService.getSystemService(ConnectivityManager::class.java) + private var underlyingNetworkCallback: ConnectivityManager.NetworkCallback? = null - override fun start(spec: RuntimeSpec) { + /** Blocking: reaps any previous core, establishes the tun and forks the child with [runtime]. */ + fun start(runtime: LoadedRuntime) { log.i(RuntimeLog.Type.Transport, "start begin") - // Prefer the precompiled YAML attached to the spec (single compile on the start path). - // Fall back to a local compile only for callers that have not prepared the spec yet. - val config = - if (spec.compiledFinalYaml.isNotBlank()) { - spec.compiledFinalYaml - } else { - runBlocking { pipeline.compile(spec) } - } - val rootMode = CoreProcess.rootDaemonMode() - try { - core.startVpn( - config = config, - stack = vpnTunStack(store.tunStackMode), - openTunnel = { openTunnel(config) }, - ) - } finally { - RootSessionLauncher.releaseReapedHost(vpnService, rootMode) - } + core.startVpn( + config = runtime.config, + stack = vpnTunStack(store.tunStackMode), + openTunnel = { openTunnel(runtime.config) }, + ) + trackUnderlyingNetworks() log.i(RuntimeLog.Type.Transport, "success: tun attached and core launched") } - private fun openTunnel(config: String): VpnTunnel { - val device = - with(vpnService.Builder()) { - val explicitRouteExcludes = - store.tunRouteExcludeAddress.map(String::trim).filter(String::isNotEmpty) - - addAddress(TUN_GATEWAY, TUN_SUBNET_PREFIX) - if (store.allowIpv6) { - addAddress(TUN_GATEWAY6, TUN_SUBNET_PREFIX6) - } + private fun openTunnel(config: String): VpnTunnel = + with(vpnService.Builder()) { + val ipv6 = store.allowIpv6 + addAddress(TUN_GATEWAY, TUN_SUBNET_PREFIX) + if (ipv6) addAddress(TUN_GATEWAY6, TUN_SUBNET_PREFIX6) - configureRoutes(explicitRouteExcludes) - configurePerAppRouting() + configureRoutes(ipv6) + configurePerAppRouting() - setBlocking(false) - setMtu(TUN_MTU) - setSession("YumeBox") - addDnsServer(TUN_DNS) - if (store.allowIpv6) { - addDnsServer(TUN_DNS6) - } - setConfigureIntent( - PendingIntent.getActivity( - vpnService, - R.id.nf_vpn_status, - Intent() - .setComponent(Components.PROXY_SHEET_ACTIVITY) - .addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_SINGLE_TOP or - Intent.FLAG_ACTIVITY_NO_ANIMATION - ), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) + setBlocking(false) + setMtu(TUN_MTU) + setSession("YumeBox") + addDnsServer(TUN_DNS) + if (ipv6) addDnsServer(TUN_DNS6) + setConfigureIntent( + PendingIntent.getActivity( + vpnService, + R.id.nf_vpn_status, + Intent() + .setComponent(Components.PROXY_SHEET_ACTIVITY) + .addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_NO_ANIMATION + ), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) + ) + if (store.allowBypass) allowBypass() - if (Build.VERSION.SDK_INT >= 29) { - setMetered(false) - } - - if (store.allowBypass) { - allowBypass() - } - - // VPN system HTTP proxy: point apps at the core's mixed/http port so apps that - // honour - // the system proxy (rather than only the TUN) still route through the core. API - // 29+. - if (Build.VERSION.SDK_INT >= 29 && store.systemProxy) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + setMetered(false) + // Apps that honour the system proxy rather than only the tun still reach the core. + if (store.systemProxy) { httpProxyPort(config)?.let { port -> setHttpProxy( ProxyInfo.buildDirectProxy("127.0.0.1", port, httpProxyExclusionList) ) } } - - VpnTunnel( - fd = establish()?.detachFd() ?: error("Establish VPN rejected by system"), - gateway = - "$TUN_GATEWAY/$TUN_SUBNET_PREFIX" + - if (store.allowIpv6) ",$TUN_GATEWAY6/$TUN_SUBNET_PREFIX6" else "", - dns = - if (store.dnsHijacking) { - NET_ANY - } else { - (TUN_DNS + if (store.allowIpv6) ",$TUN_DNS6" else "") - }, - ) } - return device - } + + VpnTunnel( + fd = establish()?.detachFd() ?: error("Establish VPN rejected by system"), + gateway = + "$TUN_GATEWAY/$TUN_SUBNET_PREFIX" + + if (ipv6) ",$TUN_GATEWAY6/$TUN_SUBNET_PREFIX6" else "", + dns = if (store.dnsHijacking) NET_ANY else TUN_DNS + if (ipv6) ",$TUN_DNS6" else "", + ) + } /** * Route table: explicit config route excludes win (native excludeRoute on API 33+, computed * included routes below), then the private-network bypass list, otherwise route everything. + * Every partial table also routes the tun DNS address. */ - private fun VpnService.Builder.configureRoutes(explicitRouteExcludes: List) { - val hasExplicitRouteExcludes = explicitRouteExcludes.isNotEmpty() - val canUseExcludeRoute = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU - val includedRoutes = - if (hasExplicitRouteExcludes && !canUseExcludeRoute) { - buildIncludedRoutesFromExcludedCidrs( - cidrs = explicitRouteExcludes, - includeIpv6 = store.allowIpv6, - ) - } else { - null - } - - if (hasExplicitRouteExcludes && canUseExcludeRoute) { - addRoute(NET_ANY, 0) - if (store.allowIpv6) { - addRoute(NET_ANY6, 0) - } - explicitRouteExcludes.map(::parseCIDR).forEach { - runCatching { - excludeRoute(IpPrefix(InetAddress.getByName(it.ip), it.prefix)) + private fun VpnService.Builder.configureRoutes(ipv6: Boolean) { + val excludes = store.tunRouteExcludeAddress.map(String::trim).filter(String::isNotEmpty) + when { + excludes.isNotEmpty() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> { + addRoute(NET_ANY, 0) + if (ipv6) addRoute(NET_ANY6, 0) + excludes.map(::parseCIDR).forEach { + runCatching { excludeRoute(IpPrefix(InetAddress.getByName(it.ip), it.prefix)) } } } - addRoute(TUN_DNS, 32) - if (store.allowIpv6) { - addRoute(TUN_DNS6, 128) - } - } else if (includedRoutes != null) { - includedRoutes.ipv4.forEach { addRoute(it.ip, it.prefix) } - if (store.allowIpv6) { - includedRoutes.ipv6.forEach { addRoute(it.ip, it.prefix) } - } - addRoute(TUN_DNS, 32) - if (store.allowIpv6) { - addRoute(TUN_DNS6, 128) + excludes.isNotEmpty() -> { + val routes = buildIncludedRoutesFromExcludedCidrs(excludes, includeIpv6 = ipv6) + routes.ipv4.forEach { addRoute(it.ip, it.prefix) } + if (ipv6) routes.ipv6.forEach { addRoute(it.ip, it.prefix) } } - } else if (store.bypassPrivateNetwork) { - vpnService.resources - .getStringArray(R.array.bypass_private_route) - .map(::parseCIDR) - .forEach { addRoute(it.ip, it.prefix) } - if (store.allowIpv6) { - vpnService.resources - .getStringArray(R.array.bypass_private_route6) - .map(::parseCIDR) - .forEach { addRoute(it.ip, it.prefix) } + store.bypassPrivateNetwork -> { + addRoutes(R.array.bypass_private_route) + if (ipv6) addRoutes(R.array.bypass_private_route6) } - addRoute(TUN_DNS, 32) - if (store.allowIpv6) { - addRoute(TUN_DNS6, 128) - } - } else { - addRoute(NET_ANY, 0) - if (store.allowIpv6) { - addRoute(NET_ANY6, 0) + else -> { + addRoute(NET_ANY, 0) + if (ipv6) addRoute(NET_ANY6, 0) + return } } + addRoute(TUN_DNS, 32) + if (ipv6) addRoute(TUN_DNS6, 128) + } + + private fun VpnService.Builder.addRoutes(cidrArray: Int) { + vpnService.resources.getStringArray(cidrArray).map(::parseCIDR).forEach { + addRoute(it.ip, it.prefix) + } } /** Core sockets are protected individually; the app's own traffic must stay in the VPN. */ @@ -243,14 +191,49 @@ class VpnTunTransport( ?.toIntOrNull() ?.takeIf { it in 1..65535 } - override fun stop() { + /** Blocking: SIGTERM with a grace period, then SIGKILL. */ + fun stop() { + underlyingNetworkCallback?.let { callback -> + runCatching { connectivity?.unregisterNetworkCallback(callback) } + } + underlyingNetworkCallback = null core.stop() } - override fun onNetworkChanged() { - if (Build.VERSION.SDK_INT in 22..28) { - @Suppress("DEPRECATION") vpnService.setUnderlyingNetworks(null) + /** + * Before API 29 a VPN does not follow default-network switches on its own; re-declaring "use + * the default network" on every change keeps the tun's egress from sticking to a dead link. + */ + private fun trackUnderlyingNetworks() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || underlyingNetworkCallback != null) { + return } + val callback = + object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) = resetUnderlyingNetworks() + + override fun onLosing(network: Network, maxMsToLive: Int) = + resetUnderlyingNetworks() + + override fun onLost(network: Network) = resetUnderlyingNetworks() + + override fun onLinkPropertiesChanged( + network: Network, + linkProperties: LinkProperties, + ) = resetUnderlyingNetworks() + } + val request = + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) + .build() + runCatching { connectivity?.registerNetworkCallback(request, callback) } + .onSuccess { underlyingNetworkCallback = callback } + } + + private fun resetUnderlyingNetworks() { + vpnService.setUnderlyingNetworks(null) } private companion object { diff --git a/runtime/service/src/runtime/service/util/AutoStartUpdatePolicy.kt b/runtime/service/src/runtime/service/util/AutoStartUpdatePolicy.kt deleted file mode 100644 index b580a731e..000000000 --- a/runtime/service/src/runtime/service/util/AutoStartUpdatePolicy.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.service.util - -import com.github.yumeyucca.yumebox.runtime.api.Profile - -object AutoStartUpdatePolicy { - enum class Decision { - Proceed, - AutoUpdateDisabled, - SkipPostUpdateColdStart, - SkipColdStartReason, - NoActiveProfile, - UnsupportedProfileType, - } - - fun decide( - autoUpdateEnabled: Boolean, - activeProfile: Profile?, - skipForPostUpdateColdStart: Boolean, - startupReason: String? = null, - coldStartReasons: Set = emptySet(), - ): Decision { - if (!autoUpdateEnabled) return Decision.AutoUpdateDisabled - if (skipForPostUpdateColdStart) return Decision.SkipPostUpdateColdStart - if (!startupReason.isNullOrBlank() && startupReason in coldStartReasons) { - return Decision.SkipColdStartReason - } - if (activeProfile == null) return Decision.NoActiveProfile - if (activeProfile.type != Profile.Type.Url) return Decision.UnsupportedProfileType - return Decision.Proceed - } -} diff --git a/runtime/service/src/runtime/service/util/ContextUtils.kt b/runtime/service/src/runtime/service/util/ContextUtils.kt index d3e355f59..cf7cdca17 100644 --- a/runtime/service/src/runtime/service/util/ContextUtils.kt +++ b/runtime/service/src/runtime/service/util/ContextUtils.kt @@ -18,8 +18,6 @@ * */ -@file:Suppress("UnusedSymbol", "SimplifiableCallChain") - package com.github.yumeyucca.yumebox.runtime.service.util import android.content.Context @@ -32,9 +30,7 @@ val Context.importedDir: File get() = filesDir.resolve("imported") val File.directoryLastModified: Long? - get() { - return walk().map { it.lastModified() }.maxOrNull() - } + get() = walk().maxOfOrNull { it.lastModified() } fun Context.sendBroadcastSelf(intent: Intent) { sendBroadcast(intent.setPackage(this.packageName)) @@ -48,46 +44,3 @@ fun Context.sendProfileChanged(uuid: UUID, affectsRuntime: Boolean) { sendBroadcastSelf(intent) } - -fun Context.sendProfileLoaded(uuid: UUID) { - sendBroadcastSelf( - Intent(Intents.ACTION_PROFILE_LOADED).putExtra(Intents.EXTRA_UUID, uuid.toString()) - ) -} - -fun Context.sendOverrideChanged() { - sendBroadcastSelf(Intent(Intents.ACTION_OVERRIDE_CHANGED)) -} - -fun Context.sendServiceRecreated() { - sendBroadcastSelf(Intent(Intents.ACTION_SERVICE_RECREATED)) -} - -fun Context.sendRuntimeStarted() { - sendBroadcastSelf(Intent(Intents.ACTION_RUNTIME_STARTED)) - requestTileRefresh() -} - -fun Context.sendRuntimeStopped(reason: String?) { - sendBroadcastSelf( - Intent(Intents.ACTION_RUNTIME_STOPPED).putExtra(Intents.EXTRA_STOP_REASON, reason) - ) - requestTileRefresh() -} - -/** - * Nudge the QS tile to re-read the runtime state. The tile only self-refreshes while its panel is - * open (onStartListening); without this, starting/stopping from the app UI leaves it stale. This - * asks the system to call onStartListening even when the panel is closed. - */ -private fun Context.requestTileRefresh() { - runCatching { - android.service.quicksettings.TileService.requestListeningState( - this, - android.content.ComponentName( - this, - com.github.yumeyucca.yumebox.runtime.service.ProxyTileService::class.java, - ), - ) - } -} diff --git a/runtime/service/src/runtime/service/util/InetAddressUtils.kt b/runtime/service/src/runtime/service/util/InetAddressUtils.kt deleted file mode 100644 index 9990bded2..000000000 --- a/runtime/service/src/runtime/service/util/InetAddressUtils.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -package com.github.yumeyucca.yumebox.runtime.service.util - -import java.net.Inet4Address -import java.net.Inet6Address -import java.net.InetAddress - -fun InetAddress.asSocketAddressText(port: Int): String = - when (this) { - is Inet6Address -> "[${numericToTextFormat(this)}]:$port" - is Inet4Address -> "${this.hostAddress}:$port" - else -> throw IllegalArgumentException("Unsupported Inet type ${this.javaClass}") - } - -// hostAddress renders the Inet6 scope as an interface NAME (e.g. %wlan0); the Go core -// needs the numeric scope id, so the groups are formatted by hand. -private fun numericToTextFormat(address: Inet6Address): String { - val src = address.address - val sb = StringBuilder(39) - for (i in 0 until 8) { - sb.append( - Integer.toHexString( - src[i shl 1].toInt() shl 8 and 0xff00 or (src[(i shl 1) + 1].toInt() and 0xff) - ) - ) - if (i < 7) { - sb.append(":") - } - } - if (address.scopeId > 0) { - sb.append("%") - sb.append(address.scopeId) - } - return sb.toString() -} diff --git a/runtime/service/src/runtime/service/util/RuntimeActivationAwaiter.kt b/runtime/service/src/runtime/service/util/RuntimeActivationAwaiter.kt deleted file mode 100644 index 3396db95c..000000000 --- a/runtime/service/src/runtime/service/util/RuntimeActivationAwaiter.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * This file is part of YumeBox. - * - * YumeBox is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * Copyright (c) YumeYucca 2025 - Present - * - */ - -@file:Suppress("ConvertLongToDuration") - -package com.github.yumeyucca.yumebox.runtime.service.util - -import android.os.SystemClock -import com.github.yumeyucca.yumebox.data.model.RunMode -import com.github.yumeyucca.yumebox.runtime.api.RuntimePhase -import kotlinx.coroutines.delay -import kotlin.math.min -import kotlin.time.Duration.Companion.milliseconds - -data class RuntimeActivationState( - val phase: RuntimePhase, - val error: String? = null, -) - -sealed interface RuntimeActivationResult { - val mode: RunMode - - data class Running(override val mode: RunMode) : RuntimeActivationResult - - data class Failed( - override val mode: RunMode, - val error: String?, - ) : RuntimeActivationResult - - data class TimedOut( - override val mode: RunMode, - val lastState: RuntimeActivationState, - ) : RuntimeActivationResult -} - -class RuntimeActivationAwaiter( - private val elapsedRealtimeMillis: () -> Long = SystemClock::elapsedRealtime, - private val delayMillis: suspend (Long) -> Unit = { delay(it.milliseconds) }, -) { - suspend fun await( - mode: RunMode, - timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, - pollIntervalMillis: Long = DEFAULT_POLL_INTERVAL_MILLIS, - queryState: suspend () -> RuntimeActivationState, - ): RuntimeActivationResult { - require(timeoutMillis >= 0L) { "timeoutMillis must not be negative" } - require(pollIntervalMillis > 0L) { "pollIntervalMillis must be positive" } - - val startedAt = elapsedRealtimeMillis() - while (true) { - val state = queryState() - when (state.phase) { - RuntimePhase.Running -> return RuntimeActivationResult.Running(mode) - RuntimePhase.Failed -> return RuntimeActivationResult.Failed(mode, state.error) - RuntimePhase.Idle, - RuntimePhase.Starting, - RuntimePhase.Stopping -> Unit - } - - val elapsed = (elapsedRealtimeMillis() - startedAt).coerceAtLeast(0L) - val remaining = timeoutMillis - elapsed - if (remaining <= 0L) { - return RuntimeActivationResult.TimedOut(mode, state) - } - delayMillis(min(pollIntervalMillis, remaining)) - } - } - - companion object { - const val DEFAULT_TIMEOUT_MILLIS = 60_000L - const val DEFAULT_POLL_INTERVAL_MILLIS = 250L - } -}