From e3ba6976ff2924d2f4d0484fbc95de628d7deadd Mon Sep 17 00:00:00 2001 From: "Elie G." Date: Mon, 3 Aug 2026 11:02:53 +0300 Subject: [PATCH 1/6] feat(native-proxy): add OS proxy configuration module (Windows) New `native-proxy` module exposing the OS proxy configuration, modelled on `native-ssl` and ported from Chromium's `net::ProxyConfigServiceWin` and `net::ProxyResolverWinHttp`: - `WinHttpGetIEProxyConfigForCurrentUser` for the effective configuration (WPAD flag, PAC URL, proxy string, bypass list) - `WinHttpGetProxyForUrl` for PAC/WPAD resolution, without auto-logon first and retried with it only on ERROR_WINHTTP_LOGIN_FAILURE - `RegNotifyChangeKeyValue` on the Internet Settings / Policies / Connections keys for change notification, with Chromium's 2 s coalescing delay - Chromium-compatible parsing of per-scheme proxy strings and bypass rules (glob patterns, CIDR blocks, ``, `<-loopback>`, implicit loopback) `NativeProxy.install()` publishes a `ProxySelector` backed by the OS configuration, keeping the previous default as fallback. macOS and Linux use a no-op provider: `isSupported` is false and everything degrades to direct. --- .github/workflows/build-natives.yaml | 6 + .github/workflows/pre-merge.yaml | 2 + .github/workflows/publish-maven.yaml | 2 + CLAUDE.md | 1 + README.md | 1 + build.gradle.kts | 1 + native-proxy/build.gradle.kts | 89 ++++ .../nativeproxy/BypassRule.kt | 78 ++++ .../nucleusframework/nativeproxy/HostPort.kt | 29 ++ .../nativeproxy/IpLiterals.kt | 80 ++++ .../nucleusframework/nativeproxy/Logger.kt | 21 + .../nativeproxy/NativeProxy.kt | 167 ++++++++ .../nativeproxy/NativeProxySelector.kt | 51 +++ .../nativeproxy/NoopSystemProxyProvider.kt | 26 ++ .../nativeproxy/ProxyBypassRules.kt | 111 +++++ .../nativeproxy/ProxyChangeWatcher.kt | 72 ++++ .../nativeproxy/ProxyProtocol.kt | 50 +++ .../nativeproxy/ProxyRules.kt | 88 ++++ .../nativeproxy/ProxyServer.kt | 80 ++++ .../nativeproxy/SystemProxyProvider.kt | 46 +++ .../nativeproxy/SystemProxySettings.kt | 31 ++ .../nativeproxy/windows/WindowsProxyBridge.kt | 93 +++++ .../windows/WindowsSystemProxyProvider.kt | 62 +++ .../main/native/windows/NucleusProxyBridge.c | 382 ++++++++++++++++++ .../src/main/native/windows/build.bat | 140 +++++++ .../reachability-metadata.json | 8 + .../nativeproxy/NativeProxyTest.kt | 56 +++ .../nativeproxy/ProxyBypassRulesTest.kt | 94 +++++ .../nativeproxy/ProxyRulesTest.kt | 84 ++++ settings.gradle.kts | 1 + 30 files changed, 1952 insertions(+) create mode 100644 native-proxy/build.gradle.kts create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/BypassRule.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/HostPort.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/IpLiterals.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/Logger.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NativeProxy.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NativeProxySelector.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NoopSystemProxyProvider.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyBypassRules.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyChangeWatcher.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyProtocol.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyRules.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyServer.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/SystemProxyProvider.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/SystemProxySettings.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/windows/WindowsProxyBridge.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/windows/WindowsSystemProxyProvider.kt create mode 100644 native-proxy/src/main/native/windows/NucleusProxyBridge.c create mode 100644 native-proxy/src/main/native/windows/build.bat create mode 100644 native-proxy/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.native-proxy/reachability-metadata.json create mode 100644 native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/NativeProxyTest.kt create mode 100644 native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/ProxyBypassRulesTest.kt create mode 100644 native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/ProxyRulesTest.kt diff --git a/.github/workflows/build-natives.yaml b/.github/workflows/build-natives.yaml index 70d5fab2f..d1dd52c42 100644 --- a/.github/workflows/build-natives.yaml +++ b/.github/workflows/build-natives.yaml @@ -47,6 +47,11 @@ jobs: shell: cmd run: call native-ssl\src\main\native\windows\build.bat + - name: Build native-proxy Windows DLLs + if: steps.natives-cache.outputs.cache-hit != 'true' + shell: cmd + run: call native-proxy\src\main\native\windows\build.bat + - name: Build decorated-window-jni Windows DLLs if: steps.natives-cache.outputs.cache-hit != 'true' shell: cmd @@ -139,6 +144,7 @@ jobs: FILES=( "darkmode-detector/nucleus_windows_theme.dll" "native-ssl/nucleus_ssl.dll" + "native-proxy/nucleus_proxy.dll" "decorated-window-jni/nucleus_windows_decoration.dll" "system-color/nucleus_systemcolor.dll" "energy-manager/nucleus_energy_manager.dll" diff --git a/.github/workflows/pre-merge.yaml b/.github/workflows/pre-merge.yaml index 26eae8bb4..8e61268a6 100644 --- a/.github/workflows/pre-merge.yaml +++ b/.github/workflows/pre-merge.yaml @@ -49,6 +49,8 @@ jobs: "native-ssl/src/main/resources/nucleus/native/darwin-x64/libnucleus_ssl.dylib" "native-ssl/src/main/resources/nucleus/native/win32-x64/nucleus_ssl.dll" "native-ssl/src/main/resources/nucleus/native/win32-aarch64/nucleus_ssl.dll" + "native-proxy/src/main/resources/nucleus/native/win32-x64/nucleus_proxy.dll" + "native-proxy/src/main/resources/nucleus/native/win32-aarch64/nucleus_proxy.dll" "decorated-window-jbr/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_macos.dylib" "decorated-window-jbr/src/main/resources/nucleus/native/darwin-x64/libnucleus_macos.dylib" "decorated-window-jni/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_macos_jni.dylib" diff --git a/.github/workflows/publish-maven.yaml b/.github/workflows/publish-maven.yaml index 80c1e13e3..f40c3a3cc 100644 --- a/.github/workflows/publish-maven.yaml +++ b/.github/workflows/publish-maven.yaml @@ -48,6 +48,8 @@ jobs: "native-ssl/src/main/resources/nucleus/native/darwin-x64/libnucleus_ssl.dylib" "native-ssl/src/main/resources/nucleus/native/win32-x64/nucleus_ssl.dll" "native-ssl/src/main/resources/nucleus/native/win32-aarch64/nucleus_ssl.dll" + "native-proxy/src/main/resources/nucleus/native/win32-x64/nucleus_proxy.dll" + "native-proxy/src/main/resources/nucleus/native/win32-aarch64/nucleus_proxy.dll" "decorated-window-jbr/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_macos.dylib" "decorated-window-jbr/src/main/resources/nucleus/native/darwin-x64/libnucleus_macos.dylib" "decorated-window-jni/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_macos_jni.dylib" diff --git a/CLAUDE.md b/CLAUDE.md index 2638cee00..332d98f62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ A multi-module Gradle plugin and runtime library toolkit for shipping production - `system-color` - Reactive system accent color and high contrast detection via JNI - `energy-manager` - Energy efficiency & screen-awake APIs - `native-ssl` / `native-http` / `native-http-okhttp` / `native-http-ktor` - OS trust store integration +- `native-proxy` - OS proxy configuration via JNI (Windows: WinHTTP/WPAD/PAC + Internet Settings registry watching; no-op on macOS/Linux) - `linux-hidpi` - Native HiDPI scale detection on Linux - `graalvm-runtime` - GraalVM native-image bootstrap - `decorated-window-core` - Shared types, layout, styling (design-system agnostic) diff --git a/README.md b/README.md index 0633c5455..b6f2a8d34 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ Each module is published independently to Maven Central — use them together or | `nucleus.autolaunch` | Start the app at user login across all platforms | | `nucleus.native-ssl` | OS trust store integration | | `nucleus.native-http` | HTTP client with native SSL | +| `nucleus.native-proxy` | OS proxy configuration — WPAD/PAC, bypass rules (Windows) | | `nucleus.linux-hidpi` | Native HiDPI scale detection on Linux | | `nucleus.graalvm-runtime` | Native-image bootstrap, font fixes, automatic resource inclusion | diff --git a/build.gradle.kts b/build.gradle.kts index bc5cc14c8..5ca1b9132 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -212,6 +212,7 @@ tasks.register("preMerge") { dependsOn(":native-http:check") dependsOn(":native-http-okhttp:check") dependsOn(":native-http-ktor:check") + dependsOn(":native-proxy:check") dependsOn(":decorated-window-core:check") dependsOn(":decorated-window-tao:check") dependsOn(":decorated-window-jbr:check") diff --git a/native-proxy/build.gradle.kts b/native-proxy/build.gradle.kts new file mode 100644 index 000000000..ee54bb690 --- /dev/null +++ b/native-proxy/build.gradle.kts @@ -0,0 +1,89 @@ +import org.apache.tools.ant.taskdefs.condition.Os +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("jvm") + alias(libs.plugins.vanniktechMavenPublish) +} + +val publishVersion = + providers + .environmentVariable("GITHUB_REF") + .orNull + ?.removePrefix("refs/tags/v") + ?: "1.0.0" + +dependencies { + implementation(project(":core-runtime")) + testImplementation(libs.junit) +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + +val buildNativeWindows by tasks.registering(Exec::class) { + description = "Compiles the C JNI bridge into Windows DLLs (x64 + ARM64)" + group = "build" + val nativeDir = file("src/main/native/windows") + val outputDir = file("src/main/resources/nucleus/native") + val checkFile = File(outputDir, "win32-x64/nucleus_proxy.dll") + onlyIf { Os.isFamily(Os.FAMILY_WINDOWS) && !checkFile.exists() } + inputs.dir(nativeDir) + outputs.dir(outputDir) + workingDir(nativeDir) + commandLine("cmd", "/c", File(nativeDir, "build.bat").absolutePath) +} + +tasks.processResources { + dependsOn(buildNativeWindows) +} + +tasks.configureEach { + if (name == "sourcesJar") { + dependsOn(buildNativeWindows) + } +} + +mavenPublishing { + coordinates("dev.nucleusframework", "nucleus.native-proxy", publishVersion) + + pom { + name.set("Nucleus Native Proxy") + description.set("OS proxy configuration integration (WinHTTP/WPAD/PAC) for JVM desktop applications") + url.set("https://github.com/NucleusFramework/Nucleus") + + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + + developers { + developer { + id.set("nucleusframework") + name.set("NucleusFramework") + url.set("https://github.com/NucleusFramework") + } + } + + scm { + url.set("https://github.com/NucleusFramework/Nucleus") + connection.set("scm:git:git://github.com/NucleusFramework/Nucleus.git") + developerConnection.set("scm:git:ssh://git@github.com/NucleusFramework/Nucleus.git") + } + } + + publishToMavenCentral() + if (project.hasProperty("signingInMemoryKey")) { + signAllPublications() + } +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/BypassRule.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/BypassRule.kt new file mode 100644 index 000000000..3da30cd18 --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/BypassRule.kt @@ -0,0 +1,78 @@ +package dev.nucleusframework.nativeproxy + +import java.net.InetAddress + +/** + * A single entry of a proxy bypass list. + * + * The variants mirror Chromium's `SchemeHostPortMatcherRule` implementations. + */ +sealed interface BypassRule { + fun matches( + scheme: String, + host: String, + port: Int, + ): Boolean + + /** `*` — every URL bypasses the proxy. */ + data object MatchAll : BypassRule { + override fun matches( + scheme: String, + host: String, + port: Int, + ): Boolean = true + } + + /** + * A hostname wildcard pattern, optionally restricted to a scheme and/or a port. + * + * `*` matches any run of characters and `?` a single one, so `*.corp.com` + * matches `a.corp.com` but not `corp.com` — exactly as `base::MatchPattern`. + */ + data class HostnamePattern( + val pattern: String, + val scheme: String? = null, + val port: Int? = null, + ) : BypassRule { + private val regex = globToRegex(pattern) + + override fun matches( + scheme: String, + host: String, + port: Int, + ): Boolean { + if (this.scheme != null && this.scheme != scheme) return false + if (this.port != null && this.port != port) return false + return regex.matches(host) + } + } + + /** An IP block in CIDR notation (`10.0.0.0/8`, `fe80::/10`). */ + data class IpBlock( + val prefix: InetAddress, + val prefixBits: Int, + val scheme: String? = null, + ) : BypassRule { + override fun matches( + scheme: String, + host: String, + port: Int, + ): Boolean { + if (this.scheme != null && this.scheme != scheme) return false + val address = parseIpLiteral(host) ?: return false + return matchesCidr(address, prefix, prefixBits) + } + } +} + +private fun globToRegex(pattern: String): Regex { + val builder = StringBuilder() + for (char in pattern) { + when (char) { + '*' -> builder.append(".*") + '?' -> builder.append('.') + else -> builder.append(Regex.escape(char.toString())) + } + } + return Regex(builder.toString()) +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/HostPort.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/HostPort.kt new file mode 100644 index 000000000..1d0f25f26 --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/HostPort.kt @@ -0,0 +1,29 @@ +package dev.nucleusframework.nativeproxy + +/** + * Splits a `host`, `host:port` or `[v6]:port` authority, returning the port as + * raw text so callers decide whether a malformed port is fatal. + * + * A bare IPv6 literal without brackets carries no port: the last colon belongs + * to the address. + */ +internal fun splitHostPort(value: String): Pair? { + if (value.startsWith('[')) return splitBracketedHostPort(value) + + val colon = value.lastIndexOf(':') + if (colon < 0 || value.indexOf(':') != colon) return value to null + return value.substring(0, colon) to value.substring(colon + 1) +} + +private fun splitBracketedHostPort(value: String): Pair? { + val closing = value.indexOf(']') + if (closing < 0) return null + + val host = value.substring(1, closing) + val tail = value.substring(closing + 1) + return when { + tail.isEmpty() -> host to null + tail.startsWith(':') -> host to tail.substring(1) + else -> null + } +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/IpLiterals.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/IpLiterals.kt new file mode 100644 index 000000000..4c22fca7c --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/IpLiterals.kt @@ -0,0 +1,80 @@ +package dev.nucleusframework.nativeproxy + +import java.net.InetAddress +import java.net.UnknownHostException +import java.util.Locale + +private const val IPV4_LOOPBACK_PREFIX = "127." +private const val LINK_LOCAL_IPV4_PREFIX = "169.254." +private const val BITS_PER_BYTE = 8 +private const val BYTE_MASK = 0xFF + +private val IPV4_PATTERN = Regex("""^\d{1,3}(\.\d{1,3}){3}$""") + +/** + * Parses [host] as an IP literal without ever hitting DNS. + * + * [InetAddress.getByName] resolves anything that is not a literal, so the shape + * is checked first and only literals are handed over. + */ +internal fun parseIpLiteral(host: String): InetAddress? { + val candidate = host.removeSurrounding("[", "]") + val looksNumeric = IPV4_PATTERN.matches(candidate) || candidate.contains(':') + if (!looksNumeric) return null + return try { + InetAddress.getByName(candidate) + } catch (_: UnknownHostException) { + null + } +} + +internal fun isIpLiteral(host: String): Boolean = parseIpLiteral(host) != null + +/** + * Whether [host] designates the local machine. + * + * Same set as Chromium's `net::IsLocalhost`: the `localhost` family (including + * subdomains and the trailing-dot form), 127.0.0.0/8 and `::1`. + */ +internal fun isLocalhost(host: String): Boolean { + val name = host.lowercase(Locale.ROOT).removeSuffix(".") + if (name == "localhost" || name.endsWith(".localhost")) return true + if (name == "localhost6" || name == "localhost6.localdomain6") return true + if (name.startsWith(IPV4_LOOPBACK_PREFIX)) return isIpLiteral(name) + val address = parseIpLiteral(name) ?: return false + return address.isLoopbackAddress +} + +/** + * Whether [host] is a link-local address (169.254.0.0/16 or fe80::/10). + * + * Chromium bypasses these implicitly together with localhost. + */ +internal fun isLinkLocal(host: String): Boolean { + if (host.startsWith(LINK_LOCAL_IPV4_PREFIX) && isIpLiteral(host)) return true + val address = parseIpLiteral(host) ?: return false + return address.isLinkLocalAddress +} + +/** Whether [address] falls inside the CIDR block `[prefix]/[prefixBits]`. */ +internal fun matchesCidr( + address: InetAddress, + prefix: InetAddress, + prefixBits: Int, +): Boolean { + val addressBytes = address.address + val prefixBytes = prefix.address + if (addressBytes.size != prefixBytes.size) return false + if (prefixBits > addressBytes.size * BITS_PER_BYTE) return false + + val fullBytes = prefixBits / BITS_PER_BYTE + for (i in 0 until fullBytes) { + if (addressBytes[i] != prefixBytes[i]) return false + } + + val remainingBits = prefixBits % BITS_PER_BYTE + if (remainingBits == 0) return true + + val mask = (BYTE_MASK shl (BITS_PER_BYTE - remainingBits)) and BYTE_MASK + return (addressBytes[fullBytes].toInt() and mask) == (prefixBytes[fullBytes].toInt() and mask) +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/Logger.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/Logger.kt new file mode 100644 index 000000000..43c2bd67b --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/Logger.kt @@ -0,0 +1,21 @@ +package dev.nucleusframework.nativeproxy + +import dev.nucleusframework.core.runtime.tools.allowNucleusRuntimeLogging + +internal fun debugln( + tag: String, + message: () -> String, +) { + if (allowNucleusRuntimeLogging) { + println("[$tag] ${message()}") + } +} + +internal fun errorln( + tag: String, + message: () -> String, +) { + if (allowNucleusRuntimeLogging) { + System.err.println("[$tag] ${message()}") + } +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NativeProxy.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NativeProxy.kt new file mode 100644 index 000000000..0cf7f3bd9 --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NativeProxy.kt @@ -0,0 +1,167 @@ +package dev.nucleusframework.nativeproxy + +import java.net.Proxy +import java.net.ProxySelector +import java.net.URI +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +private const val TAG = "NativeProxy" +private const val MAX_PAC_CACHE_ENTRIES = 256 + +/** + * Entry point for the OS proxy configuration. + * + * ```kotlin + * NativeProxy.install() // route every JDK connection through the OS proxy + * val proxies = NativeProxy.proxiesFor(URI("https://intranet.corp")) + * NativeProxy.addChangeListener { println("proxy configuration changed: $it") } + * ``` + * + * Windows is the only implemented platform (WinHTTP/WPAD/PAC + Internet + * Settings registry watching); macOS and Linux report [isSupported] `false` and + * every call degrades to a direct configuration. + */ +object NativeProxy { + private val provider = SystemProxyProvider.forCurrentPlatform() + private val cachedSettings = AtomicReference() + private val pacResults = ConcurrentHashMap>() + private val listeners = CopyOnWriteArrayList<(SystemProxySettings) -> Unit>() + private val installedSelector = AtomicReference() + private val previousSelector = AtomicReference() + private val installed = AtomicBoolean(false) + private val watcher by lazy { ProxyChangeWatcher(provider, ::onConfigurationChanged) } + + /** Whether the current platform has a native proxy backend available. */ + val isSupported: Boolean get() = provider.isSupported + + /** A selector answering purely from the OS configuration, without any fallback. */ + val selector: ProxySelector by lazy { NativeProxySelector(fallback = null) } + + /** The cached OS proxy configuration, read on first access. */ + fun settings(): SystemProxySettings = cachedSettings.get() ?: refresh() + + /** Re-reads the OS proxy configuration, dropping the PAC result cache. */ + fun refresh(): SystemProxySettings { + pacResults.clear() + val settings = provider.readSettings() + cachedSettings.set(settings) + return settings + } + + /** + * The proxies to try for [uri], in order. An empty list means a direct + * connection — either because no proxy is configured or because [uri] + * matches the bypass list. + * + * Resolution order matches Chromium: bypass list, then the PAC script + * (explicit URL or WPAD), then the static proxy rules — the latter also + * acting as the fallback when the PAC script cannot be evaluated. + */ + fun proxiesFor(uri: URI): List { + val settings = settings() + if (settings.isDirect) return emptyList() + if (settings.bypassRules.matches(uri)) return emptyList() + + if (settings.usesPacScript) { + val resolved = resolveWithPacScript(uri, settings) + if (resolved != null) return resolved + } + + return settings.rules.proxiesForUrlScheme(uri.scheme.orEmpty()) + } + + /** [proxiesFor] as JDK proxies, never empty: a direct connection is [Proxy.NO_PROXY]. */ + fun javaProxiesFor(uri: URI): List = + proxiesFor(uri) + .map { it.toJavaProxy() } + .ifEmpty { listOf(Proxy.NO_PROXY) } + + /** + * Installs the OS proxy configuration as the JVM-wide default [ProxySelector]. + * + * The selector that was default beforehand becomes the fallback, so JDK + * proxy system properties keep working for URIs the OS has no opinion on. + * Also starts watching the configuration so the selector stays in sync. + * + * @return false when the platform has no native backend, leaving the JVM default untouched. + */ + fun install(): Boolean { + if (!provider.isSupported) { + debugln(TAG) { "No native proxy backend on this platform, keeping the JVM default selector" } + return false + } + if (!installed.compareAndSet(false, true)) return true + + val previous = ProxySelector.getDefault() + previousSelector.set(previous) + val selector = NativeProxySelector(previous) + installedSelector.set(selector) + ProxySelector.setDefault(selector) + watcher.start() + debugln(TAG) { "Installed the native proxy selector as the JVM default" } + return true + } + + /** Restores the [ProxySelector] that was default before [install]. */ + fun uninstall() { + if (!installed.compareAndSet(true, false)) return + // Only restore when nothing else replaced the default in the meantime. + if (ProxySelector.getDefault() === installedSelector.getAndSet(null)) { + ProxySelector.setDefault(previousSelector.getAndSet(null)) + } + if (listeners.isEmpty()) watcher.stop() + debugln(TAG) { "Uninstalled the native proxy selector" } + } + + /** + * Registers [listener], invoked on a background thread whenever the OS proxy + * configuration changes. Starts the configuration watcher on first listener. + */ + fun addChangeListener(listener: (SystemProxySettings) -> Unit) { + listeners += listener + watcher.start() + } + + /** Unregisters [listener], stopping the watcher when no listener is left. */ + fun removeChangeListener(listener: (SystemProxySettings) -> Unit) { + listeners -= listener + if (listeners.isEmpty() && !installed.get()) watcher.stop() + } + + private fun resolveWithPacScript( + uri: URI, + settings: SystemProxySettings, + ): List? { + val key = pacCacheKey(uri) + pacResults[key]?.let { return it } + + val resolved = provider.resolveWithPacScript(uri, settings) ?: return null + if (pacResults.size >= MAX_PAC_CACHE_ENTRIES) pacResults.clear() + pacResults[key] = resolved + return resolved + } + + /** + * PAC results are cached per origin rather than per URL: scripts keying on + * the path are vanishingly rare, and each miss is a blocking WinHTTP call. + */ + private fun pacCacheKey(uri: URI): String = "${uri.scheme}://${uri.host}:${uri.port}" + + private fun onConfigurationChanged() { + val previous = cachedSettings.get() + val current = refresh() + if (current == previous) return + + listeners.forEach { listener -> + @Suppress("TooGenericExceptionCaught") + try { + listener(current) + } catch (e: Exception) { + errorln(TAG) { "Proxy change listener failed: ${e.message}" } + } + } + } +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NativeProxySelector.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NativeProxySelector.kt new file mode 100644 index 000000000..4f997c680 --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NativeProxySelector.kt @@ -0,0 +1,51 @@ +package dev.nucleusframework.nativeproxy + +import java.io.IOException +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.ProxySelector +import java.net.SocketAddress +import java.net.URI + +private const val TAG = "NativeProxySelector" + +/** + * A [ProxySelector] answering from the OS proxy configuration. + * + * Any URI the native backend cannot answer for — unsupported platform, missing + * native library, non-TCP scheme — is delegated to [fallback] (usually the + * selector that was the JDK default before installation), so installing this + * selector never loses the `http.proxyHost` system properties. + */ +class NativeProxySelector internal constructor( + private val fallback: ProxySelector?, +) : ProxySelector() { + override fun select(uri: URI?): List { + if (uri == null) return listOf(Proxy.NO_PROXY) + if (!NativeProxy.isSupported) return fallback?.select(uri) ?: listOf(Proxy.NO_PROXY) + + val proxies = NativeProxy.proxiesFor(uri) + if (proxies.isEmpty()) { + // No OS proxy applies: an explicitly configured JDK proxy may still. + val settings = NativeProxy.settings() + return if (settings.isDirect) { + fallback?.select(uri) ?: listOf(Proxy.NO_PROXY) + } else { + listOf(Proxy.NO_PROXY) + } + } + + // The JDK tries the returned proxies in order and falls through to DIRECT. + return proxies.map { it.toJavaProxy() } + } + + override fun connectFailed( + uri: URI?, + socketAddress: SocketAddress?, + exception: IOException?, + ) { + val address = socketAddress as? InetSocketAddress + debugln(TAG) { "Proxy connection failed for $uri via $address: ${exception?.message}" } + fallback?.connectFailed(uri, socketAddress, exception) + } +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NoopSystemProxyProvider.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NoopSystemProxyProvider.kt new file mode 100644 index 000000000..9ef34b787 --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NoopSystemProxyProvider.kt @@ -0,0 +1,26 @@ +package dev.nucleusframework.nativeproxy + +import java.net.URI + +/** + * No-op backend used on macOS and Linux. + * + * Reports an unsupported platform and a direct configuration, so + * [NativeProxySelector] transparently delegates to the JDK default selector + * (which already honours `http.proxyHost` and, on macOS/GNOME, the + * `java.net.useSystemProxies` bridge). + */ +internal object NoopSystemProxyProvider : SystemProxyProvider { + override val isSupported: Boolean = false + + override fun readSettings(): SystemProxySettings = SystemProxySettings.DIRECT + + override fun resolveWithPacScript( + uri: URI, + settings: SystemProxySettings, + ): List? = null + + override fun awaitConfigurationChange(timeoutMillis: Int): Boolean = false + + override fun wakeConfigurationWatcher() = Unit +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyBypassRules.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyBypassRules.kt new file mode 100644 index 000000000..1fe0ab0fa --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyBypassRules.kt @@ -0,0 +1,111 @@ +package dev.nucleusframework.nativeproxy + +import java.net.URI +import java.util.Locale + +private const val LOCAL_TOKEN = "" +private const val NEGATE_LOOPBACK_TOKEN = "<-loopback>" +private const val SCHEME_SEPARATOR = "://" +private const val DEFAULT_HTTP_PORT = 80 +private const val DEFAULT_HTTPS_PORT = 443 +private const val DEFAULT_FTP_PORT = 21 + +/** + * The bypass list of a system proxy configuration. + * + * Semantics are ported from Chromium's `net::ProxyBypassRules`: hostname + * patterns, CIDR blocks, the `` token for dot-less hostnames, and the + * implicit localhost / link-local bypass that `<-loopback>` turns off. + */ +data class ProxyBypassRules( + val rules: List = emptyList(), + /** `` — bypass hostnames without a dot (`intranet`, `build-server`). */ + val bypassesSimpleHostnames: Boolean = false, + /** Localhost and link-local addresses always bypass the proxy unless `<-loopback>` is listed. */ + val bypassesImplicitLoopback: Boolean = true, +) { + fun matches(uri: URI): Boolean { + val host = uri.host ?: uri.schemeSpecificPart?.substringAfter("//")?.substringBefore('/') ?: return false + val scheme = uri.scheme.orEmpty().lowercase(Locale.ROOT) + val port = if (uri.port != -1) uri.port else defaultPortForScheme(scheme) + return matches(scheme, host, port) + } + + internal fun matches( + scheme: String, + rawHost: String, + port: Int, + ): Boolean { + val host = rawHost.removeSurrounding("[", "]").lowercase(Locale.ROOT) + if (bypassesImplicitLoopback && (isLocalhost(host) || isLinkLocal(host))) return true + if (bypassesSimpleHostnames && !host.contains('.') && !isIpLiteral(host)) return true + return rules.any { it.matches(scheme, host, port) } + } + + companion object { + val EMPTY = ProxyBypassRules() + + /** + * Parses a WinInet bypass list — entries separated by `;` or whitespace. + */ + fun parse(value: String): ProxyBypassRules { + val rules = mutableListOf() + var simpleHostnames = false + var implicitLoopback = true + + for (token in value.split(';', ' ', '\t', '\n', '\r')) { + when (val entry = token.trim().lowercase(Locale.ROOT)) { + "" -> continue + LOCAL_TOKEN -> simpleHostnames = true + NEGATE_LOOPBACK_TOKEN -> implicitLoopback = false + else -> parseRule(entry)?.let(rules::add) + } + } + + return ProxyBypassRules(rules, simpleHostnames, implicitLoopback) + } + + private fun parseRule(entry: String): BypassRule? { + if (entry == "*") return BypassRule.MatchAll + + var rest = entry + var scheme: String? = null + val schemeEnd = rest.indexOf(SCHEME_SEPARATOR) + if (schemeEnd >= 0) { + scheme = rest.substring(0, schemeEnd) + rest = rest.substring(schemeEnd + SCHEME_SEPARATOR.length) + if (scheme == "*") scheme = null + } + if (rest.isEmpty()) return null + + if (rest.contains('/')) return parseIpBlock(rest, scheme) + + val (host, portText) = splitHostPort(rest) ?: return null + if (host.isEmpty()) return null + val port = portText?.toIntOrNull() + if (portText != null && port == null) return null + + // Chromium rewrites a leading dot into a subdomain wildcard. + val pattern = if (host.startsWith('.')) "*$host" else host + return BypassRule.HostnamePattern(pattern, scheme, port) + } + + private fun parseIpBlock( + value: String, + scheme: String?, + ): BypassRule? { + val prefixText = value.substringBefore('/') + val bits = value.substringAfter('/').toIntOrNull() ?: return null + if (bits < 0) return null + val prefix = parseIpLiteral(prefixText) ?: return null + return BypassRule.IpBlock(prefix, bits, scheme) + } + + private fun defaultPortForScheme(scheme: String): Int = + when (scheme) { + "https", "wss" -> DEFAULT_HTTPS_PORT + "ftp" -> DEFAULT_FTP_PORT + else -> DEFAULT_HTTP_PORT + } + } +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyChangeWatcher.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyChangeWatcher.kt new file mode 100644 index 000000000..b3d89d830 --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyChangeWatcher.kt @@ -0,0 +1,72 @@ +package dev.nucleusframework.nativeproxy + +private const val TAG = "ProxyChangeWatcher" +private const val WAIT_TIMEOUT_MILLIS = 60_000 + +/** + * Chromium waits 2 s after a registry notification before re-reading the + * configuration: the settings UI writes several values in a row, and reading in + * the middle of that burst yields a torn configuration. + */ +private const val COALESCE_DELAY_MILLIS = 2_000L + +/** + * Daemon thread parking inside the platform provider until the OS proxy + * configuration changes, then invoking [onChange] on that same thread. + */ +internal class ProxyChangeWatcher( + private val provider: SystemProxyProvider, + private val onChange: () -> Unit, +) { + private val lock = Any() + private var thread: Thread? = null + + @Volatile + private var running = false + + fun start() { + if (!provider.isSupported) return + synchronized(lock) { + if (thread != null) return + running = true + thread = + Thread(::watch, "nucleus-proxy-watcher").apply { + isDaemon = true + start() + } + } + } + + fun stop() { + val stopped: Thread? + synchronized(lock) { + running = false + stopped = thread + thread = null + } + if (stopped != null) { + provider.wakeConfigurationWatcher() + } + } + + private fun watch() { + debugln(TAG) { "Watching the OS proxy configuration for changes" } + while (running) { + if (provider.awaitConfigurationChange(WAIT_TIMEOUT_MILLIS) && running) { + coalesceBurst() + debugln(TAG) { "OS proxy configuration changed" } + onChange() + } + } + debugln(TAG) { "Stopped watching the OS proxy configuration" } + } + + private fun coalesceBurst() { + try { + Thread.sleep(COALESCE_DELAY_MILLIS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + running = false + } + } +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyProtocol.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyProtocol.kt new file mode 100644 index 000000000..d10533f7e --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyProtocol.kt @@ -0,0 +1,50 @@ +package dev.nucleusframework.nativeproxy + +import java.net.Proxy +import java.util.Locale + +private const val DEFAULT_HTTP_PORT = 80 +private const val DEFAULT_HTTPS_PORT = 443 +private const val DEFAULT_SOCKS_PORT = 1080 + +/** + * Proxy protocols supported by the system proxy resolvers. + * + * Mirrors the schemes Chromium accepts in a proxy URI (`net::ProxyServer::Scheme`), + * minus the ones the JDK cannot dial (`quic`). + */ +enum class ProxyProtocol( + val uriScheme: String, + val defaultPort: Int, +) { + HTTP("http", DEFAULT_HTTP_PORT), + HTTPS("https", DEFAULT_HTTPS_PORT), + SOCKS4("socks4", DEFAULT_SOCKS_PORT), + SOCKS5("socks5", DEFAULT_SOCKS_PORT), + ; + + /** The [Proxy.Type] used when dialing through this protocol from the JDK. */ + val javaProxyType: Proxy.Type + get() = + when (this) { + HTTP, HTTPS -> Proxy.Type.HTTP + SOCKS4, SOCKS5 -> Proxy.Type.SOCKS + } + + companion object { + /** + * Resolves a proxy URI scheme (the part before `://`) to a protocol. + * + * `socks` is an alias for SOCKS4, matching Chromium and WinInet. + * Returns `null` for `direct` and for unsupported schemes. + */ + fun fromUriScheme(scheme: String): ProxyProtocol? = + when (scheme.lowercase(Locale.ROOT)) { + "http" -> HTTP + "https" -> HTTPS + "socks", "socks4" -> SOCKS4 + "socks5" -> SOCKS5 + else -> null + } + } +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyRules.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyRules.kt new file mode 100644 index 000000000..5c5f64312 --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyRules.kt @@ -0,0 +1,88 @@ +package dev.nucleusframework.nativeproxy + +import java.util.Locale + +/** + * The static proxy servers of a system configuration, either a single list used + * for every URL scheme or one list per scheme. + * + * Layout and lookup semantics mirror Chromium's `net::ProxyConfig::ProxyRules`. + */ +data class ProxyRules( + val singleProxies: List = emptyList(), + val proxiesForHttp: List = emptyList(), + val proxiesForHttps: List = emptyList(), + val proxiesForFtp: List = emptyList(), + /** Used for every scheme without a dedicated list — the WinInet `socks=` entry. */ + val fallbackProxies: List = emptyList(), +) { + val isEmpty: Boolean + get() = + singleProxies.isEmpty() && + proxiesForHttp.isEmpty() && + proxiesForHttps.isEmpty() && + proxiesForFtp.isEmpty() && + fallbackProxies.isEmpty() + + /** + * The proxies to try for a URL of [urlScheme], empty meaning a direct connection. + * + * A scheme without a dedicated list falls back to [fallbackProxies], which is + * how WinInet treats its `socks=` entry. + */ + fun proxiesForUrlScheme(urlScheme: String): List { + if (singleProxies.isNotEmpty()) return singleProxies + return when (urlScheme.lowercase(Locale.ROOT)) { + "http" -> proxiesForHttp + "https", "wss" -> proxiesForHttps + "ftp" -> proxiesForFtp + else -> fallbackProxies + }.ifEmpty { fallbackProxies } + } + + companion object { + val EMPTY = ProxyRules() + + /** + * Parses a WinInet proxy string. + * + * Two shapes are accepted, as in Chromium's `ProxyRules::ParseFromString`: + * a bare list (`host:port`, applied to every scheme) and a per-scheme list + * (`http=host:port;https=host:port;socks=host:1080`). Entries without a + * scheme prefix default to HTTP, except `socks=` which defaults to SOCKS4. + */ + fun parse(value: String): ProxyRules { + val entries = + value + .split(';') + .map { it.trim() } + .filter { it.isNotEmpty() } + if (entries.isEmpty()) return EMPTY + + val perScheme = entries.any { it.contains('=') } + if (!perScheme) { + return ProxyRules(singleProxies = entries.mapNotNull { ProxyServer.parse(it) }) + } + + val lists = mutableMapOf>() + for (entry in entries) { + val separator = entry.indexOf('=') + if (separator < 0) continue + val scheme = entry.substring(0, separator).trim().lowercase(Locale.ROOT) + val servers = entry.substring(separator + 1).trim() + val default = if (scheme == "socks") ProxyProtocol.SOCKS4 else ProxyProtocol.HTTP + val parsed = ProxyServer.parseList(servers, default) + if (parsed.isNotEmpty()) { + lists[scheme] = lists.getOrElse(scheme) { emptyList() } + parsed + } + } + + return ProxyRules( + proxiesForHttp = lists["http"].orEmpty(), + proxiesForHttps = lists["https"].orEmpty(), + proxiesForFtp = lists["ftp"].orEmpty(), + fallbackProxies = lists["socks"].orEmpty(), + ) + } + } +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyServer.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyServer.kt new file mode 100644 index 000000000..488631ae9 --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/ProxyServer.kt @@ -0,0 +1,80 @@ +package dev.nucleusframework.nativeproxy + +import java.net.InetSocketAddress +import java.net.Proxy +import java.util.Locale + +private const val MAX_PORT = 65535 +private const val SCHEME_SEPARATOR = "://" + +/** + * A single proxy server: protocol, host and port. + * + * The host is never resolved by this library — [toJavaProxy] builds an + * unresolved address so the proxy hostname is resolved by the connection + * itself (and, for SOCKS, possibly by the proxy). + */ +data class ProxyServer( + val protocol: ProxyProtocol, + val host: String, + val port: Int, +) { + fun toJavaProxy(): Proxy = Proxy(protocol.javaProxyType, InetSocketAddress.createUnresolved(host, port)) + + override fun toString(): String = "${protocol.uriScheme}://${formatHost()}:$port" + + private fun formatHost(): String = if (host.contains(':')) "[$host]" else host + + companion object { + /** + * Parses a proxy URI as written in a WinInet/PAC proxy list. + * + * Accepted forms: `host`, `host:port`, `[::1]:port`, `scheme://host:port`. + * Returns `null` for `direct://`, unsupported schemes and malformed input. + */ + fun parse( + spec: String, + defaultProtocol: ProxyProtocol = ProxyProtocol.HTTP, + ): ProxyServer? { + val trimmed = spec.trim() + if (trimmed.isEmpty()) return null + + val schemeEnd = trimmed.indexOf(SCHEME_SEPARATOR) + if (schemeEnd < 0) return parseAuthority(trimmed, defaultProtocol) + + val protocol = ProxyProtocol.fromUriScheme(trimmed.substring(0, schemeEnd)) ?: return null + return parseAuthority(trimmed.substring(schemeEnd + SCHEME_SEPARATOR.length), protocol) + } + + /** + * Parses a semicolon- or whitespace-separated proxy list, as returned by + * `WinHttpGetProxyForUrl` or found in a per-scheme WinInet proxy entry. + */ + internal fun parseList( + value: String, + defaultProtocol: ProxyProtocol = ProxyProtocol.HTTP, + ): List = + value + .split(';', ' ', '\t', '\n', '\r') + .mapNotNull { entry -> + entry.takeIf { it.isNotBlank() }?.let { parse(it, defaultProtocol) } + } + + private fun parseAuthority( + authority: String, + protocol: ProxyProtocol, + ): ProxyServer? { + // A PAC script may hand out a proxy with a trailing path; it is meaningless here. + val value = authority.substringBefore('/').trim() + if (value.isEmpty()) return null + + val (host, portText) = splitHostPort(value) ?: return null + if (host.isEmpty()) return null + + val port = portText?.toIntOrNull() ?: protocol.defaultPort + if (port !in 1..MAX_PORT) return null + + return ProxyServer(protocol, host.lowercase(Locale.ROOT), port) + } + } +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/SystemProxyProvider.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/SystemProxyProvider.kt new file mode 100644 index 000000000..e2d1a8101 --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/SystemProxyProvider.kt @@ -0,0 +1,46 @@ +package dev.nucleusframework.nativeproxy + +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.nativeproxy.windows.WindowsSystemProxyProvider +import java.net.URI + +/** + * Platform backend reading — and watching — the OS proxy configuration. + */ +internal interface SystemProxyProvider { + /** Whether this platform has a native implementation. */ + val isSupported: Boolean + + /** Reads the current configuration, [SystemProxySettings.DIRECT] when unavailable. */ + fun readSettings(): SystemProxySettings + + /** + * Runs the PAC script (explicit URL or WPAD-discovered) for [uri]. + * + * @return the resolved proxy list, empty for `DIRECT`, or `null` when the + * script could not be fetched or evaluated — callers then fall back to + * the static rules. + */ + fun resolveWithPacScript( + uri: URI, + settings: SystemProxySettings, + ): List? + + /** + * Blocks until the OS proxy configuration changes or [timeoutMillis] elapses. + * + * @return true when a change was observed. + */ + fun awaitConfigurationChange(timeoutMillis: Int): Boolean + + /** Unblocks a thread parked in [awaitConfigurationChange]. */ + fun wakeConfigurationWatcher() + + companion object { + fun forCurrentPlatform(): SystemProxyProvider = + when (Platform.Current) { + Platform.Windows -> WindowsSystemProxyProvider + Platform.MacOS, Platform.Linux, Platform.Unknown -> NoopSystemProxyProvider + } + } +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/SystemProxySettings.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/SystemProxySettings.kt new file mode 100644 index 000000000..95715241b --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/SystemProxySettings.kt @@ -0,0 +1,31 @@ +package dev.nucleusframework.nativeproxy + +/** + * A snapshot of the OS proxy configuration. + * + * Shaped after Chromium's `net::ProxyConfig`: WPAD auto-detection, an explicit + * PAC script URL and static proxy servers are independent settings, and are + * consulted in that order when resolving a URL. + */ +data class SystemProxySettings( + /** WPAD is enabled ("Automatically detect settings"): a PAC script is discovered via DHCP/DNS. */ + val autoDetect: Boolean = false, + /** An explicit PAC script URL ("Use automatic configuration script"). */ + val pacUrl: String? = null, + /** Statically configured proxy servers. */ + val rules: ProxyRules = ProxyRules.EMPTY, + /** Hosts that must be reached without a proxy. */ + val bypassRules: ProxyBypassRules = ProxyBypassRules.EMPTY, +) { + /** Whether the configuration asks for direct connections only. */ + val isDirect: Boolean + get() = !autoDetect && pacUrl == null && rules.isEmpty + + /** Whether resolving a URL requires running a PAC script. */ + val usesPacScript: Boolean + get() = autoDetect || pacUrl != null + + companion object { + val DIRECT = SystemProxySettings() + } +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/windows/WindowsProxyBridge.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/windows/WindowsProxyBridge.kt new file mode 100644 index 000000000..856c74678 --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/windows/WindowsProxyBridge.kt @@ -0,0 +1,93 @@ +package dev.nucleusframework.nativeproxy.windows + +import dev.nucleusframework.core.runtime.NativeLibraryLoader +import dev.nucleusframework.nativeproxy.errorln +import java.util.logging.Level +import java.util.logging.Logger + +private const val TAG = "WindowsProxyBridge" +private const val LIBRARY_NAME = "nucleus_proxy" + +/** + * JNI bridge over WinHTTP and the Internet Settings registry keys. + * + * Every entry point degrades to a neutral value when the native library could + * not be loaded, so callers never have to guard the load state themselves. + */ +internal object WindowsProxyBridge { + /** Index of the WinInet proxy string (`lpszProxy`) in the [nativeGetProxyConfig] result. */ + const val INDEX_PROXY = 0 + + /** Index of the bypass list (`lpszProxyBypass`). */ + const val INDEX_BYPASS = 1 + + /** Index of the PAC script URL (`lpszAutoConfigUrl`). */ + const val INDEX_PAC_URL = 2 + + /** Index of the WPAD flag (`fAutoDetect`), `"1"` or `"0"`. */ + const val INDEX_AUTO_DETECT = 3 + + private val logger = Logger.getLogger(WindowsProxyBridge::class.java.simpleName) + private val loaded = NativeLibraryLoader.load(LIBRARY_NAME, WindowsProxyBridge::class.java) + + val isLoaded: Boolean get() = loaded + + /** + * Returns `WinHttpGetIEProxyConfigForCurrentUser` as a 4-element array + * indexed by the `INDEX_*` constants, or `null` when the call failed. + */ + @JvmStatic + external fun nativeGetProxyConfig(): Array? + + /** + * Runs `WinHttpGetProxyForUrl` for [url]. + * + * @param pacUrl an explicit PAC script URL, or `null` to use WPAD auto-detection. + * @return the WinHTTP proxy list, an empty string when the script returned + * `DIRECT`, or `null` when the script could not be fetched or evaluated. + */ + @JvmStatic + external fun nativeResolveProxyForUrl( + url: String, + pacUrl: String?, + ): String? + + /** + * Blocks on registry change notifications for the Internet Settings keys and + * returns true when one of them changed before [timeoutMillis] elapsed. + */ + @JvmStatic + external fun nativeWaitForConfigChange(timeoutMillis: Int): Boolean + + /** Signals the event [nativeWaitForConfigChange] also waits on. */ + @JvmStatic + external fun nativeWakeWatcher() + + fun getProxyConfig(): Array? = call("nativeGetProxyConfig") { nativeGetProxyConfig() } + + fun resolveProxyForUrl( + url: String, + pacUrl: String?, + ): String? = call("nativeResolveProxyForUrl") { nativeResolveProxyForUrl(url, pacUrl) } + + fun waitForConfigChange(timeoutMillis: Int): Boolean = + call("nativeWaitForConfigChange") { nativeWaitForConfigChange(timeoutMillis) } ?: false + + fun wakeWatcher() { + call("nativeWakeWatcher") { nativeWakeWatcher() } + } + + private fun call( + name: String, + block: () -> T, + ): T? { + if (!loaded) return null + return try { + block() + } catch (e: UnsatisfiedLinkError) { + logger.log(Level.WARNING, "JNI call failed for $name", e) + errorln(TAG) { "Native proxy bridge unavailable: $name" } + null + } + } +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/windows/WindowsSystemProxyProvider.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/windows/WindowsSystemProxyProvider.kt new file mode 100644 index 000000000..abfbda8d9 --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/windows/WindowsSystemProxyProvider.kt @@ -0,0 +1,62 @@ +package dev.nucleusframework.nativeproxy.windows + +import dev.nucleusframework.nativeproxy.ProxyBypassRules +import dev.nucleusframework.nativeproxy.ProxyRules +import dev.nucleusframework.nativeproxy.ProxyServer +import dev.nucleusframework.nativeproxy.SystemProxyProvider +import dev.nucleusframework.nativeproxy.SystemProxySettings +import dev.nucleusframework.nativeproxy.debugln +import java.net.URI + +private const val TAG = "WindowsSystemProxyProvider" + +/** + * Windows backend built on `WinHttpGetIEProxyConfigForCurrentUser`, + * `WinHttpGetProxyForUrl` and `RegNotifyChangeKeyValue`. + * + * This is the same set of APIs Chromium's `ProxyConfigServiceWin` and + * `ProxyResolverWinHttp` use, so the effective configuration matches what + * Edge/Chrome see — including per-machine and Group Policy settings, which are + * merged by WinHTTP itself. + */ +internal object WindowsSystemProxyProvider : SystemProxyProvider { + override val isSupported: Boolean + get() = WindowsProxyBridge.isLoaded + + override fun readSettings(): SystemProxySettings { + val config = WindowsProxyBridge.getProxyConfig() ?: return SystemProxySettings.DIRECT + + val settings = + SystemProxySettings( + autoDetect = config.getOrNull(WindowsProxyBridge.INDEX_AUTO_DETECT) == "1", + pacUrl = config.getOrNull(WindowsProxyBridge.INDEX_PAC_URL)?.takeIf { it.isNotBlank() }, + rules = + config + .getOrNull(WindowsProxyBridge.INDEX_PROXY) + ?.let(ProxyRules::parse) ?: ProxyRules.EMPTY, + bypassRules = + config + .getOrNull(WindowsProxyBridge.INDEX_BYPASS) + ?.let(ProxyBypassRules::parse) ?: ProxyBypassRules.EMPTY, + ) + + debugln(TAG) { "Windows proxy configuration: $settings" } + return settings + } + + override fun resolveWithPacScript( + uri: URI, + settings: SystemProxySettings, + ): List? { + if (!settings.usesPacScript) return null + // A configured script URL wins over WPAD, matching WinHTTP and Chromium. + val resolved = WindowsProxyBridge.resolveProxyForUrl(uri.toString(), settings.pacUrl) ?: return null + if (resolved.isEmpty()) return emptyList() + return ProxyServer.parseList(resolved) + } + + override fun awaitConfigurationChange(timeoutMillis: Int): Boolean = + WindowsProxyBridge.waitForConfigChange(timeoutMillis) + + override fun wakeConfigurationWatcher() = WindowsProxyBridge.wakeWatcher() +} diff --git a/native-proxy/src/main/native/windows/NucleusProxyBridge.c b/native-proxy/src/main/native/windows/NucleusProxyBridge.c new file mode 100644 index 000000000..e3d7c7efa --- /dev/null +++ b/native-proxy/src/main/native/windows/NucleusProxyBridge.c @@ -0,0 +1,382 @@ +#include +#include +#include + +/** + * Windows JNI bridge for the system proxy configuration. + * + * Uses the same WinHTTP surface as Chromium (`net::ProxyConfigServiceWin` and + * `net::ProxyResolverWinHttp`): + * + * - `WinHttpGetIEProxyConfigForCurrentUser` reads the effective per-user + * configuration: WPAD flag, PAC script URL, proxy string and bypass list. + * WinHTTP already merges the machine-wide and Group Policy values. + * - `WinHttpGetProxyForUrl` fetches and evaluates the PAC script, either from + * the configured URL or from a WPAD lookup (DHCP + DNS A). As in Chromium, + * the call is first made without auto-logon and only retried with it after + * ERROR_WINHTTP_LOGIN_FAILURE, because auto-logon disables the script cache. + * - `RegNotifyChangeKeyValue` on the Internet Settings keys reports + * configuration changes without polling. + * + * Built with /NODEFAULTLIB - no CRT dependency, Win32 heap APIs only. + */ + +/* CRT-free: provide memcpy/memset/memcmp so the linker resolves them. */ +#pragma function(memcpy, memset, memcmp) + +void *memcpy(void *dst, const void *src, size_t n) { + BYTE *d = (BYTE *)dst; + const BYTE *s = (const BYTE *)src; + while (n--) *d++ = *s++; + return dst; +} + +void *memset(void *dst, int val, size_t n) { + BYTE *d = (BYTE *)dst; + while (n--) *d++ = (BYTE)val; + return dst; +} + +int memcmp(const void *a, const void *b, size_t n) { + const BYTE *pa = (const BYTE *)a; + const BYTE *pb = (const BYTE *)b; + while (n--) { + if (*pa != *pb) return (int)*pa - (int)*pb; + pa++; pb++; + } + return 0; +} + +/* ── Config array layout, mirrors WindowsProxyBridge.INDEX_* ── */ + +#define CONFIG_INDEX_PROXY 0 +#define CONFIG_INDEX_BYPASS 1 +#define CONFIG_INDEX_PAC_URL 2 +#define CONFIG_INDEX_AUTO_DETECT 3 +#define CONFIG_LENGTH 4 + +/* WinHTTP timeouts (ms) - a PAC fetch must never stall a connection for long. */ +#define RESOLVE_TIMEOUT_MS 10000 +#define CONNECT_TIMEOUT_MS 10000 +#define SEND_TIMEOUT_MS 10000 +#define RECEIVE_TIMEOUT_MS 10000 + +/* ── Heap helpers ── */ + +static void *heap_alloc(SIZE_T size) { + return HeapAlloc(GetProcessHeap(), 0, size); +} + +static void heap_free(void *ptr) { + if (ptr) HeapFree(GetProcessHeap(), 0, ptr); +} + +/* ── String helpers ── */ + +static SIZE_T wide_length(const WCHAR *text) { + SIZE_T length = 0; + if (text == NULL) return 0; + while (text[length] != L'\0') length++; + return length; +} + +static jstring wide_to_java(JNIEnv *env, const WCHAR *text) { + if (text == NULL) return NULL; + return (*env)->NewString(env, (const jchar *)text, (jsize)wide_length(text)); +} + +/** Copies a Java string into a heap-allocated, NUL-terminated UTF-16 buffer. */ +static WCHAR *java_to_wide(JNIEnv *env, jstring text) { + jsize length; + WCHAR *buffer; + + if (text == NULL) return NULL; + length = (*env)->GetStringLength(env, text); + buffer = (WCHAR *)heap_alloc(((SIZE_T)length + 1) * sizeof(WCHAR)); + if (buffer == NULL) return NULL; + if (length > 0) { + (*env)->GetStringRegion(env, text, 0, length, (jchar *)buffer); + } + buffer[length] = L'\0'; + return buffer; +} + +/* ── Cached WinHTTP session ── */ + +static PVOID volatile g_session = NULL; + +/** + * Returns the process-wide WinHTTP session used for PAC resolution, creating it + * on first use. WINHTTP_ACCESS_TYPE_NO_PROXY is required: the session must not + * itself go through a proxy to fetch the script. + */ +static HINTERNET proxy_session(void) { + HINTERNET created; + PVOID previous; + + PVOID existing = InterlockedCompareExchangePointer(&g_session, NULL, NULL); + if (existing != NULL) return (HINTERNET)existing; + + created = WinHttpOpen( + L"Nucleus", WINHTTP_ACCESS_TYPE_NO_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (created == NULL) return NULL; + + WinHttpSetTimeouts( + created, RESOLVE_TIMEOUT_MS, CONNECT_TIMEOUT_MS, + SEND_TIMEOUT_MS, RECEIVE_TIMEOUT_MS); + + previous = InterlockedCompareExchangePointer(&g_session, (PVOID)created, NULL); + if (previous != NULL) { + WinHttpCloseHandle(created); + return (HINTERNET)previous; + } + return created; +} + +/* ── Watched registry keys (same set as Chromium) ── */ + +typedef struct { + HKEY root; + LPCWSTR path; +} WatchKey; + +#define INTERNET_SETTINGS L"Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings" +#define INTERNET_SETTINGS_POLICY L"Software\\Policies\\Microsoft\\Windows\\CurrentVersion\\Internet Settings" +#define INTERNET_CONNECTIONS L"Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings\\Connections" + +static const WatchKey WATCH_KEYS[] = { + { HKEY_CURRENT_USER, INTERNET_SETTINGS }, + { HKEY_LOCAL_MACHINE, INTERNET_SETTINGS }, + { HKEY_CURRENT_USER, INTERNET_SETTINGS_POLICY }, + { HKEY_LOCAL_MACHINE, INTERNET_SETTINGS_POLICY }, + { HKEY_CURRENT_USER, INTERNET_CONNECTIONS }, + { HKEY_LOCAL_MACHINE, INTERNET_CONNECTIONS } +}; + +#define WATCH_KEY_COUNT (sizeof(WATCH_KEYS) / sizeof(WATCH_KEYS[0])) +#define WATCH_FILTER (REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET) + +/* ── Wake event, lets the JVM release a parked watcher thread ── */ + +static PVOID volatile g_wake_event = NULL; + +static HANDLE wake_event(void) { + HANDLE created; + PVOID previous; + + PVOID existing = InterlockedCompareExchangePointer(&g_wake_event, NULL, NULL); + if (existing != NULL) return (HANDLE)existing; + + /* Manual reset: the waiter clears it once it has observed the signal. */ + created = CreateEventW(NULL, TRUE, FALSE, NULL); + if (created == NULL) return NULL; + + previous = InterlockedCompareExchangePointer(&g_wake_event, (PVOID)created, NULL); + if (previous != NULL) { + CloseHandle(created); + return (HANDLE)previous; + } + return created; +} + +BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpReserved) { + (void)hinstDLL; + (void)fdwReason; + (void)lpReserved; + return TRUE; +} + +JNIEXPORT jobjectArray JNICALL +Java_dev_nucleusframework_nativeproxy_windows_WindowsProxyBridge_nativeGetProxyConfig( + JNIEnv *env, jclass clazz) { + + WINHTTP_CURRENT_USER_IE_PROXY_CONFIG config; + jclass stringClass; + jobjectArray result; + + (void)clazz; + + memset(&config, 0, sizeof(config)); + if (!WinHttpGetIEProxyConfigForCurrentUser(&config)) { + /* Fails when there is no interactive user (services, session 0). */ + return NULL; + } + + stringClass = (*env)->FindClass(env, "java/lang/String"); + result = stringClass != NULL + ? (*env)->NewObjectArray(env, CONFIG_LENGTH, stringClass, NULL) + : NULL; + + if (result != NULL) { + jstring proxy = wide_to_java(env, config.lpszProxy); + jstring bypass = wide_to_java(env, config.lpszProxyBypass); + jstring pacUrl = wide_to_java(env, config.lpszAutoConfigUrl); + jstring autoDetect = wide_to_java(env, config.fAutoDetect ? L"1" : L"0"); + + (*env)->SetObjectArrayElement(env, result, CONFIG_INDEX_PROXY, proxy); + (*env)->SetObjectArrayElement(env, result, CONFIG_INDEX_BYPASS, bypass); + (*env)->SetObjectArrayElement(env, result, CONFIG_INDEX_PAC_URL, pacUrl); + (*env)->SetObjectArrayElement(env, result, CONFIG_INDEX_AUTO_DETECT, autoDetect); + + if (proxy != NULL) (*env)->DeleteLocalRef(env, proxy); + if (bypass != NULL) (*env)->DeleteLocalRef(env, bypass); + if (pacUrl != NULL) (*env)->DeleteLocalRef(env, pacUrl); + if (autoDetect != NULL) (*env)->DeleteLocalRef(env, autoDetect); + } + + /* The struct members are allocated by WinHTTP and owned by the caller. */ + if (config.lpszProxy != NULL) GlobalFree(config.lpszProxy); + if (config.lpszProxyBypass != NULL) GlobalFree(config.lpszProxyBypass); + if (config.lpszAutoConfigUrl != NULL) GlobalFree(config.lpszAutoConfigUrl); + + return result; +} + +JNIEXPORT jstring JNICALL +Java_dev_nucleusframework_nativeproxy_windows_WindowsProxyBridge_nativeResolveProxyForUrl( + JNIEnv *env, jclass clazz, jstring url, jstring pacUrl) { + + HINTERNET session; + WCHAR *urlText; + WCHAR *pacText; + WINHTTP_AUTOPROXY_OPTIONS options; + WINHTTP_PROXY_INFO info; + BOOL resolved; + jstring result; + + (void)clazz; + + session = proxy_session(); + if (session == NULL) return NULL; + + urlText = java_to_wide(env, url); + if (urlText == NULL) return NULL; + pacText = java_to_wide(env, pacUrl); + + memset(&options, 0, sizeof(options)); + if (pacText != NULL) { + options.dwFlags = WINHTTP_AUTOPROXY_CONFIG_URL; + options.lpszAutoConfigUrl = pacText; + } else { + options.dwFlags = WINHTTP_AUTOPROXY_AUTO_DETECT; + options.dwAutoDetectFlags = + WINHTTP_AUTO_DETECT_TYPE_DHCP | WINHTTP_AUTO_DETECT_TYPE_DNS_A; + } + /* Auto-logon bypasses the script cache, so only enable it when required. */ + options.fAutoLogonIfChallenged = FALSE; + + memset(&info, 0, sizeof(info)); + resolved = WinHttpGetProxyForUrl(session, urlText, &options, &info); + if (!resolved && GetLastError() == ERROR_WINHTTP_LOGIN_FAILURE) { + options.fAutoLogonIfChallenged = TRUE; + memset(&info, 0, sizeof(info)); + resolved = WinHttpGetProxyForUrl(session, urlText, &options, &info); + } + + heap_free(urlText); + heap_free(pacText); + + if (!resolved) return NULL; + + if (info.dwAccessType == WINHTTP_ACCESS_TYPE_NO_PROXY || info.lpszProxy == NULL) { + /* The script resolved to DIRECT: an empty string, not a failure. */ + result = (*env)->NewString(env, (const jchar *)L"", 0); + } else { + result = wide_to_java(env, info.lpszProxy); + } + + /* The per-URL bypass list is redundant here: the caller already applied the + * configured bypass rules before asking for a PAC resolution. */ + if (info.lpszProxy != NULL) GlobalFree(info.lpszProxy); + if (info.lpszProxyBypass != NULL) GlobalFree(info.lpszProxyBypass); + + return result; +} + +JNIEXPORT jboolean JNICALL +Java_dev_nucleusframework_nativeproxy_windows_WindowsProxyBridge_nativeWaitForConfigChange( + JNIEnv *env, jclass clazz, jint timeoutMillis) { + + HKEY keys[WATCH_KEY_COUNT]; + HANDLE events[WATCH_KEY_COUNT + 1]; + DWORD watched = 0; + DWORD total; + DWORD waitResult; + HANDLE wake; + BOOL changed = FALSE; + DWORD i; + + (void)env; + (void)clazz; + + for (i = 0; i < WATCH_KEY_COUNT; i++) { + HKEY key = NULL; + HANDLE event; + + if (RegOpenKeyExW(WATCH_KEYS[i].root, WATCH_KEYS[i].path, 0, + KEY_NOTIFY, &key) != ERROR_SUCCESS) { + /* A policy key simply may not exist on this machine. */ + continue; + } + + event = CreateEventW(NULL, TRUE, FALSE, NULL); + if (event == NULL) { + RegCloseKey(key); + continue; + } + + if (RegNotifyChangeKeyValue(key, TRUE, WATCH_FILTER, event, TRUE) != ERROR_SUCCESS) { + CloseHandle(event); + RegCloseKey(key); + continue; + } + + keys[watched] = key; + events[watched] = event; + watched++; + } + + wake = wake_event(); + total = watched; + if (wake != NULL) { + events[total] = wake; + total++; + } + + if (total == 0) { + Sleep(timeoutMillis > 0 ? (DWORD)timeoutMillis : 0); + return JNI_FALSE; + } + + waitResult = WaitForMultipleObjects( + total, events, FALSE, + timeoutMillis >= 0 ? (DWORD)timeoutMillis : INFINITE); + + /* Only the registry handles mean "configuration changed"; the wake event is + * the JVM asking the watcher to stop. */ + if (waitResult >= WAIT_OBJECT_0 && waitResult < WAIT_OBJECT_0 + watched) { + changed = TRUE; + } else if (wake != NULL && waitResult == WAIT_OBJECT_0 + watched) { + ResetEvent(wake); + } + + for (i = 0; i < watched; i++) { + CloseHandle(events[i]); + RegCloseKey(keys[i]); + } + + return changed ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_nativeproxy_windows_WindowsProxyBridge_nativeWakeWatcher( + JNIEnv *env, jclass clazz) { + + HANDLE wake = wake_event(); + + (void)env; + (void)clazz; + + if (wake != NULL) SetEvent(wake); +} diff --git a/native-proxy/src/main/native/windows/build.bat b/native-proxy/src/main/native/windows/build.bat new file mode 100644 index 000000000..ad0f2f21f --- /dev/null +++ b/native-proxy/src/main/native/windows/build.bat @@ -0,0 +1,140 @@ +@echo off +REM Compiles NucleusProxyBridge.c into per-architecture DLLs (x64 + ARM64). +REM The outputs are placed in the JAR resources so they ship with the library. +REM +REM Prerequisites: Visual Studio Build Tools (MSVC) with ARM64 support. +REM Usage: build.bat + +setlocal enabledelayedexpansion + +set "SCRIPT_DIR=%~dp0" +set "SRC=%SCRIPT_DIR%NucleusProxyBridge.c" +set "RESOURCE_DIR=%SCRIPT_DIR%..\..\resources\nucleus\native" +set "OUT_DIR_X64=%RESOURCE_DIR%\win32-x64" +set "OUT_DIR_ARM64=%RESOURCE_DIR%\win32-aarch64" +set "LIB_NAME=nucleus_proxy.dll" + +REM Check JAVA_HOME +if "%JAVA_HOME%"=="" ( + echo ERROR: JAVA_HOME is not set. >&2 + exit /b 1 +) +if not exist "%JAVA_HOME%\include\jni.h" ( + echo ERROR: JNI headers not found at %JAVA_HOME%\include >&2 + exit /b 1 +) + +set "JNI_INCLUDE=%JAVA_HOME%\include" +set "JNI_INCLUDE_WIN32=%JAVA_HOME%\include\win32" + +REM Locate vcvarsall.bat +set "VCVARSALL=" +REM Prefer vswhere: resolves any installed VS version (incl. 18+ and previews). +set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" +if exist "%VSWHERE%" ( + for /f "usebackq tokens=*" %%i in (`"%VSWHERE%" -latest -prerelease -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do ( + if exist "%%i\VC\Auxiliary\Build\vcvarsall.bat" set "VCVARSALL=%%i\VC\Auxiliary\Build\vcvarsall.bat" + ) +) +REM Fallback: scan well-known install locations if vswhere did not resolve a path. +if "%VCVARSALL%"=="" ( + for %%v in (18 2022 2019 2017) do ( + for %%e in (Enterprise Professional Community BuildTools) do ( + if exist "C:\Program Files\Microsoft Visual Studio\%%v\%%e\VC\Auxiliary\Build\vcvarsall.bat" ( + set "VCVARSALL=C:\Program Files\Microsoft Visual Studio\%%v\%%e\VC\Auxiliary\Build\vcvarsall.bat" + goto :found_vc + ) + if exist "C:\Program Files (x86)\Microsoft Visual Studio\%%v\%%e\VC\Auxiliary\Build\vcvarsall.bat" ( + set "VCVARSALL=C:\Program Files (x86)\Microsoft Visual Studio\%%v\%%e\VC\Auxiliary\Build\vcvarsall.bat" + goto :found_vc + ) + ) + ) +) +:found_vc +if "%VCVARSALL%"=="" ( + echo ERROR: Could not locate vcvarsall.bat. Install Visual Studio Build Tools. >&2 + exit /b 1 +) + +echo Using vcvarsall.bat: %VCVARSALL% + +REM Create output directories +if not exist "%OUT_DIR_X64%" mkdir "%OUT_DIR_X64%" +if not exist "%OUT_DIR_ARM64%" mkdir "%OUT_DIR_ARM64%" + +REM ---- Compile x64 ---- +REM Use setlocal/endlocal to isolate vcvarsall environment per architecture, +REM preventing PATH accumulation that exceeds cmd.exe line length on CI. +echo. +echo === Building x64 DLL === +setlocal +call "%VCVARSALL%" x64 +if errorlevel 1 ( + echo ERROR: vcvarsall x64 failed >&2 + exit /b 1 +) + +cl /LD /O1 /GS- /nologo ^ + /I"%JNI_INCLUDE%" /I"%JNI_INCLUDE_WIN32%" ^ + "%SRC%" ^ + /Fe:"%OUT_DIR_X64%\%LIB_NAME%" ^ + /link /NODEFAULTLIB /ENTRY:DllMain winhttp.lib advapi32.lib kernel32.lib +if errorlevel 1 ( + echo ERROR: x64 compilation failed >&2 + exit /b 1 +) +endlocal + +REM Clean up intermediate files +del /q "%OUT_DIR_X64%\*.obj" "%OUT_DIR_X64%\*.lib" "%OUT_DIR_X64%\*.exp" 2>nul +del /q "%SCRIPT_DIR%\*.obj" 2>nul + +REM ---- Compile ARM64 ---- +echo. +echo === Building ARM64 DLL === +setlocal +call "%VCVARSALL%" x64_arm64 +if errorlevel 1 ( + echo WARNING: vcvarsall x64_arm64 failed. ARM64 cross-compilation may not be available. >&2 + endlocal + goto :done +) + +cl /LD /O1 /GS- /nologo ^ + /I"%JNI_INCLUDE%" /I"%JNI_INCLUDE_WIN32%" ^ + "%SRC%" ^ + /Fe:"%OUT_DIR_ARM64%\%LIB_NAME%" ^ + /link /NODEFAULTLIB /ENTRY:DllMain winhttp.lib advapi32.lib kernel32.lib +if errorlevel 1 ( + echo WARNING: ARM64 compilation failed. >&2 + endlocal + goto :done +) +endlocal + +REM Clean up intermediate files +del /q "%OUT_DIR_ARM64%\*.obj" "%OUT_DIR_ARM64%\*.lib" "%OUT_DIR_ARM64%\*.exp" 2>nul +del /q "%SCRIPT_DIR%\*.obj" 2>nul + +:done +echo. +echo Built DLLs: +if exist "%OUT_DIR_X64%\%LIB_NAME%" echo %OUT_DIR_X64%\%LIB_NAME% +if exist "%OUT_DIR_ARM64%\%LIB_NAME%" echo %OUT_DIR_ARM64%\%LIB_NAME% + +REM Clear the NativeLibraryLoader cache: it is content-addressed, so the stale +REM copy lives under a fingerprint subdirectory and must be removed recursively. +if defined LOCALAPPDATA ( + set "CACHE_BASE=%LOCALAPPDATA%\nucleus\native" + if exist "!CACHE_BASE!\win32-x64" ( + del /s /q "!CACHE_BASE!\win32-x64\%LIB_NAME%" >nul 2>nul + echo Cleared x64 cache + ) + if exist "!CACHE_BASE!\win32-aarch64" ( + del /s /q "!CACHE_BASE!\win32-aarch64\%LIB_NAME%" >nul 2>nul + echo Cleared ARM64 cache + ) +) + +endlocal diff --git a/native-proxy/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.native-proxy/reachability-metadata.json b/native-proxy/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.native-proxy/reachability-metadata.json new file mode 100644 index 000000000..57858eb85 --- /dev/null +++ b/native-proxy/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.native-proxy/reachability-metadata.json @@ -0,0 +1,8 @@ +{ + "reflection": [ + { + "type": "dev.nucleusframework.nativeproxy.windows.WindowsProxyBridge", + "jniAccessible": true + } + ] +} diff --git a/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/NativeProxyTest.kt b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/NativeProxyTest.kt new file mode 100644 index 000000000..256bb25e1 --- /dev/null +++ b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/NativeProxyTest.kt @@ -0,0 +1,56 @@ +package dev.nucleusframework.nativeproxy + +import dev.nucleusframework.nativeproxy.windows.WindowsProxyBridge +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.net.Proxy +import java.net.URI + +class NativeProxyTest { + private val isWindows = System.getProperty("os.name", "").lowercase().contains("win") + + @Test + fun `unsupported platforms report a direct configuration`() { + assumeTrue("Test requires a non-Windows host", !isWindows) + + assertFalse(NativeProxy.isSupported) + assertEquals(SystemProxySettings.DIRECT, NativeProxy.settings()) + assertTrue(NativeProxy.proxiesFor(URI("https://example.com")).isEmpty()) + assertFalse(NativeProxy.install()) + } + + @Test + fun `native library loads on Windows`() { + assumeTrue("Test requires Windows", isWindows) + + assertTrue("Native proxy bridge should be loaded", WindowsProxyBridge.isLoaded) + } + + @Test + fun `the Windows configuration is readable`() { + assumeTrue("Test requires Windows", isWindows) + assumeTrue("Native library not loaded", WindowsProxyBridge.isLoaded) + + // Any outcome is valid — the CI machine may or may not have a proxy — + // but reading must never throw and must be internally consistent. + val settings = NativeProxy.settings() + assertNotNull(settings) + assertEquals(settings.isDirect, !settings.usesPacScript && settings.rules.isEmpty) + } + + @Test + fun `loopback is never proxied`() { + assertTrue(NativeProxy.proxiesFor(URI("http://127.0.0.1:8080")).isEmpty()) + } + + @Test + fun `java proxies always contain at least a direct entry`() { + val proxies = NativeProxy.javaProxiesFor(URI("http://localhost")) + + assertEquals(listOf(Proxy.NO_PROXY), proxies) + } +} diff --git a/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/ProxyBypassRulesTest.kt b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/ProxyBypassRulesTest.kt new file mode 100644 index 000000000..48e74a2ad --- /dev/null +++ b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/ProxyBypassRulesTest.kt @@ -0,0 +1,94 @@ +package dev.nucleusframework.nativeproxy + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.URI + +class ProxyBypassRulesTest { + @Test + fun `local token bypasses dot-less hostnames only`() { + val rules = ProxyBypassRules.parse("") + + assertTrue(rules.matches(URI("http://intranet"))) + assertFalse(rules.matches(URI("http://intranet.corp.com"))) + assertFalse(rules.matches(URI("http://93.184.216.34"))) + } + + @Test + fun `leading dot matches subdomains but not the domain itself`() { + val rules = ProxyBypassRules.parse(".corp.com") + + assertTrue(rules.matches(URI("https://build.corp.com"))) + assertTrue(rules.matches(URI("https://a.b.corp.com"))) + assertFalse(rules.matches(URI("https://corp.com"))) + } + + @Test + fun `an exact hostname does not match its subdomains`() { + val rules = ProxyBypassRules.parse("corp.com") + + assertTrue(rules.matches(URI("https://corp.com"))) + assertFalse(rules.matches(URI("https://build.corp.com"))) + } + + @Test + fun `wildcards and star match as glob patterns`() { + assertTrue(ProxyBypassRules.parse("*.corp.com").matches(URI("http://a.corp.com"))) + assertTrue(ProxyBypassRules.parse("*").matches(URI("http://anything.example"))) + } + + @Test + fun `a port suffix restricts the rule to that port`() { + val rules = ProxyBypassRules.parse("build.corp.com:8080") + + assertTrue(rules.matches(URI("http://build.corp.com:8080/path"))) + assertFalse(rules.matches(URI("http://build.corp.com/path"))) + } + + @Test + fun `a scheme prefix restricts the rule to that scheme`() { + val rules = ProxyBypassRules.parse("http://build.corp.com") + + assertTrue(rules.matches(URI("http://build.corp.com"))) + assertFalse(rules.matches(URI("https://build.corp.com"))) + } + + @Test + fun `cidr blocks match ipv4 and ipv6 literals`() { + assertTrue(ProxyBypassRules.parse("10.0.0.0/8").matches(URI("http://10.4.5.6"))) + assertFalse(ProxyBypassRules.parse("10.0.0.0/8").matches(URI("http://11.4.5.6"))) + assertTrue(ProxyBypassRules.parse("192.168.1.0/24").matches(URI("http://192.168.1.42"))) + assertFalse(ProxyBypassRules.parse("192.168.1.0/24").matches(URI("http://192.168.2.42"))) + assertTrue(ProxyBypassRules.parse("fd00::/8").matches(URI("http://[fd12::1]"))) + } + + @Test + fun `localhost and link-local addresses bypass implicitly`() { + val rules = ProxyBypassRules.EMPTY + + assertTrue(rules.matches(URI("http://localhost:3000"))) + assertTrue(rules.matches(URI("http://127.0.0.1"))) + assertTrue(rules.matches(URI("http://[::1]:8080"))) + assertTrue(rules.matches(URI("http://169.254.10.20"))) + assertFalse(rules.matches(URI("http://example.com"))) + } + + @Test + fun `negate loopback token disables the implicit bypass`() { + val rules = ProxyBypassRules.parse("<-loopback>") + + assertFalse(rules.matches(URI("http://localhost:3000"))) + assertFalse(rules.matches(URI("http://127.0.0.1"))) + } + + @Test + fun `entries are separated by semicolons and whitespace`() { + val rules = ProxyBypassRules.parse(" .corp.com; 10.0.0.0/8 ") + + assertTrue(rules.bypassesSimpleHostnames) + assertTrue(rules.matches(URI("http://a.corp.com"))) + assertTrue(rules.matches(URI("http://10.1.1.1"))) + assertTrue(rules.matches(URI("http://intranet"))) + } +} diff --git a/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/ProxyRulesTest.kt b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/ProxyRulesTest.kt new file mode 100644 index 000000000..71ff6419f --- /dev/null +++ b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/ProxyRulesTest.kt @@ -0,0 +1,84 @@ +package dev.nucleusframework.nativeproxy + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProxyRulesTest { + @Test + fun `single proxy applies to every scheme`() { + val rules = ProxyRules.parse("proxy.corp:8080") + + assertEquals( + listOf(ProxyServer(ProxyProtocol.HTTP, "proxy.corp", 8080)), + rules.proxiesForUrlScheme("http"), + ) + assertEquals(rules.proxiesForUrlScheme("http"), rules.proxiesForUrlScheme("https")) + assertEquals(rules.proxiesForUrlScheme("http"), rules.proxiesForUrlScheme("ftp")) + } + + @Test + fun `per-scheme entries are mapped to their scheme`() { + val rules = ProxyRules.parse("http=http1.corp:80;https=http2.corp:8443;ftp=ftp.corp:21") + + assertEquals(listOf(ProxyServer(ProxyProtocol.HTTP, "http1.corp", 80)), rules.proxiesForUrlScheme("http")) + assertEquals(listOf(ProxyServer(ProxyProtocol.HTTP, "http2.corp", 8443)), rules.proxiesForUrlScheme("https")) + assertEquals(listOf(ProxyServer(ProxyProtocol.HTTP, "ftp.corp", 21)), rules.proxiesForUrlScheme("ftp")) + } + + @Test + fun `socks entry defaults to socks4 and serves unlisted schemes`() { + val rules = ProxyRules.parse("http=http.corp:80;socks=socks.corp:1080") + + assertEquals(listOf(ProxyServer(ProxyProtocol.SOCKS4, "socks.corp", 1080)), rules.fallbackProxies) + assertEquals(rules.fallbackProxies, rules.proxiesForUrlScheme("gopher")) + assertEquals(rules.fallbackProxies, rules.proxiesForUrlScheme("https")) + } + + @Test + fun `explicit scheme prefixes win over the default`() { + val rules = ProxyRules.parse("https=https://secure.corp:443;socks=socks5://socks.corp:1080") + + assertEquals(listOf(ProxyServer(ProxyProtocol.HTTPS, "secure.corp", 443)), rules.proxiesForUrlScheme("https")) + assertEquals(listOf(ProxyServer(ProxyProtocol.SOCKS5, "socks.corp", 1080)), rules.fallbackProxies) + } + + @Test + fun `a missing port falls back to the protocol default`() { + val rules = ProxyRules.parse("proxy.corp") + + assertEquals(listOf(ProxyServer(ProxyProtocol.HTTP, "proxy.corp", 80)), rules.singleProxies) + } + + @Test + fun `blank and malformed input yields empty rules`() { + assertTrue(ProxyRules.parse("").isEmpty) + assertTrue(ProxyRules.parse(" ;; ").isEmpty) + assertTrue(ProxyRules.parse("direct://proxy.corp:80").isEmpty) + } + + @Test + fun `proxy list entries are parsed in order`() { + val servers = ProxyServer.parseList("first.corp:8080; second.corp:3128 third.corp") + + assertEquals(3, servers.size) + assertEquals("first.corp", servers[0].host) + assertEquals(3128, servers[1].port) + assertEquals(80, servers[2].port) + } + + @Test + fun `ipv6 proxies keep their literal host`() { + val server = ProxyServer.parse("[::1]:8080") + + assertEquals(ProxyServer(ProxyProtocol.HTTP, "::1", 8080), server) + assertEquals("http://[::1]:8080", server.toString()) + } + + @Test + fun `out of range ports are rejected`() { + assertNull(ProxyServer.parse("proxy.corp:0")) + assertNull(ProxyServer.parse("proxy.corp:70000")) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 8d2746a4c..dfe892e8c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,6 +39,7 @@ include(":native-ssl") include(":native-http") include(":native-http-okhttp") include(":native-http-ktor") +include(":native-proxy") include(":linux-hidpi") include(":system-color") include(":decorated-window-core") From c3e96776a32bb90665cc04d3429a6614ca655646 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Thu, 6 Aug 2026 00:36:20 +0300 Subject: [PATCH 2/6] feat(native-proxy): add Linux backend (GSettings, KDE, env) Port Chromium's ProxyConfigServiceLinux resolution order: - GNOME-like: org.gnome.system.proxy via dlopen'd libgio - KDE: kioslaverc under ~/.config and XDG_CONFIG_DIRS - Fallback: http_proxy / https_proxy / all_proxy / no_proxy / SOCKS_SERVER PAC/WPAD is reported but not evaluated (no WinHTTP equivalent). Change detection polls the configuration; GSettings D-Bus signals require a process-default GMainContext the JVM does not drive. --- .github/workflows/build-natives.yaml | 5 + .github/workflows/pre-merge.yaml | 2 + .github/workflows/publish-maven.yaml | 2 + CLAUDE.md | 2 +- README.md | 2 +- native-proxy/build.gradle.kts | 23 +- .../nativeproxy/NativeProxy.kt | 7 +- .../nativeproxy/NoopSystemProxyProvider.kt | 4 +- .../nativeproxy/SystemProxyProvider.kt | 4 +- .../nativeproxy/linux/EnvProxySettings.kt | 141 ++++ .../nativeproxy/linux/KdeProxySettings.kt | 212 ++++++ .../nativeproxy/linux/LinuxProxyBridge.kt | 77 +++ .../linux/LinuxSystemProxyProvider.kt | 130 ++++ native-proxy/src/main/native/linux/build.sh | 64 ++ .../main/native/linux/nucleus_proxy_linux.c | 645 ++++++++++++++++++ .../reachability-metadata.json | 4 + .../nativeproxy/NativeProxyTest.kt | 46 +- .../nativeproxy/linux/EnvProxySettingsTest.kt | 122 ++++ .../nativeproxy/linux/KdeProxySettingsTest.kt | 118 ++++ .../nativeproxy/linux/LinuxProxyE2ETest.kt | 135 ++++ 20 files changed, 1730 insertions(+), 15 deletions(-) create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/linux/EnvProxySettings.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/linux/KdeProxySettings.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/linux/LinuxProxyBridge.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/linux/LinuxSystemProxyProvider.kt create mode 100755 native-proxy/src/main/native/linux/build.sh create mode 100644 native-proxy/src/main/native/linux/nucleus_proxy_linux.c create mode 100644 native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/linux/EnvProxySettingsTest.kt create mode 100644 native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/linux/KdeProxySettingsTest.kt create mode 100644 native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/linux/LinuxProxyE2ETest.kt diff --git a/.github/workflows/build-natives.yaml b/.github/workflows/build-natives.yaml index d1dd52c42..ca14fb623 100644 --- a/.github/workflows/build-natives.yaml +++ b/.github/workflows/build-natives.yaml @@ -383,6 +383,10 @@ jobs: if: steps.natives-cache.outputs.cache-hit != 'true' run: bash linux-hidpi/src/main/native/linux/build.sh + - name: Build native-proxy Linux native shared library + if: steps.natives-cache.outputs.cache-hit != 'true' + run: bash native-proxy/src/main/native/linux/build.sh + - name: Build system-color Linux native shared library if: steps.natives-cache.outputs.cache-hit != 'true' run: bash system-color/src/main/native/linux/build.sh @@ -444,6 +448,7 @@ jobs: "darkmode-detector/libnucleus_linux_theme.so" "decorated-window-jni/libnucleus_linux_jni.so" "linux-hidpi/libnucleus_linux_hidpi_jni.so" + "native-proxy/libnucleus_proxy.so" "system-color/libnucleus_systemcolor.so" "energy-manager/libnucleus_energy_manager.so" "notification-linux/libnucleus_notification_linux.so" diff --git a/.github/workflows/pre-merge.yaml b/.github/workflows/pre-merge.yaml index 8e61268a6..69c8e15af 100644 --- a/.github/workflows/pre-merge.yaml +++ b/.github/workflows/pre-merge.yaml @@ -61,6 +61,8 @@ jobs: "decorated-window-jni/src/main/resources/nucleus/native/win32-aarch64/nucleus_windows_decoration.dll" "linux-hidpi/src/main/resources/nucleus/native/linux-x64/libnucleus_linux_hidpi_jni.so" "linux-hidpi/src/main/resources/nucleus/native/linux-aarch64/libnucleus_linux_hidpi_jni.so" + "native-proxy/src/main/resources/nucleus/native/linux-x64/libnucleus_proxy.so" + "native-proxy/src/main/resources/nucleus/native/linux-aarch64/libnucleus_proxy.so" "system-color/src/main/resources/nucleus/native/linux-x64/libnucleus_systemcolor.so" "system-color/src/main/resources/nucleus/native/linux-aarch64/libnucleus_systemcolor.so" "system-color/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_systemcolor.dylib" diff --git a/.github/workflows/publish-maven.yaml b/.github/workflows/publish-maven.yaml index f40c3a3cc..2da648e41 100644 --- a/.github/workflows/publish-maven.yaml +++ b/.github/workflows/publish-maven.yaml @@ -60,6 +60,8 @@ jobs: "decorated-window-jni/src/main/resources/nucleus/native/win32-aarch64/nucleus_windows_decoration.dll" "linux-hidpi/src/main/resources/nucleus/native/linux-x64/libnucleus_linux_hidpi_jni.so" "linux-hidpi/src/main/resources/nucleus/native/linux-aarch64/libnucleus_linux_hidpi_jni.so" + "native-proxy/src/main/resources/nucleus/native/linux-x64/libnucleus_proxy.so" + "native-proxy/src/main/resources/nucleus/native/linux-aarch64/libnucleus_proxy.so" "system-color/src/main/resources/nucleus/native/linux-x64/libnucleus_systemcolor.so" "system-color/src/main/resources/nucleus/native/linux-aarch64/libnucleus_systemcolor.so" "system-color/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_systemcolor.dylib" diff --git a/CLAUDE.md b/CLAUDE.md index 332d98f62..19259a5ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ A multi-module Gradle plugin and runtime library toolkit for shipping production - `system-color` - Reactive system accent color and high contrast detection via JNI - `energy-manager` - Energy efficiency & screen-awake APIs - `native-ssl` / `native-http` / `native-http-okhttp` / `native-http-ktor` - OS trust store integration -- `native-proxy` - OS proxy configuration via JNI (Windows: WinHTTP/WPAD/PAC + Internet Settings registry watching; no-op on macOS/Linux) +- `native-proxy` - OS proxy configuration via JNI (Windows: WinHTTP/WPAD/PAC; Linux: GSettings/KDE/env; no-op on macOS) - `linux-hidpi` - Native HiDPI scale detection on Linux - `graalvm-runtime` - GraalVM native-image bootstrap - `decorated-window-core` - Shared types, layout, styling (design-system agnostic) diff --git a/README.md b/README.md index b6f2a8d34..9cedb0851 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ Each module is published independently to Maven Central — use them together or | `nucleus.autolaunch` | Start the app at user login across all platforms | | `nucleus.native-ssl` | OS trust store integration | | `nucleus.native-http` | HTTP client with native SSL | -| `nucleus.native-proxy` | OS proxy configuration — WPAD/PAC, bypass rules (Windows) | +| `nucleus.native-proxy` | OS proxy configuration — WPAD/PAC (Windows), GSettings/KDE/env (Linux) | | `nucleus.linux-hidpi` | Native HiDPI scale detection on Linux | | `nucleus.graalvm-runtime` | Native-image bootstrap, font fixes, automatic resource inclusion | diff --git a/native-proxy/build.gradle.kts b/native-proxy/build.gradle.kts index ee54bb690..135535b17 100644 --- a/native-proxy/build.gradle.kts +++ b/native-proxy/build.gradle.kts @@ -42,13 +42,27 @@ val buildNativeWindows by tasks.registering(Exec::class) { commandLine("cmd", "/c", File(nativeDir, "build.bat").absolutePath) } +val buildNativeLinux by tasks.registering(Exec::class) { + description = "Compiles the C JNI bridge into a Linux shared library" + group = "build" + val nativeDir = file("src/main/native/linux") + val outputDir = file("src/main/resources/nucleus/native") + val arch = if (System.getProperty("os.arch") == "aarch64") "linux-aarch64" else "linux-x64" + val checkFile = File(outputDir, "$arch/libnucleus_proxy.so") + onlyIf { Os.isFamily(Os.FAMILY_UNIX) && !Os.isFamily(Os.FAMILY_MAC) && !checkFile.exists() } + inputs.dir(nativeDir) + outputs.dir(outputDir) + workingDir(nativeDir) + commandLine("bash", File(nativeDir, "build.sh").absolutePath) +} + tasks.processResources { - dependsOn(buildNativeWindows) + dependsOn(buildNativeWindows, buildNativeLinux) } tasks.configureEach { if (name == "sourcesJar") { - dependsOn(buildNativeWindows) + dependsOn(buildNativeWindows, buildNativeLinux) } } @@ -57,7 +71,10 @@ mavenPublishing { pom { name.set("Nucleus Native Proxy") - description.set("OS proxy configuration integration (WinHTTP/WPAD/PAC) for JVM desktop applications") + description.set( + "OS proxy configuration integration (WinHTTP/WPAD/PAC on Windows; " + + "GSettings/KDE/env on Linux) for JVM desktop applications", + ) url.set("https://github.com/NucleusFramework/Nucleus") licenses { diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NativeProxy.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NativeProxy.kt index 0cf7f3bd9..4d9d5c6de 100644 --- a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NativeProxy.kt +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NativeProxy.kt @@ -20,9 +20,10 @@ private const val MAX_PAC_CACHE_ENTRIES = 256 * NativeProxy.addChangeListener { println("proxy configuration changed: $it") } * ``` * - * Windows is the only implemented platform (WinHTTP/WPAD/PAC + Internet - * Settings registry watching); macOS and Linux report [isSupported] `false` and - * every call degrades to a direct configuration. + * Windows (WinHTTP/WPAD/PAC + Internet Settings registry watching) and Linux + * (GSettings / KDE kioslaverc / env vars) are implemented; macOS reports + * [isSupported] `false` and every call degrades to a direct configuration. + * Linux does not evaluate PAC scripts yet — only static rules and bypass lists. */ object NativeProxy { private val provider = SystemProxyProvider.forCurrentPlatform() diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NoopSystemProxyProvider.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NoopSystemProxyProvider.kt index 9ef34b787..93f2bdeb3 100644 --- a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NoopSystemProxyProvider.kt +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NoopSystemProxyProvider.kt @@ -3,11 +3,11 @@ package dev.nucleusframework.nativeproxy import java.net.URI /** - * No-op backend used on macOS and Linux. + * No-op backend used on macOS (and unknown platforms). * * Reports an unsupported platform and a direct configuration, so * [NativeProxySelector] transparently delegates to the JDK default selector - * (which already honours `http.proxyHost` and, on macOS/GNOME, the + * (which already honours `http.proxyHost` and, on macOS, the * `java.net.useSystemProxies` bridge). */ internal object NoopSystemProxyProvider : SystemProxyProvider { diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/SystemProxyProvider.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/SystemProxyProvider.kt index e2d1a8101..f9a17a109 100644 --- a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/SystemProxyProvider.kt +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/SystemProxyProvider.kt @@ -1,6 +1,7 @@ package dev.nucleusframework.nativeproxy import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.nativeproxy.linux.LinuxSystemProxyProvider import dev.nucleusframework.nativeproxy.windows.WindowsSystemProxyProvider import java.net.URI @@ -40,7 +41,8 @@ internal interface SystemProxyProvider { fun forCurrentPlatform(): SystemProxyProvider = when (Platform.Current) { Platform.Windows -> WindowsSystemProxyProvider - Platform.MacOS, Platform.Linux, Platform.Unknown -> NoopSystemProxyProvider + Platform.Linux -> LinuxSystemProxyProvider + Platform.MacOS, Platform.Unknown -> NoopSystemProxyProvider } } } diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/linux/EnvProxySettings.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/linux/EnvProxySettings.kt new file mode 100644 index 000000000..6955273bc --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/linux/EnvProxySettings.kt @@ -0,0 +1,141 @@ +package dev.nucleusframework.nativeproxy.linux + +import dev.nucleusframework.nativeproxy.BypassRule +import dev.nucleusframework.nativeproxy.ProxyBypassRules +import dev.nucleusframework.nativeproxy.ProxyProtocol +import dev.nucleusframework.nativeproxy.ProxyRules +import dev.nucleusframework.nativeproxy.ProxyServer +import dev.nucleusframework.nativeproxy.SystemProxySettings + +/** + * Reads the classic Unix proxy environment variables. + * + * Port of Chromium's `ProxyConfigServiceLinux::Delegate::GetConfigFromEnv`: + * `auto_proxy`, `all_proxy`, `{http,https,ftp}_proxy`, `SOCKS_SERVER` / + * `SOCKS_VERSION`, and `no_proxy`. Hostname bypass entries use suffix matching + * (`google.com` also matches `www.google.com`). + * + * Returns `null` when no proxy-related variable is set, so the caller can + * distinguish "nothing configured" from "explicitly direct" (`no_proxy=*` with + * no proxy host). + */ +internal object EnvProxySettings { + fun read(getenv: (String) -> String? = ::envLookup): SystemProxySettings? { + val autoProxy = getenv("auto_proxy") + if (autoProxy != null) { + return if (autoProxy.isBlank()) { + SystemProxySettings(autoDetect = true) + } else { + SystemProxySettings(pacUrl = autoProxy.trim()) + } + } + + val allProxy = getenv("all_proxy")?.takeIf { it.isNotBlank() } + val httpProxy = getenv("http_proxy")?.takeIf { it.isNotBlank() } + val httpsProxy = getenv("https_proxy")?.takeIf { it.isNotBlank() } + val ftpProxy = getenv("ftp_proxy")?.takeIf { it.isNotBlank() } + val socksServer = getenv("SOCKS_SERVER")?.takeIf { it.isNotBlank() } + val noProxy = getenv("no_proxy").orEmpty() + + val rules = + buildRules(allProxy, httpProxy, httpsProxy, ftpProxy, socksServer, getenv) + ?: return null + + if (rules.isEmpty) { + return if (noProxy.isNotBlank()) SystemProxySettings.DIRECT else null + } + + val bypass = + if (noProxy.isBlank()) { + ProxyBypassRules.EMPTY + } else { + ProxyBypassRules + .parse(noProxy.replace(',', ';')) + .withSuffixMatching() + } + + return SystemProxySettings(rules = rules, bypassRules = bypass) + } + + private fun buildRules( + allProxy: String?, + httpProxy: String?, + httpsProxy: String?, + ftpProxy: String?, + socksServer: String?, + getenv: (String) -> String?, + ): ProxyRules? { + if (allProxy != null) { + val server = parseEnvProxy(allProxy, ProxyProtocol.HTTP) ?: return null + return ProxyRules(singleProxies = listOf(server)) + } + if (httpProxy != null || httpsProxy != null || ftpProxy != null) { + return ProxyRules( + proxiesForHttp = parseList(httpProxy, ProxyProtocol.HTTP), + proxiesForHttps = parseList(httpsProxy, ProxyProtocol.HTTP), + proxiesForFtp = parseList(ftpProxy, ProxyProtocol.HTTP), + ) + } + if (socksServer != null) { + val scheme = + if (getenv("SOCKS_VERSION") == "4") { + ProxyProtocol.SOCKS4 + } else { + ProxyProtocol.SOCKS5 + } + val server = parseEnvProxy(socksServer, scheme) ?: return null + return ProxyRules(singleProxies = listOf(server)) + } + return ProxyRules.EMPTY + } + + private fun parseList( + value: String?, + protocol: ProxyProtocol, + ): List = + value + ?.let { parseEnvProxy(it, protocol) } + ?.let(::listOf) + .orEmpty() + + private fun envLookup(name: String): String? = System.getenv(name) ?: System.getenv(name.uppercase()) + + private fun parseEnvProxy( + value: String, + defaultProtocol: ProxyProtocol, + ): ProxyServer? { + var host = value.trim() + if (host.isEmpty()) return null + + val at = host.lastIndexOf('@') + if (at >= 0) host = host.substring(at + 1) + + if (host.endsWith('/')) host = host.dropLast(1) + + return ProxyServer.parse(host, defaultProtocol) + } +} + +/** + * Chromium rewrites env-var bypass hostnames into suffix matches so that a + * rule of `google.com` also matches `www.google.com`. GNOME ignore-hosts does + * not do this; only the env-var path applies it. + */ +internal fun ProxyBypassRules.withSuffixMatching(): ProxyBypassRules { + if (rules.isEmpty()) return this + val rewritten = + rules.map { rule -> + when (rule) { + is BypassRule.HostnamePattern -> { + val pattern = rule.pattern + if (pattern.startsWith('*') || pattern.contains('/')) { + rule + } else { + rule.copy(pattern = "*$pattern") + } + } + else -> rule + } + } + return copy(rules = rewritten) +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/linux/KdeProxySettings.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/linux/KdeProxySettings.kt new file mode 100644 index 000000000..7eced912c --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/linux/KdeProxySettings.kt @@ -0,0 +1,212 @@ +package dev.nucleusframework.nativeproxy.linux + +import dev.nucleusframework.nativeproxy.ProxyBypassRules +import dev.nucleusframework.nativeproxy.ProxyProtocol +import dev.nucleusframework.nativeproxy.ProxyRules +import dev.nucleusframework.nativeproxy.ProxyServer +import dev.nucleusframework.nativeproxy.SystemProxySettings +import dev.nucleusframework.nativeproxy.debugln +import java.io.File + +private const val TAG = "KdeProxySettings" +private const val PROXY_SECTION = "[Proxy Settings]" + +/** KDE `ProxyType`: no proxy. */ +private const val KDE_PROXY_NONE = 0 + +/** KDE `ProxyType`: manual host:port list. */ +private const val KDE_PROXY_MANUAL = 1 + +/** KDE `ProxyType`: PAC script URL. */ +private const val KDE_PROXY_PAC = 2 + +/** KDE `ProxyType`: WPAD auto-detect. */ +private const val KDE_PROXY_WPAD = 3 + +/** KDE `ProxyType`: manual, but host fields name environment variables. */ +private const val KDE_PROXY_ENV = 4 + +/** + * Reads KDE's `kioslaverc` proxy section. + * + * Port of Chromium's `SettingGetterImplKDE`. Looks under `$KDEHOME`, + * `~/.config`, and `$XDG_CONFIG_DIRS` for a `kioslaverc` file. Later paths + * override earlier ones (ascending priority, matching Chromium). + * + * Returns `null` when no readable `kioslaverc` with a `[Proxy Settings]` + * section is found. + */ +internal object KdeProxySettings { + fun read(getenv: (String) -> String? = System::getenv): SystemProxySettings? { + val values = loadMergedSettings(getenv) ?: return null + return settingsFromKioslaverc(values, getenv) + } + + private fun loadMergedSettings(getenv: (String) -> String?): Map? { + val files = resolveKioslavercPaths(getenv) + if (files.isEmpty()) return null + + val values = linkedMapOf() + var opened = false + for (file in files.filter { it.isFile && it.canRead() }) { + val section = readProxySection(file) ?: continue + if (!opened) { + values.clear() + opened = true + } + values.putAll(section) + debugln(TAG) { "Loaded ${section.size} keys from ${file.absolutePath}" } + } + return values.takeIf { opened && it.isNotEmpty() } + } + + internal fun resolveKioslavercPaths(getenv: (String) -> String?): List { + val dirs = mutableListOf() + val kdeHome = getenv("KDEHOME") + if (!kdeHome.isNullOrBlank()) { + dirs += File(kdeHome, "share/config") + } else { + val home = getenv("HOME") + // Low priority first so later putAll wins. + val xdgDirs = getenv("XDG_CONFIG_DIRS").orEmpty() + for (dir in xdgDirs + .split(':') + .map { it.trim() } + .filter { it.isNotEmpty() } + .reversed()) { + dirs += File(dir) + } + if (!home.isNullOrBlank()) { + dirs += File(home, ".kde/share/config") + dirs += File(home, ".kde4/share/config") + dirs += File(home, ".config") + } + } + return dirs.map { File(it, "kioslaverc") } + } + + internal fun readProxySection(file: File): Map? { + val result = linkedMapOf() + try { + file.bufferedReader().useLines { lines -> + var inSection = false + lines.forEach { raw -> + val line = raw.trimEnd('\r') + when { + line.startsWith('[') -> inSection = line.trim() == PROXY_SECTION + !inSection -> Unit + else -> parseKeyValue(line)?.let { (key, value) -> result[key] = value } + } + } + } + } catch (_: Exception) { + return null + } + return result.takeIf { it.isNotEmpty() } + } + + private fun parseKeyValue(line: String): Pair? { + val eq = line.indexOf('=') + if (eq <= 0) return null + var key = line.substring(0, eq).trim() + val value = line.substring(eq + 1).trim() + if (key.endsWith(']')) { + val bracket = key.lastIndexOf('[') + if (bracket > 0) key = key.substring(0, bracket).trimEnd() + } + return key.takeIf { it.isNotEmpty() }?.let { it to value } + } + + private fun settingsFromKioslaverc( + values: Map, + getenv: (String) -> String?, + ): SystemProxySettings? { + val proxyType = values["ProxyType"]?.toIntOrNull() ?: KDE_PROXY_NONE + return when (proxyType) { + KDE_PROXY_NONE -> SystemProxySettings.DIRECT + KDE_PROXY_PAC -> pacSettings(values) + KDE_PROXY_WPAD -> SystemProxySettings(autoDetect = true) + KDE_PROXY_MANUAL, KDE_PROXY_ENV -> manualSettings(values, getenv, proxyType == KDE_PROXY_ENV) + else -> SystemProxySettings.DIRECT + } + } + + private fun pacSettings(values: Map): SystemProxySettings { + val script = values["Proxy Config Script"]?.takeIf { it.isNotBlank() } + return if (script != null) { + val pacUrl = if (script.startsWith('/')) "file://$script" else script + SystemProxySettings(pacUrl = pacUrl) + } else { + SystemProxySettings(autoDetect = true) + } + } + + private fun manualSettings( + values: Map, + getenv: (String) -> String?, + indirect: Boolean, + ): SystemProxySettings? { + fun resolve(key: String): String? { + val raw = values[key]?.takeIf { it.isNotBlank() } ?: return null + if (!indirect) return normalizeKdeHost(raw) + return getenv(raw)?.takeIf { it.isNotBlank() }?.let(::normalizeKdeHost) + } + + val http = resolve("httpProxy")?.let { ProxyServer.parse(it, ProxyProtocol.HTTP) } + val https = resolve("httpsProxy")?.let { ProxyServer.parse(it, ProxyProtocol.HTTP) } + val ftp = resolve("ftpProxy")?.let { ProxyServer.parse(it, ProxyProtocol.HTTP) } + val socks = resolve("socksProxy")?.let { ProxyServer.parse(it, ProxyProtocol.SOCKS5) } + + if (listOfNotNull(http, https, ftp, socks).isEmpty()) return null + + val rules = buildManualRules(http, https, ftp, socks) + val noProxyRaw = + if (indirect) { + values["NoProxyFor"]?.let { getenv(it) }.orEmpty() + } else { + values["NoProxyFor"].orEmpty() + } + val bypass = + if (noProxyRaw.isBlank()) { + ProxyBypassRules.EMPTY + } else { + ProxyBypassRules + .parse(noProxyRaw.replace(',', ';')) + .withSuffixMatching() + } + + return SystemProxySettings(rules = rules, bypassRules = bypass) + } + + private fun buildManualRules( + http: ProxyServer?, + https: ProxyServer?, + ftp: ProxyServer?, + socks: ProxyServer?, + ): ProxyRules = + when { + socks != null && http == null && https == null && ftp == null -> + ProxyRules(singleProxies = listOf(socks)) + http != null && https == null && ftp == null && socks == null -> + ProxyRules(singleProxies = listOf(http)) + else -> + ProxyRules( + proxiesForHttp = http?.let(::listOf).orEmpty(), + proxiesForHttps = https?.let(::listOf).orEmpty(), + proxiesForFtp = ftp?.let(::listOf).orEmpty(), + fallbackProxies = socks?.let(::listOf).orEmpty(), + ) + } + + /** KDE 5+ uses a space between host and port; normalise to `host:port`. */ + private fun normalizeKdeHost(value: String): String { + val trimmed = value.trim() + if (trimmed.startsWith("//:")) return "" + val space = trimmed.indexOf(' ') + return if (space > 0) { + trimmed.substring(0, space) + ":" + trimmed.substring(space + 1).trim() + } else { + trimmed + } + } +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/linux/LinuxProxyBridge.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/linux/LinuxProxyBridge.kt new file mode 100644 index 000000000..ca6b97816 --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/linux/LinuxProxyBridge.kt @@ -0,0 +1,77 @@ +package dev.nucleusframework.nativeproxy.linux + +import dev.nucleusframework.core.runtime.NativeLibraryLoader +import dev.nucleusframework.nativeproxy.errorln +import java.util.logging.Level +import java.util.logging.Logger + +private const val TAG = "LinuxProxyBridge" +private const val LIBRARY_NAME = "nucleus_proxy" + +/** + * JNI bridge over GNOME GSettings (`org.gnome.system.proxy`). + * + * Every entry point degrades to a neutral value when the native library could + * not be loaded or the schema is missing, so callers never have to guard the + * load state themselves. + */ +internal object LinuxProxyBridge { + /** Index of the WinInet-style proxy string in the [nativeGetProxyConfig] result. */ + const val INDEX_PROXY = 0 + + /** Index of the `;`-joined ignore-hosts list. */ + const val INDEX_BYPASS = 1 + + /** Index of the PAC script URL (`autoconfig-url`). */ + const val INDEX_PAC_URL = 2 + + /** Index of the WPAD flag, `"1"` or `"0"`. */ + const val INDEX_AUTO_DETECT = 3 + + private val logger = Logger.getLogger(LinuxProxyBridge::class.java.simpleName) + private val loaded = NativeLibraryLoader.load(LIBRARY_NAME, LinuxProxyBridge::class.java) + + val isLoaded: Boolean get() = loaded + + /** + * Returns the GSettings proxy configuration as a 4-element array indexed by + * the `INDEX_*` constants, or `null` when GIO / the schema is unavailable. + */ + @JvmStatic + external fun nativeGetProxyConfig(): Array? + + /** + * Blocks until a GSettings proxy key changes or [timeoutMillis] elapses. + * + * @return true when a change was observed. + */ + @JvmStatic + external fun nativeWaitForConfigChange(timeoutMillis: Int): Boolean + + /** Signals the event [nativeWaitForConfigChange] also waits on. */ + @JvmStatic + external fun nativeWakeWatcher() + + fun getProxyConfig(): Array? = call("nativeGetProxyConfig") { nativeGetProxyConfig() } + + fun waitForConfigChange(timeoutMillis: Int): Boolean = + call("nativeWaitForConfigChange") { nativeWaitForConfigChange(timeoutMillis) } ?: false + + fun wakeWatcher() { + call("nativeWakeWatcher") { nativeWakeWatcher() } + } + + private fun call( + name: String, + block: () -> T, + ): T? { + if (!loaded) return null + return try { + block() + } catch (e: UnsatisfiedLinkError) { + logger.log(Level.WARNING, "JNI call failed for $name", e) + errorln(TAG) { "Native proxy bridge unavailable: $name" } + null + } + } +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/linux/LinuxSystemProxyProvider.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/linux/LinuxSystemProxyProvider.kt new file mode 100644 index 000000000..30ec9985a --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/linux/LinuxSystemProxyProvider.kt @@ -0,0 +1,130 @@ +package dev.nucleusframework.nativeproxy.linux + +import dev.nucleusframework.nativeproxy.ProxyBypassRules +import dev.nucleusframework.nativeproxy.ProxyRules +import dev.nucleusframework.nativeproxy.ProxyServer +import dev.nucleusframework.nativeproxy.SystemProxyProvider +import dev.nucleusframework.nativeproxy.SystemProxySettings +import dev.nucleusframework.nativeproxy.debugln +import java.net.URI +import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean + +private const val TAG = "LinuxSystemProxyProvider" + +/** How often [awaitConfigurationChange] re-reads the desktop configuration. */ +private const val POLL_INTERVAL_MILLIS = 250L + +private const val NANOS_PER_MILLI = 1_000_000L + +/** + * Linux backend modelled on Chromium's `ProxyConfigServiceLinux`. + * + * Resolution order: + * 1. Desktop settings — `kioslaverc` on KDE, GSettings on every other session + * that ships `org.gnome.system.proxy` (GNOME, Cinnamon, …) + * 2. Environment variables (`http_proxy`, `no_proxy`, …) when the desktop + * exposes no configuration + * + * PAC / WPAD evaluation is not implemented: there is no WinHTTP equivalent on + * Linux without embedding a JS engine. When a PAC URL or WPAD is configured, + * [resolveWithPacScript] returns `null` so the caller falls back to the static + * rules (usually empty → direct). + * + * Change watching polls the configuration rather than parking on GSettings + * signals: D-Bus deliveries for `GSettings` are bound to the process-default + * `GMainContext`, which a JVM process does not drive. Polling is cheap (a few + * GSettings reads per second) and works for GSettings, KDE and env alike. + * + * Always [isSupported]: env vars and KDE need no JNI, and GSettings is optional. + */ +internal object LinuxSystemProxyProvider : SystemProxyProvider { + override val isSupported: Boolean = true + + private val wakeRequested = AtomicBoolean(false) + + override fun readSettings(): SystemProxySettings { + val settings = + if (isKdeDesktop()) { + KdeProxySettings.read() ?: EnvProxySettings.read() + } else { + readGsettings() ?: EnvProxySettings.read() + } ?: SystemProxySettings.DIRECT + + debugln(TAG) { "Linux proxy configuration: $settings" } + return settings + } + + override fun resolveWithPacScript( + uri: URI, + settings: SystemProxySettings, + ): List? { + if (!settings.usesPacScript) return null + debugln(TAG) { + "PAC/WPAD is configured but not evaluated on Linux " + + "(pacUrl=${settings.pacUrl}, autoDetect=${settings.autoDetect}); " + + "falling back to static rules" + } + return null + } + + override fun awaitConfigurationChange(timeoutMillis: Int): Boolean { + wakeRequested.set(false) + val initial = readSettings() + val deadline = System.nanoTime() + timeoutMillis.coerceAtLeast(0) * NANOS_PER_MILLI + + while (System.nanoTime() < deadline) { + if (wakeRequested.get()) return false + try { + Thread.sleep(POLL_INTERVAL_MILLIS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return false + } + if (wakeRequested.get()) return false + if (readSettings() != initial) return true + } + return false + } + + override fun wakeConfigurationWatcher() { + wakeRequested.set(true) + // Best-effort: also poke the native side if a legacy wait is parked there. + if (LinuxProxyBridge.isLoaded) { + LinuxProxyBridge.wakeWatcher() + } + } + + private fun readGsettings(): SystemProxySettings? { + val config = LinuxProxyBridge.getProxyConfig() ?: return null + + return SystemProxySettings( + autoDetect = config.getOrNull(LinuxProxyBridge.INDEX_AUTO_DETECT) == "1", + pacUrl = config.getOrNull(LinuxProxyBridge.INDEX_PAC_URL)?.takeIf { it.isNotBlank() }, + rules = + config + .getOrNull(LinuxProxyBridge.INDEX_PROXY) + ?.let(ProxyRules::parse) ?: ProxyRules.EMPTY, + bypassRules = + config + .getOrNull(LinuxProxyBridge.INDEX_BYPASS) + ?.let(ProxyBypassRules::parse) ?: ProxyBypassRules.EMPTY, + ) + } + + internal fun isKdeDesktop(getenv: (String) -> String? = System::getenv): Boolean { + val tokens = + sequenceOf( + getenv("XDG_CURRENT_DESKTOP"), + getenv("DESKTOP_SESSION"), + getenv("GDMSESSION"), + ).filterNotNull() + .flatMap { it.split(':', ';', ',') } + .map { it.trim().lowercase(Locale.ROOT) } + .filter { it.isNotEmpty() } + return tokens.any { it in KDE_TOKENS } + } + + private val KDE_TOKENS = + setOf("kde", "kde3", "kde4", "kde5", "kde6", "plasma", "plasma5", "plasma6") +} diff --git a/native-proxy/src/main/native/linux/build.sh b/native-proxy/src/main/native/linux/build.sh new file mode 100755 index 000000000..32cce11c2 --- /dev/null +++ b/native-proxy/src/main/native/linux/build.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# Compiles nucleus_proxy_linux.c into per-architecture shared libraries. +# libgio is loaded at runtime via dlopen — only libdl/libpthread are linked. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SRC="$SCRIPT_DIR/nucleus_proxy_linux.c" +RESOURCE_DIR="$SCRIPT_DIR/../../resources/nucleus/native" +LIB_NAME="libnucleus_proxy.so" + +if [ -z "${JAVA_HOME:-}" ]; then + for jdk in /usr/lib/jvm/java-*-openjdk-* /usr/lib/jvm/default-java; do + if [ -d "$jdk/include" ]; then + JAVA_HOME="$jdk" + break + fi + done +fi +if [ -z "${JAVA_HOME:-}" ]; then + echo "ERROR: JAVA_HOME not set and could not auto-detect a JDK." >&2 + exit 1 +fi + +JNI_INCLUDE="$JAVA_HOME/include" +JNI_INCLUDE_LINUX="$JAVA_HOME/include/linux" +if [ ! -d "$JNI_INCLUDE" ]; then + echo "ERROR: JNI headers not found at $JNI_INCLUDE" >&2 + exit 1 +fi + +HOST_ARCH="$(uname -m)" +case "$HOST_ARCH" in + x86_64) HOST_SUBDIR="linux-x64" ;; + aarch64) HOST_SUBDIR="linux-aarch64" ;; + *) echo "ERROR: Unsupported architecture: $HOST_ARCH" >&2; exit 1 ;; +esac + +COMMON_FLAGS=( + -shared -fPIC + -I"$JNI_INCLUDE" -I"$JNI_INCLUDE_LINUX" + -ldl -lpthread + -O2 -fvisibility=hidden -s + -Wall -Wextra -Wno-unused-parameter +) + +OUT_DIR="$RESOURCE_DIR/$HOST_SUBDIR" +mkdir -p "$OUT_DIR" +gcc "${COMMON_FLAGS[@]}" -o "$OUT_DIR/$LIB_NAME" "$SRC" +echo "Built $HOST_SUBDIR:" +ls -lh "$OUT_DIR/$LIB_NAME" + +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/nucleus/native/$HOST_SUBDIR" +if [ -f "$CACHE_DIR/$LIB_NAME" ]; then + rm -f "$CACHE_DIR/$LIB_NAME" + echo "Cleared cached $CACHE_DIR/$LIB_NAME" +fi + +if [ "$HOST_ARCH" = "x86_64" ] && command -v aarch64-linux-gnu-gcc &>/dev/null; then + CROSS_DIR="$RESOURCE_DIR/linux-aarch64" + mkdir -p "$CROSS_DIR" + aarch64-linux-gnu-gcc "${COMMON_FLAGS[@]}" -o "$CROSS_DIR/$LIB_NAME" "$SRC" \ + && echo "Built linux-aarch64 (cross):" && ls -lh "$CROSS_DIR/$LIB_NAME" \ + || echo "WARNING: aarch64 cross-compilation failed (non-fatal)." +fi diff --git a/native-proxy/src/main/native/linux/nucleus_proxy_linux.c b/native-proxy/src/main/native/linux/nucleus_proxy_linux.c new file mode 100644 index 000000000..daaccbd80 --- /dev/null +++ b/native-proxy/src/main/native/linux/nucleus_proxy_linux.c @@ -0,0 +1,645 @@ +/** + * Linux JNI bridge for the system proxy configuration. + * + * Mirrors Chromium's `net::ProxyConfigServiceLinux` GSettings path + * (`org.gnome.system.proxy` and its http/https/ftp/socks children): + * + * - mode / autoconfig-url / per-scheme host+port / ignore-hosts + * - change notifications via a private GMainContext (no polling) + * + * All GLib/GIO symbols are resolved with dlopen so the .so has no hard + * link-time dependency on libgio — same pattern as linux-hidpi and + * decorated-window-core. + * + * The 4-string config array reuses the Windows layout so the Kotlin side + * can feed the same ProxyRules / ProxyBypassRules parsers: + * + * [0] proxy — WinInet-style `http=…;https=…;socks=socks5://…` or bare host:port + * [1] bypass — `;`-joined ignore-hosts list + * [2] pacUrl — autoconfig-url (empty when unset) + * [3] auto — "1" when mode=auto without an explicit PAC URL (WPAD) + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define CONFIG_INDEX_PROXY 0 +#define CONFIG_INDEX_BYPASS 1 +#define CONFIG_INDEX_PAC_URL 2 +#define CONFIG_INDEX_AUTO_DETECT 3 +#define CONFIG_LENGTH 4 + +#define PROXY_SCHEMA "org.gnome.system.proxy" + +typedef void *(*fn_schema_source_get_default)(void); +typedef void *(*fn_schema_source_lookup)(void *, const char *, int); +typedef void *(*fn_settings_new)(const char *); +typedef void *(*fn_settings_get_child)(void *, const char *); +typedef char *(*fn_settings_get_string)(void *, const char *); +typedef int (*fn_settings_get_int)(void *, const char *); +typedef char **(*fn_settings_get_strv)(void *, const char *); +typedef void (*fn_object_unref)(void *); +typedef void (*fn_g_free)(void *); +typedef void (*fn_strfreev)(char **); +typedef unsigned long (*fn_signal_connect_data)( + void *, const char *, void *, void *, void *, int); + +typedef void *(*fn_main_context_new)(void); +typedef void (*fn_main_context_unref)(void *); +typedef int (*fn_main_context_iteration)(void *, int); +typedef void (*fn_main_context_push_thread_default)(void *); +typedef void (*fn_main_context_pop_thread_default)(void *); +typedef void (*fn_main_context_wakeup)(void *); + +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static int g_change_pipe[2] = { -1, -1 }; +static int g_wake_pipe[2] = { -1, -1 }; +static int g_inotify_fd = -1; +static int g_inotify_wd = -1; +static pthread_t g_watch_thread; +static volatile int g_watch_running = 0; +static volatile int g_watch_started = 0; +/** -1 unknown, 0 unavailable, 1 running. */ +static volatile int g_watch_available = -1; +static void *g_watch_libgio = NULL; +static void *g_watch_context = NULL; + +static void *open_gio(void) { + void *lib = dlopen("libgio-2.0.so.0", RTLD_LAZY | RTLD_LOCAL); + if (!lib) lib = dlopen("libgio-2.0.so", RTLD_LAZY | RTLD_LOCAL); + return lib; +} + +static int schema_exists( + fn_schema_source_get_default gssg, + fn_schema_source_lookup gssl, + const char *schema) { + void *source; + void *found; + if (!gssg || !gssl) return 0; + source = gssg(); + if (!source) return 0; + found = gssl(source, schema, 1); + return found != NULL; +} + +static jstring utf8_to_java(JNIEnv *env, const char *text) { + if (text == NULL) return NULL; + return (*env)->NewStringUTF(env, text); +} + +static void append_proxy_entry( + char *buffer, + size_t capacity, + int *first, + const char *scheme_prefix, + const char *host, + int port) { + size_t used; + if (host == NULL || host[0] == '\0') return; + used = strlen(buffer); + if (used + 64 >= capacity) return; + if (!*first) { + buffer[used++] = ';'; + buffer[used] = '\0'; + } + *first = 0; + if (scheme_prefix != NULL) { + snprintf(buffer + used, capacity - used, "%s%s:%d", scheme_prefix, host, port); + } else { + snprintf(buffer + used, capacity - used, "%s:%d", host, port); + } +} + +static jobjectArray read_gsettings_config(JNIEnv *env) { + void *libgio; + fn_schema_source_get_default gssg; + fn_schema_source_lookup gssl; + fn_settings_new gsn; + fn_settings_get_child gchild; + fn_settings_get_string gstr; + fn_settings_get_int gint; + fn_settings_get_strv gstrv; + fn_object_unref gou; + fn_g_free gfree; + fn_strfreev gstrfreev; + + void *root = NULL; + void *http = NULL; + void *https = NULL; + void *ftp = NULL; + void *socks = NULL; + + char *mode = NULL; + char *pac_url = NULL; + char *http_host = NULL; + char *https_host = NULL; + char *ftp_host = NULL; + char *socks_host = NULL; + char **ignore = NULL; + + int http_port = 0; + int https_port = 0; + int ftp_port = 0; + int socks_port = 0; + + char proxy_buf[1024]; + char bypass_buf[2048]; + char auto_detect_flag[2]; + + jclass string_class; + jobjectArray result; + jstring j_proxy, j_bypass, j_pac, j_auto; + + libgio = open_gio(); + if (!libgio) return NULL; + + gssg = (fn_schema_source_get_default)dlsym(libgio, "g_settings_schema_source_get_default"); + gssl = (fn_schema_source_lookup)dlsym(libgio, "g_settings_schema_source_lookup"); + gsn = (fn_settings_new)dlsym(libgio, "g_settings_new"); + gchild = (fn_settings_get_child)dlsym(libgio, "g_settings_get_child"); + gstr = (fn_settings_get_string)dlsym(libgio, "g_settings_get_string"); + gint = (fn_settings_get_int)dlsym(libgio, "g_settings_get_int"); + gstrv = (fn_settings_get_strv)dlsym(libgio, "g_settings_get_strv"); + gou = (fn_object_unref)dlsym(libgio, "g_object_unref"); + gfree = (fn_g_free)dlsym(libgio, "g_free"); + gstrfreev = (fn_strfreev)dlsym(libgio, "g_strfreev"); + + if (!gssg || !gssl || !gsn || !gchild || !gstr || !gint || !gstrv || + !gou || !gfree || !gstrfreev) { + dlclose(libgio); + return NULL; + } + + if (!schema_exists(gssg, gssl, PROXY_SCHEMA)) { + dlclose(libgio); + return NULL; + } + + root = gsn(PROXY_SCHEMA); + if (!root) { + dlclose(libgio); + return NULL; + } + + http = gchild(root, "http"); + https = gchild(root, "https"); + ftp = gchild(root, "ftp"); + socks = gchild(root, "socks"); + + mode = gstr(root, "mode"); + pac_url = gstr(root, "autoconfig-url"); + ignore = gstrv(root, "ignore-hosts"); + + if (http) { + http_host = gstr(http, "host"); + http_port = gint(http, "port"); + } + if (https) { + https_host = gstr(https, "host"); + https_port = gint(https, "port"); + } + if (ftp) { + ftp_host = gstr(ftp, "host"); + ftp_port = gint(ftp, "port"); + } + if (socks) { + socks_host = gstr(socks, "host"); + socks_port = gint(socks, "port"); + } + + proxy_buf[0] = '\0'; + bypass_buf[0] = '\0'; + auto_detect_flag[0] = '0'; + auto_detect_flag[1] = '\0'; + + if (mode != NULL && strcmp(mode, "auto") == 0) { + if (pac_url == NULL || pac_url[0] == '\0') { + auto_detect_flag[0] = '1'; + } + } else if (mode != NULL && strcmp(mode, "manual") == 0) { + int first = 1; + int has_http = http_host && http_host[0]; + int has_https = https_host && https_host[0]; + int has_ftp = ftp_host && ftp_host[0]; + int has_socks = socks_host && socks_host[0]; + int only_http = has_http && !has_https && !has_ftp && !has_socks; + + if (only_http) { + append_proxy_entry(proxy_buf, sizeof(proxy_buf), &first, NULL, http_host, http_port); + } else { + append_proxy_entry(proxy_buf, sizeof(proxy_buf), &first, "http=", http_host, http_port); + append_proxy_entry(proxy_buf, sizeof(proxy_buf), &first, "https=", https_host, https_port); + append_proxy_entry(proxy_buf, sizeof(proxy_buf), &first, "ftp=", ftp_host, ftp_port); + if (has_socks) { + char socks_spec[512]; + int port = socks_port > 0 ? socks_port : 1080; + snprintf(socks_spec, sizeof(socks_spec), "socks5://%s:%d", socks_host, port); + if (!first) { + size_t used = strlen(proxy_buf); + if (used + 2 < sizeof(proxy_buf)) { + proxy_buf[used++] = ';'; + proxy_buf[used] = '\0'; + } + } + first = 0; + { + size_t used = strlen(proxy_buf); + snprintf(proxy_buf + used, sizeof(proxy_buf) - used, "socks=%s", socks_spec); + } + } + } + } + + if (ignore != NULL) { + size_t used = 0; + size_t i; + for (i = 0; ignore[i] != NULL; i++) { + size_t len = strlen(ignore[i]); + if (used + len + 2 >= sizeof(bypass_buf)) break; + if (used > 0) bypass_buf[used++] = ';'; + memcpy(bypass_buf + used, ignore[i], len); + used += len; + bypass_buf[used] = '\0'; + } + } + + string_class = (*env)->FindClass(env, "java/lang/String"); + result = string_class != NULL + ? (*env)->NewObjectArray(env, CONFIG_LENGTH, string_class, NULL) + : NULL; + + if (result != NULL) { + j_proxy = utf8_to_java(env, proxy_buf); + j_bypass = utf8_to_java(env, bypass_buf); + j_pac = utf8_to_java(env, (pac_url && pac_url[0]) ? pac_url : ""); + j_auto = utf8_to_java(env, auto_detect_flag); + + (*env)->SetObjectArrayElement(env, result, CONFIG_INDEX_PROXY, j_proxy); + (*env)->SetObjectArrayElement(env, result, CONFIG_INDEX_BYPASS, j_bypass); + (*env)->SetObjectArrayElement(env, result, CONFIG_INDEX_PAC_URL, j_pac); + (*env)->SetObjectArrayElement(env, result, CONFIG_INDEX_AUTO_DETECT, j_auto); + + if (j_proxy) (*env)->DeleteLocalRef(env, j_proxy); + if (j_bypass) (*env)->DeleteLocalRef(env, j_bypass); + if (j_pac) (*env)->DeleteLocalRef(env, j_pac); + if (j_auto) (*env)->DeleteLocalRef(env, j_auto); + } + + if (mode) gfree(mode); + if (pac_url) gfree(pac_url); + if (http_host) gfree(http_host); + if (https_host) gfree(https_host); + if (ftp_host) gfree(ftp_host); + if (socks_host) gfree(socks_host); + if (ignore) gstrfreev(ignore); + + if (socks) gou(socks); + if (ftp) gou(ftp); + if (https) gou(https); + if (http) gou(http); + gou(root); + dlclose(libgio); + return result; +} + +static void signal_pipe(int fd) { + char byte = 1; + if (fd >= 0) { + ssize_t n = write(fd, &byte, 1); + (void)n; + } +} + +static void drain_pipe(int fd) { + char buf[64]; + if (fd < 0) return; + while (read(fd, buf, sizeof(buf)) > 0) { } +} + +static void on_gsettings_changed(void *settings, const char *key, void *user_data) { + (void)settings; + (void)key; + (void)user_data; + signal_pipe(g_change_pipe[1]); +} + +static void mark_watch_unavailable(void) { + pthread_mutex_lock(&g_lock); + g_watch_available = 0; + g_watch_running = 0; + pthread_mutex_unlock(&g_lock); +} + +static void *watch_thread_main(void *arg) { + void *libgio; + fn_schema_source_get_default gssg; + fn_schema_source_lookup gssl; + fn_settings_new gsn; + fn_settings_get_child gchild; + fn_object_unref gou; + fn_signal_connect_data gsignal; + fn_main_context_new ctx_new; + fn_main_context_unref ctx_unref; + fn_main_context_iteration ctx_iter; + fn_main_context_push_thread_default ctx_push; + fn_main_context_pop_thread_default ctx_pop; + + void *root = NULL; + void *http = NULL; + void *https = NULL; + void *ftp = NULL; + void *socks = NULL; + void *context = NULL; + + (void)arg; + + libgio = open_gio(); + if (!libgio) { + mark_watch_unavailable(); + return NULL; + } + + gssg = (fn_schema_source_get_default)dlsym(libgio, "g_settings_schema_source_get_default"); + gssl = (fn_schema_source_lookup)dlsym(libgio, "g_settings_schema_source_lookup"); + gsn = (fn_settings_new)dlsym(libgio, "g_settings_new"); + gchild = (fn_settings_get_child)dlsym(libgio, "g_settings_get_child"); + gou = (fn_object_unref)dlsym(libgio, "g_object_unref"); + gsignal = (fn_signal_connect_data)dlsym(libgio, "g_signal_connect_data"); + ctx_new = (fn_main_context_new)dlsym(libgio, "g_main_context_new"); + ctx_unref = (fn_main_context_unref)dlsym(libgio, "g_main_context_unref"); + ctx_iter = (fn_main_context_iteration)dlsym(libgio, "g_main_context_iteration"); + ctx_push = (fn_main_context_push_thread_default)dlsym(libgio, "g_main_context_push_thread_default"); + ctx_pop = (fn_main_context_pop_thread_default)dlsym(libgio, "g_main_context_pop_thread_default"); + + if (!gssg || !gssl || !gsn || !gchild || !gou || !gsignal || + !ctx_new || !ctx_unref || !ctx_iter || !ctx_push || !ctx_pop) { + dlclose(libgio); + mark_watch_unavailable(); + return NULL; + } + + if (!schema_exists(gssg, gssl, PROXY_SCHEMA)) { + dlclose(libgio); + mark_watch_unavailable(); + return NULL; + } + + context = ctx_new(); + if (!context) { + dlclose(libgio); + mark_watch_unavailable(); + return NULL; + } + ctx_push(context); + + root = gsn(PROXY_SCHEMA); + if (!root) { + ctx_pop(context); + ctx_unref(context); + dlclose(libgio); + mark_watch_unavailable(); + return NULL; + } + + http = gchild(root, "http"); + https = gchild(root, "https"); + ftp = gchild(root, "ftp"); + socks = gchild(root, "socks"); + + gsignal(root, "changed", (void *)on_gsettings_changed, NULL, NULL, 0); + if (http) gsignal(http, "changed", (void *)on_gsettings_changed, NULL, NULL, 0); + if (https) gsignal(https, "changed", (void *)on_gsettings_changed, NULL, NULL, 0); + if (ftp) gsignal(ftp, "changed", (void *)on_gsettings_changed, NULL, NULL, 0); + if (socks) gsignal(socks, "changed", (void *)on_gsettings_changed, NULL, NULL, 0); + + pthread_mutex_lock(&g_lock); + g_watch_libgio = libgio; + g_watch_context = context; + g_watch_available = 1; + pthread_mutex_unlock(&g_lock); + + while (g_watch_running) { + ctx_iter(context, 1); + } + + ctx_pop(context); + if (socks) gou(socks); + if (ftp) gou(ftp); + if (https) gou(https); + if (http) gou(http); + gou(root); + ctx_unref(context); + + pthread_mutex_lock(&g_lock); + g_watch_libgio = NULL; + g_watch_context = NULL; + pthread_mutex_unlock(&g_lock); + + dlclose(libgio); + return NULL; +} + + +/** + * Watches the user dconf database. GSettings change signals are delivered on + * the process-default GMainContext's GDBus connection; a private context often + * never sees them. inotify on ~/.config/dconf/user is the reliable alternative + * used by several desktop tools and covers both GNOME Settings and `gsettings`. + */ +static int ensure_inotify(void) { + const char *home; + char path[512]; + + if (g_inotify_fd >= 0) return 1; + + g_inotify_fd = inotify_init1(IN_NONBLOCK | IN_CLOEXEC); + if (g_inotify_fd < 0) return 0; + + home = getenv("HOME"); + if (home == NULL || home[0] == '\0') return 1; /* pipe-only fallback */ + + snprintf(path, sizeof(path), "%s/.config/dconf/user", home); + g_inotify_wd = inotify_add_watch( + g_inotify_fd, path, IN_CLOSE_WRITE | IN_MODIFY | IN_MOVED_TO); + /* Missing file is fine — GSettings may still work via the pipe. */ + return 1; +} + +static void drain_inotify(void) { + char buf[4096]; + if (g_inotify_fd < 0) return; + while (read(g_inotify_fd, buf, sizeof(buf)) > 0) { } +} + +static int ensure_pipes(void) { + if (g_change_pipe[0] < 0 && pipe(g_change_pipe) != 0) return 0; + if (g_wake_pipe[0] < 0 && pipe(g_wake_pipe) != 0) return 0; + return 1; +} + +static int ensure_watcher(void) { + pthread_mutex_lock(&g_lock); + if (g_watch_available == 0) { + pthread_mutex_unlock(&g_lock); + return 0; + } + if (g_watch_started && g_watch_running) { + pthread_mutex_unlock(&g_lock); + return 1; + } + if (g_watch_started && !g_watch_running) { + pthread_mutex_unlock(&g_lock); + pthread_join(g_watch_thread, NULL); + pthread_mutex_lock(&g_lock); + g_watch_started = 0; + if (g_watch_available == 0) { + pthread_mutex_unlock(&g_lock); + return 0; + } + } + if (!ensure_pipes()) { + pthread_mutex_unlock(&g_lock); + return 0; + } + ensure_inotify(); + g_watch_running = 1; + g_watch_available = -1; + if (pthread_create(&g_watch_thread, NULL, watch_thread_main, NULL) != 0) { + g_watch_running = 0; + g_watch_available = 0; + pthread_mutex_unlock(&g_lock); + return 0; + } + g_watch_started = 1; + pthread_mutex_unlock(&g_lock); + + { + int i; + for (i = 0; i < 50; i++) { + if (g_watch_available != -1) break; + usleep(1000); + } + } + return g_watch_available == 1; +} + +JNIEXPORT jobjectArray JNICALL +Java_dev_nucleusframework_nativeproxy_linux_LinuxProxyBridge_nativeGetProxyConfig( + JNIEnv *env, jclass clazz) { + (void)clazz; + return read_gsettings_config(env); +} + +JNIEXPORT jboolean JNICALL +Java_dev_nucleusframework_nativeproxy_linux_LinuxProxyBridge_nativeWaitForConfigChange( + JNIEnv *env, jclass clazz, jint timeoutMillis) { + struct pollfd fds[3]; + int nfds = 0; + int idx_change = -1; + int idx_wake = -1; + int idx_inotify = -1; + int rc; + int changed = 0; + + (void)env; + (void)clazz; + + /* Prefer the GSettings watcher when available; always try inotify. */ + ensure_watcher(); + ensure_inotify(); + ensure_pipes(); + + if (g_change_pipe[0] < 0 && g_inotify_fd < 0) { + if (timeoutMillis > 0) usleep((useconds_t)timeoutMillis * 1000u); + return JNI_FALSE; + } + + drain_pipe(g_change_pipe[0]); + drain_pipe(g_wake_pipe[0]); + drain_inotify(); + + if (g_change_pipe[0] >= 0) { + idx_change = nfds; + fds[nfds].fd = g_change_pipe[0]; + fds[nfds].events = POLLIN; + nfds++; + } + if (g_wake_pipe[0] >= 0) { + idx_wake = nfds; + fds[nfds].fd = g_wake_pipe[0]; + fds[nfds].events = POLLIN; + nfds++; + } + if (g_inotify_fd >= 0) { + idx_inotify = nfds; + fds[nfds].fd = g_inotify_fd; + fds[nfds].events = POLLIN; + nfds++; + } + + if (nfds == 0) { + if (timeoutMillis > 0) usleep((useconds_t)timeoutMillis * 1000u); + return JNI_FALSE; + } + + rc = poll(fds, (nfds_t)nfds, timeoutMillis >= 0 ? timeoutMillis : -1); + if (rc > 0) { + if (idx_change >= 0 && (fds[idx_change].revents & POLLIN)) { + drain_pipe(g_change_pipe[0]); + changed = 1; + } + if (idx_inotify >= 0 && (fds[idx_inotify].revents & POLLIN)) { + drain_inotify(); + changed = 1; + } + if (idx_wake >= 0 && (fds[idx_wake].revents & POLLIN)) { + drain_pipe(g_wake_pipe[0]); + } + } + return changed ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_nativeproxy_linux_LinuxProxyBridge_nativeWakeWatcher( + JNIEnv *env, jclass clazz) { + void *libgio; + void *context; + fn_main_context_wakeup ctx_wakeup; + int should_join = 0; + + (void)env; + (void)clazz; + + signal_pipe(g_wake_pipe[1]); + + pthread_mutex_lock(&g_lock); + if (g_watch_started && g_watch_running) { + g_watch_running = 0; + libgio = g_watch_libgio; + context = g_watch_context; + if (libgio && context) { + ctx_wakeup = (fn_main_context_wakeup)dlsym(libgio, "g_main_context_wakeup"); + if (ctx_wakeup) ctx_wakeup(context); + } + should_join = 1; + } + pthread_mutex_unlock(&g_lock); + + if (should_join) { + pthread_join(g_watch_thread, NULL); + pthread_mutex_lock(&g_lock); + g_watch_started = 0; + if (g_watch_available == 1) g_watch_available = -1; + pthread_mutex_unlock(&g_lock); + } +} diff --git a/native-proxy/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.native-proxy/reachability-metadata.json b/native-proxy/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.native-proxy/reachability-metadata.json index 57858eb85..2b7af7d13 100644 --- a/native-proxy/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.native-proxy/reachability-metadata.json +++ b/native-proxy/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.native-proxy/reachability-metadata.json @@ -3,6 +3,10 @@ { "type": "dev.nucleusframework.nativeproxy.windows.WindowsProxyBridge", "jniAccessible": true + }, + { + "type": "dev.nucleusframework.nativeproxy.linux.LinuxProxyBridge", + "jniAccessible": true } ] } diff --git a/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/NativeProxyTest.kt b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/NativeProxyTest.kt index 256bb25e1..8d6ea2242 100644 --- a/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/NativeProxyTest.kt +++ b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/NativeProxyTest.kt @@ -1,5 +1,6 @@ package dev.nucleusframework.nativeproxy +import dev.nucleusframework.nativeproxy.linux.LinuxProxyBridge import dev.nucleusframework.nativeproxy.windows.WindowsProxyBridge import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -11,11 +12,14 @@ import java.net.Proxy import java.net.URI class NativeProxyTest { - private val isWindows = System.getProperty("os.name", "").lowercase().contains("win") + private val os = System.getProperty("os.name", "").lowercase() + private val isWindows = os.contains("win") + private val isLinux = os.contains("linux") + private val isMac = os.contains("mac") @Test fun `unsupported platforms report a direct configuration`() { - assumeTrue("Test requires a non-Windows host", !isWindows) + assumeTrue("Test requires macOS (or another unsupported host)", isMac) assertFalse(NativeProxy.isSupported) assertEquals(SystemProxySettings.DIRECT, NativeProxy.settings()) @@ -35,13 +39,47 @@ class NativeProxyTest { assumeTrue("Test requires Windows", isWindows) assumeTrue("Native library not loaded", WindowsProxyBridge.isLoaded) - // Any outcome is valid — the CI machine may or may not have a proxy — - // but reading must never throw and must be internally consistent. val settings = NativeProxy.settings() assertNotNull(settings) assertEquals(settings.isDirect, !settings.usesPacScript && settings.rules.isEmpty) } + @Test + fun `Linux is supported and the configuration is readable`() { + assumeTrue("Test requires Linux", isLinux) + + assertTrue(NativeProxy.isSupported) + val settings = NativeProxy.settings() + assertNotNull(settings) + assertEquals(settings.isDirect, !settings.usesPacScript && settings.rules.isEmpty) + } + + @Test + fun `native library loads on Linux when GIO is present`() { + assumeTrue("Test requires Linux", isLinux) + + // The library ships with the JAR; load failure is only acceptable when + // the resource is missing from the test classpath (should not happen). + assertTrue("Native proxy bridge should be loaded", LinuxProxyBridge.isLoaded) + } + + @Test + fun `install returns true on Linux`() { + assumeTrue("Test requires Linux", isLinux) + + val previous = java.net.ProxySelector.getDefault() + try { + assertTrue(NativeProxy.install()) + assertTrue(java.net.ProxySelector.getDefault() is NativeProxySelector) + } finally { + NativeProxy.uninstall() + // Best-effort restore if uninstall did not. + if (java.net.ProxySelector.getDefault() is NativeProxySelector) { + java.net.ProxySelector.setDefault(previous) + } + } + } + @Test fun `loopback is never proxied`() { assertTrue(NativeProxy.proxiesFor(URI("http://127.0.0.1:8080")).isEmpty()) diff --git a/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/linux/EnvProxySettingsTest.kt b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/linux/EnvProxySettingsTest.kt new file mode 100644 index 000000000..4b4b23818 --- /dev/null +++ b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/linux/EnvProxySettingsTest.kt @@ -0,0 +1,122 @@ +package dev.nucleusframework.nativeproxy.linux + +import dev.nucleusframework.nativeproxy.ProxyProtocol +import dev.nucleusframework.nativeproxy.ProxyServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.URI + +class EnvProxySettingsTest { + @Test + fun `empty environment yields null`() { + assertNull(EnvProxySettings.read { null }) + } + + @Test + fun `all_proxy sets a single proxy for every scheme`() { + val env = mapOf("all_proxy" to "http://proxy.corp:8080") + val settings = EnvProxySettings.read(env::get)!! + + assertEquals( + listOf(ProxyServer(ProxyProtocol.HTTP, "proxy.corp", 8080)), + settings.rules.proxiesForUrlScheme("https"), + ) + } + + @Test + fun `per-scheme env vars are mapped independently`() { + val env = + mapOf( + "http_proxy" to "http://http.proxy:80", + "https_proxy" to "http://https.proxy:443", + ) + val settings = EnvProxySettings.read(env::get)!! + + assertEquals( + "http.proxy", + settings.rules + .proxiesForUrlScheme("http") + .single() + .host, + ) + assertEquals( + "https.proxy", + settings.rules + .proxiesForUrlScheme("https") + .single() + .host, + ) + assertTrue(settings.rules.proxiesForUrlScheme("ftp").isEmpty()) + } + + @Test + fun `SOCKS_SERVER defaults to socks5 unless SOCKS_VERSION is 4`() { + val socks5 = + EnvProxySettings.read( + mapOf("SOCKS_SERVER" to "socks.corp:1080")::get, + )!! + assertEquals( + ProxyProtocol.SOCKS5, + socks5.rules.singleProxies + .single() + .protocol, + ) + + val socks4 = + EnvProxySettings.read( + mapOf("SOCKS_SERVER" to "socks.corp:1080", "SOCKS_VERSION" to "4")::get, + )!! + assertEquals( + ProxyProtocol.SOCKS4, + socks4.rules.singleProxies + .single() + .protocol, + ) + } + + @Test + fun `no_proxy alone is an explicit direct configuration`() { + val settings = EnvProxySettings.read(mapOf("no_proxy" to "*")::get)!! + assertTrue(settings.isDirect) + } + + @Test + fun `no_proxy uses suffix matching and comma separators`() { + val env = + mapOf( + "http_proxy" to "proxy.corp:8080", + "no_proxy" to "localhost,corp.com,10.0.0.0/8", + ) + val settings = EnvProxySettings.read(env::get)!! + + assertTrue(settings.bypassRules.matches(URI("http://localhost"))) + assertTrue(settings.bypassRules.matches(URI("http://www.corp.com"))) + assertTrue(settings.bypassRules.matches(URI("http://corp.com"))) + assertTrue(settings.bypassRules.matches(URI("http://10.1.2.3"))) + assertFalse(settings.bypassRules.matches(URI("http://example.com"))) + } + + @Test + fun `auto_proxy empty enables WPAD and non-empty sets the PAC URL`() { + assertTrue(EnvProxySettings.read(mapOf("auto_proxy" to "")::get)!!.autoDetect) + assertEquals( + "http://wpad/proxy.pac", + EnvProxySettings.read(mapOf("auto_proxy" to "http://wpad/proxy.pac")::get)!!.pacUrl, + ) + } + + @Test + fun `userinfo in a proxy URL is stripped`() { + val settings = + EnvProxySettings.read( + mapOf("http_proxy" to "http://user:pass@proxy.corp:8080/")::get, + )!! + assertEquals( + ProxyServer(ProxyProtocol.HTTP, "proxy.corp", 8080), + settings.rules.proxiesForHttp.single(), + ) + } +} diff --git a/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/linux/KdeProxySettingsTest.kt b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/linux/KdeProxySettingsTest.kt new file mode 100644 index 000000000..7f00b3bc0 --- /dev/null +++ b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/linux/KdeProxySettingsTest.kt @@ -0,0 +1,118 @@ +package dev.nucleusframework.nativeproxy.linux + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class KdeProxySettingsTest { + @get:Rule + val tmp = TemporaryFolder() + + @Test + fun `manual proxy with space-separated port is parsed`() { + val home = tmp.newFolder("home") + val config = File(home, ".config").apply { mkdirs() } + File(config, "kioslaverc").writeText( + """ + [Proxy Settings] + ProxyType=1 + httpProxy=proxy.corp 8080 + httpsProxy=proxy.corp 8443 + NoProxyFor=localhost,127.0.0.1 + """.trimIndent(), + ) + + val settings = + KdeProxySettings.read( + mapOf("HOME" to home.absolutePath, "XDG_CONFIG_DIRS" to "")::get, + )!! + + assertEquals( + "proxy.corp", + settings.rules.proxiesForHttp + .single() + .host, + ) + assertEquals( + 8080, + settings.rules.proxiesForHttp + .single() + .port, + ) + assertEquals( + 8443, + settings.rules.proxiesForHttps + .single() + .port, + ) + assertTrue(settings.bypassRules.matches(java.net.URI("http://localhost"))) + } + + @Test + fun `ProxyType 0 is direct`() { + val home = tmp.newFolder("home-direct") + val config = File(home, ".config").apply { mkdirs() } + File(config, "kioslaverc").writeText( + """ + [Proxy Settings] + ProxyType=0 + httpProxy=proxy.corp 8080 + """.trimIndent(), + ) + + val settings = + KdeProxySettings.read( + mapOf("HOME" to home.absolutePath, "XDG_CONFIG_DIRS" to "")::get, + )!! + assertTrue(settings.isDirect) + } + + @Test + fun `ProxyType 3 is WPAD and ProxyType 2 is PAC`() { + val home = tmp.newFolder("home-auto") + val config = File(home, ".config").apply { mkdirs() } + val file = File(config, "kioslaverc") + val env = mapOf("HOME" to home.absolutePath, "XDG_CONFIG_DIRS" to "") + + file.writeText( + """ + [Proxy Settings] + ProxyType=3 + """.trimIndent(), + ) + assertTrue(KdeProxySettings.read(env::get)!!.autoDetect) + + file.writeText( + """ + [Proxy Settings] + ProxyType=2 + Proxy Config Script=http://wpad/proxy.pac + """.trimIndent(), + ) + assertEquals("http://wpad/proxy.pac", KdeProxySettings.read(env::get)!!.pacUrl) + } + + @Test + fun `socks-only manual config becomes a single proxy list`() { + val home = tmp.newFolder("home-socks") + val config = File(home, ".config").apply { mkdirs() } + File(config, "kioslaverc").writeText( + """ + [Proxy Settings] + ProxyType=1 + socksProxy=socks.corp 1080 + """.trimIndent(), + ) + + val settings = + KdeProxySettings.read( + mapOf("HOME" to home.absolutePath, "XDG_CONFIG_DIRS" to "")::get, + )!! + val server = settings.rules.singleProxies.single() + assertEquals("socks.corp", server.host) + assertEquals(dev.nucleusframework.nativeproxy.ProxyProtocol.SOCKS5, server.protocol) + } +} diff --git a/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/linux/LinuxProxyE2ETest.kt b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/linux/LinuxProxyE2ETest.kt new file mode 100644 index 000000000..e776558c2 --- /dev/null +++ b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/linux/LinuxProxyE2ETest.kt @@ -0,0 +1,135 @@ +package dev.nucleusframework.nativeproxy.linux + +import dev.nucleusframework.nativeproxy.NativeProxy +import dev.nucleusframework.nativeproxy.ProxyProtocol +import dev.nucleusframework.nativeproxy.SystemProxySettings +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.net.URI +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * End-to-end checks against the live GSettings on this machine. + * Opt-in via `NUCLEUS_PROXY_E2E=true` so regular CI is not mutated. + */ +class LinuxProxyE2ETest { + private val isLinux = System.getProperty("os.name", "").lowercase().contains("linux") + private val e2e = System.getenv("NUCLEUS_PROXY_E2E") == "true" + + @Test + fun `GSettings mode none is direct even when HTTPS_PROXY is set`() { + assumeTrue(isLinux && e2e) + assumeTrue(LinuxProxyBridge.isLoaded) + assumeTrue(hasProxySchema()) + + withGsettingsMode("none") { + // HTTPS_PROXY is typically set in developer environments; mode=none + // must still win, matching Chromium. + val settings = NativeProxy.refresh() + assertTrue("mode=none must be direct (must not fall through to env)", settings.isDirect) + assertTrue(NativeProxy.proxiesFor(URI("https://example.com")).isEmpty()) + } + } + + @Test + fun `GSettings manual mode is applied`() { + assumeTrue(isLinux && e2e) + assumeTrue(LinuxProxyBridge.isLoaded) + assumeTrue(hasProxySchema()) + + withManualProxy("proxy.e2e.test", 3128) { + val settings = NativeProxy.refresh() + assertFalse(settings.isDirect) + val proxies = NativeProxy.proxiesFor(URI("https://example.com")) + assertEquals(1, proxies.size) + assertEquals("proxy.e2e.test", proxies[0].host) + assertEquals(3128, proxies[0].port) + assertEquals(ProxyProtocol.HTTP, proxies[0].protocol) + assertTrue(NativeProxy.proxiesFor(URI("http://localhost")).isEmpty()) + } + } + + @Test + fun `GSettings change is observed by the watcher`() { + assumeTrue(isLinux && e2e) + assumeTrue(LinuxProxyBridge.isLoaded) + assumeTrue(hasProxySchema()) + + val latch = CountDownLatch(1) + val seen = AtomicReference(null) + val listener: (SystemProxySettings) -> Unit = { settings -> + seen.set(settings) + latch.countDown() + } + + // Start from a known mode so the flip is always a real change. + runGsettings("set", "org.gnome.system.proxy", "mode", "none") + NativeProxy.refresh() + NativeProxy.addChangeListener(listener) + try { + runGsettings("set", "org.gnome.system.proxy", "mode", "manual") + runGsettings("set", "org.gnome.system.proxy.http", "host", "watch.e2e.test") + runGsettings("set", "org.gnome.system.proxy.http", "port", "9999") + val ok = latch.await(10, TimeUnit.SECONDS) + assertTrue("expected a configuration change within 10s", ok) + assertTrue(seen.get() != null) + } finally { + NativeProxy.removeChangeListener(listener) + runGsettings("set", "org.gnome.system.proxy", "mode", "none") + } + } + + private fun hasProxySchema(): Boolean = LinuxProxyBridge.getProxyConfig() != null + + private fun withGsettingsMode( + mode: String, + block: () -> Unit, + ) { + val previous = runGsettings("get", "org.gnome.system.proxy", "mode").trim().trim('\'') + runGsettings("set", "org.gnome.system.proxy", "mode", mode) + try { + block() + } finally { + runGsettings("set", "org.gnome.system.proxy", "mode", previous) + } + } + + private fun withManualProxy( + host: String, + port: Int, + block: () -> Unit, + ) { + val prevMode = runGsettings("get", "org.gnome.system.proxy", "mode").trim().trim('\'') + val prevHost = runGsettings("get", "org.gnome.system.proxy.http", "host").trim().trim('\'') + val prevPort = runGsettings("get", "org.gnome.system.proxy.http", "port").trim() + val prevHttpsHost = runGsettings("get", "org.gnome.system.proxy.https", "host").trim().trim('\'') + val prevHttpsPort = runGsettings("get", "org.gnome.system.proxy.https", "port").trim() + runGsettings("set", "org.gnome.system.proxy", "mode", "manual") + runGsettings("set", "org.gnome.system.proxy.http", "host", host) + runGsettings("set", "org.gnome.system.proxy.http", "port", port.toString()) + runGsettings("set", "org.gnome.system.proxy.https", "host", host) + runGsettings("set", "org.gnome.system.proxy.https", "port", port.toString()) + try { + block() + } finally { + runGsettings("set", "org.gnome.system.proxy", "mode", prevMode) + runGsettings("set", "org.gnome.system.proxy.http", "host", prevHost) + runGsettings("set", "org.gnome.system.proxy.http", "port", prevPort) + runGsettings("set", "org.gnome.system.proxy.https", "host", prevHttpsHost) + runGsettings("set", "org.gnome.system.proxy.https", "port", prevHttpsPort) + } + } + + private fun runGsettings(vararg args: String): String { + val proc = ProcessBuilder("gsettings", *args).start() + val out = proc.inputStream.bufferedReader().readText() + val err = proc.errorStream.bufferedReader().readText() + check(proc.waitFor() == 0) { "gsettings ${args.toList()} failed: $err" } + return out + } +} From 5e71aca44c60327ffe2c9683925ae56a5355fdc0 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Thu, 6 Aug 2026 07:54:07 +0300 Subject: [PATCH 3/6] fix(native-proxy): freestanding InitOnce for ARM64 Windows build InterlockedCompareExchangePointer is not a freestanding intrinsic on ARM64 MSVC and pulls an unresolved _InterlockedCompareExchangePointer under /NODEFAULTLIB. Use InitOnceExecuteOnce (kernel32) for the WinHTTP session and wake-event singletons, and fail the ARM64 link step hard when it errors. --- .../main/native/windows/NucleusProxyBridge.c | 75 +++++++++++-------- 1 file changed, 42 insertions(+), 33 deletions(-) diff --git a/native-proxy/src/main/native/windows/NucleusProxyBridge.c b/native-proxy/src/main/native/windows/NucleusProxyBridge.c index e3d7c7efa..7bccc90f0 100644 --- a/native-proxy/src/main/native/windows/NucleusProxyBridge.c +++ b/native-proxy/src/main/native/windows/NucleusProxyBridge.c @@ -103,35 +103,45 @@ static WCHAR *java_to_wide(JNIEnv *env, jstring text) { /* ── Cached WinHTTP session ── */ -static PVOID volatile g_session = NULL; - -/** - * Returns the process-wide WinHTTP session used for PAC resolution, creating it - * on first use. WINHTTP_ACCESS_TYPE_NO_PROXY is required: the session must not - * itself go through a proxy to fetch the script. +/* + * Lazy init uses InitOnceExecuteOnce (kernel32) rather than + * InterlockedCompareExchangePointer. The latter is not a freestanding + * intrinsic on ARM64 MSVC and pulls an unresolved `_Interlocked…` symbol + * under /NODEFAULTLIB. */ -static HINTERNET proxy_session(void) { +static INIT_ONCE g_session_once = INIT_ONCE_STATIC_INIT; +static HINTERNET g_session = NULL; + +static BOOL CALLBACK init_proxy_session( + PINIT_ONCE once, + PVOID parameter, + PVOID *context) { HINTERNET created; - PVOID previous; - PVOID existing = InterlockedCompareExchangePointer(&g_session, NULL, NULL); - if (existing != NULL) return (HINTERNET)existing; + (void)once; + (void)parameter; + (void)context; created = WinHttpOpen( L"Nucleus", WINHTTP_ACCESS_TYPE_NO_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); - if (created == NULL) return NULL; + if (created == NULL) return FALSE; WinHttpSetTimeouts( created, RESOLVE_TIMEOUT_MS, CONNECT_TIMEOUT_MS, SEND_TIMEOUT_MS, RECEIVE_TIMEOUT_MS); + g_session = created; + return TRUE; +} - previous = InterlockedCompareExchangePointer(&g_session, (PVOID)created, NULL); - if (previous != NULL) { - WinHttpCloseHandle(created); - return (HINTERNET)previous; - } - return created; +/** + * Returns the process-wide WinHTTP session used for PAC resolution, creating it + * on first use. WINHTTP_ACCESS_TYPE_NO_PROXY is required: the session must not + * itself go through a proxy to fetch the script. + */ +static HINTERNET proxy_session(void) { + InitOnceExecuteOnce(&g_session_once, init_proxy_session, NULL, NULL); + return g_session; } /* ── Watched registry keys (same set as Chromium) ── */ @@ -159,25 +169,24 @@ static const WatchKey WATCH_KEYS[] = { /* ── Wake event, lets the JVM release a parked watcher thread ── */ -static PVOID volatile g_wake_event = NULL; - -static HANDLE wake_event(void) { - HANDLE created; - PVOID previous; - - PVOID existing = InterlockedCompareExchangePointer(&g_wake_event, NULL, NULL); - if (existing != NULL) return (HANDLE)existing; +static INIT_ONCE g_wake_once = INIT_ONCE_STATIC_INIT; +static HANDLE g_wake_event = NULL; +static BOOL CALLBACK init_wake_event( + PINIT_ONCE once, + PVOID parameter, + PVOID *context) { + (void)once; + (void)parameter; + (void)context; /* Manual reset: the waiter clears it once it has observed the signal. */ - created = CreateEventW(NULL, TRUE, FALSE, NULL); - if (created == NULL) return NULL; + g_wake_event = CreateEventW(NULL, TRUE, FALSE, NULL); + return g_wake_event != NULL; +} - previous = InterlockedCompareExchangePointer(&g_wake_event, (PVOID)created, NULL); - if (previous != NULL) { - CloseHandle(created); - return (HANDLE)previous; - } - return created; +static HANDLE wake_event(void) { + InitOnceExecuteOnce(&g_wake_once, init_wake_event, NULL, NULL); + return g_wake_event; } BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpReserved) { From d6f169fd98bf2445b28dd2fe0ece115e7abb0432 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Thu, 6 Aug 2026 07:54:16 +0300 Subject: [PATCH 4/6] fix(native-proxy): fail the Windows build when ARM64 link fails --- native-proxy/src/main/native/windows/build.bat | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/native-proxy/src/main/native/windows/build.bat b/native-proxy/src/main/native/windows/build.bat index ad0f2f21f..65afe2498 100644 --- a/native-proxy/src/main/native/windows/build.bat +++ b/native-proxy/src/main/native/windows/build.bat @@ -107,9 +107,9 @@ cl /LD /O1 /GS- /nologo ^ /Fe:"%OUT_DIR_ARM64%\%LIB_NAME%" ^ /link /NODEFAULTLIB /ENTRY:DllMain winhttp.lib advapi32.lib kernel32.lib if errorlevel 1 ( - echo WARNING: ARM64 compilation failed. >&2 + echo ERROR: ARM64 compilation failed. >&2 endlocal - goto :done + exit /b 1 ) endlocal From c41d4937ff7543eae6fbe55f54e95c3b5caa9603 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Thu, 6 Aug 2026 07:56:12 +0300 Subject: [PATCH 5/6] feat(native-proxy): add macOS backend (SCDynamicStore + CFNetwork PAC) Port Chromium's ProxyConfigServiceMac / ProxyResolverApple: - SCDynamicStoreCopyProxies for the effective configuration (per-scheme HTTP/HTTPS/FTP/SOCKS, ExceptionsList, ExcludeSimpleHostnames, PAC URL, ProxyAutoDiscoveryEnable) - CFNetworkExecuteProxyAutoConfigurationURL for PAC evaluation, pumping a private CFRunLoop mode so the call stays synchronous from the JVM - SCDynamicStore notification keys for change detection (no polling) Verified on a Mac with a live HTTP/HTTPS/SOCKS proxy: config matches `scutil --proxy`, loopback stays DIRECT, install() wires the JVM selector. --- .github/workflows/build-natives.yaml | 5 + .github/workflows/pre-merge.yaml | 2 + .github/workflows/publish-maven.yaml | 2 + CLAUDE.md | 2 +- README.md | 2 +- native-proxy/build.gradle.kts | 20 +- .../nativeproxy/NativeProxy.kt | 8 +- .../nativeproxy/NoopSystemProxyProvider.kt | 5 +- .../nativeproxy/SystemProxyProvider.kt | 4 +- .../nativeproxy/macos/MacOsProxyBridge.kt | 95 +++ .../macos/MacOsSystemProxyProvider.kt | 66 ++ .../main/native/macos/NucleusProxyBridge.m | 625 ++++++++++++++++++ native-proxy/src/main/native/macos/build.sh | 73 ++ .../reachability-metadata.json | 4 + .../nativeproxy/NativeProxyTest.kt | 47 +- .../nativeproxy/macos/MacOsProxyE2ETest.kt | 154 +++++ .../nativeproxy/macos/MacOsProxySmokeTest.kt | 49 ++ 17 files changed, 1139 insertions(+), 24 deletions(-) create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsProxyBridge.kt create mode 100644 native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsSystemProxyProvider.kt create mode 100644 native-proxy/src/main/native/macos/NucleusProxyBridge.m create mode 100755 native-proxy/src/main/native/macos/build.sh create mode 100644 native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsProxyE2ETest.kt create mode 100644 native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsProxySmokeTest.kt diff --git a/.github/workflows/build-natives.yaml b/.github/workflows/build-natives.yaml index ca14fb623..9f69c737c 100644 --- a/.github/workflows/build-natives.yaml +++ b/.github/workflows/build-natives.yaml @@ -215,6 +215,10 @@ jobs: if: steps.natives-cache.outputs.cache-hit != 'true' run: bash native-ssl/src/main/native/macos/build.sh + - name: Build native-proxy macOS dylibs + if: steps.natives-cache.outputs.cache-hit != 'true' + run: bash native-proxy/src/main/native/macos/build.sh + - name: Build decorated-window-jbr macOS dylibs if: steps.natives-cache.outputs.cache-hit != 'true' run: bash decorated-window-jbr/src/main/native/macos/build.sh @@ -295,6 +299,7 @@ jobs: FILES=( "darkmode-detector/libnucleus_darkmode.dylib" "native-ssl/libnucleus_ssl.dylib" + "native-proxy/libnucleus_proxy.dylib" "decorated-window-jbr/libnucleus_macos.dylib" "decorated-window-jni/libnucleus_macos_jni.dylib" "system-color/libnucleus_systemcolor.dylib" diff --git a/.github/workflows/pre-merge.yaml b/.github/workflows/pre-merge.yaml index 69c8e15af..3b365ca7c 100644 --- a/.github/workflows/pre-merge.yaml +++ b/.github/workflows/pre-merge.yaml @@ -51,6 +51,8 @@ jobs: "native-ssl/src/main/resources/nucleus/native/win32-aarch64/nucleus_ssl.dll" "native-proxy/src/main/resources/nucleus/native/win32-x64/nucleus_proxy.dll" "native-proxy/src/main/resources/nucleus/native/win32-aarch64/nucleus_proxy.dll" + "native-proxy/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_proxy.dylib" + "native-proxy/src/main/resources/nucleus/native/darwin-x64/libnucleus_proxy.dylib" "decorated-window-jbr/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_macos.dylib" "decorated-window-jbr/src/main/resources/nucleus/native/darwin-x64/libnucleus_macos.dylib" "decorated-window-jni/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_macos_jni.dylib" diff --git a/.github/workflows/publish-maven.yaml b/.github/workflows/publish-maven.yaml index 2da648e41..069c1dbc7 100644 --- a/.github/workflows/publish-maven.yaml +++ b/.github/workflows/publish-maven.yaml @@ -50,6 +50,8 @@ jobs: "native-ssl/src/main/resources/nucleus/native/win32-aarch64/nucleus_ssl.dll" "native-proxy/src/main/resources/nucleus/native/win32-x64/nucleus_proxy.dll" "native-proxy/src/main/resources/nucleus/native/win32-aarch64/nucleus_proxy.dll" + "native-proxy/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_proxy.dylib" + "native-proxy/src/main/resources/nucleus/native/darwin-x64/libnucleus_proxy.dylib" "decorated-window-jbr/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_macos.dylib" "decorated-window-jbr/src/main/resources/nucleus/native/darwin-x64/libnucleus_macos.dylib" "decorated-window-jni/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_macos_jni.dylib" diff --git a/CLAUDE.md b/CLAUDE.md index 19259a5ea..d05a80ae6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ A multi-module Gradle plugin and runtime library toolkit for shipping production - `system-color` - Reactive system accent color and high contrast detection via JNI - `energy-manager` - Energy efficiency & screen-awake APIs - `native-ssl` / `native-http` / `native-http-okhttp` / `native-http-ktor` - OS trust store integration -- `native-proxy` - OS proxy configuration via JNI (Windows: WinHTTP/WPAD/PAC; Linux: GSettings/KDE/env; no-op on macOS) +- `native-proxy` - OS proxy configuration via JNI (Windows: WinHTTP/WPAD/PAC; macOS: SCDynamicStore/CFNetwork PAC; Linux: GSettings/KDE/env) - `linux-hidpi` - Native HiDPI scale detection on Linux - `graalvm-runtime` - GraalVM native-image bootstrap - `decorated-window-core` - Shared types, layout, styling (design-system agnostic) diff --git a/README.md b/README.md index 9cedb0851..25a2eccba 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ Each module is published independently to Maven Central — use them together or | `nucleus.autolaunch` | Start the app at user login across all platforms | | `nucleus.native-ssl` | OS trust store integration | | `nucleus.native-http` | HTTP client with native SSL | -| `nucleus.native-proxy` | OS proxy configuration — WPAD/PAC (Windows), GSettings/KDE/env (Linux) | +| `nucleus.native-proxy` | OS proxy configuration — WPAD/PAC (Windows), SCDynamicStore/PAC (macOS), GSettings/KDE/env (Linux) | | `nucleus.linux-hidpi` | Native HiDPI scale detection on Linux | | `nucleus.graalvm-runtime` | Native-image bootstrap, font fixes, automatic resource inclusion | diff --git a/native-proxy/build.gradle.kts b/native-proxy/build.gradle.kts index 135535b17..9f1e4ead9 100644 --- a/native-proxy/build.gradle.kts +++ b/native-proxy/build.gradle.kts @@ -42,6 +42,19 @@ val buildNativeWindows by tasks.registering(Exec::class) { commandLine("cmd", "/c", File(nativeDir, "build.bat").absolutePath) } +val buildNativeMacOs by tasks.registering(Exec::class) { + description = "Compiles the ObjC JNI bridge into macOS dylibs (arm64 + x86_64)" + group = "build" + val nativeDir = file("src/main/native/macos") + val outputDir = file("src/main/resources/nucleus/native") + val checkFile = File(outputDir, "darwin-aarch64/libnucleus_proxy.dylib") + onlyIf { Os.isFamily(Os.FAMILY_MAC) && !checkFile.exists() } + inputs.dir(nativeDir) + outputs.dir(outputDir) + workingDir(nativeDir) + commandLine("bash", File(nativeDir, "build.sh").absolutePath) +} + val buildNativeLinux by tasks.registering(Exec::class) { description = "Compiles the C JNI bridge into a Linux shared library" group = "build" @@ -57,12 +70,12 @@ val buildNativeLinux by tasks.registering(Exec::class) { } tasks.processResources { - dependsOn(buildNativeWindows, buildNativeLinux) + dependsOn(buildNativeWindows, buildNativeMacOs, buildNativeLinux) } tasks.configureEach { if (name == "sourcesJar") { - dependsOn(buildNativeWindows, buildNativeLinux) + dependsOn(buildNativeWindows, buildNativeMacOs, buildNativeLinux) } } @@ -73,7 +86,8 @@ mavenPublishing { name.set("Nucleus Native Proxy") description.set( "OS proxy configuration integration (WinHTTP/WPAD/PAC on Windows; " + - "GSettings/KDE/env on Linux) for JVM desktop applications", + "SCDynamicStore/CFNetwork PAC on macOS; GSettings/KDE/env on Linux) " + + "for JVM desktop applications", ) url.set("https://github.com/NucleusFramework/Nucleus") diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NativeProxy.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NativeProxy.kt index 4d9d5c6de..72cc271a6 100644 --- a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NativeProxy.kt +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NativeProxy.kt @@ -20,10 +20,10 @@ private const val MAX_PAC_CACHE_ENTRIES = 256 * NativeProxy.addChangeListener { println("proxy configuration changed: $it") } * ``` * - * Windows (WinHTTP/WPAD/PAC + Internet Settings registry watching) and Linux - * (GSettings / KDE kioslaverc / env vars) are implemented; macOS reports - * [isSupported] `false` and every call degrades to a direct configuration. - * Linux does not evaluate PAC scripts yet — only static rules and bypass lists. + * Windows (WinHTTP/WPAD/PAC + Internet Settings registry watching), macOS + * (`SCDynamicStore` + PAC via `CFNetworkExecuteProxyAutoConfigurationURL`) and + * Linux (GSettings / KDE kioslaverc / env vars) are implemented. Linux does not + * evaluate PAC scripts yet — only static rules and bypass lists. */ object NativeProxy { private val provider = SystemProxyProvider.forCurrentPlatform() diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NoopSystemProxyProvider.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NoopSystemProxyProvider.kt index 93f2bdeb3..7da1d8741 100644 --- a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NoopSystemProxyProvider.kt +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/NoopSystemProxyProvider.kt @@ -3,12 +3,11 @@ package dev.nucleusframework.nativeproxy import java.net.URI /** - * No-op backend used on macOS (and unknown platforms). + * No-op backend used on unknown platforms. * * Reports an unsupported platform and a direct configuration, so * [NativeProxySelector] transparently delegates to the JDK default selector - * (which already honours `http.proxyHost` and, on macOS, the - * `java.net.useSystemProxies` bridge). + * (which already honours `http.proxyHost` / `https.proxyHost`). */ internal object NoopSystemProxyProvider : SystemProxyProvider { override val isSupported: Boolean = false diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/SystemProxyProvider.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/SystemProxyProvider.kt index f9a17a109..6f2fb98d1 100644 --- a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/SystemProxyProvider.kt +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/SystemProxyProvider.kt @@ -2,6 +2,7 @@ package dev.nucleusframework.nativeproxy import dev.nucleusframework.core.runtime.Platform import dev.nucleusframework.nativeproxy.linux.LinuxSystemProxyProvider +import dev.nucleusframework.nativeproxy.macos.MacOsSystemProxyProvider import dev.nucleusframework.nativeproxy.windows.WindowsSystemProxyProvider import java.net.URI @@ -42,7 +43,8 @@ internal interface SystemProxyProvider { when (Platform.Current) { Platform.Windows -> WindowsSystemProxyProvider Platform.Linux -> LinuxSystemProxyProvider - Platform.MacOS, Platform.Unknown -> NoopSystemProxyProvider + Platform.MacOS -> MacOsSystemProxyProvider + Platform.Unknown -> NoopSystemProxyProvider } } } diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsProxyBridge.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsProxyBridge.kt new file mode 100644 index 000000000..624cc5600 --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsProxyBridge.kt @@ -0,0 +1,95 @@ +package dev.nucleusframework.nativeproxy.macos + +import dev.nucleusframework.core.runtime.NativeLibraryLoader +import dev.nucleusframework.nativeproxy.errorln +import java.util.logging.Level +import java.util.logging.Logger + +private const val TAG = "MacOsProxyBridge" +private const val LIBRARY_NAME = "nucleus_proxy" + +/** + * JNI bridge over `SCDynamicStoreCopyProxies` and + * `CFNetworkExecuteProxyAutoConfigurationURL`. + * + * Every entry point degrades to a neutral value when the native library could + * not be loaded, so callers never have to guard the load state themselves. + */ +internal object MacOsProxyBridge { + /** Index of the WinInet-style proxy string in the [nativeGetProxyConfig] result. */ + const val INDEX_PROXY = 0 + + /** Index of the `;`-joined ExceptionsList (plus `` when configured). */ + const val INDEX_BYPASS = 1 + + /** Index of the PAC script URL (`ProxyAutoConfigURLString`). */ + const val INDEX_PAC_URL = 2 + + /** Index of the WPAD flag (`ProxyAutoDiscoveryEnable`), `"1"` or `"0"`. */ + const val INDEX_AUTO_DETECT = 3 + + private val logger = Logger.getLogger(MacOsProxyBridge::class.java.simpleName) + private val loaded = NativeLibraryLoader.load(LIBRARY_NAME, MacOsProxyBridge::class.java) + + val isLoaded: Boolean get() = loaded + + /** + * Returns `SCDynamicStoreCopyProxies` as a 4-element array indexed by the + * `INDEX_*` constants, or `null` when the call failed. + */ + @JvmStatic + external fun nativeGetProxyConfig(): Array? + + /** + * Runs `CFNetworkExecuteProxyAutoConfigurationURL` for [url]. + * + * @param pacUrl an explicit PAC script URL (required — pure WPAD without a + * discovered URL is not evaluated here). + * @return the proxy list, an empty string when the script returned + * `DIRECT`, or `null` when the script could not be fetched or evaluated. + */ + @JvmStatic + external fun nativeResolveProxyForUrl( + url: String, + pacUrl: String, + ): String? + + /** + * Blocks on `SCDynamicStore` proxy-key notifications and returns true when + * the configuration changed before [timeoutMillis] elapsed. + */ + @JvmStatic + external fun nativeWaitForConfigChange(timeoutMillis: Int): Boolean + + /** Signals [nativeWaitForConfigChange] to return early. */ + @JvmStatic + external fun nativeWakeWatcher() + + fun getProxyConfig(): Array? = call("nativeGetProxyConfig") { nativeGetProxyConfig() } + + fun resolveProxyForUrl( + url: String, + pacUrl: String, + ): String? = call("nativeResolveProxyForUrl") { nativeResolveProxyForUrl(url, pacUrl) } + + fun waitForConfigChange(timeoutMillis: Int): Boolean = + call("nativeWaitForConfigChange") { nativeWaitForConfigChange(timeoutMillis) } ?: false + + fun wakeWatcher() { + call("nativeWakeWatcher") { nativeWakeWatcher() } + } + + private fun call( + name: String, + block: () -> T, + ): T? { + if (!loaded) return null + return try { + block() + } catch (e: UnsatisfiedLinkError) { + logger.log(Level.WARNING, "JNI call failed for $name", e) + errorln(TAG) { "Native proxy bridge unavailable: $name" } + null + } + } +} diff --git a/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsSystemProxyProvider.kt b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsSystemProxyProvider.kt new file mode 100644 index 000000000..9f50104f5 --- /dev/null +++ b/native-proxy/src/main/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsSystemProxyProvider.kt @@ -0,0 +1,66 @@ +package dev.nucleusframework.nativeproxy.macos + +import dev.nucleusframework.nativeproxy.ProxyBypassRules +import dev.nucleusframework.nativeproxy.ProxyRules +import dev.nucleusframework.nativeproxy.ProxyServer +import dev.nucleusframework.nativeproxy.SystemProxyProvider +import dev.nucleusframework.nativeproxy.SystemProxySettings +import dev.nucleusframework.nativeproxy.debugln +import java.net.URI + +private const val TAG = "MacOsSystemProxyProvider" + +/** + * macOS backend built on `SCDynamicStoreCopyProxies` and + * `CFNetworkExecuteProxyAutoConfigurationURL`. + * + * This is the same set of APIs Chromium's `ProxyConfigServiceMac` and + * `ProxyResolverApple` use, so the effective configuration matches what + * Safari/Chrome see — including per-interface and managed (MDM) settings, + * which SystemConfiguration merges itself. + * + * Pure WPAD without an explicit PAC URL is reported via [SystemProxySettings.autoDetect] + * but not evaluated: Apple embeds a discovered PAC URL into the system settings + * when DHCP WPAD succeeds, so an empty [SystemProxySettings.pacUrl] almost + * always means there is nothing to run. Callers fall back to the static rules. + */ +internal object MacOsSystemProxyProvider : SystemProxyProvider { + override val isSupported: Boolean + get() = MacOsProxyBridge.isLoaded + + override fun readSettings(): SystemProxySettings { + val config = MacOsProxyBridge.getProxyConfig() ?: return SystemProxySettings.DIRECT + + val settings = + SystemProxySettings( + autoDetect = config.getOrNull(MacOsProxyBridge.INDEX_AUTO_DETECT) == "1", + pacUrl = config.getOrNull(MacOsProxyBridge.INDEX_PAC_URL)?.takeIf { it.isNotBlank() }, + rules = + config + .getOrNull(MacOsProxyBridge.INDEX_PROXY) + ?.let(ProxyRules::parse) ?: ProxyRules.EMPTY, + bypassRules = + config + .getOrNull(MacOsProxyBridge.INDEX_BYPASS) + ?.let(ProxyBypassRules::parse) ?: ProxyBypassRules.EMPTY, + ) + + debugln(TAG) { "macOS proxy configuration: $settings" } + return settings + } + + override fun resolveWithPacScript( + uri: URI, + settings: SystemProxySettings, + ): List? { + val pacUrl = settings.pacUrl ?: return null + val resolved = MacOsProxyBridge.resolveProxyForUrl(uri.toString(), pacUrl) ?: return null + if (resolved.isEmpty()) return emptyList() + return ProxyServer.parseList(resolved) + } + + override fun awaitConfigurationChange(timeoutMillis: Int): Boolean = + MacOsProxyBridge.waitForConfigChange(timeoutMillis) + + override fun wakeConfigurationWatcher() = MacOsProxyBridge.wakeWatcher() +} diff --git a/native-proxy/src/main/native/macos/NucleusProxyBridge.m b/native-proxy/src/main/native/macos/NucleusProxyBridge.m new file mode 100644 index 000000000..41624368c --- /dev/null +++ b/native-proxy/src/main/native/macos/NucleusProxyBridge.m @@ -0,0 +1,625 @@ +#include +#import +#import +#import + +#include +#include +#include + +/** + * macOS JNI bridge for the system proxy configuration. + * + * Mirrors Chromium's `net::ProxyConfigServiceMac` and `ProxyResolverApple`: + * + * - `SCDynamicStoreCopyProxies` reads the effective configuration (PAC URL, + * WPAD flag, per-scheme HTTP/HTTPS/FTP/SOCKS hosts, exception list, + * ExcludeSimpleHostnames). + * - `CFNetworkExecuteProxyAutoConfigurationURL` evaluates a PAC script for a + * URL, pumping a private CFRunLoop mode so the call looks synchronous from + * the JVM (same technique as Chromium's ProxyResolverApple). + * - `SCDynamicStore` notification keys report configuration changes without + * polling; the wait parks on the current thread's CFRunLoop until a change + * or timeout. + * + * The 4-string config array reuses the Windows/Linux layout so the Kotlin side + * can feed the same ProxyRules / ProxyBypassRules parsers: + * + * [0] proxy — WinInet-style `http=…;https=…;socks=socks5://…` + * [1] bypass — `;`-joined ExceptionsList, plus `` when + * ExcludeSimpleHostnames is set + * [2] pacUrl — ProxyAutoConfigURLString (empty when unset / disabled) + * [3] auto — "1" when ProxyAutoDiscoveryEnable is set (WPAD) + */ + +#define CONFIG_INDEX_PROXY 0 +#define CONFIG_INDEX_BYPASS 1 +#define CONFIG_INDEX_PAC_URL 2 +#define CONFIG_INDEX_AUTO_DETECT 3 +#define CONFIG_LENGTH 4 + +/* PAC evaluation timeout — a stuck script must never park a connection forever. */ +#define PAC_TIMEOUT_SECONDS 10.0 + +#define PROXY_BUF_SIZE 1024 +#define BYPASS_BUF_SIZE 2048 + +/* ── CF helpers ── */ + +static int dict_bool(CFDictionaryRef dict, CFStringRef key, int defaultValue) { + CFNumberRef number; + int value; + + if (dict == NULL) return defaultValue; + number = (CFNumberRef)CFDictionaryGetValue(dict, key); + if (number == NULL || CFGetTypeID(number) != CFNumberGetTypeID()) { + return defaultValue; + } + if (!CFNumberGetValue(number, kCFNumberIntType, &value)) { + return defaultValue; + } + return value != 0; +} + +static int dict_int(CFDictionaryRef dict, CFStringRef key, int defaultValue) { + CFNumberRef number; + int value; + + if (dict == NULL) return defaultValue; + number = (CFNumberRef)CFDictionaryGetValue(dict, key); + if (number == NULL || CFGetTypeID(number) != CFNumberGetTypeID()) { + return defaultValue; + } + if (!CFNumberGetValue(number, kCFNumberIntType, &value)) { + return defaultValue; + } + return value; +} + +/** + * Copies a CFString into [out], returning the number of bytes written (excluding + * the trailing NUL), or -1 when the string is missing / conversion fails. + */ +static int cfstring_to_utf8(CFStringRef text, char *out, size_t outSize) { + if (text == NULL || out == NULL || outSize == 0) return -1; + if (!CFStringGetCString(text, out, (CFIndex)outSize, kCFStringEncodingUTF8)) { + return -1; + } + return (int)strlen(out); +} + +static jstring utf8_to_java(JNIEnv *env, const char *text) { + if (text == NULL) return NULL; + return (*env)->NewStringUTF(env, text); +} + +/** + * Appends `scheme=host:port` (or `scheme=socks5://host:port` for SOCKS) to + * [buffer]. Returns 0 on success, -1 when the host is missing or the buffer is + * full. SOCKS defaults to SOCKS5 on modern macOS (System Preferences has no + * SOCKS4 toggle). + */ +static int append_proxy_entry( + char *buffer, + size_t bufferSize, + int *length, + const char *scheme, + CFDictionaryRef dict, + CFStringRef hostKey, + CFStringRef portKey, + int defaultPort, + int socks) { + + char host[256]; + int port; + int written; + CFStringRef hostRef = (CFStringRef)CFDictionaryGetValue(dict, hostKey); + + if (hostRef == NULL || CFGetTypeID(hostRef) != CFStringGetTypeID()) return -1; + if (cfstring_to_utf8(hostRef, host, sizeof(host)) <= 0) return -1; + + port = dict_int(dict, portKey, defaultPort); + if (port <= 0 || port > 65535) port = defaultPort; + + if (*length > 0 && (size_t)*length + 1 < bufferSize) { + buffer[(*length)++] = ';'; + buffer[*length] = '\0'; + } + + if (socks) { + written = snprintf( + buffer + *length, + bufferSize - (size_t)*length, + "%s=socks5://%s:%d", + scheme, + host, + port); + } else { + written = snprintf( + buffer + *length, + bufferSize - (size_t)*length, + "%s=%s:%d", + scheme, + host, + port); + } + + if (written < 0 || (size_t)written >= bufferSize - (size_t)*length) { + buffer[*length] = '\0'; + return -1; + } + *length += written; + return 0; +} + +/** Builds the WinInet-style proxy string from an SCDynamicStore proxies dict. */ +static void build_proxy_string(CFDictionaryRef dict, char *out, size_t outSize) { + int length = 0; + out[0] = '\0'; + + if (dict_bool(dict, kSCPropNetProxiesHTTPEnable, 0)) { + append_proxy_entry( + out, outSize, &length, "http", dict, + kSCPropNetProxiesHTTPProxy, kSCPropNetProxiesHTTPPort, 80, 0); + } + if (dict_bool(dict, kSCPropNetProxiesHTTPSEnable, 0)) { + append_proxy_entry( + out, outSize, &length, "https", dict, + kSCPropNetProxiesHTTPSProxy, kSCPropNetProxiesHTTPSPort, 443, 0); + } + if (dict_bool(dict, kSCPropNetProxiesFTPEnable, 0)) { + append_proxy_entry( + out, outSize, &length, "ftp", dict, + kSCPropNetProxiesFTPProxy, kSCPropNetProxiesFTPPort, 21, 0); + } + if (dict_bool(dict, kSCPropNetProxiesSOCKSEnable, 0)) { + /* `socks=` is the WinInet fallback scheme; see ProxyRules.parse. */ + append_proxy_entry( + out, outSize, &length, "socks", dict, + kSCPropNetProxiesSOCKSProxy, kSCPropNetProxiesSOCKSPort, 1080, 1); + } +} + +/** + * Builds the bypass list: ExceptionsList joined by `;`, plus `` when + * ExcludeSimpleHostnames is set (Chromium's PrependRuleToBypassSimpleHostnames). + */ +static void build_bypass_string(CFDictionaryRef dict, char *out, size_t outSize) { + CFArrayRef exceptions; + CFIndex count; + CFIndex i; + int length = 0; + + out[0] = '\0'; + + exceptions = (CFArrayRef)CFDictionaryGetValue(dict, kSCPropNetProxiesExceptionsList); + if (exceptions != NULL && CFGetTypeID(exceptions) == CFArrayGetTypeID()) { + count = CFArrayGetCount(exceptions); + for (i = 0; i < count; i++) { + char entry[256]; + CFStringRef item = (CFStringRef)CFArrayGetValueAtIndex(exceptions, i); + if (item == NULL || CFGetTypeID(item) != CFStringGetTypeID()) continue; + if (cfstring_to_utf8(item, entry, sizeof(entry)) <= 0) continue; + + if (length > 0 && (size_t)length + 1 < outSize) { + out[length++] = ';'; + out[length] = '\0'; + } + { + int written = snprintf(out + length, outSize - (size_t)length, "%s", entry); + if (written < 0 || (size_t)written >= outSize - (size_t)length) { + out[length] = '\0'; + break; + } + length += written; + } + } + } + + if (dict_bool(dict, kSCPropNetProxiesExcludeSimpleHostnames, 0)) { + if (length > 0 && (size_t)length + 1 < outSize) { + out[length++] = ';'; + out[length] = '\0'; + } + if ((size_t)length + 7 < outSize) { + memcpy(out + length, "", 8); + } + } +} + +/* ── PAC resolution ── */ + +typedef struct { + CFTypeRef result; /* retained CFArrayRef of proxies, or CFErrorRef */ +} PacResult; + +static void pac_result_callback(void *client, CFArrayRef proxies, CFErrorRef error) { + PacResult *state = (PacResult *)client; + if (state == NULL || state->result != NULL) return; + if (error != NULL) { + state->result = CFRetain(error); + } else if (proxies != NULL) { + state->result = CFRetain(proxies); + } + CFRunLoopStop(CFRunLoopGetCurrent()); +} + +/** + * Formats a CFArray of CFNetwork proxy dictionaries into a WinInet-style proxy + * list (`host:port` / `socks5://host:port`, `;`-joined). Returns an empty + * string for DIRECT-only results. Writes into [out]; returns 0 on success. + */ +static int format_proxy_array(CFArrayRef proxies, char *out, size_t outSize) { + CFIndex count; + CFIndex i; + int length = 0; + int sawDirect = 0; + + out[0] = '\0'; + if (proxies == NULL) return -1; + + count = CFArrayGetCount(proxies); + for (i = 0; i < count; i++) { + CFDictionaryRef entry = (CFDictionaryRef)CFArrayGetValueAtIndex(proxies, i); + CFStringRef type; + char host[256]; + int port; + int written; + CFStringRef hostRef; + CFNumberRef portRef; + + if (entry == NULL || CFGetTypeID(entry) != CFDictionaryGetTypeID()) continue; + + type = (CFStringRef)CFDictionaryGetValue(entry, kCFProxyTypeKey); + if (type == NULL) continue; + + if (CFEqual(type, kCFProxyTypeNone)) { + sawDirect = 1; + continue; + } + /* Nested PAC URLs are not re-resolved; treat as failure to fall back. */ + if (CFEqual(type, kCFProxyTypeAutoConfigurationURL) || + CFEqual(type, kCFProxyTypeAutoConfigurationJavaScript)) { + return -1; + } + + hostRef = (CFStringRef)CFDictionaryGetValue(entry, kCFProxyHostNameKey); + if (hostRef == NULL || CFGetTypeID(hostRef) != CFStringGetTypeID()) continue; + if (cfstring_to_utf8(hostRef, host, sizeof(host)) <= 0) continue; + + port = 0; + portRef = (CFNumberRef)CFDictionaryGetValue(entry, kCFProxyPortNumberKey); + if (portRef != NULL && CFGetTypeID(portRef) == CFNumberGetTypeID()) { + CFNumberGetValue(portRef, kCFNumberIntType, &port); + } + + if (length > 0 && (size_t)length + 1 < outSize) { + out[length++] = ';'; + out[length] = '\0'; + } + + if (CFEqual(type, kCFProxyTypeSOCKS)) { + if (port <= 0) port = 1080; + written = snprintf( + out + length, outSize - (size_t)length, "socks5://%s:%d", host, port); + } else if (CFEqual(type, kCFProxyTypeHTTPS)) { + if (port <= 0) port = 443; + written = snprintf( + out + length, outSize - (size_t)length, "https://%s:%d", host, port); + } else { + /* HTTP and FTP proxies dial as HTTP CONNECT / plain HTTP. */ + if (port <= 0) port = 80; + written = snprintf( + out + length, outSize - (size_t)length, "http://%s:%d", host, port); + } + + if (written < 0 || (size_t)written >= outSize - (size_t)length) { + out[length] = '\0'; + return -1; + } + length += written; + } + + if (length == 0 && sawDirect) { + /* DIRECT: empty string (matches the Windows bridge contract). */ + out[0] = '\0'; + return 0; + } + return length > 0 ? 0 : -1; +} + +/** + * Evaluates the PAC script at [pacUrlUtf8] for [urlUtf8]. Returns a newly + * allocated UTF-8 proxy list (caller frees with free), an empty malloc'd string + * for DIRECT, or NULL on failure. + */ +static char *resolve_pac(const char *urlUtf8, const char *pacUrlUtf8) { + CFURLRef queryUrl; + CFURLRef pacUrl; + CFDictionaryRef emptyDict; + CFArrayRef dummy; + PacResult state; + CFStreamClientContext context; + CFRunLoopSourceRef source; + CFStringRef privateMode; + char formatted[PROXY_BUF_SIZE]; + char *result; + + if (urlUtf8 == NULL || pacUrlUtf8 == NULL || pacUrlUtf8[0] == '\0') return NULL; + + queryUrl = CFURLCreateWithBytes( + kCFAllocatorDefault, + (const UInt8 *)urlUtf8, + (CFIndex)strlen(urlUtf8), + kCFStringEncodingUTF8, + NULL); + pacUrl = CFURLCreateWithBytes( + kCFAllocatorDefault, + (const UInt8 *)pacUrlUtf8, + (CFIndex)strlen(pacUrlUtf8), + kCFStringEncodingUTF8, + NULL); + if (queryUrl == NULL || pacUrl == NULL) { + if (queryUrl) CFRelease(queryUrl); + if (pacUrl) CFRelease(pacUrl); + return NULL; + } + + /* + * Work around : a dummy CFNetworkCopyProxiesForURL + * call initialises internal CFNetwork state required by + * CFNetworkExecuteProxyAutoConfigurationURL (same fix as Chromium). + */ + emptyDict = CFDictionaryCreate(NULL, NULL, NULL, 0, NULL, NULL); + dummy = emptyDict != NULL + ? CFNetworkCopyProxiesForURL(queryUrl, emptyDict) + : NULL; + if (emptyDict) CFRelease(emptyDict); + if (dummy) CFRelease(dummy); + + state.result = NULL; + memset(&context, 0, sizeof(context)); + context.info = &state; + + source = CFNetworkExecuteProxyAutoConfigurationURL( + pacUrl, queryUrl, pac_result_callback, &context); + CFRelease(queryUrl); + CFRelease(pacUrl); + + if (source == NULL) return NULL; + + privateMode = CFSTR("dev.nucleusframework.nativeproxy.pac"); + CFRunLoopAddSource(CFRunLoopGetCurrent(), source, privateMode); + CFRunLoopRunInMode(privateMode, PAC_TIMEOUT_SECONDS, false); + CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, privateMode); + CFRelease(source); + + if (state.result == NULL) return NULL; + + if (CFGetTypeID(state.result) == CFErrorGetTypeID()) { + CFRelease(state.result); + return NULL; + } + + if (format_proxy_array((CFArrayRef)state.result, formatted, sizeof(formatted)) != 0) { + CFRelease(state.result); + return NULL; + } + CFRelease(state.result); + + result = (char *)malloc(strlen(formatted) + 1); + if (result == NULL) return NULL; + memcpy(result, formatted, strlen(formatted) + 1); + return result; +} + +/* ── Change watching via SCDynamicStore ── */ + +static pthread_mutex_t g_wait_mu = PTHREAD_MUTEX_INITIALIZER; +static CFRunLoopRef g_wait_loop = NULL; +static volatile int g_changed = 0; +static volatile int g_wake = 0; + +static void on_proxy_config_change( + SCDynamicStoreRef store, + CFArrayRef changedKeys, + void *info) { + (void)store; + (void)changedKeys; + (void)info; + g_changed = 1; + pthread_mutex_lock(&g_wait_mu); + if (g_wait_loop != NULL) { + CFRunLoopStop(g_wait_loop); + } + pthread_mutex_unlock(&g_wait_mu); +} + +/* ── JNI entry points ── */ + +JNIEXPORT jobjectArray JNICALL +Java_dev_nucleusframework_nativeproxy_macos_MacOsProxyBridge_nativeGetProxyConfig( + JNIEnv *env, + jclass clazz) { + + CFDictionaryRef dict; + char proxy[PROXY_BUF_SIZE]; + char bypass[BYPASS_BUF_SIZE]; + char pacUrl[1024]; + jclass stringClass; + jobjectArray result; + jstring jProxy; + jstring jBypass; + jstring jPacUrl; + jstring jAuto; + + (void)clazz; + + dict = SCDynamicStoreCopyProxies(NULL); + if (dict == NULL) return NULL; + + build_proxy_string(dict, proxy, sizeof(proxy)); + build_bypass_string(dict, bypass, sizeof(bypass)); + + pacUrl[0] = '\0'; + if (dict_bool(dict, kSCPropNetProxiesProxyAutoConfigEnable, 0)) { + CFStringRef pacRef = + (CFStringRef)CFDictionaryGetValue(dict, kSCPropNetProxiesProxyAutoConfigURLString); + if (pacRef != NULL && CFGetTypeID(pacRef) == CFStringGetTypeID()) { + cfstring_to_utf8(pacRef, pacUrl, sizeof(pacUrl)); + } + } + + stringClass = (*env)->FindClass(env, "java/lang/String"); + result = stringClass != NULL + ? (*env)->NewObjectArray(env, CONFIG_LENGTH, stringClass, NULL) + : NULL; + + if (result != NULL) { + jProxy = utf8_to_java(env, proxy); + jBypass = utf8_to_java(env, bypass); + jPacUrl = utf8_to_java(env, pacUrl); + jAuto = utf8_to_java( + env, + dict_bool(dict, kSCPropNetProxiesProxyAutoDiscoveryEnable, 0) ? "1" : "0"); + + (*env)->SetObjectArrayElement(env, result, CONFIG_INDEX_PROXY, jProxy); + (*env)->SetObjectArrayElement(env, result, CONFIG_INDEX_BYPASS, jBypass); + (*env)->SetObjectArrayElement(env, result, CONFIG_INDEX_PAC_URL, jPacUrl); + (*env)->SetObjectArrayElement(env, result, CONFIG_INDEX_AUTO_DETECT, jAuto); + + if (jProxy) (*env)->DeleteLocalRef(env, jProxy); + if (jBypass) (*env)->DeleteLocalRef(env, jBypass); + if (jPacUrl) (*env)->DeleteLocalRef(env, jPacUrl); + if (jAuto) (*env)->DeleteLocalRef(env, jAuto); + } + + CFRelease(dict); + return result; +} + +JNIEXPORT jstring JNICALL +Java_dev_nucleusframework_nativeproxy_macos_MacOsProxyBridge_nativeResolveProxyForUrl( + JNIEnv *env, + jclass clazz, + jstring url, + jstring pacUrl) { + + const char *urlUtf8; + const char *pacUtf8; + char *resolved; + jstring result; + + (void)clazz; + + if (url == NULL || pacUrl == NULL) return NULL; + + urlUtf8 = (*env)->GetStringUTFChars(env, url, NULL); + pacUtf8 = (*env)->GetStringUTFChars(env, pacUrl, NULL); + if (urlUtf8 == NULL || pacUtf8 == NULL) { + if (urlUtf8) (*env)->ReleaseStringUTFChars(env, url, urlUtf8); + if (pacUtf8) (*env)->ReleaseStringUTFChars(env, pacUrl, pacUtf8); + return NULL; + } + + resolved = resolve_pac(urlUtf8, pacUtf8); + + (*env)->ReleaseStringUTFChars(env, url, urlUtf8); + (*env)->ReleaseStringUTFChars(env, pacUrl, pacUtf8); + + if (resolved == NULL) return NULL; + result = (*env)->NewStringUTF(env, resolved); + free(resolved); + return result; +} + +JNIEXPORT jboolean JNICALL +Java_dev_nucleusframework_nativeproxy_macos_MacOsProxyBridge_nativeWaitForConfigChange( + JNIEnv *env, + jclass clazz, + jint timeoutMillis) { + + SCDynamicStoreContext ctx; + SCDynamicStoreRef store; + CFStringRef proxiesKey; + CFArrayRef keyArray; + CFRunLoopSourceRef source; + CFTimeInterval timeout; + int changed; + + (void)env; + (void)clazz; + + g_changed = 0; + g_wake = 0; + + memset(&ctx, 0, sizeof(ctx)); + store = SCDynamicStoreCreate( + kCFAllocatorDefault, CFSTR("dev.nucleusframework.nativeproxy"), on_proxy_config_change, &ctx); + if (store == NULL) return JNI_FALSE; + + proxiesKey = SCDynamicStoreKeyCreateProxies(NULL); + if (proxiesKey == NULL) { + CFRelease(store); + return JNI_FALSE; + } + keyArray = CFArrayCreate( + kCFAllocatorDefault, (const void **)&proxiesKey, 1, &kCFTypeArrayCallBacks); + CFRelease(proxiesKey); + if (keyArray == NULL) { + CFRelease(store); + return JNI_FALSE; + } + + if (!SCDynamicStoreSetNotificationKeys(store, keyArray, NULL)) { + CFRelease(keyArray); + CFRelease(store); + return JNI_FALSE; + } + CFRelease(keyArray); + + source = SCDynamicStoreCreateRunLoopSource(kCFAllocatorDefault, store, 0); + if (source == NULL) { + CFRelease(store); + return JNI_FALSE; + } + + pthread_mutex_lock(&g_wait_mu); + g_wait_loop = CFRunLoopGetCurrent(); + pthread_mutex_unlock(&g_wait_mu); + + CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode); + + timeout = timeoutMillis <= 0 ? 0.0 : ((CFTimeInterval)timeoutMillis / 1000.0); + /* returnAfterSourceHandled=false: run until CFRunLoopStop or the timeout. */ + CFRunLoopRunInMode(kCFRunLoopDefaultMode, timeout, false); + + CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode); + + pthread_mutex_lock(&g_wait_mu); + g_wait_loop = NULL; + pthread_mutex_unlock(&g_wait_mu); + + CFRelease(source); + CFRelease(store); + + if (g_wake) return JNI_FALSE; + changed = g_changed; + return changed ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_nativeproxy_macos_MacOsProxyBridge_nativeWakeWatcher( + JNIEnv *env, + jclass clazz) { + + (void)env; + (void)clazz; + + g_wake = 1; + pthread_mutex_lock(&g_wait_mu); + if (g_wait_loop != NULL) { + CFRunLoopStop(g_wait_loop); + } + pthread_mutex_unlock(&g_wait_mu); +} diff --git a/native-proxy/src/main/native/macos/build.sh b/native-proxy/src/main/native/macos/build.sh new file mode 100755 index 000000000..d199c7361 --- /dev/null +++ b/native-proxy/src/main/native/macos/build.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Compiles NucleusProxyBridge.m into per-architecture dylibs (arm64 + x86_64). +# The outputs are placed in the JAR resources so they ship with the library. +# +# Prerequisites: Xcode command-line tools (clang). +# Usage: ./build.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SRC="$SCRIPT_DIR/NucleusProxyBridge.m" +RESOURCE_DIR="$SCRIPT_DIR/../../resources/nucleus/native" +OUT_DIR_ARM64="$RESOURCE_DIR/darwin-aarch64" +OUT_DIR_X64="$RESOURCE_DIR/darwin-x64" +LIB_NAME="libnucleus_proxy.dylib" + +# Detect JAVA_HOME for JNI headers +if [ -z "${JAVA_HOME:-}" ]; then + JAVA_HOME=$(/usr/libexec/java_home 2>/dev/null || true) +fi +if [ -z "${JAVA_HOME:-}" ]; then + echo "ERROR: JAVA_HOME not set and /usr/libexec/java_home failed." >&2 + exit 1 +fi + +JNI_INCLUDE="$JAVA_HOME/include" +JNI_INCLUDE_DARWIN="$JAVA_HOME/include/darwin" + +if [ ! -d "$JNI_INCLUDE" ]; then + echo "ERROR: JNI headers not found at $JNI_INCLUDE" >&2 + exit 1 +fi + +mkdir -p "$OUT_DIR_ARM64" "$OUT_DIR_X64" + +COMMON_FLAGS=( + -dynamiclib + -I"$JNI_INCLUDE" -I"$JNI_INCLUDE_DARWIN" + -framework SystemConfiguration + -framework CFNetwork + -framework CoreFoundation + -mmacosx-version-min=10.13 + -fobjc-arc + -Oz + -flto + -fvisibility=hidden + -Wl,-dead_strip + -Wl,-x + -Wall -Wextra -Wno-unused-parameter +) + +# Compile for arm64 +clang -arch arm64 "${COMMON_FLAGS[@]}" \ + -o "$OUT_DIR_ARM64/$LIB_NAME" "$SRC" +strip -x "$OUT_DIR_ARM64/$LIB_NAME" + +# Compile for x86_64 +clang -arch x86_64 "${COMMON_FLAGS[@]}" \ + -o "$OUT_DIR_X64/$LIB_NAME" "$SRC" +strip -x "$OUT_DIR_X64/$LIB_NAME" + +echo "Built per-architecture dylibs:" +ls -lh "$OUT_DIR_ARM64/$LIB_NAME" +ls -lh "$OUT_DIR_X64/$LIB_NAME" + +# Clear the NativeLibraryLoader cache so the freshly built dylib is picked up. +CACHE_BASE="${HOME}/.cache/nucleus/native" +for arch in darwin-aarch64 darwin-x64; do + if [ -d "$CACHE_BASE/$arch" ]; then + find "$CACHE_BASE/$arch" -name "$LIB_NAME" -delete 2>/dev/null || true + echo "Cleared cached $LIB_NAME under $CACHE_BASE/$arch" + fi +done diff --git a/native-proxy/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.native-proxy/reachability-metadata.json b/native-proxy/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.native-proxy/reachability-metadata.json index 2b7af7d13..e3b313163 100644 --- a/native-proxy/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.native-proxy/reachability-metadata.json +++ b/native-proxy/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.native-proxy/reachability-metadata.json @@ -4,6 +4,10 @@ "type": "dev.nucleusframework.nativeproxy.windows.WindowsProxyBridge", "jniAccessible": true }, + { + "type": "dev.nucleusframework.nativeproxy.macos.MacOsProxyBridge", + "jniAccessible": true + }, { "type": "dev.nucleusframework.nativeproxy.linux.LinuxProxyBridge", "jniAccessible": true diff --git a/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/NativeProxyTest.kt b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/NativeProxyTest.kt index 8d6ea2242..c71aff6a7 100644 --- a/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/NativeProxyTest.kt +++ b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/NativeProxyTest.kt @@ -1,9 +1,9 @@ package dev.nucleusframework.nativeproxy import dev.nucleusframework.nativeproxy.linux.LinuxProxyBridge +import dev.nucleusframework.nativeproxy.macos.MacOsProxyBridge import dev.nucleusframework.nativeproxy.windows.WindowsProxyBridge import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Assume.assumeTrue @@ -17,16 +17,6 @@ class NativeProxyTest { private val isLinux = os.contains("linux") private val isMac = os.contains("mac") - @Test - fun `unsupported platforms report a direct configuration`() { - assumeTrue("Test requires macOS (or another unsupported host)", isMac) - - assertFalse(NativeProxy.isSupported) - assertEquals(SystemProxySettings.DIRECT, NativeProxy.settings()) - assertTrue(NativeProxy.proxiesFor(URI("https://example.com")).isEmpty()) - assertFalse(NativeProxy.install()) - } - @Test fun `native library loads on Windows`() { assumeTrue("Test requires Windows", isWindows) @@ -80,6 +70,41 @@ class NativeProxyTest { } } + @Test + fun `native library loads on macOS`() { + assumeTrue("Test requires macOS", isMac) + + assertTrue("Native proxy bridge should be loaded", MacOsProxyBridge.isLoaded) + } + + @Test + fun `macOS is supported and the configuration is readable`() { + assumeTrue("Test requires macOS", isMac) + assumeTrue("Native library not loaded", MacOsProxyBridge.isLoaded) + + assertTrue(NativeProxy.isSupported) + val settings = NativeProxy.settings() + assertNotNull(settings) + assertEquals(settings.isDirect, !settings.usesPacScript && settings.rules.isEmpty) + } + + @Test + fun `install returns true on macOS`() { + assumeTrue("Test requires macOS", isMac) + assumeTrue("Native library not loaded", MacOsProxyBridge.isLoaded) + + val previous = java.net.ProxySelector.getDefault() + try { + assertTrue(NativeProxy.install()) + assertTrue(java.net.ProxySelector.getDefault() is NativeProxySelector) + } finally { + NativeProxy.uninstall() + if (java.net.ProxySelector.getDefault() is NativeProxySelector) { + java.net.ProxySelector.setDefault(previous) + } + } + } + @Test fun `loopback is never proxied`() { assertTrue(NativeProxy.proxiesFor(URI("http://127.0.0.1:8080")).isEmpty()) diff --git a/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsProxyE2ETest.kt b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsProxyE2ETest.kt new file mode 100644 index 000000000..b08a42682 --- /dev/null +++ b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsProxyE2ETest.kt @@ -0,0 +1,154 @@ +package dev.nucleusframework.nativeproxy.macos + +import dev.nucleusframework.nativeproxy.NativeProxy +import dev.nucleusframework.nativeproxy.ProxyProtocol +import dev.nucleusframework.nativeproxy.SystemProxySettings +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.net.URI +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * End-to-end checks against the live System Preferences proxy settings. + * Opt-in via `NUCLEUS_PROXY_E2E=true` so regular CI is not mutated. + * + * Mutates the HTTP proxy via `networksetup` and restores the previous state. + */ +class MacOsProxyE2ETest { + private val isMac = System.getProperty("os.name", "").lowercase().contains("mac") + private val e2e = System.getenv("NUCLEUS_PROXY_E2E") == "true" + + @Test + fun `SCDynamicStore reads the current configuration`() { + assumeTrue(isMac) + assumeTrue(MacOsProxyBridge.isLoaded) + + val config = MacOsProxyBridge.getProxyConfig() + assertTrue("nativeGetProxyConfig must return a 4-element array", config != null && config.size == 4) + + val settings = NativeProxy.refresh() + // Just smoke-test that the call does not throw and returns a value. + assertTrue(settings == settings) + } + + @Test + fun `manual HTTP proxy is applied`() { + assumeTrue(isMac && e2e) + assumeTrue(MacOsProxyBridge.isLoaded) + + val service = primaryNetworkService() ?: return + withManualHttpProxy(service, "proxy.e2e.test", 3128) { + val settings = NativeProxy.refresh() + assertFalse("manual HTTP proxy must not be direct", settings.isDirect) + val proxies = NativeProxy.proxiesFor(URI("http://example.com")) + assertEquals(1, proxies.size) + assertEquals("proxy.e2e.test", proxies[0].host) + assertEquals(3128, proxies[0].port) + assertEquals(ProxyProtocol.HTTP, proxies[0].protocol) + assertTrue(NativeProxy.proxiesFor(URI("http://localhost")).isEmpty()) + } + } + + @Test + fun `configuration change is observed by the watcher`() { + assumeTrue(isMac && e2e) + assumeTrue(MacOsProxyBridge.isLoaded) + + val service = primaryNetworkService() ?: return + val latch = CountDownLatch(1) + val seen = AtomicReference(null) + val listener: (SystemProxySettings) -> Unit = { settings -> + seen.set(settings) + latch.countDown() + } + + // Start from a known state so the flip is always a real change. + runNetworksetup("-setwebproxystate", service, "off") + NativeProxy.refresh() + NativeProxy.addChangeListener(listener) + try { + runNetworksetup("-setwebproxy", service, "watch.e2e.test", "9999") + runNetworksetup("-setwebproxystate", service, "on") + val ok = latch.await(10, TimeUnit.SECONDS) + assertTrue("expected a configuration change within 10s", ok) + assertTrue(seen.get() != null) + } finally { + NativeProxy.removeChangeListener(listener) + runNetworksetup("-setwebproxystate", service, "off") + } + } + + private fun primaryNetworkService(): String? { + // Prefer Wi-Fi / Ethernet — the first hardware port that is not a VPN/bridge. + val hardware = runNetworksetup("-listallhardwareports") + val blocks = hardware.split("\n\n") + for (block in blocks) { + val name = + block + .lineSequence() + .firstOrNull { it.startsWith("Hardware Port:") } + ?.substringAfter(':') + ?.trim() + if (name != null && name in setOf("Wi-Fi", "Ethernet", "USB 10/100/1000 LAN")) { + return name + } + } + return blocks + .asSequence() + .mapNotNull { block -> + block + .lineSequence() + .firstOrNull { it.startsWith("Hardware Port:") } + ?.substringAfter(':') + ?.trim() + }.firstOrNull() + } + + private fun withManualHttpProxy( + service: String, + host: String, + port: Int, + block: () -> Unit, + ) { + val previous = runNetworksetup("-getwebproxy", service) + val wasEnabled = previous.lineSequence().any { it.trim() == "Enabled: Yes" } + val prevServer = + previous + .lineSequence() + .firstOrNull { it.startsWith("Server:") } + ?.substringAfter(':') + ?.trim() + .orEmpty() + val prevPort = + previous + .lineSequence() + .firstOrNull { it.startsWith("Port:") } + ?.substringAfter(':') + ?.trim() + .orEmpty() + + runNetworksetup("-setwebproxy", service, host, port.toString()) + runNetworksetup("-setwebproxystate", service, "on") + try { + block() + } finally { + if (prevServer.isNotEmpty() && prevPort.isNotEmpty()) { + runNetworksetup("-setwebproxy", service, prevServer, prevPort) + } + runNetworksetup("-setwebproxystate", service, if (wasEnabled) "on" else "off") + } + } + + private fun runNetworksetup(vararg args: String): String { + val proc = ProcessBuilder("networksetup", *args).start() + val out = proc.inputStream.bufferedReader().readText() + val err = proc.errorStream.bufferedReader().readText() + check(proc.waitFor() == 0) { "networksetup ${args.toList()} failed: $err" } + return out + } +} diff --git a/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsProxySmokeTest.kt b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsProxySmokeTest.kt new file mode 100644 index 000000000..a137a2cac --- /dev/null +++ b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsProxySmokeTest.kt @@ -0,0 +1,49 @@ +package dev.nucleusframework.nativeproxy.macos + +import dev.nucleusframework.nativeproxy.NativeProxy +import dev.nucleusframework.nativeproxy.ProxyProtocol +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.net.URI + +/** + * Smoke against the live System Preferences on this machine. + * Expects the proxy that `scutil --proxy` reports on the developer's Mac. + * Skipped when no HTTP proxy is configured (clean CI / clean machines). + */ +class MacOsProxySmokeTest { + private val isMac = System.getProperty("os.name", "").lowercase().contains("mac") + + @Test + fun `matches scutil when an HTTP proxy is configured`() { + assumeTrue(isMac) + assumeTrue(MacOsProxyBridge.isLoaded) + + val scutil = ProcessBuilder("scutil", "--proxy").start() + val text = scutil.inputStream.bufferedReader().readText() + check(scutil.waitFor() == 0) + + val httpEnabled = text.contains("HTTPEnable : 1") + assumeTrue("No HTTP proxy configured on this Mac", httpEnabled) + + val host = Regex("HTTPProxy : (\\S+)").find(text)?.groupValues?.get(1) + val port = Regex("HTTPPort : (\\d+)").find(text)?.groupValues?.get(1)?.toInt() + check(host != null && port != null) + + val settings = NativeProxy.refresh() + assertFalse(settings.isDirect) + + val proxies = NativeProxy.proxiesFor(URI("http://example.com")) + assertEquals(1, proxies.size) + assertEquals(host, proxies[0].host) + assertEquals(port, proxies[0].port) + assertEquals(ProxyProtocol.HTTP, proxies[0].protocol) + + // Implicit loopback bypass + assertTrue(NativeProxy.proxiesFor(URI("http://127.0.0.1")).isEmpty()) + assertTrue(NativeProxy.proxiesFor(URI("http://localhost")).isEmpty()) + } +} From a77ca8edbba70a4827561f2b9fbd054ceceaf448 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Thu, 6 Aug 2026 08:15:43 +0300 Subject: [PATCH 6/6] style(native-proxy): fix ktlint chain-method-continuation in macOS smoke test --- .../nativeproxy/macos/MacOsProxySmokeTest.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsProxySmokeTest.kt b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsProxySmokeTest.kt index a137a2cac..e7c62f8ce 100644 --- a/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsProxySmokeTest.kt +++ b/native-proxy/src/test/kotlin/dev/nucleusframework/nativeproxy/macos/MacOsProxySmokeTest.kt @@ -29,8 +29,17 @@ class MacOsProxySmokeTest { val httpEnabled = text.contains("HTTPEnable : 1") assumeTrue("No HTTP proxy configured on this Mac", httpEnabled) - val host = Regex("HTTPProxy : (\\S+)").find(text)?.groupValues?.get(1) - val port = Regex("HTTPPort : (\\d+)").find(text)?.groupValues?.get(1)?.toInt() + val host = + Regex("HTTPProxy : (\\S+)") + .find(text) + ?.groupValues + ?.get(1) + val port = + Regex("HTTPPort : (\\d+)") + .find(text) + ?.groupValues + ?.get(1) + ?.toInt() check(host != null && port != null) val settings = NativeProxy.refresh()