diff --git a/cdp-generate/build.gradle.kts b/cdp-generate/build.gradle.kts index 59ff7ddde..e00a8e701 100644 --- a/cdp-generate/build.gradle.kts +++ b/cdp-generate/build.gradle.kts @@ -9,7 +9,7 @@ repositories { kotlin { dependencies { - implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.10") + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.21") implementation("com.squareup:kotlinpoet:2.2.0") } } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index ff60be820..549289956 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -92,11 +92,18 @@ kotlin { api(libs.ktor.client.darwin) } } + val posixMain by creating { + dependsOn(commonMain) + } val linuxMain by getting { + dependsOn(posixMain) dependencies { api(libs.ktor.client.curl) } } + val macosMain by getting { + dependsOn(posixMain) + } val mingwMain by getting { dependencies { api(libs.ktor.client.winhttp) diff --git a/core/src/appleMain/kotlin/dev/kdriver/core/utils/Client.apple.kt b/core/src/appleMain/kotlin/dev/kdriver/core/connection/Client.apple.kt similarity index 85% rename from core/src/appleMain/kotlin/dev/kdriver/core/utils/Client.apple.kt rename to core/src/appleMain/kotlin/dev/kdriver/core/connection/Client.apple.kt index 3f9fe2b03..ffa0ab02c 100644 --- a/core/src/appleMain/kotlin/dev/kdriver/core/utils/Client.apple.kt +++ b/core/src/appleMain/kotlin/dev/kdriver/core/connection/Client.apple.kt @@ -1,4 +1,4 @@ -package dev.kdriver.core.utils +package dev.kdriver.core.connection import io.ktor.client.engine.* import io.ktor.client.engine.darwin.* diff --git a/core/src/appleMain/kotlin/dev/kdriver/core/network/Extensions.apple.kt b/core/src/appleMain/kotlin/dev/kdriver/core/network/Extensions.apple.kt new file mode 100644 index 000000000..8256a2cde --- /dev/null +++ b/core/src/appleMain/kotlin/dev/kdriver/core/network/Extensions.apple.kt @@ -0,0 +1,60 @@ +package dev.kdriver.core.network + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.Foundation.* +import platform.posix.memcpy + +actual fun ByteArray.decompressZstd(): ByteArray { + // Zstandard decompression is not readily available in Kotlin/Native without additional libraries + // For now, return data as-is or throw an exception + throw UnsupportedOperationException("Zstandard decompression not yet supported on macOS native") +} + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +actual fun ByteArray.decompressGzip(): ByteArray { + // Create NSData from ByteArray + val nsData = usePinned { pinned -> + NSData.create(bytes = pinned.addressOf(0), length = size.toULong()) + } + + // Use the /usr/bin/gunzip command as a workaround + // This is not ideal but works without needing zlib cinterop definitions + val tempDir = NSTemporaryDirectory() + val inputPath = tempDir + "input.gz" + + // Write compressed data to temp file + nsData.writeToFile(inputPath, atomically = false) + + // Run gunzip command + val task = NSTask() + task.launchPath = "/usr/bin/gunzip" + task.arguments = listOf("-c", inputPath) + + val outputPipe = NSPipe() + task.standardOutput = outputPipe + task.standardError = NSPipe() + + task.launch() + task.waitUntilExit() + + // Read decompressed data + val outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + + // Clean up temp file + NSFileManager.defaultManager.removeItemAtPath(inputPath, error = null) + + // Convert NSData back to ByteArray + val size = outputData.length.toInt() + return if (size > 0) { + val result = ByteArray(size) + result.usePinned { pinned -> + memcpy(pinned.addressOf(0), outputData.bytes, size.toULong()) + } + result + } else { + this // Return original if decompression failed + } +} diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/browser/BrowserSearchConfig.kt b/core/src/commonMain/kotlin/dev/kdriver/core/browser/BrowserSearchConfig.kt new file mode 100644 index 000000000..69abc4dbe --- /dev/null +++ b/core/src/commonMain/kotlin/dev/kdriver/core/browser/BrowserSearchConfig.kt @@ -0,0 +1,175 @@ +package dev.kdriver.core.browser + +import kotlinx.io.files.Path + +/** + * Browser search configuration flags + */ +data class BrowserSearchConfig( + val pathSeparator: String, + val searchInPath: Boolean = true, + val searchMacosApplications: Boolean = false, + val searchWindowsProgramFiles: Boolean = false, + val searchLinuxCommonPaths: Boolean = false, +) { + + fun findBrowserExecutable(): Path? { + return findChromeExecutable() + ?: findOperaExecutable() + ?: findBraveExecutable() + ?: findEdgeExecutable() + } + + fun findChromeExecutable(): Path? { + return findBrowserExecutableCommon( + executableNames = listOf( + "google-chrome", + "chromium", + "chromium-browser", + "chrome", + "google-chrome-stable" + ), + macosAppPaths = listOf( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium" + ), + linuxCommonPaths = listOf( + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + "/opt/google/chrome/chrome", + ), + windowsProgramFilesSuffixes = listOf( + "Google/Chrome/Application", + "Google/Chrome Beta/Application", + "Google/Chrome Canary/Application", + "Google/Chrome SxS/Application", + ), + windowsExecutableNames = listOf("chrome.exe"), + ) + } + + fun findOperaExecutable(): Path? { + return findBrowserExecutableCommon( + executableNames = listOf("opera"), + macosAppPaths = listOf( + "/Applications/Opera.app/Contents/MacOS/Opera" + ), + linuxCommonPaths = listOf( + "/usr/bin/opera", + "/usr/local/bin/opera", + ), + windowsProgramFilesSuffixes = listOf( + "Opera", + "Programs/Opera" + ), + windowsExecutableNames = listOf("opera.exe"), + ) + } + + fun findBraveExecutable(): Path? { + return findBrowserExecutableCommon( + executableNames = listOf("brave-browser", "brave"), + macosAppPaths = listOf( + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" + ), + linuxCommonPaths = listOf( + "/usr/bin/brave-browser", + "/usr/bin/brave", + "/snap/bin/brave", + ), + windowsProgramFilesSuffixes = listOf( + "BraveSoftware/Brave-Browser/Application" + ), + windowsExecutableNames = listOf("brave.exe"), + ) + } + + fun findEdgeExecutable(): Path? { + return findBrowserExecutableCommon( + executableNames = listOf( + "microsoft-edge", + "microsoft-edge-stable", + "microsoft-edge-beta", + "microsoft-edge-dev" + ), + macosAppPaths = listOf( + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" + ), + linuxCommonPaths = listOf( + "/usr/bin/microsoft-edge", + "/usr/bin/microsoft-edge-stable", + ), + windowsProgramFilesSuffixes = listOf( + "Microsoft/Edge/Application" + ), + windowsExecutableNames = listOf("msedge.exe"), + ) + } + + /** + * Common helper to search for browser executables based on platform configuration. + * + * @param executableNames List of executable names to search for (e.g., ["chrome", "google-chrome"]) + * @param macosAppPaths macOS .app bundle paths (only used if searchMacosApplications is true) + * @param windowsProgramFilesSuffixes Windows Program Files subdirectories (only used if searchWindowsProgramFiles is true) + * @param linuxCommonPaths Common Linux installation paths (only used if searchLinuxCommonPaths is true) + * @param windowsExecutableNames Windows executable names with .exe extension + * + * @return The Path to the found executable, or null if not found. + */ + private fun findBrowserExecutableCommon( + executableNames: List, + macosAppPaths: List = emptyList(), + windowsProgramFilesSuffixes: List = emptyList(), + windowsExecutableNames: List = emptyList(), + linuxCommonPaths: List = emptyList(), + ): Path? { + val candidates = mutableListOf() + + // macOS applications + if (searchMacosApplications) { + candidates.addAll(macosAppPaths.map { Path(it) }) + } + + // Windows Program Files + if (searchWindowsProgramFiles) { + val programFiles = listOfNotNull( + getEnv("PROGRAMFILES"), + getEnv("PROGRAMFILES(X86)"), + getEnv("LOCALAPPDATA"), + getEnv("PROGRAMW6432") + ) + for (base in programFiles) { + for (suffix in windowsProgramFilesSuffixes) { + for (exe in windowsExecutableNames) { + candidates.add(Path("$base/$suffix/$exe")) + } + } + } + } + + // Linux common paths + if (searchLinuxCommonPaths) { + candidates.addAll(linuxCommonPaths.map { Path(it) }) + } + + // Search in PATH + if (searchInPath) { + val pathEnv = getEnv("PATH") + val paths = pathEnv?.split(pathSeparator) ?: emptyList() + for (pathDir in paths) { + for (exe in executableNames + windowsExecutableNames) { + candidates.add(Path("$pathDir/$exe")) + } + } + } + + // Return the shortest path that exists + return candidates + .filter { exists(it) } + .minByOrNull { it.toString().length } + } + +} diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/browser/Config.kt b/core/src/commonMain/kotlin/dev/kdriver/core/browser/Config.kt index 310ec2ae9..c994d59dd 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/browser/Config.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/browser/Config.kt @@ -1,7 +1,6 @@ package dev.kdriver.core.browser import dev.kdriver.core.exceptions.NoBrowserExecutablePathException -import dev.kdriver.core.utils.* import io.ktor.util.logging.* import kotlinx.io.files.Path @@ -30,10 +29,7 @@ class Config( internal val _extensions: MutableList = mutableListOf() val browserExecutablePath: Path = browserExecutablePath - ?: findChromeExecutable() - ?: findOperaExecutable() - ?: findBraveExecutable() - ?: findEdgeExecutable() + ?: defaultBrowserSearchConfig().findBrowserExecutable() ?: throw NoBrowserExecutablePathException() var sandbox: Boolean = sandbox diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/browser/DefaultBrowser.kt b/core/src/commonMain/kotlin/dev/kdriver/core/browser/DefaultBrowser.kt index 5a87a6c60..0e9a5346f 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/browser/DefaultBrowser.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/browser/DefaultBrowser.kt @@ -11,7 +11,6 @@ import dev.kdriver.core.exceptions.BrowserExecutableNotFoundException import dev.kdriver.core.exceptions.FailedToConnectToBrowserException import dev.kdriver.core.tab.DefaultTab import dev.kdriver.core.tab.Tab -import dev.kdriver.core.utils.* import io.ktor.util.logging.* import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/browser/HTTPApi.kt b/core/src/commonMain/kotlin/dev/kdriver/core/browser/HTTPApi.kt index 7fa23a4a7..70252b590 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/browser/HTTPApi.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/browser/HTTPApi.kt @@ -1,7 +1,7 @@ package dev.kdriver.core.browser import dev.kaccelero.serializers.Serialization -import dev.kdriver.core.utils.getHttpApiClientEngine +import dev.kdriver.core.connection.getHttpApiClientEngine import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.plugins.contentnegotiation.* diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/browser/Process.kt b/core/src/commonMain/kotlin/dev/kdriver/core/browser/Process.kt new file mode 100644 index 000000000..04fe39f92 --- /dev/null +++ b/core/src/commonMain/kotlin/dev/kdriver/core/browser/Process.kt @@ -0,0 +1,19 @@ +package dev.kdriver.core.browser + +import kotlinx.io.files.Path + +expect abstract class Process { + fun isAlive(): Boolean + fun pid(): Long + abstract fun destroy() +} + +expect suspend fun startProcess(exe: Path, params: List): Process +expect fun addShutdownHook(hook: suspend () -> Unit) +expect fun isPosix(): Boolean +expect fun isRoot(): Boolean +expect fun tempProfileDir(): Path +expect fun exists(path: Path): Boolean +expect fun getEnv(name: String): String? +expect fun freePort(): Int? +expect fun defaultBrowserSearchConfig(): BrowserSearchConfig diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/utils/Client.kt b/core/src/commonMain/kotlin/dev/kdriver/core/connection/Client.kt similarity index 81% rename from core/src/commonMain/kotlin/dev/kdriver/core/utils/Client.kt rename to core/src/commonMain/kotlin/dev/kdriver/core/connection/Client.kt index fbea8c67d..1a991a404 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/utils/Client.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/connection/Client.kt @@ -1,4 +1,4 @@ -package dev.kdriver.core.utils +package dev.kdriver.core.connection import io.ktor.client.engine.* diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/connection/DefaultConnection.kt b/core/src/commonMain/kotlin/dev/kdriver/core/connection/DefaultConnection.kt index 4ae91a242..ebaebfd91 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/connection/DefaultConnection.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/connection/DefaultConnection.kt @@ -4,8 +4,7 @@ import dev.kaccelero.serializers.Serialization import dev.kdriver.cdp.* import dev.kdriver.cdp.domain.* import dev.kdriver.core.browser.Browser -import dev.kdriver.core.utils.getWebSocketClientEngine -import dev.kdriver.core.utils.parseWebSocketUrl +import dev.kdriver.core.browser.WebSocketInfo import io.ktor.client.* import io.ktor.client.plugins.websocket.* import io.ktor.http.* @@ -207,6 +206,20 @@ open class DefaultConnection( prepareExpertDone = true } + private fun parseWebSocketUrl(url: String): WebSocketInfo { + val uri = Url(url) + + val host = uri.host + val port = if (uri.port != -1) uri.port else when (uri.protocol) { + URLProtocol.WS -> 80 + URLProtocol.WSS -> 443 + else -> throw IllegalArgumentException("Unsupported scheme: ${uri.protocol}") + } + val path = uri.encodedPath + + return WebSocketInfo(host, port, path) + } + override fun toString(): String { return "Connection: ${targetInfo?.toString() ?: "no target"}" } diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt b/core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt index 9dcd1aee7..c37524114 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt @@ -3,8 +3,6 @@ package dev.kdriver.core.dom import dev.kdriver.cdp.domain.* import dev.kdriver.core.exceptions.EvaluateException import dev.kdriver.core.tab.Tab -import dev.kdriver.core.utils.filterRecurse -import dev.kdriver.core.utils.filterRecurseAll import io.ktor.util.logging.* import kotlinx.io.files.Path import kotlinx.serialization.json.JsonElement @@ -26,10 +24,10 @@ open class DefaultElement( get() = node.nodeName.lowercase() override val text: String - get() = filterRecurse(node) { it.nodeType == 3 }?.nodeValue ?: "" + get() = node.filterRecurse { it.nodeType == 3 }?.nodeValue ?: "" override val textAll: String - get() = filterRecurseAll(node) { it.nodeType == 3 }.joinToString(" ") { it.nodeValue } + get() = node.filterRecurseAll { it.nodeType == 3 }.joinToString(" ") { it.nodeValue } override val backendNodeId: Int get() = node.backendNodeId @@ -46,7 +44,7 @@ open class DefaultElement( override val parent: Element? get() { val tree = this.tree ?: throw RuntimeException("could not get parent since the element has no tree set") - val parentNode = filterRecurse(tree) { node -> node.nodeId == parentId } ?: return null + val parentNode = tree.filterRecurse { node -> node.nodeId == parentId } ?: return null return DefaultElement(tab, parentNode, tree) } @@ -88,7 +86,7 @@ open class DefaultElement( override suspend fun update(nodeOverride: DOM.Node?): Element { val doc = nodeOverride ?: tab.dom.getDocument(depth = -1, pierce = true).root - val updatedNode = filterRecurse(doc) { it.backendNodeId == node.backendNodeId } + val updatedNode = doc.filterRecurse { it.backendNodeId == node.backendNodeId } if (updatedNode != null) { logger.debug("node seems changed, and has now been updated.") this.node = updatedNode @@ -98,7 +96,7 @@ open class DefaultElement( remoteObject = tab.dom.resolveNode(backendNodeId = node.backendNodeId).`object` if (node.nodeName != "IFRAME") { - val parentNode = filterRecurse(doc) { it.nodeId == node.parentId } + val parentNode = doc.filterRecurse { it.nodeId == node.parentId } if (parentNode != null) { // What's the point of this? (object is never used) val _parent = DefaultElement(tab, parentNode, tree) diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/dom/Extensions.kt b/core/src/commonMain/kotlin/dev/kdriver/core/dom/Extensions.kt index 7cf9915cd..2fa8a157a 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/dom/Extensions.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/dom/Extensions.kt @@ -1,6 +1,7 @@ package dev.kdriver.core.dom import dev.kaccelero.serializers.Serialization +import dev.kdriver.cdp.domain.DOM import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.decodeFromJsonElement @@ -27,3 +28,50 @@ suspend inline fun Element.apply( if (raw is JsonNull) return null return Serialization.json.decodeFromJsonElement(raw) } + +/** + * Recursively searches the DOM tree starting from this node, returning the first node that matches the given predicate. + * + * @param predicate A function that takes a DOM.Node and returns true if it matches the search criteria. + * + * @return The first DOM.Node that matches the predicate, or null if no matching node is found. + */ +fun DOM.Node.filterRecurse(predicate: (DOM.Node) -> Boolean): DOM.Node? { + val children = children ?: return null + for (child in children) { + if (predicate(child)) return child + + val shadowRoots = child.shadowRoots + if (shadowRoots != null && shadowRoots.isNotEmpty()) { + val shadowResult = shadowRoots[0].filterRecurse(predicate) + if (shadowResult != null) return shadowResult + } + + val recursiveResult = child.filterRecurse(predicate) + if (recursiveResult != null) return recursiveResult + } + return null +} + +/** + * Recursively searches the DOM tree starting from this node, returning all nodes that match the given predicate. + * + * @param predicate A function that takes a DOM.Node and returns true if it matches the search criteria. + * + * @return A list of all DOM.Nodes that match the predicate. + */ +fun DOM.Node.filterRecurseAll(predicate: (DOM.Node) -> Boolean): List { + val children = children ?: return emptyList() + val out = mutableListOf() + for (child in children) { + if (predicate(child)) { + out.add(child) + } + val shadowRoots = child.shadowRoots + if (shadowRoots != null && shadowRoots.isNotEmpty()) { + out.addAll(shadowRoots[0].filterRecurseAll(predicate)) + } + out.addAll(child.filterRecurseAll(predicate)) + } + return out +} diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/network/EncodedBody.kt b/core/src/commonMain/kotlin/dev/kdriver/core/network/EncodedBody.kt index f64c89b53..53f7140a0 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/network/EncodedBody.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/network/EncodedBody.kt @@ -2,7 +2,6 @@ package dev.kdriver.core.network import dev.kdriver.cdp.domain.Fetch import dev.kdriver.cdp.domain.Network -import dev.kdriver.core.utils.decompressIfNeeded import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -58,7 +57,7 @@ data class EncodedBody( val decodedBody: String get() { val rawBytes = if (base64Encoded) Base64.decode(body) else body.encodeToByteArray() - val decompressedBytes = decompressIfNeeded(rawBytes) + val decompressedBytes = rawBytes.decompressIfNeeded() return decompressedBytes.decodeToString() } diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/network/Extensions.kt b/core/src/commonMain/kotlin/dev/kdriver/core/network/Extensions.kt index 8c832d983..06b9dc61d 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/network/Extensions.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/network/Extensions.kt @@ -18,3 +18,48 @@ suspend inline fun RequestExpectation.getResponseBody(): T { suspend inline fun FetchInterception.getResponseBody(): T { return Serialization.json.decodeFromString(getRawResponseBody().decodedBody) } + +/** + * Checks if the byte array is compressed using Zstandard (Zstd) compression. + * + * @return True if the byte array is Zstd compressed, false otherwise. + */ +fun ByteArray.isZstdCompressed(): Boolean { + val header = take(4).map { it.toUByte().toInt() } + return header == listOf(0x28, 0xB5, 0x2F, 0xFD) +} + +/** + * Checks if the byte array is compressed using Gzip compression. + * + * @return True if the byte array is Gzip compressed, false otherwise. + */ +fun ByteArray.isGzipCompressed(): Boolean { + val header = take(2).map { it.toUByte().toInt() } + return header == listOf(0x1F, 0x8B) +} + +/** + * Decompresses the byte array if it is compressed using Zstd or Gzip compression. + * + * @return The decompressed byte array, or the original byte array if it is not compressed. + */ +fun ByteArray.decompressIfNeeded(): ByteArray = when { + isZstdCompressed() -> decompressZstd() + isGzipCompressed() -> decompressGzip() + else -> this +} + +/** + * Decompresses a Zstd compressed byte array. + * + * @return The decompressed byte array. + */ +expect fun ByteArray.decompressZstd(): ByteArray + +/** + * Decompresses a Gzip compressed byte array. + * + * @return The decompressed byte array. + */ +expect fun ByteArray.decompressGzip(): ByteArray diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt b/core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt index bb785da7d..63b22aabe 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt @@ -8,11 +8,11 @@ import dev.kdriver.core.connection.DefaultConnection import dev.kdriver.core.dom.DefaultElement import dev.kdriver.core.dom.Element import dev.kdriver.core.dom.NodeOrElement +import dev.kdriver.core.dom.filterRecurse import dev.kdriver.core.exceptions.EvaluateException import dev.kdriver.core.exceptions.TimeoutWaitingForElementException import dev.kdriver.core.exceptions.TimeoutWaitingForReadyStateException import dev.kdriver.core.network.* -import dev.kdriver.core.utils.filterRecurse import io.ktor.http.* import io.ktor.util.logging.* import io.ktor.utils.io.core.* @@ -368,7 +368,7 @@ open class DefaultTab( val items = mutableListOf() for (nid in nodeIds) { - val innerNode = filterRecurse(doc) { it.nodeId == nid } + val innerNode = doc.filterRecurse { it.nodeId == nid } if (innerNode != null) { val elem = DefaultElement(this, innerNode, doc) items.add(elem) @@ -420,7 +420,7 @@ open class DefaultTab( if (nodeId == null) return null - val foundNode = filterRecurse(doc) { it.nodeId == nodeId } + val foundNode = doc.filterRecurse { it.nodeId == nodeId } return foundNode?.let { DefaultElement(this, it, doc) } } @@ -438,7 +438,7 @@ open class DefaultTab( val items = mutableListOf() for (nid in nodeIds) { - val node = filterRecurse(doc) { it.nodeId == nid } + val node = doc.filterRecurse { it.nodeId == nid } if (node == null) { // Try to resolve the node if not found in the local tree val resolvedNode = try { @@ -473,11 +473,11 @@ open class DefaultTab( // since we already fetched the entire doc, including shadow and frames // let's also search through the iframes - val iframes = filterRecurse(doc) { it.nodeName == "IFRAME" } + val iframes = doc.filterRecurse { it.nodeName == "IFRAME" } if (iframes != null) { val iframeElems = listOf(DefaultElement(this, iframes, iframes.contentDocument ?: doc)) for (iframeElem in iframeElems) { - val iframeTextNodes = filterRecurse(iframeElem.node) { n -> + val iframeTextNodes = iframeElem.node.filterRecurse { n -> n.nodeType == 3 && n.nodeValue.contains(trimmedText, ignoreCase = true) } if (iframeTextNodes != null) { diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/utils/Utils.kt b/core/src/commonMain/kotlin/dev/kdriver/core/utils/Utils.kt deleted file mode 100644 index 207a092e1..000000000 --- a/core/src/commonMain/kotlin/dev/kdriver/core/utils/Utils.kt +++ /dev/null @@ -1,156 +0,0 @@ -package dev.kdriver.core.utils - -import dev.kdriver.cdp.domain.DOM -import dev.kdriver.core.browser.WebSocketInfo -import io.ktor.http.* -import kotlinx.io.files.Path - -fun filterRecurse(node: DOM.Node, predicate: (DOM.Node) -> Boolean): DOM.Node? { - val children = node.children ?: return null - for (child in children) { - if (predicate(child)) return child - - val shadowRoots = child.shadowRoots - if (shadowRoots != null && shadowRoots.isNotEmpty()) { - val shadowResult = filterRecurse(shadowRoots[0], predicate) - if (shadowResult != null) return shadowResult - } - - val recursiveResult = filterRecurse(child, predicate) - if (recursiveResult != null) return recursiveResult - } - return null -} - -fun filterRecurseAll(node: DOM.Node, predicate: (DOM.Node) -> Boolean): List { - val children = node.children ?: return emptyList() - val out = mutableListOf() - for (child in children) { - if (predicate(child)) { - out.add(child) - } - val shadowRoots = child.shadowRoots - if (shadowRoots != null && shadowRoots.isNotEmpty()) { - out.addAll(filterRecurseAll(shadowRoots[0], predicate)) - } - out.addAll(filterRecurseAll(child, predicate)) - } - return out -} - -fun parseWebSocketUrl(url: String): WebSocketInfo { - val uri = Url(url) - - val host = uri.host - val port = if (uri.port != -1) uri.port else when (uri.protocol) { - URLProtocol.WS -> 80 - URLProtocol.WSS -> 443 - else -> throw IllegalArgumentException("Unsupported scheme: ${uri.protocol}") - } - val path = uri.encodedPath - - return WebSocketInfo(host, port, path) -} - -fun isZstdCompressed(data: ByteArray): Boolean { - val header = data.take(4).map { it.toUByte().toInt() } - return header == listOf(0x28, 0xB5, 0x2F, 0xFD) -} - -fun isGzipCompressed(data: ByteArray): Boolean { - val header = data.take(2).map { it.toUByte().toInt() } - return header == listOf(0x1F, 0x8B) -} - -/** - * Browser search configuration flags - */ -data class BrowserSearchConfig( - val searchInPath: Boolean = true, - val searchMacosApplications: Boolean = false, - val searchWindowsProgramFiles: Boolean = false, - val searchLinuxCommonPaths: Boolean = false, -) - -/** - * Common helper to search for browser executables based on platform configuration. - * @param config Platform-specific search configuration - * @param pathSeparator The path separator character (":" for POSIX, ";" for Windows) - * @param pathEnv The PATH environment variable value - * @param executableNames List of executable names to search for (e.g., ["chrome", "google-chrome"]) - * @param macosAppPaths macOS .app bundle paths (only used if searchMacosApplications is true) - * @param windowsProgramFilesSuffixes Windows Program Files subdirectories (only used if searchWindowsProgramFiles is true) - * @param linuxCommonPaths Common Linux installation paths (only used if searchLinuxCommonPaths is true) - * @param windowsExecutableNames Windows executable names with .exe extension - * @param windowsProgramFilesGetter Callback to get Windows program files directories - */ -internal fun findBrowserExecutableCommon( - config: BrowserSearchConfig, - pathSeparator: String, - pathEnv: String?, - executableNames: List, - macosAppPaths: List = emptyList(), - windowsProgramFilesSuffixes: List = emptyList(), - windowsExecutableNames: List = emptyList(), - linuxCommonPaths: List = emptyList(), - windowsProgramFilesGetter: () -> List = { emptyList() }, -): Path? { - val candidates = mutableListOf() - - // macOS applications - if (config.searchMacosApplications) { - candidates.addAll(macosAppPaths.map { Path(it) }) - } - - // Windows Program Files - if (config.searchWindowsProgramFiles) { - val programFiles = windowsProgramFilesGetter() - for (base in programFiles) { - for (suffix in windowsProgramFilesSuffixes) { - for (exe in windowsExecutableNames) { - candidates.add(Path("$base/$suffix/$exe")) - } - } - } - } - - // Linux common paths - if (config.searchLinuxCommonPaths) { - candidates.addAll(linuxCommonPaths.map { Path(it) }) - } - - // Search in PATH - if (config.searchInPath) { - val paths = pathEnv?.split(pathSeparator) ?: emptyList() - for (pathDir in paths) { - for (exe in executableNames) { - candidates.add(Path("$pathDir/$exe")) - } - } - } - - // Return the shortest path that exists - return candidates - .filter { exists(it) } - .minByOrNull { it.toString().length } -} - -expect abstract class Process { - fun isAlive(): Boolean - fun pid(): Long - abstract fun destroy() -} - -expect suspend fun startProcess(exe: Path, params: List): Process -expect fun addShutdownHook(hook: suspend () -> Unit) -expect fun isPosix(): Boolean -expect fun isRoot(): Boolean -expect fun tempProfileDir(): Path -expect fun exists(path: Path): Boolean -expect fun getEnv(name: String): String? -expect fun findChromeExecutable(): Path? -expect fun findOperaExecutable(): Path? -expect fun findBraveExecutable(): Path? -expect fun findEdgeExecutable(): Path? -expect fun freePort(): Int? -expect fun decompressIfNeeded(data: ByteArray): ByteArray diff --git a/core/src/jsMain/kotlin/dev/kdriver/core/utils/Utils.js.kt b/core/src/jsMain/kotlin/dev/kdriver/core/browser/Process.js.kt similarity index 69% rename from core/src/jsMain/kotlin/dev/kdriver/core/utils/Utils.js.kt rename to core/src/jsMain/kotlin/dev/kdriver/core/browser/Process.js.kt index 2f398c146..c0d60ec43 100644 --- a/core/src/jsMain/kotlin/dev/kdriver/core/utils/Utils.js.kt +++ b/core/src/jsMain/kotlin/dev/kdriver/core/browser/Process.js.kt @@ -1,8 +1,7 @@ -package dev.kdriver.core.utils +package dev.kdriver.core.browser import kotlinx.io.files.Path - actual abstract class Process { actual fun isAlive(): Boolean { throw UnsupportedOperationException() @@ -46,26 +45,10 @@ actual fun getEnv(name: String): String? { throw UnsupportedOperationException() } -actual fun findChromeExecutable(): Path? { - throw UnsupportedOperationException() -} - -actual fun findOperaExecutable(): Path? { - throw UnsupportedOperationException() -} - -actual fun findBraveExecutable(): Path? { - throw UnsupportedOperationException() -} - -actual fun findEdgeExecutable(): Path? { - throw UnsupportedOperationException() -} - actual fun freePort(): Int? { throw UnsupportedOperationException() } -actual fun decompressIfNeeded(data: ByteArray): ByteArray { +actual fun defaultBrowserSearchConfig(): BrowserSearchConfig { throw UnsupportedOperationException() } diff --git a/core/src/jsMain/kotlin/dev/kdriver/core/utils/Client.js.kt b/core/src/jsMain/kotlin/dev/kdriver/core/connection/Client.js.kt similarity index 85% rename from core/src/jsMain/kotlin/dev/kdriver/core/utils/Client.js.kt rename to core/src/jsMain/kotlin/dev/kdriver/core/connection/Client.js.kt index b8e26aae2..e65e01f93 100644 --- a/core/src/jsMain/kotlin/dev/kdriver/core/utils/Client.js.kt +++ b/core/src/jsMain/kotlin/dev/kdriver/core/connection/Client.js.kt @@ -1,4 +1,4 @@ -package dev.kdriver.core.utils +package dev.kdriver.core.connection import io.ktor.client.engine.* import io.ktor.client.engine.js.* diff --git a/core/src/jsMain/kotlin/dev/kdriver/core/network/Extensions.js.kt b/core/src/jsMain/kotlin/dev/kdriver/core/network/Extensions.js.kt new file mode 100644 index 000000000..1ee38c084 --- /dev/null +++ b/core/src/jsMain/kotlin/dev/kdriver/core/network/Extensions.js.kt @@ -0,0 +1,9 @@ +package dev.kdriver.core.network + +actual fun ByteArray.decompressZstd(): ByteArray { + throw UnsupportedOperationException() +} + +actual fun ByteArray.decompressGzip(): ByteArray { + throw UnsupportedOperationException() +} diff --git a/core/src/jvmMain/kotlin/dev/kdriver/core/browser/Process.jvm.kt b/core/src/jvmMain/kotlin/dev/kdriver/core/browser/Process.jvm.kt new file mode 100644 index 000000000..86184342b --- /dev/null +++ b/core/src/jvmMain/kotlin/dev/kdriver/core/browser/Process.jvm.kt @@ -0,0 +1,90 @@ +package dev.kdriver.core.browser + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.io.files.Path +import java.io.File +import java.net.InetAddress +import java.net.ServerSocket +import kotlin.io.bufferedReader +import kotlin.io.path.createTempDirectory +import kotlin.io.readText +import kotlin.use + +actual typealias Process = java.lang.Process + +actual suspend fun startProcess( + exe: Path, + params: List, +): Process { + val isPosix = isPosix() + return withContext(Dispatchers.IO) { + val command = listOf(exe.toString()) + params + + val builder = ProcessBuilder(command) + builder.redirectInput(ProcessBuilder.Redirect.PIPE) + builder.redirectOutput(ProcessBuilder.Redirect.PIPE) + builder.redirectError(ProcessBuilder.Redirect.PIPE) + if (isPosix) builder.redirectErrorStream(false) + + val process = builder.start() + process + } +} + +actual fun addShutdownHook(hook: suspend () -> Unit) { + Runtime.getRuntime().addShutdownHook(Thread { + runBlocking { + hook() + } + }) +} + +actual fun isPosix(): Boolean { + val os = System.getProperty("os.name").lowercase() + return os.contains("nix") || os.contains("nux") || os.contains("mac") +} + +actual fun isRoot(): Boolean { + return try { + val process = ProcessBuilder("id", "-u").start() + val result = process.inputStream.bufferedReader().readText().trim() + result == "0" + } catch (_: Exception) { + false + } +} + +actual fun tempProfileDir(): Path { + return Path(createTempDirectory(prefix = "kdriver_").toFile().absolutePath) +} + +actual fun exists(path: Path): Boolean { + return try { + val file = File(path.toString()) + file.exists() && file.canRead() + } catch (_: Exception) { + false + } +} + +actual fun getEnv(name: String): String? { + return System.getenv(name) +} + +actual fun freePort(): Int? { + ServerSocket(0, 5, InetAddress.getByName("127.0.0.1")).use { socket -> + return socket.localPort + } +} + + +actual fun defaultBrowserSearchConfig(): BrowserSearchConfig { + val os = System.getProperty("os.name").lowercase() + return when { + os.contains("mac") -> BrowserSearchConfig(File.pathSeparator, searchMacosApplications = true) + isPosix() -> BrowserSearchConfig(File.pathSeparator, searchLinuxCommonPaths = true) + else -> BrowserSearchConfig(File.pathSeparator, searchWindowsProgramFiles = true) + } +} diff --git a/core/src/jvmMain/kotlin/dev/kdriver/core/utils/Client.jvm.kt b/core/src/jvmMain/kotlin/dev/kdriver/core/connection/Client.jvm.kt similarity index 87% rename from core/src/jvmMain/kotlin/dev/kdriver/core/utils/Client.jvm.kt rename to core/src/jvmMain/kotlin/dev/kdriver/core/connection/Client.jvm.kt index 83563fe68..537aef9ee 100644 --- a/core/src/jvmMain/kotlin/dev/kdriver/core/utils/Client.jvm.kt +++ b/core/src/jvmMain/kotlin/dev/kdriver/core/connection/Client.jvm.kt @@ -1,4 +1,4 @@ -package dev.kdriver.core.utils +package dev.kdriver.core.connection import io.ktor.client.engine.* import io.ktor.client.engine.apache.* diff --git a/core/src/jvmMain/kotlin/dev/kdriver/core/network/Extensions.jvm.kt b/core/src/jvmMain/kotlin/dev/kdriver/core/network/Extensions.jvm.kt new file mode 100644 index 000000000..5c3b35492 --- /dev/null +++ b/core/src/jvmMain/kotlin/dev/kdriver/core/network/Extensions.jvm.kt @@ -0,0 +1,22 @@ +package dev.kdriver.core.network + +import com.github.luben.zstd.ZstdInputStream +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPInputStream +import kotlin.io.copyTo +import kotlin.use + +actual fun ByteArray.decompressZstd(): ByteArray { + val input = ByteArrayInputStream(this) + val output = ByteArrayOutputStream() + ZstdInputStream(input).use { it.copyTo(output) } + return output.toByteArray() +} + +actual fun ByteArray.decompressGzip(): ByteArray { + val input = ByteArrayInputStream(this) + val output = ByteArrayOutputStream() + GZIPInputStream(input).use { it.copyTo(output) } + return output.toByteArray() +} diff --git a/core/src/jvmMain/kotlin/dev/kdriver/core/utils/Utils.jvm.kt b/core/src/jvmMain/kotlin/dev/kdriver/core/utils/Utils.jvm.kt deleted file mode 100644 index a43cee536..000000000 --- a/core/src/jvmMain/kotlin/dev/kdriver/core/utils/Utils.jvm.kt +++ /dev/null @@ -1,269 +0,0 @@ -package dev.kdriver.core.utils - -import com.github.luben.zstd.ZstdInputStream -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import kotlinx.io.files.Path -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.File -import java.net.InetAddress -import java.net.ServerSocket -import java.util.zip.GZIPInputStream -import kotlin.io.bufferedReader -import kotlin.io.copyTo -import kotlin.io.path.createTempDirectory -import kotlin.io.readText -import kotlin.use - -actual typealias Process = java.lang.Process - -actual suspend fun startProcess( - exe: Path, - params: List, -): Process { - val isPosix = isPosix() - return withContext(Dispatchers.IO) { - val command = listOf(exe.toString()) + params - - val builder = ProcessBuilder(command) - builder.redirectInput(ProcessBuilder.Redirect.PIPE) - builder.redirectOutput(ProcessBuilder.Redirect.PIPE) - builder.redirectError(ProcessBuilder.Redirect.PIPE) - if (isPosix) builder.redirectErrorStream(false) - - val process = builder.start() - process - } -} - -actual fun addShutdownHook(hook: suspend () -> Unit) { - Runtime.getRuntime().addShutdownHook(Thread { - runBlocking { - hook() - } - }) -} - -actual fun isPosix(): Boolean { - val os = System.getProperty("os.name").lowercase() - return os.contains("nix") || os.contains("nux") || os.contains("mac") -} - -actual fun isRoot(): Boolean { - return try { - val process = ProcessBuilder("id", "-u").start() - val result = process.inputStream.bufferedReader().readText().trim() - result == "0" - } catch (_: Exception) { - false - } -} - -actual fun tempProfileDir(): Path { - return Path(createTempDirectory(prefix = "kdriver_").toFile().absolutePath) -} - -actual fun exists(path: Path): Boolean { - return try { - val file = File(path.toString()) - file.exists() && file.canRead() - } catch (_: Exception) { - false - } -} - -actual fun getEnv(name: String): String? { - return System.getenv(name) -} - -actual fun findChromeExecutable(): Path? { - val os = System.getProperty("os.name").lowercase() - - val config = when { - os.contains("mac") -> BrowserSearchConfig(searchMacosApplications = true) - isPosix() -> BrowserSearchConfig(searchLinuxCommonPaths = true) - else -> BrowserSearchConfig(searchWindowsProgramFiles = true) - } - - return findBrowserExecutableCommon( - config = config, - pathSeparator = File.pathSeparator, - pathEnv = getEnv("PATH"), - executableNames = listOf( - "google-chrome", - "chromium", - "chromium-browser", - "chrome", - "google-chrome-stable" - ), - macosAppPaths = listOf( - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Chromium.app/Contents/MacOS/Chromium" - ), - linuxCommonPaths = listOf( - "/usr/bin/google-chrome", - "/usr/bin/chromium", - "/usr/bin/chromium-browser", - "/snap/bin/chromium", - "/opt/google/chrome/chrome", - ), - windowsProgramFilesSuffixes = listOf( - "Google/Chrome/Application", - "Google/Chrome Beta/Application", - "Google/Chrome Canary/Application", - "Google/Chrome SxS/Application", - ), - windowsExecutableNames = listOf("chrome.exe"), - windowsProgramFilesGetter = { - listOfNotNull( - getEnv("PROGRAMFILES"), - getEnv("PROGRAMFILES(X86)"), - getEnv("LOCALAPPDATA"), - getEnv("PROGRAMW6432") - ) - } - ) -} - -actual fun findOperaExecutable(): Path? { - val os = System.getProperty("os.name").lowercase() - - val config = when { - os.contains("mac") -> BrowserSearchConfig(searchMacosApplications = true) - isPosix() -> BrowserSearchConfig(searchLinuxCommonPaths = true) - else -> BrowserSearchConfig(searchWindowsProgramFiles = true) - } - - return findBrowserExecutableCommon( - config = config, - pathSeparator = File.pathSeparator, - pathEnv = getEnv("PATH"), - executableNames = listOf("opera"), - macosAppPaths = listOf( - "/Applications/Opera.app/Contents/MacOS/Opera" - ), - linuxCommonPaths = listOf( - "/usr/bin/opera", - "/usr/local/bin/opera", - ), - windowsProgramFilesSuffixes = listOf( - "Opera", - "Programs/Opera" - ), - windowsExecutableNames = listOf("opera.exe"), - windowsProgramFilesGetter = { - listOfNotNull( - getEnv("PROGRAMFILES"), - getEnv("PROGRAMFILES(X86)"), - getEnv("LOCALAPPDATA"), - getEnv("PROGRAMW6432") - ) - } - ) -} - -actual fun findBraveExecutable(): Path? { - val os = System.getProperty("os.name").lowercase() - - val config = when { - os.contains("mac") -> BrowserSearchConfig(searchMacosApplications = true) - isPosix() -> BrowserSearchConfig(searchLinuxCommonPaths = true) - else -> BrowserSearchConfig(searchWindowsProgramFiles = true) - } - - return findBrowserExecutableCommon( - config = config, - pathSeparator = File.pathSeparator, - pathEnv = getEnv("PATH"), - executableNames = listOf("brave-browser", "brave"), - macosAppPaths = listOf( - "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" - ), - linuxCommonPaths = listOf( - "/usr/bin/brave-browser", - "/usr/bin/brave", - "/snap/bin/brave", - ), - windowsProgramFilesSuffixes = listOf( - "BraveSoftware/Brave-Browser/Application" - ), - windowsExecutableNames = listOf("brave.exe"), - windowsProgramFilesGetter = { - listOfNotNull( - getEnv("PROGRAMFILES"), - getEnv("PROGRAMFILES(X86)"), - getEnv("LOCALAPPDATA"), - getEnv("PROGRAMW6432") - ) - } - ) -} - -actual fun findEdgeExecutable(): Path? { - val os = System.getProperty("os.name").lowercase() - - val config = when { - os.contains("mac") -> BrowserSearchConfig(searchMacosApplications = true) - isPosix() -> BrowserSearchConfig(searchLinuxCommonPaths = true) - else -> BrowserSearchConfig(searchWindowsProgramFiles = true) - } - - return findBrowserExecutableCommon( - config = config, - pathSeparator = File.pathSeparator, - pathEnv = getEnv("PATH"), - executableNames = listOf( - "microsoft-edge", - "microsoft-edge-stable", - "microsoft-edge-beta", - "microsoft-edge-dev" - ), - macosAppPaths = listOf( - "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" - ), - linuxCommonPaths = listOf( - "/usr/bin/microsoft-edge", - "/usr/bin/microsoft-edge-stable", - ), - windowsProgramFilesSuffixes = listOf( - "Microsoft/Edge/Application" - ), - windowsExecutableNames = listOf("msedge.exe"), - windowsProgramFilesGetter = { - listOfNotNull( - getEnv("PROGRAMFILES"), - getEnv("PROGRAMFILES(X86)"), - getEnv("LOCALAPPDATA"), - getEnv("PROGRAMW6432") - ) - } - ) -} - -actual fun freePort(): Int? { - ServerSocket(0, 5, InetAddress.getByName("127.0.0.1")).use { socket -> - return socket.localPort - } -} - -actual fun decompressIfNeeded(data: ByteArray): ByteArray { - return when { - isZstdCompressed(data) -> { - val input = ByteArrayInputStream(data) - val output = ByteArrayOutputStream() - ZstdInputStream(input).use { it.copyTo(output) } - output.toByteArray() - } - - isGzipCompressed(data) -> { - val input = ByteArrayInputStream(data) - val output = ByteArrayOutputStream() - GZIPInputStream(input).use { it.copyTo(output) } - output.toByteArray() - } - - else -> data - } -} diff --git a/core/src/linuxMain/kotlin/dev/kdriver/core/browser/Process.linux.kt b/core/src/linuxMain/kotlin/dev/kdriver/core/browser/Process.linux.kt new file mode 100644 index 000000000..077cd35b9 --- /dev/null +++ b/core/src/linuxMain/kotlin/dev/kdriver/core/browser/Process.linux.kt @@ -0,0 +1,58 @@ +package dev.kdriver.core.browser + +import kotlinx.cinterop.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext +import kotlinx.io.files.Path +import platform.posix.* + +private class PosixProcess(override val processIdentifier: Int) : Process() { + override fun destroy() { + if (isAlive()) { + kill(processIdentifier, SIGTERM) + } + } +} + +@OptIn(ExperimentalForeignApi::class) +actual suspend fun startProcess( + exe: Path, + params: List, +): Process = withContext(Dispatchers.IO) { + val pid = fork() + + when { + pid < 0 -> throw RuntimeException("Failed to fork process") + pid == 0 -> { + // Child process + memScoped { + // Build argv array for execvp + val argc = params.size + 2 // exe + params + null + val argv = allocArray>(argc) + argv[0] = exe.toString().cstr.ptr + params.forEachIndexed { index, param -> + argv[index + 1] = param.cstr.ptr + } + argv[argc - 1] = null + + // Execute the process + execvp(exe.toString(), argv) + + // If we reach here, execvp failed + exit(1) + } + // This line will never be reached but is needed for type checking + throw IllegalStateException("Child process should have exited") + } + + else -> { + // Parent process + PosixProcess(pid) + } + } +} + +actual fun defaultBrowserSearchConfig(): BrowserSearchConfig { + return BrowserSearchConfig(":", searchLinuxCommonPaths = true) +} diff --git a/core/src/linuxMain/kotlin/dev/kdriver/core/utils/Client.linux.kt b/core/src/linuxMain/kotlin/dev/kdriver/core/connection/Client.linux.kt similarity index 85% rename from core/src/linuxMain/kotlin/dev/kdriver/core/utils/Client.linux.kt rename to core/src/linuxMain/kotlin/dev/kdriver/core/connection/Client.linux.kt index 3cbe57a4d..d619da9f1 100644 --- a/core/src/linuxMain/kotlin/dev/kdriver/core/utils/Client.linux.kt +++ b/core/src/linuxMain/kotlin/dev/kdriver/core/connection/Client.linux.kt @@ -1,4 +1,4 @@ -package dev.kdriver.core.utils +package dev.kdriver.core.connection import io.ktor.client.engine.* import io.ktor.client.engine.curl.* diff --git a/core/src/linuxMain/kotlin/dev/kdriver/core/network/Extensions.linux.kt b/core/src/linuxMain/kotlin/dev/kdriver/core/network/Extensions.linux.kt new file mode 100644 index 000000000..53d6651dd --- /dev/null +++ b/core/src/linuxMain/kotlin/dev/kdriver/core/network/Extensions.linux.kt @@ -0,0 +1,95 @@ +package dev.kdriver.core.network + +import kotlinx.cinterop.* +import platform.posix.* +import kotlin.random.Random + +actual fun ByteArray.decompressZstd(): ByteArray { + // Zstandard decompression is not readily available in Kotlin/Native without additional libraries + // For now, throw an exception + throw UnsupportedOperationException("Zstandard decompression not yet supported on Linux native") +} + +@OptIn(ExperimentalForeignApi::class) +actual fun ByteArray.decompressGzip(): ByteArray { + // Write to temp file and use gunzip command as a workaround + val tempDir = getenv("TMPDIR")?.toKString() ?: "/tmp" + val inputPath = "$tempDir/kdriver_input_${Random.nextLong().toString(16)}.gz" + val outputPath = "$tempDir/kdriver_output_${Random.nextLong().toString(16)}" + + // Write compressed data to temp file + val file = fopen(inputPath, "wb") + if (file == null) return this + + try { + usePinned { pinned -> + fwrite(pinned.addressOf(0), 1u, size.toULong(), file) + } + fclose(file) + + // Fork and exec gunzip + val pid = fork() + return when { + pid < 0 -> this // Fork failed + pid == 0 -> { + // Child process - redirect output to file + memScoped { + val outFile = fopen(outputPath, "wb") + if (outFile != null) { + dup2(fileno(outFile), STDOUT_FILENO) + fclose(outFile) + } + + val argv = allocArray>(4) + argv[0] = "gunzip".cstr.ptr + argv[1] = "-c".cstr.ptr + argv[2] = inputPath.cstr.ptr + argv[3] = null + + execvp("gunzip", argv) + exit(1) + } + throw IllegalStateException("Child process should have exited") + } + + else -> { + // Parent process - wait for child + memScoped { + val status = alloc() + waitpid(pid, status.ptr, 0) + } + + // Read decompressed data + val outFile = fopen(outputPath, "rb") + if (outFile == null) { + unlink(inputPath) + return this + } + + try { + // Get file size + fseek(outFile, 0, SEEK_END) + val size = ftell(outFile).toInt() + fseek(outFile, 0, SEEK_SET) + + if (size <= 0) return this + + // Read data + val result = ByteArray(size) + result.usePinned { pinned -> + fread(pinned.addressOf(0), 1u, size.toULong(), outFile) + } + + result + } finally { + fclose(outFile) + unlink(inputPath) + unlink(outputPath) + } + } + } + } catch (e: Exception) { + unlink(inputPath) + return this + } +} diff --git a/core/src/linuxMain/kotlin/dev/kdriver/core/utils/Utils.linux.kt b/core/src/linuxMain/kotlin/dev/kdriver/core/utils/Utils.linux.kt deleted file mode 100644 index ba105d4c4..000000000 --- a/core/src/linuxMain/kotlin/dev/kdriver/core/utils/Utils.linux.kt +++ /dev/null @@ -1,300 +0,0 @@ -package dev.kdriver.core.utils - -import kotlinx.cinterop.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.withContext -import kotlinx.io.files.Path -import platform.posix.* -import kotlin.random.Random - -actual abstract class Process { - abstract val processIdentifier: Int - - actual fun isAlive(): Boolean { - // Check if process exists by sending signal 0 - return kill(processIdentifier, 0) == 0 - } - - actual fun pid(): Long { - return processIdentifier.toLong() - } - - actual abstract fun destroy() -} - -private class PosixProcess(override val processIdentifier: Int) : Process() { - override fun destroy() { - if (isAlive()) { - kill(processIdentifier, SIGTERM) - } - } -} - -@OptIn(ExperimentalForeignApi::class) -actual suspend fun startProcess( - exe: Path, - params: List, -): Process = withContext(Dispatchers.IO) { - val pid = fork() - - when { - pid < 0 -> throw RuntimeException("Failed to fork process") - pid == 0 -> { - // Child process - memScoped { - // Build argv array for execvp - val argc = params.size + 2 // exe + params + null - val argv = allocArray>(argc) - argv[0] = exe.toString().cstr.ptr - params.forEachIndexed { index, param -> - argv[index + 1] = param.cstr.ptr - } - argv[argc - 1] = null - - // Execute the process - execvp(exe.toString(), argv) - - // If we reach here, execvp failed - exit(1) - } - // This line will never be reached but is needed for type checking - throw IllegalStateException("Child process should have exited") - } - - else -> { - // Parent process - PosixProcess(pid) - } - } -} - -actual fun addShutdownHook(hook: suspend () -> Unit) { - // Linux doesn't have a direct equivalent to Java shutdown hooks - // Could use atexit() but it doesn't support suspend functions - // For now, keeping it as no-op -} - -actual fun isPosix(): Boolean { - return true // Linux is POSIX-compliant -} - -@OptIn(ExperimentalForeignApi::class) -actual fun isRoot(): Boolean { - return getuid() == 0u -} - -@OptIn(ExperimentalForeignApi::class) -actual fun tempProfileDir(): Path { - val tempDir = getenv("TMPDIR")?.toKString() ?: "/tmp" - val uniqueName = "kdriver_${Random.nextLong().toString(16)}" - val profilePath = "$tempDir/$uniqueName" - - mkdir(profilePath, 0x1FFu) // 0777 permissions - - return Path(profilePath) -} - -@OptIn(ExperimentalForeignApi::class) -actual fun exists(path: Path): Boolean { - return access(path.toString(), F_OK) == 0 -} - -@OptIn(ExperimentalForeignApi::class) -actual fun getEnv(name: String): String? { - return getenv(name)?.toKString() -} - -@OptIn(ExperimentalForeignApi::class) -private fun isExecutable(path: Path): Boolean { - return access(path.toString(), X_OK) == 0 -} - -actual fun findChromeExecutable(): Path? = findBrowserExecutableCommon( - config = BrowserSearchConfig(searchLinuxCommonPaths = true), - pathSeparator = ":", - pathEnv = getEnv("PATH"), - executableNames = listOf( - "google-chrome", - "chromium", - "chromium-browser", - "chrome", - "google-chrome-stable" - ), - linuxCommonPaths = listOf( - "/usr/bin/google-chrome", - "/usr/bin/chromium", - "/usr/bin/chromium-browser", - "/snap/bin/chromium", - "/opt/google/chrome/chrome", - ), -) - -actual fun findOperaExecutable(): Path? = findBrowserExecutableCommon( - config = BrowserSearchConfig(searchLinuxCommonPaths = true), - pathSeparator = ":", - pathEnv = getEnv("PATH"), - executableNames = listOf("opera"), - linuxCommonPaths = listOf( - "/usr/bin/opera", - "/usr/local/bin/opera", - ), -) - -actual fun findBraveExecutable(): Path? = findBrowserExecutableCommon( - config = BrowserSearchConfig(searchLinuxCommonPaths = true), - pathSeparator = ":", - pathEnv = getEnv("PATH"), - executableNames = listOf("brave-browser", "brave"), - linuxCommonPaths = listOf( - "/usr/bin/brave-browser", - "/usr/bin/brave", - "/snap/bin/brave", - ), -) - -actual fun findEdgeExecutable(): Path? = findBrowserExecutableCommon( - config = BrowserSearchConfig(searchLinuxCommonPaths = true), - pathSeparator = ":", - pathEnv = getEnv("PATH"), - executableNames = listOf( - "microsoft-edge", - "microsoft-edge-stable", - "microsoft-edge-beta", - "microsoft-edge-dev" - ), - linuxCommonPaths = listOf( - "/usr/bin/microsoft-edge", - "/usr/bin/microsoft-edge-stable", - ), -) - -@OptIn(ExperimentalForeignApi::class) -actual fun freePort(): Int? { - return memScoped { - val sockfd = socket(AF_INET, SOCK_STREAM, 0) - if (sockfd < 0) return null - - try { - val addr = alloc() - memset(addr.ptr, 0, sizeOf().convert()) - addr.sin_family = AF_INET.convert() - addr.sin_addr.s_addr = 0x0100007Fu // 127.0.0.1 in network byte order (little-endian) - addr.sin_port = 0u // Let the OS choose a port - - if (bind(sockfd, addr.ptr.reinterpret(), sizeOf().convert()) < 0) { - return null - } - - val len = alloc() - len.value = sizeOf().convert() - - if (getsockname(sockfd, addr.ptr.reinterpret(), len.ptr) < 0) { - return null - } - - // Convert port from network byte order to host byte order - val portBytes = addr.sin_port - ((portBytes.toInt() shr 8) and 0xFF) or ((portBytes.toInt() and 0xFF) shl 8) - } finally { - close(sockfd) - } - } -} - -actual fun decompressIfNeeded(data: ByteArray): ByteArray { - return when { - isGzipCompressed(data) -> decompressGzip(data) - isZstdCompressed(data) -> { - // Zstandard decompression is not readily available in Kotlin/Native without additional libraries - // For now, throw an exception - throw UnsupportedOperationException("Zstandard decompression not yet supported on Linux native") - } - - else -> data - } -} - -@OptIn(ExperimentalForeignApi::class) -private fun decompressGzip(data: ByteArray): ByteArray { - // Write to temp file and use gunzip command as a workaround - val tempDir = getenv("TMPDIR")?.toKString() ?: "/tmp" - val inputPath = "$tempDir/kdriver_input_${Random.nextLong().toString(16)}.gz" - val outputPath = "$tempDir/kdriver_output_${Random.nextLong().toString(16)}" - - // Write compressed data to temp file - val file = fopen(inputPath, "wb") - if (file == null) return data - - try { - data.usePinned { pinned -> - fwrite(pinned.addressOf(0), 1u, data.size.toULong(), file) - } - fclose(file) - - // Fork and exec gunzip - val pid = fork() - return when { - pid < 0 -> data // Fork failed - pid == 0 -> { - // Child process - redirect output to file - memScoped { - val outFile = fopen(outputPath, "wb") - if (outFile != null) { - dup2(fileno(outFile), STDOUT_FILENO) - fclose(outFile) - } - - val argv = allocArray>(4) - argv[0] = "gunzip".cstr.ptr - argv[1] = "-c".cstr.ptr - argv[2] = inputPath.cstr.ptr - argv[3] = null - - execvp("gunzip", argv) - exit(1) - } - throw IllegalStateException("Child process should have exited") - } - - else -> { - // Parent process - wait for child - memScoped { - val status = alloc() - waitpid(pid, status.ptr, 0) - } - - // Read decompressed data - val outFile = fopen(outputPath, "rb") - if (outFile == null) { - unlink(inputPath) - return data - } - - try { - // Get file size - fseek(outFile, 0, SEEK_END) - val size = ftell(outFile).toInt() - fseek(outFile, 0, SEEK_SET) - - if (size <= 0) return data - - // Read data - val result = ByteArray(size) - result.usePinned { pinned -> - fread(pinned.addressOf(0), 1u, size.toULong(), outFile) - } - - result - } finally { - fclose(outFile) - unlink(inputPath) - unlink(outputPath) - } - } - } - } catch (e: Exception) { - unlink(inputPath) - return data - } -} diff --git a/core/src/linuxTest/kotlin/dev/kdriver/core/tab/TabTest.kt b/core/src/linuxTest/kotlin/dev/kdriver/core/tab/TabTest.kt new file mode 100644 index 000000000..da534b4a9 --- /dev/null +++ b/core/src/linuxTest/kotlin/dev/kdriver/core/tab/TabTest.kt @@ -0,0 +1,19 @@ +package dev.kdriver.core.tab + +import dev.kdriver.core.browser.createBrowser +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertTrue + +class TabTest { + + @Test + fun testGetContentGetsHtmlContent() = runBlocking { + val browser = createBrowser(this, headless = true, sandbox = false) + val tab = browser.get("https://example.com") + val content = tab.getContent() + assertTrue(content.lowercase().startsWith("")) + browser.stop() + } + +} diff --git a/core/src/macosMain/kotlin/dev/kdriver/core/browser/Process.macos.kt b/core/src/macosMain/kotlin/dev/kdriver/core/browser/Process.macos.kt new file mode 100644 index 000000000..9421248ba --- /dev/null +++ b/core/src/macosMain/kotlin/dev/kdriver/core/browser/Process.macos.kt @@ -0,0 +1,42 @@ +package dev.kdriver.core.browser + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext +import kotlinx.io.files.Path +import platform.Foundation.NSPipe +import platform.Foundation.NSTask +import platform.Foundation.launch +import platform.Foundation.launchPath + +private class NSTaskProcess(private val task: NSTask) : Process() { + override val processIdentifier: Int + get() = task.processIdentifier + + override fun destroy() { + if (task.isRunning()) { + task.terminate() + } + } +} + +actual suspend fun startProcess( + exe: Path, + params: List, +): Process = withContext(Dispatchers.IO) { + val task = NSTask() + task.launchPath = exe.toString() + task.arguments = params + + // Set up pipes for stdin, stdout, stderr + task.standardInput = NSPipe() + task.standardOutput = NSPipe() + task.standardError = NSPipe() + + task.launch() + NSTaskProcess(task) +} + +actual fun defaultBrowserSearchConfig(): BrowserSearchConfig { + return BrowserSearchConfig(":", searchMacosApplications = true) +} diff --git a/core/src/macosMain/kotlin/dev/kdriver/core/utils/Utils.macos.kt b/core/src/macosMain/kotlin/dev/kdriver/core/utils/Utils.macos.kt deleted file mode 100644 index 049b90645..000000000 --- a/core/src/macosMain/kotlin/dev/kdriver/core/utils/Utils.macos.kt +++ /dev/null @@ -1,237 +0,0 @@ -package dev.kdriver.core.utils - -import kotlinx.cinterop.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.withContext -import kotlinx.io.files.Path -import platform.Foundation.* -import platform.posix.* -import kotlin.random.Random - -actual abstract class Process { - abstract val processIdentifier: Int - - actual fun isAlive(): Boolean { - // Check if process exists by sending signal 0 - return kill(processIdentifier, 0) == 0 - } - - actual fun pid(): Long { - return processIdentifier.toLong() - } - - actual abstract fun destroy() -} - -private class NSTaskProcess(private val task: NSTask) : Process() { - override val processIdentifier: Int - get() = task.processIdentifier - - override fun destroy() { - if (task.isRunning()) { - task.terminate() - } - } -} - -actual suspend fun startProcess( - exe: Path, - params: List, -): Process = withContext(Dispatchers.IO) { - val task = NSTask() - task.launchPath = exe.toString() - task.arguments = params - - // Set up pipes for stdin, stdout, stderr - task.standardInput = NSPipe() - task.standardOutput = NSPipe() - task.standardError = NSPipe() - - task.launch() - NSTaskProcess(task) -} - -actual fun addShutdownHook(hook: suspend () -> Unit) { - // macOS doesn't have a direct equivalent to Java shutdown hooks in the same way - // We could use atexit() but it doesn't support suspend functions - // For now, keeping it as no-op, similar to the original placeholder -} - -actual fun isPosix(): Boolean { - return true // macOS is always POSIX-compliant -} - -actual fun isRoot(): Boolean { - return getuid() == 0u -} - -@OptIn(ExperimentalForeignApi::class) -actual fun tempProfileDir(): Path { - val tempDir = NSTemporaryDirectory() - val uniqueName = "kdriver_${Random.nextLong().toString(16)}" - val profilePath = tempDir + uniqueName - - NSFileManager.defaultManager.createDirectoryAtPath( - profilePath, - withIntermediateDirectories = true, - attributes = null, - error = null - ) - - return Path(profilePath) -} - -actual fun exists(path: Path): Boolean { - return NSFileManager.defaultManager.fileExistsAtPath(path.toString()) -} - -@OptIn(ExperimentalForeignApi::class) -actual fun getEnv(name: String): String? { - return getenv(name)?.toKString() -} - -actual fun findChromeExecutable(): Path? = findBrowserExecutableCommon( - config = BrowserSearchConfig(searchMacosApplications = true), - pathSeparator = ":", - pathEnv = getEnv("PATH"), - executableNames = listOf( - "google-chrome", - "chromium", - "chromium-browser", - "chrome", - "google-chrome-stable" - ), - macosAppPaths = listOf( - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Chromium.app/Contents/MacOS/Chromium" - ), -) - -actual fun findOperaExecutable(): Path? = findBrowserExecutableCommon( - config = BrowserSearchConfig(searchMacosApplications = true), - pathSeparator = ":", - pathEnv = getEnv("PATH"), - executableNames = listOf("opera"), - macosAppPaths = listOf( - "/Applications/Opera.app/Contents/MacOS/Opera" - ), -) - -actual fun findBraveExecutable(): Path? = findBrowserExecutableCommon( - config = BrowserSearchConfig(searchMacosApplications = true), - pathSeparator = ":", - pathEnv = getEnv("PATH"), - executableNames = listOf("brave-browser", "brave"), - macosAppPaths = listOf( - "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" - ), -) - -actual fun findEdgeExecutable(): Path? = findBrowserExecutableCommon( - config = BrowserSearchConfig(searchMacosApplications = true), - pathSeparator = ":", - pathEnv = getEnv("PATH"), - executableNames = listOf( - "microsoft-edge", - "microsoft-edge-stable", - "microsoft-edge-beta", - "microsoft-edge-dev" - ), - macosAppPaths = listOf( - "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" - ), -) - -@OptIn(ExperimentalForeignApi::class) -actual fun freePort(): Int? { - return memScoped { - val sockfd = socket(AF_INET, SOCK_STREAM, 0) - if (sockfd < 0) return null - - try { - val addr = alloc() - memset(addr.ptr, 0, sizeOf().convert()) - addr.sin_family = AF_INET.convert() - addr.sin_addr.s_addr = 0x0100007Fu // 127.0.0.1 in network byte order (little-endian) - addr.sin_port = 0u // Let the OS choose a port - - if (bind(sockfd, addr.ptr.reinterpret(), sizeOf().convert()) < 0) { - return null - } - - val len = alloc() - len.value = sizeOf().convert() - - if (getsockname(sockfd, addr.ptr.reinterpret(), len.ptr) < 0) { - return null - } - - // Convert port from network byte order to host byte order - val portBytes = addr.sin_port - ((portBytes.toInt() shr 8) and 0xFF) or ((portBytes.toInt() and 0xFF) shl 8) - } finally { - close(sockfd) - } - } -} - -actual fun decompressIfNeeded(data: ByteArray): ByteArray { - return when { - isGzipCompressed(data) -> decompressGzip(data) - isZstdCompressed(data) -> { - // Zstandard decompression is not readily available in Kotlin/Native without additional libraries - // For now, return data as-is or throw an exception - throw UnsupportedOperationException("Zstandard decompression not yet supported on macOS native") - } - - else -> data - } -} - -@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) -private fun decompressGzip(data: ByteArray): ByteArray { - // Create NSData from ByteArray - val nsData = data.usePinned { pinned -> - NSData.create(bytes = pinned.addressOf(0), length = data.size.toULong()) - } - - // Use the /usr/bin/gunzip command as a workaround - // This is not ideal but works without needing zlib cinterop definitions - val tempDir = NSTemporaryDirectory() - val inputPath = tempDir + "input.gz" - val outputPath = tempDir + "output" - - // Write compressed data to temp file - nsData.writeToFile(inputPath, atomically = false) - - // Run gunzip command - val task = NSTask() - task.launchPath = "/usr/bin/gunzip" - task.arguments = listOf("-c", inputPath) - - val outputPipe = NSPipe() - task.standardOutput = outputPipe - task.standardError = NSPipe() - - task.launch() - task.waitUntilExit() - - // Read decompressed data - val outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() - - // Clean up temp file - NSFileManager.defaultManager.removeItemAtPath(inputPath, error = null) - - // Convert NSData back to ByteArray - val size = outputData.length.toInt() - return if (size > 0) { - val result = ByteArray(size) - result.usePinned { pinned -> - memcpy(pinned.addressOf(0), outputData.bytes, size.toULong()) - } - result - } else { - data // Return original if decompression failed - } -} diff --git a/core/src/mingwMain/kotlin/dev/kdriver/core/utils/Utils.mingw.kt b/core/src/mingwMain/kotlin/dev/kdriver/core/browser/Process.mingw.kt similarity index 55% rename from core/src/mingwMain/kotlin/dev/kdriver/core/utils/Utils.mingw.kt rename to core/src/mingwMain/kotlin/dev/kdriver/core/browser/Process.mingw.kt index 535820856..4c2ed7de7 100644 --- a/core/src/mingwMain/kotlin/dev/kdriver/core/utils/Utils.mingw.kt +++ b/core/src/mingwMain/kotlin/dev/kdriver/core/browser/Process.mingw.kt @@ -1,4 +1,4 @@ -package dev.kdriver.core.utils +package dev.kdriver.core.browser import kotlinx.cinterop.* import kotlinx.coroutines.Dispatchers @@ -124,11 +124,10 @@ actual fun tempProfileDir(): Path { return Path(profilePath) } -@OptIn(ExperimentalForeignApi::class) +@OptIn(ExperimentalForeignApi::class, UnsafeNumber::class) actual fun exists(path: Path): Boolean { - // Simplified check - just return true for now to unblock compilation - // TODO: Properly implement using GetFileAttributesW - return true + val attributes = GetFileAttributesW(path.toString()) + return attributes != INVALID_FILE_ATTRIBUTES } @OptIn(ExperimentalForeignApi::class) @@ -137,86 +136,6 @@ actual fun getEnv(name: String): String? { return getenv(name)?.toKString() } -actual fun findChromeExecutable(): Path? = findBrowserExecutableCommon( - config = BrowserSearchConfig(searchWindowsProgramFiles = true), - pathSeparator = ";", - pathEnv = getEnv("PATH"), - executableNames = listOf("chrome.exe", "google-chrome.exe"), - windowsProgramFilesSuffixes = listOf( - "Google/Chrome/Application", - "Google/Chrome Beta/Application", - "Google/Chrome Canary/Application", - "Google/Chrome SxS/Application", - ), - windowsExecutableNames = listOf("chrome.exe"), - windowsProgramFilesGetter = { - listOfNotNull( - getEnv("PROGRAMFILES"), - getEnv("PROGRAMFILES(X86)"), - getEnv("LOCALAPPDATA"), - getEnv("PROGRAMW6432") - ) - } -) - -actual fun findOperaExecutable(): Path? = findBrowserExecutableCommon( - config = BrowserSearchConfig(searchWindowsProgramFiles = true), - pathSeparator = ";", - pathEnv = getEnv("PATH"), - executableNames = listOf("opera.exe"), - windowsProgramFilesSuffixes = listOf( - "Opera", - "Programs/Opera" - ), - windowsExecutableNames = listOf("opera.exe"), - windowsProgramFilesGetter = { - listOfNotNull( - getEnv("PROGRAMFILES"), - getEnv("PROGRAMFILES(X86)"), - getEnv("LOCALAPPDATA"), - getEnv("PROGRAMW6432") - ) - } -) - -actual fun findBraveExecutable(): Path? = findBrowserExecutableCommon( - config = BrowserSearchConfig(searchWindowsProgramFiles = true), - pathSeparator = ";", - pathEnv = getEnv("PATH"), - executableNames = listOf("brave.exe"), - windowsProgramFilesSuffixes = listOf( - "BraveSoftware/Brave-Browser/Application" - ), - windowsExecutableNames = listOf("brave.exe"), - windowsProgramFilesGetter = { - listOfNotNull( - getEnv("PROGRAMFILES"), - getEnv("PROGRAMFILES(X86)"), - getEnv("LOCALAPPDATA"), - getEnv("PROGRAMW6432") - ) - } -) - -actual fun findEdgeExecutable(): Path? = findBrowserExecutableCommon( - config = BrowserSearchConfig(searchWindowsProgramFiles = true), - pathSeparator = ";", - pathEnv = getEnv("PATH"), - executableNames = listOf("msedge.exe", "microsoft-edge.exe"), - windowsProgramFilesSuffixes = listOf( - "Microsoft/Edge/Application" - ), - windowsExecutableNames = listOf("msedge.exe"), - windowsProgramFilesGetter = { - listOfNotNull( - getEnv("PROGRAMFILES"), - getEnv("PROGRAMFILES(X86)"), - getEnv("LOCALAPPDATA"), - getEnv("PROGRAMW6432") - ) - } -) - @OptIn(ExperimentalForeignApi::class) actual fun freePort(): Int? { // TODO: Implement Windows-specific freePort using Winsock @@ -224,17 +143,6 @@ actual fun freePort(): Int? { return (49152..65535).random() } -actual fun decompressIfNeeded(data: ByteArray): ByteArray { - return when { - isGzipCompressed(data) -> { - // Gzip decompression not readily available in Kotlin/Native for Windows - throw UnsupportedOperationException("Gzip decompression not yet supported on Windows native") - } - - isZstdCompressed(data) -> { - throw UnsupportedOperationException("Zstandard decompression not yet supported on Windows native") - } - - else -> data - } +actual fun defaultBrowserSearchConfig(): BrowserSearchConfig { + return BrowserSearchConfig(";", searchWindowsProgramFiles = true) } diff --git a/core/src/mingwMain/kotlin/dev/kdriver/core/utils/Client.mingw.kt b/core/src/mingwMain/kotlin/dev/kdriver/core/connection/Client.mingw.kt similarity index 86% rename from core/src/mingwMain/kotlin/dev/kdriver/core/utils/Client.mingw.kt rename to core/src/mingwMain/kotlin/dev/kdriver/core/connection/Client.mingw.kt index 243129887..c63550e2f 100644 --- a/core/src/mingwMain/kotlin/dev/kdriver/core/utils/Client.mingw.kt +++ b/core/src/mingwMain/kotlin/dev/kdriver/core/connection/Client.mingw.kt @@ -1,4 +1,4 @@ -package dev.kdriver.core.utils +package dev.kdriver.core.connection import io.ktor.client.engine.* import io.ktor.client.engine.winhttp.* diff --git a/core/src/mingwMain/kotlin/dev/kdriver/core/network/Extensions.mingw.kt b/core/src/mingwMain/kotlin/dev/kdriver/core/network/Extensions.mingw.kt new file mode 100644 index 000000000..d6085a50d --- /dev/null +++ b/core/src/mingwMain/kotlin/dev/kdriver/core/network/Extensions.mingw.kt @@ -0,0 +1,11 @@ +package dev.kdriver.core.network + +actual fun ByteArray.decompressZstd(): ByteArray { + // Zstandard decompression not readily available in Kotlin/Native for Windows + throw UnsupportedOperationException("Zstandard decompression not yet supported on Windows native") +} + +actual fun ByteArray.decompressGzip(): ByteArray { + // Gzip decompression not readily available in Kotlin/Native for Windows + throw UnsupportedOperationException("Gzip decompression not yet supported on Windows native") +} diff --git a/core/src/mingwTest/kotlin/dev/kdriver/core/tab/TabTest.kt b/core/src/mingwTest/kotlin/dev/kdriver/core/tab/TabTest.kt new file mode 100644 index 000000000..da534b4a9 --- /dev/null +++ b/core/src/mingwTest/kotlin/dev/kdriver/core/tab/TabTest.kt @@ -0,0 +1,19 @@ +package dev.kdriver.core.tab + +import dev.kdriver.core.browser.createBrowser +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertTrue + +class TabTest { + + @Test + fun testGetContentGetsHtmlContent() = runBlocking { + val browser = createBrowser(this, headless = true, sandbox = false) + val tab = browser.get("https://example.com") + val content = tab.getContent() + assertTrue(content.lowercase().startsWith("")) + browser.stop() + } + +} diff --git a/core/src/posixMain/kotlin/dev/kdriver/core/browser/Process.posix.kt b/core/src/posixMain/kotlin/dev/kdriver/core/browser/Process.posix.kt new file mode 100644 index 000000000..1a78a6726 --- /dev/null +++ b/core/src/posixMain/kotlin/dev/kdriver/core/browser/Process.posix.kt @@ -0,0 +1,90 @@ +package dev.kdriver.core.browser + +import kotlinx.cinterop.* +import kotlinx.io.files.Path +import platform.posix.* +import kotlin.random.Random + +actual abstract class Process { + abstract val processIdentifier: Int + + actual fun isAlive(): Boolean { + // Check if process exists by sending signal 0 + return kill(processIdentifier, 0) == 0 + } + + actual fun pid(): Long { + return processIdentifier.toLong() + } + + actual abstract fun destroy() +} + +actual fun addShutdownHook(hook: suspend () -> Unit) { + // POSIX doesn't have a direct equivalent to Java shutdown hooks + // Could use atexit() but it doesn't support suspend functions + // For now, keeping it as no-op +} + +actual fun isPosix(): Boolean { + return true // POSIX-compliant platforms +} + +@OptIn(ExperimentalForeignApi::class) +actual fun isRoot(): Boolean { + return getuid() == 0u +} + +@OptIn(ExperimentalForeignApi::class) +actual fun getEnv(name: String): String? { + return getenv(name)?.toKString() +} + +@OptIn(ExperimentalForeignApi::class, UnsafeNumber::class) +actual fun freePort(): Int? { + return memScoped { + val sockfd = socket(AF_INET, SOCK_STREAM, 0) + if (sockfd < 0) return null + + try { + val addr = alloc() + memset(addr.ptr, 0, sizeOf().convert()) + addr.sin_family = AF_INET.convert() + addr.sin_addr.s_addr = 0x0100007Fu // 127.0.0.1 in network byte order (little-endian) + addr.sin_port = 0u // Let the OS choose a port + + if (bind(sockfd, addr.ptr.reinterpret(), sizeOf().convert()) < 0) { + return null + } + + val len = alloc() + len.value = sizeOf().convert() + + if (getsockname(sockfd, addr.ptr.reinterpret(), len.ptr) < 0) { + return null + } + + // Convert port from network byte order to host byte order + val portBytes = addr.sin_port + ((portBytes.toInt() shr 8) and 0xFF) or ((portBytes.toInt() and 0xFF) shl 8) + } finally { + close(sockfd) + } + } +} + +@OptIn(ExperimentalForeignApi::class) +actual fun exists(path: Path): Boolean { + return access(path.toString(), F_OK) == 0 +} + +@OptIn(ExperimentalForeignApi::class, UnsafeNumber::class) +actual fun tempProfileDir(): Path { + val tempDir = getenv("TMPDIR")?.toKString() ?: "/tmp" + val uniqueName = "kdriver_${Random.nextLong().toString(16)}" + val profilePath = "$tempDir/$uniqueName" + + mkdir(profilePath, 0x1FFu) // 0777 permissions + + return Path(profilePath) +}