2 Commits

Author SHA1 Message Date
lenn
360b57e3e2 Add Android USB serial bridge docs 2026-05-11 22:30:45 +08:00
lenn
c5f4f854bf perf: optimize mobile line chart performance and remove window controls
- Remove drop-shadow filters on SVG paths on mobile (SignalChart, SummaryCurve)
- Hide scan-haze overlay (mix-blend-mode: screen) on mobile
- Remove feTurbulence noise filter on mobile (biggest perf win)
- Simplify backgrounds and box-shadows on mobile
- Remove blur transition on inactive panels
- Hide window control buttons (minimize/maximize/close) on mobile
- Configure Android release build to sign with debug keystore
- Update README with changelog and Android build instructions
2026-05-11 22:11:40 +08:00
15 changed files with 647 additions and 110 deletions

View File

@@ -1,10 +1,11 @@
# Tauri Demo (SvelteKit + TypeScript) # JE-Skin (SvelteKit + Tauri)
## 环境要求 ## 环境要求
- Node.js 18+(建议 LTS - Node.js 18+(建议 LTS
- Rust stable`rustup` + `cargo` - Rust stable`rustup` + `cargo`
- Windows 下请确保已安装 WebView2 Runtime 和 MSVC C++ 构建工具 - Windows 下请确保已安装 WebView2 Runtime 和 MSVC C++ 构建工具
- Android 构建需要 Android SDK + NDK
## 安装依赖 ## 安装依赖
@@ -42,12 +43,61 @@ npm run build
npm run tauri build npm run tauri build
``` ```
构建 Android APK / AAB
```sh
npx tauri android build
```
产物路径:
- APK: `src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk`
- AAB: `src-tauri/gen/android/app/build/outputs/bundle/universalRelease/app-universal-release.aab`
Release APK 默认使用 debug keystore 签名(`src-tauri/gen/android/app/je-skin-debug.keystore`),可直接 `adb install` 到设备。
## 代码检查 ## 代码检查
```sh ```sh
npm run check npm run check
``` ```
## v0.5.0 修改记录
### Android USB 串口接入
- **Tauri 插件注册**Android 端通过 Rust builder 注册 `usb-serial` 插件,移除 `MainActivity` 中的手动加载逻辑
- **USB 设备枚举**:使用 `usb-serial-for-android``UsbSerialProber` 识别串口设备,并返回设备名、厂商 ID、产品 ID、权限状态等信息
- **USB 权限申请**:完善 Android USB 授权回调支持按设备名、vendorId/productId 解析设备并处理授权后的打开流程
- **串口数据桥接**Kotlin 端打开 USB serial port 后通过 Unix socketpair 将 fd 交给 RustRust 端继续复用 `serial_connect_fd` 数据采集链路
- **资源释放**:关闭连接时同步释放桥接 fd、USB serial port 和 `UsbDeviceConnection`,避免重复打开后的资源残留
### Tauri 权限与构建
- 新增 `src-tauri/permissions/usb-serial/default.toml`,声明 Android USB serial 插件命令和前端所需本地命令权限
- `default.json` 增加 USB serial 与本地命令权限,兼容 snake_case / camelCase 插件命令名
- Android Gradle 仓库加入 JitPack用于解析 USB serial 驱动依赖
- ProGuard 增加 Tauri 插件注解、`UsbSerialPlugin``com.hoho.android.usbserial` 保留规则,避免 release 包混淆后插件命令失效
- Android 构建下 `serial_enum` 返回空列表,并仅保留 fd 连接入口,避免桌面串口枚举依赖进入 Android 编译路径
## v0.4.0 修改记录
### 移动端性能优化
- **SignalChart / SummaryCurve**:在 `@media (max-width: 900px)` 下移除 SVG 路径上的 `filter: drop-shadow()`,避免移动端 GPU 软件回流导致卡顿
- **SignalChart**:隐藏 `.scan-haze``mix-blend-mode: screen` 合成开销大),简化面板 `background` / `box-shadow`
- **SummaryCurve**:移动端移除 `.summary-line``.summary-dot` 的 drop-shadow 滤镜
- **页面级**:移动端隐藏 `.hud-noise``feTurbulence` SVG 滤镜是最大性能杀手),降低 `.hud-vignette` 透明度,简化 `.hud-gradient`
- 移除 inactive 面板的 `filter: blur()` 过渡动画
- 移除 transition 中的 `filter` 属性,添加 `will-change: d` 优化路径更新
### 移动端 UI 调整
- **隐藏三大金刚**`@media (max-width: 900px)` 下隐藏标题栏右侧的最小化/最大化/关闭按钮Android 系统自带窗口管理)
### Android 打包
- Release 构建配置使用 debug keystore 签名,输出签名 APK 而非 unsigned
## 推荐 IDE 插件 ## 推荐 IDE 插件
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) [VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)

View File

@@ -11,6 +11,13 @@
"core:window:allow-set-size", "core:window:allow-set-size",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"opener:default", "opener:default",
"process:default" "process:default",
"allow-usb-serial-list",
"allow-usb-serial-open",
"allow-usb-serial-close",
"allow-usb-serial-list-camel",
"allow-usb-serial-open-camel",
"allow-usb-serial-close-camel",
"allow-local-commands"
] ]
} }

