killswitch and domain list, also aexcluded apps

This commit is contained in:
2026-04-30 16:12:36 +07:00
parent d1f53ad84c
commit 6891e64c4f
17 changed files with 373 additions and 5692 deletions
@@ -1,21 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="app.netrunner.vpn">
<uses-permission android:name="android.permission.BIND_VPN_SERVICE" />
<!-- Базовые сетевые разрешения -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BIND_VPN_SERVICE" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Разрешение для уведомлений (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!--
Видимость пакетов для функции Split Tunneling.
Позволяет PackageManager видеть приложения, имеющие иконку в меню (Launcher).
-->
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application>
<service
android:name=".TunVpnService"
android:process=":vpn"
android:permission="android.permission.BIND_VPN_SERVICE"
android:exported="false"
android:foregroundServiceType="specialUse">
android:foregroundServiceType="specialUse"> <!-- ИСПРАВЛЕНИЕ ЗДЕСЬ -->
<intent-filter>
<action android:name="android.net.VpnService"/>
</intent-filter>
@@ -23,6 +41,11 @@
<meta-data
android:name="android.net.VpnService.SUPPORTS_USER_CONTROL"
android:value="true" />
<!-- Это свойство критично для specialUse -->
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_TYPE"
android:value="vpn" />
</service>
</application>
</manifest>
@@ -13,6 +13,7 @@ import app.tauri.annotation.ActivityCallback
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.JSArray
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
@@ -38,7 +39,10 @@ data class TunnelConfig @JsonCreator constructor(
@JsonProperty("address") val address: String,
@JsonProperty("prefix") val prefix: Int,
@JsonProperty("dns") val dns: String,
@JsonProperty("mtu") val mtu: Int
@JsonProperty("mtu") val mtu: Int,
@JsonProperty("killswitchEnabled") val killswitchEnabled: Boolean,
@JsonProperty("excludedApps") val excludedApps: List<String>,
@JsonProperty("excludedDomains") val excludedDomains: List<String>
) : Parcelable
class TunVpnService : VpnService() {
@@ -76,6 +80,17 @@ class TunVpnService : VpnService() {
private fun startVpn(config: TunnelConfig, ip: String, port: Int) {
val builder = Builder().addAddress(config.address, config.prefix)
.addDnsServer(config.dns).addRoute("0.0.0.0", 0).setMtu(config.mtu)
for (appPkg in config.excludedApps) {
if (appPkg.isNotBlank()) {
try {
builder.addDisallowedApplication(appPkg.trim())
Log.d(TAG, "Excluded app from VPN tunnel: $appPkg")
} catch (e: Exception) {
Log.w(TAG, "Failed to exclude app $appPkg (possibly not installed)", e)
}
}
}
try { builder.addDisallowedApplication(packageName) } catch (e: Exception) { Log.e(TAG, "Exclude fail") }
@@ -84,11 +99,18 @@ class TunVpnService : VpnService() {
try {
val manager = SessionManager()
activeSession = manager.spawnSession("$ip:$port", fd, cacheDir.absolutePath)
// Передаем параметры в ядро Rust
activeSession = manager.spawnSession(
"$ip:$port",
fd,
cacheDir.absolutePath,
config.killswitchEnabled,
config.excludedApps,
config.excludedDomains
)
triggerVibration(true)
sendStatusBroadcast("connected")
// ДОБАВЛЕНО: Запускаем цикл статистики при успешном коннекте
startStatsLoop()
} catch (e: Exception) {
Log.e(TAG, "Rust fail", e)
@@ -399,6 +421,42 @@ class VpnPlugin(private val activity: Activity) : Plugin(activity) {
val status = getHardwareVpnStatus()
invoke.resolve(JSObject().apply { put("status", status) })
}
@Command
fun getInstalledApps(invoke: Invoke) {
val pm = activity.packageManager
// Используем флаг GET_META_DATA для более полной информации
val packages = pm.getInstalledPackages(0)
val jsArray = JSArray()
for (pkg in packages) {
val info = pkg.applicationInfo ?: continue
// Условие 1: Это не наше собственное приложение
if (pkg.packageName == activity.packageName) continue
// Условие 2: Это пользовательское приложение ИЛИ оно имеет иконку в лаунчере
val isSystem = (info.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0
val hasLauncher = pm.getLaunchIntentForPackage(pkg.packageName) != null
// Если это не системное приложение (установлено юзером) ИЛИ у него есть лаунчер
if (!isSystem || hasLauncher) {
val appName = info.loadLabel(pm).toString()
val obj = JSObject()
obj.put("appName", appName)
obj.put("packageName", pkg.packageName)
jsArray.put(obj)
}
}
Log.d(TAG, "Found ${jsArray.length()} apps for exclusion list")
val result = JSObject()
result.put("apps", jsArray)
invoke.resolve(result)
}
private fun updateStatus(status: String) {
trigger("status-change", JSObject().apply { put("status", status) })
@@ -4,6 +4,9 @@ export interface TunnelConfig {
prefix: number;
dns: string;
mtu: number;
killswitchEnabled: boolean;
excludedApps: string[];
excludedDomains: string[];
}
export interface TrafficStatsResponse {
@@ -13,6 +16,11 @@ export interface TrafficStatsResponse {
txPackets: number;
}
export interface AppInfo {
appName: string;
packageName: string;
}
export async function startVpn(config: TunnelConfig, ip: string, port: number) {
return await invoke("plugin:vpn|start_vpn", {
config,
@@ -54,3 +62,8 @@ export const setupTrafficStatsListener = async (
callback(payload as TrafficStatsResponse);
});
};
export async function getInstalledApps(): Promise<AppInfo[]> {
// Rust теперь сам распаковывает "apps" и отдает нам готовый массив
return await invoke<AppInfo[]>("plugin:vpn|get_installed_apps");
}
@@ -3,7 +3,7 @@ identifier = "allow-vpn-control"
description = "Allows Vpn Control"
[permission.commands]
allow = ["start_vpn", "stop_vpn", "get_traffic_stats", "check_actual_vpn_status", "registerListener", "unregisterListener"]
allow = ["start_vpn", "stop_vpn", "get_traffic_stats", "check_actual_vpn_status", "registerListener", "unregisterListener", "get_installed_apps"]
[permission.events]
allow = ["status-change"]
@@ -58,3 +58,8 @@ pub(crate) fn check_actual_vpn_status<R: tauri::Runtime>(
// Вызываем метод из vpn_impl (в mobile это уйдет в Kotlin)
vpn.check_actual_vpn_status()
}
#[command]
pub(crate) fn get_installed_apps<R: Runtime>(app: AppHandle<R>) -> crate::Result<Vec<AppInfo>> {
app.state::<Vpn<R>>().get_installed_apps()
}
@@ -47,9 +47,15 @@ impl<R: Runtime> VpnInterface<R> for Vpn<R> {
},
);
// Обрати внимание: SessionManager.spawn_session -> SessionManager::new().spawn_session
let session =
SessionManager::new().spawn_session(format!("{}:{}", ip, port), None, ".".into());
// Передаем новые параметры конфига прямо в UniFFI-метод
let session = SessionManager::new().spawn_session(
format!("{}:{}", ip, port),
None,
".".into(),
config.killswitch_enabled,
config.excluded_apps.clone(),
config.excluded_domains.clone(),
);
// Сохраняем сессию
{
@@ -122,4 +128,8 @@ impl<R: Runtime> VpnInterface<R> for Vpn<R> {
status: status.to_string(),
})
}
fn get_installed_apps(&self) -> crate::Result<Vec<AppInfo>> {
Ok(vec![]) // На десктопе пока отдаем пустоту
}
}
@@ -22,6 +22,7 @@ pub trait VpnInterface<R: Runtime>: Send + Sync + 'static {
fn start_vpn(&self, config: TunnelConfig, ip: String, port: i32) -> crate::Result<()>;
fn stop_vpn(&self) -> crate::Result<()>;
fn check_actual_vpn_status(&self) -> crate::Result<StatusResponse>;
fn get_installed_apps(&self) -> crate::Result<Vec<AppInfo>>;
}
pub struct Vpn<R: Runtime> {
inner: Box<dyn VpnInterface<R>>,
@@ -51,6 +52,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::start_vpn,
commands::stop_vpn,
commands::check_actual_vpn_status,
commands::get_installed_apps, // <--- НОВОЕ
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -54,6 +54,22 @@ impl<R: Runtime> VpnInterface<R> for Vpn<R> {
crate::Error::from(e)
})
}
fn get_installed_apps(&self) -> crate::Result<Vec<AppInfo>> {
// Создаем временную структуру для распаковки JSObject, который шлет Kotlin
#[derive(serde::Deserialize)]
struct Wrapper {
apps: Vec<AppInfo>,
}
let wrapper: Wrapper = self
.0
.run_mobile_plugin("getInstalledApps", ())
.map_err(crate::error::Error::from)?;
// Отдаем наружу (в React) чистый массив
Ok(wrapper.apps)
}
}
impl<R: Runtime> Vpn<R> {
+12 -1
View File
@@ -4,9 +4,13 @@ use serde::{Deserialize, Serialize};
#[serde(rename_all = "camelCase")]
pub struct TunnelConfig {
pub address: String,
pub prefix: i32, // Соответствует Int в Kotlin
pub prefix: i32,
pub dns: String,
pub mtu: i32,
// Новые параметры
pub killswitch_enabled: bool,
pub excluded_apps: Vec<String>,
pub excluded_domains: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize)]
@@ -34,3 +38,10 @@ pub struct TrafficStatsResponse {
pub rx_packets: u64,
pub tx_packets: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppInfo {
pub app_name: String,
pub package_name: String,
}