修改了后台视频录制等功能

This commit is contained in:
root 2026-02-04 16:05:48 +08:00
parent 5059ad6158
commit cd35871476
7 changed files with 905 additions and 167 deletions

View File

@ -1103,12 +1103,15 @@ class DatabaseManager:
cursor.execute(''' cursor.execute('''
INSERT INTO detection_video ( INSERT INTO detection_video (
id, session_id, screen_video, timestamp id, session_id, screen_video, body_video, foot_video1, foot_video2, timestamp
) VALUES (?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?)
''', ( ''', (
video_id, video_id,
session_id, session_id,
video.get('screen_video_path'), video.get('screen_video_path'),
video.get('body_video_path'),
video.get('foot_video1_path'),
video.get('foot_video2_path'),
china_time china_time
)) ))
@ -1120,6 +1123,50 @@ class DatabaseManager:
logger.error(f'保存检测视频失败: {e}') logger.error(f'保存检测视频失败: {e}')
return False return False
def update_detection_video_latest(self, session_id: str, video: Dict[str, Any]) -> bool:
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute('''
SELECT id, screen_video, body_video, foot_video1, foot_video2
FROM detection_video
WHERE session_id = ?
ORDER BY timestamp DESC
LIMIT 1
''', (session_id,))
row = cursor.fetchone()
if not row:
return self.save_detection_video(session_id, video)
row_dict = dict(row)
video_id = row_dict.get('id')
screen_video = video.get('screen_video_path')
body_video = video.get('body_video_path')
foot_video1 = video.get('foot_video1_path')
foot_video2 = video.get('foot_video2_path')
if not screen_video:
screen_video = row_dict.get('screen_video')
if not body_video:
body_video = row_dict.get('body_video')
if not foot_video1:
foot_video1 = row_dict.get('foot_video1')
if not foot_video2:
foot_video2 = row_dict.get('foot_video2')
cursor.execute('''
UPDATE detection_video
SET screen_video = ?, body_video = ?, foot_video1 = ?, foot_video2 = ?
WHERE id = ?
''', (screen_video, body_video, foot_video1, foot_video2, video_id))
conn.commit()
return True
except Exception as e:
conn.rollback()
logger.error(f'更新检测视频失败: {e}')
return False
def delete_detection_video(self, video_ids: Union[str, List[str]]) -> bool: def delete_detection_video(self, video_ids: Union[str, List[str]]) -> bool:
"""删除检测视频记录支持单个或多个ID""" """删除检测视频记录支持单个或多个ID"""
conn = self.get_connection() conn = self.get_connection()

View File

@ -14,6 +14,7 @@ from typing import Optional, Dict, Any
import logging import logging
import queue import queue
import gc import gc
import os
try: try:
from .base_device import BaseDevice from .base_device import BaseDevice
@ -110,7 +111,19 @@ class CameraManager(BaseDevice):
} }
# 全局帧队列(用于录制) # 全局帧队列(用于录制)
self.frame_queue = queue.Queue(maxsize=10) # 最大长度10自动丢弃旧帧 self.frame_queue = queue.Queue(maxsize=10)
self._recording_enabled = False
self._recording_session_id = None
self._recording_frames_dir = None
self._recording_target_fps = None
self._recording_last_ts = 0.0
self._recording_index = 0
self._recording_written = 0
self._recording_drop = 0
self._recording_queue = queue.Queue(maxsize=300)
self._recording_thread = None
self._recording_stop_event = threading.Event()
# 属性缓存机制 - 避免重复设置相同属性值 # 属性缓存机制 - 避免重复设置相同属性值
self._property_cache = {} self._property_cache = {}
@ -126,6 +139,130 @@ class CameraManager(BaseDevice):
self.logger.info(f"相机管理器初始化完成 - 设备索引: {self.device_index}") self.logger.info(f"相机管理器初始化完成 - 设备索引: {self.device_index}")
def start_jpeg_recording(self, session_id: str, frames_dir: str, record_fps: Optional[int] = None) -> Dict[str, Any]:
try:
if not session_id:
return {'success': False, 'message': '缺少session_id'}
if not frames_dir:
return {'success': False, 'message': '缺少frames_dir'}
try:
os.makedirs(frames_dir, exist_ok=True)
except Exception as e:
return {'success': False, 'message': f'创建录制目录失败: {e}'}
if record_fps is None:
record_fps = int(self.config_manager.get_config_value('CAMERA_RECORDING', 'fps', fallback='10'))
record_fps = max(1, int(record_fps))
self._recording_session_id = session_id
self._recording_frames_dir = frames_dir
self._recording_target_fps = record_fps
self._recording_last_ts = 0.0
self._recording_index = 0
self._recording_written = 0
self._recording_drop = 0
self._recording_stop_event.clear()
self._recording_enabled = True
if not self._recording_thread or not self._recording_thread.is_alive():
self._recording_thread = threading.Thread(
target=self._recording_writer_loop,
name=f"{self.device_id}-JpegWriter",
daemon=True
)
self._recording_thread.start()
return {'success': True, 'message': '相机JPEG录制已启动', 'device_id': self.device_id, 'fps': record_fps, 'frames_dir': frames_dir}
except Exception as e:
return {'success': False, 'message': str(e)}
def stop_jpeg_recording(self, session_id: Optional[str] = None) -> Dict[str, Any]:
try:
if session_id and self._recording_session_id and session_id != self._recording_session_id:
return {'success': False, 'message': 'session_id不匹配'}
self._recording_enabled = False
self._recording_session_id = None
frames_dir = self._recording_frames_dir
fps = self._recording_target_fps
written = int(self._recording_written)
dropped = int(self._recording_drop)
self._recording_frames_dir = None
self._recording_target_fps = None
self._recording_last_ts = 0.0
self._recording_stop_event.set()
if self._recording_thread and self._recording_thread.is_alive():
self._recording_thread.join(timeout=2.0)
self._recording_thread = None
while not self._recording_queue.empty():
try:
self._recording_queue.get_nowait()
except queue.Empty:
break
self._recording_stop_event.clear()
return {
'success': True,
'message': '相机JPEG录制已停止',
'device_id': self.device_id,
'frames_dir': frames_dir,
'fps': fps,
'frames_written': written,
'frames_dropped': dropped
}
except Exception as e:
return {'success': False, 'message': str(e)}
def _recording_writer_loop(self):
while not self._recording_stop_event.is_set():
try:
item = self._recording_queue.get(timeout=0.2)
except queue.Empty:
continue
try:
frames_dir, idx, jpeg_bytes = item
if not frames_dir or not jpeg_bytes:
continue
filename = f"frame_{idx:06d}.jpg"
fpath = os.path.join(frames_dir, filename)
with open(fpath, 'wb') as f:
f.write(jpeg_bytes)
self._recording_written += 1
except Exception:
self._recording_drop += 1
finally:
try:
self._recording_queue.task_done()
except Exception:
pass
def _maybe_enqueue_recording(self, timestamp: float, frame_bytes: bytes):
if not self._recording_enabled:
return
frames_dir = self._recording_frames_dir
target_fps = self._recording_target_fps
if not frames_dir or not target_fps or target_fps <= 0:
return
if self._recording_last_ts > 0:
min_interval = 1.0 / float(target_fps)
if (timestamp - self._recording_last_ts) < min_interval:
return
idx = self._recording_index
self._recording_index += 1
self._recording_last_ts = timestamp
try:
self._recording_queue.put_nowait((frames_dir, idx, frame_bytes))
except queue.Full:
self._recording_drop += 1
def _set_property_optimized(self, prop, value): def _set_property_optimized(self, prop, value):
""" """
优化的属性设置方法使用缓存避免重复设置 优化的属性设置方法使用缓存避免重复设置
@ -649,23 +786,6 @@ class CameraManager(BaseDevice):
# 更新心跳时间,防止连接监控线程判定为超时 # 更新心跳时间,防止连接监控线程判定为超时
self.update_heartbeat() self.update_heartbeat()
# 保存原始帧到队列(用于录制)
try:
self.frame_queue.put_nowait({
'frame': frame.copy(),
'timestamp': time.time()
})
except queue.Full:
# 队列满时丢弃最旧的帧,添加新帧
try:
self.frame_queue.get_nowait() # 移除最旧的帧
self.frame_queue.put_nowait({
'frame': frame.copy(),
'timestamp': time.time()
})
except queue.Empty:
pass # 队列为空,忽略
# 处理帧(降采样以优化传输负载) # 处理帧(降采样以优化传输负载)
processed_frame = self._process_frame(frame) processed_frame = self._process_frame(frame)
@ -740,6 +860,7 @@ class CameraManager(BaseDevice):
# 转换为bytes再做base64减少中间numpy对象的长生命周期 # 转换为bytes再做base64减少中间numpy对象的长生命周期
frame_bytes = buffer.tobytes() frame_bytes = buffer.tobytes()
self._maybe_enqueue_recording(time.time(), frame_bytes)
frame_data = base64.b64encode(frame_bytes).decode('utf-8') frame_data = base64.b64encode(frame_bytes).decode('utf-8')
# 发送数据 # 发送数据
@ -986,6 +1107,21 @@ class CameraManager(BaseDevice):
except queue.Empty: except queue.Empty:
break break
self._recording_enabled = False
self._recording_session_id = None
self._recording_frames_dir = None
self._recording_target_fps = None
self._recording_last_ts = 0.0
self._recording_stop_event.set()
if self._recording_thread and self._recording_thread.is_alive():
self._recording_thread.join(timeout=2.0)
self._recording_thread = None
while not self._recording_queue.empty():
try:
self._recording_queue.get_nowait()
except queue.Empty:
break
self.last_frame = None self.last_frame = None
super().cleanup() super().cleanup()