View File

@@ -43,6 +43,7 @@ android {
.plus(getDefaultProguardFile("proguard-android-optimize.txt")) .plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray() .toList().toTypedArray()
) )
signingConfig = signingConfigs.getByName("debug")
} }
} }
kotlinOptions { kotlinOptions {
@@ -63,9 +64,10 @@ dependencies {
implementation("androidx.activity:activity-ktx:1.10.1") implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0") implementation("com.google.android.material:material:1.12.0")
implementation("androidx.lifecycle:lifecycle-process:2.10.0") implementation("androidx.lifecycle:lifecycle-process:2.10.0")
implementation("com.github.mik3y:usb-serial-for-android:3.9.0")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4") androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
} }
apply(from = "tauri.build.gradle.kts") apply(from = "tauri.build.gradle.kts")

View File

@@ -18,4 +18,17 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
-keepattributes RuntimeVisibleAnnotations,RuntimeInvisibleAnnotations,*Annotation*,Signature,InnerClasses,EnclosingMethod
-keep class app.tauri.annotation.** { *; }
-keep class app.tauri.plugin.** { *; }
-keep class com.lenn.tauri_serial.MainActivity { *; }
-keep class com.lenn.tauri_serial.UsbSerialPlugin { *; }
-keepclassmembers class com.lenn.tauri_serial.UsbSerialPlugin {
public *;
}
-keep class com.hoho.android.usbserial.** { *; }

View File

@@ -7,7 +7,5 @@ class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val plugin = UsbSerialPlugin(this)
pluginManager.load(null, "usb-serial", plugin, "")
} }
} }

View File

