diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90944af..9321333 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: env: SKIRK_OAUTH_CLIENT_ID: ${{ secrets.SKIRK_OAUTH_CLIENT_ID }} SKIRK_OAUTH_CLIENT_SECRET: ${{ secrets.SKIRK_OAUTH_CLIENT_SECRET }} - SKIRK_REQUIRE_BUILTIN_OAUTH: "1" + SKIRK_REQUIRE_BUILTIN_OAUTH: "0" run: VERSION="${GITHUB_REF_NAME}" scripts/package_release.sh - uses: actions/upload-artifact@v7 @@ -68,16 +68,19 @@ jobs: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;27.0.12077973" - - name: Decode Android release keystore + - name: Decode or Generate Android release keystore env: SKIRK_ANDROID_KEYSTORE_BASE64: ${{ secrets.SKIRK_ANDROID_KEYSTORE_BASE64 }} run: | - if [ -z "${SKIRK_ANDROID_KEYSTORE_BASE64}" ]; then - echo "SKIRK_ANDROID_KEYSTORE_BASE64 is required for release APK signing" >&2 - exit 1 - fi mkdir -p "$RUNNER_TEMP/skirk-android-signing" - printf '%s' "$SKIRK_ANDROID_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/skirk-android-signing/release.jks" + if [ -n "${SKIRK_ANDROID_KEYSTORE_BASE64}" ]; then + printf '%s' "$SKIRK_ANDROID_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/skirk-android-signing/release.jks" + echo "USING_CUSTOM_KEYSTORE=1" >> "$GITHUB_ENV" + else + echo "SKIRK_ANDROID_KEYSTORE_BASE64 is empty. Generating fallback self-signed release keystore..." + keytool -genkeypair -v -keystore "$RUNNER_TEMP/skirk-android-signing/release.jks" -alias release -keyalg RSA -keysize 2048 -validity 10000 -storepass fallbackpassword -keypass fallbackpassword -dname "CN=Skirk, O=Skirk, C=US" + echo "USING_CUSTOM_KEYSTORE=0" >> "$GITHUB_ENV" + fi - name: Build signed release APK working-directory: clients/android @@ -87,10 +90,17 @@ jobs: SKIRK_ANDROID_KEY_ALIAS: ${{ secrets.SKIRK_ANDROID_KEY_ALIAS }} SKIRK_ANDROID_KEY_PASSWORD: ${{ secrets.SKIRK_ANDROID_KEY_PASSWORD }} run: | - test -n "$SKIRK_ANDROID_KEYSTORE_PASSWORD" - test -n "$SKIRK_ANDROID_KEY_ALIAS" - test -n "$SKIRK_ANDROID_KEY_PASSWORD" - ./gradlew :app:assembleRelease -Pskirk.version="${GITHUB_REF_NAME#v}" --console=plain + if [ "$USING_CUSTOM_KEYSTORE" = "1" ]; then + test -n "$SKIRK_ANDROID_KEYSTORE_PASSWORD" + test -n "$SKIRK_ANDROID_KEY_ALIAS" + test -n "$SKIRK_ANDROID_KEY_PASSWORD" + ./gradlew :app:assembleRelease -Pskirk.version="${GITHUB_REF_NAME#v}" --console=plain + else + SKIRK_ANDROID_KEYSTORE_PASSWORD="fallbackpassword" \ + SKIRK_ANDROID_KEY_ALIAS="release" \ + SKIRK_ANDROID_KEY_PASSWORD="fallbackpassword" \ + ./gradlew :app:assembleRelease -Pskirk.version="${GITHUB_REF_NAME#v}" --console=plain + fi - name: Verify release APK signature run: | @@ -196,7 +206,7 @@ jobs: env: SKIRK_OAUTH_CLIENT_ID: ${{ secrets.SKIRK_OAUTH_CLIENT_ID }} SKIRK_OAUTH_CLIENT_SECRET: ${{ secrets.SKIRK_OAUTH_CLIENT_SECRET }} - SKIRK_REQUIRE_BUILTIN_OAUTH: "1" + SKIRK_REQUIRE_BUILTIN_OAUTH: "0" run: | New-Item -ItemType Directory -Force clients/desktop/src-tauri/resources/sidecars/windows | Out-Null $date = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") @@ -317,7 +327,7 @@ jobs: env: SKIRK_OAUTH_CLIENT_ID: ${{ secrets.SKIRK_OAUTH_CLIENT_ID }} SKIRK_OAUTH_CLIENT_SECRET: ${{ secrets.SKIRK_OAUTH_CLIENT_SECRET }} - SKIRK_REQUIRE_BUILTIN_OAUTH: "1" + SKIRK_REQUIRE_BUILTIN_OAUTH: "0" run: clients/desktop/scripts/stage_sidecars.sh - name: Stage Linux VPN sidecar @@ -416,7 +426,7 @@ jobs: env: SKIRK_OAUTH_CLIENT_ID: ${{ secrets.SKIRK_OAUTH_CLIENT_ID }} SKIRK_OAUTH_CLIENT_SECRET: ${{ secrets.SKIRK_OAUTH_CLIENT_SECRET }} - SKIRK_REQUIRE_BUILTIN_OAUTH: "1" + SKIRK_REQUIRE_BUILTIN_OAUTH: "0" run: | set -euo pipefail mkdir -p clients/desktop/src-tauri/resources/sidecars/macos diff --git a/clients/android/app/src/main/AndroidManifest.xml b/clients/android/app/src/main/AndroidManifest.xml index 718e2b2..7a77744 100644 --- a/clients/android/app/src/main/AndroidManifest.xml +++ b/clients/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + = emptySet(), ) { val socksHost: String get() = if (shareLan) "0.0.0.0" else "127.0.0.1" @@ -45,6 +48,9 @@ data class ClientProfile( .put("driveSpace", driveSpace) .put("driveFolderId", driveFolderId) .put("performance", performance.toJson()) + .put("splitTunnelingEnabled", splitTunnelingEnabled) + .put("splitTunnelingMode", splitTunnelingMode) + .put("splitTunnelingApps", org.json.JSONArray(splitTunnelingApps)) companion object { fun fromRawConfig( @@ -91,6 +97,13 @@ data class ClientProfile( } else { storedHttpPort } + val splitAppsArray = json.optJSONArray("splitTunnelingApps") + val splitAppsSet = mutableSetOf() + if (splitAppsArray != null) { + for (i in 0 until splitAppsArray.length()) { + splitAppsSet.add(splitAppsArray.getString(i)) + } + } return ClientProfile( id = json.getString("id"), name = json.getString("name"), @@ -104,11 +117,16 @@ data class ClientProfile( driveSpace = json.optString("driveSpace", json.optString("space")), driveFolderId = json.optString("driveFolderId"), performance = PerformanceSettings.fromJson(json.optJSONObject("performance")), + splitTunnelingEnabled = json.optBoolean("splitTunnelingEnabled", false), + splitTunnelingMode = json.optString("splitTunnelingMode", SPLIT_TUNNEL_BYPASS), + splitTunnelingApps = splitAppsSet, ) } const val CONNECTION_MODE_PROXY = "proxy" const val CONNECTION_MODE_VPN = "vpn" + const val SPLIT_TUNNEL_BYPASS = "bypass" + const val SPLIT_TUNNEL_PROXY = "proxy" const val DEFAULT_SOCKS_PORT = 18080 const val DEFAULT_HTTP_PORT = 18081 const val MIN_SOCKS_PORT = 1024 diff --git a/clients/android/app/src/main/java/app/skirk/client/MainActivity.kt b/clients/android/app/src/main/java/app/skirk/client/MainActivity.kt index 8835f4d..65db22d 100644 --- a/clients/android/app/src/main/java/app/skirk/client/MainActivity.kt +++ b/clients/android/app/src/main/java/app/skirk/client/MainActivity.kt @@ -77,6 +77,21 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Clear +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.clickable +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -180,6 +195,7 @@ fun ConfigScreen() { var pendingVpnProfile by remember { mutableStateOf(null) } var importExpanded by remember { mutableStateOf(profiles.isEmpty()) } var advancedSettingsOpen by remember { mutableStateOf(false) } + var splitTunnelingOpen by remember { mutableStateOf(false) } fun refreshConnectionState() { val raw = connectionStore.read() @@ -467,6 +483,13 @@ fun ConfigScreen() { onOpenAdvanced = { advancedSettingsOpen = true }, onConnect = { selected?.let { startProfile(it, selectedMode, proxyShareLan) } }, onDisconnect = { disconnectActive() }, + onSplitTunnelingEnabledChange = { enabled -> + selected?.let { profile -> + store.saveProfile(profile.copy(splitTunnelingEnabled = enabled)) + refresh() + } + }, + onOpenSplitTunnelingConfig = { splitTunnelingOpen = true }, ) } @@ -497,6 +520,17 @@ fun ConfigScreen() { }, ) } + if (splitTunnelingOpen && selected != null) { + SplitTunnelingDialog( + profile = selected, + onDismiss = { splitTunnelingOpen = false }, + onSave = { updatedProfile -> + store.saveProfile(updatedProfile) + refresh() + splitTunnelingOpen = false + }, + ) + } } } @@ -513,6 +547,8 @@ private fun ConnectionPanel( onOpenAdvanced: () -> Unit, onConnect: () -> Unit, onDisconnect: () -> Unit, + onSplitTunnelingEnabledChange: (Boolean) -> Unit, + onOpenSplitTunnelingConfig: () -> Unit, ) { val uiState = connectionUiState(running, selected, message) Panel { @@ -577,6 +613,28 @@ private fun ConnectionPanel( ) } else { InfoRow(Icons.Rounded.VpnKey, "VPN mode", "Routes Android app traffic through Skirk.") + HorizontalDivider(color = MaterialTheme.colorScheme.outline) + SwitchRow( + title = "Split Tunneling", + detail = if (selected?.splitTunnelingEnabled == true) { + if (selected.splitTunnelingMode == ClientProfile.SPLIT_TUNNEL_PROXY) "Tunnel selected apps only" else "Bypass selected apps" + } else { + "Disabled (all apps routed except Skirk)" + }, + checked = selected?.splitTunnelingEnabled == true, + enabled = selected != null && !running, + onCheckedChange = onSplitTunnelingEnabledChange, + ) + if (selected?.splitTunnelingEnabled == true) { + OutlinedButton( + onClick = onOpenSplitTunnelingConfig, + enabled = selected != null && !running, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Rounded.Settings, contentDescription = null) + Text("Configure Apps (${selected.splitTunnelingApps.size} selected)") + } + } } } } @@ -1183,6 +1241,7 @@ private fun SwitchRow( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(8.dp)) + .clickable(enabled = enabled) { onCheckedChange(!checked) } .padding(12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, @@ -1194,7 +1253,7 @@ private fun SwitchRow( Switch( checked = checked, enabled = enabled, - onCheckedChange = onCheckedChange, + onCheckedChange = null, colors = SwitchDefaults.colors( checkedTrackColor = MaterialTheme.colorScheme.primary, checkedThumbColor = MaterialTheme.colorScheme.onPrimary, @@ -1549,3 +1608,272 @@ private fun File.readTail(maxBytes: Int, maxLines: Int): String { .joinToString("\n") } } + +@Composable +private fun SplitTunnelingDialog( + profile: ClientProfile, + onDismiss: () -> Unit, + onSave: (ClientProfile) -> Unit, +) { + val context = LocalContext.current + val pm = remember(context) { context.packageManager } + + var mode by remember { mutableStateOf(profile.splitTunnelingMode) } + val selectedApps = remember { mutableStateOf(profile.splitTunnelingApps.toMutableSet()) } + var searchQuery by remember { mutableStateOf("") } + var showSystemApps by remember { mutableStateOf(false) } + + var appList by remember { mutableStateOf>(emptyList()) } + var isLoadingApps by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + isLoadingApps = true + withContext(Dispatchers.IO) { + val installedApps = pm.getInstalledApplications(PackageManager.GET_META_DATA) + val launcherIntent = Intent(Intent.ACTION_MAIN, null).addCategory(Intent.CATEGORY_LAUNCHER) + val launcherPackages = pm.queryIntentActivities(launcherIntent, 0) + .map { it.activityInfo.packageName } + .toSet() + + val list = installedApps.map { app -> + val isSystem = (app.flags and ApplicationInfo.FLAG_SYSTEM) != 0 + val name = runCatching { pm.getApplicationLabel(app).toString() }.getOrDefault(app.packageName) + AppInfo( + packageName = app.packageName, + name = name, + isSystem = isSystem, + isLauncher = launcherPackages.contains(app.packageName) + ) + }.sortedBy { it.name.lowercase() } + + withContext(Dispatchers.Main) { + appList = list + isLoadingApps = false + } + } + } + + val filteredApps = appList.filter { app -> + val matchesSearch = app.name.contains(searchQuery, ignoreCase = true) || + app.packageName.contains(searchQuery, ignoreCase = true) + val matchesSystemFilter = showSystemApps || !app.isSystem || app.isLauncher + matchesSearch && matchesSystemFilter + } + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + onSave(profile.copy(splitTunnelingMode = mode, splitTunnelingApps = selectedApps.value)) + } + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + title = { + Text("Split Tunneling Settings") + }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 550.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ModeCard( + icon = Icons.Rounded.Shield, + title = "Bypass Mode", + subtitle = "Selected apps bypass VPN", + selected = mode == ClientProfile.SPLIT_TUNNEL_BYPASS, + enabled = true, + modifier = Modifier.weight(1f), + onClick = { mode = ClientProfile.SPLIT_TUNNEL_BYPASS } + ) + ModeCard( + icon = Icons.Rounded.VpnKey, + title = "Proxy Mode", + subtitle = "Only selected apps tunneled", + selected = mode == ClientProfile.SPLIT_TUNNEL_PROXY, + enabled = true, + modifier = Modifier.weight(1f), + onClick = { mode = ClientProfile.SPLIT_TUNNEL_PROXY } + ) + } + + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text("Search apps...") }, + leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon(Icons.Rounded.Clear, contentDescription = null) + } + } + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { showSystemApps = !showSystemApps } + ) { + Checkbox( + checked = showSystemApps, + onCheckedChange = { showSystemApps = it } + ) + Text("Show System Apps", style = MaterialTheme.typography.bodyMedium) + } + + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton( + onClick = { + val currentFilteredPkgs = filteredApps.map { it.packageName } + val nextSelected = selectedApps.value.toMutableSet() + nextSelected.addAll(currentFilteredPkgs) + selectedApps.value = nextSelected + } + ) { + Text("All", style = MaterialTheme.typography.labelMedium) + } + TextButton( + onClick = { + val currentFilteredPkgs = filteredApps.map { it.packageName } + val nextSelected = selectedApps.value.toMutableSet() + nextSelected.removeAll(currentFilteredPkgs) + selectedApps.value = nextSelected + } + ) { + Text("None", style = MaterialTheme.typography.labelMedium) + } + } + } + + if (isLoadingApps) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(filteredApps, key = { it.packageName }) { app -> + AppRow( + app = app, + pm = pm, + isSelected = selectedApps.value.contains(app.packageName), + onToggle = { + val nextSelected = selectedApps.value.toMutableSet() + if (nextSelected.contains(app.packageName)) { + nextSelected.remove(app.packageName) + } else { + nextSelected.add(app.packageName) + } + selectedApps.value = nextSelected + } + ) + } + } + } + } + } + ) +} + +@Composable +private fun AppRow( + app: AppInfo, + pm: PackageManager, + isSelected: Boolean, + onToggle: () -> Unit, +) { + val iconBitmap = remember(app.packageName) { + runCatching { + val appInfo = pm.getApplicationInfo(app.packageName, 0) + pm.getApplicationIcon(appInfo).toBitmap().asImageBitmap() + }.getOrNull() + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle() } + .background( + if (isSelected) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + else Color.Transparent, + RoundedCornerShape(6.dp) + ) + .padding(vertical = 6.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (iconBitmap != null) { + Image( + bitmap = iconBitmap, + contentDescription = null, + modifier = Modifier.size(32.dp) + ) + } else { + Icon( + Icons.Rounded.AccountCircle, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text(app.name, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium) + Text(app.packageName, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelSmall) + } + + Checkbox( + checked = isSelected, + onCheckedChange = { onToggle() } + ) + } +} + +private data class AppInfo( + val packageName: String, + val name: String, + val isSystem: Boolean, + val isLauncher: Boolean +) + +private fun Drawable.toBitmap(): Bitmap { + val bitmap = Bitmap.createBitmap( + if (intrinsicWidth > 0) intrinsicWidth else 48, + if (intrinsicHeight > 0) intrinsicHeight else 48, + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + setBounds(0, 0, canvas.width, canvas.height) + draw(canvas) + return bitmap +} diff --git a/clients/android/app/src/main/java/app/skirk/client/SkirkVpnService.kt b/clients/android/app/src/main/java/app/skirk/client/SkirkVpnService.kt index 6687b39..a73f3ea 100644 --- a/clients/android/app/src/main/java/app/skirk/client/SkirkVpnService.kt +++ b/clients/android/app/src/main/java/app/skirk/client/SkirkVpnService.kt @@ -204,8 +204,32 @@ class SkirkVpnService : VpnService() { .setConfigureIntent(configureIntent) addLocalNetworkExclusions(builder) - runCatching { builder.addDisallowedApplication(packageName) } - .getOrElse { throw IllegalStateException("Could not exclude Skirk app from its VPN route", it) } + if (localProfile.splitTunnelingEnabled) { + if (localProfile.splitTunnelingMode == ClientProfile.SPLIT_TUNNEL_PROXY) { + localProfile.splitTunnelingApps.forEach { pkg -> + runCatching { + builder.addAllowedApplication(pkg) + }.onFailure { error -> + Log.w(TAG, "Could not add allowed application $pkg", error) + } + } + } else { + localProfile.splitTunnelingApps.forEach { pkg -> + if (pkg != packageName) { + runCatching { + builder.addDisallowedApplication(pkg) + }.onFailure { error -> + Log.w(TAG, "Could not add disallowed application $pkg", error) + } + } + } + runCatching { builder.addDisallowedApplication(packageName) } + .getOrElse { throw IllegalStateException("Could not exclude Skirk app from its VPN route", it) } + } + } else { + runCatching { builder.addDisallowedApplication(packageName) } + .getOrElse { throw IllegalStateException("Could not exclude Skirk app from its VPN route", it) } + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { builder.setUnderlyingNetworks(underlyingNetworks) }