View File

@ -13,7 +13,8 @@ import signal
import base64 import base64
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional, Callable
import threading
try: try:
import pyautogui import pyautogui
@ -39,11 +40,38 @@ class RecordingManager:
# FFmpeg进程管理 # FFmpeg进程管理
self._ffmpeg_processes = {} self._ffmpeg_processes = {}
self._ffmpeg_meta = {} self._ffmpeg_meta = {}
self._transcode_threads = {}
# 默认参数 # 默认参数
self.screen_fps = 25 self.screen_fps = 25
self.screen_size = self._get_screen_size() self.screen_size = self._get_screen_size()
def _resolve_ffmpeg_path(self) -> Optional[str]:
ffmpeg_path = None
if self.config_manager:
ffmpeg_path = (
self.config_manager.get_config_value('SCREEN_RECORDING', 'ffmpeg_path', fallback=None) or
self.config_manager.get_config_value('RECORDING', 'ffmpeg_path', fallback=None)
)
if ffmpeg_path and os.path.isfile(str(ffmpeg_path)):
return str(ffmpeg_path)
base_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(os.path.abspath(__file__))
alt_path = os.path.join(base_dir, 'ffmpeg', 'bin', 'ffmpeg.exe')
if os.path.isfile(alt_path):
return alt_path
return None
def get_active_video_base(self, session_id: str) -> Optional[Dict[str, Any]]:
meta = self._ffmpeg_meta.get('screen') or {}
if meta.get('session_id') != session_id:
return None
return {
'base_path': meta.get('base_path'),
'file_dir': meta.get('file_dir'),
'patient_id': meta.get('patient_id'),
'session_id': meta.get('session_id')
}
def _get_screen_size(self): def _get_screen_size(self):
try: try:
import pyautogui import pyautogui
@ -82,18 +110,8 @@ class RecordingManager:
os.makedirs(base_path, exist_ok=True) os.makedirs(base_path, exist_ok=True)
screen_video_path = os.path.join(base_path, 'screen.mp4') screen_video_path = os.path.join(base_path, 'screen.mp4')
target_fps = fps or self.screen_fps target_fps = fps or self.screen_fps
ffmpeg_path = None ffmpeg_path = self._resolve_ffmpeg_path()
if self.config_manager: if not ffmpeg_path:
ffmpeg_path = (
self.config_manager.get_config_value('SCREEN_RECORDING', 'ffmpeg_path', fallback=None) or
self.config_manager.get_config_value('RECORDING', 'ffmpeg_path', fallback=None)
)
if not ffmpeg_path or not os.path.isfile(str(ffmpeg_path)):
base_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(os.path.abspath(__file__))
alt_path = os.path.join(base_dir, 'ffmpeg', 'bin', 'ffmpeg.exe')
if os.path.isfile(alt_path):
ffmpeg_path = alt_path
else:
result['message'] = '未配置有效的ffmpeg_path请在配置中设置 SCREEN_RECORDING.ffmpeg_path 或 RECORDING.ffmpeg_path' result['message'] = '未配置有效的ffmpeg_path请在配置中设置 SCREEN_RECORDING.ffmpeg_path 或 RECORDING.ffmpeg_path'
return result return result
@ -136,7 +154,7 @@ class RecordingManager:
creationflags=getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0) creationflags=getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)
) )
self._ffmpeg_processes['screen'] = proc self._ffmpeg_processes['screen'] = proc
self._ffmpeg_meta['screen'] = {'base_path': base_path, 'patient_id': patient_id, 'session_id': session_id, 'video_path': screen_video_path} self._ffmpeg_meta['screen'] = {'base_path': base_path, 'file_dir': file_dir, 'patient_id': patient_id, 'session_id': session_id, 'video_path': screen_video_path}
result['success'] = True result['success'] = True
result['message'] = 'ffmpeg录制已启动' result['message'] = 'ffmpeg录制已启动'
result['database_updates'] = { result['database_updates'] = {
@ -151,6 +169,86 @@ class RecordingManager:
result['message'] = f'ffmpeg启动失败: {e}' result['message'] = f'ffmpeg启动失败: {e}'
return result return result
def transcode_jpeg_sequence_async(
self,
name: str,
frames_dir: str,
output_mp4_path: str,
fps: int,
on_done: Optional[Callable[[int], None]] = None
) -> Dict[str, Any]:
try:
if not frames_dir or not os.path.isdir(frames_dir):
return {'success': False, 'message': 'frames_dir不存在'}
try:
os.makedirs(os.path.dirname(output_mp4_path), exist_ok=True)
except Exception:
pass
ffmpeg_path = self._resolve_ffmpeg_path()
if not ffmpeg_path:
return {'success': False, 'message': 'ffmpeg_path无效'}
codec = (
self.config_manager.get_config_value('CAMERA_RECORDING', 'ffmpeg_codec', fallback=None) or
self.config_manager.get_config_value('SCREEN_RECORDING', 'ffmpeg_codec', fallback=None) or
'libx264'
)
preset = (
self.config_manager.get_config_value('CAMERA_RECORDING', 'ffmpeg_preset', fallback=None) or
self.config_manager.get_config_value('SCREEN_RECORDING', 'ffmpeg_preset', fallback=None) or
('p1' if codec == 'h264_nvenc' else 'ultrafast')
)
threads = int(self.config_manager.get_config_value('CAMERA_RECORDING', 'ffmpeg_threads', fallback='1') or '1')
bframes = int(self.config_manager.get_config_value('CAMERA_RECORDING', 'ffmpeg_bframes', fallback='0') or '0')
gop = int(self.config_manager.get_config_value('CAMERA_RECORDING', 'ffmpeg_gop', fallback=str(max(1, int(fps * 2)))) or str(max(1, int(fps * 2))))
input_pattern = os.path.join(frames_dir, 'frame_%06d.jpg')
cmd = [
str(ffmpeg_path),
'-y',
'-framerate', str(int(max(1, fps))),
'-start_number', '0',
'-i', input_pattern,
'-c:v', str(codec),
'-preset', str(preset),
'-bf', str(bframes),
'-g', str(gop),
'-pix_fmt', 'yuv420p',
'-threads', str(threads),
str(output_mp4_path)
]
def worker():
code = 1
try:
proc = subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)
)
self._ffmpeg_processes[name] = proc
code = proc.wait()
except Exception:
code = 1
finally:
self._ffmpeg_processes.pop(name, None)
self._transcode_threads.pop(name, None)
if on_done:
try:
on_done(int(code))
except Exception:
pass
t = threading.Thread(target=worker, name=f"FFmpegTranscode-{name}", daemon=True)
self._transcode_threads[name] = t
t.start()
return {'success': True, 'message': '转码任务已启动', 'name': name, 'output': output_mp4_path}
except Exception as e:
return {'success': False, 'message': str(e)}
def stop_recording_ffmpeg(self, session_id: str = None) -> Dict[str, Any]: def stop_recording_ffmpeg(self, session_id: str = None) -> Dict[str, Any]:
result = {'success': False, 'message': ''} result = {'success': False, 'message': ''}
try: try:

View File

@ -16,6 +16,7 @@ from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa, padding from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.exceptions import InvalidSignature from cryptography.exceptions import InvalidSignature
import base64 import base64
import shutil
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -51,85 +52,345 @@ class LicenseManager:
def __init__(self, config_manager=None): def __init__(self, config_manager=None):
self.config_manager = config_manager self.config_manager = config_manager
self._machine_id = None self._machine_id = None
self._machine_id_candidates = None
self._license_cache = None self._license_cache = None
self._cache_timestamp = None self._cache_timestamp = None
def _get_backend_base_dir(self) -> str:
return os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
def _get_persistent_license_dir(self) -> str:
system = platform.system()
if system == "Windows":
base = os.environ.get("PROGRAMDATA") or os.environ.get("ALLUSERSPROFILE") or ""
if not base:
base = os.path.expanduser("~\\AppData\\Local")
else:
base = os.path.expanduser("~/.config")
return os.path.join(base, "BodyCheck", "license")
def _get_configured_license_paths(self) -> Tuple[str, str, int]:
license_path = "data/license.json"
public_key_path = "backend/license_pub.pem"
grace_days = 3
if self.config_manager:
license_path = self.config_manager.get_config_value("LICENSE", "path", license_path)
public_key_path = self.config_manager.get_config_value("LICENSE", "public_key", public_key_path)
grace_days = int(self.config_manager.get_config_value("LICENSE", "grace_days", str(grace_days)))
if not os.path.isabs(license_path):
license_path = os.path.join(self._get_backend_base_dir(), license_path)
if not os.path.isabs(public_key_path):
public_key_path = os.path.join(self._get_backend_base_dir(), public_key_path)
return license_path, public_key_path, grace_days
def _get_persistent_license_paths(self) -> Tuple[str, str]:
pdir = self._get_persistent_license_dir()
return os.path.join(pdir, "license.json"), os.path.join(pdir, "license_public_key.pem")
def _resolve_license_paths(self) -> Tuple[str, str, int]:
cfg_license_path, cfg_public_key_path, grace_days = self._get_configured_license_paths()
p_license_path, p_public_key_path = self._get_persistent_license_paths()
effective_license_path = cfg_license_path
if not os.path.exists(effective_license_path) and os.path.exists(p_license_path):
effective_license_path = p_license_path
effective_public_key_path = cfg_public_key_path
if not os.path.exists(effective_public_key_path) and os.path.exists(p_public_key_path):
effective_public_key_path = p_public_key_path
return effective_license_path, effective_public_key_path, grace_days
def _mirror_license_assets(self, license_path: Optional[str] = None, public_key_path: Optional[str] = None) -> None:
p_license_path, p_public_key_path = self._get_persistent_license_paths()
pdir = os.path.dirname(p_license_path)
try:
os.makedirs(pdir, exist_ok=True)
except Exception:
return
if license_path and os.path.exists(license_path):
try:
shutil.copyfile(license_path, p_license_path)
except Exception:
pass
if public_key_path and os.path.exists(public_key_path):
try:
shutil.copyfile(public_key_path, p_public_key_path)
except Exception:
pass
def install_license_file(self, source_license_path: str) -> Tuple[bool, str]:
try:
if not os.path.exists(source_license_path):
return False, "源授权文件不存在"
cfg_license_path, cfg_public_key_path, _ = self._get_configured_license_paths()
os.makedirs(os.path.dirname(cfg_license_path), exist_ok=True)
shutil.copyfile(source_license_path, cfg_license_path)
self._mirror_license_assets(license_path=cfg_license_path, public_key_path=cfg_public_key_path)
return True, cfg_license_path
except Exception as e:
return False, str(e)
def mirror_license_dir(self, source_dir: str) -> None:
if not source_dir or not os.path.isdir(source_dir):
return
license_candidate = os.path.join(source_dir, "license.json")
pub_candidates = [
os.path.join(source_dir, "license_public_key.pem"),
os.path.join(source_dir, "license_pub.pem"),
os.path.join(source_dir, "public_key.pem"),
]
pub_path = ""
for c in pub_candidates:
if os.path.exists(c):
pub_path = c
break
self._mirror_license_assets(license_path=license_candidate if os.path.exists(license_candidate) else None, public_key_path=pub_path or None)
def _run_powershell(self, command: str, timeout: int = 10) -> str:
try:
result = subprocess.run(
[
"powershell",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
command,
],
capture_output=True,
text=True,
timeout=timeout,
)
return (result.stdout or "").strip()
except Exception:
return ""
def _get_windows_cpu_id(self) -> str:
out = self._run_powershell(
"(Get-CimInstance -ClassName Win32_Processor | Select-Object -First 1 -ExpandProperty ProcessorId)"
)
return out.strip()
def _get_windows_baseboard_serial(self) -> str:
out = self._run_powershell(
"(Get-CimInstance -ClassName Win32_BaseBoard | Select-Object -First 1 -ExpandProperty SerialNumber)"
)
serial = out.strip()
if serial and serial != "To be filled by O.E.M.":
return serial
return ""
def _get_windows_disk_serials(self) -> list:
raw = self._run_powershell(
"$d=Get-CimInstance -ClassName Win32_DiskDrive | Select-Object SerialNumber,InterfaceType,PNPDeviceID,MediaType; $d | ConvertTo-Json -Compress"
)
if not raw:
return []
try:
data = json.loads(raw)
except Exception:
return []
disks = data if isinstance(data, list) else ([data] if isinstance(data, dict) else [])
serials = []
for d in disks:
serial = str((d.get("SerialNumber") or "")).strip()
iface = str((d.get("InterfaceType") or "")).strip().upper()
pnp = str((d.get("PNPDeviceID") or "")).strip().upper()
media = str((d.get("MediaType") or "")).strip().upper()
if serial and iface != "USB" and not pnp.startswith("USBSTOR") and "REMOVABLE" not in media:
serials.append(serial)
serials = sorted(set(serials))
return serials
def _get_windows_identifiers(self) -> Dict[str, Any]:
cpu_id = self._get_windows_cpu_id()
board_serial = self._get_windows_baseboard_serial()
disk_serials = self._get_windows_disk_serials()
if not cpu_id:
try:
result = subprocess.run(
["wmic", "cpu", "get", "ProcessorId", "/value"],
capture_output=True,
text=True,
timeout=10,
)
for line in (result.stdout or "").split("\n"):
if "ProcessorId=" in line:
cpu_id = line.split("=", 1)[1].strip()
if cpu_id:
break
except Exception:
pass
if not board_serial:
try:
result = subprocess.run(
["wmic", "baseboard", "get", "SerialNumber", "/value"],
capture_output=True,
text=True,
timeout=10,
)
for line in (result.stdout or "").split("\n"):
if "SerialNumber=" in line:
board_serial = line.split("=", 1)[1].strip()
if board_serial and board_serial != "To be filled by O.E.M.":
break
board_serial = ""
except Exception:
pass
if not disk_serials:
try:
result = subprocess.run(
[
"wmic",
"path",
"Win32_DiskDrive",
"get",
"SerialNumber,InterfaceType,PNPDeviceID,MediaType",
"/value",
],
capture_output=True,
text=True,
timeout=10,
)
block = {}
serials = []
for line in (result.stdout or "").split("\n"):
line = line.strip()
if not line:
serial = (block.get("SerialNumber") or "").strip()
iface = (block.get("InterfaceType") or "").strip().upper()
pnp = (block.get("PNPDeviceID") or "").strip().upper()
media = (block.get("MediaType") or "").strip().upper()
if serial and iface != "USB" and not pnp.startswith("USBSTOR") and "REMOVABLE" not in media:
serials.append(serial)
block = {}
continue
if "=" in line:
k, v = line.split("=", 1)
block[k] = v
if block:
serial = (block.get("SerialNumber") or "").strip()
iface = (block.get("InterfaceType") or "").strip().upper()
pnp = (block.get("PNPDeviceID") or "").strip().upper()
media = (block.get("MediaType") or "").strip().upper()
if serial and iface != "USB" and not pnp.startswith("USBSTOR") and "REMOVABLE" not in media:
serials.append(serial)
disk_serials = sorted(set(serials))
except Exception:
pass
mac = ""
try:
import uuid
mac = ":".join(
["{:02x}".format((uuid.getnode() >> elements) & 0xFF) for elements in range(0, 2 * 6, 2)][::-1]
)
except Exception:
mac = ""
return {
"cpu_id": cpu_id.strip() if cpu_id else "",
"board_serial": board_serial.strip() if board_serial else "",
"disk_serials": disk_serials or [],
"mac": mac,
"node": platform.node(),
"processor": platform.processor(),
}
def _hash_core_info(self, core_info: list) -> str:
combined_info = "|".join(sorted(core_info))
return hashlib.sha256(combined_info.encode("utf-8")).hexdigest()[:16].upper()
def _build_machine_id_candidates(self) -> list:
system = platform.system()
info: Dict[str, Any] = {}
if system == "Windows":
info = self._get_windows_identifiers()
else:
info = {
"cpu_id": "",
"board_serial": "",
"disk_serials": [],
"mac": "",
"node": platform.node(),
"processor": platform.processor(),
}
cpu_id = info.get("cpu_id") or ""
board_serial = info.get("board_serial") or ""
disk_serials = info.get("disk_serials") or []
mac = info.get("mac") or ""
variants = []
full_core = []
if cpu_id:
full_core.append(f"CPU:{cpu_id}")
if board_serial:
full_core.append(f"BOARD:{board_serial}")
for s in disk_serials:
full_core.append(f"DISK:{s}")
if full_core:
variants.append(full_core)
no_disk = []
if cpu_id:
no_disk.append(f"CPU:{cpu_id}")
if board_serial:
no_disk.append(f"BOARD:{board_serial}")
if no_disk:
variants.append(no_disk)
if cpu_id:
variants.append([f"CPU:{cpu_id}"])
if board_serial:
variants.append([f"BOARD:{board_serial}"])
if mac:
variants.append([f"MAC:{mac}"])
if cpu_id and mac:
variants.append([f"CPU:{cpu_id}", f"MAC:{mac}"])
fallback_core = []
node = (info.get("node") or "").strip()
proc = (info.get("processor") or "").strip()
if node:
fallback_core.append(f"NODE:{node}")
if proc:
fallback_core.append(f"PROCESSOR:{proc}")
if fallback_core:
variants.append(fallback_core)
prefix = "W10-" if system == "Windows" else "FB-"
candidates = []
for core in variants:
try:
mid = f"{prefix}{self._hash_core_info(core)}"
except Exception:
continue
if mid not in candidates:
candidates.append(mid)
return candidates
def get_machine_id(self) -> str: def get_machine_id(self) -> str:
"""生成机器硬件指纹""" """生成机器硬件指纹"""
if self._machine_id: if self._machine_id:
return self._machine_id return self._machine_id
try: try:
core_info = [] candidates = self._build_machine_id_candidates()
aux_info = [] if not candidates:
try: raise RuntimeError("无法生成机器指纹")
if platform.system() == "Windows": self._machine_id_candidates = candidates
result = subprocess.run(['wmic', 'cpu', 'get', 'ProcessorId', '/value'], capture_output=True, text=True, timeout=10) self._machine_id = candidates[0]
for line in result.stdout.split('\n'):
if 'ProcessorId=' in line:
cpu_id = line.split('=')[1].strip()
if cpu_id:
core_info.append(f"CPU:{cpu_id}")
break
except Exception as e:
logger.warning(f"获取CPU信息失败: {e}")
try:
if platform.system() == "Windows":
result = subprocess.run(['wmic', 'baseboard', 'get', 'SerialNumber', '/value'], capture_output=True, text=True, timeout=10)
for line in result.stdout.split('\n'):
if 'SerialNumber=' in line:
board_serial = line.split('=')[1].strip()
if board_serial and board_serial != "To be filled by O.E.M.":
core_info.append(f"BOARD:{board_serial}")
break
except Exception as e:
logger.warning(f"获取主板信息失败: {e}")
try:
if platform.system() == "Windows":
# 获取磁盘信息并过滤掉USB/可移动介质,收集所有内部磁盘序列号
result = subprocess.run(
['wmic', 'path', 'Win32_DiskDrive', 'get', 'SerialNumber,InterfaceType,PNPDeviceID,MediaType', '/value'],
capture_output=True, text=True, timeout=10
)
block = {}
for line in result.stdout.split('\n'):
line = line.strip()
if not line:
# 结束一个块
serial = (block.get('SerialNumber') or '').strip()
iface = (block.get('InterfaceType') or '').strip().upper()
pnp = (block.get('PNPDeviceID') or '').strip().upper()
media = (block.get('MediaType') or '').strip().upper()
if serial and iface != 'USB' and not pnp.startswith('USBSTOR') and 'REMOVABLE' not in media:
core_info.append(f"DISK:{serial}")
block = {}
continue
if '=' in line:
k, v = line.split('=', 1)
block[k] = v
# 处理最后一个块
if block:
serial = (block.get('SerialNumber') or '').strip()
iface = (block.get('InterfaceType') or '').strip().upper()
pnp = (block.get('PNPDeviceID') or '').strip().upper()
media = (block.get('MediaType') or '').strip().upper()
if serial and iface != 'USB' and not pnp.startswith('USBSTOR') and 'REMOVABLE' not in media:
core_info.append(f"DISK:{serial}")
except Exception as e:
logger.warning(f"获取磁盘信息失败: {e}")
try:
import uuid
mac = ':'.join(['{:02x}'.format((uuid.getnode() >> elements) & 0xff) for elements in range(0, 2 * 6, 2)][::-1])
aux_info.append(f"MAC:{mac}")
except Exception as e:
logger.warning(f"获取MAC地址失败: {e}")
aux_info.append(f"OS:{platform.system()}")
aux_info.append(f"MACHINE:{platform.machine()}")
if len(core_info) < 1:
core_info.append(f"NODE:{platform.node()}")
core_info.append(f"PROCESSOR:{platform.processor()}")
combined_info = "|".join(sorted(core_info))
machine_id = hashlib.sha256(combined_info.encode('utf-8')).hexdigest()[:16].upper()
self._machine_id = f"W10-{machine_id}"
logger.info(f"生成机器指纹: {self._machine_id}") logger.info(f"生成机器指纹: {self._machine_id}")
return self._machine_id return self._machine_id
except Exception as e: except Exception as e:
@ -137,8 +398,14 @@ class LicenseManager:
fallback_info = f"{platform.system()}-{platform.node()}-{platform.machine()}" fallback_info = f"{platform.system()}-{platform.node()}-{platform.machine()}"
fallback_id = hashlib.md5(fallback_info.encode('utf-8')).hexdigest()[:12].upper() fallback_id = hashlib.md5(fallback_info.encode('utf-8')).hexdigest()[:12].upper()
self._machine_id = f"FB-{fallback_id}" self._machine_id = f"FB-{fallback_id}"
self._machine_id_candidates = [self._machine_id]
return self._machine_id return self._machine_id
def get_machine_id_candidates(self) -> list:
if self._machine_id_candidates is None:
self.get_machine_id()
return list(self._machine_id_candidates or [])
def load_license(self, license_path: str) -> Optional[Dict[str, Any]]: def load_license(self, license_path: str) -> Optional[Dict[str, Any]]:
"""加载授权文件""" """加载授权文件"""
try: try:
@ -231,7 +498,13 @@ class LicenseManager:
# 检查机器绑定 # 检查机器绑定
license_machine_id = license_data.get('machine_id', '') license_machine_id = license_data.get('machine_id', '')
if license_machine_id != machine_id: candidates = []
if machine_id:
candidates.append(machine_id)
for mid in self.get_machine_id_candidates():
if mid not in candidates:
candidates.append(mid)
if license_machine_id not in candidates:
return False, f"授权文件与当前机器不匹配 (当前: {machine_id}, 授权: {license_machine_id})" return False, f"授权文件与当前机器不匹配 (当前: {machine_id}, 授权: {license_machine_id})"
# 检查有效期 # 检查有效期
@ -269,18 +542,7 @@ class LicenseManager:
if not self.config_manager: if not self.config_manager:
return LicenseStatus(valid=False, message="配置管理器未初始化") return LicenseStatus(valid=False, message="配置管理器未初始化")
license_path = self.config_manager.get_config_value('LICENSE', 'path', 'data/license.json') license_path, public_key_path, grace_days = self._resolve_license_paths()
public_key_path = self.config_manager.get_config_value('LICENSE', 'public_key', 'backend/license_pub.pem')
grace_days = int(self.config_manager.get_config_value('LICENSE', 'grace_days', '3'))
# 转换为绝对路径
if not os.path.isabs(license_path):
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # backend目录
license_path = os.path.join(base_dir, license_path)
if not os.path.isabs(public_key_path):
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # backend目录
public_key_path = os.path.join(base_dir, public_key_path)
# 获取机器指纹 # 获取机器指纹
machine_id = self.get_machine_id() machine_id = self.get_machine_id()
@ -309,6 +571,8 @@ class LicenseManager:
self._cache_timestamp = datetime.now().timestamp() self._cache_timestamp = datetime.now().timestamp()
return status return status
self._mirror_license_assets(license_path=license_path, public_key_path=public_key_path)
# 检查有效性 # 检查有效性
is_valid, message = self.check_validity(license_data, machine_id, grace_days) is_valid, message = self.check_validity(license_data, machine_id, grace_days)
@ -362,14 +626,7 @@ class LicenseManager:
if not self.config_manager: if not self.config_manager:
return False, "配置管理器未初始化" return False, "配置管理器未初始化"
# 解析公钥路径与宽限期 _, public_key_path, grace_days = self._resolve_license_paths()
public_key_path = self.config_manager.get_config_value('LICENSE', 'public_key', 'backend/license_pub.pem')
grace_days = int(self.config_manager.get_config_value('LICENSE', 'grace_days', '3'))
# 转换为绝对路径相对backend目录
if not os.path.isabs(public_key_path):
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
public_key_path = os.path.join(base_dir, public_key_path)
if not os.path.exists(license_path): if not os.path.exists(license_path):
return False, f"授权文件不存在: {license_path}" return False, f"授权文件不存在: {license_path}"

