diff --git a/README.md b/README.md index b1f9349..b1cd17a 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ -# Tauri Demo (SvelteKit + TypeScript) +# JE-Skin (SvelteKit + Tauri) ## 环境要求 - Node.js 18+(建议 LTS) - Rust stable(`rustup` + `cargo`) - Windows 下请确保已安装 WebView2 Runtime 和 MSVC C++ 构建工具 +- Android 构建需要 Android SDK + NDK ## 安装依赖 @@ -42,12 +43,43 @@ npm run 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 npm run check ``` +## 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 插件 [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) diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts index a34a594..85ad388 100644 --- a/src-tauri/gen/android/app/build.gradle.kts +++ b/src-tauri/gen/android/app/build.gradle.kts @@ -43,6 +43,7 @@ android { .plus(getDefaultProguardFile("proguard-android-optimize.txt")) .toList().toTypedArray() ) + signingConfig = signingConfigs.getByName("debug") } } kotlinOptions { @@ -63,9 +64,10 @@ dependencies { implementation("androidx.activity:activity-ktx:1.10.1") implementation("com.google.android.material:material:1.12.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") androidTestImplementation("androidx.test.ext:junit:1.1.4") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") } -apply(from = "tauri.build.gradle.kts") \ No newline at end of file +apply(from = "tauri.build.gradle.kts") diff --git a/src/lib/components/HudPanel.svelte b/src/lib/components/HudPanel.svelte index ee9263d..8f05843 100644 --- a/src/lib/components/HudPanel.svelte +++ b/src/lib/components/HudPanel.svelte @@ -460,6 +460,12 @@ color: rgb(var(--hud-orange-rgb) / 0.96); } + @media (max-width: 900px) { + .window-controls { + display: none; + } + } + .control-bar { display: grid; gap: 0.45rem; diff --git a/src/lib/components/SignalChart.svelte b/src/lib/components/SignalChart.svelte index 484090a..db3576a 100644 --- a/src/lib/components/SignalChart.svelte +++ b/src/lib/components/SignalChart.svelte @@ -200,7 +200,6 @@ border-color: transparent; opacity: 0; transform: translateX(var(--offset-x)) scale(0.98) rotate(-0.6deg); - filter: blur(1.3px); pointer-events: none; transition-delay: 0ms; } @@ -302,6 +301,7 @@ stroke-linecap: round; stroke-linejoin: round; filter: drop-shadow(0 0 2px rgb(0 0 0 / 0.42)); + will-change: d; } .series-line.tone-cyan { @@ -397,6 +397,10 @@ aspect-ratio: 1.5 / 1; min-block-size: 10.1rem; } + + .chart-stage { + block-size: clamp(5.7rem, 7.6vw, 6.9rem); + } } @media (max-height: 900px) { @@ -452,6 +456,21 @@ inline-size: 100%; aspect-ratio: 1.7 / 1; 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; } } diff --git a/src/lib/components/SummaryCurve.svelte b/src/lib/components/SummaryCurve.svelte index 77a7d58..143e9e7 100644 --- a/src/lib/components/SummaryCurve.svelte +++ b/src/lib/components/SummaryCurve.svelte @@ -637,6 +637,20 @@ @media (max-width: 900px) { .signal-panel { 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)); } } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a9b8603..d179c4c 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -44,6 +44,29 @@ 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 = { "zh-CN": { appName: "JE-Skin", @@ -203,6 +226,7 @@ let connectionState: ConnectionState = "offline"; let serialPortValue = "COM14"; let serialPortOptions = ["COM4", "COM9", "COM14", "COM16"]; + let androidUsbSerialDevices: AndroidUsbSerialDevice[] = []; let isRefreshingPorts = false; let connectionNotice = ""; let connectionNoticeTone: HudNoticeTone = "info"; @@ -287,6 +311,66 @@ 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( + command: AndroidUsbSerialCommand, + args?: Record + ): Promise { + const commandNames: Record = { + list: ["usb_serial_list", "usbSerialList"], + open: ["usb_serial_open", "usbSerialOpen"], + close: ["usb_serial_close", "usbSerialClose"] + }; + + const [primary, fallback] = commandNames[command]; + + try { + return await invoke(`plugin:usb-serial|${primary}`, args); + } catch (error) { + const message = String(error); + if (!message.includes("No command")) { + throw error; + } + + return await invoke(`plugin:usb-serial|${fallback}`, args); + } + } + function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } @@ -1221,6 +1305,10 @@ function handlePortChange(event: CustomEvent): void { serialPortValue = event.detail; + if (isAndroidRuntime()) { + const selectedDevice = findAndroidUsbSerialDevice(serialPortValue); + deviceValue = selectedDevice ? formatAndroidUsbSerialLabel(selectedDevice) : "Android USB Serial"; + } connectionState = "offline"; connectionNotice = ""; clearHudPanels(); @@ -1256,7 +1344,7 @@ case "InvalidConfig": return "当前串口配置无效,请重新选择端口。"; default: - return "串口连接失败,请稍后重试。"; + return `串口连接失败:${errorCode}`; } } @@ -1277,7 +1365,7 @@ case "InvalidConfig": return "The selected serial port is invalid. Choose another port."; default: - return "Connection failed. Please try again."; + return `Connection failed: ${errorCode}`; } } @@ -1288,14 +1376,16 @@ const errorCode = normalizeInvokeError(error); if (locale === "zh-CN") { - return errorCode === "ScanError" - ? "串口列表刷新失败,请确认系统串口服务正常。" - : "刷新串口列表失败,请稍后重试。"; + if (errorCode === "ScanError") { + return "串口列表刷新失败,请确认系统串口服务正常。"; + } + + return `刷新串口列表失败:${errorCode}`; } return errorCode === "ScanError" ? "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 { @@ -1350,6 +1440,33 @@ isRefreshingPorts = true; try { + try { + const result = await invokeAndroidUsbSerial("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("serial_enum"); serialPortOptions = ports; @@ -1390,7 +1507,28 @@ connectionNotice = ""; try { - const result = await invoke("serial_connect", { port: event.detail }); + let result: SerialConnectResult; + + if (isAndroidRuntime()) { + const selectedDevice = findAndroidUsbSerialDevice(event.detail); + const opened = await invokeAndroidUsbSerial("open", { + name: event.detail, + vendorId: selectedDevice?.vendorId, + productId: selectedDevice?.productId + }); + result = await invoke("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("serial_connect", { port: event.detail }); + } + connectionState = result.connected ? "online" : "offline"; serialPortValue = result.port; connectionNotice = ""; @@ -1398,6 +1536,13 @@ clearHudPanels(); console.info("[serial] connect result:", result.message); } catch (error) { + if (isAndroidRuntime()) { + try { + await invokeAndroidUsbSerial("close"); + } catch (closeError) { + console.warn("USB serial close after failed connect failed:", closeError); + } + } connectionState = "offline"; connectionNotice = resolveSerialNotice(error, "connect"); connectionNoticeTone = "warn"; @@ -1409,6 +1554,9 @@ async function handleSerialDisconnect(): Promise { try { const result = await invoke("serial_disconnect"); + if (isAndroidRuntime()) { + await invokeAndroidUsbSerial("close"); + } connectionState = result.connected ? "online" : "offline"; connectionNotice = ""; connectionNoticeTone = "info"; @@ -2014,6 +2162,27 @@ 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 { position: relative; z-index: 1;