From 7cec9cadc16b6daf902c89aca770f37f1843099b Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Mon, 06 Jul 2026 15:47:55 +0800
Subject: [PATCH] merge: R222 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