diff --git a/devkit/__pycache__/sensor_stream_pb2.cpython-310.pyc b/devkit/__pycache__/sensor_stream_pb2.cpython-310.pyc new file mode 100644 index 0000000..e7e986d Binary files /dev/null and b/devkit/__pycache__/sensor_stream_pb2.cpython-310.pyc differ diff --git a/devkit/__pycache__/sensor_stream_pb2_grpc.cpython-310.pyc b/devkit/__pycache__/sensor_stream_pb2_grpc.cpython-310.pyc new file mode 100644 index 0000000..31742b2 Binary files /dev/null and b/devkit/__pycache__/sensor_stream_pb2_grpc.cpython-310.pyc differ diff --git a/devkit/build_server.bat b/devkit/build_server.bat new file mode 100644 index 0000000..10de7db --- /dev/null +++ b/devkit/build_server.bat @@ -0,0 +1,24 @@ +@echo off +REM ── JE-Skin DevKit: 打包 Python gRPC server 为 exe ── +REM 前提: pip install pyinstaller grpcio grpcio-tools openpyxl + +echo [1/3] Generating gRPC stubs... +python -m grpc_tools.protoc ^ + -I../src-tauri/proto ^ + --python_out=. ^ + --grpc_python_out=. ^ + ../src-tauri/proto/sensor_stream.proto + +echo [2/3] Building exe with PyInstaller... +pyinstaller ^ + --onefile ^ + --name je-skin-devkit-server ^ + --add-data "sensor_stream_pb2*.py;." ^ + --hidden-import grpc ^ + --hidden-import openpyxl ^ + --noconfirm ^ + sensor_server.py + +echo [3/3] Done! +echo Output: dist/je-skin-devkit-server.exe +pause \ No newline at end of file diff --git a/devkit/je-skin-devkit-server.spec b/devkit/je-skin-devkit-server.spec new file mode 100644 index 0000000..e12a2fa --- /dev/null +++ b/devkit/je-skin-devkit-server.spec @@ -0,0 +1,38 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['sensor_server.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=['grpc', 'openpyxl'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='je-skin-devkit-server', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/devkit/sensor_server.py b/devkit/sensor_server.py new file mode 100644 index 0000000..7a031fe --- /dev/null +++ b/devkit/sensor_server.py @@ -0,0 +1,323 @@ +""" +JE-Skin DevKit — Python gRPC Sensor Server + +提供两个服务: +1. SensorPush (streaming) — 接收实时传感器帧 +2. ExportProcessor (unary) — 处理导出的 CSV 文件:梯度过滤、xlsx 转换 + +安装依赖: + pip install grpcio grpcio-tools openpyxl + +生成 gRPC 代码: + python -m grpc_tools.protoc -I../src-tauri/proto --python_out=. --grpc_python_out=. ../src-tauri/proto/sensor_stream.proto + +启动: + python sensor_server.py [--port 50051] +""" + +from __future__ import annotations + +import argparse +import csv +import os +import signal +import statistics +import sys +import time +from concurrent import futures +from pathlib import Path + +import grpc +import sensor_stream_pb2 +import sensor_stream_pb2_grpc + +# ── 梯度过滤逻辑(来自用户的 main.py) ───────────────────────── + + +def load_rows(path: Path) -> list[list[str]]: + with path.open("r", encoding="utf-8-sig", newline="") as f: + return [row for row in csv.reader(f) if row] + + +def row_sum(row: list[str]) -> float: + return sum(float(v) for v in row[1:] if v.strip()) + + +def find_threshold(sum_values: list[float]) -> float: + if len(sum_values) < 2: + raise ValueError("At least two rows are required.") + sorted_v = sorted(sum_values) + idx = max( + range(len(sorted_v) - 1), + key=lambda i: sorted_v[i + 1] - sorted_v[i], + ) + return (sorted_v[idx] + sorted_v[idx + 1]) / 2.0 + + +def extract_press_groups( + rows: list[list[str]], sum_values: list[float], threshold: float +) -> tuple[list[list[str]], list[float]]: + filtered: list[list[str]] = [] + group_means: list[float] = [] + current_group: list[float] = [] + + for row, total in zip(rows, sum_values): + if total >= threshold: + filtered.append(row) + current_group.append(total) + continue + if current_group: + group_means.append(statistics.fmean(current_group)) + current_group = [] + + if current_group: + group_means.append(statistics.fmean(current_group)) + + return filtered, group_means + + +def write_csv(path: Path, rows: list[list[str]]) -> Path: + out = path.with_name(f"{path.stem}_filtered.csv") + with out.open("w", encoding="utf-8-sig", newline="") as f: + csv.writer(f).writerows(rows) + return out + + +def write_xlsx(path: Path, rows: list[list[str]], stats: dict) -> Path: + """将过滤后的数据和统计信息写入 xlsx""" + try: + import openpyxl + except ImportError: + raise RuntimeError("openpyxl is required for xlsx output. Install it with: pip install openpyxl") + + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + + wb = openpyxl.Workbook() + + # Sheet 1: 过滤后的数据 + ws_data = wb.active + ws_data.title = "Filtered Data" + for row in rows: + ws_data.append([float(c) if c.strip().replace(".", "").replace("-", "").isdigit() else c for c in row]) + + # Sheet 2: 统计信息 + ws_stats = wb.create_sheet("Statistics") + header_font = Font(bold=True, size=11) + header_fill = PatternFill(start_color="E0E0E0", end_color="E0E0E0", fill_type="solid") + + ws_stats.append(["Parameter", "Value"]) + ws_stats["A1"].font = header_font + ws_stats["A1"].fill = header_fill + ws_stats["B1"].font = header_font + ws_stats["B1"].fill = header_fill + + stats_rows = [ + ("Source File", stats.get("source_file", "")), + ("Total Rows", stats.get("rows_total", 0)), + ("Filtered Rows", stats.get("rows_kept", 0)), + ("Groups Used", stats.get("groups_used", 0)), + ("Mean Value", f"{stats.get('mean_value', 0):.3f}"), + ("Threshold", f"{stats.get('threshold', 0):.3f}"), + ("Process Time", stats.get("process_time", "")), + ] + for label, value in stats_rows: + ws_stats.append([label, value]) + + ws_stats.column_dimensions["A"].width = 18 + ws_stats.column_dimensions["B"].width = 30 + + out = path.with_name(f"{path.stem}_filtered.xlsx") + wb.save(str(out)) + return out + + +def process_csv(csv_path: str, save_as_xlsx: bool) -> dict: + """执行梯度过滤,返回结果统计""" + path = Path(csv_path) + if not path.is_file(): + raise FileNotFoundError(f"CSV file not found: {csv_path}") + + rows = load_rows(path) + if not rows: + raise ValueError("CSV file is empty.") + + sum_values = [row_sum(r) for r in rows] + threshold = find_threshold(sum_values) + filtered_rows, group_means = extract_press_groups(rows, sum_values, threshold) + + if not filtered_rows: + raise ValueError("No large press-down data was found.") + + overall_mean = statistics.fmean(group_means) + + stats = { + "source_file": path.name, + "rows_total": len(rows), + "rows_kept": len(filtered_rows), + "groups_used": len(group_means), + "mean_value": overall_mean, + "threshold": threshold, + "process_time": time.strftime("%Y-%m-%d %H:%M:%S"), + } + + if save_as_xlsx: + output_path = write_xlsx(path, filtered_rows, stats) + # 删除源 CSV + try: + path.unlink() + except OSError: + pass + else: + output_path = write_csv(path, filtered_rows) + # 用过滤后的文件替换源文件 + try: + path.unlink() + output_path.rename(path) + output_path = path + except OSError: + pass + + # 追加一行到汇总 xlsx + _append_analysis_log(csv_path, stats) + + return { + "ok": True, + "output_path": str(output_path), + "groups_used": len(group_means), + "mean_value": overall_mean, + "threshold": threshold, + "rows_total": len(rows), + "rows_kept": len(filtered_rows), + "message": "OK", + } + + +def _append_analysis_log(source_csv: str, stats: dict): + """将处理结果追加到 devkit_analysis_results.xlsx""" + try: + import openpyxl + except ImportError: + return # openpyxl 不可用时跳过 + + log_path = Path(source_csv).parent / "devkit_analysis_results.xlsx" + + if log_path.exists(): + wb = openpyxl.load_workbook(str(log_path)) + ws = wb.active + else: + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Analysis Log" + ws.append(["Time", "Source File", "Total Rows", "Kept Rows", + "Groups", "Mean Value", "Threshold", "Output File"]) + + ws.append([ + stats.get("process_time", ""), + stats.get("source_file", ""), + stats.get("rows_total", 0), + stats.get("rows_kept", 0), + stats.get("groups_used", 0), + round(stats.get("mean_value", 0), 3), + round(stats.get("threshold", 0), 3), + f"{Path(stats.get('source_file', '')).stem}_filtered", + ]) + + wb.save(str(log_path)) + + +# ── gRPC 服务实现 ──────────────────────────────────────────────── + + +class SensorPushServicer(sensor_stream_pb2_grpc.SensorPushServicer): + """接收实时传感器帧(streaming)""" + + def __init__(self): + self.frame_count = 0 + self.last_report_time = time.time() + + def Upload(self, request_iterator, context): + print("[SensorPush] Client connected, waiting for frames...") + + for frame in request_iterator: + self.frame_count += 1 + if self.frame_count % 100 == 0: + now = time.time() + elapsed = now - self.last_report_time + fps = 100 / elapsed if elapsed > 0 else 0 + self.last_report_time = now + print( + f"[SensorPush] Frame #{frame.seq} | " + f"{frame.rows}x{frame.cols} | " + f"force={frame.resultant_force:.1f} | " + f"total={self.frame_count} | ~{fps:.1f} fps" + ) + + print(f"[SensorPush] Stream ended. Total: {self.frame_count}") + return sensor_stream_pb2.UploadResponse( + ok=True, + frames_received=self.frame_count, + message=f"Processed {self.frame_count} frames", + ) + + +class ExportProcessorServicer(sensor_stream_pb2_grpc.ExportProcessorServicer): + """处理导出的 CSV 文件(unary)""" + + def ProcessFile(self, request, context): + csv_path = request.csv_path + save_as_xlsx = request.save_as_xlsx + + print(f"[ExportProcessor] Processing: {csv_path} (xlsx={save_as_xlsx})") + + try: + result = process_csv(csv_path, save_as_xlsx) + return sensor_stream_pb2.ProcessResponse( + ok=result["ok"], + output_path=result["output_path"], + groups_used=result["groups_used"], + mean_value=result["mean_value"], + threshold=result["threshold"], + rows_total=result["rows_total"], + rows_kept=result["rows_kept"], + message=result["message"], + ) + except Exception as e: + print(f"[ExportProcessor] Error: {e}") + return sensor_stream_pb2.ProcessResponse( + ok=False, + output_path="", + message=str(e), + ) + + +# ── 启动 ──────────────────────────────────────────────────────── + + +def serve(port: int): + server = grpc.server(futures.ThreadPoolExecutor(max_workers=4)) + sensor_stream_pb2_grpc.add_SensorPushServicer_to_server(SensorPushServicer(), server) + sensor_stream_pb2_grpc.add_ExportProcessorServicer_to_server(ExportProcessorServicer(), server) + + listen_addr = f"0.0.0.0:{port}" + server.add_insecure_port(listen_addr) + server.start() + + print(f"[DevKit Server] gRPC listening on {listen_addr}") + print(f"[DevKit Server] Services: SensorPush (streaming), ExportProcessor (unary)") + + def shutdown(signum, frame): + print("\n[DevKit Server] Shutting down...") + server.stop(grace=5) + sys.exit(0) + + signal.signal(signal.SIGINT, shutdown) + signal.signal(signal.SIGTERM, shutdown) + + server.wait_for_termination() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="JE-Skin DevKit gRPC Server") + parser.add_argument("--port", type=int, default=50051, help="gRPC listen port (default: 50051)") + args = parser.parse_args() + serve(args.port) \ No newline at end of file diff --git a/devkit/sensor_stream_pb2.py b/devkit/sensor_stream_pb2.py new file mode 100644 index 0000000..92a4d8d --- /dev/null +++ b/devkit/sensor_stream_pb2.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: sensor_stream.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'sensor_stream.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13sensor_stream.proto\x12\rsensor_stream\"\x85\x01\n\x0bSensorFrame\x12\x0b\n\x03seq\x18\x01 \x01(\x04\x12\x14\n\x0ctimestamp_ms\x18\x02 \x01(\x04\x12\x0c\n\x04rows\x18\x03 \x01(\r\x12\x0c\n\x04\x63ols\x18\x04 \x01(\r\x12\x0e\n\x06matrix\x18\x05 \x03(\r\x12\x17\n\x0fresultant_force\x18\x06 \x01(\x01\x12\x0e\n\x06\x64ts_ms\x18\x07 \x01(\r\"F\n\x0eUploadResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x17\n\x0f\x66rames_received\x18\x02 \x01(\x04\x12\x0f\n\x07message\x18\x03 \x01(\t\"8\n\x0eProcessRequest\x12\x10\n\x08\x63sv_path\x18\x01 \x01(\t\x12\x14\n\x0csave_as_xlsx\x18\x02 \x01(\x08\"\xa6\x01\n\x0fProcessResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x13\n\x0boutput_path\x18\x02 \x01(\t\x12\x13\n\x0bgroups_used\x18\x03 \x01(\r\x12\x12\n\nmean_value\x18\x04 \x01(\x01\x12\x11\n\tthreshold\x18\x05 \x01(\x01\x12\x12\n\nrows_total\x18\x06 \x01(\r\x12\x11\n\trows_kept\x18\x07 \x01(\r\x12\x0f\n\x07message\x18\x08 \x01(\t2S\n\nSensorPush\x12\x45\n\x06Upload\x12\x1a.sensor_stream.SensorFrame\x1a\x1d.sensor_stream.UploadResponse(\x01\x32_\n\x0f\x45xportProcessor\x12L\n\x0bProcessFile\x12\x1d.sensor_stream.ProcessRequest\x1a\x1e.sensor_stream.ProcessResponseb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'sensor_stream_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_SENSORFRAME']._serialized_start=39 + _globals['_SENSORFRAME']._serialized_end=172 + _globals['_UPLOADRESPONSE']._serialized_start=174 + _globals['_UPLOADRESPONSE']._serialized_end=244 + _globals['_PROCESSREQUEST']._serialized_start=246 + _globals['_PROCESSREQUEST']._serialized_end=302 + _globals['_PROCESSRESPONSE']._serialized_start=305 + _globals['_PROCESSRESPONSE']._serialized_end=471 + _globals['_SENSORPUSH']._serialized_start=473 + _globals['_SENSORPUSH']._serialized_end=556 + _globals['_EXPORTPROCESSOR']._serialized_start=558 + _globals['_EXPORTPROCESSOR']._serialized_end=653 +# @@protoc_insertion_point(module_scope) diff --git a/devkit/sensor_stream_pb2_grpc.py b/devkit/sensor_stream_pb2_grpc.py new file mode 100644 index 0000000..fb4ef6a --- /dev/null +++ b/devkit/sensor_stream_pb2_grpc.py @@ -0,0 +1,175 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +import sensor_stream_pb2 as sensor__stream__pb2 + +GRPC_GENERATED_VERSION = '1.80.0' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in sensor_stream_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class SensorPushStub(object): + """传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧 + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Upload = channel.stream_unary( + '/sensor_stream.SensorPush/Upload', + request_serializer=sensor__stream__pb2.SensorFrame.SerializeToString, + response_deserializer=sensor__stream__pb2.UploadResponse.FromString, + _registered_method=True) + + +class SensorPushServicer(object): + """传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧 + """ + + def Upload(self, request_iterator, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_SensorPushServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Upload': grpc.stream_unary_rpc_method_handler( + servicer.Upload, + request_deserializer=sensor__stream__pb2.SensorFrame.FromString, + response_serializer=sensor__stream__pb2.UploadResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'sensor_stream.SensorPush', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('sensor_stream.SensorPush', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class SensorPush(object): + """传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧 + """ + + @staticmethod + def Upload(request_iterator, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.stream_unary( + request_iterator, + target, + '/sensor_stream.SensorPush/Upload', + sensor__stream__pb2.SensorFrame.SerializeToString, + sensor__stream__pb2.UploadResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + +class ExportProcessorStub(object): + """导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤 + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.ProcessFile = channel.unary_unary( + '/sensor_stream.ExportProcessor/ProcessFile', + request_serializer=sensor__stream__pb2.ProcessRequest.SerializeToString, + response_deserializer=sensor__stream__pb2.ProcessResponse.FromString, + _registered_method=True) + + +class ExportProcessorServicer(object): + """导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤 + """ + + def ProcessFile(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_ExportProcessorServicer_to_server(servicer, server): + rpc_method_handlers = { + 'ProcessFile': grpc.unary_unary_rpc_method_handler( + servicer.ProcessFile, + request_deserializer=sensor__stream__pb2.ProcessRequest.FromString, + response_serializer=sensor__stream__pb2.ProcessResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'sensor_stream.ExportProcessor', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('sensor_stream.ExportProcessor', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class ExportProcessor(object): + """导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤 + """ + + @staticmethod + def ProcessFile(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/sensor_stream.ExportProcessor/ProcessFile', + sensor__stream__pb2.ProcessRequest.SerializeToString, + sensor__stream__pb2.ProcessResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/package-lock.json b/package-lock.json index bdd86eb..765dc5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "three": "^0.183.2" }, "devDependencies": { @@ -1280,6 +1282,24 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-process": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", + "integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", + "integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, "node_modules/@tweenjs/tween.js": { "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", diff --git a/package.json b/package.json index c5d429d..d5fe997 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,16 @@ "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "tauri": "tauri" + "tauri": "tauri", + "tauri:devkit": "tauri dev -- --features devkit", + "tauri:devkit:build": "tauri build -- --features devkit" }, "license": "MIT", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "three": "^0.183.2" }, "devDependencies": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5e38141..257a588 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -7,21 +7,36 @@ name = "JE-Skin" version = "0.3.0" dependencies = [ "anyhow", + "async-stream", "async-trait", + "axum 0.8.9", "chrono", "crc", "csv", + "dirs", "fern", + "futures-util", "humantime", "log", + "prost", + "prost-types", + "protoc-bin-vendored", + "rand 0.8.5", + "reqwest 0.12.28", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-updater", "tokio", "tokio-serial", "tokio-util", + "tonic", + "tonic-build", + "tower-http", + "uuid", ] [[package]] @@ -69,6 +84,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -183,6 +207,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-task" version = "4.7.1" @@ -235,6 +281,108 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core 0.5.6", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -734,6 +882,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "deranged" version = "0.5.8" @@ -744,6 +898,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -906,6 +1071,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "embed-resource" version = "3.0.7" @@ -1037,12 +1208,29 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1354,8 +1542,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1365,9 +1555,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1531,6 +1723,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1637,6 +1848,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "humantime" version = "2.3.0" @@ -1653,9 +1870,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1664,6 +1883,35 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1681,7 +1929,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -1912,6 +2160,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2076,7 +2333,10 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ + "bitflags 2.11.0", "libc", + "plain", + "redox_syscall 0.7.4", ] [[package]] @@ -2106,6 +2366,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -2163,6 +2429,18 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -2184,6 +2462,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2240,6 +2524,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "ndk" version = "0.9.0" @@ -2412,6 +2702,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2427,6 +2718,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2483,6 +2786,12 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2499,6 +2808,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -2548,7 +2871,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -2565,6 +2888,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.13.0", +] + [[package]] name = "phf" version = "0.8.0" @@ -2752,6 +3085,26 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2781,6 +3134,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.8.0" @@ -2929,6 +3288,122 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck 0.5.0", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -2938,6 +3413,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2984,6 +3514,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -3004,6 +3544,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -3022,6 +3572,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -3055,6 +3614,15 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3115,6 +3683,44 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower 0.5.3", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -3129,17 +3735,22 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -3149,6 +3760,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3177,6 +3802,80 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3198,6 +3897,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3255,6 +3963,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -3368,6 +4099,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -3397,6 +4139,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.18.0" @@ -3487,6 +4241,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3544,6 +4309,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -3569,7 +4344,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -3663,6 +4438,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3778,6 +4559,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3814,7 +4606,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -3937,6 +4729,49 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.2", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.10.1" @@ -4152,6 +4987,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.50.0" @@ -4164,7 +5014,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -4180,6 +5030,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-serial" version = "5.4.5" @@ -4194,6 +5054,29 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4312,6 +5195,70 @@ version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum 0.7.9", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -4325,6 +5272,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -4340,7 +5288,7 @@ dependencies = [ "http-body", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", ] @@ -4363,6 +5311,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4416,6 +5365,22 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", +] + [[package]] name = "typeid" version = "1.0.3" @@ -4507,6 +5472,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4753,6 +5724,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.3" @@ -4809,6 +5790,24 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -5475,6 +6474,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.1" @@ -5600,6 +6609,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" @@ -5633,6 +6648,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.13.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4a0ef9b..4b74496 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,14 +14,26 @@ edition = "2021" name = "tauri_demo_lib" crate-type = ["staticlib", "cdylib", "rlib"] +[features] +default = [] +devkit = ["dep:tonic", "dep:prost", "dep:prost-types", "dep:async-stream", "dep:dirs"] + [build-dependencies] tauri-build = { version = "2", features = [] } +tonic-build = { version = "0.12" } +protoc-bin-vendored = "3" [dependencies] tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-opener = "2" +tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } anyhow = "1.0.102" +tonic = { version = "0.12", optional = true } +prost = { version = "0.13", optional = true } +prost-types = { version = "0.13", optional = true } +async-stream = { version = "0.3", optional = true } +dirs = { version = "6", optional = true } tokio-serial = { version = "5.4.5" } tokio = { version = "1.50.0", features = ["full"] } async-trait = "0.1.89" @@ -33,3 +45,12 @@ humantime = "2.3.0" csv = "1.4.0" chrono = "0.4.44" crc = "3.4.0" +axum = { version = "0.8", features = ["ws"] } +tower-http = { version = "0.6", features = ["cors"] } +futures-util = "0.3" +uuid = { version = "1", features = ["v4", "serde"] } +rand = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +tauri-plugin-updater = "2" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..6b6f486 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,11 @@ fn main() { + if std::env::var("CARGO_FEATURE_DEVKIT").is_ok() { + let protoc = protoc_bin_vendored::protoc_bin_path() + .unwrap_or_else(|error| panic!("Failed to resolve bundled protoc: {error}")); + std::env::set_var("PROTOC", protoc); + tonic_build::compile_protos("proto/sensor_stream.proto") + .unwrap_or_else(|error| panic!("Failed to compile devkit proto: {error}")); + } + tauri_build::build() } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4ccf441..7149512 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,6 +10,8 @@ "core:window:allow-inner-size", "core:window:allow-set-size", "core:window:allow-start-dragging", - "opener:default" + "opener:default", + "process:default", + "updater:default" ] } diff --git a/src-tauri/proto/sensor_stream.proto b/src-tauri/proto/sensor_stream.proto new file mode 100644 index 0000000..f1a6855 --- /dev/null +++ b/src-tauri/proto/sensor_stream.proto @@ -0,0 +1,49 @@ +syntax = "proto3"; + +package sensor_stream; + +// 传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧 +service SensorPush { + rpc Upload (stream SensorFrame) returns (UploadResponse); +} + +// 导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤 +service ExportProcessor { + rpc ProcessFile (ProcessRequest) returns (ProcessResponse); +} + +// 一帧传感器数据 +message SensorFrame { + uint64 seq = 1; + uint64 timestamp_ms = 2; + uint32 rows = 3; + uint32 cols = 4; + repeated uint32 matrix = 5; + double resultant_force = 6; + uint32 dts_ms = 7; +} + +// 上传确认响应 +message UploadResponse { + bool ok = 1; + uint64 frames_received = 2; + string message = 3; +} + +// 导出处理请求 +message ProcessRequest { + string csv_path = 1; // 导出的 CSV 文件路径 + bool save_as_xlsx = 2; // 是否以 xlsx 保存(删除源 CSV) +} + +// 导出处理响应 +message ProcessResponse { + bool ok = 1; + string output_path = 2; // 输出文件路径 + uint32 groups_used = 3; // 分组数 + double mean_value = 4; // 均值 + double threshold = 5; // 梯度阈值 + uint32 rows_total = 6; // 原始行数 + uint32 rows_kept = 7; // 保留行数 + string message = 8; +} \ No newline at end of file diff --git a/src-tauri/resources/je-skin-devkit-server.exe b/src-tauri/resources/je-skin-devkit-server.exe new file mode 100644 index 0000000..05ab567 Binary files /dev/null and b/src-tauri/resources/je-skin-devkit-server.exe differ diff --git a/src-tauri/src/commands/devkit.rs b/src-tauri/src/commands/devkit.rs new file mode 100644 index 0000000..45b4e4e --- /dev/null +++ b/src-tauri/src/commands/devkit.rs @@ -0,0 +1,47 @@ +//! DevKit Tauri 命令 +//! +//! 仅在 `devkit` feature 启用时编译。 + +use tauri::State; + +use crate::devkit::{DevKitConfig, DevKitState, DevKitStatusSnapshot, ExportProcessResult}; + +#[tauri::command] +pub fn devkit_status(state: State<'_, DevKitState>) -> DevKitStatusSnapshot { + state.status() +} + +#[tauri::command] +pub async fn devkit_start(state: State<'_, DevKitState>, port: Option) -> Result { + let target_port = port.unwrap_or(50051); + state.start(target_port).await?; + Ok(state.status()) +} + +#[tauri::command] +pub async fn devkit_stop(state: State<'_, DevKitState>) -> Result { + state.stop().await?; + Ok(state.status()) +} + +#[tauri::command] +pub fn devkit_get_config(state: State<'_, DevKitState>) -> DevKitConfig { + state.get_config() +} + +#[tauri::command] +pub fn devkit_set_config(state: State<'_, DevKitState>, config: DevKitConfig) -> Result { + state.set_config(config)?; + Ok(state.get_config()) +} + +#[tauri::command] +pub async fn devkit_process_export( + state: State<'_, DevKitState>, + csv_path: String, + save_as_xlsx: Option, +) -> Result { + let config = state.get_config(); + let use_xlsx = save_as_xlsx.unwrap_or(config.save_as_xlsx); + state.process_export(&csv_path, use_xlsx).await +} \ No newline at end of file diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index b5e49fa..1e6d9ae 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,6 @@ pub mod file_explorer; pub mod serial; pub mod window; + +#[cfg(feature = "devkit")] +pub mod devkit; diff --git a/src-tauri/src/devkit/client.rs b/src-tauri/src/devkit/client.rs new file mode 100644 index 0000000..f2cee57 --- /dev/null +++ b/src-tauri/src/devkit/client.rs @@ -0,0 +1,268 @@ +//! DevKit gRPC Client +//! +//! Rust 端作为 gRPC client: +//! 1. 以 client-streaming 方式推送实时帧(SensorPush.Upload) +//! 2. 以 unary 方式发送导出文件路径做后处理(ExportProcessor.ProcessFile) + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::Arc; + +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use serde::{Deserialize, Serialize}; + +use super::proto::sensor_push_client::SensorPushClient; +use super::proto::export_processor_client::ExportProcessorClient; +use super::proto::{ProcessRequest, SensorFrame}; + +// ── DevKit 配置 ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DevKitConfig { + /// 导出过滤抬起:导出 CSV 后自动调用 Python 做梯度过滤 + pub filter_lift_enabled: bool, + /// 以 xlsx 保存:Python 处理后输出 xlsx 并删除源 CSV + pub save_as_xlsx: bool, +} + +impl Default for DevKitConfig { + fn default() -> Self { + Self { + filter_lift_enabled: true, + save_as_xlsx: false, + } + } +} + +impl DevKitConfig { + fn config_path() -> PathBuf { + let base = dirs::config_dir() + .or_else(|| dirs::data_dir()) + .unwrap_or_else(|| PathBuf::from(".")); + base.join("JE-Skin").join("devkit_config.json") + } + + /// 从文件加载配置,失败则返回默认值 + pub fn load() -> Self { + let path = Self::config_path(); + match std::fs::read_to_string(&path) { + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), + Err(_) => Self::default(), + } + } + + /// 保存配置到文件 + pub fn save(&self) -> Result<(), String> { + let path = Self::config_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create config dir: {e}"))?; + } + let json = serde_json::to_string_pretty(self) + .map_err(|e| format!("Failed to serialize config: {e}"))?; + std::fs::write(&path, json) + .map_err(|e| format!("Failed to write config: {e}"))?; + Ok(()) + } +} + +// ── 导出处理结果 ─────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportProcessResult { + pub ok: bool, + pub output_path: String, + pub groups_used: u32, + pub mean_value: f64, + pub threshold: f64, + pub rows_total: u32, + pub rows_kept: u32, + pub message: String, +} + +// ── Tauri 状态 ───────────────────────────────────────────────────── + +/// DevKit 全局状态,由 Tauri manage +#[derive(Clone)] +pub struct DevKitState { + pub running: Arc, + pub port: Arc>, + pub frame_count: Arc, + pub config: Arc>, + frame_tx: Arc>>>, + client_handle: Arc>>>, +} + +impl Default for DevKitState { + fn default() -> Self { + Self { + running: Arc::new(AtomicBool::new(false)), + port: Arc::new(std::sync::Mutex::new(50051)), + frame_count: Arc::new(AtomicU32::new(0)), + config: Arc::new(std::sync::Mutex::new(DevKitConfig::load())), + frame_tx: Arc::new(std::sync::Mutex::new(None)), + client_handle: Arc::new(std::sync::Mutex::new(None)), + } + } +} + +/// 前端查询到的状态快照 +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DevKitStatusSnapshot { + pub enabled: bool, + pub running: bool, + pub port: u16, + pub frames_sent: u32, + pub config: DevKitConfig, +} + +impl DevKitState { + pub fn status(&self) -> DevKitStatusSnapshot { + let cfg = self.config.lock().unwrap().clone(); + DevKitStatusSnapshot { + enabled: true, + running: self.running.load(Ordering::SeqCst), + port: *self.port.lock().unwrap(), + frames_sent: self.frame_count.load(Ordering::SeqCst), + config: cfg, + } + } + + /// 获取当前配置 + pub fn get_config(&self) -> DevKitConfig { + self.config.lock().unwrap().clone() + } + + /// 更新配置并持久化 + pub fn set_config(&self, new_config: DevKitConfig) -> Result<(), String> { + new_config.save()?; + *self.config.lock().unwrap() = new_config; + Ok(()) + } + + /// 启动 gRPC client,连接到 Python server 并开始推送数据 + pub async fn start(&self, port: u16) -> Result<(), String> { + if self.running.load(Ordering::SeqCst) { + return Err("AlreadyRunning".into()); + } + + let addr = format!("http://127.0.0.1:{port}"); + *self.port.lock().unwrap() = port; + self.running.store(true, Ordering::SeqCst); + self.frame_count.store(0, Ordering::SeqCst); + + // mpsc channel: 主线程 send 帧 → gRPC task 推送给 Python + let (tx, rx) = mpsc::channel::(512); + *self.frame_tx.lock().unwrap() = Some(tx); + + let running = Arc::clone(&self.running); + let frame_count = Arc::clone(&self.frame_count); + + let handle = tokio::spawn(async move { + if let Err(e) = run_grpc_upload(addr, rx, frame_count).await { + ::log::error!("DevKit gRPC upload error: {e:?}"); + } + running.store(false, Ordering::SeqCst); + }); + + *self.client_handle.lock().unwrap() = Some(handle); + ::log::info!("DevKit gRPC client started, connecting to 127.0.0.1:{port}"); + Ok(()) + } + + /// 停止 gRPC client + pub async fn stop(&self) -> Result<(), String> { + if !self.running.load(Ordering::SeqCst) { + return Err("NotRunning".into()); + } + + *self.frame_tx.lock().unwrap() = None; + + if let Some(handle) = self.client_handle.lock().unwrap().take() { + handle.abort(); + } + + self.running.store(false, Ordering::SeqCst); + ::log::info!("DevKit gRPC client stopped"); + Ok(()) + } + + /// 推送一帧数据到 gRPC stream(由主线程调用) + pub fn push_frame(&self, frame: SensorFrame) { + if !self.running.load(Ordering::SeqCst) { + return; + } + if let Some(tx) = self.frame_tx.lock().unwrap().as_ref() { + let _ = tx.try_send(frame); + } + } + + /// 调用 Python ExportProcessor.ProcessFile 做导出后处理(unary) + pub async fn process_export( + &self, + csv_path: &str, + save_as_xlsx: bool, + ) -> Result { + let port = *self.port.lock().unwrap(); + let addr = format!("http://127.0.0.1:{port}"); + + let mut client = ExportProcessorClient::connect(addr) + .await + .map_err(|e| format!("Failed to connect to DevKit server: {e}"))?; + + let request = ProcessRequest { + csv_path: csv_path.to_string(), + save_as_xlsx, + }; + + let response = client + .process_file(request) + .await + .map_err(|e| format!("ProcessFile RPC failed: {e}"))?; + + let resp = response.into_inner(); + Ok(ExportProcessResult { + ok: resp.ok, + output_path: resp.output_path, + groups_used: resp.groups_used, + mean_value: resp.mean_value, + threshold: resp.threshold, + rows_total: resp.rows_total, + rows_kept: resp.rows_kept, + message: resp.message, + }) + } +} + +// ── gRPC Upload Client ───────────────────────────────────────────── + +async fn run_grpc_upload( + addr: String, + mut rx: mpsc::Receiver, + frame_count: Arc, +) -> Result<(), Box> { + let mut client = SensorPushClient::connect(addr.clone()).await?; + + let stream = async_stream::stream! { + while let Some(frame) = rx.recv().await { + frame_count.fetch_add(1, Ordering::SeqCst); + yield frame; + } + }; + + let response = client.upload(stream).await?; + let resp = response.into_inner(); + + ::log::info!( + "DevKit upload complete: ok={}, frames={}, msg={}", + resp.ok, + resp.frames_received, + resp.message + ); + + Ok(()) +} \ No newline at end of file diff --git a/src-tauri/src/devkit/mod.rs b/src-tauri/src/devkit/mod.rs new file mode 100644 index 0000000..0115e7b --- /dev/null +++ b/src-tauri/src/devkit/mod.rs @@ -0,0 +1,13 @@ +//! Develop Kit 模块 +//! +//! 仅在 `devkit` feature 启用时编译。 +//! Rust 端作为 gRPC client,将传感器压力矩阵数据实时推送给 Python gRPC server。 + +mod client; + +pub use client::{DevKitConfig, DevKitState, DevKitStatusSnapshot, ExportProcessResult}; + +// 导入 tonic 生成的 gRPC 代码 +pub mod proto { + tonic::include_proto!("sensor_stream"); +} \ No newline at end of file diff --git a/src-tauri/src/lan_game.rs b/src-tauri/src/lan_game.rs new file mode 100644 index 0000000..e1a1ac6 --- /dev/null +++ b/src-tauri/src/lan_game.rs @@ -0,0 +1,1250 @@ +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + State, + }, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use futures_util::{SinkExt, StreamExt}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + net::{IpAddr, Ipv4Addr, SocketAddr}, + sync::Arc, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use tokio::{ + net::{TcpListener, UdpSocket}, + sync::{mpsc, RwLock}, + time, +}; +use tower_http::cors::CorsLayer; +use uuid::Uuid; + +const HTTP_PORT: u16 = 47888; +const DISCOVERY_PORT: u16 = 47889; +const PROTOCOL_VERSION: u8 = 1; +const MAGIC: &str = "JE_SKIN_LAN_GAME_V1"; +const TICK_RATE: u64 = 30; + +const FIELD_HALF_W: f32 = 46.0; +const FIELD_HALF_H: f32 = 62.0; +const PADDLE_Y: f32 = -53.0; +const PADDLE_W: f32 = 16.0; +const PADDLE_H: f32 = 2.6; +const PADDLE_SPEED: f32 = 74.0; +const BALL_RADIUS: f32 = 1.45; +const BASE_BALL_SPEED: f32 = 58.0; + +const BRICK_COLS: usize = 12; +const BRICK_ROWS: usize = 7; +const BRICK_W: f32 = 6.2; +const BRICK_H: f32 = 2.9; +const BRICK_GAP_X: f32 = 0.8; +const BRICK_GAP_Y: f32 = 0.9; +const BRICK_TOP: f32 = 44.0; + +#[derive(Clone)] +struct AppState { + inner: Arc>, + lan_ip: IpAddr, +} + +struct Inner { + room: Option, + discovered: HashMap, +} + +struct Room { + room_id: String, + pairing_code: String, + players: HashMap, + tickets: HashMap, + phase: GamePhase, + game: GameState, +} + +struct Player { + id: String, + name: String, + role: PlayerRole, + ready: bool, + wants_restart: bool, + connected: bool, + tx: Option>, + input: PlayerInput, + prev_pause: bool, + paddle_x: f32, + score: i32, + lives: i32, +} + +#[derive(Default, Clone)] +struct PlayerInput { + axis: f32, + launch: bool, + pause: bool, +} + +struct GameState { + tick: u64, + ball_x: f32, + ball_y: f32, + ball_vx: f32, + ball_vy: f32, + ball_launched: bool, + bricks: Vec, + last_pause_toggle: Instant, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum GamePhase { + Lobby, + Running, + Paused, + Finished, +} + +#[derive(Clone)] +struct DiscoveredRoom { + room: LanRoom, + http_url: String, + seen_at: Instant, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LanRoom { + room_id: String, + pairing_code: String, + host_name: String, + address: String, + port: u16, + players: usize, + max_players: usize, + protocol_version: u8, + app_version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum PlayerRole { + Host, + Guest, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateRoomRequest { + player_name: String, + protocol_version: u8, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct JoinRoomRequest { + pairing_code: String, + player_name: String, + protocol_version: u8, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct JoinRoomResponse { + room_id: String, + player_id: String, + ticket: String, + ws_url: String, + pairing_code: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RoomsResponse { + rooms: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Beacon { + magic: String, + protocol_version: u8, + room_id: String, + pairing_code: String, + host_name: String, + http_url: String, + players: usize, + max_players: usize, + app_version: String, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +enum ClientMessage { + Hello { + protocol_version: u8, + room_id: String, + player_id: String, + ticket: String, + player_name: String, + }, + Ready { + ready: bool, + }, + Restart, + Input { + seq: u64, + client_time: f64, + axis: f32, + launch: bool, + pause: bool, + }, + Ping { + client_time: f64, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +enum ServerMessage { + Welcome { + room_id: String, + player_id: String, + role: PlayerRole, + tick_rate: u32, + seed: u64, + }, + Lobby { + players: Vec, + }, + Snapshot { + tick: u64, + server_time: f64, + phase: String, + players: Vec, + ball: BallState, + bricks: String, + }, + Pong { + client_time: f64, + server_time: f64, + }, + Error { + code: String, + message: String, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct LobbyPlayer { + id: String, + name: String, + ready: bool, + role: PlayerRole, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct SnapshotPlayer { + id: String, + name: String, + role: PlayerRole, + ready: bool, + paddle_x: f32, + score: i32, + lives: i32, +} + +#[derive(Debug, Clone, Serialize)] +struct BallState { + x: f32, + y: f32, + vx: f32, + vy: f32, +} + +#[derive(Serialize)] +struct ErrorBody { + message: String, +} + +struct ApiError { + status: StatusCode, + message: String, +} + +impl ApiError { + fn bad_request(message: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: message.into(), + } + } + + fn not_found(message: impl Into) -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: message.into(), + } + } + + fn upstream(message: impl Into) -> Self { + Self { + status: StatusCode::BAD_GATEWAY, + message: message.into(), + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + ( + self.status, + Json(ErrorBody { + message: self.message, + }), + ) + .into_response() + } +} + +pub async fn serve() -> anyhow::Result<()> { + let state = AppState { + inner: Arc::new(RwLock::new(Inner { + room: None, + discovered: HashMap::new(), + })), + lan_ip: guess_lan_ip(), + }; + + tokio::spawn(discovery_recv(state.clone())); + tokio::spawn(discovery_broadcast(state.clone())); + tokio::spawn(game_loop(state.clone())); + + let app = Router::new() + .route("/lan/rooms", get(list_rooms).post(create_room)) + .route("/lan/join", post(join_room)) + .route("/game", get(ws_handler)) + .layer(CorsLayer::permissive()) + .with_state(state); + + let listener = TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], HTTP_PORT))).await?; + log::info!("LAN game server listening on 0.0.0.0:{HTTP_PORT}"); + axum::serve(listener, app).await?; + Ok(()) +} + +async fn list_rooms(State(state): State) -> Json { + let mut inner = state.inner.write().await; + inner + .discovered + .retain(|_, room| room.seen_at.elapsed() < Duration::from_secs(4)); + + Json(RoomsResponse { + rooms: inner + .discovered + .values() + .map(|room| room.room.clone()) + .collect(), + }) +} + +async fn create_room( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + ensure_protocol(req.protocol_version)?; + + let room_id = Uuid::new_v4().to_string(); + let player_id = Uuid::new_v4().to_string(); + let ticket = Uuid::new_v4().to_string(); + let pairing_code = format!("{:06}", rand::thread_rng().gen_range(0..1_000_000)); + + let mut players = HashMap::new(); + players.insert( + player_id.clone(), + Player { + id: player_id.clone(), + name: sanitize_name(&req.player_name), + role: PlayerRole::Host, + ready: false, + wants_restart: false, + connected: false, + tx: None, + input: PlayerInput::default(), + prev_pause: false, + paddle_x: 0.0, + score: 0, + lives: 3, + }, + ); + + let mut tickets = HashMap::new(); + tickets.insert(player_id.clone(), ticket.clone()); + + let room = Room { + room_id: room_id.clone(), + pairing_code: pairing_code.clone(), + players, + tickets, + phase: GamePhase::Lobby, + game: GameState::new(), + }; + + state.inner.write().await.room = Some(room); + + Ok(Json(JoinRoomResponse { + room_id, + player_id, + ticket, + ws_url: format!("ws://{}:{HTTP_PORT}/game", state.lan_ip), + pairing_code: Some(pairing_code), + })) +} + +async fn join_room( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + ensure_protocol(req.protocol_version)?; + + if let Some(response) = try_join_local_room(&state, &req).await? { + return Ok(Json(response)); + } + + let remote = { + let inner = state.inner.read().await; + inner.discovered.get(&req.pairing_code).cloned() + } + .ok_or_else(|| ApiError::not_found("pairing code not found"))?; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(3)) + .build() + .map_err(|error| ApiError::upstream(error.to_string()))?; + + let response = client + .post(format!("{}/lan/join", remote.http_url)) + .json(&req) + .send() + .await + .map_err(|error| ApiError::upstream(error.to_string()))?; + + if !response.status().is_success() { + return Err(ApiError::upstream(format!( + "remote join failed: {}", + response.status() + ))); + } + + let body = response + .json::() + .await + .map_err(|error| ApiError::upstream(error.to_string()))?; + + Ok(Json(body)) +} + +async fn try_join_local_room( + state: &AppState, + req: &JoinRoomRequest, +) -> Result, ApiError> { + let mut inner = state.inner.write().await; + let Some(room) = inner.room.as_mut() else { + return Ok(None); + }; + + if room.pairing_code != req.pairing_code { + return Ok(None); + } + + if room.players.len() >= 2 { + return Err(ApiError::bad_request("room is full")); + } + + let player_id = Uuid::new_v4().to_string(); + let ticket = Uuid::new_v4().to_string(); + + room.players.insert( + player_id.clone(), + Player { + id: player_id.clone(), + name: sanitize_name(&req.player_name), + role: PlayerRole::Guest, + ready: false, + wants_restart: false, + connected: false, + tx: None, + input: PlayerInput::default(), + prev_pause: false, + paddle_x: 0.0, + score: 0, + lives: 3, + }, + ); + room.tickets.insert(player_id.clone(), ticket.clone()); + + Ok(Some(JoinRoomResponse { + room_id: room.room_id.clone(), + player_id, + ticket, + ws_url: format!("ws://{}:{HTTP_PORT}/game", state.lan_ip), + pairing_code: Some(room.pairing_code.clone()), + })) +} + +async fn ws_handler(ws: WebSocketUpgrade, State(state): State) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket, state)) +} + +async fn handle_socket(mut socket: WebSocket, state: AppState) { + let Some(Ok(Message::Text(text))) = socket.recv().await else { + return; + }; + + let Ok(ClientMessage::Hello { + protocol_version, + room_id, + player_id, + ticket, + player_name, + }) = serde_json::from_str::(&text) + else { + return; + }; + + if protocol_version != PROTOCOL_VERSION { + send_socket_error( + &mut socket, + "ProtocolMismatch", + "protocol version mismatch", + ) + .await; + return; + } + + let (tx, mut rx) = mpsc::unbounded_channel::(); + + let role = { + let mut inner = state.inner.write().await; + let Some(room) = inner.room.as_mut() else { + return; + }; + + if room.room_id != room_id || room.tickets.get(&player_id) != Some(&ticket) { + return; + } + + let Some(player) = room.players.get_mut(&player_id) else { + return; + }; + + player.name = sanitize_name(&player_name); + player.connected = true; + player.tx = Some(tx.clone()); + player.role.clone() + }; + + let _ = tx.send(ServerMessage::Welcome { + room_id: room_id.clone(), + player_id: player_id.clone(), + role, + tick_rate: TICK_RATE as u32, + seed: 1, + }); + + broadcast_lobby(&state).await; + + let (mut sender, mut receiver) = socket.split(); + + tokio::spawn(async move { + while let Some(message) = rx.recv().await { + let Ok(text) = serde_json::to_string(&message) else { + continue; + }; + + if sender.send(Message::Text(text.into())).await.is_err() { + break; + } + } + }); + + while let Some(Ok(message)) = receiver.next().await { + let Message::Text(text) = message else { + continue; + }; + + let Ok(message) = serde_json::from_str::(&text) else { + continue; + }; + + match message { + ClientMessage::Ready { ready } => { + set_player_ready(&state, &player_id, ready).await; + broadcast_lobby(&state).await; + } + ClientMessage::Restart => { + set_player_wants_restart(&state, &player_id).await; + broadcast_lobby(&state).await; + } + ClientMessage::Input { + seq, + client_time, + axis, + launch, + pause, + } => { + let _input_timing = (seq, client_time); + set_player_input(&state, &player_id, axis, launch, pause).await; + } + ClientMessage::Ping { client_time } => { + let _ = tx.send(ServerMessage::Pong { + client_time, + server_time: now_ms(), + }); + } + ClientMessage::Hello { .. } => {} + } + } + + detach_player(&state, &player_id).await; + broadcast_lobby(&state).await; +} + +async fn send_socket_error(socket: &mut WebSocket, code: &str, message: &str) { + let message = ServerMessage::Error { + code: code.to_string(), + message: message.to_string(), + }; + + if let Ok(text) = serde_json::to_string(&message) { + let _ = socket.send(Message::Text(text.into())).await; + } +} + +async fn set_player_ready(state: &AppState, player_id: &str, ready: bool) { + let mut inner = state.inner.write().await; + if let Some(room) = inner.room.as_mut() { + if let Some(player) = room.players.get_mut(player_id) { + player.ready = ready; + } + } +} + +async fn set_player_wants_restart(state: &AppState, player_id: &str) { + let mut inner = state.inner.write().await; + if let Some(room) = inner.room.as_mut() { + if let Some(player) = room.players.get_mut(player_id) { + player.wants_restart = true; + player.ready = false; + } + } +} + +async fn set_player_input( + state: &AppState, + player_id: &str, + axis: f32, + launch: bool, + pause: bool, +) { + let mut inner = state.inner.write().await; + if let Some(room) = inner.room.as_mut() { + if let Some(player) = room.players.get_mut(player_id) { + player.input.axis = axis.clamp(-1.0, 1.0); + player.input.launch = launch; + player.input.pause = pause; + } + } +} + +async fn detach_player(state: &AppState, player_id: &str) { + let mut inner = state.inner.write().await; + if let Some(room) = inner.room.as_mut() { + if let Some(player) = room.players.get_mut(player_id) { + player.connected = false; + player.ready = false; + player.wants_restart = false; + player.tx = None; + } + + let remove_guest = room + .players + .get(player_id) + .map(|player| player.role == PlayerRole::Guest) + .unwrap_or(false); + + if remove_guest { + room.players.remove(player_id); + room.tickets.remove(player_id); + } + + if room.players.values().filter(|player| player.connected).count() < 2 { + room.phase = GamePhase::Lobby; + } + } +} + +async fn broadcast_lobby(state: &AppState) { + let message = { + let inner = state.inner.read().await; + let Some(room) = inner.room.as_ref() else { + return; + }; + + ServerMessage::Lobby { + players: room + .players + .values() + .map(|player| LobbyPlayer { + id: player.id.clone(), + name: player.name.clone(), + ready: player.ready, + role: player.role.clone(), + }) + .collect(), + } + }; + + broadcast(state, message).await; +} + +async fn broadcast(state: &AppState, message: ServerMessage) { + let senders = { + let inner = state.inner.read().await; + let Some(room) = inner.room.as_ref() else { + return; + }; + + room.players + .values() + .filter_map(|player| player.tx.clone()) + .collect::>() + }; + + for tx in senders { + let _ = tx.send(message.clone()); + } +} + +async fn game_loop(state: AppState) { + let mut ticker = time::interval(Duration::from_millis(1000 / TICK_RATE)); + + loop { + ticker.tick().await; + + let should_broadcast_lobby = { + let mut inner = state.inner.write().await; + let Some(room) = inner.room.as_mut() else { + continue; + }; + + let snapshot = tick_room(room, 1.0 / TICK_RATE as f32); + + if let Some(snapshot) = snapshot { + // Broadcast snapshot below (need to drop read lock first) + // We'll collect the senders here + let senders: Vec<_> = room + .players + .values() + .filter_map(|player| player.tx.clone()) + .collect(); + + let snapshot_msg = snapshot; + drop(inner); + + for tx in senders { + let _ = tx.send(snapshot_msg.clone()); + } + } + + false + }; + + if should_broadcast_lobby { + broadcast_lobby(&state).await; + } + } +} + +fn tick_room(room: &mut Room, dt: f32) -> Option { + // Handle Finished phase: check if both players want restart + if room.phase == GamePhase::Finished { + let connected_count = room + .players + .values() + .filter(|player| player.connected) + .count(); + let all_want_restart = connected_count >= 2 + && room + .players + .values() + .filter(|player| player.connected) + .all(|player| player.wants_restart); + + if all_want_restart { + // Reset to lobby, everyone needs to re-ready + room.phase = GamePhase::Lobby; + room.game = GameState::new(); + for player in room.players.values_mut() { + player.ready = false; + player.wants_restart = false; + player.paddle_x = 0.0; + player.score = 0; + player.lives = 3; + player.prev_pause = false; + player.input = PlayerInput::default(); + } + return None; // Lobby snapshots are handled by broadcast_lobby + } + + // Still finished - keep sending snapshots so frontend shows game over state + return Some(build_snapshot(room)); + } + + let connected_count = room + .players + .values() + .filter(|player| player.connected) + .count(); + let all_ready = connected_count >= 2 + && room + .players + .values() + .filter(|player| player.connected) + .all(|player| player.ready); + + if room.phase == GamePhase::Lobby && all_ready { + start_match(room); + } + + if room.phase == GamePhase::Lobby { + return None; + } + + let pause_pressed = room.players.values().any(|player| { + player.connected && player.input.pause && !player.prev_pause + }); + + for player in room.players.values_mut() { + player.prev_pause = player.input.pause; + } + + if pause_pressed + && room.game.last_pause_toggle.elapsed() > Duration::from_millis(640) + { + room.phase = match room.phase { + GamePhase::Running => GamePhase::Paused, + GamePhase::Paused => GamePhase::Running, + other => other, + }; + room.game.last_pause_toggle = Instant::now(); + } + + if room.phase == GamePhase::Running { + update_game(room, dt); + } + + Some(build_snapshot(room)) +} + +fn start_match(room: &mut Room) { + room.phase = GamePhase::Running; + room.game = GameState::new(); + + for player in room.players.values_mut() { + player.paddle_x = 0.0; + player.score = 0; + player.lives = 3; + player.ready = true; + player.wants_restart = false; + player.prev_pause = false; + player.input = PlayerInput::default(); + } +} + +fn update_game(room: &mut Room, dt: f32) { + for player in room.players.values_mut() { + if !player.connected { + continue; + } + + player.paddle_x = (player.paddle_x + player.input.axis * PADDLE_SPEED * dt) + .clamp( + -FIELD_HALF_W + PADDLE_W * 0.5, + FIELD_HALF_W - PADDLE_W * 0.5, + ); + } + + // Check if any connected player has sent a launch input + if !room.game.ball_launched { + let any_launch = room + .players + .values() + .any(|player| player.connected && player.input.launch); + + if any_launch { + room.game.ball_launched = true; + let mut rng = rand::thread_rng(); + let direction_x: f32 = rng.gen_range(-0.72..0.72); + let len = (direction_x * direction_x + 1.0).sqrt(); + room.game.ball_vx = direction_x / len * BASE_BALL_SPEED; + room.game.ball_vy = 1.0 / len * BASE_BALL_SPEED; + } else { + // Ball stays at paddle position, still send snapshots + let has_connected = room.players.values().any(|player| player.connected); + if has_connected { + room.game.tick += 1; + } + return; + } + } + + room.game.ball_x += room.game.ball_vx * dt; + room.game.ball_y += room.game.ball_vy * dt; + + if room.game.ball_x - BALL_RADIUS <= -FIELD_HALF_W { + room.game.ball_x = -FIELD_HALF_W + BALL_RADIUS; + room.game.ball_vx = room.game.ball_vx.abs(); + } else if room.game.ball_x + BALL_RADIUS >= FIELD_HALF_W { + room.game.ball_x = FIELD_HALF_W - BALL_RADIUS; + room.game.ball_vx = -room.game.ball_vx.abs(); + } + + if room.game.ball_y + BALL_RADIUS >= FIELD_HALF_H { + room.game.ball_y = FIELD_HALF_H - BALL_RADIUS; + room.game.ball_vy = -room.game.ball_vy.abs(); + } + + resolve_paddle_collision(room); + resolve_brick_collision(room); + + if room.game.ball_y - BALL_RADIUS < -FIELD_HALF_H { + for player in room + .players + .values_mut() + .filter(|player| player.connected) + { + player.lives -= 1; + } + + if room + .players + .values() + .filter(|player| player.connected) + .all(|player| player.lives <= 0) + { + room.phase = GamePhase::Finished; + } else { + reset_ball(&mut room.game); + } + } + + room.game.tick += 1; +} + +fn resolve_paddle_collision(room: &mut Room) { + if room.game.ball_vy >= 0.0 { + return; + } + + let paddle_top = PADDLE_Y + PADDLE_H * 0.5; + let paddle_bottom = PADDLE_Y - PADDLE_H * 0.5; + + for player in room.players.values() { + if !player.connected { + continue; + } + + let left = player.paddle_x - PADDLE_W * 0.5; + let right = player.paddle_x + PADDLE_W * 0.5; + + let hit = room.game.ball_y - BALL_RADIUS <= paddle_top + && room.game.ball_y + BALL_RADIUS >= paddle_bottom + && room.game.ball_x >= left - BALL_RADIUS + && room.game.ball_x <= right + BALL_RADIUS; + + if !hit { + continue; + } + + room.game.ball_y = paddle_top + BALL_RADIUS + 0.04; + let offset = (room.game.ball_x - player.paddle_x) / (PADDLE_W * 0.5); + room.game.ball_vx = offset * BASE_BALL_SPEED * 0.9; + room.game.ball_vy = BASE_BALL_SPEED.abs(); + normalize_ball_speed(&mut room.game); + break; + } +} + +fn resolve_brick_collision(room: &mut Room) { + for index in 0..room.game.bricks.len() { + if !room.game.bricks[index] { + continue; + } + + let (left, right, top, bottom) = brick_bounds(index); + let closest_x = room.game.ball_x.clamp(left, right); + let closest_y = room.game.ball_y.clamp(bottom, top); + let dx = room.game.ball_x - closest_x; + let dy = room.game.ball_y - closest_y; + + if dx * dx + dy * dy > BALL_RADIUS * BALL_RADIUS { + continue; + } + + let overlap_x = (room.game.ball_x + BALL_RADIUS - left) + .min(right - (room.game.ball_x - BALL_RADIUS)); + let overlap_y = (room.game.ball_y + BALL_RADIUS - bottom) + .min(top - (room.game.ball_y - BALL_RADIUS)); + + if overlap_x < overlap_y { + room.game.ball_vx *= -1.0; + } else { + room.game.ball_vy *= -1.0; + } + + room.game.bricks[index] = false; + + for player in room + .players + .values_mut() + .filter(|player| player.connected) + { + player.score += 40; + } + + if room.game.bricks.iter().all(|alive| !alive) { + room.game.bricks = vec![true; BRICK_COLS * BRICK_ROWS]; + reset_ball(&mut room.game); + } + + break; + } +} + +fn brick_bounds(index: usize) -> (f32, f32, f32, f32) { + let row = index / BRICK_COLS; + let col = index % BRICK_COLS; + let total_width = BRICK_COLS as f32 * BRICK_W + (BRICK_COLS as f32 - 1.0) * BRICK_GAP_X; + let start_x = -total_width / 2.0 + BRICK_W / 2.0; + let x = start_x + col as f32 * (BRICK_W + BRICK_GAP_X); + let y = BRICK_TOP - row as f32 * (BRICK_H + BRICK_GAP_Y); + + ( + x - BRICK_W / 2.0, + x + BRICK_W / 2.0, + y + BRICK_H / 2.0, + y - BRICK_H / 2.0, + ) +} + +fn reset_ball(game: &mut GameState) { + game.ball_x = 0.0; + game.ball_y = PADDLE_Y + PADDLE_H * 0.5 + BALL_RADIUS + 0.4; + game.ball_vx = 0.0; + game.ball_vy = 0.0; + game.ball_launched = false; +} + +fn normalize_ball_speed(game: &mut GameState) { + let len = (game.ball_vx * game.ball_vx + game.ball_vy * game.ball_vy).sqrt(); + if len <= 0.01 { + game.ball_vx = 18.0; + game.ball_vy = BASE_BALL_SPEED; + return; + } + + game.ball_vx = game.ball_vx / len * BASE_BALL_SPEED; + game.ball_vy = game.ball_vy / len * BASE_BALL_SPEED; +} + +fn build_snapshot(room: &Room) -> ServerMessage { + ServerMessage::Snapshot { + tick: room.game.tick, + server_time: now_ms(), + phase: match room.phase { + GamePhase::Lobby => "lobby", + GamePhase::Running => "running", + GamePhase::Paused => "paused", + GamePhase::Finished => "finished", + } + .to_string(), + players: room + .players + .values() + .map(|player| SnapshotPlayer { + id: player.id.clone(), + name: player.name.clone(), + role: player.role.clone(), + ready: player.ready, + paddle_x: player.paddle_x, + score: player.score, + lives: player.lives, + }) + .collect(), + ball: BallState { + x: room.game.ball_x, + y: room.game.ball_y, + vx: room.game.ball_vx, + vy: room.game.ball_vy, + }, + bricks: room + .game + .bricks + .iter() + .map(|alive| if *alive { '1' } else { '0' }) + .collect(), + } +} + +async fn discovery_broadcast(state: AppState) { + let socket = UdpSocket::bind("0.0.0.0:0") + .await + .expect("bind UDP sender"); + socket.set_broadcast(true).expect("enable UDP broadcast"); + + loop { + let beacon = { + let inner = state.inner.read().await; + inner.room.as_ref().map(|room| Beacon { + magic: MAGIC.to_string(), + protocol_version: PROTOCOL_VERSION, + room_id: room.room_id.clone(), + pairing_code: room.pairing_code.clone(), + host_name: host_name(), + http_url: format!("http://{}:{HTTP_PORT}", state.lan_ip), + players: room.players.len(), + max_players: 2, + app_version: env!("CARGO_PKG_VERSION").to_string(), + }) + }; + + if let Some(beacon) = beacon { + if let Ok(bytes) = serde_json::to_vec(&beacon) { + let _ = socket + .send_to( + &bytes, + SocketAddr::from(([255, 255, 255, 255], DISCOVERY_PORT)), + ) + .await; + } + } + + time::sleep(Duration::from_millis(800)).await; + } +} + +async fn discovery_recv(state: AppState) { + let socket = UdpSocket::bind(("0.0.0.0", DISCOVERY_PORT)) + .await + .expect("bind UDP receiver"); + + let mut buf = [0u8; 2048]; + + loop { + let Ok((len, addr)) = socket.recv_from(&mut buf).await else { + continue; + }; + + let Ok(beacon) = serde_json::from_slice::(&buf[..len]) else { + continue; + }; + + if beacon.magic != MAGIC || beacon.protocol_version != PROTOCOL_VERSION { + continue; + } + + let own_room_id = { + let inner = state.inner.read().await; + inner.room.as_ref().map(|room| room.room_id.clone()) + }; + + if own_room_id.as_deref() == Some(&beacon.room_id) { + continue; + } + + let room = LanRoom { + room_id: beacon.room_id, + pairing_code: beacon.pairing_code.clone(), + host_name: beacon.host_name, + address: addr.ip().to_string(), + port: HTTP_PORT, + players: beacon.players, + max_players: beacon.max_players, + protocol_version: beacon.protocol_version, + app_version: beacon.app_version, + }; + + state.inner.write().await.discovered.insert( + beacon.pairing_code, + DiscoveredRoom { + room, + http_url: beacon.http_url, + seen_at: Instant::now(), + }, + ); + } +} + +impl GameState { + fn new() -> Self { + let mut game = Self { + tick: 0, + ball_x: 0.0, + ball_y: 0.0, + ball_vx: 0.0, + ball_vy: 0.0, + ball_launched: false, + bricks: vec![true; BRICK_COLS * BRICK_ROWS], + last_pause_toggle: Instant::now() - Duration::from_secs(1), + }; + reset_ball(&mut game); + game + } +} + +fn ensure_protocol(version: u8) -> Result<(), ApiError> { + if version == PROTOCOL_VERSION { + Ok(()) + } else { + Err(ApiError::bad_request("protocol version mismatch")) + } +} + +fn sanitize_name(name: &str) -> String { + let name = name.trim(); + if name.is_empty() { + "Player".to_string() + } else { + name.chars().take(18).collect() + } +} + +fn now_ms() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as f64 +} + +fn guess_lan_ip() -> IpAddr { + std::net::UdpSocket::bind("0.0.0.0:0") + .and_then(|socket| { + socket.connect("8.8.8.8:80")?; + socket.local_addr() + }) + .map(|addr| addr.ip()) + .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)) +} + +fn host_name() -> String { + std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .unwrap_or_else(|_| "JE-Skin Host".to_string()) +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ffd5864..7bd96ff 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,27 +1,147 @@ mod commands; -pub mod serial_core; +mod lan_game; pub mod log; +pub mod serial_core; + +#[cfg(feature = "devkit")] +pub mod devkit; + use commands::serial::SerialConnectionState; +#[cfg(feature = "devkit")] +use tauri::Manager; + +#[cfg(feature = "devkit")] +fn start_server_exe(exe_path: &std::path::Path) { + let mut command = std::process::Command::new(exe_path); + command.arg("--port").arg("50051"); + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); + } + + match command.spawn() { + Ok(_) => ::log::info!("DevKit Python server started: {}", exe_path.display()), + Err(error) => ::log::warn!("Failed to start DevKit Python server: {error}"), + } +} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + let builder = tauri::Builder::default() + .plugin(tauri_plugin_process::init()) .manage(SerialConnectionState::default()) - .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![ - commands::file_explorer::file_explorer_list, - commands::serial::serial_enum, - commands::serial::serial_connect, - commands::serial::serial_disconnect, - commands::serial::serial_export_csv, - commands::serial::serial_has_record_data, - commands::serial::serial_export_csv_to_path, - commands::serial::serial_import_csv, - commands::serial::serial_import_csv_from_path, - commands::window::win_minimize, - commands::window::win_toggle_maximize, - commands::window::win_close - ]) + .plugin(tauri_plugin_opener::init()); + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + let builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); + + #[cfg(any(target_os = "android", target_os = "ios"))] + let builder = builder; + + #[cfg(feature = "devkit")] + let builder = { + let devkit_state = devkit::DevKitState::default(); + let devkit_state_clone = devkit_state.clone(); + + builder.manage(devkit_state).setup(move |app| { + tauri::async_runtime::spawn(async { + if let Err(error) = lan_game::serve().await { + ::log::error!("LAN game server failed: {error:?}"); + } + }); + + let resource_dir = app + .path() + .resource_dir() + .unwrap_or_else(|_| std::path::PathBuf::from("./resources")); + + tauri::async_runtime::spawn(async move { + #[cfg(target_os = "windows")] + let exe_name = "je-skin-devkit-server.exe"; + #[cfg(not(target_os = "windows"))] + let exe_name = "je-skin-devkit-server"; + + let bundled_exe = resource_dir.join(exe_name); + let fallback_exe = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(|parent| parent.join(exe_name))); + + let server_exe = if bundled_exe.exists() { + Some(bundled_exe) + } else { + fallback_exe.filter(|path| path.exists()) + }; + + if let Some(exe_path) = server_exe { + start_server_exe(&exe_path); + tokio::time::sleep(std::time::Duration::from_millis(1200)).await; + } else { + ::log::info!("DevKit Python server not found, skipping auto-start"); + } + + if let Err(error) = devkit_state_clone.start(50051).await { + ::log::warn!("DevKit auto-start failed: {error}"); + } else { + ::log::info!("DevKit auto-started on 127.0.0.1:50051"); + } + }); + + Ok(()) + }) + }; + + #[cfg(not(feature = "devkit"))] + let builder = builder.setup(|_app| { + tauri::async_runtime::spawn(async { + if let Err(error) = lan_game::serve().await { + ::log::error!("LAN game server failed: {error:?}"); + } + }); + + Ok(()) + }); + + #[cfg(feature = "devkit")] + let builder = builder.invoke_handler(tauri::generate_handler![ + commands::file_explorer::file_explorer_list, + commands::serial::serial_enum, + commands::serial::serial_connect, + commands::serial::serial_disconnect, + commands::serial::serial_export_csv, + commands::serial::serial_has_record_data, + commands::serial::serial_export_csv_to_path, + commands::serial::serial_import_csv, + commands::serial::serial_import_csv_from_path, + commands::window::win_minimize, + commands::window::win_toggle_maximize, + commands::window::win_close, + commands::devkit::devkit_status, + commands::devkit::devkit_start, + commands::devkit::devkit_stop, + commands::devkit::devkit_get_config, + commands::devkit::devkit_set_config, + commands::devkit::devkit_process_export + ]); + + #[cfg(not(feature = "devkit"))] + let builder = builder.invoke_handler(tauri::generate_handler![ + commands::file_explorer::file_explorer_list, + commands::serial::serial_enum, + commands::serial::serial_connect, + commands::serial::serial_disconnect, + commands::serial::serial_export_csv, + commands::serial::serial_has_record_data, + commands::serial::serial_export_csv_to_path, + commands::serial::serial_import_csv, + commands::serial::serial_import_csv_from_path, + commands::window::win_minimize, + commands::window::win_toggle_maximize, + commands::window::win_close + ]); + + builder .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/log.rs b/src-tauri/src/log.rs index bbf3da9..54ede8a 100644 --- a/src-tauri/src/log.rs +++ b/src-tauri/src/log.rs @@ -3,7 +3,16 @@ use fern::{ Dispatch, }; use log::debug; -use std::time::SystemTime; +use std::{path::{Path, PathBuf}, time::SystemTime}; + +fn log_directory() -> PathBuf { + let base_dir = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))) + .unwrap_or_else(std::env::temp_dir); + + base_dir.join("JE-Skin").join("logs") +} pub fn setup_logger() { let colors_line = ColoredLevelConfig::new() .error(Color::Red) @@ -38,7 +47,11 @@ pub fn setup_logger() { // .chain(fern::DateBased::new("program.log", "%Y-%m-%d")) // .apply() // .unwrap(); - let log_path = std::env::temp_dir().join("program.log"); + let log_dir = log_directory(); + if let Err(error) = std::fs::create_dir_all(&log_dir) { + eprintln!("failed to create log_directory {}: {error}", log_dir.display()); + } + // let log_path = std::env::temp_dir().join("program.log"); let file_config = fern::Dispatch::new() .format(move |out, message, record| { out.finish(format_args!( @@ -50,7 +63,7 @@ pub fn setup_logger() { )); }) .level(level) - .chain(fern::DateBased::new(&log_path, "%Y-%m-%d")); + .chain(fern::DateBased::new(log_dir.join("program.log"), "%Y-%m-%d")); Dispatch::new() .level(log::LevelFilter::Debug) diff --git a/src-tauri/src/serial_core/serial.rs b/src-tauri/src/serial_core/serial.rs index 21daf6f..7b161d2 100644 --- a/src-tauri/src/serial_core/serial.rs +++ b/src-tauri/src/serial_core/serial.rs @@ -4,15 +4,21 @@ use crate::serial_core::frame::{FrameHandler, TactileAFrame, TestFrame}; use crate::serial_core::model::{HudChartState, HudPacket}; use crate::serial_core::record::Recording; use crate::serial_core::record::{FrameTiming, RecordedFrame}; +#[cfg(feature = "devkit")] +use crate::devkit::{proto::SensorFrame, DevKitState}; use anyhow::Result; use std::future::pending; use std::sync::{Arc, Mutex}; use std::time::Instant; use tauri::{AppHandle, Emitter}; +#[cfg(feature = "devkit")] +use tauri::Manager; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::time::{self, Duration, MissedTickBehavior}; use tokio_serial::SerialStream; use tokio_util::sync::CancellationToken; +#[cfg(feature = "devkit")] +use std::sync::atomic::Ordering; pub enum PollMode { Disable, @@ -271,6 +277,8 @@ where let force = raw_to_g1(summary as u32); chart_state.record_summary(force as f32); chart_state.record_pressure_matrix(vals.as_slice()); + #[cfg(feature = "devkit")] + push_devkit_frame(&app, vals.as_slice(), frame.dts_ms(), force); Some(vec![summary]) } else { None @@ -286,6 +294,58 @@ where Ok(()) } +#[cfg(feature = "devkit")] +fn push_devkit_frame(app: &AppHandle, values: &[i32], dts_ms: u64, resultant_force: f64) { + let devkit_state = app.state::(); + if !devkit_state.running.load(Ordering::Relaxed) { + return; + } + + let (rows, cols) = infer_matrix_shape(values.len()); + let timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let seq = timestamp_ms; + let matrix = values + .iter() + .map(|value| (*value).max(0) as u32) + .collect::>(); + + devkit_state.push_frame(SensorFrame { + seq, + timestamp_ms, + rows, + cols, + matrix, + resultant_force, + dts_ms: dts_ms as u32, + }); +} + +#[cfg(feature = "devkit")] +fn infer_matrix_shape(len: usize) -> (u32, u32) { + if len == 84 { + return (12, 7); + } + + if len == 0 { + return (0, 0); + } + + let mut best = (len, 1); + let mut factor = 1usize; + while factor * factor <= len { + if len % factor == 0 { + best = (len / factor, factor); + } + factor += 1; + } + + (best.0 as u32, best.1 as u32) +} + fn raw_to_g1(raw: u32) -> f64 { const X: [u32; 12] = [ 0, 84402, 117218, 140176, 159126, 175812, 191484, 208758, 224703, 252448, 302361, 352703, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d336b05..00ed87a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,6 +23,7 @@ } }, "bundle": { + "createUpdaterArtifacts": true, "active": true, "targets": "all", "icon": [ @@ -31,6 +32,27 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" + ], + "windows": { + "nsis": { + "installMode": "both", + "displayLanguageSelector": false, + "installerIcon": "icons/icon.ico" + } + }, + "resources": [ + "resources/je-skin-devkit-server.exe" ] + }, + "plugins": { + "updater": { + "windows": { + "installMode": "passive" + }, + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkwODM1QkFEODI2NkZENgpSV1RXYnliWXVqVUlDUVRxbjlseDNDNjhQTGpDYis4TEZMeUk2WVhiMEhTRWJhN3hGRnQ3TTJtcwo=", + "endpoints": [ + "https://je-skin.cn-nb1.rains3.com/latest.json" + ] + } } } diff --git a/src/lib/components/DevKitConfigPanel.svelte b/src/lib/components/DevKitConfigPanel.svelte new file mode 100644 index 0000000..a0d56a3 --- /dev/null +++ b/src/lib/components/DevKitConfigPanel.svelte @@ -0,0 +1,362 @@ + + +
+
+

