diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a3aa866..9538033 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.rainy.token" minSdk = 31 targetSdk = 35 - versionCode = 47 - versionName = "1.7.27" + versionCode = 48 + versionName = "1.7.28" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt b/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt index 0f26125..907514e 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt @@ -38,7 +38,7 @@ import kotlinx.coroutines.flow.asStateFlow * 按模型前缀路由:wbcn- → WorkBuddy 国内版;traecn- → Trae CN;其余 → Sub2API 透传 * * 安全: - * - 只绑定 127.0.0.1 + * - 默认只绑定 127.0.0.1;config.allowLan=true 时绑定 0.0.0.0(热点/局域网共享) * - config.apiKey 非空时,所有 v1 业务路由要求 Bearer Key 一致,否则 401 */ @Singleton @@ -62,7 +62,8 @@ class KtorLocalProxyServer @Inject constructor( synchronized(lock) { if (_isRunning.value) return Result.success(Unit) return try { - val engine = embeddedServer(CIO, host = "127.0.0.1", port = config.port) { + val bindHost = if (config.allowLan) "0.0.0.0" else "127.0.0.1" + val engine = embeddedServer(CIO, host = bindHost, port = config.port) { proxyModule(config.apiKey, sub2ApiChatProxy, workBuddyChatProxy, traeChatProxy, providerRouter, accountPool) } engine.start(wait = false) diff --git a/app/src/main/java/com/rainy/token/data/proxy/LocalProxyServer.kt b/app/src/main/java/com/rainy/token/data/proxy/LocalProxyServer.kt index 441d361..27a86f4 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/LocalProxyServer.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/LocalProxyServer.kt @@ -10,6 +10,8 @@ data class ProxyServerConfig( val port: Int = 8787, /** 客户端必须携带的 Bearer Key;null = 关闭鉴权(仅本机建议)。 */ val apiKey: String? = null, + /** true = 绑定 0.0.0.0,允许热点/同一 WiFi 下其他设备连接(需配合 API Key 使用)。 */ + val allowLan: Boolean = false, ) /** diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt index 6aaeb32..f19fc5e 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt @@ -104,6 +104,7 @@ fun SettingsScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val proxyRunning by viewModel.proxyRunning.collectAsStateWithLifecycle() + val proxyAllowLan by viewModel.proxyAllowLan.collectAsStateWithLifecycle() val proxyApiKey by viewModel.proxyApiKey.collectAsStateWithLifecycle() val proxyError by viewModel.proxyError.collectAsStateWithLifecycle() val proxyDebug by viewModel.proxyDebug.collectAsStateWithLifecycle() @@ -184,6 +185,7 @@ fun SettingsScreen( item { ProxyGatewayCard( running = proxyRunning, + allowLan = proxyAllowLan, apiKey = proxyApiKey, error = proxyError, debug = proxyDebug, @@ -196,6 +198,7 @@ fun SettingsScreen( onSelectModel = viewModel::selectProxyModel, onRefreshModels = viewModel::refreshProxyModels, onToggle = viewModel::setProxyEnabled, + onAllowLanChange = viewModel::setProxyAllowLan, onTestTrae = viewModel::testTraeStream, onTestWorkBuddy = viewModel::testWorkBuddyStream ) @@ -225,6 +228,7 @@ fun SettingsScreen( @Composable private fun ProxyGatewayCard( running: Boolean, + allowLan: Boolean, apiKey: String, error: String?, debug: String?, @@ -237,6 +241,7 @@ private fun ProxyGatewayCard( onSelectModel: (String) -> Unit, onRefreshModels: () -> Unit, onToggle: (Boolean) -> Unit, + onAllowLanChange: (Boolean) -> Unit, onTestTrae: () -> Unit, onTestWorkBuddy: () -> Unit ) { @@ -265,7 +270,11 @@ private fun ProxyGatewayCard( fontWeight = FontWeight.SemiBold ) Text( - text = if (running) "运行中 · http://127.0.0.1:8787/v1" else "已停止", + text = when { + !running -> "已停止" + allowLan -> "运行中 · 局域网模式(其他设备填 http://手机热点IP:8787/v1)" + else -> "运行中 · http://127.0.0.1:8787/v1" + }, style = MaterialTheme.typography.bodySmall, color = InkMuted, modifier = Modifier.padding(top = 2.dp) @@ -276,6 +285,21 @@ private fun ProxyGatewayCard( color = InkMuted, modifier = Modifier.padding(top = 2.dp) ) + Row( + modifier = Modifier.padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "允许局域网/热点设备连接", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + modifier = Modifier.weight(1f) + ) + Switch( + checked = allowLan, + onCheckedChange = onAllowLanChange + ) + } if (error != null) { Text( text = error, diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt index 6241ef4..2e692d9 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt @@ -1,5 +1,6 @@ package com.rainy.token.ui.settings +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.rainy.token.data.proxy.LocalProxyServer @@ -14,6 +15,7 @@ import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.service.ServiceType import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import java.util.UUID import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -37,6 +39,7 @@ import javax.inject.Inject */ @HiltViewModel class SettingsViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val credentialRepository: CredentialRepository, private val localProxyServer: LocalProxyServer, private val traeChatProxy: TraeChatProxy, @@ -51,6 +54,13 @@ class SettingsViewModel @Inject constructor( /** 本地反代服务运行状态。 */ val proxyRunning: StateFlow = localProxyServer.isRunning + /** 是否允许局域网/热点设备连接(绑定 0.0.0.0)。 */ + private val _proxyAllowLan = MutableStateFlow( + context.getSharedPreferences("proxy_settings", Context.MODE_PRIVATE) + .getBoolean("allow_lan", false) + ) + val proxyAllowLan: StateFlow = _proxyAllowLan.asStateFlow() + /** 当前本机 API Key(每次进程随机生成,仅提示用)。 */ val proxyApiKey: StateFlow = MutableStateFlow(generateProxyKey()) @@ -61,7 +71,11 @@ class SettingsViewModel @Inject constructor( /** 启动/停止本地反代服务。 */ fun setProxyEnabled(enabled: Boolean) { if (enabled) { - localProxyServer.start(ProxyServerConfig(apiKey = proxyApiKey.value)) + val config = ProxyServerConfig( + apiKey = proxyApiKey.value, + allowLan = _proxyAllowLan.value + ) + localProxyServer.start(config) .onSuccess { _proxyError.value = null } .onFailure { e -> _proxyError.value = e.message ?: "代理网关启动失败" } } else { @@ -70,6 +84,17 @@ class SettingsViewModel @Inject constructor( } } + /** 切换「允许局域网/热点设备连接」;运行中会按新配置重启代理。 */ + fun setProxyAllowLan(enabled: Boolean) { + context.getSharedPreferences("proxy_settings", Context.MODE_PRIVATE) + .edit().putBoolean("allow_lan", enabled).apply() + _proxyAllowLan.value = enabled + if (proxyRunning.value) { + localProxyServer.stop() + setProxyEnabled(true) + } + } + /** 代理调试输出(结构化摘要)。 */ private val _proxyDebug = MutableStateFlow(null) val proxyDebug: StateFlow = _proxyDebug.asStateFlow()