@@ -10,20 +10,36 @@ import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbManager import android.hardware.usb.UsbManager
import android.os.Build import android.os.Build
import android.os.ParcelFileDescriptor
import android.system.Os
import android.system.OsConstants
import app.tauri.annotation.Command import app.tauri.annotation.Command
import app.tauri.annotation.TauriPlugin import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin import app.tauri.plugin.Plugin
import app.tauri.plugin.Invoke import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.driver.UsbSerialProber
import java.io.FileDescriptor
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import org.json.JSONArray
@TauriPlugin @TauriPlugin
class UsbSerialPlugin(private val activity: Activity) : Plugin(activity) { class UsbSerialPlugin(private val activity: Activity) : Plugin(activity) {
companion object { companion object {
private const val ACTION_USB_PERMISSION = "com.lenn.tauri_serial.USB_PERMISSION" private const val ACTION_USB_PERMISSION = "com.lenn.tauri_serial.USB_PERMISSION"
private const val BAUD_RATE = 921600
private const val READ_TIMEOUT_MS = 100
private const val WRITE_TIMEOUT_MS = 100
} }
private var pendingConnectInvoke: Invoke? = null private var pendingConnectInvoke: Invoke? = null
private var pendingConnectDevice: UsbDevice? = null private var pendingConnectDeviceName: String? = null
private var activeBridge: SerialBridge? = null
private val usbPermissionReceiver = object : BroadcastReceiver() { private val usbPermissionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
@@ -39,10 +55,10 @@ class UsbSerialPlugin(private val activity: Activity) : Plugin(activity) {
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
val invoke = pendingConnectInvoke val invoke = pendingConnectInvoke
val targetDevice = pendingConnectDevice val targetDeviceName = pendingConnectDeviceName
pendingConnectInvoke = null pendingConnectInvoke = null
pendingConnectDevice = null pendingConnectDeviceName = null
if (invoke == null || device == null) return if (invoke == null || device == null) return
@@ -51,8 +67,8 @@ class UsbSerialPlugin(private val activity: Activity) : Plugin(activity) {
return return
} }
if (targetDevice != null && device.deviceName == targetDevice.deviceName) { if (targetDeviceName != null && device.deviceName == targetDeviceName) {
openAndReturn(invoke, device) openAndReturn(invoke, device.deviceName)
} else { } else {
invoke.reject("USB device mismatch") invoke.reject("USB device mismatch")
} }
@@ -65,7 +81,9 @@ class UsbSerialPlugin(private val activity: Activity) : Plugin(activity) {
val filter = IntentFilter(ACTION_USB_PERMISSION) val filter = IntentFilter(ACTION_USB_PERMISSION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.applicationContext.registerReceiver( activity.applicationContext.registerReceiver(
usbPermissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED usbPermissionReceiver,
filter,
Context.RECEIVER_NOT_EXPORTED
) )
} else { } else {
activity.applicationContext.registerReceiver(usbPermissionReceiver, filter) activity.applicationContext.registerReceiver(usbPermissionReceiver, filter)
@@ -74,45 +92,66 @@ class UsbSerialPlugin(private val activity: Activity) : Plugin(activity) {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
activeBridge?.close()
activeBridge = null
try { try {
activity.applicationContext.unregisterReceiver(usbPermissionReceiver) activity.applicationContext.unregisterReceiver(usbPermissionReceiver)
} catch (_: Exception) {} } catch (_: Exception) {
}
} }
@Command @Command
fun usb_serial_list(invoke: Invoke) { fun usb_serial_list(invoke: Invoke) {
listDevices(invoke)
}
@Command
fun usbSerialList(invoke: Invoke) {
listDevices(invoke)
}
private fun listDevices(invoke: Invoke) {
val usbManager = activity.getSystemService(Context.USB_SERVICE) as? UsbManager val usbManager = activity.getSystemService(Context.USB_SERVICE) as? UsbManager
if (usbManager == null) { if (usbManager == null) {
invoke.reject("USB service not available") invoke.reject("USB service not available")
return return
} }
val devices = usbManager.deviceList
val result = JSObject() val result = JSObject()
val serialDevices = JSONArray()
val serialDevices = mutableListOf<JSObject>() for (driver in UsbSerialProber.getDefaultProber().findAllDrivers(usbManager)) {
for ((_, device) in devices) { val device = driver.device
if (isUsbSerialDevice(device)) { val obj = JSObject()
val obj = JSObject() obj.put("name", device.deviceName)
obj.put("name", device.deviceName) obj.put("vendorId", device.vendorId)
obj.put("vendorId", device.vendorId) obj.put("productId", device.productId)
obj.put("productId", device.productId) obj.put("manufacturer", safeDeviceString { device.manufacturerName })
obj.put("manufacturer", device.manufacturerName ?: "") obj.put("product", safeDeviceString { device.productName })
obj.put("product", device.productName ?: "") obj.put("serial", safeDeviceString { device.serialNumber })
obj.put("serial", device.serialNumber ?: "") obj.put("hasPermission", usbManager.hasPermission(device))
obj.put("hasPermission", usbManager.hasPermission(device)) serialDevices.put(obj)
serialDevices.add(obj)
}
} }
result.put("devices", serialDevices.toTypedArray()) result.put("devices", serialDevices)
invoke.resolve(result) invoke.resolve(result)
} }
@Command @Command
fun usb_serial_open(invoke: Invoke) { fun usb_serial_open(invoke: Invoke) {
openDevice(invoke)
}
@Command
fun usbSerialOpen(invoke: Invoke) {
openDevice(invoke)
}
private fun openDevice(invoke: Invoke) {
val args = invoke.parseArgs(JSObject::class.java) val args = invoke.parseArgs(JSObject::class.java)
val deviceName = args.optString("name", "") val deviceName = args.optString("name", "")
val vendorId = if (args.has("vendorId")) args.optInt("vendorId") else null
val productId = if (args.has("productId")) args.optInt("productId") else null
val usbManager = activity.getSystemService(Context.USB_SERVICE) as? UsbManager val usbManager = activity.getSystemService(Context.USB_SERVICE) as? UsbManager
if (usbManager == null) { if (usbManager == null) {
@@ -120,98 +159,230 @@ class UsbSerialPlugin(private val activity: Activity) : Plugin(activity) {
return return
} }
val device = usbManager.deviceList[deviceName] val device = resolveDevice(usbManager, deviceName, vendorId, productId)
if (device == null) { if (device == null) {
invoke.reject("USB device not found: $deviceName") val available = usbManager.deviceList.values.joinToString(", ") { it.deviceName }
invoke.reject("USB device not found: $deviceName; available: $available")
return return
} }
if (!usbManager.hasPermission(device)) { if (!usbManager.hasPermission(device)) {
synchronized(this) { synchronized(this) {
pendingConnectInvoke = invoke pendingConnectInvoke = invoke
pendingConnectDevice = device pendingConnectDeviceName = device.deviceName
} }
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
} else { } else {
0 PendingIntent.FLAG_UPDATE_CURRENT
} }
val permissionRequest = Intent(ACTION_USB_PERMISSION).setPackage(activity.packageName)
val permissionIntent = PendingIntent.getBroadcast( val permissionIntent = PendingIntent.getBroadcast(
activity, 0, Intent(ACTION_USB_PERMISSION), flags activity,
0,
permissionRequest,
flags
) )
usbManager.requestPermission(device, permissionIntent) usbManager.requestPermission(device, permissionIntent)
return return
} }
openAndReturn(invoke, device) openAndReturn(invoke, device.deviceName)
} }
@Command @Command
fun usb_serial_close(invoke: Invoke) { fun usb_serial_close(invoke: Invoke) {
closeBridge()
invoke.resolve(JSObject()) invoke.resolve(JSObject())
} }
private fun openAndReturn(invoke: Invoke, device: UsbDevice) { @Command
fun usbSerialClose(invoke: Invoke) {
closeBridge()
invoke.resolve(JSObject())
}
private fun closeBridge() {
activeBridge?.close()
activeBridge = null
}
private fun openAndReturn(invoke: Invoke, deviceName: String) {
val usbManager = activity.getSystemService(Context.USB_SERVICE) as? UsbManager val usbManager = activity.getSystemService(Context.USB_SERVICE) as? UsbManager
if (usbManager == null) { if (usbManager == null) {
invoke.reject("USB service not available") invoke.reject("USB service not available")
return return
} }
val connection: UsbDeviceConnection = usbManager.openDevice(device) val driver = findDriver(usbManager, deviceName)
?: run { if (driver == null) {
invoke.reject("Failed to open USB device") invoke.reject("USB serial driver not found: $deviceName")
return
}
var claimedInterface = false
for (i in 0 until device.interfaceCount) {
val iface = device.getInterface(i)
if (iface.endpointCount >= 2) {
connection.claimInterface(iface, true)
claimedInterface = true
break
}
}
if (!claimedInterface) {
invoke.reject("No usable USB interface found")
return return
} }
val fd = connection.fileDescriptor val connection = usbManager.openDevice(driver.device)
val result = JSObject() if (connection == null) {
result.put("fd", fd) invoke.reject("Failed to open USB device")
result.put("name", device.deviceName) return
result.put("vendorId", device.vendorId) }
result.put("productId", device.productId)
invoke.resolve(result) val port = driver.ports.firstOrNull()
if (port == null) {
connection.close()
invoke.reject("No serial port found on USB device")
return
}
try {
port.open(connection)
port.setParameters(
BAUD_RATE,
UsbSerialPort.DATABITS_8,
UsbSerialPort.STOPBITS_1,
UsbSerialPort.PARITY_NONE
)
val rustSide = FileDescriptor()
val bridgeSide = FileDescriptor()
Os.socketpair(OsConstants.AF_UNIX, OsConstants.SOCK_STREAM, 0, rustSide, bridgeSide)
val rustFd = ParcelFileDescriptor.dup(rustSide).detachFd()
Os.close(rustSide)
activeBridge?.close()
activeBridge = SerialBridge(bridgeSide, port, connection).also { it.start() }
val result = JSObject()
result.put("fd", rustFd)
result.put("name", driver.device.deviceName)
result.put("vendorId", driver.device.vendorId)
result.put("productId", driver.device.productId)
invoke.resolve(result)
} catch (error: Exception) {
try {
port.close()
} catch (_: Exception) {
}
connection.close()
invoke.reject(error.message ?: "Failed to open USB serial port")
}
} }
private fun isUsbSerialDevice(device: UsbDevice): Boolean { private fun findDriver(usbManager: UsbManager, deviceName: String): UsbSerialDriver? {
for (i in 0 until device.interfaceCount) { return UsbSerialProber.getDefaultProber()
val iface = device.getInterface(i) .findAllDrivers(usbManager)
val classId = iface.interfaceClass .firstOrNull { it.device.deviceName == deviceName || it.device.deviceName.equals(deviceName, ignoreCase = true) }
if (classId == 0x02 || classId == 0xFF) { }
if (iface.endpointCount >= 2) {
return true private fun resolveDevice(
usbManager: UsbManager,
deviceName: String,
vendorId: Int?,
productId: Int?
): UsbDevice? {
usbManager.deviceList[deviceName]?.let { return it }
val devices = usbManager.deviceList.values.toList()
devices.firstOrNull { it.deviceName.equals(deviceName, ignoreCase = true) }?.let { return it }
if (vendorId != null && productId != null) {
devices.firstOrNull { it.vendorId == vendorId && it.productId == productId }?.let { return it }
}
val drivers = UsbSerialProber.getDefaultProber().findAllDrivers(usbManager)
drivers.firstOrNull {
it.device.deviceName == deviceName || it.device.deviceName.equals(deviceName, ignoreCase = true)
}?.device?.let { return it }
if (drivers.size == 1) {
return drivers.first().device
}
if (devices.size == 1) {
return devices.first()
}
return null
}
private fun safeDeviceString(read: () -> String?): String {
return try {
read() ?: ""
} catch (_: SecurityException) {
""
}
}
private class SerialBridge(
private val bridgeFd: FileDescriptor,
private val port: UsbSerialPort,
private val connection: UsbDeviceConnection
) {
private val running = AtomicBoolean(false)
private lateinit var serialToRustThread: Thread
private lateinit var rustToSerialThread: Thread
fun start() {
running.set(true)
serialToRustThread = Thread(::copySerialToRust, "JE-Skin-usb-serial-rx")
rustToSerialThread = Thread(::copyRustToSerial, "JE-Skin-usb-serial-tx")
serialToRustThread.start()
rustToSerialThread.start()
}
fun close() {
if (!running.getAndSet(false)) return
try {
Os.close(bridgeFd)
} catch (_: Exception) {
}
try {
port.close()
} catch (_: Exception) {
}
connection.close()
}
private fun copySerialToRust() {
val output = FileOutputStream(bridgeFd)
val buffer = ByteArray(4096)
while (running.get()) {
try {
val count = port.read(buffer, READ_TIMEOUT_MS)
if (count > 0) {
output.write(buffer, 0, count)
output.flush()
}
} catch (_: IOException) {
close()
} catch (_: Exception) {
close()
} }
} }
} }
val knownVendors = setOf( private fun copyRustToSerial() {
0x1A86, // CH340/CH341 val input = FileInputStream(bridgeFd)
0x10C4, // CP210x val buffer = ByteArray(4096)
0x0403, // FTDI
0x067B, // PL2303
0x2341, // Arduino
0x239A, // Adafruit
)
if (device.vendorId in knownVendors) {
return true
}
return false while (running.get()) {
try {
val count = input.read(buffer)
if (count < 0) {
close()
return
}
if (count > 0) {
port.write(buffer.copyOf(count), WRITE_TIMEOUT_MS)
}
} catch (_: IOException) {
close()
} catch (_: Exception) {
close()
}
}
}
} }
} }

View File

@@ -13,10 +13,10 @@ allprojects {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
maven(url = "https://jitpack.io")
} }
} }
tasks.register("clean").configure { tasks.register("clean").configure {
delete("build") delete("build")
} }

