Ariver
2026-07-06 7ed43baaa6be88a55952a3411575922c8d883a08
feat: default right modifier hotkey
11 files modified
3 files added
468 ■■■■■ changed files
C1.source/privatevoice.src/app.go 4 ●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/frontend/src/lib/i18n/en.json 4 ●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/frontend/src/lib/i18n/zh.json 4 ●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/frontend/src/lib/stores/config.ts 2 ●●● patch | view | raw | blame | history
C1.source/privatevoice.src/frontend/src/lib/stores/indicator.ts 7 ●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/config/config.go 30 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/config/config_test.go 150 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/engine/engine.go 4 ●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/hotkey/hotkey.go 11 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/internal/hotkey/hotkey_test.go 26 ●●●●● patch | view | raw | blame | history
C1.source/privatevoice.src/services/engine_service.go 2 ●●● patch | view | raw | blame | history
CODEGRAPH.md 3 ●●●●● patch | view | raw | blame | history
K2.项目管理/BR-HOTKEY-DEFAULTS-分支创建记录.md 56 ●●●●● patch | view | raw | blame | history
K2.项目管理/R222-5-开发自查.md 165 ●●●●● patch | view | raw | blame | history
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()
        }
    }
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",
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": "版本",
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");
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);
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
    }
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
}
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)
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"
C1.source/privatevoice.src/internal/hotkey/hotkey_test.go
New file
@@ -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)
            }
        })
    }
}
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
    }
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`
K2.项目管理/BR-HOTKEY-DEFAULTS-分支创建记录.md
New file
@@ -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 明确指令,不得从该分支进入发布或冻结。
K2.项目管理/R222-5-开发自查.md
New file
@@ -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、发布完成或主线合并许可。