{labels.title}

+ +
+ +
+
+ + {labels.status} + {running ? labels.connected : labels.disconnected} +
+ {#if running} +
+
+ {labels.port} + :{port} +
+
+ {labels.framesSent} + {framesSent} +
+
+ {/if} +
+ +
+ +
+ +
+ +
+ + {#if lastProcessResult} +
+

{labels.lastResult}

+
+
+ {labels.output} + {lastProcessResult.outputPath} +
+
+ {labels.groups} + {lastProcessResult.groupsUsed} +
+
+ {labels.mean} + {lastProcessResult.meanValue.toFixed(3)} +
+
+ {labels.threshold} + {lastProcessResult.threshold.toFixed(3)} +
+
+ {labels.rows} + {lastProcessResult.rowsTotal} → {lastProcessResult.rowsKept} +
+
+
+ {/if} +
+ + diff --git a/src/lib/components/HudPanel.svelte b/src/lib/components/HudPanel.svelte index f69e204..ee9263d 100644 --- a/src/lib/components/HudPanel.svelte +++ b/src/lib/components/HudPanel.svelte @@ -41,6 +41,10 @@ export let importActionLabel = ""; export let connectionNotice = ""; export let connectionNoticeTone: HudNoticeTone = "info"; + export let noticeConfirmLabel = ""; + export let noticeCancelLabel = ""; + export let noticeShowActions = false; + export let noticeActionBusy = false; export let isRefreshingPorts = false; export let isConnectDisabled = false; export let isExporting = false; @@ -58,6 +62,8 @@ serialexport: void; csvimport: void; noticeclear: void; + noticeconfirm: void; + noticecancel: void; }>(); const connectionToneByState: Record = { @@ -123,6 +129,14 @@ function emitNoticeClear(): void { dispatch("noticeclear"); } + + function emitNoticeConfirm(): void { + dispatch("noticeconfirm"); + } + + function emitNoticeCancel(): void { + dispatch("noticecancel"); + }
@@ -301,14 +315,25 @@ {#if connectionNotice}

{connectionNotice}

- + {#if noticeShowActions} +
+ + +
+ {:else} + + {/if}
{/if} @@ -921,6 +946,58 @@ background: rgb(9 16 22 / 0.92); } + .notice-actions { + display: inline-flex; + align-items: center; + gap: 0.42rem; + flex-shrink: 0; + } + + .notice-action-btn { + min-block-size: 1.55rem; + border: 1px solid rgb(116 151 176 / 0.38); + border-radius: 999px; + padding: 0.24rem 0.72rem; + background: rgb(7 12 16 / 0.76); + color: rgb(194 225 245 / 0.94); + font-size: 0.68rem; + letter-spacing: 0.04em; + cursor: pointer; + transition: + border-color 180ms ease, + color 180ms ease, + background-color 180ms ease, + box-shadow 200ms ease, + opacity 180ms ease; + } + + .notice-action-btn:hover:not(:disabled) { + border-color: rgb(62 232 255 / 0.46); + color: rgb(237 250 255 / 0.98); + background: rgb(9 16 22 / 0.92); + } + + .notice-action-btn.is-primary { + border-color: rgb(var(--hud-lime-rgb) / 0.44); + background: + linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.94), rgb(var(--hud-surface-rgb) / 0.88)), + radial-gradient(circle at 50% 0, rgb(var(--hud-lime-rgb) / 0.14), transparent 58%); + color: rgb(var(--hud-text-main-rgb) / 0.98); + box-shadow: 0 0 10px rgb(var(--hud-lime-rgb) / 0.08); + } + + .notice-action-btn.is-primary:hover:not(:disabled) { + border-color: rgb(var(--hud-lime-rgb) / 0.62); + box-shadow: + inset 0 0 0 1px rgb(var(--hud-text-main-rgb) / 0.08), + 0 0 13px rgb(var(--hud-lime-rgb) / 0.14); + } + + .notice-action-btn:disabled { + cursor: default; + opacity: 0.64; + } + .connection-notice.tone-warn .notice-close-btn:hover { border-color: rgb(255 91 63 / 0.6); color: rgb(255 227 220 / 0.98); @@ -1139,4 +1216,4 @@ } } - + \ No newline at end of file diff --git a/src/lib/components/NeonBreakoutArena.svelte b/src/lib/components/NeonBreakoutArena.svelte index 93efc7c..37cf5fa 100644 --- a/src/lib/components/NeonBreakoutArena.svelte +++ b/src/lib/components/NeonBreakoutArena.svelte @@ -39,8 +39,121 @@ bricks: string; chase: string; pausedOverlay: string; + online: string; + local: string; + createRoom: string; + discoverRooms: string; + joinRoom: string; + pairingCode: string; + joinCode: string; + playerName: string; + ready: string; + cancelReady: string; + disconnect: string; + opponent: string; + waitingOpponent: string; + roomStatus: string; + latency: string; + noRooms: string; + apiOffline: string; + connected: string; + connecting: string; + hosting: string; + discovering: string; } + type LanStatus = "offline" | "discovering" | "hosting" | "connecting" | "lobby" | "running" | "error"; + type PlayerRole = "host" | "guest"; + type GamePhase = "lobby" | "countdown" | "running" | "paused" | "finished"; + + interface LanRoom { + roomId: string; + pairingCode: string; + hostName: string; + address: string; + port: number; + players: number; + maxPlayers: number; + protocolVersion: number; + appVersion: string; + } + + interface LanPlayer { + id: string; + name: string; + ready: boolean; + role: PlayerRole; + score?: number; + lives?: number; + latencyMs?: number; + } + + interface JoinRoomResponse { + roomId: string; + playerId: string; + ticket: string; + wsUrl: string; + pairingCode?: string; + } + + interface CreateRoomResponse extends JoinRoomResponse { + pairingCode: string; + expiresAt?: number; + } + + type ClientMessage = + | { + type: "hello"; + protocolVersion: 1; + roomId: string; + playerId: string; + ticket: string; + playerName: string; + } + | { type: "ready"; ready: boolean } + | { type: "restart" } + | { + type: "input"; + seq: number; + clientTime: number; + axis: number; + launch: boolean; + pause: boolean; + } + | { type: "ping"; clientTime: number }; + + type ServerMessage = + | { + type: "welcome"; + roomId: string; + playerId: string; + role: PlayerRole; + tickRate: number; + seed: number; + } + | { type: "lobby"; players: LanPlayer[] } + | { type: "start"; startAt: number; seed: number } + | { + type: "snapshot"; + tick: number; + serverTime: number; + phase: GamePhase; + players: Array<{ + id: string; + name?: string; + role?: PlayerRole; + ready?: boolean; + paddleX: number; + score: number; + lives: number; + latencyMs?: number; + }>; + ball: { x: number; y: number; vx: number; vy: number }; + bricks: string; + } + | { type: "pong"; clientTime: number; serverTime: number } + | { type: "error"; code: string; message: string }; + export let locale: LocaleCode = "zh-CN"; export let pressureMatrix: number[] | null = null; export let matrixRows = 12; @@ -71,6 +184,10 @@ const UPPER_STALL_Y = 14; const ANTI_STALL_COOLDOWN_MS = 420; const MAX_BRICK_HITS_WITHOUT_PADDLE = 5; + const LAN_PROTOCOL_VERSION = 1; + const LAN_API_BASE_STORAGE_KEY = "je-skin-lan-api-base"; + const LAN_DEFAULT_API_BASE = "http://127.0.0.1:47888"; + const LAN_INPUT_INTERVAL_MS = 33; const copyByLocale: Record = { "zh-CN": { @@ -88,7 +205,28 @@ level: "关卡", bricks: "剩余", chase: "追击", - pausedOverlay: "已暂停 / 顶部双角施压继续" + pausedOverlay: "已暂停 / 顶部双角施压继续", + online: "联机", + local: "本地", + createRoom: "创建房间", + discoverRooms: "搜索", + joinRoom: "加入", + pairingCode: "配对码", + joinCode: "输入配对码", + playerName: "玩家名", + ready: "准备", + cancelReady: "取消准备", + disconnect: "断开", + opponent: "对手", + waitingOpponent: "等待对手加入", + roomStatus: "房间", + latency: "延迟", + noRooms: "暂无局域网房间", + apiOffline: "联机后端未启动或不可访问", + connected: "已连接", + connecting: "连接中", + hosting: "房主等待中", + discovering: "搜索中" }, "en-US": { title: "NEON BREAKOUT", @@ -105,7 +243,28 @@ level: "Level", bricks: "Bricks", chase: "Chase", - pausedOverlay: "Paused / Top corners to resume" + pausedOverlay: "Paused / Top corners to resume", + online: "Online", + local: "Local", + createRoom: "Create", + discoverRooms: "Scan", + joinRoom: "Join", + pairingCode: "Code", + joinCode: "Pairing code", + playerName: "Name", + ready: "Ready", + cancelReady: "Cancel", + disconnect: "Disconnect", + opponent: "Opponent", + waitingOpponent: "Waiting for opponent", + roomStatus: "Room", + latency: "Ping", + noRooms: "No LAN rooms found", + apiOffline: "LAN backend is offline or unreachable", + connected: "Connected", + connecting: "Connecting", + hosting: "Hosting", + discovering: "Scanning" } }; @@ -126,6 +285,25 @@ let flashSprite: THREE.Sprite | null = null; let flashLife = 0; let flashMaxLife = 0.2; + let lanSocket: WebSocket | null = null; + let lanMode: "local" | "online" = "local"; + let lanStatus: LanStatus = "offline"; + let lanRooms: LanRoom[] = []; + let lanNotice = ""; + let lanApiBase = LAN_DEFAULT_API_BASE; + let playerName = "Player"; + let joinCode = ""; + let pairingCode = ""; + let roomId = ""; + let localPlayerId = ""; + let localRole: PlayerRole | null = null; + let localReady = false; + let lanPlayers: LanPlayer[] = []; + let pingMs: number | null = null; + let lastLanInputAt = 0; + let lanInputSeq = 0; + let intentionalLanDisconnect = false; + let lanLaunchAt = 0; let keyLeft = false; let keyRight = false; @@ -160,6 +338,23 @@ $: pauseGestureThreshold = Math.max(420, Math.round(Math.max(1000, rangeMax - rangeMin) * 0.07)); $: statusText = gameState === "running" ? ui.running : gameState === "paused" ? ui.paused : gameState === "over" ? ui.over : ui.start; + $: isLanBusy = lanStatus === "discovering" || lanStatus === "hosting" || lanStatus === "connecting"; + $: isLanConnected = lanStatus === "lobby" || lanStatus === "running"; + $: selfPlayer = lanPlayers.find((player) => player.id === localPlayerId) ?? null; + $: opponentPlayers = lanPlayers.filter((player) => player.id !== localPlayerId); + $: primaryOpponent = opponentPlayers[0] ?? null; + $: roomStatusText = + lanStatus === "discovering" + ? ui.discovering + : lanStatus === "hosting" + ? ui.hosting + : lanStatus === "connecting" + ? ui.connecting + : isLanConnected + ? ui.connected + : lanStatus === "error" + ? ui.apiOffline + : ui.local; $: if (scene && renderer) { applyTheme(); @@ -228,8 +423,363 @@ } } + function getLanApiBase(): string { + if (typeof window === "undefined") { + return LAN_DEFAULT_API_BASE; + } + + return window.localStorage.getItem(LAN_API_BASE_STORAGE_KEY)?.trim() || LAN_DEFAULT_API_BASE; + } + + function normalizePairingCode(value: string): string { + return value.replace(/\D/g, "").slice(0, 8); + } + + function makeDefaultPlayerName(): string { + if (typeof window === "undefined") { + return "Player"; + } + + const saved = window.localStorage.getItem("je-skin-lan-player-name")?.trim(); + if (saved) { + return saved.slice(0, 18); + } + + return `JE-${Math.floor(1000 + Math.random() * 9000)}`; + } + + function persistPlayerName(): void { + if (typeof window === "undefined") { + return; + } + + const safeName = playerName.trim().slice(0, 18) || makeDefaultPlayerName(); + playerName = safeName; + window.localStorage.setItem("je-skin-lan-player-name", safeName); + } + + async function lanFetch(path: string, init?: RequestInit): Promise { + const response = await fetch(`${lanApiBase}${path}`, { + ...init, + headers: { + "content-type": "application/json", + ...(init?.headers ?? {}) + } + }); + + if (!response.ok) { + let message = ""; + try { + const body = (await response.json()) as { message?: string }; + message = body.message ?? ""; + } catch { + message = ""; + } + + if (response.status === 404) { + throw new Error(locale === "zh-CN" ? "未找到这个配对码,请确认房主已创建房间。" : "Pairing code not found. Make sure the host created a room."); + } + + throw new Error(message || `LAN API ${response.status}`); + } + + return (await response.json()) as T; + } + + function setLanError(error: unknown): void { + lanStatus = "error"; + lanNotice = error instanceof Error ? error.message : ui.apiOffline; + console.error("[lan] operation failed:", error); + } + + async function discoverLanRooms(): Promise { + lanMode = "online"; + lanStatus = "discovering"; + lanNotice = ""; + lanApiBase = getLanApiBase(); + + try { + const result = await lanFetch<{ rooms: LanRoom[] }>("/lan/rooms"); + lanRooms = result.rooms ?? []; + lanStatus = "offline"; + lanNotice = lanRooms.length ? "" : ui.noRooms; + } catch (error) { + lanRooms = []; + setLanError(error); + } + } + + async function createLanRoom(): Promise { + lanMode = "online"; + lanStatus = "hosting"; + lanNotice = ""; + persistPlayerName(); + lanApiBase = getLanApiBase(); + + try { + const result = await lanFetch("/lan/rooms", { + method: "POST", + body: JSON.stringify({ + playerName, + protocolVersion: LAN_PROTOCOL_VERSION + }) + }); + + await connectLanSocket(result); + pairingCode = result.pairingCode; + } catch (error) { + setLanError(error); + } + } + + async function joinLanRoom(code = joinCode): Promise { + const normalizedCode = normalizePairingCode(code); + if (!normalizedCode) { + return; + } + + joinCode = normalizedCode; + lanMode = "online"; + lanStatus = "connecting"; + lanNotice = ""; + persistPlayerName(); + lanApiBase = getLanApiBase(); + + try { + const result = await lanFetch("/lan/join", { + method: "POST", + body: JSON.stringify({ + pairingCode: normalizedCode, + playerName, + protocolVersion: LAN_PROTOCOL_VERSION + }) + }); + + await connectLanSocket(result); + pairingCode = result.pairingCode ?? normalizedCode; + } catch (error) { + setLanError(error); + } + } + + async function connectLanSocket(result: JoinRoomResponse): Promise { + disconnectLanSocket(false); + intentionalLanDisconnect = false; + roomId = result.roomId; + localPlayerId = result.playerId; + lanStatus = "connecting"; + + await new Promise((resolve, reject) => { + const socket = new WebSocket(result.wsUrl); + lanSocket = socket; + + socket.onopen = () => { + sendLanMessage({ + type: "hello", + protocolVersion: LAN_PROTOCOL_VERSION, + roomId: result.roomId, + playerId: result.playerId, + ticket: result.ticket, + playerName + }); + resolve(); + }; + + socket.onerror = () => { + reject(new Error("WebSocket connect failed")); + }; + + socket.onmessage = (event) => { + handleLanMessage(JSON.parse(event.data) as ServerMessage); + }; + + socket.onclose = () => { + if (!intentionalLanDisconnect) { + lanStatus = "offline"; + lanNotice = locale === "zh-CN" ? "联机连接已断开。" : "LAN connection closed."; + } + lanSocket = null; + }; + }); + } + + function disconnectLanSocket(resetMode = true): void { + intentionalLanDisconnect = true; + lanSocket?.close(1000, "client disconnect"); + lanSocket = null; + lanStatus = "offline"; + localReady = false; + localRole = null; + localPlayerId = ""; + roomId = ""; + pairingCode = ""; + pingMs = null; + lanPlayers = []; + lanLaunchAt = 0; + if (resetMode) { + lanMode = "local"; + lanNotice = ""; + } + } + + function sendLanMessage(message: ClientMessage): void { + if (lanSocket?.readyState !== WebSocket.OPEN) { + return; + } + + lanSocket.send(JSON.stringify(message)); + } + + function handleLanMessage(message: ServerMessage): void { + if (message.type === "welcome") { + roomId = message.roomId; + localPlayerId = message.playerId; + localRole = message.role; + lanStatus = "lobby"; + lanNotice = ""; + return; + } + + if (message.type === "lobby") { + lanPlayers = message.players; + localReady = lanPlayers.find((player) => player.id === localPlayerId)?.ready ?? localReady; + lanStatus = "lobby"; + return; + } + + if (message.type === "start") { + lanStatus = "running"; + if (gameState !== "running") { + startGame(); + } + return; + } + + if (message.type === "snapshot") { + lanStatus = message.phase === "running" ? "running" : "lobby"; + applyLanSnapshot(message); + return; + } + + if (message.type === "pong") { + pingMs = Math.max(0, Math.round(performance.now() - message.clientTime)); + return; + } + + if (message.type === "error") { + lanNotice = message.message; + lanStatus = "error"; + } + } + + function applyLanSnapshot(snapshot: Extract): void { + const phaseToState: Record = { + lobby: "idle", + countdown: "idle", + running: "running", + paused: "paused", + finished: "over" + }; + + const prevGameState = gameState; + gameState = phaseToState[snapshot.phase]; + + // Detect transition to running → schedule launch after delay + if (gameState === "running" && prevGameState !== "running" && lanLaunchAt === 0) { + lanLaunchAt = performance.now() + START_DELAY_MS; + } + + // Reset launch timer when game ends or returns to lobby + if (gameState === "over" || gameState === "idle") { + lanLaunchAt = 0; + } + ballPos.set(snapshot.ball.x, snapshot.ball.y); + ballVel.set(snapshot.ball.vx, snapshot.ball.vy); + ball?.position.set(ballPos.x, ballPos.y, 1.2); + + const playersById = new Map(lanPlayers.map((player) => [player.id, player])); + lanPlayers = snapshot.players.map((player, index) => { + const previous = playersById.get(player.id); + return { + id: player.id, + name: player.name ?? previous?.name ?? (index === 0 ? "Host" : "Guest"), + role: player.role ?? previous?.role ?? (index === 0 ? "host" : "guest"), + ready: player.ready ?? previous?.ready ?? true, + score: player.score, + lives: player.lives, + latencyMs: player.latencyMs ?? previous?.latencyMs + }; + }); + + const selfSnapshot = snapshot.players.find((player) => player.id === localPlayerId) ?? snapshot.players[0]; + if (selfSnapshot) { + paddleX = Math.min(FIELD_HALF_W - PADDLE_W * 0.5, Math.max(-FIELD_HALF_W + PADDLE_W * 0.5, selfSnapshot.paddleX)); + paddle?.position.setX(paddleX); + score = selfSnapshot.score; + lives = selfSnapshot.lives; + } + + applyBrickMask(snapshot.bricks); + } + + function applyBrickMask(mask: string): void { + if (!mask || !bricks.length) { + return; + } + + let aliveCount = 0; + for (let index = 0; index < bricks.length; index += 1) { + const alive = mask[index] !== "0"; + bricks[index].alive = alive; + bricks[index].mesh.visible = alive; + if (alive) { + aliveCount += 1; + } + } + bricksLeft = aliveCount; + } + + function requestLanRestart(): void { + if (isLanConnected) { + sendLanMessage({ type: "restart" }); + } else { + startGame(); + } + } + + function toggleLanReady(): void { + if (!isLanConnected) { + return; + } + + localReady = !localReady; + sendLanMessage({ type: "ready", ready: localReady }); + } + + function sendLanInput(nowMs: number, axis: number, launch: boolean, pause: boolean): void { + if (!isLanConnected || nowMs - lastLanInputAt < LAN_INPUT_INTERVAL_MS) { + return; + } + + lastLanInputAt = nowMs; + sendLanMessage({ + type: "input", + seq: ++lanInputSeq, + clientTime: nowMs, + axis, + launch, + pause + }); + + if (lanInputSeq % 60 === 0) { + sendLanMessage({ type: "ping", clientTime: nowMs }); + } + } + onMount(() => { if (!hostEl || !canvasEl) return; + lanApiBase = getLanApiBase(); + playerName = makeDefaultPlayerName(); const uiTheme = themePalette.uiTheme; const paddleColor = themePalette.rangeStops[2]; const ballColor = themePalette.rangeStops[0]; @@ -340,7 +890,7 @@ const onKeyDown = (event: KeyboardEvent) => { if (event.code === "ArrowLeft" || event.code === "KeyA") keyLeft = true; if (event.code === "ArrowRight" || event.code === "KeyD") keyRight = true; - if (event.code === "Space" && gameState !== "running") restartGame(); + if (event.code === "Space" && gameState !== "running") startGame(); }; const onKeyUp = (event: KeyboardEvent) => { if (event.code === "ArrowLeft" || event.code === "KeyA") keyLeft = false; @@ -370,7 +920,7 @@ observer.observe(hostEl); resize(); - restartGame(); + resetGameToIdle(); lastFrameTs = performance.now(); const loop = (ts: number) => { const dt = Math.min((ts - lastFrameTs) / 1000, 0.034); @@ -398,6 +948,7 @@ ballMesh.material.dispose(); (flash.material as THREE.SpriteMaterial).map?.dispose(); (flash.material as THREE.SpriteMaterial).dispose(); + disconnectLanSocket(false); renderer?.dispose(); }; }); @@ -526,13 +1077,13 @@ bricksSincePaddle = 0; } - function restartGame(): void { + function resetGameToIdle(): void { if (!paddle || !ball) return; score = 0; combo = 0; lives = 3; level = 1; - gameState = "running"; + gameState = "idle"; prevPauseGesture = false; pauseGestureLockUntil = 0; lastPaddleContactAt = performance.now(); @@ -541,9 +1092,21 @@ paddleX = 0; paddle.position.x = 0; buildBricks(); + prepareBall(performance.now(), Number.POSITIVE_INFINITY); + ball.position.set(ballPos.x, ballPos.y, 1.2); + } + + function startGame(): void { + if (!paddle || !ball) return; + resetGameToIdle(); + gameState = "running"; prepareBall(performance.now(), START_DELAY_MS); } + function restartGame(): void { + startGame(); + } + function togglePause(): void { if (gameState === "running") { gameState = "paused"; @@ -590,14 +1153,18 @@ } function handleSensorPauseGesture(nowMs: number): void { - if (gameState !== "running" && gameState !== "paused") { + if (gameState !== "idle" && gameState !== "running" && gameState !== "paused") { prevPauseGesture = false; return; } const active = topForce >= pauseGestureThreshold; if (active && !prevPauseGesture && nowMs >= pauseGestureLockUntil) { - togglePause(); + if (gameState === "idle") { + startGame(); + } else { + togglePause(); + } pauseGestureLockUntil = nowMs + PAUSE_COOLDOWN_MS; } prevPauseGesture = active; @@ -609,9 +1176,15 @@ const keyAxis = (keyRight ? 1 : 0) - (keyLeft ? 1 : 0); const axis = keyAxis !== 0 ? keyAxis : sensorControlAxis(); - paddleX += axis * PADDLE_SPEED * dt; - paddleX = Math.min(FIELD_HALF_W - PADDLE_W * 0.5, Math.max(-FIELD_HALF_W + PADDLE_W * 0.5, paddleX)); - paddle.position.x = paddleX; + const shouldLaunch = lanLaunchAt > 0 && nowMs >= lanLaunchAt; + const launchIntent = shouldLaunch || (gameState !== "running" && topForce >= pauseGestureThreshold); + const pauseIntent = topForce >= pauseGestureThreshold; + sendLanInput(nowMs, axis, launchIntent, pauseIntent); + + // Reset launch timer after sending + if (shouldLaunch) { + lanLaunchAt = 0; + } if (gameState !== "running") { ball.position.set(ballPos.x, ballPos.y, 1.2); @@ -619,10 +1192,16 @@ return; } + if (!isLanConnected) { + paddleX += axis * PADDLE_SPEED * dt; + paddleX = Math.min(FIELD_HALF_W - PADDLE_W * 0.5, Math.max(-FIELD_HALF_W + PADDLE_W * 0.5, paddleX)); + paddle.position.x = paddleX; + } + if (!ballLaunched) { ballPos.set(paddleX, PADDLE_Y + PADDLE_H * 0.5 + BALL_RADIUS + 0.4); if (nowMs >= nextLaunchAt) launchBall(nowMs); - } else { + } else if (!isLanConnected) { ballPos.addScaledVector(ballVel, dt); resolveWallCollision(nowMs); resolvePaddleCollision(); @@ -774,7 +1353,7 @@

{ui.title}

- + @@ -789,6 +1368,98 @@
{ui.bricks}{Math.max(0, bricksLeft)}
+
+
+
+

{ui.online}

+ {roomStatusText} +
+
+ {#if pingMs !== null} + {ui.latency} {pingMs}ms + {/if} + +
+
+ + {#if lanMode === "online"} +
+ + +
+ + + {#if isLanConnected} + + {/if} +
+ +
+ + +
+ + {#if pairingCode} +
+ {ui.pairingCode} + {pairingCode} +
+ {/if} + + {#if lanRooms.length > 0 && !isLanConnected} +
+ {#each lanRooms as room (room.roomId)} + + {/each} +
+ {/if} + + {#if lanNotice} +

{lanNotice}

+ {/if} +
+ + {#if isLanConnected} +
+
+ {playerName || "You"} {localRole ? `/${localRole}` : ""} + {selfPlayer?.ready || localReady ? ui.ready : statusText} + {Math.round(score)} / {lives} +
+
+ {ui.opponent} + {primaryOpponent?.name ?? ui.waitingOpponent} + + {#if primaryOpponent} + {(primaryOpponent.score ?? 0)} / {(primaryOpponent.lives ?? 3)} + {:else} + -- + {/if} + +
+ +
+ {/if} + {/if} +
+

{ui.chase} / {statusText}

@@ -806,7 +1477,7 @@

{ui.over}

- +
{/if} @@ -910,6 +1581,256 @@ line-height: 1; } + .lan-panel { + align-self: start; + justify-self: end; + inline-size: min(20rem, 58%); + display: grid; + gap: 0.42rem; + border: 1px solid rgb(var(--hud-border-rgb) / 0.24); + border-radius: 0.56rem; + padding: 0.48rem; + background: + linear-gradient(180deg, rgb(var(--hud-surface-rgb) / 0.72), rgb(var(--hud-surface-deep-rgb) / 0.78)), + radial-gradient(circle at 50% 0, rgb(var(--hud-glow-rgb) / 0.05), transparent 58%); + box-shadow: inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.07); + pointer-events: auto; + } + + .lan-panel:not(.is-online) { + inline-size: auto; + padding: 0.32rem; + background: rgb(var(--hud-surface-deep-rgb) / 0.54); + } + + .lan-panel-head, + .lan-head-actions, + .lan-action-row, + .lan-join-row, + .versus-strip { + display: flex; + align-items: center; + gap: 0.36rem; + } + + .lan-panel-head { + justify-content: space-between; + } + + .lan-panel-head div:first-child { + min-inline-size: 0; + display: grid; + gap: 0.08rem; + } + + .lan-kicker { + margin: 0; + color: rgb(var(--hud-text-dim-rgb) / 0.84); + font-size: 0.48rem; + letter-spacing: 0.12em; + text-transform: uppercase; + } + + .lan-panel-head strong { + color: rgb(var(--hud-text-main-rgb) / 0.96); + font-size: 0.66rem; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + .lan-ping, + .lan-notice { + color: rgb(var(--hud-text-dim-rgb) / 0.84); + font-size: 0.54rem; + letter-spacing: 0.05em; + } + + .lan-controls { + display: grid; + gap: 0.36rem; + } + + .lan-input-label { + min-inline-size: 0; + display: grid; + gap: 0.18rem; + } + + .lan-input-label span { + color: rgb(var(--hud-text-dim-rgb) / 0.82); + font-size: 0.5rem; + letter-spacing: 0.09em; + text-transform: uppercase; + } + + .lan-input-label input { + min-inline-size: 0; + inline-size: 100%; + border: 1px solid rgb(var(--hud-border-rgb) / 0.34); + border-radius: 999px; + padding: 0.28rem 0.52rem; + background: rgb(var(--hud-surface-deep-rgb) / 0.78); + color: rgb(var(--hud-text-main-rgb) / 0.96); + font: inherit; + font-size: 0.64rem; + letter-spacing: 0.05em; + outline: none; + } + + .lan-input-label input:focus { + border-color: rgb(var(--hud-cyan-rgb) / 0.52); + box-shadow: 0 0 0 2px rgb(var(--hud-cyan-rgb) / 0.12); + } + + .lan-input-label.is-code { + flex: 1; + } + + .lan-action-row, + .lan-join-row { + flex-wrap: wrap; + } + + .lan-mode-btn, + .lan-action-row button, + .lan-join-row button, + .versus-strip button, + .room-card { + min-block-size: 1.42rem; + border: 1px solid rgb(var(--hud-cyan-rgb) / 0.38); + border-radius: 999px; + padding: 0.18rem 0.52rem; + background: rgb(var(--hud-surface-alt-rgb) / 0.74); + color: rgb(var(--hud-text-main-rgb) / 0.96); + font: inherit; + font-size: 0.56rem; + letter-spacing: 0.05em; + cursor: pointer; + } + + .lan-mode-btn:hover, + .lan-action-row button:hover:not(:disabled), + .lan-join-row button:hover:not(:disabled), + .versus-strip button:hover:not(:disabled), + .room-card:hover { + border-color: rgb(var(--hud-lime-rgb) / 0.54); + box-shadow: 0 0 10px rgb(var(--hud-lime-rgb) / 0.1); + } + + .lan-action-row button:disabled, + .lan-join-row button:disabled, + .versus-strip button:disabled { + opacity: 0.42; + cursor: not-allowed; + } + + .lan-action-row button.is-danger { + border-color: rgb(var(--hud-orange-rgb) / 0.46); + color: rgb(var(--hud-orange-rgb) / 0.96); + background: rgb(var(--hud-surface-deep-rgb) / 0.82); + } + + .pair-code-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + border: 1px solid rgb(var(--hud-lime-rgb) / 0.36); + border-radius: 0.5rem; + padding: 0.36rem 0.48rem; + background: rgb(var(--hud-surface-alt-rgb) / 0.58); + } + + .pair-code-card span { + color: rgb(var(--hud-text-dim-rgb) / 0.84); + font-size: 0.5rem; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .pair-code-card strong { + color: rgb(var(--hud-lime-rgb) / 0.96); + font-size: 1rem; + letter-spacing: 0.18em; + } + + .room-list { + display: grid; + gap: 0.24rem; + } + + .room-card { + inline-size: 100%; + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 0.4rem; + border-radius: 0.44rem; + text-align: left; + } + + .room-card span { + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .room-card strong { + color: rgb(var(--hud-lime-rgb) / 0.96); + letter-spacing: 0.12em; + } + + .room-card em { + color: rgb(var(--hud-text-dim-rgb) / 0.82); + font-style: normal; + } + + .lan-notice { + margin: 0; + color: rgb(var(--hud-orange-rgb) / 0.94); + } + + .versus-strip { + justify-content: stretch; + align-items: stretch; + } + + .versus-strip article { + flex: 1; + min-inline-size: 0; + border: 1px solid rgb(var(--hud-border-rgb) / 0.26); + border-radius: 0.46rem; + padding: 0.28rem 0.38rem; + background: rgb(var(--hud-surface-rgb) / 0.58); + } + + .versus-strip article.waiting { + border-style: dashed; + opacity: 0.78; + } + + .versus-strip span, + .versus-strip em { + display: block; + color: rgb(var(--hud-text-dim-rgb) / 0.82); + font-size: 0.48rem; + letter-spacing: 0.07em; + text-transform: uppercase; + font-style: normal; + } + + .versus-strip strong { + display: block; + margin-block: 0.1rem; + overflow: hidden; + color: rgb(var(--hud-text-main-rgb) / 0.96); + font-size: 0.62rem; + letter-spacing: 0.04em; + text-overflow: ellipsis; + white-space: nowrap; + } + .overlay-foot { display: flex; justify-content: flex-start; diff --git a/src/lib/config/pressure-range.ts b/src/lib/config/pressure-range.ts index 243d6aa..e7fb074 100644 --- a/src/lib/config/pressure-range.ts +++ b/src/lib/config/pressure-range.ts @@ -1,2 +1,2 @@ export const DEFAULT_PRESSURE_RANGE_MIN = 0; -export const DEFAULT_PRESSURE_RANGE_MAX = 6000; +export const DEFAULT_PRESSURE_RANGE_MAX = 7000; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 6ca9cf0..3ed695a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -3,9 +3,12 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { LogicalSize, currentMonitor, getCurrentWindow } from "@tauri-apps/api/window"; + import { relaunch } from "@tauri-apps/plugin-process"; + import { check } from "@tauri-apps/plugin-updater"; import HudPanel from "$lib/components/HudPanel.svelte"; import CenterStage from "$lib/components/CenterStage.svelte"; import FileExplorerModal from "$lib/components/FileExplorerModal.svelte"; + import DevKitConfigPanel from "$lib/components/DevKitConfigPanel.svelte"; import { DEFAULT_PRESSURE_RANGE_MAX, DEFAULT_PRESSURE_RANGE_MIN } from "$lib/config/pressure-range"; import { pressureColorPalettes } from "$lib/config/color-map"; import "$lib/styles/theme.css"; @@ -203,6 +206,9 @@ let isRefreshingPorts = false; let connectionNotice = ""; let connectionNoticeTone: HudNoticeTone = "info"; + let updateNoticeVisible = false; + let updateInstallBusy = false; + let pendingUpdate: Awaited> | null = null; let isExporting = false; let deviceValue = "JE-Skin-F"; let sampleRateValue = "100Hz"; @@ -238,6 +244,22 @@ let fileExplorerRoots: FileExplorerRoot[] = []; let fileExplorerSelectedPath = ""; let fileExplorerFileName = ""; + let isDevKitConfigOpen = false; + let devkitEnabled = false; + let devkitRunning = false; + let devkitPort = 50051; + let devkitFramesSent = 0; + let devkitFilterLift = true; + let devkitSaveXlsx = false; + let devkitLastResult: { + outputPath: string; + groupsUsed: number; + meanValue: number; + threshold: number; + rowsTotal: number; + rowsKept: number; + } | null = null; + let devkitStatusTimer: number | null = null; $: uiCopy = copyByLocale[locale]; $: configLinks = buildConfigLinks(locale, activeConfigLinkId, isConfigPanelOpen, isPrecisionTestOpen); @@ -1012,7 +1034,7 @@ settings: "Setup" }; - return [ + const links: HudConfigLink[] = [ { id: "stream-on", label: labels.streamOn, @@ -1044,6 +1066,17 @@ active: isSettingsOpen } ]; + + if (devkitEnabled) { + links.push({ + id: "devkit", + label: "DevKit", + tone: "cyan", + active: isDevKitConfigOpen + }); + } + + return links; } async function ensureDefaultWindowSize(): Promise { @@ -1097,6 +1130,71 @@ const context = canvas.getContext("webgl2"); } + async function checkForAppUpdate(): Promise { + if (!isTauriRuntime()) { + return; + } + + const updateDismissKey = "je-skin-update-dismissed-version"; + + try { + const update = await check(); + if (!update) { + return; + } + + if (window.sessionStorage.getItem(updateDismissKey) === update.version) { + return; + } + + const message = + locale === "zh-CN" + ? `发现新版本 ${update.version},是否现在下载并安装?` + : `Version ${update.version} is available. Download and install now?`; + + pendingUpdate = update; + updateNoticeVisible = true; + updateInstallBusy = false; + connectionNotice = message; + connectionNoticeTone = "info"; + } catch (error) { + console.error("App update check failed:", error); + } + } + + async function handleUpdateConfirm(): Promise { + if (!pendingUpdate || updateInstallBusy) { + return; + } + + updateInstallBusy = true; + connectionNotice = locale === "zh-CN" ? "正在下载并安装更新..." : "Downloading and installing update..."; + connectionNoticeTone = "info"; + + try { + await pendingUpdate.downloadAndInstall(); + await relaunch(); + } catch (error) { + updateInstallBusy = false; + updateNoticeVisible = false; + pendingUpdate = null; + connectionNotice = locale === "zh-CN" ? "更新安装失败,请稍后重试。" : "Update failed. Please try again later."; + connectionNoticeTone = "warn"; + console.error("App update install failed:", error); + } + } + + function handleUpdateCancel(): void { + if (pendingUpdate) { + window.sessionStorage.setItem("je-skin-update-dismissed-version", pendingUpdate.version); + } + + pendingUpdate = null; + updateNoticeVisible = false; + updateInstallBusy = false; + connectionNotice = ""; + } + function handleLocaleChange(event: CustomEvent): void { locale = event.detail; } @@ -1318,6 +1416,57 @@ ? await invoke("serial_export_csv_to_path", { filePath }) : await invoke("serial_export_csv"); + if (devkitEnabled && devkitRunning && devkitFilterLift) { + try { + const processResult = await invoke<{ + ok: boolean; + outputPath: string; + groupsUsed: number; + meanValue: number; + threshold: number; + rowsTotal: number; + rowsKept: number; + message: string; + }>("devkit_process_export", { + csvPath: result.path, + saveAsXlsx: devkitSaveXlsx + }); + + if (processResult.ok) { + devkitLastResult = { + outputPath: processResult.outputPath, + groupsUsed: processResult.groupsUsed, + meanValue: processResult.meanValue, + threshold: processResult.threshold, + rowsTotal: processResult.rowsTotal, + rowsKept: processResult.rowsKept + }; + + connectionNotice = + locale === "zh-CN" + ? `CSV 已导出并完成 DevKit 处理(${result.frameCount} 帧):${processResult.outputPath}` + : `CSV exported and processed by DevKit (${result.frameCount} frames): ${processResult.outputPath}`; + connectionNoticeTone = "ok"; + return true; + } + + connectionNotice = + locale === "zh-CN" + ? `CSV 已导出,但 DevKit 处理失败:${processResult.message}` + : `CSV exported, but DevKit processing failed: ${processResult.message}`; + connectionNoticeTone = "warn"; + return true; + } catch (error) { + connectionNotice = + locale === "zh-CN" + ? "CSV 已导出,但 DevKit 后处理调用失败。" + : "CSV exported, but DevKit post-processing failed."; + connectionNoticeTone = "warn"; + console.error("DevKit export post-process failed:", error); + return true; + } + } + connectionNotice = locale === "zh-CN" ? `CSV 导出成功(${result.frameCount} 帧):${result.path}` @@ -1477,17 +1626,27 @@ if (event.detail === "precision-test") { isPrecisionTestOpen = !isPrecisionTestOpen; isConfigPanelOpen = false; + isDevKitConfigOpen = false; return; } if (event.detail === "settings") { isPrecisionTestOpen = false; isConfigPanelOpen = !isConfigPanelOpen; + isDevKitConfigOpen = false; + return; + } + + if (event.detail === "devkit") { + isPrecisionTestOpen = false; + isConfigPanelOpen = false; + isDevKitConfigOpen = !isDevKitConfigOpen; return; } isPrecisionTestOpen = false; isConfigPanelOpen = false; + isDevKitConfigOpen = false; activeConfigLinkId = event.detail; console.info("[hud] config link clicked:", event.detail); } @@ -1511,6 +1670,55 @@ } } + // ── DevKit Functions ──────────────────────────────────────────── + + async function pollDevKitStatus(): Promise { + if (!isTauriRuntime()) return; + try { + const status = await invoke<{ + enabled: boolean; + running: boolean; + port: number; + framesSent: number; + config: { filterLiftEnabled: boolean; saveAsXlsx: boolean }; + }>("devkit_status"); + devkitEnabled = status.enabled; + devkitRunning = status.running; + devkitPort = status.port; + devkitFramesSent = status.framesSent; + devkitFilterLift = status.config.filterLiftEnabled; + devkitSaveXlsx = status.config.saveAsXlsx; + } catch { + devkitEnabled = false; + devkitRunning = false; + isDevKitConfigOpen = false; + } + } + + async function handleDevKitToggleFilterLift(): Promise { + if (!isTauriRuntime()) return; + try { + const newConfig = { filterLiftEnabled: !devkitFilterLift, saveAsXlsx: devkitSaveXlsx }; + const result = await invoke<{ filterLiftEnabled: boolean; saveAsXlsx: boolean }>("devkit_set_config", { config: newConfig }); + devkitFilterLift = result.filterLiftEnabled; + devkitSaveXlsx = result.saveAsXlsx; + } catch (error) { + console.error("DevKit config update failed:", error); + } + } + + async function handleDevKitToggleXlsx(): Promise { + if (!isTauriRuntime()) return; + try { + const newConfig = { filterLiftEnabled: devkitFilterLift, saveAsXlsx: !devkitSaveXlsx }; + const result = await invoke<{ filterLiftEnabled: boolean; saveAsXlsx: boolean }>("devkit_set_config", { config: newConfig }); + devkitFilterLift = result.filterLiftEnabled; + devkitSaveXlsx = result.saveAsXlsx; + } catch (error) { + console.error("DevKit config update failed:", error); + } + } + function handleMatrixDisplayToggle(event: CustomEvent): void { matrixDisplayMode = event.detail ? "dots" : "numeric"; } @@ -1526,6 +1734,9 @@ if (isTauriRuntime()) { void refreshSerialPorts(); + void checkForAppUpdate(); + void pollDevKitStatus(); + devkitStatusTimer = window.setInterval(() => void pollDevKitStatus(), 3000); void startTauriHudStream(applyPacket) .then((unlisten) => { if (disposed) { @@ -1547,6 +1758,10 @@ pauseReplayPlayback(); stopMockFeed?.(); unlistenHudStream?.(); + if (devkitStatusTimer != null) { + window.clearInterval(devkitStatusTimer); + devkitStatusTimer = null; + } }; }); @@ -1591,6 +1806,10 @@ importActionLabel={uiCopy.importActionLabel} {connectionNotice} {connectionNoticeTone} + noticeConfirmLabel={locale === "zh-CN" ? "确定" : "Confirm"} + noticeCancelLabel={locale === "zh-CN" ? "取消" : "Cancel"} + noticeShowActions={updateNoticeVisible} + noticeActionBusy={updateInstallBusy} {configLinks} {isRefreshingPorts} {isExporting} @@ -1606,7 +1825,12 @@ on:serialconnect={handleSerialConnect} on:serialexport={handleSerialExportRequest} on:csvimport={handleReplayImportRequest} - on:noticeclear={() => (connectionNotice = "")} + on:noticeclear={() => { + connectionNotice = ""; + updateNoticeVisible = false; + }} + on:noticeconfirm={handleUpdateConfirm} + on:noticecancel={handleUpdateCancel} /> + + {#if isDevKitConfigOpen && devkitEnabled} + + {/if}