diff --git a/BLE_tcp_bridge/AppConfig.cs b/BLE_tcp_bridge/AppConfig.cs index 86affe32..e199d8fd 100644 --- a/BLE_tcp_bridge/AppConfig.cs +++ b/BLE_tcp_bridge/AppConfig.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Linq; using System.Text; @@ -39,7 +39,7 @@ public static AppConfig Load() } catch (Exception ex) { - Console.WriteLine("配置文件读取失败: " + ex.Message); + Console.WriteLine(BridgeText.T("configReadError", ex.Message)); return CreateDefault(); } } @@ -60,7 +60,7 @@ public void Save() } catch (Exception ex) { - Console.WriteLine("配置文件保存失败: " + ex.Message); + Console.WriteLine(BridgeText.T("configWriteError", ex.Message)); } } diff --git a/BLE_tcp_bridge/BLE_tcp_driver.csproj b/BLE_tcp_bridge/BLE_tcp_driver.csproj index dc6da56b..af96d9cf 100644 --- a/BLE_tcp_bridge/BLE_tcp_driver.csproj +++ b/BLE_tcp_bridge/BLE_tcp_driver.csproj @@ -68,6 +68,7 @@ Form1.cs + @@ -75,6 +76,15 @@ Form1.cs + + BLE_tcp_driver.Messages.en.resources + + + BLE_tcp_driver.Messages.ru.resources + + + BLE_tcp_driver.Messages.zh.resources + ResXFileCodeGenerator Resources.Designer.cs @@ -115,4 +125,4 @@ - \ No newline at end of file + diff --git a/BLE_tcp_bridge/BridgeText.cs b/BLE_tcp_bridge/BridgeText.cs new file mode 100644 index 00000000..77e9825a --- /dev/null +++ b/BLE_tcp_bridge/BridgeText.cs @@ -0,0 +1,60 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Resources; + +namespace BLE_tcp_driver +{ + /// UI text only; language never changes wire data or diagnostic IDs. + internal static class BridgeText + { + private static readonly ResourceManager English = Manager("en"); + private static ResourceManager selected = English; + internal static string Language { get; private set; } = "en"; + + private static ResourceManager Manager(string language) => + new ResourceManager("BLE_tcp_driver.Messages." + language, typeof(BridgeText).Assembly); + + internal static string Normalize(string language) + { + string code = (language ?? "").Trim().ToLowerInvariant().Split('-', '_')[0]; + return code == "ru" || code == "zh" ? code : "en"; + } + + internal static void Initialize(string[] arguments, string preferencesPath, string systemLanguage) + { + string explicitLanguage = arguments.FirstOrDefault(arg => + arg.StartsWith("--language=", StringComparison.OrdinalIgnoreCase)); + string language = explicitLanguage == null ? null : explicitLanguage.Substring(11); + if (string.IsNullOrWhiteSpace(language)) + { + try + { + if (File.Exists(preferencesPath)) + foreach (string line in File.ReadLines(preferencesPath)) + { + string entry = line.Trim(); + if (entry.StartsWith("#") || entry.StartsWith("!")) continue; + int delimiter = entry.IndexOfAny(new[] { '=', ':' }); + if (delimiter > 0 && entry.Substring(0, delimiter).Trim() == "AhaKeySelectedLanguage") + language = entry.Substring(delimiter + 1).Trim(); + } + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + Language = Normalize(string.IsNullOrWhiteSpace(language) ? systemLanguage : language); + selected = Manager(Language); + } + + internal static string T(string key, params object[] arguments) + { + string template = selected.GetString(key, CultureInfo.InvariantCulture) + ?? English.GetString(key, CultureInfo.InvariantCulture); + if (template == null) throw new InvalidOperationException("Missing bridge translation: " + key); + return arguments.Length == 0 ? template + : string.Format(CultureInfo.GetCultureInfo(Language), template, arguments); + } + } +} diff --git a/BLE_tcp_bridge/Form1.Designer.cs b/BLE_tcp_bridge/Form1.Designer.cs index a53ffda9..40335415 100644 --- a/BLE_tcp_bridge/Form1.Designer.cs +++ b/BLE_tcp_bridge/Form1.Designer.cs @@ -59,13 +59,13 @@ private void InitializeComponent() this.BtnConnect.Name = "BtnConnect"; this.BtnConnect.Size = new System.Drawing.Size(75, 23); this.BtnConnect.TabIndex = 5; - this.BtnConnect.Text = "连接"; + this.BtnConnect.Text = BridgeText.T("connect"); this.BtnConnect.UseVisualStyleBackColor = true; this.BtnConnect.Click += new System.EventHandler(this.BtnConnect_Click); // // rtbMsg // - this.rtbMsg.Font = new System.Drawing.Font("微软雅黑", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(134))); + this.rtbMsg.Font = new System.Drawing.Font("Consolas", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(134))); this.rtbMsg.Location = new System.Drawing.Point(0, 116); this.rtbMsg.Name = "rtbMsg"; this.rtbMsg.ReadOnly = true; @@ -83,7 +83,7 @@ private void InitializeComponent() this.label_connected_devices.Name = "label_connected_devices"; this.label_connected_devices.Size = new System.Drawing.Size(95, 12); this.label_connected_devices.TabIndex = 7; - this.label_connected_devices.Text = "当前连接设备:无"; + this.label_connected_devices.Text = BridgeText.T("connectedDevice", BridgeText.T("none")); // // label_ip_port // @@ -92,7 +92,7 @@ private void InitializeComponent() this.label_ip_port.Name = "label_ip_port"; this.label_ip_port.Size = new System.Drawing.Size(131, 12); this.label_ip_port.TabIndex = 8; - this.label_ip_port.Text = "当前服务器地址及端口:"; + this.label_ip_port.Text = BridgeText.T("server", "—", 0, 0); // // checkBox_start_mode // @@ -101,14 +101,14 @@ private void InitializeComponent() this.checkBox_start_mode.Name = "checkBox_start_mode"; this.checkBox_start_mode.Size = new System.Drawing.Size(108, 16); this.checkBox_start_mode.TabIndex = 9; - this.checkBox_start_mode.Text = "下次最小化启动"; + this.checkBox_start_mode.Text = BridgeText.T("startMinimized"); this.checkBox_start_mode.UseVisualStyleBackColor = true; // // notifyIcon1 // this.notifyIcon1.ContextMenuStrip = this.trayContextMenu; this.notifyIcon1.Icon = ((System.Drawing.Icon)(resources.GetObject("notifyIcon1.Icon"))); - this.notifyIcon1.Text = "BLE TCP Bridge"; + this.notifyIcon1.Text = BridgeText.T("tray"); this.notifyIcon1.Visible = true; this.notifyIcon1.MouseClick += new System.Windows.Forms.MouseEventHandler(this.notifyIcon1_MouseClick); // @@ -123,7 +123,7 @@ private void InitializeComponent() // this.tsmiExit.Name = "tsmiExit"; this.tsmiExit.Size = new System.Drawing.Size(124, 22); - this.tsmiExit.Text = "退出程序"; + this.tsmiExit.Text = BridgeText.T("exit"); this.tsmiExit.Click += new System.EventHandler(this.tsmiExit_Click); // // button1 @@ -132,7 +132,7 @@ private void InitializeComponent() this.button1.Name = "button1"; this.button1.Size = new System.Drawing.Size(75, 23); this.button1.TabIndex = 10; - this.button1.Text = "退出程序"; + this.button1.Text = BridgeText.T("exit"); this.button1.UseVisualStyleBackColor = true; this.button1.Click += new System.EventHandler(this.button1_Click); // @@ -143,7 +143,7 @@ private void InitializeComponent() this.checkBox_follow_system.Name = "checkBox_follow_system"; this.checkBox_follow_system.Size = new System.Drawing.Size(96, 16); this.checkBox_follow_system.TabIndex = 11; - this.checkBox_follow_system.Text = "跟随系统启动"; + this.checkBox_follow_system.Text = BridgeText.T("startWithWindows"); this.checkBox_follow_system.UseVisualStyleBackColor = true; this.checkBox_follow_system.CheckedChanged += new System.EventHandler(this.checkBox_follow_system_CheckedChanged); // diff --git a/BLE_tcp_bridge/Form1.cs b/BLE_tcp_bridge/Form1.cs index c3b6d05c..8db0855f 100644 --- a/BLE_tcp_bridge/Form1.cs +++ b/BLE_tcp_bridge/Form1.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Drawing; using System.Linq; @@ -26,6 +26,7 @@ public partial class Form1 : Form public Form1() { InitializeComponent(); + ConfigureLocalizedLayout(); // Stable title used by Studio to restore/adopt this exact bridge window. Text = "AhaKey BLE TCP Driver"; config = AppConfig.Load(); @@ -50,6 +51,32 @@ protected override void SetVisibleCore(bool value) base.SetVisibleCore(value); } + private void ConfigureLocalizedLayout() + { + // Flow layout accommodates translated text and Windows font scaling. + Font = new Font("Segoe UI", 9F); + MinimumSize = new Size(680, 420); + var header = new TableLayoutPanel + { + Dock = DockStyle.Top, AutoSize = true, ColumnCount = 1, + Padding = new Padding(12) + }; + var connection = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; + DeviceSelect.Width = 240; + BtnConnect.AutoSize = true; + button1.AutoSize = true; + connection.Controls.AddRange(new Control[] { DeviceSelect, BtnConnect, button1 }); + var options = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; + options.Controls.AddRange(new Control[] { checkBox_start_mode, checkBox_follow_system }); + header.Controls.Add(connection); + header.Controls.Add(label_connected_devices); + header.Controls.Add(label_ip_port); + header.Controls.Add(options); + rtbMsg.Dock = DockStyle.Fill; + rtbMsg.Font = new Font("Consolas", 10F); + Controls.Add(header); + } + private void log(Color c, string message) { BeginInvoke(new Action(() => @@ -68,7 +95,7 @@ private void Form1_Load(object sender, EventArgs e) if (_loaded) return; _loaded = true; - this.Opacity = 0.8; + this.Opacity = 1.0; // 同步checkbox checkBox_start_mode.Checked = config.StartMinimized; @@ -97,11 +124,11 @@ private void Form1_Load(object sender, EventArgs e) { BeginInvoke(new Action(() => { - label_ip_port.Text = $"TCP服务: {TcpServer.GetLocalIPAddress()}:{config.ServerPort} (客户端:{count})"; + label_ip_port.Text = BridgeText.T("server", TcpServer.GetLocalIPAddress(), config.ServerPort, count); })); }; tcpServer.Start(); - label_ip_port.Text = $"TCP服务: {TcpServer.GetLocalIPAddress()}:{config.ServerPort} (客户端:0)"; + label_ip_port.Text = BridgeText.T("server", TcpServer.GetLocalIPAddress(), config.ServerPort, 0); // 重试定时器 (UI线程Timer) retryTimer = new Timer(); @@ -111,11 +138,11 @@ private void Form1_Load(object sender, EventArgs e) // 自动开始扫描 devicesList = new List(); bleCore.StartBleDeviceWatcher(); - log(Color.Blue, "自动扫描蓝牙设备中..."); + log(Color.Blue, BridgeText.T("scan")); if (config.HasSavedDevice) { - log(Color.Blue, $"目标设备: {config.BleName} [{config.BleMac}]"); + log(Color.Blue, BridgeText.T("target", config.BleName, config.BleMac)); retryTimer.Start(); } } @@ -133,13 +160,13 @@ private void RetryTimer_Tick(object sender, EventArgs e) try { bleCore.StopBleDeviceWatcher(); } catch { } bleCore.StartBleDeviceWatcher(); - log(Color.Gray, "重新扫描蓝牙设备..."); + log(Color.Gray, BridgeText.T("rescan")); } private void WriteDataSuccess(GattCharacteristic sender, byte[] data) { UTF8Encoding utf8 = new UTF8Encoding(); - log(Color.FromArgb(0x00ff00FF), Utilities.ConvertUuidToShortId(sender.Uuid).ToString() + "write :" + utf8.GetString(data)); + log(Color.FromArgb(0x00ff00FF), BridgeText.T("write", Utilities.ConvertUuidToShortId(sender.Uuid), utf8.GetString(data))); } private void ReceiveNotifyData(GattCharacteristic sender, byte[] data) @@ -147,14 +174,11 @@ private void ReceiveNotifyData(GattCharacteristic sender, byte[] data) if (ProtocolHelper.IsDeviceStatusNotification(data)) { var info = ProtocolHelper.ParseDeviceStatusFromNotification(data); - log(Color.DarkGreen, $"设备状态: 电量={info.BatteryLevel} 信号={info.SignalStrength} " + - $"固件={info.FirmwareVersionMain}.{info.FirmwareVersionSub} " + - $"模式={info.WorkMode} 灯光={info.LightMode} 开关={info.SwitchState}"); + log(Color.DarkGreen, BridgeText.T("status", info.BatteryLevel, info.SignalStrength, info.FirmwareVersionMain, info.FirmwareVersionSub, info.WorkMode, info.LightMode, info.SwitchState)); } else { - log(Color.FromArgb(0x00ff0000), Utilities.ConvertUuidToShortId(sender.Uuid).ToString() + - "receive :" + BitConverter.ToString(data)); + log(Color.FromArgb(0x00ff0000), BridgeText.T("receive", Utilities.ConvertUuidToShortId(sender.Uuid), BitConverter.ToString(data))); } } @@ -177,7 +201,7 @@ private void DeviceAdded(DeviceInformation deviceInformation) string.Equals(mac, config.BleMac, StringComparison.OrdinalIgnoreCase)) { autoConnecting = true; - log(Color.Blue, "发现目标设备, 自动连接..."); + log(Color.Blue, BridgeText.T("targetConnecting")); try { bleCore.StopBleDeviceWatcher(); } catch { } bleCore.ConnectDeviceByInfo(deviceInformation); } @@ -189,8 +213,8 @@ private void DeviceConnected(BluetoothLEDevice bluetoothLEDevice) { BeginInvoke(new Action(() => { - log(Color.FromArgb(0x00ff00FF), "Connected:" + bluetoothLEDevice.Name); - label_connected_devices.Text = "当前连接设备:" + bluetoothLEDevice.Name; + log(Color.FromArgb(0x00ff00FF), BridgeText.T("connected", bluetoothLEDevice.Name)); + label_connected_devices.Text = BridgeText.T("connectedDevice", bluetoothLEDevice.Name); retryTimer.Stop(); autoConnecting = false; targetConfirmed = false; @@ -201,13 +225,13 @@ private void DeviceDisconnected(BluetoothLEDevice bluetoothLEDevice) { BeginInvoke(new Action(() => { - log(Color.Red, "Disconnected:" + (bluetoothLEDevice?.Name ?? "")); - label_connected_devices.Text = "当前连接设备:无"; + log(Color.Red, BridgeText.T("disconnected", bluetoothLEDevice?.Name ?? "")); + label_connected_devices.Text = BridgeText.T("connectedDevice", BridgeText.T("none")); autoConnecting = false; if (config.HasSavedDevice) { - log(Color.Gray, "将在数秒后尝试重连..."); + log(Color.Gray, BridgeText.T("retry")); retryTimer.Start(); } })); @@ -218,8 +242,7 @@ private void CharacteristicAdded(GattCharacteristic gattCharacteristic) BeginInvoke(new Action(() => { ushort shortId = Utilities.ConvertUuidToShortId(gattCharacteristic.Uuid); - log(Color.Black, "Chara:0x" + shortId.ToString("X") + - ", des:" + gattCharacteristic.UserDescription); + log(Color.Black, BridgeText.T("characteristic", shortId, gattCharacteristic.UserDescription)); })); } @@ -238,21 +261,21 @@ private void OnAllCharacteristicsDiscovered() if (!allFound) { - string devName = bleCore.CurrentDevice?.Name ?? "未知"; - log(Color.OrangeRed, $"设备 [{devName}] 未找齐目标UUID, 断开连接"); + string devName = bleCore.CurrentDevice?.Name ?? BridgeText.T("unknown"); + log(Color.OrangeRed, BridgeText.T("missingCharacteristics", devName)); bleCore.Dispose(); - label_connected_devices.Text = "当前连接设备:无"; + label_connected_devices.Text = BridgeText.T("connectedDevice", BridgeText.T("none")); if (config.HasSavedDevice) { - log(Color.Gray, "将继续尝试查找目标设备..."); + log(Color.Gray, BridgeText.T("continueSearch")); retryTimer.Start(); } } else { targetConfirmed = true; - log(Color.Blue, "目标设备已确认, 所有特征就绪"); + log(Color.Blue, BridgeText.T("ready")); SaveCurrentDeviceToConfig(); } })); @@ -272,7 +295,7 @@ private void SaveCurrentDeviceToConfig() config.BleName = bleCore.CurrentDevice.Name; config.BleMac = mac; config.Save(); - log(Color.Blue, $"已保存设备: {config.BleName} [{config.BleMac}]"); + log(Color.Blue, BridgeText.T("saved", config.BleName, config.BleMac)); } private void BtnConnect_Click(object sender, EventArgs e) @@ -315,13 +338,13 @@ private void BindRichTextBoxContextMenu(RichTextBox textBox) { ContextMenu contextMenu = new ContextMenu(); - System.Windows.Forms.MenuItem cutItem = new System.Windows.Forms.MenuItem("剪切"); + System.Windows.Forms.MenuItem cutItem = new System.Windows.Forms.MenuItem(BridgeText.T("cut")); cutItem.Click += (sender, eventArgs) => textBox.Cut(); - System.Windows.Forms.MenuItem copyItem = new System.Windows.Forms.MenuItem("复制"); + System.Windows.Forms.MenuItem copyItem = new System.Windows.Forms.MenuItem(BridgeText.T("copy")); copyItem.Click += (sender, eventArgs) => textBox.Copy(); - System.Windows.Forms.MenuItem pasteItem = new System.Windows.Forms.MenuItem("粘贴"); + System.Windows.Forms.MenuItem pasteItem = new System.Windows.Forms.MenuItem(BridgeText.T("paste")); pasteItem.Click += (sender, eventArgs) => textBox.Paste(); contextMenu.MenuItems.Add(cutItem); @@ -396,7 +419,7 @@ private void checkBox_follow_system_CheckedChanged(object sender, EventArgs e) } catch (Exception ex) { - MessageBox.Show("设置开机自启动失败: " + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); + MessageBox.Show(BridgeText.T("autoStartError", ex.Message), BridgeText.T("error"), MessageBoxButtons.OK, MessageBoxIcon.Error); // 还原 checkbox 状态 checkBox_follow_system.CheckedChanged -= checkBox_follow_system_CheckedChanged; checkBox_follow_system.Checked = !checkBox_follow_system.Checked; diff --git a/BLE_tcp_bridge/Localization/Messages_en.resx b/BLE_tcp_bridge/Localization/Messages_en.resx new file mode 100644 index 00000000..0efeeffd --- /dev/null +++ b/BLE_tcp_bridge/Localization/Messages_en.resx @@ -0,0 +1,156 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Connect + + + Connected device: {0} + + + None + + + Unknown + + + TCP server: {0}:{1} (clients: {2}) + + + Start minimized next time + + + Start with Windows + + + AhaKey BLE bridge + + + Exit + + + Scanning for Bluetooth devices... + + + Scanning for Bluetooth devices again... + + + Target device: {0} [{1}] + + + Target found; connecting automatically... + + + Connected: {0} + + + Disconnected: {0} + + + Will retry the connection in a few seconds... + + + Characteristic 0x{0:X}: {1} + + + Device [{0}] lacks required characteristics; disconnecting + + + Continuing to search for the target device... + + + Target confirmed; all characteristics are ready + + + Device saved: {0} [{1}] + + + Device status: battery={0}, signal={1}, firmware={2}.{3}, mode={4}, light={5}, switch={6} + + + 0x{0:X} write: {1} + + + 0x{0:X} received: {1} + + + Cut + + + Copy + + + Paste + + + Error + + + Could not change Windows startup settings: {0} + + + TCP server started on port {0} + + + TCP server stopped + + + TCP client connected: {0} + + + Connection accept error: {0} + + + Client processing error: {0} + + + BLE data (0x7341): {0} bytes + + + BLE data characteristic (0x7341) is not ready + + + BLE command (0x7343): {0} bytes + + + BLE command characteristic (0x7343) is not ready + + + Responding to BLE connection status query + + + Responding to cached device information query + + + Unknown packet type: 0x{0:X2} + + + TCP client disconnected: {0} + + + Could not read configuration: {0} + + + Could not save configuration: {0} + + + Target service and characteristics: + + + YES + + + NO + + \ No newline at end of file diff --git a/BLE_tcp_bridge/Localization/Messages_ru.resx b/BLE_tcp_bridge/Localization/Messages_ru.resx new file mode 100644 index 00000000..c234ada5 --- /dev/null +++ b/BLE_tcp_bridge/Localization/Messages_ru.resx @@ -0,0 +1,156 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Подключить + + + Подключённое устройство: {0} + + + Нет + + + Неизвестно + + + TCP-сервер: {0}:{1} (клиентов: {2}) + + + Запускать свёрнутым + + + Запускать вместе с Windows + + + BLE-мост AhaKey + + + Выход + + + Поиск устройств Bluetooth… + + + Повторный поиск устройств Bluetooth… + + + Целевое устройство: {0} [{1}] + + + Устройство найдено; автоматическое подключение… + + + Подключено: {0} + + + Отключено: {0} + + + Повторное подключение через несколько секунд… + + + Характеристика 0x{0:X}: {1} + + + У устройства [{0}] не найдены нужные характеристики; отключение + + + Поиск целевого устройства продолжается… + + + Устройство подтверждено; все характеристики готовы + + + Устройство сохранено: {0} [{1}] + + + Состояние: заряд={0}, сигнал={1}, прошивка={2}.{3}, режим={4}, подсветка={5}, переключатель={6} + + + 0x{0:X} отправлено: {1} + + + 0x{0:X} получено: {1} + + + Вырезать + + + Копировать + + + Вставить + + + Ошибка + + + Не удалось изменить автозапуск: {0} + + + TCP-сервер запущен на порту {0} + + + TCP-сервер остановлен + + + TCP-клиент подключён: {0} + + + Ошибка приёма соединения: {0} + + + Ошибка обработки клиента: {0} + + + Данные BLE (0x7341): {0} байт + + + Характеристика данных BLE (0x7341) не готова + + + Команда BLE (0x7343): {0} байт + + + Характеристика команд BLE (0x7343) не готова + + + Ответ на запрос состояния соединения BLE + + + Ответ на запрос сохранённых сведений об устройстве + + + Неизвестный тип пакета: 0x{0:X2} + + + TCP-клиент отключён: {0} + + + Не удалось прочитать настройки: {0} + + + Не удалось сохранить настройки: {0} + + + Целевой сервис и характеристики: + + + ДА + + + НЕТ + + \ No newline at end of file diff --git a/BLE_tcp_bridge/Localization/Messages_zh.resx b/BLE_tcp_bridge/Localization/Messages_zh.resx new file mode 100644 index 00000000..ec2f48bb --- /dev/null +++ b/BLE_tcp_bridge/Localization/Messages_zh.resx @@ -0,0 +1,156 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + 连接 + + + 当前连接设备: {0} + + + + + + 未知 + + + TCP服务: {0}:{1} (客户端: {2}) + + + 下次最小化启动 + + + 跟随系统启动 + + + AhaKey BLE 桥接 + + + 退出程序 + + + 自动扫描蓝牙设备中... + + + 重新扫描蓝牙设备... + + + 目标设备: {0} [{1}] + + + 发现目标设备, 自动连接... + + + 已连接: {0} + + + 已断开: {0} + + + 将在数秒后尝试重连... + + + 特征 0x{0:X}: {1} + + + 设备 [{0}] 未找齐目标UUID, 断开连接 + + + 将继续尝试查找目标设备... + + + 目标设备已确认, 所有特征就绪 + + + 已保存设备: {0} [{1}] + + + 设备状态: 电量={0}, 信号={1}, 固件={2}.{3}, 模式={4}, 灯光={5}, 开关={6} + + + 0x{0:X} 写入: {1} + + + 0x{0:X} 收到: {1} + + + 剪切 + + + 复制 + + + 粘贴 + + + 错误 + + + 设置开机自启动失败: {0} + + + TCP服务器已启动, 监听端口: {0} + + + TCP服务器已停止 + + + TCP客户端已连接: {0} + + + 接受连接异常: {0} + + + 客户端处理异常: {0} + + + BLE数据(0x7341): {0}字节 + + + BLE数据特征(0x7341)未就绪 + + + BLE命令(0x7343): {0}字节 + + + BLE命令特征(0x7343)未就绪 + + + 响应BLE状态查询 + + + 响应设备信息查询 + + + 未知包类型: 0x{0:X2} + + + TCP客户端已断开: {0} + + + 配置文件读取失败: {0} + + + 配置文件保存失败: {0} + + + 目标服务和特征: + + + + + + + + \ No newline at end of file diff --git a/BLE_tcp_bridge/Program.cs b/BLE_tcp_bridge/Program.cs index eb6863a8..2af35b78 100644 --- a/BLE_tcp_bridge/Program.cs +++ b/BLE_tcp_bridge/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -247,11 +247,11 @@ private void FinishDiscoveryIfReady(int generation) if (!shouldNotify) return; - LogDiscovery("TARGET SUMMARY:"); - LogDiscovery("7340 service = " + (serviceFound ? "YES" : "NO")); - LogDiscovery("7341 = " + (dataFound ? "YES" : "NO")); - LogDiscovery("7343 = " + (writeFound ? "YES" : "NO")); - LogDiscovery("7344 = " + (notifyFound ? "YES" : "NO")); + LogDiscovery(BridgeText.T("discoverySummary")); + LogDiscovery("7340 service = " + BridgeText.T(serviceFound ? "yes" : "no")); + LogDiscovery("7341 = " + BridgeText.T(dataFound ? "yes" : "no")); + LogDiscovery("7343 = " + BridgeText.T(writeFound ? "yes" : "no")); + LogDiscovery("7344 = " + BridgeText.T(notifyFound ? "yes" : "no")); // 额外输出机器可读的诊断结果,便于从 UI 日志直接复制给 HV-005 调查。 LogDiscovery("SERVICE_7340_FOUND=" + (serviceFound ? "YES" : "NO")); LogDiscovery("CHAR_7341_FOUND=" + (dataFound ? "YES" : "NO")); @@ -993,6 +993,10 @@ private static void ActivateExistingWindow() [STAThread] static void Main() { + BridgeText.Initialize(Environment.GetCommandLineArgs(), + System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".ahakey", "preferences.properties"), + System.Globalization.CultureInfo.CurrentUICulture.Name); bool created; singleInstanceMutex = new Mutex(true, "Global\\AhaKey.BLETcpDriver", out created); if (!created) diff --git a/BLE_tcp_bridge/TcpServer.cs b/BLE_tcp_bridge/TcpServer.cs index 4180cc77..18e783c2 100644 --- a/BLE_tcp_bridge/TcpServer.cs +++ b/BLE_tcp_bridge/TcpServer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -51,7 +51,7 @@ public void Start() // 订阅BLE通知, 转发给所有TCP客户端 _bleCore.ReceiveNotifyData += OnBleNotify; - Log($"TCP服务器已启动, 监听端口: {_port}"); + Log(BridgeText.T("tcpStarted", _port)); Task.Run(() => AcceptLoop()); } @@ -70,7 +70,7 @@ public void Stop() _clients.Clear(); } OnClientCountChanged?.Invoke(0); - Log("TCP服务器已停止"); + Log(BridgeText.T("tcpStopped")); } /// @@ -86,7 +86,7 @@ private async Task AcceptLoop() lock (_clientLock) _clients.Add(client); string ep = GetEndpointString(client); - Log($"TCP客户端已连接: {ep}"); + Log(BridgeText.T("clientConnected", ep)); OnClientCountChanged?.Invoke(ClientCount); var _ = Task.Run(() => ClientLoop(client)); @@ -95,7 +95,7 @@ private async Task AcceptLoop() catch (SocketException) { if (!_running) break; } catch (Exception ex) { - if (_running) Log($"接受连接异常: {ex.Message}"); + if (_running) Log(BridgeText.T("acceptError", ex.Message)); } } } @@ -138,7 +138,7 @@ private async Task ClientLoop(TcpClient client) catch (IOException) { } catch (SocketException) { } catch (ObjectDisposedException) { } - catch (Exception ex) { Log($"客户端处理异常: {ex.Message}"); } + catch (Exception ex) { Log(BridgeText.T("clientError", ex.Message)); } finally { RemoveClient(client); @@ -160,10 +160,10 @@ private void HandlePacket(TcpClient client, PacketType type, byte[] data) _bleCore.WriteDataToCharacterstuc(_bleCore.CurrentDataCharacteristic, data.Skip(i).Take(Math.Min(data.Count() - i, 200)).ToArray()); } //_bleCore.WriteDataToCharacterstuc(_bleCore.CurrentDataCharacteristic, data ?? new byte[0]); - Log($"→BLE数据(0x7341) [{data?.Length ?? 0}字节]"); + Log(BridgeText.T("bleData", data?.Length ?? 0)); } else - Log("BLE数据特征(0x7341)未就绪"); + Log(BridgeText.T("bleDataNotReady")); break; case PacketType.WriteCommand: @@ -179,27 +179,27 @@ private void HandlePacket(TcpClient client, PacketType type, byte[] data) _bleCore.WriteDataToCharacterstuc(_bleCore.CurrentWriteCharacteristic, data.Skip(i).Take(Math.Min(data.Count() - i, 20)).ToArray()); } //_bleCore.WriteDataToCharacterstuc(_bleCore.CurrentWriteCharacteristic, data ?? new byte[0]); - Log($"→BLE命令(0x7343) [{data?.Length ?? 0}字节]"); + Log(BridgeText.T("bleCommand", data?.Length ?? 0)); } else - Log("BLE命令特征(0x7343)未就绪"); + Log(BridgeText.T("bleCommandNotReady")); break; case PacketType.QueryBleStatus: var status = BuildBleStatus(); SendToClient(client, ProtocolHelper.BuildBleStatusPacket(status)); - Log("响应BLE状态查询"); + Log(BridgeText.T("statusQuery")); break; case PacketType.QueryDeviceInfo: DeviceStatusInfo info; lock (_statusLock) info = _deviceStatus; SendToClient(client, ProtocolHelper.BuildDeviceInfoPacket(info)); - Log("响应设备信息查询"); + Log(BridgeText.T("infoQuery")); break; default: - Log($"未知包类型: 0x{(byte)type:X2}"); + Log(BridgeText.T("unknownPacket", (byte)type)); break; } } @@ -247,9 +247,7 @@ private void OnBleNotify(GattCharacteristic sender, byte[] data) { var newStatus = ProtocolHelper.ParseDeviceStatusFromNotification(data); lock (_statusLock) _deviceStatus = newStatus; - Log($"设备状态更新: 电量={newStatus.BatteryLevel} 信号={newStatus.SignalStrength} " + - $"固件={newStatus.FirmwareVersionMain}.{newStatus.FirmwareVersionSub} " + - $"工作模式={newStatus.WorkMode} 灯光={newStatus.LightMode} 开关={newStatus.SwitchState}"); + Log(BridgeText.T("status", newStatus.BatteryLevel, newStatus.SignalStrength, newStatus.FirmwareVersionMain, newStatus.FirmwareVersionSub, newStatus.WorkMode, newStatus.LightMode, newStatus.SwitchState)); } byte[] packet = ProtocolHelper.BuildPacket(PacketType.BleNotify, data); @@ -289,7 +287,7 @@ private void RemoveClient(TcpClient client) if (removed) { string ep = GetEndpointString(client); - Log($"TCP客户端已断开: {ep}"); + Log(BridgeText.T("clientDisconnected", ep)); try { client.Close(); } catch { } OnClientCountChanged?.Invoke(ClientCount); } diff --git a/BLE_tcp_bridge/readme.md b/BLE_tcp_bridge/readme.md index c5c71037..45c9b83a 100644 --- a/BLE_tcp_bridge/readme.md +++ b/BLE_tcp_bridge/readme.md @@ -1,3 +1,21 @@ ### 使用vs .NetFramework开发的 BLE - TCP 桥接 -给python写的vibe code 上位机和设备间通信用的 \ No newline at end of file +给python写的vibe code 上位机和设备间通信用的 + +### Interface languages / Языки интерфейса + +The bridge embeds English, Russian and Chinese catalogs under `Localization/`. +Studio passes `--language=ru`, `--language=en` or `--language=zh` at launch. +Standalone launch reads `AhaKeySelectedLanguage` from +`~/.ahakey/preferences.properties`, then falls back to the Windows UI language. +Restart the bridge after changing language. Unknown locales use English. +The preference file is read only; wire protocol and diagnostic IDs are unchanged. + +```powershell +MSBuild .\BLE_tcp_driver.csproj /restore /p:Configuration=Release +.\tests\Test-Localization.ps1 +.\bin\Release\BLE_tcp_driver.exe --language=ru --show +``` + +Keep each key and its format placeholders present in all three RESX files. +The resources are embedded in the executable; no language DLLs need deployment. diff --git a/BLE_tcp_bridge/tests/LocalizationTests.cs b/BLE_tcp_bridge/tests/LocalizationTests.cs new file mode 100644 index 00000000..ccb51fc4 --- /dev/null +++ b/BLE_tcp_bridge/tests/LocalizationTests.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Resources; +using System.Text.RegularExpressions; + +internal static class LocalizationTests +{ + private static Type text; + private static object Call(string name, params object[] arguments) => + text.GetMethod(name, BindingFlags.Static | BindingFlags.NonPublic).Invoke(null, arguments); + private static void Check(bool condition, string message) + { + if (!condition) throw new Exception(message); + } + private static string T(string key, params object[] arguments) => (string)Call("T", key, arguments); + + [STAThread] + private static int Main(string[] args) + { + string temp = Path.Combine(Path.GetTempPath(), "ahakey-locale-" + Guid.NewGuid()); + Directory.CreateDirectory(temp); + string preferences = Path.Combine(temp, "preferences.properties"); + try + { + var assembly = Assembly.LoadFrom(Path.GetFullPath(args[0])); + text = assembly.GetType("BLE_tcp_driver.BridgeText", true); + var english = new ResourceManager("BLE_tcp_driver.Messages.en", assembly) + .GetResourceSet(CultureInfo.InvariantCulture, true, false); + var placeholders = new Regex(@"\{\d+(?:[^}]*)\}"); + foreach (string language in new[] { "en", "ru", "zh" }) + { + var catalog = new ResourceManager("BLE_tcp_driver.Messages." + language, assembly) + .GetResourceSet(CultureInfo.InvariantCulture, true, false); + Check(catalog.Cast().Count() == english.Cast().Count(), language + " key count"); + foreach (DictionaryEntry entry in english) + { + string value = catalog.GetString((string)entry.Key); + Check(!string.IsNullOrWhiteSpace(value), language + ": " + entry.Key); + Check(placeholders.Matches((string)entry.Value).Cast().Select(m => m.Value) + .SequenceEqual(placeholders.Matches(value).Cast().Select(m => m.Value)), "Placeholders: " + entry.Key); + if (language == "ru") Check(!Regex.IsMatch(value, @"[\u4e00-\u9fff]"), "Untranslated: " + entry.Key); + string.Format(CultureInfo.InvariantCulture, value, Enumerable.Range(0, 10).Cast().ToArray()); + } + } + File.WriteAllText(preferences, "# Java preferences\nother.setting=keep\nAhaKeySelectedLanguage=ru\n"); + Call("Initialize", new string[0], preferences, "zh-CN"); + Check(T("connect") == "Подключить", "Shared Java preference"); + Check(T("server", "127.0.0.1", 9000, 2).Contains("9000"), "Formatted status"); + Call("Initialize", new[] { "--language=en" }, preferences, "ru-RU"); + Check(T("connect") == "Connect", "Command-line override"); + Call("Initialize", new string[0], preferences + ".missing", "ru-RU"); + Check(T("connect") == "Подключить", "System locale fallback"); + Call("Initialize", new[] { "--language=de" }, preferences, "ru-RU"); + Check(T("connect") == "Connect", "Unsupported locale fallback"); + Check(File.ReadAllText(preferences).Contains("other.setting=keep"), "Preferences must not be modified"); + if (args.Length > 1) + { + Call("Initialize", new[] { "--language=ru" }, preferences, "en"); + // Render our own off-screen form with its startup handler detached: + // no BLE scan, TCP server or registry settings are touched. + using (var form = (System.Windows.Forms.Form)Activator.CreateInstance(assembly.GetType("BLE_tcp_driver.Form1"))) + { + form.Load -= (EventHandler)Delegate.CreateDelegate(typeof(EventHandler), form, + form.GetType().GetMethod("Form1_Load", BindingFlags.Instance | BindingFlags.NonPublic)); + form.ShowInTaskbar = false; + form.StartPosition = System.Windows.Forms.FormStartPosition.Manual; + form.Location = new System.Drawing.Point(-20000, -20000); + form.Show(); + form.PerformLayout(); + var log = (System.Windows.Forms.RichTextBox)form.Controls.Find("rtbMsg", true)[0]; + var header = form.Controls.OfType().Single(); + Check(log.Top >= header.Bottom, "Log must not overlap translated controls"); + log.Text = T("tcpStarted", 9000) + Environment.NewLine + T("status", 98, 50, 1, 0, 2, 0, 0); + using (var bitmap = new System.Drawing.Bitmap(form.Width, form.Height)) + { + form.DrawToBitmap(bitmap, new System.Drawing.Rectangle(System.Drawing.Point.Empty, form.Size)); + bitmap.Save(args[1], System.Drawing.Imaging.ImageFormat.Png); + } + } + } + Console.WriteLine("BLE_LOCALIZATION_TESTS=PASS (catalogs, placeholders, preference priority, fallback)"); + return 0; + } + finally + { + File.Delete(preferences); + Directory.Delete(temp); + } + } +} diff --git a/BLE_tcp_bridge/tests/Test-Localization.ps1 b/BLE_tcp_bridge/tests/Test-Localization.ps1 new file mode 100644 index 00000000..824ea55b --- /dev/null +++ b/BLE_tcp_bridge/tests/Test-Localization.ps1 @@ -0,0 +1,18 @@ +param([string]$CscPath, [string]$PreviewPath = "") +$ErrorActionPreference = 'Stop' +if (-not $CscPath) { + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + $CscPath = & $vswhere -latest -products '*' -requires Microsoft.Component.MSBuild -find 'MSBuild\**\Bin\Roslyn\csc.exe' | Select-Object -First 1 +} +if (-not $CscPath -or -not (Test-Path -LiteralPath $CscPath)) { throw 'Visual Studio C# compiler required.' } +$bridge = Join-Path (Split-Path $PSScriptRoot -Parent) 'bin\Release\BLE_tcp_driver.exe' +if (-not (Test-Path -LiteralPath $bridge)) { throw 'Build the Release bridge first.' } +$output = Join-Path (Split-Path $PSScriptRoot -Parent) 'bin\tests' +New-Item -ItemType Directory -Force -Path $output | Out-Null +$exe = Join-Path $output 'LocalizationTests.exe' +& $CscPath /nologo /warnaserror+ /target:exe "/out:$exe" /r:System.Windows.Forms.dll /r:System.Drawing.dll (Join-Path $PSScriptRoot 'LocalizationTests.cs') +if ($LASTEXITCODE -ne 0) { throw 'Localization test compilation failed.' } +$arguments = @($bridge) +if ($PreviewPath) { $arguments += $PreviewPath } +& $exe @arguments +if ($LASTEXITCODE -ne 0) { throw 'Bridge localization regression failed.' } diff --git a/ahakeyconfig-win-java/Test-LocalInstallerUpgrade.ps1 b/ahakeyconfig-win-java/Test-LocalInstallerUpgrade.ps1 new file mode 100644 index 00000000..e15d4bfd --- /dev/null +++ b/ahakeyconfig-win-java/Test-LocalInstallerUpgrade.ps1 @@ -0,0 +1,47 @@ +param( + [Parameter(Mandatory = $true)][string]$PreviousMsi, + [Parameter(Mandatory = $true)][string]$NewMsi +) + +# Read-only check of actual packages; never installs, repairs or removes products. +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +$installer = New-Object -ComObject WindowsInstaller.Installer +function Read-Properties([string]$path) { + $db = $installer.OpenDatabase([IO.Path]::GetFullPath($path), 0) + $view = $db.OpenView('SELECT `Property`, `Value` FROM `Property`') + [void]$view.Execute() + $properties = @{} + while ($record = $view.Fetch()) { + $properties[$record.StringData(1)] = $record.StringData(2) + } + [void]$view.Close() + return $properties +} +function Msi-Version([string]$value) { + # MSI ignores the fourth component even if a producer supplies one. + return [version](($value.Split('.') | Select-Object -First 3) -join '.') +} +$previous = Read-Properties $PreviousMsi +$next = Read-Properties $NewMsi +if ((Msi-Version $next.ProductVersion) -le (Msi-Version $previous.ProductVersion)) { + throw "Upgrade package must have a newer three-component version." +} +if ($next.ProductCode -eq $previous.ProductCode) { + throw "Major upgrade must not reuse the installed ProductCode." +} +if ($next.UpgradeCode -ne $previous.UpgradeCode) { + throw "UpgradeCode must remain stable to replace the previous product." +} +$db = $installer.OpenDatabase([IO.Path]::GetFullPath($NewMsi), 0) +$view = $db.OpenView('SELECT `UpgradeCode`, `VersionMax`, `ActionProperty` FROM `Upgrade`') +[void]$view.Execute() +$found = $false +while ($record = $view.Fetch()) { + if ($record.StringData(1) -eq $previous.UpgradeCode -and + $record.StringData(2) -eq $next.ProductVersion -and + $record.StringData(3) -eq 'JP_UPGRADABLE_FOUND') { $found = $true } +} +[void]$view.Close() +if (-not $found) { throw "Missing upgrade detection for the previous product family." } +Write-Output "LOCAL_INSTALLER_UPGRADE=PASS ($($previous.ProductVersion) -> $($next.ProductVersion))" diff --git a/ahakeyconfig-win-java/Test-ReleaseArtifactContents.ps1 b/ahakeyconfig-win-java/Test-ReleaseArtifactContents.ps1 index 2a72c21e..9c48c9cd 100644 --- a/ahakeyconfig-win-java/Test-ReleaseArtifactContents.ps1 +++ b/ahakeyconfig-win-java/Test-ReleaseArtifactContents.ps1 @@ -76,6 +76,8 @@ $requiredEntries = @( "com/example/ahakey/sherpa/LibraryLoader.class", "firmware-capabilities.properties", "model_config.properties", + "messages_ru.properties", + "legacy_ru.properties", "wchisp/CONFIG_CH57X59X-sanitized.WCH", "wchisp/baseline.properties", "wchisp/wchisp-runtime.json" diff --git a/ahakeyconfig-win-java/build-local-windows.ps1 b/ahakeyconfig-win-java/build-local-windows.ps1 new file mode 100644 index 00000000..6739547f --- /dev/null +++ b/ahakeyconfig-win-java/build-local-windows.ps1 @@ -0,0 +1,132 @@ +param( + [Parameter(Mandatory = $true)][string]$RuntimeImage, + [Parameter(Mandatory = $true)][string]$IconPath, + [string]$JdkHome = $env:JAVA_HOME, + [string]$WixBin = "", + [string]$OutputRoot = "", + [string]$PackageVersion = "" +) + +# Local desktop build: no firmware, speech-model assets or vendor flasher are +# implied by this target. The formal release pipeline remains separate. +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +if ([string]::IsNullOrWhiteSpace($OutputRoot)) { + $OutputRoot = Join-Path $PSScriptRoot "target\windows-ru" +} +[xml]$pom = Get-Content (Join-Path $PSScriptRoot "pom.xml") -Raw +$sourceVersion = [string]$pom.project.version +# Rebuilt packages must use a newer MSI version when upgrading an installation. +# A fourth component is intentionally rejected: MSI ignores it for upgrades. +$version = if ([string]::IsNullOrWhiteSpace($PackageVersion)) { $sourceVersion } else { $PackageVersion } +if ($version -notmatch '^\d+\.\d+\.\d+$') { throw "PackageVersion must have exactly three numeric components." } +$parsedVersion = [version]$version +if ($parsedVersion.Major -gt 255 -or $parsedVersion.Minor -gt 255 -or $parsedVersion.Build -gt 65535) { + throw "PackageVersion exceeds Windows Installer limits (255.255.65535)." +} +$jarName = "ahakey-studio-$sourceVersion.jar" +$jar = Join-Path $PSScriptRoot "target\$jarName" +$lib = Join-Path $PSScriptRoot "target\lib" +$bridge = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\BLE_tcp_bridge\bin\Release\BLE_tcp_driver.exe")) +$jpackage = Join-Path $JdkHome "bin\jpackage.exe" +foreach ($required in @($jar, $lib, $bridge, $jpackage, $RuntimeImage, $IconPath)) { + if (-not (Test-Path -LiteralPath $required)) { throw "Missing build input: $required" } +} +& (Join-Path $PSScriptRoot "Test-ReleaseArtifactContents.ps1") -JarPath $jar +$modules = & (Join-Path $RuntimeImage "bin\java.exe") --list-modules +if ($LASTEXITCODE -ne 0 -or -not ($modules -match '^javafx.controls')) { + throw "RuntimeImage must include JavaFX controls, graphics and FXML." +} + +# New operation directory every time; do not delete earlier builds or installations. +$build = Join-Path ([IO.Path]::GetFullPath($OutputRoot)) (Get-Date -Format "yyyyMMdd-HHmmss") +$inputDir = Join-Path $build "input" +$images = Join-Path $build "portable" +$installers = Join-Path $build "installer" +New-Item -ItemType Directory -Path $inputDir, $images, $installers -Force | Out-Null +Copy-Item -LiteralPath $jar -Destination $inputDir +Copy-Item -LiteralPath $lib -Destination (Join-Path $inputDir "lib") -Recurse +New-Item -ItemType Directory -Path (Join-Path $inputDir "ble-driver") | Out-Null +Copy-Item -LiteralPath $bridge -Destination (Join-Path $inputDir "ble-driver\BLE_tcp_driver.exe") +if (Test-Path -LiteralPath "$bridge.config") { + Copy-Item -LiteralPath "$bridge.config" -Destination (Join-Path $inputDir "ble-driver\BLE_tcp_driver.exe.config") +} +& (Join-Path $PSScriptRoot "Test-BleDriverPackaging.ps1") -ReleaseInputDir $inputDir + +$arguments = @( + "--type", "app-image", "--name", "AhaKeyStudio", + "--app-version", $version, "--vendor", "AhaKey", + "--description", "AhaKey Studio - Russian interface", + "--input", $inputDir, "--main-jar", $jarName, + "--main-class", "com.example.ahakey.App", + "--runtime-image", ([IO.Path]::GetFullPath($RuntimeImage)), + "--icon", ([IO.Path]::GetFullPath($IconPath)), "--dest", $images, + "--java-options", "-Dfile.encoding=UTF-8", + "--java-options", "-Dahakey.defaultLanguage=ru", + "--java-options", "-Dapp.version=$version", + "--java-options", "-Dprism.allowhidpi=true", + "--java-options", "--add-opens=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED", + "--java-options", "--add-opens=javafx.controls/com.sun.javafx.scene.control=ALL-UNNAMED", + "--java-options", "--add-opens=javafx.fxml/com.sun.javafx.fxml=ALL-UNNAMED" +) +& $jpackage @arguments +if ($LASTEXITCODE -ne 0) { throw "App-image build failed: $LASTEXITCODE" } +$image = Join-Path $images "AhaKeyStudio" +if (-not (Test-Path (Join-Path $image "AhaKeyStudio.exe"))) { throw "EXE missing" } +@" +AhaKey Studio $version — русский интерфейс + +Запуск: AhaKeyStudio.exe. Сохраняйте папки app и runtime рядом с EXE. +Язык: Ещё → Язык / Language → Русский, затем перезапустите приложение. +Java и BLE-мост включены в комплект. +Модели локального распознавания речи, прошивка и WCHISP не включены. +Это локальная сборка без цифровой подписи издателя. +"@ | Set-Content -LiteralPath (Join-Path $image "ПРОЧИТАЙТЕ.txt") -Encoding UTF8 +Compress-Archive -LiteralPath $image -DestinationPath (Join-Path $build "AhaKey-Studio-$version-RU-Portable.zip") + +if (-not [string]::IsNullOrWhiteSpace($WixBin)) { + foreach ($exe in @("candle.exe", "light.exe")) { + if (-not (Test-Path (Join-Path $WixBin $exe))) { throw "WiX missing: $exe" } + } + $env:PATH = "$WixBin;$env:PATH" + $resources = Join-Path $build "installer-resources" + New-Item -ItemType Directory -Path $resources | Out-Null + Copy-Item (Join-Path $PSScriptRoot "packaging\windows\*") -Destination $resources + # Translate only the custom chooser in this local installer; retain the + # repository's directory ownership and upgrade safeguards. + $uiPath = Join-Path $resources "ui.wxf" + $ui = [IO.File]::ReadAllText($uiPath) + $labels = @{ + '[ProductName] 安装程序' = 'Установка [ProductName]' + '下一步(&N)' = 'Далее' + '上一步(&B)' = 'Назад' + '取消' = 'Отмена' + '选择父目录,安装程序会自动创建 AhaKeyStudio 文件夹。' = 'В выбранной папке будет создана папка AhaKeyStudio.' + '选择安装位置' = 'Папка установки' + '请选择安装位置的父文件夹。实际安装目录将显示在下方。' = 'Выберите родительскую папку. Итоговый путь показан ниже.' + '程序文件夹:' = 'Папка программы: ' + '浏览父文件夹...' = 'Обзор...' + } + foreach ($label in ($labels.Keys | Sort-Object Length -Descending)) { $ui = $ui.Replace($label, $labels[$label]) } + [IO.File]::WriteAllText($uiPath, $ui, [Text.UTF8Encoding]::new($false)) + $mainPath = Join-Path $resources "main.wxs" + $mainXml = [IO.File]::ReadAllText($mainPath).Replace(' + + diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/App.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/App.java index 5285134e..3a5dc3b8 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/App.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/App.java @@ -1,5 +1,7 @@ package com.example.ahakey; +import static com.example.ahakey.util.LanguageManager.localize; + import com.example.ahakey.app.StudioController; import com.example.ahakey.app.ApplicationLifecycle; import com.example.ahakey.service.BleBridgeProcessOwner; @@ -294,7 +296,7 @@ private void initSystemTray() { PopupMenu popupMenu = new PopupMenu(); // 退出菜单项(使用英文避免中文乱码) - MenuItem exitItem = new MenuItem("Exit"); + MenuItem exitItem = new MenuItem(LanguageManager.getInstance().getString("menu.exit")); exitItem.addActionListener(e -> shutdownApplication()); popupMenu.add(exitItem); @@ -339,7 +341,7 @@ private void minimizeToTray() { primaryStage.setIconified(false); primaryStage.hide(); if (trayIcon != null) { - trayIcon.displayMessage("AhaKey Studio", "应用已最小化到托盘", TrayIcon.MessageType.INFO); + trayIcon.displayMessage("AhaKey Studio", localize("应用已最小化到托盘"), TrayIcon.MessageType.INFO); } }); } diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/app/StudioController.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/app/StudioController.java index 3d84418b..ece624ca 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/app/StudioController.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/app/StudioController.java @@ -1,5 +1,7 @@ package com.example.ahakey.app; +import static com.example.ahakey.util.LanguageManager.localize; + import com.example.ahakey.config.ModelConfig; import com.example.ahakey.model.*; import com.example.ahakey.platform.VoiceRelayPlatform; @@ -258,8 +260,8 @@ public long getLastHookRequestTimeMillis(String platform) { public void setMultiTaskDisplay(boolean enabled) { taskActivityService.setMultiMode(enabled); studioState.syncStatusProperty().set(deviceStatus.isConnected() - ? "正在等待设备确认任务显示模式…" - : "任务显示模式仅在本地修改,待设备重连后同步。"); + ? localize("正在等待设备确认任务显示模式…") + : localize("任务显示模式仅在本地修改,待设备重连后同步。")); } public boolean isEffectivelyConnected() { @@ -323,7 +325,7 @@ private void clearResolvedConnectionError() { return; } if (connectionError.equals(studioState.syncStatusProperty().get())) { - studioState.syncStatusProperty().set("设备已通过 BLE 连接。"); + studioState.syncStatusProperty().set(localize("设备已通过 BLE 连接。")); } lastConnectionError = null; } @@ -332,7 +334,7 @@ private static boolean isTransientConnectionError(String message) { if (message == null) { return false; } - return message.contains("BLE bridge") || message.contains("BLE 桥"); + return message.contains("BLE bridge") || message.contains(localize("BLE 桥")); } public void userConnect() { @@ -343,7 +345,7 @@ public void userConnect() { deviceStatus.setConnected(true); deviceStatus.setScanning(false); deviceStatus.setBatteryLevel(84); - deviceStatus.setDeviceName("AhaKey Keyboard (模拟)"); + deviceStatus.setDeviceName(localize("AhaKey Keyboard (模拟)")); return; } logger.info("使用真实BLE连接"); @@ -372,36 +374,49 @@ public void selectKeyboardMode(ModeSlot mode) { ); switch (result) { case OFFLINE_UI_ONLY -> studioState.syncStatusProperty().set( - "已选择 " + mode.getTitle() + "(仅本地编辑,尚未同步到设备)。"); + localize("已选择 ") + mode.getTitle() + localize("(仅本地编辑,尚未同步到设备)。")); case SENT_PENDING -> studioState.syncStatusProperty().set( - "模式命令已发送,等待设备确认。"); + localize("模式命令已发送,等待设备确认。")); case SEND_FAILED -> studioState.syncStatusProperty().set( - "模式发送失败,已回滚到设备上次确认的模式。"); + localize("模式发送失败,已回滚到设备上次确认的模式。")); } } public void enterEditingConfiguration() { agentManager.setBluetoothOwner(AgentManager.BluetoothOwner.AHAKEY_STUDIO); - studioState.syncStatusProperty().set("已进入编辑配置模式。"); + studioState.syncStatusProperty().set(localize("已进入编辑配置模式。")); refreshVoiceRoutes(); if (!deviceStatus.isConnected() && !simulateBle && !manuallyDisconnected) { userConnect(); } else if (!deviceStatus.isConnected() && manuallyDisconnected) { studioState.syncStatusProperty().set( - "已手动断开设备;编辑内容仅保存在本地,点击“连接设备”后才能写入键盘。"); + localize("已手动断开设备;编辑内容仅保存在本地,点击“连接设备”后才能写入键盘。")); } } public void finishEditingConfiguration() { + if (studioState.syncingProperty().get()) return; + if (studioState.hasIncompleteVoiceShortcut()) { + studioState.syncStatusProperty().set(LanguageManager.text("sync.incomplete-shortcut")); + return; + } if (!hasUnsyncedChanges()) { returnToKeyboardControl(); return; } - if (deviceStatus.isConnected() || simulateBle) { + if (!studioState.hasDeviceConfigurationChanges()) { + // K1 is a desktop action, not a firmware configuration command. + // Persist successfully before acknowledging the local save. + if (!persistDraft()) return; + studioState.clearDirtyAfterSync(studioState.captureDirtySnapshot()); + lastSyncedRevision = studioState.getRevision(); + returnToKeyboardControl(); + studioState.syncStatusProperty().set(LanguageManager.text("sync.local-saved")); + } else if (deviceStatus.isConnected() || simulateBle) { syncAllModes(true); } else { studioState.syncStatusProperty().set( - "设备已断开;配置仍保存在本地,请连接设备后再写入键盘。"); + localize("设备已断开;配置仍保存在本地,请连接设备后再写入键盘。")); if (!manuallyDisconnected) { userConnect(); } @@ -410,13 +425,14 @@ public void finishEditingConfiguration() { public void returnToKeyboardControl() { agentManager.setBluetoothOwner(AgentManager.BluetoothOwner.KEYBOARD_DEVICE); - studioState.syncStatusProperty().set("已交还控制权给键盘设备,连接保持。"); + studioState.syncStatusProperty().set(localize("已交还控制权给键盘设备,连接保持。")); // 保持 BLE 连接不断开,避免用户需要重新连接 } public void syncAllModes(boolean returnToAgentWhenDone) { + if (studioState.syncingProperty().get()) return; if (!deviceStatus.isConnected() && !simulateBle) { - studioState.syncStatusProperty().set("设备未连接,当前只保存本地草稿。"); + studioState.syncStatusProperty().set(localize("设备未连接,当前只保存本地草稿。")); return; } if (simulateBle) { @@ -426,54 +442,48 @@ public void syncAllModes(boolean returnToAgentWhenDone) { studioState.clearDirtyAfterSync(dirtySnapshot); lastSyncedRevision = syncRevision; studioState.syncStatusProperty().set(studioState.getRevision() == syncRevision - ? "模拟模式:已标记为保存。" - : "模拟模式:已保存先前快照,后续修改仍待保存。"); + ? localize("模拟模式:已标记为保存。") + : localize("模拟模式:已保存先前快照,后续修改仍待保存。")); if (returnToAgentWhenDone) { returnToKeyboardControl(); } return; } - String transport; - try { - transport = bleManager.selectPreferredTransport(); - } catch (Exception e) { - studioState.syncStatusProperty().set("连接不可用,请重新连接键盘后再保存。"); - return; - } - - try { - bleManager.requireStabilizedDeviceContract(); - } catch (Exception exception) { - logger.warn("设备未满足稳定版能力合同,已阻止配置写入: {}", - exception.getMessage()); - studioState.syncStatusProperty().set( - "设备能力合同不兼容,无法安全保存配置:" + exception.getMessage()); - return; - } + if (!persistDraft()) return; int syncRevision = studioState.getRevision(); StudioState.DirtySnapshot dirtySnapshot = studioState.captureDirtySnapshot(); var commands = List.copyOf(DeviceSyncService.commandsForModes( studioState, false, ModeSlot.values())); studioState.syncingProperty().set(true); - studioState.syncStatusProperty().set("正在通过 " + transport + " 写入设备配置..."); - studioState.syncStatusProperty().set("正在写入设备配置..."); - studioState.syncStatusProperty().set("Saving via " + transport + "..."); + studioState.syncStatusProperty().set(LanguageManager.text("sync.checking-device")); DeviceSyncService.SyncHandle syncHandle = DeviceSyncService.writeSequentially( bleManager, commands, + () -> { + bleManager.selectPreferredTransport(); + try { + bleManager.requireStabilizedDeviceContract(); + } catch (Exception exception) { + logger.warn("Device configuration preflight failed; no writes sent: {}", + exception.getMessage()); + throw new java.io.IOException( + LanguageManager.text("sync.incompatible-device"), exception); + } + return null; + }, () -> Platform.runLater(() -> { studioState.clearDirtyAfterSync(dirtySnapshot); lastSyncedRevision = syncRevision; studioState.syncingProperty().set(false); + if (returnToAgentWhenDone && studioState.getRevision() == syncRevision) { + returnToKeyboardControl(); + } studioState.syncStatusProperty().set( studioState.getRevision() == syncRevision - ? "已保存配置。" - : "设备已保存先前快照,后续修改仍待保存。"); + ? localize("已保存配置。") + : localize("设备已保存先前快照,后续修改仍待保存。")); statusRefreshScheduler.submit(bleManager::queryStatus); - if (returnToAgentWhenDone) { - returnToKeyboardControl(); - } }), () -> Platform.runLater(() -> studioState.syncingProperty().set(false)), msg -> Platform.runLater(() -> studioState.syncStatusProperty().set(msg)) @@ -488,7 +498,7 @@ public void syncAllModes(boolean returnToAgentWhenDone) { if (syncHandle.isRunning() && studioState.syncingProperty().get()) { syncHandle.cancel(); Platform.runLater(() -> studioState.syncStatusProperty().set( - "保存超时,已请求取消;在后台事务实际退出前将阻止冲突写入。")); + localize("保存超时,已请求取消;在后台事务实际退出前将阻止冲突写入。"))); } }, "device-sync-watchdog"); watchdog.setDaemon(true); @@ -497,12 +507,12 @@ public void syncAllModes(boolean returnToAgentWhenDone) { public void previewLightOnDevice() { LightBarPreviewState preview = studioState.getLightBarPreview(); if (!deviceStatus.isConnected() && !simulateBle) { - studioState.syncStatusProperty().set("请先连接设备再预览灯效。"); + studioState.syncStatusProperty().set(localize("请先连接设备再预览灯效。")); return; } // 使用 IDE 状态码发送(适配当前固件,固件根据 claude_state 映射灯效) IDEState ideState = preview.getIdeState(); - String success = "已发送灯效预览:" + preview.getTitle() + " → " + String success = localize("已发送灯效预览:") + preview.getTitle() + " → " + ideState.getFullLabel(); if (simulateBle) { studioState.syncStatusProperty().set(success); @@ -510,7 +520,7 @@ public void previewLightOnDevice() { } runLightOperation("light-preview", () -> LightOperationCoordinator.execute( success, - LightOperationCoordinator.step("灯效预览写入", + LightOperationCoordinator.step(localize("灯效预览写入"), () -> bleManager.updateStateOrThrow((byte) ideState.getCode())))); } @@ -519,55 +529,55 @@ public void previewLightEffectOnDevice(LightEffectStyle effect) { return; } if (!deviceStatus.isConnected() && !simulateBle) { - studioState.syncStatusProperty().set("请先连接键盘,再测试灯效。"); + studioState.syncStatusProperty().set(localize("请先连接键盘,再测试灯效。")); return; } - String success = "已发送灯效测试:" + effect.getTitle(); + String success = localize("已发送灯效测试:") + effect.getTitle(); if (simulateBle) { studioState.syncStatusProperty().set(success); return; } runLightOperation("light-effect-preview", () -> LightOperationCoordinator.execute( success, - LightOperationCoordinator.step("灯效写入", + LightOperationCoordinator.step(localize("灯效写入"), () -> bleManager.setLightEffect(effect.getCode())))); } public void sendLightBrightnessToDevice() { if (!deviceStatus.isConnected() && !simulateBle) { - studioState.syncStatusProperty().set("请先连接键盘,再测试灯光亮度。"); + studioState.syncStatusProperty().set(localize("请先连接键盘,再测试灯光亮度。")); return; } int brightness = studioState.getLightBrightness(); - studioState.syncStatusProperty().set("正在测试灯光亮度:" + brightness); + studioState.syncStatusProperty().set(localize("正在测试灯光亮度:") + brightness); if (simulateBle) { - studioState.syncStatusProperty().set("已发送灯光亮度:" + brightness); + studioState.syncStatusProperty().set(localize("已发送灯光亮度:") + brightness); return; } runLightOperation("brightness-test", () -> LightOperationCoordinator.execute( - "已发送灯光亮度:" + brightness, - LightOperationCoordinator.step("亮度写入", + localize("已发送灯光亮度:") + brightness, + LightOperationCoordinator.step(localize("亮度写入"), () -> bleManager.setLightBrightness(brightness)), - LightOperationCoordinator.step("灯效写入", + LightOperationCoordinator.step(localize("灯效写入"), () -> bleManager.setLightEffect(LightEffectStyle.RAINBOW_MOVE.getCode())))); } public void syncCurrentModeLightConfig() { ModeSlot mode = studioState.getSelectedMode(); if (!deviceStatus.isConnected() && !simulateBle) { - studioState.syncStatusProperty().set("请先连接键盘,再保存当前模式灯效。"); + studioState.syncStatusProperty().set(localize("请先连接键盘,再保存当前模式灯效。")); return; } - String success = "已保存 " + mode.getTitle() + " 的 AI 状态灯效和亮度。"; + String success = localize("已保存 ") + mode.getTitle() + localize(" 的 AI 状态灯效和亮度。"); if (simulateBle) { studioState.syncStatusProperty().set(success); return; } runLightOperation("light-mode-sync", () -> LightOperationCoordinator.execute( success, - LightOperationCoordinator.step("AI 状态灯效配置写入", + LightOperationCoordinator.step(localize("AI 状态灯效配置写入"), () -> bleManager.setAiLightConfig( mode.getIndex(), studioState.getAiLightEffectBytes(mode))), - LightOperationCoordinator.step("亮度写入", + LightOperationCoordinator.step(localize("亮度写入"), () -> bleManager.setLightBrightness(studioState.getLightBrightness())))); } @@ -585,7 +595,7 @@ private void runLightOperation( public void updateSwitchState(int state) { if (!deviceStatus.isConnected() && !simulateBle) { - studioState.syncStatusProperty().set("请先连接设备再修改拨杆状态。"); + studioState.syncStatusProperty().set(localize("请先连接设备再修改拨杆状态。")); return; } // 先更新本地状态 @@ -595,7 +605,7 @@ public void updateSwitchState(int state) { bleManager.updateState((byte) state); } studioState.syncStatusProperty().set( - "拨杆状态已更新为: " + deviceStatus.getSwitchTitle() + localize("拨杆状态已更新为: ") + deviceStatus.getSwitchTitle() ); } @@ -734,14 +744,14 @@ public void selectOledGif(javafx.stage.Window owner) { String fileName = file.getName().toLowerCase(); boolean isStaticImage = isStaticOledImage(fileName); if (!isStaticImage && !isGifImage(fileName)) { - throw new IllegalStateException("只支持 GIF、PNG、JPG、JPEG 文件。"); + throw new IllegalStateException(localize("只支持 GIF、PNG、JPG、JPEG 文件。")); } if (!isStaticImage) { GifUploadRules.Preflight preflight = OLEDFrameEncoder.preflight(path, 0); if (preflight.needsOptimization()) { javafx.scene.control.Alert confirmation = new javafx.scene.control.Alert( javafx.scene.control.Alert.AlertType.CONFIRMATION, - String.format("GIF 将自动优化为 %d×%d、最多 %d 帧,并尽量保持原始总时长。是否继续?", + String.format(localize("GIF 将自动优化为 %d×%d、最多 %d 帧,并尽量保持原始总时长。是否继续?"), GifUploadRules.WIDTH, GifUploadRules.HEIGHT, preflight.targetFrameLimit()), javafx.scene.control.ButtonType.OK, @@ -755,30 +765,30 @@ public void selectOledGif(javafx.stage.Window owner) { studioState.applyOledGifSelection(path.toString(), count); studioState.syncStatusProperty().set( isStaticImage - ? "已选择 " + studioState.getSelectedMode().getTitle() + " 的图片,连接键盘后可上传。" - : "已选择 " + studioState.getSelectedMode().getTitle() + " 的 GIF(" + count + " 帧),连接键盘后可上传。" + ? localize("已选择 ") + studioState.getSelectedMode().getTitle() + localize(" 的图片,连接键盘后可上传。") + : localize("已选择 ") + studioState.getSelectedMode().getTitle() + localize(" 的 GIF(") + count + localize(" 帧),连接键盘后可上传。") ); } catch (Exception e) { String message = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); - studioState.syncStatusProperty().set("GIF / 图片导入失败:" + message); - showOledWarning("GIF / 图片不适合上传", message); + studioState.syncStatusProperty().set(localize("GIF / 图片导入失败:") + message); + showOledWarning(localize("GIF / 图片不适合上传"), message); } } public void uploadCurrentOledToDevice() { if (!deviceStatus.isConnected() && !simulateBle) { - studioState.syncStatusProperty().set("设备未连接,请先连接键盘。"); + studioState.syncStatusProperty().set(localize("设备未连接,请先连接键盘。")); userConnect(); return; } OledModeDraft draft = studioState.getOledDraft(); String path = draft.getLocalAssetPath(); if (path == null || path.isBlank()) { - studioState.syncStatusProperty().set("请先选择 GIF 或图片。"); + studioState.syncStatusProperty().set(localize("请先选择 GIF 或图片。")); return; } if (simulateBle) { - studioState.syncStatusProperty().set("(模拟)OLED 上传已跳过。"); + studioState.syncStatusProperty().set(localize("(模拟)OLED 上传已跳过。")); return; } @@ -787,9 +797,9 @@ public void uploadCurrentOledToDevice() { String lowerPath = path.toLowerCase(); boolean isStaticImage = isStaticOledImage(lowerPath); if (!isStaticImage && !isGifImage(lowerPath)) { - String message = "只支持 GIF、PNG、JPG、JPEG 文件。"; + String message = localize("只支持 GIF、PNG、JPG、JPEG 文件。"); studioState.syncStatusProperty().set(message); - showOledWarning("无法上传 OLED GIF / 图片", message); + showOledWarning(localize("无法上传 OLED GIF / 图片"), message); return; } try { @@ -799,31 +809,31 @@ public void uploadCurrentOledToDevice() { } } catch (Exception e) { String message = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); - studioState.syncStatusProperty().set("OLED 上传已取消:" + message); - showOledWarning("无法上传 OLED GIF / 图片", message); + studioState.syncStatusProperty().set(localize("OLED 上传已取消:") + message); + showOledWarning(localize("无法上传 OLED GIF / 图片"), message); return; } logger.info("[OLED上传] 当前选择模式: {} (索引: {}){}", mode.getShortName(), mode.getIndex(), - isStaticImage ? ", 类型: 静态图片" : ", 类型: GIF动图"); + isStaticImage ? localize(", 类型: 静态图片") : localize(", 类型: GIF动图")); javafx.stage.Stage progressStage = new javafx.stage.Stage(); progressStage.initModality(javafx.stage.Modality.APPLICATION_MODAL); - progressStage.setTitle(isStaticImage ? "上传 OLED 图片" : "上传 OLED GIF"); + progressStage.setTitle(isStaticImage ? localize("上传 OLED 图片") : localize("上传 OLED GIF")); progressStage.setResizable(false); javafx.scene.layout.VBox dialogContent = new javafx.scene.layout.VBox(12); dialogContent.setPadding(new javafx.geometry.Insets(16)); javafx.scene.control.Label titleLabel = new javafx.scene.control.Label( - isStaticImage ? "正在上传 OLED 图片..." : "正在上传 OLED GIF..." + isStaticImage ? localize("正在上传 OLED 图片...") : localize("正在上传 OLED GIF...") ); titleLabel.getStyleClass().add("dialog-title"); javafx.scene.control.ProgressBar progressBar = new javafx.scene.control.ProgressBar(0); progressBar.setPrefWidth(300); - javafx.scene.control.Label detailLabel = new javafx.scene.control.Label("准备数据..."); + javafx.scene.control.Label detailLabel = new javafx.scene.control.Label(localize("准备数据...")); detailLabel.getStyleClass().add("dialog-detail"); dialogContent.getChildren().addAll(titleLabel, progressBar, detailLabel); @@ -853,16 +863,16 @@ public void uploadCurrentOledToDevice() { }), msg -> Platform.runLater(() -> { clearUploading.run(); - draft.setStatusLine("上传完成"); - draft.setCaptionLine(mode.getTitle() + " - 静态图片"); - studioState.setOledSummary("上传完成"); - studioState.setOledCaption(mode.getTitle() + " - 静态图片"); + draft.setStatusLine(localize("上传完成")); + draft.setCaptionLine(mode.getTitle() + localize(" - 静态图片")); + studioState.setOledSummary(localize("上传完成")); + studioState.setOledCaption(mode.getTitle() + localize(" - 静态图片")); studioState.syncStatusProperty().set(msg); }), err -> Platform.runLater(() -> { clearUploading.run(); - studioState.syncStatusProperty().set(mode.getTitle() + " OLED 上传失败:" + err); - showOledWarning("OLED 上传失败", err); + studioState.syncStatusProperty().set(mode.getTitle() + localize(" OLED 上传失败:") + err); + showOledWarning(localize("OLED 上传失败"), err); }) ); } else { @@ -879,16 +889,16 @@ public void uploadCurrentOledToDevice() { }), msg -> Platform.runLater(() -> { clearUploading.run(); - draft.setStatusLine("上传完成"); - draft.setCaptionLine(mode.getTitle() + " - " + draft.getFrameCount() + " 帧"); - studioState.setOledSummary("上传完成"); - studioState.setOledCaption(mode.getTitle() + " - " + draft.getFrameCount() + " 帧"); + draft.setStatusLine(localize("上传完成")); + draft.setCaptionLine(mode.getTitle() + " - " + draft.getFrameCount() + localize(" 帧")); + studioState.setOledSummary(localize("上传完成")); + studioState.setOledCaption(mode.getTitle() + " - " + draft.getFrameCount() + localize(" 帧")); studioState.syncStatusProperty().set(msg); }), err -> Platform.runLater(() -> { clearUploading.run(); - studioState.syncStatusProperty().set(mode.getTitle() + " OLED 上传失败:" + err); - showOledWarning("OLED 上传失败", err); + studioState.syncStatusProperty().set(mode.getTitle() + localize(" OLED 上传失败:") + err); + showOledWarning(localize("OLED 上传失败"), err); }) ); } @@ -974,10 +984,12 @@ private void applyReportedWorkMode(int reportedMode) { } } - private void persistDraft() { + private boolean persistDraft() { if (!StudioStore.save(studioState.toPersisted())) { - studioState.syncStatusProperty().set("本地配置保存失败;设备配置未受影响,请检查磁盘权限。"); + studioState.syncStatusProperty().set(localize("本地配置保存失败;设备配置未受影响,请检查磁盘权限。")); + return false; } + return true; } } diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/app/VoiceInputController.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/app/VoiceInputController.java index 5dab8eb2..cf5bf52a 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/app/VoiceInputController.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/app/VoiceInputController.java @@ -1,5 +1,7 @@ package com.example.ahakey.app; +import static com.example.ahakey.util.LanguageManager.localize; + import com.example.ahakey.service.VoiceInputManager; import javafx.application.Platform; import javafx.beans.property.BooleanProperty; @@ -33,7 +35,7 @@ public class VoiceInputController { private VoiceInputManager voiceManager; private final BooleanProperty isListening = new SimpleBooleanProperty(false); private final StringProperty recognitionResult = new SimpleStringProperty(""); - private final StringProperty statusText = new SimpleStringProperty("语音输入已就绪"); + private final StringProperty statusText = new SimpleStringProperty(localize("语音输入已就绪")); /** * 初始化控制器 @@ -44,7 +46,7 @@ public void initialize() { resultPreview.textProperty().bind(recognitionResult); // 设置按钮样式和提示 - voiceToggleButton.setTooltip(new Tooltip("点击开始/停止语音输入")); + voiceToggleButton.setTooltip(new Tooltip(localize("点击开始/停止语音输入"))); updateButtonState(false); // 添加按钮事件 @@ -74,13 +76,13 @@ private void toggleVoiceInput() { */ public void startVoiceInput() { if (voiceManager == null) { - statusText.set("错误:语音服务未初始化"); + statusText.set(localize("错误:语音服务未初始化")); return; } isListening.set(true); updateButtonState(true); - statusText.set("正在听..."); + statusText.set(localize("正在听...")); recognitionResult.set(""); voiceManager.startVoiceInput(text -> { @@ -101,9 +103,9 @@ public void stopVoiceInput() { voiceManager.stopVoiceInput(); if (recognitionResult.get().isEmpty()) { - statusText.set("未检测到语音"); + statusText.set(localize("未检测到语音")); } else { - statusText.set("识别完成"); + statusText.set(localize("识别完成")); } } @@ -113,10 +115,10 @@ public void stopVoiceInput() { private void updateButtonState(boolean listening) { if (listening) { voiceToggleButton.getStyleClass().add("voice-active"); - voiceToggleButton.setText("停止"); + voiceToggleButton.setText(localize("停止")); } else { voiceToggleButton.getStyleClass().remove("voice-active"); - voiceToggleButton.setText("语音输入"); + voiceToggleButton.setText(localize("语音输入")); } } @@ -127,7 +129,7 @@ public void reset() { isListening.set(false); updateButtonState(false); recognitionResult.set(""); - statusText.set("语音输入已就绪"); + statusText.set(localize("语音输入已就绪")); } /** diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/model/KeyConfig.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/model/KeyConfig.java index 75eb3c95..1ce0273c 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/model/KeyConfig.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/model/KeyConfig.java @@ -1,5 +1,7 @@ package com.example.ahakey.model; +import static com.example.ahakey.util.LanguageManager.localize; + import java.util.ArrayList; import java.util.List; @@ -56,7 +58,7 @@ public void setMacro(List macro) { } public String getDisplayName() { - if (hidCode == 0) return "未设置"; + if (hidCode == 0) return localize("未设置"); return HIDUsage.getName(hidCode); } @@ -69,7 +71,7 @@ public String getDisplaySummary() { return voicePreset.getDisplayName(); } if (usesMacro()) { - return "宏 (" + macro.size() + " 步)"; + return localize("宏 (") + macro.size() + localize(" 步)"); } return getDisplayName(); } @@ -153,11 +155,11 @@ public String formatMacroPreview() { } enum MacroAction { - NO_OP("无操作", false, false), - DOWN_KEY("按下键", true, false), - UP_KEY("释放键", true, false), - UP_ALL_KEYS("释放所有", false, false), - DELAY("延时", false, true); + NO_OP(localize("无操作"), false, false), + DOWN_KEY(localize("按下键"), true, false), + UP_KEY(localize("释放键"), true, false), + UP_ALL_KEYS(localize("释放所有"), false, false), + DELAY(localize("延时"), false, true); private final String title; private final boolean takesKeycodeParam; diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/model/OledModeDraft.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/model/OledModeDraft.java index 89fb2090..9b4a1ddc 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/model/OledModeDraft.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/model/OledModeDraft.java @@ -1,5 +1,7 @@ package com.example.ahakey.model; +import static com.example.ahakey.util.LanguageManager.localize; + import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.beans.property.IntegerProperty; @@ -9,8 +11,8 @@ public class OledModeDraft { private final StringProperty localAssetPath = new SimpleStringProperty(null); private final IntegerProperty frameCount = new SimpleIntegerProperty(0); - private final StringProperty statusLine = new SimpleStringProperty("未上传"); - private final StringProperty captionLine = new SimpleStringProperty("等待 GIF"); + private final StringProperty statusLine = new SimpleStringProperty(localize("未上传")); + private final StringProperty captionLine = new SimpleStringProperty(localize("等待 GIF")); public StringProperty localAssetPathProperty() { return localAssetPath; diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/model/StudioState.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/model/StudioState.java index ed97a2db..6bcb487a 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/model/StudioState.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/model/StudioState.java @@ -1,5 +1,7 @@ package com.example.ahakey.model; +import static com.example.ahakey.util.LanguageManager.localize; + import javafx.beans.property.BooleanProperty; import javafx.beans.property.IntegerProperty; import javafx.beans.property.ObjectProperty; @@ -27,11 +29,11 @@ public class StudioState { private final ObjectProperty selectedPart = new SimpleObjectProperty<>(StudioPart.KEY1); private final IntegerProperty dirtyCount = new SimpleIntegerProperty(0); private final IntegerProperty revision = new SimpleIntegerProperty(0); - private final StringProperty syncStatus = new SimpleStringProperty("修改会先保存在本地,保存配置后写入键盘。"); - private final StringProperty lastSyncSummary = new SimpleStringProperty("尚未保存"); + private final StringProperty syncStatus = new SimpleStringProperty(localize("修改会先保存在本地,保存配置后写入键盘。")); + private final StringProperty lastSyncSummary = new SimpleStringProperty(localize("尚未保存")); private final BooleanProperty syncing = new SimpleBooleanProperty(false); private final BooleanProperty ahaTypeEnabled = new SimpleBooleanProperty(false); - private final StringProperty ahaTypeStatus = new SimpleStringProperty("AhaType 未启用"); + private final StringProperty ahaTypeStatus = new SimpleStringProperty(localize("AhaType 未启用")); private final AhaTypeService ahaTypeService; private BooleanSupplier localSpeechAvailable = () -> true; private final ObjectProperty lightBarPreview = @@ -129,7 +131,7 @@ private void resetModeDefaults(ModeSlot mode) { map.put(StudioPart.KEY4, createKey(HIDUsage.BACKSPACE, "Backspace")); oledSummaries.put(mode, new SimpleStringProperty("Claude")); oledCaptions.put(mode, new SimpleStringProperty("Mode 1")); - lightBarSummaries.put(mode, new SimpleStringProperty("AI 状态灯效")); + lightBarSummaries.put(mode, new SimpleStringProperty(localize("AI 状态灯效"))); } else if (mode == ModeSlot.MODE1) { map.put(StudioPart.KEY1, createVoiceKey(HIDUsage.F18, "Record", VoicePreset.WINDOWS_NATIVE)); map.put(StudioPart.KEY2, createKey(HIDUsage.ENTER, "Accept")); @@ -137,7 +139,7 @@ private void resetModeDefaults(ModeSlot mode) { map.put(StudioPart.KEY4, createKey(HIDUsage.BACKSPACE, "Backspace")); oledSummaries.put(mode, new SimpleStringProperty("Cursor")); oledCaptions.put(mode, new SimpleStringProperty("Mode 2")); - lightBarSummaries.put(mode, new SimpleStringProperty("AI 状态灯效")); + lightBarSummaries.put(mode, new SimpleStringProperty(localize("AI 状态灯效"))); } else if (mode == ModeSlot.MODE2) { map.put(StudioPart.KEY1, createVoiceKey(HIDUsage.F18, "Record", VoicePreset.WINDOWS_NATIVE)); map.put(StudioPart.KEY2, createKey(HIDUsage.ENTER, "Accept")); @@ -145,7 +147,7 @@ private void resetModeDefaults(ModeSlot mode) { map.put(StudioPart.KEY4, createKey(HIDUsage.BACKSPACE, "Backspace")); oledSummaries.put(mode, new SimpleStringProperty("Codex")); oledCaptions.put(mode, new SimpleStringProperty("Mode 3")); - lightBarSummaries.put(mode, new SimpleStringProperty("AI 状态灯效")); + lightBarSummaries.put(mode, new SimpleStringProperty(localize("AI 状态灯效"))); } else { map.put(StudioPart.KEY1, createKey(0, "N/A")); map.put(StudioPart.KEY2, createKey(0, "N/A")); @@ -153,7 +155,7 @@ private void resetModeDefaults(ModeSlot mode) { map.put(StudioPart.KEY4, createKey(HIDUsage.BACKSPACE, "Backspace")); oledSummaries.put(mode, new SimpleStringProperty("N/A")); oledCaptions.put(mode, new SimpleStringProperty("Mode 4")); - lightBarSummaries.put(mode, new SimpleStringProperty("AI 状态灯效")); + lightBarSummaries.put(mode, new SimpleStringProperty(localize("AI 状态灯效"))); } resetAiLightDefaults(mode); } @@ -236,10 +238,10 @@ public void refreshAhaTypeState() { if (effective) { ahaTypeStatus.set(ahaTypeService.getLastProcessIssue() == AhaTypeService.ProcessIssue.NONE - ? "AhaType 已启用" + ? localize("AhaType 已启用") : ahaTypeService.getStatusMessage()); } else { - ahaTypeStatus.set(ahaTypeService.getStatusMessage()); + ahaTypeStatus.set(localize(ahaTypeService.getStatusMessage())); } } @@ -273,7 +275,7 @@ public LightEffectStyle getAiLightEffect(ModeSlot mode, IDEState state) { public void setAiLightEffect(ModeSlot mode, IDEState state, LightEffectStyle effect) { aiLightConfigs.get(mode).put(state, effect); - lightBarSummaries.get(mode).set("已自定义 AI 状态灯效"); + lightBarSummaries.get(mode).set(localize("已自定义 AI 状态灯效")); markDirty(StudioPart.LIGHT_BAR); } @@ -334,7 +336,7 @@ public int getVoiceShortCustomShortcutHid() { } public void setVoiceShortCustomShortcutHid(int value) { - if (!VoiceActionRouter.isValidCustomShortcut(value)) { + if (!VoiceActionRouter.isValidShortcutDraft(value)) { throw new IllegalArgumentException("F18 or invalid key cannot be a custom voice shortcut"); } voiceShortCustomShortcutHid.set(value); @@ -350,7 +352,7 @@ public int getVoiceLongCustomShortcutHid() { } public void setVoiceLongCustomShortcutHid(int value) { - if (!VoiceActionRouter.isValidCustomShortcut(value)) { + if (!VoiceActionRouter.isValidShortcutDraft(value)) { throw new IllegalArgumentException("F18 or invalid key cannot be a custom voice shortcut"); } voiceLongCustomShortcutHid.set(value); @@ -440,8 +442,8 @@ public void applyOledGifSelection(String path, int frameCount) { OledModeDraft draft = getOledDraft(); draft.setLocalAssetPath(path); draft.setFrameCount(frameCount); - draft.setStatusLine("已选择 GIF / 图片"); - draft.setCaptionLine(frameCount + " 帧 · " + java.nio.file.Path.of(path).getFileName()); + draft.setStatusLine(localize("已选择 GIF / 图片")); + draft.setCaptionLine(frameCount + localize(" 帧 · ") + java.nio.file.Path.of(path).getFileName()); oledSummaries.get(getSelectedMode()).set(draft.getStatusLine()); oledCaptions.get(getSelectedMode()).set(draft.getCaptionLine()); markDirty(StudioPart.OLED); @@ -481,22 +483,22 @@ public void setOledCaption(String caption) { public boolean toggleAhaType(boolean enabled) { if (!enabled) { if (!ahaTypeService.setEnabled(false)) { - ahaTypeStatus.set(ahaTypeService.getStatusMessage()); + ahaTypeStatus.set(localize(ahaTypeService.getStatusMessage())); return false; } refreshAhaTypeState(); return true; } if (!localSpeechAvailable.getAsBoolean()) { - ahaTypeStatus.set("本地语音未就绪"); + ahaTypeStatus.set(localize("本地语音未就绪")); return false; } if (!ahaTypeService.hasValidToken()) { - ahaTypeStatus.set("请先登录 AhaType"); + ahaTypeStatus.set(localize("请先登录 AhaType")); return false; } if (!ahaTypeService.setEnabled(true)) { - ahaTypeStatus.set(ahaTypeService.getStatusMessage()); + ahaTypeStatus.set(localize(ahaTypeService.getStatusMessage())); return false; } refreshAhaTypeState(); @@ -518,7 +520,7 @@ public void markDirty(StudioPart part) { dirtyRevisions.put(part, nextRevision); dirtyCount.set(dirtyParts.size()); revision.set(nextRevision); - syncStatus.set("有 " + dirtyParts.size() + " 处改动待保存。"); + syncStatus.set(localize("有 ") + dirtyParts.size() + localize(" 处改动待保存。")); } public void restoreCurrentModeDefaults() { @@ -531,17 +533,17 @@ public void restoreCurrentModeDefaults() { } dirtyCount.set(dirtyParts.size()); revision.set(nextRevision); - syncStatus.set("已恢复 " + getSelectedMode().getTitle() + " 默认值,等待保存。"); + syncStatus.set(localize("已恢复 ") + getSelectedMode().getTitle() + localize(" 默认值,等待保存。")); } public void clearOledPreview() { OledModeDraft draft = getOledDraft(); draft.setLocalAssetPath(null); draft.setFrameCount(0); - draft.setStatusLine("未选择"); - draft.setCaptionLine("等待选择 GIF / 图片"); - oledSummaries.get(getSelectedMode()).set("未选择"); - oledCaptions.get(getSelectedMode()).set("等待选择 GIF / 图片"); + draft.setStatusLine(localize("未选择")); + draft.setCaptionLine(localize("等待选择 GIF / 图片")); + oledSummaries.get(getSelectedMode()).set(localize("未选择")); + oledCaptions.get(getSelectedMode()).set(localize("等待选择 GIF / 图片")); markDirty(StudioPart.OLED); } @@ -553,6 +555,18 @@ public DirtySnapshot captureDirtySnapshot() { return new DirtySnapshot(dirtyRevisions); } + /** K1 actions are stored on the desktop; other edits require device ACKs. */ + public boolean hasDeviceConfigurationChanges() { + return dirtyParts.stream().anyMatch(part -> part != StudioPart.KEY1); + } + + public boolean hasIncompleteVoiceShortcut() { + return (getVoiceShortAction() == VoiceAction.CUSTOM_SHORTCUT + && !VoiceActionRouter.isValidCustomShortcut(getVoiceShortCustomShortcutHid())) + || (getVoiceLongAction() == VoiceAction.CUSTOM_SHORTCUT + && !VoiceActionRouter.isValidCustomShortcut(getVoiceLongCustomShortcutHid())); + } + public void clearDirtyAfterSync(DirtySnapshot snapshot) { if (snapshot == null) return; for (Map.Entry saved : snapshot.revisions().entrySet()) { @@ -563,7 +577,7 @@ public void clearDirtyAfterSync(DirtySnapshot snapshot) { } } dirtyCount.set(dirtyParts.size()); - lastSyncSummary.set("最近保存 " + LocalDateTime.now().format(SYNC_TIME_FORMAT)); + lastSyncSummary.set(localize("最近保存 ") + LocalDateTime.now().format(SYNC_TIME_FORMAT)); } public int getRevision() { @@ -766,7 +780,7 @@ private static VoiceAction parseVoiceAction(String value, VoiceAction fallback) } private static int normalizeCustomShortcut(Integer value, int fallback) { - return value != null && VoiceActionRouter.isValidCustomShortcut(value) ? value : fallback; + return value != null && VoiceActionRouter.isValidShortcutDraft(value) ? value : fallback; } } diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/model/VoicePreset.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/model/VoicePreset.java index 54363fc1..c816531c 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/model/VoicePreset.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/model/VoicePreset.java @@ -1,12 +1,14 @@ package com.example.ahakey.model; +import static com.example.ahakey.util.LanguageManager.localize; + /** Legacy display/migration preset; Desktop voice actions are the runtime source of truth. */ public enum VoicePreset { - CUSTOM("自定义快捷键", false), - WINDOWS_NATIVE("Windows 语音 (Win+H)", true), - MACOS_NATIVE("macOS 原生语音", true), + CUSTOM(localize("自定义快捷键"), false), + WINDOWS_NATIVE(localize("Windows 语音 (Win+H)"), true), + MACOS_NATIVE(localize("macOS 原生语音"), true), TYPELESS("Typeless / Fn", true), - WECHAT("微信语音", true); + WECHAT(localize("微信语音"), true); private final String displayName; private final boolean locksShortcut; @@ -27,12 +29,12 @@ public boolean locksShortcut() { public String getDetail() { return switch (this) { case WINDOWS_NATIVE -> - "AhaKey Studio 在后台拦截物理 F18;短按或显式配置的长按系统动作会发送 Win+H 打开 Windows 语音输入。请在「设置 → 时间和语言 → 语音」中启用语音输入。"; + localize("AhaKey Studio 在后台拦截物理 F18;短按或显式配置的长按系统动作会发送 Win+H 打开 Windows 语音输入。请在「设置 → 时间和语言 → 语音」中启用语音输入。"); case MACOS_NATIVE -> - "仅 macOS 完整支持;Windows 请改用「Windows 语音 (Win+H)」。"; + localize("仅 macOS 完整支持;Windows 请改用「Windows 语音 (Win+H)」。"); case TYPELESS, WECHAT -> - "Windows 版暂未实现 Fn 注入;请使用 Windows 语音 (Win+H) 或自定义快捷键。"; - case CUSTOM -> "自行绑定 HID 单键或组合键。"; + localize("Windows 版暂未实现 Fn 注入;请使用 Windows 语音 (Win+H) 或自定义快捷键。"); + case CUSTOM -> localize("自行绑定 HID 单键或组合键。"); }; } } diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/platform/voice/VoiceActionRouter.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/platform/voice/VoiceActionRouter.java index 076c3eae..c8b76ac8 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/platform/voice/VoiceActionRouter.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/platform/voice/VoiceActionRouter.java @@ -1,5 +1,7 @@ package com.example.ahakey.platform.voice; +import static com.example.ahakey.util.LanguageManager.text; + import com.example.ahakey.model.HIDUsage; import java.util.EnumMap; @@ -74,19 +76,28 @@ public static int parseShortcut(String text) { /** Formats the shared HID representation for display in the editor. */ public static String formatShortcut(int hidCode) { - if (hidCode == 0) return "未设置"; + if (hidCode == 0) return text("common.unset"); int base = hidCode & 0xFF; - if (base == 0) return "未设置"; + if (base == 0) return text("common.unset"); java.util.List parts = new java.util.ArrayList<>(); if ((hidCode & 0x800) != 0) parts.add("Win"); if ((hidCode & 0x200) != 0) parts.add("Ctrl"); if ((hidCode & 0x400) != 0) parts.add("Alt"); if ((hidCode & 0x100) != 0) parts.add("Shift"); + if ((hidCode & 0x8000) != 0) parts.add("RWin"); + if ((hidCode & 0x2000) != 0) parts.add("RCtrl"); + if ((hidCode & 0x4000) != 0) parts.add("RAlt"); + if ((hidCode & 0x1000) != 0) parts.add("RShift"); parts.add(HIDUsage.getName(base)); return String.join("+", parts); } - /** Custom shortcuts must have one known base key and cannot target F18. */ + /** Editing may temporarily leave only modifiers, or no keys at all. */ + public static boolean isValidShortcutDraft(int hidCode) { + return (hidCode & ~0xFF00) == 0 || isValidCustomShortcut(hidCode); + } + + /** Executable shortcuts must have one known base key and cannot target F18. */ public static boolean isValidCustomShortcut(int hidCode) { int allowedModifiers = 0xFF00; int base = hidCode & 0xFF; diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/platform/windows/WindowsVoiceRelayService.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/platform/windows/WindowsVoiceRelayService.java index 33728f72..3082c738 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/platform/windows/WindowsVoiceRelayService.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/platform/windows/WindowsVoiceRelayService.java @@ -1,5 +1,7 @@ package com.example.ahakey.platform.windows; +import static com.example.ahakey.util.LanguageManager.text; + import com.example.ahakey.model.ModeSlot; import com.example.ahakey.model.StudioState; import com.example.ahakey.model.VoicePreset; @@ -74,8 +76,8 @@ public final class WindowsVoiceRelayService { private static WindowsVoiceRelayService instance; private final BooleanProperty listening = new SimpleBooleanProperty(false); - private final StringProperty statusMessage = new SimpleStringProperty("语音桥尚未启动。"); - private final StringProperty activeRouteSummary = new SimpleStringProperty("未配置路由。"); + private final StringProperty statusMessage = new SimpleStringProperty(text("voice.bridge.not-started")); + private final StringProperty activeRouteSummary = new SimpleStringProperty(text("voice.route.not-configured")); private final StringProperty lastSimulateHint = new SimpleStringProperty(null); private WinUser.HHOOK hookHandle; @@ -123,7 +125,7 @@ private WindowsVoiceRelayService() { // An unavailable local model must be visible and fail closed. It // must not silently become the unrelated short Win+H action. if (event.type() == VoiceButtonEvent.Type.LONG_PRESS_START) { - statusMessage.set("AhaKey 本地语音(当前不可用),未执行动作。"); + statusMessage.set(text("voice.local.unavailable")); } }); actionRouter.setExecutor(VoiceAction.CUSTOM_SHORTCUT, event -> { @@ -132,7 +134,7 @@ private WindowsVoiceRelayService() { int hid = event.type() == VoiceButtonEvent.Type.SHORT_PRESS ? shortCustomShortcutHid : longCustomShortcutHid; if (!VoiceActionRouter.isValidCustomShortcut(hid)) { - statusMessage.set("自定义快捷键无效;F18 为 AhaKey 语音键保留。"); + statusMessage.set(text("voice.shortcut.invalid")); return; } customShortcutEmitter.accept(hid); @@ -224,9 +226,10 @@ public synchronized void configureVoiceActions( configuredThresholdMs = VOICE_LONG_PRESS_THRESHOLD_MS; pressGeneration++; cancelThresholdTask(); - activeRouteSummary.set("固定 F18(短按=" + VoiceActionRouter.migrateShortAction(shortAction) - + ",长按=" + VoiceActionRouter.migrateLongAction(longAction) - + ",阈值=" + VOICE_LONG_PRESS_THRESHOLD_MS + "ms)"); + activeRouteSummary.set(text("voice.route.summary", + voiceActionTitle(VoiceActionRouter.migrateShortAction(shortAction)), + voiceActionTitle(VoiceActionRouter.migrateLongAction(longAction)), + VOICE_LONG_PRESS_THRESHOLD_MS)); refreshStatus(); } @@ -234,6 +237,10 @@ public void setOnVoiceAction(Consumer callback) { this.onVoiceAction = callback; } + private static String voiceActionTitle(VoiceAction action) { + return text("voice.action." + action.name().toLowerCase(java.util.Locale.ROOT)); + } + /** Enables local push-to-talk only while VoiceInputManager is active. */ public void setAhaKeyVoiceAvailable(boolean available) { this.ahaKeyVoiceAvailable = available; @@ -271,7 +278,7 @@ public boolean isRawF18RoutingEnabled() { public void updateRoutes(StudioState state) { if (state == null) { - activeRouteSummary.set("未配置路由。"); + activeRouteSummary.set(text("voice.route.not-configured")); return; } configureVoiceActions(state.getVoiceShortAction(), state.getVoiceLongAction(), @@ -282,7 +289,7 @@ public void updateRoutes(StudioState state) { private static int normalizeCustomShortcut(int hidCode) { return VoiceActionRouter.isValidCustomShortcut(hidCode) - ? hidCode : VoiceActionRouter.defaultWindowsVoiceShortcut(); + ? hidCode : 0; } /** Package-private emitter seam for non-Windows action tests. */ @@ -297,7 +304,7 @@ private boolean sendCustomShortcutOnce(int hidCode) { public void start() { if (!WindowsVoiceTyping.isWindows()) { - statusMessage.set("当前系统不是 Windows,语音桥未启动。"); + statusMessage.set(text("voice.bridge.unsupported")); return; } if (messagePump != null && messagePump.isAlive()) { @@ -341,12 +348,12 @@ public void stop() { hookHandle = null; hookThreadId = 0; listening.set(false); - statusMessage.set("语音桥已停止。"); + statusMessage.set(text("voice.bridge.stopped")); } public void simulateVoiceKeyTap(ModeSlot mode) { WindowsVoiceTyping.trigger(); - lastSimulateHint.set("已模拟 Windows 语音(" + mode.getShortName() + ",物理 F18)"); + lastSimulateHint.set(text("voice.simulate.windows-prefix") + mode.getShortName() + text("voice.simulate.windows-suffix")); } /** @@ -373,13 +380,13 @@ public void simulateVoiceKeyTap(ModeSlot mode, VoicePreset preset) { onSimulateRecordStop.run(); } }).start(); - lastSimulateHint.set("已开始录音(模拟 F18,录制3秒)"); + lastSimulateHint.set(text("voice.simulate.recording")); } else { - lastSimulateHint.set("Windows 不执行 macOS 原生语音,也不会合成 F18。"); + lastSimulateHint.set(text("voice.simulate.macos")); } break; default: - lastSimulateHint.set("当前语音预设不支持模拟。"); + lastSimulateHint.set(text("voice.simulate.unsupported")); } } @@ -391,7 +398,7 @@ public void simulateVoiceKeyTap(ModeSlot mode, VoicePreset preset) { */ public void simulateKeyByHid(int hidCode) { if (hidCode == 0) { - lastSimulateHint.set("未设置按键,无法模拟。"); + lastSimulateHint.set(text("voice.simulate.no-key")); return; } java.util.List modVks = new java.util.ArrayList<>(); @@ -409,7 +416,7 @@ public void simulateKeyByHid(int hidCode) { int baseHid = hidCode & 0xFF; int baseVk = hidBaseToVk(baseHid); if (baseVk < 0 && modVks.isEmpty()) { - lastSimulateHint.set("无法识别 HID 0x" + String.format("%02X", baseHid) + " 对应的虚拟键码。"); + lastSimulateHint.set(text("voice.simulate.unknown-hid-prefix") + String.format("%02X", baseHid) + text("voice.simulate.unknown-hid-suffix")); return; } @@ -443,7 +450,7 @@ public void simulateKeyByHid(int hidCode) { if ((hidCode & 0x8000) != 0) names.add("RWin"); desc = String.join("+", names) + "+" + desc; } - lastSimulateHint.set("已模拟 " + desc); + lastSimulateHint.set(text("voice.simulate.done-prefix") + desc); } public void simulateMacro(com.example.ahakey.model.KeyConfig config) { @@ -457,9 +464,9 @@ public void simulateMacro(com.example.ahakey.model.KeyConfig config) { else if ("DOWN_KEY".equals(action)) sendRawHid(value, false); else if ("UP_KEY".equals(action)) sendRawHid(value, true); } - lastSimulateHint.set("宏按键模拟完成。"); + lastSimulateHint.set(text("voice.simulate.macro-done")); } catch (Exception e) { - lastSimulateHint.set("宏按键模拟失败:" + e.getMessage()); + lastSimulateHint.set(text("voice.simulate.macro-error") + e.getMessage()); } finally { releaseAllSimulatedKeys(); } @@ -481,20 +488,20 @@ private void sendRawHid(int hidCode, boolean keyUp) { /** Presses a configured shortcut and keeps it down until releaseKeyByHid. */ public void pressKeyByHid(int hidCode) { if (sendHidState(hidCode, true)) { - lastSimulateHint.set("模拟按键已按下;松开测试按钮时释放。"); + lastSimulateHint.set(text("voice.simulate.pressed")); } } /** Releases a shortcut previously pressed by pressKeyByHid. */ public void releaseKeyByHid(int hidCode) { if (sendHidState(hidCode, false)) { - lastSimulateHint.set("模拟按键已释放。"); + lastSimulateHint.set(text("voice.simulate.released")); } } private boolean sendHidState(int hidCode, boolean down) { if (hidCode == 0) { - lastSimulateHint.set("未设置按键,无法模拟。"); + lastSimulateHint.set(text("voice.simulate.no-key")); return false; } java.util.List modifierVks = new java.util.ArrayList<>(); @@ -509,7 +516,7 @@ private boolean sendHidState(int hidCode, boolean down) { int baseVk = hidBaseToVk(hidCode & 0xFF); int total = modifierVks.size() + (baseVk >= 0 ? 1 : 0); if (total == 0) { - lastSimulateHint.set("无法识别当前按键,无法模拟。"); + lastSimulateHint.set(text("voice.simulate.unknown-key")); return false; } WinUser.INPUT[] inputs = (WinUser.INPUT[]) new WinUser.INPUT().toArray(total); @@ -650,7 +657,7 @@ private void messageLoop() { ); if (hookHandle == null) { Platform.runLater(() -> { - statusMessage.set("安装键盘钩子失败;请检查安全软件或以管理员重试。"); + statusMessage.set(text("voice.hook.failed")); listening.set(false); }); return; @@ -787,21 +794,21 @@ private synchronized void cancelThresholdTask() { private void refreshStatus() { if (!WindowsVoiceTyping.isWindows()) { - statusMessage.set("非 Windows 平台。"); + statusMessage.set(text("voice.platform.unsupported")); return; } if (!rawF18RoutingEnabled) { - String version = firmwareVersion == null ? "未知" : firmwareVersion.toString(); - statusMessage.set("物理 F18 桌面语音未启用(固件 " + version - + ";需要 1.4.8 或更高版本)。"); + String version = firmwareVersion == null ? text("common.unknown") : firmwareVersion.toString(); + statusMessage.set(text("voice.firmware.prefix") + version + + text("voice.firmware.suffix")); return; } if (hookHandle == null) { - statusMessage.set("语音桥未运行;进入编辑配置或启动应用后会自动安装钩子。"); + statusMessage.set(text("voice.hook.not-running")); return; } - statusMessage.set("正在监听物理 F18;Desktop 负责短按/长按语义(阈值 " - + configuredThresholdMs + "ms)。"); + statusMessage.set(text("voice.listening.prefix") + + configuredThresholdMs + text("voice.listening.suffix")); } } diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/service/AgentManager.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/service/AgentManager.java index 2957392d..0da69c15 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/service/AgentManager.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/service/AgentManager.java @@ -1,5 +1,7 @@ package com.example.ahakey.service; +import static com.example.ahakey.util.LanguageManager.text; + import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleBooleanProperty; @@ -71,6 +73,6 @@ public void installHooks() { installed.set(true); hooksInstalled.set(true); operationInProgress.set(false); - userAlert.set("Hooks 安装成功!AI 应用重启后生效。"); + userAlert.set(text("hooks.installed")); } } diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/service/DeviceSyncService.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/service/DeviceSyncService.java index ac37a057..6f1f580c 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/service/DeviceSyncService.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/service/DeviceSyncService.java @@ -1,5 +1,7 @@ package com.example.ahakey.service; +import static com.example.ahakey.util.LanguageManager.text; + import com.example.ahakey.model.ModeSlot; import com.example.ahakey.model.StudioPart; import com.example.ahakey.model.StudioState; @@ -71,7 +73,7 @@ public static List commandsForModes( out.add(new LabeledCommand( AhaKeyProtocol.setKeyMacro(modeIndex, keyIndex, macroData), - mode.getTitle() + " " + part.getTitle() + " 宏" + mode.getTitle() + " " + part.getTitle() + text("sync.macro") )); } else { // 处理快捷键:包含修饰键和基础键 @@ -105,33 +107,33 @@ public static List commandsForModes( out.add(new LabeledCommand( AhaKeyProtocol.setKeyMapping(modeIndex, keyIndex, hid), - mode.getTitle() + " " + part.getTitle() + " 键码" + mode.getTitle() + " " + part.getTitle() + text("sync.keycode") )); } out.add(new LabeledCommand( AhaKeyProtocol.setKeyDescription(modeIndex, keyIndex, key.getDescription()), - mode.getTitle() + " " + part.getTitle() + " 描述" + mode.getTitle() + " " + part.getTitle() + text("sync.description") )); } out.add(new LabeledCommand( AhaKeyProtocol.setAiLightConfig(modeIndex, state.getAiLightEffectBytes(mode)), - mode.getTitle() + " AI 状态灯效" + mode.getTitle() + text("sync.ai-light") )); } - out.add(new LabeledCommand(AhaKeyProtocol.setLightBrightness(state.getLightBrightness()), "灯光亮度")); + out.add(new LabeledCommand(AhaKeyProtocol.setLightBrightness(state.getLightBrightness()), text("sync.brightness"))); if (includeVoiceKey) { out.add(new LabeledCommand( AhaKeyProtocol.setVoiceKeyConfig( state.getVoiceKeyShort().getHidCode(), state.getVoiceKeyLong().getHidCode() ), - "语音键短按/长按快捷键" + text("sync.voice-shortcuts") )); } - out.add(new LabeledCommand(AhaKeyProtocol.saveConfig(), "保存全部配置到设备")); + out.add(new LabeledCommand(AhaKeyProtocol.saveConfig(), text("sync.save-all"))); return out; } @@ -141,20 +143,34 @@ public static SyncHandle writeSequentially( Runnable onComplete, Runnable onError, Consumer onProgress + ) { + return writeSequentially(ble, commands, () -> null, onComplete, onError, onProgress); + } + + public static SyncHandle writeSequentially( + BleManager ble, + List commands, + BleManager.DeviceTransaction beforeWrite, + Runnable onComplete, + Runnable onError, + Consumer onProgress ) { ExpectedVoiceConfig expectedVoice = findExpectedVoiceConfig(commands); Thread worker = new Thread(() -> { try { ble.executeDeviceTransaction(() -> { + // Capability probes can time out. Keep them off the FX thread + // and within the same transaction as the following writes. + beforeWrite.execute(); int i = 0; for (LabeledCommand cmd : commands) { i++; if (onProgress != null) { - onProgress.accept("保存中 (" + i + "/" + commands.size() + ") " + cmd.label()); + onProgress.accept(text("sync.progress") + i + "/" + commands.size() + ") " + cmd.label()); } byte[] frame = cmd.data(); if (frame.length < 5) { - throw new java.io.IOException("配置命令格式无效: " + cmd.label()); + throw new java.io.IOException(text("sync.invalid-command") + cmd.label()); } // Nested command transactions are reentrant. The outer // lifecycle transaction prevents status recovery from @@ -169,7 +185,7 @@ public static SyncHandle writeSequentially( || !java.util.Arrays.equals(verified.shortCodes(), expectedVoice.shortCodes()) || !java.util.Arrays.equals(verified.longCodes(), expectedVoice.longCodes()) || verified.longPressMs() != AhaKeyProtocol.VOICE_KEY_LONG_PRESS_MS) { - throw new java.io.IOException("语音键配置回读校验失败,请确认固件支持短按/长按功能"); + throw new java.io.IOException(text("sync.voice-readback-error")); } } return null; @@ -179,7 +195,7 @@ public static SyncHandle writeSequentially( } } catch (Exception e) { if (onProgress != null) { - onProgress.accept("保存失败:" + e.getMessage()); + onProgress.accept(text("sync.failed") + e.getMessage()); } if (onError != null) { onError.run(); diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/service/TaskActivityService.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/service/TaskActivityService.java index 2fa12e3e..ddacd067 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/service/TaskActivityService.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/service/TaskActivityService.java @@ -1,5 +1,7 @@ package com.example.ahakey.service; +import static com.example.ahakey.util.LanguageManager.text; + import com.example.ahakey.model.IDEState; import javafx.application.Platform; import javafx.beans.property.ReadOnlyListProperty; @@ -63,7 +65,7 @@ public void setMultiMode(boolean enabled) { worker.execute(() -> { if (!ble.getCachedStatus().isConnected()) { notifyDisplayMode(DisplayModeStatus.OFFLINE_PENDING, - "任务显示模式已保存为本地选择,设备重连后同步。"); + text("task.mode.saved-local")); publish(); return; } @@ -74,12 +76,12 @@ public void setMultiMode(boolean enabled) { if (enabled) resendSnapshot(); lastConnected = true; notifyDisplayMode(DisplayModeStatus.CONFIRMED, - "任务显示模式已由设备确认。"); + text("task.mode.confirmed")); } catch (Exception failure) { desiredMultiMode = confirmedMultiMode; - reportSyncFailure("切换任务显示模式", failure); + reportSyncFailure(text("task.mode.change"), failure); notifyDisplayMode(DisplayModeStatus.FAILED, - "任务显示模式同步失败,已回滚到设备确认值:" + text("task.mode.rollback") + failure.getMessage()); } publish(); @@ -163,12 +165,12 @@ private void release(MutableTask task) { if (task.slot < 0) return; int old = task.slot; task.slot = -1; try { if (ble.getCachedStatus().isConnected()) ble.updateTaskSlot(old, task.profile, 0, false); } - catch (Exception failure) { reportSyncFailure("清除任务灯效槽", failure); } + catch (Exception failure) { reportSyncFailure(text("task.slot.clear"), failure); } } private void send(MutableTask task) { if (!confirmedMultiMode || task.slot < 0 || !ble.getCachedStatus().isConnected()) return; try { ble.updateTaskSlot(task.slot, task.profile, task.state, task.foreground); } - catch (Exception failure) { reportSyncFailure("同步任务灯效", failure); } + catch (Exception failure) { reportSyncFailure(text("task.light.sync"), failure); } } private void heartbeatAndReconcile() { heartbeatAndReconcile(System.currentTimeMillis()); @@ -192,7 +194,7 @@ private void heartbeatAndReconcile(long now) { try { int deviceMode = ble.queryTaskDisplayMode(); if (deviceMode != 0 && deviceMode != 1) { - throw new IllegalStateException("设备返回了无效任务显示模式"); + throw new IllegalStateException(text("task.mode.invalid")); } confirmedMultiMode = deviceMode == 1; if (desiredMultiMode != confirmedMultiMode) { @@ -202,24 +204,24 @@ private void heartbeatAndReconcile(long now) { clearHardwareSlots(); if (confirmedMultiMode) resendSnapshot(); notifyDisplayMode(DisplayModeStatus.CONFIRMED, - "重连后已通过 0x98 确认任务显示模式。"); + text("task.mode.reconnected")); lastConnected = true; } catch (Exception failure) { - reportSyncFailure("重连后确认任务灯效", failure); + reportSyncFailure(text("task.light.confirm"), failure); notifyDisplayMode(DisplayModeStatus.FAILED, - "重连后任务显示模式尚未确认:" + failure.getMessage()); + text("task.mode.unconfirmed") + failure.getMessage()); return; } } if (confirmedMultiMode) { try { ble.sendTaskHeartbeat(); } - catch (Exception failure) { reportSyncFailure("任务灯效心跳", failure); } + catch (Exception failure) { reportSyncFailure(text("task.light.heartbeat"), failure); } } } private void clearHardwareSlots() { for (int slot = 0; slot < 4; slot++) { try { ble.updateTaskSlot(slot, 0, 0, false); } - catch (Exception failure) { reportSyncFailure("清空任务灯效槽 " + slot, failure); } + catch (Exception failure) { reportSyncFailure(text("task.slot.clear-prefix") + slot, failure); } } } private void resendSnapshot() { @@ -228,7 +230,7 @@ private void resendSnapshot() { private void reportSyncFailure(String operation, Exception failure) { logger.warn("{}失败,设备灯效可能与 IDE 状态不同步: {}", operation, failure.getMessage()); - ble.reportDeviceError(operation + "失败;灯效/OLED 状态可能不同步:" + ble.reportDeviceError(operation + text("task.sync.failed") + failure.getMessage()); } private void notifyDisplayMode(DisplayModeStatus status, String message) { diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/update/AppUpdateCoordinator.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/update/AppUpdateCoordinator.java index d2080aa8..6c4302e0 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/update/AppUpdateCoordinator.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/update/AppUpdateCoordinator.java @@ -1,5 +1,7 @@ package com.example.ahakey.update; +import static com.example.ahakey.util.LanguageManager.localize; + import javafx.application.Platform; import javafx.geometry.Insets; import javafx.scene.control.Alert; @@ -35,8 +37,8 @@ public static void onApplicationReady(Stage owner) { if (installed.equals(CURRENT.toString())) { PREFS.remove(INSTALLED_NOTICE); Alert done = alert(owner, Alert.AlertType.INFORMATION, - text("更新完成", "Update Complete"), - text("AhaKeyStudio 已更新到 ", "AhaKeyStudio was updated to ") + installed); + text(localize("更新完成"), "Update Complete"), + text(localize("AhaKeyStudio 已更新到 "), "AhaKeyStudio was updated to ") + installed); done.show(); } long now = Instant.now().toEpochMilli(); @@ -49,12 +51,12 @@ public static void onApplicationReady(Stage owner) { public static void showAboutAndCheck(Stage owner) { Alert about = alert(owner, Alert.AlertType.INFORMATION, - text("关于与软件更新", "About & Software Update"), + text(localize("关于与软件更新"), "About & Software Update"), "AhaKeyStudio " + CURRENT + "\nWindows 10/11 x64\n\n" - + text("将通过 ahakey.com 检查稳定版本。", + + text(localize("将通过 ahakey.com 检查稳定版本。"), "Checks the stable release through ahakey.com.")); ButtonType check = new ButtonType( - text("检查更新", "Check for Updates"), ButtonBar.ButtonData.OK_DONE); + text(localize("检查更新"), "Check for Updates"), ButtonBar.ButtonData.OK_DONE); about.getButtonTypes().setAll(check, ButtonType.CLOSE); if (about.showAndWait().filter(check::equals).isPresent()) { check(owner, true); @@ -74,8 +76,8 @@ public static void check(Stage owner, boolean manual) { || !optional.get().appVersion().isNewerThan(CURRENT)) { if (manual) { Platform.runLater(() -> alert(owner, Alert.AlertType.INFORMATION, - text("已是最新版本", "Up to Date"), - text("当前已安装最新稳定版。", "The latest stable version is installed.")) + text(localize("已是最新版本"), "Up to Date"), + text(localize("当前已安装最新稳定版。"), "The latest stable version is installed.")) .showAndWait()); } return; @@ -85,7 +87,7 @@ public static void check(Stage owner, boolean manual) { } catch (Exception exception) { if (manual) { Platform.runLater(() -> alert(owner, Alert.AlertType.WARNING, - text("检查更新失败", "Update Check Failed"), + text(localize("检查更新失败"), "Update Check Failed"), exception.getMessage()).showAndWait()); } } finally { @@ -96,12 +98,12 @@ public static void check(Stage owner, boolean manual) { private static void prompt(Stage owner, StableRelease release) { Alert prompt = alert(owner, Alert.AlertType.INFORMATION, - text("发现新版本 ", "New Version ") + release.appVersion(), + text(localize("发现新版本 "), "New Version ") + release.appVersion(), release.appName() + "\n\n" + release.appNotes()); ButtonType install = new ButtonType( - text("下载并安装", "Download & Install"), ButtonBar.ButtonData.OK_DONE); + text(localize("下载并安装"), "Download & Install"), ButtonBar.ButtonData.OK_DONE); ButtonType later = new ButtonType( - text("稍后提醒", "Remind Me Later"), ButtonBar.ButtonData.CANCEL_CLOSE); + text(localize("稍后提醒"), "Remind Me Later"), ButtonBar.ButtonData.CANCEL_CLOSE); prompt.getButtonTypes().setAll(install, later); prompt.showAndWait().ifPresent(choice -> { if (choice == install) { @@ -119,8 +121,8 @@ private static void downloadInstaller( ) { Stage progressStage = new Stage(); progressStage.initOwner(owner); - progressStage.setTitle(text("下载更新", "Downloading Update")); - Label detail = new Label(text("正在下载安装包…", "Downloading installer…")); + progressStage.setTitle(text(localize("下载更新"), "Downloading Update")); + Label detail = new Label(text(localize("正在下载安装包…"), "Downloading installer…")); ProgressBar progress = new ProgressBar(-1); progress.setPrefWidth(380); VBox root = new VBox(12, detail, progress); @@ -138,7 +140,7 @@ private static void downloadInstaller( asset, destination, (done, total) -> Platform.runLater(() -> { progress.setProgress(total > 0 ? (double) done / total : -1); - detail.setText(text("已下载 ", "Downloaded ") + done + detail.setText(text(localize("已下载 "), "Downloaded ") + done + (total > 0 ? " / " + total : "") + " bytes"); }) ); @@ -146,12 +148,12 @@ private static void downloadInstaller( Platform.runLater(() -> { progressStage.close(); Alert confirm = alert(owner, Alert.AlertType.CONFIRMATION, - text("准备安装", "Ready to Install"), + text(localize("准备安装"), "Ready to Install"), text( - "安装包已下载并通过 MZ 与 Authenticode 发布者校验。\n", + localize("安装包已下载并通过 MZ 与 Authenticode 发布者校验。\n"), "The installer passed MZ and Authenticode publisher validation.\n" ) + text( - "确认后将退出 AhaKeyStudio 并启动安装程序。", + localize("确认后将退出 AhaKeyStudio 并启动安装程序。"), "AhaKeyStudio will quit and start the installer." )); if (confirm.showAndWait().filter( @@ -162,7 +164,7 @@ private static void downloadInstaller( com.example.ahakey.app.ApplicationLifecycle.requestExit(); } catch (Exception exception) { alert(owner, Alert.AlertType.ERROR, - text("无法启动安装程序", "Cannot Start Installer"), + text(localize("无法启动安装程序"), "Cannot Start Installer"), exception.getMessage()).showAndWait(); } } @@ -171,7 +173,7 @@ private static void downloadInstaller( Platform.runLater(() -> { progressStage.close(); alert(owner, Alert.AlertType.ERROR, - text("更新下载失败", "Update Download Failed"), + text(localize("更新下载失败"), "Update Download Failed"), exception.getMessage()).showAndWait(); }); } @@ -209,6 +211,7 @@ private static void daemon(String name, Runnable task) { } private static String text(String zh, String en) { - return Locale.getDefault().getLanguage().equalsIgnoreCase("zh") ? zh : en; + return com.example.ahakey.util.LanguageManager.getInstance().isChinese() + || com.example.ahakey.util.LanguageManager.getInstance().isRussian() ? zh : en; } } diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/update/FirmwareUpdateNotifier.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/update/FirmwareUpdateNotifier.java index 0f695d74..bc5efec6 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/update/FirmwareUpdateNotifier.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/update/FirmwareUpdateNotifier.java @@ -1,5 +1,7 @@ package com.example.ahakey.update; +import static com.example.ahakey.util.LanguageManager.localize; + import com.example.ahakey.model.DeviceStatus; import com.example.ahakey.service.BleManager; import javafx.application.Platform; @@ -67,11 +69,11 @@ private static void check(Stage owner, BleManager manager) { Platform.runLater(() -> { Alert alert = new Alert(Alert.AlertType.INFORMATION); if (owner != null) alert.initOwner(owner); - alert.setTitle(text("发现新固件", "New Firmware Available")); + alert.setTitle(text(localize("发现新固件"), "New Firmware Available")); alert.setHeaderText(null); alert.setContentText(text( - "当前固件 " + current + ",可更新到 " + firmware.version() - + "。请在“设备信息 → 固件管理”中手动开始;不会自动烧录。", + localize("当前固件 ") + current + localize(",可更新到 ") + firmware.version() + + localize("。请在“设备信息 → 固件管理”中手动开始;不会自动烧录。"), "Firmware " + firmware.version() + " is available (current " + current + "). Open Device Info → Firmware Management. Flashing never starts automatically." )); @@ -86,6 +88,7 @@ private static void check(Stage owner, BleManager manager) { } private static String text(String zh, String en) { - return Locale.getDefault().getLanguage().equalsIgnoreCase("zh") ? zh : en; + return com.example.ahakey.util.LanguageManager.getInstance().isChinese() + || com.example.ahakey.util.LanguageManager.getInstance().isRussian() ? zh : en; } } diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/util/LanguageManager.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/util/LanguageManager.java index e562a407..796dcb8b 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/util/LanguageManager.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/util/LanguageManager.java @@ -13,12 +13,23 @@ public class LanguageManager { private Properties currentProperties; private String currentLanguage; + private final Properties legacyRussian = readProperties("/legacy_ru.properties"); private static final String PREFERENCE_KEY = "AhaKeySelectedLanguage"; private LanguageManager() { - currentLanguage = loadUserPreference(); + currentLanguage = normalizeLanguage(loadUserPreference()); loadResources(currentLanguage); } + + LanguageManager(String language) { + currentLanguage = normalizeLanguage(language); + loadResources(currentLanguage); + } + + static String normalizeLanguage(String language) { + String code = language == null ? "en" : language.toLowerCase(Locale.ROOT).split("[-_]", 2)[0]; + return switch (code) { case "zh", "ru" -> code; default -> "en"; }; + } public static synchronized LanguageManager getInstance() { if (instance == null) { @@ -56,30 +67,40 @@ private String loadUserPreference() { // ignore } - return detectSystemLanguage(); + return System.getProperty("ahakey.defaultLanguage", detectSystemLanguage()); } private String detectSystemLanguage() { - Locale locale = Locale.getDefault(); - String language = locale.getLanguage(); - if (language.equalsIgnoreCase("zh") || language.equalsIgnoreCase("zh_CN")) { - return "zh"; - } - return "en"; + return normalizeLanguage(Locale.getDefault().getLanguage()); } private void loadResources(String language) { - currentProperties = new Properties(); - String resourceName = "/messages_" + language + ".properties"; - try (InputStream is = getClass().getResourceAsStream(resourceName)) { - if (is != null) { - currentProperties.load(new InputStreamReader(is, StandardCharsets.UTF_8)); - } else { - loadResources("en"); - } - } catch (Exception e) { - loadResources("en"); + currentProperties = readProperties("/messages_en.properties"); + currentProperties.putAll(readProperties("/messages_" + language + ".properties")); + } + + private static Properties readProperties(String resourceName) { + Properties properties = new Properties(); + try (InputStream is = LanguageManager.class.getResourceAsStream(resourceName)) { + if (is != null) properties.load(new InputStreamReader(is, StandardCharsets.UTF_8)); + } catch (java.io.IOException e) { + throw new IllegalStateException("Cannot read language resource: " + resourceName, e); } + return properties; + } + + /** Localizes legacy display literals without changing protocol or stored identifiers. */ + public static String localize(String source) { + return getInstance().localizeText(source); + } + + /** Stable resource keys for new UI text, including background-service status. */ + public static String text(String key, Object... arguments) { + return getInstance().getString(key, arguments); + } + + String localizeText(String source) { + return isRussian() ? legacyRussian.getProperty(source, source) : source; } public String getString(String key) { @@ -103,6 +124,7 @@ public String getString(String key, Object... args) { } public void switchLanguage(String language) { + language = normalizeLanguage(language); if (!language.equalsIgnoreCase(currentLanguage)) { currentLanguage = language; saveUserPreference(language); @@ -147,6 +169,10 @@ public String getCurrentLanguage() { public boolean isChinese() { return "zh".equalsIgnoreCase(currentLanguage); } + + public boolean isRussian() { + return "ru".equals(currentLanguage); + } public String getLanguageToggleText() { return getString("menu.switch-language"); @@ -174,4 +200,4 @@ public static void notifyListeners() { } } } -} \ No newline at end of file +} diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/BluetoothPairingGuide.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/BluetoothPairingGuide.java index 7afd2f56..16aeea43 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/BluetoothPairingGuide.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/BluetoothPairingGuide.java @@ -1,5 +1,7 @@ package com.example.ahakey.view; +import static com.example.ahakey.util.LanguageManager.localize; + import javafx.geometry.Insets; import javafx.scene.control.Label; import javafx.scene.layout.VBox; @@ -7,10 +9,10 @@ /** Shared Windows re-pairing instructions. */ final class BluetoothPairingGuide { static final String WINDOWS_STEPS = - "如果设备反复连接/断开、无法搜索,或刚恢复初始化/重置蓝牙:\n" - + "1. 打开 Windows 设置 → 蓝牙和设备,找到名称含 AhaKey 的设备并选择“删除设备”;\n" - + "2. 关闭再开启电脑蓝牙,然后重新搜索并完成配对;\n" - + "3. 在两台电脑间切换时,先断开当前电脑的蓝牙,再在另一台电脑连接。"; + localize("如果设备反复连接/断开、无法搜索,或刚恢复初始化/重置蓝牙:\n") + + localize("1. 打开 Windows 设置 → 蓝牙和设备,找到名称含 AhaKey 的设备并选择“删除设备”;\n") + + localize("2. 关闭再开启电脑蓝牙,然后重新搜索并完成配对;\n") + + localize("3. 在两台电脑间切换时,先断开当前电脑的蓝牙,再在另一台电脑连接。"); private BluetoothPairingGuide() { } @@ -19,7 +21,7 @@ static VBox createCard() { VBox card = new VBox(8); card.getStyleClass().add("dialog-card"); card.setPadding(new Insets(12)); - Label title = new Label("蓝牙重新配对指南"); + Label title = new Label(localize("蓝牙重新配对指南")); title.getStyleClass().add("dialog-card-title"); Label body = new Label(WINDOWS_STEPS); body.getStyleClass().add("dialog-text"); diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/CanvasController.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/CanvasController.java index 18c75dcf..b51ed581 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/CanvasController.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/CanvasController.java @@ -1,5 +1,7 @@ package com.example.ahakey.view; +import static com.example.ahakey.util.LanguageManager.localize; + import com.example.ahakey.model.DeviceStatus; import com.example.ahakey.model.IDEState; import com.example.ahakey.model.LightEffectStyle; @@ -143,7 +145,7 @@ private void refreshTaskLights() { var task = taskActivityService.getVisibleTasks().stream().filter(t -> t.slot() == slot).findFirst(); if (task.isEmpty()) { setLightSegment(seg, COLOR_DIM, 0.35); - Tooltip.install(seg, new Tooltip("任务槽 " + (i + 1) + ":空闲")); + Tooltip.install(seg, new Tooltip(localize("任务槽 ") + (i + 1) + localize(":空闲"))); } else { var t = task.get(); String color = t.state() == 2 || t.state() == 4 ? COLOR_RED : COLOR_GREEN; @@ -154,7 +156,7 @@ private void refreshTaskLights() { } private String taskStateText(int state) { - return switch (state) { case 1 -> "运行中"; case 2 -> "等待审批"; case 3 -> "已完成"; case 4 -> "错误"; default -> "空闲"; }; + return switch (state) { case 1 -> localize("运行中"); case 2 -> localize("等待审批"); case 3 -> localize("已完成"); case 4 -> localize("错误"); default -> localize("空闲"); }; } private void bindDeviceStatus() { diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/DeviceMaintenancePane.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/DeviceMaintenancePane.java index 7a399a4a..84869218 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/DeviceMaintenancePane.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/DeviceMaintenancePane.java @@ -1,5 +1,7 @@ package com.example.ahakey.view; +import static com.example.ahakey.util.LanguageManager.localize; + import com.example.ahakey.app.StudioController; import com.example.ahakey.firmware.FirmwareCapabilities; import com.example.ahakey.firmware.FirmwareOperationHandle; @@ -53,8 +55,6 @@ public final class DeviceMaintenancePane { private final BleManager bleManager; private final DeviceStatus deviceStatus; private final FirmwareUpdateService firmwareUpdateService; - private final boolean chinese = - Locale.getDefault().getLanguage().equalsIgnoreCase("zh"); public DeviceMaintenancePane(StudioController controller) { this.controller = controller; @@ -69,14 +69,14 @@ public VBox create(Stage owner) { private VBox firmwareCard(Stage owner) { VBox card = card(); - Label title = title(text("固件管理(CH582)", "Firmware Management (CH582)")); + Label title = title(text(localize("固件管理(CH582)"), "Firmware Management (CH582)")); Label description = body(text( - "支持安装包内置固件、ahakey.com 最新固件和本地 .hex。普通更新保留设备数据。", + localize("支持安装包内置固件、ahakey.com 最新固件和本地 .hex。普通更新保留设备数据。"), "Use bundled, latest ahakey.com, or local .hex firmware. Normal updates preserve device data." )); - Label selected = body(text("尚未选择固件", "No firmware selected")); + Label selected = body(text(localize("尚未选择固件"), "No firmware selected")); Label status = body(text( - "烧录前:断开设备,按住最左侧“语音输入键”,再插入 USB。", + localize("烧录前:断开设备,按住最左侧“语音输入键”,再插入 USB。"), "Before flashing: unplug, hold the leftmost Voice Input key, then connect USB." )); ProgressBar progress = new ProgressBar(0); @@ -85,11 +85,11 @@ private VBox firmwareCard(Stage owner) { progress.setManaged(false); CheckBox allowDowngrade = new CheckBox(text( - "高级选项:我了解风险,允许降级", + localize("高级选项:我了解风险,允许降级"), "Advanced: I understand the risk and allow downgrade" )); CheckBox allowUnknown = new CheckBox(text( - "我了解本地固件版本未知的风险", + localize("我了解本地固件版本未知的风险"), "I understand the risk of unknown local firmware" )); allowUnknown.setVisible(false); @@ -115,23 +115,23 @@ private VBox firmwareCard(Stage owner) { final FirmwareOperationHandle[] activeOperation = {null}; Button bundled = new Button(text( - "选择内置 " + BUNDLED_FIRMWARE_VERSION, + localize("选择内置 ") + BUNDLED_FIRMWARE_VERSION, "Bundled " + BUNDLED_FIRMWARE_VERSION )); - Button latest = new Button(text("下载最新固件", "Download Latest")); - Button local = new Button(text("选择本地 .hex", "Choose Local .hex")); - Button flash = new Button(text("开始烧录", "Flash Firmware")); - Button cancelFlash = new Button(text("取消烧录", "Cancel Flash")); + Button latest = new Button(text(localize("下载最新固件"), "Download Latest")); + Button local = new Button(text(localize("选择本地 .hex"), "Choose Local .hex")); + Button flash = new Button(text(localize("开始烧录"), "Flash Firmware")); + Button cancelFlash = new Button(text(localize("取消烧录"), "Cancel Flash")); cancelFlash.setDisable(true); - Button readDeviceVersion = new Button(text("读取设备版本", "Read Device Version")); + Button readDeviceVersion = new Button(text(localize("读取设备版本"), "Read Device Version")); Label detectedVersion = body(text( - "当前设备固件:尚未读取", + localize("当前设备固件:尚未读取"), "Current device firmware: not read" )); detectedVersion.setWrapText(true); if (currentVersion[0] != null) { detectedVersion.setText(text( - "当前设备固件(已缓存):", + localize("当前设备固件(已缓存):"), "Current device firmware (cached): " ) + currentVersion[0]); } @@ -159,16 +159,16 @@ private VBox firmwareCard(Stage owner) { flash.setDisable(!canStartFlash(block, preparedArmed)); flashRequirement.setText(switch (block) { case "FIRMWARE_REQUIRED" -> text( - "请先在第 2 步选择固件。", + localize("请先在第 2 步选择固件。"), "Choose firmware in step 2 first."); case "UNKNOWN_CONFIRMATION_REQUIRED" -> text( - "本地固件版本未知,需要勾选风险确认。", + localize("本地固件版本未知,需要勾选风险确认。"), "Confirm the risk for unknown local firmware."); case "CURRENT_VERSION_REQUIRED" -> text( - "请先在普通连接模式完成第 1 步版本读取。", + localize("请先在普通连接模式完成第 1 步版本读取。"), "Read the device version in normal mode in step 1 first."); case "DOWNGRADE_CONFIRMATION_REQUIRED" -> text( - "检测到固件降级,默认禁止。", + localize("检测到固件降级,默认禁止。"), "Firmware downgrade detected and blocked by default."); default -> preparedArmed ? text( "条件已满足,可以开始烧录。", @@ -257,14 +257,14 @@ private VBox firmwareCard(Stage owner) { readDeviceVersion.setOnAction(event -> { if (!deviceStatus.isConnected()) { detectedVersion.setText(text( - "当前设备固件:请先正常连接设备", + localize("当前设备固件:请先正常连接设备"), "Current device firmware: connect the device normally first" )); return; } readDeviceVersion.setDisable(true); detectedVersion.setText(text( - "当前设备固件:正在读取…", + localize("当前设备固件:正在读取…"), "Current device firmware: reading…" )); daemon("firmware-read-version", () -> { @@ -272,7 +272,7 @@ private VBox firmwareCard(Stage owner) { var caps = bleManager.queryDeviceCapabilities(); if (caps == null) { throw new IllegalStateException(text( - "设备未返回有效的 0x9F 版本信息", + localize("设备未返回有效的 0x9F 版本信息"), "The device did not return valid 0x9F version information" )); } @@ -283,11 +283,11 @@ private VBox firmwareCard(Stage owner) { ); currentVersion[0] = version; controller.setLastKnownFirmwareVersion(version); - String value = text("当前设备固件:", "Current device firmware: ") + String value = text(localize("当前设备固件:"), "Current device firmware: ") + version - + text(";协议 ", "; protocol ") + + text(localize(";协议 "), "; protocol ") + caps.protocolMajor() + "." + caps.protocolMinor() - + text(";能力位 0x", "; capabilities 0x") + + text(localize(";能力位 0x"), "; capabilities 0x") + String.format("%08X", caps.capabilityBits()); Platform.runLater(() -> { detectedVersion.setText(value); @@ -304,33 +304,33 @@ private VBox firmwareCard(Stage owner) { controller.setPendingFirmwareVersion(null); if (expected.equals(version)) { status.setText(text( - "固件升级成功,设备当前版本为 ", + localize("固件升级成功,设备当前版本为 "), "Firmware update succeeded; device version is " ) + version); show(owner, Alert.AlertType.INFORMATION, - text("固件升级成功", "Firmware Update Succeeded"), - text("已从设备读取到目标版本 ", "The device reported target version ") + text(localize("固件升级成功"), "Firmware Update Succeeded"), + text(localize("已从设备读取到目标版本 "), "The device reported target version ") + expected + "。"); } else { status.setText(text( - "烧录未生效:目标版本 ", + localize("烧录未生效:目标版本 "), "Flash did not take effect: target " - ) + expected + text(",设备仍为 ", ", device still reports ") + ) + expected + text(localize(",设备仍为 "), ", device still reports ") + version); show(owner, Alert.AlertType.ERROR, - text("固件升级未生效", "Firmware Update Did Not Take Effect"), - text("目标版本为 ", "Target version is ") + expected - + text(",但设备返回 ", ", but the device reported ") + text(localize("固件升级未生效"), "Firmware Update Did Not Take Effect"), + text(localize("目标版本为 "), "Target version is ") + expected + + text(localize(",但设备返回 "), ", but the device reported ") + version + "。\n\n" + text( - "请重新进入 ISP 模式后点击“重新烧录”。", + localize("请重新进入 ISP 模式后点击“重新烧录”。"), "Re-enter ISP mode and click Flash Firmware again." )); } }); } catch (Exception exception) { Platform.runLater(() -> detectedVersion.setText( - text("版本读取失败:", "Version read failed: ") + text(localize("版本读取失败:"), "Version read failed: ") + exception.getMessage() )); } finally { @@ -343,8 +343,8 @@ private VBox firmwareCard(Stage owner) { Path path = bundledFirmwarePath(); if (!Files.isRegularFile(path)) { show(owner, Alert.AlertType.WARNING, - text("内置固件尚未生成", "Bundled Firmware Missing"), - text("请先执行安全发布构建,将 " + BUNDLED_FIRMWARE_VERSION + " 固件放入安装包。", + text(localize("内置固件尚未生成"), "Bundled Firmware Missing"), + text(localize("请先执行安全发布构建,将 ") + BUNDLED_FIRMWARE_VERSION + localize(" 固件放入安装包。"), "Run the safe release build and include firmware " + BUNDLED_FIRMWARE_VERSION + " first.")); return; @@ -363,7 +363,7 @@ private VBox firmwareCard(Stage owner) { local.setOnAction(event -> { FileChooser chooser = new FileChooser(); - chooser.setTitle(text("选择 CH582 固件", "Choose CH582 Firmware")); + chooser.setTitle(text(localize("选择 CH582 固件"), "Choose CH582 Firmware")); chooser.getExtensionFilters().add( new FileChooser.ExtensionFilter("Intel HEX (*.hex)", "*.hex") ); @@ -387,14 +387,14 @@ private VBox firmwareCard(Stage owner) { latest.setOnAction(event -> { setBusy(true, progress, bundled, latest, local, flash); status.setText(text( - "正在读取 ahakey.com 稳定版本…", + localize("正在读取 ahakey.com 稳定版本…"), "Checking the ahakey.com stable release…")); daemon("firmware-release", () -> { try { var release = new StableReleaseClient().fetchLatest() - .orElseThrow(() -> new IllegalStateException("尚未发布稳定版本")); + .orElseThrow(() -> new IllegalStateException(localize("尚未发布稳定版本"))); var asset = release.ch582Firmware() - .orElseThrow(() -> new IllegalStateException("稳定版本中没有 CH582 固件")); + .orElseThrow(() -> new IllegalStateException(localize("稳定版本中没有 CH582 固件"))); Path destination = Path.of( System.getProperty("user.home"), ".ahakey", "downloads", asset.asset().name() @@ -416,14 +416,14 @@ private VBox firmwareCard(Stage owner) { selected.setText(asset.asset().name()); expandStep(steps, 2); status.setText(text( - "下载完成并通过固件格式检查。", + localize("下载完成并通过固件格式检查。"), "Downloaded and firmware format validated.")); setBusy(false, progress, bundled, latest, local, flash); beginPreparation[0].run(); }); } catch (Exception exception) { Platform.runLater(() -> { - status.setText(text("下载失败:", "Download failed: ") + exception.getMessage()); + status.setText(text(localize("下载失败:"), "Download failed: ") + exception.getMessage()); setBusy(false, progress, bundled, latest, local, flash); updateFlashState.run(); }); @@ -461,7 +461,7 @@ private VBox firmwareCard(Stage owner) { setBusy(false, progress, bundled, latest, local, flash); status.setText(admission.rejection().detail()); show(owner, Alert.AlertType.WARNING, - text("烧录正在进行", "Firmware operation is busy"), admission.rejection().detail()); + text(localize("烧录正在进行"), "Firmware operation is busy"), admission.rejection().detail()); return; } FirmwareOperationHandle operation = admission.handle(); @@ -480,7 +480,7 @@ private VBox firmwareCard(Stage owner) { if (failure != null) { expandStep(steps, 2); show(owner, Alert.AlertType.ERROR, - text("固件更新失败", "Firmware Update Failed"), failure.getMessage()); + text(localize("固件更新失败"), "Firmware Update Failed"), failure.getMessage()); } else if (result.success()) { pendingFlashVersion[0] = targetVersion[0]; controller.setPendingFirmwareVersion(targetVersion[0]); @@ -491,7 +491,7 @@ private VBox firmwareCard(Stage owner) { } else { expandStep(steps, 2); show(owner, Alert.AlertType.ERROR, - text("固件更新失败", "Firmware Update Failed"), result.detail()); + text(localize("固件更新失败"), "Firmware Update Failed"), result.detail()); } })); }); @@ -499,7 +499,7 @@ private VBox firmwareCard(Stage owner) { FirmwareOperationHandle operation = activeOperation[0]; if (operation != null && operation.cancel()) { cancelFlash.setDisable(true); - status.setText(text("正在取消烧录…", "Cancelling firmware operation…")); + status.setText(text(localize("正在取消烧录…"), "Cancelling firmware operation…")); } }); @@ -520,7 +520,7 @@ private VBox firmwareCard(Stage owner) { } updateFlashState.run(); diagnose.setDisable(true); - ispStatus.setText(text("正在检查 WCHISP 工具、配置和 CH582 ISP 设备…", + ispStatus.setText(text(localize("正在检查 WCHISP 工具、配置和 CH582 ISP 设备…"), "Checking WCHISP tools, configuration, and the CH582 ISP device…")); daemon("wchisp-diagnostics", () -> { StringBuilder report = new StringBuilder("AhaKey WCHISP diagnostics\n"); @@ -549,7 +549,7 @@ private VBox firmwareCard(Stage owner) { }); exportDiagnostic.setOnAction(event -> { FileChooser chooser = new FileChooser(); - chooser.setTitle(text("导出烧录诊断报告", "Export Flash Diagnostics")); + chooser.setTitle(text(localize("导出烧录诊断报告"), "Export Flash Diagnostics")); chooser.setInitialFileName("ahakey-wchisp-diagnostics.txt"); var file = chooser.showSaveDialog(owner); if (file == null) return; @@ -558,22 +558,22 @@ private VBox firmwareCard(Stage owner) { java.nio.charset.StandardCharsets.UTF_8); } catch (Exception exception) { show(owner, Alert.AlertType.ERROR, - text("导出失败", "Export Failed"), exception.getMessage()); + text(localize("导出失败"), "Export Failed"), exception.getMessage()); } }); HBox sources = new HBox(8, bundled, latest, local); HBox version = new HBox(8, readDeviceVersion, detectedVersion); HBox.setHgrow(detectedVersion, Priority.ALWAYS); - steps[0] = step(text("1. 读取设备版本", "1. Read Device Version"), version, true); - steps[1] = step(text("2. 选择固件", "2. Choose Firmware"), + steps[0] = step(text(localize("1. 读取设备版本"), "1. Read Device Version"), version, true); + steps[1] = step(text(localize("2. 选择固件"), "2. Choose Firmware"), new VBox(8, sources, selected, allowUnknown, allowDowngrade), false); - steps[2] = step(text("3. 进入 ISP 并检测", "3. Enter and Detect ISP"), + steps[2] = step(text(localize("3. 进入 ISP 并检测"), "3. Enter and Detect ISP"), new VBox(8, body(text( - "断开 USB,将键盘关机,按住最左侧“语音输入键”,再插入 USB;随后点击检测。", + localize("断开 USB,将键盘关机,按住最左侧“语音输入键”,再插入 USB;随后点击检测。"), "Disconnect USB, hold the leftmost Voice Input key, reconnect USB, then run detection." )), new HBox(8, diagnose, exportDiagnostic), ispStatus), false); - steps[3] = step(text("4. 烧录、校验并确认版本", "4. Flash, Verify, and Confirm"), + steps[3] = step(text(localize("4. 烧录、校验并确认版本"), "4. Flash, Verify, and Confirm"), new VBox(8, new HBox(8, flash, cancelFlash), flashRequirement, progress, status), false); card.getChildren().addAll( title, description, steps[0], steps[1], steps[2], steps[3] @@ -584,28 +584,28 @@ private VBox firmwareCard(Stage owner) { private VBox resetCard(Stage owner) { VBox card = card(); - Label title = title(text("危险操作:恢复初始化", "Danger: Factory Reset")); + Label title = title(text(localize("危险操作:恢复初始化"), "Danger: Factory Reset")); title.setStyle("-fx-text-fill: #d73a49;"); Label description = body(text( - "仅限 USB。将清除 GIF、用户配置、按键配置、待机时间、其他用户数据和蓝牙配对;保留固件版本、设备标识和 MAC。数据不可恢复。", + localize("仅限 USB。将清除 GIF、用户配置、按键配置、待机时间、其他用户数据和蓝牙配对;保留固件版本、设备标识和 MAC。数据不可恢复。"), "USB only. Erases GIFs, user/key settings, standby, other user data and Bluetooth bonds; preserves firmware, device identity and MAC. This cannot be undone." )); CheckBox understood = new CheckBox(text( - "我已了解数据不可恢复", + localize("我已了解数据不可恢复"), "I understand the data cannot be recovered" )); understood.getStyleClass().add("dialog-dark-check-box"); - Button reset = new Button(text("恢复初始化", "Factory Reset")); + Button reset = new Button(text(localize("恢复初始化"), "Factory Reset")); reset.setStyle("-fx-background-color: #d73a49; -fx-text-fill: white;"); reset.disableProperty().bind(understood.selectedProperty().not()); - Button reconnect = new Button(text("重新检测 USB", "Detect USB Again")); + Button reconnect = new Button(text(localize("重新检测 USB"), "Detect USB Again")); reconnect.setVisible(false); reconnect.setManaged(false); Label status = body(""); reconnect.setOnAction(event -> { reconnect.setDisable(true); - status.setText(text("正在重新检测 USB…", "Detecting USB again…")); + status.setText(text(localize("正在重新检测 USB…"), "Detecting USB again…")); controller.userConnect(); javafx.animation.PauseTransition delay = new javafx.animation.PauseTransition(javafx.util.Duration.seconds(2)); @@ -614,10 +614,10 @@ private VBox resetCard(Stage owner) { if (bleManager.isUsbConnected()) { reconnect.setVisible(false); reconnect.setManaged(false); - status.setText(text("USB 已重新连接,可读取设备设置。", + status.setText(text(localize("USB 已重新连接,可读取设备设置。"), "USB reconnected; device settings are available.")); } else { - status.setText(text("仍未检测到 USB,请重新拔插后再试。", + status.setText(text(localize("仍未检测到 USB,请重新拔插后再试。"), "USB is still unavailable; reconnect the cable and try again.")); } }); @@ -627,17 +627,17 @@ private VBox resetCard(Stage owner) { reset.setOnAction(event -> { if (!bleManager.isUsbConnected()) { show(owner, Alert.AlertType.WARNING, - text("需要 USB 连接", "USB Required"), - text("恢复初始化不能通过 BLE 执行,请连接 USB 数据线。", + text(localize("需要 USB 连接"), "USB Required"), + text(localize("恢复初始化不能通过 BLE 执行,请连接 USB 数据线。"), "Factory reset cannot run over BLE. Connect the USB cable.")); return; } Alert confirmation = new Alert(Alert.AlertType.CONFIRMATION); confirmation.initOwner(owner); - confirmation.setTitle(text("确认恢复初始化", "Confirm Factory Reset")); - confirmation.setHeaderText(text("所有设备用户数据将被永久删除", + confirmation.setTitle(text(localize("确认恢复初始化"), "Confirm Factory Reset")); + confirmation.setHeaderText(text(localize("所有设备用户数据将被永久删除"), "All device user data will be permanently erased")); - confirmation.setContentText(text("确认后设备会重启,请保持 USB 连接。", + confirmation.setContentText(text(localize("确认后设备会重启,请保持 USB 连接。"), "The device will reboot. Keep USB connected.")); if (confirmation.showAndWait().filter( button -> button == javafx.scene.control.ButtonType.OK).isEmpty()) { @@ -646,7 +646,7 @@ private VBox resetCard(Stage owner) { understood.setSelected(false); reconnect.setVisible(false); reconnect.setManaged(false); - status.setText(text("正在发送恢复命令…", "Sending factory-reset command…")); + status.setText(text(localize("正在发送恢复命令…"), "Sending factory-reset command…")); daemon("factory-reset", () -> performFactoryReset( owner, understood, reconnect, status)); }); @@ -664,7 +664,7 @@ private void performFactoryReset( bleManager.queryDeviceCapabilities(); if (capabilities == null || !capabilities.supports(AhaKeyProtocol.CAP_FACTORY_RESET_V1)) { - throw new IllegalStateException("当前固件不支持安全恢复初始化,请先更新固件"); + throw new IllegalStateException(localize("当前固件不支持安全恢复初始化,请先更新固件")); } bleManager.factoryReset(); Files.deleteIfExists(Path.of( @@ -678,22 +678,22 @@ private void performFactoryReset( reconnect.setVisible(true); reconnect.setManaged(true); status.setText(text( - "设备已接受恢复命令。请重新拔插 USB 后点击“重新检测 USB”;蓝牙配对已清除,需要在 Windows 中删除旧 AhaKey 配对记录后重新配对。", + localize("设备已接受恢复命令。请重新拔插 USB 后点击“重新检测 USB”;蓝牙配对已清除,需要在 Windows 中删除旧 AhaKey 配对记录后重新配对。"), "The reset was accepted. Reconnect USB and click Detect USB Again. Bluetooth bonding was cleared; pair the keyboard again in Windows." )); show(owner, Alert.AlertType.INFORMATION, - text("恢复初始化命令已执行", "Factory Reset Accepted"), + text(localize("恢复初始化命令已执行"), "Factory Reset Accepted"), text( - "客户端不会等待蓝牙自动重连。请重新拔插 USB。\n\n" + localize("客户端不会等待蓝牙自动重连。请重新拔插 USB。\n\n") + BluetoothPairingGuide.WINDOWS_STEPS, "The app will not wait for automatic Bluetooth reconnection. Reconnect USB and pair Bluetooth again." )); }); } catch (Exception exception) { Platform.runLater(() -> { - status.setText(text("恢复失败:", "Reset failed: ") + exception.getMessage()); + status.setText(text(localize("恢复失败:"), "Reset failed: ") + exception.getMessage()); show(owner, Alert.AlertType.ERROR, - text("恢复初始化失败", "Factory Reset Failed"), + text(localize("恢复初始化失败"), "Factory Reset Failed"), exception.getMessage()); }); } @@ -711,19 +711,19 @@ private boolean isVersionAllowed( SemanticVersion current = cachedCurrent; if (current == null && !allowDowngrade) { show(owner, Alert.AlertType.WARNING, - text("无法验证固件版本", "Cannot Verify Firmware Version"), + text(localize("无法验证固件版本"), "Cannot Verify Firmware Version"), text( - "请先在正常模式连接设备后选择固件;或在高级选项中确认风险后继续。", + localize("请先在正常模式连接设备后选择固件;或在高级选项中确认风险后继续。"), "Connect the device in normal mode before choosing firmware, or explicitly accept the advanced risk." )); return false; } if (current != null && target.compareTo(current) < 0 && !allowDowngrade) { show(owner, Alert.AlertType.WARNING, - text("已阻止固件降级", "Firmware Downgrade Blocked"), - text("当前版本 ", "Current version ") + current - + text(",目标版本 ", ", target version ") + target - + text("。如确需降级,请勾选高级风险选项。", + text(localize("已阻止固件降级"), "Firmware Downgrade Blocked"), + text(localize("当前版本 "), "Current version ") + current + + text(localize(",目标版本 "), ", target version ") + target + + text(localize("。如确需降级,请勾选高级风险选项。"), ". Enable the advanced risk option to continue.")); return false; } @@ -885,6 +885,7 @@ private void daemon(String name, Runnable task) { } private String text(String zh, String en) { - return chinese ? zh : en; + return com.example.ahakey.util.LanguageManager.getInstance().isChinese() + || com.example.ahakey.util.LanguageManager.getInstance().isRussian() ? localize(zh) : en; } } diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/FloatingVoiceNotification.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/FloatingVoiceNotification.java index a9511809..92e67bab 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/FloatingVoiceNotification.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/FloatingVoiceNotification.java @@ -1,5 +1,7 @@ package com.example.ahakey.view; +import static com.example.ahakey.util.LanguageManager.localize; + import javafx.application.Platform; import javafx.scene.Scene; import javafx.scene.canvas.Canvas; @@ -119,12 +121,12 @@ public void updateStatus(String status, String message) { */ private StatusConfig getStatusConfig(String status, String message) { return switch (status) { - case "recording" -> new StatusConfig("语音输入中", Color.rgb(231, 76, 60), true); // 红色 - case "recognizing" -> new StatusConfig("识别中", Color.rgb(245, 166, 35), true); // 橙色 - case "processing" -> new StatusConfig("处理中", Color.rgb(245, 166, 35), true); // 橙色 - case "ready" -> new StatusConfig("语音就绪", Color.rgb(46, 204, 113), false); // 绿色 - case "starting" -> new StatusConfig("启动中", Color.rgb(245, 166, 35), true); // 橙色 - default -> new StatusConfig(message != null ? message : "空闲", Color.rgb(167, 175, 186), false); // 灰色 + case "recording" -> new StatusConfig(localize("语音输入中"), Color.rgb(231, 76, 60), true); // 红色 + case "recognizing" -> new StatusConfig(localize("识别中"), Color.rgb(245, 166, 35), true); // 橙色 + case "processing" -> new StatusConfig(localize("处理中"), Color.rgb(245, 166, 35), true); // 橙色 + case "ready" -> new StatusConfig(localize("语音就绪"), Color.rgb(46, 204, 113), false); // 绿色 + case "starting" -> new StatusConfig(localize("启动中"), Color.rgb(245, 166, 35), true); // 橙色 + default -> new StatusConfig(message != null ? message : localize("空闲"), Color.rgb(167, 175, 186), false); // 灰色 }; } diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/InspectorPane.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/InspectorPane.java index 6798df5a..ed4c9670 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/InspectorPane.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/InspectorPane.java @@ -1,5 +1,9 @@ package com.example.ahakey.view; +import static com.example.ahakey.util.LanguageManager.text; + +import static com.example.ahakey.util.LanguageManager.localize; + import com.example.ahakey.app.StudioController; import com.example.ahakey.model.DeviceStatus; import com.example.ahakey.model.KeyConfig; @@ -133,11 +137,11 @@ private void rebuild() { } private VBox createDualVoiceKeyGroup() { - return createGroupBox("语音键:短按 / 长按", () -> { + return createGroupBox(localize("语音键:短按 / 长按"), () -> { VBox box = new VBox(16); Label hint = new Label( - "物理语音键由 Desktop 自动区分短按与长按;" - + "动作不会写入固件,也不会模拟 Typeless/微信 Fn。" + localize("物理语音键由 Desktop 自动区分短按与长按;") + + localize("动作不会写入固件,也不会模拟 Typeless/微信 Fn。") ); hint.setWrapText(true); hint.getStyleClass().add("warning-note"); @@ -149,7 +153,7 @@ private VBox createDualVoiceKeyGroup() { VBox shortShortcutBox = createVoiceShortcutEditor(true); shortShortcutBox.setManaged(studioState.getVoiceShortAction() == VoiceAction.CUSTOM_SHORTCUT); shortShortcutBox.setVisible(studioState.getVoiceShortAction() == VoiceAction.CUSTOM_SHORTCUT); - VBox shortEditorBlock = new VBox(6, new Label("短按快捷键"), shortShortcutBox); + VBox shortEditorBlock = new VBox(6, new Label(localize("短按快捷键")), shortShortcutBox); shortEditorBlock.setManaged(studioState.getVoiceShortAction() == VoiceAction.CUSTOM_SHORTCUT); shortEditorBlock.setVisible(studioState.getVoiceShortAction() == VoiceAction.CUSTOM_SHORTCUT); shortAction.valueProperty().addListener((obs, oldValue, value) -> { @@ -169,7 +173,7 @@ private VBox createDualVoiceKeyGroup() { VBox longShortcutBox = createVoiceShortcutEditor(false); longShortcutBox.setManaged(studioState.getVoiceLongAction() == VoiceAction.CUSTOM_SHORTCUT); longShortcutBox.setVisible(studioState.getVoiceLongAction() == VoiceAction.CUSTOM_SHORTCUT); - VBox longEditorBlock = new VBox(6, new Label("长按快捷键"), longShortcutBox); + VBox longEditorBlock = new VBox(6, new Label(localize("长按快捷键")), longShortcutBox); longEditorBlock.setManaged(studioState.getVoiceLongAction() == VoiceAction.CUSTOM_SHORTCUT); longEditorBlock.setVisible(studioState.getVoiceLongAction() == VoiceAction.CUSTOM_SHORTCUT); longAction.valueProperty().addListener((obs, oldValue, value) -> { @@ -180,13 +184,13 @@ private VBox createDualVoiceKeyGroup() { }); VBox longActionBox = new VBox(8, longAction, longEditorBlock); if (!localModelConfigured) { - Label unavailable = new Label("AhaKey 本地语音(当前不可用)"); + Label unavailable = new Label(localize("AhaKey 本地语音(当前不可用)")); unavailable.getStyleClass().add("warning-note"); longActionBox.getChildren().add(0, unavailable); } box.getChildren().addAll(hint, - new Label("短按动作(一次触发)"), shortAction, shortEditorBlock, - new Label("长按动作(按住说话)"), longActionBox); + new Label(localize("短按动作(一次触发)")), shortAction, shortEditorBlock, + new Label(localize("长按动作(按住说话)")), longActionBox); return box; }); } @@ -234,10 +238,10 @@ private ComboBox voiceActionCombo( private String voiceActionLabel(VoiceAction action) { return switch (action) { - case SYSTEM_VOICE -> "系统语音(Win+H)"; - case AHAKEY_VOICE -> "AhaKey 本地语音(按住说话)"; - case NONE -> "禁用"; - case CUSTOM_SHORTCUT -> "自定义快捷键"; + case SYSTEM_VOICE -> localize("系统语音(Win+H)"); + case AHAKEY_VOICE -> localize("AhaKey 本地语音(按住说话)"); + case NONE -> localize("禁用"); + case CUSTOM_SHORTCUT -> localize("自定义快捷键"); }; } @@ -247,7 +251,7 @@ private VBox createSimulateKeyGroup(StudioPart part) { KeyConfig key = studioState.getKeyConfig(part); var voice = controller.getVoiceRelay(); - Button simulate = new Button("模拟按键"); + Button simulate = new Button(localize("模拟按键")); simulate.getStyleClass().add("button-prominent"); simulate.setOnAction(e -> { if (!key.usesMacro()) voice.simulateKeyByHid(key.getHidCode()); @@ -257,7 +261,7 @@ private VBox createSimulateKeyGroup(StudioPart part) { Label hint = new Label(); hint.getStyleClass().add("warning-note"); if (key.usesMacro()) { - hint.setText("复杂宏序列暂不执行模拟;切换为单个快捷键后可在此测试。"); + hint.setText(localize("复杂宏序列暂不执行模拟;切换为单个快捷键后可在此测试。")); } else { hint.textProperty().bind(voice.lastSimulateHintProperty()); } @@ -339,14 +343,14 @@ private VBox createShortcutEditor( if (hidCode != 0) { // 修饰键:区分 Left/Right(新编码 0xNN00 + 旧编码 0x0N00 兼容) - if (((hidCode & 0x100) != 0) && ((hidCode & 0x1000) == 0)) keyCodes.add("Left Shift (0xE1)"); - else if ((hidCode & 0x1000) != 0) keyCodes.add("Right Shift (0xE5)"); - if (((hidCode & 0x200) != 0) && ((hidCode & 0x2000) == 0)) keyCodes.add("Left Ctrl (0xE0)"); - else if ((hidCode & 0x2000) != 0) keyCodes.add("Right Ctrl (0xE4)"); - if (((hidCode & 0x400) != 0) && ((hidCode & 0x4000) == 0)) keyCodes.add("Left Alt (0xE2)"); - else if ((hidCode & 0x4000) != 0) keyCodes.add("Right Alt (0xE6)"); - if (((hidCode & 0x800) != 0) && ((hidCode & 0x8000) == 0)) keyCodes.add("Left Win (0xE3)"); - else if ((hidCode & 0x8000) != 0) keyCodes.add("Right Win (0xE7)"); + if ((hidCode & 0x100) != 0) keyCodes.add("Left Shift (0xE1)"); + if ((hidCode & 0x1000) != 0) keyCodes.add("Right Shift (0xE5)"); + if ((hidCode & 0x200) != 0) keyCodes.add("Left Ctrl (0xE0)"); + if ((hidCode & 0x2000) != 0) keyCodes.add("Right Ctrl (0xE4)"); + if ((hidCode & 0x400) != 0) keyCodes.add("Left Alt (0xE2)"); + if ((hidCode & 0x4000) != 0) keyCodes.add("Right Alt (0xE6)"); + if ((hidCode & 0x800) != 0) keyCodes.add("Left Win (0xE3)"); + if ((hidCode & 0x8000) != 0) keyCodes.add("Right Win (0xE7)"); int baseCode = hidCode & 0xFF; if (baseCode != 0) { @@ -357,13 +361,13 @@ private VBox createShortcutEditor( keyListView.getItems().addAll(keyCodes); - HBox buttonRow = new HBox(8); + FlowPane buttonRow = new FlowPane(8, 8); ComboBox keySelector = new ComboBox<>(); java.util.List keyItems = new java.util.ArrayList<>(); // 修饰键 (modifier) - keyItems.add("--- 修饰键 ---"); + keyItems.add(localize("--- 修饰键 ---")); keyItems.add("Left Ctrl (0xE0)"); keyItems.add("Left Shift (0xE1)"); keyItems.add("Left Alt (0xE2)"); @@ -374,7 +378,7 @@ private VBox createShortcutEditor( keyItems.add("Right Win (0xE7)"); // 字母键 (alpha) - keyItems.add("--- 字母 ---"); + keyItems.add(localize("--- 字母 ---")); String[] letters = {"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"}; int[] letterCodes = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, @@ -385,7 +389,7 @@ private VBox createShortcutEditor( } // 数字键 (number) - keyItems.add("--- 数字 ---"); + keyItems.add(localize("--- 数字 ---")); keyItems.add("1 (0x1E)"); keyItems.add("2 (0x1F)"); keyItems.add("3 (0x20)"); @@ -398,7 +402,7 @@ private VBox createShortcutEditor( keyItems.add("0 (0x27)"); // 基础键 (basic) - keyItems.add("--- 基础键 ---"); + keyItems.add(localize("--- 基础键 ---")); keyItems.add("Enter (0x28)"); keyItems.add("Escape (0x29)"); keyItems.add("Backspace (0x2A)"); @@ -418,7 +422,7 @@ private VBox createShortcutEditor( keyItems.add("Caps Lock (0x39)"); // 功能键 (function) - keyItems.add("--- 功能键 ---"); + keyItems.add(localize("--- 功能键 ---")); keyItems.add("F1 (0x3A)"); keyItems.add("F2 (0x3B)"); keyItems.add("F3 (0x3C)"); @@ -445,7 +449,7 @@ private VBox createShortcutEditor( keyItems.add("F24 (0x73)"); // 控制键 (control) - keyItems.add("--- 控制键 ---"); + keyItems.add(localize("--- 控制键 ---")); keyItems.add("Print Screen (0x46)"); keyItems.add("Scroll Lock (0x47)"); keyItems.add("Pause (0x48)"); @@ -457,14 +461,14 @@ private VBox createShortcutEditor( keyItems.add("Page Down (0x4E)"); // 方向键 (arrow) - keyItems.add("--- 方向键 ---"); + keyItems.add(localize("--- 方向键 ---")); keyItems.add("Right (0x4F)"); keyItems.add("Left (0x50)"); keyItems.add("Down (0x51)"); keyItems.add("Up (0x52)"); // 小键盘 (numpad) - keyItems.add("--- 小键盘 ---"); + keyItems.add(localize("--- 小键盘 ---")); keyItems.add("Num Lock (0x53)"); keyItems.add("KP / (0x54)"); keyItems.add("KP * (0x55)"); @@ -485,7 +489,7 @@ private VBox createShortcutEditor( keySelector.getItems().addAll(keyItems); keySelector.getStyleClass().addAll("combo-box", "combo-box-small"); - keySelector.setValue("--- 修饰键 ---"); + keySelector.setValue(localize("--- 修饰键 ---")); Label validation = new Label(); validation.getStyleClass().add("warning-note"); @@ -526,7 +530,7 @@ private VBox createShortcutEditor( } if (reservePhysicalF18 && (codeToAdd & 0xFF) == com.example.ahakey.model.HIDUsage.F18) { - validation.setText("F18 为 AhaKey 语音键保留,请选择其他快捷键。"); + validation.setText(localize("F18 为 AhaKey 语音键保留,请选择其他快捷键。")); return; } @@ -623,9 +627,21 @@ private VBox createShortcutEditor( deleteBtn.setDisable(newVal.intValue() < 0); }); - buttonRow.getChildren().addAll(keySelector, addBtn, deleteBtn); + Button clearBtn = new Button(languageManager.getString("inspector.clear")); + clearBtn.getStyleClass().add("btn-secondary"); + clearBtn.setDisable(key.getHidCode() == 0); + clearBtn.setOnAction(event -> { + key.setHidCode(0); + if (dirtyPart != null) studioState.markDirty(dirtyPart); + onChanged.run(); + rebuild(); + }); + Label hint = new Label(text("inspector.shortcut-hint")); + hint.setWrapText(true); + hint.getStyleClass().add("field-label"); + buttonRow.getChildren().addAll(keySelector, addBtn, deleteBtn, clearBtn); - box.getChildren().addAll(listLabel, keyListView, buttonRow, validation); + box.getChildren().addAll(listLabel, keyListView, hint, buttonRow, validation); return box; } @@ -742,18 +758,18 @@ private HBox createMacroStepRow(StudioPart part, int index) { key.updateMacroStep(index, "DELAY", newValue); studioState.markDirty(part); }); - Label msLabel = new Label("ms"); + Label msLabel = new Label(text("unit.milliseconds")); row.getChildren().addAll(indexLabel, actionCombo, delaySpinner, msLabel); } else { ComboBox keyCombo = new ComboBox<>(); java.util.List keyItems = new java.util.ArrayList<>(); - keyItems.add("--- 修饰键 ---"); + keyItems.add(localize("--- 修饰键 ---")); keyItems.add("Shift"); keyItems.add("Ctrl"); keyItems.add("Alt"); keyItems.add("Win"); - keyItems.add("--- 功能键 ---"); + keyItems.add(localize("--- 功能键 ---")); keyItems.add("F1"); keyItems.add("F2"); keyItems.add("F3"); @@ -772,18 +788,18 @@ private HBox createMacroStepRow(StudioPart part, int index) { keyItems.add("F16"); keyItems.add("F17"); keyItems.add("F18"); - keyItems.add("--- 字母键 ---"); + keyItems.add(localize("--- 字母键 ---")); String[] letters = {"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"}; for (String letter : letters) { keyItems.add(letter); } - keyItems.add("--- 数字键 ---"); + keyItems.add(localize("--- 数字键 ---")); for (int i = 1; i <= 9; i++) { keyItems.add(String.valueOf(i)); } keyItems.add("0"); - keyItems.add("--- 其他键 ---"); + keyItems.add(localize("--- 其他键 ---")); keyItems.add("Enter"); keyItems.add("Escape"); keyItems.add("Backspace"); @@ -871,11 +887,11 @@ private VBox createLightBarGroup() { VBox root = new VBox(16); ModeSlot mode = studioState.getSelectedMode(); - VBox taskModeBox = createGroupBox("任务灯效模式", () -> { + VBox taskModeBox = createGroupBox(localize("任务灯效模式"), () -> { VBox box = new VBox(8); ToggleGroup group = new ToggleGroup(); - ToggleButton single = new ToggleButton("单任务"); - ToggleButton multi = new ToggleButton("多任务"); + ToggleButton single = new ToggleButton(localize("单任务")); + ToggleButton multi = new ToggleButton(localize("多任务")); single.setToggleGroup(group); multi.setToggleGroup(group); single.getStyleClass().add("mode-toggle"); @@ -889,7 +905,7 @@ private VBox createLightBarGroup() { multi.setOnAction(event -> controller.setMultiTaskDisplay(true)); taskModeRefresh = refresh; refresh.run(); - Label note = new Label("先发送并获得设备确认,再提交 Preferences;失败会回滚。"); + Label note = new Label(localize("先发送并获得设备确认,再提交 Preferences;失败会回滚。")); note.getStyleClass().add("group-note"); note.setWrapText(true); HBox controls = new HBox(8, single, multi); @@ -972,7 +988,7 @@ private VBox createLightBarGroup() { sync.getStyleClass().add("button-prominent"); sync.setDisable(!deviceStatus.isConnected()); sync.setOnAction(e -> controller.syncCurrentModeLightConfig()); - Button animations = new Button("屏幕动画设置"); + Button animations = new Button(localize("屏幕动画设置")); animations.setOnAction(e -> ScreenAnimationDialog.show( getScene() == null ? null : getScene().getWindow(), controller, mode)); actions.getChildren().addAll(sync, animations); @@ -1028,7 +1044,7 @@ private VBox createOledGroup() { asset.setWrapText(true); HBox actions = new HBox(8); - Button configure = new Button("配置屏幕动画"); + Button configure = new Button(localize("配置屏幕动画")); configure.getStyleClass().add("button-prominent"); configure.setOnAction(e -> { Window w = getScene() != null ? getScene().getWindow() : null; diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/ScreenAnimationDialog.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/ScreenAnimationDialog.java index 5c9c7b7c..21450eb6 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/ScreenAnimationDialog.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/ScreenAnimationDialog.java @@ -1,5 +1,7 @@ package com.example.ahakey.view; +import static com.example.ahakey.util.LanguageManager.localize; + import com.example.ahakey.app.StudioController; import com.example.ahakey.firmware.FirmwareCapabilities; import com.example.ahakey.model.ModeSlot; @@ -35,7 +37,7 @@ /** USB-only editor for the firmware fixed 4x4 GIF layout. */ public final class ScreenAnimationDialog { - private static final String[] ASSETS = {"默认", "运行中", "等待/错误", "已完成"}; + private static final String[] ASSETS = {localize("默认"), localize("运行中"), localize("等待/错误"), localize("已完成")}; private static final long JOB_TIMEOUT_MINUTES = 5; private static final ScreenAnimationAssetStore ASSET_STORE = new ScreenAnimationAssetStore(); @@ -75,10 +77,10 @@ public static void show(Window owner, StudioController controller, ModeSlot init Stage stage = new Stage(); if (owner != null) stage.initOwner(owner); stage.initModality(Modality.NONE); - stage.setTitle("屏幕动画"); + stage.setTitle(localize("屏幕动画")); DialogState dialogState = new DialogState(); - Label flashInfo = new Label("正在读取设备 Flash 与动画配置…"); + Label flashInfo = new Label(localize("正在读取设备 Flash 与动画配置…")); flashInfo.setWrapText(true); TabPane profiles = new TabPane(); for (ModeSlot mode : ModeSlot.values()) @@ -87,16 +89,16 @@ public static void show(Window owner, StudioController controller, ModeSlot init profiles.getSelectionModel().select(initialMode.ordinal()); } - Button restoreAll = new Button("恢复全部内置动画"); + Button restoreAll = new Button(localize("恢复全部内置动画")); dialogState.register(restoreAll); restoreAll.setOnAction(event -> { - if (!confirm("将依次覆盖四个模式的全部 16 个动画分区,是否继续?")) return; - runJobs(controller, dialogState, new ArrayList<>(dialogState.allJobs), "全部内置动画恢复完成。"); + if (!confirm(localize("将依次覆盖四个模式的全部 16 个动画分区,是否继续?"))) return; + runJobs(controller, dialogState, new ArrayList<>(dialogState.allJobs), localize("全部内置动画恢复完成。")); }); Label guidance = new Label( - "每个模式包含 4 个固定分区;仅支持 USB 写入。软件会先读取真实 Flash 容量," - + "所有写入严格串行执行。GIF 建议 160×80;默认动画最多 8 帧,其他状态最多 12 帧。" + localize("每个模式包含 4 个固定分区;仅支持 USB 写入。软件会先读取真实 Flash 容量,") + + localize("所有写入严格串行执行。GIF 建议 160×80;默认动画最多 8 帧,其他状态最多 12 帧。") ); guidance.setWrapText(true); VBox root = new VBox(10, guidance, flashInfo, restoreAll, profiles); @@ -120,8 +122,8 @@ private static Tab profileTab(Stage owner, StudioController controller, ModeSlot cards.getChildren().add(card); } - Button uploadAllSelected = new Button("写入本模式全部已修改动画"); - Button restoreMode = new Button("恢复本模式内置动画"); + Button uploadAllSelected = new Button(localize("写入本模式全部已修改动画")); + Button restoreMode = new Button(localize("恢复本模式内置动画")); dialogState.register(uploadAllSelected); dialogState.register(restoreMode); uploadAllSelected.setOnAction(event -> { @@ -134,14 +136,14 @@ private static Tab profileTab(Stage owner, StudioController controller, ModeSlot } } if (jobs.isEmpty()) { - new Alert(Alert.AlertType.WARNING, "本模式没有可写入的动画。").showAndWait(); + new Alert(Alert.AlertType.WARNING, localize("本模式没有可写入的动画。")).showAndWait(); return; } - runJobs(controller, dialogState, jobs, "本模式全部已修改动画写入完成。"); + runJobs(controller, dialogState, jobs, localize("本模式全部已修改动画写入完成。")); }); restoreMode.setOnAction(event -> { - if (confirm("将覆盖 " + mode.getShortName() + " 的四个动画分区,是否继续?")) - runJobs(controller, dialogState, new ArrayList<>(modeDefaults), "本模式内置动画恢复完成。"); + if (confirm(localize("将覆盖 ") + mode.getShortName() + localize(" 的四个动画分区,是否继续?"))) + runJobs(controller, dialogState, new ArrayList<>(modeDefaults), localize("本模式内置动画恢复完成。")); }); ScrollPane scroll = new ScrollPane(cards); @@ -162,9 +164,9 @@ private static VBox assetCard(Stage owner, StudioController controller, ModeSlot preview.setFitWidth(160); preview.setFitHeight(80); preview.setPreserveRatio(true); - Label fileName = new Label("内置动画"); + Label fileName = new Label(localize("内置动画")); fileName.setMaxWidth(190); - Label status = new Label("正在读取设备配置…"); + Label status = new Label(localize("正在读取设备配置…")); status.setWrapText(true); statuses[asset] = status; @@ -175,7 +177,7 @@ private static VBox assetCard(Stage owner, StudioController controller, ModeSlot preview.setImage(new Image(BundledGifLibrary.resource(mode, asset).toExternalForm(), 160, 80, true, true)); } catch (Exception e) { - status.setText("内置动画不可用:" + e.getMessage()); + status.setText(localize("内置动画不可用:") + e.getMessage()); } StudioState.PersistedDraft.ScreenAssetMetadata current = @@ -185,13 +187,13 @@ private static VBox assetCard(Stage owner, StudioController controller, ModeSlot preview.setImage(new Image(Path.of(current.managedCachePath).toUri().toString(), 160, 80, true, true)); fileName.setText(current.originalFileName == null - ? "当前本地资源" : current.originalFileName); - status.setText("当前本地资源(设备状态仅用于校验,不作为预览来源)"); + ? localize("当前本地资源") : current.originalFileName); + status.setText(localize("当前本地资源(设备状态仅用于校验,不作为预览来源)")); } catch (RuntimeException ignored) { - status.setText("当前本地资源不可读,将显示内置动画"); + status.setText(localize("当前本地资源不可读,将显示内置动画")); } } else { - status.setText("尚未记录当前本地资源,显示内置动画"); + status.setText(localize("尚未记录当前本地资源,显示内置动画")); } Job defaultJob = bundled == null ? null : new Job(mode, asset, bundled, status, () -> {}); @@ -200,10 +202,10 @@ private static VBox assetCard(Stage owner, StudioController controller, ModeSlot dialogState.allJobs.add(defaultJob); } - Button choose = new Button("选择 GIF"); - Button upload = new Button("写入当前动画"); - Button restore = new Button("写入内置动画"); - Button clear = new Button("清空状态"); + Button choose = new Button(localize("选择 GIF")); + Button upload = new Button(localize("写入当前动画")); + Button restore = new Button(localize("写入内置动画")); + Button clear = new Button(localize("清空状态")); dialogState.register(choose); dialogState.register(upload); dialogState.register(restore); @@ -213,8 +215,8 @@ private static VBox assetCard(Stage owner, StudioController controller, ModeSlot choose.setOnAction(event -> { FileChooser picker = new FileChooser(); - picker.setTitle("选择 160×80 GIF"); - picker.getExtensionFilters().add(new FileChooser.ExtensionFilter("GIF 动画", "*.gif")); + picker.setTitle(localize("选择 160×80 GIF")); + picker.getExtensionFilters().add(new FileChooser.ExtensionFilter(localize("GIF 动画"), "*.gif")); File last = lastSelectedGif(); if (last != null) { File directory = last.isDirectory() ? last : last.getParentFile(); @@ -228,9 +230,9 @@ private static VBox assetCard(Stage owner, StudioController controller, ModeSlot GifUploadRules.Preflight preflight = OLEDFrameEncoder.preflight(file.toPath(), asset); if (preflight.needsOptimization() && !confirm(String.format( - "所选 GIF 不完全符合设备限制:%n文件 %.1f MB(建议不超过 2 MB)%n" - + "尺寸 %d×%d(设备 %d×%d)%n帧数 %d(该状态上限 %d)%n%n" - + "是否自动缩放、按时间轴抽帧并尽量保持原始总时长?", + localize("所选 GIF 不完全符合设备限制:%n文件 %.1f MB(建议不超过 2 MB)%n") + + localize("尺寸 %d×%d(设备 %d×%d)%n帧数 %d(该状态上限 %d)%n%n") + + localize("是否自动缩放、按时间轴抽帧并尽量保持原始总时长?"), preflight.fileBytes() / 1048576.0, preflight.width(), preflight.height(), GifUploadRules.WIDTH, GifUploadRules.HEIGHT, preflight.sourceFrames(), preflight.targetFrameLimit()))) { @@ -238,7 +240,7 @@ private static VBox assetCard(Stage owner, StudioController controller, ModeSlot } } catch (Exception failure) { new Alert(Alert.AlertType.ERROR, - "GIF 预检失败:" + errorMessage(failure)).showAndWait(); + localize("GIF 预检失败:") + errorMessage(failure)).showAndWait(); return; } selected[asset] = file; @@ -246,34 +248,34 @@ private static VBox assetCard(Stage owner, StudioController controller, ModeSlot GifSelectionHistory.remember(file.toPath()); fileName.setText(file.getName()); preview.setImage(new Image(file.toURI().toString(), 160, 80, true, true)); - status.setText("已选择本地 GIF,等待写入;设备原配置尚未改变。"); + status.setText(localize("已选择本地 GIF,等待写入;设备原配置尚未改变。")); upload.setDisable(false); }); upload.setOnAction(event -> { if (selected[asset] == null || !dirty[asset]) { - status.setText("请先选择需要写入的自定义 GIF。"); + status.setText(localize("请先选择需要写入的自定义 GIF。")); return; } runJobs(controller, dialogState, List.of(new Job(mode, asset, selected[asset].toPath(), status, - () -> dirty[asset] = false)), "动画写入完成。"); + () -> dirty[asset] = false)), localize("动画写入完成。")); }); restore.setOnAction(event -> { if (defaultJob != null) - runJobs(controller, dialogState, List.of(defaultJob), "内置动画恢复完成。"); + runJobs(controller, dialogState, List.of(defaultJob), localize("内置动画恢复完成。")); }); clear.setOnAction(event -> { if (!dialogState.begin()) { - status.setText("已有动画操作正在进行,请等待完成。"); + status.setText(localize("已有动画操作正在进行,请等待完成。")); return; } OledUploadService.clearAsset(controller.getBleManager(), mode, asset, result -> Platform.runLater(() -> { - status.setText(result + ";设备当前 0 帧"); + status.setText(result + localize(";设备当前 0 帧")); dialogState.finish(); }), error -> Platform.runLater(() -> { - status.setText("清空失败:" + error); + status.setText(localize("清空失败:") + error); dialogState.finish(); })); }); @@ -291,7 +293,7 @@ private static VBox assetCard(Stage owner, StudioController controller, ModeSlot private static void runJobs(StudioController controller, DialogState dialogState, List jobs, String successMessage) { if (!dialogState.begin()) { - new Alert(Alert.AlertType.WARNING, "已有动画操作正在进行,请等待完成。").showAndWait(); + new Alert(Alert.AlertType.WARNING, localize("已有动画操作正在进行,请等待完成。")).showAndWait(); return; } Thread worker = new Thread(() -> { @@ -309,20 +311,20 @@ private static void runJobs(StudioController controller, DialogState dialogState }, message -> { error.set(message); - Platform.runLater(() -> job.status().setText("写入失败:" + message)); + Platform.runLater(() -> job.status().setText(localize("写入失败:") + message)); done.countDown(); }); try { if (!done.await(JOB_TIMEOUT_MINUTES, TimeUnit.MINUTES)) { handle.cancel(); - failure = "写入超过 " + JOB_TIMEOUT_MINUTES - + " 分钟,已请求取消;设备事务锁会保持到后台操作实际退出。"; + failure = localize("写入超过 ") + JOB_TIMEOUT_MINUTES + + localize(" 分钟,已请求取消;设备事务锁会保持到后台操作实际退出。"); break; } } catch (InterruptedException e) { handle.cancel(); Thread.currentThread().interrupt(); - failure = "操作已中断"; + failure = localize("操作已中断"); break; } if (error.get() != null) { @@ -332,7 +334,7 @@ private static void runJobs(StudioController controller, DialogState dialogState try { persistSuccessfulAsset(controller, job); } catch (Exception persistFailure) { - failure = "设备已确认写入,但本地资源缓存保存失败:" + failure = localize("设备已确认写入,但本地资源缓存保存失败:") + errorMessage(persistFailure); break; } @@ -344,7 +346,7 @@ private static void runJobs(StudioController controller, DialogState dialogState if (finalFailure == null) new Alert(Alert.AlertType.INFORMATION, successMessage).showAndWait(); else - new Alert(Alert.AlertType.ERROR, "写入未完成:" + finalFailure).showAndWait(); + new Alert(Alert.AlertType.ERROR, localize("写入未完成:") + finalFailure).showAndWait(); }); }, "oled-dialog-sequence"); worker.setDaemon(true); @@ -356,16 +358,16 @@ private static void refreshDeviceSummary(StudioController controller, Label labe String text; try { var layout = controller.getBleManager().queryGifLayout(); - if (layout == null) text = "设备未返回 Flash 布局。"; + if (layout == null) text = localize("设备未返回 Flash 布局。"); else if (!layout.hasPhysicalFlashDiagnostics()) - text = "固件仅返回旧版布局,写入前请升级到固件 " + text = localize("固件仅返回旧版布局,写入前请升级到固件 ") + FirmwareCapabilities.MINIMUM_GIF_VERSION + "。"; else text = String.format( - "Flash ID 0x%04X;实际容量 %.1f MiB;可用帧槽 %d;规划分区共 %d 个帧槽。", + localize("Flash ID 0x%04X;实际容量 %.1f MiB;可用帧槽 %d;规划分区共 %d 个帧槽。"), layout.flashId(), layout.flashBytes() / 1048576.0, layout.frameSlots(), AhaKeyProtocol.GIF_TOTAL_PLANNED_FRAMES); } catch (Exception e) { - text = "读取 Flash 布局失败:" + errorMessage(e); + text = localize("读取 Flash 布局失败:") + errorMessage(e); } String result = text; Platform.runLater(() -> label.setText(result)); @@ -380,19 +382,19 @@ private static void refreshAssetState(StudioController controller, ModeSlot mode String text; try { var state = OledUploadService.readAssetState(controller.getBleManager(), mode, asset); - text = "设备当前参数:" + state.frameCount() + " 帧,起始帧 " + state.startIndex() - + (state.frameCount() > 0 ? ",帧间隔 " + state.frameInterval() + " ms" : ""); + text = localize("设备当前参数:") + state.frameCount() + localize(" 帧,起始帧 ") + state.startIndex() + + (state.frameCount() > 0 ? localize(",帧间隔 ") + state.frameInterval() + " ms" : ""); StudioState.PersistedDraft.ScreenAssetMetadata metadata = controller.getStudioState().getScreenAssetMetadata(mode, asset); if (ScreenAnimationAssetStore.isUsable(metadata)) { - text = "当前本地资源:" - + (metadata.originalFileName == null ? "已管理文件" : metadata.originalFileName) + text = localize("当前本地资源:") + + (metadata.originalFileName == null ? localize("已管理文件") : metadata.originalFileName) + ";" + text; } else { - text = "尚未记录当前本地资源;" + text; + text = localize("尚未记录当前本地资源;") + text; } } catch (Exception e) { - text = "设备配置读取失败:" + errorMessage(e); + text = localize("设备配置读取失败:") + errorMessage(e); } String result = text; Platform.runLater(() -> status.setText(result)); @@ -415,7 +417,7 @@ private static void persistSuccessfulAsset(StudioController controller, Job job) controller.getStudioState().setScreenAssetMetadata( job.mode(), job.asset(), stored.metadata()); if (!StudioStore.save(controller.getStudioState().toPersisted())) { - throw new java.io.IOException("本地配置保存失败"); + throw new java.io.IOException(localize("本地配置保存失败")); } } diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/StandbySettingsPane.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/StandbySettingsPane.java index d76d9b85..c597dbc6 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/StandbySettingsPane.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/StandbySettingsPane.java @@ -1,5 +1,7 @@ package com.example.ahakey.view; +import static com.example.ahakey.util.LanguageManager.localize; + import com.example.ahakey.model.DeviceStatus; import com.example.ahakey.protocol.AhaKeyProtocol; import com.example.ahakey.service.BleManager; @@ -29,8 +31,6 @@ public final class StandbySettingsPane { private static final List OPTIONS = List.of(0, 5, 10, 15, 30); private final BleManager bleManager; private final DeviceStatus deviceStatus; - private final boolean chinese = - Locale.getDefault().getLanguage().equalsIgnoreCase("zh"); public StandbySettingsPane(BleManager bleManager, DeviceStatus deviceStatus) { this.bleManager = bleManager; @@ -41,20 +41,20 @@ public VBox create(Stage owner) { VBox card = new VBox(10); card.getStyleClass().add("dialog-card"); card.setPadding(new Insets(12)); - Label title = label(text("自动待机时间", "Automatic Standby"), true); + Label title = label(text(localize("自动待机时间"), "Automatic Standby"), true); Label description = label(text( - "设备无操作达到指定时间后自动待机。支持 USB 或 BLE,保存后会回读校验。", + localize("设备无操作达到指定时间后自动待机。支持 USB 或 BLE,保存后会回读校验。"), "The device enters standby after the selected period. USB and BLE are supported; saves are read back and verified." ), false); Label current = label(currentText(null), false); Label status = label(text( - "请先通过 USB 或 BLE 连接设备。", + localize("请先通过 USB 或 BLE 连接设备。"), "Connect the device by USB or BLE first." ), false); ComboBox options = new ComboBox<>(); options.getItems().setAll(OPTIONS); - options.setPromptText(text("选择时间", "Select timeout")); + options.setPromptText(text(localize("选择时间"), "Select timeout")); options.setConverter(new StringConverter<>() { @Override public String toString(Integer value) { return value == null ? "" : minutes(value); @@ -63,7 +63,7 @@ public VBox create(Stage owner) { return null; } }); - Button save = new Button(text("保存", "Save")); + Button save = new Button(text(localize("保存"), "Save")); save.getStyleClass().add("button-prominent"); ProgressIndicator progress = new ProgressIndicator(); progress.setPrefSize(22, 22); @@ -101,7 +101,7 @@ public VBox create(Stage owner) { busy.set(false); options.getSelectionModel().clearSelection(); current.setText(currentText(null)); - status.setText(text("请先通过 USB 或 BLE 连接设备。", + status.setText(text(localize("请先通过 USB 或 BLE 连接设备。"), "Connect the device by USB or BLE first.")); } } @@ -126,19 +126,19 @@ private void refresh( long request = generation.incrementAndGet(); if (!deviceStatus.isConnected()) { supported.set(false); - status.setText(text("请先连接设备。", "Connect the device first.")); + status.setText(text(localize("请先连接设备。"), "Connect the device first.")); return; } supported.set(false); busy.set(true); - status.setText(text("正在读取设备设置…", "Reading device setting…")); + status.setText(text(localize("正在读取设备设置…"), "Reading device setting…")); daemon("standby-read", () -> { try { var capabilities = bleManager.queryDeviceCapabilities(); if (capabilities == null || capabilities.protocolMajor() < 2 || !capabilities.supports(AhaKeyProtocol.CAP_STANDBY_TIMEOUT_V2)) { throw new IllegalStateException(text( - "当前固件不支持安全待机协议,请先更新固件。", + localize("当前固件不支持安全待机协议,请先更新固件。"), "Firmware update required for safe standby settings." )); } @@ -149,16 +149,16 @@ private void refresh( current.setText(currentText(value)); supported.set(true); busy.set(false); - status.setText(text("已通过 ", "Connected over ") + status.setText(text(localize("已通过 "), "Connected over ") + (bleManager.isUsbConnected() ? "USB" : "BLE") - + text(" 读取成功。", ".")); + + text(localize(" 读取成功。"), ".")); }); } catch (Exception exception) { Platform.runLater(() -> { if (!owner.isShowing() || generation.get() != request) return; busy.set(false); supported.set(false); - status.setText(text("读取失败:", "Read failed: ") + status.setText(text(localize("读取失败:"), "Read failed: ") + exception.getMessage()); }); } @@ -174,7 +174,7 @@ private void save( long request = generation.get(); busy.set(true); status.setText(text( - "正在设置、保存并回读校验,请勿断开设备…", + localize("正在设置、保存并回读校验,请勿断开设备…"), "Setting, saving, and verifying. Do not disconnect…" )); daemon("standby-save", () -> { @@ -185,19 +185,19 @@ private void save( options.setValue(verified); current.setText(currentText(verified)); busy.set(false); - status.setText(text("已保存并验证:", "Saved and verified: ") + status.setText(text(localize("已保存并验证:"), "Saved and verified: ") + minutes(verified)); }); } catch (Exception exception) { Platform.runLater(() -> { if (!owner.isShowing() || generation.get() != request) return; busy.set(false); - String message = text("保存失败:", "Save failed: ") + String message = text(localize("保存失败:"), "Save failed: ") + exception.getMessage(); status.setText(message); Alert alert = new Alert(Alert.AlertType.WARNING); alert.initOwner(owner); - alert.setTitle(text("待机时间设置失败", "Standby Setting Failed")); + alert.setTitle(text(localize("待机时间设置失败"), "Standby Setting Failed")); alert.setHeaderText(null); alert.setContentText(message); alert.showAndWait(); @@ -215,17 +215,18 @@ private Label label(String value, boolean title) { private String currentText(Integer value) { return value == null - ? text("当前设置:—", "Current setting: —") - : text("当前设置:", "Current setting: ") + minutes(value); + ? text(localize("当前设置:—"), "Current setting: —") + : text(localize("当前设置:"), "Current setting: ") + minutes(value); } private String minutes(int value) { - if (value == 0) return text("永不关机", "Never power off"); - return chinese ? value + " 分钟" : value + " minutes"; + if (value == 0) return text(localize("永不关机"), "Never power off"); + return text(value + localize(" 分钟"), value + " minutes"); } private String text(String zh, String en) { - return chinese ? zh : en; + return com.example.ahakey.util.LanguageManager.getInstance().isChinese() + || com.example.ahakey.util.LanguageManager.getInstance().isRussian() ? zh : en; } private void daemon(String name, Runnable task) { diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/SupportPane.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/SupportPane.java index d5d6466e..fa91e776 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/SupportPane.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/SupportPane.java @@ -1,5 +1,7 @@ package com.example.ahakey.view; +import static com.example.ahakey.util.LanguageManager.localize; + import javafx.geometry.Insets; import javafx.scene.control.Label; import javafx.scene.image.Image; @@ -13,19 +15,17 @@ public final class SupportPane { private static final String SUPPORT_QR_RESOURCE = "/images/support-service-qr.png"; - private final boolean chinese = - Locale.getDefault().getLanguage().equalsIgnoreCase("zh"); public VBox create() { VBox card = new VBox(10); card.getStyleClass().add("dialog-card"); card.setPadding(new Insets(12)); - Label title = new Label(text("帮助与客服", "Help & Support")); + Label title = new Label(text(localize("帮助与客服"), "Help & Support")); title.getStyleClass().add("dialog-card-title"); Label instruction = new Label(text( - "遇到问题,请使用手机扫描下方二维码联系客服。", + localize("遇到问题,请使用手机扫描下方二维码联系客服。"), "If you need help, scan the QR code below to contact support." )); instruction.getStyleClass().add("dialog-text"); @@ -45,13 +45,13 @@ public VBox create() { qr.setVisible(false); qr.setManaged(false); status.setText(text( - "客服二维码资源缺失,请重新安装 AhaKeyStudio。", + localize("客服二维码资源缺失,请重新安装 AhaKeyStudio。"), "The support QR code is missing. Please reinstall AhaKeyStudio." )); } else { qr.setImage(new Image(resource.toExternalForm(), true)); status.setText(text( - "扫码后即可与客服沟通。", + localize("扫码后即可与客服沟通。"), "Scan the code to start a support conversation." )); } @@ -61,6 +61,7 @@ public VBox create() { } private String text(String zh, String en) { - return chinese ? zh : en; + return com.example.ahakey.util.LanguageManager.getInstance().isChinese() + || com.example.ahakey.util.LanguageManager.getInstance().isRussian() ? zh : en; } } diff --git a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/TopBar.java b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/TopBar.java index e152c491..3938b2df 100644 --- a/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/TopBar.java +++ b/ahakeyconfig-win-java/src/main/java/com/example/ahakey/view/TopBar.java @@ -1,5 +1,9 @@ package com.example.ahakey.view; +import static com.example.ahakey.util.LanguageManager.text; + +import static com.example.ahakey.util.LanguageManager.localize; + import com.example.ahakey.app.StudioController; import com.example.ahakey.model.DeviceStatus; import com.example.ahakey.model.StudioState; @@ -111,16 +115,16 @@ private void showHookOnboardingIfNeeded(Stage owner) { return; } - ButtonType openManager = new ButtonType("打开 Hook 管理"); - ButtonType later = new ButtonType("稍后设置"); + ButtonType openManager = new ButtonType(localize("打开 Hook 管理")); + ButtonType later = new ButtonType(localize("稍后设置")); Alert alert = new Alert(Alert.AlertType.INFORMATION, - "AhaKey Studio 尚未安装 AI 软件的 Hook 组件。未安装 Hook 时,键盘配置和语音功能仍可使用," - + "但 Claude、Cursor、Codex、Kimi 等 AI 联动状态与审批功能不可用。\n\n" - + "安装 Hook 后,请在对应 AI 软件中同意 Hook/钩子权限,并完全退出后重新启动该 Agent。", + localize("AhaKey Studio 尚未安装 AI 软件的 Hook 组件。未安装 Hook 时,键盘配置和语音功能仍可使用,") + + localize("但 Claude、Cursor、Codex、Kimi 等 AI 联动状态与审批功能不可用。\n\n") + + localize("安装 Hook 后,请在对应 AI 软件中同意 Hook/钩子权限,并完全退出后重新启动该 Agent。"), openManager, later); if (owner != null) alert.initOwner(owner); - alert.setTitle("首次使用:安装 AI Hook 组件"); - alert.setHeaderText("需要 AI 联动功能时,请先安装对应 Hook"); + alert.setTitle(localize("首次使用:安装 AI Hook 组件")); + alert.setHeaderText(localize("需要 AI 联动功能时,请先安装对应 Hook")); Optional selected = alert.showAndWait(); FirstRunState.markHookOnboardingShown(); if (selected.filter(openManager::equals).isPresent()) { @@ -312,7 +316,7 @@ private void initContent() { }); MenuItem clearOled = new MenuItem(languageManager.getString("menu.clear-oled")); clearOled.setOnAction(event -> studioState.clearOledPreview()); - MenuItem screenAnimations = new MenuItem("屏幕动画"); + MenuItem screenAnimations = new MenuItem(localize("屏幕动画")); screenAnimations.setOnAction(event -> ScreenAnimationDialog.show(getScene() == null ? null : getScene().getWindow(), controller)); SeparatorMenuItem divider1 = new SeparatorMenuItem(); MenuItem deviceInfo = new MenuItem(languageManager.getString("menu.device-info")); @@ -359,6 +363,8 @@ private void initContent() { } MenuBar menuBar = new MenuBar(moreMenu); + menuBar.setMinWidth(Region.USE_PREF_SIZE); + configModeButton.setMinWidth(Region.USE_PREF_SIZE); menuBar.setUseSystemMenuBar(false); menuBar.getStyleClass().add("toolbar-menu"); @@ -367,44 +373,22 @@ private void initContent() { actionButtons.setAlignment(Pos.CENTER_LEFT); actionButtons.getChildren().addAll(connectButton, bleButton); - // 状态信息与操作按钮之间的固定间距 + // Primary actions stay visible; secondary groups wrap at narrow widths. Region spacer = new Region(); - spacer.setMinWidth(12); - spacer.setPrefWidth(16); - spacer.setMaxWidth(40); - - // 主行 HBox:所有控件在一行,不会换行 - HBox mainRow = new HBox(10); - mainRow.setAlignment(Pos.CENTER_LEFT); - mainRow.setPadding(new Insets(6, 16, 6, 16)); - mainRow.setMinWidth(Region.USE_PREF_SIZE); // 保持首选宽度,不缩小 - mainRow.getChildren().addAll(titleBox, infoPills, spacer, actionButtons); - // Keep the main-branch AhaType and local voice controls visible. The - // button still reports an unavailable service when model assets are - // absent; hiding the controls based solely on a legacy config flag - // made the restored feature impossible to discover or activate. - mainRow.getChildren().addAll(ahaTypeToggle, ahaTypeStatus, voiceControlBox); - mainRow.getChildren().addAll(configStatus, configModeButton, menuBar); - - // 右侧弹性 spacer:把编辑配置/菜单推到最右 - Region rightSpacer = new Region(); - HBox.setHgrow(rightSpacer, Priority.ALWAYS); - mainRow.getChildren().add( - mainRow.getChildren().size() - 3, rightSpacer // 插到 configStatus 前面 - ); - - // 包裹在水平 ScrollPane 中:宽屏时不显示滚动条,分屏窄时可水平滚动 - ScrollPane scrollWrapper = new ScrollPane(mainRow); - scrollWrapper.setFitToWidth(true); - scrollWrapper.setFitToHeight(true); - scrollWrapper.setHbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); - scrollWrapper.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); - scrollWrapper.setPannable(false); - scrollWrapper.setStyle("-fx-background: transparent; -fx-background-color: transparent;"); - // 让 ScrollPane 内容背景透明 - mainRow.setStyle("-fx-background-color: transparent;"); - - getChildren().add(scrollWrapper); + HBox.setHgrow(spacer, Priority.ALWAYS); + HBox toolbar = new HBox(8, titleBox, spacer, actionButtons, configModeButton, menuBar); + toolbar.setAlignment(Pos.CENTER_LEFT); + toolbar.setPadding(new Insets(8, 16, 6, 16)); + titleBox.setMinWidth(Region.USE_PREF_SIZE); + actionButtons.setMinWidth(Region.USE_PREF_SIZE); + + HBox ahaTypeControls = new HBox(8, ahaTypeToggle, ahaTypeStatus); + ahaTypeControls.setAlignment(Pos.CENTER_LEFT); + FlowPane statusRow = new FlowPane(16, 8, infoPills, ahaTypeControls, voiceControlBox, configStatus); + statusRow.setPadding(new Insets(0, 16, 8, 16)); + statusRow.setMinWidth(0); + voiceResultPreview.setMaxWidth(260); + getChildren().addAll(toolbar, statusRow); updateVoiceButtonState(); } @@ -523,7 +507,8 @@ private void stopManagedBleDriver() { private void launchBleDriver(Path executable) { try { - ProcessBuilder builder = new ProcessBuilder(executable.toString(), "--show"); + ProcessBuilder builder = new ProcessBuilder(executable.toString(), "--show", + "--language=" + languageManager.getCurrentLanguage()); builder.directory(executable.getParent().toFile()); builder.redirectOutput(ProcessBuilder.Redirect.INHERIT); builder.redirectError(ProcessBuilder.Redirect.INHERIT); @@ -538,7 +523,7 @@ private void launchBleDriver(Path executable) { Platform.runLater(() -> showAlert( languageManager.getString("dialog.ble-driver-title"), String.format(languageManager.getString("dialog.ble-start-fail"), - executable + " (TCP 9000 未在 5 秒内就绪)"))); + executable + localize(" (TCP 9000 未在 5 秒内就绪)")))); return; } // Bounded foreground retry; the bridge owns BLE discovery. @@ -641,7 +626,7 @@ private void startVoiceService() { boolean activated = voiceInputManager.isActivated(); controller.getVoiceRelay().setAhaKeyVoiceAvailable(activated); if (!activated) { - setVoiceStatus("error", "本地语音不可用;长按不会执行其他语音动作。"); + setVoiceStatus("error", localize("本地语音不可用;长按不会执行其他语音动作。")); } } @@ -827,12 +812,12 @@ private void showDeviceInfoDialog() { logArea.setEditable(false); logArea.setPrefHeight(150); logArea.setWrapText(true); - logArea.setText("[System] Hook installation tool started\n"); + logArea.setText(text("hooks.log.started")); String homeDir = System.getProperty("user.home"); - addLog("[System] User Directory: " + homeDir); - addLog("[System] OS: " + System.getProperty("os.name")); - addLog("[System] Java Version: " + System.getProperty("java.version")); + addLog(text("hooks.log.home") + homeDir); + addLog(text("hooks.log.os") + System.getProperty("os.name")); + addLog(text("hooks.log.java") + System.getProperty("java.version")); addLog(""); // Hook 安装卡片:配置、分发服务和最近活动是相互独立的状态。 @@ -863,7 +848,7 @@ private void showDeviceInfoDialog() { disconnectBtn.setOnAction(event -> controller.userDisconnect()); Button clearLogBtn = new Button(languageManager.getString("dialog.clear-log")); - clearLogBtn.setOnAction(event -> logArea.setText("[System] Log cleared\n")); + clearLogBtn.setOnAction(event -> logArea.setText(text("hooks.log.cleared"))); Button closeBtn = new Button(languageManager.getString("dialog.close")); closeBtn.setOnAction(event -> dialog.close()); @@ -1091,28 +1076,28 @@ private boolean installHook(String hookName) { if (getScene() != null && getScene().getWindow() != null) { result.initOwner(getScene().getWindow()); } - result.setTitle(installed ? "Hook 安装完成" : "Hook 安装失败"); + result.setTitle(installed ? localize("Hook 安装完成") : localize("Hook 安装失败")); result.setHeaderText(null); result.setContentText(installed - ? hookName + " Hook 已安装。请在 " + hookName - + " 中同意 Hook/钩子权限,然后完全退出并重新启动该 Agent。" - : hookName + " Hook 未能完成安装,请查看本窗口底部日志后重试。"); + ? hookName + localize(" Hook 已安装。请在 ") + hookName + + localize(" 中同意 Hook/钩子权限,然后完全退出并重新启动该 Agent。") + : hookName + localize(" Hook 未能完成安装,请查看本窗口底部日志后重试。")); result.showAndWait(); return installed; } private boolean uninstallHook(String hookName) { - addLog("[卸载] 开始卸载 " + hookName + " Hook..."); + addLog(localize("[卸载] 开始卸载 ") + hookName + " Hook..."); boolean removed = hookInstaller.uninstall(hookName); if (!removed) { Alert result = new Alert(Alert.AlertType.ERROR); if (getScene() != null && getScene().getWindow() != null) { result.initOwner(getScene().getWindow()); } - result.setTitle("Hook 卸载失败"); + result.setTitle(localize("Hook 卸载失败")); result.setHeaderText(null); result.setContentText( - hookName + " Hook 卸载后仍可检测到,请查看本窗口底部日志。"); + hookName + localize(" Hook 卸载后仍可检测到,请查看本窗口底部日志。")); result.showAndWait(); } return removed; @@ -1141,15 +1126,35 @@ private String getVersion() { } private void toggleLanguage() { - String newLang = languageManager.isChinese() ? "en" : "zh"; + var choices = java.util.List.of("Русский", "English", "中文"); + String selected = switch (languageManager.getCurrentLanguage()) { + case "ru" -> "Русский"; + case "zh" -> "中文"; + default -> "English"; + }; + var chooser = new javafx.scene.control.ChoiceDialog(selected, choices); + chooser.initOwner(getScene().getWindow()); + chooser.setTitle(languageManager.getString("language.select")); + chooser.setHeaderText(languageManager.getString("language.restart")); + chooser.setContentText("Language / Язык / 语言:"); + var choice = chooser.showAndWait(); + if (choice.isEmpty()) return; + String newLang = switch (choice.get()) { + case "Русский" -> "ru"; + case "中文" -> "zh"; + default -> "en"; + }; + if (newLang.equals(languageManager.getCurrentLanguage())) return; languageManager.switchLanguage(newLang); Alert alert = new Alert(Alert.AlertType.CONFIRMATION); alert.setTitle(languageManager.getString("language-change-title")); alert.setHeaderText(null); - alert.setContentText(languageManager.isChinese() - ? languageManager.getString("language-change-chinese") - : languageManager.getString("language-change-english")); + alert.setContentText(languageManager.getString(switch (newLang) { + case "ru" -> "language-change-russian"; + case "zh" -> "language-change-chinese"; + default -> "language-change-english"; + })); ButtonType exitBtn = new ButtonType(languageManager.getString("dialog.exit-now")); ButtonType laterBtn = new ButtonType(languageManager.getString("dialog.exit-later")); @@ -1181,10 +1186,10 @@ private static String formatBattery(DeviceStatus status) { return "—"; } if ("USB".equals(status.getTransport())) { - return "USB 供电"; + return localize("USB 供电"); } int level = status.getBatteryLevel(); - return level >= 0 && level <= 100 ? level + "%" : "读取中"; + return level >= 0 && level <= 100 ? level + "%" : localize("读取中"); } /** diff --git a/ahakeyconfig-win-java/src/main/resources/legacy_ru.properties b/ahakeyconfig-win-java/src/main/resources/legacy_ru.properties new file mode 100644 index 00000000..9b51dc3c --- /dev/null +++ b/ahakeyconfig-win-java/src/main/resources/legacy_ru.properties @@ -0,0 +1,383 @@ +# UTF-8 translations for legacy literal UI text. Keys preserve the original source text. +如果设备反复连接/断开、无法搜索,或刚恢复初始化/重置蓝牙:\n=Если устройство постоянно подключается и отключается, не находится или Bluetooth был сброшен:\n +1.\ 打开\ Windows\ 设置\ →\ 蓝牙和设备,找到名称含\ AhaKey\ 的设备并选择“删除设备”;\n=1. Откройте Параметры Windows → Bluetooth и устройства, найдите AhaKey и выберите «Удалить устройство».\n +2.\ 关闭再开启电脑蓝牙,然后重新搜索并完成配对;\n=2. Выключите и включите Bluetooth на компьютере, затем выполните сопряжение заново.\n +3.\ 在两台电脑间切换时,先断开当前电脑的蓝牙,再在另一台电脑连接。=3. При переходе между компьютерами сначала отключите Bluetooth на предыдущем компьютере. +蓝牙重新配对指南=Повторное сопряжение Bluetooth +任务槽\ =Задача +:空闲=: свободно +运行中=В работе +等待审批=Ожидание подтверждения +已完成=Завершено +错误=Ошибка +空闲=Свободно +固件管理(CH582)=Прошивка (CH582) +支持安装包内置固件、ahakey.com\ 最新固件和本地\ .hex。普通更新保留设备数据。=Можно выбрать встроенную прошивку, загрузить её с ahakey.com или открыть файл .hex. Обычное обновление сохраняет данные устройства. +尚未选择固件=Прошивка не выбрана +烧录前:断开设备,按住最左侧“语音输入键”,再插入\ USB。=Перед прошивкой: отключите устройство, зажмите крайнюю левую голосовую клавишу и подключите USB. +高级选项:我了解风险,允许降级=Дополнительно: понимаю риск и разрешаю понижение версии +我了解本地固件版本未知的风险=Понимаю риск установки локальной прошивки неизвестной версии +选择内置\ =Выбрать встроенную +下载最新固件=Загрузить последнюю +选择本地\ .hex=Выбрать файл .hex +开始烧录=Записать прошивку +取消烧录=Отменить запись +读取设备版本=Прочитать версию +当前设备固件:尚未读取=Версия устройства: ещё не прочитана +当前设备固件(已缓存):=Версия устройства (из кэша): +请先在第\ 2\ 步选择固件。=Сначала выберите прошивку на шаге 2. +本地固件版本未知,需要勾选风险确认。=Версия файла неизвестна. Отметьте подтверждение риска. +请先在普通连接模式完成第\ 1\ 步版本读取。=Сначала прочитайте версию на шаге 1 в обычном режиме подключения. +检测到固件降级,默认禁止。=Обнаружено понижение версии. По умолчанию оно запрещено. +条件已满足,可以开始烧录。=Всё готово к записи прошивки. +请先完成第\ 3\ 步烧录环境检查。=Сначала выполните проверку на шаге 3. +正在准备\ WCHISP\ 烧录会话…=Подготовка сеанса WCHISP… +烧录会话已准备,请进入\ ISP\ 后检测设备。=Сеанс готов. Переведите устройство в ISP и выполните поиск. +烧录准备失败:=Ошибка подготовки: +当前设备固件:请先正常连接设备=Версия устройства: сначала подключите его в обычном режиме +当前设备固件:正在读取…=Чтение версии устройства… +设备未返回有效的\ 0x9F\ 版本信息=Устройство не вернуло корректную версию по команде 0x9F +当前设备固件:=Прошивка устройства: +;协议\ =; протокол +;能力位\ 0x=; возможности 0x +固件升级成功,设备当前版本为\ =Прошивка обновлена. Текущая версия: +固件升级成功=Прошивка обновлена +已从设备读取到目标版本\ =Устройство подтвердило целевую версию +烧录未生效:目标版本\ =Обновление не подтверждено. Целевая версия: +,设备仍为\ =, устройство по-прежнему сообщает +固件升级未生效=Обновление прошивки не подтверждено +目标版本为\ =Целевая версия: +,但设备返回\ =, но устройство сообщает +请重新进入\ ISP\ 模式后点击“重新烧录”。=Снова войдите в режим ISP и повторите запись. +版本读取失败:=Не удалось прочитать версию: +内置固件尚未生成=Встроенная прошивка отсутствует +请先执行安全发布构建,将\ =Сначала подготовьте сборку с прошивкой +\ 固件放入安装包。=\ в установочном пакете. +选择\ CH582\ 固件=Выбрать прошивку CH582 +正在读取\ ahakey.com\ 稳定版本…=Проверка стабильного релиза на ahakey.com… +尚未发布稳定版本=Стабильный релиз ещё не опубликован +稳定版本中没有\ CH582\ 固件=Стабильный релиз не содержит прошивку CH582 +下载完成并通过固件格式检查。=Загрузка завершена, формат прошивки проверен. +下载失败:=Ошибка загрузки: +正在调用\ WCHISP…=Запуск WCHISP… +烧录会话尚未完成准备或设备尚未检测。=Сеанс ещё не готов или устройство не обнаружено. +烧录正在进行=Операция с прошивкой уже выполняется +固件更新失败=Ошибка обновления прошивки +固件已烧录并通过设备回读校验。=Прошивка записана и подтверждена чтением с устройства. +正在取消烧录…=Отмена записи… +检测烧录环境和\ ISP=Проверить WCHISP и ISP +导出诊断报告=Экспорт диагностики +正在检查\ WCHISP\ 工具、配置和\ CH582\ ISP\ 设备…=Проверка WCHISP, конфигурации и устройства CH582 в режиме ISP… +导出烧录诊断报告=Сохранить диагностику прошивки +导出失败=Ошибка экспорта +1.\ 读取设备版本=1. Прочитать версию устройства +2.\ 选择固件=2. Выбрать прошивку +3.\ 进入\ ISP\ 并检测=3. Войти в ISP и найти устройство +断开\ USB,将键盘关机,按住最左侧“语音输入键”,再插入\ USB;随后点击检测。=Отключите USB, выключите клавиатуру, зажмите крайнюю левую голосовую клавишу и подключите USB. Затем нажмите кнопку проверки. +4.\ 烧录、校验并确认版本=4. Записать, проверить и подтвердить версию +危险操作:恢复初始化=Опасная операция: сброс устройства +仅限\ USB。将清除\ GIF、用户配置、按键配置、待机时间、其他用户数据和蓝牙配对;保留固件版本、设备标识和\ MAC。数据不可恢复。=Только USB. Будут удалены GIF, настройки, назначения клавиш, время сна, остальные пользовательские данные и сопряжения Bluetooth. Прошивка, идентификатор и MAC сохранятся. Данные восстановить нельзя. +我已了解数据不可恢复=Понимаю, что данные восстановить нельзя +恢复初始化=Сбросить устройство +重新检测\ USB=Повторить поиск USB +正在重新检测\ USB…=Поиск USB… +USB\ 已重新连接,可读取设备设置。=USB подключён. Можно читать настройки устройства. +仍未检测到\ USB,请重新拔插后再试。=USB не найден. Отключите и снова подключите кабель. +需要\ USB\ 连接=Требуется USB +恢复初始化不能通过\ BLE\ 执行,请连接\ USB\ 数据线。=Сброс недоступен по BLE. Подключите USB-кабель с передачей данных. +确认恢复初始化=Подтвердить сброс +所有设备用户数据将被永久删除=Все пользовательские данные устройства будут удалены навсегда +确认后设备会重启,请保持\ USB\ 连接。=После подтверждения устройство перезапустится. Не отключайте USB. +正在发送恢复命令…=Отправка команды сброса… +当前固件不支持安全恢复初始化,请先更新固件=Прошивка не поддерживает безопасный сброс. Сначала обновите её +设备已接受恢复命令。请重新拔插\ USB\ 后点击“重新检测\ USB”;蓝牙配对已清除,需要在\ Windows\ 中删除旧\ AhaKey\ 配对记录后重新配对。=Команда сброса принята. Переподключите USB и повторите поиск. Сопряжения Bluetooth удалены: удалите старую запись AhaKey в Windows и выполните сопряжение заново. +恢复初始化命令已执行=Команда сброса выполнена +客户端不会等待蓝牙自动重连。请重新拔插\ USB。\n\n=Клиент не ждёт автоматического подключения Bluetooth. Переподключите USB.\n\n +恢复失败:=Ошибка сброса: +恢复初始化失败=Не удалось сбросить устройство +无法验证固件版本=Не удалось проверить версию прошивки +请先在正常模式连接设备后选择固件;或在高级选项中确认风险后继续。=Подключите устройство в обычном режиме и выберите прошивку либо подтвердите риск в дополнительных параметрах. +已阻止固件降级=Понижение версии заблокировано +当前版本\ =Текущая версия: +,目标版本\ =, целевая: +。如确需降级,请勾选高级风险选项。=. Для понижения версии подтвердите риск в дополнительных параметрах. +语音输入中=Голосовой ввод +识别中=Распознавание +处理中=Обработка +语音就绪=Голосовой ввод готов +启动中=Запуск +语音键:短按\ /\ 长按=Голосовая клавиша: короткое / долгое нажатие +物理语音键由\ Desktop\ 自动区分短按与长按;=Приложение различает короткое и долгое нажатие голосовой клавиши; +动作不会写入固件,也不会模拟\ Typeless/微信\ Fn。=действия не записываются в прошивку и не имитируют Fn в Typeless/WeChat. +短按快捷键=Сочетание для короткого нажатия +长按快捷键=Сочетание для долгого нажатия +AhaKey\ 本地语音(当前不可用)=Локальный голосовой ввод AhaKey (недоступен) +短按动作(一次触发)=Короткое нажатие (однократно) +长按动作(按住说话)=Долгое нажатие (удерживать для записи) +系统语音(Win+H)=Голосовой ввод Windows (Win+H) +AhaKey\ 本地语音(按住说话)=Локальный голосовой ввод AhaKey (удерживать для записи) +禁用=Выключено +自定义快捷键=Своё сочетание клавиш +模拟按键=Проверка клавиши +复杂宏序列暂不执行模拟;切换为单个快捷键后可在此测试。=Проверка сложных макросов недоступна. Выберите одиночное сочетание клавиш. +---\ 修饰键\ ---=--- Модификаторы --- +---\ 字母\ ---=--- Буквы --- +---\ 数字\ ---=--- Цифры --- +---\ 基础键\ ---=--- Основные клавиши --- +---\ 功能键\ ---=--- Функциональные клавиши --- +---\ 控制键\ ---=--- Управляющие клавиши --- +---\ 方向键\ ---=--- Стрелки --- +---\ 小键盘\ ---=--- Цифровой блок --- +F18\ 为\ AhaKey\ 语音键保留,请选择其他快捷键。=F18 зарезервирована для голосового ввода AhaKey. Выберите другое сочетание. +---\ 字母键\ ---=--- Буквенные клавиши --- +---\ 数字键\ ---=--- Цифровые клавиши --- +---\ 其他键\ ---=--- Другие клавиши --- +任务灯效模式=Подсветка задач +单任务=Одна задача +多任务=Несколько задач +先发送并获得设备确认,再提交\ Preferences;失败会回滚。=Настройка сохраняется после подтверждения устройством. При ошибке изменение отменяется. +屏幕动画设置=Настройка анимаций экрана +配置屏幕动画=Настроить анимации экрана +默认=По умолчанию +等待/错误=Ожидание / ошибка +屏幕动画=Анимации экрана +正在读取设备\ Flash\ 与动画配置…=Чтение памяти и настроек анимации… +恢复全部内置动画=Восстановить все встроенные анимации +将依次覆盖四个模式的全部\ 16\ 个动画分区,是否继续?=Будут перезаписаны все 16 разделов анимации четырёх режимов. Продолжить? +全部内置动画恢复完成。=Все встроенные анимации восстановлены. +每个模式包含\ 4\ 个固定分区;仅支持\ USB\ 写入。软件会先读取真实\ Flash\ 容量,=В каждом режиме четыре раздела. Запись доступна только по USB. Сначала считывается ёмкость памяти; +所有写入严格串行执行。GIF\ 建议\ 160×80;默认动画最多\ 8\ 帧,其他状态最多\ 12\ 帧。=все записи выполняются по очереди. Рекомендуется GIF 160×80: до 8 кадров по умолчанию, до 12 — в остальных состояниях. +写入本模式全部已修改动画=Записать изменённые анимации режима +恢复本模式内置动画=Восстановить встроенные анимации режима +本模式没有可写入的动画。=В этом режиме нет анимаций для записи. +本模式全部已修改动画写入完成。=Изменённые анимации режима записаны. +将覆盖\ =Будут перезаписаны разделы режима +\ 的四个动画分区,是否继续?=\ (четыре анимации). Продолжить? +本模式内置动画恢复完成。=Встроенные анимации режима восстановлены. +内置动画=Встроенная анимация +正在读取设备配置…=Чтение настроек устройства… +内置动画不可用:=Встроенная анимация недоступна: +当前本地资源=Текущий локальный файл +当前本地资源(设备状态仅用于校验,不作为预览来源)=Текущий локальный файл (данные устройства используются для проверки, а не предпросмотра) +当前本地资源不可读,将显示内置动画=Локальный файл не читается. Показана встроенная анимация +尚未记录当前本地资源,显示内置动画=Локальный файл не сохранён. Показана встроенная анимация +选择\ GIF=Выбрать GIF +写入当前动画=Записать текущую анимацию +写入内置动画=Записать встроенную анимацию +清空状态=Очистить состояние +选择\ 160×80\ GIF=Выбрать GIF 160×80 +GIF\ 动画=Анимация GIF +所选\ GIF\ 不完全符合设备限制:%n文件\ %.1f\ MB(建议不超过\ 2\ MB)%n=GIF превышает ограничения устройства:%nфайл %.1f МБ (рекомендуется до 2 МБ)%n +尺寸\ %d×%d(设备\ %d×%d)%n帧数\ %d(该状态上限\ %d)%n%n=размер %d×%d (экран %d×%d)%nкадров %d (лимит состояния %d)%n%n +是否自动缩放、按时间轴抽帧并尽量保持原始总时长?=Изменить размер и сократить кадры с сохранением длительности, насколько это возможно? +GIF\ 预检失败:=Ошибка проверки GIF: +已选择本地\ GIF,等待写入;设备原配置尚未改变。=GIF выбран, но ещё не записан. Настройки устройства не изменены. +请先选择需要写入的自定义\ GIF。=Сначала выберите GIF для записи. +动画写入完成。=Анимация записана. +内置动画恢复完成。=Встроенная анимация восстановлена. +已有动画操作正在进行,请等待完成。=Операция с анимацией уже выполняется. Дождитесь завершения. +;设备当前\ 0\ 帧=; на устройстве 0 кадров +清空失败:=Ошибка очистки: +写入失败:=Ошибка записи: +写入超过\ =Запись длится более +\ 分钟,已请求取消;设备事务锁会保持到后台操作实际退出。=\ мин. Запрошена отмена; доступ к устройству освободится после завершения фоновой операции. +操作已中断=Операция прервана +设备已确认写入,但本地资源缓存保存失败:=Устройство подтвердило запись, но локальный файл не сохранён: +写入未完成:=Запись не завершена: +设备未返回\ Flash\ 布局。=Устройство не вернуло разметку памяти. +固件仅返回旧版布局,写入前请升级到固件\ =Устройство вернуло устаревшую разметку. Перед записью обновите прошивку до +Flash\ ID\ 0x%04X;实际容量\ %.1f\ MiB;可用帧槽\ %d;规划分区共\ %d\ 个帧槽。=Flash ID 0x%04X; ёмкость %.1f МиБ; доступно кадров %d; разделам требуется %d кадров. +读取\ Flash\ 布局失败:=Ошибка чтения разметки памяти: +设备当前参数:=Параметры устройства: +\ 帧,起始帧\ =\ кадров, первый кадр +,帧间隔\ =, интервал +当前本地资源:=Локальный файл: +已管理文件=Сохранённый файл +尚未记录当前本地资源;=Локальный файл ещё не сохранён; +设备配置读取失败:=Ошибка чтения настроек: +本地配置保存失败=Ошибка сохранения локальных настроек +自动待机时间=Автоматический сон +设备无操作达到指定时间后自动待机。支持\ USB\ 或\ BLE,保存后会回读校验。=Устройство засыпает после выбранного периода бездействия. Настройка по USB или BLE проверяется чтением после сохранения. +请先通过\ USB\ 或\ BLE\ 连接设备。=Сначала подключите устройство по USB или BLE. +选择时间=Выберите время +保存=Сохранить +请先连接设备。=Сначала подключите устройство. +正在读取设备设置…=Чтение настроек устройства… +当前固件不支持安全待机协议,请先更新固件。=Прошивка не поддерживает безопасную настройку сна. Сначала обновите её. +已通过\ =Успешно прочитано по +\ 读取成功。=. +读取失败:=Ошибка чтения: +正在设置、保存并回读校验,请勿断开设备…=Запись, сохранение и проверка. Не отключайте устройство… +已保存并验证:=Сохранено и проверено: +保存失败:=Ошибка сохранения: +待机时间设置失败=Ошибка настройки сна +当前设置:—=Текущая настройка: — +当前设置:=Текущая настройка: +永不关机=Никогда +\ 分钟=\ мин. +帮助与客服=Помощь и поддержка +遇到问题,请使用手机扫描下方二维码联系客服。=Чтобы связаться с поддержкой, отсканируйте QR-код телефоном. +客服二维码资源缺失,请重新安装\ AhaKeyStudio。=QR-код поддержки отсутствует. Переустановите AhaKeyStudio. +扫码后即可与客服沟通。=После сканирования можно написать в поддержку. +打开\ Hook\ 管理=Управление хуками +稍后设置=Настроить позже +AhaKey\ Studio\ 尚未安装\ AI\ 软件的\ Hook\ 组件。未安装\ Hook\ 时,键盘配置和语音功能仍可使用,=Хуки для ИИ ещё не установлены. Настройки клавиатуры и голосовой ввод доступны, +但\ Claude、Cursor、Codex、Kimi\ 等\ AI\ 联动状态与审批功能不可用。\n\n=но состояния и подтверждения действий Claude, Cursor, Codex и Kimi работать не будут.\n\n +安装\ Hook\ 后,请在对应\ AI\ 软件中同意\ Hook/钩子权限,并完全退出后重新启动该\ Agent。=После установки разрешите хуки в нужном приложении ИИ, затем полностью закройте и запустите его снова. +首次使用:安装\ AI\ Hook\ 组件=Первый запуск: хуки для ИИ +需要\ AI\ 联动功能时,请先安装对应\ Hook=Для взаимодействия с ИИ установите соответствующий хук +\ (TCP\ 9000\ 未在\ 5\ 秒内就绪)=\ (порт TCP 9000 не стал доступен за 5 секунд) +本地语音不可用;长按不会执行其他语音动作。=Локальный голосовой ввод недоступен. Долгое нажатие не запустит другое действие. +Hook\ 安装完成=Хук установлен +Hook\ 安装失败=Ошибка установки хука +\ Hook\ 已安装。请在\ =: хук установлен. Разрешите хуки в +\ 中同意\ Hook/钩子权限,然后完全退出并重新启动该\ Agent。=, затем полностью закройте и запустите приложение снова. +\ Hook\ 未能完成安装,请查看本窗口底部日志后重试。=: хук не установлен. Проверьте журнал внизу окна и повторите попытку. +[卸载]\ 开始卸载\ =[Удаление] Удаляется +Hook\ 卸载失败=Ошибка удаления хука +\ Hook\ 卸载后仍可检测到,请查看本窗口底部日志。=: хук всё ещё обнаруживается. Проверьте журнал внизу окна. +USB\ 供电=Питание USB +读取中=Чтение +正在等待设备确认任务显示模式…=Ожидание подтверждения режима отображения задач… +任务显示模式仅在本地修改,待设备重连后同步。=Режим изменён локально и будет передан после подключения устройства. +设备已通过\ BLE\ 连接。=Устройство подключено по BLE. +BLE\ 桥=BLE-мост +AhaKey\ Keyboard\ (模拟)=AhaKey Keyboard (симуляция) +已选择\ =Выбрано: +(仅本地编辑,尚未同步到设备)。=\ (локально, ещё не передано на устройство). +模式命令已发送,等待设备确认。=Команда смены режима отправлена. Ожидание подтверждения. +模式发送失败,已回滚到设备上次确认的模式。=Ошибка отправки. Восстановлен последний подтверждённый режим. +已进入编辑配置模式。=Включено редактирование настроек. +已手动断开设备;编辑内容仅保存在本地,点击“连接设备”后才能写入键盘。=Устройство отключено вручную. Изменения сохраняются локально; для записи подключите клавиатуру. +设备已断开;配置仍保存在本地,请连接设备后再写入键盘。=Устройство отключено. Настройки сохранены локально; подключите его для записи. +已交还控制权给键盘设备,连接保持。=Управление передано клавиатуре. Подключение сохранено. +设备未连接,当前只保存本地草稿。=Устройство не подключено. Сохранён только локальный черновик. +模拟模式:已标记为保存。=Симуляция: отмечено как сохранённое. +模拟模式:已保存先前快照,后续修改仍待保存。=Симуляция: предыдущая версия сохранена, новые изменения ещё не сохранены. +连接不可用,请重新连接键盘后再保存。=Подключение недоступно. Подключите клавиатуру снова. +设备能力合同不兼容,无法安全保存配置:=Возможности устройства несовместимы; безопасное сохранение недоступно: +正在通过\ =Запись настроек по +\ 写入设备配置...=… +正在写入设备配置...=Запись настроек на устройство… +已保存配置。=Настройки сохранены. +设备已保存先前快照,后续修改仍待保存。=На устройстве сохранена предыдущая версия. Новые изменения ещё не сохранены. +保存超时,已请求取消;在后台事务实际退出前将阻止冲突写入。=Время сохранения истекло. Запрошена отмена; другие записи блокируются до завершения фоновой операции. +请先连接设备再预览灯效。=Подключите устройство для предпросмотра подсветки. +已发送灯效预览:=Эффект отправлен для предпросмотра: +灯效预览写入=Предпросмотр подсветки +请先连接键盘,再测试灯效。=Подключите клавиатуру для проверки подсветки. +已发送灯效测试:=Эффект отправлен для проверки: +灯效写入=Запись подсветки +请先连接键盘,再测试灯光亮度。=Подключите клавиатуру для проверки яркости. +正在测试灯光亮度:=Проверка яркости: +已发送灯光亮度:=Яркость отправлена: +亮度写入=Запись яркости +请先连接键盘,再保存当前模式灯效。=Подключите клавиатуру для сохранения подсветки режима. +已保存\ =Сохранено для режима +\ 的\ AI\ 状态灯效和亮度。=: подсветка состояний ИИ и яркость. +AI\ 状态灯效配置写入=Запись подсветки состояний ИИ +请先连接设备再修改拨杆状态。=Подключите устройство, чтобы изменить состояние переключателя. +拨杆状态已更新为\:\ =Состояние переключателя: +只支持\ GIF、PNG、JPG、JPEG\ 文件。=Поддерживаются только GIF, PNG, JPG и JPEG. +GIF\ 将自动优化为\ %d×%d、最多\ %d\ 帧,并尽量保持原始总时长。是否继续?=GIF будет оптимизирован до %d×%d, максимум %d кадров, с сохранением длительности по возможности. Продолжить? +\ 的图片,连接键盘后可上传。=: изображение можно загрузить после подключения клавиатуры. +\ 的\ GIF(=: GIF ( +\ 帧),连接键盘后可上传。=\ кадров). Подключите клавиатуру для загрузки. +GIF\ /\ 图片导入失败:=Ошибка импорта GIF / изображения: +GIF\ /\ 图片不适合上传=GIF / изображение нельзя загрузить +设备未连接,请先连接键盘。=Устройство не подключено. Подключите клавиатуру. +请先选择\ GIF\ 或图片。=Сначала выберите GIF или изображение. +(模拟)OLED\ 上传已跳过。=Симуляция: загрузка OLED пропущена. +无法上传\ OLED\ GIF\ /\ 图片=Не удалось загрузить GIF / изображение OLED +OLED\ 上传已取消:=Загрузка OLED отменена: +,\ 类型\:\ 静态图片=, тип: статичное изображение +,\ 类型\:\ GIF动图=, тип: анимация GIF +上传\ OLED\ 图片=Загрузка изображения OLED +上传\ OLED\ GIF=Загрузка GIF OLED +正在上传\ OLED\ 图片...=Загрузка изображения OLED… +正在上传\ OLED\ GIF...=Загрузка GIF OLED… +准备数据...=Подготовка данных… +上传完成=Загрузка завершена +\ -\ 静态图片=\ — статичное изображение +\ OLED\ 上传失败:=: ошибка загрузки OLED: +OLED\ 上传失败=Ошибка загрузки OLED +\ 帧=\ кадров +本地配置保存失败;设备配置未受影响,请检查磁盘权限。=Не удалось сохранить локальные настройки. Настройки устройства не затронуты. Проверьте права доступа к диску. +语音输入已就绪=Голосовой ввод готов +点击开始/停止语音输入=Начать / остановить голосовой ввод +错误:语音服务未初始化=Ошибка: служба голосового ввода не инициализирована +正在听...=Слушаю… +未检测到语音=Речь не обнаружена +识别完成=Распознавание завершено +停止=Остановить +语音输入=Голосовой ввод +更新完成=Обновление завершено +AhaKeyStudio\ 已更新到\ =AhaKeyStudio обновлён до +关于与软件更新=О программе и обновления +将通过\ ahakey.com\ 检查稳定版本。=Стабильная версия проверяется на ahakey.com. +检查更新=Проверить обновления +已是最新版本=Установлена последняя версия +当前已安装最新稳定版。=Установлена последняя стабильная версия. +检查更新失败=Ошибка проверки обновлений +发现新版本\ =Новая версия +下载并安装=Скачать и установить +稍后提醒=Напомнить позже +下载更新=Загрузка обновления +正在下载安装包…=Загрузка установщика… +已下载\ =Загружено +准备安装=Всё готово к установке +安装包已下载并通过\ MZ\ 与\ Authenticode\ 发布者校验。\n=Установщик загружен, формат MZ и подпись издателя Authenticode проверены.\n +确认后将退出\ AhaKeyStudio\ 并启动安装程序。=После подтверждения AhaKeyStudio закроется и запустит установщик. +无法启动安装程序=Не удалось запустить установщик +更新下载失败=Ошибка загрузки обновления +发现新固件=Доступна новая прошивка +当前固件\ =Текущая прошивка +,可更新到\ =, доступна версия +。请在“设备信息\ →\ 固件管理”中手动开始;不会自动烧录。=. Запустите обновление вручную в разделе «Устройство → Прошивка». Автоматической записи не будет. +修改会先保存在本地,保存配置后写入键盘。=Изменения хранятся локально. Нажмите «Сохранить», чтобы записать их на клавиатуру. +尚未保存=Ещё не сохранено +云端整理已启用=Облачная обработка включена +AI\ 状态灯效=Подсветка состояний ИИ +已自定义\ AI\ 状态灯效=Подсветка состояний ИИ настроена +已选择\ GIF\ /\ 图片=GIF / изображение выбрано +\ 帧\ ·\ =\ кадров · +语音结果直接粘贴=Распознанный текст вставляется напрямую +有\ =Изменений: +\ 处改动待保存。=. Сохраните настройки. +已恢复\ =Восстановлен режим +\ 默认值,等待保存。=: настройки по умолчанию. Ожидание сохранения. +未选择=Не выбрано +等待选择\ GIF\ /\ 图片=Выберите GIF / изображение +最近保存\ =Последнее сохранение: +未上传=Не загружено +等待\ GIF=Ожидание GIF +未设置=Не назначено +宏\ (=Макрос ( +\ 步)=\ шагов) +无操作=Без действия +按下键=Нажать клавишу +释放键=Отпустить клавишу +释放所有=Отпустить все +延时=Задержка +Windows\ 语音\ (Win+H)=Голосовой ввод Windows (Win+H) +macOS\ 原生语音=Голосовой ввод macOS +微信语音=Голосовой ввод WeChat +AhaKey\ Studio\ 在后台拦截物理\ F18;短按或显式配置的长按系统动作会发送\ Win+H\ 打开\ Windows\ 语音输入。请在「设置\ →\ 时间和语言\ →\ 语音」中启用语音输入。=AhaKey Studio обрабатывает физическую клавишу F18 и вызывает Win+H для назначенного системного действия. Включите голосовой ввод в параметрах Windows. +仅\ macOS\ 完整支持;Windows\ 请改用「Windows\ 语音\ (Win+H)」。=Полная поддержка доступна только в macOS. В Windows используйте Win+H. +Windows\ 版暂未实现\ Fn\ 注入;请使用\ Windows\ 语音\ (Win+H)\ 或自定义快捷键。=Имитация Fn в Windows не поддерживается. Используйте Win+H или своё сочетание. +自行绑定\ HID\ 单键或组合键。=Назначьте HID-клавишу или сочетание клавиш. +应用已最小化到托盘=Приложение свёрнуто в область уведомлений + +# Upstream prepared-session and AhaType status changes +固件烧录完成,请退出\ ISP\ 并以普通模式重新连接设备。=Запись прошивки завершена. Выйдите из режима ISP и подключите устройство в обычном режиме. +WCHISP\ 已完成固件写入。请退出\ ISP\ 并以普通模式重新连接设备;本次流程未自动读取设备版本。=WCHISP завершил запись прошивки. Выйдите из режима ISP и подключите устройство в обычном режиме. Версия устройства автоматически не проверялась. +正在准备烧录环境。=Подготовка среды прошивки. +请先满足固件版本和风险确认条件,再准备烧录环境。=Перед подготовкой среды проверьте версию прошивки и подтвердите необходимые предупреждения. +正在准备烧录环境(不会启动\ WCHISP)…=Подготовка среды прошивки (без запуска WCHISP)… +烧录环境已准备完成。请进入\ ISP\ 后执行第\ 3\ 步检测。=Среда прошивки готова. Войдите в режим ISP и выполните проверку на шаге 3. +烧录环境准备失败:=Не удалось подготовить среду прошивки:\u0020 +固件烧录完成=Запись прошивки завершена +AhaType\ 未启用=AhaType выключен +AhaType\ 已启用=AhaType включён +本地语音未就绪=Локальный голосовой ввод не готов +请先登录\ AhaType=Сначала войдите в AhaType diff --git a/ahakeyconfig-win-java/src/main/resources/messages_en.properties b/ahakeyconfig-win-java/src/main/resources/messages_en.properties index 1ae96b6e..ebb0cfb1 100644 --- a/ahakeyconfig-win-java/src/main/resources/messages_en.properties +++ b/ahakeyconfig-win-java/src/main/resources/messages_en.properties @@ -8,7 +8,7 @@ menu.device-info=Device Info · Settings · Hooks menu.version-info=View Version menu.cloud-account=Cloud Account · AhaType… menu.refresh-ahatype=Refresh AhaType Status -menu.switch-language=Switch to Chinese +menu.switch-language=Language / Язык menu.exit=Exit status.connected=Connected @@ -101,9 +101,9 @@ dialog.ble-kill-success=All BLE processes have been closed!\nPlease click BLE Dr dialog.ble-kill-fail=Failed to close processes, %d BLE processes remain.\nPlease manually end BLE_tcp_driver.exe in Task Manager. dialog.ble-no-process=No BLE processes found -status-bar.selection=Current Selection: -status-bar.device=Device: -status-bar.dirty=Unsaved Changes: +status-bar.selection=Current Selection:\u0020 +status-bar.device=Device:\u0020 +status-bar.dirty=Unsaved Changes:\u0020 voice.status.voice-service-unavailable=Voice Service Not Loaded @@ -118,6 +118,9 @@ config.status.editing-detail=Editing configuration in progress config.status.keyboard-detail=Keyboard running normally language-change-title=Language Changed +language.select=Interface Language +language.restart=Restart the application to update all windows. +language-change-russian=Language changed to Russian; restart the application to apply it everywhere. language-change-chinese=Language changed to Chinese, will take effect on next launch. language-change-english=Language changed to English, will take effect on next launch. @@ -134,7 +137,7 @@ inspector.add=Add inspector.delete=Delete inspector.add-step=+ Add Step inspector.clear=Clear -inspector.preview=Preview: +inspector.preview=Preview:\u0020 inspector.macro-note=Firmware sends steps sequentially; delay unit is 3ms (max 765ms). Use multiple delay steps for longer delays. inspector.press=Press inspector.release=Release @@ -143,7 +146,7 @@ inspector.ms=ms inspector.key-description=Key Description inspector.desc-placeholder=e.g. Record / Approve / Reject / Backspace inspector.desc-warning=Use English, numbers and common symbols only. -inspector.device-write=Device writes: +inspector.device-write=Device writes:\u0020 inspector.light-brightness=Light Brightness inspector.brightness=Brightness inspector.test-brightness=Test Brightness @@ -256,3 +259,77 @@ light-bar-preview.stopped=Stopped light-bar-preview.stopped-detail=Defaults to solid red. light-bar-preview.task-completed=Task Completed light-bar-preview.task-completed-detail=Indicates current execution round is complete. + +# Background-service status and remaining UI labels +common.unset=Not set +common.unknown=Unknown +hooks.installed=Hooks installed. Restart the AI application to apply. +voice.bridge.not-started=Voice bridge has not started. +voice.route.not-configured=No voice route configured. +voice.local.unavailable=Local AhaKey voice is unavailable; no action was performed. +voice.shortcut.invalid=Invalid shortcut; F18 is reserved for the AhaKey voice key. +voice.bridge.unsupported=The voice bridge requires Windows. +voice.bridge.stopped=Voice bridge stopped. +voice.simulate.windows-prefix=Simulated Windows voice ( +voice.simulate.windows-suffix=, physical F18) +voice.simulate.recording=Recording started (simulated F18, 3 seconds). +voice.simulate.macos=Windows cannot run native macOS voice or synthesize F18. +voice.simulate.unsupported=This voice preset does not support simulation. +voice.simulate.no-key=No key is configured for simulation. +voice.simulate.unknown-hid-prefix=Cannot identify HID 0x +voice.simulate.unknown-hid-suffix=\ virtual key code. +voice.simulate.done-prefix=Simulated\u0020 +voice.simulate.macro-done=Macro simulation completed. +voice.simulate.macro-error=Macro simulation failed:\u0020 +voice.simulate.pressed=Simulated key pressed; release the test button to release it. +voice.simulate.released=Simulated key released. +voice.simulate.unknown-key=Unknown key; cannot simulate. +voice.hook.failed=Could not install the keyboard hook; check security software or retry as administrator. +voice.platform.unsupported=Not running on Windows. +voice.firmware.prefix=Physical F18 voice is disabled (firmware\u0020 +voice.firmware.suffix=; version 1.4.8 or newer is required). +voice.hook.not-running=Voice bridge is stopped; opening configuration or restarting the app installs the hook. +voice.listening.prefix=Listening for F18; the app handles short/long presses (threshold\u0020 +voice.listening.suffix=\ ms). +sync.macro=\ macro +sync.keycode=\ key code +sync.description=\ description +sync.ai-light=\ AI status lighting +sync.brightness=Light brightness +sync.voice-shortcuts=Voice key short/long press shortcuts +sync.save-all=Save all settings to device +sync.progress=Saving ( +sync.invalid-command=Invalid configuration command:\u0020 +sync.voice-readback-error=Voice key readback failed; verify firmware support for short/long presses. +sync.failed=Save failed:\u0020 +task.mode.saved-local=Task display choice saved locally; it will sync after reconnection. +task.mode.confirmed=Task display mode confirmed by the device. +task.mode.change=Change task display mode +task.mode.rollback=Task display sync failed; reverted to the confirmed device value:\u0020 +task.slot.clear=Clear task lighting slot +task.light.sync=Sync task lighting +task.mode.invalid=Device returned an invalid task display mode. +task.mode.reconnected=Task display mode confirmed via 0x98 after reconnection. +task.light.confirm=Confirm task lighting after reconnection +task.mode.unconfirmed=Task display mode not yet confirmed after reconnection:\u0020 +task.light.heartbeat=Task lighting heartbeat +task.slot.clear-prefix=Clear task lighting slot\u0020 +task.sync.failed=\ failed; lighting/OLED may be out of sync:\u0020 +hooks.log.started=[System] Hook installation tool started\n +hooks.log.home=[System] User directory:\u0020 +hooks.log.os=[System] OS:\u0020 +hooks.log.java=[System] Java version:\u0020 +hooks.log.cleared=[System] Log cleared\n +unit.milliseconds=ms +voice.route.summary=F18: short press=%s, long press=%s, threshold=%d ms +voice.action.ahakey_voice=Local AhaKey voice +voice.action.system_voice=Windows voice +voice.action.custom_shortcut=Custom shortcut +voice.action.none=No action + +# Shortcut editing and configuration save +sync.incomplete-shortcut=Choose a letter or another main key for each custom voice shortcut, or select No action. +sync.local-saved=Voice settings saved on this computer. Keyboard support depends on its firmware. +sync.checking-device=Checking keyboard compatibility… +sync.incompatible-device=The keyboard does not support the required configuration protocol. Your draft is saved on this computer; no settings were written to the keyboard. +inspector.shortcut-hint=A shortcut contains modifiers and one main key. Adding another main key replaces the previous one. diff --git a/ahakeyconfig-win-java/src/main/resources/messages_ru.properties b/ahakeyconfig-win-java/src/main/resources/messages_ru.properties new file mode 100644 index 00000000..ccfd9c42 --- /dev/null +++ b/ahakeyconfig-win-java/src/main/resources/messages_ru.properties @@ -0,0 +1,316 @@ +app.title=AhaKey Studio +menu.more=Ещё +menu.restore-defaults=Сбросить текущий режим +menu.reconnect=Переподключить устройство +menu.clear-oled=Очистить предпросмотр OLED +menu.device-info=Устройство · Настройки · Хуки +menu.version-info=О программе +menu.cloud-account=Облачный аккаунт · AhaType… +menu.refresh-ahatype=Обновить статус AhaType +menu.switch-language=Язык / Language +menu.exit=Выход +status.connected=Подключено +status.scanning=Поиск +status.disconnected=Отключено +status.waiting-device=Ожидание устройства +status.battery=Заряд +status.switch=Переключатель +status.auto-approval=Автоподтверждение +status.manual-approval=Ручное подтверждение +status.approval-disconnected=Устройство отключено +status.approval-unknown=Положение переключателя неизвестно +status.editing-config=Редактирование настроек +status.keyboard-control=Управление клавиатурой +button.connect=Подключить +button.disconnect=Отключить +button.ble-driver=BLE-мост +button.start-voice=Голосовой ввод +button.stop-voice=Остановить запись +button.voice-unavailable=Голосовой ввод недоступен +button.edit-config=Настроить +button.save-config=Сохранить +button.save-progress=Сохранение… +voice.status.stopped=Голосовой ввод выключен +voice.status.starting=Запуск голосового ввода +voice.status.stopping=Остановка голосового ввода +voice.status.recording=Запись +voice.status.ready=Готово +voice.status.processing=Обработка +dialog.version-title=О программе +dialog.version-content=Версия: %s +dialog.hook-title=Настройки устройства · Установка хуков +dialog.device-info=Устройство +dialog.log=Журнал +dialog.close=Закрыть +dialog.clear-log=Очистить журнал +dialog.connection=Подключение +dialog.device-name=Имя устройства +standby.title=Автоматический сон +standby.description=Устройство засыпает после выбранного периода бездействия. Настройка передаётся по USB или BLE и сохраняется в памяти устройства. +standby.current=Текущая настройка: %d мин. +standby.current-unknown=Текущая настройка: — +standby.select=Время бездействия +standby.minutes=%d мин. +standby.apply=Сохранить на устройстве +standby.checking=Проверка возможностей прошивки и чтение настройки… +standby.disconnected=Сначала подключите устройство по USB или BLE. +standby.unsupported=Прошивка не поддерживает безопасную настройку сна. Сначала обновите прошивку. +standby.ready=Подключено по %s. Протокол v%d.%d поддерживает настройку сна. +standby.saving=Запись, сохранение и проверка. Не отключайте устройство… +standby.saved=Сохранено и проверено: %d мин. +standby.read-failed=Не удалось прочитать настройку сна: %s +standby.save-failed=Не удалось сохранить настройку сна; значение на устройстве не подтверждено: %s +standby.save-failed-recovered=Сохранение не завершено (%s). Текущее значение на устройстве: %d мин. +standby.error-title=Ошибка настройки сна +hook.detection=[Проверка] +hook.check-path=Проверка пути +hook.file-exists=Файл существует +hook.file-size=Размер файла +hook.contains=Содержит +hook.read-failed=Не удалось прочитать файл +hook.final-status=Итоговый статус +hook.installation-status=Установка +hook.configured=Настроено +hook.enabled-trusted=Включено / доверено +hook.yes=Да +hook.no=Нет +hook.installed=Установлено +hook.not-installed=Не установлено +hook.checking=Проверка… +hook.incomplete=Настройка не завершена +hook.server-status=Служба обработки +hook.server-online=Работает +hook.server-offline=Остановлена +hook.recent-activity=Последняя активность +hook.recent-none=Нет +hook.recent-just-now=Только что +button.install=Установить +button.uninstall=Удалить +dialog.ble-driver-title=BLE-мост +dialog.ble-running=BLE-мост уже запущен +dialog.ble-start-fail=Не удалось запустить BLE-мост. Запустите вручную: %s +dialog.ble-not-found=Файл BLE_tcp_driver.exe не найден +dialog.ble-kill-success=Процессы BLE закрыты.\nНажмите «BLE-мост», чтобы запустить снова. +dialog.ble-kill-fail=Не удалось закрыть процессы; осталось: %d.\nЗавершите BLE_tcp_driver.exe в диспетчере задач. +dialog.ble-no-process=Процессы BLE не найдены +status-bar.selection=Выбрано:\u0020 +status-bar.device=Устройство:\u0020 +status-bar.dirty=Несохранённые изменения:\u0020 +voice.status.voice-service-unavailable=Служба голосового ввода не загружена +aha-type.enabled=AhaType включён +aha-type.disabled=AhaType выключен +aha-type.enabled-detail=Облачная обработка текста включена +aha-type.disabled-detail=Распознанный текст вставляется напрямую +config.status.editing=Редактирование настроек +config.status.keyboard-control=Управление клавиатурой +config.status.editing-detail=Настройки редактируются +config.status.keyboard-detail=Клавиатура работает +language-change-title=Язык изменён +language-change-chinese=Выбран китайский язык. Изменение вступит в силу после перезапуска. +language-change-english=Выбран английский язык. Изменение вступит в силу после перезапуска. +language-change-russian=Выбран русский язык. Изменение вступит в силу после перезапуска. +language.select=Язык интерфейса +language.restart=Перезапустите приложение, чтобы обновить все окна. +inspector.simulate-key=Проверить клавишу +inspector.simulate-key1=Проверить клавишу 1 +inspector.key-binding=Назначение клавиши (на устройстве) +inspector.key-type=Тип действия +inspector.shortcut=Сочетание клавиш +inspector.macro=Макрос +inspector.voice-preset-mode=Режим голосового ввода +inspector.voice-preset-note=Голосовой режим использует F17/F18. Для изменения HID выберите своё сочетание. +inspector.key-code-list=Коды клавиш: сначала модификаторы, затем обычные клавиши +inspector.add=Добавить +inspector.delete=Удалить +inspector.add-step=+ Добавить шаг +inspector.clear=Очистить +inspector.preview=Предпросмотр:\u0020 +inspector.macro-note=Прошивка выполняет шаги по порядку. Шаг задержки — 3 мс, максимум — 765 мс. Для большей паузы добавьте несколько задержек. +inspector.press=Нажать +inspector.release=Отпустить +inspector.delay=Задержка +inspector.ms=мс +inspector.key-description=Описание клавиши +inspector.desc-placeholder=Например: Record / Approve / Reject / Backspace +inspector.desc-warning=Для экрана устройства используйте латиницу, цифры и обычные символы. +inspector.device-write=На устройство:\u0020 +inspector.light-brightness=Яркость подсветки +inspector.brightness=Яркость +inspector.test-brightness=Проверить яркость +inspector.brightness-note=Проверьте яркость, затем сохраните настройки на клавиатуре. +inspector.ai-light-effects=Подсветка состояния ИИ +inspector.test=Проверить +inspector.save-light-config=Сохранить подсветку режима +inspector.light-note=«Проверить» показывает эффект. Сохранение записывает эффекты для девяти состояний ИИ в текущем режиме. +inspector.oled-preview=GIF / изображение OLED для текущего режима +inspector.oled-none=GIF / изображение не выбрано +inspector.oled-select=Выбрать GIF или изображение +inspector.oled-upload=Загрузить на устройство +inspector.oled-uploading=Загрузка… +inspector.oled-limits=GIF/PNG/JPG: до 2 МиБ, 160×80 пикселей и лимит кадров каждого состояния. При необходимости оптимизации будет показана причина. +inspector.screen-text=Текст на экране +inspector.main-title=Заголовок +inspector.sub-title=Подзаголовок +inspector.switch-semantics=Режим подтверждения +inspector.auto-approval=Автоматически +inspector.manual-approval=Вручную +inspector.switch-note=Переключатель меняет режим подтверждения на устройстве и индикатор состояния, но не назначает HID-клавиши. +canvas.keyboard-mode=Режим клавиатуры +dialog.confirm-title=AhaKey — Подтверждение действия +dialog.confirm-content=%s запрашивает действие (%s)\n\nВключён ручной режим. Разрешить действие? +dialog.allow=Разрешить +dialog.deny=Запретить +dialog.select-gif=Выбрать GIF или изображение +dialog.filter-gif=Анимация GIF +dialog.filter-png=Изображение PNG +dialog.filter-jpg=Изображение JPG +dialog.filter-all=GIF или изображение +dialog.gif-no-frames=В этом GIF нет читаемых кадров. Выберите другой файл. +dialog.exit-now=Выйти сейчас +dialog.exit-later=Позже +dialog.gif-too-many-frames=В GIF %d кадров, а прошивка допускает %d кадров на режим. Сократите анимацию или используйте статичное изображение. +studio-part.light-bar=Подсветка +studio-part.light-bar-sub=Состояния ИИ +studio-part.oled=Экран OLED +studio-part.oled-sub=GIF / изображение +studio-part.key1=Клавиша 1 +studio-part.key2=Клавиша 2 +studio-part.key3=Клавиша 3 +studio-part.key4=Клавиша 4 +studio-part.key-sub=Сочетание клавиш +studio-part.toggle-switch=Переключатель +studio-part.toggle-switch-sub=Подтверждение действий +ide-state.session-start=Начало сеанса +ide-state.session-start-desc=Сеанс Claude/Codex запущен. +ide-state.user-prompt-submit=Запрос отправлен +ide-state.user-prompt-submit-desc=Пользователь отправил вопрос или инструкцию ИИ. +ide-state.pre-tool-use=Перед действием +ide-state.pre-tool-use-desc=ИИ готовится вызвать инструмент или выполнить действие. +ide-state.permission-request=Запрос разрешения +ide-state.permission-request-desc=ИИ ожидает подтверждения команды, изменения файла или вызова инструмента. +ide-state.post-tool-use=После действия +ide-state.post-tool-use-desc=Инструмент завершил работу; ИИ может продолжить анализ. +ide-state.notification=Уведомление +ide-state.notification-desc=Общее уведомление или изменение состояния. +ide-state.task-completed=Задача завершена +ide-state.task-completed-desc=Текущая задача выполнена. +ide-state.stop=ИИ остановлен +ide-state.stop-desc=ИИ завершил ответ или ожидает. +ide-state.session-end=Конец сеанса +ide-state.session-end-desc=Сеанс закрывается. +light-effect.off=Выключено +light-effect.off-detail=Подсветка выключена. +light-effect.single-move=Бегущий огонёк +light-effect.single-move-detail=Огонёк движется вперёд и назад во время работы. +light-effect.rainbow-move=Радужный огонёк +light-effect.rainbow-move-detail=Бегущий разноцветный огонёк. +light-effect.rainbow-wave=Радужная волна +light-effect.rainbow-wave-detail=Яркая радужная волна по всей полосе. +light-effect.rainbow-wave-slow=Медленная радуга +light-effect.rainbow-wave-slow-detail=Плавная радужная волна. +light-effect.breathing=Дыхание +light-effect.breathing-detail=Плавное изменение яркости при ожидании подтверждения. +light-effect.middle-light=Свечение в центре +light-effect.middle-light-detail=Яркий центр с приглушёнными краями для ожидания. +light-effect.typing-ripple=Волна ввода +light-effect.typing-ripple-detail=Волна от точки ввода при отправке запроса. +light-effect.comet=Комета +light-effect.comet-detail=Бегущий огонёк со световым следом. +light-effect.scan-bar=Сканирование +light-effect.scan-bar-detail=Горизонтальное сканирование до или после действия. +light-effect.pulse-center=Пульсация центра +light-effect.pulse-center-detail=Пульсация в центре во время размышления. +light-effect.warning-blink=Предупреждение +light-effect.warning-blink-detail=Мигание для уведомлений. +light-effect.success-sweep=Волна завершения +light-effect.success-sweep-detail=Световая волна при завершении задачи. +light-effect.blue-thinking=Синее размышление +light-effect.blue-thinking-detail=Синяя подсветка во время размышления. +light-effect.low-battery=Низкий заряд +light-effect.low-battery-detail=Напоминание о низком заряде. +light-effect.charging-flow=Зарядка +light-effect.charging-flow-detail=Бегущая подсветка при зарядке. +light-effect.approval-wait=Ожидание разрешения +light-effect.approval-wait-detail=Сигнал ожидания подтверждения. +light-bar-preview.ai-running=ИИ работает +light-bar-preview.ai-running-detail=В ручном режиме огонёк движется вперёд и назад, в автоматическом — радужная подсветка. +light-bar-preview.waiting-approval=Ожидание подтверждения +light-bar-preview.waiting-approval-detail=Напоминает подтвердить текущее действие. +light-bar-preview.stopped=Остановлено +light-bar-preview.stopped-detail=По умолчанию — постоянный красный свет. +light-bar-preview.task-completed=Задача завершена +light-bar-preview.task-completed-detail=Текущий этап работы завершён. + +# Background-service status and remaining UI labels +common.unset=Не задано +common.unknown=Неизвестно +hooks.installed=Hook установлены. Перезапустите AI-приложение. +voice.bridge.not-started=Голосовой модуль ещё не запущен. +voice.route.not-configured=Действия голосовой клавиши не настроены. +voice.local.unavailable=Локальный голосовой ввод AhaKey недоступен; действие не выполнено. +voice.shortcut.invalid=Недопустимое сочетание: F18 зарезервирована для голосовой клавиши AhaKey. +voice.bridge.unsupported=Голосовой модуль работает только в Windows. +voice.bridge.stopped=Голосовой модуль остановлен. +voice.simulate.windows-prefix=Проверка голосового ввода Windows ( +voice.simulate.windows-suffix=, физическая клавиша F18) +voice.simulate.recording=Запись начата (проверка F18, 3 секунды). +voice.simulate.macos=Windows не поддерживает голосовой ввод macOS и не имитирует F18. +voice.simulate.unsupported=Для этого голосового действия проверка недоступна. +voice.simulate.no-key=Клавиша не задана; проверка невозможна. +voice.simulate.unknown-hid-prefix=Не удалось определить виртуальную клавишу для HID 0x +voice.simulate.unknown-hid-suffix=. +voice.simulate.done-prefix=Проверено:\u0020 +voice.simulate.macro-done=Проверка макроса завершена. +voice.simulate.macro-error=Ошибка проверки макроса:\u0020 +voice.simulate.pressed=Клавиша нажата; отпустите кнопку проверки, чтобы отпустить клавишу. +voice.simulate.released=Клавиша отпущена. +voice.simulate.unknown-key=Клавиша не распознана; проверка невозможна. +voice.hook.failed=Не удалось подключить перехват клавиш; проверьте защитное ПО или повторите от имени администратора. +voice.platform.unsupported=Эта функция доступна в Windows. +voice.firmware.prefix=Голосовая клавиша F18 недоступна (прошивка\u0020 +voice.firmware.suffix=; требуется версия 1.4.8 или новее). +voice.hook.not-running=Голосовой модуль не работает; откройте настройки или перезапустите приложение для подключения перехвата клавиш. +voice.listening.prefix=Ожидание F18; приложение обрабатывает короткие и долгие нажатия (порог\u0020 +voice.listening.suffix=\ мс). +sync.macro=\ макрос +sync.keycode=\ код клавиши +sync.description=\ описание +sync.ai-light=\ подсветка статуса AI +sync.brightness=Яркость подсветки +sync.voice-shortcuts=Короткое и долгое нажатия голосовой клавиши +sync.save-all=Сохранение всех настроек на устройство +sync.progress=Сохранение ( +sync.invalid-command=Неверный формат команды настройки:\u0020 +sync.voice-readback-error=Не удалось проверить настройки голосовой клавиши. Проверьте поддержку коротких и долгих нажатий прошивкой. +sync.failed=Ошибка сохранения:\u0020 +task.mode.saved-local=Режим отображения задач сохранён локально и будет передан после подключения. +task.mode.confirmed=Устройство подтвердило режим отображения задач. +task.mode.change=Переключение режима отображения задач +task.mode.rollback=Не удалось синхронизировать режим задач; восстановлено подтверждённое значение:\u0020 +task.slot.clear=Очистка ячейки подсветки задач +task.light.sync=Синхронизация подсветки задач +task.mode.invalid=Устройство вернуло недопустимый режим отображения задач. +task.mode.reconnected=После подключения режим задач подтверждён командой 0x98. +task.light.confirm=Подтверждение подсветки задач после подключения +task.mode.unconfirmed=После подключения режим задач ещё не подтверждён:\u0020 +task.light.heartbeat=Проверка активности подсветки задач +task.slot.clear-prefix=Очистка ячейки подсветки задач\u0020 +task.sync.failed=: ошибка; подсветка и OLED могут быть не синхронизированы:\u0020 +hooks.log.started=[Система] Открыта установка Hook\n +hooks.log.home=[Система] Папка пользователя:\u0020 +hooks.log.os=[Система] ОС:\u0020 +hooks.log.java=[Система] Версия Java:\u0020 +hooks.log.cleared=[Система] Журнал очищен\n +unit.milliseconds=мс +voice.route.summary=F18: короткое нажатие=%s, долгое нажатие=%s, порог=%d мс +voice.action.ahakey_voice=Локальный голосовой ввод AhaKey +voice.action.system_voice=Голосовой ввод Windows +voice.action.custom_shortcut=Своё сочетание клавиш +voice.action.none=Без действия + +# Shortcut editing and configuration save +sync.incomplete-shortcut=Добавьте букву или другую основную клавишу для каждого голосового сочетания либо выберите «Нет действия». +sync.local-saved=Настройки голосовой клавиши сохранены на компьютере. Работа клавиши зависит от поддержки в прошивке. +sync.checking-device=Проверяем совместимость клавиатуры… +sync.incompatible-device=Клавиатура не поддерживает нужный протокол настройки. Черновик сохранён на компьютере; настройки в клавиатуру не записаны. +inspector.shortcut-hint=Сочетание: модификаторы и одна основная клавиша. Новая основная клавиша заменяет предыдущую. diff --git a/ahakeyconfig-win-java/src/main/resources/messages_zh.properties b/ahakeyconfig-win-java/src/main/resources/messages_zh.properties index aa865f99..3b9a4906 100644 --- a/ahakeyconfig-win-java/src/main/resources/messages_zh.properties +++ b/ahakeyconfig-win-java/src/main/resources/messages_zh.properties @@ -1,4 +1,7 @@ app.title=AhaKey Studio +language.select=界面语言 +language.restart=重新启动应用以更新所有窗口。 +language-change-russian=已选择俄语,重新启动应用后生效。 menu.more=更多 menu.restore-defaults=恢复当前模式默认值 @@ -8,7 +11,7 @@ menu.device-info=设备信息 · 设置 · Hooks安装 menu.version-info=查看版本号 menu.cloud-account=云端账号 · AhaType… menu.refresh-ahatype=刷新 AhaType 状态 -menu.switch-language=Switch to English +menu.switch-language=语言 / Language / Язык menu.exit=退出程序 status.connected=已连接 @@ -101,9 +104,9 @@ dialog.ble-kill-success=所有 BLE 进程已关闭!\n请点击 BLE 驱动按 dialog.ble-kill-fail=关闭进程失败,仍有 %d 个 BLE 进程未关闭。\n请手动在任务管理器中结束 BLE_tcp_driver.exe。 dialog.ble-no-process=未找到 BLE 进程 -status-bar.selection=当前选中: -status-bar.device=设备: -status-bar.dirty=待保存改动: +status-bar.selection=当前选中:\u0020 +status-bar.device=设备:\u0020 +status-bar.dirty=待保存改动:\u0020 voice.status.voice-service-unavailable=语音服务未加载 @@ -134,7 +137,7 @@ inspector.add=添加 inspector.delete=删除 inspector.add-step=+ 添加步骤 inspector.clear=清空 -inspector.preview=预览: +inspector.preview=预览:\u0020 inspector.macro-note=固件按顺序串行发送;延时单位 3ms(最大 765ms)。需要更长延时请叠加多个延时步骤。 inspector.press=按下 inspector.release=松开 @@ -143,7 +146,7 @@ inspector.ms=ms inspector.key-description=按键描述 inspector.desc-placeholder=例如 Record / Approve / Reject / Backspace inspector.desc-warning=建议使用英文、数字和常用符号。 -inspector.device-write=设备实际写入: +inspector.device-write=设备实际写入:\u0020 inspector.light-brightness=灯光亮度 inspector.brightness=亮度 inspector.test-brightness=测试亮度 @@ -256,3 +259,77 @@ light-bar-preview.stopped=已停止 light-bar-preview.stopped-detail=默认用红色常亮停住。 light-bar-preview.task-completed=任务完成 light-bar-preview.task-completed-detail=表示本轮执行已经完成。 + +# Background-service status and remaining UI labels +common.unset=未设置 +common.unknown=未知 +hooks.installed=Hooks 安装成功!AI 应用重启后生效。 +voice.bridge.not-started=语音桥尚未启动。 +voice.route.not-configured=未配置路由。 +voice.local.unavailable=AhaKey 本地语音(当前不可用),未执行动作。 +voice.shortcut.invalid=自定义快捷键无效;F18 为 AhaKey 语音键保留。 +voice.bridge.unsupported=当前系统不是 Windows,语音桥未启动。 +voice.bridge.stopped=语音桥已停止。 +voice.simulate.windows-prefix=已模拟 Windows 语音( +voice.simulate.windows-suffix=,物理 F18) +voice.simulate.recording=已开始录音(模拟 F18,录制3秒) +voice.simulate.macos=Windows 不执行 macOS 原生语音,也不会合成 F18。 +voice.simulate.unsupported=当前语音预设不支持模拟。 +voice.simulate.no-key=未设置按键,无法模拟。 +voice.simulate.unknown-hid-prefix=无法识别 HID 0x +voice.simulate.unknown-hid-suffix=\ 对应的虚拟键码。 +voice.simulate.done-prefix=已模拟\u0020 +voice.simulate.macro-done=宏按键模拟完成。 +voice.simulate.macro-error=宏按键模拟失败: +voice.simulate.pressed=模拟按键已按下;松开测试按钮时释放。 +voice.simulate.released=模拟按键已释放。 +voice.simulate.unknown-key=无法识别当前按键,无法模拟。 +voice.hook.failed=安装键盘钩子失败;请检查安全软件或以管理员重试。 +voice.platform.unsupported=非 Windows 平台。 +voice.firmware.prefix=物理 F18 桌面语音未启用(固件\u0020 +voice.firmware.suffix=;需要 1.4.8 或更高版本)。 +voice.hook.not-running=语音桥未运行;进入编辑配置或启动应用后会自动安装钩子。 +voice.listening.prefix=正在监听物理 F18;Desktop 负责短按/长按语义(阈值\u0020 +voice.listening.suffix=ms)。 +sync.macro=\ 宏 +sync.keycode=\ 键码 +sync.description=\ 描述 +sync.ai-light=\ AI 状态灯效 +sync.brightness=灯光亮度 +sync.voice-shortcuts=语音键短按/长按快捷键 +sync.save-all=保存全部配置到设备 +sync.progress=保存中 ( +sync.invalid-command=配置命令格式无效:\u0020 +sync.voice-readback-error=语音键配置回读校验失败,请确认固件支持短按/长按功能 +sync.failed=保存失败: +task.mode.saved-local=任务显示模式已保存为本地选择,设备重连后同步。 +task.mode.confirmed=任务显示模式已由设备确认。 +task.mode.change=切换任务显示模式 +task.mode.rollback=任务显示模式同步失败,已回滚到设备确认值: +task.slot.clear=清除任务灯效槽 +task.light.sync=同步任务灯效 +task.mode.invalid=设备返回了无效任务显示模式 +task.mode.reconnected=重连后已通过 0x98 确认任务显示模式。 +task.light.confirm=重连后确认任务灯效 +task.mode.unconfirmed=重连后任务显示模式尚未确认: +task.light.heartbeat=任务灯效心跳 +task.slot.clear-prefix=清空任务灯效槽\u0020 +task.sync.failed=失败;灯效/OLED 状态可能不同步: +hooks.log.started=[System] Hook installation tool started\n +hooks.log.home=[System] User Directory:\u0020 +hooks.log.os=[System] OS:\u0020 +hooks.log.java=[System] Java Version:\u0020 +hooks.log.cleared=[System] Log cleared\n +unit.milliseconds=ms +voice.route.summary=固定 F18(短按=%s,长按=%s,阈值=%d ms) +voice.action.ahakey_voice=AhaKey 本地语音 +voice.action.system_voice=Windows 语音 +voice.action.custom_shortcut=自定义快捷键 +voice.action.none=无操作 + +# Shortcut editing and configuration save +sync.incomplete-shortcut=请为每个自定义语音快捷键选择字母或其他主键,或选择无动作。 +sync.local-saved=语音键设置已保存在本机。按键功能取决于固件支持。 +sync.checking-device=正在检查键盘兼容性… +sync.incompatible-device=键盘不支持所需的配置协议。草稿已保存在本机;未向键盘写入设置。 +inspector.shortcut-hint=快捷键由修饰键和一个主键组成。添加新的主键会替换旧主键。 diff --git a/ahakeyconfig-win-java/src/test/java/com/example/ahakey/firmware/ReleaseArtifactContentsTest.java b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/firmware/ReleaseArtifactContentsTest.java index 7e710811..cdc9b868 100644 --- a/ahakeyconfig-win-java/src/test/java/com/example/ahakey/firmware/ReleaseArtifactContentsTest.java +++ b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/firmware/ReleaseArtifactContentsTest.java @@ -76,6 +76,8 @@ class ReleaseArtifactContentsTest { "com/example/ahakey/sherpa/LibraryLoader.class", "firmware-capabilities.properties", "model_config.properties", + "messages_ru.properties", + "legacy_ru.properties", "wchisp/CONFIG_CH57X59X-sanitized.WCH", "wchisp/baseline.properties", "wchisp/wchisp-runtime.json" diff --git a/ahakeyconfig-win-java/src/test/java/com/example/ahakey/model/StudioStateDirtySnapshotTest.java b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/model/StudioStateDirtySnapshotTest.java index cbc9b1a5..2d3add3f 100644 --- a/ahakeyconfig-win-java/src/test/java/com/example/ahakey/model/StudioStateDirtySnapshotTest.java +++ b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/model/StudioStateDirtySnapshotTest.java @@ -22,6 +22,49 @@ class StudioStateDirtySnapshotTest { @TempDir Path tempDir; + @Test + void deletingDefaultShortcutThenAddingModifiersBeforeDDoesNotRestoreH() { + StudioState state = new StudioState(); + for (int draft : new int[]{0x800, 0, 0x100, 0x300, 0x307}) { + state.setVoiceShortCustomShortcutHid(draft); + assertEquals(draft, state.getVoiceShortCustomShortcutHid()); + StudioState restored = new StudioState(); + restored.loadFromPersisted(state.toPersisted()); + assertEquals(draft, restored.getVoiceShortCustomShortcutHid()); + assertEquals((draft & 0xFF) == 0, restored.hasIncompleteVoiceShortcut()); + assertFalse(com.example.ahakey.platform.voice.VoiceActionRouter + .isValidCustomShortcut(draft & 0xFF00)); + } + assertTrue(state.isDirty(StudioPart.KEY1)); + assertFalse(state.hasDeviceConfigurationChanges()); + assertEquals("Ctrl+Shift+D", com.example.ahakey.platform.voice.VoiceActionRouter + .formatShortcut(state.getVoiceShortCustomShortcutHid())); + } + + @Test + void incompleteInactiveShortcutDoesNotBlockSavingOtherActions() { + StudioState state = new StudioState(); + state.setVoiceLongCustomShortcutHid(0); + assertFalse(state.hasIncompleteVoiceShortcut()); + state.setVoiceActions(VoiceAction.NONE, VoiceAction.CUSTOM_SHORTCUT, 350); + assertTrue(state.hasIncompleteVoiceShortcut()); + state.setVoiceLongCustomShortcutHid(0x3307); + assertFalse(state.hasIncompleteVoiceShortcut()); + assertEquals("Ctrl+Shift+RCtrl+RShift+D", + com.example.ahakey.platform.voice.VoiceActionRouter.formatShortcut(0x3307)); + } + + @Test + void mixedDeviceAndDesktopEditsStillRequireDeviceSave() { + StudioState state = new StudioState(); + state.setVoiceShortCustomShortcutHid(0x307); + assertFalse(state.hasDeviceConfigurationChanges()); + state.markDirty(StudioPart.KEY2); + assertTrue(state.hasDeviceConfigurationChanges()); + assertThrows(IllegalArgumentException.class, + () -> state.setVoiceShortCustomShortcutHid(0x10000)); + } + @Test void editingSameItemDuringSaveRemainsDirty() { StudioState state = new StudioState(); @@ -127,7 +170,7 @@ void missingLoginRequestsAccountUiButMissingLocalModelDoesNot() throws Exception state.setLocalSpeechAvailable(() -> false); assertFalse(state.toggleAhaType(true)); assertFalse(state.shouldOpenAhaTypeAccountForEnable()); - assertTrue(state.ahaTypeStatusProperty().get().contains("本地语音")); + assertTrue(state.ahaTypeStatusProperty().get().equals(com.example.ahakey.util.LanguageManager.localize("本地语音未就绪"))); } @Test diff --git a/ahakeyconfig-win-java/src/test/java/com/example/ahakey/platform/windows/WindowsVoiceRelayServiceTest.java b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/platform/windows/WindowsVoiceRelayServiceTest.java index a69e212a..c7607e09 100644 --- a/ahakeyconfig-win-java/src/test/java/com/example/ahakey/platform/windows/WindowsVoiceRelayServiceTest.java +++ b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/platform/windows/WindowsVoiceRelayServiceTest.java @@ -118,6 +118,26 @@ void customShortcutShortAndLongStartAreOneShotAndReleaseIsNoOp() { VoiceActionRouterTestSupport.CTRL_ALT_A), emitted); } + @Test + void clearingOrPartiallyEditingShortcutNeverRestoresWinHAtRuntime() { + List emitted = new java.util.ArrayList<>(); + relay.setCustomShortcutEmitterForTest(emitted::add); + for (int draft : new int[]{0, 0x100, 0x300, HIDUsage.F18}) { + relay.configureVoiceActions(VoiceAction.CUSTOM_SHORTCUT, + VoiceAction.CUSTOM_SHORTCUT, 350, draft, draft); + relay.dispatchVoiceEventForTest( + new VoiceButtonEvent(VoiceButtonEvent.Type.SHORT_PRESS, 1)); + relay.dispatchVoiceEventForTest( + new VoiceButtonEvent(VoiceButtonEvent.Type.LONG_PRESS_START, 2)); + } + assertTrue(emitted.isEmpty()); + relay.configureVoiceActions(VoiceAction.CUSTOM_SHORTCUT, + VoiceAction.NONE, 350, 0x307, 0); + relay.dispatchVoiceEventForTest( + new VoiceButtonEvent(VoiceButtonEvent.Type.SHORT_PRESS, 3)); + assertEquals(List.of(0x307), emitted); + } + @Test void unavailableAhaKeyVoiceDoesNotFallbackToWindowsVoice() { List emitted = new java.util.ArrayList<>(); diff --git a/ahakeyconfig-win-java/src/test/java/com/example/ahakey/service/DeviceSyncServiceTest.java b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/service/DeviceSyncServiceTest.java index 88c35332..d0d5020d 100644 --- a/ahakeyconfig-win-java/src/test/java/com/example/ahakey/service/DeviceSyncServiceTest.java +++ b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/service/DeviceSyncServiceTest.java @@ -17,6 +17,49 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class DeviceSyncServiceTest { + @Test + void capabilityPreflightRunsOffCallerThreadAndFailureSendsNoWrites() throws Exception { + AckingBleManager ble = new AckingBleManager(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch failed = new CountDownLatch(1); + CountDownLatch completed = new CountDownLatch(1); + Thread caller = Thread.currentThread(); + var handle = DeviceSyncService.writeSequentially(ble, + List.of(new DeviceSyncService.LabeledCommand(AhaKeyProtocol.saveConfig(), "save")), + () -> { + assertFalse(Thread.currentThread() == caller); + entered.countDown(); + release.await(3, TimeUnit.SECONDS); + throw new java.io.IOException("Unsupported firmware contract"); + }, completed::countDown, failed::countDown, message -> { }); + try { + assertTrue(entered.await(2, TimeUnit.SECONDS)); + assertTrue(handle.isRunning()); + assertTrue(ble.events.isEmpty()); + } finally { + release.countDown(); + } + assertTrue(failed.await(2, TimeUnit.SECONDS)); + assertEquals(1, completed.getCount()); + assertTrue(ble.events.isEmpty()); + } + + @Test + void successfulPreflightPrecedesEveryWrite() throws Exception { + AckingBleManager ble = new AckingBleManager(); + CountDownLatch completed = new CountDownLatch(1); + CountDownLatch failed = new CountDownLatch(1); + var command = AhaKeyProtocol.saveConfig(); + DeviceSyncService.writeSequentially(ble, + List.of(new DeviceSyncService.LabeledCommand(command, "save")), + () -> { ble.events.add(-2); return null; }, + completed::countDown, failed::countDown, message -> { }); + assertTrue(completed.await(2, TimeUnit.SECONDS)); + assertEquals(1, failed.getCount()); + assertEquals(List.of(-2, command[2] & 0xFF), ble.events); + } + @Test void productionPlanDoesNotWriteLegacyGlobalVoiceConfig() { StudioState state = new StudioState(); diff --git a/ahakeyconfig-win-java/src/test/java/com/example/ahakey/service/HookDispatchServerTest.java b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/service/HookDispatchServerTest.java index bb60f69b..edbba500 100644 --- a/ahakeyconfig-win-java/src/test/java/com/example/ahakey/service/HookDispatchServerTest.java +++ b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/service/HookDispatchServerTest.java @@ -148,7 +148,12 @@ private BleManager disconnectedBleManager() { @Override public void onDisconnected() {} @Override public void onStatusReceived(DeviceStatus status) {} @Override public void onError(String message) {} - }); + }) { + @Override public void sendCommand(byte[] command) throws java.io.IOException { + // A disconnected test double must never auto-open a real USB keyboard. + throw new java.io.IOException("Test transport disconnected"); + } + }; } private String send(HookDispatchServer server, String event) throws Exception { diff --git a/ahakeyconfig-win-java/src/test/java/com/example/ahakey/service/TaskActivityServiceTest.java b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/service/TaskActivityServiceTest.java index 9a3eda99..9111900e 100644 --- a/ahakeyconfig-win-java/src/test/java/com/example/ahakey/service/TaskActivityServiceTest.java +++ b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/service/TaskActivityServiceTest.java @@ -45,7 +45,9 @@ void taskLightWriteFailureIsReportedToUiCallback() throws Exception { service.awaitIdleForTest(); assertEquals( - "同步任务灯效失败;灯效/OLED 状态可能不同步:slot transport failed", + com.example.ahakey.util.LanguageManager.text("task.light.sync") + + com.example.ahakey.util.LanguageManager.text("task.sync.failed") + + "slot transport failed", ble.reported.get()); } } diff --git a/ahakeyconfig-win-java/src/test/java/com/example/ahakey/util/LanguageManagerTest.java b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/util/LanguageManagerTest.java new file mode 100644 index 00000000..3ccbb411 --- /dev/null +++ b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/util/LanguageManagerTest.java @@ -0,0 +1,108 @@ +package com.example.ahakey.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.regex.Pattern; +import static org.junit.jupiter.api.Assertions.*; + +class LanguageManagerTest { + @TempDir Path home; + + private Properties resource(String name) throws Exception { + Properties p = new Properties(); + try (var reader = new InputStreamReader(Objects.requireNonNull( + getClass().getResourceAsStream("/" + name)), StandardCharsets.UTF_8)) { + p.load(reader); + } + return p; + } + + @Test void russianCoversEveryEnglishKeyAndPreservesFormatArguments() throws Exception { + var english = resource("messages_en.properties"); + var russian = resource("messages_ru.properties"); + var format = Pattern.compile("%(?:\\d+\\$)?[-#+ 0,(]*\\d*(?:\\.\\d+)?[a-zA-Z%]"); + for (String key : english.stringPropertyNames()) { + assertNotNull(russian.getProperty(key), key); + assertFalse(russian.getProperty(key).isBlank(), key); + assertEquals(format.matcher(english.getProperty(key)).results().map(m -> m.group()).toList(), + format.matcher(russian.getProperty(key)).results().map(m -> m.group()).toList(), key); + } + } + + @Test void legacyCatalogPreservesFormattingAndHasNoUntranslatedChinese() throws Exception { + var catalog = resource("legacy_ru.properties"); + var format = Pattern.compile("%(?:\\d+\\$)?[-#+ 0,(]*\\d*(?:\\.\\d+)?[a-zA-Z%]"); + for (String source : catalog.stringPropertyNames()) { + String translated = catalog.getProperty(source); + assertFalse(translated.matches("(?s).*[\\p{IsHan}].*"), source); + assertEquals(format.matcher(source).results().map(m -> m.group()).toList(), + format.matcher(translated).results().map(m -> m.group()).toList(), source); + } + var ru = new LanguageManager("ru-RU"); + assertEquals("Прошивка (CH582)", ru.localizeText("固件管理(CH582)")); + assertEquals(" мин.", ru.localizeText(" 分钟")); + assertEquals("v1.4.8", ru.localizeText("v1.4.8")); + } + + @Test void languageSelectionPersistsWithoutDiscardingOtherPreferences() throws Exception { + String oldHome = System.getProperty("user.home"); + String oldLanguage = System.getProperty("AhaKeySelectedLanguage"); + try { + System.setProperty("user.home", home.toString()); + Path file = home.resolve(".ahakey/preferences.properties"); + Files.createDirectories(file.getParent()); + Files.writeString(file, "existing.setting=keep\n"); + var manager = new LanguageManager("en"); + manager.switchLanguage("ru"); + assertEquals("Подключить", manager.getString("button.connect")); + var prefs = new Properties(); + try (var input = Files.newInputStream(file)) { prefs.load(input); } + assertEquals("ru", prefs.getProperty("AhaKeySelectedLanguage")); + assertEquals("keep", prefs.getProperty("existing.setting")); + manager.switchLanguage("zh"); + assertTrue(manager.isChinese()); + manager.switchLanguage("unknown"); + assertEquals("Connect Device", manager.getString("button.connect")); + } finally { + System.setProperty("user.home", oldHome); + if (oldLanguage == null) System.clearProperty("AhaKeySelectedLanguage"); + else System.setProperty("AhaKeySelectedLanguage", oldLanguage); + } + } + + @Test void localeVariantsAndUnknownLanguagesNormalize() { + assertEquals("ru", LanguageManager.normalizeLanguage("ru_RU")); + assertEquals("zh", LanguageManager.normalizeLanguage("zh-CN")); + assertEquals("en", LanguageManager.normalizeLanguage("de")); + assertEquals("en", LanguageManager.normalizeLanguage(null)); + } + + @Test void backgroundStatusUsesRussianResourcesWithoutChangingTechnicalDetails() { + var ru = new LanguageManager("ru"); + assertEquals("Сохранение (", ru.getString("sync.progress")); + assertEquals("мс", ru.getString("unit.milliseconds")); + assertEquals("F18: короткое нажатие=Windows, долгое нажатие=AhaKey, порог=500 мс", + ru.getString("voice.route.summary", "Windows", "AhaKey", 500)); + assertTrue(ru.getString("voice.firmware.suffix").contains("1.4.8")); + } + + @Test void extractedStatusKeysExistInEveryCatalog() throws Exception { + String[] files = {"platform/windows/WindowsVoiceRelayService.java", + "platform/voice/VoiceActionRouter.java", "service/AgentManager.java", + "service/DeviceSyncService.java", "service/TaskActivityService.java", + "view/TopBar.java", "view/InspectorPane.java"}; + var keyPattern = Pattern.compile("\\btext\\(\"([a-z][a-z0-9.-]+)\"\\s*[,)]"); + for (String language : List.of("en", "ru", "zh")) { + var catalog = resource("messages_" + language + ".properties"); + for (String file : files) { + var keys = keyPattern.matcher(Files.readString( + Path.of("src/main/java/com/example/ahakey", file))); + while (keys.find()) assertNotNull(catalog.getProperty(keys.group(1)), language + ": " + keys.group(1)); + } + } + } +} diff --git a/ahakeyconfig-win-java/src/test/java/com/example/ahakey/view/InspectorPaneVoiceActionChoicesTest.java b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/view/InspectorPaneVoiceActionChoicesTest.java index dff904fb44e..4ff1ad66 100644 --- a/ahakeyconfig-win-java/src/test/java/com/example/ahakey/view/InspectorPaneVoiceActionChoicesTest.java +++ b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/view/InspectorPaneVoiceActionChoicesTest.java @@ -44,8 +44,8 @@ void k1CustomEditorsUseTheSharedShortcutEditorAndStayInShortThenLongOrder() String source = Files.readString(Path.of( "src/main/java/com/example/ahakey/view/InspectorPane.java")); assertTrue(source.replace("\r\n", "\n").contains("createShortcutEditor(\n shortcutModel")); - assertTrue(source.contains("new Label(\"短按动作(一次触发)\")")); - assertTrue(source.contains("new Label(\"长按动作(按住说话)\")")); + assertTrue(source.contains("new Label(localize(\"短按动作(一次触发)\"))")); + assertTrue(source.contains("new Label(localize(\"长按动作(按住说话)\"))")); assertTrue(source.contains("reservePhysicalF18")); assertFalse(source.contains("TextField shortcut ="), "K1 must not have a second text-field-only shortcut editor"); @@ -55,7 +55,7 @@ void k1CustomEditorsUseTheSharedShortcutEditorAndStayInShortThenLongOrder() void mainAhaTypeAndVoiceControlsRemainDiscoverableWithoutModelFlag() throws IOException { String source = Files.readString(Path.of( "src/main/java/com/example/ahakey/view/TopBar.java")); - assertTrue(source.contains("mainRow.getChildren().addAll(ahaTypeToggle, ahaTypeStatus, voiceControlBox)")); + assertTrue(source.contains("new FlowPane(16, 8, infoPills, ahaTypeControls, voiceControlBox, configStatus)")); assertTrue(source.contains("button.start-voice")); } } diff --git a/ahakeyconfig-win-java/src/test/java/com/example/ahakey/view/ShortcutEditorUiSmoke.java b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/view/ShortcutEditorUiSmoke.java new file mode 100644 index 00000000..00d319ae --- /dev/null +++ b/ahakeyconfig-win-java/src/test/java/com/example/ahakey/view/ShortcutEditorUiSmoke.java @@ -0,0 +1,135 @@ +package com.example.ahakey.view; + +import com.example.ahakey.app.StudioController; +import com.example.ahakey.service.AgentManager; +import com.example.ahakey.util.LanguageManager; +import com.example.ahakey.util.StudioStore; +import javafx.application.Platform; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.scene.image.PixelFormat; +import javafx.scene.layout.*; +import java.nio.file.*; +import java.util.List; +import java.util.stream.Stream; +import java.awt.image.BufferedImage; +import javax.imageio.ImageIO; + +/** Explicit Windows/JavaFX smoke run; isolated draft, no device connection. */ +public final class ShortcutEditorUiSmoke { + private static StudioController controller; + private static InspectorPane inspector; + + public static void main(String[] args) throws Exception { + if (!"1".equals(System.getenv("AHAKEY_STUDIO_SIMULATE_BLE"))) { + throw new IllegalStateException("Run with AHAKEY_STUDIO_SIMULATE_BLE=1"); + } + Path output = Path.of(args[0]); + Files.createDirectories(output); + System.setProperty("user.home", Files.createTempDirectory("ahakey-ui-smoke-").toString()); + System.setProperty("ahakey.defaultLanguage", "ru"); + Platform.startup(() -> { + try { + controller = new StudioController(); + controller.shutdown(); + controller.getAgentManager().setBluetoothOwner(AgentManager.BluetoothOwner.AHAKEY_STUDIO); + inspector = new InspectorPane(controller); + var state = controller.getStudioState(); + var editor = editor(); + list(editor).getSelectionModel().select("H (0x0B)"); + button(editor, "inspector.delete").fire(); + check(state.getVoiceShortCustomShortcutHid() == 0x800, "Delete H"); + button(editor(), "inspector.clear").fire(); + check(state.getVoiceShortCustomShortcutHid() == 0, "Clear all"); + for (String key : List.of("Left Shift (0xE1)", "Left Ctrl (0xE0)", "D (0x07)")) add(key); + check(state.getVoiceShortCustomShortcutHid() == 0x307, "Shift Ctrl D"); + add("Right Ctrl (0xE4)"); + check(list(editor()).getItems().containsAll(List.of("Left Ctrl (0xE0)", "Right Ctrl (0xE4)")), + "Both modifier sides visible"); + editor = editor(); + list(editor).getSelectionModel().select("Right Ctrl (0xE4)"); + button(editor, "inspector.delete").fire(); + check(state.getVoiceShortCustomShortcutHid() == 0x307, "Delete right Ctrl only"); + check(!controller.getDeviceStatus().isConnected(), "Save test must be disconnected"); + controller.finishEditingConfiguration(); + check(!controller.hasUnsyncedChanges(), "Local save clears dirty state"); + check(StudioStore.loadOrDefault().voiceShortCustomShortcutHid == 0x307, "Saved draft"); + check(state.syncStatusProperty().get().equals(LanguageManager.text("sync.local-saved")), "Success visible"); + state.setVoiceShortCustomShortcutHid(0x300); + controller.finishEditingConfiguration(); + check(controller.hasUnsyncedChanges(), "Incomplete shortcut stays dirty"); + check(state.syncStatusProperty().get().equals(LanguageManager.text("sync.incomplete-shortcut")), "Validation visible"); + state.setVoiceShortCustomShortcutHid(0x307); + controller.getAgentManager().setBluetoothOwner(AgentManager.BluetoothOwner.AHAKEY_STUDIO); + + TopBar top = new TopBar(controller, controller.getDeviceStatus(), state, controller.getAgentManager()); + BorderPane root = new BorderPane(); + root.getStyleClass().add("root"); + root.setTop(top); + var canvas = new CanvasPane(controller); + canvas.setPrefWidth(480); + ScrollPane center = new ScrollPane(new HBox(canvas, inspector)); + center.setFitToHeight(true); + center.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + canvas.prefWidthProperty().bind(javafx.beans.binding.Bindings.createDoubleBinding( + () -> Math.max(460, center.getViewportBounds().getWidth() * .52), center.viewportBoundsProperty())); + inspector.prefWidthProperty().bind(javafx.beans.binding.Bindings.createDoubleBinding( + () -> Math.max(520, center.getViewportBounds().getWidth() * .48), center.viewportBoundsProperty())); + root.setCenter(center); + root.setBottom(new StatusBar(controller.getDeviceStatus(), state)); + Scene scene = new Scene(root, 1280, 820); + scene.getStylesheets().add(ShortcutEditorUiSmoke.class.getResource("/style.css").toExternalForm()); + for (int width : new int[]{1024, 1280}) { + root.applyCss(); root.resize(width, 820); root.layout(); + check(nodes(top).noneMatch(ScrollPane.class::isInstance), "Toolbar must not scroll horizontally"); + for (Node node : nodes(top).filter(n -> n instanceof Button || n instanceof ToggleButton || n instanceof MenuBar).toList()) { + var bounds = node.localToScene(node.getLayoutBounds()); + check(bounds.getMinX() >= 0 && bounds.getMaxX() <= width, "Toolbar control outside window: " + node); + } + var image = root.snapshot(null, null); + int w = (int)image.getWidth(), h = (int)image.getHeight(); + int[] pixels = new int[w * h]; + image.getPixelReader().getPixels(0, 0, w, h, PixelFormat.getIntArgbInstance(), pixels, 0, w); + BufferedImage bitmap = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + bitmap.setRGB(0, 0, w, h, pixels, 0, w); + ImageIO.write(bitmap, "png", output.resolve("shortcuts-" + width + ".png").toFile()); + } + System.out.println("SHORTCUT_UI_SMOKE=PASS (delete, clear, modifier order, local save, incomplete validation, toolbar 1024/1280)"); + System.exit(0); + } catch (Throwable failure) { + failure.printStackTrace(); + System.exit(1); + } + }); + } + + private static Parent editor() throws Exception { + var method = InspectorPane.class.getDeclaredMethod("createVoiceShortcutEditor", boolean.class); + method.setAccessible(true); + return (Parent)method.invoke(inspector, true); + } + private static Stream nodes(Node node) { + return Stream.concat(Stream.of(node), node instanceof Parent parent + ? parent.getChildrenUnmodifiable().stream().flatMap(ShortcutEditorUiSmoke::nodes) : Stream.empty()); + } + @SuppressWarnings("unchecked") + private static ListView list(Parent editor) { + return (ListView)nodes(editor).filter(ListView.class::isInstance).findFirst().orElseThrow(); + } + private static Button button(Parent editor, String key) { + return nodes(editor).filter(Button.class::isInstance).map(Button.class::cast) + .filter(b -> b.getText().equals(LanguageManager.text(key))).findFirst().orElseThrow(); + } + @SuppressWarnings("unchecked") + private static void add(String key) throws Exception { + Parent editor = editor(); + var selector = (ComboBox)nodes(editor).filter(ComboBox.class::isInstance).findFirst().orElseThrow(); + selector.setValue(key); + button(editor, "inspector.add").fire(); + } + private static void check(boolean value, String message) { + if (!value) throw new AssertionError(message); + } +} diff --git a/docs/windows-shortcut-save-fix.md b/docs/windows-shortcut-save-fix.md new file mode 100644 index 00000000..07d181a9 --- /dev/null +++ b/docs/windows-shortcut-save-fix.md @@ -0,0 +1,82 @@ +# Windows shortcut editing and save regression — 2026-09-19 + +## Confirmed causes and changes + +- `Form1_Load` explicitly set BLE window opacity to `0.8`; it now uses `1.0`. +- The shortcut editor removed the base key from its temporary `KeyConfig`, then + called a `StudioState` setter that rejected modifier-only/empty values. The + exception interrupted rebuilding; subsequent rebuilds restored the previous + persisted key. Draft validation now accepts these intermediate values, including + after reload. Final save still requires a complete shortcut for active custom + actions. F18 remains reserved. Runtime normalization no longer silently replaces + an incomplete draft with Win+H; it emits nothing. +- The list hid left modifiers whenever the matching right modifier was present. + Both sides are now displayed independently. An explicit Clear button removes all + keys. The translated hint explains the existing protocol constraint: modifiers + plus one base key, with a new base key replacing the old one. +- Save synchronously selected a transport and probed the firmware contract on the + JavaFX thread, before setting the busy indicator. These potentially blocking + operations now run in the background save transaction, before any writes. Failed + validation sends no commands and retains dirty state. ACK/readback checks and + recovery safety remain unchanged. +- K1 desktop actions were unnecessarily gated by the device configuration contract + even though the production write plan excludes K1. A desktop-only edit now saves + locally, including while disconnected, and acknowledges success only after the + store succeeds. Mixed K1/device edits still require device validation and ACKs. +- The top toolbar forced every group into one scrollable row. Primary actions now + stay in the first row; secondary groups wrap. This is a limited toolbar change, + not a redesign of the keyboard canvas or the central pane's existing scrolling. + +Local saving does not add firmware capabilities. The previously observed legacy +firmware lacks the required `0x98`/`0x9F` payloads. Physical F18 routing and device +configuration retain their existing compatibility requirements; no firmware was +flashed or included in this build. + +## Validation + +- Combined checkout (Russian UI plus physical-status and WCHISP fixes): **314 Java + tests, zero failures/errors/skips**, Maven package and release contents PASS. +- Added model regressions for delete H -> modifiers -> clear -> Shift/Ctrl -> D, + draft reload, incomplete active/inactive actions, and mixed local/device dirtiness. +- Added runtime regression: incomplete/F18 custom shortcuts emit neither Win+H nor + any other keys. Added background-preflight tests: no writes before successful + validation, and no completion/commands when validation fails. +- Explicit JavaFX smoke test fires the actual Add/Delete/Clear controls, saves a + local Ctrl+Shift+D while disconnected, checks the persisted draft and incomplete + save feedback, and renders the toolbar at widths 1024 and 1280 with bounds checks. + It uses a temporary user home and simulation; no device connection is opened. + The installed app occupied Hook port 8765, so the smoke instance logged a bind + failure before shutting down its services; editor/save checks still passed. +- MSBuild Release, BLE localization test and physical-status loopback test PASS. +- Portable EXE/ZIP and WiX setup built; the installed/running app was not replaced. + End-to-end physical shortcut execution is not part of this software-only check. + +## Reproduction commands (PowerShell, repository root) + +```powershell +$env:JAVA_HOME = 'C:\Program Files\Eclipse Adoptium\jdk-17.0.20.101-hotspot' +& 'C:\Tools\apache-maven-3.9.16\bin\mvn.cmd' -f ahakeyconfig-win-java/pom.xml package +& 'C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe' BLE_tcp_bridge/BLE_tcp_driver.csproj /restore /p:Configuration=Release /verbosity:minimal +& ./BLE_tcp_bridge/tests/Test-Localization.ps1 +& ./BLE_tcp_bridge/tests/Test-PhysicalStatus.ps1 +$env:AHAKEY_STUDIO_SIMULATE_BLE = '1' +& 'C:\Program Files\AhaKeyStudio\runtime\bin\java.exe' '-Dfile.encoding=UTF-8' -cp 'ahakeyconfig-win-java/target/test-classes;ahakeyconfig-win-java/target/classes;ahakeyconfig-win-java/target/lib/*' com.example.ahakey.view.ShortcutEditorUiSmoke ./ahakeyconfig-win-java/target/shortcut-preview +Remove-Item Env:AHAKEY_STUDIO_SIMULATE_BLE +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ./ahakeyconfig-win-java/build-local-windows.ps1 -RuntimeImage 'C:\Program Files\AhaKeyStudio\runtime' -IconPath 'C:\Program Files\AhaKeyStudio\AhaKeyStudio.ico' -JdkHome 'C:\Program Files\Eclipse Adoptium\jdk-17.0.20.101-hotspot' -WixBin 'D:\dev\ahakey\desktop\.toolchain\wix314' -OutputRoot 'D:\dev\ahakey\builds\shortcut-ui-fix' +``` + +Installer correction: the initial package reused 1.5.3 and Windows Installer +returned 1638 when replacing the previously installed 1.5.3 package. Append +`-PackageVersion 1.5.4` to the build command above. This sets the package and +display version while retaining the source JAR filename. Future replacement +builds need a newer three-component package version; MSI ignores a fourth field. + +Read-only regression: `Test-LocalInstallerUpgrade.ps1 -PreviousMsi +-NewMsi ` checks actual MSI version/ProductCode/UpgradeCode and the upgrade +table. The 1.5.3 -> 1.5.4 packages pass; checking the old package against itself +is rejected. The rebuilt EXE was installed over 1.5.3 with exit code 0, registry +version 1.5.4, matching installed JAR/bridge hashes, and unchanged user draft and +language preferences. No manual uninstall was needed. + +The local package remains unsigned, without speech-model/native speech assets, +firmware, or vendor flasher. diff --git a/docs/windows-stabilization-plan.md b/docs/windows-stabilization-plan.md index e89f385b..056db03f 100644 --- a/docs/windows-stabilization-plan.md +++ b/docs/windows-stabilization-plan.md @@ -83,6 +83,25 @@ HEX 或 provenance。 ## 1. 生产路径与安全不变式 +2026-09-19 installer correction: rebuilding local MSI/EXE version 1.5.3 reused +the installed ProductCode and Windows Installer returned 1638 before showing UI. +`build-local-windows.ps1 -PackageVersion 1.5.4` separates package/app-display +version from the source JAR version. A read-only MSI upgrade check verifies the +version increase, changed ProductCode and stable upgrade family. The new EXE +successfully upgraded the installed 1.5.3 to 1.5.4 with exit 0; user draft and +language preference hashes remained unchanged and installed JAR/bridge hashes +matched the tested inputs. No firmware or Java behavior changes in this correction. + +2026-09-19 Windows editor follow-up: [shortcut/save regression report](windows-shortcut-save-fix.md). +Shortcut drafts now permit empty/modifier-only editing states without restoring +Win+H. Executable shortcuts and save validation remain strict. K1-only edits save +locally; device configuration capability probes now run inside the background +save transaction. Firmware requirements and physical-status freshness are unchanged. +BLE UI is opaque and the Studio toolbar wraps secondary groups without horizontal +scrolling. Combined validation: 314 Java tests, JavaFX editor/save smoke test, +MSBuild Release, both bridge regressions and EXE packaging passed. Physical shortcut +execution on legacy firmware is not claimed; no firmware was flashed. + 生产客户端是 `ahakeyconfig-win-java`: ```text @@ -846,6 +865,84 @@ VoiceActionRouter、SpeechService、VoiceInputManager、BLE/GATT、Firmware、WC 烧录、USB/BLE、麦克风/F18 端到端验证。状态区分如下:代码/脚本完成、自动测试完成、 app-image 启动验证完成;正式签名、干净安装和真机验证待发布环境完成。 +### Russian interface and local Windows packaging (2026-09-19) + +LanguageManager supports ru/ru-RU/ru_RU, UTF-8 catalogs, a persisted +Russian/English/Chinese choice and the optional `ahakey.defaultLanguage` +distribution default. Existing saved preferences take precedence. The Russian +catalogs cover 242 resource keys and 368 legacy display literals across the +main UI, settings, OLED/lighting, maintenance/update dialogs and tray. +Protocol identifiers and the keyboard's stored display text are unchanged. +Language changes request a restart. TopBar keeps Configure and the language +menu visible outside the horizontal scroller for longer translated labels. + +`build-local-windows.ps1` validates the JAR/BLE driver and packages a supplied +JavaFX runtime into a portable app, ZIP and optional WiX EXE installer. Each +run uses a new timestamped output directory. The local distribution defaults +to Russian and retains installer directory ownership/upgrade safeguards. +Russian WixUI dialogs use code page 1251; JDK 17 auxiliary installer messages +retain English fallback. The formal release pipeline is unchanged: this +unsigned local target does not include speech models/native speech assets, +firmware or WCHISP, and does not bypass the formal release asset gates. + +Build from the repository root with JDK 17, Maven, MSBuild and WiX 3 on PATH: + +```powershell +mvn -f .\ahakeyconfig-win-java\pom.xml package +MSBuild .\BLE_tcp_bridge\BLE_tcp_driver.csproj /restore /p:Configuration=Release +.\ahakeyconfig-win-java\build-local-windows.ps1 ` + -RuntimeImage 'C:\Program Files\AhaKeyStudio\runtime' ` + -IconPath 'C:\Program Files\AhaKeyStudio\AhaKeyStudio.ico' ` + -JdkHome $env:JAVA_HOME -WixBin 'C:\tools\wix314' +``` + +RuntimeImage must contain JavaFX; paths above are examples, not downloaded +assets. Omit WixBin to build only the portable app and ZIP. Save the entire +portable folder, including its app/runtime subdirectories. + +Tests cover Russian key coverage, format placeholders, locale normalization, +preference persistence and packaged resource inclusion. Existing source-text +assertions normalize Windows CRLF, and the disconnected Hook test double +refuses commands rather than opening real USB hardware. Seven JavaFX UI +snapshots rendered; key, lighting and OLED screens were visually inspected. +Local jpackage builds produced both EXE variants and the packaged launcher +initialized language `ru`. Missing speech models correctly left local speech +unavailable. This validation does not certify firmware compatibility or an +end-to-end installer upgrade/uninstall cycle. + +Independent PR validation on the upstream base: Maven package passed all +304 tests with zero failures/errors/skips and `RELEASE_ARTIFACT_CONTENTS=OK`. +MSBuild restore/Release and both jpackage outputs also passed in that checkout, +including invocation from Windows PowerShell 5.1. The packaging script uses +UTF-8 BOM for its translated literals and resolves its default output directory +after parameter binding for compatibility with that shell. + +Follow-up: the BLE bridge now embeds en/ru/zh RESX catalogs in its main EXE. +Language selection priority is `--language=CODE`, the shared read-only Studio +preference, then the Windows UI language; unsupported locales fall back to +English. Studio passes its selected language when launching the bridge. +An already running bridge adopts the language on its next restart. The stable +window title used for process adoption and raw GATT diagnostic identifiers stay +unchanged. Flow layout accommodates longer labels. Display messages for +connection/TCP status, device readings, context menus and startup errors are +localized; wire data and vendor exception details are not translated. + +Another 65 Java resource keys cover voice-relay status, F18 action summaries, +Hook notices, synchronization progress/errors and milliseconds. These use +stable keys in all three messages catalogs. Existing legacy catalogs remain; +low-level vendor/tool logs and the remaining firmware exception text require +a separate pass rather than changing firmware error handling here. +Tests verify bridge catalog coverage/placeholders and language precedence, +and Java tests verify extracted key coverage and formatted Russian status. +`BLE_tcp_bridge/tests/Test-Localization.ps1` tests the compiled Release EXE; +optional `-PreviewPath` renders the form without BLE/TCP startup side effects. +Follow-up verification: independent Java package passed 306 tests; bridge +Release build and localization tests passed. A local integration with the +separate physical-status and WCHISP fixes passed 308 Java tests, both bridge +test programs, and portable/installer packaging. Integrating the physical +status fix requires retaining the localized cache log while deleting its +early return, so the same physical frame continues to the TCP broadcast. + ## 29. WCHISP terminal success precedence (2026-09-17) 修复 Windows 烧录结果判定:`WchIspResultParser.parseFlash()` 现在优先使用官方 @@ -1256,6 +1353,15 @@ AhaType 顶部开关在本地语音已就绪但账号未登录或 token 过期 上述自动结果不代替真实环境验证。USB/BLE 热插拔、真实审批状态、真实账号、微信沙箱/实付、 兑换码、麦克风和最终键盘注入仍需 Windows 真机/真实服务人工验证,当前均保持 **pending**。 +## PR #68 conflict resolution against eternal-dev (2026-09-20) + +Merged upstream `a878f63` without replacing the primary workspace. +Retained upstream verified-USB preference, real AhaType/account behavior and +prepared WCHISP session/completion rules, together with Russian UI and local +shortcut/save fixes. Updated maintenance messages do not claim device readback. +Maven package: 354 tests, zero failures/errors/skips, release contents OK. +MSBuild Release and BLE localization regression passed. + ## BLE legacy physical-status forwarding (2026-09-19) `TcpServer.OnBleNotify` cached the legacy 13-byte `AA BB 00 ... CC DD` @@ -1314,7 +1420,7 @@ The C# loopback regression passed and MSBuild `/restore /p:Configuration=Release rebuilt the normal Release bridge successfully. No hardware interaction was needed for these PR preparation checks. -## PR conflict resolution against eternal-dev (2026-09-20) +## PR #66 conflict resolution against eternal-dev (2026-09-20) Merged upstream `a878f63` without replacing the primary workspace. The physical-status log now shares upstream acceptedSequence and retains @@ -1324,3 +1430,30 @@ MSBuild Release and the bridge physical-status loopback regression passed. Source-inspection tests normalize CRLF before multiline matching; test runs use separate temporary directories to avoid shared GIF extraction conflicts. No application installation, firmware flashing, or physical-device test was performed. + +## PR #68 combined validation after PR #66 merge (2026-09-21) + +Merged upstream `eternal-dev` at `9b1fb291f82533df61c93e9f0d39a7e815b0ccca` +into `feat/windows-russian-ui` in its dedicated checkout. Resolved all three +conflicts: keep localized bridge status logging while forwarding the original +physical response (no early return), retain localized inspector assertions and +CRLF normalization, and preserve both branches' stabilization evidence. + +Combined validation on the merged tree: + +- Maven `clean package`, using a checkout-specific `java.io.tmpdir`: **356 tests, + 0 failures, 0 errors, 0 skipped**, `RELEASE_ARTIFACT_CONTENTS=OK`. +- MSBuild `/restore /t:Rebuild /p:Configuration=Release /p:Platform=AnyCPU`: PASS. +- `BLE_tcp_bridge/tests/Test-PhysicalStatus.ps1`: PASS (query routing, legacy + and extended forwarding, cached metadata, command responses). +- `BLE_tcp_bridge/tests/Test-Localization.ps1`: PASS (catalogs, placeholders, + language preference priority, fallback). +- JavaFX `ShortcutEditorUiSmoke`, with `AHAKEY_STUDIO_SIMULATE_BLE=1` and an + isolated draft: PASS (delete, clear, modifier order, local save, incomplete + shortcut validation, toolbar at 1024/1280). The hook listener logged that + port 8765 was already occupied; this UI smoke does not validate hook serving. + The existing listener was left untouched. +- `git diff --check`: PASS. + +The primary checkout and running applications were preserved. No installation, +firmware flashing, or physical-device testing was performed for this merge.