mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 18:44:25 +08:00
105
Cargo.toml
105
Cargo.toml
@@ -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
7
android/.gitignore
vendored
@@ -1,7 +0,0 @@
|
|||||||
.gradle/
|
|
||||||
.kotlin/
|
|
||||||
build/
|
|
||||||
local.properties
|
|
||||||
app/build/
|
|
||||||
app/src/main/jniLibs/
|
|
||||||
native/target/
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">One-KVM Android Host</string>
|
|
||||||
</resources>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("com.android.application") version "9.0.0" apply false
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
android.useAndroidX=true
|
|
||||||
android.nonTransitiveRClass=true
|
|
||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@@ -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
251
android/gradlew
vendored
@@ -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
94
android/gradlew.bat
vendored
@@ -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
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
@@ -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
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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]
|
||||||
|
|||||||
2
libs/hwcodec/.gitignore
vendored
2
libs/hwcodec/.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 _,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
|||||||
6
libs/v4l2r/Cargo.toml.orig
generated
6
libs/v4l2r/Cargo.toml.orig
generated
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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()) }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ license = "BSD-3-Clause"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
bindgen = "0.70.1"
|
bindgen = "0.72"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -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>>,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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(¤t.id).await.unwrap();
|
||||||
|
assert_eq!(revoked, vec![other.id.clone()]);
|
||||||
|
assert!(sessions.get(¤t.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
498
src/auth/two_factor.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
85
src/config/schema/otg_network.rs
Normal file
85
src/config/schema/otg_network.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/config/schema/watchdog.rs
Normal file
9
src/config/schema/watchdog.rs
Normal 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,
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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#"
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
¤t,
|
||||||
|
"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(
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
src/lib.rs
63
src/lib.rs
@@ -1,68 +1,67 @@
|
|||||||
//! Core library for One-KVM (IP‑KVM: capture, HID, OTG, streaming, Web UI glue).
|
//! Core library for One-KVM (IP‑KVM: 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};
|
||||||
|
|||||||
112
src/main.rs
112
src/main.rs
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|
||||||
|
|||||||
137
src/msd/types.rs
137
src/msd/types.rs
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
945
src/otg/bridge.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user