View File

@@ -0,0 +1,66 @@
[default]
description = "Allows Android USB serial plugin commands."
permissions = [
"allow-usb-serial-list",
"allow-usb-serial-open",
"allow-usb-serial-close",
"allow-usb-serial-list-camel",
"allow-usb-serial-open-camel",
"allow-usb-serial-close-camel",
"allow-local-commands"
]
[[permission]]
identifier = "allow-usb-serial-list"
description = "Allows listing Android USB serial devices."
commands.allow = ["plugin:usb-serial|usb_serial_list"]
[[permission]]
identifier = "allow-usb-serial-open"
description = "Allows opening Android USB serial devices."
commands.allow = ["plugin:usb-serial|usb_serial_open"]
[[permission]]
identifier = "allow-usb-serial-close"
description = "Allows closing Android USB serial devices."
commands.allow = ["plugin:usb-serial|usb_serial_close"]
[[permission]]
identifier = "allow-usb-serial-list-camel"
description = "Allows listing Android USB serial devices via camelCase command."
commands.allow = ["plugin:usb-serial|usbSerialList"]
[[permission]]
identifier = "allow-usb-serial-open-camel"
description = "Allows opening Android USB serial devices via camelCase command."
commands.allow = ["plugin:usb-serial|usbSerialOpen"]
[[permission]]
identifier = "allow-usb-serial-close-camel"
description = "Allows closing Android USB serial devices via camelCase command."
commands.allow = ["plugin:usb-serial|usbSerialClose"]
[[permission]]
identifier = "allow-local-commands"
description = "Allows application commands used by the Android frontend."
commands.allow = [
"file_explorer_list",
"serial_enum",
"serial_connect",
"serial_connect_fd",
"serial_disconnect",
"serial_export_csv",
"serial_has_record_data",
"serial_export_csv_to_path",
"serial_import_csv",
"serial_import_csv_from_path",
"win_minimize",
"win_toggle_maximize",
"win_close",
"devkit_status",
"devkit_start",
"devkit_stop",
"devkit_get_config",
"devkit_set_config",
"devkit_process_export"
]

