Merge pull request #278 from mofeng-git/dev

Dev
This commit is contained in:
SilentWind
2026-07-19 21:50:59 +08:00
committed by GitHub
330 changed files with 15896 additions and 13600 deletions

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "one-kvm" name = "one-kvm"
version = "0.2.4" version = "0.2.5"
edition = "2021" edition = "2021"
authors = ["SilentWind"] authors = ["SilentWind"]
description = "A open and lightweight IP-KVM solution written in Rust" description = "A open and lightweight IP-KVM solution written in Rust"
@@ -27,6 +27,7 @@ desktop = [
"dep:anyhow", "dep:anyhow",
"dep:argon2", "dep:argon2",
"dep:rand", "dep:rand",
"dep:totp-rs",
"dep:uuid", "dep:uuid",
"dep:base64", "dep:base64",
"dep:nix", "dep:nix",
@@ -71,71 +72,9 @@ desktop = [
"dep:cpal", "dep:cpal",
"dep:windows-sys", "dep:windows-sys",
] ]
android = [
"dep:anyhow",
"dep:argon2",
"dep:arc-swap",
"dep:async-stream",
"dep:async-trait",
"dep:axum",
"dep:axum-extra",
"dep:base64",
"dep:bytemuck",
"dep:bytes",
"dep:futures",
"dep:gpio-cdev",
"dep:hwcodec",
"dep:libc",
"dep:libyuv",
"dep:mime_guess",
"dep:nix",
"dep:parking_lot",
"dep:protobuf",
"dep:rand",
"dep:rcgen",
"dep:reqwest",
"dep:rtp",
"dep:rtsp-types",
"dep:rust-embed",
"dep:rustls",
"dep:sdp-types",
"dep:serde",
"dep:serde_json",
"dep:toml_edit",
"dep:serialport",
"dep:sha2",
"dep:sodiumoxide",
"dep:des",
"dep:sqlx",
"dep:alsa",
"dep:audiopus",
"dep:thiserror",
"dep:time",
"dep:tempfile",
"dep:tokio",
"dep:tokio-tungstenite",
"dep:tokio-util",
"dep:axum-server",
"dep:tower-http",
"dep:tracing",
"dep:tracing-log",
"dep:tracing-subscriber",
"dep:turbojpeg",
"dep:typeshare",
"dep:urlencoding",
"dep:uuid",
"dep:ventoy-img",
"dep:v4l2r",
"dep:webrtc",
"dep:xxhash-rust",
]
android-mediacodec = [
"android",
]
[dependencies] [dependencies]
# Async runtime # Async runtime
tokio = { version = "1", features = ["full"], optional = true } tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"], optional = true }
tokio-util = { version = "0.7", features = ["rt"], optional = true } tokio-util = { version = "0.7", features = ["rt"], optional = true }
# Web framework # Web framework
@@ -144,7 +83,7 @@ axum-extra = { version = "0.12", features = ["cookie"], optional = true }
tower-http = { version = "0.6", features = ["cors", "trace", "set-header"], optional = true } tower-http = { version = "0.6", features = ["cors", "trace", "set-header"], optional = true }
# Database - Use bundled SQLite for static linking # Database - Use bundled SQLite for static linking
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"], optional = true } sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "sqlite-bundled"], optional = true }
# Serialization # Serialization
serde = { version = "1", features = ["derive"], optional = true } serde = { version = "1", features = ["derive"], optional = true }
@@ -153,7 +92,6 @@ toml_edit = { version = "0.25", optional = true }
# Logging # Logging
tracing = { version = "0.1", optional = true } tracing = { version = "0.1", optional = true }
tracing-log = { version = "0.2", optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "tracing-log"], optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "tracing-log"], optional = true }
# Error handling # Error handling
@@ -162,7 +100,8 @@ anyhow = { version = "1", optional = true }
# Authentication # Authentication
argon2 = { version = "0.5", optional = true } argon2 = { version = "0.5", optional = true }
rand = { version = "0.9", optional = true } rand = { version = "0.10", optional = true }
totp-rs = { version = "5.7", features = ["gen_secret", "otpauth", "zeroize"], optional = true }
# Utilities # Utilities
uuid = { version = "1", features = ["v4", "serde"], optional = true } uuid = { version = "1", features = ["v4", "serde"], optional = true }
@@ -171,7 +110,7 @@ tempfile = { version = "3", optional = true }
# HTTP client (for URL downloads) # HTTP client (for URL downloads)
# Use rustls by default, but allow native-tls for systems with older GLIBC # Use rustls by default, but allow native-tls for systems with older GLIBC
reqwest = { version = "0.13", features = ["stream", "rustls", "json"], default-features = false, optional = true } reqwest = { version = "0.13", features = ["stream", "rustls-no-provider", "json"], default-features = false, optional = true }
urlencoding = { version = "2", optional = true } urlencoding = { version = "2", optional = true }
# Static file embedding # Static file embedding
@@ -179,9 +118,9 @@ rust-embed = { version = "8", features = ["compression", "debug-embed"], optiona
mime_guess = { version = "2", optional = true } mime_guess = { version = "2", optional = true }
# TLS/HTTPS # TLS/HTTPS
rustls = { version = "0.23", features = ["ring"], optional = true } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"], optional = true }
rcgen = { version = "0.14", optional = true } rcgen = { version = "0.14", optional = true }
axum-server = { version = "0.8", features = ["tls-rustls"], optional = true } axum-server = { version = "0.8", features = ["tls-rustls-no-provider"], optional = true }
# CLI argument parsing # CLI argument parsing
clap = { version = "4", features = ["derive"], optional = true } clap = { version = "4", features = ["derive"], optional = true }
@@ -201,15 +140,15 @@ async-stream = { version = "0.3", optional = true }
futures = { version = "0.3", optional = true } futures = { version = "0.3", optional = true }
# WebSocket client (for ttyd proxy) # WebSocket client (for ttyd proxy)
tokio-tungstenite = { version = "0.28", optional = true } tokio-tungstenite = { version = "0.29", optional = true }
# High-performance synchronization # High-performance synchronization
parking_lot = { version = "0.12", optional = true } parking_lot = { version = "0.12", optional = true }
arc-swap = { version = "1.8", optional = true } arc-swap = { version = "1.8", optional = true }
# WebRTC # WebRTC
webrtc = { version = "0.14", optional = true } webrtc = { version = "0.17", optional = true }
rtp = { version = "0.14", optional = true } rtp = { version = "0.17", optional = true }
rtsp-types = { version = "0.1", optional = true } rtsp-types = { version = "0.1", optional = true }
sdp-types = { version = "0.1", optional = true } sdp-types = { version = "0.1", optional = true }
@@ -224,12 +163,12 @@ ventoy-img = { path = "libs/ventoy-img-rs", optional = true }
# RustDesk protocol support # RustDesk protocol support
protobuf = { version = "3.7", features = ["with-bytes"], optional = true } protobuf = { version = "3.7", features = ["with-bytes"], optional = true }
sodiumoxide = { version = "0.2", optional = true } sodiumoxide = { version = "0.2", optional = true }
des = { version = "0.8", optional = true } des = { version = "0.9", optional = true }
sha2 = { version = "0.10", optional = true } sha2 = { version = "0.11", optional = true }
# TypeScript type generation # TypeScript type generation
typeshare = { version = "1.0", optional = true } typeshare = { version = "1.0", optional = true }
[target.'cfg(any(unix, windows))'.dependencies] [target.'cfg(any(target_os = "linux", windows))'.dependencies]
# Video encoding/decoding (FFmpeg/libjpeg-turbo/libyuv; available on Windows and Linux) # Video encoding/decoding (FFmpeg/libjpeg-turbo/libyuv; available on Windows and Linux)
hwcodec = { path = "libs/hwcodec", features = ["bytes"], optional = true } hwcodec = { path = "libs/hwcodec", features = ["bytes"], optional = true }
libyuv = { path = "res/vcpkg/libyuv", optional = true } libyuv = { path = "res/vcpkg/libyuv", optional = true }
@@ -237,15 +176,10 @@ turbojpeg = { version = "1.3", optional = true }
# Note: audiopus links to libopus.so (unavoidable for audio support) # Note: audiopus links to libopus.so (unavoidable for audio support)
audiopus = { version = "0.2", optional = true } audiopus = { version = "0.2", optional = true }
[target.'cfg(all(unix, not(target_os = "android")))'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
# Utilities # Utilities
nix = { version = "0.30", default-features = false, features = ["fs", "socket", "net", "hostname", "poll"], optional = true } nix = { version = "0.31", default-features = false, features = ["fs", "socket", "net", "hostname", "poll"], optional = true }
[target.'cfg(target_os = "android")'.dependencies]
# Utilities
nix = { version = "0.30", default-features = false, features = ["fs", "socket", "hostname", "poll"], optional = true }
[target.'cfg(unix)'.dependencies]
# Video capture (V4L2) # Video capture (V4L2)
v4l2r = { path = "libs/v4l2r", optional = true } v4l2r = { path = "libs/v4l2r", optional = true }
@@ -256,7 +190,7 @@ alsa = { version = "0.11", optional = true }
gpio-cdev = { version = "0.6", optional = true } gpio-cdev = { version = "0.6", optional = true }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
cpal = { version = "0.17", default-features = false, optional = true } cpal = { version = "0.18", default-features = false, optional = true }
windows-sys = { version = "0.61", features = [ windows-sys = { version = "0.61", features = [
"Win32_Foundation", "Win32_Foundation",
"Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_IpHelper",
@@ -266,9 +200,6 @@ windows-sys = { version = "0.61", features = [
"Win32_System_Threading", "Win32_System_Threading",
], optional = true } ], optional = true }
[dev-dependencies]
tempfile = "3"
[build-dependencies] [build-dependencies]
protobuf-codegen = "3.7" protobuf-codegen = "3.7"

7
android/.gitignore vendored
View File

@@ -1,7 +0,0 @@
.gradle/
.kotlin/
build/
local.properties
app/build/
app/src/main/jniLibs/
native/target/

View File

@@ -1,559 +0,0 @@
import org.gradle.api.tasks.Exec
import java.security.MessageDigest
import java.util.Properties
plugins {
id("com.android.application")
}
val androidNdkVersion = "27.3.13750724"
val androidApiLevel = 21
val nativeCrateDir = layout.projectDirectory.dir("../native")
val rootCrateDir = layout.projectDirectory.dir("../..")
val nativeCargoOutputDir = layout.buildDirectory.dir("generated/oneKvm/cargoJniLibs")
val nativeOutputRoot = layout.buildDirectory.dir("generated/oneKvm/jniLibs")
val nativeAssetRoot = layout.buildDirectory.dir("generated/oneKvm/assets")
val defaultAndroidFfmpegRoot = rootProject.layout.projectDirectory.dir("../dist/android-ffmpeg-mediacodec")
val defaultAndroidLibyuvRoot = rootProject.layout.projectDirectory.dir("../dist/android-libyuv")
val defaultAndroidTurbojpegRoot = rootProject.layout.projectDirectory.dir("../dist/android-turbojpeg")
val defaultAndroidAlsaRoot = rootProject.layout.projectDirectory.dir("../dist/android-alsa")
val defaultAndroidOpusRoot = rootProject.layout.projectDirectory.dir("../dist/android-opus")
val androidFfmpegRoot = providers.environmentVariable("ONE_KVM_ANDROID_FFMPEG_ROOT")
.orElse(defaultAndroidFfmpegRoot.asFile.absolutePath)
val androidLibyuvRoot = providers.environmentVariable("ONE_KVM_ANDROID_LIBYUV_ROOT")
.orElse(defaultAndroidLibyuvRoot.asFile.absolutePath)
val androidTurbojpegRoot = providers.environmentVariable("ONE_KVM_ANDROID_TURBOJPEG_ROOT")
.orElse(defaultAndroidTurbojpegRoot.asFile.absolutePath)
val androidAlsaRoot = providers.environmentVariable("ONE_KVM_ANDROID_ALSA_ROOT")
.orElse(defaultAndroidAlsaRoot.asFile.absolutePath)
val androidOpusRoot = providers.environmentVariable("ONE_KVM_ANDROID_OPUS_ROOT")
.orElse(defaultAndroidOpusRoot.asFile.absolutePath)
val selectedAndroidAbis = providers.environmentVariable("ONE_KVM_ANDROID_ABIS")
.orElse("arm64-v8a,armeabi-v7a")
.get()
.split(',', ' ', ';')
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
val androidBuildProfile = providers.environmentVariable("ONE_KVM_ANDROID_PROFILE")
.orElse("debug")
.get()
.lowercase()
val oneKvmVersion = Regex("""(?m)^version\s*=\s*"([^"]+)"""")
.find(rootCrateDir.file("Cargo.toml").asFile.readText())
?.groupValues
?.get(1)
?: throw GradleException("Failed to resolve version from root Cargo.toml")
val localProperties = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) {
file.inputStream().use { load(it) }
}
}
val androidSdkDir = file(
providers.environmentVariable("ANDROID_HOME")
.orElse(providers.environmentVariable("ANDROID_SDK_ROOT"))
.orElse(localProperties.getProperty("sdk.dir") ?: "/root/android-sdk")
.get(),
)
val androidNdkDir = androidSdkDir.resolve("ndk/$androidNdkVersion")
val androidFfmpegBuildScript = rootProject.layout.projectDirectory
.dir("..")
.file("scripts/build-android-ffmpeg-mediacodec.sh")
val androidLibyuvBuildScript = rootProject.layout.projectDirectory
.dir("..")
.file("scripts/build-android-libyuv.sh")
val androidTurbojpegBuildScript = rootProject.layout.projectDirectory
.dir("..")
.file("scripts/build-android-turbojpeg.sh")
val androidAlsaBuildScript = rootProject.layout.projectDirectory
.dir("..")
.file("scripts/build-android-alsa.sh")
val androidOpusBuildScript = rootProject.layout.projectDirectory
.dir("..")
.file("scripts/build-android-opus.sh")
val androidAbiTargets = mapOf(
"arm64-v8a" to Triple("arm64", "aarch64-linux-android", "aarch64-linux-android"),
"armeabi-v7a" to Triple("arm32", "armv7-linux-androideabi", "arm-linux-androideabi"),
)
val selectedAndroidAbiTargets = selectedAndroidAbis.associateWith { abi ->
androidAbiTargets[abi] ?: throw GradleException(
"Unsupported ONE_KVM_ANDROID_ABIS entry: $abi. Supported values: ${androidAbiTargets.keys.joinToString(", ")}",
)
}
if (androidBuildProfile != "debug" && androidBuildProfile != "release") {
throw GradleException("Unsupported ONE_KVM_ANDROID_PROFILE: $androidBuildProfile. Use debug or release.")
}
fun androidFfmpegBuildStamp(script: File): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(script.readBytes())
.joinToString("") { "%02x".format(it) }
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest"
}
fun androidFfmpegRequiredFiles(root: File): List<File> = listOf(
"include/libavcodec/avcodec.h",
"lib/libavcodec.a",
"lib/libavutil.a",
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
fun androidLibyuvBuildStamp(script: File): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(script.readBytes())
.joinToString("") { "%02x".format(it) }
val turbojpegScriptDigest = MessageDigest.getInstance("SHA-256")
.digest(androidTurbojpegBuildScript.asFile.readBytes())
.joinToString("") { "%02x".format(it) }
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest;turbojpegScript=$turbojpegScriptDigest"
}
fun androidLibyuvRequiredFiles(root: File): List<File> = listOf(
"include/libyuv.h",
"lib/libyuv.a",
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
fun androidTurbojpegBuildStamp(script: File): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(script.readBytes())
.joinToString("") { "%02x".format(it) }
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest"
}
fun androidAlsaBuildStamp(script: File): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(script.readBytes())
.joinToString("") { "%02x".format(it) }
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest"
}
fun androidOpusBuildStamp(script: File): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(script.readBytes())
.joinToString("") { "%02x".format(it) }
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest"
}
fun androidTurbojpegRequiredFiles(root: File): List<File> = listOf(
"include/turbojpeg.h",
"include/jpeglib.h",
"lib/libjpeg.a",
"lib/libturbojpeg.a",
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
fun androidAlsaRequiredFiles(root: File): List<File> = listOf(
"include/alsa/asoundlib.h",
"lib/libasound.so",
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
fun androidOpusRequiredFiles(root: File): List<File> = listOf(
"include/opus/opus.h",
"lib/libopus.so",
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
android {
namespace = "cn.one_kvm.androidhost"
compileSdk = 36
ndkVersion = androidNdkVersion
flavorDimensions += "abi"
defaultConfig {
applicationId = "cn.one_kvm.androidhost"
minSdk = androidApiLevel
targetSdk = 36
versionCode = 1
versionName = oneKvmVersion
}
productFlavors {
create("arm32") {
dimension = "abi"
ndk {
abiFilters += "armeabi-v7a"
}
}
create("arm64") {
dimension = "abi"
ndk {
abiFilters += "arm64-v8a"
}
}
}
sourceSets {
getByName("main") {
assets.directories.clear()
jniLibs.directories.clear()
}
getByName("arm32") {
assets.directories.add("build/generated/oneKvm/assets/arm32")
jniLibs.directories.add("build/generated/oneKvm/jniLibs/arm32")
}
getByName("arm64") {
assets.directories.add("build/generated/oneKvm/assets/arm64")
jniLibs.directories.add("build/generated/oneKvm/jniLibs/arm64")
}
}
}
tasks.register<Exec>("buildAndroidFfmpegMediaCodec") {
description = "Builds the default Android FFmpeg MediaCodec static libraries."
group = "build"
val ffmpegRoot = file(androidFfmpegRoot.get())
val scriptFile = androidFfmpegBuildScript.asFile
val stampFile = ffmpegRoot.resolve(".one-kvm-android-ffmpeg.stamp")
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
commandLine(
"bash",
scriptFile.absolutePath,
"--output",
ffmpegRoot.absolutePath,
"--ndk",
androidNdkDir.absolutePath,
"--api",
androidApiLevel.toString(),
"--abis",
selectedAndroidAbis.joinToString(","),
)
inputs.file(scriptFile)
outputs.dir(ffmpegRoot)
onlyIf {
val hasAndroidFfmpeg = androidFfmpegRequiredFiles(ffmpegRoot).all { it.exists() }
val hasCurrentBuildStamp =
stampFile.exists() && stampFile.readText() == androidFfmpegBuildStamp(scriptFile)
!hasAndroidFfmpeg || !hasCurrentBuildStamp
}
doLast {
stampFile.writeText(androidFfmpegBuildStamp(scriptFile))
}
}
tasks.register<Exec>("buildAndroidLibyuv") {
description = "Builds Android libyuv static libraries."
group = "build"
val libyuvRoot = file(androidLibyuvRoot.get())
val turbojpegRoot = file(androidTurbojpegRoot.get())
val scriptFile = androidLibyuvBuildScript.asFile
val stampFile = libyuvRoot.resolve(".one-kvm-android-libyuv.stamp")
dependsOn("buildAndroidTurbojpeg")
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
commandLine(
"bash",
scriptFile.absolutePath,
"--output",
libyuvRoot.absolutePath,
"--ndk",
androidNdkDir.absolutePath,
"--api",
androidApiLevel.toString(),
"--abis",
selectedAndroidAbis.joinToString(","),
"--jpeg-root",
turbojpegRoot.absolutePath,
)
inputs.file(scriptFile)
outputs.dir(libyuvRoot)
onlyIf {
val hasAndroidLibyuv = androidLibyuvRequiredFiles(libyuvRoot).all { it.exists() }
val hasCurrentBuildStamp =
stampFile.exists() && stampFile.readText() == androidLibyuvBuildStamp(scriptFile)
!hasAndroidLibyuv || !hasCurrentBuildStamp
}
doLast {
stampFile.writeText(androidLibyuvBuildStamp(scriptFile))
}
}
tasks.register<Exec>("buildAndroidTurbojpeg") {
description = "Builds Android TurboJPEG static libraries."
group = "build"
val turbojpegRoot = file(androidTurbojpegRoot.get())
val scriptFile = androidTurbojpegBuildScript.asFile
val stampFile = turbojpegRoot.resolve(".one-kvm-android-turbojpeg.stamp")
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
commandLine(
"bash",
scriptFile.absolutePath,
"--output",
turbojpegRoot.absolutePath,
"--ndk",
androidNdkDir.absolutePath,
"--api",
androidApiLevel.toString(),
"--abis",
selectedAndroidAbis.joinToString(","),
)
inputs.file(scriptFile)
outputs.dir(turbojpegRoot)
onlyIf {
val hasAndroidTurbojpeg = androidTurbojpegRequiredFiles(turbojpegRoot).all { it.exists() }
val hasCurrentBuildStamp =
stampFile.exists() && stampFile.readText() == androidTurbojpegBuildStamp(scriptFile)
!hasAndroidTurbojpeg || !hasCurrentBuildStamp
}
doLast {
stampFile.writeText(androidTurbojpegBuildStamp(scriptFile))
}
}
tasks.register<Exec>("buildAndroidAlsa") {
description = "Builds Android ALSA shared libraries."
group = "build"
val alsaRoot = file(androidAlsaRoot.get())
val scriptFile = androidAlsaBuildScript.asFile
val stampFile = alsaRoot.resolve(".one-kvm-android-alsa.stamp")
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
commandLine(
"bash",
scriptFile.absolutePath,
"--output",
alsaRoot.absolutePath,
"--ndk",
androidNdkDir.absolutePath,
"--api",
androidApiLevel.toString(),
"--abis",
selectedAndroidAbis.joinToString(","),
)
inputs.file(scriptFile)
outputs.dir(alsaRoot)
onlyIf {
val hasAndroidAlsa = androidAlsaRequiredFiles(alsaRoot).all { it.exists() }
val hasCurrentBuildStamp =
stampFile.exists() && stampFile.readText() == androidAlsaBuildStamp(scriptFile)
!hasAndroidAlsa || !hasCurrentBuildStamp
}
doLast {
stampFile.writeText(androidAlsaBuildStamp(scriptFile))
}
}
tasks.register<Exec>("buildAndroidOpus") {
description = "Builds Android Opus shared libraries."
group = "build"
val opusRoot = file(androidOpusRoot.get())
val scriptFile = androidOpusBuildScript.asFile
val stampFile = opusRoot.resolve(".one-kvm-android-opus.stamp")
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
commandLine(
"bash",
scriptFile.absolutePath,
"--output",
opusRoot.absolutePath,
"--ndk",
androidNdkDir.absolutePath,
"--api",
androidApiLevel.toString(),
"--abis",
selectedAndroidAbis.joinToString(","),
)
inputs.file(scriptFile)
outputs.dir(opusRoot)
onlyIf {
val hasAndroidOpus = androidOpusRequiredFiles(opusRoot).all { it.exists() }
val hasCurrentBuildStamp =
stampFile.exists() && stampFile.readText() == androidOpusBuildStamp(scriptFile)
!hasAndroidOpus || !hasCurrentBuildStamp
}
doLast {
stampFile.writeText(androidOpusBuildStamp(scriptFile))
}
}
val cargoBuildAndroidAbiTaskNames = selectedAndroidAbiTargets.map { (abi, targets) ->
val (flavor, _, _) = targets
val taskName = "cargoBuildAndroid" + flavor.replaceFirstChar {
if (it.isLowerCase()) it.titlecase() else it.toString()
}
tasks.register<Exec>(taskName) {
description = "Builds the Android Rust bootstrap libraries for $abi."
group = "build"
dependsOn(
"buildAndroidFfmpegMediaCodec",
"buildAndroidLibyuv",
"buildAndroidTurbojpeg",
"buildAndroidAlsa",
"buildAndroidOpus",
)
val cargoCommand = mutableListOf(
"cargo",
"ndk",
"-t",
abi,
"-P",
androidApiLevel.toString(),
"-o",
nativeCargoOutputDir.get().asFile.absolutePath,
"build",
"--lib",
"--bins",
)
if (androidBuildProfile == "release") {
cargoCommand.add("--release")
}
workingDir = nativeCrateDir.asFile
commandLine(cargoCommand)
args("--features", "android-mediacodec")
environment("ONE_KVM_ANDROID_FFMPEG_ROOT", androidFfmpegRoot.get())
environment("ONE_KVM_ANDROID_LIBYUV_ROOT", androidLibyuvRoot.get())
environment("ONE_KVM_ANDROID_LIBYUV_STATIC", "1")
environment("TURBOJPEG_SOURCE", "explicit")
environment("TURBOJPEG_STATIC", "1")
environment(
"TURBOJPEG_LIB_DIR",
file(androidTurbojpegRoot.get()).resolve("$abi/lib").absolutePath,
)
environment(
"TURBOJPEG_INCLUDE_DIR",
file(androidTurbojpegRoot.get()).resolve("$abi/include").absolutePath,
)
environment("PKG_CONFIG_ALLOW_CROSS", "1")
environment(
"PKG_CONFIG_LIBDIR",
file(androidAlsaRoot.get()).resolve("$abi/lib/pkgconfig").absolutePath,
)
environment("PKG_CONFIG_SYSROOT_DIR", "")
environment("LIBOPUS_NO_PKG", "1")
environment("LIBOPUS_LIB_DIR", file(androidOpusRoot.get()).resolve("$abi/lib").absolutePath)
environment("ANDROID_HOME", androidSdkDir.absolutePath)
environment("ANDROID_SDK_ROOT", androidSdkDir.absolutePath)
environment("ANDROID_NDK_HOME", androidNdkDir.absolutePath)
environment("ANDROID_NDK", androidNdkDir.absolutePath)
environment("ANDROID_NDK_ROOT", androidNdkDir.absolutePath)
inputs.files(
nativeCrateDir.file("Cargo.toml"),
nativeCrateDir.dir("src"),
rootCrateDir.file("Cargo.lock"),
rootCrateDir.file("Cargo.toml"),
rootCrateDir.file("build.rs"),
rootCrateDir.dir("libs"),
rootCrateDir.dir("res/vcpkg/libyuv"),
rootCrateDir.dir("src"),
)
outputs.dir(nativeCargoOutputDir)
outputs.dir(file(androidFfmpegRoot.get()))
outputs.dir(file(androidLibyuvRoot.get()))
outputs.dir(file(androidTurbojpegRoot.get()))
outputs.dir(file(androidAlsaRoot.get()))
outputs.dir(file(androidOpusRoot.get()))
}
taskName
}
tasks.register("cargoBuildAndroid") {
description = "Builds the Android Rust bootstrap libraries."
group = "build"
dependsOn(cargoBuildAndroidAbiTaskNames)
outputs.dir(nativeOutputRoot)
outputs.dir(nativeAssetRoot)
doLast {
selectedAndroidAbiTargets.forEach { (abi, targets) ->
val (flavor, rustTriple, ndkTriple) = targets
val nativeLibSource = nativeCargoOutputDir.get().file("$abi/libone_kvm_android_bootstrap.so").asFile
if (!nativeLibSource.exists()) {
throw GradleException("Missing Android JNI library: ${nativeLibSource.absolutePath}")
}
copy {
from(nativeLibSource)
into(nativeOutputRoot.get().dir(flavor).dir(abi))
}
val source = nativeCrateDir.file("target/$rustTriple/$androidBuildProfile/one-kvm-android-host").asFile
if (!source.exists()) {
throw GradleException("Missing Android host binary: ${source.absolutePath}")
}
copy {
from(source)
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi"))
rename { "one-kvm-android-host" }
}
val cxxShared = androidNdkDir
.resolve("toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/$ndkTriple/libc++_shared.so")
if (!cxxShared.exists()) {
throw GradleException("Missing NDK libc++_shared.so: ${cxxShared.absolutePath}")
}
copy {
from(cxxShared)
into(nativeOutputRoot.get().dir(flavor).dir(abi))
}
copy {
from(cxxShared)
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi"))
}
val alsaShared = file(androidAlsaRoot.get()).resolve("$abi/lib/libasound.so")
if (!alsaShared.exists()) {
throw GradleException("Missing Android ALSA library: ${alsaShared.absolutePath}")
}
copy {
from(alsaShared)
into(nativeOutputRoot.get().dir(flavor).dir(abi))
}
copy {
from(alsaShared)
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi"))
}
copy {
from(file(androidAlsaRoot.get()).resolve("$abi/share/alsa"))
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi/alsa"))
}
val opusShared = file(androidOpusRoot.get()).resolve("$abi/lib/libopus.so")
if (!opusShared.exists()) {
throw GradleException("Missing Android Opus library: ${opusShared.absolutePath}")
}
copy {
from(opusShared)
into(nativeOutputRoot.get().dir(flavor).dir(abi))
}
copy {
from(opusShared)
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi"))
}
}
}
}
tasks.named("preBuild") {
dependsOn("cargoBuildAndroid")
}

View File

@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:allowBackup="false"
android:icon="@drawable/ic_launcher_one_kvm"
android:label="@string/app_name"
android:theme="@style/AppTheme">
<service
android:name=".OneKvmService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
<receiver
android:name=".BootReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -1,13 +0,0 @@
package cn.one_kvm.androidhost
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED && HostSettings.getAutoStart(context)) {
OneKvmService.start(context)
}
}
}

View File

@@ -1,33 +0,0 @@
package cn.one_kvm.androidhost
import android.content.Context
object HostSettings {
private const val PREFS = "one_kvm_android"
private const val KEY_AUTO_START = "auto_start"
private const val KEY_CLEAR_EXISTING_OTG = "clear_existing_otg"
fun getAutoStart(context: Context): Boolean {
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getBoolean(KEY_AUTO_START, false)
}
fun setAutoStart(context: Context, enabled: Boolean) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_AUTO_START, enabled)
.apply()
}
fun getClearExistingOtg(context: Context): Boolean {
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getBoolean(KEY_CLEAR_EXISTING_OTG, false)
}
fun setClearExistingOtg(context: Context, enabled: Boolean) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_CLEAR_EXISTING_OTG, enabled)
.apply()
}
}

View File

@@ -1,30 +0,0 @@
package cn.one_kvm.androidhost
import android.content.Context
object LogConfig {
private const val PREFS = "one_kvm_android"
private const val KEY_LOG_LEVEL = "log_level"
const val DEFAULT_LEVEL = "info"
val LEVELS = arrayOf("error", "warn", "info", "debug", "trace")
fun getLevel(context: Context): String {
val value = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_LOG_LEVEL, DEFAULT_LEVEL)
?: DEFAULT_LEVEL
return if (LEVELS.contains(value)) value else DEFAULT_LEVEL
}
fun setLevel(context: Context, level: String) {
val safeLevel = if (LEVELS.contains(level)) level else DEFAULT_LEVEL
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(KEY_LOG_LEVEL, safeLevel)
.apply()
}
fun rustLogFilter(level: String): String {
val safeLevel = if (LEVELS.contains(level)) level else DEFAULT_LEVEL
return "one_kvm=$safeLevel,hwcodec=$safeLevel,tower_http=$safeLevel,webrtc_sctp=warn"
}
}

View File

@@ -1,71 +0,0 @@
package cn.one_kvm.androidhost
import android.content.Context
import java.io.File
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
object LogStore {
private const val FLUSH_DELAY_MS = 250L
private const val MAX_BUFFER_CHARS = 64 * 1024
private val lock = Any()
private val buffer = StringBuilder()
private val executor = Executors.newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "OneKvmLogStore").apply { isDaemon = true }
}
private var logFile: File? = null
private var flushScheduled = false
fun defaultLogFile(context: Context): File {
return File(File(context.getExternalFilesDir(null), "runtime"), "one-kvm.log")
}
fun configure(file: File) {
synchronized(lock) {
flushLocked()
file.parentFile?.mkdirs()
file.writeText("")
buffer.clear()
logFile = file
flushScheduled = false
}
}
fun append(line: String) {
synchronized(lock) {
if (logFile == null) return
buffer.append(line).append('\n')
if (buffer.length >= MAX_BUFFER_CHARS) {
flushLocked()
return
}
if (!flushScheduled) {
flushScheduled = true
executor.schedule({ flush() }, FLUSH_DELAY_MS, TimeUnit.MILLISECONDS)
}
}
}
fun flush() {
synchronized(lock) {
flushLocked()
}
}
private fun flushLocked() {
val file = logFile ?: return
if (buffer.isEmpty()) {
flushScheduled = false
return
}
val text = buffer.toString()
buffer.clear()
flushScheduled = false
file.appendText(text)
}
}

View File

@@ -1,452 +0,0 @@
package cn.one_kvm.androidhost
import android.app.Activity
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.CompoundButton
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.Spinner
import android.widget.Switch
import android.widget.TextView
import java.net.Inet4Address
import java.net.InetSocketAddress
import java.net.NetworkInterface
import java.net.Socket
import java.util.Collections
class MainActivity : Activity() {
private lateinit var statusValue: TextView
private lateinit var hostActionButton: Button
private lateinit var logLevelSpinner: Spinner
private lateinit var autoStartSwitch: Switch
private lateinit var clearOtgSwitch: Switch
private val statusHandler = Handler(Looper.getMainLooper())
private var statusPollsRemaining = 0
private val statusPoller = object : Runnable {
override fun run() {
refreshStatus()
statusPollsRemaining -= 1
if (statusPollsRemaining > 0) {
statusHandler.postDelayed(this, STATUS_POLL_INTERVAL_MS)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.statusBarColor = color("#F8FAFC")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
window.navigationBarColor = color("#F8FAFC")
}
val content = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(20.dp(), 24.dp(), 20.dp(), 28.dp())
background = solid("#F8FAFC")
}
content.addView(startCard())
content.addView(settingsCard())
content.addView(infoCard())
setContentView(ScrollView(this).apply {
isFillViewport = true
setBackgroundColor(color("#F8FAFC"))
addView(content)
})
}
override fun onResume() {
super.onResume()
reconcilePersistedStatus()
refreshStatus()
autoStartSwitch.isChecked = HostSettings.getAutoStart(this)
clearOtgSwitch.isChecked = HostSettings.getClearExistingOtg(this)
}
override fun onPause() {
statusHandler.removeCallbacks(statusPoller)
super.onPause()
}
private fun startCard(): View {
return card {
addView(sectionTitle("启动管理"))
addView(TextView(this@MainActivity).apply {
text = "管理本机 One-KVM 服务进程。暂停会停止前台服务并释放运行资源。"
textSize = 14f
setTextColor(color("#64748B"))
setPadding(0, 6.dp(), 0, 14.dp())
})
statusValue = TextView(this@MainActivity).apply {
textSize = 14f
typeface = Typeface.DEFAULT_BOLD
setTextColor(color("#0F172A"))
background = rounded("#EFF6FF", "#BFDBFE", 8)
setPadding(12.dp(), 8.dp(), 12.dp(), 8.dp())
}
addView(statusValue, matchWrap())
addView(LinearLayout(this@MainActivity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(0, 14.dp(), 0, 0)
hostActionButton = actionButton("启动", primary = true) { toggleHost() }
addView(hostActionButton, matchButton())
})
refreshStatus()
}
}
private fun settingsCard(): View {
return card {
addView(sectionTitle("运行设置"))
val (autoStartRow, autoStartControl) = settingSwitchRow(
title = "开机自启动",
subtitle = "系统启动完成后自动拉起 One-KVM 前台服务。",
checked = HostSettings.getAutoStart(this@MainActivity),
) { _, checked ->
HostSettings.setAutoStart(this@MainActivity, checked)
LogStore.append("Boot auto-start ${if (checked) "enabled" else "disabled"}")
}
autoStartSwitch = autoStartControl
addView(autoStartRow)
addView(divider())
val (clearOtgRow, clearOtgControl) = settingSwitchRow(
title = "清除已有 OTG Gadget",
subtitle = "启动 root host 前尝试解绑并删除 configfs 中已有的 USB gadget。",
checked = HostSettings.getClearExistingOtg(this@MainActivity),
) { _, checked ->
HostSettings.setClearExistingOtg(this@MainActivity, checked)
LogStore.append("Clear existing OTG gadget ${if (checked) "enabled" else "disabled"}")
}
clearOtgSwitch = clearOtgControl
addView(clearOtgRow)
addView(divider())
addView(logLevelRow())
}
}
private fun infoCard(): View {
return card {
addView(sectionTitle("应用信息"))
addView(infoRow("软件内核版本", kernelVersion()))
addView(infoRow("访问地址", accessAddresses(), selectable = true))
addView(infoRow("日志文件", LogStore.defaultLogFile(this@MainActivity).absolutePath, selectable = true))
}
}
private fun settingSwitchRow(
title: String,
subtitle: String,
checked: Boolean,
listener: CompoundButton.OnCheckedChangeListener,
): Pair<View, Switch> {
val switch = Switch(this).apply {
isChecked = checked
setOnCheckedChangeListener(listener)
}
val row = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(0, 12.dp(), 0, 12.dp())
addView(LinearLayout(this@MainActivity).apply {
orientation = LinearLayout.VERTICAL
addView(TextView(this@MainActivity).apply {
text = title
textSize = 15f
typeface = Typeface.DEFAULT_BOLD
setTextColor(color("#0F172A"))
})
addView(TextView(this@MainActivity).apply {
text = subtitle
textSize = 13f
setTextColor(color("#64748B"))
setPadding(0, 4.dp(), 12.dp(), 0)
})
}, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
addView(switch)
}
return row to switch
}
private fun infoRow(label: String, value: String, selectable: Boolean = false): View {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, 12.dp(), 0, 12.dp())
addView(TextView(this@MainActivity).apply {
text = label
textSize = 13f
setTextColor(color("#64748B"))
})
addView(TextView(this@MainActivity).apply {
text = value
textSize = 15f
setTextColor(color("#0F172A"))
setPadding(0, 4.dp(), 0, 0)
setTextIsSelectable(selectable)
})
addView(divider())
}
}
private fun logLevelRow(): View {
return LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(0, 12.dp(), 0, 0)
addView(TextView(this@MainActivity).apply {
text = "日志级别"
textSize = 15f
typeface = Typeface.DEFAULT_BOLD
setTextColor(color("#0F172A"))
}, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
logLevelSpinner = Spinner(this@MainActivity).apply {
adapter = ArrayAdapter(
this@MainActivity,
android.R.layout.simple_spinner_dropdown_item,
LogConfig.LEVELS,
)
setSelection(LogConfig.LEVELS.indexOf(LogConfig.getLevel(this@MainActivity)).coerceAtLeast(0))
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
val level = LogConfig.LEVELS[position]
if (level != LogConfig.getLevel(this@MainActivity)) {
LogConfig.setLevel(this@MainActivity, level)
LogStore.append("Log level set to $level; restart service to apply")
}
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}
}
addView(logLevelSpinner)
}
}
private fun card(build: LinearLayout.() -> Unit): View {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(16.dp(), 16.dp(), 16.dp(), 16.dp())
background = rounded("#FFFFFF", "#E2E8F0", 10)
elevation = 1.5f.dpFloat()
build()
}.also {
it.layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
).apply { setMargins(0, 0, 0, 14.dp()) }
}
}
private fun sectionTitle(text: String): View {
return TextView(this).apply {
this.text = text
textSize = 17f
typeface = Typeface.DEFAULT_BOLD
setTextColor(color("#0F172A"))
}
}
private fun actionButton(text: String, primary: Boolean, action: () -> Unit): Button {
return Button(this).apply {
this.text = text
textSize = 15f
isAllCaps = false
minHeight = 44.dp()
setTextColor(color(if (primary) "#FFFFFF" else "#0F172A"))
background = if (primary) rounded("#2563EB", "#2563EB", 8) else rounded("#FFFFFF", "#CBD5E1", 8)
setOnClickListener { action() }
}
}
private fun toggleHost() {
when (ServiceStatusStore.snapshot(this).state) {
ServiceStatusStore.STATE_RUNNING -> pauseHost()
ServiceStatusStore.STATE_STOPPED, ServiceStatusStore.STATE_ERROR -> startHost()
}
}
private fun startHost() {
ServiceStatusStore.setStarting(this)
refreshStatus()
OneKvmService.start(this)
LogStore.append("Start requested from app UI")
pollStatusForAWhile()
}
private fun pauseHost() {
ServiceStatusStore.setStopping(this)
refreshStatus()
OneKvmService.stop(this)
LogStore.append("Pause requested from app UI")
pollStatusForAWhile()
}
private fun refreshStatus() {
if (::statusValue.isInitialized) {
statusValue.text = "状态:${hostStatusSummary()}"
}
updateHostActionButton()
}
private fun hostStatusSummary(): String {
val serviceStatus = ServiceStatusStore.snapshot(this)
if (serviceStatus.state != ServiceStatusStore.STATE_STOPPED) {
return serviceStatus.labelText()
}
val nativeRunning = runCatching {
NativeBridge.hostStatus().contains("running", ignoreCase = true)
}.getOrDefault(false)
return if (nativeRunning) "运行中" else "已停止"
}
private fun reconcilePersistedStatus() {
val serviceStatus = ServiceStatusStore.snapshot(this)
if (serviceStatus.state == ServiceStatusStore.STATE_STOPPED) return
if (
serviceStatus.state == ServiceStatusStore.STATE_STARTING &&
System.currentTimeMillis() - serviceStatus.updatedAt < STARTING_RECONCILE_GRACE_MS
) {
return
}
Thread {
val portOpen = isLocalWebPortOpen()
val nativeRunning = runCatching { NativeBridge.hostStatus().contains("running", ignoreCase = true) }
.getOrDefault(false)
if (!portOpen && !nativeRunning) {
ServiceStatusStore.setStopped(this, "服务未运行")
runOnUiThread { refreshStatus() }
}
}.start()
}
private fun isLocalWebPortOpen(): Boolean {
return runCatching {
Socket().use { socket ->
socket.connect(InetSocketAddress("127.0.0.1", 8080), 250)
}
true
}.getOrDefault(false)
}
private fun updateHostActionButton() {
if (!::hostActionButton.isInitialized) return
when (ServiceStatusStore.snapshot(this).state) {
ServiceStatusStore.STATE_STARTING -> setHostActionButton("启动中...", enabled = false, primary = true)
ServiceStatusStore.STATE_RUNNING -> setHostActionButton("停止", enabled = true, primary = false)
ServiceStatusStore.STATE_STOPPING -> setHostActionButton("停止中...", enabled = false, primary = false)
else -> setHostActionButton("启动", enabled = true, primary = true)
}
}
private fun setHostActionButton(text: String, enabled: Boolean, primary: Boolean) {
hostActionButton.text = text
hostActionButton.isEnabled = enabled
hostActionButton.alpha = if (enabled) 1f else 0.65f
hostActionButton.setTextColor(color(if (primary) "#FFFFFF" else "#0F172A"))
hostActionButton.background = if (primary) {
rounded("#2563EB", "#2563EB", 8)
} else {
rounded("#FFFFFF", "#CBD5E1", 8)
}
}
private fun pollStatusForAWhile() {
statusPollsRemaining = 20
statusHandler.removeCallbacks(statusPoller)
statusHandler.postDelayed(statusPoller, STATUS_POLL_INTERVAL_MS)
}
private fun kernelVersion(): String {
return runCatching { NativeBridge.kernelVersion() }
.getOrElse { "unknown" }
}
private fun accessAddresses(): String {
val addresses = runCatching {
Collections.list(NetworkInterface.getNetworkInterfaces())
.filter { it.isUp && !it.isLoopback }
.flatMap { iface -> Collections.list(iface.inetAddresses) }
.filterIsInstance<Inet4Address>()
.filter { !it.isLoopbackAddress }
.map { "http://${it.hostAddress}:8080" }
.distinct()
}.getOrDefault(emptyList())
return (addresses.ifEmpty { listOf("http://127.0.0.1:8080") }).joinToString("\n")
}
private fun divider(): View {
return View(this).apply {
setBackgroundColor(color("#E2E8F0"))
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
1,
).apply { setMargins(0, 0, 0, 0) }
}
}
private fun matchWrap(): LinearLayout.LayoutParams {
return LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
)
}
private fun matchButton(): LinearLayout.LayoutParams {
return LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
48.dp(),
)
}
private fun solid(hex: String): GradientDrawable = GradientDrawable().apply {
setColor(color(hex))
}
private fun rounded(fill: String, stroke: String, radiusDp: Int): GradientDrawable {
return GradientDrawable().apply {
setColor(color(fill))
cornerRadius = radiusDp.dpFloat()
setStroke(1.dp(), color(stroke))
}
}
private fun color(hex: String): Int = Color.parseColor(hex)
private fun Int.dp(): Int = (this * resources.displayMetrics.density + 0.5f).toInt()
private fun Int.dpFloat(): Float = this * resources.displayMetrics.density
private fun Float.dpFloat(): Float = this * resources.displayMetrics.density
companion object {
private const val STATUS_POLL_INTERVAL_MS = 500L
private const val STARTING_RECONCILE_GRACE_MS = 15_000L
}
}

View File

@@ -1,21 +0,0 @@
package cn.one_kvm.androidhost
import android.content.Context
object NativeBridge {
init {
System.loadLibrary("one_kvm_android_bootstrap")
}
external fun initTlsVerifier(context: Context): Int
external fun setEnv(name: String, value: String): Int
external fun startHost(dataDir: String, bindAddress: String, port: Int): String
external fun stopHost(): String
external fun hostStatus(): String
external fun kernelVersion(): String
}

View File

@@ -1,413 +0,0 @@
package cn.one_kvm.androidhost
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import java.io.InterruptedIOException
import java.util.concurrent.Executors
class OneKvmService : Service() {
private var rootProcess: Process? = null
private val commandExecutor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "OneKvmServiceCommand")
}
override fun onCreate() {
super.onCreate()
ensureNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action ?: ACTION_START) {
ACTION_STOP -> {
ServiceStatusStore.setStopping(this)
commandExecutor.execute {
stopHostRuntime()
stopSelfResult(startId)
}
return START_NOT_STICKY
}
ACTION_START -> {
ServiceStatusStore.setStarting(this)
startForegroundCompat(NOTIFICATION_ID, notification("启动中"))
commandExecutor.execute {
val currentState = ServiceStatusStore.snapshot(this).state
if (currentState == ServiceStatusStore.STATE_RUNNING && isPortOpen(8080, 100)) {
return@execute
}
val dataDir = File(getExternalFilesDir(null), "runtime")
if (!dataDir.exists()) dataDir.mkdirs()
val result = startRustHost(dataDir)
if (result.startsWith("Running") && !result.contains("start failed", ignoreCase = true)) {
ServiceStatusStore.setRunning(this, "服务已启动")
notificationManager().notify(NOTIFICATION_ID, notification("运行中"))
} else {
ServiceStatusStore.setError(this, "启动失败")
notificationManager().notify(NOTIFICATION_ID, notification("启动失败"))
}
}
}
}
return START_STICKY
}
override fun onDestroy() {
stopHostRuntime(updateNotification = false)
commandExecutor.shutdownNow()
ServiceStatusStore.setStopped(this)
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun notification(state: String): Notification {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = createContentIntent(intent)
val builder = createNotificationBuilder()
return builder
.setSmallIcon(R.drawable.ic_stat_one_kvm)
.setContentTitle("One-KVM Android Host")
.setContentText(state)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
CHANNEL_ID,
"One-KVM Host",
NotificationManager.IMPORTANCE_LOW,
)
notificationManager().createNotificationChannel(channel)
}
@Suppress("DEPRECATION")
private fun createNotificationBuilder(): Notification.Builder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
Notification.Builder(this)
}
}
private fun createContentIntent(intent: Intent): PendingIntent {
val flags = pendingIntentFlags()
return PendingIntent.getActivity(this, 0, intent, flags)
}
private fun pendingIntentFlags(): Int {
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags = flags or pendingIntentImmutableFlag()
}
return flags
}
private fun pendingIntentImmutableFlag(): Int {
return try {
PendingIntent::class.java.getField("FLAG_IMMUTABLE").getInt(null)
} catch (_: ReflectiveOperationException) {
0
}
}
private fun notificationManager(): NotificationManager {
return getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
private fun stopHostRuntime(updateNotification: Boolean = true) {
stopRootHost()
NativeBridge.stopHost()
waitForPortRelease(8080, 2_000)
LogStore.flush()
ServiceStatusStore.setStopped(this)
if (updateNotification) {
notificationManager().notify(NOTIFICATION_ID, notification("已停止"))
}
}
private fun startRustHost(dataDir: File): String {
val logLevel = LogConfig.getLevel(this)
val rustLog = LogConfig.rustLogFilter(logLevel)
val appLogFile = LogStore.defaultLogFile(this)
LogStore.configure(appLogFile)
val rustLogFile = appLogFile
LogStore.append("Starting One-KVM Rust host, data_dir=${dataDir.absolutePath}, log_level=$logLevel")
val executable = extractHostBinary()
return runCatching {
val tlsInit = NativeBridge.initTlsVerifier(this)
if (tlsInit != 0) {
throw IllegalStateException("rustls platform verifier init failed with code $tlsInit")
}
stopRootHost(executable)
clearExistingOtgGadgetsIfEnabled()
startRootHost(executable, dataDir, rustLog, rustLogFile, logLevel)
LogStore.append("Rust host running as root on port 8080")
"Running as root on port 8080"
}.getOrElse { rootError ->
LogStore.append("Root host unavailable: ${rootError.message ?: rootError::class.java.simpleName}")
configureAlsaEnvironment(executable)
NativeBridge.setEnv("RUST_LOG", rustLog)
NativeBridge.setEnv("ONE_KVM_FFMPEG_LOG", ffmpegLogLevel(logLevel))
NativeBridge.setEnv("ONE_KVM_ANDROID_LOG_FILE", rustLogFile.absolutePath)
val jniResult = NativeBridge.startHost(dataDir.absolutePath, "0.0.0.0", 8080)
LogStore.append("Rust host running in app process on port 8080: $jniResult")
"Running in app process on port 8080 (${rootError.message ?: "root unavailable"}; $jniResult)"
}
}
private fun clearExistingOtgGadgetsIfEnabled() {
if (!HostSettings.getClearExistingOtg(this)) return
val command = """
root=/sys/kernel/config/usb_gadget
[ -d "${'$'}root" ] || exit 0
for gadget in "${'$'}root"/*; do
[ -d "${'$'}gadget" ] || continue
[ -w "${'$'}gadget/UDC" ] && echo "" > "${'$'}gadget/UDC" 2>/dev/null || true
find "${'$'}gadget/configs" -type l -delete 2>/dev/null || true
rm -rf "${'$'}gadget" 2>/dev/null || true
done
""".trimIndent()
runCatching {
ProcessBuilder("/system/xbin/su", "0", "sh", "-c", command)
.redirectErrorStream(true)
.start()
.waitFor()
}.onSuccess { exit ->
LogStore.append("Existing OTG gadget cleanup finished with exit code $exit")
}.onFailure { err ->
LogStore.append("Existing OTG gadget cleanup failed: ${err.message ?: err::class.java.simpleName}")
}
}
private fun configureAlsaEnvironment(executable: File) {
val binDir = executable.parentFile
?: throw IllegalStateException("host binary has no parent directory")
val alsaConfigDir = File(binDir, "alsa")
val alsaConfigPath = File(alsaConfigDir, "alsa.conf")
NativeBridge.setEnv("ALSA_CONFIG_DIR", alsaConfigDir.absolutePath)
NativeBridge.setEnv("ALSA_CONFIG_PATH", alsaConfigPath.absolutePath)
}
private fun extractHostBinary(): File {
val abi = Build.SUPPORTED_ABIS.firstOrNull { it == "arm64-v8a" || it == "armeabi-v7a" }
?: throw IllegalStateException("unsupported ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
val binDir = File(filesDir, "bin/$abi")
val target = File(binDir, "one-kvm-android-host")
copyAssetIfChanged("bin/$abi/one-kvm-android-host", target)
copyAssetIfChanged("bin/$abi/libc++_shared.so", File(binDir, "libc++_shared.so"))
copyAssetIfChanged("bin/$abi/libasound.so", File(binDir, "libasound.so"))
copyAssetIfChanged("bin/$abi/libopus.so", File(binDir, "libopus.so"))
copyAssetDirectoryIfChanged("bin/$abi/alsa", File(binDir, "alsa"))
if (!target.setExecutable(true, false)) {
throw IllegalStateException("cannot mark host binary executable")
}
return target
}
private fun copyAssetIfChanged(assetPath: String, target: File) {
val stamp = File(target.parentFile, "${target.name}.stamp")
@Suppress("DEPRECATION")
val packageInfo = packageManager.getPackageInfo(packageName, 0)
val expectedStamp = "${packageInfo.lastUpdateTime}:$assetPath"
if (target.exists() && stamp.exists() && stamp.readText() == expectedStamp) return
target.parentFile?.mkdirs()
assets.open(assetPath).use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
stamp.writeText(expectedStamp)
}
private fun copyAssetDirectoryIfChanged(assetDir: String, targetDir: File) {
@Suppress("DEPRECATION")
val packageInfo = packageManager.getPackageInfo(packageName, 0)
val stamp = File(targetDir, ".stamp")
val expectedStamp = "${packageInfo.lastUpdateTime}:$assetDir"
if (targetDir.exists() && stamp.exists() && stamp.readText() == expectedStamp) return
if (targetDir.exists()) targetDir.deleteRecursively()
copyAssetDirectory(assetDir, targetDir)
stamp.writeText(expectedStamp)
}
private fun copyAssetDirectory(assetDir: String, targetDir: File) {
targetDir.mkdirs()
val children = assets.list(assetDir)?.filter { it.isNotEmpty() }.orEmpty()
for (child in children) {
val childAsset = "$assetDir/$child"
val childTarget = File(targetDir, child)
val grandChildren = assets.list(childAsset)?.filter { it.isNotEmpty() }.orEmpty()
if (grandChildren.isEmpty()) {
copyAssetIfChanged(childAsset, childTarget)
} else {
copyAssetDirectory(childAsset, childTarget)
}
}
}
private fun startRootHost(
executable: File,
dataDir: File,
rustLog: String,
rustLogFile: File,
logLevel: String,
) {
stopRootHost(executable)
waitForPortRelease(8080, 2_000)
val libDir = executable.parentFile?.absolutePath
?: throw IllegalStateException("host binary has no parent directory")
val alsaConfigDir = File(executable.parentFile, "alsa")
val alsaConfigPath = File(alsaConfigDir, "alsa.conf")
val command =
"export LD_LIBRARY_PATH=${shellQuote(libDir)}:\${LD_LIBRARY_PATH:-}; " +
"export ALSA_CONFIG_DIR=${shellQuote(alsaConfigDir.absolutePath)}; " +
"export ALSA_CONFIG_PATH=${shellQuote(alsaConfigPath.absolutePath)}; " +
"export RUST_LOG=${shellQuote(rustLog)}; " +
"export ONE_KVM_FFMPEG_LOG=${shellQuote(ffmpegLogLevel(logLevel))}; " +
"export ONE_KVM_ANDROID_LOG_FILE=${shellQuote(rustLogFile.absolutePath)}; " +
"${shellQuote(executable.absolutePath)} ${shellQuote(dataDir.absolutePath)} 0.0.0.0 8080"
val process = ProcessBuilder("/system/xbin/su", "0", "sh", "-c", command)
.redirectErrorStream(true)
.start()
rootProcess = process
Thread {
val readError = runCatching {
BufferedReader(InputStreamReader(process.inputStream)).useLines { lines ->
lines.forEach {
android.util.Log.i("OneKvmService", it)
}
}
}.exceptionOrNull()
if (readError != null && readError !is InterruptedIOException) {
android.util.Log.w("OneKvmService", "Root host log reader stopped", readError)
LogStore.append("Root host log reader stopped: ${readError.message ?: readError::class.java.simpleName}")
}
val exit = runCatching { process.waitFor() }.getOrNull()
if (rootProcess === process && exit != null) {
rootProcess = null
ServiceStatusStore.setError(this, "Root host exited with code $exit")
}
}.start()
Thread.sleep(500)
val exit = runCatching { process.exitValue() }.getOrNull()
if (exit != null) {
rootProcess = null
throw IllegalStateException("root host exited immediately: $exit")
}
}
private fun startForegroundCompat(id: Int, notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val invoked = runCatching {
val method = Service::class.java.getMethod(
"startForeground",
Int::class.javaPrimitiveType,
Notification::class.java,
Int::class.javaPrimitiveType,
)
method.invoke(this, id, notification, foregroundServiceTypeConnectedDevice())
}.isSuccess
if (invoked) return
}
super.startForeground(id, notification)
}
private fun foregroundServiceTypeConnectedDevice(): Int {
return try {
Service::class.java.getField("FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE").getInt(null)
} catch (_: ReflectiveOperationException) {
0
}
}
private fun stopRootHost(executable: File? = null) {
rootProcess?.destroy()
rootProcess = null
stopRootHostProcess(executable)
}
private fun stopRootHostProcess(executable: File? = null) {
val command = buildString {
append("pkill -TERM -f '[o]ne-kvm-android-host' 2>/dev/null || true; ")
append("for pid in $(pidof one-kvm-android-host 2>/dev/null); do kill -TERM \"${'$'}pid\" 2>/dev/null || true; done; ")
append("sleep 0.2; ")
append("pkill -KILL -f '[o]ne-kvm-android-host' 2>/dev/null || true; ")
append("for pid in $(pidof one-kvm-android-host 2>/dev/null); do kill -KILL \"${'$'}pid\" 2>/dev/null || true; done; ")
}
runCatching {
ProcessBuilder("/system/xbin/su", "0", "sh", "-c", command)
.redirectErrorStream(true)
.start()
.waitFor()
}.onFailure { err ->
LogStore.append("Failed to stop stale root host: ${err.message ?: err::class.java.simpleName}")
}
}
private fun waitForPortRelease(port: Int, timeoutMs: Long) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val inUse = isPortOpen(port, 100)
if (!inUse) return
Thread.sleep(100)
}
}
private fun isPortOpen(port: Int, timeoutMs: Int): Boolean {
return runCatching {
java.net.Socket().use { socket ->
socket.connect(java.net.InetSocketAddress("127.0.0.1", port), timeoutMs)
}
true
}.getOrDefault(false)
}
private fun shellQuote(value: String): String {
return "'" + value.replace("'", "'\\''") + "'"
}
private fun ffmpegLogLevel(level: String): String {
return when (level) {
"trace" -> "trace"
"debug" -> "debug"
"info" -> "info"
"warn" -> "warning"
else -> "error"
}
}
companion object {
private const val CHANNEL_ID = "one_kvm_host"
private const val NOTIFICATION_ID = 1001
const val ACTION_START = "cn.one_kvm.androidhost.START"
const val ACTION_STOP = "cn.one_kvm.androidhost.STOP"
fun start(context: Context) {
val intent = Intent(context, OneKvmService::class.java).setAction(ACTION_START)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.startService(Intent(context, OneKvmService::class.java).setAction(ACTION_STOP))
}
}
}

View File

@@ -1,75 +0,0 @@
package cn.one_kvm.androidhost
import android.content.Context
object ServiceStatusStore {
private const val PREFS = "one_kvm_android_status"
private const val KEY_STATE = "state"
private const val KEY_MESSAGE = "message"
private const val KEY_UPDATED_AT = "updated_at"
const val STATE_STOPPED = "stopped"
const val STATE_STARTING = "starting"
const val STATE_RUNNING = "running"
const val STATE_STOPPING = "stopping"
const val STATE_ERROR = "error"
data class Snapshot(
val state: String,
val message: String,
val updatedAt: Long,
) {
fun labelText(): String {
return when (state) {
STATE_STARTING -> "启动中"
STATE_RUNNING -> "运行中"
STATE_STOPPING -> "停止中"
STATE_ERROR -> "错误"
else -> "已停止"
}
}
fun displayText(): String {
val label = labelText()
return if (message.isBlank()) label else "$label$message"
}
}
fun setStarting(context: Context, message: String = "正在启动服务") {
write(context, STATE_STARTING, message)
}
fun setRunning(context: Context, message: String) {
write(context, STATE_RUNNING, message)
}
fun setStopping(context: Context, message: String = "正在停止服务") {
write(context, STATE_STOPPING, message)
}
fun setStopped(context: Context, message: String = "服务已停止") {
write(context, STATE_STOPPED, message)
}
fun setError(context: Context, message: String) {
write(context, STATE_ERROR, message)
}
fun snapshot(context: Context): Snapshot {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return Snapshot(
state = prefs.getString(KEY_STATE, STATE_STOPPED) ?: STATE_STOPPED,
message = prefs.getString(KEY_MESSAGE, "") ?: "",
updatedAt = prefs.getLong(KEY_UPDATED_AT, 0L),
)
}
private fun write(context: Context, state: String, message: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(KEY_STATE, state)
.putString(KEY_MESSAGE, message)
.putLong(KEY_UPDATED_AT, System.currentTimeMillis())
.apply()
}
}

View File

@@ -1,38 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1D7BF2"
android:pathData="M24,0h60a24,24 0,0 1,24 24v60a24,24 0,0 1,-24 24H24a24,24 0,0 1,-24 -24V24a24,24 0,0 1,24 -24z" />
<path
android:fillColor="#AED8E8"
android:pathData="M29,25h50a3,3 0,0 1,3 3v31a3,3 0,0 1,-3 3H29a3,3 0,0 1,-3 -3V28a3,3 0,0 1,3 -3z" />
<path
android:fillColor="#3F3F3D"
android:pathData="M31,30h46v27H31z" />
<path
android:fillColor="#E7F1F4"
android:pathData="M31,26h10a1.4,1.4 0,0 1,0 2.8H31a1.4,1.4 0,0 1,0 -2.8z" />
<path
android:fillColor="#8BBFD1"
android:pathData="M49,62h10l1.5,8h-13z" />
<path
android:fillColor="#9FCFE0"
android:pathData="M40,70a14,4.5 0,1 0,28 0a14,4.5 0,1 0,-28 0z" />
<path
android:fillColor="#E8F5F8"
android:pathData="M45,70a7,1.8 0,1 0,14 0a7,1.8 0,1 0,-14 0z" />
<path
android:fillColor="#BFE6F1"
android:pathData="M32,76h38l5,8H27z" />
<path
android:fillColor="#76ADC2"
android:pathData="M28,84h47v2H28z" />
<path
android:fillColor="#FFFFFF"
android:fillAlpha="0.82"
android:pathData="M37,79h6v2h-6zM46,79h5v2h-5zM54,79h5v2h-5zM62,79h6v2h-6zM34,82h7v2h-7zM44,82h6v2h-6zM53,82h11v2H53zM67,82h4v2h-4z" />
</vector>

View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4,5h16v10H4z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9,17h6v2h3v2H6v-2h3z" />
</vector>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">One-KVM Android Host</string>
</resources>

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#2563EB</item>
</style>
</resources>

View File

@@ -1,3 +0,0 @@
plugins {
id("com.android.application") version "9.0.0" apply false
}

View File

@@ -1,3 +0,0 @@
android.useAndroidX=true
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8

Binary file not shown.

View File

@@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
android/gradlew vendored
View File

@@ -1,251 +0,0 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored
View File

@@ -1,94 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -1,21 +0,0 @@
[package]
name = "one-kvm-android-bootstrap"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "one_kvm_android_bootstrap"
crate-type = ["cdylib"]
[[bin]]
name = "one-kvm-android-host"
path = "src/bin/one-kvm-android-host.rs"
[dependencies]
jni = "0.22.4"
one-kvm = { path = "../..", default-features = false, features = ["android", "android-mediacodec"] }
rustls-platform-verifier = "0.7"
[features]
android-mediacodec = ["one-kvm/android-mediacodec"]

View File

@@ -1,24 +0,0 @@
use one_kvm::runtime::android::{self, AndroidRuntimeConfig};
fn main() {
let mut args = std::env::args().skip(1);
let data_dir = args
.next()
.unwrap_or_else(|| "/data/local/tmp/one-kvm".to_string());
let bind_address = args.next().unwrap_or_else(|| "0.0.0.0".to_string());
let port = args
.next()
.and_then(|value| value.parse::<u16>().ok())
.unwrap_or(8080);
one_kvm::runtime::android::init_rustls_provider();
if let Err(err) = android::run_foreground(AndroidRuntimeConfig {
data_dir,
bind_address,
port,
}) {
eprintln!("one-kvm android host failed: {err}");
std::process::exit(1);
}
}

View File

@@ -1,182 +0,0 @@
use jni::errors::{ErrorPolicy, ThrowRuntimeExAndDefault};
use jni::objects::{JClass, JObject, JString};
use jni::sys::{jint, jstring};
use jni::{Env, EnvOutcome, EnvUnowned};
use one_kvm::runtime::android::{self, AndroidRuntimeConfig};
#[derive(Debug)]
struct BridgeError(String);
impl From<jni::errors::Error> for BridgeError {
fn from(err: jni::errors::Error) -> Self {
Self(err.to_string())
}
}
impl From<String> for BridgeError {
fn from(err: String) -> Self {
Self(err)
}
}
impl std::fmt::Display for BridgeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Default)]
struct StatusPolicy;
impl ErrorPolicy<jint, BridgeError> for StatusPolicy {
type Captures<'unowned_env_local: 'native_method, 'native_method> = ();
fn on_error<'unowned_env_local: 'native_method, 'native_method>(
_env: &mut Env<'unowned_env_local>,
_cap: &mut Self::Captures<'unowned_env_local, 'native_method>,
_err: BridgeError,
) -> jni::errors::Result<jint> {
Ok(-1)
}
fn on_panic<'unowned_env_local: 'native_method, 'native_method>(
_env: &mut Env<'unowned_env_local>,
_cap: &mut Self::Captures<'unowned_env_local, 'native_method>,
_payload: Box<dyn std::any::Any + Send + 'static>,
) -> jni::errors::Result<jint> {
Ok(-1)
}
}
#[derive(Debug, Default)]
struct StringResultPolicy;
impl ErrorPolicy<String, BridgeError> for StringResultPolicy {
type Captures<'unowned_env_local: 'native_method, 'native_method> = ();
fn on_error<'unowned_env_local: 'native_method, 'native_method>(
_env: &mut Env<'unowned_env_local>,
_cap: &mut Self::Captures<'unowned_env_local, 'native_method>,
err: BridgeError,
) -> jni::errors::Result<String> {
Ok(format!("start failed: {err}"))
}
fn on_panic<'unowned_env_local: 'native_method, 'native_method>(
_env: &mut Env<'unowned_env_local>,
_cap: &mut Self::Captures<'unowned_env_local, 'native_method>,
_payload: Box<dyn std::any::Any + Send + 'static>,
) -> jni::errors::Result<String> {
Ok("start failed: panic in native bridge".to_string())
}
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_setEnv<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
name: JString<'local>,
value: JString<'local>,
) -> jint {
let outcome: EnvOutcome<'local, jint, BridgeError> = env.with_env_no_catch(|env| {
let name = name
.try_to_string(env)
.map_err(|err| BridgeError(format!("invalid env name: {err}")))?;
let value = value
.try_to_string(env)
.map_err(|err| BridgeError(format!("invalid env value: {err}")))?;
if name.contains('\0') || value.contains('\0') {
return Err(BridgeError("env contains NUL".to_string()));
}
std::env::set_var(name, value);
Ok(0)
});
outcome.resolve_with::<StatusPolicy, _>(|| ())
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_initTlsVerifier<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
context: JObject<'local>,
) -> jint {
let outcome: EnvOutcome<'local, jint, BridgeError> =
env.with_env_no_catch(|env| init_tls_verifier(env, context));
outcome.resolve_with::<StatusPolicy, _>(|| ())
}
#[cfg(target_os = "android")]
fn init_tls_verifier(env: &mut Env<'_>, context: JObject<'_>) -> Result<jint, BridgeError> {
rustls_platform_verifier::android::init_with_env(env, context)
.map_err(|err| BridgeError(format!("failed to initialize rustls platform verifier: {err}")))?;
Ok(0)
}
#[cfg(not(target_os = "android"))]
fn init_tls_verifier(_env: &mut Env<'_>, _context: JObject<'_>) -> Result<jint, BridgeError> {
Ok(0)
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_startHost<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
data_dir: JString<'local>,
bind_address: JString<'local>,
port: i32,
) -> jstring {
let outcome: EnvOutcome<'local, String, BridgeError> = env.with_env_no_catch(|env| {
let data_dir = data_dir
.try_to_string(env)
.map_err(|err| BridgeError(format!("invalid data dir: {err}")))?;
let bind_address = bind_address
.try_to_string(env)
.map_err(|err| BridgeError(format!("invalid bind address: {err}")))?;
let port = u16::try_from(port).map_err(|_| BridgeError("invalid port".to_string()))?;
android::start(AndroidRuntimeConfig {
data_dir,
bind_address,
port,
})
.map_err(BridgeError)
});
let result = outcome.resolve_with::<StringResultPolicy, _>(|| ());
env.with_env_no_catch(|env| env.new_string(result))
.resolve_with::<ThrowRuntimeExAndDefault, _>(|| ())
.into_raw()
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_stopHost<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
) -> jstring {
env.with_env_no_catch(|env| env.new_string(android::stop()))
.resolve_with::<ThrowRuntimeExAndDefault, _>(|| ())
.into_raw()
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_hostStatus<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
) -> jstring {
env.with_env_no_catch(|env| env.new_string(android::status()))
.resolve_with::<ThrowRuntimeExAndDefault, _>(|| ())
.into_raw()
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_kernelVersion<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
) -> jstring {
env.with_env_no_catch(|env| env.new_string(env!("CARGO_PKG_VERSION")))
.resolve_with::<ThrowRuntimeExAndDefault, _>(|| ())
.into_raw()
}

View File

@@ -1,39 +0,0 @@
pluginManagement {
fun isEnabled(value: String?): Boolean = when (value?.lowercase()) {
"1", "true", "yes", "on" -> true
else -> false
}
val mirrorAcceleration = isEnabled(System.getenv("CHINAMIRRO"))
repositories {
if (mirrorAcceleration) {
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/public")
maven("https://maven.aliyun.com/repository/gradle-plugin")
}
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
fun isEnabled(value: String?): Boolean = when (value?.lowercase()) {
"1", "true", "yes", "on" -> true
else -> false
}
val mirrorAcceleration = isEnabled(System.getenv("CHINAMIRRO"))
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
if (mirrorAcceleration) {
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/public")
}
google()
mavenCentral()
}
}
rootProject.name = "OneKvmAndroidHost"
include(":app")

View File

@@ -1,103 +0,0 @@
#!/usr/bin/env bash
# Build Android APKs using the Docker build image.
# Usage: ./build/build-android.sh [arm64|armv7|all|help]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DOCKERFILE="$PROJECT_ROOT/build/cross/Dockerfile.android"
IMAGE_NAME="${ONE_KVM_ANDROID_DOCKER_IMAGE:-one-kvm-android-build:cn}"
fail() {
echo "Error: $*" >&2
exit 1
}
build_android() {
local arch="$1"
local docker_build_args=()
local gradle_distribution_url="${ONE_KVM_GRADLE_DISTRIBUTION_URL:-}"
local gradle_distribution_url_cn="${ONE_KVM_GRADLE_DISTRIBUTION_URL_CN:-https://mirrors.cloud.tencent.com/gradle/gradle-9.1.0-bin.zip}"
local gradle_network_timeout="${ONE_KVM_GRADLE_NETWORK_TIMEOUT:-120000}"
if [[ "${CHINAMIRRO:-}" == "1" ]]; then
docker_build_args+=("--build-arg" "CHINAMIRRO=1")
docker_build_args+=("--build-arg" "DEBIAN_IMAGE=${DEBIAN_IMAGE:-docker.1ms.run/library/debian:11}")
docker_build_args+=("--build-arg" "RUSTUP_DIST_SERVER_CN=${RUSTUP_DIST_SERVER_CN:-https://rsproxy.cn}")
docker_build_args+=("--build-arg" "RUSTUP_UPDATE_ROOT_CN=${RUSTUP_UPDATE_ROOT_CN:-https://rsproxy.cn/rustup}")
docker_build_args+=("--build-arg" "CARGO_INDEX_CN=${CARGO_INDEX_CN:-https://rsproxy.cn/crates.io-index}")
docker_build_args+=("--build-arg" "CARGO_REGISTRY_CN=${CARGO_REGISTRY_CN:-sparse+https://rsproxy.cn/index/}")
docker_build_args+=("--build-arg" "MAVEN_REPOSITORY_CN=${MAVEN_REPOSITORY_CN:-https://maven.aliyun.com/repository/public}")
docker_build_args+=("--build-arg" "GOOGLE_MAVEN_REPOSITORY_CN=${GOOGLE_MAVEN_REPOSITORY_CN:-https://maven.aliyun.com/repository/google}")
docker_build_args+=("--build-arg" "GRADLE_PLUGIN_REPOSITORY_CN=${GRADLE_PLUGIN_REPOSITORY_CN:-https://maven.aliyun.com/repository/gradle-plugin}")
docker_build_args+=("--build-arg" "GRADLE_DISTRIBUTION_URL_CN=$gradle_distribution_url_cn")
if [[ -z "$gradle_distribution_url" ]]; then
gradle_distribution_url="$gradle_distribution_url_cn"
fi
fi
if [[ "${ONE_KVM_ANDROID_SKIP_DOCKER_BUILD:-0}" == "1" ]]; then
echo "=== Skipping Android image build: $IMAGE_NAME ==="
else
echo "=== Building Android image: $IMAGE_NAME ==="
docker build \
-f "$DOCKERFILE" \
-t "$IMAGE_NAME" \
"${docker_build_args[@]}" \
"$PROJECT_ROOT/build/cross"
fi
echo "=== Building Android APK: $arch ==="
docker run --rm \
-v "$PROJECT_ROOT:/workspace" \
-w /workspace \
-e "CHINAMIRRO=${CHINAMIRRO:-0}" \
-e "GH_PROXY=${GH_PROXY:-https://gh-proxy.com}" \
-e "ONE_KVM_GRADLE_DISTRIBUTION_URL=$gradle_distribution_url" \
-e "ONE_KVM_GRADLE_DISTRIBUTION_URL_CN=$gradle_distribution_url_cn" \
-e "ONE_KVM_GRADLE_NETWORK_TIMEOUT=$gradle_network_timeout" \
"$IMAGE_NAME" \
"$arch"
}
[[ -f "$DOCKERFILE" ]] || fail "Android Dockerfile not found: $DOCKERFILE"
command -v docker >/dev/null 2>&1 || fail "docker is required"
case "${1:-all}" in
all)
build_android all
;;
arm64)
build_android arm64
;;
armv7)
build_android armv7
;;
help | --help | -h)
cat <<'EOF'
Usage: build/build-android.sh [arch|help]
Commands:
all (default) Build arm64 and armv7 APKs
arm64 Build only arm64 APK
armv7 Build only ARMv7 APK
help Show this help
Examples:
build/build-android.sh
build/build-android.sh arm64
CHINAMIRRO=1 build/build-android.sh all
CHINAMIRRO=1 ONE_KVM_GRADLE_DISTRIBUTION_URL=https://mirrors.aliyun.com/macports/distfiles/gradle/gradle-9.1.0-bin.zip build/build-android.sh all
Environment:
ONE_KVM_ANDROID_SKIP_DOCKER_BUILD=1 Reuse an already loaded Docker image
APK output:
target/android/one-kvm_<version>_<arm32|arm64>.apk
EOF
;;
*)
fail "Unknown argument: $1"
;;
esac

View File

@@ -1,319 +0,0 @@
# Android build image for One-KVM
# Based on Debian 11 for stable toolchain/runtime compatibility
ARG DEBIAN_IMAGE=debian:11
FROM ${DEBIAN_IMAGE}
ARG CHINAMIRRO=0
ARG ANDROID_SDK_ROOT=/root/android-sdk
ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708_latest
ARG ANDROID_NDK_VERSION=27.3.13750724
ARG ANDROID_PLATFORM=36
ARG ANDROID_BUILD_TOOLS=36.0.0
ARG CARGO_NDK_VERSION=4.1.2
ARG RUSTUP_DIST_SERVER_CN=https://rsproxy.cn
ARG RUSTUP_UPDATE_ROOT_CN=https://rsproxy.cn/rustup
ARG CARGO_INDEX_CN=https://rsproxy.cn/crates.io-index
ARG CARGO_REGISTRY_CN=sparse+https://rsproxy.cn/index/
ARG MAVEN_REPOSITORY_CN=https://maven.aliyun.com/repository/public
ARG GOOGLE_MAVEN_REPOSITORY_CN=https://maven.aliyun.com/repository/google
ARG GRADLE_PLUGIN_REPOSITORY_CN=https://maven.aliyun.com/repository/gradle-plugin
ARG GRADLE_DISTRIBUTION_URL_CN=https://mirrors.cloud.tencent.com/gradle/gradle-9.1.0-bin.zip
ARG ANDROID_CMDLINE_TOOLS_URL=
ENV DEBIAN_FRONTEND=noninteractive
ENV ANDROID_HOME=${ANDROID_SDK_ROOT}
ENV ANDROID_SDK_ROOT=${ANDROID_SDK_ROOT}
ENV ANDROID_NDK_HOME=${ANDROID_SDK_ROOT}/ndk/${ANDROID_NDK_VERSION}
ENV ANDROID_NDK_ROOT=${ANDROID_SDK_ROOT}/ndk/${ANDROID_NDK_VERSION}
ENV ANDROID_BUILD_TOOLS=${ANDROID_BUILD_TOOLS}
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
ENV PATH=/root/.cargo/bin:${PATH}
ENV ONE_KVM_GRADLE_DISTRIBUTION_URL_CN=${GRADLE_DISTRIBUTION_URL_CN}
RUN if [ "$CHINAMIRRO" = "1" ]; then \
sed -i -E \
-e 's|http://deb.debian.org/debian([[:space:]])|http://mirrors.tuna.tsinghua.edu.cn/debian\1|g' \
/etc/apt/sources.list; \
fi
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
wget \
bzip2 \
unzip \
zip \
git \
bash \
build-essential \
pkg-config \
cmake \
ninja-build \
autoconf \
automake \
libtool \
nasm \
yasm \
python3 \
openjdk-17-jdk-headless \
libstdc++6 \
&& rm -rf /var/lib/apt/lists/*
RUN if [ "$CHINAMIRRO" = "1" ]; then \
export RUSTUP_DIST_SERVER=${RUSTUP_DIST_SERVER_CN}; \
export RUSTUP_UPDATE_ROOT=${RUSTUP_UPDATE_ROOT_CN}; \
mkdir -p /root/.cargo; \
printf '%s\n' \
'[source.crates-io]' \
"replace-with = 'rsproxy-sparse'" \
'[source.rsproxy]' \
"registry = '${CARGO_INDEX_CN}'" \
'[source.rsproxy-sparse]' \
"registry = '${CARGO_REGISTRY_CN}'" \
'[registries.rsproxy]' \
"index = '${CARGO_INDEX_CN}'" \
'[net]' \
'git-fetch-with-cli = true' \
> /root/.cargo/config.toml; \
fi \
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable \
&& cargo install cargo-ndk --version ${CARGO_NDK_VERSION} --locked \
&& rustup target add armv7-linux-androideabi aarch64-linux-android
RUN mkdir -p /opt/android-cmdline-tools \
&& cd /tmp \
&& if [ -n "$ANDROID_CMDLINE_TOOLS_URL" ]; then \
wget -q "$ANDROID_CMDLINE_TOOLS_URL" -O cmdline-tools.zip; \
else \
wget -q https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}.zip -O cmdline-tools.zip; \
fi \
&& unzip -q cmdline-tools.zip -d /opt/android-cmdline-tools \
&& mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools/latest \
&& mv /opt/android-cmdline-tools/cmdline-tools/* ${ANDROID_SDK_ROOT}/cmdline-tools/latest/ \
&& rm -rf /tmp/cmdline-tools.zip /opt/android-cmdline-tools
RUN mkdir -p ${ANDROID_SDK_ROOT}/licenses \
&& yes | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --sdk_root=${ANDROID_SDK_ROOT} --licenses >/dev/null \
&& ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --sdk_root=${ANDROID_SDK_ROOT} \
"platform-tools" \
"platforms;android-${ANDROID_PLATFORM}" \
"build-tools;${ANDROID_BUILD_TOOLS}" \
"ndk;${ANDROID_NDK_VERSION}" \
"cmake;3.22.1" \
&& mkdir -p ${ANDROID_NDK_HOME}
RUN if [ "$CHINAMIRRO" = "1" ]; then \
mkdir -p /root/.gradle; \
printf '%s\n' \
"beforeSettings { settings ->" \
" settings.pluginManagement.repositories.maven { url = uri('${GOOGLE_MAVEN_REPOSITORY_CN}') }" \
" settings.pluginManagement.repositories.maven { url = uri('${MAVEN_REPOSITORY_CN}') }" \
" settings.pluginManagement.repositories.maven { url = uri('${GRADLE_PLUGIN_REPOSITORY_CN}') }" \
" settings.dependencyResolutionManagement.repositories.maven { url = uri('${GOOGLE_MAVEN_REPOSITORY_CN}') }" \
" settings.dependencyResolutionManagement.repositories.maven { url = uri('${MAVEN_REPOSITORY_CN}') }" \
"}" \
"allprojects {" \
" buildscript.repositories.maven { url = uri('${GOOGLE_MAVEN_REPOSITORY_CN}') }" \
" buildscript.repositories.maven { url = uri('${MAVEN_REPOSITORY_CN}') }" \
"}" \
> /root/.gradle/init.gradle; \
fi
RUN apt-get update && apt-get install -y --no-install-recommends \
libclang-dev \
llvm \
&& rm -rf /var/lib/apt/lists/*
ENV LIBCLANG_PATH=/usr/lib/llvm-11/lib
RUN printf '%s\n' \
'#!/usr/bin/env bash' \
'set -euo pipefail' \
'' \
'PROJECT_ROOT="${ONE_KVM_ANDROID_PROJECT_ROOT:-/workspace}"' \
'ANDROID_DIR="${PROJECT_ROOT}/android"' \
'BUILD_TYPE="release"' \
'ARCH="${1:-all}"' \
'FFMPEG_ROOT="${ONE_KVM_ANDROID_FFMPEG_ROOT:-${PROJECT_ROOT}/dist/android-ffmpeg-mediacodec}"' \
'OUTPUT_DIR="${PROJECT_ROOT}/target/android"' \
'SIGNING_DIR="${PROJECT_ROOT}/target/android-signing"' \
'KEYSTORE_PATH="${SIGNING_DIR}/one-kvm-release.jks"' \
'KEY_ALIAS="one-kvm-release"' \
'KEY_PASSWORD="one-kvm-release"' \
'ANDROID_BUILD_TOOLS_DIR="${ANDROID_SDK_ROOT}/build-tools/${ANDROID_BUILD_TOOLS}"' \
'WRAPPER_PROPERTIES="$ANDROID_DIR/gradle/wrapper/gradle-wrapper.properties"' \
'GRADLE_DISTRIBUTION_URL="${ONE_KVM_GRADLE_DISTRIBUTION_URL:-}"' \
'GRADLE_DISTRIBUTION_URL_CN="${ONE_KVM_GRADLE_DISTRIBUTION_URL_CN:-https://mirrors.cloud.tencent.com/gradle/gradle-9.1.0-bin.zip}"' \
'GRADLE_NETWORK_TIMEOUT="${ONE_KVM_GRADLE_NETWORK_TIMEOUT:-120000}"' \
'' \
'usage() {' \
' cat <<EOF' \
'Usage:' \
' docker run --rm -v "$PWD:/workspace" one-kvm-android-build:cn [arm64|armv7|all|help]' \
'' \
'Commands:' \
' all Build arm64 and armv7 APKs. Default.' \
' arm64 Build only arm64 APK.' \
' armv7 Build only ARMv7 APK.' \
' help Show this help.' \
'' \
'APK output:' \
' target/android/one-kvm_<version>_<arm32|arm64>.apk' \
'EOF' \
'}' \
'' \
'fail() {' \
' echo "Error: $*" >&2' \
' exit 1' \
'}' \
'' \
'read_project_version() {' \
' local version' \
' version="$(awk -F "\"" '"'"'/^version[[:space:]]*=/ { print $2; exit }'"'"' "$PROJECT_ROOT/Cargo.toml")"' \
' [[ -n "$version" ]] || fail "Failed to resolve version from $PROJECT_ROOT/Cargo.toml"' \
' printf "%s\n" "$version"' \
'}' \
'' \
'copy_apks() {' \
' local flavor="$1"' \
' local src_dir="$ANDROID_DIR/app/build/outputs/apk/$flavor/$BUILD_TYPE"' \
' local found=0' \
' mkdir -p "$OUTPUT_DIR"' \
' for apk in "$src_dir"/*.apk; do' \
' [[ -f "$apk" ]] || continue' \
' sign_apk "$apk" "$OUTPUT_DIR/one-kvm_${PROJECT_VERSION}_${flavor}.apk"' \
' found=1' \
' done' \
' [[ "$found" == "1" ]] || fail "No APK files found in: $src_dir"' \
'}' \
'' \
'ensure_keystore() {' \
' if [[ -f "$KEYSTORE_PATH" ]]; then' \
' return' \
' fi' \
' mkdir -p "$SIGNING_DIR"' \
' keytool -genkeypair -noprompt -keystore "$KEYSTORE_PATH" -storetype PKCS12 -alias "$KEY_ALIAS" -keyalg RSA -keysize 2048 -validity 10000 -storepass "$KEY_PASSWORD" -keypass "$KEY_PASSWORD" -dname "CN=One-KVM, OU=One-KVM, O=One-KVM, L=Local, S=Local, C=US" >/dev/null' \
'}' \
'' \
'sign_apk() {' \
' local input_apk="$1"' \
' local output_apk="$2"' \
' local aligned_apk' \
' aligned_apk="$(mktemp --suffix=.apk)"' \
' "$ANDROID_BUILD_TOOLS_DIR/zipalign" -f -p 4 "$input_apk" "$aligned_apk"' \
' "$ANDROID_BUILD_TOOLS_DIR/apksigner" sign --ks "$KEYSTORE_PATH" --ks-key-alias "$KEY_ALIAS" --ks-pass "pass:$KEY_PASSWORD" --key-pass "pass:$KEY_PASSWORD" --out "$output_apk" "$aligned_apk"' \
' "$ANDROID_BUILD_TOOLS_DIR/apksigner" verify --verbose "$output_apk" >/dev/null' \
' rm -f "$aligned_apk"' \
'}' \
'' \
'cd "$PROJECT_ROOT"' \
'' \
'case "$ARCH" in' \
'help | --help | -h)' \
' usage' \
' exit 0' \
' ;;' \
'esac' \
'' \
'[[ -d "$ANDROID_DIR" ]] || fail "Android project not found: $ANDROID_DIR"' \
'[[ -x "$ANDROID_DIR/gradlew" ]] || fail "Gradle wrapper is not executable: $ANDROID_DIR/gradlew"' \
'[[ -f "$WRAPPER_PROPERTIES" ]] || fail "Gradle wrapper properties not found: $WRAPPER_PROPERTIES"' \
'' \
'ORIGINAL_WRAPPER_PROPERTIES="$(mktemp)"' \
'cp "$WRAPPER_PROPERTIES" "$ORIGINAL_WRAPPER_PROPERTIES"' \
'cleanup_wrapper_properties() {' \
' cp "$ORIGINAL_WRAPPER_PROPERTIES" "$WRAPPER_PROPERTIES"' \
' rm -f "$ORIGINAL_WRAPPER_PROPERTIES"' \
'}' \
'trap cleanup_wrapper_properties EXIT' \
'' \
'if [[ "${CHINAMIRRO:-0}" == "1" && -z "$GRADLE_DISTRIBUTION_URL" ]]; then' \
' GRADLE_DISTRIBUTION_URL="$GRADLE_DISTRIBUTION_URL_CN"' \
'fi' \
'' \
'if [[ -n "$GRADLE_DISTRIBUTION_URL" ]]; then' \
' WRAPPER_PROPERTIES_TMP="$(mktemp)"' \
' awk -v url="$GRADLE_DISTRIBUTION_URL" -v timeout="$GRADLE_NETWORK_TIMEOUT" '"'"'' \
' BEGIN { seen_url = 0; seen_timeout = 0 }' \
' /^distributionUrl=/ { print "distributionUrl=" url; seen_url = 1; next }' \
' /^networkTimeout=/ { print "networkTimeout=" timeout; seen_timeout = 1; next }' \
' { print }' \
' END {' \
' if (!seen_url) print "distributionUrl=" url;' \
' if (!seen_timeout) print "networkTimeout=" timeout;' \
' }' \
' '"'"' "$WRAPPER_PROPERTIES" > "$WRAPPER_PROPERTIES_TMP"' \
' cp "$WRAPPER_PROPERTIES_TMP" "$WRAPPER_PROPERTIES"' \
' rm -f "$WRAPPER_PROPERTIES_TMP"' \
' if [[ -d /root/.gradle/wrapper/dists ]]; then' \
' find /root/.gradle/wrapper/dists \( -name "*.lck" -o -name "*.part" \) -print0 | xargs -0 -r rm -f' \
' fi' \
'fi' \
'' \
'ensure_keystore' \
'' \
'case "$ARCH" in' \
'arm64)' \
' ANDROID_ABIS="arm64-v8a"' \
' GRADLE_TASK=":app:assembleArm64Release"' \
' APK_FLAVORS="arm64"' \
' ;;' \
'armv7)' \
' ANDROID_ABIS="armeabi-v7a"' \
' GRADLE_TASK=":app:assembleArm32Release"' \
' APK_FLAVORS="arm32"' \
' ;;' \
'all)' \
' ANDROID_ABIS="arm64-v8a,armeabi-v7a"' \
' GRADLE_TASK=":app:assembleRelease"' \
' APK_FLAVORS="arm64 arm32"' \
' ;;' \
'*) fail "Unsupported architecture: $ARCH (expected arm64, armv7, or all)" ;;' \
'esac' \
'' \
'printf "sdk.dir=%s\n" "$ANDROID_HOME" > "$ANDROID_DIR/local.properties"' \
'mkdir -p "$OUTPUT_DIR"' \
'PROJECT_VERSION="$(read_project_version)"' \
'' \
'export ONE_KVM_ANDROID_PROFILE="$BUILD_TYPE"' \
'export ONE_KVM_ANDROID_ABIS="$ANDROID_ABIS"' \
'export ONE_KVM_ANDROID_FFMPEG_ROOT="$FFMPEG_ROOT"' \
'export ANDROID_HOME' \
'export ANDROID_SDK_ROOT' \
'export ANDROID_NDK_HOME' \
'export ANDROID_NDK_ROOT' \
'' \
'echo "Building Android APK"' \
'echo " task: $GRADLE_TASK"' \
'echo " profile: $ONE_KVM_ANDROID_PROFILE"' \
'echo " version: $PROJECT_VERSION"' \
'echo " abis: $ONE_KVM_ANDROID_ABIS"' \
'echo " output: $OUTPUT_DIR"' \
'echo " sdk: $ANDROID_HOME"' \
'echo " ndk: $ANDROID_NDK_HOME"' \
'echo " build tools: $ANDROID_BUILD_TOOLS_DIR"' \
'echo " ffmpeg root: $ONE_KVM_ANDROID_FFMPEG_ROOT"' \
'if [[ -n "$GRADLE_DISTRIBUTION_URL" ]]; then' \
' echo " gradle distribution: $GRADLE_DISTRIBUTION_URL"' \
'fi' \
'' \
'(' \
' cd "$ANDROID_DIR"' \
' ./gradlew "$GRADLE_TASK"' \
')' \
'' \
'for flavor in $APK_FLAVORS; do' \
' copy_apks "$flavor"' \
'done' \
'' \
'echo' \
'echo "APK output:"' \
'ls -1 "$OUTPUT_DIR"' \
> /usr/local/bin/build-one-kvm-android \
&& chmod +x /usr/local/bin/build-one-kvm-android
WORKDIR /workspace
ENTRYPOINT ["/usr/local/bin/build-one-kvm-android"]
CMD ["all"]

View File

@@ -1,7 +1,7 @@
[Unit] [Unit]
Description=One-KVM IP-KVM Service Description=One-KVM IP-KVM Service
Documentation=https://github.com/mofeng-git/One-KVM Documentation=https://github.com/mofeng-git/One-KVM
After=network.target After=network-online.target
Wants=network-online.target Wants=network-online.target
[Service] [Service]

View File

@@ -8,8 +8,6 @@
/ffmpeg/linux/debug /ffmpeg/linux/debug
!/ffmpeg/mac !/ffmpeg/mac
/ffmpeg/mac/debug /ffmpeg/mac/debug
!/ffmpeg/android
/ffmpeg/android/debug
!/ffmpeg/ios !/ffmpeg/ios
/ffmpeg/ios/debug /ffmpeg/ios/debug
/input /input

View File

@@ -21,4 +21,4 @@ serde_json = "1.0"
[build-dependencies] [build-dependencies]
cc = "1.0" cc = "1.0"
bindgen = "0.70.1" bindgen = "0.72"

View File

@@ -41,18 +41,6 @@ Based on the information above, there are several optimizations and changes made
* remove hevc_vaapi because of possible poor quality * remove hevc_vaapi because of possible poor quality
* amf: not tested, https://github.com/GPUOpen-LibrariesAndSDKs/AMF/issues/378 * amf: not tested, https://github.com/GPUOpen-LibrariesAndSDKs/AMF/issues/378
### MacOS
| FFmpeg ram encode | FFmpeg ram decode |
| ------------------ | ------------------ |
| h265 only | Y |
### Android
| FFmpeg ram encode |
| ------------------ |
| Y |
## System requirements ## System requirements
* intel * intel
@@ -76,4 +64,3 @@ Based on the information above, there are several optimizations and changes made
https://docs.nvidia.com/video-technologies/video-codec-sdk/11.1/read-me/index.html https://docs.nvidia.com/video-technologies/video-codec-sdk/11.1/read-me/index.html
https://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new?ncid=em-prod-816193 https://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new?ncid=em-prod-816193

View File

@@ -21,15 +21,11 @@ fn build_common(builder: &mut Build) {
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap(); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
let common_dir = manifest_dir.join("cpp").join("common"); let common_dir = manifest_dir.join("cpp").join("common");
let mut bindings = bindgen::builder() let bindings = bindgen::builder()
.header(common_dir.join("common.h").to_string_lossy().to_string()) .header(common_dir.join("common.h").to_string_lossy().to_string())
.header(common_dir.join("callback.h").to_string_lossy().to_string()) .header(common_dir.join("callback.h").to_string_lossy().to_string())
.rustified_enum(".*") .rustified_enum(".*")
.parse_callbacks(Box::new(CommonCallbacks)); .parse_callbacks(Box::new(CommonCallbacks));
if target_os == "android" {
print_android_bindgen_env();
bindings = bindings.clang_args(android_clang_args());
}
bindings bindings
.generate() .generate()
.unwrap() .unwrap()
@@ -62,9 +58,9 @@ fn build_common(builder: &mut Build) {
} }
// Unsupported platforms // Unsupported platforms
if target_os != "windows" && target_os != "linux" && target_os != "android" { if target_os != "windows" && target_os != "linux" {
panic!( panic!(
"Unsupported OS: {}. Only Windows, Linux, and Android are supported.", "Unsupported OS: {}. Only Windows and Linux are supported.",
target_os target_os
); );
} }
@@ -89,123 +85,12 @@ impl bindgen::callbacks::ParseCallbacks for CommonCallbacks {
} }
} }
fn print_android_bindgen_env() {
println!("cargo:rerun-if-env-changed=ANDROID_NDK_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_NDK_ROOT");
println!("cargo:rerun-if-env-changed=NDK_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_SDK_ROOT");
println!("cargo:rerun-if-env-changed=CARGO_NDK_PLATFORM");
}
fn android_clang_args() -> Vec<String> {
let ndk = android_ndk_home();
let target = env::var("TARGET").unwrap_or_default();
let toolchain = ndk.join("toolchains/llvm/prebuilt").join(host_tag());
let sysroot = toolchain.join("sysroot");
let clang_include = toolchain
.join("lib/clang")
.join(clang_version(&toolchain))
.join("include");
let api = env::var("CARGO_NDK_PLATFORM")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(21);
let clang_target = android_clang_target(&target);
vec![
format!("--target={clang_target}"),
format!("--sysroot={}", sysroot.display()),
format!("-D__ANDROID_API__={api}"),
format!("-isystem{}", clang_include.display()),
format!("-isystem{}", sysroot.join("usr/include").display()),
format!(
"-isystem{}",
sysroot.join("usr/include").join(clang_target).display()
),
]
}
fn android_clang_target(target: &str) -> &'static str {
match target {
"aarch64-linux-android" => "aarch64-linux-android",
"armv7-linux-androideabi" => "armv7a-linux-androideabi",
"i686-linux-android" => "i686-linux-android",
"x86_64-linux-android" => "x86_64-linux-android",
other => panic!("unsupported Android target for hwcodec bindgen: {other}"),
}
}
fn android_ndk_home() -> PathBuf {
for key in ["ANDROID_NDK_HOME", "ANDROID_NDK_ROOT", "NDK_HOME"] {
if let Ok(value) = env::var(key) {
return PathBuf::from(value);
}
}
for key in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
if let Ok(value) = env::var(key) {
let ndk_dir = PathBuf::from(value).join("ndk");
if let Some(newest) = newest_child_dir(&ndk_dir) {
return newest;
}
}
}
panic!(
"hwcodec Android bindgen requires ANDROID_NDK_HOME, ANDROID_NDK_ROOT, NDK_HOME, \
or ANDROID_HOME/ANDROID_SDK_ROOT with an ndk directory"
);
}
fn newest_child_dir(path: &Path) -> Option<PathBuf> {
let mut entries = std::fs::read_dir(path)
.ok()?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>();
entries.sort();
entries.pop()
}
fn host_tag() -> &'static str {
if cfg!(target_os = "linux") {
"linux-x86_64"
} else if cfg!(target_os = "macos") {
"darwin-x86_64"
} else if cfg!(target_os = "windows") {
"windows-x86_64"
} else {
panic!("unsupported host OS for Android NDK");
}
}
fn clang_version(toolchain: &Path) -> String {
let clang_dir = toolchain.join("lib/clang");
let mut entries = std::fs::read_dir(&clang_dir)
.unwrap_or_else(|_| panic!("missing NDK clang directory: {}", clang_dir.display()))
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
entries.sort();
entries
.pop()
.unwrap_or_else(|| panic!("no clang versions found under: {}", clang_dir.display()))
}
mod ffmpeg { mod ffmpeg {
use super::*; use super::*;
pub fn build_ffmpeg(builder: &mut Build) { pub fn build_ffmpeg(builder: &mut Build) {
ffmpeg_ffi(); ffmpeg_ffi();
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") {
link_android_ffmpeg(builder);
build_ffmpeg_ram(builder);
return;
}
// Try VCPKG first, fallback to system FFmpeg via pkg-config // Try VCPKG first, fallback to system FFmpeg via pkg-config
if let Some(vcpkg_installed) = vcpkg_installed_root() { if let Some(vcpkg_installed) = vcpkg_installed_root() {
link_vcpkg(builder, vcpkg_installed); link_vcpkg(builder, vcpkg_installed);
@@ -220,67 +105,6 @@ mod ffmpeg {
build_ffmpeg_capture(builder); build_ffmpeg_capture(builder);
} }
fn link_android_ffmpeg(builder: &mut Build) {
let root = std::env::var("ONE_KVM_ANDROID_FFMPEG_ROOT").unwrap_or_else(|_| {
panic!(
"ONE_KVM_ANDROID_FFMPEG_ROOT is required when building hwcodec for Android. \
It must point to an FFmpeg Android build with MediaCodec enabled."
)
});
let root = PathBuf::from(root);
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let abi = match target_arch.as_str() {
"aarch64" => "arm64-v8a",
"arm" => "armeabi-v7a",
"x86" => "x86",
"x86_64" => "x86_64",
_ => target_arch.as_str(),
};
let abi_root = root.join(abi);
let lib_dir = if abi_root.join("lib").exists() {
abi_root.join("lib")
} else {
root.join("lib")
};
let include_dir = if abi_root.join("include").exists() {
abi_root.join("include")
} else {
root.join("include")
};
if !include_dir.exists() || !lib_dir.exists() {
panic!(
"Invalid ONE_KVM_ANDROID_FFMPEG_ROOT: include/lib not found for ABI {} under {}",
abi,
root.display()
);
}
println!("cargo:rustc-link-search=native={}", lib_dir.display());
builder.include(&include_dir);
let use_static = std::env::var("ONE_KVM_ANDROID_FFMPEG_STATIC")
.map(|value| value != "0")
.unwrap_or(true);
for lib in ["avcodec", "avutil"] {
if use_static {
println!("cargo:rustc-link-lib=static={}", lib);
} else {
println!("cargo:rustc-link-lib={}", lib);
}
}
println!("cargo:rustc-link-lib=log");
println!("cargo:rustc-link-lib=mediandk");
println!("cargo:rustc-link-lib=android");
println!("cargo:rustc-link-lib=dl");
println!("cargo:rustc-link-lib=m");
println!("cargo:rustc-link-lib=z");
println!("cargo:rustc-link-lib=c++_shared");
println!("cargo:info=Using Android FFmpeg from {}", root.display());
}
fn vcpkg_installed_root() -> Option<PathBuf> { fn vcpkg_installed_root() -> Option<PathBuf> {
println!("cargo:rerun-if-env-changed=VCPKG_INSTALLED_DIR"); println!("cargo:rerun-if-env-changed=VCPKG_INSTALLED_DIR");
println!("cargo:rerun-if-env-changed=VCPKG_ROOT"); println!("cargo:rerun-if-env-changed=VCPKG_ROOT");
@@ -530,11 +354,9 @@ mod ffmpeg {
} }
// ARM (aarch64, arm): no X11 needed, uses RKMPP/V4L2 // ARM (aarch64, arm): no X11 needed, uses RKMPP/V4L2
v v
} else if target_os == "android" {
Vec::new()
} else { } else {
panic!( panic!(
"Unsupported OS: {}. Only Windows, Linux, and Android are supported.", "Unsupported OS: {}. Only Windows and Linux are supported.",
target_os target_os
); );
}; };
@@ -550,13 +372,7 @@ mod ffmpeg {
let ffi_header_path = ffmpeg_ram_dir.join("ffmpeg_ffi.h"); let ffi_header_path = ffmpeg_ram_dir.join("ffmpeg_ffi.h");
println!("cargo:rerun-if-changed={}", ffi_header_path.display()); println!("cargo:rerun-if-changed={}", ffi_header_path.display());
let ffi_header = ffi_header_path.to_string_lossy().to_string(); let ffi_header = ffi_header_path.to_string_lossy().to_string();
let mut bindings = bindgen::builder() let bindings = bindgen::builder().header(ffi_header).rustified_enum(".*");
.header(ffi_header)
.rustified_enum(".*");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") {
print_android_bindgen_env();
bindings = bindings.clang_args(android_clang_args());
}
bindings bindings
.generate() .generate()
.unwrap() .unwrap()
@@ -571,13 +387,7 @@ mod ffmpeg {
.join("ffmpeg_ram_ffi.h") .join("ffmpeg_ram_ffi.h")
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let mut bindings = bindgen::builder() let bindings = bindgen::builder().header(ffi_header).rustified_enum(".*");
.header(ffi_header)
.rustified_enum(".*");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") {
print_android_bindgen_env();
bindings = bindings.clang_args(android_clang_args());
}
bindings bindings
.generate() .generate()
.unwrap() .unwrap()
@@ -589,12 +399,13 @@ mod ffmpeg {
// RKMPP decode only exists on ARM builds where FFmpeg is compiled with RKMPP support. // RKMPP decode only exists on ARM builds where FFmpeg is compiled with RKMPP support.
// Avoid compiling this file on x86/x64 where `AV_HWDEVICE_TYPE_RKMPP` doesn't exist. // Avoid compiling this file on x86/x64 where `AV_HWDEVICE_TYPE_RKMPP` doesn't exist.
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); let enable_rkmpp = matches!(target_arch.as_str(), "aarch64" | "arm")
let enable_rkmpp = target_os != "android"
&& matches!(target_arch.as_str(), "aarch64" | "arm")
|| std::env::var_os("CARGO_FEATURE_RKMPP").is_some(); || std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
if enable_rkmpp { if enable_rkmpp {
builder.file(ffmpeg_ram_dir.join("ffmpeg_ram_decode.cpp")); builder.file(ffmpeg_ram_dir.join("ffmpeg_ram_decode.cpp"));
if enable_rkmpp {
builder.define("ONE_KVM_FFMPEG_RKMPP", None);
}
} else { } else {
println!( println!(
"cargo:info=Skipping ffmpeg_ram_decode.cpp (RKMPP) for arch {}", "cargo:info=Skipping ffmpeg_ram_decode.cpp (RKMPP) for arch {}",
@@ -647,9 +458,7 @@ mod ffmpeg {
.unwrap(); .unwrap();
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); let enable_rkmpp = matches!(target_arch.as_str(), "aarch64" | "arm")
let enable_rkmpp = target_os != "android"
&& matches!(target_arch.as_str(), "aarch64" | "arm")
|| std::env::var_os("CARGO_FEATURE_RKMPP").is_some(); || std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
if enable_rkmpp { if enable_rkmpp {
// Include RGA headers for NV16->NV12 conversion (RGA im2d API) // Include RGA headers for NV16->NV12 conversion (RGA im2d API)

View File

@@ -24,7 +24,7 @@ bool is_software_h264(const std::string &name) {
// Exclude all hardware encoders // Exclude all hardware encoders
static const char* hw_suffixes[] = { static const char* hw_suffixes[] = {
"nvenc", "amf", "qsv", "vaapi", "rkmpp", "nvenc", "amf", "qsv", "vaapi", "rkmpp",
"v4l2m2m", "videotoolbox", "mediacodec", "_mf" "v4l2m2m", "videotoolbox", "_mf"
}; };
for (const auto& suffix : hw_suffixes) { for (const auto& suffix : hw_suffixes) {
if (name.find(suffix) != std::string::npos) return false; if (name.find(suffix) != std::string::npos) return false;
@@ -37,7 +37,7 @@ bool is_software_hevc(const std::string &name) {
if (name != "hevc" && name != "libx265") return false; if (name != "hevc" && name != "libx265") return false;
static const char* hw_suffixes[] = { static const char* hw_suffixes[] = {
"nvenc", "amf", "qsv", "vaapi", "rkmpp", "nvenc", "amf", "qsv", "vaapi", "rkmpp",
"v4l2m2m", "videotoolbox", "mediacodec", "_mf" "v4l2m2m", "videotoolbox", "_mf"
}; };
for (const auto& suffix : hw_suffixes) { for (const auto& suffix : hw_suffixes) {
if (name.find(suffix) != std::string::npos) return false; if (name.find(suffix) != std::string::npos) return false;
@@ -100,13 +100,8 @@ void set_av_codec_ctx(AVCodecContext *c, const std::string &name, int kbs,
c->color_primaries = AVCOL_PRI_SMPTE170M; c->color_primaries = AVCOL_PRI_SMPTE170M;
c->color_trc = AVCOL_TRC_SMPTE170M; c->color_trc = AVCOL_TRC_SMPTE170M;
// WebRTC SDP advertises constrained baseline. Keep most hardware and software // WebRTC SDP advertises constrained baseline. Keep hardware and software
// encoders on the same browser-friendly H264 profile. Android MediaCodec is // encoders on the same browser-friendly H264 profile.
// deliberately excluded because older vendor OMX encoders can reject explicit
// profile/level combinations during configure().
if (name.find("mediacodec") != std::string::npos) {
return;
}
if (name.find("h264") != std::string::npos) { if (name.find("h264") != std::string::npos) {
c->profile = AV_PROFILE_H264_CONSTRAINED_BASELINE; c->profile = AV_PROFILE_H264_CONSTRAINED_BASELINE;
} else if (name.find("hevc") != std::string::npos) { } else if (name.find("hevc") != std::string::npos) {
@@ -310,9 +305,6 @@ bool set_quality(void *priv_data, const std::string &name, int quality) {
break; break;
} }
} }
// Do not force MediaCodec level here. Some Android TV vendor encoders,
// including older Amlogic OMX implementations, reject explicit level values
// even when they support the requested resolution and bitrate.
// libx264 software encoder presets // libx264 software encoder presets
if (is_software_h264(name)) { if (is_software_h264(name)) {
const char* preset = nullptr; const char* preset = nullptr;
@@ -387,7 +379,6 @@ bool set_rate_control(AVCodecContext *c, const std::string &name, int rc,
} }
return true; return true;
} }
if (name.find("qsv") != std::string::npos) { if (name.find("qsv") != std::string::npos) {
// https://github.com/LizardByte/Sunshine/blob/3e47cd3cc8fd37a7a88be82444ff4f3c0022856b/src/video.cpp#L1635 // https://github.com/LizardByte/Sunshine/blob/3e47cd3cc8fd37a7a88be82444ff4f3c0022856b/src/video.cpp#L1635
c->strict_std_compliance = FF_COMPLIANCE_UNOFFICIAL; c->strict_std_compliance = FF_COMPLIANCE_UNOFFICIAL;
@@ -395,9 +386,6 @@ bool set_rate_control(AVCodecContext *c, const std::string &name, int rc,
std::vector<CodecOptions> codecs = { std::vector<CodecOptions> codecs = {
{"nvenc", "rc", {{RC_CBR, "cbr"}, {RC_VBR, "vbr"}}}, {"nvenc", "rc", {{RC_CBR, "cbr"}, {RC_VBR, "vbr"}}},
{"amf", "rc", {{RC_CBR, "cbr"}, {RC_VBR, "vbr_latency"}}}, {"amf", "rc", {{RC_CBR, "cbr"}, {RC_VBR, "vbr_latency"}}},
{"mediacodec",
"bitrate_mode",
{{RC_CBR, "cbr"}, {RC_VBR, "vbr"}, {RC_CQ, "cq"}}},
// {"videotoolbox", "constant_bit_rate", {{RC_CBR, "1"}}}, // {"videotoolbox", "constant_bit_rate", {{RC_CBR, "1"}}},
}; };
@@ -412,13 +400,6 @@ bool set_rate_control(AVCodecContext *c, const std::string &name, int rc,
it->second + " failed, ret = " + av_err2str(ret)); it->second + " failed, ret = " + av_err2str(ret));
return false; return false;
} }
if (name.find("mediacodec") != std::string::npos) {
if (rc == RC_CQ) {
if (q >= 0 && q <= 51) {
c->global_quality = q;
}
}
}
} }
break; break;
} }
@@ -468,13 +449,6 @@ bool set_others(void *priv_data, const std::string &name) {
return false; return false;
} }
} }
if (name.find("mediacodec") != std::string::npos) {
if ((ret = av_opt_set_int(priv_data, "ndk_codec", 1, 0)) < 0) {
LOG_ERROR(std::string("mediacodec set ndk_codec failed, ret = ") +
av_err2str(ret));
return false;
}
}
// NOTE: Removed idr_interval = INT_MAX for VAAPI. // NOTE: Removed idr_interval = INT_MAX for VAAPI.
// This was disabling automatic keyframe generation. // This was disabling automatic keyframe generation.
// The encoder should respect c->gop_size for keyframe interval. // The encoder should respect c->gop_size for keyframe interval.

View File

@@ -1,4 +1,4 @@
// Minimal FFmpeg RAM MJPEG decoder (RKMPP only) -> NV12 in CPU memory. // FFmpeg RAM decoder with optional RKMPP hardware-frame support.
extern "C" { extern "C" {
#include <libavcodec/avcodec.h> #include <libavcodec/avcodec.h>
@@ -54,9 +54,11 @@ public:
thread_count_ = thread_count > 0 ? thread_count : 1; thread_count_ = thread_count > 0 ? thread_count : 1;
callback_ = callback; callback_ = callback;
#ifdef ONE_KVM_FFMPEG_RKMPP
if (name_.find("rkmpp") != std::string::npos) { if (name_.find("rkmpp") != std::string::npos) {
hw_device_type_ = AV_HWDEVICE_TYPE_RKMPP; hw_device_type_ = AV_HWDEVICE_TYPE_RKMPP;
} }
#endif
} }
~FFmpegRamDecoder() {} ~FFmpegRamDecoder() {}
@@ -137,13 +139,6 @@ public:
av_buffer_unref(&frames_ref); av_buffer_unref(&frames_ref);
} }
if (name_.find("mediacodec") != std::string::npos && c_->priv_data) {
if ((ret = av_opt_set_int(c_->priv_data, "ndk_codec", 1, 0)) < 0) {
LOG_WARN(std::string("mediacodec decoder ndk_codec option failed, ret = ") +
av_err2str(ret));
}
}
if ((ret = avcodec_open2(c_, codec, NULL)) < 0) { if ((ret = avcodec_open2(c_, codec, NULL)) < 0) {
set_last_error(std::string("avcodec_open2 failed, ret = ") + av_err2str(ret)); set_last_error(std::string("avcodec_open2 failed, ret = ") + av_err2str(ret));
return false; return false;

View File

@@ -11,6 +11,7 @@ extern "C" {
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <string>
#include "common.h" #include "common.h"
@@ -21,6 +22,13 @@ extern "C" {
#include "win.h" #include "win.h"
#endif #endif
static thread_local std::string g_encoder_last_error;
static void set_encoder_last_error(const std::string &message) {
g_encoder_last_error = message;
LOG_ERROR(message);
}
static int calculate_offset_length(int pix_fmt, int height, const int *linesize, static int calculate_offset_length(int pix_fmt, int height, const int *linesize,
int *offset, int *length) { int *offset, int *length) {
switch (pix_fmt) { switch (pix_fmt) {
@@ -122,7 +130,6 @@ public:
AVFrame *frame_ = NULL; AVFrame *frame_ = NULL;
AVPacket *pkt_ = NULL; AVPacket *pkt_ = NULL;
std::string name_; std::string name_;
std::string mc_name_; // for mediacodec
int width_ = 0; int width_ = 0;
int height_ = 0; int height_ = 0;
@@ -145,14 +152,12 @@ public:
AVPixelFormat hw_pixfmt_ = AV_PIX_FMT_NONE; AVPixelFormat hw_pixfmt_ = AV_PIX_FMT_NONE;
AVBufferRef *hw_device_ctx_ = NULL; AVBufferRef *hw_device_ctx_ = NULL;
AVFrame *hw_frame_ = NULL; AVFrame *hw_frame_ = NULL;
AVFrame *borrowed_frame_ = NULL;
FFmpegRamEncoder(const char *name, const char *mc_name, int width, int height, FFmpegRamEncoder(const char *name, int width, int height,
int pixfmt, int align, int fps, int gop, int rc, int quality, int pixfmt, int align, int fps, int gop, int rc, int quality,
int kbs, int q, int thread_count, int gpu, int kbs, int q, int thread_count, int gpu,
RamEncodeCallback callback) { RamEncodeCallback callback) {
name_ = name; name_ = name;
mc_name_ = mc_name ? mc_name : "";
width_ = width; width_ = width;
height_ = height; height_ = height;
pixfmt_ = (AVPixelFormat)pixfmt; pixfmt_ = (AVPixelFormat)pixfmt;
@@ -184,12 +189,13 @@ public:
} }
bool init(int *linesize, int *offset, int *length) { bool init(int *linesize, int *offset, int *length) {
g_encoder_last_error.clear();
const AVCodec *codec = NULL; const AVCodec *codec = NULL;
int ret; int ret;
if (!(codec = avcodec_find_encoder_by_name(name_.c_str()))) { if (!(codec = avcodec_find_encoder_by_name(name_.c_str()))) {
LOG_ERROR(std::string("Codec ") + name_ + " not found"); set_encoder_last_error(std::string("Codec ") + name_ + " not found");
return false; return false;
} }
@@ -252,12 +258,6 @@ public:
LOG_ERROR(std::string("Could not allocate video packet")); LOG_ERROR(std::string("Could not allocate video packet"));
return false; return false;
} }
borrowed_frame_ = av_frame_alloc();
if (!borrowed_frame_) {
LOG_ERROR(std::string("Could not allocate borrowed video frame"));
return false;
}
/* resolution must be a multiple of two */ /* resolution must be a multiple of two */
c_->width = width_; c_->width = width_;
c_->height = height_; c_->height = height_;
@@ -280,19 +280,9 @@ public:
util_encode::set_gpu(c_->priv_data, name_, gpu_); util_encode::set_gpu(c_->priv_data, name_, gpu_);
util_encode::force_hw(c_->priv_data, name_); util_encode::force_hw(c_->priv_data, name_);
util_encode::set_others(c_->priv_data, name_); util_encode::set_others(c_->priv_data, name_);
if (name_.find("mediacodec") != std::string::npos) {
if (mc_name_.length() > 0) {
LOG_INFO(std::string("mediacodec codec_name: ") + mc_name_);
if ((ret = av_opt_set(c_->priv_data, "codec_name", mc_name_.c_str(),
0)) < 0) {
LOG_ERROR(std::string("mediacodec codec_name failed, ret = ") + av_err2str(ret));
}
}
}
if ((ret = avcodec_open2(c_, codec, NULL)) < 0) { if ((ret = avcodec_open2(c_, codec, NULL)) < 0) {
LOG_ERROR(std::string("avcodec_open2 failed, ret = ") + av_err2str(ret) + set_encoder_last_error(std::string("avcodec_open2 failed, ret = ") +
", name: " + name_); av_err2str(ret) + ", name: " + name_);
return false; return false;
} }
@@ -310,14 +300,6 @@ public:
int encode(const uint8_t *data, int length, const void *obj, uint64_t ms) { int encode(const uint8_t *data, int length, const void *obj, uint64_t ms) {
int ret; int ret;
if (can_borrow_input(length)) {
AVFrame *borrowed = wrap_borrowed_frame(data, length);
if (!borrowed) {
return -1;
}
return do_encode(borrowed, obj, ms);
}
if ((ret = av_frame_make_writable(frame_)) != 0) { if ((ret = av_frame_make_writable(frame_)) != 0) {
LOG_ERROR(std::string("av_frame_make_writable failed, ret = ") + av_err2str(ret)); LOG_ERROR(std::string("av_frame_make_writable failed, ret = ") + av_err2str(ret));
return ret; return ret;
@@ -353,8 +335,6 @@ public:
av_frame_free(&frame_); av_frame_free(&frame_);
if (hw_frame_) if (hw_frame_)
av_frame_free(&hw_frame_); av_frame_free(&hw_frame_);
if (borrowed_frame_)
av_frame_free(&borrowed_frame_);
if (hw_device_ctx_) if (hw_device_ctx_)
av_buffer_unref(&hw_device_ctx_); av_buffer_unref(&hw_device_ctx_);
if (c_) if (c_)
@@ -613,65 +593,6 @@ private:
return 0; return 0;
} }
bool can_borrow_input(int data_length) const {
if (hw_device_type_ != AV_HWDEVICE_TYPE_NONE) {
return false;
}
if (name_.find("mediacodec") == std::string::npos) {
return false;
}
switch (pixfmt_) {
case AV_PIX_FMT_NV12:
case AV_PIX_FMT_NV21:
return data_length >= width_ * height_ * 3 / 2;
case AV_PIX_FMT_YUV420P:
return data_length >= width_ * height_ * 3 / 2;
default:
return false;
}
}
AVFrame *wrap_borrowed_frame(const uint8_t *data, int data_length) {
if (!borrowed_frame_) {
return NULL;
}
av_frame_unref(borrowed_frame_);
borrowed_frame_->format = pixfmt_;
borrowed_frame_->width = width_;
borrowed_frame_->height = height_;
const int y_size = width_ * height_;
const int uv_size = y_size / 4;
switch (pixfmt_) {
case AV_PIX_FMT_NV12:
case AV_PIX_FMT_NV21:
if (data_length < y_size + y_size / 2) {
LOG_ERROR("wrap_borrowed_frame: NV12/NV21 data length error");
return NULL;
}
borrowed_frame_->data[0] = const_cast<uint8_t *>(data);
borrowed_frame_->data[1] = const_cast<uint8_t *>(data + y_size);
borrowed_frame_->linesize[0] = width_;
borrowed_frame_->linesize[1] = width_;
break;
case AV_PIX_FMT_YUV420P:
if (data_length < y_size + uv_size * 2) {
LOG_ERROR("wrap_borrowed_frame: YUV420P data length error");
return NULL;
}
borrowed_frame_->data[0] = const_cast<uint8_t *>(data);
borrowed_frame_->data[1] = const_cast<uint8_t *>(data + y_size);
borrowed_frame_->data[2] = const_cast<uint8_t *>(data + y_size + uv_size);
borrowed_frame_->linesize[0] = width_;
borrowed_frame_->linesize[1] = width_ / 2;
borrowed_frame_->linesize[2] = width_ / 2;
break;
default:
return NULL;
}
return borrowed_frame_;
}
int bytes_per_pixel(int pix_fmt) { int bytes_per_pixel(int pix_fmt) {
switch (pix_fmt) { switch (pix_fmt) {
case AV_PIX_FMT_YUYV422: case AV_PIX_FMT_YUYV422:
@@ -690,7 +611,7 @@ private:
} // namespace } // namespace
extern "C" FFmpegRamEncoder * extern "C" FFmpegRamEncoder *
ffmpeg_ram_new_encoder(const char *name, const char *mc_name, int width, ffmpeg_ram_new_encoder(const char *name, int width,
int height, int pixfmt, int align, int fps, int gop, int height, int pixfmt, int align, int fps, int gop,
int rc, int quality, int kbs, int q, int thread_count, int rc, int quality, int kbs, int q, int thread_count,
int gpu, int *linesize, int *offset, int *length, int gpu, int *linesize, int *offset, int *length,
@@ -699,8 +620,8 @@ ffmpeg_ram_new_encoder(const char *name, const char *mc_name, int width,
try { try {
auto try_create = [&](int attempt_rc, int attempt_kbs) { auto try_create = [&](int attempt_rc, int attempt_kbs) {
FFmpegRamEncoder *candidate = new FFmpegRamEncoder( FFmpegRamEncoder *candidate = new FFmpegRamEncoder(
name, mc_name, width, height, pixfmt, align, fps, gop, attempt_rc, name, width, height, pixfmt, align, fps, gop, attempt_rc, quality,
quality, attempt_kbs, q, thread_count, gpu, callback); attempt_kbs, q, thread_count, gpu, callback);
if (candidate && candidate->init(linesize, offset, length)) { if (candidate && candidate->init(linesize, offset, length)) {
return candidate; return candidate;
} }
@@ -802,3 +723,7 @@ extern "C" void ffmpeg_ram_request_keyframe(FFmpegRamEncoder *encoder) {
LOG_ERROR(std::string("ffmpeg_ram_request_keyframe failed, ") + std::string(e.what())); LOG_ERROR(std::string("ffmpeg_ram_request_keyframe failed, ") + std::string(e.what()));
} }
} }
extern "C" const char *ffmpeg_ram_encoder_last_error(void) {
return g_encoder_last_error.c_str();
}

View File

@@ -13,7 +13,7 @@ typedef void (*RamEncodePacketCallback)(void *packet, const uint8_t *data,
typedef void (*RamDecodeCallback)(const uint8_t *data, int len, int width, typedef void (*RamDecodeCallback)(const uint8_t *data, int len, int width,
int height, int pixfmt, const void *obj); int height, int pixfmt, const void *obj);
void *ffmpeg_ram_new_encoder(const char *name, const char *mc_name, int width, void *ffmpeg_ram_new_encoder(const char *name, int width,
int height, int pixfmt, int align, int fps, int height, int pixfmt, int align, int fps,
int gop, int rc, int quality, int kbs, int q, int gop, int rc, int quality, int kbs, int q,
int thread_count, int gpu, int *linesize, int thread_count, int gpu, int *linesize,
@@ -31,6 +31,7 @@ int ffmpeg_ram_get_linesize_offset_length(int pix_fmt, int width, int height,
int *length); int *length);
int ffmpeg_ram_set_bitrate(void *encoder, int kbs); int ffmpeg_ram_set_bitrate(void *encoder, int kbs);
void ffmpeg_ram_request_keyframe(void *encoder); void ffmpeg_ram_request_keyframe(void *encoder);
const char *ffmpeg_ram_encoder_last_error(void);
void *ffmpeg_ram_new_decoder(const char *name, int width, int height, void *ffmpeg_ram_new_decoder(const char *name, int width, int height,
int sw_pixfmt, int thread_count, int sw_pixfmt, int thread_count,

View File

@@ -13,7 +13,7 @@ pub enum Driver {
FFMPEG, FFMPEG,
} }
#[cfg(any(windows, target_os = "linux", target_os = "android"))] #[cfg(any(windows, target_os = "linux"))]
pub(crate) fn supported_gpu(_encode: bool) -> (bool, bool, bool) { pub(crate) fn supported_gpu(_encode: bool) -> (bool, bool, bool) {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use std::ffi::c_int; use std::ffi::c_int;
@@ -39,8 +39,6 @@ pub(crate) fn supported_gpu(_encode: bool) -> (bool, bool, bool) {
linux_support_amd() == 0, linux_support_amd() == 0,
linux_support_intel() == 0, linux_support_intel() == 0,
); );
#[cfg(target_os = "android")]
return (false, false, false);
#[allow(unreachable_code)] #[allow(unreachable_code)]
(false, false, false) (false, false, false)
} }

View File

@@ -114,7 +114,7 @@ impl Drop for Decoder {
} }
} }
fn last_error_message() -> String { pub fn last_error_message() -> String {
unsafe { unsafe {
let ptr = ffmpeg_ram_last_error(); let ptr = ffmpeg_ram_last_error();
if ptr.is_null() { if ptr.is_null() {

View File

@@ -1,10 +1,12 @@
#[cfg(feature = "bytes")]
use crate::ffmpeg_ram::{ffmpeg_ram_encode_packet, ffmpeg_ram_free_packet};
use crate::{ use crate::{
common::DataFormat::{self, *}, common::DataFormat::{self, *},
ffmpeg::{init_av_log, AVPixelFormat}, ffmpeg::{init_av_log, AVPixelFormat},
ffmpeg_ram::{ ffmpeg_ram::{
ffmpeg_linesize_offset_length, ffmpeg_ram_encode, ffmpeg_ram_encode_packet, ffmpeg_linesize_offset_length, ffmpeg_ram_encode, ffmpeg_ram_encoder_last_error,
ffmpeg_ram_free_encoder, ffmpeg_ram_free_packet, ffmpeg_ram_new_encoder, ffmpeg_ram_free_encoder, ffmpeg_ram_new_encoder, ffmpeg_ram_request_keyframe,
ffmpeg_ram_request_keyframe, ffmpeg_ram_set_bitrate, CodecInfo, AV_NUM_DATA_POINTERS, ffmpeg_ram_set_bitrate, CodecInfo, AV_NUM_DATA_POINTERS,
}, },
}; };
#[cfg(feature = "bytes")] #[cfg(feature = "bytes")]
@@ -17,7 +19,7 @@ use std::{
slice, slice,
}; };
#[cfg(any(windows, target_os = "linux", target_os = "android"))] #[cfg(any(windows, target_os = "linux"))]
use crate::common::Driver; use crate::common::Driver;
/// Timeout for encoder test in milliseconds /// Timeout for encoder test in milliseconds
@@ -28,7 +30,6 @@ const PRIORITY_AMF: i32 = 2;
const PRIORITY_RKMPP: i32 = 3; const PRIORITY_RKMPP: i32 = 3;
const PRIORITY_VAAPI: i32 = 4; const PRIORITY_VAAPI: i32 = 4;
const PRIORITY_V4L2M2M: i32 = 5; const PRIORITY_V4L2M2M: i32 = 5;
const PRIORITY_MEDIACODEC: i32 = 2;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct CandidateCodecSpec { struct CandidateCodecSpec {
@@ -95,32 +96,12 @@ fn linux_support_v4l2m2m() -> bool {
false false
} }
#[cfg(any(windows, target_os = "linux", target_os = "android"))] #[cfg(any(windows, target_os = "linux"))]
fn enumerate_candidate_codecs(ctx: &EncodeContext) -> Vec<CodecInfo> { fn enumerate_candidate_codecs(ctx: &EncodeContext) -> Vec<CodecInfo> {
use log::debug; use log::debug;
let mut codecs = Vec::new(); let mut codecs = Vec::new();
if cfg!(target_os = "android") {
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "h264_mediacodec",
format: H264,
priority: PRIORITY_MEDIACODEC,
},
);
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "hevc_mediacodec",
format: H265,
priority: PRIORITY_MEDIACODEC,
},
);
return codecs;
}
let contains = |_vendor: Driver, _format: DataFormat| { let contains = |_vendor: Driver, _format: DataFormat| {
// Without VRAM feature, we can't check SDK availability. // Without VRAM feature, we can't check SDK availability.
// Keep the prefilter coarse and let FFmpeg validation do the real check. // Keep the prefilter coarse and let FFmpeg validation do the real check.
@@ -281,13 +262,7 @@ struct ProbePolicy {
impl ProbePolicy { impl ProbePolicy {
fn for_codec(codec_name: &str) -> Self { fn for_codec(codec_name: &str) -> Self {
if codec_name.contains("mediacodec") { if codec_name.contains("amf") {
Self {
max_attempts: 30,
request_keyframe: true,
accept_any_output: true,
}
} else if codec_name.contains("amf") {
Self { Self {
max_attempts: 5, max_attempts: 5,
request_keyframe: true, request_keyframe: true,
@@ -340,7 +315,8 @@ fn log_failed_probe_attempt(
if frames.is_empty() { if frames.is_empty() {
trace!( trace!(
"Encoder {} test produced no output on attempt {}", "Encoder {} test produced no output on attempt {}",
codec_name, attempt codec_name,
attempt
); );
} else { } else {
debug!( debug!(
@@ -373,7 +349,6 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo
let test_ctx = EncodeContext { let test_ctx = EncodeContext {
name: codec.name.clone(), name: codec.name.clone(),
mc_name: codec.mc_name.clone(),
..ctx.clone() ..ctx.clone()
}; };
@@ -447,10 +422,6 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo
fn add_software_fallback(codecs: &mut Vec<CodecInfo>) { fn add_software_fallback(codecs: &mut Vec<CodecInfo>) {
use log::debug; use log::debug;
if cfg!(target_os = "android") {
return;
}
for fallback in CodecInfo::soft().into_vec() { for fallback in CodecInfo::soft().into_vec() {
if !codecs.iter().any(|codec| codec.format == fallback.format) { if !codecs.iter().any(|codec| codec.format == fallback.format) {
debug!( debug!(
@@ -465,7 +436,6 @@ fn add_software_fallback(codecs: &mut Vec<CodecInfo>) {
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct EncodeContext { pub struct EncodeContext {
pub name: String, pub name: String,
pub mc_name: Option<String>,
pub width: i32, pub width: i32,
pub height: i32, pub height: i32,
pub pixfmt: i32, pub pixfmt: i32,
@@ -550,10 +520,8 @@ impl Encoder {
.unwrap_or("-1".to_owned()) .unwrap_or("-1".to_owned())
.parse() .parse()
.unwrap_or(-1); .unwrap_or(-1);
let mc_name = ctx.mc_name.clone().unwrap_or_default();
let codec = ffmpeg_ram_new_encoder( let codec = ffmpeg_ram_new_encoder(
CString::new(ctx.name.as_str()).map_err(|_| ())?.as_ptr(), CString::new(ctx.name.as_str()).map_err(|_| ())?.as_ptr(),
CString::new(mc_name.as_str()).map_err(|_| ())?.as_ptr(),
ctx.width, ctx.width,
ctx.height, ctx.height,
ctx.pixfmt, ctx.pixfmt,
@@ -573,6 +541,10 @@ impl Encoder {
); );
if codec.is_null() { if codec.is_null() {
let message = encoder_last_error_message();
if !message.is_empty() {
log::error!("ffmpeg_ram_new_encoder failed: {}", message);
}
return Err(()); return Err(());
} }
@@ -698,11 +670,11 @@ impl Encoder {
pub fn available_encoders(ctx: EncodeContext, _sdk: Option<String>) -> Vec<CodecInfo> { pub fn available_encoders(ctx: EncodeContext, _sdk: Option<String>) -> Vec<CodecInfo> {
use log::debug; use log::debug;
if !(cfg!(windows) || cfg!(target_os = "linux") || cfg!(target_os = "android")) { if !(cfg!(windows) || cfg!(target_os = "linux")) {
return vec![]; return vec![];
} }
let mut res = vec![]; let mut res = vec![];
#[cfg(any(windows, target_os = "linux", target_os = "android"))] #[cfg(any(windows, target_os = "linux"))]
let codecs = enumerate_candidate_codecs(&ctx); let codecs = enumerate_candidate_codecs(&ctx);
if let Ok(yuv) = Encoder::dummy_yuv(ctx.clone()) { if let Ok(yuv) = Encoder::dummy_yuv(ctx.clone()) {
@@ -736,6 +708,16 @@ impl Encoder {
} }
} }
pub fn encoder_last_error_message() -> String {
unsafe {
let ptr = ffmpeg_ram_encoder_last_error();
if ptr.is_null() {
return String::new();
}
std::ffi::CStr::from_ptr(ptr).to_string_lossy().to_string()
}
}
impl Drop for Encoder { impl Drop for Encoder {
fn drop(&mut self) { fn drop(&mut self) {
unsafe { unsafe {

View File

@@ -9,18 +9,12 @@ use std::ffi::c_int;
include!(concat!(env!("OUT_DIR"), "/ffmpeg_ram_ffi.rs")); include!(concat!(env!("OUT_DIR"), "/ffmpeg_ram_ffi.rs"));
#[cfg(all( #[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))]
any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"),
not(target_os = "android")
))]
pub mod decode; pub mod decode;
// Provide a small stub on non-ARM builds so dependents can still compile, but decoder // Provide a small stub on non-ARM builds so dependents can still compile, but decoder
// construction will fail (since the C++ RKMPP decoder isn't built/linked). // construction will fail (since the C++ RKMPP decoder isn't built/linked).
#[cfg(any( #[cfg(not(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp")))]
not(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp")),
target_os = "android"
))]
pub mod decode { pub mod decode {
use crate::ffmpeg::AVPixelFormat; use crate::ffmpeg::AVPixelFormat;
@@ -69,8 +63,6 @@ pub enum Priority {
#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] #[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)]
pub struct CodecInfo { pub struct CodecInfo {
pub name: String, pub name: String,
#[serde(skip)]
pub mc_name: Option<String>,
pub format: DataFormat, pub format: DataFormat,
pub priority: i32, pub priority: i32,
pub hwdevice: AVHWDeviceType, pub hwdevice: AVHWDeviceType,
@@ -80,7 +72,6 @@ impl Default for CodecInfo {
fn default() -> Self { fn default() -> Self {
Self { Self {
name: Default::default(), name: Default::default(),
mc_name: Default::default(),
format: DataFormat::H264, format: DataFormat::H264,
priority: Default::default(), priority: Default::default(),
hwdevice: AVHWDeviceType::AV_HWDEVICE_TYPE_NONE, hwdevice: AVHWDeviceType::AV_HWDEVICE_TYPE_NONE,
@@ -93,28 +84,24 @@ impl CodecInfo {
match format { match format {
H264 => Some(CodecInfo { H264 => Some(CodecInfo {
name: "libx264".to_owned(), name: "libx264".to_owned(),
mc_name: Default::default(),
format: H264, format: H264,
hwdevice: AV_HWDEVICE_TYPE_NONE, hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _, priority: Priority::Soft as _,
}), }),
H265 => Some(CodecInfo { H265 => Some(CodecInfo {
name: "libx265".to_owned(), name: "libx265".to_owned(),
mc_name: Default::default(),
format: H265, format: H265,
hwdevice: AV_HWDEVICE_TYPE_NONE, hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _, priority: Priority::Soft as _,
}), }),
VP8 => Some(CodecInfo { VP8 => Some(CodecInfo {
name: "libvpx".to_owned(), name: "libvpx".to_owned(),
mc_name: Default::default(),
format: VP8, format: VP8,
hwdevice: AV_HWDEVICE_TYPE_NONE, hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _, priority: Priority::Soft as _,
}), }),
VP9 => Some(CodecInfo { VP9 => Some(CodecInfo {
name: "libvpx-vp9".to_owned(), name: "libvpx-vp9".to_owned(),
mc_name: Default::default(),
format: VP9, format: VP9,
hwdevice: AV_HWDEVICE_TYPE_NONE, hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _, priority: Priority::Soft as _,

View File

@@ -2,10 +2,7 @@
pub mod capture; pub mod capture;
pub mod common; pub mod common;
pub mod ffmpeg; pub mod ffmpeg;
#[cfg(all( #[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))]
any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"),
not(target_os = "android")
))]
pub mod ffmpeg_hw; pub mod ffmpeg_hw;
pub mod ffmpeg_ram; pub mod ffmpeg_ram;

View File

@@ -1,52 +0,0 @@
// This file is generated by cargo_embargo.
// Do not modify this file because the changes will be overridden on upgrade.
package {
default_applicable_licenses: ["external_rust_crates_v4l2r_license"],
}
rust_library {
name: "libv4l2r",
crate_name: "v4l2r",
cargo_env_compat: true,
cargo_pkg_version: "0.0.7",
crate_root: "src/lib.rs",
edition: "2021",
rustlibs: [
"libbitflags",
"liblog_rust",
"libnix",
"libthiserror",
],
proc_macros: ["libenumn"],
apex_available: [
"//apex_available:platform",
"//apex_available:anyapex",
],
product_available: true,
vendor_available: true,
// Bindgen-generated bindings of our local videodev2.h.
srcs: [":libv4l2r_bindgen"],
}
rust_test {
name: "v4l2r_test_src_lib",
crate_name: "v4l2r",
cargo_env_compat: true,
cargo_pkg_version: "0.0.7",
crate_root: "src/lib.rs",
test_suites: ["general-tests"],
auto_gen_config: true,
edition: "2021",
rustlibs: [
"libbitflags",
"liblog_rust",
"libnix",
"libthiserror",
],
proc_macros: ["libenumn"],
// Bindgen-generated bindings of our local videodev2.h.
srcs: [":libv4l2r_bindgen"],
}

View File

@@ -49,7 +49,7 @@ version = "0.1.6"
version = "0.4.14" version = "0.4.14"
[dependencies.nix] [dependencies.nix]
version = "0.28" version = "0.31"
features = [ features = [
"ioctl", "ioctl",
"mman", "mman",
@@ -59,7 +59,7 @@ features = [
] ]
[dependencies.thiserror] [dependencies.thiserror]
version = "1.0" version = "2"
[build-dependencies.bindgen] [build-dependencies.bindgen]
version = "0.70.1" version = "0.72"

View File

@@ -18,11 +18,11 @@ arch64 = []
arch32 = [] arch32 = []
[dependencies] [dependencies]
nix = { version = "0.28", features = ["ioctl", "mman", "poll", "fs", "event"] } nix = { version = "0.31", features = ["ioctl", "mman", "poll", "fs", "event"] }
bitflags = "2.4" bitflags = "2.4"
thiserror = "1.0" thiserror = "2"
log = "0.4.14" log = "0.4.14"
enumn = "0.1.6" enumn = "0.1.6"
[build-dependencies] [build-dependencies]
bindgen = "0.70.1" bindgen = "0.72"

View File

@@ -17,7 +17,3 @@ parts are intentionally removed here so this dependency stays scoped to capture.
`cargo build` generates V4L2 bindings from the vendored Linux UAPI headers in `cargo build` generates V4L2 bindings from the vendored Linux UAPI headers in
`include/`. `include/`.
For Android targets, the build script uses the Android NDK sysroot. Set one of
`ANDROID_NDK_HOME`, `ANDROID_NDK_ROOT`, `NDK_HOME`, `ANDROID_HOME`, or
`ANDROID_SDK_ROOT` if the NDK cannot be found automatically.

View File

@@ -1,15 +1,15 @@
// This file defines the customizations to the bindgen builder used to generate the v4l2r // This file defines the customizations to the bindgen builder used to generate the v4l2r
// bindings. // bindings.
// //
// It is meant to be included from `lib/build.rs` and `android/build.rs`. // It is meant to be included from `build.rs`.
#[derive(Debug)] #[derive(Debug)]
/// Workaround for https://github.com/rust-lang/rust-bindgen/issues/753. /// Workaround for https://github.com/rust-lang/rust-bindgen/issues/753.
pub struct Fix753; pub struct Fix753;
impl bindgen::callbacks::ParseCallbacks for Fix753 { impl bindgen::callbacks::ParseCallbacks for Fix753 {
fn item_name(&self, original_item_name: &str) -> Option<String> { fn item_name(&self, item_info: bindgen::callbacks::ItemInfo<'_>) -> Option<String> {
Some(original_item_name.trim_start_matches("Fix753_").to_owned()) Some(item_info.name.trim_start_matches("Fix753_").to_owned())
} }
} }

View File

@@ -3,7 +3,7 @@ use std::path::PathBuf;
include!("bindgen.rs"); include!("bindgen.rs");
/// Vendored Linux UAPI include root used for non-Android targets. /// Vendored Linux UAPI include root.
const VENDORED_INCLUDE_DIR: &str = "include"; const VENDORED_INCLUDE_DIR: &str = "include";
/// Wrapper file to use as input of bindgen. /// Wrapper file to use as input of bindgen.
@@ -13,28 +13,16 @@ const WRAPPER_H: &str = "v4l2r_wrapper.h";
const FIX753_H: &str = "fix753.h"; const FIX753_H: &str = "fix753.h";
fn main() { fn main() {
let target = env::var("TARGET").unwrap_or_default(); let include_root =
let is_android = target.contains("android");
let include_root = if is_android {
android_sysroot().join("usr/include")
} else {
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("`CARGO_MANIFEST_DIR` is not set")) PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("`CARGO_MANIFEST_DIR` is not set"))
.join(VENDORED_INCLUDE_DIR) .join(VENDORED_INCLUDE_DIR);
};
let videodev2_h = include_root.join("linux/videodev2.h"); let videodev2_h = include_root.join("linux/videodev2.h");
println!("cargo::rerun-if-env-changed=ANDROID_NDK_HOME");
println!("cargo::rerun-if-env-changed=ANDROID_NDK_ROOT");
println!("cargo::rerun-if-env-changed=NDK_HOME");
println!("cargo::rerun-if-env-changed=ANDROID_HOME");
println!("cargo::rerun-if-env-changed=ANDROID_SDK_ROOT");
println!("cargo::rerun-if-env-changed=CARGO_NDK_PLATFORM");
println!("cargo::rerun-if-changed={}", videodev2_h.display()); println!("cargo::rerun-if-changed={}", videodev2_h.display());
println!("cargo::rerun-if-changed={}", FIX753_H); println!("cargo::rerun-if-changed={}", FIX753_H);
println!("cargo::rerun-if-changed={}", WRAPPER_H); println!("cargo::rerun-if-changed={}", WRAPPER_H);
let mut clang_args = vec![ let clang_args = vec![
format!("-I{}", include_root.display()), format!("-I{}", include_root.display()),
#[cfg(all(feature = "arch64", not(feature = "arch32")))] #[cfg(all(feature = "arch64", not(feature = "arch32")))]
"--target=x86_64-linux-gnu".into(), "--target=x86_64-linux-gnu".into(),
@@ -42,10 +30,6 @@ fn main() {
"--target=i686-linux-gnu".into(), "--target=i686-linux-gnu".into(),
]; ];
if is_android {
clang_args.extend(android_clang_args(&target));
}
let bindings = v4l2r_bindgen_builder(bindgen::Builder::default()) let bindings = v4l2r_bindgen_builder(bindgen::Builder::default())
.header(WRAPPER_H) .header(WRAPPER_H)
.clang_args(clang_args) .clang_args(clang_args)
@@ -57,105 +41,3 @@ fn main() {
.write_to_file(out_path.join("bindings.rs")) .write_to_file(out_path.join("bindings.rs"))
.expect("Couldn't write bindings!"); .expect("Couldn't write bindings!");
} }
fn android_clang_args(target: &str) -> Vec<String> {
let ndk = android_ndk_home();
let toolchain = ndk.join("toolchains/llvm/prebuilt").join(host_tag());
let sysroot = toolchain.join("sysroot");
let clang_include = toolchain
.join("lib/clang")
.join(clang_version(&toolchain))
.join("include");
let api = env::var("CARGO_NDK_PLATFORM")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(21);
let clang_target = android_clang_target(target);
vec![
format!("--target={clang_target}"),
format!("--sysroot={}", sysroot.display()),
format!("-D__ANDROID_API__={api}"),
format!("-isystem{}", clang_include.display()),
format!("-isystem{}", sysroot.join("usr/include").display()),
format!(
"-isystem{}",
sysroot.join("usr/include").join(clang_target).display()
),
]
}
fn android_clang_target(target: &str) -> &'static str {
match target {
"aarch64-linux-android" => "aarch64-linux-android",
"armv7-linux-androideabi" => "armv7a-linux-androideabi",
"i686-linux-android" => "i686-linux-android",
"x86_64-linux-android" => "x86_64-linux-android",
other => panic!("unsupported Android target for v4l2r bindgen: {other}"),
}
}
fn android_sysroot() -> PathBuf {
android_ndk_home()
.join("toolchains/llvm/prebuilt")
.join(host_tag())
.join("sysroot")
}
fn android_ndk_home() -> PathBuf {
for key in ["ANDROID_NDK_HOME", "ANDROID_NDK_ROOT", "NDK_HOME"] {
if let Ok(value) = env::var(key) {
return PathBuf::from(value);
}
}
for key in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
if let Ok(value) = env::var(key) {
let ndk_dir = PathBuf::from(value).join("ndk");
if let Some(newest) = newest_child_dir(&ndk_dir) {
return newest;
}
}
}
panic!(
"v4l2r Android bindgen requires ANDROID_NDK_HOME, ANDROID_NDK_ROOT, NDK_HOME, \
or ANDROID_HOME/ANDROID_SDK_ROOT with an ndk directory"
);
}
fn newest_child_dir(path: &PathBuf) -> Option<PathBuf> {
let mut entries = std::fs::read_dir(path)
.ok()?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>();
entries.sort();
entries.pop()
}
fn host_tag() -> &'static str {
if cfg!(target_os = "linux") {
"linux-x86_64"
} else if cfg!(target_os = "macos") {
"darwin-x86_64"
} else if cfg!(target_os = "windows") {
"windows-x86_64"
} else {
panic!("unsupported host OS for Android NDK");
}
}
fn clang_version(toolchain: &PathBuf) -> String {
let clang_dir = toolchain.join("lib/clang");
let mut entries = std::fs::read_dir(&clang_dir)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", clang_dir.display()))
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
entries.sort();
entries
.pop()
.unwrap_or_else(|| panic!("no clang resource directory in {}", clang_dir.display()))
}

View File

@@ -2,6 +2,7 @@ use crate::ioctl::ioctl_and_convert;
use crate::ioctl::IoctlConvertError; use crate::ioctl::IoctlConvertError;
use crate::ioctl::IoctlConvertResult; use crate::ioctl::IoctlConvertResult;
use crate::ioctl::UncheckedV4l2Buffer; use crate::ioctl::UncheckedV4l2Buffer;
use crate::memory::MemoryType;
use crate::QueueType; use crate::QueueType;
use std::convert::TryFrom; use std::convert::TryFrom;
@@ -51,12 +52,13 @@ pub type DqBufError<CE> = IoctlConvertError<DqBufIoctlError, CE>;
pub type DqBufResult<O, CE> = IoctlConvertResult<O, DqBufIoctlError, CE>; pub type DqBufResult<O, CE> = IoctlConvertResult<O, DqBufIoctlError, CE>;
/// Safe wrapper around the `VIDIOC_DQBUF` ioctl. /// Safe wrapper around the `VIDIOC_DQBUF` ioctl.
pub fn dqbuf<O>(fd: &impl AsRawFd, queue: QueueType) -> DqBufResult<O, O::Error> pub fn dqbuf<O>(fd: &impl AsRawFd, queue: QueueType, memory: MemoryType) -> DqBufResult<O, O::Error>
where where
O: TryFrom<UncheckedV4l2Buffer>, O: TryFrom<UncheckedV4l2Buffer>,
O::Error: std::fmt::Debug, O::Error: std::fmt::Debug,
{ {
let mut v4l2_buf = UncheckedV4l2Buffer::new_for_querybuf(queue, None); let mut v4l2_buf = UncheckedV4l2Buffer::new_for_querybuf(queue, None);
v4l2_buf.0.memory = memory as u32;
ioctl_and_convert( ioctl_and_convert(
unsafe { ioctl::vidioc_dqbuf(fd.as_raw_fd(), v4l2_buf.as_mut()) } unsafe { ioctl::vidioc_dqbuf(fd.as_raw_fd(), v4l2_buf.as_mut()) }

View File

@@ -1,9 +1,3 @@
#ifdef __ANDROID__
#include <stddef.h>
#include <stdint.h>
#include <sys/types.h>
#endif
#include <linux/videodev2.h> #include <linux/videodev2.h>
#define MARK_FIX_753(name) const unsigned long int Fix753_##name = name; #define MARK_FIX_753(name) const unsigned long int Fix753_##name = name;

View File

@@ -10,9 +10,7 @@ license = "GPL-2.0"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
# Error handling # Error handling
thiserror = "1" thiserror = "2"
[dev-dependencies]
tempfile = "3" tempfile = "3"
[profile.release] [profile.release]

File diff suppressed because it is too large Load Diff

View File

@@ -28,8 +28,15 @@ impl VentoyImage {
path.display() path.display()
); );
// Create sparse file // Build beside the destination so a failed create cannot leave a partial image.
let mut file = File::create(path)?; let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let mut temp = tempfile::Builder::new()
.prefix(".ventoy-")
.tempfile_in(parent)?;
let mut file = temp.as_file_mut();
file.set_len(size)?; file.set_len(size)?;
// Write boot code // Write boot code
@@ -64,6 +71,8 @@ impl VentoyImage {
format_exfat(&mut file, layout.data_offset(), layout.data_size(), label)?; format_exfat(&mut file, layout.data_offset(), layout.data_size(), label)?;
file.flush()?; file.flush()?;
temp.persist(path)
.map_err(|error| VentoyError::Io(error.error))?;
println!("[INFO] Ventoy IMG created successfully!"); println!("[INFO] Ventoy IMG created successfully!");
@@ -274,3 +283,20 @@ impl VentoyImage {
&self.path &self.path
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failed_create_does_not_publish_partial_image() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ventoy.img");
let error = VentoyImage::create(&path, "64M", "TEST").err().unwrap();
assert!(matches!(error, VentoyError::ResourceNotFound(_)));
assert!(!path.exists());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
}

View File

@@ -8,4 +8,4 @@ license = "BSD-3-Clause"
[dependencies] [dependencies]
[build-dependencies] [build-dependencies]
bindgen = "0.70.1" bindgen = "0.72"

View File

@@ -19,17 +19,7 @@ fn main() {
fn generate_bindings(cpp_dir: &Path) { fn generate_bindings(cpp_dir: &Path) {
let ffi_header = cpp_dir.join("yuv_ffi.h"); let ffi_header = cpp_dir.join("yuv_ffi.h");
let mut builder = bindgen::builder().header(ffi_header.to_string_lossy().to_string()); let builder = bindgen::builder().header(ffi_header.to_string_lossy().to_string());
if env::var("CARGO_CFG_TARGET_OS").ok().as_deref() == Some("android") {
println!("cargo:rerun-if-env-changed=ANDROID_NDK_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_NDK_ROOT");
println!("cargo:rerun-if-env-changed=NDK_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_SDK_ROOT");
println!("cargo:rerun-if-env-changed=CARGO_NDK_PLATFORM");
builder = builder.clang_args(android_clang_args());
}
builder builder
// YUYV conversions // YUYV conversions
@@ -96,28 +86,19 @@ fn generate_bindings(cpp_dir: &Path) {
} }
fn link_libyuv() { fn link_libyuv() {
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); println!("cargo:rerun-if-env-changed=ONE_KVM_LIBS_PATH");
println!("cargo:rerun-if-env-changed=LIBYUV_STATIC");
if target_os == "android" { // An explicit library root must take precedence over host discovery.
if link_android_libyuv() { if env::var("ONE_KVM_LIBS_PATH")
.ok()
.is_some_and(|path| !path.trim().is_empty())
{
if link_system() {
return; return;
} }
if let Some(vcpkg_installed) = vcpkg_installed_root() {
if link_vcpkg(vcpkg_installed) {
return;
}
}
panic!( panic!("libyuv not found under ONE_KVM_LIBS_PATH");
"Android libyuv not found!\n\
\n\
Build it with scripts/build-android-libyuv.sh and set:\n\
export ONE_KVM_ANDROID_LIBYUV_ROOT=/path/to/android-libyuv\n\
\n\
Expected layout:\n\
$ONE_KVM_ANDROID_LIBYUV_ROOT/<abi>/include\n\
$ONE_KVM_ANDROID_LIBYUV_ROOT/<abi>/lib/libyuv.a"
);
} }
// Try vcpkg first // Try vcpkg first
@@ -148,217 +129,6 @@ fn link_libyuv() {
); );
} }
fn link_android_libyuv() -> bool {
println!("cargo:rerun-if-env-changed=ONE_KVM_ANDROID_LIBYUV_ROOT");
println!("cargo:rerun-if-env-changed=ONE_KVM_ANDROID_LIBYUV_STATIC");
let root = match env::var("ONE_KVM_ANDROID_LIBYUV_ROOT")
.ok()
.filter(|path| !path.trim().is_empty())
{
Some(path) => PathBuf::from(path),
None => return false,
};
let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let abi = android_abi(&target_arch);
let abi_root = root.join(abi);
let lib_dir = if abi_root.join("lib").exists() {
abi_root.join("lib")
} else {
root.join("lib")
};
let include_dir = if abi_root.join("include").exists() {
abi_root.join("include")
} else {
root.join("include")
};
let static_lib = lib_dir.join("libyuv.a");
let shared_lib = lib_dir.join("libyuv.so");
let use_static = env::var("ONE_KVM_ANDROID_LIBYUV_STATIC")
.or_else(|_| env::var("LIBYUV_STATIC"))
.map(|value| value != "0")
.unwrap_or(true);
if use_static && static_lib.exists() {
println!("cargo:rustc-link-search=native={}", lib_dir.display());
println!("cargo:rustc-link-lib=static=yuv");
link_android_libjpeg(&root, abi);
println!("cargo:rustc-link-lib=c++_shared");
println!(
"cargo:info=Using Android libyuv from {} (static linking)",
root.display()
);
return true;
}
if shared_lib.exists() {
println!("cargo:rustc-link-search=native={}", lib_dir.display());
println!("cargo:rustc-link-lib=yuv");
println!("cargo:rustc-link-lib=c++_shared");
println!(
"cargo:info=Using Android libyuv from {} (dynamic linking)",
root.display()
);
return true;
}
println!(
"cargo:warning=Android libyuv not found under {} for ABI {} (checked {}, {})",
root.display(),
abi,
static_lib.display(),
shared_lib.display()
);
if !include_dir.exists() {
println!(
"cargo:warning=Android libyuv include directory not found: {}",
include_dir.display()
);
}
false
}
fn link_android_libjpeg(libyuv_root: &Path, abi: &str) {
println!("cargo:rerun-if-env-changed=ONE_KVM_ANDROID_TURBOJPEG_ROOT");
let mut roots = Vec::new();
if let Ok(root) = env::var("ONE_KVM_ANDROID_TURBOJPEG_ROOT") {
if !root.trim().is_empty() {
roots.push(PathBuf::from(root));
}
}
roots.push(libyuv_root.with_file_name("android-turbojpeg"));
for root in roots {
let abi_lib_dir = root.join(abi).join("lib");
let lib_dir = if abi_lib_dir.exists() {
abi_lib_dir
} else {
root.join("lib")
};
let jpeg_lib = lib_dir.join("libjpeg.a");
if jpeg_lib.exists() {
println!("cargo:rustc-link-search=native={}", lib_dir.display());
println!("cargo:rustc-link-lib=static=jpeg");
println!(
"cargo:info=Using Android libjpeg for libyuv MJPEG from {}",
root.display()
);
return;
}
}
println!("cargo:warning=Android libjpeg.a not found; libyuv MJPEG symbols may fail to link");
}
fn android_abi(target_arch: &str) -> &'static str {
match target_arch {
"aarch64" => "arm64-v8a",
"arm" => "armeabi-v7a",
"x86" => "x86",
"x86_64" => "x86_64",
_ => "unknown",
}
}
fn android_clang_args() -> Vec<String> {
let ndk = android_ndk_home();
let target = env::var("TARGET").unwrap_or_default();
let toolchain = ndk.join("toolchains/llvm/prebuilt").join(host_tag());
let sysroot = toolchain.join("sysroot");
let clang_include = toolchain
.join("lib/clang")
.join(clang_version(&toolchain))
.join("include");
let api = env::var("CARGO_NDK_PLATFORM")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(21);
let clang_target = android_clang_target(&target);
vec![
format!("--target={clang_target}"),
format!("--sysroot={}", sysroot.display()),
format!("-D__ANDROID_API__={api}"),
format!("-isystem{}", clang_include.display()),
format!("-isystem{}", sysroot.join("usr/include").display()),
format!(
"-isystem{}",
sysroot.join("usr/include").join(clang_target).display()
),
]
}
fn android_clang_target(target: &str) -> &'static str {
match target {
"aarch64-linux-android" => "aarch64-linux-android",
"armv7-linux-androideabi" => "armv7a-linux-androideabi",
"i686-linux-android" => "i686-linux-android",
"x86_64-linux-android" => "x86_64-linux-android",
other => panic!("unsupported Android target for libyuv bindgen: {other}"),
}
}
fn android_ndk_home() -> PathBuf {
for key in ["ANDROID_NDK_HOME", "ANDROID_NDK_ROOT", "NDK_HOME"] {
if let Ok(value) = env::var(key) {
return PathBuf::from(value);
}
}
for key in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
if let Ok(value) = env::var(key) {
let ndk_dir = PathBuf::from(value).join("ndk");
if let Some(newest) = newest_child_dir(&ndk_dir) {
return newest;
}
}
}
panic!(
"libyuv Android bindgen requires ANDROID_NDK_HOME, ANDROID_NDK_ROOT, NDK_HOME, \
or ANDROID_HOME/ANDROID_SDK_ROOT with an ndk directory"
);
}
fn newest_child_dir(path: &Path) -> Option<PathBuf> {
let mut entries = std::fs::read_dir(path)
.ok()?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>();
entries.sort();
entries.pop()
}
fn host_tag() -> &'static str {
if cfg!(target_os = "linux") {
"linux-x86_64"
} else if cfg!(target_os = "macos") {
"darwin-x86_64"
} else if cfg!(target_os = "windows") {
"windows-x86_64"
} else {
panic!("unsupported host OS for Android NDK");
}
}
fn clang_version(toolchain: &Path) -> String {
let clang_dir = toolchain.join("lib/clang");
let mut entries = std::fs::read_dir(&clang_dir)
.unwrap_or_else(|_| panic!("missing NDK clang directory: {}", clang_dir.display()))
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
entries.sort();
entries
.pop()
.unwrap_or_else(|| panic!("no clang versions found under: {}", clang_dir.display()))
}
fn vcpkg_installed_root() -> Option<PathBuf> { fn vcpkg_installed_root() -> Option<PathBuf> {
println!("cargo:rerun-if-env-changed=VCPKG_INSTALLED_DIR"); println!("cargo:rerun-if-env-changed=VCPKG_INSTALLED_DIR");
println!("cargo:rerun-if-env-changed=VCPKG_ROOT"); println!("cargo:rerun-if-env-changed=VCPKG_ROOT");
@@ -383,10 +153,6 @@ fn link_vcpkg(mut path: PathBuf) -> bool {
("linux", "x86_64") => "x64-linux", ("linux", "x86_64") => "x64-linux",
("linux", "aarch64") => "arm64-linux", ("linux", "aarch64") => "arm64-linux",
("linux", "arm") => "arm-linux", ("linux", "arm") => "arm-linux",
("android", "x86_64") => "x64-android",
("android", "x86") => "x86-android",
("android", "aarch64") => "arm64-android",
("android", "arm") => "arm-neon-android",
("windows", "x86_64") => "x64-windows-static", ("windows", "x86_64") => "x64-windows-static",
("windows", "x86") => "x86-windows-static", ("windows", "x86") => "x86-windows-static",
("macos", "x86_64") => "x64-osx", ("macos", "x86_64") => "x64-osx",
@@ -426,8 +192,6 @@ fn link_vcpkg(mut path: PathBuf) -> bool {
link_libjpeg_for_static_libyuv(&[lib_path.clone()], &target_os); link_libjpeg_for_static_libyuv(&[lib_path.clone()], &target_os);
if target_os == "linux" { if target_os == "linux" {
println!("cargo:rustc-link-lib=stdc++"); println!("cargo:rustc-link-lib=stdc++");
} else if target_os == "android" {
println!("cargo:rustc-link-lib=c++_shared");
} }
println!("cargo:info=Using libyuv from vcpkg (static linking)"); println!("cargo:info=Using libyuv from vcpkg (static linking)");
} else { } else {
@@ -435,8 +199,6 @@ fn link_vcpkg(mut path: PathBuf) -> bool {
println!("cargo:rustc-link-lib=yuv"); println!("cargo:rustc-link-lib=yuv");
if target_os == "linux" { if target_os == "linux" {
println!("cargo:rustc-link-lib=stdc++"); println!("cargo:rustc-link-lib=stdc++");
} else if target_os == "android" {
println!("cargo:rustc-link-lib=c++_shared");
} }
println!("cargo:info=Using libyuv from vcpkg (dynamic linking)"); println!("cargo:info=Using libyuv from vcpkg (dynamic linking)");
} }
@@ -485,9 +247,13 @@ fn link_system() -> bool {
// Build custom library paths based on target architecture: // Build custom library paths based on target architecture:
// 1. Check ONE_KVM_LIBS_PATH environment variable (explicit override) // 1. Check ONE_KVM_LIBS_PATH environment variable (explicit override)
// 2. Fall back to architecture-based detection // 2. Fall back to architecture-based detection
let custom_lib_path = if let Ok(path) = env::var("ONE_KVM_LIBS_PATH") { let explicit_lib_root = env::var("ONE_KVM_LIBS_PATH")
format!("{}/lib", path) .ok()
} else { .filter(|path| !path.trim().is_empty());
let custom_lib_path = explicit_lib_root
.as_ref()
.map(|path| format!("{}/lib", path))
.unwrap_or_else(|| {
match target_arch.as_str() { match target_arch.as_str() {
"x86_64" => "/usr/local/lib", "x86_64" => "/usr/local/lib",
"aarch64" => "/usr/aarch64-linux-gnu/lib", "aarch64" => "/usr/aarch64-linux-gnu/lib",
@@ -495,7 +261,7 @@ fn link_system() -> bool {
_ => "", _ => "",
} }
.to_string() .to_string()
}; });
// Try common system library paths (custom paths first) // Try common system library paths (custom paths first)
let mut lib_paths: Vec<String> = Vec::new(); let mut lib_paths: Vec<String> = Vec::new();
@@ -505,7 +271,8 @@ fn link_system() -> bool {
lib_paths.push(custom_lib_path); lib_paths.push(custom_lib_path);
} }
// Then standard paths // An explicit root is strict: do not silently fall back to host libraries.
if explicit_lib_root.is_none() {
lib_paths.extend( lib_paths.extend(
[ [
"/usr/local/lib", // Custom builds "/usr/local/lib", // Custom builds
@@ -519,6 +286,7 @@ fn link_system() -> bool {
.iter() .iter()
.map(|s| s.to_string()), .map(|s| s.to_string()),
); );
}
for path in &lib_paths { for path in &lib_paths {
let lib_path = Path::new(path); let lib_path = Path::new(path);

View File

@@ -1391,4 +1391,15 @@ mod tests {
assert_eq!(converter.dimensions(), (8, 8)); assert_eq!(converter.dimensions(), (8, 8));
assert_eq!(converter.nv12_buffer().len(), nv12_size(8, 8)); assert_eq!(converter.nv12_buffer().len(), nv12_size(8, 8));
} }
#[test]
fn test_bgr24_to_nv12() {
let src = [0u8; 2 * 2 * 3];
let mut dst = [0xffu8; 2 * 2 * 3 / 2];
bgr24_to_nv12(&src, &mut dst, 2, 2).unwrap();
assert_eq!(&dst[..4], &[16, 16, 16, 16]);
assert_eq!(&dst[4..], &[128, 128]);
}
} }

View File

@@ -1,200 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
OUTPUT_DIR="${PROJECT_ROOT}/dist/android-alsa"
ANDROID_API="${ANDROID_API:-21}"
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
BUILD_ABIS="arm64-v8a armeabi-v7a"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
ALSA_VERSION="${ALSA_VERSION:-1.2.15}"
usage() {
cat <<'EOF'
Usage:
scripts/build-android-alsa.sh [options]
Options:
--output <dir> Output root. Default: dist/android-alsa
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
--api <level> Android API level. Default: 21.
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
-h, --help Show this help.
The output layout is compatible with ONE_KVM_ANDROID_ALSA_ROOT:
<output>/arm64-v8a/include/alsa/asoundlib.h
<output>/arm64-v8a/lib/libasound.so
<output>/arm64-v8a/lib/pkgconfig/alsa.pc
<output>/armeabi-v7a/include/alsa/asoundlib.h
<output>/armeabi-v7a/lib/libasound.so
<output>/armeabi-v7a/lib/pkgconfig/alsa.pc
EOF
}
fail() {
echo "Error: $*" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
OUTPUT_DIR="${2:-}"
shift 2
;;
--ndk)
NDK_ROOT="${2:-}"
shift 2
;;
--api)
ANDROID_API="${2:-}"
shift 2
;;
--abis)
BUILD_ABIS="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$NDK_ROOT" ]] || fail "--ndk or ANDROID_NDK_HOME/ANDROID_NDK_ROOT is required"
[[ -d "$NDK_ROOT/toolchains/llvm/prebuilt" ]] || fail "Invalid NDK root: $NDK_ROOT"
SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-alsa-src"
rm -rf "$SOURCE_DIR"
mkdir -p "${PROJECT_ROOT}/.tmp"
archive="${PROJECT_ROOT}/.tmp/alsa-lib-${ALSA_VERSION}.tar.bz2"
url="https://www.alsa-project.org/files/pub/lib/alsa-lib-${ALSA_VERSION}.tar.bz2"
echo "Downloading ALSA ${ALSA_VERSION}: $url"
curl -fL "$url" -o "$archive"
tar -xjf "$archive" -C "${PROJECT_ROOT}/.tmp"
mv "${PROJECT_ROOT}/.tmp/alsa-lib-${ALSA_VERSION}" "$SOURCE_DIR"
SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
HOST_TAG="$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64"
TOOLCHAIN="${NDK_ROOT}/toolchains/llvm/prebuilt/${HOST_TAG}"
normalize_abis() {
printf '%s\n' "$BUILD_ABIS" | tr ',' ' '
}
clean_generated_source_headers() {
rm -f \
"$SOURCE_DIR/include/asoundlib.h" \
"$SOURCE_DIR/include/version.h" \
"$SOURCE_DIR/include/stamp-vh" \
"$SOURCE_DIR/include/alsa"
}
build_one() {
local abi="$1"
local prefix build_dir
case "$abi" in
arm64-v8a | armeabi-v7a) ;;
*) fail "Unsupported ABI: $abi" ;;
esac
prefix="${OUTPUT_DIR}/${abi}"
build_dir="${PROJECT_ROOT}/.tmp/alsa-android-build/${abi}"
rm -rf "$build_dir"
mkdir -p "$build_dir" "$prefix"
case "$abi" in
arm64-v8a)
export CC="${TOOLCHAIN}/bin/aarch64-linux-android${ANDROID_API}-clang"
export CXX="${TOOLCHAIN}/bin/aarch64-linux-android${ANDROID_API}-clang++"
export HOST_TRIPLE="aarch64-linux-android"
;;
armeabi-v7a)
export CC="${TOOLCHAIN}/bin/armv7a-linux-androideabi${ANDROID_API}-clang"
export CXX="${TOOLCHAIN}/bin/armv7a-linux-androideabi${ANDROID_API}-clang++"
export HOST_TRIPLE="arm-linux-androideabi"
;;
esac
export AR="${TOOLCHAIN}/bin/llvm-ar"
export RANLIB="${TOOLCHAIN}/bin/llvm-ranlib"
export STRIP="${TOOLCHAIN}/bin/llvm-strip"
export CFLAGS="-fPIC"
export CXXFLAGS="-fPIC"
clean_generated_source_headers
if [[ ! -x "$SOURCE_DIR/configure" ]]; then
(
cd "$SOURCE_DIR"
autoreconf -fi
)
fi
(
cd "$build_dir"
pcm_plugins="copy linear route mulaw alaw adpcm rate plug multi file null empty meter hooks lfloat ladspa asym iec958 softvol extplug ioplug mmap_emul"
ctl_plugins="remap ext"
ac_cv_header_sys_shm_h=no \
"$SOURCE_DIR/configure" \
--host="$HOST_TRIPLE" \
--prefix="$prefix" \
--enable-shared \
--disable-static \
--disable-python \
--with-pcm-plugins="$pcm_plugins" \
--with-ctl-plugins="$ctl_plugins" \
--disable-doc \
--disable-oss \
--disable-seq \
--disable-ucm \
--disable-topology \
--disable-rawmidi \
--disable-hwdep \
--disable-usb \
--disable-firewire \
--disable-instr \
--disable-alisp
make -j"$JOBS"
make install
)
mkdir -p "$prefix/lib/pkgconfig"
cat > "$prefix/lib/pkgconfig/alsa.pc" <<EOF
prefix=\${pcfiledir}/../..
exec_prefix=\${prefix}
libdir=\${exec_prefix}/lib
includedir=\${prefix}/include
Name: alsa
Description: ALSA sound library
Version: 1.2.15
Libs: -L\${libdir} -lasound
Cflags: -I\${includedir}
EOF
echo "Built ALSA for ${abi}: ${prefix}"
}
for abi in $(normalize_abis); do
build_one "$abi"
done
cat <<EOF
Done.
Use this when building the Android APK:
export ONE_KVM_ANDROID_ALSA_ROOT="${OUTPUT_DIR}"
cd android && ./gradlew :app:assembleDebug
EOF

View File

@@ -1,291 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
OUTPUT_DIR="${PROJECT_ROOT}/dist/android-ffmpeg-mediacodec"
ANDROID_API="${ANDROID_API:-21}"
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
BUILD_ABIS="arm64-v8a armeabi-v7a"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
FFMPEG_ROCKCHIP_REV="${FFMPEG_ROCKCHIP_REV:-40c412daccf08164493da0de990eb99a8948116b}"
usage() {
cat <<'EOF'
Usage:
scripts/build-android-ffmpeg-mediacodec.sh [options]
Options:
--output <dir> Output root. Default: dist/android-ffmpeg-mediacodec
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
--api <level> Android API level. Default: 21.
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
-h, --help Show this help.
The output layout is compatible with ONE_KVM_ANDROID_FFMPEG_ROOT:
<output>/arm64-v8a/include
<output>/arm64-v8a/lib
<output>/armeabi-v7a/include
<output>/armeabi-v7a/lib
Example:
scripts/build-android-ffmpeg-mediacodec.sh --output /opt/one-kvm/android-ffmpeg
export ONE_KVM_ANDROID_FFMPEG_ROOT=/opt/one-kvm/android-ffmpeg
cd android && ./gradlew :app:assembleDebug
EOF
}
fail() {
echo "Error: $*" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
OUTPUT_DIR="${2:-}"
shift 2
;;
--ndk)
NDK_ROOT="${2:-}"
shift 2
;;
--api)
ANDROID_API="${2:-}"
shift 2
;;
--abis)
BUILD_ABIS="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-ffmpeg-check/src/ffmpeg-rockchip"
rm -rf "$SOURCE_DIR"
mkdir -p "$(dirname "$SOURCE_DIR")"
repo_url="https://github.com/nyanmisaka/ffmpeg-rockchip.git"
if [[ "${CHINAMIRRO:-0}" == "1" ]]; then
repo_url="${GH_PROXY:-https://gh-proxy.com}"
repo_url="${repo_url%/}/https://github.com/nyanmisaka/ffmpeg-rockchip.git"
fi
echo "Cloning FFmpeg source: $repo_url"
git init "$SOURCE_DIR"
(
cd "$SOURCE_DIR"
git remote add origin "$repo_url"
git fetch --depth 1 origin "$FFMPEG_ROCKCHIP_REV"
git checkout --detach FETCH_HEAD
)
[[ -n "$NDK_ROOT" ]] || fail "--ndk or ANDROID_NDK_HOME/ANDROID_NDK_ROOT is required"
SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
HOST_TAG="$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64"
TOOLCHAIN="${NDK_ROOT}/toolchains/llvm/prebuilt/${HOST_TAG}"
normalize_abis() {
printf '%s\n' "$BUILD_ABIS" | tr ',' ' '
}
patch_android_ffmpeg_mjpeg_mediacodec() {
local avcodec_dir="${SOURCE_DIR}/libavcodec"
local configure_file="${SOURCE_DIR}/configure"
local mediacodecdec="${avcodec_dir}/mediacodecdec.c"
local allcodecs="${avcodec_dir}/allcodecs.c"
local makefile="${avcodec_dir}/Makefile"
python3 - "$mediacodecdec" "$allcodecs" "$configure_file" "$makefile" <<'PY'
from pathlib import Path
import sys
mediacodecdec, allcodecs, configure_file, makefile = map(Path, sys.argv[1:])
def replace_once(path: Path, old: str, new: str) -> None:
text = path.read_text()
if new in text:
return
if old not in text:
raise SystemExit(f"patch anchor not found in {path}: {old!r}")
path.write_text(text.replace(old, new, 1))
replace_once(
mediacodecdec,
"CONFIG_MPEG2_MEDIACODEC_DECODER || \\\n",
"CONFIG_MJPEG_MEDIACODEC_DECODER || \\\n"
" CONFIG_MPEG2_MEDIACODEC_DECODER || \\\n",
)
replace_once(
mediacodecdec,
"#if CONFIG_MPEG2_MEDIACODEC_DECODER\n"
" case AV_CODEC_ID_MPEG2VIDEO:",
"#if CONFIG_MJPEG_MEDIACODEC_DECODER\n"
" case AV_CODEC_ID_MJPEG:\n"
" codec_mime = \"video/mjpeg\";\n\n"
" ret = common_set_extradata(avctx, format);\n"
" if (ret < 0)\n"
" goto done;\n"
" break;\n"
"#endif\n"
"#if CONFIG_MPEG2_MEDIACODEC_DECODER\n"
" case AV_CODEC_ID_MPEG2VIDEO:",
)
replace_once(
mediacodecdec,
"#if CONFIG_MPEG2_MEDIACODEC_DECODER\n"
"DECLARE_MEDIACODEC_VDEC(mpeg2, \"MPEG-2\", AV_CODEC_ID_MPEG2VIDEO, NULL)",
"#if CONFIG_MJPEG_MEDIACODEC_DECODER\n"
"DECLARE_MEDIACODEC_VDEC(mjpeg, \"MJPEG\", AV_CODEC_ID_MJPEG, NULL)\n"
"#endif\n\n"
"#if CONFIG_MPEG2_MEDIACODEC_DECODER\n"
"DECLARE_MEDIACODEC_VDEC(mpeg2, \"MPEG-2\", AV_CODEC_ID_MPEG2VIDEO, NULL)",
)
replace_once(
allcodecs,
"extern const FFCodec ff_mjpeg_cuvid_decoder;",
"extern const FFCodec ff_mjpeg_cuvid_decoder;\n"
"extern const FFCodec ff_mjpeg_mediacodec_decoder;",
)
replace_once(
configure_file,
'mjpeg_cuvid_decoder_deps="cuvid"',
'mjpeg_cuvid_decoder_deps="cuvid"\n'
'mjpeg_mediacodec_decoder_deps="mediacodec"',
)
replace_once(
makefile,
"OBJS-$(CONFIG_MJPEG_RKMPP_DECODER)",
"OBJS-$(CONFIG_MJPEG_MEDIACODEC_DECODER) += mediacodecdec.o\n"
"OBJS-$(CONFIG_MJPEG_RKMPP_DECODER)",
)
PY
}
abi_arch() {
case "$1" in
arm64-v8a) echo "aarch64" ;;
armeabi-v7a) echo "arm" ;;
*) fail "Unsupported ABI: $1" ;;
esac
}
abi_cpu() {
case "$1" in
arm64-v8a) echo "armv8-a" ;;
armeabi-v7a) echo "armv7-a" ;;
*) fail "Unsupported ABI: $1" ;;
esac
}
abi_target() {
case "$1" in
arm64-v8a) echo "aarch64-linux-android" ;;
armeabi-v7a) echo "armv7a-linux-androideabi" ;;
*) fail "Unsupported ABI: $1" ;;
esac
}
build_one() {
local abi="$1"
local arch cpu target prefix build_dir cc cxx ar ranlib strip extra_cflags extra_ldflags
arch="$(abi_arch "$abi")"
cpu="$(abi_cpu "$abi")"
target="$(abi_target "$abi")"
prefix="${OUTPUT_DIR}/${abi}"
build_dir="${PROJECT_ROOT}/.tmp/ffmpeg-android-build/${abi}"
cc="${TOOLCHAIN}/bin/${target}${ANDROID_API}-clang"
cxx="${TOOLCHAIN}/bin/${target}${ANDROID_API}-clang++"
ar="${TOOLCHAIN}/bin/llvm-ar"
ranlib="${TOOLCHAIN}/bin/llvm-ranlib"
strip="${TOOLCHAIN}/bin/llvm-strip"
extra_cflags="-fPIC"
extra_ldflags=""
if [[ "$abi" == "armeabi-v7a" ]]; then
extra_cflags="${extra_cflags} -march=armv7-a -mfloat-abi=softfp -mfpu=neon"
extra_ldflags="${extra_ldflags} -Wl,--fix-cortex-a8"
fi
rm -rf "$build_dir"
mkdir -p "$build_dir" "$prefix"
(
cd "$build_dir"
"${SOURCE_DIR}/configure" \
--prefix="$prefix" \
--target-os=android \
--arch="$arch" \
--cpu="$cpu" \
--cc="$cc" \
--cxx="$cxx" \
--ar="$ar" \
--ranlib="$ranlib" \
--strip="$strip" \
--cross-prefix="${TOOLCHAIN}/bin/llvm-" \
--sysroot="${TOOLCHAIN}/sysroot" \
--enable-cross-compile \
--enable-static \
--disable-shared \
--disable-programs \
--disable-doc \
--disable-avdevice \
--disable-avformat \
--disable-avfilter \
--disable-swscale \
--disable-swresample \
--disable-postproc \
--disable-network \
--disable-everything \
--disable-hwaccels \
--disable-cuda-llvm \
--disable-v4l2-m2m \
--disable-vulkan \
--enable-pthreads \
--enable-jni \
--enable-mediacodec \
--enable-decoder=mjpeg_mediacodec \
--enable-decoder=mjpeg \
--enable-encoder=h264_mediacodec \
--enable-encoder=hevc_mediacodec \
--enable-parser=mjpeg \
--enable-bsf=h264_metadata \
--enable-bsf=hevc_metadata \
--enable-protocol=file \
--extra-cflags="$extra_cflags" \
--extra-ldflags="$extra_ldflags"
make -j"$JOBS"
make install
)
echo "Built FFmpeg MediaCodec for ${abi}: ${prefix}"
}
patch_android_ffmpeg_mjpeg_mediacodec
for abi in $(normalize_abis); do
build_one "$abi"
done
cat <<EOF
Done.
Use this when building the Android APK:
export ONE_KVM_ANDROID_FFMPEG_ROOT="${OUTPUT_DIR}"
cd android && ./gradlew :app:assembleDebug
EOF

View File

@@ -1,184 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
OUTPUT_DIR="${PROJECT_ROOT}/dist/android-libyuv"
JPEG_ROOT="${ONE_KVM_ANDROID_TURBOJPEG_ROOT:-${PROJECT_ROOT}/dist/android-turbojpeg}"
ANDROID_API="${ANDROID_API:-21}"
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
BUILD_ABIS="arm64-v8a armeabi-v7a"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
LIBYUV_REV="${LIBYUV_REV:-957f295ea946cbbd13fcfc46e7066f2efa801233}"
usage() {
cat <<'EOF'
Usage:
scripts/build-android-libyuv.sh [options]
Options:
--output <dir> Output root. Default: dist/android-libyuv
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
--api <level> Android API level. Default: 21.
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
--jpeg-root <dir> Android libjpeg root. Defaults to ONE_KVM_ANDROID_TURBOJPEG_ROOT
or dist/android-turbojpeg when present. Enables libyuv HAVE_JPEG.
-h, --help Show this help.
The output layout is compatible with ONE_KVM_ANDROID_LIBYUV_ROOT:
<output>/arm64-v8a/include
<output>/arm64-v8a/lib/libyuv.a
<output>/armeabi-v7a/include
<output>/armeabi-v7a/lib/libyuv.a
Example:
scripts/build-android-libyuv.sh --output /opt/one-kvm/android-libyuv
export ONE_KVM_ANDROID_LIBYUV_ROOT=/opt/one-kvm/android-libyuv
cd android && ./gradlew :app:assembleDebug
EOF
}
fail() {
echo "Error: $*" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
OUTPUT_DIR="${2:-}"
shift 2
;;
--ndk)
NDK_ROOT="${2:-}"
shift 2
;;
--api)
ANDROID_API="${2:-}"
shift 2
;;
--abis)
BUILD_ABIS="${2:-}"
shift 2
;;
--jpeg-root)
JPEG_ROOT="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$NDK_ROOT" ]] || fail "--ndk or ANDROID_NDK_HOME/ANDROID_NDK_ROOT is required"
[[ -d "$NDK_ROOT/toolchains/llvm/prebuilt" ]] || fail "Invalid NDK root: $NDK_ROOT"
SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-libyuv-src"
rm -rf "$SOURCE_DIR"
repo_url="https://github.com/lemenkov/libyuv.git"
if [[ "${CHINAMIRRO:-0}" == "1" ]]; then
repo_url="${GH_PROXY:-https://gh-proxy.com}"
repo_url="${repo_url%/}/https://github.com/lemenkov/libyuv.git"
fi
echo "Cloning libyuv source: $repo_url"
git init "$SOURCE_DIR"
(
cd "$SOURCE_DIR"
git remote add origin "$repo_url"
git fetch --depth 1 origin "$LIBYUV_REV"
git checkout --detach FETCH_HEAD
)
SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
HOST_TAG="$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64"
ANDROID_TOOLCHAIN_FILE="${NDK_ROOT}/build/cmake/android.toolchain.cmake"
[[ -f "$ANDROID_TOOLCHAIN_FILE" ]] || fail "NDK CMake toolchain not found: $ANDROID_TOOLCHAIN_FILE"
normalize_abis() {
printf '%s\n' "$BUILD_ABIS" | tr ',' ' '
}
build_one() {
local abi="$1"
local prefix build_dir jpeg_include jpeg_library
local -a jpeg_args
case "$abi" in
arm64-v8a | armeabi-v7a | x86 | x86_64) ;;
*) fail "Unsupported ABI: $abi" ;;
esac
prefix="${OUTPUT_DIR}/${abi}"
build_dir="${PROJECT_ROOT}/.tmp/libyuv-android-build/${abi}"
rm -rf "$build_dir"
mkdir -p "$build_dir" "$prefix"
jpeg_include="$JPEG_ROOT/$abi/include"
jpeg_library="$JPEG_ROOT/$abi/lib/libjpeg.a"
jpeg_args=()
if [[ -f "$jpeg_library" && -f "$jpeg_include/jpeglib.h" ]]; then
jpeg_args=(
-DJPEG_FOUND=TRUE
-DJPEG_INCLUDE_DIR="$jpeg_include"
-DJPEG_LIBRARY="$jpeg_library"
-DCMAKE_C_FLAGS="-DHAVE_JPEG"
-DCMAKE_CXX_FLAGS="-DHAVE_JPEG"
)
else
echo "Warning: Android libjpeg not found for ${abi}; libyuv MJPEG APIs will be disabled." >&2
echo " Checked: $jpeg_library and $jpeg_include/jpeglib.h" >&2
fi
cmake -S "$SOURCE_DIR" -B "$build_dir" \
-DCMAKE_TOOLCHAIN_FILE="$ANDROID_TOOLCHAIN_FILE" \
-DANDROID_ABI="$abi" \
-DANDROID_PLATFORM="android-${ANDROID_API}" \
-DANDROID_STL=c++_shared \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$prefix" \
-DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DBUILD_SHARED_LIBS=OFF \
-DUNIT_TEST=OFF \
-DTEST=OFF \
"${jpeg_args[@]}"
cmake --build "$build_dir" --target yuv --parallel "$JOBS"
mkdir -p "$prefix/lib" "$prefix/include"
if [[ -f "$build_dir/libyuv.a" ]]; then
cp "$build_dir/libyuv.a" "$prefix/lib/libyuv.a"
elif [[ -f "$build_dir/lib/libyuv.a" ]]; then
cp "$build_dir/lib/libyuv.a" "$prefix/lib/libyuv.a"
else
fail "Built libyuv.a was not found under: $build_dir"
fi
cp -R "$SOURCE_DIR/include/." "$prefix/include/"
echo "Built libyuv for ${abi}: ${prefix}"
}
for abi in $(normalize_abis); do
build_one "$abi"
done
cat <<EOF
Done.
Use this when building the Android APK:
export ONE_KVM_ANDROID_LIBYUV_ROOT="${OUTPUT_DIR}"
export ONE_KVM_ANDROID_TURBOJPEG_ROOT="${JPEG_ROOT}"
cd android && ./gradlew :app:assembleDebug
EOF

View File

@@ -1,151 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
OUTPUT_DIR="${PROJECT_ROOT}/dist/android-opus"
ANDROID_API="${ANDROID_API:-21}"
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
BUILD_ABIS="arm64-v8a armeabi-v7a"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
OPUS_VERSION="${OPUS_VERSION:-1.5.2}"
usage() {
cat <<'EOF'
Usage:
scripts/build-android-opus.sh [options]
Options:
--output <dir> Output root. Default: dist/android-opus
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
--api <level> Android API level. Default: 21.
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
-h, --help Show this help.
The output layout is compatible with ONE_KVM_ANDROID_OPUS_ROOT:
<output>/arm64-v8a/include/opus/opus.h
<output>/arm64-v8a/lib/libopus.so
<output>/armeabi-v7a/include/opus/opus.h
<output>/armeabi-v7a/lib/libopus.so
EOF
}
fail() {
echo "Error: $*" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
OUTPUT_DIR="${2:-}"
shift 2
;;
--ndk)
NDK_ROOT="${2:-}"
shift 2
;;
--api)
ANDROID_API="${2:-}"
shift 2
;;
--abis)
BUILD_ABIS="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$NDK_ROOT" ]] || fail "--ndk or ANDROID_NDK_HOME/ANDROID_NDK_ROOT is required"
[[ -d "$NDK_ROOT/toolchains/llvm/prebuilt" ]] || fail "Invalid NDK root: $NDK_ROOT"
SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-opus-src"
rm -rf "$SOURCE_DIR"
mkdir -p "$SOURCE_DIR"
tarball="${PROJECT_ROOT}/.tmp/opus-${OPUS_VERSION}.tar.gz"
url="https://downloads.xiph.org/releases/opus/opus-${OPUS_VERSION}.tar.gz"
curl -fsSL "$url" -o "$tarball"
tar -xzf "$tarball" -C "$SOURCE_DIR" --strip-components=1
SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
HOST_TAG="$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64"
TOOLCHAIN="${NDK_ROOT}/toolchains/llvm/prebuilt/${HOST_TAG}"
normalize_abis() {
printf '%s\n' "$BUILD_ABIS" | tr ',' ' '
}
build_one() {
local abi="$1"
local prefix build_dir
case "$abi" in
arm64-v8a | armeabi-v7a) ;;
*) fail "Unsupported ABI: $abi" ;;
esac
prefix="${OUTPUT_DIR}/${abi}"
build_dir="${PROJECT_ROOT}/.tmp/opus-android-build/${abi}"
rm -rf "$build_dir"
mkdir -p "$build_dir" "$prefix"
(
cd "$build_dir"
case "$abi" in
arm64-v8a)
export CC="${TOOLCHAIN}/bin/aarch64-linux-android${ANDROID_API}-clang"
export CXX="${TOOLCHAIN}/bin/aarch64-linux-android${ANDROID_API}-clang++"
export HOST_TRIPLE="aarch64-linux-android"
;;
armeabi-v7a)
export CC="${TOOLCHAIN}/bin/armv7a-linux-androideabi${ANDROID_API}-clang"
export CXX="${TOOLCHAIN}/bin/armv7a-linux-androideabi${ANDROID_API}-clang++"
export HOST_TRIPLE="arm-linux-androideabi"
;;
esac
export AR="${TOOLCHAIN}/bin/llvm-ar"
export RANLIB="${TOOLCHAIN}/bin/llvm-ranlib"
export STRIP="${TOOLCHAIN}/bin/llvm-strip"
export CFLAGS="-fPIC"
export CXXFLAGS="-fPIC"
export LDFLAGS=""
"$SOURCE_DIR/configure" \
--prefix="$prefix" \
--host="$HOST_TRIPLE" \
--disable-static \
--enable-shared \
--disable-doc \
--disable-extra-programs \
--with-pic
make -j"$JOBS"
make install
)
echo "Built Opus for ${abi}: ${prefix}"
}
for abi in $(normalize_abis); do
build_one "$abi"
done
cat <<EOF
Done.
Use this when building the Android APK:
export ONE_KVM_ANDROID_OPUS_ROOT="${OUTPUT_DIR}"
cd android && ./gradlew :app:assembleDebug
EOF

View File

@@ -1,173 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
OUTPUT_DIR="${PROJECT_ROOT}/dist/android-turbojpeg"
ANDROID_API="${ANDROID_API:-21}"
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
BUILD_ABIS="arm64-v8a armeabi-v7a"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
LIBJPEG_TURBO_VERSION="${LIBJPEG_TURBO_VERSION:-3.1.4.1}"
usage() {
cat <<'EOF'
Usage:
scripts/build-android-turbojpeg.sh [options]
Options:
--output <dir> Output root. Default: dist/android-turbojpeg
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
--api <level> Android API level. Default: 21.
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
-h, --help Show this help.
The output layout is compatible with ONE_KVM_ANDROID_TURBOJPEG_ROOT:
<output>/arm64-v8a/include/turbojpeg.h
<output>/arm64-v8a/lib/libturbojpeg.a
<output>/arm64-v8a/include/jpeglib.h
<output>/arm64-v8a/lib/libjpeg.a
<output>/armeabi-v7a/include/turbojpeg.h
<output>/armeabi-v7a/lib/libturbojpeg.a
<output>/armeabi-v7a/include/jpeglib.h
<output>/armeabi-v7a/lib/libjpeg.a
EOF
}
fail() {
echo "Error: $*" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
OUTPUT_DIR="${2:-}"
shift 2
;;
--ndk)
NDK_ROOT="${2:-}"
shift 2
;;
--api)
ANDROID_API="${2:-}"
shift 2
;;
--abis)
BUILD_ABIS="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$NDK_ROOT" ]] || fail "--ndk or ANDROID_NDK_HOME/ANDROID_NDK_ROOT is required"
[[ -d "$NDK_ROOT/toolchains/llvm/prebuilt" ]] || fail "Invalid NDK root: $NDK_ROOT"
SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-turbojpeg-src"
rm -rf "$SOURCE_DIR"
repo_url="https://github.com/libjpeg-turbo/libjpeg-turbo.git"
if [[ "${CHINAMIRRO:-0}" == "1" ]]; then
repo_url="${GH_PROXY:-https://gh-proxy.com}"
repo_url="${repo_url%/}/https://github.com/libjpeg-turbo/libjpeg-turbo.git"
fi
echo "Cloning libjpeg-turbo ${LIBJPEG_TURBO_VERSION}: $repo_url"
git init "$SOURCE_DIR"
(
cd "$SOURCE_DIR"
git remote add origin "$repo_url"
git fetch --depth 1 origin "refs/tags/$LIBJPEG_TURBO_VERSION"
git checkout --detach FETCH_HEAD
)
SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
HOST_TAG="$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64"
ANDROID_TOOLCHAIN_FILE="${NDK_ROOT}/build/cmake/android.toolchain.cmake"
[[ -f "$ANDROID_TOOLCHAIN_FILE" ]] || fail "NDK CMake toolchain not found: $ANDROID_TOOLCHAIN_FILE"
normalize_abis() {
printf '%s\n' "$BUILD_ABIS" | tr ',' ' '
}
build_one() {
local abi="$1"
local prefix build_dir lib_path
case "$abi" in
arm64-v8a | armeabi-v7a | x86 | x86_64) ;;
*) fail "Unsupported ABI: $abi" ;;
esac
prefix="${OUTPUT_DIR}/${abi}"
build_dir="${PROJECT_ROOT}/.tmp/turbojpeg-android-build/${abi}"
rm -rf "$build_dir"
mkdir -p "$build_dir" "$prefix"
cmake -S "$SOURCE_DIR" -B "$build_dir" \
-DCMAKE_TOOLCHAIN_FILE="$ANDROID_TOOLCHAIN_FILE" \
-DANDROID_ABI="$abi" \
-DANDROID_PLATFORM="android-${ANDROID_API}" \
-DANDROID_STL=c++_shared \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$prefix" \
-DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DCMAKE_C_FLAGS="-DANDROID -Dstderr=__sF+2" \
-DCMAKE_CXX_FLAGS="-DANDROID -Dstderr=__sF+2" \
-DENABLE_SHARED=OFF \
-DENABLE_STATIC=ON \
-DWITH_TURBOJPEG=ON \
-DWITH_JAVA=OFF \
-DWITH_12BIT=OFF \
-DWITH_ARITH_DEC=ON \
-DWITH_ARITH_ENC=ON
cmake --build "$build_dir" --target turbojpeg-static jpeg-static --parallel "$JOBS"
mkdir -p "$prefix/lib" "$prefix/include"
lib_path="$build_dir/libturbojpeg.a"
if [[ ! -f "$lib_path" ]]; then
lib_path="$build_dir/lib/libturbojpeg.a"
fi
[[ -f "$lib_path" ]] || fail "Built libturbojpeg.a was not found under: $build_dir"
cp "$lib_path" "$prefix/lib/libturbojpeg.a"
lib_path="$build_dir/libjpeg.a"
if [[ ! -f "$lib_path" ]]; then
lib_path="$build_dir/lib/libjpeg.a"
fi
[[ -f "$lib_path" ]] || fail "Built libjpeg.a was not found under: $build_dir"
cp "$lib_path" "$prefix/lib/libjpeg.a"
cp "$SOURCE_DIR/src/turbojpeg.h" "$prefix/include/turbojpeg.h"
cp "$SOURCE_DIR/src/jerror.h" "$prefix/include/jerror.h"
cp "$SOURCE_DIR/src/jmorecfg.h" "$prefix/include/jmorecfg.h"
cp "$SOURCE_DIR/src/jpeglib.h" "$prefix/include/jpeglib.h"
cp "$build_dir/jconfig.h" "$prefix/include/jconfig.h"
echo "Built TurboJPEG for ${abi}: ${prefix}"
}
for abi in $(normalize_abis); do
build_one "$abi"
done
cat <<EOF
Done.
Use this when building the Android APK:
export ONE_KVM_ANDROID_TURBOJPEG_ROOT="${OUTPUT_DIR}"
cd android && ./gradlew :app:assembleDebug
EOF

View File

@@ -1,11 +1,7 @@
#[cfg(all(unix, not(feature = "android")))] #[cfg(unix)]
#[path = "capture_linux.rs"] #[path = "capture_linux.rs"]
mod imp; mod imp;
#[cfg(feature = "android")]
#[path = "capture_android.rs"]
mod imp;
#[cfg(windows)] #[cfg(windows)]
#[path = "capture_windows.rs"] #[path = "capture_windows.rs"]
mod imp; mod imp;

View File

@@ -1,292 +0,0 @@
use alsa::pcm::{Access, Format, Frames, HwParams};
use alsa::{Direction, ValueOr, PCM};
use bytes::Bytes;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{broadcast, watch, Mutex};
use tracing::{debug, info};
use crate::audio::device::AudioDeviceInfo;
use crate::error::{AppError, Result};
use crate::utils::LogThrottler;
use crate::{error_throttled, warn_throttled};
#[derive(Debug, Clone)]
pub struct AudioConfig {
pub device_name: String,
pub sample_rate: u32,
pub channels: u32,
pub frame_size: u32,
pub buffer_frames: u32,
pub period_frames: u32,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
device_name: String::new(),
sample_rate: 48_000,
channels: 2,
frame_size: 960,
buffer_frames: 4096,
period_frames: 960,
}
}
}
impl AudioConfig {
pub fn for_device(device: &AudioDeviceInfo) -> Self {
Self {
device_name: device.name.clone(),
..Default::default()
}
}
pub fn bytes_per_sample(&self) -> u32 {
2 * self.channels
}
pub fn bytes_per_frame(&self) -> usize {
(self.frame_size * self.bytes_per_sample()) as usize
}
}
#[derive(Debug, Clone)]
pub struct AudioFrame {
pub data: Bytes,
pub sample_rate: u32,
pub channels: u32,
pub samples: u32,
pub sequence: u64,
pub timestamp: Instant,
}
impl AudioFrame {
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self {
let bps = 2 * channels;
Self {
samples: data.len() as u32 / bps,
data,
sample_rate,
channels,
sequence,
timestamp: Instant::now(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureState {
Stopped,
Running,
Error,
}
pub struct AudioCapturer {
config: AudioConfig,
state: Arc<watch::Sender<CaptureState>>,
state_rx: watch::Receiver<CaptureState>,
frame_tx: broadcast::Sender<AudioFrame>,
stop_flag: Arc<AtomicBool>,
sequence: Arc<AtomicU64>,
capture_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
log_throttler: LogThrottler,
}
impl AudioCapturer {
pub fn new(config: AudioConfig) -> Self {
let (state_tx, state_rx) = watch::channel(CaptureState::Stopped);
let (frame_tx, _) = broadcast::channel(16);
Self {
config,
state: Arc::new(state_tx),
state_rx,
frame_tx,
stop_flag: Arc::new(AtomicBool::new(false)),
sequence: Arc::new(AtomicU64::new(0)),
capture_handle: Mutex::new(None),
log_throttler: LogThrottler::with_secs(5),
}
}
pub fn state(&self) -> CaptureState {
*self.state_rx.borrow()
}
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
self.state_rx.clone()
}
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
self.frame_tx.subscribe()
}
pub async fn start(&self) -> Result<()> {
if self.state() == CaptureState::Running {
return Ok(());
}
debug!(
"Starting audio capture on {} at {}Hz {}ch",
self.config.device_name, self.config.sample_rate, self.config.channels
);
self.stop_flag.store(false, Ordering::SeqCst);
let config = self.config.clone();
let state = self.state.clone();
let frame_tx = self.frame_tx.clone();
let stop_flag = self.stop_flag.clone();
let sequence = self.sequence.clone();
let log_throttler = self.log_throttler.clone();
let handle = tokio::task::spawn_blocking(move || {
let result = run_capture(
&config,
&state,
&frame_tx,
&stop_flag,
&sequence,
&log_throttler,
);
if let Err(e) = result {
error_throttled!(log_throttler, "capture_error", "Audio capture error: {}", e);
let _ = state.send(CaptureState::Error);
} else {
let _ = state.send(CaptureState::Stopped);
}
});
*self.capture_handle.lock().await = Some(handle);
Ok(())
}
pub async fn stop(&self) -> Result<()> {
info!("Stopping audio capture");
self.stop_flag.store(true, Ordering::SeqCst);
if let Some(handle) = self.capture_handle.lock().await.take() {
let _ = handle.await;
}
let _ = self.state.send(CaptureState::Stopped);
Ok(())
}
pub fn is_running(&self) -> bool {
self.state() == CaptureState::Running
}
}
fn run_capture(
config: &AudioConfig,
state: &watch::Sender<CaptureState>,
frame_tx: &broadcast::Sender<AudioFrame>,
stop_flag: &AtomicBool,
sequence: &AtomicU64,
log_throttler: &LogThrottler,
) -> Result<()> {
let pcm = PCM::new(&config.device_name, Direction::Capture, false).map_err(|e| {
AppError::AudioError(format!(
"Failed to open audio device {}: {}",
config.device_name, e
))
})?;
{
let hwp = HwParams::any(&pcm)
.map_err(|e| AppError::AudioError(format!("Failed to get HwParams: {}", e)))?;
hwp.set_channels(config.channels)
.map_err(|e| AppError::AudioError(format!("Failed to set channels: {}", e)))?;
hwp.set_rate(config.sample_rate, ValueOr::Nearest)
.map_err(|e| AppError::AudioError(format!("Failed to set sample rate: {}", e)))?;
hwp.set_format(Format::s16())
.map_err(|e| AppError::AudioError(format!("Failed to set format: {}", e)))?;
hwp.set_access(Access::RWInterleaved)
.map_err(|e| AppError::AudioError(format!("Failed to set access: {}", e)))?;
hwp.set_buffer_size_near(config.buffer_frames as Frames)
.map_err(|e| AppError::AudioError(format!("Failed to set buffer size: {}", e)))?;
hwp.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest)
.map_err(|e| AppError::AudioError(format!("Failed to set period size: {}", e)))?;
pcm.hw_params(&hwp)
.map_err(|e| AppError::AudioError(format!("Failed to apply hw params: {}", e)))?;
}
let hw_now = pcm.hw_params_current().map_err(|e| {
AppError::AudioError(format!("Failed to read hw_params after apply: {}", e))
})?;
let actual_rate = hw_now
.get_rate()
.map_err(|e| AppError::AudioError(format!("Failed to read sample rate: {}", e)))?;
let actual_ch = hw_now
.get_channels()
.map_err(|e| AppError::AudioError(format!("Failed to read channels: {}", e)))?;
if actual_rate != 48_000 {
return Err(AppError::AudioError(format!(
"Audio capture requires 48000 Hz; device is {} Hz",
actual_rate
)));
}
if actual_ch != 2 {
return Err(AppError::AudioError(format!(
"Audio capture requires 2 channels (stereo); device has {}",
actual_ch
)));
}
debug!("Audio capture: 48000 Hz, 2 ch");
pcm.prepare()
.map_err(|e| AppError::AudioError(format!("Failed to prepare PCM: {}", e)))?;
let _ = state.send(CaptureState::Running);
let period_frames = pcm
.hw_params_current()
.ok()
.and_then(|h| h.get_period_size().ok())
.map(|f| f as usize)
.unwrap_or(1024)
.max(256);
let buf_frames = period_frames.saturating_mul(4).max(2048);
let io = pcm
.io_i16()
.map_err(|e| AppError::AudioError(format!("Failed to get PCM IO: {}", e)))?;
let mut buffer = vec![0i16; buf_frames * 2];
let mut next_log = Instant::now();
while !stop_flag.load(Ordering::SeqCst) {
match io.readi(&mut buffer[..period_frames * 2]) {
Ok(frames_read) => {
if frames_read == 0 {
continue;
}
let samples = frames_read * 2;
let data = Bytes::copy_from_slice(bytemuck::cast_slice(&buffer[..samples]));
let seq = sequence.fetch_add(1, Ordering::SeqCst);
let frame = AudioFrame::new_interleaved(data, 2, 48_000, seq);
let _ = frame_tx.send(frame);
if next_log.elapsed().as_secs() >= 5 {
debug!("Captured audio frame {} ({} samples)", seq, samples / 2);
next_log = Instant::now();
}
}
Err(err) => {
warn_throttled!(
log_throttler,
"alsa_read",
"ALSA read error on {}: {}",
config.device_name,
err
);
let _ = pcm.try_recover(err, false);
}
}
}
let _ = pcm.drain();
Ok(())
}

View File

@@ -220,7 +220,7 @@ fn run_capture(
let stream = match sample_format { let stream = match sample_format {
SampleFormat::F32 => build_stream::<f32>( SampleFormat::F32 => build_stream::<f32>(
&device, &device,
&stream_config, stream_config,
input_channels, input_channels,
input_rate, input_rate,
tx.clone(), tx.clone(),
@@ -229,7 +229,7 @@ fn run_capture(
), ),
SampleFormat::I16 => build_stream::<i16>( SampleFormat::I16 => build_stream::<i16>(
&device, &device,
&stream_config, stream_config,
input_channels, input_channels,
input_rate, input_rate,
tx.clone(), tx.clone(),
@@ -238,7 +238,7 @@ fn run_capture(
), ),
SampleFormat::U16 => build_stream::<u16>( SampleFormat::U16 => build_stream::<u16>(
&device, &device,
&stream_config, stream_config,
input_channels, input_channels,
input_rate, input_rate,
tx.clone(), tx.clone(),
@@ -361,7 +361,7 @@ fn select_input_config(
fn build_stream<T>( fn build_stream<T>(
device: &cpal::Device, device: &cpal::Device,
config: &StreamConfig, config: StreamConfig,
input_channels: u32, input_channels: u32,
input_rate: u32, input_rate: u32,
tx: mpsc::SyncSender<Vec<i16>>, tx: mpsc::SyncSender<Vec<i16>>,

View File

@@ -1,11 +1,7 @@
#[cfg(all(unix, not(feature = "android")))] #[cfg(unix)]
#[path = "device_linux.rs"] #[path = "device_linux.rs"]
mod imp; mod imp;
#[cfg(feature = "android")]
#[path = "device_android.rs"]
mod imp;
#[cfg(windows)] #[cfg(windows)]
#[path = "device_windows.rs"] #[path = "device_windows.rs"]
mod imp; mod imp;

View File

@@ -1,185 +0,0 @@
use alsa::pcm::HwParams;
use alsa::{Direction, PCM};
use serde::Serialize;
use tracing::{debug, info, warn};
use crate::error::{AppError, Result};
#[derive(Debug, Clone, Serialize)]
pub struct AudioDeviceInfo {
pub name: String,
pub description: String,
pub card_index: i32,
pub device_index: i32,
pub sample_rates: Vec<u32>,
pub channels: Vec<u32>,
pub is_capture: bool,
pub is_hdmi: bool,
pub usb_bus: Option<String>,
}
fn get_usb_bus_info(card_index: i32) -> Option<String> {
if card_index < 0 {
return None;
}
let device_path = format!("/sys/class/sound/card{}/device", card_index);
let link_target = std::fs::read_link(&device_path).ok()?;
let link_str = link_target.to_string_lossy();
for component in link_str.split('/') {
if component.contains('-') && !component.contains(':') {
if component
.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false)
{
return Some(component.to_string());
}
}
}
None
}
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
enumerate_audio_devices_with_current(None)
}
pub fn enumerate_audio_devices_with_current(
current_device: Option<&str>,
) -> Result<Vec<AudioDeviceInfo>> {
let mut devices = Vec::new();
for card_result in alsa::card::Iter::new() {
let card = match card_result {
Ok(card) => card,
Err(err) => {
debug!("Error iterating card: {}", err);
continue;
}
};
let card_index = card.get_index();
let card_name = card.get_name().unwrap_or_else(|_| "Unknown".to_string());
let card_longname = card.get_longname().unwrap_or_else(|_| card_name.clone());
debug!("Found audio card {}: {}", card_index, card_longname);
let long_lower = card_longname.to_lowercase();
let is_hdmi = long_lower.contains("hdmi")
|| long_lower.contains("capture")
|| long_lower.contains("usb");
let usb_bus = get_usb_bus_info(card_index);
for device_index in 0..8 {
let device_name = format!("hw:{},{}", card_index, device_index);
let is_current_device = current_device == Some(device_name.as_str());
let mut push_info =
|sample_rates: Vec<u32>, channels: Vec<u32>, description: String| {
devices.push(AudioDeviceInfo {
name: device_name.clone(),
description,
card_index,
device_index,
sample_rates,
channels,
is_capture: true,
is_hdmi,
usb_bus: usb_bus.clone(),
});
};
match PCM::new(&device_name, Direction::Capture, false) {
Ok(pcm) => {
let (sample_rates, channels) = query_device_caps(&pcm);
if !sample_rates.is_empty() && !channels.is_empty() {
push_info(
sample_rates,
channels,
format!("{} - Device {}", card_longname, device_index),
);
}
}
Err(_) if is_current_device => {
debug!(
"Device {} is busy (in use by us), adding with default caps",
device_name
);
push_info(
vec![44_100, 48_000],
vec![2],
format!("{} - Device {} (in use)", card_longname, device_index),
);
}
Err(_) => {}
}
}
}
info!("Found {} audio capture devices", devices.len());
Ok(devices)
}
fn query_device_caps(pcm: &PCM) -> (Vec<u32>, Vec<u32>) {
let hwp = match HwParams::any(pcm) {
Ok(h) => h,
Err(_) => return (vec![], vec![]),
};
let common_rates = [8000, 16000, 22050, 44100, 48000, 96000];
let mut supported_rates = Vec::new();
for rate in &common_rates {
if hwp.test_rate(*rate).is_ok() {
supported_rates.push(*rate);
}
}
let mut supported_channels = Vec::new();
for ch in 1..=8 {
if hwp.test_channels(ch).is_ok() {
supported_channels.push(ch);
}
}
(supported_rates, supported_channels)
}
pub fn find_best_audio_device() -> Result<AudioDeviceInfo> {
let devices = enumerate_audio_devices()?;
if devices.is_empty() {
return Err(AppError::AudioError(
"No audio capture devices found".to_string(),
));
}
let mut first_48k_stereo: Option<&AudioDeviceInfo> = None;
for device in &devices {
if !device.sample_rates.contains(&48_000) || !device.channels.contains(&2) {
continue;
}
if device.is_hdmi {
info!("Selected HDMI audio device: {}", device.description);
return Ok(device.clone());
}
if first_48k_stereo.is_none() {
first_48k_stereo = Some(device);
}
}
if let Some(device) = first_48k_stereo {
info!("Selected audio device: {}", device.description);
return Ok(device.clone());
}
let device = devices.into_iter().next().unwrap();
warn!(
"Using fallback audio device: {} (may not support optimal settings)",
device.description
);
Ok(device)
}

View File

@@ -130,11 +130,11 @@ fn device_labels(device: &cpal::Device) -> DeviceLabels {
let formatted = desc.to_string(); let formatted = desc.to_string();
let display = desc let display = desc
.extended() .extended()
.first() .next()
.cloned() .map(str::to_owned)
.unwrap_or_else(|| formatted.clone()); .unwrap_or_else(|| formatted.clone());
let mut parts = vec![formatted, desc.name().to_string(), display.clone()]; let mut parts = vec![formatted, desc.name().to_string(), display.clone()];
parts.extend(desc.extended().iter().cloned()); parts.extend(desc.extended().map(str::to_owned));
DeviceLabels { DeviceLabels {
display, display,

View File

@@ -79,7 +79,7 @@ fn unauthorized_response(message: &str) -> Response {
fn is_public_endpoint(path: &str) -> bool { fn is_public_endpoint(path: &str) -> bool {
matches!( matches!(
path, path,
"/" | "/auth/login" | "/health" | "/setup" | "/setup/init" "/" | "/auth/login" | "/auth/login/totp" | "/health" | "/setup" | "/setup/init"
) || path.starts_with("/assets/") ) || path.starts_with("/assets/")
|| path.starts_with("/static/") || path.starts_with("/static/")
|| path.ends_with(".js") || path.ends_with(".js")

View File

@@ -1,9 +1,11 @@
pub mod middleware; pub mod middleware;
mod password; mod password;
mod session; mod session;
mod two_factor;
mod user; mod user;
pub use middleware::{auth_middleware, SESSION_COOKIE}; pub use middleware::{auth_middleware, SESSION_COOKIE};
pub use password::{hash_password, verify_password}; pub use password::{hash_password, verify_password};
pub use session::{Session, SessionStore}; pub use session::{Session, SessionStore};
pub use two_factor::{server_time_unix_ms, ChallengeInfo, EnrollmentInfo, TwoFactorService};
pub use user::{User, UserStore}; pub use user::{User, UserStore};

View File

@@ -39,18 +39,39 @@ impl SessionStore {
} }
pub async fn create(&self, user_id: &str) -> Result<Session> { pub async fn create(&self, user_id: &str) -> Result<Session> {
let session = self.new_session(user_id);
let mut guard = self.inner.write().await;
guard.insert(session.id.clone(), session.clone());
Ok(session)
}
pub async fn create_for_login(
&self,
user_id: &str,
allow_multiple_sessions: bool,
) -> Result<(Session, Vec<String>)> {
let session = self.new_session(user_id);
let mut guard = self.inner.write().await;
let revoked = if allow_multiple_sessions {
Vec::new()
} else {
let ids = guard.keys().cloned().collect();
guard.clear();
ids
};
guard.insert(session.id.clone(), session.clone());
Ok((session, revoked))
}
fn new_session(&self, user_id: &str) -> Session {
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let session = Session { Session {
id: Uuid::new_v4().to_string(), id: Uuid::new_v4().to_string(),
user_id: user_id.to_string(), user_id: user_id.to_string(),
created_at: now, created_at: now,
expires_at: now + self.default_ttl, expires_at: now + self.default_ttl,
data: None, data: None,
}; }
let mut guard = self.inner.write().await;
guard.insert(session.id.clone(), session.clone());
Ok(session)
} }
pub async fn get(&self, session_id: &str) -> Result<Option<Session>> { pub async fn get(&self, session_id: &str) -> Result<Option<Session>> {
@@ -85,6 +106,17 @@ impl SessionStore {
Ok(n) Ok(n)
} }
pub async fn delete_all_except(&self, session_id: &str) -> Result<Vec<String>> {
let mut guard = self.inner.write().await;
let revoked: Vec<String> = guard
.keys()
.filter(|id| id.as_str() != session_id)
.cloned()
.collect();
guard.retain(|id, _| id == session_id);
Ok(revoked)
}
pub async fn list_ids(&self) -> Result<Vec<String>> { pub async fn list_ids(&self) -> Result<Vec<String>> {
let guard = self.inner.read().await; let guard = self.inner.read().await;
Ok(guard.keys().cloned().collect()) Ok(guard.keys().cloned().collect())
@@ -102,3 +134,38 @@ impl SessionStore {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn delete_all_except_preserves_only_current_session() {
let sessions = SessionStore::new(60);
let current = sessions.create("user").await.unwrap();
let other = sessions.create("user").await.unwrap();
let revoked = sessions.delete_all_except(&current.id).await.unwrap();
assert_eq!(revoked, vec![other.id.clone()]);
assert!(sessions.get(&current.id).await.unwrap().is_some());
assert!(sessions.get(&other.id).await.unwrap().is_none());
}
#[tokio::test]
async fn login_creation_applies_session_policy_atomically() {
let sessions = SessionStore::new(60);
let existing = sessions.create("user").await.unwrap();
let (multiple, revoked) = sessions.create_for_login("user", true).await.unwrap();
assert!(revoked.is_empty());
assert!(sessions.get(&existing.id).await.unwrap().is_some());
assert!(sessions.get(&multiple.id).await.unwrap().is_some());
let (single, mut revoked) = sessions.create_for_login("user", false).await.unwrap();
revoked.sort();
let mut expected = vec![existing.id, multiple.id];
expected.sort();
assert_eq!(revoked, expected);
assert_eq!(sessions.list_ids().await.unwrap(), vec![single.id]);
}
}

498
src/auth/two_factor.rs Normal file
View File

@@ -0,0 +1,498 @@
use std::collections::{HashMap, VecDeque};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use sqlx::{Pool, Sqlite};
use tokio::sync::Mutex;
use totp_rs::{Algorithm, Secret, TOTP};
use uuid::Uuid;
use crate::error::{AppError, Result};
const LOGIN_TTL: Duration = Duration::from_secs(5 * 60);
const ENROLLMENT_TTL: Duration = Duration::from_secs(10 * 60);
const FAILURE_WINDOW: Duration = Duration::from_secs(60);
const MAX_FAILURES: usize = 5;
struct LoginChallenge {
id: String,
user_id: String,
expires_at: Instant,
expires_at_unix_ms: u64,
failures: usize,
}
struct EnrollmentChallenge {
id: String,
user_id: String,
secret: Secret,
expires_at: Instant,
expires_at_unix_ms: u64,
failures: usize,
}
#[derive(Clone)]
pub struct ChallengeInfo {
pub id: String,
pub expires_at_unix_ms: u64,
}
#[derive(Clone)]
pub struct EnrollmentInfo {
pub id: String,
pub secret: String,
pub otpauth_uri: String,
pub expires_at_unix_ms: u64,
}
#[derive(Clone)]
pub struct TwoFactorService {
pool: Pool<Sqlite>,
login_challenges: std::sync::Arc<Mutex<HashMap<String, LoginChallenge>>>,
enrollment_challenges: std::sync::Arc<Mutex<HashMap<String, EnrollmentChallenge>>>,
failures: std::sync::Arc<Mutex<HashMap<String, VecDeque<Instant>>>>,
}
impl TwoFactorService {
pub fn new(pool: Pool<Sqlite>) -> Self {
Self {
pool,
login_challenges: Default::default(),
enrollment_challenges: Default::default(),
failures: Default::default(),
}
}
pub async fn is_enabled(&self, user_id: &str) -> Result<bool> {
let exists: Option<(i64,)> =
sqlx::query_as("SELECT 1 FROM user_totp_credentials WHERE user_id = ?1 LIMIT 1")
.bind(user_id)
.fetch_optional(&self.pool)
.await?;
Ok(exists.is_some())
}
pub async fn begin_login(&self, user_id: &str) -> Result<Option<ChallengeInfo>> {
if !self.is_enabled(user_id).await? {
return Ok(None);
}
let challenge = LoginChallenge {
id: Uuid::new_v4().to_string(),
user_id: user_id.to_string(),
expires_at: Instant::now() + LOGIN_TTL,
expires_at_unix_ms: expires_at_unix_ms(LOGIN_TTL),
failures: 0,
};
let info = ChallengeInfo {
id: challenge.id.clone(),
expires_at_unix_ms: challenge.expires_at_unix_ms,
};
self.login_challenges
.lock()
.await
.insert(user_id.to_string(), challenge);
Ok(Some(info))
}
pub async fn complete_login(&self, challenge_id: &str, code: &str) -> Result<String> {
validate_code_format(code)?;
let (user_id, expired) = {
let challenges = self.login_challenges.lock().await;
let challenge = challenges
.values()
.find(|challenge| challenge.id == challenge_id)
.ok_or_else(|| AppError::AuthError("TOTP challenge expired".to_string()))?;
(
challenge.user_id.clone(),
Instant::now() >= challenge.expires_at,
)
};
if expired {
self.login_challenges.lock().await.remove(&user_id);
return Err(AppError::AuthError("TOTP challenge expired".to_string()));
}
self.enforce_failure_limit(&user_id).await?;
let valid = match self.credential_secret(&user_id).await? {
Some(secret) => verify_at(&secret, code, unix_time_secs())?,
None => {
self.login_challenges.lock().await.remove(&user_id);
return Err(AppError::AuthError("TOTP challenge expired".to_string()));
}
};
if !valid {
self.record_failure(&user_id).await;
let mut challenges = self.login_challenges.lock().await;
let mut exhausted = false;
if let Some(challenge) = challenges.get_mut(&user_id) {
challenge.failures += 1;
if challenge.failures >= MAX_FAILURES {
exhausted = true;
challenges.remove(&user_id);
}
}
if exhausted {
return Err(AppError::AuthError("TOTP challenge expired".to_string()));
}
return Err(AppError::AuthError("Invalid TOTP code".to_string()));
}
let consumed = self
.login_challenges
.lock()
.await
.remove(&user_id)
.is_some_and(|challenge| challenge.id == challenge_id);
if !consumed {
return Err(AppError::AuthError("TOTP challenge expired".to_string()));
}
Ok(user_id)
}
pub async fn begin_enrollment(
&self,
session_id: &str,
user_id: &str,
username: &str,
) -> Result<EnrollmentInfo> {
if self.is_enabled(user_id).await? {
return Err(AppError::Conflict("TOTP is already enabled".to_string()));
}
let secret = Secret::generate_secret().to_encoded();
let totp = totp(&secret, username)?;
let challenge = EnrollmentChallenge {
id: Uuid::new_v4().to_string(),
user_id: user_id.to_string(),
secret,
expires_at: Instant::now() + ENROLLMENT_TTL,
expires_at_unix_ms: expires_at_unix_ms(ENROLLMENT_TTL),
failures: 0,
};
let info = EnrollmentInfo {
id: challenge.id.clone(),
secret: challenge.secret.to_string(),
otpauth_uri: totp.get_url(),
expires_at_unix_ms: challenge.expires_at_unix_ms,
};
self.enrollment_challenges
.lock()
.await
.insert(session_id.to_string(), challenge);
Ok(info)
}
pub async fn confirm_enrollment(
&self,
session_id: &str,
user_id: &str,
enrollment_id: &str,
code: &str,
) -> Result<()> {
validate_code_format(code)?;
self.enforce_failure_limit(user_id).await?;
let (secret, expired) = {
let challenges = self.enrollment_challenges.lock().await;
let challenge = challenges
.get(session_id)
.filter(|challenge| challenge.id == enrollment_id && challenge.user_id == user_id)
.ok_or_else(|| AppError::AuthError("TOTP enrollment expired".to_string()))?;
(
challenge.secret.clone(),
Instant::now() >= challenge.expires_at,
)
};
if expired {
self.enrollment_challenges.lock().await.remove(session_id);
return Err(AppError::AuthError("TOTP enrollment expired".to_string()));
}
if !verify_at(&secret, code, unix_time_secs())? {
self.record_failure(user_id).await;
let mut challenges = self.enrollment_challenges.lock().await;
let mut exhausted = false;
if let Some(challenge) = challenges.get_mut(session_id) {
challenge.failures += 1;
if challenge.failures >= MAX_FAILURES {
exhausted = true;
challenges.remove(session_id);
}
}
if exhausted {
return Err(AppError::AuthError("TOTP enrollment expired".to_string()));
}
return Err(AppError::AuthError("Invalid TOTP code".to_string()));
}
let mut transaction = self.pool.begin().await?;
let result =
sqlx::query("INSERT INTO user_totp_credentials (user_id, secret) VALUES (?1, ?2)")
.bind(user_id)
.bind(secret.to_string())
.execute(&mut *transaction)
.await;
match result {
Ok(_) => transaction.commit().await?,
Err(sqlx::Error::Database(error)) if error.is_unique_violation() => {
return Err(AppError::Conflict("TOTP is already enabled".to_string()));
}
Err(error) => return Err(error.into()),
}
self.enrollment_challenges.lock().await.remove(session_id);
Ok(())
}
pub async fn disable(&self, user_id: &str, code: &str) -> Result<()> {
validate_code_format(code)?;
self.enforce_failure_limit(user_id).await?;
let secret = self
.credential_secret(user_id)
.await?
.ok_or_else(|| AppError::Conflict("TOTP is not enabled".to_string()))?;
if !verify_at(&secret, code, unix_time_secs())? {
self.record_failure(user_id).await;
return Err(AppError::AuthError("Invalid TOTP code".to_string()));
}
let result = sqlx::query("DELETE FROM user_totp_credentials WHERE user_id = ?1")
.bind(user_id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::Conflict("TOTP is not enabled".to_string()));
}
self.clear_user_challenges(user_id).await;
Ok(())
}
pub async fn disable_without_code(&self, user_id: &str) -> Result<bool> {
let result = sqlx::query("DELETE FROM user_totp_credentials WHERE user_id = ?1")
.bind(user_id)
.execute(&self.pool)
.await?;
self.clear_user_challenges(user_id).await;
Ok(result.rows_affected() > 0)
}
async fn credential_secret(&self, user_id: &str) -> Result<Option<Secret>> {
let row: Option<(String,)> =
sqlx::query_as("SELECT secret FROM user_totp_credentials WHERE user_id = ?1")
.bind(user_id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|(secret,)| Secret::Encoded(secret)))
}
async fn enforce_failure_limit(&self, user_id: &str) -> Result<()> {
let now = Instant::now();
let mut failures = self.failures.lock().await;
let attempts = failures.entry(user_id.to_string()).or_default();
while attempts
.front()
.is_some_and(|attempt| now.duration_since(*attempt) >= FAILURE_WINDOW)
{
attempts.pop_front();
}
if attempts.len() >= MAX_FAILURES {
return Err(AppError::RateLimited(
"TOTP verification is temporarily limited".to_string(),
));
}
Ok(())
}
async fn record_failure(&self, user_id: &str) {
self.failures
.lock()
.await
.entry(user_id.to_string())
.or_default()
.push_back(Instant::now());
}
async fn clear_user_challenges(&self, user_id: &str) {
self.login_challenges.lock().await.remove(user_id);
self.enrollment_challenges
.lock()
.await
.retain(|_, challenge| challenge.user_id != user_id);
self.failures.lock().await.remove(user_id);
}
}
fn totp(secret: &Secret, account_name: &str) -> Result<TOTP> {
let account_name = account_name.replace(':', "_");
TOTP::new(
Algorithm::SHA1,
6,
1,
30,
secret
.to_bytes()
.map_err(|error| AppError::Internal(error.to_string()))?,
Some("One-KVM".to_string()),
account_name,
)
.map_err(|error| AppError::Internal(error.to_string()))
}
fn verify_at(secret: &Secret, code: &str, unix_time: u64) -> Result<bool> {
validate_code_format(code)?;
Ok(totp(secret, "user")?.check(code, unix_time))
}
fn validate_code_format(code: &str) -> Result<()> {
if code.len() != 6 || !code.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(AppError::BadRequest(
"TOTP code must contain exactly 6 digits".to_string(),
));
}
Ok(())
}
pub fn server_time_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
fn unix_time_secs() -> u64 {
server_time_unix_ms() / 1000
}
fn expires_at_unix_ms(ttl: Duration) -> u64 {
server_time_unix_ms().saturating_add(ttl.as_millis() as u64)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::DatabasePool;
use tempfile::tempdir;
async fn test_service() -> (tempfile::TempDir, TwoFactorService, String) {
let dir = tempdir().unwrap();
let db = DatabasePool::new(&dir.path().join("test.db"))
.await
.unwrap();
db.init_schema().await.unwrap();
let user_id = "user-1".to_string();
sqlx::query("INSERT INTO users (id, username, password_hash) VALUES (?1, 'admin', 'hash')")
.bind(&user_id)
.execute(db.pool())
.await
.unwrap();
let service = TwoFactorService::new(db.clone_pool());
(dir, service, user_id)
}
async fn install_known_credential(service: &TwoFactorService, user_id: &str) -> Secret {
let secret = Secret::Raw(b"12345678901234567890".to_vec()).to_encoded();
sqlx::query("INSERT INTO user_totp_credentials (user_id, secret) VALUES (?1, ?2)")
.bind(user_id)
.bind(secret.to_string())
.execute(&service.pool)
.await
.unwrap();
secret
}
#[test]
fn accepts_rfc_vector_and_adjacent_window() {
let secret = Secret::Raw(b"12345678901234567890".to_vec());
assert!(verify_at(&secret, "287082", 59).unwrap());
let code = totp(&secret, "user").unwrap().generate(30);
assert!(verify_at(&secret, &code, 60).unwrap());
}
#[test]
fn rejects_malformed_codes() {
let secret = Secret::Raw(b"12345678901234567890".to_vec());
assert!(verify_at(&secret, "12345", 59).is_err());
assert!(verify_at(&secret, "12345x", 59).is_err());
}
#[tokio::test]
async fn login_challenges_are_replaced_expire_and_are_consumed_once() {
let (_dir, service, user_id) = test_service().await;
let secret = install_known_credential(&service, &user_id).await;
let first = service.begin_login(&user_id).await.unwrap().unwrap();
let second = service.begin_login(&user_id).await.unwrap().unwrap();
let code = totp(&secret, "user").unwrap().generate_current().unwrap();
assert!(service.complete_login(&first.id, &code).await.is_err());
assert_eq!(
service.complete_login(&second.id, &code).await.unwrap(),
user_id
);
assert!(service.complete_login(&second.id, &code).await.is_err());
let expired = service.begin_login(&user_id).await.unwrap().unwrap();
service
.login_challenges
.lock()
.await
.get_mut(&user_id)
.unwrap()
.expires_at = Instant::now() - Duration::from_secs(1);
assert!(service.complete_login(&expired.id, &code).await.is_err());
}
#[tokio::test]
async fn challenge_failure_limit_is_shared_across_new_challenges() {
let (_dir, service, user_id) = test_service().await;
let secret = install_known_credential(&service, &user_id).await;
let valid = totp(&secret, "user").unwrap().generate_current().unwrap();
let invalid = if valid == "000000" {
"000001"
} else {
"000000"
};
let challenge = service.begin_login(&user_id).await.unwrap().unwrap();
for _ in 0..4 {
let error = service
.complete_login(&challenge.id, invalid)
.await
.unwrap_err();
assert!(matches!(error, AppError::AuthError(_)));
}
let error = service
.complete_login(&challenge.id, invalid)
.await
.unwrap_err();
assert!(error.to_string().contains("challenge expired"));
let replacement = service.begin_login(&user_id).await.unwrap().unwrap();
let error = service
.complete_login(&replacement.id, &valid)
.await
.unwrap_err();
assert!(matches!(error, AppError::RateLimited(_)));
}
#[tokio::test]
async fn enrollment_persists_and_disable_is_idempotent_for_cli() {
let (_dir, service, user_id) = test_service().await;
let enrollment = service
.begin_enrollment("session-1", &user_id, "admin")
.await
.unwrap();
let secret = Secret::Encoded(enrollment.secret.clone());
let code = totp(&secret, "admin").unwrap().generate_current().unwrap();
service
.confirm_enrollment("session-1", &user_id, &enrollment.id, &code)
.await
.unwrap();
let restarted = TwoFactorService::new(service.pool.clone());
assert!(restarted.is_enabled(&user_id).await.unwrap());
restarted.disable(&user_id, &code).await.unwrap();
assert!(!restarted.is_enabled(&user_id).await.unwrap());
assert!(!restarted.disable_without_code(&user_id).await.unwrap());
}
}

View File

@@ -104,19 +104,14 @@ pub struct ComputerUseStartRequest {
#[serde(default)] #[serde(default)]
pub continue_conversation: bool, pub continue_conversation: bool,
pub client_id: String, pub client_id: String,
pub max_steps: Option<u32>,
pub timeout_seconds: Option<u32>,
} }
#[typeshare] #[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComputerUseConfigResponse { pub struct ComputerUseConfigResponse {
pub enabled: bool, pub enabled: bool,
pub provider: String,
pub base_url: String, pub base_url: String,
pub model: String, pub model: String,
pub max_steps: u32,
pub timeout_seconds: u32,
pub api_key_configured: bool, pub api_key_configured: bool,
pub api_key_source: String, pub api_key_source: String,
} }
@@ -127,10 +122,10 @@ pub struct ComputerUseConfigUpdate {
pub enabled: Option<bool>, pub enabled: Option<bool>,
pub base_url: Option<String>, pub base_url: Option<String>,
pub model: Option<String>, pub model: Option<String>,
pub max_steps: Option<u32>, #[serde(alias = "openai_api_key")]
pub timeout_seconds: Option<u32>, pub api_key: Option<String>,
pub openai_api_key: Option<String>, #[serde(alias = "clear_openai_api_key")]
pub clear_openai_api_key: Option<bool>, pub clear_api_key: Option<bool>,
} }
#[typeshare] #[typeshare]
@@ -140,7 +135,6 @@ pub struct ComputerUseSessionSummary {
pub status: ComputerUseSessionStatus, pub status: ComputerUseSessionStatus,
pub prompt: Option<String>, pub prompt: Option<String>,
pub step: u32, pub step: u32,
pub max_steps: u32,
pub last_error: Option<String>, pub last_error: Option<String>,
pub final_message: Option<String>, pub final_message: Option<String>,
} }
@@ -152,6 +146,10 @@ pub enum ComputerUseWsClientMessage {
request_id: String, request_id: String,
screenshot: ComputerUseScreenshot, screenshot: ComputerUseScreenshot,
}, },
ScreenshotError {
request_id: String,
message: String,
},
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -161,6 +159,8 @@ pub enum ComputerUseWsServerMessage {
ScreenshotRequested { request_id: String }, ScreenshotRequested { request_id: String },
ScreenshotCaptured { screenshot: ComputerUseScreenshot }, ScreenshotCaptured { screenshot: ComputerUseScreenshot },
StepStarted { step: u32 }, StepStarted { step: u32 },
ReasoningDelta { delta: String },
ReasoningCompleted { failed: bool },
ActionsExecuted { actions: Vec<ComputerUseAction> }, ActionsExecuted { actions: Vec<ComputerUseAction> },
Error { message: String }, Error { message: String },
} }
@@ -203,4 +203,16 @@ mod tests {
}) })
); );
} }
#[test]
fn config_update_accepts_legacy_api_key_names() {
let update: ComputerUseConfigUpdate = serde_json::from_value(json!({
"openai_api_key": "legacy-key",
"clear_openai_api_key": true
}))
.unwrap();
assert_eq!(update.api_key.as_deref(), Some("legacy-key"));
assert_eq!(update.clear_api_key, Some(true));
}
} }

View File

@@ -1,9 +1,8 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::Duration;
use axum::extract::ws::{Message, WebSocket}; use axum::extract::ws::{Message, WebSocket};
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
use serde_json::Value;
use tokio::sync::{broadcast, oneshot, watch, Mutex}; use tokio::sync::{broadcast, oneshot, watch, Mutex};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use uuid::Uuid; use uuid::Uuid;
@@ -43,7 +42,7 @@ struct ManagerState {
struct ScreenshotWaiter { struct ScreenshotWaiter {
request_id: String, request_id: String,
client_id: String, client_id: String,
tx: oneshot::Sender<ComputerUseScreenshot>, tx: oneshot::Sender<Result<ComputerUseScreenshot>>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -74,24 +73,16 @@ impl ComputerUseManager {
pub fn config_response(&self) -> ComputerUseConfigResponse { pub fn config_response(&self) -> ComputerUseConfigResponse {
let config = self.config.get(); let config = self.config.get();
let key_env = std::env::var("OPENAI_API_KEY") let key_env = cua_api_key_env();
.ok()
.filter(|key| !key.is_empty());
let key_db = config let key_db = config
.computer_use .computer_use
.openai_api_key .api_key
.as_ref() .as_ref()
.filter(|key| !key.is_empty()); .filter(|key| !key.is_empty());
ComputerUseConfigResponse { ComputerUseConfigResponse {
enabled: config.computer_use.enabled, enabled: config.computer_use.enabled,
provider: config.computer_use.provider.clone(), base_url: cua_base_url_env().unwrap_or_else(|| config.computer_use.base_url.clone()),
base_url: std::env::var("ONE_KVM_OPENAI_BASE_URL")
.ok()
.filter(|url| !url.trim().is_empty())
.unwrap_or_else(|| config.computer_use.base_url.clone()),
model: config.computer_use.model.clone(), model: config.computer_use.model.clone(),
max_steps: config.computer_use.max_steps,
timeout_seconds: config.computer_use.timeout_seconds,
api_key_configured: key_env.is_some() || key_db.is_some(), api_key_configured: key_env.is_some() || key_db.is_some(),
api_key_source: if key_env.is_some() { api_key_source: if key_env.is_some() {
"env".to_string() "env".to_string()
@@ -107,7 +98,6 @@ impl ComputerUseManager {
&self, &self,
req: ComputerUseConfigUpdate, req: ComputerUseConfigUpdate,
) -> Result<ComputerUseConfigResponse> { ) -> Result<ComputerUseConfigResponse> {
validate_limits(req.max_steps, req.timeout_seconds)?;
if let Some(base_url) = req if let Some(base_url) = req
.base_url .base_url
.as_ref() .as_ref()
@@ -131,17 +121,11 @@ impl ComputerUseManager {
{ {
config.computer_use.base_url = base_url.trim().to_string(); config.computer_use.base_url = base_url.trim().to_string();
} }
if let Some(max_steps) = req.max_steps { if req.clear_api_key.unwrap_or(false) {
config.computer_use.max_steps = max_steps; config.computer_use.api_key = None;
} }
if let Some(timeout_seconds) = req.timeout_seconds { if let Some(key) = req.api_key.as_ref() {
config.computer_use.timeout_seconds = timeout_seconds; config.computer_use.api_key = if key.trim().is_empty() {
}
if req.clear_openai_api_key.unwrap_or(false) {
config.computer_use.openai_api_key = None;
}
if let Some(key) = req.openai_api_key.as_ref() {
config.computer_use.openai_api_key = if key.trim().is_empty() {
None None
} else { } else {
Some(key.trim().to_string()) Some(key.trim().to_string())
@@ -169,7 +153,6 @@ impl ComputerUseManager {
if req.prompt.trim().is_empty() { if req.prompt.trim().is_empty() {
return Err(AppError::BadRequest("Task prompt is required".to_string())); return Err(AppError::BadRequest("Task prompt is required".to_string()));
} }
validate_limits(req.max_steps, req.timeout_seconds)?;
let client_id = req.client_id.trim(); let client_id = req.client_id.trim();
if client_id.is_empty() { if client_id.is_empty() {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
@@ -184,15 +167,12 @@ impl ComputerUseManager {
)); ));
} }
let api_key = std::env::var("OPENAI_API_KEY") let api_key = cua_api_key_env()
.ok() .or(config.api_key.clone())
.filter(|key| !key.is_empty()) .ok_or_else(|| {
.or(config.openai_api_key.clone()) AppError::BadRequest("Computer Use API key is not configured".to_string())
.ok_or_else(|| AppError::BadRequest("OpenAI API key is not configured".to_string()))?; })?;
let base_url = std::env::var("ONE_KVM_OPENAI_BASE_URL") let base_url = cua_base_url_env().unwrap_or_else(|| config.base_url.clone());
.ok()
.filter(|url| !url.trim().is_empty())
.unwrap_or_else(|| config.base_url.clone());
validate_endpoint_url(&base_url)?; validate_endpoint_url(&base_url)?;
let mut state = self.state.lock().await; let mut state = self.state.lock().await;
@@ -225,10 +205,9 @@ impl ComputerUseManager {
let session_id = Uuid::new_v4().to_string(); let session_id = Uuid::new_v4().to_string();
state.session = ComputerUseSessionSummary { state.session = ComputerUseSessionSummary {
id: Some(session_id), id: Some(session_id),
status: ComputerUseSessionStatus::WaitingScreenshot, status: ComputerUseSessionStatus::Thinking,
prompt: Some(req.prompt.trim().to_string()), prompt: Some(req.prompt.trim().to_string()),
step: 0, step: 0,
max_steps: req.max_steps.unwrap_or(config.max_steps),
last_error: None, last_error: None,
final_message: None, final_message: None,
}; };
@@ -240,9 +219,6 @@ impl ComputerUseManager {
self.publish_session().await; self.publish_session().await;
let manager = self.clone(); let manager = self.clone();
let prompt = req.prompt.trim().to_string(); let prompt = req.prompt.trim().to_string();
let max_steps = summary.max_steps;
let timeout =
Duration::from_secs(req.timeout_seconds.unwrap_or(config.timeout_seconds) as u64);
let model = config.model.clone(); let model = config.model.clone();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
manager manager
@@ -253,8 +229,6 @@ impl ComputerUseManager {
model, model,
conversation, conversation,
client_id, client_id,
max_steps,
timeout,
cancel_rx, cancel_rx,
stop_rx, stop_rx,
) )
@@ -304,10 +278,30 @@ impl ComputerUseManager {
state.screenshot_waiter = Some(waiter); state.screenshot_waiter = Some(waiter);
return Ok(()); return Ok(());
} }
let _ = waiter.tx.send(screenshot); let _ = waiter.tx.send(Ok(screenshot));
Ok(()) Ok(())
} }
async fn submit_screenshot_error(&self, client_id: &str, request_id: String, message: String) {
let mut state = self.state.lock().await;
let Some(waiter) = state.screenshot_waiter.take() else {
return;
};
if waiter.request_id != request_id || waiter.client_id != client_id {
state.screenshot_waiter = Some(waiter);
return;
}
let message: String = message.chars().take(300).collect();
let _ = waiter.tx.send(Err(AppError::ServiceUnavailable(format!(
"Screenshot capture failed: {}",
if message.trim().is_empty() {
"client did not provide an error"
} else {
message.trim()
}
))));
}
pub async fn handle_socket(self: Arc<Self>, socket: WebSocket, client_id: Option<String>) { pub async fn handle_socket(self: Arc<Self>, socket: WebSocket, client_id: Option<String>) {
let (mut sender, mut receiver) = socket.split(); let (mut sender, mut receiver) = socket.split();
let mut event_rx = self.event_tx.subscribe(); let mut event_rx = self.event_tx.subscribe();
@@ -352,11 +346,15 @@ impl ComputerUseManager {
msg = receiver.next() => { msg = receiver.next() => {
match msg { match msg {
Some(Ok(Message::Text(text))) => { Some(Ok(Message::Text(text))) => {
if let Ok(ComputerUseWsClientMessage::ScreenshotResult { request_id, screenshot }) = match serde_json::from_str::<ComputerUseWsClientMessage>(&text) {
serde_json::from_str::<ComputerUseWsClientMessage>(&text) Ok(ComputerUseWsClientMessage::ScreenshotResult { request_id, screenshot }) => {
{
let _ = self.submit_screenshot(&client_id, request_id, screenshot).await; let _ = self.submit_screenshot(&client_id, request_id, screenshot).await;
} }
Ok(ComputerUseWsClientMessage::ScreenshotError { request_id, message }) => {
self.submit_screenshot_error(&client_id, request_id, message).await;
}
Err(_) => {}
}
} }
Some(Ok(Message::Close(_))) | None => break, Some(Ok(Message::Close(_))) | None => break,
Some(Err(_)) => break, Some(Err(_)) => break,
@@ -375,84 +373,81 @@ impl ComputerUseManager {
model: String, model: String,
conversation: Vec<ComputerUseConversationMessage>, conversation: Vec<ComputerUseConversationMessage>,
client_id: String, client_id: String,
max_steps: u32,
timeout: Duration,
cancel_rx: watch::Receiver<bool>, cancel_rx: watch::Receiver<bool>,
mut stop_rx: oneshot::Receiver<()>, mut stop_rx: oneshot::Receiver<()>,
) { ) {
let provider = OpenAiComputerProvider::new(api_key, base_url, model); let provider = OpenAiComputerProvider::new(api_key, base_url, model);
let started_at = Instant::now(); let mut latest_screenshot: Option<ComputerUseScreenshot> = None;
let mut previous_response_id: Option<String> = None; let mut action_history: Vec<String> = Vec::new();
let mut previous_call_id: Option<String> = None; let mut step = 0_u32;
let mut safety_checks: Vec<Value> = Vec::new();
for step in 1..=max_steps {
if started_at.elapsed() > timeout {
self.fail("Computer use task timed out").await;
return;
}
self.set_status(ComputerUseSessionStatus::WaitingScreenshot, step, None)
.await;
let screenshot = tokio::select! {
_ = &mut stop_rx => {
self.set_stopped().await;
return;
}
screenshot = self.request_screenshot(&client_id) => screenshot,
};
let screenshot = match screenshot {
Ok(screenshot) => screenshot,
Err(err) => {
self.fail(&err.to_string()).await;
return;
}
};
let _ = self
.event_tx
.send(ComputerUseWsServerMessage::ScreenshotCaptured {
screenshot: screenshot.clone(),
});
loop {
step = step.saturating_add(1);
self.set_status(ComputerUseSessionStatus::Thinking, step, None) self.set_status(ComputerUseSessionStatus::Thinking, step, None)
.await; .await;
let response = tokio::select! { let response = tokio::select! {
_ = &mut stop_rx => { _ = &mut stop_rx => {
let _ = self.event_tx.send(ComputerUseWsServerMessage::ReasoningCompleted {
failed: true,
});
self.set_stopped().await; self.set_stopped().await;
return; return;
} }
response = provider.next_actions( response = provider.next_actions(
&prompt, &prompt,
&conversation, &conversation,
&screenshot, &action_history,
previous_response_id.as_deref(), latest_screenshot.as_ref(),
previous_call_id.as_deref(), |delta| {
safety_checks.clone(), let _ = self.event_tx.send(ComputerUseWsServerMessage::ReasoningDelta {
delta: delta.to_string(),
});
},
) => response, ) => response,
}; };
let response = match response { let response = match response {
Ok(response) => response, Ok(response) => {
let _ = self
.event_tx
.send(ComputerUseWsServerMessage::ReasoningCompleted { failed: false });
response
}
Err(err) => { Err(err) => {
let _ = self
.event_tx
.send(ComputerUseWsServerMessage::ReasoningCompleted { failed: true });
self.fail(&err.to_string()).await; self.fail(&err.to_string()).await;
return; return;
} }
}; };
previous_response_id = response.response_id;
previous_call_id = response.call_id;
safety_checks = response.safety_checks;
if response.actions.is_empty() { if *cancel_rx.borrow() {
self.complete(response.final_message).await; self.set_stopped().await;
return; return;
} }
if response.done {
self.complete(response.message).await;
return;
}
let executable = &response.actions[..response.actions.len().saturating_sub(1)];
action_history.push(format!(
"Step {step}: {}",
serde_json::to_string(&response.actions).unwrap_or_else(|_| "[]".to_string())
));
if !executable.is_empty() {
let Some(screenshot) = latest_screenshot.as_ref() else {
self.fail("Computer Use protocol error: actions require a screenshot")
.await;
return;
};
self.set_status(ComputerUseSessionStatus::Executing, step, None) self.set_status(ComputerUseSessionStatus::Executing, step, None)
.await; .await;
if let Err(err) = self if let Err(err) = self
.execute_actions( .execute_actions(
&response.actions, executable,
screenshot.width, screenshot.width,
screenshot.height, screenshot.height,
cancel_rx.clone(), cancel_rx.clone(),
@@ -469,12 +464,33 @@ impl ComputerUseManager {
let _ = self let _ = self
.event_tx .event_tx
.send(ComputerUseWsServerMessage::ActionsExecuted { .send(ComputerUseWsServerMessage::ActionsExecuted {
actions: response.actions, actions: executable.to_vec(),
}); });
} }
self.complete(Some("Reached the maximum number of steps.".to_string())) self.set_status(ComputerUseSessionStatus::WaitingScreenshot, step, None)
.await; .await;
let screenshot = tokio::select! {
_ = &mut stop_rx => {
self.set_stopped().await;
return;
}
screenshot = self.request_screenshot(&client_id) => screenshot,
};
let screenshot = match screenshot {
Ok(screenshot) => screenshot,
Err(err) => {
self.fail(&err.to_string()).await;
return;
}
};
let _ = self
.event_tx
.send(ComputerUseWsServerMessage::ScreenshotCaptured {
screenshot: screenshot.clone(),
});
latest_screenshot = Some(screenshot);
}
} }
async fn request_screenshot(&self, client_id: &str) -> Result<ComputerUseScreenshot> { async fn request_screenshot(&self, client_id: &str) -> Result<ComputerUseScreenshot> {
@@ -492,14 +508,15 @@ impl ComputerUseManager {
request_id, request_id,
client_id: client_id.to_string(), client_id: client_id.to_string(),
}); });
tokio::time::timeout(SCREENSHOT_TIMEOUT, rx) let reply = tokio::time::timeout(SCREENSHOT_TIMEOUT, rx)
.await .await
.map_err(|_| { .map_err(|_| {
AppError::ServiceUnavailable("Timed out waiting for screenshot".to_string()) AppError::ServiceUnavailable("Timed out waiting for screenshot".to_string())
})? })?
.map_err(|_| { .map_err(|_| {
AppError::ServiceUnavailable("Screenshot request was cancelled".to_string()) AppError::ServiceUnavailable("Screenshot request was cancelled".to_string())
}) })?;
reply
} }
async fn execute_actions( async fn execute_actions(
@@ -742,36 +759,39 @@ fn stopped_error() -> AppError {
AppError::BadRequest(STOPPED_MESSAGE.to_string()) AppError::BadRequest(STOPPED_MESSAGE.to_string())
} }
fn validate_limits(max_steps: Option<u32>, timeout_seconds: Option<u32>) -> Result<()> {
if let Some(max_steps) = max_steps {
if !(1..=100).contains(&max_steps) {
return Err(AppError::BadRequest(
"max_steps must be between 1 and 100".to_string(),
));
}
}
if let Some(timeout_seconds) = timeout_seconds {
if !(30..=3600).contains(&timeout_seconds) {
return Err(AppError::BadRequest(
"timeout_seconds must be between 30 and 3600".to_string(),
));
}
}
Ok(())
}
fn empty_session() -> ComputerUseSessionSummary { fn empty_session() -> ComputerUseSessionSummary {
ComputerUseSessionSummary { ComputerUseSessionSummary {
id: None, id: None,
status: ComputerUseSessionStatus::Idle, status: ComputerUseSessionStatus::Idle,
prompt: None, prompt: None,
step: 0, step: 0,
max_steps: 0,
last_error: None, last_error: None,
final_message: None, final_message: None,
} }
} }
fn cua_api_key_env() -> Option<String> {
std::env::var("ONE_KVM_CUA_API_KEY")
.ok()
.filter(|key| !key.trim().is_empty())
.or_else(|| {
std::env::var("OPENAI_API_KEY")
.ok()
.filter(|key| !key.trim().is_empty())
})
}
fn cua_base_url_env() -> Option<String> {
std::env::var("ONE_KVM_CUA_BASE_URL")
.ok()
.filter(|url| !url.trim().is_empty())
.or_else(|| {
std::env::var("ONE_KVM_OPENAI_BASE_URL")
.ok()
.filter(|url| !url.trim().is_empty())
})
}
fn validate_endpoint_url(url: &str) -> Result<()> { fn validate_endpoint_url(url: &str) -> Result<()> {
let trimmed = url.trim(); let trimmed = url.trim();
if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) { if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) {

File diff suppressed because it is too large Load Diff

View File

@@ -6,25 +6,42 @@ use typeshare::typeshare;
#[serde(default)] #[serde(default)]
pub struct ComputerUseConfig { pub struct ComputerUseConfig {
pub enabled: bool, pub enabled: bool,
pub provider: String,
pub base_url: String, pub base_url: String,
pub model: String, pub model: String,
#[typeshare(skip)] #[typeshare(skip)]
pub openai_api_key: Option<String>, #[serde(alias = "openai_api_key")]
pub max_steps: u32, pub api_key: Option<String>,
pub timeout_seconds: u32,
} }
impl Default for ComputerUseConfig { impl Default for ComputerUseConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: false, enabled: false,
provider: "openai".to_string(),
base_url: "https://api.openai.com/v1/responses".to_string(), base_url: "https://api.openai.com/v1/responses".to_string(),
model: "gpt-5.5".to_string(), model: "gpt-5.5".to_string(),
openai_api_key: None, api_key: None,
max_steps: 30,
timeout_seconds: 600,
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_openai_api_key_migrates_to_generic_key() {
let config: ComputerUseConfig = serde_json::from_value(serde_json::json!({
"enabled": true,
"provider": "openai",
"base_url": "https://example.test/v1/chat/completions",
"model": "vision-model",
"openai_api_key": "legacy-key",
"max_steps": 30,
"timeout_seconds": 600
}))
.unwrap();
assert_eq!(config.api_key.as_deref(), Some("legacy-key"));
assert_eq!(config.model, "vision-model");
}
}

View File

@@ -81,29 +81,6 @@ pub enum OtgHidProfile {
Custom, Custom,
} }
#[typeshare]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum OtgEndpointBudget {
#[default]
Auto,
Five,
Six,
Unlimited,
}
impl OtgEndpointBudget {
pub fn endpoint_limit_raw(&self) -> Option<u8> {
match self {
Self::Five => Some(5),
Self::Six => Some(6),
Self::Unlimited => None,
Self::Auto => None,
}
}
}
#[typeshare] #[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)] #[serde(default)]
@@ -154,26 +131,6 @@ impl OtgHidFunctions {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
!self.keyboard && !self.mouse_relative && !self.mouse_absolute && !self.consumer !self.keyboard && !self.mouse_relative && !self.mouse_absolute && !self.consumer
} }
pub fn endpoint_cost(&self, keyboard_leds: bool) -> u8 {
let mut endpoints = 0;
if self.keyboard {
endpoints += 1;
if keyboard_leds {
endpoints += 1;
}
}
if self.mouse_relative {
endpoints += 1;
}
if self.mouse_absolute {
endpoints += 1;
}
if self.consumer {
endpoints += 1;
}
endpoints
}
} }
impl Default for OtgHidFunctions { impl Default for OtgHidFunctions {
@@ -216,8 +173,6 @@ pub struct HidConfig {
#[serde(default)] #[serde(default)]
pub otg_profile: OtgHidProfile, pub otg_profile: OtgHidProfile,
#[serde(default)] #[serde(default)]
pub otg_endpoint_budget: OtgEndpointBudget,
#[serde(default)]
pub otg_functions: OtgHidFunctions, pub otg_functions: OtgHidFunctions,
#[serde(default)] #[serde(default)]
pub otg_keyboard_leds: bool, pub otg_keyboard_leds: bool,
@@ -237,7 +192,6 @@ impl Default for HidConfig {
otg_udc: None, otg_udc: None,
otg_descriptor: OtgDescriptorConfig::default(), otg_descriptor: OtgDescriptorConfig::default(),
otg_profile: OtgHidProfile::default(), otg_profile: OtgHidProfile::default(),
otg_endpoint_budget: OtgEndpointBudget::default(),
otg_functions: OtgHidFunctions::default(), otg_functions: OtgHidFunctions::default(),
otg_keyboard_leds: false, otg_keyboard_leds: false,
ch9329_port: "/dev/ttyUSB0".to_string(), ch9329_port: "/dev/ttyUSB0".to_string(),
@@ -262,16 +216,7 @@ impl HidConfig {
self.effective_otg_functions() self.effective_otg_functions()
} }
pub fn effective_otg_required_endpoints(&self, msd_enabled: bool) -> u8 { pub fn validate_otg_functions(&self) -> crate::error::Result<()> {
let functions = self.effective_otg_functions();
let mut endpoints = functions.endpoint_cost(self.effective_otg_keyboard_leds());
if msd_enabled {
endpoints += 2;
}
endpoints
}
pub fn validate_otg_endpoint_budget(&self, msd_enabled: bool) -> crate::error::Result<()> {
if self.backend != HidBackend::Otg { if self.backend != HidBackend::Otg {
return Ok(()); return Ok(());
} }
@@ -283,17 +228,6 @@ impl HidConfig {
)); ));
} }
let resolved_limit = self.resolved_otg_endpoint_limit();
let required = self.effective_otg_required_endpoints(msd_enabled);
if let Some(limit) = resolved_limit {
if required > limit {
return Err(crate::error::AppError::BadRequest(format!(
"OTG selection requires {} endpoints, but the configured limit is {}",
required, limit
)));
}
}
Ok(()) Ok(())
} }
@@ -317,30 +251,4 @@ impl HidConfig {
} }
}) })
} }
#[inline]
pub fn resolved_otg_endpoint_limit(&self) -> Option<u8> {
if self.backend != HidBackend::Otg {
return None;
}
match self.otg_endpoint_budget {
OtgEndpointBudget::Five => Some(5),
OtgEndpointBudget::Six => Some(6),
OtgEndpointBudget::Unlimited => None,
OtgEndpointBudget::Auto => {
#[cfg(unix)]
let udc = self.resolved_otg_udc().unwrap_or_default();
#[cfg(unix)]
if crate::otg::configfs::is_low_endpoint_udc(&udc) {
Some(5)
} else {
Some(6)
}
#[cfg(not(unix))]
{
Some(6)
}
}
}
}
} }

View File

@@ -8,14 +8,18 @@ mod atx;
mod common; mod common;
mod computer_use; mod computer_use;
mod hid; mod hid;
mod otg_network;
mod stream; mod stream;
mod watchdog;
mod web; mod web;
pub use atx::*; pub use atx::*;
pub use common::*; pub use common::*;
pub use computer_use::*; pub use computer_use::*;
pub use hid::*; pub use hid::*;
pub use otg_network::*;
pub use stream::*; pub use stream::*;
pub use watchdog::*;
pub use web::*; pub use web::*;
#[typeshare] #[typeshare]
@@ -27,6 +31,7 @@ pub struct AppConfig {
pub auth: AuthConfig, pub auth: AuthConfig,
pub video: VideoConfig, pub video: VideoConfig,
pub hid: HidConfig, pub hid: HidConfig,
pub otg_network: OtgNetworkConfig,
pub msd: MsdConfig, pub msd: MsdConfig,
pub atx: AtxConfig, pub atx: AtxConfig,
pub audio: AudioConfig, pub audio: AudioConfig,
@@ -38,12 +43,14 @@ pub struct AppConfig {
pub vnc: VncConfig, pub vnc: VncConfig,
pub rtsp: RtspConfig, pub rtsp: RtspConfig,
pub redfish: RedfishConfig, pub redfish: RedfishConfig,
pub watchdog: WatchdogConfig,
} }
impl AppConfig { impl AppConfig {
pub fn enforce_invariants(&mut self) { pub fn enforce_invariants(&mut self) {
if self.hid.backend != HidBackend::Otg { if self.hid.backend != HidBackend::Otg {
self.msd.enabled = false; self.msd.enabled = false;
self.otg_network.enabled = false;
} }
self.atx.normalize(); self.atx.normalize();
} }
@@ -53,3 +60,18 @@ impl AppConfig {
self.enforce_invariants(); self.enforce_invariants();
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_watchdog_config_defaults_to_disabled() {
let value = serde_json::to_value(AppConfig::default()).unwrap();
let mut object = value.as_object().unwrap().clone();
object.remove("watchdog");
let config: AppConfig = serde_json::from_value(object.into()).unwrap();
assert!(!config.watchdog.enabled);
}
}

View File

@@ -0,0 +1,85 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
#[typeshare]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum OtgNetworkDriverMode {
#[default]
Ncm,
Ecm,
Rndis,
}
impl OtgNetworkDriverMode {
pub fn function_name(self) -> &'static str {
match self {
Self::Ncm => "ncm",
Self::Ecm => "ecm",
Self::Rndis => "rndis",
}
}
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct OtgNetworkConfig {
pub enabled: bool,
pub driver_mode: OtgNetworkDriverMode,
/// Empty means select the connected NetworkManager Ethernet interface.
pub bridge_interface: String,
/// Empty values are resolved from the machine identity at runtime.
pub host_mac: String,
pub device_mac: String,
}
impl OtgNetworkConfig {
pub fn validate(&self) -> crate::error::Result<()> {
for (name, value) in [
("host_mac", self.host_mac.as_str()),
("device_mac", self.device_mac.as_str()),
] {
if !value.is_empty() && !is_valid_unicast_mac(value) {
return Err(crate::error::AppError::BadRequest(format!(
"OTG network {name} must be a locally administered unicast MAC address"
)));
}
}
if !self.host_mac.is_empty()
&& !self.device_mac.is_empty()
&& self.host_mac.eq_ignore_ascii_case(&self.device_mac)
{
return Err(crate::error::AppError::BadRequest(
"OTG network host_mac and device_mac must be different".to_string(),
));
}
if self.bridge_interface.contains('/') || self.bridge_interface.contains('\0') {
return Err(crate::error::AppError::BadRequest(
"Invalid OTG network bridge interface".to_string(),
));
}
Ok(())
}
}
fn is_valid_unicast_mac(value: &str) -> bool {
let bytes = value
.split(':')
.map(|part| u8::from_str_radix(part, 16))
.collect::<Result<Vec<_>, _>>();
matches!(bytes, Ok(ref bytes) if bytes.len() == 6 && bytes[0] & 0x03 == 0x02)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_local_unicast_mac_addresses() {
assert!(is_valid_unicast_mac("02:00:00:00:10:01"));
assert!(!is_valid_unicast_mac("01:00:00:00:10:01"));
assert!(!is_valid_unicast_mac("00:00:00:00:10:01"));
assert!(!is_valid_unicast_mac("bad"));
}
}

View File

@@ -41,7 +41,6 @@ pub struct VncConfig {
pub bind: String, pub bind: String,
pub port: u16, pub port: u16,
pub encoding: VncEncoding, pub encoding: VncEncoding,
pub jpeg_quality: u8,
pub allow_one_client: bool, pub allow_one_client: bool,
#[typeshare(skip)] #[typeshare(skip)]
pub password: Option<String>, pub password: Option<String>,
@@ -54,7 +53,6 @@ impl Default for VncConfig {
bind: "0.0.0.0".to_string(), bind: "0.0.0.0".to_string(),
port: 5900, port: 5900,
encoding: VncEncoding::TightJpeg, encoding: VncEncoding::TightJpeg,
jpeg_quality: 80,
allow_one_client: true, allow_one_client: true,
password: None, password: None,
} }

View File

@@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
#[typeshare]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct WatchdogConfig {
pub enabled: bool,
}

View File

@@ -7,8 +7,6 @@ use typeshare::typeshare;
pub struct AuthConfig { pub struct AuthConfig {
pub session_timeout_secs: u32, pub session_timeout_secs: u32,
pub single_user_allow_multiple_sessions: bool, pub single_user_allow_multiple_sessions: bool,
pub totp_enabled: bool,
pub totp_secret: Option<String>,
} }
impl Default for AuthConfig { impl Default for AuthConfig {
@@ -16,8 +14,6 @@ impl Default for AuthConfig {
Self { Self {
session_timeout_secs: 3600 * 24, session_timeout_secs: 3600 * 24,
single_user_allow_multiple_sessions: false, single_user_allow_multiple_sessions: false,
totp_enabled: false,
totp_secret: None,
} }
} }
} }

View File

@@ -27,13 +27,16 @@ impl ConfigStore {
} }
pub async fn load(&self) -> Result<()> { pub async fn load(&self) -> Result<()> {
let mut config = Self::load_config(&self.pool).await?; let (mut config, removed_legacy_totp) = Self::load_config(&self.pool).await?;
config.enforce_invariants(); config.enforce_invariants();
if removed_legacy_totp {
Self::save_config_to_db(&self.pool, &config).await?;
}
self.cache.store(Arc::new(config)); self.cache.store(Arc::new(config));
Ok(()) Ok(())
} }
async fn load_config(pool: &Pool<Sqlite>) -> Result<AppConfig> { async fn load_config(pool: &Pool<Sqlite>) -> Result<(AppConfig, bool)> {
let row: Option<(String,)> = let row: Option<(String,)> =
sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'") sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'")
.fetch_optional(pool) .fetch_optional(pool)
@@ -41,12 +44,24 @@ impl ConfigStore {
match row { match row {
Some((json,)) => { Some((json,)) => {
serde_json::from_str(&json).map_err(|e| AppError::Config(e.to_string())) let mut value: serde_json::Value =
serde_json::from_str(&json).map_err(|e| AppError::Config(e.to_string()))?;
let mut removed = false;
if let Some(auth) = value
.get_mut("auth")
.and_then(|value| value.as_object_mut())
{
removed |= auth.remove("totp_enabled").is_some();
removed |= auth.remove("totp_secret").is_some();
}
let config =
serde_json::from_value(value).map_err(|e| AppError::Config(e.to_string()))?;
Ok((config, removed))
} }
None => { None => {
let config = AppConfig::default(); let config = AppConfig::default();
Self::save_config_to_db(pool, &config).await?; Self::save_config_to_db(pool, &config).await?;
Ok(config) Ok((config, false))
} }
} }
} }
@@ -154,4 +169,55 @@ mod tests {
assert!(config.initialized); assert!(config.initialized);
assert_eq!(config.web.http_port, 9000); assert_eq!(config.web.http_port, 9000);
} }
#[tokio::test]
async fn failed_watchdog_persistence_does_not_update_cache() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("test.db");
let db = DatabasePool::new(&db_path).await.unwrap();
db.init_schema().await.unwrap();
let store = ConfigStore::new(db.clone_pool()).unwrap();
store.load().await.unwrap();
sqlx::query("DROP TABLE config")
.execute(&db.clone_pool())
.await
.unwrap();
assert!(store
.update(|config| config.watchdog.enabled = true)
.await
.is_err());
assert!(!store.get().watchdog.enabled);
}
#[tokio::test]
async fn load_removes_legacy_totp_fields_from_persisted_config() {
let dir = tempdir().unwrap();
let db = DatabasePool::new(&dir.path().join("test.db"))
.await
.unwrap();
db.init_schema().await.unwrap();
let mut value = serde_json::to_value(AppConfig::default()).unwrap();
let auth = value.get_mut("auth").unwrap().as_object_mut().unwrap();
auth.insert("totp_enabled".to_string(), serde_json::json!(true));
auth.insert(
"totp_secret".to_string(),
serde_json::json!("legacy-secret"),
);
sqlx::query("INSERT INTO config (key, value) VALUES ('app_config', ?1)")
.bind(value.to_string())
.execute(db.pool())
.await
.unwrap();
let store = ConfigStore::new(db.clone_pool()).unwrap();
store.load().await.unwrap();
let (persisted,): (String,) =
sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'")
.fetch_one(db.pool())
.await
.unwrap();
assert!(!persisted.contains("totp_enabled"));
assert!(!persisted.contains("totp_secret"));
}
} }

View File

@@ -30,6 +30,7 @@ impl DatabasePool {
pub async fn init_schema(&self) -> Result<()> { pub async fn init_schema(&self) -> Result<()> {
self.create_config_table().await?; self.create_config_table().await?;
self.create_users_table().await?; self.create_users_table().await?;
self.create_user_totp_credentials_table().await?;
self.create_api_tokens_table().await?; self.create_api_tokens_table().await?;
self.create_wol_history_table().await?; self.create_wol_history_table().await?;
Ok(()) Ok(())
@@ -86,6 +87,22 @@ impl DatabasePool {
Ok(()) Ok(())
} }
async fn create_user_totp_credentials_table(&self) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS user_totp_credentials (
user_id TEXT PRIMARY KEY,
secret TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"#,
)
.execute(&self.pool)
.await?;
Ok(())
}
async fn create_wol_history_table(&self) -> Result<()> { async fn create_wol_history_table(&self) -> Result<()> {
sqlx::query( sqlx::query(
r#" r#"

View File

@@ -176,18 +176,9 @@ fn get_meminfo() -> MemInfo {
} }
fn get_network_addresses() -> Vec<NetworkAddress> { fn get_network_addresses() -> Vec<NetworkAddress> {
#[cfg(target_os = "android")]
{
return get_network_addresses_android();
}
#[cfg(not(target_os = "android"))]
{
get_network_addresses_ifaddrs() get_network_addresses_ifaddrs()
}
} }
#[cfg(not(target_os = "android"))]
fn get_network_addresses_ifaddrs() -> Vec<NetworkAddress> { fn get_network_addresses_ifaddrs() -> Vec<NetworkAddress> {
let all_addrs = match nix::ifaddrs::getifaddrs() { let all_addrs = match nix::ifaddrs::getifaddrs() {
Ok(addrs) => addrs, Ok(addrs) => addrs,
@@ -260,101 +251,6 @@ fn get_network_addresses_ifaddrs() -> Vec<NetworkAddress> {
addresses addresses
} }
#[cfg(target_os = "android")]
fn get_network_addresses_android() -> Vec<NetworkAddress> {
let net_dir = match std::fs::read_dir("/sys/class/net") {
Ok(dir) => dir,
Err(_) => return Vec::new(),
};
let mut addresses = Vec::new();
let mut seen = std::collections::HashSet::new();
for entry in net_dir.flatten() {
let iface_name = match entry.file_name().into_string() {
Ok(name) => name,
Err(_) => continue,
};
if iface_name == "lo" {
continue;
}
let operstate_path = entry.path().join("operstate");
let is_up = std::fs::read_to_string(&operstate_path)
.map(|s| s.trim() == "up")
.unwrap_or(false);
if !is_up {
continue;
}
let Some(ip) = android_ipv4_for_interface(&iface_name) else {
continue;
};
if ip.is_loopback() || ip.is_unspecified() {
continue;
}
let ip_str = ip.to_string();
if seen.insert((iface_name.clone(), ip_str.clone())) {
addresses.push(NetworkAddress {
interface: iface_name,
ip: ip_str,
});
}
}
addresses
}
#[cfg(target_os = "android")]
fn android_ipv4_for_interface(iface_name: &str) -> Option<std::net::Ipv4Addr> {
use std::ffi::CString;
use std::mem::{size_of, zeroed};
let name = CString::new(iface_name).ok()?;
if name.as_bytes().len() >= libc::IFNAMSIZ {
return None;
}
unsafe {
let fd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0);
if fd < 0 {
return None;
}
let mut request: libc::ifreq = zeroed();
std::ptr::copy_nonoverlapping(
name.as_ptr(),
request.ifr_name.as_mut_ptr(),
name.as_bytes_with_nul().len(),
);
let request_code = libc::SIOCGIFADDR.try_into().ok()?;
let result = libc::ioctl(fd, request_code, &mut request);
libc::close(fd);
if result < 0 {
return None;
}
let sockaddr = request.ifr_ifru.ifru_addr;
if sockaddr.sa_family as libc::c_int != libc::AF_INET {
return None;
}
let mut storage = [0u8; size_of::<libc::sockaddr_in>()];
std::ptr::copy_nonoverlapping(
&sockaddr as *const libc::sockaddr as *const u8,
storage.as_mut_ptr(),
size_of::<libc::sockaddr>(),
);
let sockaddr_in = &*(storage.as_ptr() as *const libc::sockaddr_in);
Some(std::net::Ipv4Addr::from(u32::from_be(
sockaddr_in.sin_addr.s_addr,
)))
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{parse_cpu_model_from_cpuinfo_content, parse_device_tree_model_bytes}; use super::{parse_cpu_model_from_cpuinfo_content, parse_device_tree_model_bytes};

View File

@@ -14,6 +14,12 @@ pub enum AppError {
#[error("Bad request: {0}")] #[error("Bad request: {0}")]
BadRequest(String), BadRequest(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("Too many attempts: {0}")]
RateLimited(String),
#[error("Persistence error: {0}")] #[error("Persistence error: {0}")]
Persistence(String), Persistence(String),

View File

@@ -6,7 +6,7 @@ use self::types::EXACT_EVENT_TOPICS;
pub use types::{ pub use types::{
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, LedState, MsdDeviceInfo, AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, LedState, MsdDeviceInfo,
StreamDeviceLostKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo, MsdDeviceMediaInfo, StreamDeviceLostKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo,
}; };
use tokio::sync::broadcast; use tokio::sync::broadcast;

View File

@@ -42,12 +42,24 @@ pub struct HidDeviceInfo {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MsdDeviceInfo { pub struct MsdDeviceInfo {
pub available: bool, pub available: bool,
pub mode: String, pub disk_mode: String,
pub connected: bool, pub slot_capacity: u8,
pub image_id: Option<String>, pub mounted_count: u8,
pub mounted_media: Vec<MsdDeviceMediaInfo>,
pub usb_reenumerating: bool,
pub error: Option<String>, pub error: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MsdDeviceMediaInfo {
pub id: String,
pub kind: String,
pub name: String,
pub cdrom: bool,
pub read_only: bool,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AtxDeviceInfo { pub struct AtxDeviceInfo {
pub available: bool, pub available: bool,

View File

@@ -83,6 +83,10 @@ pub trait HidBackend: Send + Sync {
async fn reset(&self) -> Result<()>; async fn reset(&self) -> Result<()>;
async fn prepare_rebuild(&self) -> Result<()> {
self.shutdown().await
}
async fn shutdown(&self) -> Result<()>; async fn shutdown(&self) -> Result<()>;
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot; fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot;

View File

@@ -234,6 +234,30 @@ impl HidController {
Ok(()) Ok(())
} }
pub async fn prepare_otg_rebuild(&self) -> Result<()> {
if !matches!(*self.backend_type.read().await, HidBackendType::Otg) {
return Ok(());
}
info!("Preparing OTG HID backend for gadget rebuild");
self.backend_available.store(false, Ordering::Release);
self.stop_runtime_worker().await;
if let Some(backend) = self.backend.write().await.take() {
backend.prepare_rebuild().await?;
}
let current = self.runtime_state.read().await.clone();
let rebuilding_state = HidRuntimeState::with_error(
&HidBackendType::Otg,
&current,
"OTG gadget is rebuilding",
"rebuilding",
);
self.apply_runtime_state(rebuilding_state).await;
Ok(())
}
pub async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> { pub async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
if !self.backend_available.load(Ordering::Acquire) { if !self.backend_available.load(Ordering::Acquire) {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(

View File

@@ -903,6 +903,19 @@ impl HidBackend for OtgBackend {
Ok(()) Ok(())
} }
async fn prepare_rebuild(&self) -> Result<()> {
self.stop_runtime_worker();
*self.keyboard_dev.lock() = None;
*self.mouse_rel_dev.lock() = None;
*self.mouse_abs_dev.lock() = None;
*self.consumer_dev.lock() = None;
self.initialized.store(false, Ordering::Relaxed);
self.online.store(false, Ordering::Relaxed);
self.notify_runtime_changed();
info!("OTG backend prepared for gadget rebuild");
Ok(())
}
async fn shutdown(&self) -> Result<()> { async fn shutdown(&self) -> Result<()> {
self.stop_runtime_worker(); self.stop_runtime_worker();
@@ -957,6 +970,7 @@ impl Drop for OtgBackend {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::io::{Seek, SeekFrom, Write};
#[test] #[test]
fn test_led_state() { fn test_led_state() {
@@ -973,4 +987,22 @@ mod tests {
let kb_report = KeyboardReport::default(); let kb_report = KeyboardReport::default();
assert_eq!(kb_report.to_bytes().len(), 8); assert_eq!(kb_report.to_bytes().len(), 8);
} }
#[tokio::test]
async fn prepare_rebuild_closes_devices_without_writing_reset_reports() {
let mut file = tempfile::tempfile().unwrap();
file.write_all(b"sentinel").unwrap();
file.seek(SeekFrom::Start(0)).unwrap();
let backend = OtgBackend::from_handles(HidDevicePaths::default()).unwrap();
*backend.keyboard_dev.lock() = Some(file);
backend.initialized.store(true, Ordering::Relaxed);
backend.online.store(true, Ordering::Relaxed);
backend.prepare_rebuild().await.unwrap();
assert!(backend.keyboard_dev.lock().is_none());
assert!(!backend.initialized.load(Ordering::Relaxed));
assert!(!backend.online.load(Ordering::Relaxed));
}
} }

View File

@@ -1,68 +1,67 @@
//! Core library for One-KVM (IPKVM: capture, HID, OTG, streaming, Web UI glue). //! Core library for One-KVM (IPKVM: capture, HID, OTG, streaming, Web UI glue).
#[cfg(not(any(feature = "android", unix, windows)))] #[cfg(not(any(target_os = "linux", windows)))]
compile_error!("One-KVM supports Linux and Windows targets only."); compile_error!("One-KVM supports Linux and Windows targets only.");
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod runtime;
#[cfg(any(feature = "android", feature = "desktop"))]
pub mod atx; pub mod atx;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod audio; pub mod audio;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod auth; pub mod auth;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod computer_use; pub mod computer_use;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod config; pub mod config;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod db; pub mod db;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod diagnostics; pub mod diagnostics;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod error; pub mod error;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod events; pub mod events;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod extensions; pub mod extensions;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod hid; pub mod hid;
#[cfg(all(unix, any(feature = "android", feature = "desktop")))] #[cfg(all(unix, feature = "desktop"))]
pub mod msd; pub mod msd;
#[cfg(all(unix, any(feature = "android", feature = "desktop")))] #[cfg(all(unix, feature = "desktop"))]
pub mod otg; pub mod otg;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod platform; pub mod platform;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod redfish; pub mod redfish;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod rtsp; pub mod rtsp;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod rustdesk; pub mod rustdesk;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod state; pub mod state;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod stream; pub mod stream;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod stream_encoder; pub mod stream_encoder;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod update; pub mod update;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod utils; pub mod utils;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod video; pub mod video;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod vnc; pub mod vnc;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod watchdog;
#[cfg(feature = "desktop")]
pub mod web; pub mod web;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod webrtc; pub mod webrtc;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod secrets { pub mod secrets {
include!(concat!(env!("OUT_DIR"), "/secrets_generated.rs")); include!(concat!(env!("OUT_DIR"), "/secrets_generated.rs"));
} }
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub use error::{AppError, Result}; pub use error::{AppError, Result};

View File

@@ -14,7 +14,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use one_kvm::atx::AtxController; use one_kvm::atx::AtxController;
use one_kvm::audio::{AudioController, AudioControllerConfig, AudioQuality}; use one_kvm::audio::{AudioController, AudioControllerConfig, AudioQuality};
use one_kvm::auth::{SessionStore, UserStore}; use one_kvm::auth::{SessionStore, TwoFactorService, UserStore};
use one_kvm::computer_use::ComputerUseManager; use one_kvm::computer_use::ComputerUseManager;
use one_kvm::config::{self, AppConfig, ConfigStore}; use one_kvm::config::{self, AppConfig, ConfigStore};
use one_kvm::db::DatabasePool; use one_kvm::db::DatabasePool;
@@ -65,7 +65,12 @@ struct CliArgs {
address: Option<String>, address: Option<String>,
/// HTTP port (overrides database config) /// HTTP port (overrides database config)
#[arg(short = 'p', long, value_name = "PORT")] #[arg(
short = 'p',
long = "port",
visible_alias = "http-port",
value_name = "PORT"
)]
http_port: Option<u16>, http_port: Option<u16>,
/// HTTPS port (overrides database config) /// HTTPS port (overrides database config)
@@ -84,7 +89,7 @@ struct CliArgs {
#[arg(long, value_name = "FILE", requires = "ssl_cert")] #[arg(long, value_name = "FILE", requires = "ssl_cert")]
ssl_key: Option<PathBuf>, ssl_key: Option<PathBuf>,
/// Data directory path (default: /etc/one-kvm, or the executable directory on Windows) /// Data directory path
#[arg(short = 'd', long, value_name = "DIR")] #[arg(short = 'd', long, value_name = "DIR")]
data_dir: Option<PathBuf>, data_dir: Option<PathBuf>,
@@ -113,6 +118,8 @@ struct UserCommand {
enum UserAction { enum UserAction {
/// Set password for the single local user (interactive terminal prompt) /// Set password for the single local user (interactive terminal prompt)
SetPassword, SetPassword,
/// Disable TOTP for the single local user
DisableTotp,
} }
#[tokio::main] #[tokio::main]
@@ -183,6 +190,7 @@ async fn main() -> anyhow::Result<()> {
let session_store = SessionStore::new(config.auth.session_timeout_secs as i64); let session_store = SessionStore::new(config.auth.session_timeout_secs as i64);
let user_store = UserStore::new(db.clone_pool()); let user_store = UserStore::new(db.clone_pool());
let two_factor = TwoFactorService::new(db.clone_pool());
let (shutdown_tx, _) = broadcast::channel::<ShutdownAction>(1); let (shutdown_tx, _) = broadcast::channel::<ShutdownAction>(1);
@@ -299,7 +307,10 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("OTG Service created"); tracing::info!("OTG Service created");
#[cfg(unix)] #[cfg(unix)]
if let Err(e) = otg_service.apply_config(&config.hid, &config.msd).await { if let Err(e) = otg_service
.apply_config(&config.hid, &config.msd, &config.otg_network)
.await
{
tracing::warn!("Failed to apply OTG config: {}", e); tracing::warn!("Failed to apply OTG config: {}", e);
} }
@@ -324,24 +335,8 @@ async fn main() -> anyhow::Result<()> {
#[cfg(unix)] #[cfg(unix)]
let msd = if config.msd.enabled { let msd = if config.msd.enabled {
let ventoy_resource_dir = data_dir.join("ventoy"); let ventoy_resource_dir = data_dir.join("ventoy");
if ventoy_resource_dir.exists() {
if let Err(e) = ventoy_img::init_resources(&ventoy_resource_dir) {
tracing::warn!("Failed to initialize Ventoy resources: {}", e);
} else {
tracing::info!(
"Ventoy resources initialized from {}",
ventoy_resource_dir.display()
);
}
} else {
tracing::warn!(
"Ventoy resource directory not found: {}",
ventoy_resource_dir.display()
);
}
let controller = MsdController::new(otg_service.clone(), config.msd.msd_dir_path()); let controller = MsdController::new(otg_service.clone(), config.msd.msd_dir_path());
if let Err(e) = controller.init().await { if let Err(e) = controller.init(&ventoy_resource_dir).await {
tracing::warn!("Failed to initialize MSD controller: {}", e); tracing::warn!("Failed to initialize MSD controller: {}", e);
None None
} else { } else {
@@ -563,6 +558,7 @@ async fn main() -> anyhow::Result<()> {
config_store.clone(), config_store.clone(),
session_store, session_store,
user_store, user_store,
two_factor,
#[cfg(unix)] #[cfg(unix)]
otg_service, otg_service,
stream_manager, stream_manager,
@@ -583,6 +579,17 @@ async fn main() -> anyhow::Result<()> {
data_dir.clone(), data_dir.clone(),
); );
if config.watchdog.enabled {
if let Err(error) = state.watchdog.enable().await {
tracing::error!(
"Configured hardware watchdog failed to start; web service will continue: {}",
error
);
} else {
tracing::info!("Hardware watchdog started");
}
}
extensions.set_event_bus(events.clone()).await; extensions.set_event_bus(events.clone()).await;
if let Some(ref service) = rustdesk { if let Some(ref service) = rustdesk {
@@ -624,8 +631,12 @@ async fn main() -> anyhow::Result<()> {
} }
{ {
let runtime_config = state.config.get(); let runtime_config = state.runtime_third_party_config().await;
let constraints = StreamCodecConstraints::from_config(&runtime_config); let constraints = StreamCodecConstraints::from_config(&runtime_config);
state
.stream_manager
.set_runtime_codec_constraints(constraints.clone())
.await;
match enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await { match enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await {
Ok(result) if result.changed => { Ok(result) if result.changed => {
if let Some(message) = result.message { if let Some(message) = result.message {
@@ -668,9 +679,11 @@ async fn main() -> anyhow::Result<()> {
let mut shutdown_rx = shutdown_tx.subscribe(); let mut shutdown_rx = shutdown_tx.subscribe();
async move { async move {
tokio::select! { tokio::select! {
result = tokio::signal::ctrl_c() => { result = shutdown_signal() => {
result.expect("Failed to install CTRL+C handler"); if let Err(e) = result {
tracing::info!("Shutdown signal received"); tracing::error!("Failed while waiting for shutdown signal: {}", e);
}
tracing::info!("SIGINT or SIGTERM received");
ShutdownAction::Exit ShutdownAction::Exit
} }
request = shutdown_rx.recv() => { request = shutdown_rx.recv() => {
@@ -794,6 +807,24 @@ fn get_data_dir() -> PathBuf {
PathBuf::from("/etc/one-kvm") PathBuf::from("/etc/one-kvm")
} }
#[cfg(unix)]
async fn shutdown_signal() -> anyhow::Result<()> {
use tokio::signal::unix::{signal, SignalKind};
let mut terminate = signal(SignalKind::terminate())?;
tokio::select! {
result = tokio::signal::ctrl_c() => result?,
_ = terminate.recv() => {},
}
Ok(())
}
#[cfg(not(unix))]
async fn shutdown_signal() -> anyhow::Result<()> {
tokio::signal::ctrl_c().await?;
Ok(())
}
async fn open_database_pool(data_dir: &Path) -> anyhow::Result<DatabasePool> { async fn open_database_pool(data_dir: &Path) -> anyhow::Result<DatabasePool> {
let db_path = data_dir.join("one-kvm.db"); let db_path = data_dir.join("one-kvm.db");
let db = DatabasePool::new(&db_path).await?; let db = DatabasePool::new(&db_path).await?;
@@ -850,10 +881,13 @@ async fn run_cli_command(command: CliCommand, data_dir: PathBuf) -> anyhow::Resu
tokio::fs::create_dir_all(&data_dir).await?; tokio::fs::create_dir_all(&data_dir).await?;
let db = open_database_pool(&data_dir).await?; let db = open_database_pool(&data_dir).await?;
let users = UserStore::new(db.clone_pool()); let users = UserStore::new(db.clone_pool());
let two_factor = TwoFactorService::new(db.clone_pool());
let sessions = SessionStore::new(0); let sessions = SessionStore::new(0);
match command { match command {
CliCommand::User(user) => run_user_action(user.action, &users, &sessions).await, CliCommand::User(user) => {
run_user_action(user.action, &users, &sessions, &two_factor).await
}
} }
} }
@@ -919,12 +953,26 @@ async fn run_user_action(
action: UserAction, action: UserAction,
users: &UserStore, users: &UserStore,
sessions: &SessionStore, sessions: &SessionStore,
two_factor: &TwoFactorService,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
match action { match action {
UserAction::SetPassword => set_user_password(users, sessions).await, UserAction::SetPassword => set_user_password(users, sessions).await,
UserAction::DisableTotp => disable_user_totp(users, two_factor).await,
} }
} }
async fn disable_user_totp(users: &UserStore, two_factor: &TwoFactorService) -> anyhow::Result<()> {
let user = users.single_user().await?.ok_or_else(|| {
anyhow::anyhow!("No local user exists yet; complete setup in the web UI first.")
})?;
if two_factor.disable_without_code(&user.id).await? {
println!("TOTP disabled for user '{}'.", user.username);
} else {
println!("TOTP is already disabled for user '{}'.", user.username);
}
Ok(())
}
async fn set_user_password(users: &UserStore, sessions: &SessionStore) -> anyhow::Result<()> { async fn set_user_password(users: &UserStore, sessions: &SessionStore) -> anyhow::Result<()> {
let user = users.single_user().await?.ok_or_else(|| { let user = users.single_user().await?.ok_or_else(|| {
anyhow::anyhow!("No local user exists yet; complete setup in the web UI first.") anyhow::anyhow!("No local user exists yet; complete setup in the web UI first.")
@@ -1207,6 +1255,11 @@ async fn cleanup(state: &Arc<AppState>) {
} }
} }
#[cfg(unix)]
if let Err(e) = state.otg_service.shutdown().await {
tracing::warn!("Failed to shutdown OTG: {}", e);
}
if let Some(atx) = state.atx.write().await.as_mut() { if let Some(atx) = state.atx.write().await.as_mut() {
if let Err(e) = atx.shutdown().await { if let Err(e) = atx.shutdown().await {
tracing::warn!("Failed to shutdown ATX: {}", e); tracing::warn!("Failed to shutdown ATX: {}", e);
@@ -1216,4 +1269,11 @@ async fn cleanup(state: &Arc<AppState>) {
if let Err(e) = state.audio.shutdown().await { if let Err(e) = state.audio.shutdown().await {
tracing::warn!("Failed to shutdown audio: {}", e); tracing::warn!("Failed to shutdown audio: {}", e);
} }
if let Err(error) = state.watchdog.disable().await {
tracing::error!(
"CRITICAL: failed to disable hardware watchdog during shutdown: {}",
error
);
}
} }

View File

@@ -1,5 +1,5 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@@ -7,7 +7,10 @@ use tracing::{debug, info, warn};
use super::image::ImageManager; use super::image::ImageManager;
use super::monitor::MsdHealthMonitor; use super::monitor::MsdHealthMonitor;
use super::types::{DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MsdMode, MsdState}; use super::types::{
DiskMode, DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MountedMedia,
MountedMediaKind, MsdState,
};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::otg::{MsdFunction, MsdLunConfig, OtgService}; use crate::otg::{MsdFunction, MsdLunConfig, OtgService};
@@ -44,9 +47,21 @@ impl MsdController {
} }
} }
pub async fn init(&self) -> Result<()> { pub async fn init(&self, ventoy_resource_dir: &Path) -> Result<()> {
info!("Initializing MSD controller"); info!("Initializing MSD controller");
match ventoy_img::init_resources(ventoy_resource_dir) {
Ok(()) => info!(
"Ventoy resources ready from {}",
ventoy_resource_dir.display()
),
Err(e) => warn!(
"Failed to initialize Ventoy resources from {}: {}. Ventoy drive creation will be unavailable, but regular ISO/IMG MSD remains available",
ventoy_resource_dir.display(),
e
),
}
if let Err(e) = std::fs::create_dir_all(&self.images_path) { if let Err(e) = std::fs::create_dir_all(&self.images_path) {
warn!("Failed to create images directory: {}", e); warn!("Failed to create images directory: {}", e);
} }
@@ -62,17 +77,23 @@ impl MsdController {
*self.msd_function.write().await = Some(msd_func); *self.msd_function.write().await = Some(msd_func);
let mut state = self.state.write().await; let mut state = self.state.write().await;
state.disk_mode = if self.otg_service.msd_lun_capacity().await == 1 {
DiskMode::Single
} else {
DiskMode::Multi
};
state.available = true; state.available = true;
if self.drive_path.exists() { if self.drive_path.exists() {
if let Ok(metadata) = std::fs::metadata(&self.drive_path) { if let Ok(metadata) = std::fs::metadata(&self.drive_path) {
state.drive_info = Some(DriveInfo { let drive_info = DriveInfo {
size: metadata.len(), size: metadata.len(),
used: 0, used: 0,
free: metadata.len(), free: metadata.len(),
initialized: true, initialized: true,
path: self.drive_path.clone(), path: self.drive_path.clone(),
}); };
state.drive_info = Some(drive_info.clone());
debug!( debug!(
"Found existing virtual drive: {}", "Found existing virtual drive: {}",
self.drive_path.display() self.drive_path.display()
@@ -104,20 +125,34 @@ impl MsdController {
} }
} }
pub async fn is_available(&self) -> bool { pub async fn mount_image(&self, image: &ImageInfo, cdrom: bool, read_only: bool) -> Result<()> {
self.state.read().await.available self.mount_image_in_slot(image, cdrom, read_only, None)
.await
} }
pub async fn connect_image( pub async fn mount_image_at_lun(
&self, &self,
image: &ImageInfo, image: &ImageInfo,
cdrom: bool, cdrom: bool,
read_only: bool, read_only: bool,
lun: u8,
) -> Result<()> {
self.mount_image_in_slot(image, cdrom, read_only, Some(lun))
.await
}
async fn mount_image_in_slot(
&self,
image: &ImageInfo,
cdrom: bool,
read_only: bool,
requested_lun: Option<u8>,
) -> Result<()> { ) -> Result<()> {
let _op_guard = self.operation_lock.write().await; let _op_guard = self.operation_lock.write().await;
let mut state = self.state.write().await; let mut state = self.state.write().await;
let previous_state = state.clone();
self.assert_can_connect(&state).await?; self.assert_available(&state).await?;
if !image.path.exists() { if !image.path.exists() {
let error_msg = format!("Image file not found: {}", image.path.display()); let error_msg = format!("Image file not found: {}", image.path.display());
@@ -127,20 +162,29 @@ impl MsdController {
return Err(AppError::Internal(error_msg)); return Err(AppError::Internal(error_msg));
} }
let config = if cdrom { if state
MsdLunConfig::cdrom(image.path.clone()) .mounted_media
} else { .iter()
MsdLunConfig::disk(image.path.clone(), read_only) .any(|media| media.kind == MountedMediaKind::Image && media.id == image.id)
}; {
self.configure_lun_now(&config).await?; return Err(AppError::BadRequest("Image is already mounted".to_string()));
}
state.connected = true; let lun = Self::select_lun(&state, requested_lun)?;
state.mode = MsdMode::Image;
state.current_image = Some(image.clone()); let media = MountedMedia::image(lun, image, cdrom, read_only);
if let Err(e) = self.configure_media(&media).await {
*state = previous_state;
return Err(e);
}
state.mounted_media.push(media);
info!( info!(
"Connected image: {} (cdrom={}, ro={})", "Mounted image: {} on LUN {} (cdrom={}, ro={})",
image.name, cdrom, read_only image.name,
lun,
cdrom,
cdrom || read_only
); );
drop(state); drop(state);
@@ -150,11 +194,12 @@ impl MsdController {
Ok(()) Ok(())
} }
pub async fn connect_drive(&self) -> Result<()> { pub async fn mount_drive(&self) -> Result<()> {
let _op_guard = self.operation_lock.write().await; let _op_guard = self.operation_lock.write().await;
let mut state = self.state.write().await; let mut state = self.state.write().await;
let previous_state = state.clone();
self.assert_can_connect(&state).await?; self.assert_available(&state).await?;
if !self.drive_path.exists() { if !self.drive_path.exists() {
let err = let err =
@@ -165,14 +210,48 @@ impl MsdController {
return Err(err); return Err(err);
} }
let config = MsdLunConfig::disk(self.drive_path.clone(), false); let drive_info = state.drive_info.clone().or_else(|| {
self.configure_lun_now(&config).await?; std::fs::metadata(&self.drive_path)
.ok()
.map(|metadata| DriveInfo {
size: metadata.len(),
used: 0,
free: metadata.len(),
initialized: true,
path: self.drive_path.clone(),
})
});
if state.drive_info.is_none() {
state.drive_info = drive_info.clone();
}
state.connected = true; if state
state.mode = MsdMode::Drive; .mounted_media
state.current_image = None; .iter()
.any(|media| media.kind == MountedMediaKind::Drive)
{
return Err(AppError::BadRequest(
"Virtual drive is already mounted".to_string(),
));
}
info!("Connected virtual drive: {}", self.drive_path.display()); let drive_info = drive_info
.ok_or_else(|| AppError::Internal("Virtual drive info is unavailable".to_string()))?;
let lun = Self::lowest_free_lun(&state)
.ok_or_else(|| AppError::BadRequest("Media slots are full".to_string()))?;
let media = MountedMedia::drive(lun, &drive_info);
if let Err(e) = self.configure_media(&media).await {
*state = previous_state;
return Err(e);
}
state.mounted_media.push(media);
info!(
"Mounted virtual drive on LUN {}: {}",
lun,
self.drive_path.display()
);
drop(state); drop(state);
drop(_op_guard); drop(_op_guard);
@@ -181,22 +260,165 @@ impl MsdController {
Ok(()) Ok(())
} }
async fn assert_can_connect(&self, state: &MsdState) -> Result<()> { async fn assert_available(&self, state: &MsdState) -> Result<()> {
if !state.available { if !state.available {
self.monitor self.monitor
.report_error("MSD not available", "not_available") .report_error("MSD not available", "not_available")
.await; .await;
return Err(AppError::Internal("MSD not available".to_string())); return Err(AppError::Internal("MSD not available".to_string()));
} }
if state.connected {
return Err(AppError::Internal(
"Already connected. Disconnect first.".to_string(),
));
}
Ok(()) Ok(())
} }
async fn configure_lun_now(&self, config: &MsdLunConfig) -> Result<()> { fn media_config(media: &MountedMedia) -> MsdLunConfig {
if media.cdrom {
MsdLunConfig::cdrom(media.path.clone())
} else {
MsdLunConfig::disk(media.path.clone(), media.read_only)
}
}
fn lowest_free_lun(state: &MsdState) -> Option<u8> {
(0..state.disk_mode.capacity())
.find(|lun| !state.mounted_media.iter().any(|media| media.lun == *lun))
}
fn select_lun(state: &MsdState, requested_lun: Option<u8>) -> Result<u8> {
let Some(lun) = requested_lun else {
return Self::lowest_free_lun(state)
.ok_or_else(|| AppError::BadRequest("Media slots are full".to_string()));
};
if lun >= state.disk_mode.capacity() {
return Err(AppError::BadRequest(format!(
"Media slot {} is outside the current disk mode capacity",
lun + 1
)));
}
if state.mounted_media.iter().any(|media| media.lun == lun) {
return Err(AppError::BadRequest(format!(
"Media slot {} is already occupied",
lun + 1
)));
}
Ok(lun)
}
fn reset_mounts_for_mode(state: &mut MsdState, disk_mode: DiskMode) {
state.disk_mode = disk_mode;
state.mounted_media.clear();
}
pub async fn set_disk_mode(&self, disk_mode: DiskMode) -> Result<bool> {
let _op_guard = self.operation_lock.write().await;
let previous_state = {
let mut state = self.state.write().await;
self.assert_available(&state).await?;
if state.disk_mode == disk_mode {
return Ok(false);
}
let previous_state = state.clone();
state.usb_reenumerating = true;
previous_state
};
self.mark_device_info_dirty().await;
let switch_result = async {
self.otg_service
.set_msd_lun_capacity(disk_mode.capacity())
.await?;
self.otg_service.msd_function().await.ok_or_else(|| {
AppError::Internal("MSD function missing after OTG rebuild".to_string())
})
}
.await;
let msd_function = match switch_result {
Ok(msd_function) => msd_function,
Err(switch_error) => {
if let Err(rollback_error) = self.rollback_mode_switch(&previous_state).await {
let mut state = self.state.write().await;
state.available = false;
state.mounted_media.clear();
state.usb_reenumerating = false;
*self.msd_function.write().await = None;
let error_msg = format!(
"Failed to switch MSD disk mode: {switch_error}; rollback failed: {rollback_error}"
);
self.monitor
.report_error(&error_msg, "disk_mode_rollback_failed")
.await;
self.mark_device_info_dirty().await;
return Err(AppError::Internal(error_msg));
}
let mut state = self.state.write().await;
*state = previous_state;
state.usb_reenumerating = false;
let error_msg = format!("Failed to switch MSD disk mode: {switch_error}");
self.monitor
.report_error(&error_msg, "disk_mode_switch_failed")
.await;
self.mark_device_info_dirty().await;
return Err(AppError::Internal(error_msg));
}
};
*self.msd_function.write().await = Some(msd_function);
let mut state = self.state.write().await;
Self::reset_mounts_for_mode(&mut state, disk_mode);
state.usb_reenumerating = false;
info!("Switched MSD disk mode to {:?}", disk_mode);
drop(state);
drop(_op_guard);
self.mark_device_info_dirty().await;
Ok(true)
}
pub async fn unmount_image(&self, image_id: &str) -> Result<()> {
self.unmount_media(|media| media.kind == MountedMediaKind::Image && media.id == image_id)
.await
.map(|_| ())
}
pub async fn unmount_drive(&self) -> Result<()> {
self.unmount_media(|media| media.kind == MountedMediaKind::Drive)
.await
.map(|_| ())
}
pub async fn unmount_lun(&self, lun: u8) -> Result<bool> {
self.unmount_media(|media| media.lun == lun).await
}
async fn unmount_media<F>(&self, predicate: F) -> Result<bool>
where
F: Fn(&MountedMedia) -> bool,
{
let _op_guard = self.operation_lock.write().await;
let mut state = self.state.write().await;
let Some(index) = state.mounted_media.iter().position(predicate) else {
debug!("Requested media was not mounted, skipping unmount");
return Ok(false);
};
let media = state.mounted_media[index].clone();
self.disconnect_lun(media.lun).await?;
state.mounted_media.remove(index);
info!("Unmounted media");
drop(state);
drop(_op_guard);
self.mark_device_info_dirty().await;
Ok(true)
}
async fn configure_media(&self, media: &MountedMedia) -> Result<()> {
let gadget_path = self.active_gadget_path().await?; let gadget_path = self.active_gadget_path().await?;
let msd_hold = self.msd_function.read().await; let msd_hold = self.msd_function.read().await;
let Some(ref msd) = *msd_hold else { let Some(ref msd) = *msd_hold else {
@@ -207,8 +429,11 @@ impl MsdController {
"MSD function not initialized".to_string(), "MSD function not initialized".to_string(),
)); ));
}; };
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, config).await { if let Err(e) = msd
let error_msg = format!("Failed to configure LUN: {}", e); .configure_lun_async(&gadget_path, media.lun, &Self::media_config(media))
.await
{
let error_msg = format!("Failed to configure LUN {}: {}", media.lun, e);
self.monitor self.monitor
.report_error(&error_msg, "configfs_error") .report_error(&error_msg, "configfs_error")
.await; .await;
@@ -217,6 +442,29 @@ impl MsdController {
Ok(()) Ok(())
} }
async fn disconnect_lun(&self, lun: u8) -> Result<()> {
let gadget_path = self.active_gadget_path().await?;
let msd_hold = self.msd_function.read().await;
let msd = msd_hold
.as_ref()
.ok_or_else(|| AppError::Internal("MSD function not initialized".to_string()))?;
msd.disconnect_lun_async(&gadget_path, lun).await
}
async fn rollback_mode_switch(&self, previous_state: &MsdState) -> Result<()> {
self.otg_service
.set_msd_lun_capacity(previous_state.disk_mode.capacity())
.await?;
let msd_function = self.otg_service.msd_function().await.ok_or_else(|| {
AppError::Internal("MSD function missing after OTG rollback".to_string())
})?;
*self.msd_function.write().await = Some(msd_function);
for media in &previous_state.mounted_media {
self.configure_media(media).await?;
}
Ok(())
}
async fn finish_connect_success(&self) { async fn finish_connect_success(&self) {
if self.monitor.is_error().await { if self.monitor.is_error().await {
self.monitor.report_recovered().await; self.monitor.report_recovered().await;
@@ -228,22 +476,31 @@ impl MsdController {
let _op_guard = self.operation_lock.write().await; let _op_guard = self.operation_lock.write().await;
let mut state = self.state.write().await; let mut state = self.state.write().await;
if state.mounted_media.is_empty() {
if !state.connected { debug!("Nothing mounted, skipping disconnect");
debug!("Nothing connected, skipping disconnect");
return Ok(()); return Ok(());
} }
let gadget_path = self.active_gadget_path().await?; let mounted_media = state.mounted_media.clone();
if let Some(ref msd) = *self.msd_function.read().await { let mut disconnected = Vec::new();
msd.disconnect_lun_async(&gadget_path, 0).await?; for media in &mounted_media {
if let Err(error) = self.disconnect_lun(media.lun).await {
for prior in &disconnected {
if let Err(restore_error) = self.configure_media(prior).await {
state.available = false;
return Err(AppError::Internal(format!(
"Failed to disconnect LUN {}: {error}; restore failed: {restore_error}",
media.lun
)));
}
}
return Err(error);
}
disconnected.push(media.clone());
} }
state.connected = false; state.mounted_media.clear();
state.mode = MsdMode::None; info!("Disconnected all mounted media");
state.current_image = None;
info!("Disconnected storage");
drop(state); drop(state);
drop(_op_guard); drop(_op_guard);
@@ -253,29 +510,29 @@ impl MsdController {
Ok(()) Ok(())
} }
pub fn images_path(&self) -> &PathBuf { pub async fn is_drive_connected(&self) -> bool {
&self.images_path self.state
.read()
.await
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Drive)
} }
pub fn ventoy_dir(&self) -> &PathBuf { pub async fn delete_image(&self, image_id: &str) -> Result<()> {
&self.ventoy_dir let _op_guard = self.operation_lock.write().await;
let state = self.state.read().await;
if state
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Image && media.id == image_id)
{
return Err(AppError::BadRequest(
"Cannot delete image while it is mounted".to_string(),
));
} }
pub fn drive_path(&self) -> &PathBuf { ImageManager::new(self.images_path.clone()).delete(image_id)
&self.drive_path
}
pub async fn is_connected(&self) -> bool {
self.state.read().await.connected
}
pub async fn mode(&self) -> MsdMode {
self.state.read().await.mode.clone()
}
pub async fn update_drive_info(&self, info: DriveInfo) {
let mut state = self.state.write().await;
state.drive_info = Some(info);
} }
pub async fn download_image( pub async fn download_image(
@@ -423,6 +680,8 @@ impl MsdController {
let mut state = self.state.write().await; let mut state = self.state.write().await;
state.available = false; state.available = false;
state.mounted_media.clear();
state.usb_reenumerating = false;
info!("MSD controller shutdown complete"); info!("MSD controller shutdown complete");
Ok(()) Ok(())
@@ -436,6 +695,7 @@ impl MsdController {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::msd::MULTI_DISK_MSD_LUNS;
use tempfile::TempDir; use tempfile::TempDir;
#[tokio::test] #[tokio::test]
@@ -462,7 +722,228 @@ mod tests {
let state = controller.state().await; let state = controller.state().await;
assert!(!state.available); assert!(!state.available);
assert!(!state.connected); assert_eq!(state.disk_mode, DiskMode::Single);
assert_eq!(state.mode, MsdMode::None); assert!(state.mounted_media.is_empty());
}
#[test]
fn single_disk_mode_only_exposes_lun_zero() {
let mut state = MsdState::default();
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Single);
assert_eq!(state.disk_mode.capacity(), 1);
assert_eq!(MsdController::lowest_free_lun(&state), Some(0));
let temp_dir = TempDir::new().unwrap();
let image_path = temp_dir.path().join("test.iso");
std::fs::write(&image_path, b"iso").unwrap();
let image = ImageInfo::new("test".into(), "test.iso".into(), image_path, 3);
state
.mounted_media
.push(MountedMedia::image(0, &image, true, false));
assert_eq!(MsdController::lowest_free_lun(&state), None);
let config = MsdController::media_config(&state.mounted_media[0]);
assert!(config.cdrom);
assert!(config.ro);
}
#[test]
fn multi_disk_mode_allocates_lowest_free_lun() {
let temp_dir = TempDir::new().unwrap();
let mut state = MsdState::default();
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
for lun in [0, 1, 3] {
let image_path = temp_dir.path().join(format!("test{lun}.img"));
std::fs::write(&image_path, b"img").unwrap();
let image = ImageInfo::new(
format!("test{lun}"),
format!("test{lun}.img"),
image_path,
3,
);
state
.mounted_media
.push(MountedMedia::image(lun, &image, false, false));
}
assert_eq!(MsdController::lowest_free_lun(&state), Some(2));
}
#[test]
fn explicit_lun_selection_rejects_occupied_and_out_of_range_slots() {
let temp_dir = TempDir::new().unwrap();
let image_path = temp_dir.path().join("test.img");
std::fs::write(&image_path, b"img").unwrap();
let image = ImageInfo::new("test".into(), "test.img".into(), image_path, 3);
let mut state = MsdState::default();
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
state
.mounted_media
.push(MountedMedia::image(3, &image, false, true));
assert_eq!(MsdController::select_lun(&state, Some(5)).unwrap(), 5);
assert!(MsdController::select_lun(&state, Some(3))
.unwrap_err()
.to_string()
.contains("already occupied"));
assert!(MsdController::select_lun(&state, Some(8))
.unwrap_err()
.to_string()
.contains("outside"));
}
#[test]
fn multi_disk_mode_supports_eight_images_and_rejects_ninth_slot() {
let temp_dir = TempDir::new().unwrap();
let mut state = MsdState::default();
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
for lun in 0..MULTI_DISK_MSD_LUNS {
let image_path = temp_dir.path().join(format!("test{lun}.img"));
std::fs::write(&image_path, b"img").unwrap();
let image = ImageInfo::new(
format!("test{lun}"),
format!("test{lun}.img"),
image_path,
3,
);
let next_lun = MsdController::lowest_free_lun(&state).unwrap();
assert_eq!(next_lun, lun);
state
.mounted_media
.push(MountedMedia::image(next_lun, &image, false, false));
}
assert_eq!(state.mounted_media.len(), 8);
assert_eq!(MsdController::lowest_free_lun(&state), None);
}
#[test]
fn multi_disk_mode_supports_drive_plus_seven_images() {
let temp_dir = TempDir::new().unwrap();
let drive_path = temp_dir.path().join("ventoy.img");
std::fs::write(&drive_path, b"drive").unwrap();
let drive = DriveInfo {
size: 5,
used: 0,
free: 5,
initialized: true,
path: drive_path,
};
let mut state = MsdState::default();
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
state.mounted_media.push(MountedMedia::drive(0, &drive));
for lun in 1..MULTI_DISK_MSD_LUNS {
let image_path = temp_dir.path().join(format!("test{lun}.img"));
std::fs::write(&image_path, b"img").unwrap();
let image = ImageInfo::new(
format!("test{lun}"),
format!("test{lun}.img"),
image_path,
3,
);
state
.mounted_media
.push(MountedMedia::image(lun, &image, false, false));
}
assert_eq!(state.mounted_media.len(), 8);
assert_eq!(MsdController::lowest_free_lun(&state), None);
assert!(state
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Drive));
}
#[test]
fn mode_switch_clears_mount_state() {
let temp_dir = TempDir::new().unwrap();
let image_path = temp_dir.path().join("test.img");
std::fs::write(&image_path, b"img").unwrap();
let image = ImageInfo::new("test".into(), "test.img".into(), image_path, 3);
let mut state = MsdState::default();
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
state
.mounted_media
.push(MountedMedia::image(0, &image, false, false));
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Single);
assert_eq!(state.disk_mode, DiskMode::Single);
assert_eq!(state.disk_mode.capacity(), 1);
assert!(state.mounted_media.is_empty());
}
#[test]
fn duplicate_image_and_drive_detection_use_media_identity() {
let temp_dir = TempDir::new().unwrap();
let image_path = temp_dir.path().join("test.img");
std::fs::write(&image_path, b"img").unwrap();
let image = ImageInfo::new("test".into(), "test.img".into(), image_path, 3);
let drive = DriveInfo {
size: 5,
used: 0,
free: 5,
initialized: true,
path: temp_dir.path().join("ventoy.img"),
};
let mut state = MsdState::default();
state
.mounted_media
.push(MountedMedia::image(0, &image, false, false));
state.mounted_media.push(MountedMedia::drive(1, &drive));
assert!(state
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Image && media.id == "test"));
assert!(state
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Drive));
}
#[tokio::test]
async fn delete_image_is_serialized_with_mount_operations() {
let temp_dir = TempDir::new().unwrap();
let otg_service = Arc::new(OtgService::new());
let controller = MsdController::new(otg_service, temp_dir.path());
std::fs::create_dir_all(&controller.images_path).unwrap();
let image_path = controller.images_path.join("test.img");
std::fs::write(&image_path, b"img").unwrap();
let image = ImageManager::new(controller.images_path.clone())
.get_by_name("test.img")
.unwrap();
controller
.state
.write()
.await
.mounted_media
.push(MountedMedia::image(0, &image, false, false));
assert!(controller.delete_image(&image.id).await.is_err());
assert!(image_path.exists());
controller.state.write().await.mounted_media.clear();
controller.delete_image(&image.id).await.unwrap();
assert!(!image_path.exists());
}
#[test]
fn slot_configs_force_cdrom_read_only() {
let temp_dir = TempDir::new().unwrap();
let image_path = temp_dir.path().join("test.iso");
std::fs::write(&image_path, b"iso").unwrap();
let image = ImageInfo::new("test".into(), "test.iso".into(), image_path, 3);
let mut state = MsdState::default();
state
.mounted_media
.push(MountedMedia::image(0, &image, true, false));
let config = MsdController::media_config(&state.mounted_media[0]);
assert!(config.cdrom);
assert!(config.ro);
} }
} }

View File

@@ -393,10 +393,6 @@ impl ImageManager {
self.get_by_name(&final_filename) self.get_by_name(&final_filename)
} }
pub fn images_path(&self) -> &PathBuf {
&self.images_path
}
} }
fn stable_image_id_from_filename(name: &str) -> String { fn stable_image_id_from_filename(name: &str) -> String {

View File

@@ -8,8 +8,9 @@ pub use controller::MsdController;
pub use image::ImageManager; pub use image::ImageManager;
pub use monitor::MsdHealthMonitor; pub use monitor::MsdHealthMonitor;
pub use types::{ pub use types::{
DownloadProgress, DownloadStatus, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest, DiskMode, DiskModeRequest, DownloadProgress, DownloadStatus, DriveFile, DriveInfo,
ImageInfo, MsdConnectRequest, MsdMode, MsdState, DriveInitRequest, ImageDownloadRequest, ImageInfo, ImageMountRequest, MountedMedia,
MountedMediaKind, MsdState, MsdStateResponse, MULTI_DISK_MSD_LUNS, SINGLE_DISK_MSD_LUNS,
}; };
pub use ventoy_drive::{VentoyDrive, MIN_DRIVE_SIZE_MB}; pub use ventoy_drive::{VentoyDrive, MIN_DRIVE_SIZE_MB};

View File

@@ -2,13 +2,12 @@ use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
use time::OffsetDateTime; use time::OffsetDateTime;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum MsdMode { pub enum DiskMode {
#[default] #[default]
None, Single,
Image, Multi,
Drive,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -50,23 +49,109 @@ impl ImageInfo {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone)]
pub struct MsdState { pub struct MsdState {
pub available: bool, pub available: bool,
pub mode: MsdMode, pub disk_mode: DiskMode,
pub connected: bool, pub mounted_media: Vec<MountedMedia>,
pub current_image: Option<ImageInfo>,
pub drive_info: Option<DriveInfo>, pub drive_info: Option<DriveInfo>,
pub usb_reenumerating: bool,
} }
impl Default for MsdState { impl Default for MsdState {
fn default() -> Self { fn default() -> Self {
Self { Self {
available: false, available: false,
mode: MsdMode::None, disk_mode: DiskMode::Single,
connected: false, mounted_media: Vec::new(),
current_image: None,
drive_info: None, drive_info: None,
usb_reenumerating: false,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct MsdStateResponse {
pub available: bool,
pub disk_mode: DiskMode,
pub slot_capacity: u8,
pub mounted_count: u8,
pub mounted_media: Vec<MountedMedia>,
pub drive_info: Option<DriveInfo>,
pub usb_reenumerating: bool,
}
impl From<&MsdState> for MsdStateResponse {
fn from(state: &MsdState) -> Self {
Self {
available: state.available,
disk_mode: state.disk_mode,
slot_capacity: state.disk_mode.capacity(),
mounted_count: state.mounted_media.len() as u8,
mounted_media: state.mounted_media.clone(),
drive_info: state.drive_info.clone(),
usb_reenumerating: state.usb_reenumerating,
}
}
}
pub const SINGLE_DISK_MSD_LUNS: u8 = 1;
pub const MULTI_DISK_MSD_LUNS: u8 = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MountedMediaKind {
Drive,
Image,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MountedMedia {
pub id: String,
pub kind: MountedMediaKind,
pub name: String,
pub cdrom: bool,
pub read_only: bool,
pub size: u64,
#[serde(skip)]
pub lun: u8,
#[serde(skip)]
pub path: PathBuf,
}
impl MountedMedia {
pub fn image(lun: u8, image: &ImageInfo, cdrom: bool, read_only: bool) -> Self {
Self {
id: image.id.clone(),
lun,
kind: MountedMediaKind::Image,
name: image.name.clone(),
cdrom,
read_only: cdrom || read_only,
size: image.size,
path: image.path.clone(),
}
}
pub fn drive(lun: u8, info: &DriveInfo) -> Self {
Self {
id: "drive".to_string(),
lun,
kind: MountedMediaKind::Drive,
name: "Virtual USB".to_string(),
cdrom: false,
read_only: false,
size: info.size,
path: info.path.clone(),
}
}
}
impl DiskMode {
pub fn capacity(self) -> u8 {
match self {
DiskMode::Single => SINGLE_DISK_MSD_LUNS,
DiskMode::Multi => MULTI_DISK_MSD_LUNS,
} }
} }
} }
@@ -104,13 +189,16 @@ pub struct DriveFile {
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct MsdConnectRequest { pub struct DiskModeRequest {
pub mode: MsdMode, pub disk_mode: DiskMode,
pub image_id: Option<String>, }
#[derive(Debug, Clone, Deserialize)]
pub struct ImageMountRequest {
#[serde(default)] #[serde(default)]
pub cdrom: Option<bool>, pub cdrom: bool,
#[serde(default)] #[serde(default)]
pub read_only: Option<bool>, pub read_only: bool,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@@ -164,4 +252,19 @@ mod tests {
); );
assert!(info.size_display().contains("GB")); assert!(info.size_display().contains("GB"));
} }
#[test]
fn default_state_serializes_single_disk_mode() {
assert_eq!(DiskMode::default(), DiskMode::Single);
let state = MsdState::default();
assert_eq!(state.disk_mode, DiskMode::Single);
let json = serde_json::to_value(MsdStateResponse::from(&state)).unwrap();
assert_eq!(json["disk_mode"], "single");
assert_eq!(json["slot_capacity"], 1);
assert!(json.get("mode").is_none());
assert!(json.get("current_image").is_none());
assert!(json.get("slots").is_none());
}
} }

View File

@@ -56,7 +56,7 @@ impl VentoyDrive {
info!("Creating {} MB Ventoy drive at {}", size_mb, path.display()); info!("Creating {} MB Ventoy drive at {}", size_mb, path.display());
let info = tokio::task::spawn_blocking(move || { let info = tokio::task::spawn_blocking(move || {
VentoyImage::create(&path, &size_str, DEFAULT_LABEL).map_err(ventoy_to_app_error)?; VentoyImage::create(&path, &size_str, DEFAULT_LABEL).map_err(drive_init_error)?;
let metadata = std::fs::metadata(&path) let metadata = std::fs::metadata(&path)
.map_err(|e| AppError::Internal(format!("Failed to read drive metadata: {}", e)))?; .map_err(|e| AppError::Internal(format!("Failed to read drive metadata: {}", e)))?;
@@ -354,6 +354,30 @@ fn ventoy_to_app_error(err: VentoyError) -> AppError {
} }
} }
fn drive_init_error(err: VentoyError) -> AppError {
let VentoyError::Io(error) = err else {
return ventoy_to_app_error(err);
};
#[cfg(unix)]
match error.raw_os_error() {
Some(libc::EFBIG) => AppError::BadRequest(
"MSD directory filesystem does not support a virtual drive file of this size".into(),
),
Some(libc::ENOSPC) => AppError::BadRequest(
"MSD directory does not have enough free space for the virtual drive".into(),
),
Some(libc::EROFS) => AppError::BadRequest("MSD directory filesystem is read-only".into()),
Some(libc::EACCES | libc::EPERM) => AppError::BadRequest(
"One-KVM does not have permission to write to the MSD directory".into(),
),
_ => AppError::Io(error),
}
#[cfg(not(unix))]
AppError::Io(error)
}
fn ventoy_file_to_drive_file(info: VentoyFileInfo, parent_path: &str) -> DriveFile { fn ventoy_file_to_drive_file(info: VentoyFileInfo, parent_path: &str) -> DriveFile {
let full_path = if parent_path.is_empty() || parent_path == "/" { let full_path = if parent_path.is_empty() || parent_path == "/" {
format!("/{}", info.name) format!("/{}", info.name)
@@ -436,12 +460,26 @@ impl Drop for ChannelWriter {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::error::AppError;
use std::process::Command; use std::process::Command;
use std::sync::OnceLock; use std::sync::OnceLock;
use tempfile::TempDir; use tempfile::TempDir;
static RESOURCE_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../ventoy-img-rs/resources"); static RESOURCE_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../ventoy-img-rs/resources");
#[test]
fn classifies_drive_creation_io_errors() {
for (errno, expected) in [
(libc::EFBIG, "does not support"),
(libc::ENOSPC, "enough free space"),
(libc::EROFS, "read-only"),
(libc::EACCES, "permission"),
] {
let error = drive_init_error(VentoyError::Io(std::io::Error::from_raw_os_error(errno)));
assert!(matches!(error, AppError::BadRequest(message) if message.contains(expected)));
}
}
fn init_ventoy_resources() -> bool { fn init_ventoy_resources() -> bool {
static INIT: OnceLock<bool> = OnceLock::new(); static INIT: OnceLock<bool> = OnceLock::new();
*INIT.get_or_init(|| { *INIT.get_or_init(|| {

945
src/otg/bridge.rs Normal file
View File

@@ -0,0 +1,945 @@
use std::fs;
use std::path::Path;
use std::process::{Command, Output};
use std::thread;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use uuid::Uuid;
use crate::error::{AppError, Result};
const BRIDGE_IF: &str = "okvm-br0";
const PROFILE_PREFIX: &str = "one-kvm-otg";
const JOURNAL_PATH: &str = "/run/one-kvm/otg-network-bridge.json";
const JOURNAL_VERSION: u8 = 2;
const NETWORK_MANAGER_DEVICE_WAIT_TIMEOUT: Duration = Duration::from_secs(10);
const DHCP_IDENTITY_PROPERTIES: &[&str] = &[
"ipv4.dhcp-client-id",
"ipv4.dhcp-iaid",
"ipv4.dhcp-hostname",
"ipv4.dhcp-fqdn",
"ipv4.dhcp-send-hostname",
"ipv4.dhcp-hostname-flags",
];
const STATIC_IPV4_PROPERTIES: &[&str] = &[
"ipv4.dns",
"ipv4.dns-search",
"ipv4.dns-options",
"ipv4.dns-priority",
"ipv4.routes",
"ipv4.route-table",
"ipv4.routing-rules",
"ipv4.never-default",
"ipv4.may-fail",
"ipv4.ignore-auto-routes",
"ipv4.ignore-auto-dns",
];
#[typeshare]
#[derive(Debug, Clone, Serialize)]
pub struct NetworkInterfaceInfo {
pub name: String,
pub interface_type: String,
pub state: String,
pub connection: String,
pub addresses: Vec<String>,
pub has_default_route: bool,
pub bridge_supported: bool,
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct NetworkManagerDevice {
name: String,
interface_type: String,
state: String,
connection: String,
addresses: Vec<String>,
has_default_route: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BridgeJournal {
version: u8,
uplink: String,
existing_bridge: bool,
original_connection_uuid: Option<String>,
bridge_profile_uuid: Option<String>,
uplink_profile_uuid: Option<String>,
usb_profile_uuid: String,
}
#[derive(Debug)]
struct TransactionProfiles {
bridge_name: String,
bridge_uuid: String,
uplink_name: String,
uplink_uuid: String,
usb_name: String,
usb_uuid: String,
}
impl TransactionProfiles {
fn new() -> Self {
let transaction = Uuid::new_v4().simple().to_string();
let suffix = &transaction[..12];
Self {
bridge_name: format!("{PROFILE_PREFIX}-bridge-{suffix}"),
bridge_uuid: Uuid::new_v4().to_string(),
uplink_name: format!("{PROFILE_PREFIX}-uplink-{suffix}"),
uplink_uuid: Uuid::new_v4().to_string(),
usb_name: format!("{PROFILE_PREFIX}-usb-{suffix}"),
usb_uuid: Uuid::new_v4().to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct NetworkBridgeRuntime {
journal: BridgeJournal,
}
impl NetworkBridgeRuntime {
pub fn activate(requested: &str, usb_interface: &str) -> Result<Self> {
ensure_command("nmcli")?;
ensure_command("ip")?;
let interfaces = list_network_interfaces()?;
let selected = select_bridge_candidate(&interfaces, requested)?;
prepare_device_for_network_manager(usb_interface, "ethernet")?;
Self::activate_physical_uplink(&selected.name, &selected.connection, usb_interface)
}
fn activate_physical_uplink(
uplink: &str,
original_connection: &str,
usb_interface: &str,
) -> Result<Self> {
if original_connection.is_empty() || original_connection == "--" {
return Err(AppError::BadRequest(format!(
"Ethernet interface {uplink} has no active NetworkManager connection"
)));
}
reset_bridge_interface()?;
let original_connection_uuid = active_connection_uuid(uplink)?;
let ipv4_method = connection_value(&original_connection_uuid, "ipv4.method")?;
if !matches!(ipv4_method.as_str(), "auto" | "manual") {
return Err(AppError::BadRequest(format!(
"Connection {original_connection} uses unsupported ipv4.method={ipv4_method}"
)));
}
let ipv6_method = connection_value(&original_connection_uuid, "ipv6.method")?;
if !matches!(ipv6_method.as_str(), "auto" | "disabled" | "ignore") {
return Err(AppError::BadRequest(format!(
"Connection {original_connection} uses unsupported ipv6.method={ipv6_method}"
)));
}
let ipv4_metric = connection_value(&original_connection_uuid, "ipv4.route-metric")?;
let ipv6_metric = connection_value(&original_connection_uuid, "ipv6.route-metric")?;
let original_had_default_route = default_route(uplink).is_some();
let mac_path = Path::new("/sys/class/net").join(uplink).join("address");
let uplink_mac = fs::read_to_string(&mac_path)
.map_err(|e| {
AppError::Internal(format!("Failed to read {}: {}", mac_path.display(), e))
})?
.trim()
.to_string();
let profiles = TransactionProfiles::new();
let journal = BridgeJournal {
version: JOURNAL_VERSION,
uplink: uplink.to_string(),
existing_bridge: false,
original_connection_uuid: Some(original_connection_uuid.clone()),
bridge_profile_uuid: Some(profiles.bridge_uuid.clone()),
uplink_profile_uuid: Some(profiles.uplink_uuid.clone()),
usb_profile_uuid: profiles.usb_uuid.clone(),
};
write_journal(&journal)?;
let prepare_result: Result<()> = (|| {
create_bridge_interface(&uplink_mac)?;
run_nmcli(&[
"connection",
"add",
"type",
"bridge",
"ifname",
BRIDGE_IF,
"con-name",
&profiles.bridge_name,
"connection.uuid",
&profiles.bridge_uuid,
])?;
run_nmcli(&[
"connection",
"modify",
&profiles.bridge_uuid,
"connection.interface-name",
BRIDGE_IF,
"bridge.mac-address",
&uplink_mac,
"bridge.stp",
"no",
"ipv6.method",
&ipv6_method,
"connection.autoconnect",
"no",
])?;
configure_ipv4_profile(
&original_connection_uuid,
&profiles.bridge_uuid,
&ipv4_method,
)?;
for (property, value) in [
("ipv4.route-metric", ipv4_metric.as_str()),
("ipv6.route-metric", ipv6_metric.as_str()),
] {
if !value.is_empty() && value != "-1" {
run_nmcli(&[
"connection",
"modify",
&profiles.bridge_uuid,
property,
value,
])?;
}
}
run_nmcli(&[
"connection",
"add",
"type",
"ethernet",
"ifname",
uplink,
"con-name",
&profiles.uplink_name,
"connection.uuid",
&profiles.uplink_uuid,
"master",
BRIDGE_IF,
"slave-type",
"bridge",
"connection.autoconnect",
"no",
])?;
run_nmcli(&[
"connection",
"add",
"type",
"ethernet",
"ifname",
usb_interface,
"con-name",
&profiles.usb_name,
"connection.uuid",
&profiles.usb_uuid,
"master",
BRIDGE_IF,
"slave-type",
"bridge",
"connection.autoconnect",
"no",
])?;
Ok(())
})();
if let Err(error) = prepare_result {
return Err(restore_or_combine(&journal, error));
}
let result = (|| {
run_nmcli(&["connection", "down", "uuid", &original_connection_uuid])?;
activate_connection(
"bridge",
&profiles.bridge_name,
&profiles.bridge_uuid,
Some(BRIDGE_IF),
)?;
activate_connection(
"uplink",
&profiles.uplink_name,
&profiles.uplink_uuid,
Some(uplink),
)?;
activate_connection(
"USB",
&profiles.usb_name,
&profiles.usb_uuid,
Some(usb_interface),
)?;
let deadline = Instant::now() + Duration::from_secs(35);
while Instant::now() < deadline {
if first_ipv4_address(BRIDGE_IF).is_some()
&& (!original_had_default_route || default_route(BRIDGE_IF).is_some())
{
break;
}
thread::sleep(Duration::from_secs(1));
}
let address = first_ipv4_address(BRIDGE_IF).ok_or_else(|| {
AppError::Internal(
"OTG bridge did not obtain an IPv4 address from upstream DHCP".to_string(),
)
})?;
let route = default_route(BRIDGE_IF);
if original_had_default_route && route.is_none() {
return Err(AppError::Internal(
"OTG bridge did not obtain the original default route".to_string(),
));
}
if let Some(route) = route.as_deref() {
if let Some(gateway) = gateway_from_route(route) {
if let Err(error) = run_command("ping", &["-c", "1", "-W", "2", gateway]) {
tracing::warn!(
"OTG bridge gateway ICMP diagnostic failed for {}: {}",
gateway,
error
);
}
}
}
Ok(address)
})();
match result {
Ok(_address) => Ok(Self { journal }),
Err(error) => Err(restore_or_combine(&journal, error)),
}
}
pub fn deactivate(&self) -> Result<()> {
restore_from_journal(&self.journal)
}
pub fn recover_stale_transaction() -> Result<()> {
let value = match fs::read_to_string(JOURNAL_PATH) {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(AppError::Internal(format!(
"Failed to read OTG network recovery journal: {error}"
)))
}
};
match serde_json::from_str::<BridgeJournal>(&value) {
Ok(journal) if journal.version == JOURNAL_VERSION => restore_from_journal(&journal),
Ok(journal) => Err(AppError::Config(format!(
"Unsupported OTG network recovery journal version {}",
journal.version
))),
Err(error) => Err(AppError::Config(format!(
"Invalid OTG network recovery journal: {error}"
))),
}
}
}
pub fn list_network_interfaces() -> Result<Vec<NetworkInterfaceInfo>> {
let devices = enumerate_network_manager_devices()?;
Ok(bridge_candidates(devices, is_physical_network_interface))
}
fn enumerate_network_manager_devices() -> Result<Vec<NetworkManagerDevice>> {
ensure_command("nmcli")?;
let output = run_command(
"nmcli",
&[
"-t",
"--escape",
"no",
"-f",
"DEVICE,TYPE,STATE,CONNECTION",
"device",
"status",
],
)?;
let text = String::from_utf8_lossy(&output.stdout);
let mut devices = parse_network_manager_devices(&text);
for device in &mut devices {
device.addresses = ipv4_addresses(&device.name);
device.has_default_route = default_route(&device.name).is_some();
}
Ok(devices)
}
fn parse_network_manager_devices(text: &str) -> Vec<NetworkManagerDevice> {
let mut devices = Vec::new();
for line in text.lines() {
let fields = line.splitn(4, ':').collect::<Vec<_>>();
if fields.len() != 4 || fields[0].is_empty() {
continue;
}
devices.push(NetworkManagerDevice {
name: fields[0].to_string(),
interface_type: fields[1].to_string(),
state: fields[2].to_string(),
connection: fields[3].to_string(),
addresses: Vec::new(),
has_default_route: false,
});
}
devices
}
fn bridge_candidates(
devices: Vec<NetworkManagerDevice>,
is_physical: impl Fn(&str) -> bool,
) -> Vec<NetworkInterfaceInfo> {
devices
.into_iter()
.filter(|device| {
device.interface_type == "ethernet"
&& device.state == "connected"
&& !device.connection.is_empty()
&& device.connection != "--"
&& is_physical(&device.name)
})
.map(|device| NetworkInterfaceInfo {
name: device.name,
interface_type: device.interface_type,
state: device.state,
connection: device.connection,
addresses: device.addresses,
has_default_route: device.has_default_route,
bridge_supported: true,
reason: None,
})
.collect()
}
fn is_physical_network_interface(name: &str) -> bool {
Path::new("/sys/class/net")
.join(name)
.join("device")
.exists()
}
fn select_bridge_candidate<'a>(
interfaces: &'a [NetworkInterfaceInfo],
requested: &str,
) -> Result<&'a NetworkInterfaceInfo> {
if requested.trim().is_empty() {
return interfaces
.iter()
.max_by_key(|item| item.has_default_route)
.ok_or_else(|| {
AppError::Config(
"No connected physical NetworkManager Ethernet interface is available for OTG bridging"
.to_string(),
)
});
}
interfaces
.iter()
.find(|item| item.name == requested)
.ok_or_else(|| {
AppError::Config(format!(
"Network interface {requested} is not a connected physical NetworkManager Ethernet interface"
))
})
}
fn restore_from_journal(journal: &BridgeJournal) -> Result<()> {
let mut errors = Vec::new();
for (kind, profile_uuid) in [
("USB", Some(journal.usb_profile_uuid.as_str())),
("uplink", journal.uplink_profile_uuid.as_deref()),
("bridge", journal.bridge_profile_uuid.as_deref()),
] {
let Some(profile_uuid) = profile_uuid else {
continue;
};
if let Err(error) = delete_connection(profile_uuid) {
errors.push(format!(
"failed to remove owned {kind} profile {profile_uuid}: {error}"
));
}
}
if !journal.existing_bridge {
if let Err(error) = delete_bridge_interface() {
errors.push(format!(
"failed to remove owned bridge interface {BRIDGE_IF}: {error}"
));
}
if let Some(ref original_uuid) = journal.original_connection_uuid {
if let Err(error) = run_nmcli(&[
"connection",
"up",
"uuid",
original_uuid,
"ifname",
&journal.uplink,
]) {
errors.push(format!(
"failed to restore original profile {original_uuid}: {error}"
));
}
}
}
if !errors.is_empty() {
return Err(AppError::Config(errors.join("; ")));
}
match fs::remove_file(JOURNAL_PATH) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(AppError::Internal(format!(
"Failed to remove OTG network recovery journal: {error}"
))),
}
}
fn restore_or_combine(journal: &BridgeJournal, primary: AppError) -> AppError {
match restore_from_journal(journal) {
Ok(()) => primary,
Err(rollback) => AppError::Config(format!("{primary}; bridge rollback failed: {rollback}")),
}
}
fn reset_bridge_interface() -> Result<()> {
for profile_uuid in connection_uuids()? {
if connection_value(&profile_uuid, "connection.interface-name")? == BRIDGE_IF {
tracing::warn!(
"Removing NetworkManager profile {} bound to reserved interface {}",
profile_uuid,
BRIDGE_IF
);
run_nmcli(&["connection", "delete", "uuid", &profile_uuid])?;
}
}
delete_bridge_interface()
}
fn create_bridge_interface(mac_address: &str) -> Result<()> {
run_command("ip", &["link", "add", "name", BRIDGE_IF, "type", "bridge"])?;
run_command(
"ip",
&["link", "set", "dev", BRIDGE_IF, "address", mac_address],
)?;
prepare_device_for_network_manager(BRIDGE_IF, "bridge")
}
fn delete_bridge_interface() -> Result<()> {
if !Path::new("/sys/class/net").join(BRIDGE_IF).exists() {
return Ok(());
}
run_command("ip", &["link", "delete", BRIDGE_IF, "type", "bridge"])?;
Ok(())
}
fn prepare_device_for_network_manager(interface: &str, expected_type: &str) -> Result<()> {
run_command("ip", &["link", "set", interface, "up"])?;
let deadline = Instant::now() + NETWORK_MANAGER_DEVICE_WAIT_TIMEOUT;
let mut requested_managed = false;
while Instant::now() < deadline {
match enumerate_network_manager_devices() {
Ok(devices) => {
if let Some(device) = devices.iter().find(|device| device.name == interface) {
if device.interface_type != expected_type {
return Err(AppError::BadRequest(format!(
"One-KVM interface {interface} has NetworkManager type {}, expected {expected_type}",
device.interface_type,
)));
}
if device.state != "unmanaged" {
return Ok(());
}
if !requested_managed {
tracing::info!(
"Marking One-KVM interface {} as managed by NetworkManager",
interface
);
run_nmcli(&["device", "set", interface, "managed", "yes"])?;
requested_managed = true;
}
}
}
Err(error) => {
tracing::debug!(
"Waiting for NetworkManager to discover One-KVM interface {}: {}",
interface,
error
);
}
}
thread::sleep(Duration::from_millis(100));
}
Err(AppError::Internal(format!(
"NetworkManager did not discover One-KVM {expected_type} interface {interface} within {} seconds",
NETWORK_MANAGER_DEVICE_WAIT_TIMEOUT.as_secs()
)))
}
fn activate_connection(kind: &str, name: &str, uuid: &str, interface: Option<&str>) -> Result<()> {
let result = match interface {
Some(interface) => run_nmcli(&["connection", "up", name, "ifname", interface]),
None => run_nmcli(&["connection", "up", name]),
};
result.map_err(|error| {
let target = interface
.map(|value| format!(" on {value}"))
.unwrap_or_default();
AppError::Internal(format!(
"Failed to activate One-KVM {kind} profile {name} ({uuid}){target}: {error}"
))
})?;
Ok(())
}
fn active_connection_uuid(interface: &str) -> Result<String> {
let output = run_nmcli(&[
"--escape",
"no",
"-g",
"GENERAL.CON-UUID",
"device",
"show",
interface,
])?;
let uuid = String::from_utf8_lossy(&output.stdout).trim().to_string();
if uuid.is_empty() || uuid == "--" {
return Err(AppError::BadRequest(format!(
"Ethernet interface {interface} has no active NetworkManager profile UUID"
)));
}
Ok(uuid)
}
fn connection_uuids() -> Result<Vec<String>> {
let output = run_nmcli(&["-t", "--escape", "no", "-f", "UUID", "connection", "show"])?;
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect())
}
fn delete_connection(profile_uuid: &str) -> Result<()> {
if !connection_uuids()?.iter().any(|uuid| uuid == profile_uuid) {
return Ok(());
}
run_nmcli(&["connection", "delete", "uuid", profile_uuid])?;
Ok(())
}
fn copy_connection_properties(source: &str, target: &str, properties: &[&str]) -> Result<()> {
for property in properties {
let Ok(value) = connection_value(source, property) else {
tracing::debug!(
"Skipping unsupported NetworkManager property {} while configuring OTG bridge",
property
);
continue;
};
if value.is_empty() || value == "--" {
continue;
}
run_nmcli(&["connection", "modify", target, property, &value])?;
}
Ok(())
}
fn configure_ipv4_profile(source: &str, target: &str, method: &str) -> Result<()> {
match method {
"auto" => {
run_nmcli(&["connection", "modify", target, "ipv4.method", "auto"])?;
copy_connection_properties(source, target, DHCP_IDENTITY_PROPERTIES)
}
"manual" => {
let addresses = connection_value(source, "ipv4.addresses")?;
if addresses.is_empty() || addresses == "--" {
return Err(AppError::BadRequest(
"Static IPv4 profile has no ipv4.addresses value".to_string(),
));
}
let gateway = connection_value(source, "ipv4.gateway")?;
if gateway.is_empty() || gateway == "--" {
run_nmcli(&[
"connection",
"modify",
target,
"ipv4.method",
"manual",
"ipv4.addresses",
&addresses,
])?;
} else {
run_nmcli(&[
"connection",
"modify",
target,
"ipv4.method",
"manual",
"ipv4.addresses",
&addresses,
"ipv4.gateway",
&gateway,
])?;
}
copy_connection_properties(source, target, STATIC_IPV4_PROPERTIES)
}
_ => Err(AppError::BadRequest(format!(
"Unsupported IPv4 method {method}"
))),
}
}
fn write_journal(journal: &BridgeJournal) -> Result<()> {
let path = Path::new(JOURNAL_PATH);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
AppError::Internal(format!("Failed to create {}: {}", parent.display(), e))
})?;
}
let value = serde_json::to_vec(journal)
.map_err(|e| AppError::Internal(format!("Failed to serialize bridge journal: {e}")))?;
let temporary = path.with_extension("json.tmp");
fs::write(&temporary, value)
.map_err(|e| AppError::Internal(format!("Failed to write bridge recovery journal: {e}")))?;
fs::rename(&temporary, path)
.map_err(|e| AppError::Internal(format!("Failed to commit bridge recovery journal: {e}")))
}
fn connection_value(connection: &str, property: &str) -> Result<String> {
let output = run_nmcli(&[
"--escape",
"no",
"-g",
property,
"connection",
"show",
connection,
])?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn first_ipv4_address(interface: &str) -> Option<String> {
ipv4_addresses(interface).into_iter().next()
}
fn ipv4_addresses(interface: &str) -> Vec<String> {
let Ok(output) = Command::new("ip")
.args(["-4", "-o", "address", "show", "dev", interface])
.output()
else {
return Vec::new();
};
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let fields = line.split_whitespace().collect::<Vec<_>>();
fields
.iter()
.position(|field| *field == "inet")
.and_then(|index| fields.get(index + 1))
.map(|value| (*value).to_string())
})
.collect()
}
fn default_route(interface: &str) -> Option<String> {
let output = Command::new("ip")
.args(["-4", "route", "show", "default", "dev", interface])
.output()
.ok()?;
String::from_utf8_lossy(&output.stdout)
.lines()
.find(|line| !line.trim().is_empty())
.map(str::to_string)
}
fn gateway_from_route(route: &str) -> Option<&str> {
let fields = route.split_whitespace().collect::<Vec<_>>();
fields
.windows(2)
.find_map(|part| (part[0] == "via").then_some(part[1]))
}
fn ensure_command(name: &str) -> Result<()> {
let status = Command::new(name).arg("--version").output();
if status.is_err() {
return Err(AppError::BadRequest(format!(
"OTG bridge requires the {name} command"
)));
}
Ok(())
}
fn run_nmcli(args: &[&str]) -> Result<Output> {
run_command("nmcli", args)
}
fn run_command(command: &str, args: &[&str]) -> Result<Output> {
let output = Command::new(command)
.env("LC_ALL", "C")
.args(args)
.output()
.map_err(|e| {
AppError::Internal(format!(
"Failed to execute {command} {}: {e}",
args.join(" ")
))
})?;
if output.status.success() {
return Ok(output);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
Err(AppError::Internal(format!(
"{command} {} failed: {}",
args.join(" "),
if stderr.is_empty() { stdout } else { stderr }
)))
}
#[cfg(test)]
mod tests {
use super::*;
fn device(
name: &str,
interface_type: &str,
state: &str,
connection: &str,
has_default_route: bool,
) -> NetworkManagerDevice {
NetworkManagerDevice {
name: name.to_string(),
interface_type: interface_type.to_string(),
state: state.to_string(),
connection: connection.to_string(),
addresses: Vec::new(),
has_default_route,
}
}
#[test]
fn bridge_journal_round_trip() {
let journal = BridgeJournal {
version: JOURNAL_VERSION,
uplink: "eth0".to_string(),
existing_bridge: false,
original_connection_uuid: Some("original-uuid".to_string()),
bridge_profile_uuid: Some("bridge-uuid".to_string()),
uplink_profile_uuid: Some("uplink-uuid".to_string()),
usb_profile_uuid: "usb-uuid".to_string(),
};
let value = serde_json::to_string(&journal).unwrap();
let decoded: BridgeJournal = serde_json::from_str(&value).unwrap();
assert_eq!(decoded.uplink, "eth0");
assert_eq!(decoded.bridge_profile_uuid.as_deref(), Some("bridge-uuid"));
}
#[test]
fn transaction_profiles_use_unique_names_and_uuids() {
let first = TransactionProfiles::new();
let second = TransactionProfiles::new();
assert_ne!(first.bridge_name, second.bridge_name);
assert_ne!(first.bridge_uuid, second.bridge_uuid);
assert!(first.usb_name.starts_with(PROFILE_PREFIX));
assert!(Uuid::parse_str(&first.usb_uuid).is_ok());
}
#[test]
fn gateway_is_optional_diagnostic_data() {
assert_eq!(
gateway_from_route("default via 192.0.2.1 dev okvm-br0"),
Some("192.0.2.1")
);
assert_eq!(gateway_from_route("default dev okvm-br0"), None);
}
#[test]
fn dhcp_identity_properties_include_client_id_and_hostname() {
assert!(DHCP_IDENTITY_PROPERTIES.contains(&"ipv4.dhcp-client-id"));
assert!(DHCP_IDENTITY_PROPERTIES.contains(&"ipv4.dhcp-iaid"));
assert!(DHCP_IDENTITY_PROPERTIES.contains(&"ipv4.dhcp-hostname"));
}
#[test]
fn static_ipv4_properties_cover_dns_routes_and_policy() {
assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.dns"));
assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.routes"));
assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.route-table"));
assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.never-default"));
}
#[test]
fn full_network_manager_enumeration_keeps_runtime_devices() {
let devices = parse_network_manager_devices(
"eth0:ethernet:connected:Wired connection 1\n\
usb0:ethernet:disconnected:--\n\
okvm-br0:bridge:unmanaged:--\n",
);
assert_eq!(
devices
.iter()
.map(|device| device.name.as_str())
.collect::<Vec<_>>(),
["eth0", "usb0", "okvm-br0"]
);
}
#[test]
fn bridge_candidates_only_keep_connected_physical_ethernet() {
let devices = vec![
device("eth0", "ethernet", "connected", "one-kvm-otg-uplink", false),
device("wlx76012dc07213", "wifi", "connected", "Wi-Fi", true),
device("okvm-br0", "bridge", "connected", "Bridge", true),
device("usb0", "ethernet", "connected", "USB", false),
device("lo", "loopback", "connected", "lo", false),
device("bond0", "bond", "connected", "Bond", false),
device("tun0", "tun", "connected", "Tunnel", false),
device("veth0", "ethernet", "connected", "Virtual", false),
device("eth1", "ethernet", "disconnected", "--", false),
device("eth2", "ethernet", "connected", "--", false),
device("eth3", "ethernet", "connected", "", false),
];
let candidates = bridge_candidates(devices, |name| {
matches!(name, "eth0" | "eth1" | "eth2" | "eth3")
});
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].name, "eth0");
assert!(candidates[0].bridge_supported);
assert_eq!(candidates[0].reason, None);
}
#[test]
fn automatic_bridge_selection_prefers_default_route() {
let candidates = bridge_candidates(
vec![
device("eth0", "ethernet", "connected", "Wired 1", false),
device("eth1", "ethernet", "connected", "Wired 2", true),
],
|_| true,
);
let selected = select_bridge_candidate(&candidates, "").unwrap();
assert_eq!(selected.name, "eth1");
}
#[test]
fn bridge_selection_reports_when_no_candidate_exists() {
let error = select_bridge_candidate(&[], "").unwrap_err();
assert!(matches!(error, AppError::Config(_)));
assert!(error.to_string().contains("connected physical"));
}
}

View File

@@ -13,7 +13,11 @@ pub const DEFAULT_USB_BCD_DEVICE: u16 = 0x0100;
pub const USB_BCD_USB: u16 = 0x0200; pub const USB_BCD_USB: u16 = 0x0200;
pub fn is_configfs_available() -> bool { pub fn is_configfs_available() -> bool {
Path::new(CONFIGFS_PATH).exists() configfs_path().exists()
}
pub fn configfs_path() -> &'static Path {
Path::new(CONFIGFS_PATH)
} }
/// Loads `libcomposite` if needed; does not mount configfs. /// Loads `libcomposite` if needed; does not mount configfs.
@@ -71,11 +75,6 @@ fn collect_dir_names(path: &Path, devices: &mut Vec<String>) {
} }
} }
pub fn is_low_endpoint_udc(name: &str) -> bool {
let name = name.to_ascii_lowercase();
name.contains("musb") || name.contains("musb-hdrc")
}
/// Sysfs/configfs: one write syscall with final buffer (incl. newline when needed). /// Sysfs/configfs: one write syscall with final buffer (incl. newline when needed).
pub fn write_file(path: &Path, content: &str) -> Result<()> { pub fn write_file(path: &Path, content: &str) -> Result<()> {
let mut file = OpenOptions::new() let mut file = OpenOptions::new()

View File

@@ -1,79 +0,0 @@
use crate::error::{AppError, Result};
pub const DEFAULT_MAX_ENDPOINTS: u8 = 16;
#[derive(Debug, Clone)]
pub struct EndpointAllocator {
max_endpoints: u8,
used_endpoints: u8,
}
impl EndpointAllocator {
pub fn new(max_endpoints: u8) -> Self {
Self {
max_endpoints,
used_endpoints: 0,
}
}
pub fn allocate(&mut self, count: u8) -> Result<()> {
if self.used_endpoints + count > self.max_endpoints {
return Err(AppError::Internal(format!(
"Not enough endpoints: need {}, available {}",
count,
self.available()
)));
}
self.used_endpoints += count;
Ok(())
}
pub fn release(&mut self, count: u8) {
self.used_endpoints = self.used_endpoints.saturating_sub(count);
}
pub fn available(&self) -> u8 {
self.max_endpoints.saturating_sub(self.used_endpoints)
}
pub fn used(&self) -> u8 {
self.used_endpoints
}
pub fn max(&self) -> u8 {
self.max_endpoints
}
pub fn can_allocate(&self, count: u8) -> bool {
self.available() >= count
}
}
impl Default for EndpointAllocator {
fn default() -> Self {
Self::new(DEFAULT_MAX_ENDPOINTS)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_allocator() {
let mut alloc = EndpointAllocator::new(8);
assert_eq!(alloc.available(), 8);
alloc.allocate(2).unwrap();
assert_eq!(alloc.available(), 6);
assert_eq!(alloc.used(), 2);
alloc.allocate(4).unwrap();
assert_eq!(alloc.available(), 2);
assert!(alloc.allocate(3).is_err());
alloc.release(2);
assert_eq!(alloc.available(), 4);
}
}

Some files were not shown because too many files have changed in this diff Show More