XToys 玩具多多

接管XToys的自定义串口功能,替换为通过蓝牙控制玩具(当前支持若仙)。

// ==UserScript==
// @name          More XToys
// @name:zh-CN    XToys 玩具多多
// @namespace     https://github.com/forest-devil/
// @version       1.46
// @description   Takes over XToys's custom serial port toy functionality, replacing it with custom Bluetooth toys (currently supporting Roussan).
// @description:zh-CN 接管XToys的自定义串口功能,替换为通过蓝牙控制玩具(当前支持若仙)。
// @author        forest-devil & Gemini
// @license       Apache
// @match         https://xtoys.app/*
// @grant         unsafeWindow
// @grant         GM_log
// @grant         GM_registerMenuCommand
// @grant         GM_unregisterMenuCommand
// @grant         GM_getValue
// @grant         GM_setValue
// @run-at        document-start
// @icon          https://xtoys.app/icons/favicon-96x96.png
// ==/UserScript==

(() => {
    'use strict';

    // --- 配置区域 ---
    /**
     * @type {boolean} 调试模式开关
     * 设置为 true: 启用无设备调试模式。脚本将不会连接真实蓝牙设备,而是将数据流打印到控制台。
     * 设置为 false: 正常模式,脚本将尝试连接真实的蓝牙设备。
     */
    let DEBUG_MODE = GM_getValue("DEBUG_MODE", false);
    // 用于调试模式下生成友好设备名的计数器
    let debugDeviceCounter = 0;

    // --- 模拟 USB ID 常量 ---
    // 这些常量用于为模拟串口设备创建唯一的 USB ID
    const MOCK_USB_VENDOR_ID = 0x1A86;
    // MOCK_USB_PRODUCT_ID_BASE 提供了产品 ID 的起始基数,用于生成唯一 ID
    const MOCK_USB_PRODUCT_ID_BASE = 0x7523;

    /**
     * 切换调试模式并刷新页面。
     */
    function toggleDebugMode() {
        DEBUG_MODE = !DEBUG_MODE;
        GM_setValue("DEBUG_MODE", DEBUG_MODE);
        console.log(`[Xtoys 玩具多多] 调试模式: ${DEBUG_MODE ? '已开启' : '已关闭'}`);
        location.reload();
    }

    // 注册菜单命令来切换调试模式
    GM_registerMenuCommand(
        `切换调试模式 (当前: ${DEBUG_MODE ? '开启' : '关闭'})`, // 菜单项显示的文本
        toggleDebugMode
    );

    // 在这里定义所有支持的蓝牙协议
    const PROTOCOLS = {
        "Roussan": {
            serviceUUID: "fe400001-b5a3-f393-e0a9-e50e24dcca9e",
            writeUUID: "fe400002-b5a3-f393-e0a9-e50e24dcca9e",
            notifyUUID: "fe400003-b5a3-f393-e0a9-e50e24dcca9e",
            /**
             * 将输入的指令转换为蓝牙数据包
             * @param {object} command - 从模拟串口接收到的JSON对象
             * @returns {Uint8Array | null} - 转换后的数据包或在无效输入时返回null
             */
            transform: command => {
                // 优先使用 vibrate,其次是 Speed
                let speed = command.vibrate ?? command.speed ?? null;
                if (speed === null) {
                    console.warn('[Xtoys 玩具多多] 指令对象中不包含 "vibrate" 或 "speed" 键。', command);
                    return null;
                }
                // 确保速度值在 0-100 范围内
                speed = Math.max(0, Math.min(100, parseInt(speed, 10)));
                // 若仙协议只支持30档速度,需要从0~100转换为0~30
                // 将速度值从 0-100 映射到 0-30
                speed = Math.round(speed * 0.3);
                // 封装数据包: 55 AA 03 01 XX 00
                return new Uint8Array([0x55, 0xAA, 0x03, 0x01, speed, 0x00]);
            }
        }
        // 未来可以在这里添加更多协议...
    };

    // --- 脚本核心逻辑 ---

    console.info('[Xtoys 玩具多多] 脚本已加载。正在等待拦截串口请求...');
    if (DEBUG_MODE) {
        console.warn('[Xtoys 玩具多多] 调试模式已启用。将不使用真实蓝牙设备。');
    }

    /**
     * @typedef {Object} BleConnectionState
     * @property {BluetoothDevice} device - 蓝牙设备对象
     * @property {BluetoothRemoteGATTServer} server - GATT 服务器对象
     * @property {BluetoothRemoteGATTCharacteristic} writeCharacteristic - 写入特性
     * @property {BluetoothRemoteGATTCharacteristic | null} notifyCharacteristic - 通知特性 (可能没有)
     * @property {Object} activeProtocol - 当前设备使用的协议配置
     * @property {MockSerialPort} mockPortInstance - 关联的模拟 SerialPort 实例
     * @property {string | null} xtoysDeviceId - XToys 为此设备提供的 'id' 字段 (如果有)。
     * @property {number} usbVendorId - 此设备的模拟 USB 供应商 ID。
     * @property {number} usbProductId - 此设备的设备的模拟 USB 产品 ID。
     */
    const activeConnections = new Map(); // 使用 Map 存储,键可以是 device.id

    /**
     * 重置并移除某个设备的连接状态
     * @param {string} deviceId -设备的ID
     */
    function removeConnectionState(deviceId) {
        if (!activeConnections.has(deviceId)) {
            return;
        }

        const state = activeConnections.get(deviceId);
        // 获取更友好的设备名称,用于日志
        const deviceFriendlyName = state.device?.name || `ID: ${state.device?.id}`;

        if (state.notifyCharacteristic) {
            try {
                state.notifyCharacteristic.removeEventListener('characteristicvaluechanged', handleNotifications);
                state.notifyCharacteristic.stopNotifications();
            } catch (e) {
                // 优化日志信息:根据设备是否已断开来判断是否为预期错误
                if (!state.device?.gatt?.connected) {
                    console.info(`[Xtoys 玩具多多] 设备 ${deviceFriendlyName} 已断开,停止通知时出现预期错误: ${e.message}`);
                } else {
                    console.error(`[Xtoys 玩具多多] 设备 ${deviceFriendlyName} 停止通知时出现未知错误: ${e.message}`);
                }
            }
        }
        // 检查 state.device 和 state.device.gatt 是否存在
        if (state.device?.gatt?.connected) {
            // 捕获 disconnect() Promise 的拒绝,避免 Uncaught (in promise) 错误
            state.device.gatt.disconnect().catch(e => {
                console.warn(`[Xtoys 玩具多多] 断开设备 ${deviceFriendlyName} GATT 连接时发生错误 (可能已断开):`, e);
            });
        }

        activeConnections.delete(deviceId);
        console.info(`[Xtoys 玩具多多] 已断开并移除设备 ${deviceFriendlyName} 的连接状态。当前活跃连接数: ${activeConnections.size}`);
    }

    /**
     * 处理来自蓝牙设备的通知
     * @param {Event} event - 特性值改变事件
     */
    function handleNotifications(event) {
        const { value, target: characteristic } = event;
        const { service } = characteristic;
        const { id: deviceId } = service.device;
        const deviceFriendlyName = activeConnections.get(deviceId)?.device?.name || `ID: ${deviceId}`;
        console.debug(`[Xtoys 玩具多多] 收到来自设备 ${deviceFriendlyName} 的通知:`, value);
        // 在这里可以添加对通知数据的处理逻辑,例如推送到对应的 readable stream
        // 由于Xtoys主要使用 writable stream,此处暂时只做日志输出
    }

    /**
     * 伪造的 requestPort 函数,用于替代原始的 navigator.serial.requestPort
     * 每次调用都会尝试连接一个新设备
     */
    async function mockRequestPort() {
        console.info('[Xtoys 玩具多多] 已拦截 navigator.serial.requestPort() 调用。等待设备选择...');

        // 调试模式逻辑
        if (DEBUG_MODE) {
            // 在调试模式下,每次请求都创建一个新的模拟端口
            const defaultProtocolName = Object.keys(PROTOCOLS)[0];
            if (!defaultProtocolName) {
                console.error('[Xtoys 玩具多多 调试模式] 未定义任何协议。无法在调试模式下运行。');
                return Promise.reject(new Error("未定义任何协议。"));
            }
            const activeProtocol = PROTOCOLS?.[defaultProtocolName];

            // 递增计数器,用于生成调试设备的唯一 ID
            debugDeviceCounter++;

            // 为调试设备生成唯一的友好名称和模拟 USB ID
            const debugDeviceId = `debug-device-${Date.now()}-${debugDeviceCounter}`;
            const debugDeviceName = `调试设备 #${debugDeviceCounter}`;
            const mockUsbProductId = MOCK_USB_PRODUCT_ID_BASE + debugDeviceCounter; // 为每个调试设备生成唯一的 Product ID

            console.info(`[Xtoys 玩具多多 调试模式] 正在使用协议 "${defaultProtocolName}" 模拟设备 "${debugDeviceName}"。`);
            console.info(`[Xtoys 玩具多多 调试模式] 生成的设备ID: ${debugDeviceId}, 模拟 USB 产品ID: 0x${mockUsbProductId.toString(16)}`);

            const debugConnectionState = {
                device: { id: debugDeviceId, name: debugDeviceName }, // 模拟 device 对象,包含友好名称
                server: null,
                writeCharacteristic: null, // 在调试模式下不实际使用
                notifyCharacteristic: null, // 不实际使用
                activeProtocol,
                mockPortInstance: null, // 将在 createMockSerialPort 中赋值
                xtoysDeviceId: null, // 调试设备初始没有 XToys Device ID
                usbVendorId: MOCK_USB_VENDOR_ID,
                usbProductId: mockUsbProductId
            };
            activeConnections.set(debugDeviceId, debugConnectionState);
            const mockPort = createMockSerialPort(debugConnectionState);
            debugConnectionState.mockPortInstance = mockPort;

            return mockPort;
        }

        // 真实蓝牙设备连接逻辑
        let device;
        try {
            //let filters = Object.values(PROTOCOLS).map(p => ({ services: [p.serviceUUID] }));
            // 获取所有协议的服务UUID,用于 optionalServices
            let optionalServices = Object.values(PROTOCOLS).map(p => p.serviceUUID);

            console.info('[Xtoys 玩具多多] 正在请求蓝牙设备,搜索条件:', /*filters*/ '所有设备');
            console.info('[Xtoys 玩具多多] 可选服务:', optionalServices); // 新增日志

            device = await navigator.bluetooth.requestDevice({
                acceptAllDevices: true,
                optionalServices: optionalServices // 添加 optionalServices
            });
            const deviceFriendlyName = device.name || `ID: ${device.id}`;
            console.info('[Xtoys 玩具多多] 已选择设备: %s', deviceFriendlyName);

            // 检查该设备是否已连接,如果已连接则复用其模拟端口
            if (activeConnections.has(device.id) && activeConnections.get(device.id).device?.gatt?.connected) {
                console.info(`[Xtoys 玩具多多] 设备 ${deviceFriendlyName} 已连接。正在复用现有模拟端口。`);
                return activeConnections.get(device.id).mockPortInstance;
            }

            // 添加 GATT 断开连接事件监听器
            device.addEventListener('gattserverdisconnected', function() {
                console.warn(`[Xtoys 玩具多多] 蓝牙设备 ${deviceFriendlyName} 已断开连接。`);
                removeConnectionState(device.id); // 移除该设备的连接状态
            });

            console.info(`[Xtoys 玩具多多] 正在连接设备 ${deviceFriendlyName} 的 GATT 服务器...`);
            const server = await device.gatt.connect();

            let activeProtocol = null;
            let writeCharacteristic = null;
            let notifyCharacteristic = null;

            for (const protocolName in PROTOCOLS) {
                const protocol = PROTOCOLS?.[protocolName];
                try {
                    const service = await server.getPrimaryService(protocol.serviceUUID);
                    if (service) {
                        console.info(`[Xtoys 玩具多多] 在设备 ${deviceFriendlyName} 上找到匹配的协议服务: "${protocolName}"`);
                        activeProtocol = protocol;
                        writeCharacteristic = await service.getCharacteristic(protocol.writeUUID);
                        try {
                            notifyCharacteristic = await service.getCharacteristic(protocol.notifyUUID);
                            await notifyCharacteristic.startNotifications();
                            console.info('[Xtoys 玩具多多] 已订阅通知。');
                            notifyCharacteristic.addEventListener('characteristicvaluechanged', handleNotifications);
                        } catch (notifyError) {
                            console.warn('[Xtoys 玩具多多] 无法获取或订阅通知特性。', notifyError.message);
                        }
                        break; // 找到一个匹配的协议就停止
                    }
                } catch (serviceError) {
                    // 直接在 serviceError 捕获块中给出用户提示
                    const userMessage = `尝试连接设备 ${deviceFriendlyName} 上的控制服务失败。请检查设备是否已开启且在范围内,并尝试重新连接。\n详细错误: ${serviceError.message}`;
                    showUserMessage('[Xtoys 玩具多多] 连接失败', userMessage); // 显示UI提示
                    console.error(`[Xtoys 玩具多多] ${userMessage}`); // 同时输出到控制台
                    throw serviceError; // 重新抛出错误,让外层catch捕获
                }
            }

            if (!activeProtocol || !writeCharacteristic) {
                // 如果代码执行到这里,说明没有找到匹配的协议或写入特性,
                // 且之前的 try-catch 并没有捕获到错误(例如,getPrimaryService返回null但未抛出错误)。
                // 此时抛出更通用的错误,外层catch会处理。
                const userMessage = `在所选设备 ${deviceFriendlyName} 上找不到匹配的协议或写入特性。请确保设备支持所选协议。`;
                showUserMessage('[Xtoys 玩具多多] 连接失败', userMessage); // 显示UI提示
                console.error(`[Xtoys 玩具多多] ${userMessage}`); // 同时输出到控制台
                throw new Error(userMessage); // 仍然抛出错误以保持Promise链的拒绝
            }

            // 为真实蓝牙设备生成一个唯一的模拟 USB 产品 ID
            // 使用 Date.now() 的一部分,以确保每次连接的 Product ID 都是不同的
            const mockUsbProductId = MOCK_USB_PRODUCT_ID_BASE + (Date.now() % 10000);

            // 存储新的连接状态
            const newConnectionState = {
                device,
                server,
                writeCharacteristic,
                notifyCharacteristic,
                activeProtocol,
                mockPortInstance: null, // 将在 createMockSerialPort 中赋值
                xtoysDeviceId: null, // 真实设备初始没有 XToys Device ID
                usbVendorId: MOCK_USB_VENDOR_ID,
                usbProductId: mockUsbProductId
            };
            activeConnections.set(device.id, newConnectionState); // 将新连接存储到 Map 中

            console.info(`[Xtoys 玩具多多] 设备 ${deviceFriendlyName} 蓝牙连接成功并准备就绪。模拟 USB 产品ID: 0x${mockUsbProductId.toString(16)}。当前活跃连接数: ${activeConnections.size}`);
            const mockPort = createMockSerialPort(newConnectionState);
            newConnectionState.mockPortInstance = mockPort; // 将模拟端口实例也保存到状态中

            return mockPort;

        } catch (error) {
            const deviceFriendlyName = device?.name || (device?.id ? `ID: ${device.id}` : '未知设备');
            let userMessageTitle = '[Xtoys 玩具多多] 连接失败';
            let userMessage = `尝试连接蓝牙设备 ${deviceFriendlyName} 时发生错误。`;
            let suppressUiMessage = false; // 标记是否抑制UI消息

            // 检查是否是用户取消选择 (NotFoundError)
            if (error.name === 'NotFoundError') {
                userMessage = '蓝牙设备选择已取消。'; // 更精确的控制台消息
                console.info(`[Xtoys 玩具多多] ${userMessage}`); // 记录为 info
                suppressUiMessage = true; // 抑制UI对话框
            }
            // 检查是否是连接或通信失败 (NetworkError 或特定消息)
            else if (error.name === 'NetworkError' || error.message.includes('GATT operation failed') || error.message.includes('Failed to connect')) {
                userMessage = `设备 ${deviceFriendlyName} 连接或通信失败。请检查设备是否已开启且在范围内,并尝试重新连接。`;
                console.error(`[Xtoys 玩具多多] ${userMessageTitle}: ${userMessage}\n原始错误:`, error);
            }
            // 对于 Origin is not allowed to access any service 错误
            else if (error.name === 'SecurityError' && error.message.includes('Origin is not allowed to access any service')) {
                userMessage = `连接设备 ${deviceFriendlyName} 失败:浏览器安全策略限制了对蓝牙服务的访问。请确保所有要访问的服务 UUID 都已添加到 'optionalServices' 中。`;
                console.error(`[Xtoys 玩具多多] ${userMessageTitle}: ${userMessage}\n原始错误:`, error);
            }
            // 对于其他未被内层 serviceError 捕获的通用错误,或内层 serviceError 重新抛出的错误,使用通用提示
            else {
                 userMessage = `连接过程中发生未知错误: ${error.message}。请尝试重新连接。`;
                 console.error(`[Xtoys 玩具多多] ${userMessageTitle}: ${userMessage}\n原始错误:`, error);
            }

            // 只有在不抑制UI消息的情况下才显示
            if (!suppressUiMessage) {
                showUserMessage(userMessageTitle, userMessage);
            }

            // 如果连接失败,并且设备ID已经存在于 Map 中(可能是在选择设备后但在连接前失败),则将其移除
            if (device && activeConnections.has(device.id)) {
                removeConnectionState(device.id);
            }
            return Promise.reject(error); // 仍然拒绝 Promise
        }
    }

    /**
     * 创建一个模拟的 SerialPort 对象,并将其绑定到特定的连接状态
     * @param {BleConnectionState} connectionState - 当前设备的连接状态
     * @returns {object} 一个包含可写流的模拟 Port 对象
     */
    function createMockSerialPort(connectionState) {
        // 获取更友好的设备名称,用于日志和 getInfo().deviceName
        // 优先使用 XToys Device ID,然后是连接状态中的设备名称,最后是通用的蓝牙 ID 字符串
        const deviceFriendlyName = connectionState.xtoysDeviceId || connectionState.device?.name || `蓝牙设备 ${connectionState.device?.id?.substring(0, 8)}`;

        const mockPort = {
            // 这个可写流将作为所有来自 XToys 的传入命令的路由器
            writable: new WritableStream({
                async write(chunk) {
                    try {
                        const commandStr = new TextDecoder().decode(chunk);
                        const command = JSON.parse(commandStr);
                        console.debug(`[Xtoys 玩具多多] 收到原始命令 (来自 ${deviceFriendlyName}):`, command);

                        const incomingCommandId = command.id;

                        // 根据 XToys 的保证:在一次连接中,给设备传输的所有命令的id是一致的。
                        // 因此,如果命令中包含 ID,并且它与当前连接的 xtoysDeviceId 不同,
                        // 我们就更新 xtoysDeviceId。这确保了 xtoysDeviceId 始终是 XToys 正在使用的 ID。
                        if (incomingCommandId && connectionState.xtoysDeviceId !== incomingCommandId) {
                            connectionState.xtoysDeviceId = incomingCommandId;
                            console.info(`[Xtoys 玩具多多] 设备 '${connectionState.device.name}' (内部ID: ${connectionState.device.id}) 现在映射到 XToys Device ID '${incomingCommandId}'。`);
                        }

                        // 使用当前 connectionState 的协议转换命令
                        const dataPacket = connectionState.activeProtocol?.transform(command);

                        if (dataPacket) {
                            const hexString = `${Array.from(dataPacket).map(b => b.toString(16).padStart(2, '0')).join(' ')}`;

                            if (DEBUG_MODE) {
                                // 使用最合适的名称进行调试日志
                                const logDeviceName = connectionState.xtoysDeviceId || connectionState.device?.name || connectionState.device?.id;
                                console.debug(`[Xtoys 玩具多多 调试模式] 正在路由到设备 '${logDeviceName}' (XToys Device ID: ${incomingCommandId || 'N/A'})。发送 HEX 数据: ${hexString}`);
                            } else {
                                if (!connectionState.writeCharacteristic) {
                                    console.error(`[Xtoys 玩具多多] 设备 '${connectionState.device?.name || connectionState.device?.id}' 写入失败: 此端口的蓝牙特性不可用。`);
                                    throw new Error("此端口的蓝牙特性不可用。");
                                }
                                const logDeviceName = connectionState.xtoysDeviceId || connectionState.device?.name || connectionState.device?.id;
                                console.debug(`[Xtoys 玩具多多] 正在路由到设备 '${logDeviceName}' (XToys Device ID: ${incomingCommandId || 'N/A'})。已转换为 HEX ${hexString} 并发送到蓝牙设备...`);
                                await connectionState.writeCharacteristic.writeValueWithoutResponse(dataPacket);
                            }
                        } else {
                            console.warn(`[Xtoys 玩具多多] 设备 '${connectionState.device?.name || connectionState.device?.id}' 未生成数据包,跳过写入操作。`);
                        }
                    } catch (error) {
                        console.error(`[Xtoys 玩具多多] 设备 ${deviceFriendlyName} 处理和写入数据失败:`, error);
                        // 如果是 JSON 解析错误,则特别记录
                        if (error instanceof SyntaxError && error.message.includes("JSON.parse")) {
                            console.error(`[Xtoys 玩具多多] 可能收到非 JSON 数据: ${new TextDecoder().decode(chunk).substring(0, 100)}...`);
                        }
                        throw error;
                    }
                },
                close: () => {
                    console.info(`[Xtoys 玩具多多] 设备 ${deviceFriendlyName} 的可写流已关闭。`);
                    if (!DEBUG_MODE && connectionState.device?.gatt?.connected) {
                        removeConnectionState(connectionState.device.id);
                    }
                },
                abort: (err) => {
                    console.error(`[Xtoys 玩具多多] 设备 ${deviceFriendlyName} 的可写流已中止:`, err);
                    if (!DEBUG_MODE && connectionState.device?.gatt?.connected) {
                        removeConnectionState(connectionState.device.id);
                    }
                }
            }),
            readable: new ReadableStream({
                start: (controller) => { console.info(`[Xtoys 玩具多多] 设备 ${deviceFriendlyName} 的模拟可读流已启动。`); }
                // TODO: 如果需要从蓝牙设备接收数据并推送到 ReadableStream,需要在这里实现
                // 例如:在 handleNotifications 中将数据 enqueue 到 controller
            }),
            getInfo: () => ({
                usbVendorId: connectionState.usbVendorId,
                usbProductId: connectionState.usbProductId,
                bluetoothServiceClassId: connectionState.activeProtocol?.serviceUUID || '',
                deviceId: connectionState.device?.id,
                // 优先使用 XToys Device ID 作为显示名称,然后是内部设备名称,最后是通用蓝牙 ID
                deviceName: connectionState.xtoysDeviceId || connectionState.device?.name || `蓝牙设备 ${connectionState.device?.id?.substring(0, 8)}`
            }),
            open: async (options) => {
                console.info(`[Xtoys 玩具多多] 设备 ${deviceFriendlyName} 的模拟端口已使用选项打开:`, options);
                return Promise.resolve();
            },
            close: async () => {
                console.info(`[Xtoys 玩具多多] 设备 ${deviceFriendlyName} 的模拟端口 close() 方法被调用。`);
                try {
                    if (this.writable && !this.writable.locked) {
                        await this.writable.close();
                    }
                } catch (e) {
                    // 优化:处理预期的 'Cannot close a ERRORED writable stream' 错误
                    if (e.message.includes("Cannot close a ERRORED writable stream")) {
                        console.warn(`[Xtoys 玩具多多] 关闭设备 ${deviceFriendlyName} 的可写流时出错 (预期的错误,流可能已中止): ${e.message}`);
                    } else {
                        // 对于其他非预期错误,仍然作为警告或错误处理
                        console.warn(`[Xtoys 玩具多多] 关闭设备 ${deviceFriendlyName} 的可写流时出错: ${e.message}`);
                    }
                }
            },
            setSignals: async () => {},
            getSignals: async () => ({}),
            forget: async () => {
                console.info(`[Xtoys 玩具多多] 设备 ${deviceFriendlyName} 的 forget 方法被调用。正在断开连接。`);
                await mockPort.close();
            }
        };

        console.info(`[Xtoys 玩具多多] 已为设备 ${deviceFriendlyName} 创建模拟 SerialPort 对象。`, mockPort);
        return mockPort;
    }

    // --- 最终覆盖 ---
    if (unsafeWindow.navigator?.serial) {
        unsafeWindow.navigator.serial.requestPort = mockRequestPort;
        // 覆盖 getPorts 以返回所有当前活跃的模拟端口
        unsafeWindow.navigator.serial.getPorts = async () => {
            return Array.from(activeConnections.values()).map(conn => conn.mockPortInstance).filter(Boolean);
        };
        console.info('[Xtoys 玩具多多] 已成功拦截 navigator.serial.requestPort 和 getPorts。');
    } else {
        // 如果 navigator.serial 不存在,则创建完整的模拟 API
        Object.defineProperty(unsafeWindow.navigator, 'serial', {
            value: {
                requestPort: mockRequestPort,
                getPorts: async () => {
                    return Array.from(activeConnections.values()).map(conn => conn.mockPortInstance).filter(Boolean);
                }
            },
            writable: true
        });
        console.warn('[Xtoys 玩具多多] 未找到 navigator.serial,已创建模拟 API。');
    }

    // --- 前端UI提示框相关变量和函数 ---
    const customDialog = {};

    // 创建并添加对话框UI到DOM
    function createDialogUI() {
        // 创建一个临时的div来解析HTML字符串
        const tempDiv = document.createElement('div');
        tempDiv.innerHTML = `
            <div id="more-xtoys-message-dialog" role="dialog" aria-modal="true" class="q-dialog fullscreen no-pointer-events q-dialog--modal hidden" style="--q-transition-duration: 300ms;">
                <div class="q-dialog__backdrop fixed-full" aria-hidden="true" tabindex="-1" style="--q-transition-duration: 300ms;"></div>
                <div class="q-dialog__inner flex no-pointer-events q-dialog__inner--minimized q-dialog__inner--standard fixed-full flex-center" tabindex="-1" style="--q-transition-duration: 300ms;">
                    <div class="q-card q-card--dark q-dark column no-wrap" style="max-width: 900px; min-width: 400px;">
                        <div class="q-toolbar row no-wrap items-center text-white bg-primary-7" role="toolbar">
                            <div id="more-xtoys-dialog-title" class="q-toolbar__title ellipsis"></div>
                            <button id="more-xtoys-dialog-close-btn" class="q-btn q-btn-item non-selectable no-outline q-btn--flat q-btn--rectangle q-btn--actionable q-focusable q-hoverable q-btn--dense" tabindex="0" type="button">
                                <span class="q-focus-helper"></span>
                                <span class="q-btn__content text-center col items-center q-anchor--skip justify-center row">
                                    <i class="q-icon fas fa-times" aria-hidden="true" role="img"> </i>
                                </span>
                            </button>
                        </div>
                        <div class="q-card__section q-card__section--vert scroll q-pa-md" style="max-height: 85vh;">
                            <p id="more-xtoys-dialog-message" class="text-white"></p>
                        </div>
                        <div class="q-card__actions justify-end q-card__actions--horiz row">
                            <button id="more-xtoys-dialog-confirm-btn" class="q-btn q-btn-item non-selectable no-outline q-btn--standard q-btn--rectangle bg-green text-white q-btn--actionable q-focusable q-hoverable" tabindex="0" type="button">
                                <span class="q-focus-helper"></span>
                                <span class="q-btn__content text-center col items-center q-anchor--skip justify-center row">
                                    <span class="block">确认</span>
                                </span>
                            </button>
                        </div>
                    </div>
                </div>
            </div>
        `;

        // 从临时div中获取根对话框元素
        customDialog.messageDialog = tempDiv.firstElementChild;
        document.body.appendChild(customDialog.messageDialog);

        // 重新获取所有内部元素的引用
        customDialog.backdrop = customDialog.messageDialog.querySelector('.q-dialog__backdrop');
        customDialog.titleElement = customDialog.messageDialog.querySelector('#more-xtoys-dialog-title');
        customDialog.messageElement = customDialog.messageDialog.querySelector('#more-xtoys-dialog-message');
        customDialog.closeButton = customDialog.messageDialog.querySelector('#more-xtoys-dialog-close-btn');
        customDialog.confirmButton = customDialog.messageDialog.querySelector('#more-xtoys-dialog-confirm-btn');

        // 重新绑定事件监听器
        customDialog.messageDialog.addEventListener('click', (event) => {
            // Check if the click occurred on the backdrop or the close/confirm buttons
            if (event.target === customDialog.backdrop ||
                event.target === customDialog.closeButton ||
                event.target === customDialog.confirmButton ||
                customDialog.closeButton.contains(event.target) ||
                customDialog.confirmButton.contains(event.target)) {
                customDialog.messageDialog.classList.add('hidden');
                customDialog.messageDialog.classList.add('no-pointer-events'); // 重新添加
            }
        });
    }

    // 显示用户消息的函数
    function showUserMessage(title, message) {
        if (!customDialog.messageDialog || !document.body.contains(customDialog.messageDialog)) {
            createDialogUI(); // 确保对话框存在并已添加到DOM
        }
        customDialog.titleElement.textContent = title;
        customDialog.messageElement.textContent = message;
        // 显示模态对话框
        customDialog.messageDialog.classList.remove('hidden');
        customDialog.messageDialog.classList.remove('no-pointer-events'); // 移除以允许交互
    }

    // 在DOM内容加载完毕后创建UI
    document.addEventListener('DOMContentLoaded', createDialogUI);

})();