From 7ed43baaa6be88a55952a3411575922c8d883a08 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Mon, 06 Jul 2026 15:47:42 +0800
Subject: [PATCH] feat: default right modifier hotkey
---
C1.source/privatevoice.src/frontend/src/lib/stores/config.ts | 2
C1.source/privatevoice.src/internal/hotkey/hotkey_test.go | 26 +++
C1.source/privatevoice.src/app.go | 4
C1.source/privatevoice.src/internal/hotkey/hotkey.go | 11 +
C1.source/privatevoice.src/internal/config/config.go | 30 +++
K2.项目管理/R222-5-开发自查.md | 165 ++++++++++++++++++++
C1.source/privatevoice.src/frontend/src/lib/stores/indicator.ts | 7
CODEGRAPH.md | 3
C1.source/privatevoice.src/internal/config/config_test.go | 150 ++++++++++++++++++
C1.source/privatevoice.src/services/engine_service.go | 2
K2.项目管理/BR-HOTKEY-DEFAULTS-分支创建记录.md | 56 +++++++
C1.source/privatevoice.src/internal/engine/engine.go | 4
C1.source/privatevoice.src/frontend/src/lib/i18n/zh.json | 4
C1.source/privatevoice.src/frontend/src/lib/i18n/en.json | 4
14 files changed, 455 insertions(+), 13 deletions(-)
diff --git a/C1.source/privatevoice.src/app.go b/C1.source/privatevoice.src/app.go
index 9acb5f6..908337b 100755
--- a/C1.source/privatevoice.src/app.go
+++ b/C1.source/privatevoice.src/app.go
@@ -126,7 +126,7 @@
cfg, err := config.Load()
if err != nil {
logger.Error("Failed to load config: %v", err)
- cfg = config.Default()
+ cfg = config.ExistingConfigFallback()
}
app.cfg = cfg
@@ -1244,7 +1244,7 @@
logger.Error("Failed to load config for engine init: %v", err)
cfg = a.cfg
if cfg == nil {
- cfg = config.Default()
+ cfg = config.ExistingConfigFallback()
}
}
diff --git a/C1.source/privatevoice.src/frontend/src/lib/i18n/en.json b/C1.source/privatevoice.src/frontend/src/lib/i18n/en.json
index 8a20dac..6632faf 100755
--- a/C1.source/privatevoice.src/frontend/src/lib/i18n/en.json
+++ b/C1.source/privatevoice.src/frontend/src/lib/i18n/en.json
@@ -120,7 +120,7 @@
},
"inputMonitoring": {
"title": "Input Monitoring",
- "desc": "Listens for the global right Alt hotkey while other apps are in front."
+ "desc": "Listens for the configured global hotkey while other apps are in front."
},
"backgroundActivity": {
"title": "Background Activity / Login Item",
@@ -138,7 +138,7 @@
"hintHoldDesc": "Hold the hotkey to record, release to recognize and type. Best for quick phrases.",
"hintFree": "Free Talk",
"hintFreeDesc": "Tap the hotkey once to start, speak freely, tap again to stop and type. Best for long dictation.",
- "hintCombo": "Keyboard shortcuts (e.g. Ctrl+C) won't trigger recording."
+ "hintCombo": "Keyboard shortcuts (e.g. Ctrl+C) won't trigger recording. If the right Command / Windows key is unavailable, choose another hotkey with Change."
},
"about": {
"version": "Version",
diff --git a/C1.source/privatevoice.src/frontend/src/lib/i18n/zh.json b/C1.source/privatevoice.src/frontend/src/lib/i18n/zh.json
index de30cea..8181121 100755
--- a/C1.source/privatevoice.src/frontend/src/lib/i18n/zh.json
+++ b/C1.source/privatevoice.src/frontend/src/lib/i18n/zh.json
@@ -120,7 +120,7 @@
},
"inputMonitoring": {
"title": "输入监控",
- "desc": "用于在私语输入法窗口不可见、其他 App 正在前台时监听右 Alt 全局热键。"
+ "desc": "用于在私语输入法窗口不可见、其他 App 正在前台时监听当前全局热键。"
},
"backgroundActivity": {
"title": "后台活动 / 登录项",
@@ -138,7 +138,7 @@
"hintHoldDesc": "长按热键开始录音,松开即停止识别并输入文字,适合快速短语。",
"hintFree": "自由说话",
"hintFreeDesc": "短按一下热键开始,连续说话,再短按一下停止并输入文字,适合长段口述。",
- "hintCombo": "使用组合键(如 Ctrl+C)不会触发录音。"
+ "hintCombo": "使用组合键(如 Ctrl+C)不会触发录音。若右侧 Command / Windows 键不可用,请点击修改并选择其他热键。"
},
"about": {
"version": "版本",
diff --git a/C1.source/privatevoice.src/frontend/src/lib/stores/config.ts b/C1.source/privatevoice.src/frontend/src/lib/stores/config.ts
index c9a6e4e..1488971 100755
--- a/C1.source/privatevoice.src/frontend/src/lib/stores/config.ts
+++ b/C1.source/privatevoice.src/frontend/src/lib/stores/config.ts
@@ -7,7 +7,7 @@
export const soundFeedback = writable<boolean>(true);
export const copyToClipboard = writable<boolean>(true);
export const startAtLogin = writable<boolean>(false);
-export const hotkeyVK = writable<number>(0xa5);
+export const hotkeyVK = writable<number>(0x5c);
export const hotkeyMode = writable<"tap" | "hold">("hold");
export const languageMode = writable<"auto" | "manual">("auto");
export const languageID = writable<LanguageID>("zh-CN");
diff --git a/C1.source/privatevoice.src/frontend/src/lib/stores/indicator.ts b/C1.source/privatevoice.src/frontend/src/lib/stores/indicator.ts
index 7cc6950..4c70650 100755
--- a/C1.source/privatevoice.src/frontend/src/lib/stores/indicator.ts
+++ b/C1.source/privatevoice.src/frontend/src/lib/stores/indicator.ts
@@ -15,4 +15,9 @@
export const indicatorVisible = writable<boolean>(false);
export const indicatorStatus = writable<IndicatorStatus>("hidden");
export const indicatorVolume = writable<number>(0);
-export const hotkeyName = writable<string>("Ctrl");
+const defaultHotkeyName =
+ typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform)
+ ? "R-⌘"
+ : "R-Win";
+
+export const hotkeyName = writable<string>(defaultHotkeyName);
diff --git a/C1.source/privatevoice.src/internal/config/config.go b/C1.source/privatevoice.src/internal/config/config.go
index e3e15c8..25bbffe 100755
--- a/C1.source/privatevoice.src/internal/config/config.go
+++ b/C1.source/privatevoice.src/internal/config/config.go
@@ -3,12 +3,15 @@
import (
"encoding/json"
"os"
+ "runtime"
"voicesnap/internal/logger"
"voicesnap/internal/model"
"voicesnap/internal/paths"
)
const (
+ LegacyDefaultHotkeyVK = 0xA5
+ RightModifierDefaultVK = 0x5C
HotkeyModeTap = "tap"
HotkeyModeHold = "hold"
LanguageModeAuto = "auto"
@@ -38,8 +41,22 @@
// Default returns a default configuration.
func Default() *Config {
+ return defaultWithHotkey(defaultHotkeyVKForNewConfig(runtime.GOOS))
+}
+
+func legacyDefaultForExistingConfig() *Config {
+ return defaultWithHotkey(LegacyDefaultHotkeyVK)
+}
+
+// ExistingConfigFallback returns a legacy-safe fallback for cases where a
+// config file exists but cannot be read or parsed.
+func ExistingConfigFallback() *Config {
+ return legacyDefaultForExistingConfig()
+}
+
+func defaultWithHotkey(hotkeyVK int) *Config {
return &Config{
- HotkeyVK: 0xA5, // Right Alt
+ HotkeyVK: hotkeyVK,
HotkeyMode: HotkeyModeHold,
AutoHide: true,
SoundFeedback: true,
@@ -51,6 +68,15 @@
LanguageID: model.DefaultLanguageID,
ModelDownloadUrl: "http://www.maikami.com/voicesnap/sensevoice.zip",
FallbackModelDownloadUrl: "https://modelscope.cn/models/sherpa-onnx/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2",
+ }
+}
+
+func defaultHotkeyVKForNewConfig(goos string) int {
+ switch goos {
+ case "darwin", "windows":
+ return RightModifierDefaultVK
+ default:
+ return LegacyDefaultHotkeyVK
}
}
@@ -71,7 +97,7 @@
return nil, err
}
- cfg := Default()
+ cfg := legacyDefaultForExistingConfig()
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err
}
diff --git a/C1.source/privatevoice.src/internal/config/config_test.go b/C1.source/privatevoice.src/internal/config/config_test.go
index c04af51..c6d94aa 100644
--- a/C1.source/privatevoice.src/internal/config/config_test.go
+++ b/C1.source/privatevoice.src/internal/config/config_test.go
@@ -1,9 +1,14 @@
package config
import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "runtime"
"testing"
"voicesnap/internal/model"
+ "voicesnap/internal/paths"
)
func TestDefaultLanguageConfig(t *testing.T) {
@@ -19,6 +24,117 @@
}
if cfg.ModelSelectionMode != ModelSelectionModeAuto {
t.Fatalf("model selection mode = %q, want %q", cfg.ModelSelectionMode, ModelSelectionModeAuto)
+ }
+}
+
+func TestDefaultHotkeyForNewConfig(t *testing.T) {
+ tests := []struct {
+ goos string
+ want int
+ }{
+ {goos: "darwin", want: RightModifierDefaultVK},
+ {goos: "windows", want: RightModifierDefaultVK},
+ {goos: "linux", want: LegacyDefaultHotkeyVK},
+ }
+
+ for _, tt := range tests {
+ if got := defaultHotkeyVKForNewConfig(tt.goos); got != tt.want {
+ t.Fatalf("defaultHotkeyVKForNewConfig(%q) = 0x%X, want 0x%X", tt.goos, got, tt.want)
+ }
+ }
+}
+
+func TestLoadCreatesFirstConfigWithNewPlatformDefault(t *testing.T) {
+ withIsolatedConfigHome(t)
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ if got, want := cfg.HotkeyVK, defaultHotkeyVKForNewConfig(runtime.GOOS); got != want {
+ t.Fatalf("HotkeyVK = 0x%X, want 0x%X", got, want)
+ }
+ if cfg.HotkeyMode != HotkeyModeHold {
+ t.Fatalf("HotkeyMode = %q, want %q", cfg.HotkeyMode, HotkeyModeHold)
+ }
+
+ raw := readConfigJSON(t)
+ if got, ok := raw["HotkeyVK"].(float64); !ok || int(got) != cfg.HotkeyVK {
+ t.Fatalf("saved HotkeyVK = %#v, want %d", raw["HotkeyVK"], cfg.HotkeyVK)
+ }
+ if got := raw["HotkeyMode"]; got != HotkeyModeHold {
+ t.Fatalf("saved HotkeyMode = %#v, want %q", got, HotkeyModeHold)
+ }
+}
+
+func TestLoadExistingConfigPreservesHotkeyAndMode(t *testing.T) {
+ withIsolatedConfigHome(t)
+ writeConfigJSON(t, map[string]interface{}{
+ "HotkeyVK": 0x41,
+ "HotkeyMode": HotkeyModeTap,
+ })
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ if cfg.HotkeyVK != 0x41 {
+ t.Fatalf("HotkeyVK = 0x%X, want 0x41", cfg.HotkeyVK)
+ }
+ if cfg.HotkeyMode != HotkeyModeTap {
+ t.Fatalf("HotkeyMode = %q, want %q", cfg.HotkeyMode, HotkeyModeTap)
+ }
+}
+
+func TestLoadExistingConfigMissingHotkeyUsesLegacyFallback(t *testing.T) {
+ withIsolatedConfigHome(t)
+ writeConfigJSON(t, map[string]interface{}{
+ "HotkeyMode": HotkeyModeTap,
+ })
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ if cfg.HotkeyVK != LegacyDefaultHotkeyVK {
+ t.Fatalf("HotkeyVK = 0x%X, want legacy 0x%X", cfg.HotkeyVK, LegacyDefaultHotkeyVK)
+ }
+ if cfg.HotkeyVK == RightModifierDefaultVK {
+ t.Fatalf("existing config missing HotkeyVK must not inherit new default 0x%X", RightModifierDefaultVK)
+ }
+ if cfg.HotkeyMode != HotkeyModeTap {
+ t.Fatalf("HotkeyMode = %q, want %q", cfg.HotkeyMode, HotkeyModeTap)
+ }
+}
+
+func TestLoadExistingConfigMissingModeKeepsLegacyHoldFallback(t *testing.T) {
+ withIsolatedConfigHome(t)
+ writeConfigJSON(t, map[string]interface{}{
+ "HotkeyVK": 0x41,
+ })
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ if cfg.HotkeyVK != 0x41 {
+ t.Fatalf("HotkeyVK = 0x%X, want 0x41", cfg.HotkeyVK)
+ }
+ if cfg.HotkeyMode != HotkeyModeHold {
+ t.Fatalf("HotkeyMode = %q, want %q", cfg.HotkeyMode, HotkeyModeHold)
+ }
+}
+
+func TestExistingConfigFallbackUsesLegacyHotkey(t *testing.T) {
+ cfg := ExistingConfigFallback()
+ if cfg.HotkeyVK != LegacyDefaultHotkeyVK {
+ t.Fatalf("HotkeyVK = 0x%X, want legacy 0x%X", cfg.HotkeyVK, LegacyDefaultHotkeyVK)
+ }
+ if cfg.HotkeyVK == RightModifierDefaultVK {
+ t.Fatalf("existing config fallback must not inherit new default 0x%X", RightModifierDefaultVK)
+ }
+ if cfg.HotkeyMode != HotkeyModeHold {
+ t.Fatalf("HotkeyMode = %q, want %q", cfg.HotkeyMode, HotkeyModeHold)
}
}
@@ -39,3 +155,37 @@
t.Fatalf("bad mode normalized to %q, want %q", got, ModelSelectionModeAuto)
}
}
+
+func withIsolatedConfigHome(t *testing.T) {
+ t.Helper()
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
+}
+
+func writeConfigJSON(t *testing.T, value map[string]interface{}) {
+ t.Helper()
+ if err := paths.Ensure(); err != nil {
+ t.Fatalf("paths.Ensure() error = %v", err)
+ }
+ data, err := json.Marshal(value)
+ if err != nil {
+ t.Fatalf("json.Marshal() error = %v", err)
+ }
+ if err := os.WriteFile(paths.File("config.json"), data, 0644); err != nil {
+ t.Fatalf("write config error = %v", err)
+ }
+}
+
+func readConfigJSON(t *testing.T) map[string]interface{} {
+ t.Helper()
+ data, err := os.ReadFile(paths.File("config.json"))
+ if err != nil {
+ t.Fatalf("read config error = %v", err)
+ }
+ var raw map[string]interface{}
+ if err := json.Unmarshal(data, &raw); err != nil {
+ t.Fatalf("json.Unmarshal() error = %v", err)
+ }
+ return raw
+}
diff --git a/C1.source/privatevoice.src/internal/engine/engine.go b/C1.source/privatevoice.src/internal/engine/engine.go
index 037efc5..a3eacbe 100755
--- a/C1.source/privatevoice.src/internal/engine/engine.go
+++ b/C1.source/privatevoice.src/internal/engine/engine.go
@@ -96,7 +96,7 @@
cfg, err := config.Load()
if err != nil {
logger.Error("Failed to load config for model selection: %v", err)
- cfg = config.Default()
+ cfg = config.ExistingConfigFallback()
}
current := modelselection.Resolve(cfg, language.NewSystemDetector())
return NewWithModelID(current.ModelID)
@@ -142,7 +142,7 @@
func resolveCurrentModel() (model.ResolvedModel, error) {
cfg, err := config.Load()
if err != nil {
- cfg = config.Default()
+ cfg = config.ExistingConfigFallback()
}
current := modelselection.Resolve(cfg, language.NewSystemDetector())
return model.ResolveModel(current.ModelID)
diff --git a/C1.source/privatevoice.src/internal/hotkey/hotkey.go b/C1.source/privatevoice.src/internal/hotkey/hotkey.go
index 32f6269..a3d187c 100755
--- a/C1.source/privatevoice.src/internal/hotkey/hotkey.go
+++ b/C1.source/privatevoice.src/internal/hotkey/hotkey.go
@@ -2,6 +2,7 @@
import (
"fmt"
+ "runtime"
"time"
)
@@ -24,6 +25,10 @@
// GetKeyName returns the display name for a virtual key code.
func GetKeyName(vk int) string {
+ return GetKeyNameForPlatform(vk, runtime.GOOS)
+}
+
+func GetKeyNameForPlatform(vk int, goos string) string {
switch vk {
case 0x11:
return "Ctrl"
@@ -52,8 +57,14 @@
case 0x0D:
return "Enter"
case 0x5B:
+ if goos == "darwin" {
+ return "L-⌘"
+ }
return "L-Win"
case 0x5C:
+ if goos == "darwin" {
+ return "R-⌘"
+ }
return "R-Win"
case 0x1B:
return "Esc"
diff --git a/C1.source/privatevoice.src/internal/hotkey/hotkey_test.go b/C1.source/privatevoice.src/internal/hotkey/hotkey_test.go
new file mode 100644
index 0000000..b7bc55d
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/hotkey/hotkey_test.go
@@ -0,0 +1,26 @@
+package hotkey
+
+import "testing"
+
+func TestGetKeyNameForPlatformRightModifier(t *testing.T) {
+ tests := []struct {
+ name string
+ goos string
+ vk int
+ want string
+ }{
+ {name: "darwin left command", goos: "darwin", vk: 0x5B, want: "L-⌘"},
+ {name: "darwin right command", goos: "darwin", vk: 0x5C, want: "R-⌘"},
+ {name: "windows left windows", goos: "windows", vk: 0x5B, want: "L-Win"},
+ {name: "windows right windows", goos: "windows", vk: 0x5C, want: "R-Win"},
+ {name: "linux right windows fallback", goos: "linux", vk: 0x5C, want: "R-Win"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := GetKeyNameForPlatform(tt.vk, tt.goos); got != tt.want {
+ t.Fatalf("GetKeyNameForPlatform(0x%X, %q) = %q, want %q", tt.vk, tt.goos, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/C1.source/privatevoice.src/services/engine_service.go b/C1.source/privatevoice.src/services/engine_service.go
index 2a93b00..666724e 100755
--- a/C1.source/privatevoice.src/services/engine_service.go
+++ b/C1.source/privatevoice.src/services/engine_service.go
@@ -388,7 +388,7 @@
if s.cfg == nil {
cfg, err := config.Load()
if err != nil {
- cfg = config.Default()
+ cfg = config.ExistingConfigFallback()
}
s.cfg = cfg
}
diff --git a/CODEGRAPH.md b/CODEGRAPH.md
index 2ccf054..5ddd46b 100644
--- a/CODEGRAPH.md
+++ b/CODEGRAPH.md
@@ -47,6 +47,9 @@
- Hotkey:
- macOS: `C1.source/privatevoice.src/internal/hotkey/hotkey_darwin.go`
- Windows: `C1.source/privatevoice.src/internal/hotkey/hotkey_windows.go`
+ - Shared display: `C1.source/privatevoice.src/internal/hotkey/hotkey.go`; R222 maps `0x5C` to macOS `R-⌘` and Windows `R-Win`.
+- Config defaults:
+ - `C1.source/privatevoice.src/internal/config/config.go`: first generated config uses platform default hotkey (`0x5C` on macOS / Windows) and `hold`; existing config files load through a legacy-safe fallback so missing `HotkeyVK` does not inherit the R222 new default.
- Text input:
- macOS: `C1.source/privatevoice.src/internal/input/paste_darwin.go`
- Windows: `C1.source/privatevoice.src/internal/input/paste_windows.go`
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/BR-HOTKEY-DEFAULTS-\345\210\206\346\224\257\345\210\233\345\273\272\350\256\260\345\275\225.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/BR-HOTKEY-DEFAULTS-\345\210\206\346\224\257\345\210\233\345\273\272\350\256\260\345\275\225.md"
new file mode 100644
index 0000000..e01f8c8
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/BR-HOTKEY-DEFAULTS-\345\210\206\346\224\257\345\210\233\345\273\272\350\256\260\345\275\225.md"
@@ -0,0 +1,56 @@
+# BR-HOTKEY-DEFAULTS 分支创建记录
+
+任务 ID:`BR-HOTKEY-DEFAULTS-R222-5-CODER-20260706.0151`
+父任务:`BR-HOTKEY-DEFAULTS` / `R222-5`
+角色:Coder
+时间:2026-07-06 01:51 +0800
+
+## 基线
+
+- Git 根:`/Users/ar/Projects/PrivateVoice/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/03-O/C1.source/privatevoice.src`
+- 基线分支:`main`
+- 基线 commit:`1f725abec29ad465f3c73c28c360818139761212`
+- 目标分支:`feature/default-right-modifier-hotkey`
+- 分支用途:R222 主线默认右侧修饰键热键和默认长按输入方式的短生命周期 feature 分支。
+
+## 执行前核验
+
+```bash
+git status --short --branch
+git rev-parse HEAD
+git branch --list feature/default-right-modifier-hotkey
+```
+
+结果:
+
+- `main` 工作区 clean。
+- 当前 HEAD 为 `1f725abec29ad465f3c73c28c360818139761212`。
+- 目标分支不存在。
+
+## 创建命令
+
+```bash
+git switch -c feature/default-right-modifier-hotkey 1f725abec29ad465f3c73c28c360818139761212
+git status --short --branch
+git rev-parse HEAD
+```
+
+结果:
+
+- 已切换到 `feature/default-right-modifier-hotkey`。
+- 分支 HEAD 为 `1f725abec29ad465f3c73c28c360818139761212`。
+
+## 范围与边界
+
+- 只服务 R222。
+- 只允许修改 `/Users/ar/Projects/PrivateVoice/03-O/C1.source/privatevoice.src` 内与默认热键 / 默认输入方式直接相关的源码,以及必要项目管理证据。
+- 不触碰 `/Users/ar/Projects/PrivateVoice/P1-inputmethodkit-spike`。
+- 不触碰 `/Users/ar/Projects/PrivateVoice/P2-mas-accessibility-spike`。
+- 不 push、不 commit、不 tag、不发布、不生成 manifest、不刷新 codebase-memory、不生成正式发布包。
+
+## 生命周期
+
+- R222-5 Coder 完成后进入 R222-6 正式代码审核。
+- R222-6 / R222-7 完成后由 PMO / Owner 决定是否合并、保留或删除该短生命周期分支。
+- 未经 PMO / Owner 明确指令,不得从该分支进入发布或冻结。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/R222-5-\345\274\200\345\217\221\350\207\252\346\237\245.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/R222-5-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..41f8f26
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/R222-5-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,165 @@
+# R222-5 开发自查
+
+任务 ID:`BR-HOTKEY-DEFAULTS-R222-5-CODER-20260706.0151`
+父任务:`R222-5`
+角色:Coder
+时间:2026-07-06 01:57 +0800
+结论:`PM_NOTIFY_READY R222-5`
+建议 Review Gate ID:`R222-6-GATE-20260706.0151`
+
+## 目标与边界
+
+- 目标:在当前主线基础上实现 R222 默认值需求:首次安装 / 首次生成配置时,macOS 默认热键为右 Command,Windows 默认热键为右 Windows,默认输入方式为长按。
+- 分支:`feature/default-right-modifier-hotkey`
+- 基线:`main` / `1f725abec29ad465f3c73c28c360818139761212`
+- 源码根:`/Users/ar/Projects/PrivateVoice/03-O/C1.source/privatevoice.src`
+- 未做:未 push、未 commit、未 tag、未发布、未生成 manifest、未刷新 codebase-memory、未生成正式发布包、未触碰 P1 / P2 spike worktree。
+
+## 分支记录
+
+- 分支创建记录:`/Users/ar/Projects/PrivateVoice/03-O/K2.项目管理/BR-HOTKEY-DEFAULTS-分支创建记录.md`
+- 创建命令:
+
+```bash
+git switch -c feature/default-right-modifier-hotkey 1f725abec29ad465f3c73c28c360818139761212
+```
+
+## 变更范围
+
+- `C1.source/privatevoice.src/internal/config/config.go`
+ - 新增首次生成配置默认热键选择:macOS / Windows 使用 `0x5C`,其他平台保留 legacy `0xA5`。
+ - 新增 existing-config legacy-safe fallback,避免已有配置缺字段或加载失败时继承 R222 新默认。
+- `C1.source/privatevoice.src/internal/config/config_test.go`
+ - 覆盖首次生成配置、已有配置保护、缺字段保护、自定义热键 / 输入方式保护、加载失败 fallback。
+- `C1.source/privatevoice.src/internal/hotkey/hotkey.go`
+ - 新增平台化显示映射:macOS `0x5C` 显示 `R-⌘`,Windows / 其他平台显示 `R-Win`。
+- `C1.source/privatevoice.src/internal/hotkey/hotkey_test.go`
+ - 覆盖 macOS / Windows 右侧修饰键显示映射。
+- `C1.source/privatevoice.src/app.go`
+- `C1.source/privatevoice.src/internal/engine/engine.go`
+- `C1.source/privatevoice.src/services/engine_service.go`
+ - 将 `config.Load()` 失败后的兜底改为 existing-config legacy-safe fallback,避免异常配置在运行态拿到 R222 新默认。
+- `C1.source/privatevoice.src/frontend/src/lib/stores/config.ts`
+ - 前端热键初始 store 从 `0xA5` 更新为 `0x5C`,降低首次加载闪烁。
+- `C1.source/privatevoice.src/frontend/src/lib/stores/indicator.ts`
+ - 初始热键名按浏览器平台显示为 `R-⌘` 或 `R-Win`,后端加载后仍以真实配置覆盖。
+- `C1.source/privatevoice.src/frontend/src/lib/i18n/zh.json`
+- `C1.source/privatevoice.src/frontend/src/lib/i18n/en.json`
+ - 去除权限页“右 Alt”旧默认文案,补充右侧 Command / Windows 不可用时使用修改入口的提示。
+- `CODEGRAPH.md`
+ - 记录 R222 配置默认和 hotkey 显示映射边界。
+
+## 实现策略
+
+- `config.Default()` 仍代表“新生成默认配置”,但默认热键由 `defaultHotkeyVKForNewConfig(runtime.GOOS)` 决定。
+- `Load()` 遇到 `config.json` 不存在时,调用 `Default()` 并保存,因此新装 / 首次生成配置得到:
+ - macOS:`HotkeyVK=0x5C`
+ - Windows:`HotkeyVK=0x5C`
+ - `HotkeyMode=hold`
+- `Load()` 遇到已有配置时,不再用 `Default()+Unmarshal`,而是用 `ExistingConfigFallback()+Unmarshal`:
+ - 已有配置缺 `HotkeyVK`:运行态 fallback 为旧默认 `0xA5`,不会静默拿到 R222 新默认。
+ - 已有配置缺 `HotkeyMode`:保持既有 `hold` fallback,因为 R222 前旧默认也已经是 `hold`。
+- `config.Load()` 失败后的调用方兜底改为 `ExistingConfigFallback()`,避免异常配置由调用方绕过保护。
+
+## 平台差异
+
+- macOS:
+ - 默认内部值使用 `0x5C`。
+ - 既有 `hotkey_darwin.go` 已将 `0x5C` 映射到物理 keyCode `54`,即右 Command。
+ - 展示名返回 `R-⌘`;正常文案用“右 Command / Windows 键不可用时可修改”提示兜底。
+- Windows:
+ - 默认内部值使用 `0x5C`,即 `VK_RWIN`。
+ - 展示名返回 `R-Win`。
+ - 本机未做 Windows 真机运行时验证;右 Windows 是否触发开始菜单 / 系统保留行为仍需 R222-7 QA 真机覆盖。
+- Linux / 非目标平台:
+ - 保留 legacy 默认 `0xA5`,避免 R222 主线需求扩大到未定义平台。
+
+## 配置保护策略
+
+- 新默认只写入“配置文件不存在 / 首次生成配置”的路径。
+- 只要 `config.json` 存在:
+ - 已有旧默认 `0xA5` 不会变成 `0x5C`。
+ - 用户自定义热键不会被覆盖。
+ - 用户自定义 `tap` / `hold` 不会被覆盖。
+ - 缺 `HotkeyVK` 的旧 / 异常配置不会借 R222 静默变成右 Command / 右 Windows。
+- 本轮不新增配置版本迁移,不做已有配置批量升级。
+
+## 验证命令与结果
+
+```bash
+gofmt -w app.go internal/config/config.go internal/config/config_test.go internal/engine/engine.go internal/hotkey/hotkey.go internal/hotkey/hotkey_test.go services/engine_service.go
+```
+
+结果:PASS。
+
+```bash
+go test ./internal/config ./internal/hotkey
+```
+
+结果:PASS。
+
+```bash
+npm --prefix /Users/ar/Projects/PrivateVoice/03-O/C1.source/privatevoice.src/frontend run build
+```
+
+结果:PASS。存在既有 Svelte a11y warnings:
+
+- `src/components/update/UpdateDialog.svelte`
+- `src/components/shared/ToggleSwitch.svelte`
+
+上述文件本轮未修改。
+
+```bash
+go test ./...
+```
+
+结果:PASS。存在既有 macOS SDK / onnxruntime link warnings,不影响退出码。
+
+```bash
+git diff --check
+```
+
+结果:PASS。
+
+```bash
+GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ go test -exec=/usr/bin/true ./...
+```
+
+结果:PASS。
+
+说明:曾并行运行 `go test ./...` 与前端 build,出现一次 embed 哈希文件瞬时不一致失败;随后按顺序先跑前端 build,再重跑 `go test ./...`,结果 PASS。有效门禁以顺序重跑结果为准。
+
+## 待审 diff 范围
+
+```bash
+git diff --name-only
+```
+
+范围:
+
+- `C1.source/privatevoice.src/app.go`
+- `C1.source/privatevoice.src/frontend/src/lib/i18n/en.json`
+- `C1.source/privatevoice.src/frontend/src/lib/i18n/zh.json`
+- `C1.source/privatevoice.src/frontend/src/lib/stores/config.ts`
+- `C1.source/privatevoice.src/frontend/src/lib/stores/indicator.ts`
+- `C1.source/privatevoice.src/internal/config/config.go`
+- `C1.source/privatevoice.src/internal/config/config_test.go`
+- `C1.source/privatevoice.src/internal/engine/engine.go`
+- `C1.source/privatevoice.src/internal/hotkey/hotkey.go`
+- `C1.source/privatevoice.src/internal/hotkey/hotkey_test.go`
+- `C1.source/privatevoice.src/services/engine_service.go`
+- `CODEGRAPH.md`
+- `K2.项目管理/BR-HOTKEY-DEFAULTS-分支创建记录.md`
+- `K2.项目管理/R222-5-开发自查.md`
+
+## 未覆盖风险
+
+- 未做 Windows 真机右 Windows 运行时验证;只能证明 Windows 交叉编译 / 测试通过和 `0x5C` 映射正确。R222-7 必须覆盖真实 Windows 行为、系统保留键和 Start 菜单风险。
+- 未做 macOS GUI 手动长按录音 smoke;本轮通过单元测试和现有热键物理映射证明实现面,真实右 Command 长按仍需 R222-7 QA。
+- 无右侧修饰键、远程桌面、外接键盘映射异常和快捷键冲突仍需 QA 记录;本轮保留修改入口和提示文案,不静默 fallback 到左侧键。
+
+## 结论
+
+`PM_NOTIFY_READY R222-5`
+
+建议 PMO 派发 R222-6 正式代码审核,Review Gate ID:`R222-6-GATE-20260706.0151`。本结论不代表 R222-6 代码审核 PASS、R222-7 QA PASS、发布完成或主线合并许可。
--
Gitblit v1.9.3