View File

@ -10,6 +10,7 @@ import sys
import json import json
import time import time
import threading import threading
import shutil
from datetime import datetime from datetime import datetime
from flask import Flask, jsonify from flask import Flask, jsonify
from flask import request as flask_request from flask import request as flask_request
@ -378,6 +379,10 @@ class AppServer:
'error': '授权管理器未初始化' 'error': '授权管理器未初始化'
}), 500 }), 500
if self.license_manager:
self.license_status = self.license_manager.get_license_status(force_reload=True)
self.app.license_status = self.license_status
return jsonify({ return jsonify({
'success': True, 'success': True,
'data': { 'data': {
@ -548,22 +553,14 @@ class AppServer:
if is_valid: if is_valid:
# 覆盖系统授权文件为上传的文件 # 覆盖系统授权文件为上传的文件
try: ok, result = self.license_manager.install_license_file(temp_path)
license_path_cfg = self.config_manager.get_config_value('LICENSE', 'path', 'data/license.json') if self.config_manager else 'data/license.json' if not ok:
# 解析目标路径为绝对路径 self.logger.error(f'保存授权文件失败: {result}')
if not os.path.isabs(license_path_cfg): return jsonify({'success': False, 'error': f'保存授权文件失败: {result}'}), 500
base_dir = os.path.dirname(os.path.abspath(__file__))
license_path_cfg = os.path.join(base_dir, license_path_cfg)
os.makedirs(os.path.dirname(license_path_cfg), exist_ok=True)
# 移动/覆盖授权文件
import shutil
shutil.copyfile(temp_path, license_path_cfg)
except Exception as e:
self.logger.error(f'保存授权文件失败: {e}')
return jsonify({'success': False, 'error': f'保存授权文件失败: {str(e)}'}), 500
# 更新授权状态(强制刷新) # 更新授权状态(强制刷新)
self.license_status = self.license_manager.get_license_status(force_reload=True) self.license_status = self.license_manager.get_license_status(force_reload=True)
self.app.license_status = self.license_status
return jsonify({ return jsonify({
'success': True, 'success': True,
@ -631,8 +628,11 @@ class AppServer:
return jsonify({'success': False, 'error': '压缩包包含非法路径'}), 400 return jsonify({'success': False, 'error': '压缩包包含非法路径'}), 400
zip_ref.extractall(target_dir) zip_ref.extractall(target_dir)
self.license_manager.mirror_license_dir(target_dir)
# 刷新授权 # 刷新授权
self.license_status = self.license_manager.get_license_status(force_reload=True) self.license_status = self.license_manager.get_license_status(force_reload=True)
self.app.license_status = self.license_status
return jsonify({ return jsonify({
'success': True, 'success': True,
@ -1449,6 +1449,30 @@ class AppServer:
# 使用新的ffmpeg录制方法 # 使用新的ffmpeg录制方法
recording_response = self.recording_manager.start_recording_ffmpeg(session_id, patient_id, screen_location) recording_response = self.recording_manager.start_recording_ffmpeg(session_id, patient_id, screen_location)
base_info = None
try:
base_info = self.recording_manager.get_active_video_base(session_id)
except Exception:
base_info = None
if base_info and base_info.get('base_path'):
base_path = base_info.get('base_path')
try:
cam1 = self.device_coordinator.devices.get('camera1') if self.device_coordinator and hasattr(self.device_coordinator, 'devices') else None
if cam1 and getattr(cam1, 'is_streaming', False):
cam1_frames_dir = os.path.join(base_path, 'camera1_frames')
cam1.start_jpeg_recording(session_id=session_id, frames_dir=cam1_frames_dir)
except Exception as e:
self.logger.error(f'启动camera1 JPEG录制失败: {e}')
try:
cam2 = self.device_coordinator.devices.get('camera2') if self.device_coordinator and hasattr(self.device_coordinator, 'devices') else None
if cam2 and getattr(cam2, 'is_streaming', False):
cam2_frames_dir = os.path.join(base_path, 'camera2_frames')
cam2.start_jpeg_recording(session_id=session_id, frames_dir=cam2_frames_dir)
except Exception as e:
self.logger.error(f'启动camera2 JPEG录制失败: {e}')
# 处理录制管理器返回的数据库更新信息 # 处理录制管理器返回的数据库更新信息
if recording_response and recording_response.get('success') and 'database_updates' in recording_response: if recording_response and recording_response.get('success') and 'database_updates' in recording_response:
db_updates = recording_response['database_updates'] db_updates = recording_response['database_updates']
@ -1456,7 +1480,10 @@ class AppServer:
# 保存检测视频记录(映射到 detection_video 表字段) # 保存检测视频记录(映射到 detection_video 表字段)
video_paths = db_updates.get('video_paths', {}) video_paths = db_updates.get('video_paths', {})
video_record = { video_record = {
'screen_video_path': video_paths.get('screen_video_path') 'screen_video_path': video_paths.get('screen_video_path'),
'body_video_path': None,
'foot_video1_path': None,
'foot_video2_path': None
} }
try: try:
@ -1495,6 +1522,76 @@ class AppServer:
except Exception as rec_e: except Exception as rec_e:
self.logger.error(f'停止同步录制失败: {rec_e}', exc_info=True) self.logger.error(f'停止同步录制失败: {rec_e}', exc_info=True)
raise raise
base_info = None
try:
base_info = self.recording_manager.get_active_video_base(session_id)
except Exception:
base_info = None
if base_info and base_info.get('base_path') and base_info.get('file_dir'):
base_path = base_info.get('base_path')
file_dir = base_info.get('file_dir')
cam1 = self.device_coordinator.devices.get('camera1') if self.device_coordinator and hasattr(self.device_coordinator, 'devices') else None
cam2 = self.device_coordinator.devices.get('camera2') if self.device_coordinator and hasattr(self.device_coordinator, 'devices') else None
if cam1:
try:
cam1_stop = cam1.stop_jpeg_recording(session_id=session_id)
cam1_frames_dir = cam1_stop.get('frames_dir')
cam1_fps = int(cam1_stop.get('fps') or 10)
if cam1_frames_dir and os.path.isdir(cam1_frames_dir):
out1 = os.path.join(base_path, 'foot1.mp4')
rel1 = os.path.relpath(out1, file_dir).replace('\\', '/')
def _done1(code: int):
if code == 0:
try:
self.db_manager.update_detection_video_latest(session_id, {'foot_video1_path': rel1})
except Exception as e:
self.logger.error(f'更新foot_video1失败: {e}')
try:
shutil.rmtree(cam1_frames_dir, ignore_errors=True)
except Exception as e:
self.logger.error(f'删除camera1_frames失败: {e}')
self.recording_manager.transcode_jpeg_sequence_async(
name=f'{session_id}-camera1',
frames_dir=cam1_frames_dir,
output_mp4_path=out1,
fps=cam1_fps,
on_done=_done1
)
except Exception as e:
self.logger.error(f'停止/转码camera1录制失败: {e}')
if cam2:
try:
cam2_stop = cam2.stop_jpeg_recording(session_id=session_id)
cam2_frames_dir = cam2_stop.get('frames_dir')
cam2_fps = int(cam2_stop.get('fps') or 10)
if cam2_frames_dir and os.path.isdir(cam2_frames_dir):
out2 = os.path.join(base_path, 'foot2.mp4')
rel2 = os.path.relpath(out2, file_dir).replace('\\', '/')
def _done2(code: int):
if code == 0:
try:
self.db_manager.update_detection_video_latest(session_id, {'foot_video2_path': rel2})
except Exception as e:
self.logger.error(f'更新foot_video2失败: {e}')
try:
shutil.rmtree(cam2_frames_dir, ignore_errors=True)
except Exception as e:
self.logger.error(f'删除camera2_frames失败: {e}')
self.recording_manager.transcode_jpeg_sequence_async(
name=f'{session_id}-camera2',
frames_dir=cam2_frames_dir,
output_mp4_path=out2,
fps=cam2_fps,
on_done=_done2
)
except Exception as e:
self.logger.error(f'停止/转码camera2录制失败: {e}')
return jsonify({'success': True,'msg': '停止录制成功'}) return jsonify({'success': True,'msg': '停止录制成功'})
except Exception as e: except Exception as e:
self.logger.error(f'停止检测失败: {e}', exc_info=True) self.logger.error(f'停止检测失败: {e}', exc_info=True)

View File

@ -0,0 +1,64 @@
import os
from datetime import datetime, timedelta, timezone
import pytest
class DummyConfigManager:
def __init__(self, values):
self._values = values
def get_config_value(self, section, key, fallback=None):
return self._values.get((section, key), fallback)
def test_check_validity_accepts_candidate_machine_id(monkeypatch):
from devices.utils.license_manager import LicenseManager
lm = LicenseManager(config_manager=DummyConfigManager({}))
monkeypatch.setattr(lm, "get_machine_id_candidates", lambda: ["MID-PRIMARY", "MID-ALT"])
license_data = {
"product": "BodyBalanceEvaluation",
"license_id": "L1",
"license_type": "full",
"machine_id": "MID-ALT",
"issued_at": datetime.now(timezone.utc).isoformat(),
"expires_at": (datetime.now(timezone.utc) + timedelta(days=1)).isoformat().replace("+00:00", "Z"),
"signature": "x",
"features": {"export": True},
}
ok, msg = lm.check_validity(license_data, machine_id="MID-PRIMARY", grace_days=0)
assert ok is True, msg
def test_resolve_license_paths_falls_back_to_persistent(monkeypatch, tmp_path):
from devices.utils.license_manager import LicenseManager
programdata = tmp_path / "programdata"
persistent_dir = programdata / "BodyCheck" / "license"
persistent_dir.mkdir(parents=True)
(persistent_dir / "license.json").write_text('{"a":1}', encoding="utf-8")
(persistent_dir / "license_public_key.pem").write_text("PUB", encoding="utf-8")
cfg_dir = tmp_path / "cfg"
cfg_license_path = str(cfg_dir / "license.json")
cfg_pub_path = str(cfg_dir / "license_public_key.pem")
cfg = DummyConfigManager(
{
("LICENSE", "path"): cfg_license_path,
("LICENSE", "public_key"): cfg_pub_path,
("LICENSE", "grace_days"): "3",
}
)
lm = LicenseManager(config_manager=cfg)
monkeypatch.setenv("PROGRAMDATA", str(programdata))
monkeypatch.setattr("platform.system", lambda: "Windows")
license_path, pub_path, grace_days = lm._resolve_license_paths()
assert license_path == str(persistent_dir / "license.json")
assert pub_path == str(persistent_dir / "license_public_key.pem")
assert grace_days == 3

View File

@ -3,7 +3,7 @@ const path = require('path');
const http = require('http'); const http = require('http');
const fs = require('fs'); const fs = require('fs');
const url = require('url'); const url = require('url');
const { spawn } = require('child_process'); const { spawn, exec, execSync } = require('child_process');
let mainWindow; let mainWindow;
let localServer; let localServer;
let backendProcess; let backendProcess;
@ -11,6 +11,24 @@ let splashWindow;
// app.disableHardwareAcceleration(); // app.disableHardwareAcceleration();
app.disableDomainBlockingFor3DAPIs(); app.disableDomainBlockingFor3DAPIs();
console.log('Electron version:', process.versions.electron); console.log('Electron version:', process.versions.electron);
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.focus();
return;
}
if (app.isReady()) {
createWindow();
}
});
}
ipcMain.handle('generate-report-pdf', async (event, payload) => { ipcMain.handle('generate-report-pdf', async (event, payload) => {
const win = BrowserWindow.fromWebContents(event.sender); const win = BrowserWindow.fromWebContents(event.sender);
if (!win) throw new Error('窗口未找到'); if (!win) throw new Error('窗口未找到');
@ -134,6 +152,21 @@ ipcMain.handle('generate-report-pdf', async (event, payload) => {
}); });
function startBackendService() { function startBackendService() {
if (backendProcess) {
console.log('Backend service already started (tracked).');
return;
}
try {
const tasklistOut = execSync('tasklist /FI "IMAGENAME eq BodyBalanceBackend.exe" /NH', { windowsHide: true }).toString();
if (tasklistOut && tasklistOut.toLowerCase().includes('bodybalancebackend.exe')) {
console.log('Backend service already running (tasklist). Skip spawning a new instance.');
return;
}
} catch (e) {
console.log('Backend process detection failed, continue to spawn:', e && e.message ? e.message : e);
}
// 在打包后的应用中使用process.resourcesPath获取resources目录 // 在打包后的应用中使用process.resourcesPath获取resources目录
const resourcesPath = process.resourcesPath || path.join(__dirname, '../..'); const resourcesPath = process.resourcesPath || path.join(__dirname, '../..');
const backendPath = path.join(resourcesPath, 'backend/BodyBalanceBackend/BodyBalanceBackend.exe'); const backendPath = path.join(resourcesPath, 'backend/BodyBalanceBackend/BodyBalanceBackend.exe');
@ -199,7 +232,6 @@ function stopBackendService() {
// 强制杀死所有BodyBalanceBackend.exe进程 // 强制杀死所有BodyBalanceBackend.exe进程
try { try {
const { exec } = require('child_process');
exec('taskkill /f /im BodyBalanceBackend.exe', (error, stdout, stderr) => { exec('taskkill /f /im BodyBalanceBackend.exe', (error, stdout, stderr) => {
if (error) { if (error) {
// 如果没有找到进程taskkill会返回错误这是正常的 // 如果没有找到进程taskkill会返回错误这是正常的
@ -300,6 +332,11 @@ function createWindow() {
} }
function startLocalServer(callback) { function startLocalServer(callback) {
if (localServer) {
console.log('Local server already started on http://localhost:3000');
callback();
return;
}
const staticPath = path.join(__dirname, '../dist/'); const staticPath = path.join(__dirname, '../dist/');
localServer = http.createServer((req, res) => { localServer = http.createServer((req, res) => {
@ -360,9 +397,10 @@ function startLocalServer(callback) {
// 应用事件处理 // 应用事件处理
// 关闭硬件加速以规避 GPU 进程异常导致的闪烁 // 关闭硬件加速以规避 GPU 进程异常导致的闪烁
// app.disableHardwareAcceleration(); // app.disableHardwareAcceleration();
app.whenReady().then(createWindow); if (gotSingleInstanceLock) {
app.whenReady().then(createWindow);
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
if (localServer) { if (localServer) {
localServer.close(); localServer.close();
@ -371,15 +409,16 @@ app.on('window-all-closed', () => {
stopBackendService(); stopBackendService();
app.quit(); app.quit();
} }
}); });
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
createWindow(); createWindow();
} }
}); });
// 应用退出前清理资源 // 应用退出前清理资源
app.on('before-quit', () => { app.on('before-quit', () => {
stopBackendService(); stopBackendService();
}); });
}