View File

@@ -13,6 +13,7 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::{async_runtime::JoinHandle, AppHandle, Manager, State}; use tauri::{async_runtime::JoinHandle, AppHandle, Manager, State};
#[cfg(not(target_os = "android"))]
use tokio_serial::{available_ports, SerialPortBuilderExt}; use tokio_serial::{available_ports, SerialPortBuilderExt};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@@ -113,22 +114,31 @@ pub async fn shutdown_active_session(
#[tauri::command] #[tauri::command]
pub fn serial_enum() -> Result<Vec<String>, SerialError> { pub fn serial_enum() -> Result<Vec<String>, SerialError> {
let ports = available_ports() #[cfg(target_os = "android")]
.map_err(|_| SerialError::ScanError)? {
.into_iter() Ok(Vec::new())
.filter_map(|p| { }
let name = p.port_name;
#[cfg(unix)]
if !name.contains("USB") {
return None;
}
Some(name)
})
.collect();
Ok(ports) #[cfg(not(target_os = "android"))]
{
let ports = available_ports()
.map_err(|_| SerialError::ScanError)?
.into_iter()
.filter_map(|p| {
let name = p.port_name;
#[cfg(unix)]
if !name.contains("USB") {
return None;
}
Some(name)
})
.collect();
Ok(ports)
}
} }
#[cfg(not(target_os = "android"))]
#[tauri::command] #[tauri::command]
pub async fn serial_connect( pub async fn serial_connect(
app: AppHandle, app: AppHandle,

View File

@@ -10,6 +10,16 @@ use commands::serial::SerialConnectionState;
#[cfg(feature = "devkit")] #[cfg(feature = "devkit")]
use tauri::Manager; use tauri::Manager;
#[cfg(target_os = "android")]
fn usb_serial_plugin<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("usb-serial")
.setup(|_app, api| {
api.register_android_plugin("com.lenn.tauri_serial", "UsbSerialPlugin")?;
Ok(())
})
.build()
}
#[cfg(feature = "devkit")] #[cfg(feature = "devkit")]
fn start_server_exe(exe_path: &std::path::Path) { fn start_server_exe(exe_path: &std::path::Path) {
let mut command = std::process::Command::new(exe_path); let mut command = std::process::Command::new(exe_path);
@@ -66,6 +76,9 @@ pub fn run() {
.manage(SerialConnectionState::default()) .manage(SerialConnectionState::default())
.plugin(tauri_plugin_opener::init()); .plugin(tauri_plugin_opener::init());
#[cfg(target_os = "android")]
let builder = builder.plugin(usb_serial_plugin());
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
@@ -177,7 +190,6 @@ pub fn run() {
let builder = builder.invoke_handler(tauri::generate_handler![ let builder = builder.invoke_handler(tauri::generate_handler![
commands::file_explorer::file_explorer_list, commands::file_explorer::file_explorer_list,
commands::serial::serial_enum, commands::serial::serial_enum,
commands::serial::serial_connect,
commands::serial::serial_connect_fd, commands::serial::serial_connect_fd,
commands::serial::serial_disconnect, commands::serial::serial_disconnect,
commands::serial::serial_export_csv, commands::serial::serial_export_csv,
@@ -200,7 +212,6 @@ pub fn run() {
let builder = builder.invoke_handler(tauri::generate_handler![ let builder = builder.invoke_handler(tauri::generate_handler![
commands::file_explorer::file_explorer_list, commands::file_explorer::file_explorer_list,
commands::serial::serial_enum, commands::serial::serial_enum,
commands::serial::serial_connect,
commands::serial::serial_connect_fd, commands::serial::serial_connect_fd,
commands::serial::serial_disconnect, commands::serial::serial_disconnect,
commands::serial::serial_export_csv, commands::serial::serial_export_csv,

View File

@@ -34,8 +34,9 @@ impl RawFdStream {
impl Drop for RawFdStream { impl Drop for RawFdStream {
fn drop(&mut self) { fn drop(&mut self) {
// We don't close the fd here - it's managed by the UsbDeviceConnection in Kotlin. unsafe {
// The Kotlin side is responsible for closing. libc::close(*self.inner.get_ref());
}
} }
} }
@@ -123,4 +124,4 @@ impl AsyncWrite for RawFdStream {
) -> std::task::Poll<io::Result<()>> { ) -> std::task::Poll<io::Result<()>> {
std::task::Poll::Ready(Ok(())) std::task::Poll::Ready(Ok(()))
} }
} }

View File

@@ -460,6 +460,12 @@
color: rgb(var(--hud-orange-rgb) / 0.96); color: rgb(var(--hud-orange-rgb) / 0.96);
} }
@media (max-width: 900px) {
.window-controls {
display: none;
}
}
.control-bar { .control-bar {
display: grid; display: grid;
gap: 0.45rem; gap: 0.45rem;

View File

@@ -200,7 +200,6 @@
border-color: transparent; border-color: transparent;
opacity: 0; opacity: 0;
transform: translateX(var(--offset-x)) scale(0.98) rotate(-0.6deg); transform: translateX(var(--offset-x)) scale(0.98) rotate(-0.6deg);
filter: blur(1.3px);
pointer-events: none; pointer-events: none;
transition-delay: 0ms; transition-delay: 0ms;
} }
@@ -302,6 +301,7 @@
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: round; stroke-linejoin: round;
filter: drop-shadow(0 0 2px rgb(0 0 0 / 0.42)); filter: drop-shadow(0 0 2px rgb(0 0 0 / 0.42));
will-change: d;
} }
.series-line.tone-cyan { .series-line.tone-cyan {
@@ -397,6 +397,10 @@
aspect-ratio: 1.5 / 1; aspect-ratio: 1.5 / 1;
min-block-size: 10.1rem; min-block-size: 10.1rem;
} }
.chart-stage {
block-size: clamp(5.7rem, 7.6vw, 6.9rem);
}
} }
@media (max-height: 900px) { @media (max-height: 900px) {
@@ -452,6 +456,21 @@
inline-size: 100%; inline-size: 100%;
aspect-ratio: 1.7 / 1; aspect-ratio: 1.7 / 1;
min-block-size: 0; min-block-size: 0;
background: linear-gradient(160deg, rgb(var(--hud-surface-alt-rgb) / 0.86) 0%, rgb(var(--hud-surface-deep-rgb) / 0.9) 100%);
box-shadow: inset 0 0 0 1px rgb(var(--hud-border-strong-rgb) / 0.08), 0 0 8px rgb(var(--hud-glow-rgb) / 0.1);
}
.series-line {
filter: none;
}
.scan-haze {
display: none;
}
.chart-stage {
background: linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.78), rgb(var(--hud-surface-deep-rgb) / 0.88));
box-shadow: none;
} }
} }
</style> </style>

View File

@@ -637,6 +637,20 @@
@media (max-width: 900px) { @media (max-width: 900px) {
.signal-panel { .signal-panel {
inline-size: 100%; inline-size: 100%;
background: linear-gradient(160deg, rgb(var(--hud-surface-alt-rgb) / 0.86) 0%, rgb(var(--hud-surface-deep-rgb) / 0.9) 100%);
box-shadow: inset 0 0 0 1px rgb(var(--hud-border-strong-rgb) / 0.08), 0 0 8px rgb(var(--hud-glow-rgb) / 0.1);
}
.summary-line {
filter: none;
}
.summary-dot {
filter: none;
}
.chart-stage {
background: linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.78), rgb(var(--hud-surface-deep-rgb) / 0.88));
} }
} }
</style> </style>

View File

@@ -44,6 +44,29 @@
dtsMs: number; dtsMs: number;
} }
interface AndroidUsbSerialDevice {
name: string;
vendorId: number;
productId: number;
manufacturer: string;
product: string;
serial: string;
hasPermission: boolean;
}
interface AndroidUsbSerialListResult {
devices: AndroidUsbSerialDevice[];
}
interface AndroidUsbSerialOpenResult {
fd: number;
name: string;
vendorId: number;
productId: number;
}
type AndroidUsbSerialCommand = "list" | "open" | "close";
const copyByLocale: Record<LocaleCode, HudCopy> = { const copyByLocale: Record<LocaleCode, HudCopy> = {
"zh-CN": { "zh-CN": {
appName: "JE-Skin", appName: "JE-Skin",
@@ -203,6 +226,7 @@
let connectionState: ConnectionState = "offline"; let connectionState: ConnectionState = "offline";
let serialPortValue = "COM14"; let serialPortValue = "COM14";
let serialPortOptions = ["COM4", "COM9", "COM14", "COM16"]; let serialPortOptions = ["COM4", "COM9", "COM14", "COM16"];
let androidUsbSerialDevices: AndroidUsbSerialDevice[] = [];
let isRefreshingPorts = false; let isRefreshingPorts = false;
let connectionNotice = ""; let connectionNotice = "";
let connectionNoticeTone: HudNoticeTone = "info"; let connectionNoticeTone: HudNoticeTone = "info";
@@ -287,6 +311,66 @@
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
} }
function isAndroidRuntime(): boolean {
if (!isTauriRuntime() || typeof navigator === "undefined") {
return false;
}
return /Android/i.test(navigator.userAgent);
}
function formatAndroidUsbSerialLabel(device: AndroidUsbSerialDevice): string {
const product = device.product || device.manufacturer || "USB Serial";
const ids = `${device.vendorId.toString(16).padStart(4, "0")}:${device.productId
.toString(16)
.padStart(4, "0")}`;
return `${product} (${ids})`;
}
function findAndroidUsbSerialDevice(name: string): AndroidUsbSerialDevice | null {
return androidUsbSerialDevices.find((device) => device.name === name) ?? null;
}
function normalizeAndroidUsbSerialDevices(value: unknown): AndroidUsbSerialDevice[] {
if (Array.isArray(value)) {
return value.filter((device): device is AndroidUsbSerialDevice => {
return typeof device === "object" && device !== null && typeof (device as AndroidUsbSerialDevice).name === "string";
});
}
if (typeof value === "object" && value !== null) {
return Object.values(value).filter((device): device is AndroidUsbSerialDevice => {
return typeof device === "object" && device !== null && typeof (device as AndroidUsbSerialDevice).name === "string";
});
}
return [];
}
async function invokeAndroidUsbSerial<T>(
command: AndroidUsbSerialCommand,
args?: Record<string, unknown>
): Promise<T> {
const commandNames: Record<AndroidUsbSerialCommand, [string, string]> = {
list: ["usb_serial_list", "usbSerialList"],
open: ["usb_serial_open", "usbSerialOpen"],
close: ["usb_serial_close", "usbSerialClose"]
};
const [primary, fallback] = commandNames[command];
try {
return await invoke<T>(`plugin:usb-serial|${primary}`, args);
} catch (error) {
const message = String(error);
if (!message.includes("No command")) {
throw error;
}
return await invoke<T>(`plugin:usb-serial|${fallback}`, args);
}
}
function clamp(value: number, min: number, max: number): number { function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
@@ -1221,6 +1305,10 @@
function handlePortChange(event: CustomEvent<string>): void { function handlePortChange(event: CustomEvent<string>): void {
serialPortValue = event.detail; serialPortValue = event.detail;
if (isAndroidRuntime()) {
const selectedDevice = findAndroidUsbSerialDevice(serialPortValue);
deviceValue = selectedDevice ? formatAndroidUsbSerialLabel(selectedDevice) : "Android USB Serial";
}
connectionState = "offline"; connectionState = "offline";
connectionNotice = ""; connectionNotice = "";
clearHudPanels(); clearHudPanels();
@@ -1256,7 +1344,7 @@
case "InvalidConfig": case "InvalidConfig":
return "当前串口配置无效,请重新选择端口。"; return "当前串口配置无效,请重新选择端口。";
default: default:
return "串口连接失败,请稍后重试。"; return `串口连接失败${errorCode}`;
} }
} }
@@ -1277,7 +1365,7 @@
case "InvalidConfig": case "InvalidConfig":
return "The selected serial port is invalid. Choose another port."; return "The selected serial port is invalid. Choose another port.";
default: default:
return "Connection failed. Please try again."; return `Connection failed: ${errorCode}`;
} }
} }
@@ -1288,14 +1376,16 @@
const errorCode = normalizeInvokeError(error); const errorCode = normalizeInvokeError(error);
if (locale === "zh-CN") { if (locale === "zh-CN") {
return errorCode === "ScanError" if (errorCode === "ScanError") {
? "串口列表刷新失败,请确认系统串口服务正常。" return "串口列表刷新失败,请确认系统串口服务正常。";
: "刷新串口列表失败,请稍后重试。"; }
return `刷新串口列表失败:${errorCode}`;
} }
return errorCode === "ScanError" return errorCode === "ScanError"
? "Refreshing serial ports failed. Check whether the OS serial service is available." ? "Refreshing serial ports failed. Check whether the OS serial service is available."
: "Refreshing serial ports failed. Please try again."; : `Refreshing serial ports failed: ${errorCode}`;
} }
function resolveExportNotice(error: unknown): string { function resolveExportNotice(error: unknown): string {
@@ -1350,6 +1440,33 @@
isRefreshingPorts = true; isRefreshingPorts = true;
try { try {
try {
const result = await invokeAndroidUsbSerial<AndroidUsbSerialListResult>("list");
androidUsbSerialDevices = normalizeAndroidUsbSerialDevices(result.devices);
serialPortOptions = androidUsbSerialDevices.map((device) => device.name);
if (serialPortOptions.includes(serialPortValue)) {
return;
}
serialPortValue = serialPortOptions[0] ?? "";
const selectedDevice = findAndroidUsbSerialDevice(serialPortValue);
deviceValue = selectedDevice ? formatAndroidUsbSerialLabel(selectedDevice) : "Android USB Serial";
if (!serialPortValue) {
connectionState = "offline";
clearHudPanels();
connectionNotice =
locale === "zh-CN" ? "未发现 USB 串口设备,请确认已通过 OTG 接入设备。" : "No USB serial device found.";
connectionNoticeTone = "warn";
}
return;
} catch (androidError) {
if (isAndroidRuntime()) {
throw androidError;
}
}
const ports = await invoke<string[]>("serial_enum"); const ports = await invoke<string[]>("serial_enum");
serialPortOptions = ports; serialPortOptions = ports;
@@ -1390,7 +1507,28 @@
connectionNotice = ""; connectionNotice = "";
try { try {
const result = await invoke<SerialConnectResult>("serial_connect", { port: event.detail }); let result: SerialConnectResult;
if (isAndroidRuntime()) {
const selectedDevice = findAndroidUsbSerialDevice(event.detail);
const opened = await invokeAndroidUsbSerial<AndroidUsbSerialOpenResult>("open", {
name: event.detail,
vendorId: selectedDevice?.vendorId,
productId: selectedDevice?.productId
});
result = await invoke<SerialConnectResult>("serial_connect_fd", {
fd: opened.fd,
deviceName: opened.name,
device_name: opened.name
});
const openedDevice = findAndroidUsbSerialDevice(opened.name) ?? selectedDevice;
deviceValue = openedDevice
? formatAndroidUsbSerialLabel(openedDevice)
: `USB Serial (${opened.vendorId.toString(16)}:${opened.productId.toString(16)})`;
} else {
result = await invoke<SerialConnectResult>("serial_connect", { port: event.detail });
}
connectionState = result.connected ? "online" : "offline"; connectionState = result.connected ? "online" : "offline";
serialPortValue = result.port; serialPortValue = result.port;
connectionNotice = ""; connectionNotice = "";
@@ -1398,6 +1536,13 @@
clearHudPanels(); clearHudPanels();
console.info("[serial] connect result:", result.message); console.info("[serial] connect result:", result.message);
} catch (error) { } catch (error) {
if (isAndroidRuntime()) {
try {
await invokeAndroidUsbSerial("close");
} catch (closeError) {
console.warn("USB serial close after failed connect failed:", closeError);
}
}
connectionState = "offline"; connectionState = "offline";
connectionNotice = resolveSerialNotice(error, "connect"); connectionNotice = resolveSerialNotice(error, "connect");
connectionNoticeTone = "warn"; connectionNoticeTone = "warn";
@@ -1409,6 +1554,9 @@
async function handleSerialDisconnect(): Promise<void> { async function handleSerialDisconnect(): Promise<void> {
try { try {
const result = await invoke<SerialConnectResult>("serial_disconnect"); const result = await invoke<SerialConnectResult>("serial_disconnect");
if (isAndroidRuntime()) {
await invokeAndroidUsbSerial("close");
}
connectionState = result.connected ? "online" : "offline"; connectionState = result.connected ? "online" : "offline";
connectionNotice = ""; connectionNotice = "";
connectionNoticeTone = "info"; connectionNoticeTone = "info";
@@ -2014,6 +2162,27 @@
mix-blend-mode: soft-light; mix-blend-mode: soft-light;
} }
@media (max-width: 900px) {
.hud-noise {
display: none;
}
.hud-vignette {
opacity: 0.4;
}
.hud-gradient {
background:
linear-gradient(170deg, var(--hud-bg-20) 0%, var(--hud-bg-10) 56%, var(--hud-bg-30) 100%);
}
.hud-layout {
gap: clamp(0.3rem, 1vw, 0.6rem);
padding: clamp(0.4rem, 1.2vw, 0.8rem);
box-shadow: inset 0 -12px 24px rgb(0 0 0 / 0.24);
}
}
.hud-layout { .hud-layout {
position: relative; position: relative;
z-index: 1; z-index: 1;