From 015f1089ea875a153dd2a18ef42c25145d4868f5 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sat, 11 Jul 2026 22:28:32 +0800
Subject: [PATCH] freeze(mas): snapshot tested 2.2.3 build 20260711.1807
---
K2.项目管理/AX-10BA-开发自查.md | 120
K2.项目管理/AX-10CX-TestFlight-candidate-构建自查.md | 112
C1.source/privatevoice.src/internal/config/default_hotkey_appstore_darwin_test.go | 20
C1.source/privatevoice.src/internal/axinput/clipboard_state.go | 51
C1.source/privatevoice.src/internal/axinput/axinput_other.go | 37
K2.项目管理/AX-10BM-本地QA构建rpath修复自查.md | 137
K2.项目管理/AX-10D1-开发自查.md | 107
C1.source/privatevoice.src/internal/hotkey/hotkey_test.go | 1
C1.source/privatevoice.src/app.go | 421 +
K2.项目管理/AX-10BH-开发自查.md | 62
K2.项目管理/AX-10BW-NPM-PIN-开发自查.md | 101
C1.source/privatevoice.src/internal/audio/recorder.go | 254 +
C1.source/privatevoice.src/internal/config/config_test.go | 25
K2.项目管理/AX-10CA-MAS-diff-closure-开发自查.md | 147
C1.source/privatevoice.src/services/config_service_test.go | 30
C1.source/privatevoice.src/internal/audio/microphone_other.go | 14
K2.项目管理/AX-10AB-开发自查.md | 79
C1.source/privatevoice.src/internal/config/default_hotkey_default.go | 7
K2.项目管理/AX-10P1-开发自查.md | 121
C1.source/privatevoice.src/app_hotkey_ready_text_test.go | 47
C1.source/privatevoice.src/app_text_output_event_test.go | 35
K2.项目管理/AX-10BW-PRIVACY-MANIFEST-开发自查.md | 69
C1.source/privatevoice.src/internal/hotkey/hotkey.go | 2
C1.source/privatevoice.src/internal/axinput/clipboard_state_test.go | 31
K2.项目管理/AX-10CG-MAS-signing-profile-cert-fix-retry-开发自查.md | 160
C1.source/privatevoice.src/internal/config/config.go | 10
C1.source/privatevoice.src/internal/audio/microphone_darwin.go | 59
C1.source/privatevoice.src/internal/axinput/axinput_darwin.go | 492 ++
C1.source/privatevoice.src/services/diagnostics_service.go | 23
C1.source/privatevoice.src/frontend/package-lock.json | 2
K2.项目管理/AX-10DA-TestFlight-1200-fallback-real-paste-开发自查.md | 89
C1.source/privatevoice.src/frontend/src/components/settings/HotkeysPage.svelte | 30
K2.项目管理/AX-10L2-开发自查.md | 104
K2.项目管理/AX-10DR-直接上屏根因调查与修复自查.md | 162
K2.项目管理/AX-10S-开发自查.md | 117
K2.项目管理/AX-10H-开发自查.md | 106
K2.项目管理/AX-10P-开发自查.md | 124
C1.source/privatevoice.src/internal/audio/recorder_test.go | 472 ++
C1.source/privatevoice.src/internal/singleinstance/singleinstance_unix.go | 75
C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh | 108
C1.source/privatevoice.src/internal/config/default_hotkey_appstore_darwin.go | 7
K2.项目管理/AX-10Y1-开发自查.md | 126
K2.项目管理/AX-10DF-TestFlight-1304-direct-insert-开发自查.md | 106
C1.source/privatevoice.src/internal/hotkey/hotkey_darwin_test.go | 79
K2.项目管理/AX-10BW-MAS-candidate构建自查.md | 143
K2.项目管理/AX-10L-开发自查.md | 102
K2.项目管理/AX-10CK-开发自查.md | 94
K2.项目管理/AX-10DW-自动粘贴实验candidate构建自查.md | 121
C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte | 300 +
K2.项目管理/AX-10A-开发自查.md | 123
C1.source/privatevoice.src/services/hotkey_service.go | 6
C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin.go | 258 +
C1.source/privatevoice.src/internal/axinput/axinput.go | 45
K2.项目管理/AX-10H1-开发自查.md | 85
C1.source/privatevoice.src/frontend/src/lib/i18n/zh.json | 34
K2.项目管理/AX-10CV-开发自查.md | 80
K2.项目管理/AX-10AO-开发自查.md | 51
K2.项目管理/AX-10CN-TestFlight-candidate-构建自查.md | 126
K2.项目管理/AX-10AK-开发自查.md | 84
K2.项目管理/AX-10BW-PRIVACY-COPY-开发自查.md | 98
K2.项目管理/AX-10L1-开发自查.md | 121
C1.source/privatevoice.src/internal/model/model_test.go | 16
K2.项目管理/AX-10BN-开发自查.md | 119
K2.项目管理/AX-10V-开发自查.md | 119
C1.source/privatevoice.src/internal/model/registry.go | 4
K2.项目管理/AX-10CG-MAS-signing-profile-cert-fix-开发自查.md | 128
C1.source/privatevoice.src/internal/axinput/clipboard_fallback_darwin_test.go | 99
K2.项目管理/AX-10DC-TestFlight-candidate-构建自查.md | 129
C1.source/privatevoice.src/frontend/src/lib/stores/config.ts | 3
C1.source/privatevoice.src/app_live_caption_test.go | 892 ++++
K2.项目管理/AX-10CQ-开发自查.md | 93
K2.项目管理/AX-10EB-签名证据差异调和自查.md | 235 +
C1.source/privatevoice.src/frontend/src/components/settings/TextOutputFallbackPanel.svelte | 252 +
C1.source/privatevoice.src/frontend/src/lib/stores/indicator.ts | 6
K2.项目管理/AX-10BK-本地QA构建自查.md | 98
K2.项目管理/AX-10D-开发自查.md | 146
K2.项目管理/AX-10BW-MODEL-URL-开发自查.md | 56
C1.source/privatevoice.src/internal/textoutput/router.go | 159
C1.source/privatevoice.src/internal/textoutput/router_test.go | 64
C1.source/privatevoice.src/frontend/src/lib/i18n/en.json | 34
C1.source/privatevoice.src/internal/singleinstance/singleinstance_unix_test.go | 43
K2.项目管理/AX-10BF-开发自查.md | 115
C1.source/privatevoice.src/frontend/package.json | 2
C1.source/privatevoice.src/go.sum | 233 +
K2.项目管理/AX-10Y-开发自查.md | 98
C1.source/privatevoice.src/scripts/build-appstore-macos.sh | 81
C1.source/privatevoice.src/services/hotkey_service_test.go | 22
C1.source/privatevoice.src/build/darwin/PrivacyInfo.xcprivacy | 23
K2.项目管理/AX-10CS-TestFlight-candidate-构建自查.md | 139
K2.项目管理/BR-AX-MAINLINE-分支创建记录.md | 78
K2.项目管理/AX-10DH-TestFlight-candidate-构建自查.md | 157
C1.source/privatevoice.src/internal/textoutput/router_default.go | 42
K2.项目管理/AX-10AT-开发自查.md | 53
C1.source/privatevoice.src/internal/hotkey/hotkey_darwin.go | 227
C1.source/privatevoice.src/services/config_service.go | 11
C1.source/privatevoice.src/app_version_test.go | 19
C1.source/privatevoice.src/frontend/src/App.svelte | 6
C1.source/privatevoice.src/frontend/src/components/indicator/FloatingIndicator.svelte | 9
C1.source/privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte | 3
K2.项目管理/AX-10CM-开发自查.md | 71
C1.source/privatevoice.src/scripts/verify-ax-output-boundary.sh | 22
K2.项目管理/AX-10AE-开发自查.md | 81
CODEGRAPH.md | 27
K2.项目管理/AX-10CC-MAS-platform-feedback-candidate-开发自查.md | 127
C1.source/privatevoice.src/services/text_output_service.go | 54
K2.项目管理/AX-10DT-自动粘贴实验开发自查.md | 178
C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin_test.go | 500 ++
K2.项目管理/AX-10BW-MAS-candidate-retry-开发自查.md | 287 +
K2.项目管理/AX-10BW-VERSION-INJECTION-开发自查.md | 81
K2.项目管理/AX-10S1-开发自查.md | 141
K2.项目管理/AX-10BP-本地QA构建刷新自查.md | 162
111 files changed, 11,876 insertions(+), 220 deletions(-)
diff --git a/C1.source/privatevoice.src/app.go b/C1.source/privatevoice.src/app.go
index 908337b..7f22693 100755
--- a/C1.source/privatevoice.src/app.go
+++ b/C1.source/privatevoice.src/app.go
@@ -2,6 +2,7 @@
import (
"context"
+ "errors"
"fmt"
"runtime"
"strings"
@@ -22,6 +23,7 @@
"voicesnap/internal/modelselection"
"voicesnap/internal/overlay"
"voicesnap/internal/sound"
+ "voicesnap/internal/textoutput"
"voicesnap/internal/textproc"
"voicesnap/internal/userdict"
"voicesnap/services"
@@ -30,11 +32,14 @@
"github.com/wailsapp/wails/v3/pkg/events"
)
+var (
+ appVersion = "2.2.2"
+ appBuild = "20260703.0335"
+)
+
const (
- appVersion = "2.2.2"
- appBuild = "20260703.0335"
- appDisplayVersion = appVersion + " (build " + appBuild + ")"
appName = "PrivateVoice Dictation"
+ textOutputUpdated = "text-output:updated"
silenceThreshold = 0.05 // RMS below this = silence (matches HasVoiceActivity)
silenceTimeoutDuration = 3 * time.Second // auto-stop after this much silence in free-talk
@@ -47,6 +52,15 @@
liveCaptionMaxBytes = 108
)
+func appDisplayVersion() string {
+ return appVersion + " (build " + appBuild + ")"
+}
+
+var (
+ holdStartupSampleWaitTimeout = 2 * time.Second
+ holdStartupSamplePollInterval = 25 * time.Millisecond
+)
+
// App holds all application state and orchestration logic.
type App struct {
ctx context.Context
@@ -54,7 +68,7 @@
wg sync.WaitGroup
cfg *config.Config
- recorder *audio.Recorder
+ recorder appRecorder
eng engine.Engine
engineMu sync.Mutex
engineInitMu sync.Mutex
@@ -63,6 +77,7 @@
engineFactory engineFactoryFunc
hk hotkey.Listener
paster input.Paster
+ outputRouter *textoutput.Router
history *history.Store
userdict *userdict.Store
@@ -76,6 +91,8 @@
isRecording bool
isStoppingRecording bool
isHoldRecordingPending bool
+ isHoldRecorderStarting bool
+ isHoldStopPending bool
isFreetalking bool
hotkeyActive bool
hotkeyPressTime time.Time
@@ -93,9 +110,22 @@
liveCaptionSeq uint64
releaseTailCaptureNanos atomic.Int64
holdPreCaptureEnabled atomic.Bool
+ holdActivationTimer *time.Timer
+ holdActivationSeq uint64
}
type engineFactoryFunc func(initID uint64) (engine.Engine, engineMetadata, bool, error)
+
+type appRecorder interface {
+ OnVolume(func(float64))
+ OnDeviceChange(func(string))
+ Start() error
+ Stop()
+ StopAndGetSamples() []float32
+ HasVoiceActivity() bool
+ ReadSamplesSince(offset int) ([]float32, int)
+ Close()
+}
type engineMetadata struct {
InitID uint64
@@ -131,13 +161,15 @@
app.cfg = cfg
// Initialize audio recorder
- app.recorder = audio.NewRecorder()
+ recorder := audio.NewRecorder()
+ app.recorder = recorder
// Initialize platform-specific hotkey listener
app.hk = hotkey.New()
// Initialize platform-specific paster
app.paster = input.NewPaster()
+ app.outputRouter = textoutput.NewRouter(app.paster)
// Initialize history store
app.history = history.New()
@@ -146,18 +178,20 @@
app.userdict = userdict.New()
// Create services for Wails bindings
- appService := services.NewAppService(app.cfg, appDisplayVersion)
+ displayVersion := appDisplayVersion()
+ appService := services.NewAppService(app.cfg, displayVersion)
configService := services.NewConfigService(app.cfg)
engineService := services.NewEngineService(app.cfg)
app.engineService = engineService
hotkeyService := services.NewHotkeyService(app.cfg)
permissionService := services.NewPermissionService()
updaterService := services.NewUpdaterService(appVersion)
- audioService := services.NewAudioService(app.recorder, app.cfg)
+ audioService := services.NewAudioService(recorder, app.cfg)
historyService := services.NewHistoryService(app.history)
userDictService := services.NewUserDictService(app.userdict)
correctionCSVService := services.NewCorrectionCSVService(app.history, app.userdict)
- diagnosticsService := services.NewDiagnosticsService(app.cfg, appDisplayVersion)
+ diagnosticsService := services.NewDiagnosticsService(app.cfg, displayVersion)
+ textOutputService := services.NewTextOutputService(app.outputRouter)
// Create Wails application
wailsApp := application.New(application.Options{
@@ -175,6 +209,7 @@
application.NewService(userDictService),
application.NewService(correctionCSVService),
application.NewService(diagnosticsService),
+ application.NewService(textOutputService),
},
Assets: application.AssetOptions{
Handler: application.AssetFileServerFS(assets),
@@ -294,6 +329,23 @@
a.mu.Lock()
defer a.mu.Unlock()
+ if config.IsHotkeyUnset(a.cfg.HotkeyVK) {
+ if !a.hotkeyPollSeen || a.lastHotkeyDown {
+ a.hotkeyPollSeen = true
+ a.lastHotkeyDown = false
+ logger.Info(
+ "Hotkey poll state: key=unset vk=0x0 mode=%s down=false active=%t recording=%t freetalk=%t engineReady=%t recordingHotkey=%t",
+ config.NormalizeHotkeyMode(a.cfg.HotkeyMode),
+ a.hotkeyActive,
+ a.isRecording,
+ a.isFreetalking,
+ a.eng != nil,
+ a.isRecordingHotkey,
+ )
+ }
+ return
+ }
+
isDown := a.hk.IsKeyDown(a.cfg.HotkeyVK)
if !a.hotkeyPollSeen || a.lastHotkeyDown != isDown {
a.hotkeyPollSeen = true
@@ -323,10 +375,40 @@
// Escape cancels any active recording
if a.cfg.HotkeyVK != 0x1B && (a.isRecording || a.isFreetalking) && a.hk.IsKeyDown(0x1B) {
+ if a.isHoldRecordingPending {
+ a.cancelHoldPreCaptureLocked("Escape")
+ a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
+ if a.cfg.SoundFeedback {
+ sound.PlayCancel()
+ }
+ a.delayedHide(1000)
+ return
+ }
+ if a.isHoldRecorderStarting {
+ a.isCombination = true
+ a.isFreetalking = false
+ a.isRecording = false
+ a.isHoldRecordingPending = false
+ a.isHoldStopPending = true
+ a.cancelHoldActivationTimerLocked()
+ a.lastStopTime = time.Now()
+ a.stopLiveCaptionLocked()
+ logger.Info("Recording cancellation deferred until recorder start completes (Escape)")
+ a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
+ if a.cfg.SoundFeedback {
+ sound.PlayCancel()
+ }
+ a.delayedHide(1000)
+ a.recorder.Stop()
+ return
+ }
a.isCombination = true
a.isFreetalking = false
a.isRecording = false
a.isHoldRecordingPending = false
+ a.isHoldRecorderStarting = false
+ a.isHoldStopPending = false
+ a.cancelHoldActivationTimerLocked()
a.lastStopTime = time.Now()
a.stopLiveCaptionLocked()
a.recorder.Stop()
@@ -417,6 +499,7 @@
return a.holdPreCaptureEnabled.Load() &&
!a.isRecording &&
!a.isStoppingRecording &&
+ !a.isHoldRecorderStarting &&
!a.isFreetalking &&
time.Since(a.lastStopTime) > 500*time.Millisecond
}
@@ -425,21 +508,133 @@
if a.isRecording || a.isStoppingRecording || a.isFreetalking {
return
}
+ a.isRecording = true
+ a.isHoldRecordingPending = true
+ a.isHoldRecorderStarting = true
+ a.isHoldStopPending = false
+ a.scheduleHoldActivationTimerLocked()
+ logger.Info("Hold pre-capture start requested")
+ go a.startHoldPreCaptureRecorder()
+}
+
+func (a *App) startHoldPreCaptureRecorder() {
if err := a.recorder.Start(); err != nil {
+ a.mu.Lock()
+ releasePipelineActive := a.isHoldRecorderStarting && !a.isRecording && !a.isHoldRecordingPending && !a.isHoldStopPending
+ cancelPending := a.isHoldRecorderStarting && !a.isRecording && a.isHoldStopPending
+ wasRecording := a.isRecording || a.isHoldRecordingPending || a.isHoldRecorderStarting
+ a.isRecording = false
+ a.isHoldRecordingPending = false
+ a.isHoldRecorderStarting = false
+ a.isHoldStopPending = false
+ a.cancelHoldActivationTimerLocked()
+ if releasePipelineActive {
+ a.mu.Unlock()
+ logger.Error("Hold pre-capture recorder start failed after release pipeline started: %v", err)
+ return
+ }
+ if cancelPending {
+ a.mu.Unlock()
+ logger.Error("Hold pre-capture recorder start failed after cancellation: %v", err)
+ return
+ }
+ if wasRecording {
+ a.stopLiveCaptionLocked()
+ a.setRecordingStartErrorLocked(err)
+ a.delayedHide(2000)
+ }
+ a.mu.Unlock()
logger.Error("Failed to start hold pre-capture: %v", err)
return
}
- a.isRecording = true
- a.isHoldRecordingPending = true
+
+ a.mu.Lock()
+ a.isHoldRecorderStarting = false
+ if !a.isRecording && !a.isHoldStopPending {
+ a.mu.Unlock()
+ logger.Info("Hold pre-capture recorder start completed after release pipeline started")
+ return
+ }
+ shouldStop := !a.isRecording || a.isHoldStopPending
+ if shouldStop {
+ a.isRecording = false
+ a.isHoldRecordingPending = false
+ stopPending := a.isHoldStopPending
+ a.isHoldStopPending = false
+ a.lastStopTime = time.Now()
+ a.stopLiveCaptionLocked()
+ a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
+ a.delayedHide(1000)
+ a.mu.Unlock()
+ if !stopPending {
+ a.recorder.Stop()
+ }
+ return
+ }
+ a.mu.Unlock()
logger.Info("Hold pre-capture started")
+}
+
+func (a *App) scheduleHoldActivationTimerLocked() {
+ a.cancelHoldActivationTimerLocked()
+ a.holdActivationSeq++
+ seq := a.holdActivationSeq
+ a.holdActivationTimer = time.AfterFunc(holdActivationDelay, func() {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+
+ if a.holdActivationSeq != seq {
+ return
+ }
+ a.holdActivationTimer = nil
+ if a.ctx != nil {
+ select {
+ case <-a.ctx.Done():
+ return
+ default:
+ }
+ }
+ if !a.hotkeyActive || !a.isHoldRecordingPending || !a.isRecording {
+ return
+ }
+ if a.hk == nil || !a.hk.IsKeyDown(a.cfg.HotkeyVK) {
+ a.cancelHoldPreCaptureLocked("released before activation")
+ return
+ }
+ if a.eng == nil {
+ a.cancelHoldPreCaptureLocked("engine not ready")
+ return
+ }
+ if !a.isCombination && a.hk != nil && a.hk.IsAnyOtherKeyPressedSince(a.cfg.HotkeyVK, a.hotkeyPressTime) {
+ a.isCombination = true
+ logger.Info("Hotkey marked as combination: key=%s", hotkey.GetKeyName(a.cfg.HotkeyVK))
+ a.cancelHoldPreCaptureLocked("combination key")
+ return
+ }
+ if a.isCombination {
+ return
+ }
+ logger.Info("Hotkey action: confirm hold pre-capture after %dms", time.Since(a.hotkeyPressTime).Milliseconds())
+ a.confirmHoldPreCaptureLocked()
+ })
+}
+
+func (a *App) cancelHoldActivationTimerLocked() {
+ a.holdActivationSeq++
+ if a.holdActivationTimer != nil {
+ a.holdActivationTimer.Stop()
+ a.holdActivationTimer = nil
+ }
}
func (a *App) confirmHoldPreCaptureLocked() {
if !a.isHoldRecordingPending || !a.isRecording {
a.isHoldRecordingPending = false
+ a.cancelHoldActivationTimerLocked()
return
}
a.isHoldRecordingPending = false
+ a.cancelHoldActivationTimerLocked()
a.hideGen.Add(1)
logger.Info("Hold pre-capture confirmed")
@@ -458,8 +653,10 @@
return
}
a.isHoldRecordingPending = false
+ a.cancelHoldActivationTimerLocked()
if a.isRecording {
a.isRecording = false
+ a.isHoldStopPending = a.isHoldRecorderStarting
a.lastStopTime = time.Now()
a.recorder.Stop()
}
@@ -537,6 +734,8 @@
if err := a.recorder.Start(); err != nil {
logger.Error("Failed to start recording: %v", err)
a.isRecording = false
+ a.setRecordingStartErrorLocked(err)
+ a.delayedHide(2000)
return
}
a.startLiveCaptionLocked(overlay.StatusRecording)
@@ -550,6 +749,39 @@
a.isRecording = false
a.isHoldRecordingPending = false
a.lastStopTime = time.Now()
+
+ if a.isHoldRecorderStarting {
+ a.stopLiveCaptionLocked()
+ if cancel {
+ a.isHoldStopPending = true
+ logger.Info("Hold recording stop deferred until recorder start completes")
+ logger.Info("Recording cancelled (combination key)")
+ a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
+ if a.cfg.SoundFeedback {
+ sound.PlayCancel()
+ }
+ a.delayedHide(1000)
+ a.recorder.Stop()
+ return
+ }
+
+ logger.Info("Hold recording stop proceeds while recorder start is pending")
+ a.isStoppingRecording = true
+ a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
+ go func() {
+ perfID := newPerfTraceID()
+ pipelineStart := time.Now()
+ logger.Info("PERF pipeline_start id=%d mode=hold_starting_release", perfID)
+ samples, hasVoice, ok := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart, true)
+ if !ok {
+ a.indicator.SetStatus(overlay.StatusError, "录音未就绪")
+ a.delayedHide(2000)
+ return
+ }
+ a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
+ }()
+ return
+ }
if cancel {
logger.Info("Recording cancelled (combination key)")
@@ -570,7 +802,12 @@
perfID := newPerfTraceID()
pipelineStart := time.Now()
logger.Info("PERF pipeline_start id=%d mode=hold", perfID)
- samples, hasVoice := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart)
+ samples, hasVoice, ok := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart, false)
+ if !ok {
+ a.indicator.SetStatus(overlay.StatusError, "录音未就绪")
+ a.delayedHide(2000)
+ return
+ }
a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
}()
}
@@ -597,10 +834,20 @@
logger.Error("Failed to start tap recording: %v", err)
a.isFreetalking = false
a.isRecording = false
+ a.setRecordingStartErrorLocked(err)
+ a.delayedHide(2000)
return
}
a.startLiveCaptionLocked(overlay.StatusFreetalking)
a.startRecordingTimer(overlay.StatusFreetalking)
+}
+
+func (a *App) setRecordingStartErrorLocked(err error) {
+ if errors.Is(err, audio.ErrMicrophonePermission) {
+ a.indicator.SetStatus(overlay.StatusError, "麦克风权限")
+ return
+ }
+ a.indicator.SetStatus(overlay.StatusError, "录音启动失败")
}
// startRecordingTimer updates the indicator text with elapsed time every second.
@@ -759,7 +1006,12 @@
perfID := newPerfTraceID()
pipelineStart := time.Now()
logger.Info("PERF pipeline_start id=%d mode=tap", perfID)
- samples, hasVoice := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart)
+ samples, hasVoice, ok := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart, false)
+ if !ok {
+ a.indicator.SetStatus(overlay.StatusError, "录音未就绪")
+ a.delayedHide(2000)
+ return
+ }
a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
}()
}
@@ -859,8 +1111,61 @@
return time.Duration(nanos)
}
-func (a *App) stopRecorderAfterReleaseTailCapture(perfID int64, pipelineStart time.Time) ([]float32, bool) {
+func (a *App) waitForHoldStartupSamples(perfID int64, pipelineStart time.Time) bool {
+ deadline := time.NewTimer(holdStartupSampleWaitTimeout)
+ defer deadline.Stop()
+ ticker := time.NewTicker(holdStartupSamplePollInterval)
+ defer ticker.Stop()
+
+ for {
+ samples, _ := a.recorder.ReadSamplesSince(0)
+ hasVoice := a.recorder.HasVoiceActivity()
+
+ a.mu.Lock()
+ starting := a.isHoldRecorderStarting
+ a.mu.Unlock()
+
+ if len(samples) > 0 {
+ logger.Info(
+ "PERF hold_startup_sample_wait_done id=%d result=samples samples=%d has_voice=%t since_start_ms=%d",
+ perfID,
+ len(samples),
+ hasVoice,
+ perfSinceMS(pipelineStart),
+ )
+ return true
+ }
+ if !starting {
+ logger.Info("PERF hold_startup_sample_wait_done id=%d result=start_complete_without_samples since_start_ms=%d", perfID, perfSinceMS(pipelineStart))
+ return true
+ }
+
+ select {
+ case <-a.ctx.Done():
+ logger.Info("PERF hold_startup_sample_wait_done id=%d result=app_stopping since_start_ms=%d", perfID, perfSinceMS(pipelineStart))
+ return false
+ case <-deadline.C:
+ logger.Error("Hold release reached audio startup sample wait timeout without PCM")
+ logger.Info("PERF hold_startup_sample_wait_done id=%d result=timeout timeout_ms=%d since_start_ms=%d", perfID, holdStartupSampleWaitTimeout.Milliseconds(), perfSinceMS(pipelineStart))
+ return false
+ case <-ticker.C:
+ }
+ }
+}
+
+func (a *App) stopRecorderAfterReleaseTailCapture(perfID int64, pipelineStart time.Time, waitForStartupSamples bool) ([]float32, bool, bool) {
a.waitForReleaseTailCapture(perfID)
+
+ if waitForStartupSamples && !a.waitForHoldStartupSamples(perfID, pipelineStart) {
+ stopStart := time.Now()
+ a.recorder.Stop()
+ stopElapsed := time.Since(stopStart)
+ a.mu.Lock()
+ a.isStoppingRecording = false
+ a.mu.Unlock()
+ logger.Info("PERF audio_stop_done id=%d result=audio_not_ready stop_ms=%d samples=0 audio_ms=0 has_voice=false since_start_ms=%d", perfID, perfDurationMS(stopElapsed), perfSinceMS(pipelineStart))
+ return nil, false, false
+ }
stopStart := time.Now()
samples := a.recorder.StopAndGetSamples()
@@ -885,10 +1190,10 @@
perfSinceMS(pipelineStart),
)
- return samples, hasVoice
+ return samples, hasVoice, true
}
-// recognizeAndPaste runs ASR on the recorded samples and pastes the result.
+// recognizeAndPaste runs ASR on the recorded samples and routes the result to text output.
// Shared by both hold and tap modes.
func (a *App) recognizeAndPaste(hasVoice bool, samples []float32, perfID int64, pipelineStart time.Time) {
// Snapshot config values under lock to avoid data races
@@ -897,17 +1202,32 @@
hotkeyVK := a.cfg.HotkeyVK
autoHide := a.cfg.AutoHide
copyToClipboard := a.cfg.CopyToClipboard
+ autoPasteExperiment := a.cfg.AutoPasteExperiment
a.mu.Unlock()
logger.Info(
- "PERF recognize_pipeline_input id=%d has_voice=%t samples=%d audio_ms=%d copy_to_clipboard=%t since_start_ms=%d",
+ "PERF recognize_pipeline_input id=%d has_voice=%t samples=%d audio_ms=%d copy_to_clipboard=%t auto_paste_experiment=%t since_start_ms=%d",
perfID,
hasVoice,
len(samples),
perfAudioDurationMS(samples),
copyToClipboard,
+ autoPasteExperiment,
perfSinceMS(pipelineStart),
)
+
+ outputOptions := textoutput.OutputOptions{
+ KeepClipboard: copyToClipboard,
+ AutoPasteExperiment: autoPasteExperiment,
+ }
+ if autoPasteExperiment {
+ outputOptions.AutoPasteGuard = a.outputRouter.CaptureAutoPasteGuard()
+ logger.Info(
+ "Text output auto-paste experiment guard captured ok=%t identity_present=%t",
+ outputOptions.AutoPasteGuard.OK,
+ outputOptions.AutoPasteGuard.Identity != "",
+ )
+ }
if !hasVoice {
logger.Info("No voice activity detected")
@@ -978,7 +1298,8 @@
return
}
- logger.Info("Recognized: %s", text)
+ textRunes := len([]rune(text))
+ logger.Info("Recognized text produced: text_runes=%d", textRunes)
historyStart := time.Now()
a.history.Add(text)
logger.Info("PERF history_done id=%d history_ms=%d since_start_ms=%d", perfID, perfSinceMS(historyStart), perfSinceMS(pipelineStart))
@@ -994,23 +1315,41 @@
perfSinceMS(pipelineStart),
)
- pasteStart := time.Now()
- if err := a.paster.Paste(text, copyToClipboard); err != nil {
- pasteElapsed := time.Since(pasteStart)
- logger.Info("PERF paste_done id=%d result=error paste_ms=%d since_start_ms=%d", perfID, perfDurationMS(pasteElapsed), perfSinceMS(pipelineStart))
- logger.Error("Paste failed, trying fallback: %v", err)
- typeStart := time.Now()
- if err := a.paster.TypeText(text); err != nil {
- logger.Info("PERF fallback_type_done id=%d result=error type_ms=%d total_ms=%d", perfID, perfSinceMS(typeStart), perfSinceMS(pipelineStart))
- logger.Error("Fallback type also failed: %v", err)
+ outputStart := time.Now()
+ output := a.outputRouter.OutputWithOptions(text, outputOptions)
+ a.emitTextOutputUpdated(output)
+ if !output.OK {
+ logger.Info(
+ "PERF text_output_done id=%d result=error output_ms=%d text_runes=%d fallback_available=%t since_start_ms=%d",
+ perfID,
+ perfDurationMS(time.Since(outputStart)),
+ textRunes,
+ output.FallbackAvailable,
+ perfSinceMS(pipelineStart),
+ )
+ logger.Error(
+ "Text output failed: needs_permission=%t fallback_available=%t direct_inserted=%t text_runes=%d",
+ output.NeedsPermission,
+ output.FallbackAvailable,
+ output.DirectInserted,
+ textRunes,
+ )
+ if output.FallbackAvailable {
+ a.indicator.SetStatus(overlay.StatusError, "文本已保留")
+ } else {
a.indicator.SetStatus(overlay.StatusError, "需辅助权限")
- a.delayedHideIf(autoHide, 2500)
- return
}
- logger.Info("PERF fallback_type_done id=%d result=success type_ms=%d total_ms=%d", perfID, perfSinceMS(typeStart), perfSinceMS(pipelineStart))
- } else {
- logger.Info("PERF paste_done id=%d result=success paste_ms=%d since_start_ms=%d", perfID, perfSinceMS(pasteStart), perfSinceMS(pipelineStart))
+ a.delayedHideIf(autoHide, 2500)
+ return
}
+ logger.Info(
+ "PERF text_output_done id=%d result=success direct_inserted=%t output_ms=%d text_runes=%d since_start_ms=%d",
+ perfID,
+ output.DirectInserted,
+ perfDurationMS(time.Since(outputStart)),
+ textRunes,
+ perfSinceMS(pipelineStart),
+ )
a.indicator.SetStatus(overlay.StatusDone, "完成")
if soundFeedback {
@@ -1111,6 +1450,23 @@
return "total"
default:
return ""
+ }
+}
+
+func (a *App) emitTextOutputUpdated(result textoutput.Result) {
+ if a == nil || a.wailsApp == nil {
+ return
+ }
+ a.wailsApp.Event.Emit(textOutputUpdated, textOutputUpdatePayload(result))
+}
+
+func textOutputUpdatePayload(result textoutput.Result) map[string]interface{} {
+ return map[string]interface{}{
+ "ok": result.OK,
+ "needsPermission": result.NeedsPermission,
+ "directInserted": result.DirectInserted,
+ "fallbackAvailable": result.FallbackAvailable,
+ "hasText": result.Text != "",
}
}
@@ -1357,6 +1713,9 @@
}
func hotkeyReadyText(keyName, mode string) string {
+ if keyName == "" {
+ return "触发键未设置"
+ }
if mode == config.HotkeyModeTap {
return "点按" + keyName + "说话"
}
diff --git a/C1.source/privatevoice.src/app_hotkey_ready_text_test.go b/C1.source/privatevoice.src/app_hotkey_ready_text_test.go
new file mode 100644
index 0000000..d624ee7
--- /dev/null
+++ b/C1.source/privatevoice.src/app_hotkey_ready_text_test.go
@@ -0,0 +1,47 @@
+package main
+
+import (
+ "testing"
+
+ "voicesnap/internal/config"
+)
+
+func TestHotkeyReadyTextUnsetKey(t *testing.T) {
+ tests := []struct {
+ name string
+ mode string
+ }{
+ {name: "hold", mode: config.HotkeyModeHold},
+ {name: "tap", mode: config.HotkeyModeTap},
+ {name: "unknown mode falls back to hold", mode: ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := hotkeyReadyText("", tt.mode); got != "触发键未设置" {
+ t.Fatalf("hotkeyReadyText(empty, %q) = %q, want unset prompt", tt.mode, got)
+ }
+ })
+ }
+}
+
+func TestHotkeyReadyTextConfiguredKey(t *testing.T) {
+ tests := []struct {
+ name string
+ key string
+ mode string
+ want string
+ }{
+ {name: "hold", key: "R-Win", mode: config.HotkeyModeHold, want: "按住R-Win说话"},
+ {name: "tap", key: "R-Win", mode: config.HotkeyModeTap, want: "点按R-Win说话"},
+ {name: "unknown mode falls back to hold", key: "R-Win", mode: "", want: "按住R-Win说话"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := hotkeyReadyText(tt.key, tt.mode); got != tt.want {
+ t.Fatalf("hotkeyReadyText(%q, %q) = %q, want %q", tt.key, tt.mode, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/C1.source/privatevoice.src/app_live_caption_test.go b/C1.source/privatevoice.src/app_live_caption_test.go
index accb99c..1a13c1d 100644
--- a/C1.source/privatevoice.src/app_live_caption_test.go
+++ b/C1.source/privatevoice.src/app_live_caption_test.go
@@ -1,12 +1,19 @@
package main
import (
+ "context"
+ "errors"
"strings"
"sync/atomic"
"testing"
"time"
+ "voicesnap/internal/config"
"voicesnap/internal/engine"
+ "voicesnap/internal/history"
+ "voicesnap/internal/overlay"
+ "voicesnap/internal/textoutput"
+ "voicesnap/internal/userdict"
)
func TestLiveCaptionDisplayTextFitsOverlayBuffer(t *testing.T) {
@@ -255,6 +262,760 @@
}
}
+func TestHoldPreCaptureAutoConfirmsWithoutAdditionalKeyDown(t *testing.T) {
+ a, cleanup := newHoldPreCaptureTestApp()
+ defer cleanup()
+ a.hk.(*fakeHotkeyListener).down.Store(true)
+
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(true, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.isRecording && !a.isHoldRecordingPending && a.hotkeyActive
+ })
+
+ if got := a.recorder.(*fakeAppRecorder).starts.Load(); got != 1 {
+ t.Fatalf("recorder starts = %d, want 1", got)
+ }
+ if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got != string(overlay.StatusRecording) {
+ t.Fatalf("indicator status = %q, want %q", got, overlay.StatusRecording)
+ }
+}
+
+func TestHoldPreCaptureTimerConfirmsWhileRecorderStartBlocked(t *testing.T) {
+ a, cleanup := newHoldPreCaptureTestApp()
+ defer cleanup()
+ a.hk.(*fakeHotkeyListener).down.Store(true)
+ recorder := a.recorder.(*fakeAppRecorder)
+ blockStart := make(chan struct{})
+ recorder.startBlock = blockStart
+
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(true, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ return recorder.starts.Load() == 1
+ })
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.isRecording && !a.isHoldRecordingPending && a.hotkeyActive
+ })
+
+ if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got != string(overlay.StatusRecording) {
+ t.Fatalf("indicator status = %q, want %q while recorder start is blocked", got, overlay.StatusRecording)
+ }
+ close(blockStart)
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return !a.isHoldRecorderStarting
+ })
+}
+
+func TestHoldPreCaptureReleaseAfterConfirmRunsRecognitionWhileRecorderStartBlocked(t *testing.T) {
+ a, cleanup := newHoldPreCaptureTestApp()
+ defer cleanup()
+ listener := a.hk.(*fakeHotkeyListener)
+ listener.down.Store(true)
+ recorder := a.recorder.(*fakeAppRecorder)
+ blockStart := make(chan struct{})
+ recorder.startBlock = blockStart
+ recorder.samples = []float32{0.2, 0.1, -0.1, -0.2}
+ recorder.hasVoice.Store(true)
+ eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
+ a.replaceEngine(eng)
+ paster := &fakeAppPaster{}
+ a.outputRouter = textoutput.NewRouter(paster)
+
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(true, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting
+ })
+
+ listener.down.Store(false)
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(false, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ return paster.pasted.Load() == "PrivateVoice AX POC"
+ })
+
+ if got := recorder.stopAndGetSamples.Load(); got != 1 {
+ t.Fatalf("StopAndGetSamples while recorder start is blocked = %d, want 1", got)
+ }
+ if got := eng.recognizes.Load(); got != 1 {
+ t.Fatalf("recognizes = %d, want 1", got)
+ }
+ a.mu.Lock()
+ if a.isHoldStopPending {
+ t.Fatal("normal release while recorder is starting should not mark cancel-style pending stop")
+ }
+ a.mu.Unlock()
+
+ close(blockStart)
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return !a.isRecording && !a.isHoldRecorderStarting && !a.isHoldStopPending
+ })
+ if got := recorder.stops.Load(); got != 0 {
+ t.Fatalf("app-level fake recorder stops = %d, want 0; native cleanup is covered by audio recorder tests", got)
+ }
+}
+
+func TestHoldPreCaptureReleaseAfterConfirmRunsRecognitionBeforeRecorderStartEntered(t *testing.T) {
+ a, cleanup := newHoldPreCaptureTestApp()
+ defer cleanup()
+ listener := a.hk.(*fakeHotkeyListener)
+ listener.down.Store(true)
+ recorder := a.recorder.(*fakeAppRecorder)
+ preStartBlock := make(chan struct{})
+ recorder.preStartBlock = preStartBlock
+ recorder.samples = []float32{0.2, 0.1, -0.1, -0.2}
+ recorder.hasVoice.Store(true)
+ eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
+ a.replaceEngine(eng)
+ paster := &fakeAppPaster{}
+ a.outputRouter = textoutput.NewRouter(paster)
+
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(true, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting
+ })
+ if got := recorder.starts.Load(); got != 0 {
+ t.Fatalf("recorder Start entered before release = %d, want 0", got)
+ }
+
+ listener.down.Store(false)
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(false, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ return paster.pasted.Load() == "PrivateVoice AX POC"
+ })
+ if got := recorder.stopAndGetSamples.Load(); got != 1 {
+ t.Fatalf("StopAndGetSamples before recorder Start entered = %d, want 1", got)
+ }
+ if got := eng.recognizes.Load(); got != 1 {
+ t.Fatalf("recognizes = %d, want 1", got)
+ }
+
+ close(preStartBlock)
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return !a.isRecording && !a.isHoldRecorderStarting && !a.isHoldStopPending
+ })
+ if got := recorder.stops.Load(); got != 0 {
+ t.Fatalf("app-level fake recorder stops = %d, want 0 for normal release pipeline", got)
+ }
+ if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got != string(overlay.StatusDone) {
+ t.Fatalf("indicator status = %q, want %q", got, overlay.StatusDone)
+ }
+}
+
+func TestHoldPreCaptureReleaseWaitsForStartupSamplesBeforeRecognition(t *testing.T) {
+ a, cleanup := newHoldPreCaptureTestApp()
+ defer cleanup()
+ listener := a.hk.(*fakeHotkeyListener)
+ listener.down.Store(true)
+ recorder := a.recorder.(*fakeAppRecorder)
+ blockStart := make(chan struct{})
+ readSamplesReady := make(chan struct{})
+ recorder.startBlock = blockStart
+ recorder.readSamplesBlock = readSamplesReady
+ recorder.samples = []float32{0.2, 0.1, -0.1, -0.2}
+ recorder.hasVoice.Store(true)
+ eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
+ a.replaceEngine(eng)
+ paster := &fakeAppPaster{}
+ a.outputRouter = textoutput.NewRouter(paster)
+
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(true, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting
+ })
+
+ listener.down.Store(false)
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(false, true)
+ a.mu.Unlock()
+
+ time.Sleep(holdStartupSamplePollInterval * 2)
+ if got := recorder.stopAndGetSamples.Load(); got != 0 {
+ t.Fatalf("StopAndGetSamples before startup samples = %d, want 0", got)
+ }
+ if got := eng.recognizes.Load(); got != 0 {
+ t.Fatalf("recognizes before startup samples = %d, want 0", got)
+ }
+
+ close(readSamplesReady)
+ waitForCondition(t, time.Second, func() bool {
+ return paster.pasted.Load() == "PrivateVoice AX POC"
+ })
+ if got := recorder.stopAndGetSamples.Load(); got != 1 {
+ t.Fatalf("StopAndGetSamples after startup samples = %d, want 1", got)
+ }
+ if got := eng.recognizes.Load(); got != 1 {
+ t.Fatalf("recognizes = %d, want 1", got)
+ }
+
+ close(blockStart)
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return !a.isRecording && !a.isHoldRecorderStarting && !a.isHoldStopPending
+ })
+}
+
+func TestHoldPreCaptureReleaseWithoutStartupSamplesReportsAudioNotReady(t *testing.T) {
+ oldTimeout := holdStartupSampleWaitTimeout
+ oldPoll := holdStartupSamplePollInterval
+ holdStartupSampleWaitTimeout = 40 * time.Millisecond
+ holdStartupSamplePollInterval = 5 * time.Millisecond
+ defer func() {
+ holdStartupSampleWaitTimeout = oldTimeout
+ holdStartupSamplePollInterval = oldPoll
+ }()
+
+ a, cleanup := newHoldPreCaptureTestApp()
+ defer cleanup()
+ listener := a.hk.(*fakeHotkeyListener)
+ listener.down.Store(true)
+ recorder := a.recorder.(*fakeAppRecorder)
+ blockStart := make(chan struct{})
+ recorder.startBlock = blockStart
+ eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
+ a.replaceEngine(eng)
+ paster := &fakeAppPaster{}
+ a.outputRouter = textoutput.NewRouter(paster)
+
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(true, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting
+ })
+
+ listener.down.Store(false)
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(false, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ return recorder.stops.Load() == 1 &&
+ a.indicator.(*fakeOverlay).lastStatus.Load() == string(overlay.StatusError)
+ })
+ if got := recorder.stopAndGetSamples.Load(); got != 0 {
+ t.Fatalf("StopAndGetSamples after audio-not-ready timeout = %d, want 0", got)
+ }
+ if got := eng.recognizes.Load(); got != 0 {
+ t.Fatalf("recognizes after audio-not-ready timeout = %d, want 0", got)
+ }
+ if got := paster.pasted.Load(); got != nil {
+ t.Fatalf("pasted text after audio-not-ready timeout = %v, want nil", got)
+ }
+ if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got != string(overlay.StatusError) {
+ t.Fatalf("indicator status = %q, want %q", got, overlay.StatusError)
+ }
+
+ close(blockStart)
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return !a.isRecording && !a.isHoldRecorderStarting && !a.isHoldStopPending
+ })
+}
+
+func TestHoldPreCaptureReleaseWhileRecorderPermissionPendingDoesNotRecognize(t *testing.T) {
+ oldTimeout := holdStartupSampleWaitTimeout
+ oldPoll := holdStartupSamplePollInterval
+ holdStartupSampleWaitTimeout = 40 * time.Millisecond
+ holdStartupSamplePollInterval = 5 * time.Millisecond
+ defer func() {
+ holdStartupSampleWaitTimeout = oldTimeout
+ holdStartupSamplePollInterval = oldPoll
+ }()
+
+ a, cleanup := newHoldPreCaptureTestApp()
+ defer cleanup()
+ listener := a.hk.(*fakeHotkeyListener)
+ listener.down.Store(true)
+ recorder := a.recorder.(*fakeAppRecorder)
+ preStartBlock := make(chan struct{})
+ recorder.preStartBlock = preStartBlock
+ eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
+ a.replaceEngine(eng)
+ paster := &fakeAppPaster{}
+ a.outputRouter = textoutput.NewRouter(paster)
+
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(true, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting
+ })
+ if got := recorder.starts.Load(); got != 0 {
+ t.Fatalf("recorder Start entered before permission pending release = %d, want 0", got)
+ }
+
+ listener.down.Store(false)
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(false, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ return recorder.stops.Load() == 1 &&
+ a.indicator.(*fakeOverlay).lastStatus.Load() == string(overlay.StatusError)
+ })
+ if got := recorder.stopAndGetSamples.Load(); got != 0 {
+ t.Fatalf("StopAndGetSamples after permission-pending audio-not-ready = %d, want 0", got)
+ }
+ if got := eng.recognizes.Load(); got != 0 {
+ t.Fatalf("recognizes after permission-pending audio-not-ready = %d, want 0", got)
+ }
+ if got := paster.pasted.Load(); got != nil {
+ t.Fatalf("pasted text after permission-pending audio-not-ready = %v, want nil", got)
+ }
+
+ close(preStartBlock)
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return !a.isRecording && !a.isHoldRecorderStarting && !a.isHoldStopPending
+ })
+ if got := recorder.starts.Load(); got != 1 {
+ t.Fatalf("recorder Start after permission resumes = %d, want 1", got)
+ }
+ if got := recorder.stops.Load(); got != 1 {
+ t.Fatalf("recorder stops after late permission success = %d, want 1", got)
+ }
+}
+
+func TestHoldPreCaptureEscapeAfterConfirmDefersStopUntilRecorderStart(t *testing.T) {
+ a, cleanup := newHoldPreCaptureTestApp()
+ defer cleanup()
+ listener := a.hk.(*fakeHotkeyListener)
+ listener.down.Store(true)
+ recorder := a.recorder.(*fakeAppRecorder)
+ blockStart := make(chan struct{})
+ recorder.startBlock = blockStart
+ captionCancelled := false
+
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(true, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting
+ })
+
+ a.mu.Lock()
+ a.liveCaptionCancel = func() { captionCancelled = true }
+ a.mu.Unlock()
+ listener.escapeDown.Store(true)
+
+ done := make(chan struct{})
+ go func() {
+ a.pollHotkey()
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-time.After(100 * time.Millisecond):
+ t.Fatal("Escape cancel should not wait for blocked recorder start")
+ }
+
+ if got := recorder.stops.Load(); got != 1 {
+ t.Fatalf("recorder stops before start returned = %d, want 1", got)
+ }
+ if got := recorder.stopAndGetSamples.Load(); got != 0 {
+ t.Fatalf("StopAndGetSamples before recorder start returned = %d, want 0", got)
+ }
+ a.mu.Lock()
+ if !a.isHoldStopPending {
+ t.Fatal("Escape while recorder is starting should mark pending stop")
+ }
+ if a.liveCaptionCancel != nil {
+ t.Fatal("Escape while recorder is starting should clear live caption")
+ }
+ a.mu.Unlock()
+ if !captionCancelled {
+ t.Fatal("Escape while recorder is starting should cancel live caption")
+ }
+
+ close(blockStart)
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return !a.isRecording && !a.isHoldRecorderStarting && !a.isHoldStopPending
+ })
+
+ if got := recorder.stopAndGetSamples.Load(); got != 0 {
+ t.Fatalf("StopAndGetSamples after deferred Escape stop = %d, want 0", got)
+ }
+ if got := recorder.stops.Load(); got != 1 {
+ t.Fatalf("recorder stops = %d, want 1", got)
+ }
+ if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got != string(overlay.StatusCancelled) {
+ t.Fatalf("indicator status = %q, want %q", got, overlay.StatusCancelled)
+ }
+}
+
+func TestPollHotkeyIgnoresUnsetHotkey(t *testing.T) {
+ a, cleanup := newHoldPreCaptureTestApp()
+ defer cleanup()
+ listener := a.hk.(*fakeHotkeyListener)
+ listener.down.Store(true)
+ a.cfg.HotkeyVK = config.UnsetHotkeyVK
+
+ a.pollHotkey()
+
+ if got := listener.keyChecks.Load(); got != 0 {
+ t.Fatalf("IsKeyDown calls for unset hotkey = %d, want 0", got)
+ }
+ if a.hotkeyActive {
+ t.Fatal("unset hotkey must not become active")
+ }
+ if a.isRecording || a.isFreetalking {
+ t.Fatal("unset hotkey must not start recording")
+ }
+}
+
+func TestHoldPreCaptureEscapeWhileRecorderPermissionPendingStopsLateStart(t *testing.T) {
+ a, cleanup := newHoldPreCaptureTestApp()
+ defer cleanup()
+ listener := a.hk.(*fakeHotkeyListener)
+ listener.down.Store(true)
+ recorder := a.recorder.(*fakeAppRecorder)
+ preStartBlock := make(chan struct{})
+ recorder.preStartBlock = preStartBlock
+ eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
+ a.replaceEngine(eng)
+
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(true, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting
+ })
+ if got := recorder.starts.Load(); got != 0 {
+ t.Fatalf("recorder Start entered before Escape during permission pending = %d, want 0", got)
+ }
+
+ listener.escapeDown.Store(true)
+ done := make(chan struct{})
+ go func() {
+ a.pollHotkey()
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-time.After(100 * time.Millisecond):
+ t.Fatal("Escape during permission pending should not wait for recorder Start")
+ }
+ if got := recorder.stops.Load(); got != 1 {
+ t.Fatalf("recorder Stop during permission-pending Escape = %d, want 1", got)
+ }
+ if got := recorder.stopAndGetSamples.Load(); got != 0 {
+ t.Fatalf("StopAndGetSamples during permission-pending Escape = %d, want 0", got)
+ }
+ if got := eng.recognizes.Load(); got != 0 {
+ t.Fatalf("recognizes during permission-pending Escape = %d, want 0", got)
+ }
+
+ close(preStartBlock)
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return !a.isRecording && !a.isHoldRecorderStarting && !a.isHoldStopPending
+ })
+ if got := recorder.stops.Load(); got != 1 {
+ t.Fatalf("recorder stops after late permission success = %d, want 1", got)
+ }
+ if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got != string(overlay.StatusCancelled) {
+ t.Fatalf("indicator status = %q, want %q", got, overlay.StatusCancelled)
+ }
+}
+
+func TestHoldPreCaptureCombinationWhileRecorderPermissionPendingStopsLateStart(t *testing.T) {
+ a, cleanup := newHoldPreCaptureTestApp()
+ defer cleanup()
+ listener := a.hk.(*fakeHotkeyListener)
+ listener.down.Store(true)
+ recorder := a.recorder.(*fakeAppRecorder)
+ preStartBlock := make(chan struct{})
+ recorder.preStartBlock = preStartBlock
+ eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
+ a.replaceEngine(eng)
+
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(true, true)
+ a.mu.Unlock()
+ listener.otherSince.Store(true)
+
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.isCombination && !a.isRecording && !a.isHoldRecordingPending && a.isHoldStopPending
+ })
+ if got := recorder.stops.Load(); got != 1 {
+ t.Fatalf("recorder Stop during permission-pending combination cancel = %d, want 1", got)
+ }
+ if got := recorder.stopAndGetSamples.Load(); got != 0 {
+ t.Fatalf("StopAndGetSamples during permission-pending combination cancel = %d, want 0", got)
+ }
+ if got := eng.recognizes.Load(); got != 0 {
+ t.Fatalf("recognizes during permission-pending combination cancel = %d, want 0", got)
+ }
+
+ close(preStartBlock)
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return !a.isRecording && !a.isHoldRecorderStarting && !a.isHoldStopPending
+ })
+ if got := recorder.stops.Load(); got != 1 {
+ t.Fatalf("recorder stops after late combination permission success = %d, want 1", got)
+ }
+}
+
+func TestHoldPreCaptureReleaseAfterRecorderStartRunsRecognitionAndOutput(t *testing.T) {
+ a, cleanup := newHoldPreCaptureTestApp()
+ defer cleanup()
+ listener := a.hk.(*fakeHotkeyListener)
+ listener.down.Store(true)
+ recorder := a.recorder.(*fakeAppRecorder)
+ recorder.samples = []float32{0.2, 0.1, -0.1, -0.2}
+ recorder.hasVoice.Store(true)
+ eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
+ a.replaceEngine(eng)
+ paster := &fakeAppPaster{}
+ a.outputRouter = textoutput.NewRouter(paster)
+
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(true, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.isRecording && !a.isHoldRecordingPending && !a.isHoldRecorderStarting
+ })
+
+ listener.down.Store(false)
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(false, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ return paster.pasted.Load() == "PrivateVoice AX POC"
+ })
+
+ if got := recorder.stopAndGetSamples.Load(); got != 1 {
+ t.Fatalf("StopAndGetSamples = %d, want 1", got)
+ }
+ if got := eng.recognizes.Load(); got != 1 {
+ t.Fatalf("recognizes = %d, want 1", got)
+ }
+ if got := recorder.stops.Load(); got != 0 {
+ t.Fatalf("recorder Stop = %d, want 0 for normal recognition path", got)
+ }
+}
+
+func TestHoldPreCaptureReleasePipelineSuppressesLateRecorderStartError(t *testing.T) {
+ a, cleanup := newHoldPreCaptureTestApp()
+ defer cleanup()
+ listener := a.hk.(*fakeHotkeyListener)
+ listener.down.Store(true)
+ recorder := a.recorder.(*fakeAppRecorder)
+ blockStart := make(chan struct{})
+ recorder.startBlock = blockStart
+ recorder.startErr = errors.New("start failed")
+ recorder.samples = []float32{0.2, 0.1, -0.1, -0.2}
+ recorder.hasVoice.Store(true)
+ eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
+ a.replaceEngine(eng)
+ paster := &fakeAppPaster{}
+ a.outputRouter = textoutput.NewRouter(paster)
+
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(true, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting
+ })
+
+ listener.down.Store(false)
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(false, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ return paster.pasted.Load() == "PrivateVoice AX POC"
+ })
+ close(blockStart)
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return !a.isHoldRecorderStarting
+ })
+
+ if got := eng.recognizes.Load(); got != 1 {
+ t.Fatalf("recognizes = %d, want 1", got)
+ }
+ if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got != string(overlay.StatusDone) {
+ t.Fatalf("late recorder start error overwrote indicator status = %q, want %q", got, overlay.StatusDone)
+ }
+}
+
+func TestHoldPreCaptureRecorderStartLateErrorCleansConfirmedState(t *testing.T) {
+ a, cleanup := newHoldPreCaptureTestApp()
+ defer cleanup()
+ a.hk.(*fakeHotkeyListener).down.Store(true)
+ recorder := a.recorder.(*fakeAppRecorder)
+ blockStart := make(chan struct{})
+ recorder.startBlock = blockStart
+ recorder.startErr = errors.New("start failed")
+ captionCancelled := false
+ a.liveCaptionCancel = func() { captionCancelled = true }
+
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(true, true)
+ a.mu.Unlock()
+
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting && a.liveCaptionCancel != nil
+ })
+
+ close(blockStart)
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return !a.isRecording && !a.isHoldRecorderStarting && a.liveCaptionCancel == nil
+ })
+
+ if !captionCancelled {
+ t.Fatal("late recorder start error should cancel live caption")
+ }
+ if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got != string(overlay.StatusError) {
+ t.Fatalf("indicator status = %q, want %q", got, overlay.StatusError)
+ }
+}
+
+func TestHoldPreCaptureReleaseBeforeActivationCancelsTimer(t *testing.T) {
+ a, cleanup := newHoldPreCaptureTestApp()
+ defer cleanup()
+ a.hk.(*fakeHotkeyListener).down.Store(true)
+
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(true, true)
+ a.hk.(*fakeHotkeyListener).down.Store(false)
+ a.pollHoldHotkeyLocked(false, true)
+ a.mu.Unlock()
+
+ time.Sleep(holdActivationDelay + 50*time.Millisecond)
+
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if a.isRecording || a.isHoldRecordingPending {
+ t.Fatalf("recording=%t pending=%t, want both false after early release", a.isRecording, a.isHoldRecordingPending)
+ }
+ if got := a.recorder.(*fakeAppRecorder).stops.Load(); got != 1 {
+ t.Fatalf("recorder stops = %d, want 1", got)
+ }
+}
+
+func TestHoldPreCaptureTimerCancelsWhenKeyReleasedBeforePoll(t *testing.T) {
+ a, cleanup := newHoldPreCaptureTestApp()
+ defer cleanup()
+ a.hk.(*fakeHotkeyListener).down.Store(true)
+
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(true, true)
+ a.mu.Unlock()
+
+ a.hk.(*fakeHotkeyListener).down.Store(false)
+
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return !a.isRecording && !a.isHoldRecordingPending
+ })
+
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if a.hotkeyActive != true {
+ t.Fatal("hotkeyActive should remain true until release poll handles the key-up")
+ }
+ if got := a.recorder.(*fakeAppRecorder).stops.Load(); got != 1 {
+ t.Fatalf("recorder stops = %d, want 1", got)
+ }
+ if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got == string(overlay.StatusRecording) {
+ t.Fatalf("indicator status = %q, should not confirm recording after physical release", got)
+ }
+}
+
+func TestHoldPreCaptureCombinationBeforeActivationCancels(t *testing.T) {
+ a, cleanup := newHoldPreCaptureTestApp()
+ defer cleanup()
+ a.hk.(*fakeHotkeyListener).down.Store(true)
+
+ a.mu.Lock()
+ a.pollHoldHotkeyLocked(true, true)
+ a.mu.Unlock()
+
+ a.hk.(*fakeHotkeyListener).otherSince.Store(true)
+
+ waitForCondition(t, time.Second, func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.isCombination && !a.isRecording && !a.isHoldRecordingPending
+ })
+
+ if got := a.recorder.(*fakeAppRecorder).stops.Load(); got != 1 {
+ t.Fatalf("recorder stops = %d, want 1", got)
+ }
+}
+
func TestStartRecordingIgnoredWhileStoppingRecording(t *testing.T) {
a := &App{isStoppingRecording: true}
@@ -307,3 +1068,134 @@
func (e holdPreCaptureTestEngine) HoldPreCaptureEnabled() bool {
return e.enabled
}
+
+func newHoldPreCaptureTestApp() (*App, context.CancelFunc) {
+ ctx, cancel := context.WithCancel(context.Background())
+ a := &App{
+ ctx: ctx,
+ cancel: cancel,
+ cfg: &config.Config{HotkeyVK: 0x5C, HotkeyMode: config.HotkeyModeHold},
+ recorder: &fakeAppRecorder{},
+ hk: &fakeHotkeyListener{},
+ indicator: &fakeOverlay{},
+ history: history.New(),
+ userdict: userdict.New(),
+ }
+ a.replaceEngine(holdPreCaptureTestEngine{enabled: true})
+ a.holdPreCaptureEnabled.Store(true)
+ return a, cancel
+}
+
+type fakeAppRecorder struct {
+ starts atomic.Int32
+ stops atomic.Int32
+ stopAndGetSamples atomic.Int32
+ hasVoice atomic.Bool
+ samples []float32
+ preStartBlock <-chan struct{}
+ startBlock <-chan struct{}
+ readSamplesBlock <-chan struct{}
+ startErr error
+}
+
+func (r *fakeAppRecorder) OnVolume(func(float64)) {}
+func (r *fakeAppRecorder) OnDeviceChange(func(string)) {}
+func (r *fakeAppRecorder) Start() error {
+ if r.preStartBlock != nil {
+ <-r.preStartBlock
+ }
+ r.starts.Add(1)
+ if r.startBlock != nil {
+ <-r.startBlock
+ }
+ return r.startErr
+}
+func (r *fakeAppRecorder) Stop() { r.stops.Add(1) }
+func (r *fakeAppRecorder) StopAndGetSamples() []float32 {
+ r.stopAndGetSamples.Add(1)
+ return append([]float32(nil), r.samples...)
+}
+func (r *fakeAppRecorder) HasVoiceActivity() bool { return r.hasVoice.Load() }
+func (r *fakeAppRecorder) ReadSamplesSince(offset int) ([]float32, int) {
+ if r.readSamplesBlock != nil {
+ select {
+ case <-r.readSamplesBlock:
+ default:
+ return nil, 0
+ }
+ }
+ total := len(r.samples)
+ if offset < 0 {
+ offset = 0
+ }
+ if offset > total {
+ offset = total
+ }
+ return append([]float32(nil), r.samples[offset:]...), total
+}
+func (r *fakeAppRecorder) Close() {}
+
+type recognizingHoldEngine struct {
+ text string
+ recognizes atomic.Int32
+}
+
+func (e *recognizingHoldEngine) Recognize([]float32) (string, error) {
+ e.recognizes.Add(1)
+ return e.text, nil
+}
+func (e *recognizingHoldEngine) HardwareInfo() string { return "test" }
+func (e *recognizingHoldEngine) Close() {}
+func (e *recognizingHoldEngine) HoldPreCaptureEnabled() bool { return true }
+
+type fakeAppPaster struct {
+ pasted atomic.Value
+ typed atomic.Value
+}
+
+func (p *fakeAppPaster) Paste(text string, keepClipboard bool) error {
+ p.pasted.Store(text)
+ return nil
+}
+
+func (p *fakeAppPaster) TypeText(text string) error {
+ p.typed.Store(text)
+ return nil
+}
+
+type fakeHotkeyListener struct {
+ down atomic.Bool
+ escapeDown atomic.Bool
+ otherSince atomic.Bool
+ keyChecks atomic.Int32
+}
+
+func (h *fakeHotkeyListener) IsKeyDown(key int) bool {
+ h.keyChecks.Add(1)
+ if key == 0x1B {
+ return h.escapeDown.Load()
+ }
+ return h.down.Load()
+}
+func (h *fakeHotkeyListener) IsAnyOtherKeyPressed(int) bool { return false }
+func (h *fakeHotkeyListener) IsAnyOtherKeyPressedSince(int, time.Time) bool {
+ return h.otherSince.Load()
+}
+func (h *fakeHotkeyListener) Close() {}
+
+type fakeOverlay struct {
+ lastStatus atomic.Value
+}
+
+func (o *fakeOverlay) Show() {}
+func (o *fakeOverlay) Hide() {}
+func (o *fakeOverlay) SetStatus(status overlay.Status, text string) {
+ o.lastStatus.Store(string(status))
+}
+func (o *fakeOverlay) SetVolume(float64) {}
+func (o *fakeOverlay) SetPosition(int, int) {}
+func (o *fakeOverlay) GetPosition() (int, int) { return 0, 0 }
+func (o *fakeOverlay) Size() (int, int) { return 170, 48 }
+func (o *fakeOverlay) OnDragged(func(int, int)) {}
+func (o *fakeOverlay) Close() {}
+func (o *fakeOverlay) AutoPosition() {}
diff --git a/C1.source/privatevoice.src/app_text_output_event_test.go b/C1.source/privatevoice.src/app_text_output_event_test.go
new file mode 100644
index 0000000..6886248
--- /dev/null
+++ b/C1.source/privatevoice.src/app_text_output_event_test.go
@@ -0,0 +1,35 @@
+package main
+
+import (
+ "testing"
+ "voicesnap/internal/textoutput"
+)
+
+func TestTextOutputUpdatePayloadExposesFallbackStateWithoutSensitiveText(t *testing.T) {
+ payload := textOutputUpdatePayload(textoutput.Result{
+ OK: false,
+ Text: "Private voice terminal.",
+ Method: "ax_value_replacement",
+ Target: `app=Terminal title="Secret Document.txt" role=AXTextArea`,
+ NeedsPermission: true,
+ FallbackAvailable: true,
+ })
+
+ if payload["ok"] != false {
+ t.Fatalf("ok = %v, want false", payload["ok"])
+ }
+ if payload["needsPermission"] != true {
+ t.Fatalf("needsPermission = %v, want true", payload["needsPermission"])
+ }
+ if payload["fallbackAvailable"] != true {
+ t.Fatalf("fallbackAvailable = %v, want true", payload["fallbackAvailable"])
+ }
+ if payload["hasText"] != true {
+ t.Fatalf("hasText = %v, want true", payload["hasText"])
+ }
+ for _, key := range []string{"text", "transcript", "target", "method", "message"} {
+ if _, ok := payload[key]; ok {
+ t.Fatalf("payload should not expose %q", key)
+ }
+ }
+}
diff --git a/C1.source/privatevoice.src/app_version_test.go b/C1.source/privatevoice.src/app_version_test.go
new file mode 100644
index 0000000..62bdb66
--- /dev/null
+++ b/C1.source/privatevoice.src/app_version_test.go
@@ -0,0 +1,19 @@
+package main
+
+import "testing"
+
+func TestAppDisplayVersionUsesVersionAndBuild(t *testing.T) {
+ origVersion := appVersion
+ origBuild := appBuild
+ t.Cleanup(func() {
+ appVersion = origVersion
+ appBuild = origBuild
+ })
+
+ appVersion = "9.8.7"
+ appBuild = "20260709.0200"
+
+ if got, want := appDisplayVersion(), "9.8.7 (build 20260709.0200)"; got != want {
+ t.Fatalf("appDisplayVersion() = %q, want %q", got, want)
+ }
+}
diff --git a/C1.source/privatevoice.src/build/darwin/PrivacyInfo.xcprivacy b/C1.source/privatevoice.src/build/darwin/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..1cbcec2
--- /dev/null
+++ b/C1.source/privatevoice.src/build/darwin/PrivacyInfo.xcprivacy
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>NSPrivacyAccessedAPITypes</key>
+ <array>
+ <dict>
+ <key>NSPrivacyAccessedAPIType</key>
+ <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
+ <key>NSPrivacyAccessedAPITypeReasons</key>
+ <array>
+ <string>C617.1</string>
+ </array>
+ </dict>
+ </array>
+ <key>NSPrivacyCollectedDataTypes</key>
+ <array/>
+ <key>NSPrivacyTracking</key>
+ <false/>
+ <key>NSPrivacyTrackingDomains</key>
+ <array/>
+</dict>
+</plist>
diff --git a/C1.source/privatevoice.src/frontend/package-lock.json b/C1.source/privatevoice.src/frontend/package-lock.json
index b860444..49d5361 100755
--- a/C1.source/privatevoice.src/frontend/package-lock.json
+++ b/C1.source/privatevoice.src/frontend/package-lock.json
@@ -8,7 +8,7 @@
"name": "privatevoice-dictation-frontend",
"version": "2.2.2",
"dependencies": {
- "@wailsio/runtime": "latest"
+ "@wailsio/runtime": "3.0.0-alpha.79"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
diff --git a/C1.source/privatevoice.src/frontend/package.json b/C1.source/privatevoice.src/frontend/package.json
index 6207e6e..52354dc 100755
--- a/C1.source/privatevoice.src/frontend/package.json
+++ b/C1.source/privatevoice.src/frontend/package.json
@@ -9,7 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
- "@wailsio/runtime": "latest"
+ "@wailsio/runtime": "3.0.0-alpha.79"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
diff --git a/C1.source/privatevoice.src/frontend/src/App.svelte b/C1.source/privatevoice.src/frontend/src/App.svelte
index e91d231..58a1c89 100755
--- a/C1.source/privatevoice.src/frontend/src/App.svelte
+++ b/C1.source/privatevoice.src/frontend/src/App.svelte
@@ -43,7 +43,7 @@
if (data?.status) {
indicatorStatus.set(data.status)
}
- if (data?.hotkeyName) {
+ if (typeof data?.hotkeyName === 'string') {
hotkeyName.set(data.hotkeyName)
}
}))
@@ -97,9 +97,9 @@
refreshCurrentModelAvailability()
- // Load actual hotkey name from backend (store default is generic "Ctrl")
+ // Load actual hotkey name from backend; an empty name means no trigger key is set yet.
Call.ByName('voicesnap/services.HotkeyService.GetHotkeyName').then((name: any) => {
- if (name) {
+ if (typeof name === 'string') {
hotkeyName.set(name)
}
})
diff --git a/C1.source/privatevoice.src/frontend/src/components/indicator/FloatingIndicator.svelte b/C1.source/privatevoice.src/frontend/src/components/indicator/FloatingIndicator.svelte
index f15da74..2468466 100755
--- a/C1.source/privatevoice.src/frontend/src/components/indicator/FloatingIndicator.svelte
+++ b/C1.source/privatevoice.src/frontend/src/components/indicator/FloatingIndicator.svelte
@@ -9,7 +9,7 @@
let status: IndicatorStatus = $state('hidden')
let statusText = $state('')
let barColor = $state('#007AFF')
- let key = $state('Ctrl')
+ let key = $state('')
const unsubVisible = indicatorVisible.subscribe(() => {})
const unsubStatus = indicatorStatus.subscribe(s => {
@@ -20,7 +20,10 @@
for (let i = 0; i < 4; i++) realVolumes[i] = realVolumes[i + 1]
realVolumes[4] = vol
})
- const unsubHotkey = hotkeyName.subscribe(k => { key = k })
+ const unsubHotkey = hotkeyName.subscribe(k => {
+ key = k
+ updateVisuals(status)
+ })
const AppleBlue = '#007AFF'
const AppleGreen = '#34C759'
@@ -32,7 +35,7 @@
case 'loading':
barColor = AppleBlue; statusText = '加载中'; break
case 'ready':
- barColor = AppleGreen; statusText = `按住${key}说话`; break
+ barColor = AppleGreen; statusText = key ? `按住${key}说话` : '触发键未设置'; break
case 'recording':
barColor = AppleRed; statusText = ''; break
case 'processing':
diff --git a/C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte b/C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
index 7b48d66..5cbd49c 100755
--- a/C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
+++ b/C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
@@ -1,9 +1,9 @@
<script lang="ts">
- import { onDestroy } from 'svelte'
- import { Call } from '@wailsio/runtime'
+ import { onDestroy, onMount } from 'svelte'
+ import { Call, Events } from '@wailsio/runtime'
import ToggleSwitch from '../shared/ToggleSwitch.svelte'
import { t } from '../../lib/i18n'
- import { autoHide, soundFeedback, copyToClipboard, startAtLogin, hotkeyVK, hotkeyMode } from '../../lib/stores/config'
+ import { autoHide, soundFeedback, copyToClipboard, autoPasteExperiment, startAtLogin, hotkeyVK, hotkeyMode } from '../../lib/stores/config'
import { deviceName, engineStatus, engineHardwareInfo } from '../../lib/stores/app'
import { hotkeyName } from '../../lib/stores/indicator'
@@ -12,22 +12,45 @@
isDefault: boolean
}
+ interface TextOutputResult {
+ ok?: boolean
+ needsPermission?: boolean
+ directInserted?: boolean
+ fallbackAvailable?: boolean
+ createdAt?: string
+ }
+
+ interface ClipboardFallbackResult {
+ ok?: boolean
+ prepared?: boolean
+ restored?: boolean
+ token?: string
+ }
+
let autoHideVal = $state(true)
let soundFeedbackVal = $state(true)
let copyToClipboardVal = $state(true)
+ let autoPasteExperimentVal = $state(false)
let startAtLoginVal = $state(false)
let hideDockIconVal = $state(false)
let settingsLoaded = $state(false)
let device = $state('Default')
let status = $state('loading')
let hwInfo = $state('')
- let currentKeyName = $state('Ctrl')
+ let currentKeyName = $state('')
let hotkeyModeVal: 'tap' | 'hold' = $state('hold')
let isRecording = $state(false)
let hintText = $state('')
let devices = $state<InputDevice[]>([])
let showDeviceDropdown = $state(false)
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null
+ let textOutput = $state<TextOutputResult | null>(null)
+ let outputStatusText = $state('')
+ let outputActionText = $state('')
+ let fallbackToken = $state('')
+ let fallbackPrepared = $state(false)
+ let fallbackBusy = $state(false)
+ let lastOutputCreatedAt = $state('')
const unsub1 = autoHide.subscribe(v => { autoHideVal = v })
const unsub2 = startAtLogin.subscribe(v => { startAtLoginVal = v })
@@ -38,9 +61,10 @@
const unsub7 = soundFeedback.subscribe(v => { soundFeedbackVal = v })
const unsub8 = copyToClipboard.subscribe(v => { copyToClipboardVal = v })
const unsub9 = hotkeyMode.subscribe(v => { hotkeyModeVal = v })
+ const unsub10 = autoPasteExperiment.subscribe(v => { autoPasteExperimentVal = v })
onDestroy(() => {
- unsub1(); unsub2(); unsub3(); unsub4(); unsub5(); unsub6(); unsub7(); unsub8(); unsub9()
+ unsub1(); unsub2(); unsub3(); unsub4(); unsub5(); unsub6(); unsub7(); unsub8(); unsub9(); unsub10()
clearHotkeyCapture()
})
@@ -78,10 +102,26 @@
copyToClipboardVal = copy
} catch {}
try {
+ const autoPaste: any = await Call.ByName('voicesnap/services.ConfigService.GetAutoPasteExperiment')
+ autoPasteExperiment.set(!!autoPaste)
+ autoPasteExperimentVal = !!autoPaste
+ } catch {}
+ try {
const mode: any = await Call.ByName('voicesnap/services.ConfigService.GetHotkeyMode')
const normalized = mode === 'tap' ? 'tap' : 'hold'
hotkeyMode.set(normalized)
hotkeyModeVal = normalized
+ } catch {}
+ try {
+ const vk: any = await Call.ByName('voicesnap/services.HotkeyService.GetCurrentHotkey')
+ if (typeof vk === 'number') {
+ hotkeyVK.set(vk)
+ }
+ const name: any = await Call.ByName('voicesnap/services.HotkeyService.GetHotkeyName')
+ if (typeof name === 'string') {
+ hotkeyName.set(name)
+ currentKeyName = name
+ }
} catch {}
try {
const hideDock: any = await Call.ByName('voicesnap/services.ConfigService.GetHideDockIcon')
@@ -90,6 +130,20 @@
settingsLoaded = true
}
loadSettings()
+
+ onMount(() => {
+ const offTextOutputUpdated = Events.On('text-output:updated', () => {
+ void refreshTextOutput()
+ })
+ const pollTextOutput = window.setInterval(() => {
+ void refreshTextOutput(true)
+ }, 1000)
+ void refreshTextOutput()
+ return () => {
+ window.clearInterval(pollTextOutput)
+ offTextOutputUpdated()
+ }
+ })
async function onAutoHideChange(checked: boolean) {
autoHide.set(checked)
@@ -109,6 +163,13 @@
copyToClipboard.set(checked)
try {
await Call.ByName('voicesnap/services.ConfigService.SetCopyToClipboard', checked)
+ } catch {}
+ }
+
+ async function onAutoPasteExperimentChange(checked: boolean) {
+ autoPasteExperiment.set(checked)
+ try {
+ await Call.ByName('voicesnap/services.ConfigService.SetAutoPasteExperiment', checked)
} catch {}
}
@@ -132,6 +193,101 @@
try {
await Call.ByName('voicesnap/services.ConfigService.SetHideDockIcon', checked)
} catch {}
+ }
+
+ async function refreshTextOutput(preserveActionText = false) {
+ try {
+ const result: TextOutputResult = await Call.ByName('voicesnap/services.TextOutputService.GetLastResult')
+ const nextOutputCreatedAt = outputResultKey(result)
+ const isNewOutput = !!nextOutputCreatedAt && !!lastOutputCreatedAt && nextOutputCreatedAt !== lastOutputCreatedAt
+ if (isNewOutput) {
+ fallbackToken = ''
+ fallbackPrepared = false
+ outputActionText = ''
+ }
+ if (nextOutputCreatedAt) {
+ lastOutputCreatedAt = nextOutputCreatedAt
+ }
+ textOutput = result || null
+ outputStatusText = textOutputStatus(result)
+ if (!isNewOutput && !preserveActionText && !fallbackPrepared) {
+ outputActionText = ''
+ }
+ } catch {
+ textOutput = null
+ outputStatusText = t('settings.textOutputUnavailable')
+ }
+ }
+
+ async function prepareClipboardFallback() {
+ if (fallbackPrepared) return
+ fallbackBusy = true
+ try {
+ const result: ClipboardFallbackResult = await Call.ByName('voicesnap/services.TextOutputService.PrepareLastClipboardFallback')
+ if (result?.ok && result?.prepared && result?.token) {
+ fallbackToken = result.token
+ fallbackPrepared = true
+ outputActionText = t('settings.textOutputFallbackPrepared')
+ } else {
+ outputActionText = t('settings.textOutputFallbackUnavailable')
+ }
+ } catch {
+ outputActionText = t('settings.textOutputFallbackUnavailable')
+ } finally {
+ fallbackBusy = false
+ void refreshTextOutput()
+ }
+ }
+
+ async function restoreClipboardFallback() {
+ if (!fallbackToken) return
+ fallbackBusy = true
+ try {
+ const result: ClipboardFallbackResult = await Call.ByName('voicesnap/services.TextOutputService.RestoreClipboardFallback', fallbackToken)
+ fallbackToken = ''
+ fallbackPrepared = false
+ if (result?.ok && result?.restored) {
+ outputActionText = t('settings.textOutputClipboardRestored')
+ } else {
+ outputActionText = t('settings.textOutputClipboardKept')
+ }
+ } catch {
+ outputActionText = t('settings.textOutputClipboardRestoreFailed')
+ } finally {
+ fallbackBusy = false
+ void refreshTextOutput(true)
+ }
+ }
+
+ function textOutputStatus(result: TextOutputResult | null | undefined): string {
+ if (!hasOutputResult(result)) {
+ return t('settings.textOutputNoResult')
+ }
+ if (result.ok && result.directInserted) {
+ return t('settings.textOutputInserted')
+ }
+ if (result.ok) {
+ return t('settings.textOutputCompleted')
+ }
+ if (result.needsPermission) {
+ return t('settings.textOutputNeedsPermission')
+ }
+ if (result.fallbackAvailable) {
+ return t('settings.textOutputFallbackAvailable')
+ }
+ return t('settings.textOutputFailed')
+ }
+
+ function hasOutputResult(result: TextOutputResult | null | undefined): result is TextOutputResult {
+ if (!result) return false
+ if (result.ok || result.needsPermission || result.directInserted || result.fallbackAvailable) return true
+ return !!outputResultKey(result)
+ }
+
+ function outputResultKey(result: TextOutputResult | null | undefined): string {
+ const createdAt = result?.createdAt || ''
+ if (!createdAt || createdAt.startsWith('0001-')) return ''
+ return createdAt
}
async function toggleDeviceDropdown() {
@@ -179,6 +335,12 @@
e.preventDefault()
e.stopPropagation()
+ if (isHotkeyCaptureCancel(e)) {
+ hintText = t('status.cancelled')
+ clearHotkeyCapture()
+ return
+ }
+
const vk = mapKeyToVK(e)
if (vk > 0) {
isRecording = false
@@ -189,7 +351,7 @@
try {
await Call.ByName('voicesnap/services.HotkeyService.SetHotkey', vk)
const name: any = await Call.ByName('voicesnap/services.HotkeyService.GetKeyName', vk)
- if (name) {
+ if (typeof name === 'string') {
hotkeyName.set(name)
currentKeyName = name
}
@@ -205,6 +367,10 @@
activeKeyHandler = onKey
document.addEventListener('keydown', onKey)
+ }
+
+ function isHotkeyCaptureCancel(e: KeyboardEvent): boolean {
+ return e.key === 'Escape' || e.key === 'Esc'
}
function clearHotkeyCapture() {
@@ -256,14 +422,18 @@
<div class="hotkey-display">
<div class="hotkey-info">
<span class="hotkey-label">{t('home.hotkeyLabel')}</span>
- <div class="hotkey-key" class:recording={isRecording}>
- {isRecording ? '...' : currentKeyName}
+ <div class="hotkey-key" class:recording={isRecording} class:unset={!isRecording && !currentKeyName}>
+ {isRecording ? '...' : (currentKeyName || t('hotkeys.unset'))}
</div>
</div>
<button class="change-btn" onclick={startRecordingHotkey} disabled={isRecording}>
- {t('hotkeys.change')}
+ {currentKeyName ? t('hotkeys.change') : t('hotkeys.set')}
</button>
</div>
+
+ {#if !currentKeyName && !isRecording}
+ <p class="hint">{t('hotkeys.unsetDesc')}</p>
+ {/if}
{#if hintText}
<p class="hint" class:recording={isRecording}>{hintText}</p>
@@ -376,6 +546,48 @@
<div class="setting-row">
<div class="setting-info">
+ <span class="setting-label">{t('settings.autoPasteExperiment')}</span>
+ <span class="setting-desc">{t('settings.autoPasteExperimentDesc')}</span>
+ </div>
+ <ToggleSwitch checked={autoPasteExperimentVal} onchange={onAutoPasteExperimentChange} />
+ </div>
+
+ <div class="divider"></div>
+
+ <div class="text-output-panel">
+ <div class="setting-info">
+ <span class="setting-label">{t('settings.textOutputTitle')}</span>
+ <span class="setting-desc">{t('settings.textOutputDesc')}</span>
+ </div>
+ <div class="text-output-status" class:success={!!textOutput?.ok} class:warn={!!textOutput && !textOutput.ok}>
+ {outputStatusText || t('settings.textOutputNoResult')}
+ </div>
+ {#if outputActionText}
+ <div class="fallback-message">{outputActionText}</div>
+ {/if}
+ <div class="fallback-actions">
+ <button class="secondary-btn" onclick={() => refreshTextOutput()} disabled={fallbackBusy}>
+ {t('settings.textOutputRefresh')}
+ </button>
+ <button
+ class="primary-btn"
+ onclick={prepareClipboardFallback}
+ disabled={fallbackBusy || fallbackPrepared || !textOutput?.fallbackAvailable}
+ >
+ {t('settings.textOutputUseClipboard')}
+ </button>
+ {#if fallbackPrepared}
+ <button class="secondary-btn" onclick={restoreClipboardFallback} disabled={fallbackBusy || !fallbackToken}>
+ {t('settings.textOutputRestoreClipboard')}
+ </button>
+ {/if}
+ </div>
+ </div>
+
+ <div class="divider"></div>
+
+ <div class="setting-row">
+ <div class="setting-info">
<span class="setting-label">{t('settings.autoHide')}</span>
<span class="setting-desc">{t('settings.autoHideDesc')}</span>
</div>
@@ -465,6 +677,12 @@
animation: pulse 1s infinite;
}
+ .hotkey-key.unset {
+ color: var(--color-secondary-label);
+ font-size: var(--font-size-base);
+ font-weight: 500;
+ }
+
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
@@ -537,6 +755,70 @@
color: var(--color-tertiary-label);
}
+ .text-output-panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-xs) 0;
+ }
+
+ .text-output-status {
+ display: inline-flex;
+ align-self: flex-start;
+ padding: 4px 10px;
+ border-radius: var(--radius-sm);
+ background: var(--color-bg-secondary);
+ color: var(--color-secondary-label);
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ }
+
+ .text-output-status.success {
+ color: var(--color-green);
+ }
+
+ .text-output-status.warn {
+ color: var(--color-orange);
+ }
+
+ .fallback-message {
+ color: var(--color-secondary-label);
+ font-size: var(--font-size-sm);
+ line-height: 1.4;
+ }
+
+ .fallback-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--spacing-sm);
+ }
+
+ .primary-btn,
+ .secondary-btn {
+ border: none;
+ border-radius: var(--radius-sm);
+ padding: 7px 12px;
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ cursor: pointer;
+ }
+
+ .primary-btn {
+ background: var(--color-blue);
+ color: white;
+ }
+
+ .secondary-btn {
+ background: var(--color-bg-secondary);
+ color: var(--color-label);
+ }
+
+ .primary-btn:disabled,
+ .secondary-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
.segmented {
display: inline-grid;
grid-template-columns: 1fr 1fr;
diff --git a/C1.source/privatevoice.src/frontend/src/components/settings/HotkeysPage.svelte b/C1.source/privatevoice.src/frontend/src/components/settings/HotkeysPage.svelte
index 4efaa6a..b180cb9 100755
--- a/C1.source/privatevoice.src/frontend/src/components/settings/HotkeysPage.svelte
+++ b/C1.source/privatevoice.src/frontend/src/components/settings/HotkeysPage.svelte
@@ -5,7 +5,7 @@
import { hotkeyVK } from '../../lib/stores/config'
import { hotkeyName } from '../../lib/stores/indicator'
- let currentKeyName = $state('Ctrl')
+ let currentKeyName = $state('')
let isRecording = $state(false)
let hintText = $state('')
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null
@@ -31,6 +31,12 @@
e.preventDefault()
e.stopPropagation()
+ if (isHotkeyCaptureCancel(e)) {
+ hintText = t('status.cancelled')
+ clearHotkeyCapture()
+ return
+ }
+
const vk = mapKeyToVK(e)
if (vk > 0) {
isRecording = false
@@ -42,7 +48,7 @@
try {
await Call.ByName('voicesnap/services.HotkeyService.SetHotkey', vk)
const name: any = await Call.ByName('voicesnap/services.HotkeyService.GetKeyName', vk)
- if (name) {
+ if (typeof name === 'string') {
hotkeyName.set(name)
currentKeyName = name
}
@@ -58,6 +64,10 @@
activeKeyHandler = onKey
document.addEventListener('keydown', onKey)
+ }
+
+ function isHotkeyCaptureCancel(e: KeyboardEvent): boolean {
+ return e.key === 'Escape' || e.key === 'Esc'
}
function clearHotkeyCapture() {
@@ -105,14 +115,18 @@
<div class="hotkey-display">
<div class="hotkey-info">
<span class="hotkey-label">{t('hotkeys.current')}</span>
- <div class="hotkey-key" class:recording={isRecording}>
- {isRecording ? '...' : currentKeyName}
+ <div class="hotkey-key" class:recording={isRecording} class:unset={!isRecording && !currentKeyName}>
+ {isRecording ? '...' : (currentKeyName || t('hotkeys.unset'))}
</div>
</div>
<button class="change-btn" onclick={startRecordingHotkey} disabled={isRecording}>
- {t('hotkeys.change')}
+ {currentKeyName ? t('hotkeys.change') : t('hotkeys.set')}
</button>
</div>
+
+ {#if !currentKeyName && !isRecording}
+ <p class="hint">{t('hotkeys.unsetDesc')}</p>
+ {/if}
{#if hintText}
<p class="hint" class:recording={isRecording}>{hintText}</p>
@@ -193,6 +207,12 @@
animation: pulse 1s infinite;
}
+ .hotkey-key.unset {
+ color: var(--color-secondary-label);
+ font-size: var(--font-size-base);
+ font-weight: 500;
+ }
+
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
diff --git a/C1.source/privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte b/C1.source/privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte
index e074eb2..9466e90 100644
--- a/C1.source/privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte
+++ b/C1.source/privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte'
import { Call } from '@wailsio/runtime'
+ import TextOutputFallbackPanel from './TextOutputFallbackPanel.svelte'
import { t } from '../../lib/i18n'
interface PermissionStatus {
@@ -96,6 +97,8 @@
<button class="refresh-btn" onclick={refresh}>{t('permissions.refresh')}</button>
</div>
+ <TextOutputFallbackPanel />
+
<div class="permission-list">
{#each statuses as item}
<div class="permission-row">
diff --git a/C1.source/privatevoice.src/frontend/src/components/settings/TextOutputFallbackPanel.svelte b/C1.source/privatevoice.src/frontend/src/components/settings/TextOutputFallbackPanel.svelte
new file mode 100644
index 0000000..64927e0
--- /dev/null
+++ b/C1.source/privatevoice.src/frontend/src/components/settings/TextOutputFallbackPanel.svelte
@@ -0,0 +1,252 @@
+<script lang="ts">
+ import { onMount } from 'svelte'
+ import { Call, Events } from '@wailsio/runtime'
+ import { t } from '../../lib/i18n'
+
+ interface TextOutputResult {
+ ok?: boolean
+ needsPermission?: boolean
+ directInserted?: boolean
+ fallbackAvailable?: boolean
+ createdAt?: string
+ }
+
+ interface ClipboardFallbackResult {
+ ok?: boolean
+ prepared?: boolean
+ restored?: boolean
+ token?: string
+ }
+
+ let textOutput = $state<TextOutputResult | null>(null)
+ let outputStatusText = $state('')
+ let outputActionText = $state('')
+ let fallbackToken = $state('')
+ let fallbackPrepared = $state(false)
+ let fallbackBusy = $state(false)
+ let lastOutputCreatedAt = $state('')
+
+ onMount(() => {
+ const offTextOutputUpdated = Events.On('text-output:updated', () => {
+ void refreshTextOutput()
+ })
+ const pollTextOutput = window.setInterval(() => {
+ void refreshTextOutput(true)
+ }, 1000)
+ void refreshTextOutput()
+ return () => {
+ window.clearInterval(pollTextOutput)
+ offTextOutputUpdated()
+ }
+ })
+
+ async function refreshTextOutput(preserveActionText = false) {
+ try {
+ const result: TextOutputResult = await Call.ByName('voicesnap/services.TextOutputService.GetLastResult')
+ const nextOutputCreatedAt = outputResultKey(result)
+ const isNewOutput = !!nextOutputCreatedAt && !!lastOutputCreatedAt && nextOutputCreatedAt !== lastOutputCreatedAt
+ if (isNewOutput) {
+ fallbackToken = ''
+ fallbackPrepared = false
+ outputActionText = ''
+ }
+ if (nextOutputCreatedAt) {
+ lastOutputCreatedAt = nextOutputCreatedAt
+ }
+ textOutput = result || null
+ outputStatusText = textOutputStatus(result)
+ if (!isNewOutput && !preserveActionText && !fallbackPrepared) {
+ outputActionText = ''
+ }
+ } catch {
+ textOutput = null
+ outputStatusText = t('settings.textOutputUnavailable')
+ }
+ }
+
+ async function prepareClipboardFallback() {
+ if (fallbackPrepared) return
+ fallbackBusy = true
+ try {
+ const result: ClipboardFallbackResult = await Call.ByName('voicesnap/services.TextOutputService.PrepareLastClipboardFallback')
+ if (result?.ok && result?.prepared && result?.token) {
+ fallbackToken = result.token
+ fallbackPrepared = true
+ outputActionText = t('settings.textOutputFallbackPrepared')
+ } else {
+ outputActionText = t('settings.textOutputFallbackUnavailable')
+ }
+ } catch {
+ outputActionText = t('settings.textOutputFallbackUnavailable')
+ } finally {
+ fallbackBusy = false
+ void refreshTextOutput()
+ }
+ }
+
+ async function restoreClipboardFallback() {
+ if (!fallbackToken) return
+ fallbackBusy = true
+ try {
+ const result: ClipboardFallbackResult = await Call.ByName('voicesnap/services.TextOutputService.RestoreClipboardFallback', fallbackToken)
+ fallbackToken = ''
+ fallbackPrepared = false
+ if (result?.ok && result?.restored) {
+ outputActionText = t('settings.textOutputClipboardRestored')
+ } else {
+ outputActionText = t('settings.textOutputClipboardKept')
+ }
+ } catch {
+ outputActionText = t('settings.textOutputClipboardRestoreFailed')
+ } finally {
+ fallbackBusy = false
+ void refreshTextOutput(true)
+ }
+ }
+
+ function textOutputStatus(result: TextOutputResult | null | undefined): string {
+ if (!hasOutputResult(result)) {
+ return t('settings.textOutputNoResult')
+ }
+ if (result.ok && result.directInserted) {
+ return t('settings.textOutputInserted')
+ }
+ if (result.ok) {
+ return t('settings.textOutputCompleted')
+ }
+ if (result.needsPermission) {
+ return t('settings.textOutputNeedsPermission')
+ }
+ if (result.fallbackAvailable) {
+ return t('settings.textOutputFallbackAvailable')
+ }
+ return t('settings.textOutputFailed')
+ }
+
+ function hasOutputResult(result: TextOutputResult | null | undefined): result is TextOutputResult {
+ if (!result) return false
+ if (result.ok || result.needsPermission || result.directInserted || result.fallbackAvailable) return true
+ return !!outputResultKey(result)
+ }
+
+ function outputResultKey(result: TextOutputResult | null | undefined): string {
+ const createdAt = result?.createdAt || ''
+ if (!createdAt || createdAt.startsWith('0001-')) return ''
+ return createdAt
+ }
+</script>
+
+<div class="text-output-panel">
+ <div class="setting-info">
+ <span class="setting-label">{t('settings.textOutputTitle')}</span>
+ <span class="setting-desc">{t('settings.textOutputDesc')}</span>
+ </div>
+ <div class="text-output-status" class:success={!!textOutput?.ok} class:warn={!!textOutput && !textOutput.ok}>
+ {outputStatusText || t('settings.textOutputNoResult')}
+ </div>
+ {#if outputActionText}
+ <div class="fallback-message">{outputActionText}</div>
+ {/if}
+ <div class="fallback-actions">
+ <button class="secondary-btn" onclick={() => refreshTextOutput()} disabled={fallbackBusy}>
+ {t('settings.textOutputRefresh')}
+ </button>
+ <button
+ class="primary-btn"
+ onclick={prepareClipboardFallback}
+ disabled={fallbackBusy || fallbackPrepared || !textOutput?.fallbackAvailable}
+ >
+ {t('settings.textOutputUseClipboard')}
+ </button>
+ {#if fallbackPrepared}
+ <button class="secondary-btn" onclick={restoreClipboardFallback} disabled={fallbackBusy || !fallbackToken}>
+ {t('settings.textOutputRestoreClipboard')}
+ </button>
+ {/if}
+ </div>
+</div>
+
+<style>
+ .text-output-panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ background: var(--color-bg-grouped-secondary);
+ border-radius: var(--radius-md);
+ margin-bottom: var(--spacing-md);
+ padding: var(--spacing-lg);
+ }
+
+ .setting-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+
+ .setting-label {
+ font-size: var(--font-size-base);
+ font-weight: 500;
+ }
+
+ .setting-desc {
+ color: var(--color-tertiary-label);
+ font-size: var(--font-size-xs);
+ }
+
+ .text-output-status {
+ align-self: flex-start;
+ display: inline-flex;
+ padding: 4px 10px;
+ border-radius: var(--radius-sm);
+ background: var(--color-bg-secondary);
+ color: var(--color-secondary-label);
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ }
+
+ .text-output-status.success {
+ color: var(--color-green);
+ }
+
+ .text-output-status.warn {
+ color: var(--color-orange);
+ }
+
+ .fallback-message {
+ color: var(--color-secondary-label);
+ font-size: var(--font-size-sm);
+ line-height: 1.4;
+ }
+
+ .fallback-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--spacing-sm);
+ }
+
+ .primary-btn,
+ .secondary-btn {
+ border: none;
+ border-radius: var(--radius-sm);
+ padding: 7px 12px;
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ cursor: pointer;
+ }
+
+ .primary-btn {
+ background: var(--color-blue);
+ color: white;
+ }
+
+ .secondary-btn {
+ background: var(--color-bg-secondary);
+ color: var(--color-label);
+ }
+
+ .primary-btn:disabled,
+ .secondary-btn:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+ }
+</style>
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 6632faf..8f508d4 100755
--- a/C1.source/privatevoice.src/frontend/src/lib/i18n/en.json
+++ b/C1.source/privatevoice.src/frontend/src/lib/i18n/en.json
@@ -77,8 +77,27 @@
"autoHideDesc": "Automatically hide the floating indicator after completion",
"soundFeedback": "Sound Feedback",
"soundFeedbackDesc": "Play a beep when recording starts and stops",
- "copyToClipboard": "Save Recognition to Clipboard",
- "copyToClipboardDesc": "Keep recognized text in the clipboard after input; off uses it only temporarily",
+ "copyToClipboard": "Clipboard Retention",
+ "copyToClipboardDesc": "Standard output routes keep clipboard content according to this setting. When App Store direct input fails, use the fallback button below.",
+ "autoPasteExperiment": "Automatic Paste Experiment",
+ "autoPasteExperimentDesc": "App Store route validation only. When enabled, a direct-input failure writes the clipboard and tries one automatic paste if the target is unchanged.",
+ "textOutputTitle": "Direct Input and Fallback",
+ "textOutputDesc": "Supported text fields are inserted directly. Unsupported targets keep the text and let you confirm clipboard fallback.",
+ "textOutputRefresh": "Refresh Status",
+ "textOutputUseClipboard": "Use Clipboard Fallback",
+ "textOutputRestoreClipboard": "Restore Original Clipboard",
+ "textOutputNoResult": "No retained dictation needs attention",
+ "textOutputInserted": "Inserted at the current input position",
+ "textOutputCompleted": "Last input completed",
+ "textOutputNeedsPermission": "Accessibility permission is required for direct input",
+ "textOutputFallbackAvailable": "Text is retained and clipboard fallback is available",
+ "textOutputFailed": "Input failed; text is retained",
+ "textOutputUnavailable": "Input status is temporarily unavailable",
+ "textOutputFallbackPrepared": "Dictation text is now in the clipboard. Paste it yourself, then restore the original clipboard when finished.",
+ "textOutputFallbackUnavailable": "No clipboard fallback is available right now",
+ "textOutputClipboardRestored": "Original clipboard restored",
+ "textOutputClipboardKept": "Your current clipboard was kept",
+ "textOutputClipboardRestoreFailed": "Could not confirm clipboard restoration",
"startAtLogin": "Start at Login",
"startAtLoginDesc": "Launch PrivateVoice Dictation when you log in",
"hideDockIcon": "Hide Dock Icon",
@@ -130,7 +149,10 @@
"hotkeys": {
"title": "Trigger Hotkey",
"current": "Current Hotkey",
+ "unset": "Trigger key not set",
+ "set": "Set",
"change": "Change",
+ "unsetDesc": "Choose a trigger key first. Hold remains the default input mode.",
"pressAnyKey": "Press any key...",
"updated": "Updated",
"hintTitle": "Two Input Modes",
@@ -138,7 +160,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. If the right Command / Windows key is unavailable, choose another hotkey with Change."
+ "hintCombo": "Keyboard shortcuts (e.g. Ctrl+C) won't trigger recording. Choose a trigger key that fits your keyboard and system shortcuts; right Command is experimental only."
},
"about": {
"version": "Version",
@@ -149,10 +171,10 @@
"isLatestVersion": "Up to date",
"checkFailed": "Check failed",
"displayName": "PrivateVoice Dictation",
- "tagline": "Private offline · Custom dictionary · Privacy protected",
+ "tagline": "Local-first · Custom dictionary · Privacy protected",
"slogan": "Voice dictation that truly belongs to you",
- "offlineText": "This app runs fully offline. All speech recognition is completed on the local device using local models including SenseVoice, Qwen3-ASR, Moonshine English, and Parakeet.",
- "noDataText": "Data never leaves the device. The app does not collect, upload, or store any voice, text, dictionary, or usage data.",
+ "offlineText": "Speech recognition is designed to run on your Mac with local models including SenseVoice, Qwen3-ASR, Moonshine English, and Parakeet. Optional actions such as downloading models, opening support pages, checking for updates, or exporting diagnostics may use network access or local files.",
+ "noDataText": "Your dictation audio and recognized text are not sent to us for recognition. Settings, dictionaries, models, logs, diagnostics, and optional history stay on your Mac unless you choose to export or share them.",
"legalText": "This app is a secondary development based on the open-source VoiceSnap project and follows the MIT License.",
"legalDetails": "For complete copyright notices, license text, and improvement details, visit:",
"sourceProjectLabel": "Source project",
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 8181121..f7f6b8a 100755
--- a/C1.source/privatevoice.src/frontend/src/lib/i18n/zh.json
+++ b/C1.source/privatevoice.src/frontend/src/lib/i18n/zh.json
@@ -77,8 +77,27 @@
"autoHideDesc": "操作完成后自动隐藏浮动指示器",
"soundFeedback": "提示音",
"soundFeedbackDesc": "录音开始和结束时播放提示音",
- "copyToClipboard": "识别内容保存到剪贴板",
- "copyToClipboardDesc": "输入文字后仍保留在剪贴板,关闭后仅临时用于上屏",
+ "copyToClipboard": "剪贴板保留",
+ "copyToClipboardDesc": "普通输出路径会按此设置保留剪贴板;App Store 直接输入失败时,请使用下方备用输入按钮。",
+ "autoPasteExperiment": "自动粘贴实验",
+ "autoPasteExperimentDesc": "仅用于 App Store 路线验证。打开后,直接输入失败且目标未变化时会写入剪贴板并尝试一次自动粘贴。",
+ "textOutputTitle": "直接输入与备用输入",
+ "textOutputDesc": "支持的文本区域会直接插入;不支持时会保留文本,并由你确认后使用剪贴板备用输入。",
+ "textOutputRefresh": "刷新状态",
+ "textOutputUseClipboard": "使用剪贴板备用输入",
+ "textOutputRestoreClipboard": "恢复原剪贴板",
+ "textOutputNoResult": "暂无需要处理的转写结果",
+ "textOutputInserted": "已插入到当前输入位置",
+ "textOutputCompleted": "最近一次输入已完成",
+ "textOutputNeedsPermission": "需要辅助功能权限才能直接输入",
+ "textOutputFallbackAvailable": "文本已保留,可使用剪贴板备用输入",
+ "textOutputFailed": "未能输入,文本已保留",
+ "textOutputUnavailable": "暂时无法读取输入状态",
+ "textOutputFallbackPrepared": "转写文本已放入剪贴板。请自行粘贴,完成后可恢复原剪贴板。",
+ "textOutputFallbackUnavailable": "当前没有可用的剪贴板备用输入",
+ "textOutputClipboardRestored": "已恢复原剪贴板",
+ "textOutputClipboardKept": "已保留你当前的剪贴板",
+ "textOutputClipboardRestoreFailed": "未能确认剪贴板已恢复",
"startAtLogin": "开机自启动",
"startAtLoginDesc": "登录系统时自动启动私语输入法",
"hideDockIcon": "隐藏 Dock 图标",
@@ -130,7 +149,10 @@
"hotkeys": {
"title": "触发热键",
"current": "当前热键",
+ "unset": "触发键未设置",
+ "set": "设置",
"change": "修改",
+ "unsetDesc": "请先选择一个触发键;长按仍是默认输入方式。",
"pressAnyKey": "请按任意键...",
"updated": "已更新",
"hintTitle": "两种输入模式",
@@ -138,7 +160,7 @@
"hintHoldDesc": "长按热键开始录音,松开即停止识别并输入文字,适合快速短语。",
"hintFree": "自由说话",
"hintFreeDesc": "短按一下热键开始,连续说话,再短按一下停止并输入文字,适合长段口述。",
- "hintCombo": "使用组合键(如 Ctrl+C)不会触发录音。若右侧 Command / Windows 键不可用,请点击修改并选择其他热键。"
+ "hintCombo": "使用组合键(如 Ctrl+C)不会触发录音。请按你的键盘和系统冲突情况选择触发键;右 Command 仅作为实验选项。"
},
"about": {
"version": "版本",
@@ -149,10 +171,10 @@
"isLatestVersion": "已是最新版本",
"checkFailed": "检查失败",
"displayName": "私语输入法 (PrivateVoice Dictation)",
- "tagline": "私密离线 · 自定义词库 · 保护隐私",
+ "tagline": "本地优先 · 自定义词库 · 保护隐私",
"slogan": "真正属于你的语音输入法",
- "offlineText": "本应用完全离线运行,所有语音识别均在本地设备上完成,使用 SenseVoice、Qwen3-ASR、Moonshine English、Parakeet 等本地模型。",
- "noDataText": "数据永不出设备,不会收集、上传或存储任何语音、文本、词库或使用数据。",
+ "offlineText": "语音识别设计为在你的 Mac 上使用本地模型处理,包括 SenseVoice、Qwen3-ASR、Moonshine English、Parakeet 等。下载模型、打开支持页面、检查更新或为支持导出诊断等可选操作,可能会使用网络或本地文件。",
+ "noDataText": "录音音频和识别文本不会被发送给我们用于识别。设置、词库、模型、日志、诊断和可选历史记录保存在本机,除非你主动导出或分享。",
"legalText": "本应用基于开源项目 VoiceSnap 进行二次开发,遵循 MIT License。",
"legalDetails": "完整版权声明、许可文本及改进详情,请访问:",
"sourceProjectLabel": "原项目地址",
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 1488971..e5b4857 100755
--- a/C1.source/privatevoice.src/frontend/src/lib/stores/config.ts
+++ b/C1.source/privatevoice.src/frontend/src/lib/stores/config.ts
@@ -6,8 +6,9 @@
export const autoHide = writable<boolean>(true);
export const soundFeedback = writable<boolean>(true);
export const copyToClipboard = writable<boolean>(true);
+export const autoPasteExperiment = writable<boolean>(false);
export const startAtLogin = writable<boolean>(false);
-export const hotkeyVK = writable<number>(0x5c);
+export const hotkeyVK = writable<number>(0);
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 4c70650..6bcaa57 100755
--- a/C1.source/privatevoice.src/frontend/src/lib/stores/indicator.ts
+++ b/C1.source/privatevoice.src/frontend/src/lib/stores/indicator.ts
@@ -16,8 +16,8 @@
export const indicatorStatus = writable<IndicatorStatus>("hidden");
export const indicatorVolume = writable<number>(0);
const defaultHotkeyName =
- typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform)
- ? "R-⌘"
- : "R-Win";
+ typeof navigator !== "undefined" && /Win/.test(navigator.platform)
+ ? "R-Win"
+ : "";
export const hotkeyName = writable<string>(defaultHotkeyName);
diff --git a/C1.source/privatevoice.src/go.sum b/C1.source/privatevoice.src/go.sum
index 61717fa..be6c3ea 100755
--- a/C1.source/privatevoice.src/go.sum
+++ b/C1.source/privatevoice.src/go.sum
@@ -1,5 +1,31 @@
+al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA=
+al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
+atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw=
+atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU=
+atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8=
+atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ=
+atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs=
+atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU=
+cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8=
+cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
+git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
+git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
+github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
+github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
+github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/Ladicle/tabwriter v1.0.0 h1:DZQqPvMumBDwVNElso13afjYLNp0Z7pHqHnu0r4t9Dg=
+github.com/Ladicle/tabwriter v1.0.0/go.mod h1:c4MdCjxQyTbGuQO/gvqJ+IA/89UEwrsD6hUCW98dyp4=
+github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
+github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
+github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
+github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
+github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
+github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
+github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
@@ -7,27 +33,89 @@
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
+github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
+github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/atterpac/refresh v0.8.6 h1:Q5miKV2qs9jW+USw8WZ/54Zz8/RSh/bOz5U6JvvDZmM=
+github.com/atterpac/refresh v0.8.6/go.mod h1:fJpWySLdpbANS8Ej5OvfZVZIVvi/9bmnhTjKS5EjQes=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
+github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4=
+github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI=
+github.com/bwesterb/go-ristretto v1.2.3 h1:1w53tCkGhCQ5djbat3+MH0BAQ5Kfgbt56UZQ/JMzngw=
+github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
+github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
+github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
+github.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM=
+github.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc=
+github.com/chainguard-dev/git-urls v1.0.2 h1:pSpT7ifrpc5X55n4aTTm7FFUE+ZQHKiqpiwNkJrVcKQ=
+github.com/chainguard-dev/git-urls v1.0.2/go.mod h1:rbGgj10OS7UgZlbzdUQIQpT0k/D4+An04HJY7Ol+Y/o=
+github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
+github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
+github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
+github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
+github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
+github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
+github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
+github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
+github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
+github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI=
+github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4=
+github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
+github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
+github.com/charmbracelet/x/exp/slice v0.0.0-20260122224438-b01af16209d9 h1:BBTx26Fy+CW9U3kLiWBuWn9pI9C1NybaS+p/AZeAOkA=
+github.com/charmbracelet/x/exp/slice v0.0.0-20260122224438-b01af16209d9/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
+github.com/charmbracelet/x/exp/strings v0.0.0-20260122224438-b01af16209d9 h1:JevRYfkTT0sN9OIXAOncYNC0cTP1Gml/0mCSnsmRkRk=
+github.com/charmbracelet/x/exp/strings v0.0.0-20260122224438-b01af16209d9/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE=
+github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
+github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g=
+github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc=
+github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
+github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
+github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
+github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
+github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/gen2brain/malgo v0.11.22 h1:fRtTbzVI9CDWnfEJGo/GxKxN7pXtCb0NsAeUVUjZk9U=
github.com/gen2brain/malgo v0.11.22/go.mod h1:f9TtuN7DVrXMiV/yIceMeWpvanyVzJQMlBecJFVMxww=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
@@ -44,18 +132,52 @@
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
+github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
+github.com/go-task/template v0.2.0 h1:xW7ek0o65FUSTbKcSNeg2Vyf/I7wYXFgLUznptvviBE=
+github.com/go-task/template v0.2.0/go.mod h1:dbdoUb6qKnHQi1y6o+IdIrs0J4o/SEhSTA6bbzZmdtc=
+github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
+github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/rpmpack v0.7.1 h1:YdWh1IpzOjBz60Wvdw0TU0A5NWP+JTVHA5poDqwMO2o=
+github.com/google/rpmpack v0.7.1/go.mod h1:h1JL16sUTWCLI/c39ox1rDaTBo3BXUQGjczVJyK4toU=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA=
+github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs=
+github.com/goreleaser/chglog v0.7.4 h1:3pnNt/XCrUcAOq+KC91Azlgp5CRv4GHo1nl8Aws7OzI=
+github.com/goreleaser/chglog v0.7.4/go.mod h1:dTVoZZagTz7hHdWaZ9OshHntKiF44HbWIHWxYJQ/h0Y=
+github.com/goreleaser/fileglob v1.4.0 h1:Y7zcUnzQjT1gbntacGAkIIfLv+OwojxTXBFxjSFoBBs=
+github.com/goreleaser/fileglob v1.4.0/go.mod h1:1pbHx7hhmJIxNZvm6fi6WVrnP0tndq6p3ayWdLn1Yf8=
+github.com/goreleaser/nfpm/v2 v2.44.1 h1:g+QNjkEx+C2Zu8dB48t9da/VfV0CWS5TMjxT8HG1APY=
+github.com/goreleaser/nfpm/v2 v2.44.1/go.mod h1:drIYLqkla9SaOLbSnaFOmSIv5LXGfhHcbK54st97b4s=
+github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
+github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
+github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
+github.com/jackmordaunt/icns/v2 v2.2.7 h1:K/RbfvuzjmjVY5y4g+XENRs8ZZatwz4YnLHypa2KwQg=
+github.com/jackmordaunt/icns/v2 v2.2.7/go.mod h1:ovoTxGguSuoUGKMk5Nn3R7L7BgMQkylsO+bblBuI22A=
+github.com/jaypipes/ghw v0.21.3 h1:v5mUHM+RN854Vqmk49Uh213jyUA4+8uqaRajlYESsh8=
+github.com/jaypipes/ghw v0.21.3/go.mod h1:GPrvwbtPoxYUenr74+nAnWbardIZq600vJDD5HnPsPE=
+github.com/jaypipes/pcidb v1.1.1 h1:QmPhpsbmmnCwZmHeYAATxEaoRuiMAJusKYkUncMC0ro=
+github.com/jaypipes/pcidb v1.1.1/go.mod h1:x27LT2krrUgjf875KxQXKB0Ha/YXLdZRVmw6hH0G7g8=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/k2-fsa/sherpa-onnx-go v1.12.24 h1:soZp0y966cDsOd7faaWG2Ov1IlFB0b73hhmPDFTPP7o=
github.com/k2-fsa/sherpa-onnx-go v1.12.24/go.mod h1:B/ynRbVa5gpYoZYeYgY3zPi4MTfKk95UZueZDSIhbjk=
github.com/k2-fsa/sherpa-onnx-go v1.13.2 h1:1orlwwdJcVk37OoV1gi1L6fIdeZHKJcLQjm6S5h/WWs=
@@ -74,21 +196,40 @@
github.com/k2-fsa/sherpa-onnx-go-windows v1.13.2/go.mod h1:5AX7TU8+P/gInjglY1ijtWUM2b8iyR0QX4yEngzMe64=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
+github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
+github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
+github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
+github.com/konoui/go-qsort v0.1.0 h1:0Os/0X0Fce6B54jqN26aR+J5uOExN+0t7nb9zs6zzzE=
+github.com/konoui/go-qsort v0.1.0/go.mod h1:UOsvdDPBzyQDk9Tb21hETK6KYXGYQTnoZB5qeKA1ARs=
+github.com/konoui/lipo v0.10.0 h1:1P2VkBSB6I38kgmyznvAjy9gmAqybK22pJt9iyx5CgY=
+github.com/konoui/lipo v0.10.0/go.mod h1:R+0EgDVrLKKS37SumAO8zhpEprjjoKEkrT3QqKQE35k=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leaanthony/clir v1.7.0 h1:xiAnhl7ryPwuH3ERwPWZp/pCHk8wTeiwuAOt6MiNyAw=
+github.com/leaanthony/clir v1.7.0/go.mod h1:k/RBkdkFl18xkkACMCLt09bhiZnrGORoxmomeMvDpE0=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
+github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
+github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
+github.com/leaanthony/winicon v1.0.0 h1:ZNt5U5dY71oEoKZ97UVwJRT4e+5xo5o/ieKuHuk8NqQ=
+github.com/leaanthony/winicon v1.0.0/go.mod h1:en5xhijl92aphrJdmRPlh4NI1L6wq3gEm0LpXAPghjU=
+github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
+github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
@@ -96,6 +237,32 @@
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mattn/go-zglob v0.0.6 h1:mP8RnmCgho4oaUYDIDn6GNxYk+qJGUs8fJLn+twYj2A=
+github.com/mattn/go-zglob v0.0.6/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY=
+github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
+github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
+github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
+github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
+github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
+github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
+github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
+github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
+github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
@@ -106,37 +273,86 @@
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pterm/pterm v0.12.82 h1:+D9wYhCaeaK0FIQoZtqbNQuNpe2lB2tajKKsTd5paVQ=
+github.com/pterm/pterm v0.12.82/go.mod h1:TyuyrPjnxfwP+ccJdBTeWHtd/e0ybQHkOS/TakajZCw=
+github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE=
+github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
+github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY=
+github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
+github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tc-hib/winres v0.3.1 h1:CwRjEGrKdbi5CvZ4ID+iyVhgyfatxFoizjPhzez9Io4=
+github.com/tc-hib/winres v0.3.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
+github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
+github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
+github.com/wailsapp/task/v3 v3.40.1-patched3 h1:i6O1WNdSur9CGaiMDIYGjsmj/qS4465zqv+WEs6sPRs=
+github.com/wailsapp/task/v3 v3.40.1-patched3/go.mod h1:jIP48r8ftoSQNlxFP4+aEnkvGQqQXqCnRi/B7ROaecE=
github.com/wailsapp/wails/v3 v3.0.0-alpha.71 h1:6ERh+1SJJ+tl5E4W49q8pDyQ4yeyi1yj9IdSppKtMx4=
github.com/wailsapp/wails/v3 v3.0.0-alpha.71/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
+github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
+github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
+github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
+github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
+github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
+gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8=
+gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
+golang.org/x/exp/typeparams v0.0.0-20260112195511-716be5621a96 h1:RMc8anw0hCPcg5CZYN2PEQ8nMwosk461R6vFwPrCFVg=
+golang.org/x/exp/typeparams v0.0.0-20260112195511-716be5621a96/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
+golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
+golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
+golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
+golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -155,6 +371,10 @@
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
+golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
+google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
+google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -162,6 +382,19 @@
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 h1:eeH1AIcPvSc0Z25ThsYF+Xoqbn0CI/YnXVYoTLFdGQw=
+howett.net/plist v1.0.2-0.20250314012144-ee69052608d9/go.mod h1:fyFX5Hj5tP1Mpk8obqA9MZgXT416Q5711SDT7dQLTLk=
+modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
+modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
+modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
+mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI=
+mvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg=
diff --git a/C1.source/privatevoice.src/internal/audio/microphone_darwin.go b/C1.source/privatevoice.src/internal/audio/microphone_darwin.go
new file mode 100644
index 0000000..4c471b6
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/audio/microphone_darwin.go
@@ -0,0 +1,59 @@
+//go:build darwin
+
+package audio
+
+/*
+#cgo CFLAGS: -x objective-c
+#cgo LDFLAGS: -framework AVFoundation
+#import <AVFoundation/AVFoundation.h>
+
+static int pvMicrophoneStatus(void) {
+ return (int)[AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio];
+}
+
+static int pvRequestMicrophone(void) {
+ __block int granted = 0;
+ dispatch_semaphore_t sem = dispatch_semaphore_create(0);
+ [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL ok) {
+ granted = ok ? 1 : 0;
+ dispatch_semaphore_signal(sem);
+ }];
+ dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 30 * NSEC_PER_SEC));
+ return granted;
+}
+*/
+import "C"
+
+type microphoneAuthorization struct {
+ granted bool
+ state string
+}
+
+var ensureMicrophoneAuthorization = defaultEnsureMicrophoneAuthorization
+
+func defaultEnsureMicrophoneAuthorization() microphoneAuthorization {
+ status := int(C.pvMicrophoneStatus())
+ if status == 0 {
+ C.pvRequestMicrophone()
+ status = int(C.pvMicrophoneStatus())
+ }
+ return microphoneAuthorization{
+ granted: status == 3,
+ state: microphoneAuthorizationState(status),
+ }
+}
+
+func microphoneAuthorizationState(status int) string {
+ switch status {
+ case 0:
+ return "notDetermined"
+ case 1:
+ return "restricted"
+ case 2:
+ return "denied"
+ case 3:
+ return "granted"
+ default:
+ return "unknown"
+ }
+}
diff --git a/C1.source/privatevoice.src/internal/audio/microphone_other.go b/C1.source/privatevoice.src/internal/audio/microphone_other.go
new file mode 100644
index 0000000..5657649
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/audio/microphone_other.go
@@ -0,0 +1,14 @@
+//go:build !darwin
+
+package audio
+
+type microphoneAuthorization struct {
+ granted bool
+ state string
+}
+
+var ensureMicrophoneAuthorization = defaultEnsureMicrophoneAuthorization
+
+func defaultEnsureMicrophoneAuthorization() microphoneAuthorization {
+ return microphoneAuthorization{granted: true, state: "granted"}
+}
diff --git a/C1.source/privatevoice.src/internal/audio/recorder.go b/C1.source/privatevoice.src/internal/audio/recorder.go
index 9cf8b92..44b3a2f 100755
--- a/C1.source/privatevoice.src/internal/audio/recorder.go
+++ b/C1.source/privatevoice.src/internal/audio/recorder.go
@@ -1,6 +1,8 @@
package audio
import (
+ "errors"
+ "fmt"
"math"
"sync"
"time"
@@ -18,9 +20,12 @@
maxPCMBufferBytes = sampleRate * channels * (bitsPerSample / 8) * maxRecordingSeconds
MaxRecordingDuration = time.Duration(maxRecordingSeconds) * time.Second
maxPendingDeviceStops = 2
+ deviceStartWatchDelay = 2 * time.Second
)
var pendingDeviceStops = make(chan struct{}, maxPendingDeviceStops)
+
+var ErrMicrophonePermission = errors.New("microphone permission not granted")
// InputDevice represents an audio input device.
type InputDevice struct {
@@ -28,12 +33,41 @@
IsDefault bool `json:"isDefault"`
}
+type recorderAudioContext interface {
+ devices(malgo.DeviceType) ([]malgo.DeviceInfo, error)
+ initDevice(malgo.DeviceConfig, malgo.DeviceCallbacks) (recorderDevice, error)
+ free()
+}
+
+type malgoRecorderContext struct {
+ allocated *malgo.AllocatedContext
+}
+
+func (c *malgoRecorderContext) devices(deviceType malgo.DeviceType) ([]malgo.DeviceInfo, error) {
+ return c.allocated.Context.Devices(deviceType)
+}
+
+func (c *malgoRecorderContext) initDevice(config malgo.DeviceConfig, callbacks malgo.DeviceCallbacks) (recorderDevice, error) {
+ return malgo.InitDevice(c.allocated.Context, config, callbacks)
+}
+
+func (c *malgoRecorderContext) free() {
+ c.allocated.Free()
+}
+
+type recorderDevice interface {
+ Start() error
+ Stop() error
+ Uninit()
+}
+
// Recorder captures audio from the default input device using malgo (miniaudio).
type Recorder struct {
- mu sync.Mutex
+ mu sync.Mutex
+ lifecycleMu sync.Mutex
- ctx *malgo.AllocatedContext
- device *malgo.Device
+ ctx recorderAudioContext
+ device recorderDevice
// PCM buffer (16-bit signed, little-endian)
pcmBuf []byte
@@ -43,7 +77,11 @@
// State
isRecording bool
+ startRequested bool
+ isStarting bool
+ stopAfterStart bool
bufferLimitHit bool
+ closing bool
maxVolume float64
currentVolume float64
volumeCallback func(float64)
@@ -58,7 +96,7 @@
logger.Error("Failed to init malgo context: %v", err)
return &Recorder{}
}
- return &Recorder{ctx: ctx}
+ return &Recorder{ctx: &malgoRecorderContext{allocated: ctx}}
}
// OnVolume registers a callback for real-time volume updates.
@@ -77,10 +115,14 @@
// ListInputDevices returns all available audio capture devices.
func (r *Recorder) ListInputDevices() []InputDevice {
- if r.ctx == nil {
+ r.lifecycleMu.Lock()
+ defer r.lifecycleMu.Unlock()
+
+ ctx := r.ctx
+ if ctx == nil {
return nil
}
- infos, err := r.ctx.Context.Devices(malgo.Capture)
+ infos, err := ctx.devices(malgo.Capture)
if err != nil {
logger.Error("Failed to list devices: %v", err)
return nil
@@ -105,10 +147,16 @@
// Start begins recording from the preferred (or default) capture device.
func (r *Recorder) Start() error {
- r.mu.Lock()
- defer r.mu.Unlock()
+ r.lifecycleMu.Lock()
+ defer r.lifecycleMu.Unlock()
- if r.isRecording {
+ r.mu.Lock()
+ if r.closing {
+ r.mu.Unlock()
+ return fmt.Errorf("audio recorder is closed")
+ }
+ if r.isRecording || r.startRequested || r.isStarting {
+ r.mu.Unlock()
return nil
}
@@ -116,6 +164,47 @@
r.bufferLimitHit = false
r.maxVolume = 0
r.currentVolume = 0
+ r.startRequested = true
+ r.stopAfterStart = false
+ ctx := r.ctx
+ preferredDevice := r.preferredDevice
+ r.mu.Unlock()
+ logger.Info("Audio recorder start requested sample_rate=%d channels=%d preferred_device=%q", sampleRate, channels, preferredDevice)
+
+ auth := ensureMicrophoneAuthorization()
+ if !auth.granted {
+ r.mu.Lock()
+ r.startRequested = false
+ r.stopAfterStart = false
+ r.isRecording = false
+ r.mu.Unlock()
+ logger.Error("Microphone permission not granted; audio recorder start blocked state=%s", auth.state)
+ return fmt.Errorf("%w: %s", ErrMicrophonePermission, auth.state)
+ }
+ logger.Info("Microphone permission ready state=%s", auth.state)
+
+ r.mu.Lock()
+ stopBeforeAuthComplete := r.stopAfterStart || r.closing
+ startWasClosed := r.closing
+ if stopBeforeAuthComplete {
+ r.startRequested = false
+ r.stopAfterStart = false
+ r.isRecording = false
+ r.mu.Unlock()
+ if startWasClosed {
+ return fmt.Errorf("audio recorder closed during start")
+ }
+ return nil
+ }
+ r.mu.Unlock()
+
+ if ctx == nil {
+ r.mu.Lock()
+ r.startRequested = false
+ r.stopAfterStart = false
+ r.mu.Unlock()
+ return fmt.Errorf("audio context is not initialized")
+ }
deviceConfig := malgo.DefaultDeviceConfig(malgo.Capture)
deviceConfig.Capture.Format = malgo.FormatS16
@@ -124,11 +213,11 @@
deviceConfig.PeriodSizeInMilliseconds = 50
// Use preferred device if set
- if r.preferredDevice != "" {
- infos, err := r.ctx.Context.Devices(malgo.Capture)
+ if preferredDevice != "" {
+ infos, err := ctx.devices(malgo.Capture)
if err == nil {
for _, info := range infos {
- if info.Name() == r.preferredDevice {
+ if info.Name() == preferredDevice {
deviceConfig.Capture.DeviceID = info.ID.Pointer()
break
}
@@ -142,18 +231,98 @@
},
}
- device, err := malgo.InitDevice(r.ctx.Context, deviceConfig, callbacks)
+ device, err := ctx.initDevice(deviceConfig, callbacks)
if err != nil {
+ r.mu.Lock()
+ r.startRequested = false
+ r.stopAfterStart = false
+ r.mu.Unlock()
return err
}
+ logger.Info("Audio device initialized; starting native device")
- if err := device.Start(); err != nil {
+ r.mu.Lock()
+ stopBeforeStart := r.stopAfterStart || r.closing
+ startWasClosed = r.closing
+ if r.isRecording || stopBeforeStart {
+ r.startRequested = false
+ r.stopAfterStart = false
+ r.isRecording = false
+ r.mu.Unlock()
device.Uninit()
- return err
+ if startWasClosed {
+ return fmt.Errorf("audio recorder closed during start")
+ }
+ return nil
}
-
r.device = device
r.isRecording = true
+ r.startRequested = false
+ r.isStarting = true
+ r.stopAfterStart = false
+ r.mu.Unlock()
+
+ deviceStartBegin := time.Now()
+ watchDone := make(chan struct{})
+ go func() {
+ select {
+ case <-watchDone:
+ case <-time.After(deviceStartWatchDelay):
+ logger.Error("Native audio device start still pending after %dms", deviceStartWatchDelay.Milliseconds())
+ }
+ }()
+ if err := device.Start(); err != nil {
+ close(watchDone)
+ r.mu.Lock()
+ stillCurrent := r.device == device
+ if stillCurrent {
+ r.device = nil
+ r.isRecording = false
+ r.startRequested = false
+ r.isStarting = false
+ r.stopAfterStart = false
+ }
+ r.mu.Unlock()
+ if stillCurrent {
+ device.Uninit()
+ }
+ return err
+ }
+ close(watchDone)
+ logger.Info("Native audio device start returned result=success start_ms=%d", time.Since(deviceStartBegin).Milliseconds())
+
+ r.mu.Lock()
+ stillCurrent := r.device == device
+ shouldStop := stillCurrent && (r.stopAfterStart || r.closing || !r.isRecording)
+ startWasClosed = stillCurrent && r.closing
+ if !stillCurrent && r.device == device {
+ r.device = nil
+ r.isRecording = false
+ r.isStarting = false
+ r.stopAfterStart = false
+ }
+ if stillCurrent {
+ r.startRequested = false
+ r.isStarting = false
+ }
+ if shouldStop {
+ r.device = nil
+ r.isRecording = false
+ r.stopAfterStart = false
+ }
+ r.mu.Unlock()
+ if !stillCurrent {
+ stopAndUninitDevice(device)
+ return fmt.Errorf("audio recorder closed during start")
+ }
+ if shouldStop {
+ stopAndUninitDevice(device)
+ if startWasClosed {
+ return fmt.Errorf("audio recorder closed during start")
+ }
+ return nil
+ }
+
logger.Info("Recording started (16kHz/16-bit/mono)")
return nil
}
@@ -208,10 +377,14 @@
// GetDeviceName returns the name of the current capture device.
func (r *Recorder) GetDeviceName() string {
- if r.ctx == nil {
+ r.lifecycleMu.Lock()
+ defer r.lifecycleMu.Unlock()
+
+ ctx := r.ctx
+ if ctx == nil {
return "Default"
}
- devices, err := r.ctx.Context.Devices(malgo.Capture)
+ devices, err := ctx.devices(malgo.Capture)
if err != nil || len(devices) == 0 {
return "Default"
}
@@ -220,32 +393,67 @@
// Close releases all audio resources.
func (r *Recorder) Close() {
+ r.mu.Lock()
+ r.closing = true
+ r.mu.Unlock()
+
+ r.lifecycleMu.Lock()
+ defer r.lifecycleMu.Unlock()
+
device := r.detachDevice()
stopAndUninitDevice(device)
r.mu.Lock()
defer r.mu.Unlock()
if r.ctx != nil {
- r.ctx.Free()
+ r.ctx.free()
r.ctx = nil
}
}
-func (r *Recorder) detachDevice() *malgo.Device {
+func (r *Recorder) detachDevice() recorderDevice {
r.mu.Lock()
defer r.mu.Unlock()
device := r.device
+ if r.startRequested && !r.isStarting {
+ logger.Info("Recording stop requested before audio device was published; deferring startup cleanup")
+ r.isRecording = false
+ r.stopAfterStart = true
+ return nil
+ }
+ if r.isStarting {
+ logger.Info("Recording stop requested while audio device start is still pending; deferring native device cleanup")
+ r.isRecording = false
+ r.stopAfterStart = true
+ return nil
+ }
r.device = nil
r.isRecording = false
return device
}
-func (r *Recorder) detachDeviceAndPCM() (*malgo.Device, []byte) {
+func (r *Recorder) detachDeviceAndPCM() (recorderDevice, []byte) {
r.mu.Lock()
defer r.mu.Unlock()
device := r.device
+ if r.startRequested && !r.isStarting {
+ logger.Info("Recording samples requested before audio device was published; snapshotting buffered PCM and deferring startup cleanup")
+ r.isRecording = false
+ r.stopAfterStart = true
+ pcm := append([]byte(nil), r.pcmBuf...)
+ r.pcmBuf = nil
+ return nil, pcm
+ }
+ if r.isStarting {
+ logger.Info("Recording samples requested while audio device start is still pending; snapshotting buffered PCM and deferring native device cleanup")
+ r.isRecording = false
+ r.stopAfterStart = true
+ pcm := append([]byte(nil), r.pcmBuf...)
+ r.pcmBuf = nil
+ return nil, pcm
+ }
r.device = nil
r.isRecording = false
pcm := append([]byte(nil), r.pcmBuf...)
@@ -253,7 +461,7 @@
return device, pcm
}
-func stopAndUninitDeviceAsync(device *malgo.Device) {
+func stopAndUninitDeviceAsync(device recorderDevice) {
if device == nil {
return
}
@@ -281,7 +489,7 @@
}()
}
-func stopAndUninitDevice(device *malgo.Device) {
+func stopAndUninitDevice(device recorderDevice) {
if device == nil {
return
}
diff --git a/C1.source/privatevoice.src/internal/audio/recorder_test.go b/C1.source/privatevoice.src/internal/audio/recorder_test.go
index 092b043..12b3673 100644
--- a/C1.source/privatevoice.src/internal/audio/recorder_test.go
+++ b/C1.source/privatevoice.src/internal/audio/recorder_test.go
@@ -1,6 +1,25 @@
package audio
-import "testing"
+import (
+ "errors"
+ "os"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/gen2brain/malgo"
+)
+
+func TestMain(m *testing.M) {
+ oldEnsure := ensureMicrophoneAuthorization
+ ensureMicrophoneAuthorization = func() microphoneAuthorization {
+ return microphoneAuthorization{granted: true, state: "granted"}
+ }
+ code := m.Run()
+ ensureMicrophoneAuthorization = oldEnsure
+ os.Exit(code)
+}
func TestReadSamplesSinceReturnsOnlyNewSamples(t *testing.T) {
r := &Recorder{
@@ -57,6 +76,388 @@
}
}
+func TestStartWithoutMicrophonePermissionDoesNotInitializeDevice(t *testing.T) {
+ oldEnsure := ensureMicrophoneAuthorization
+ ensureMicrophoneAuthorization = func() microphoneAuthorization {
+ return microphoneAuthorization{granted: false, state: "denied"}
+ }
+ defer func() {
+ ensureMicrophoneAuthorization = oldEnsure
+ }()
+
+ device := newFakeRecorderDevice()
+ ctx := &fakeRecorderContext{device: device}
+ r := &Recorder{ctx: ctx}
+
+ err := r.Start()
+ if err == nil {
+ t.Fatal("Start returned nil without microphone permission")
+ }
+ if ctx.initEntered.Load() {
+ t.Fatal("initDevice should not run without microphone permission")
+ }
+ if device.startEntered.Load() {
+ t.Fatal("device Start should not run without microphone permission")
+ }
+
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.startRequested || r.isStarting || r.isRecording || r.stopAfterStart || r.device != nil {
+ t.Fatalf("recorder state leaked after microphone denial: startRequested=%t isStarting=%t isRecording=%t stopAfterStart=%t deviceNil=%t",
+ r.startRequested, r.isStarting, r.isRecording, r.stopAfterStart, r.device == nil)
+ }
+}
+
+func TestStopWhileMicrophoneAuthorizationPendingPreventsNativeStart(t *testing.T) {
+ oldEnsure := ensureMicrophoneAuthorization
+ entered := make(chan struct{})
+ allow := make(chan struct{})
+ var enteredOnce sync.Once
+ ensureMicrophoneAuthorization = func() microphoneAuthorization {
+ enteredOnce.Do(func() { close(entered) })
+ <-allow
+ return microphoneAuthorization{granted: true, state: "granted"}
+ }
+ defer func() {
+ ensureMicrophoneAuthorization = oldEnsure
+ }()
+
+ device := newFakeRecorderDevice()
+ ctx := &fakeRecorderContext{device: device}
+ r := &Recorder{ctx: ctx}
+
+ startDone := make(chan error, 1)
+ go func() {
+ startDone <- r.Start()
+ }()
+ select {
+ case <-entered:
+ case <-time.After(time.Second):
+ t.Fatal("microphone authorization was not entered")
+ }
+
+ r.Stop()
+ close(allow)
+ if err := <-startDone; err != nil {
+ t.Fatalf("Start returned error = %v", err)
+ }
+ if ctx.initEntered.Load() {
+ t.Fatal("initDevice should not run after Stop during microphone authorization")
+ }
+ if device.startEntered.Load() {
+ t.Fatal("device Start should not run after Stop during microphone authorization")
+ }
+
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.startRequested || r.isStarting || r.isRecording || r.stopAfterStart || r.device != nil {
+ t.Fatalf("recorder state leaked after pending authorization stop: startRequested=%t isStarting=%t isRecording=%t stopAfterStart=%t deviceNil=%t",
+ r.startRequested, r.isStarting, r.isRecording, r.stopAfterStart, r.device == nil)
+ }
+}
+
+func TestStopAndGetSamplesWhileMicrophoneAuthorizationPendingPreventsNativeStart(t *testing.T) {
+ oldEnsure := ensureMicrophoneAuthorization
+ entered := make(chan struct{})
+ allow := make(chan struct{})
+ var enteredOnce sync.Once
+ ensureMicrophoneAuthorization = func() microphoneAuthorization {
+ enteredOnce.Do(func() { close(entered) })
+ <-allow
+ return microphoneAuthorization{granted: true, state: "granted"}
+ }
+ defer func() {
+ ensureMicrophoneAuthorization = oldEnsure
+ }()
+
+ device := newFakeRecorderDevice()
+ ctx := &fakeRecorderContext{device: device}
+ r := &Recorder{ctx: ctx}
+
+ startDone := make(chan error, 1)
+ go func() {
+ startDone <- r.Start()
+ }()
+ select {
+ case <-entered:
+ case <-time.After(time.Second):
+ t.Fatal("microphone authorization was not entered")
+ }
+
+ samples := r.StopAndGetSamples()
+ if len(samples) != 0 {
+ t.Fatalf("samples while microphone authorization pending = %d, want 0", len(samples))
+ }
+ close(allow)
+ if err := <-startDone; err != nil {
+ t.Fatalf("Start returned error = %v", err)
+ }
+ if ctx.initEntered.Load() {
+ t.Fatal("initDevice should not run after StopAndGetSamples during microphone authorization")
+ }
+ if device.startEntered.Load() {
+ t.Fatal("device Start should not run after StopAndGetSamples during microphone authorization")
+ }
+
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.startRequested || r.isStarting || r.isRecording || r.stopAfterStart || r.device != nil {
+ t.Fatalf("recorder state leaked after pending authorization samples stop: startRequested=%t isStarting=%t isRecording=%t stopAfterStart=%t deviceNil=%t",
+ r.startRequested, r.isStarting, r.isRecording, r.stopAfterStart, r.device == nil)
+ }
+}
+
+func TestCloseWaitsForBlockedStartBeforeFreeingContext(t *testing.T) {
+ device := newFakeRecorderDevice()
+ ctx := &fakeRecorderContext{device: device}
+ r := &Recorder{ctx: ctx}
+
+ startDone := make(chan error, 1)
+ go func() {
+ startDone <- r.Start()
+ }()
+ waitForRecorderTest(t, func() bool { return device.startEntered.Load() })
+
+ closeDone := make(chan struct{})
+ go func() {
+ r.Close()
+ close(closeDone)
+ }()
+
+ select {
+ case <-closeDone:
+ t.Fatal("Close returned while recorder Start was still blocked")
+ case <-time.After(50 * time.Millisecond):
+ }
+ if got := ctx.freeCount.Load(); got != 0 {
+ t.Fatalf("context freed before Start returned = %d, want 0", got)
+ }
+ if got := device.uninitCount.Load(); got != 0 {
+ t.Fatalf("device uninitialized before Start returned = %d, want 0", got)
+ }
+
+ close(device.allowStart)
+ if err := <-startDone; err == nil {
+ t.Fatal("Start returned nil after Close was requested during startup")
+ }
+ waitForRecorderTest(t, func() bool {
+ select {
+ case <-closeDone:
+ return true
+ default:
+ return false
+ }
+ })
+ if got := ctx.freeCount.Load(); got != 1 {
+ t.Fatalf("context free count = %d, want 1", got)
+ }
+ if got := device.stopCount.Load(); got != 1 {
+ t.Fatalf("device stop count = %d, want 1", got)
+ }
+ if got := device.uninitCount.Load(); got != 1 {
+ t.Fatalf("device uninit count = %d, want 1", got)
+ }
+}
+
+func TestStopWhileStartBlockedDefersDeviceCleanup(t *testing.T) {
+ device := newFakeRecorderDevice()
+ ctx := &fakeRecorderContext{device: device}
+ r := &Recorder{ctx: ctx}
+
+ startDone := make(chan error, 1)
+ go func() {
+ startDone <- r.Start()
+ }()
+ waitForRecorderTest(t, func() bool { return device.startEntered.Load() })
+
+ stopDone := make(chan struct{})
+ go func() {
+ r.Stop()
+ close(stopDone)
+ }()
+
+ select {
+ case <-stopDone:
+ case <-time.After(time.Second):
+ t.Fatal("Stop should return while recorder Start is still blocked")
+ }
+ if got := device.stopCount.Load(); got != 0 {
+ t.Fatalf("device stopped before Start returned = %d, want 0", got)
+ }
+ if got := device.uninitCount.Load(); got != 0 {
+ t.Fatalf("device uninitialized before Start returned = %d, want 0", got)
+ }
+
+ close(device.allowStart)
+ if err := <-startDone; err != nil {
+ t.Fatalf("Start returned error = %v", err)
+ }
+ waitForRecorderTest(t, func() bool { return device.uninitCount.Load() == 1 })
+ if got := device.stopCount.Load(); got != 1 {
+ t.Fatalf("device stop count = %d, want 1", got)
+ }
+}
+
+func TestStopAndGetSamplesWhileStartBlockedReturnsBufferedPCM(t *testing.T) {
+ device := newFakeRecorderDevice()
+ ctx := &fakeRecorderContext{device: device}
+ r := &Recorder{ctx: ctx}
+
+ startDone := make(chan error, 1)
+ go func() {
+ startDone <- r.Start()
+ }()
+ waitForRecorderTest(t, func() bool { return device.startEntered.Load() })
+
+ r.onData(pcm16LE(16384, -32768))
+ samples := r.StopAndGetSamples()
+ if len(samples) != 2 {
+ t.Fatalf("samples len = %d, want 2", len(samples))
+ }
+ if samples[0] != 0.5 || samples[1] != -1 {
+ t.Fatalf("samples = %#v, want [0.5 -1]", samples)
+ }
+ if got := device.stopCount.Load(); got != 0 {
+ t.Fatalf("device stopped before Start returned = %d, want 0", got)
+ }
+ if got := device.uninitCount.Load(); got != 0 {
+ t.Fatalf("device uninitialized before Start returned = %d, want 0", got)
+ }
+
+ close(device.allowStart)
+ if err := <-startDone; err != nil {
+ t.Fatalf("Start returned error = %v", err)
+ }
+ if got := device.stopCount.Load(); got != 1 {
+ t.Fatalf("device stop count after Start returned = %d, want 1", got)
+ }
+ if got := device.uninitCount.Load(); got != 1 {
+ t.Fatalf("device uninit count after Start returned = %d, want 1", got)
+ }
+}
+
+func TestStopAndGetSamplesBeforeDevicePublishDefersIntentAndCleansInitializedDevice(t *testing.T) {
+ device := newFakeRecorderDevice()
+ initBlock := make(chan struct{})
+ ctx := &fakeRecorderContext{device: device, initBlock: initBlock}
+ r := &Recorder{ctx: ctx}
+
+ startDone := make(chan error, 1)
+ go func() {
+ startDone <- r.Start()
+ }()
+ waitForRecorderTest(t, func() bool { return ctx.initEntered.Load() })
+
+ samples := r.StopAndGetSamples()
+ if len(samples) != 0 {
+ t.Fatalf("samples len before device publish = %d, want 0", len(samples))
+ }
+ if device.startEntered.Load() {
+ t.Fatal("device Start should not be entered while initDevice is blocked")
+ }
+ if got := device.stopCount.Load(); got != 0 {
+ t.Fatalf("device stopped before it was published = %d, want 0", got)
+ }
+ if got := device.uninitCount.Load(); got != 0 {
+ t.Fatalf("device uninit before initDevice returned = %d, want 0", got)
+ }
+
+ close(initBlock)
+ if err := <-startDone; err != nil {
+ t.Fatalf("Start returned error = %v", err)
+ }
+ if device.startEntered.Load() {
+ t.Fatal("device Start should not run after stop intent arrived before publish")
+ }
+ if got := device.stopCount.Load(); got != 0 {
+ t.Fatalf("device stop count = %d, want 0 for never-started device", got)
+ }
+ if got := device.uninitCount.Load(); got != 1 {
+ t.Fatalf("device uninit count = %d, want 1", got)
+ }
+
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.startRequested || r.isStarting || r.isRecording || r.stopAfterStart || r.device != nil {
+ t.Fatalf("recorder state leaked after pre-publish stop: startRequested=%t isStarting=%t isRecording=%t stopAfterStart=%t deviceNil=%t",
+ r.startRequested, r.isStarting, r.isRecording, r.stopAfterStart, r.device == nil)
+ }
+}
+
+func TestStopBeforeDevicePublishDefersIntentAndCleansInitializedDevice(t *testing.T) {
+ device := newFakeRecorderDevice()
+ initBlock := make(chan struct{})
+ ctx := &fakeRecorderContext{device: device, initBlock: initBlock}
+ r := &Recorder{ctx: ctx}
+
+ startDone := make(chan error, 1)
+ go func() {
+ startDone <- r.Start()
+ }()
+ waitForRecorderTest(t, func() bool { return ctx.initEntered.Load() })
+
+ r.Stop()
+ if device.startEntered.Load() {
+ t.Fatal("device Start should not be entered while initDevice is blocked")
+ }
+ if got := device.stopCount.Load(); got != 0 {
+ t.Fatalf("device stopped before it was published = %d, want 0", got)
+ }
+ if got := device.uninitCount.Load(); got != 0 {
+ t.Fatalf("device uninit before initDevice returned = %d, want 0", got)
+ }
+
+ close(initBlock)
+ if err := <-startDone; err != nil {
+ t.Fatalf("Start returned error = %v", err)
+ }
+ if device.startEntered.Load() {
+ t.Fatal("device Start should not run after stop intent arrived before publish")
+ }
+ if got := device.stopCount.Load(); got != 0 {
+ t.Fatalf("device stop count = %d, want 0 for never-started device", got)
+ }
+ if got := device.uninitCount.Load(); got != 1 {
+ t.Fatalf("device uninit count = %d, want 1", got)
+ }
+}
+
+func TestStopWhileStartBlockedLetsStartErrorCleanup(t *testing.T) {
+ startErr := errors.New("start failed")
+ device := newFakeRecorderDevice()
+ device.startErr = startErr
+ ctx := &fakeRecorderContext{device: device}
+ r := &Recorder{ctx: ctx}
+
+ startDone := make(chan error, 1)
+ go func() {
+ startDone <- r.Start()
+ }()
+ waitForRecorderTest(t, func() bool { return device.startEntered.Load() })
+
+ stopDone := make(chan struct{})
+ go func() {
+ r.Stop()
+ close(stopDone)
+ }()
+
+ select {
+ case <-stopDone:
+ case <-time.After(time.Second):
+ t.Fatal("Stop should return while recorder Start is still blocked")
+ }
+ if got := device.uninitCount.Load(); got != 0 {
+ t.Fatalf("device uninit before Start error returned = %d, want 0", got)
+ }
+ close(device.allowStart)
+ if err := <-startDone; !errors.Is(err, startErr) {
+ t.Fatalf("Start error = %v, want %v", err, startErr)
+ }
+ if got := device.uninitCount.Load(); got != 1 {
+ t.Fatalf("device uninit count = %d, want 1", got)
+ }
+}
+
func pcm16LE(values ...int16) []byte {
buf := make([]byte, len(values)*2)
for i, value := range values {
@@ -65,3 +466,72 @@
}
return buf
}
+
+type fakeRecorderContext struct {
+ device *fakeRecorderDevice
+ initErr error
+ initBlock <-chan struct{}
+ initEntered atomic.Bool
+ freeCount atomic.Int32
+}
+
+func (c *fakeRecorderContext) devices(malgo.DeviceType) ([]malgo.DeviceInfo, error) {
+ return nil, nil
+}
+
+func (c *fakeRecorderContext) initDevice(malgo.DeviceConfig, malgo.DeviceCallbacks) (recorderDevice, error) {
+ c.initEntered.Store(true)
+ if c.initBlock != nil {
+ <-c.initBlock
+ }
+ if c.initErr != nil {
+ return nil, c.initErr
+ }
+ return c.device, nil
+}
+
+func (c *fakeRecorderContext) free() {
+ c.freeCount.Add(1)
+}
+
+type fakeRecorderDevice struct {
+ startEntered atomic.Bool
+ startOnce sync.Once
+ allowStart chan struct{}
+ startErr error
+ stopCount atomic.Int32
+ uninitCount atomic.Int32
+}
+
+func newFakeRecorderDevice() *fakeRecorderDevice {
+ return &fakeRecorderDevice{allowStart: make(chan struct{})}
+}
+
+func (d *fakeRecorderDevice) Start() error {
+ d.startOnce.Do(func() {
+ d.startEntered.Store(true)
+ })
+ <-d.allowStart
+ return d.startErr
+}
+
+func (d *fakeRecorderDevice) Stop() error {
+ d.stopCount.Add(1)
+ return nil
+}
+
+func (d *fakeRecorderDevice) Uninit() {
+ d.uninitCount.Add(1)
+}
+
+func waitForRecorderTest(t *testing.T, cond func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatal("condition not met before timeout")
+}
diff --git a/C1.source/privatevoice.src/internal/axinput/axinput.go b/C1.source/privatevoice.src/internal/axinput/axinput.go
new file mode 100644
index 0000000..84b059b
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/axinput/axinput.go
@@ -0,0 +1,45 @@
+package axinput
+
+type DirectInsertResult struct {
+ OK bool `json:"ok"`
+ Inserted bool `json:"inserted"`
+ NeedsPermission bool `json:"needsPermission"`
+ Method string `json:"method"`
+ Message string `json:"message"`
+ Target string `json:"target,omitempty"`
+}
+
+type ClipboardResult struct {
+ OK bool `json:"ok"`
+ Prepared bool `json:"prepared"`
+ Restored bool `json:"restored"`
+ Token string `json:"token,omitempty"`
+ Message string `json:"message"`
+}
+
+type FrontmostAppIdentity struct {
+ OK bool `json:"ok"`
+ Identity string `json:"identity,omitempty"`
+ Message string `json:"message"`
+}
+
+type AutoPasteResult struct {
+ OK bool `json:"ok"`
+ Pasted bool `json:"pasted"`
+ FrontmostUnchanged bool `json:"frontmostUnchanged"`
+ Message string `json:"message"`
+}
+
+type Driver interface {
+ AccessibilityTrusted(prompt bool) bool
+ DirectInsert(text string) DirectInsertResult
+ CopyTextToClipboard(text string) ClipboardResult
+ FrontmostAppIdentity() FrontmostAppIdentity
+ AutoPasteClipboardIfFrontmostUnchanged(expectedIdentity string) AutoPasteResult
+ PrepareClipboardFallback(text string) ClipboardResult
+ RestoreClipboardFallback(token string) ClipboardResult
+}
+
+func NewDriver() Driver {
+ return newPlatformDriver()
+}
diff --git a/C1.source/privatevoice.src/internal/axinput/axinput_darwin.go b/C1.source/privatevoice.src/internal/axinput/axinput_darwin.go
new file mode 100644
index 0000000..df55e38
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/axinput/axinput_darwin.go
@@ -0,0 +1,492 @@
+//go:build darwin
+
+package axinput
+
+/*
+#cgo CFLAGS: -x objective-c
+#cgo LDFLAGS: -framework AppKit -framework ApplicationServices
+#include <ApplicationServices/ApplicationServices.h>
+#include <AppKit/AppKit.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+static char* pvAXCopyError(NSString* message) {
+ const char* utf8 = [message UTF8String];
+ if (!utf8) return strdup("unknown error");
+ return strdup(utf8);
+}
+
+static NSString* pvAXStringAttribute(AXUIElementRef element, CFStringRef attr) {
+ CFTypeRef valueRef = NULL;
+ AXError err = AXUIElementCopyAttributeValue(element, attr, &valueRef);
+ if (err != kAXErrorSuccess || !valueRef) return nil;
+ NSString* result = nil;
+ if (CFGetTypeID(valueRef) == CFStringGetTypeID()) {
+ result = [(__bridge NSString*)valueRef copy];
+ }
+ CFRelease(valueRef);
+ return [result autorelease];
+}
+
+static NSString* pvAXElementSummary(AXUIElementRef element) {
+ NSMutableArray<NSString*>* parts = [NSMutableArray array];
+ pid_t pid = 0;
+ if (AXUIElementGetPid(element, &pid) == kAXErrorSuccess && pid > 0) {
+ NSRunningApplication* app = [NSRunningApplication runningApplicationWithProcessIdentifier:pid];
+ NSString* name = [app localizedName];
+ if (name.length > 0) {
+ [parts addObject:[NSString stringWithFormat:@"app=%@", name]];
+ }
+ [parts addObject:[NSString stringWithFormat:@"pid=%d", pid]];
+ }
+ NSString* role = pvAXStringAttribute(element, kAXRoleAttribute);
+ if (role.length > 0) {
+ [parts addObject:[NSString stringWithFormat:@"role=%@", role]];
+ }
+ NSString* subrole = pvAXStringAttribute(element, kAXSubroleAttribute);
+ if (subrole.length > 0) {
+ [parts addObject:[NSString stringWithFormat:@"subrole=%@", subrole]];
+ }
+ if (parts.count == 0) return @"target=unknown";
+ return [parts componentsJoinedByString:@" "];
+}
+
+static BOOL pvAXAttributeSettable(AXUIElementRef element, CFStringRef attr, Boolean* settable) {
+ Boolean localSettable = false;
+ AXError err = AXUIElementIsAttributeSettable(element, attr, &localSettable);
+ if (settable) *settable = localSettable;
+ return err == kAXErrorSuccess;
+}
+
+static char* pvAXCopyResult(int ok, NSString* method, NSString* target, NSString* message) {
+ NSString* payload = [NSString stringWithFormat:@"%d\n%@\n%@\n%@",
+ ok,
+ method ?: @"ax_focused_element",
+ target ?: @"target=unknown",
+ message ?: @""];
+ return pvAXCopyError(payload);
+}
+
+static int pvAXTrusted(int prompt) {
+ if (prompt) {
+ NSDictionary* opts = @{(__bridge NSString*)kAXTrustedCheckOptionPrompt: @YES};
+ return AXIsProcessTrustedWithOptions((__bridge CFDictionaryRef)opts) ? 1 : 0;
+ }
+ return AXIsProcessTrusted() ? 1 : 0;
+}
+
+static AXUIElementRef pvAXCopyFrontmostFocusedElement(AXError* outErr) {
+ if (outErr) *outErr = kAXErrorFailure;
+ NSRunningApplication* app = [[NSWorkspace sharedWorkspace] frontmostApplication];
+ if (!app || app.processIdentifier <= 0) {
+ return NULL;
+ }
+ AXUIElementRef appElement = AXUIElementCreateApplication(app.processIdentifier);
+ if (!appElement) {
+ return NULL;
+ }
+ AXUIElementSetMessagingTimeout(appElement, 1.0);
+ AXUIElementRef focused = NULL;
+ AXError err = AXUIElementCopyAttributeValue(appElement, kAXFocusedUIElementAttribute, (CFTypeRef*)&focused);
+ CFRelease(appElement);
+ if (outErr) *outErr = err;
+ if (err != kAXErrorSuccess || !focused) {
+ return NULL;
+ }
+ return focused;
+}
+
+static AXUIElementRef pvAXCopyBestFocusedElement(AXError* outSystemErr, AXError* outFocusedAppErr, AXError* outFocusedAppElementErr, AXError* outFrontmostErr) {
+ if (outSystemErr) *outSystemErr = kAXErrorFailure;
+ if (outFocusedAppErr) *outFocusedAppErr = kAXErrorFailure;
+ if (outFocusedAppElementErr) *outFocusedAppElementErr = kAXErrorFailure;
+ if (outFrontmostErr) *outFrontmostErr = kAXErrorFailure;
+
+ AXUIElementRef system = AXUIElementCreateSystemWide();
+ if (!system) {
+ return NULL;
+ }
+ AXUIElementSetMessagingTimeout(system, 1.0);
+
+ AXUIElementRef focused = NULL;
+ AXError systemErr = AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute, (CFTypeRef*)&focused);
+ if (outSystemErr) *outSystemErr = systemErr;
+ if (systemErr == kAXErrorSuccess && focused) {
+ CFRelease(system);
+ return focused;
+ }
+
+ AXUIElementRef focusedApp = NULL;
+ AXError focusedAppErr = AXUIElementCopyAttributeValue(system, kAXFocusedApplicationAttribute, (CFTypeRef*)&focusedApp);
+ if (outFocusedAppErr) *outFocusedAppErr = focusedAppErr;
+ if (focusedAppErr == kAXErrorSuccess && focusedApp) {
+ AXUIElementSetMessagingTimeout(focusedApp, 1.0);
+ AXError focusedAppElementErr = AXUIElementCopyAttributeValue(focusedApp, kAXFocusedUIElementAttribute, (CFTypeRef*)&focused);
+ if (outFocusedAppElementErr) *outFocusedAppElementErr = focusedAppElementErr;
+ CFRelease(focusedApp);
+ if (focusedAppElementErr == kAXErrorSuccess && focused) {
+ CFRelease(system);
+ return focused;
+ }
+ }
+ CFRelease(system);
+
+ return pvAXCopyFrontmostFocusedElement(outFrontmostErr);
+}
+
+static char* pvAXDirectInsert(const char* text) {
+ if (!pvAXTrusted(0)) {
+ return pvAXCopyResult(0, @"ax_focused_element", nil, @"accessibility permission is not granted");
+ }
+
+ NSString* insert = [NSString stringWithUTF8String:text];
+ if (!insert) {
+ return pvAXCopyResult(0, @"ax_focused_element", nil, @"input text is not valid UTF-8");
+ }
+
+ AXUIElementRef focused = NULL;
+ AXError systemErr = kAXErrorFailure;
+ AXError focusedAppErr = kAXErrorFailure;
+ AXError focusedAppElementErr = kAXErrorFailure;
+ AXError frontmostErr = kAXErrorFailure;
+ for (int attempt = 0; attempt < 3 && !focused; attempt++) {
+ focused = pvAXCopyBestFocusedElement(&systemErr, &focusedAppErr, &focusedAppElementErr, &frontmostErr);
+ if (!focused && attempt < 2) {
+ usleep(50000);
+ }
+ }
+ if (!focused) {
+ return pvAXCopyResult(0, @"ax_focused_element", nil, [NSString stringWithFormat:@"focused element unavailable: system=%d focused_app=%d focused_app_element=%d frontmost=%d", systemErr, focusedAppErr, focusedAppElementErr, frontmostErr]);
+ }
+
+ NSString* target = pvAXElementSummary(focused);
+
+ CFTypeRef valueRef = NULL;
+ CFTypeRef rangeRef = NULL;
+ AXError valueErr = AXUIElementCopyAttributeValue(focused, kAXValueAttribute, &valueRef);
+ AXError rangeErr = AXUIElementCopyAttributeValue(focused, kAXSelectedTextRangeAttribute, &rangeRef);
+ if (valueErr != kAXErrorSuccess || rangeErr != kAXErrorSuccess || !valueRef || !rangeRef ||
+ CFGetTypeID(valueRef) != CFStringGetTypeID() || CFGetTypeID(rangeRef) != AXValueGetTypeID()) {
+ if (valueRef) CFRelease(valueRef);
+ if (rangeRef) CFRelease(rangeRef);
+ CFRelease(focused);
+ return pvAXCopyResult(0, @"ax_focused_element",
+ target,
+ [NSString stringWithFormat:@"focused text state is not readable; value=%d range=%d", valueErr, rangeErr]);
+ }
+
+ CFRange selectedRange;
+ if (!AXValueGetValue((AXValueRef)rangeRef, kAXValueCFRangeType, &selectedRange)) {
+ CFRelease(valueRef);
+ CFRelease(rangeRef);
+ CFRelease(focused);
+ return pvAXCopyResult(0, @"ax_value_replacement", target, @"focused element selected range is unreadable");
+ }
+
+ NSString* value = (__bridge NSString*)valueRef;
+ NSUInteger valueLength = [value length];
+ if (selectedRange.location < 0 || selectedRange.length < 0 ||
+ (NSUInteger)selectedRange.location > valueLength ||
+ (NSUInteger)(selectedRange.location + selectedRange.length) > valueLength) {
+ CFRelease(valueRef);
+ CFRelease(rangeRef);
+ CFRelease(focused);
+ return pvAXCopyResult(0, @"ax_value_replacement", target, @"focused element selected range is out of bounds");
+ }
+
+ Boolean valueSettable = false;
+ BOOL settableKnown = pvAXAttributeSettable(focused, kAXValueAttribute, &valueSettable);
+ if (settableKnown && !valueSettable) {
+ CFRelease(valueRef);
+ CFRelease(rangeRef);
+ CFRelease(focused);
+ return pvAXCopyResult(0,
+ @"ax_value_replacement",
+ target,
+ @"focused element value is not settable");
+ }
+
+ NSRange nsRange = NSMakeRange((NSUInteger)selectedRange.location, (NSUInteger)selectedRange.length);
+ NSString* updated = [value stringByReplacingCharactersInRange:nsRange withString:insert];
+ AXError setErr = AXUIElementSetAttributeValue(focused, kAXValueAttribute, (__bridge CFTypeRef)updated);
+ if (setErr == kAXErrorSuccess) {
+ CFRange newRange = CFRangeMake(selectedRange.location + [insert length], 0);
+ AXValueRef newRangeValue = AXValueCreate(kAXValueCFRangeType, &newRange);
+ if (newRangeValue) {
+ AXUIElementSetAttributeValue(focused, kAXSelectedTextRangeAttribute, newRangeValue);
+ CFRelease(newRangeValue);
+ }
+ }
+
+ CFTypeRef verifyRef = NULL;
+ AXError verifyErr = AXUIElementCopyAttributeValue(focused, kAXValueAttribute, &verifyRef);
+ BOOL verified = false;
+ if (verifyErr == kAXErrorSuccess && verifyRef && CFGetTypeID(verifyRef) == CFStringGetTypeID()) {
+ NSString* verifiedValue = (__bridge NSString*)verifyRef;
+ verified = [verifiedValue isEqualToString:updated];
+ }
+ if (verifyRef) CFRelease(verifyRef);
+
+ CFRelease(valueRef);
+ CFRelease(rangeRef);
+ CFRelease(focused);
+ if (setErr != kAXErrorSuccess) {
+ return pvAXCopyResult(0, @"ax_value_replacement", target, [NSString stringWithFormat:@"AX value replacement failed: %d", setErr]);
+ }
+ if (!verified) {
+ return pvAXCopyResult(0, @"ax_value_replacement", target, [NSString stringWithFormat:@"AX value replacement was not observable after write; verify=%d", verifyErr]);
+ }
+ return pvAXCopyResult(1, @"ax_value_replacement", target, @"focused element value changed after write");
+}
+
+static int pvSetClipboardText(const char* text) {
+ NSPasteboard* pb = [NSPasteboard generalPasteboard];
+ if (!pb) return 0;
+ NSString* str = [NSString stringWithUTF8String:text];
+ if (!str) return 0;
+ [pb clearContents];
+ NSArray<NSPasteboardType>* types = @[NSPasteboardTypeString];
+ [pb declareTypes:types owner:nil];
+ BOOL wrote = [pb setString:str forType:NSPasteboardTypeString];
+ NSString* verified = [pb stringForType:NSPasteboardTypeString];
+ return wrote && verified && [verified isEqualToString:str] ? 1 : 0;
+}
+
+static int pvClearClipboard(void) {
+ NSPasteboard* pb = [NSPasteboard generalPasteboard];
+ if (!pb) return 0;
+ [pb clearContents];
+ return [pb stringForType:NSPasteboardTypeString] == nil ? 1 : 0;
+}
+
+static char* pvGetClipboardText(void) {
+ NSPasteboard* pb = [NSPasteboard generalPasteboard];
+ NSString* str = [pb stringForType:NSPasteboardTypeString];
+ if (!str) return NULL;
+ const char* utf8 = [str UTF8String];
+ if (!utf8) return NULL;
+ return strdup(utf8);
+}
+
+static char* pvCopyFrontmostAppIdentity(void) {
+ NSRunningApplication* app = [[NSWorkspace sharedWorkspace] frontmostApplication];
+ if (!app || app.processIdentifier <= 0) return NULL;
+ NSString* bundleID = [app bundleIdentifier] ?: @"";
+ NSString* identity = [NSString stringWithFormat:@"bundle=%@ pid=%d", bundleID, app.processIdentifier];
+ const char* utf8 = [identity UTF8String];
+ if (!utf8) return NULL;
+ return strdup(utf8);
+}
+
+static int pvPostCommandVIfFrontmostUnchanged(const char* expectedIdentity) {
+ if (!expectedIdentity || strlen(expectedIdentity) == 0) return -1;
+ char* currentIdentity = pvCopyFrontmostAppIdentity();
+ if (!currentIdentity) return -1;
+ int same = strcmp(currentIdentity, expectedIdentity) == 0;
+ free(currentIdentity);
+ if (!same) return 0;
+
+ CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateCombinedSessionState);
+ if (!source) return -2;
+ CGEventRef keyDown = CGEventCreateKeyboardEvent(source, (CGKeyCode)9, true);
+ CGEventRef keyUp = CGEventCreateKeyboardEvent(source, (CGKeyCode)9, false);
+ if (!keyDown || !keyUp) {
+ if (keyDown) CFRelease(keyDown);
+ if (keyUp) CFRelease(keyUp);
+ CFRelease(source);
+ return -2;
+ }
+ CGEventSetFlags(keyDown, kCGEventFlagMaskCommand);
+ CGEventSetFlags(keyUp, kCGEventFlagMaskCommand);
+ CGEventPost(kCGHIDEventTap, keyDown); // AX10DT_EXPERIMENT_ALLOW_CGEVENTPOST: one-shot Cmd+V only when explicitly enabled by config.
+ CGEventPost(kCGHIDEventTap, keyUp); // AX10DT_EXPERIMENT_ALLOW_CGEVENTPOST: one-shot Cmd+V only when explicitly enabled by config.
+ CFRelease(keyDown);
+ CFRelease(keyUp);
+ CFRelease(source);
+ return 1;
+}
+*/
+import "C"
+
+import (
+ "strings"
+ "unsafe"
+)
+
+type darwinDriver struct {
+ clipboard clipboardState
+}
+
+var (
+ readClipboardSnapshot = currentClipboardSnapshot
+ writeClipboardText = writeClipboardTextDarwin
+ clearClipboardText = clearClipboardTextDarwin
+)
+
+func newPlatformDriver() Driver {
+ return &darwinDriver{}
+}
+
+func (d *darwinDriver) AccessibilityTrusted(prompt bool) bool {
+ if prompt {
+ return C.pvAXTrusted(1) != 0
+ }
+ return C.pvAXTrusted(0) != 0
+}
+
+func (d *darwinDriver) DirectInsert(text string) DirectInsertResult {
+ if text == "" {
+ return DirectInsertResult{OK: false, Method: "ax_focused_element", Message: "text is empty"}
+ }
+ cText := C.CString(text)
+ defer C.free(unsafe.Pointer(cText))
+ cResult := C.pvAXDirectInsert(cText)
+ if cResult == nil {
+ return DirectInsertResult{
+ OK: false,
+ Inserted: false,
+ NeedsPermission: !d.AccessibilityTrusted(false),
+ Method: "ax_focused_element",
+ Message: "AX direct insert returned no result",
+ }
+ }
+ defer C.free(unsafe.Pointer(cResult))
+ ok, method, target, msg := parseAXDirectInsertPayload(C.GoString(cResult))
+ if !ok {
+ return DirectInsertResult{
+ OK: false,
+ Inserted: false,
+ NeedsPermission: !d.AccessibilityTrusted(false),
+ Method: method,
+ Message: msg,
+ Target: target,
+ }
+ }
+ return DirectInsertResult{
+ OK: true,
+ Inserted: true,
+ Method: method,
+ Message: msg,
+ Target: target,
+ }
+}
+
+func parseAXDirectInsertPayload(payload string) (bool, string, string, string) {
+ parts := strings.SplitN(payload, "\n", 4)
+ if len(parts) != 4 {
+ return false, "ax_focused_element", "", payload
+ }
+ return parts[0] == "1", parts[1], parts[2], parts[3]
+}
+
+func (d *darwinDriver) CopyTextToClipboard(text string) ClipboardResult {
+ if text == "" {
+ return ClipboardResult{OK: false, Message: "text is empty"}
+ }
+ if !writeClipboardText(text) {
+ return ClipboardResult{
+ OK: false,
+ Message: "clipboard could not be written",
+ }
+ }
+ return ClipboardResult{
+ OK: true,
+ Prepared: true,
+ Message: "clipboard updated; user must paste manually",
+ }
+}
+
+func (d *darwinDriver) FrontmostAppIdentity() FrontmostAppIdentity {
+ cIdentity := C.pvCopyFrontmostAppIdentity()
+ if cIdentity == nil {
+ return FrontmostAppIdentity{OK: false, Message: "frontmost app identity is unavailable"}
+ }
+ defer C.free(unsafe.Pointer(cIdentity))
+ identity := C.GoString(cIdentity)
+ if strings.TrimSpace(identity) == "" {
+ return FrontmostAppIdentity{OK: false, Message: "frontmost app identity is empty"}
+ }
+ return FrontmostAppIdentity{OK: true, Identity: identity}
+}
+
+func (d *darwinDriver) AutoPasteClipboardIfFrontmostUnchanged(expectedIdentity string) AutoPasteResult {
+ if strings.TrimSpace(expectedIdentity) == "" {
+ return AutoPasteResult{OK: false, Message: "frontmost app identity is unavailable"}
+ }
+ cExpected := C.CString(expectedIdentity)
+ defer C.free(unsafe.Pointer(cExpected))
+ switch C.pvPostCommandVIfFrontmostUnchanged(cExpected) {
+ case 1:
+ return AutoPasteResult{OK: true, Pasted: true, FrontmostUnchanged: true, Message: "automatic paste event sent"}
+ case 0:
+ return AutoPasteResult{OK: false, Pasted: false, FrontmostUnchanged: false, Message: "frontmost app changed before automatic paste"}
+ default:
+ return AutoPasteResult{OK: false, Pasted: false, FrontmostUnchanged: false, Message: "automatic paste event could not be sent"}
+ }
+}
+
+func (d *darwinDriver) PrepareClipboardFallback(text string) ClipboardResult {
+ if text == "" {
+ return ClipboardResult{OK: false, Message: "text is empty"}
+ }
+ restore := readClipboardSnapshot()
+ if !writeClipboardText(text) {
+ return ClipboardResult{
+ OK: false,
+ Prepared: false,
+ Message: "clipboard fallback could not be written",
+ }
+ }
+ token := d.clipboard.remember(text, restore)
+ return ClipboardResult{
+ OK: true,
+ Prepared: true,
+ Token: token,
+ Message: "clipboard fallback prepared; user must paste manually",
+ }
+}
+
+func (d *darwinDriver) RestoreClipboardFallback(token string) ClipboardResult {
+ entry, ok := d.clipboard.take(token)
+ if !ok {
+ return ClipboardResult{OK: false, Message: "unknown or already restored fallback token"}
+ }
+ current := readClipboardSnapshot()
+ if !shouldRestoreClipboard(current, entry.fallbackText) {
+ return ClipboardResult{
+ OK: false,
+ Restored: false,
+ Message: "clipboard changed after fallback preparation; restore skipped",
+ }
+ }
+ if entry.restore.hasText {
+ if !writeClipboardText(entry.restore.text) {
+ return ClipboardResult{OK: false, Restored: false, Message: "clipboard restore failed"}
+ }
+ return ClipboardResult{OK: true, Restored: true, Message: "clipboard restored"}
+ }
+ if !clearClipboardText() {
+ return ClipboardResult{OK: false, Restored: false, Message: "clipboard restore failed"}
+ }
+ return ClipboardResult{OK: true, Restored: true, Message: "clipboard restored"}
+}
+
+func writeClipboardTextDarwin(text string) bool {
+ cText := C.CString(text)
+ defer C.free(unsafe.Pointer(cText))
+ return C.pvSetClipboardText(cText) != 0
+}
+
+func clearClipboardTextDarwin() bool {
+ return C.pvClearClipboard() != 0
+}
+
+func currentClipboardSnapshot() clipboardSnapshot {
+ cText := C.pvGetClipboardText()
+ if cText == nil {
+ return clipboardSnapshot{}
+ }
+ defer C.free(unsafe.Pointer(cText))
+ return clipboardSnapshot{text: C.GoString(cText), hasText: true}
+}
diff --git a/C1.source/privatevoice.src/internal/axinput/axinput_other.go b/C1.source/privatevoice.src/internal/axinput/axinput_other.go
new file mode 100644
index 0000000..a9a87ae
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/axinput/axinput_other.go
@@ -0,0 +1,37 @@
+//go:build !darwin
+
+package axinput
+
+type unsupportedDriver struct{}
+
+func newPlatformDriver() Driver {
+ return &unsupportedDriver{}
+}
+
+func (d *unsupportedDriver) AccessibilityTrusted(prompt bool) bool {
+ return false
+}
+
+func (d *unsupportedDriver) DirectInsert(text string) DirectInsertResult {
+ return DirectInsertResult{OK: false, Method: "unsupported", Message: "AX direct input is macOS-only"}
+}
+
+func (d *unsupportedDriver) CopyTextToClipboard(text string) ClipboardResult {
+ return ClipboardResult{OK: false, Message: "AX clipboard fallback is macOS-only"}
+}
+
+func (d *unsupportedDriver) FrontmostAppIdentity() FrontmostAppIdentity {
+ return FrontmostAppIdentity{OK: false, Message: "frontmost app identity is macOS-only"}
+}
+
+func (d *unsupportedDriver) AutoPasteClipboardIfFrontmostUnchanged(expectedIdentity string) AutoPasteResult {
+ return AutoPasteResult{OK: false, Message: "automatic paste experiment is macOS-only"}
+}
+
+func (d *unsupportedDriver) PrepareClipboardFallback(text string) ClipboardResult {
+ return ClipboardResult{OK: false, Message: "AX clipboard fallback is macOS-only"}
+}
+
+func (d *unsupportedDriver) RestoreClipboardFallback(token string) ClipboardResult {
+ return ClipboardResult{OK: false, Message: "AX clipboard fallback is macOS-only"}
+}
diff --git a/C1.source/privatevoice.src/internal/axinput/clipboard_fallback_darwin_test.go b/C1.source/privatevoice.src/internal/axinput/clipboard_fallback_darwin_test.go
new file mode 100644
index 0000000..ca51d28
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/axinput/clipboard_fallback_darwin_test.go
@@ -0,0 +1,99 @@
+//go:build darwin
+
+package axinput
+
+import "testing"
+
+func TestPrepareClipboardFallbackFailsWhenPasteboardWriteFails(t *testing.T) {
+ withClipboardHooks(t,
+ func() clipboardSnapshot { return clipboardSnapshot{text: "original", hasText: true} },
+ func(text string) bool { return false },
+ func() bool { return true },
+ )
+
+ driver := &darwinDriver{}
+ result := driver.PrepareClipboardFallback("recognized text")
+ if result.OK || result.Prepared || result.Token != "" {
+ t.Fatalf("result = %+v, want failed prepare without token", result)
+ }
+}
+
+func TestPrepareClipboardFallbackCreatesTokenAfterVerifiedWrite(t *testing.T) {
+ var wrote string
+ withClipboardHooks(t,
+ func() clipboardSnapshot { return clipboardSnapshot{text: "original", hasText: true} },
+ func(text string) bool {
+ wrote = text
+ return true
+ },
+ func() bool { return true },
+ )
+
+ driver := &darwinDriver{}
+ result := driver.PrepareClipboardFallback("recognized text")
+ if !result.OK || !result.Prepared || result.Token == "" {
+ t.Fatalf("result = %+v, want prepared token", result)
+ }
+ if wrote != "recognized text" {
+ t.Fatalf("wrote = %q", wrote)
+ }
+}
+
+func TestCopyTextToClipboardWritesWithoutRestoreToken(t *testing.T) {
+ var wrote string
+ withClipboardHooks(t,
+ func() clipboardSnapshot { return clipboardSnapshot{text: "original", hasText: true} },
+ func(text string) bool {
+ wrote = text
+ return true
+ },
+ func() bool { return true },
+ )
+
+ driver := &darwinDriver{}
+ result := driver.CopyTextToClipboard("recognized text")
+ if !result.OK || !result.Prepared || result.Token != "" {
+ t.Fatalf("result = %+v, want clipboard write without restore token", result)
+ }
+ if wrote != "recognized text" {
+ t.Fatalf("wrote = %q", wrote)
+ }
+}
+
+func TestRestoreClipboardFallbackReportsRestoreWriteFailure(t *testing.T) {
+ writes := 0
+ current := clipboardSnapshot{text: "recognized text", hasText: true}
+ withClipboardHooks(t,
+ func() clipboardSnapshot { return current },
+ func(text string) bool {
+ writes++
+ return writes == 1
+ },
+ func() bool { return true },
+ )
+
+ driver := &darwinDriver{}
+ prepared := driver.PrepareClipboardFallback("recognized text")
+ if !prepared.OK || prepared.Token == "" {
+ t.Fatalf("prepare = %+v", prepared)
+ }
+ restored := driver.RestoreClipboardFallback(prepared.Token)
+ if restored.OK || restored.Restored {
+ t.Fatalf("restore = %+v, want failure when original clipboard cannot be written", restored)
+ }
+}
+
+func withClipboardHooks(t *testing.T, read func() clipboardSnapshot, write func(string) bool, clear func() bool) {
+ t.Helper()
+ origRead := readClipboardSnapshot
+ origWrite := writeClipboardText
+ origClear := clearClipboardText
+ readClipboardSnapshot = read
+ writeClipboardText = write
+ clearClipboardText = clear
+ t.Cleanup(func() {
+ readClipboardSnapshot = origRead
+ writeClipboardText = origWrite
+ clearClipboardText = origClear
+ })
+}
diff --git a/C1.source/privatevoice.src/internal/axinput/clipboard_state.go b/C1.source/privatevoice.src/internal/axinput/clipboard_state.go
new file mode 100644
index 0000000..1f2f767
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/axinput/clipboard_state.go
@@ -0,0 +1,51 @@
+package axinput
+
+import (
+ "fmt"
+ "sync"
+)
+
+type clipboardSnapshot struct {
+ text string
+ hasText bool
+}
+
+type clipboardState struct {
+ mu sync.Mutex
+ nextID uint64
+ entries map[string]clipboardEntry
+}
+
+type clipboardEntry struct {
+ fallbackText string
+ restore clipboardSnapshot
+}
+
+func (s *clipboardState) remember(fallbackText string, restore clipboardSnapshot) string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.entries == nil {
+ s.entries = make(map[string]clipboardEntry)
+ }
+ s.nextID++
+ token := fmt.Sprintf("ax-output-%d", s.nextID)
+ s.entries[token] = clipboardEntry{fallbackText: fallbackText, restore: restore}
+ return token
+}
+
+func (s *clipboardState) take(token string) (clipboardEntry, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.entries == nil {
+ return clipboardEntry{}, false
+ }
+ entry, ok := s.entries[token]
+ if ok {
+ delete(s.entries, token)
+ }
+ return entry, ok
+}
+
+func shouldRestoreClipboard(current clipboardSnapshot, expectedText string) bool {
+ return current.hasText && current.text == expectedText
+}
diff --git a/C1.source/privatevoice.src/internal/axinput/clipboard_state_test.go b/C1.source/privatevoice.src/internal/axinput/clipboard_state_test.go
new file mode 100644
index 0000000..37484a9
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/axinput/clipboard_state_test.go
@@ -0,0 +1,31 @@
+package axinput
+
+import "testing"
+
+func TestShouldRestoreClipboardRequiresFallbackTextStillPresent(t *testing.T) {
+ if !shouldRestoreClipboard(clipboardSnapshot{text: "recognized text", hasText: true}, "recognized text") {
+ t.Fatal("expected matching fallback clipboard text to be restorable")
+ }
+ if shouldRestoreClipboard(clipboardSnapshot{text: "user changed clipboard", hasText: true}, "recognized text") {
+ t.Fatal("changed clipboard must not be overwritten")
+ }
+ if shouldRestoreClipboard(clipboardSnapshot{}, "recognized text") {
+ t.Fatal("empty clipboard must not be restored over")
+ }
+}
+
+func TestClipboardStateTokenIsSingleUse(t *testing.T) {
+ var state clipboardState
+ token := state.remember("recognized text", clipboardSnapshot{text: "original", hasText: true})
+
+ entry, ok := state.take(token)
+ if !ok {
+ t.Fatal("expected first token lookup to succeed")
+ }
+ if entry.fallbackText != "recognized text" || !entry.restore.hasText || entry.restore.text != "original" {
+ t.Fatalf("entry = %+v", entry)
+ }
+ if _, ok := state.take(token); ok {
+ t.Fatal("token must be single use")
+ }
+}
diff --git a/C1.source/privatevoice.src/internal/config/config.go b/C1.source/privatevoice.src/internal/config/config.go
index 25bbffe..4693a58 100755
--- a/C1.source/privatevoice.src/internal/config/config.go
+++ b/C1.source/privatevoice.src/internal/config/config.go
@@ -10,6 +10,7 @@
)
const (
+ UnsetHotkeyVK = 0
LegacyDefaultHotkeyVK = 0xA5
RightModifierDefaultVK = 0x5C
HotkeyModeTap = "tap"
@@ -28,6 +29,7 @@
SoundFeedback bool `json:"SoundFeedback"`
HideDockIcon bool `json:"HideDockIcon"`
CopyToClipboard bool `json:"CopyToClipboard"`
+ AutoPasteExperiment bool `json:"AutoPasteExperiment"`
DeviceName string `json:"DeviceName,omitempty"`
IndicatorX int `json:"IndicatorX,omitempty"`
IndicatorY int `json:"IndicatorY,omitempty"`
@@ -62,6 +64,7 @@
SoundFeedback: true,
HideDockIcon: true,
CopyToClipboard: true,
+ AutoPasteExperiment: false,
SelectedModelID: model.DefaultModelID,
ModelSelectionMode: ModelSelectionModeAuto,
LanguageMode: LanguageModeAuto,
@@ -72,6 +75,9 @@
}
func defaultHotkeyVKForNewConfig(goos string) int {
+ if defaultHotkeyUnsetForNewConfig(goos) {
+ return UnsetHotkeyVK
+ }
switch goos {
case "darwin", "windows":
return RightModifierDefaultVK
@@ -80,6 +86,10 @@
}
}
+func IsHotkeyUnset(vk int) bool {
+ return vk == UnsetHotkeyVK
+}
+
func configPath() string {
return paths.File("config.json")
}
diff --git a/C1.source/privatevoice.src/internal/config/config_test.go b/C1.source/privatevoice.src/internal/config/config_test.go
index c6d94aa..38456f4 100644
--- a/C1.source/privatevoice.src/internal/config/config_test.go
+++ b/C1.source/privatevoice.src/internal/config/config_test.go
@@ -25,22 +25,25 @@
if cfg.ModelSelectionMode != ModelSelectionModeAuto {
t.Fatalf("model selection mode = %q, want %q", cfg.ModelSelectionMode, ModelSelectionModeAuto)
}
+ if cfg.AutoPasteExperiment {
+ t.Fatal("AutoPasteExperiment default = true, want false")
+ }
}
func TestDefaultHotkeyForNewConfig(t *testing.T) {
- tests := []struct {
- goos string
- want int
- }{
- {goos: "darwin", want: RightModifierDefaultVK},
- {goos: "windows", want: RightModifierDefaultVK},
- {goos: "linux", want: LegacyDefaultHotkeyVK},
+ if got := defaultHotkeyVKForNewConfig("darwin"); defaultHotkeyUnsetForNewConfig("darwin") {
+ if got != UnsetHotkeyVK {
+ t.Fatalf("darwin appstore default = 0x%X, want unset", got)
+ }
+ } else if got != RightModifierDefaultVK {
+ t.Fatalf("darwin default = 0x%X, want 0x%X", got, RightModifierDefaultVK)
}
- 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)
- }
+ if got := defaultHotkeyVKForNewConfig("windows"); got != RightModifierDefaultVK {
+ t.Fatalf("windows default = 0x%X, want 0x%X", got, RightModifierDefaultVK)
+ }
+ if got := defaultHotkeyVKForNewConfig("linux"); got != LegacyDefaultHotkeyVK {
+ t.Fatalf("linux default = 0x%X, want 0x%X", got, LegacyDefaultHotkeyVK)
}
}
diff --git a/C1.source/privatevoice.src/internal/config/default_hotkey_appstore_darwin.go b/C1.source/privatevoice.src/internal/config/default_hotkey_appstore_darwin.go
new file mode 100644
index 0000000..3d0dae6
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/config/default_hotkey_appstore_darwin.go
@@ -0,0 +1,7 @@
+//go:build darwin && appstore
+
+package config
+
+func defaultHotkeyUnsetForNewConfig(goos string) bool {
+ return goos == "darwin"
+}
diff --git a/C1.source/privatevoice.src/internal/config/default_hotkey_appstore_darwin_test.go b/C1.source/privatevoice.src/internal/config/default_hotkey_appstore_darwin_test.go
new file mode 100644
index 0000000..1666417
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/config/default_hotkey_appstore_darwin_test.go
@@ -0,0 +1,20 @@
+//go:build darwin && appstore
+
+package config
+
+import "testing"
+
+func TestAppStoreDarwinNewConfigHotkeyUnset(t *testing.T) {
+ if got := defaultHotkeyVKForNewConfig("darwin"); got != UnsetHotkeyVK {
+ t.Fatalf("darwin appstore default = 0x%X, want unset", got)
+ }
+ if got := Default().HotkeyVK; got != UnsetHotkeyVK {
+ t.Fatalf("Default().HotkeyVK = 0x%X, want unset", got)
+ }
+ if got := Default().HotkeyMode; got != HotkeyModeHold {
+ t.Fatalf("Default().HotkeyMode = %q, want %q", got, HotkeyModeHold)
+ }
+ if got := defaultHotkeyVKForNewConfig("windows"); got != RightModifierDefaultVK {
+ t.Fatalf("windows default = 0x%X, want 0x%X", got, RightModifierDefaultVK)
+ }
+}
diff --git a/C1.source/privatevoice.src/internal/config/default_hotkey_default.go b/C1.source/privatevoice.src/internal/config/default_hotkey_default.go
new file mode 100644
index 0000000..967ea11
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/config/default_hotkey_default.go
@@ -0,0 +1,7 @@
+//go:build !darwin || !appstore
+
+package config
+
+func defaultHotkeyUnsetForNewConfig(goos string) bool {
+ return false
+}
diff --git a/C1.source/privatevoice.src/internal/hotkey/hotkey.go b/C1.source/privatevoice.src/internal/hotkey/hotkey.go
index a3d187c..9a7d20d 100755
--- a/C1.source/privatevoice.src/internal/hotkey/hotkey.go
+++ b/C1.source/privatevoice.src/internal/hotkey/hotkey.go
@@ -30,6 +30,8 @@
func GetKeyNameForPlatform(vk int, goos string) string {
switch vk {
+ case 0:
+ return ""
case 0x11:
return "Ctrl"
case 0xA2:
diff --git a/C1.source/privatevoice.src/internal/hotkey/hotkey_darwin.go b/C1.source/privatevoice.src/internal/hotkey/hotkey_darwin.go
index 8143070..3234ced 100755
--- a/C1.source/privatevoice.src/internal/hotkey/hotkey_darwin.go
+++ b/C1.source/privatevoice.src/internal/hotkey/hotkey_darwin.go
@@ -19,11 +19,11 @@
static volatile unsigned long long g_monitorStartAttempts = 0;
static volatile unsigned long long g_eventTapCreateFailures = 0;
static volatile unsigned long long g_eventTapDisabledCount = 0;
-static volatile unsigned long long g_altEventSeq[2];
-static volatile int g_altEventDown[2];
-static volatile int g_altEventType[2];
-static volatile unsigned long long g_altEventFlags[2];
-static volatile double g_altEventAt[2];
+static volatile unsigned long long g_modifierEventSeq[4];
+static volatile int g_modifierEventDown[4];
+static volatile int g_modifierEventType[4];
+static volatile unsigned long long g_modifierEventFlags[4];
+static volatile double g_modifierEventAt[4];
static id g_activityToken = nil;
static double nowSeconds(void) {
@@ -36,20 +36,22 @@
if (isDown) g_lastDownAt[keyCode] = nowSeconds();
}
-static int altIndexForKey(unsigned short keyCode) {
+static int modifierDiagnosticIndexForKey(unsigned short keyCode) {
if (keyCode == 58) return 0; // L-Option
if (keyCode == 61) return 1; // R-Option
+ if (keyCode == 55) return 2; // L-Command
+ if (keyCode == 54) return 3; // R-Command
return -1;
}
-static void recordAltEvent(unsigned short keyCode, int eventType, CGEventFlags flags) {
- int index = altIndexForKey(keyCode);
+static void recordModifierEvent(unsigned short keyCode, int eventType, CGEventFlags flags) {
+ int index = modifierDiagnosticIndexForKey(keyCode);
if (index < 0) return;
- g_altEventSeq[index]++;
- g_altEventDown[index] = g_keysDown[keyCode] ? 1 : 0;
- g_altEventType[index] = eventType;
- g_altEventFlags[index] = flags;
- g_altEventAt[index] = nowSeconds();
+ g_modifierEventSeq[index]++;
+ g_modifierEventDown[index] = g_keysDown[keyCode] ? 1 : 0;
+ g_modifierEventType[index] = eventType;
+ g_modifierEventFlags[index] = flags;
+ g_modifierEventAt[index] = nowSeconds();
}
static unsigned long long modifierMaskForKey(unsigned short keyCode) {
@@ -80,7 +82,7 @@
BOOL physicalDown = CGEventSourceKeyState(kCGEventSourceStateHIDSystemState, (CGKeyCode)keyCode);
BOOL eventDown = (flags & mask) ? YES : NO;
handleKeyEvent(keyCode, (physicalDown || eventDown) ? YES : NO);
- recordAltEvent(keyCode, 3, flags);
+ recordModifierEvent(keyCode, 3, flags);
}
static CGEventRef keyboardEventTapCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *refcon) {
@@ -99,7 +101,7 @@
handleModifierEvent(keyCode, CGEventGetFlags(event));
} else {
handleKeyEvent(keyCode, type == kCGEventKeyDown);
- recordAltEvent(keyCode, type == kCGEventKeyDown ? 1 : 2, CGEventGetFlags(event));
+ recordModifierEvent(keyCode, type == kCGEventKeyDown ? 1 : 2, CGEventGetFlags(event));
}
return event;
}
@@ -218,26 +220,57 @@
return (flags & flagMask) ? 1 : 0;
}
-static int isSideModifierDown(int keyCode) {
+static int isModifierDownCombined(unsigned long long flagMask) {
+ CGEventFlags flags = CGEventSourceFlagsState(kCGEventSourceStateCombinedSessionState);
+ return (flags & flagMask) ? 1 : 0;
+}
+
+static int isModifierDownAny(unsigned long long flagMask) {
+ if (isModifierDown(flagMask)) return 1;
+ return isModifierDownCombined(flagMask);
+}
+
+static int isCommandKey(int keyCode) {
+ return keyCode == 54 || keyCode == 55;
+}
+
+static int isPhysicalKeyDownHID(int keyCode) {
+ if (keyCode < 0 || keyCode >= 128) return 0;
+ return CGEventSourceKeyState(kCGEventSourceStateHIDSystemState, (CGKeyCode)keyCode) ? 1 : 0;
+}
+
+static int isPhysicalKeyDownCombined(int keyCode) {
+ if (keyCode < 0 || keyCode >= 128) return 0;
+ return CGEventSourceKeyState(kCGEventSourceStateCombinedSessionState, (CGKeyCode)keyCode) ? 1 : 0;
+}
+
+static int sideModifierDownSource(int keyCode) {
if (keyCode >= 0 && keyCode < 128) {
- if (CGEventSourceKeyState(kCGEventSourceStateHIDSystemState, (CGKeyCode)keyCode)) return 1;
+ if (isPhysicalKeyDownHID(keyCode)) return 1;
+ if (isPhysicalKeyDownCombined(keyCode)) return 2;
unsigned long long mask = modifierMaskForKey((unsigned short)keyCode);
- int modifierDown = mask != 0 ? isModifierDown(mask) : 0;
+ int modifierDown = mask != 0 ? isModifierDownAny(mask) : 0;
double age = g_lastDownAt[keyCode] > 0 ? nowSeconds() - g_lastDownAt[keyCode] : 999.0;
if (g_keysDown[keyCode]) {
- if (modifierDown) return 1;
- if (age < 0.25) return 1;
+ if (modifierDown) return 3;
+ if (age < 0.25) return 4;
g_keysDown[keyCode] = 0;
return 0;
}
- if (age < 0.12) return 1;
+ if (age < 0.12) return 4;
+ if (isCommandKey(keyCode) && modifierDown) return 5;
}
return 0;
}
+static int isSideModifierDown(int keyCode) {
+ int source = sideModifierDownSource(keyCode);
+ return source != 0 && source != 5;
+}
+
static int isKeyDown(int keyCode) {
if (keyCode >= 0 && keyCode < 128) {
- if (CGEventSourceKeyState(kCGEventSourceStateHIDSystemState, (CGKeyCode)keyCode)) return 1;
+ if (isPhysicalKeyDownHID(keyCode)) return 1;
if (g_keysDown[keyCode] && g_lastDownAt[keyCode] > 0 && nowSeconds() - g_lastDownAt[keyCode] < 0.25) return 1;
if (g_keysDown[keyCode]) g_keysDown[keyCode] = 0;
if (g_lastDownAt[keyCode] > 0 && nowSeconds() - g_lastDownAt[keyCode] < 0.12) return 1;
@@ -246,8 +279,7 @@
}
static int isPhysicalKeyDown(int keyCode) {
- if (keyCode < 0 || keyCode >= 128) return 0;
- return CGEventSourceKeyState(kCGEventSourceStateHIDSystemState, (CGKeyCode)keyCode) ? 1 : 0;
+ return isPhysicalKeyDownHID(keyCode);
}
static int isTrackedKeyDown(int keyCode) {
@@ -285,29 +317,29 @@
return g_eventTapDisabledCount;
}
-static unsigned long long altEventSeq(int index) {
- if (index < 0 || index > 1) return 0;
- return g_altEventSeq[index];
+static unsigned long long modifierEventSeq(int index) {
+ if (index < 0 || index > 3) return 0;
+ return g_modifierEventSeq[index];
}
-static int altEventDown(int index) {
- if (index < 0 || index > 1) return 0;
- return g_altEventDown[index];
+static int modifierEventDown(int index) {
+ if (index < 0 || index > 3) return 0;
+ return g_modifierEventDown[index];
}
-static int altEventType(int index) {
- if (index < 0 || index > 1) return 0;
- return g_altEventType[index];
+static int modifierEventType(int index) {
+ if (index < 0 || index > 3) return 0;
+ return g_modifierEventType[index];
}
-static unsigned long long altEventFlags(int index) {
- if (index < 0 || index > 1) return 0;
- return g_altEventFlags[index];
+static unsigned long long modifierEventFlags(int index) {
+ if (index < 0 || index > 3) return 0;
+ return g_modifierEventFlags[index];
}
-static double altEventAge(int index) {
- if (index < 0 || index > 1 || g_altEventAt[index] <= 0) return -1;
- return nowSeconds() - g_altEventAt[index];
+static double modifierEventAge(int index) {
+ if (index < 0 || index > 3 || g_modifierEventAt[index] <= 0) return -1;
+ return nowSeconds() - g_modifierEventAt[index];
}
*/
import "C"
@@ -358,9 +390,9 @@
}
type darwinListener struct {
- lastAltSeq map[int]uint64
- lastAltState map[int]bool
- lastMonitorRetry time.Time
+ lastModifierSeq map[int]uint64
+ lastModifierState map[int]bool
+ lastMonitorRetry time.Time
}
func newPlatformListener() Listener {
@@ -392,17 +424,18 @@
)
}
return &darwinListener{
- lastAltSeq: map[int]uint64{},
- lastAltState: map[int]bool{},
- lastMonitorRetry: time.Now(),
+ lastModifierSeq: map[int]uint64{},
+ lastModifierState: map[int]bool{},
+ lastMonitorRetry: time.Now(),
}
}
func (l *darwinListener) IsKeyDown(vk int) bool {
l.retryMonitorIfNeeded()
if keyCode, ok := vkToPhysicalModifier[vk]; ok {
- down := C.isSideModifierDown(C.int(keyCode)) != 0
- l.logAltDiagnostics(vk, keyCode, down)
+ source := int(C.sideModifierDownSource(C.int(keyCode)))
+ down := sideModifierSourceAccepted(source)
+ l.logModifierDiagnostics(vk, keyCode, down)
return down
}
// Generic modifier keys: poll system flags directly.
@@ -438,81 +471,127 @@
C.startKeyMonitor()
}
-func (l *darwinListener) logAltDiagnostics(vk, keyCode int, down bool) {
- index := altDiagnosticIndex(keyCode)
+func (l *darwinListener) logModifierDiagnostics(vk, keyCode int, down bool) {
+ index := modifierDiagnosticIndex(keyCode)
if index < 0 {
return
}
- physicalDown := C.isPhysicalKeyDown(C.int(keyCode)) != 0
+ physicalHIDDown := C.isPhysicalKeyDown(C.int(keyCode)) != 0
+ physicalCombinedDown := C.isPhysicalKeyDownCombined(C.int(keyCode)) != 0
trackedDown := C.isTrackedKeyDown(C.int(keyCode)) != 0
recentDown := C.isRecentKeyDown(C.int(keyCode)) != 0
+ source := hotkeySourceName(int(C.sideModifierDownSource(C.int(keyCode))))
+ var aggregateFlagDown bool
+ if mask := modifierFlagMaskForKeyCode(keyCode); mask != 0 {
+ aggregateFlagDown = C.isModifierDownAny(mask) != 0
+ }
- seq := uint64(C.altEventSeq(C.int(index)))
- if seq != l.lastAltSeq[vk] {
- l.lastAltSeq[vk] = seq
+ seq := uint64(C.modifierEventSeq(C.int(index)))
+ if seq != l.lastModifierSeq[vk] {
+ l.lastModifierSeq[vk] = seq
logger.Info(
- "Hotkey event tap: key=%s vk=0x%X keyCode=%d seq=%d event=%s eventDown=%t pollDown=%t physicalDown=%t trackedDown=%t recentDown=%t flags=0x%X age=%.3fs tapStarted=%t",
+ "Hotkey event tap: key=%s vk=0x%X keyCode=%d seq=%d event=%s eventDown=%t pollDown=%t source=%s physicalHID=%t physicalCombined=%t trackedDown=%t recentDown=%t aggregateFlagDown=%t flags=0x%X age=%.3fs tapStarted=%t launching=%t attempts=%d failures=%d disabled=%d",
GetKeyName(vk),
vk,
keyCode,
seq,
- altEventTypeName(int(C.altEventType(C.int(index)))),
- C.altEventDown(C.int(index)) != 0,
+ modifierEventTypeName(int(C.modifierEventType(C.int(index)))),
+ C.modifierEventDown(C.int(index)) != 0,
down,
- physicalDown,
+ source,
+ physicalHIDDown,
+ physicalCombinedDown,
trackedDown,
recentDown,
- uint64(C.altEventFlags(C.int(index))),
- float64(C.altEventAge(C.int(index))),
+ aggregateFlagDown,
+ uint64(C.modifierEventFlags(C.int(index))),
+ float64(C.modifierEventAge(C.int(index))),
C.isMonitorStarted() != 0,
+ C.isMonitorLaunching() != 0,
+ uint64(C.monitorStartAttempts()),
+ uint64(C.eventTapCreateFailures()),
+ uint64(C.eventTapDisabledCount()),
)
}
- if previous, ok := l.lastAltState[vk]; !ok || previous != down {
- l.lastAltState[vk] = down
+ if previous, ok := l.lastModifierState[vk]; !ok || previous != down {
+ l.lastModifierState[vk] = down
logger.Info(
- "Hotkey poll: key=%s vk=0x%X keyCode=%d down=%t source=%s physicalDown=%t trackedDown=%t recentDown=%t tapStarted=%t",
+ "Hotkey poll: key=%s vk=0x%X keyCode=%d down=%t source=%s physicalHID=%t physicalCombined=%t trackedDown=%t recentDown=%t aggregateFlagDown=%t tapStarted=%t launching=%t attempts=%d failures=%d disabled=%d",
GetKeyName(vk),
vk,
keyCode,
down,
- hotkeySource(down, physicalDown, trackedDown, recentDown),
- physicalDown,
+ source,
+ physicalHIDDown,
+ physicalCombinedDown,
trackedDown,
recentDown,
+ aggregateFlagDown,
C.isMonitorStarted() != 0,
+ C.isMonitorLaunching() != 0,
+ uint64(C.monitorStartAttempts()),
+ uint64(C.eventTapCreateFailures()),
+ uint64(C.eventTapDisabledCount()),
)
}
}
-func hotkeySource(down, physicalDown, trackedDown, recentDown bool) string {
- switch {
- case !down:
+func sideModifierSourceAccepted(source int) bool {
+ return source > 0 && source != 5
+}
+
+func modifierFlagMaskForKeyCode(keyCode int) C.ulonglong {
+ switch keyCode {
+ case 59, 62:
+ return maskControl
+ case 58, 61:
+ return maskOption
+ case 56, 60:
+ return maskShift
+ case 55, 54:
+ return maskCommand
+ default:
+ return 0
+ }
+}
+
+func hotkeySourceName(source int) string {
+ switch source {
+ case 0:
return "none"
- case physicalDown:
- return "physical"
- case trackedDown:
+ case 1:
+ return "physicalHID"
+ case 2:
+ return "physicalCombined"
+ case 3:
return "eventTap"
- case recentDown:
+ case 4:
return "recent"
+ case 5:
+ return "aggregateCommandFlag"
default:
return "unknown"
}
}
-func altDiagnosticIndex(keyCode int) int {
+func modifierDiagnosticIndex(keyCode int) int {
switch keyCode {
case 58:
return 0
case 61:
return 1
+ case 55:
+ return 2
+ case 54:
+ return 3
default:
return -1
}
}
-func altEventTypeName(eventType int) string {
+func modifierEventTypeName(eventType int) string {
switch eventType {
case 1:
return "keyDown"
@@ -585,6 +664,8 @@
return 2
case 0x10, 0xA0, 0xA1:
return 3
+ case 0x5B, 0x5C:
+ return 4
default:
return 0
}
diff --git a/C1.source/privatevoice.src/internal/hotkey/hotkey_darwin_test.go b/C1.source/privatevoice.src/internal/hotkey/hotkey_darwin_test.go
new file mode 100644
index 0000000..fff4695
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/hotkey/hotkey_darwin_test.go
@@ -0,0 +1,79 @@
+//go:build darwin
+
+package hotkey
+
+import "testing"
+
+func TestDarwinModifierDiagnosticsIncludeOptionAndCommand(t *testing.T) {
+ tests := []struct {
+ name string
+ keyCode int
+ want int
+ }{
+ {name: "left option", keyCode: 58, want: 0},
+ {name: "right option", keyCode: 61, want: 1},
+ {name: "left command", keyCode: 55, want: 2},
+ {name: "right command", keyCode: 54, want: 3},
+ {name: "non diagnostic", keyCode: 49, want: -1},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := modifierDiagnosticIndex(tt.keyCode); got != tt.want {
+ t.Fatalf("modifierDiagnosticIndex(%d) = %d, want %d", tt.keyCode, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestDarwinHotkeySourceNameDocumentsCommandFallback(t *testing.T) {
+ tests := []struct {
+ source int
+ want string
+ }{
+ {source: 0, want: "none"},
+ {source: 1, want: "physicalHID"},
+ {source: 2, want: "physicalCombined"},
+ {source: 3, want: "eventTap"},
+ {source: 4, want: "recent"},
+ {source: 5, want: "aggregateCommandFlag"},
+ }
+
+ for _, tt := range tests {
+ if got := hotkeySourceName(tt.source); got != tt.want {
+ t.Fatalf("hotkeySourceName(%d) = %q, want %q", tt.source, got, tt.want)
+ }
+ }
+}
+
+func TestDarwinSideModifierSourceAcceptedRejectsAggregateCommandFlag(t *testing.T) {
+ tests := []struct {
+ name string
+ source int
+ want bool
+ }{
+ {name: "none", source: 0, want: false},
+ {name: "physical HID", source: 1, want: true},
+ {name: "physical combined", source: 2, want: true},
+ {name: "event tap tracked", source: 3, want: true},
+ {name: "recent side-specific event", source: 4, want: true},
+ {name: "aggregate command diagnostic only", source: 5, want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := sideModifierSourceAccepted(tt.source); got != tt.want {
+ t.Fatalf("sideModifierSourceAccepted(%d) = %t, want %t", tt.source, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestDarwinModifierGroupIncludesCommandSides(t *testing.T) {
+ if got := modifierGroup(0x5B); got != 4 {
+ t.Fatalf("modifierGroup(L-Command) = %d, want 4", got)
+ }
+ if got := modifierGroup(0x5C); got != 4 {
+ t.Fatalf("modifierGroup(R-Command) = %d, want 4", got)
+ }
+}
diff --git a/C1.source/privatevoice.src/internal/hotkey/hotkey_test.go b/C1.source/privatevoice.src/internal/hotkey/hotkey_test.go
index b7bc55d..498b4ca 100644
--- a/C1.source/privatevoice.src/internal/hotkey/hotkey_test.go
+++ b/C1.source/privatevoice.src/internal/hotkey/hotkey_test.go
@@ -11,6 +11,7 @@
}{
{name: "darwin left command", goos: "darwin", vk: 0x5B, want: "L-⌘"},
{name: "darwin right command", goos: "darwin", vk: 0x5C, want: "R-⌘"},
+ {name: "unset", goos: "darwin", vk: 0, want: ""},
{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"},
diff --git a/C1.source/privatevoice.src/internal/model/model_test.go b/C1.source/privatevoice.src/internal/model/model_test.go
index b90ea90..0ee6ca3 100644
--- a/C1.source/privatevoice.src/internal/model/model_test.go
+++ b/C1.source/privatevoice.src/internal/model/model_test.go
@@ -188,6 +188,22 @@
}
}
+func TestModelDownloadURLsUseHTTPS(t *testing.T) {
+ for _, profile := range ListModelProfiles() {
+ profile := profile
+ t.Run(profile.ID, func(t *testing.T) {
+ for _, downloadURL := range profile.DownloadURLs {
+ if downloadURL == "" {
+ t.Fatal("download URL must not be empty")
+ }
+ if !strings.HasPrefix(downloadURL, "https://") {
+ t.Fatalf("download URL = %q, want https://", downloadURL)
+ }
+ }
+ })
+ }
+}
+
func TestChineseLanguageOffersQwen3ASRUpgrade(t *testing.T) {
profile, err := GetLanguageProfile(DefaultLanguageID)
if err != nil {
diff --git a/C1.source/privatevoice.src/internal/model/registry.go b/C1.source/privatevoice.src/internal/model/registry.go
index 641adc4..e9ae0c5 100644
--- a/C1.source/privatevoice.src/internal/model/registry.go
+++ b/C1.source/privatevoice.src/internal/model/registry.go
@@ -45,8 +45,8 @@
RecommendedFor: []string{"zh-CN"},
Description: "轻量中文模型,适合中文和中英混输。",
DownloadURLs: []string{
- "http://www.maikami.com/voicesnap/sensevoice.zip",
- "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",
+ "https://www.maikami.com/voicesnap/sensevoice.zip",
+ "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2",
},
InstallDirName: "sensevoice-zh",
LegacyDirNames: []string{"sensevoice"},
diff --git a/C1.source/privatevoice.src/internal/singleinstance/singleinstance_unix.go b/C1.source/privatevoice.src/internal/singleinstance/singleinstance_unix.go
index 4a2e38d..6738053 100755
--- a/C1.source/privatevoice.src/internal/singleinstance/singleinstance_unix.go
+++ b/C1.source/privatevoice.src/internal/singleinstance/singleinstance_unix.go
@@ -3,10 +3,13 @@
package singleinstance
import (
+ "errors"
"fmt"
"os"
"path/filepath"
"syscall"
+
+ "voicesnap/internal/paths"
)
type unixLock struct {
@@ -16,15 +19,83 @@
// Acquire tries to acquire a file lock. Returns error if another instance holds it.
func Acquire() (Lock, error) {
lockPath := filepath.Join(os.TempDir(), "privatevoice-input.lock")
+ if ensureErr := paths.Ensure(); ensureErr != nil {
+ return nil, fmt.Errorf("ensure app lock dir: %w", ensureErr)
+ }
+ appLockPath := paths.File("privatevoice-input.lock")
+ return acquireWithFallback(lockPath, appLockPath)
+}
+
+func acquireWithFallback(primaryPath, fallbackPath string) (Lock, error) {
+ lock, err := acquirePath(primaryPath)
+ if err == nil {
+ return lock, nil
+ }
+
+ var unavailable lockUnavailableError
+ if errors.As(err, &unavailable) {
+ return nil, err
+ }
+
+ lock, fallbackErr := acquirePath(fallbackPath)
+ if fallbackErr != nil {
+ return nil, fmt.Errorf("acquire temp lock: %w; acquire app lock: %v", err, fallbackErr)
+ }
+ return lock, nil
+}
+
+type lockOpenError struct {
+ path string
+ err error
+}
+
+func (e lockOpenError) Error() string {
+ return fmt.Sprintf("open lock %s: %v", e.path, e.err)
+}
+
+func (e lockOpenError) Unwrap() error {
+ return e.err
+}
+
+type lockAccessError struct {
+ path string
+ err error
+}
+
+func (e lockAccessError) Error() string {
+ return fmt.Sprintf("lock %s: %v", e.path, e.err)
+}
+
+func (e lockAccessError) Unwrap() error {
+ return e.err
+}
+
+type lockUnavailableError struct {
+ path string
+ err error
+}
+
+func (e lockUnavailableError) Error() string {
+ return fmt.Sprintf("another instance is already running: %s", e.path)
+}
+
+func (e lockUnavailableError) Unwrap() error {
+ return e.err
+}
+
+func acquirePath(lockPath string) (Lock, error) {
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
- return nil, err
+ return nil, lockOpenError{path: lockPath, err: err}
}
err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
if err != nil {
f.Close()
- return nil, fmt.Errorf("another instance is already running")
+ if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
+ return nil, lockUnavailableError{path: lockPath, err: err}
+ }
+ return nil, lockAccessError{path: lockPath, err: err}
}
return &unixLock{file: f}, nil
diff --git a/C1.source/privatevoice.src/internal/singleinstance/singleinstance_unix_test.go b/C1.source/privatevoice.src/internal/singleinstance/singleinstance_unix_test.go
new file mode 100644
index 0000000..a60b482
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/singleinstance/singleinstance_unix_test.go
@@ -0,0 +1,43 @@
+//go:build !windows
+
+package singleinstance
+
+import (
+ "errors"
+ "path/filepath"
+ "testing"
+)
+
+func TestAcquireWithFallbackUsesFallbackWhenPrimaryCannotOpen(t *testing.T) {
+ tempDir := t.TempDir()
+ primary := filepath.Join(tempDir, "missing", "privatevoice-input.lock")
+ fallback := filepath.Join(tempDir, "fallback.lock")
+
+ lock, err := acquireWithFallback(primary, fallback)
+ if err != nil {
+ t.Fatalf("acquireWithFallback() error = %v", err)
+ }
+ lock.Release()
+}
+
+func TestAcquireWithFallbackDoesNotBypassHeldPrimaryLock(t *testing.T) {
+ tempDir := t.TempDir()
+ primary := filepath.Join(tempDir, "privatevoice-input.lock")
+ fallback := filepath.Join(tempDir, "fallback.lock")
+
+ held, err := acquirePath(primary)
+ if err != nil {
+ t.Fatalf("acquirePath() error = %v", err)
+ }
+ defer held.Release()
+
+ lock, err := acquireWithFallback(primary, fallback)
+ if err == nil {
+ lock.Release()
+ t.Fatal("acquireWithFallback() unexpectedly acquired fallback while primary was held")
+ }
+ var unavailable lockUnavailableError
+ if !errors.As(err, &unavailable) {
+ t.Fatalf("acquireWithFallback() error = %T %v, want lockUnavailableError", err, err)
+ }
+}
diff --git a/C1.source/privatevoice.src/internal/textoutput/router.go b/C1.source/privatevoice.src/internal/textoutput/router.go
new file mode 100644
index 0000000..3337eef
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/textoutput/router.go
@@ -0,0 +1,159 @@
+package textoutput
+
+import (
+ "strings"
+ "sync"
+ "time"
+ "unicode/utf8"
+ "voicesnap/internal/axinput"
+ "voicesnap/internal/logger"
+)
+
+type Paster interface {
+ Paste(text string, keepClipboard bool) error
+ TypeText(text string) error
+}
+
+type Result struct {
+ OK bool `json:"ok"`
+ Text string `json:"text,omitempty"`
+ Method string `json:"method"`
+ Message string `json:"message"`
+ Target string `json:"target,omitempty"`
+ NeedsPermission bool `json:"needsPermission"`
+ DirectInserted bool `json:"directInserted"`
+ FallbackAvailable bool `json:"fallbackAvailable"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+type AutoPasteGuard struct {
+ OK bool
+ Identity string
+ Message string
+}
+
+type OutputOptions struct {
+ KeepClipboard bool
+ AutoPasteExperiment bool
+ AutoPasteGuard AutoPasteGuard
+}
+
+type Router struct {
+ mu sync.Mutex
+ paster Paster
+ driver axinput.Driver
+ last Result
+}
+
+func NewRouter(paster Paster) *Router {
+ return newPlatformRouter(paster)
+}
+
+func (r *Router) LastResult() Result {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.last
+}
+
+func (r *Router) CaptureAutoPasteGuard() AutoPasteGuard {
+ if r.driver == nil {
+ return AutoPasteGuard{OK: false, Message: "AX driver is unavailable"}
+ }
+ identity := r.driver.FrontmostAppIdentity()
+ return AutoPasteGuard{OK: identity.OK, Identity: identity.Identity, Message: identity.Message}
+}
+
+func (r *Router) remember(result Result) Result {
+ if result.CreatedAt.IsZero() {
+ result.CreatedAt = time.Now()
+ }
+ r.mu.Lock()
+ r.last = result
+ r.mu.Unlock()
+ return result
+}
+
+func (r *Router) PrepareLastClipboardFallback() axinput.ClipboardResult {
+ r.mu.Lock()
+ last := r.last
+ r.mu.Unlock()
+ if !last.FallbackAvailable || last.Text == "" {
+ logger.Info("Text output fallback prepare route status=no_pending fallback_available=%t has_text=%t", last.FallbackAvailable, last.Text != "")
+ return axinput.ClipboardResult{OK: false, Message: "no pending text output fallback is available"}
+ }
+ if !clipboardFallbackSafeForTarget(last) {
+ logger.Info("Text output fallback prepare route status=blocked_non_writable text_runes=%d", utf8.RuneCountInString(last.Text))
+ return axinput.ClipboardResult{OK: false, Message: "clipboard fallback is disabled for this non-writable target"}
+ }
+ if r.driver == nil {
+ logger.Info("Text output fallback prepare route status=no_driver text_runes=%d", utf8.RuneCountInString(last.Text))
+ return axinput.ClipboardResult{OK: false, Message: "clipboard fallback is unavailable for this output route"}
+ }
+ result := r.driver.PrepareClipboardFallback(last.Text)
+ logger.Info(
+ "Text output fallback prepare route status=driver_result ok=%t prepared=%t token_present=%t text_runes=%d",
+ result.OK,
+ result.Prepared,
+ result.Token != "",
+ utf8.RuneCountInString(last.Text),
+ )
+ return result
+}
+
+func (r *Router) RestoreClipboardFallback(token string) axinput.ClipboardResult {
+ if r.driver == nil {
+ logger.Info("Text output fallback restore route status=no_driver token_present=%t", token != "")
+ return axinput.ClipboardResult{OK: false, Message: "clipboard fallback is unavailable for this output route"}
+ }
+ result := r.driver.RestoreClipboardFallback(token)
+ logger.Info(
+ "Text output fallback restore route status=driver_result ok=%t restored=%t token_present=%t",
+ result.OK,
+ result.Restored,
+ token != "",
+ )
+ return result
+}
+
+func clipboardFallbackSafeForTarget(result Result) bool {
+ target := strings.TrimSpace(strings.ToLower(result.Target))
+ if target == "" || target == "target=unknown" {
+ return true
+ }
+ for _, marker := range []string{
+ "app=terminal",
+ "app=iterm",
+ "app=iterm2",
+ "app=warp",
+ "role=axterminal",
+ "subrole=axterminal",
+ "console",
+ "shell",
+ } {
+ if strings.Contains(target, marker) {
+ return false
+ }
+ }
+ return true
+}
+
+func autoPasteSafeForIdentity(identity string) bool {
+ normalized := strings.ToLower(strings.TrimSpace(identity))
+ if normalized == "" {
+ return false
+ }
+ for _, marker := range []string{
+ "com.apple.terminal",
+ "com.googlecode.iterm2",
+ "iterm",
+ "terminal",
+ "warp",
+ "shell",
+ "console",
+ } {
+ if strings.Contains(normalized, marker) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin.go b/C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin.go
new file mode 100644
index 0000000..663f4d3
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin.go
@@ -0,0 +1,258 @@
+//go:build darwin && appstore
+
+package textoutput
+
+import (
+ "strings"
+ "unicode/utf8"
+ "voicesnap/internal/axinput"
+ "voicesnap/internal/logger"
+)
+
+func newPlatformRouter(paster Paster) *Router {
+ return &Router{
+ paster: paster,
+ driver: axinput.NewDriver(),
+ }
+}
+
+func (r *Router) Output(text string, keepClipboard bool) Result {
+ return r.OutputWithOptions(text, OutputOptions{KeepClipboard: keepClipboard})
+}
+
+func (r *Router) OutputWithOptions(text string, options OutputOptions) Result {
+ if text == "" {
+ return r.remember(Result{OK: false, Method: "ax_direct_insert", Message: "text is empty"})
+ }
+ if r.driver == nil {
+ return r.remember(Result{
+ OK: false,
+ Text: text,
+ Method: "ax_direct_insert",
+ Message: "AX driver is unavailable",
+ FallbackAvailable: false,
+ })
+ }
+ direct := r.driver.DirectInsert(text)
+ if direct.OK && direct.Inserted {
+ return r.remember(Result{
+ OK: true,
+ Text: text,
+ Method: direct.Method,
+ Message: direct.Message,
+ Target: direct.Target,
+ DirectInserted: true,
+ })
+ }
+ fallbackAvailable := clipboardFallbackSafeForTarget(Result{Target: direct.Target})
+ category, codes := directInsertDiagnostic(direct)
+ logger.Info(
+ "Text output direct insert route status=driver_result ok=%t inserted=%t needs_permission=%t category=%s codes=%s text_runes=%d",
+ direct.OK,
+ direct.Inserted,
+ direct.NeedsPermission,
+ category,
+ codes,
+ utf8.RuneCountInString(text),
+ )
+ if fallbackAvailable && options.AutoPasteExperiment {
+ autoPaste := r.autoPasteClipboardExperiment(text, direct, options)
+ if autoPaste.OK && autoPaste.Pasted {
+ return r.remember(Result{
+ OK: true,
+ Text: text,
+ Method: "clipboard_auto_paste_experiment",
+ Message: "clipboard fallback auto-paste experiment sent one paste event",
+ Target: direct.Target,
+ DirectInserted: false,
+ })
+ }
+ return r.remember(Result{
+ OK: false,
+ Text: text,
+ Method: direct.Method,
+ Message: direct.Message,
+ Target: direct.Target,
+ NeedsPermission: direct.NeedsPermission,
+ DirectInserted: false,
+ FallbackAvailable: fallbackAvailable,
+ })
+ }
+ if fallbackAvailable && options.KeepClipboard {
+ clipboard := r.driver.CopyTextToClipboard(text)
+ logger.Info(
+ "Text output copy-to-clipboard route status=driver_result ok=%t prepared=%t text_runes=%d",
+ clipboard.OK,
+ clipboard.Prepared,
+ utf8.RuneCountInString(text),
+ )
+ }
+ return r.remember(Result{
+ OK: false,
+ Text: text,
+ Method: direct.Method,
+ Message: direct.Message,
+ Target: direct.Target,
+ NeedsPermission: direct.NeedsPermission,
+ DirectInserted: false,
+ FallbackAvailable: fallbackAvailable,
+ })
+}
+
+func (r *Router) autoPasteClipboardExperiment(text string, direct axinput.DirectInsertResult, options OutputOptions) axinput.AutoPasteResult {
+ textRunes := utf8.RuneCountInString(text)
+ guard := options.AutoPasteGuard
+ if !guard.OK || strings.TrimSpace(guard.Identity) == "" {
+ logger.Info("Text output auto-paste experiment status=blocked_guard_unavailable text_runes=%d", textRunes)
+ return axinput.AutoPasteResult{OK: false, Message: "frontmost guard is unavailable"}
+ }
+ currentIdentity := r.driver.FrontmostAppIdentity()
+ if !currentIdentity.OK || strings.TrimSpace(currentIdentity.Identity) == "" {
+ logger.Info("Text output auto-paste experiment status=blocked_current_identity_unavailable text_runes=%d", textRunes)
+ return axinput.AutoPasteResult{OK: false, Message: "frontmost identity is unavailable before clipboard write"}
+ }
+ if currentIdentity.Identity != guard.Identity {
+ logger.Info("Text output auto-paste experiment status=blocked_frontmost_changed_before_clipboard text_runes=%d", textRunes)
+ return axinput.AutoPasteResult{OK: false, Message: "frontmost app changed before clipboard write"}
+ }
+ if !autoPasteSafeForIdentity(currentIdentity.Identity) || !clipboardFallbackSafeForTarget(Result{Target: direct.Target}) {
+ logger.Info(
+ "Text output auto-paste experiment status=blocked_non_writable text_runes=%d identity_present=%t target_known=%t",
+ textRunes,
+ currentIdentity.Identity != "",
+ strings.TrimSpace(direct.Target) != "" && strings.TrimSpace(direct.Target) != "target=unknown",
+ )
+ return axinput.AutoPasteResult{OK: false, Message: "automatic paste is disabled for this target"}
+ }
+ var token string
+ if options.KeepClipboard {
+ clipboard := r.driver.CopyTextToClipboard(text)
+ logger.Info(
+ "Text output copy-to-clipboard route status=driver_result ok=%t prepared=%t text_runes=%d",
+ clipboard.OK,
+ clipboard.Prepared,
+ textRunes,
+ )
+ if !clipboard.OK || !clipboard.Prepared {
+ logger.Info(
+ "Text output auto-paste experiment status=blocked_clipboard_unprepared text_runes=%d clipboard_ok=%t clipboard_prepared=%t",
+ textRunes,
+ clipboard.OK,
+ clipboard.Prepared,
+ )
+ return axinput.AutoPasteResult{OK: false, Message: "clipboard is not prepared"}
+ }
+ } else {
+ clipboard := r.driver.PrepareClipboardFallback(text)
+ logger.Info(
+ "Text output auto-paste experiment clipboard prepare status=driver_result ok=%t prepared=%t token_present=%t text_runes=%d",
+ clipboard.OK,
+ clipboard.Prepared,
+ clipboard.Token != "",
+ textRunes,
+ )
+ if !clipboard.OK || !clipboard.Prepared || clipboard.Token == "" {
+ logger.Info(
+ "Text output auto-paste experiment status=blocked_clipboard_token_unavailable text_runes=%d clipboard_ok=%t clipboard_prepared=%t token_present=%t",
+ textRunes,
+ clipboard.OK,
+ clipboard.Prepared,
+ clipboard.Token != "",
+ )
+ return axinput.AutoPasteResult{OK: false, Message: "clipboard restore token is unavailable"}
+ }
+ token = clipboard.Token
+ }
+ result := r.driver.AutoPasteClipboardIfFrontmostUnchanged(guard.Identity)
+ logger.Info(
+ "Text output auto-paste experiment status=driver_result ok=%t pasted=%t frontmost_unchanged=%t text_runes=%d",
+ result.OK,
+ result.Pasted,
+ result.FrontmostUnchanged,
+ textRunes,
+ )
+ if token != "" {
+ restore := r.driver.RestoreClipboardFallback(token)
+ logger.Info(
+ "Text output auto-paste experiment clipboard restore status=driver_result ok=%t restored=%t token_present=%t text_runes=%d",
+ restore.OK,
+ restore.Restored,
+ token != "",
+ textRunes,
+ )
+ if result.OK && result.Pasted && !restore.OK && !restore.Restored {
+ return axinput.AutoPasteResult{OK: false, Pasted: result.Pasted, FrontmostUnchanged: result.FrontmostUnchanged, Message: "clipboard restore did not complete"}
+ }
+ }
+ return result
+}
+
+func directInsertDiagnostic(result axinput.DirectInsertResult) (string, string) {
+ if result.NeedsPermission {
+ return "accessibility_permission", "none"
+ }
+ message := strings.ToLower(result.Message)
+ switch {
+ case strings.Contains(message, "focused element unavailable"):
+ return "focused_element_unavailable", extractAXCodeSummary(result.Message)
+ case strings.Contains(message, "text state is not readable"):
+ return "focused_text_state_unreadable", extractAXCodeSummary(result.Message)
+ case strings.Contains(message, "value is not settable"):
+ return "focused_value_not_settable", "none"
+ case strings.Contains(message, "selected range is unreadable"):
+ return "selected_range_unreadable", "none"
+ case strings.Contains(message, "selected range is out of bounds"):
+ return "selected_range_out_of_bounds", "none"
+ case strings.Contains(message, "replacement failed"):
+ return "ax_value_replacement_failed", extractAXCodeSummary(result.Message)
+ case strings.Contains(message, "not observable after write"):
+ return "ax_value_verify_failed", extractAXCodeSummary(result.Message)
+ default:
+ return "direct_insert_failed", extractAXCodeSummary(result.Message)
+ }
+}
+
+func extractAXCodeSummary(message string) string {
+ allowed := map[string]bool{
+ "frontmost": true,
+ "focused_app": true,
+ "focused_app_element": true,
+ "range": true,
+ "system": true,
+ "value": true,
+ "verify": true,
+ }
+ parts := strings.FieldsFunc(message, func(r rune) bool {
+ return r == ' ' || r == ',' || r == ';' || r == '\n' || r == '\t'
+ })
+ codes := make([]string, 0, len(parts))
+ for _, part := range parts {
+ key, value, ok := strings.Cut(strings.Trim(part, ".:"), "=")
+ if !ok || !allowed[key] || !isSignedDecimal(value) {
+ continue
+ }
+ codes = append(codes, key+"="+value)
+ }
+ if len(codes) == 0 {
+ return "none"
+ }
+ return strings.Join(codes, " ")
+}
+
+func isSignedDecimal(value string) bool {
+ if value == "" {
+ return false
+ }
+ if value[0] == '-' || value[0] == '+' {
+ value = value[1:]
+ }
+ if value == "" {
+ return false
+ }
+ for _, r := range value {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return true
+}
diff --git a/C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin_test.go b/C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin_test.go
new file mode 100644
index 0000000..6352337
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin_test.go
@@ -0,0 +1,500 @@
+//go:build darwin && appstore
+
+package textoutput
+
+import (
+ "testing"
+ "voicesnap/internal/axinput"
+)
+
+type fakeAXDriver struct {
+ directResult axinput.DirectInsertResult
+ fallbackText string
+ copiedText string
+ copyResult axinput.ClipboardResult
+ prepareResult axinput.ClipboardResult
+ prepareCalled bool
+ restoreResult axinput.ClipboardResult
+ restoreToken string
+ restoreCalled bool
+ frontmostIdentity axinput.FrontmostAppIdentity
+ autoPasteResult axinput.AutoPasteResult
+ autoPasteExpected string
+ autoPasteCalled bool
+}
+
+func (d *fakeAXDriver) AccessibilityTrusted(prompt bool) bool {
+ return true
+}
+
+func (d *fakeAXDriver) DirectInsert(text string) axinput.DirectInsertResult {
+ return d.directResult
+}
+
+func (d *fakeAXDriver) PrepareClipboardFallback(text string) axinput.ClipboardResult {
+ d.prepareCalled = true
+ d.fallbackText = text
+ if d.prepareResult.Message != "" || d.prepareResult.OK || d.prepareResult.Prepared || d.prepareResult.Token != "" {
+ return d.prepareResult
+ }
+ return axinput.ClipboardResult{OK: true, Prepared: true, Token: "token"}
+}
+
+func (d *fakeAXDriver) CopyTextToClipboard(text string) axinput.ClipboardResult {
+ d.copiedText = text
+ if d.copyResult.Message != "" || d.copyResult.OK || d.copyResult.Prepared {
+ return d.copyResult
+ }
+ return axinput.ClipboardResult{OK: true, Prepared: true}
+}
+
+func (d *fakeAXDriver) FrontmostAppIdentity() axinput.FrontmostAppIdentity {
+ if d.frontmostIdentity.Message != "" || d.frontmostIdentity.OK {
+ return d.frontmostIdentity
+ }
+ return axinput.FrontmostAppIdentity{OK: true, Identity: "bundle=com.apple.TextEdit pid=123"}
+}
+
+func (d *fakeAXDriver) AutoPasteClipboardIfFrontmostUnchanged(expectedIdentity string) axinput.AutoPasteResult {
+ d.autoPasteCalled = true
+ d.autoPasteExpected = expectedIdentity
+ if d.autoPasteResult.Message != "" || d.autoPasteResult.OK || d.autoPasteResult.Pasted {
+ return d.autoPasteResult
+ }
+ return axinput.AutoPasteResult{OK: true, Pasted: true, FrontmostUnchanged: true}
+}
+
+func (d *fakeAXDriver) RestoreClipboardFallback(token string) axinput.ClipboardResult {
+ d.restoreCalled = true
+ d.restoreToken = token
+ if d.restoreResult.Message != "" || d.restoreResult.OK || d.restoreResult.Restored {
+ return d.restoreResult
+ }
+ return axinput.ClipboardResult{OK: true, Restored: true}
+}
+
+func TestAppStoreRouterUsesVerifiedAXInsert(t *testing.T) {
+ driver := &fakeAXDriver{
+ directResult: axinput.DirectInsertResult{
+ OK: true,
+ Inserted: true,
+ Method: "ax_value_replacement",
+ Message: "verified",
+ Target: "app=TextEdit",
+ },
+ }
+ router := &Router{driver: driver}
+
+ result := router.Output("recognized text", false)
+ if !result.OK || !result.DirectInserted || result.FallbackAvailable {
+ t.Fatalf("result = %+v", result)
+ }
+ if result.Text != "recognized text" || result.Target != "app=TextEdit" {
+ t.Fatalf("result = %+v", result)
+ }
+}
+
+func TestAppStoreRouterKeepsTextForExplicitFallback(t *testing.T) {
+ driver := &fakeAXDriver{
+ directResult: axinput.DirectInsertResult{
+ OK: false,
+ Method: "ax_value_replacement",
+ Message: "focused element value is not settable",
+ Target: "app=TextEdit role=AXTextArea",
+ },
+ }
+ router := &Router{driver: driver}
+
+ result := router.Output("recognized text", false)
+ if result.OK || !result.FallbackAvailable || result.Text != "recognized text" {
+ t.Fatalf("result = %+v", result)
+ }
+ fallback := router.PrepareLastClipboardFallback()
+ if !fallback.OK || driver.fallbackText != "recognized text" {
+ t.Fatalf("fallback = %+v text=%q", fallback, driver.fallbackText)
+ }
+}
+
+func TestAppStoreRouterBlocksClipboardFallbackForTerminalTarget(t *testing.T) {
+ driver := &fakeAXDriver{
+ directResult: axinput.DirectInsertResult{
+ OK: false,
+ Method: "ax_focused_element",
+ Message: "focused element unavailable",
+ Target: "app=Terminal role=AXWindow",
+ },
+ }
+ router := &Router{driver: driver}
+
+ result := router.Output("recognized text", false)
+ if result.OK || result.FallbackAvailable || result.Text != "recognized text" {
+ t.Fatalf("result = %+v", result)
+ }
+ fallback := router.PrepareLastClipboardFallback()
+ if fallback.OK || driver.fallbackText != "" {
+ t.Fatalf("fallback = %+v text=%q, want blocked without clipboard write", fallback, driver.fallbackText)
+ }
+}
+
+func TestAppStoreRouterAllowsClipboardFallbackForUnknownTarget(t *testing.T) {
+ driver := &fakeAXDriver{
+ directResult: axinput.DirectInsertResult{
+ OK: false,
+ Method: "ax_focused_element",
+ Message: "focused element unavailable",
+ Target: "target=unknown",
+ },
+ }
+ router := &Router{driver: driver}
+
+ result := router.Output("recognized text", false)
+ if result.OK || !result.FallbackAvailable || result.Text != "recognized text" {
+ t.Fatalf("result = %+v", result)
+ }
+ fallback := router.PrepareLastClipboardFallback()
+ if !fallback.OK || driver.fallbackText != "recognized text" {
+ t.Fatalf("fallback = %+v text=%q, want clipboard fallback prepared", fallback, driver.fallbackText)
+ }
+}
+
+func TestAppStoreRouterCopiesToClipboardWhenKeepClipboardEnabledAfterAXFailure(t *testing.T) {
+ driver := &fakeAXDriver{
+ directResult: axinput.DirectInsertResult{
+ OK: false,
+ Method: "ax_focused_element",
+ Message: "focused element unavailable",
+ Target: "target=unknown",
+ },
+ }
+ router := &Router{driver: driver}
+
+ result := router.Output("recognized text", true)
+ if result.OK || !result.FallbackAvailable || result.Text != "recognized text" {
+ t.Fatalf("result = %+v", result)
+ }
+ if driver.copiedText != "recognized text" {
+ t.Fatalf("copiedText = %q, want recognized text", driver.copiedText)
+ }
+}
+
+func TestAppStoreRouterAutoPasteExperimentToggleOffDoesNotPaste(t *testing.T) {
+ driver := &fakeAXDriver{
+ directResult: axinput.DirectInsertResult{
+ OK: false,
+ Method: "ax_focused_element",
+ Message: "focused element unavailable",
+ Target: "target=unknown",
+ },
+ }
+ router := &Router{driver: driver}
+
+ result := router.OutputWithOptions("recognized text", OutputOptions{
+ KeepClipboard: true,
+ AutoPasteExperiment: false,
+ AutoPasteGuard: AutoPasteGuard{OK: true, Identity: "bundle=com.apple.TextEdit pid=123"},
+ })
+ if result.OK || !result.FallbackAvailable {
+ t.Fatalf("result = %+v", result)
+ }
+ if driver.autoPasteCalled {
+ t.Fatal("auto paste called while experiment is disabled")
+ }
+}
+
+func TestAppStoreRouterAutoPasteExperimentPastesAfterClipboardReadback(t *testing.T) {
+ driver := &fakeAXDriver{
+ directResult: axinput.DirectInsertResult{
+ OK: false,
+ Method: "ax_focused_element",
+ Message: "focused element unavailable",
+ Target: "target=unknown",
+ },
+ autoPasteResult: axinput.AutoPasteResult{OK: true, Pasted: true, FrontmostUnchanged: true},
+ }
+ router := &Router{driver: driver}
+ guard := AutoPasteGuard{OK: true, Identity: "bundle=com.apple.TextEdit pid=123"}
+
+ result := router.OutputWithOptions("recognized text", OutputOptions{
+ KeepClipboard: true,
+ AutoPasteExperiment: true,
+ AutoPasteGuard: guard,
+ })
+ if !result.OK || result.DirectInserted || result.Method != "clipboard_auto_paste_experiment" {
+ t.Fatalf("result = %+v", result)
+ }
+ if driver.copiedText != "recognized text" {
+ t.Fatalf("copiedText = %q, want recognized text", driver.copiedText)
+ }
+ if !driver.autoPasteCalled || driver.autoPasteExpected != guard.Identity {
+ t.Fatalf("autoPasteCalled=%t expected=%q", driver.autoPasteCalled, driver.autoPasteExpected)
+ }
+}
+
+func TestAppStoreRouterAutoPasteExperimentUsesRestoreTokenWhenRetentionOff(t *testing.T) {
+ driver := &fakeAXDriver{
+ directResult: axinput.DirectInsertResult{
+ OK: false,
+ Method: "ax_focused_element",
+ Message: "focused element unavailable",
+ Target: "target=unknown",
+ },
+ autoPasteResult: axinput.AutoPasteResult{OK: true, Pasted: true, FrontmostUnchanged: true},
+ }
+ router := &Router{driver: driver}
+
+ result := router.OutputWithOptions("recognized text", OutputOptions{
+ KeepClipboard: false,
+ AutoPasteExperiment: true,
+ AutoPasteGuard: AutoPasteGuard{OK: true, Identity: "bundle=com.apple.TextEdit pid=123"},
+ })
+ if !result.OK || result.Method != "clipboard_auto_paste_experiment" {
+ t.Fatalf("result = %+v", result)
+ }
+ if driver.copiedText != "" {
+ t.Fatalf("copiedText = %q, want no tokenless clipboard write", driver.copiedText)
+ }
+ if driver.fallbackText != "recognized text" || !driver.prepareCalled {
+ t.Fatalf("fallbackText=%q prepareCalled=%t", driver.fallbackText, driver.prepareCalled)
+ }
+ if !driver.autoPasteCalled {
+ t.Fatal("auto paste was not called")
+ }
+ if !driver.restoreCalled || driver.restoreToken != "token" {
+ t.Fatalf("restoreCalled=%t restoreToken=%q", driver.restoreCalled, driver.restoreToken)
+ }
+}
+
+func TestAppStoreRouterAutoPasteExperimentRestoresWhenPasteGuardChangesWithRetentionOff(t *testing.T) {
+ driver := &fakeAXDriver{
+ directResult: axinput.DirectInsertResult{
+ OK: false,
+ Method: "ax_focused_element",
+ Message: "focused element unavailable",
+ Target: "target=unknown",
+ },
+ autoPasteResult: axinput.AutoPasteResult{OK: false, Pasted: false, FrontmostUnchanged: false, Message: "frontmost changed"},
+ }
+ router := &Router{driver: driver}
+
+ result := router.OutputWithOptions("recognized text", OutputOptions{
+ KeepClipboard: false,
+ AutoPasteExperiment: true,
+ AutoPasteGuard: AutoPasteGuard{OK: true, Identity: "bundle=com.apple.TextEdit pid=123"},
+ })
+ if result.OK || !result.FallbackAvailable {
+ t.Fatalf("result = %+v", result)
+ }
+ if driver.copiedText != "" {
+ t.Fatalf("copiedText = %q, want no tokenless clipboard write", driver.copiedText)
+ }
+ if !driver.prepareCalled || driver.fallbackText != "recognized text" {
+ t.Fatalf("prepareCalled=%t fallbackText=%q", driver.prepareCalled, driver.fallbackText)
+ }
+ if !driver.restoreCalled || driver.restoreToken != "token" {
+ t.Fatalf("restoreCalled=%t restoreToken=%q", driver.restoreCalled, driver.restoreToken)
+ }
+}
+
+func TestAppStoreRouterAutoPasteExperimentCancelsWhenRestoreTokenUnavailable(t *testing.T) {
+ driver := &fakeAXDriver{
+ directResult: axinput.DirectInsertResult{
+ OK: false,
+ Method: "ax_focused_element",
+ Message: "focused element unavailable",
+ Target: "target=unknown",
+ },
+ prepareResult: axinput.ClipboardResult{OK: true, Prepared: true, Message: "prepared without token"},
+ }
+ router := &Router{driver: driver}
+
+ result := router.OutputWithOptions("recognized text", OutputOptions{
+ KeepClipboard: false,
+ AutoPasteExperiment: true,
+ AutoPasteGuard: AutoPasteGuard{OK: true, Identity: "bundle=com.apple.TextEdit pid=123"},
+ })
+ if result.OK || !result.FallbackAvailable {
+ t.Fatalf("result = %+v", result)
+ }
+ if driver.autoPasteCalled {
+ t.Fatal("auto paste called without restore token")
+ }
+ if driver.restoreCalled {
+ t.Fatal("restore called without token")
+ }
+}
+
+func TestAppStoreRouterAutoPasteExperimentReportsRestoreSkipWhenClipboardChanged(t *testing.T) {
+ driver := &fakeAXDriver{
+ directResult: axinput.DirectInsertResult{
+ OK: false,
+ Method: "ax_focused_element",
+ Message: "focused element unavailable",
+ Target: "target=unknown",
+ },
+ autoPasteResult: axinput.AutoPasteResult{OK: true, Pasted: true, FrontmostUnchanged: true},
+ restoreResult: axinput.ClipboardResult{OK: false, Restored: false, Message: "clipboard changed after fallback preparation; restore skipped"},
+ }
+ router := &Router{driver: driver}
+
+ result := router.OutputWithOptions("recognized text", OutputOptions{
+ KeepClipboard: false,
+ AutoPasteExperiment: true,
+ AutoPasteGuard: AutoPasteGuard{OK: true, Identity: "bundle=com.apple.TextEdit pid=123"},
+ })
+ if result.OK || !result.FallbackAvailable {
+ t.Fatalf("result = %+v", result)
+ }
+ if !driver.autoPasteCalled || !driver.restoreCalled {
+ t.Fatalf("autoPasteCalled=%t restoreCalled=%t", driver.autoPasteCalled, driver.restoreCalled)
+ }
+}
+
+func TestAppStoreRouterAutoPasteExperimentBlocksTerminalIdentity(t *testing.T) {
+ driver := &fakeAXDriver{
+ directResult: axinput.DirectInsertResult{
+ OK: false,
+ Method: "ax_focused_element",
+ Message: "focused element unavailable",
+ Target: "target=unknown",
+ },
+ }
+ router := &Router{driver: driver}
+
+ result := router.OutputWithOptions("recognized text", OutputOptions{
+ KeepClipboard: true,
+ AutoPasteExperiment: true,
+ AutoPasteGuard: AutoPasteGuard{OK: true, Identity: "bundle=com.apple.Terminal pid=123"},
+ })
+ if result.OK || !result.FallbackAvailable {
+ t.Fatalf("result = %+v", result)
+ }
+ if driver.autoPasteCalled {
+ t.Fatal("auto paste called for terminal identity")
+ }
+ if driver.copiedText != "" || driver.fallbackText != "" || driver.prepareCalled {
+ t.Fatalf("clipboard touched for terminal identity: copied=%q fallback=%q prepareCalled=%t", driver.copiedText, driver.fallbackText, driver.prepareCalled)
+ }
+}
+
+func TestAppStoreRouterAutoPasteExperimentBlocksTerminalCurrentIdentityBeforeClipboardWrite(t *testing.T) {
+ driver := &fakeAXDriver{
+ directResult: axinput.DirectInsertResult{
+ OK: false,
+ Method: "ax_focused_element",
+ Message: "focused element unavailable",
+ Target: "target=unknown",
+ },
+ frontmostIdentity: axinput.FrontmostAppIdentity{OK: true, Identity: "bundle=com.apple.Terminal pid=456"},
+ }
+ router := &Router{driver: driver}
+
+ result := router.OutputWithOptions("recognized text", OutputOptions{
+ KeepClipboard: false,
+ AutoPasteExperiment: true,
+ AutoPasteGuard: AutoPasteGuard{OK: true, Identity: "bundle=com.apple.TextEdit pid=123"},
+ })
+ if result.OK || !result.FallbackAvailable {
+ t.Fatalf("result = %+v", result)
+ }
+ if driver.copiedText != "" || driver.fallbackText != "" || driver.prepareCalled {
+ t.Fatalf("clipboard touched before terminal current identity block: copied=%q fallback=%q prepareCalled=%t", driver.copiedText, driver.fallbackText, driver.prepareCalled)
+ }
+ if driver.autoPasteCalled {
+ t.Fatal("auto paste called after terminal current identity block")
+ }
+}
+
+func TestAppStoreRouterAutoPasteExperimentRequiresClipboardPrepared(t *testing.T) {
+ driver := &fakeAXDriver{
+ directResult: axinput.DirectInsertResult{
+ OK: false,
+ Method: "ax_focused_element",
+ Message: "focused element unavailable",
+ Target: "target=unknown",
+ },
+ copyResult: axinput.ClipboardResult{OK: false, Prepared: false, Message: "clipboard write failed"},
+ }
+ router := &Router{driver: driver}
+
+ result := router.OutputWithOptions("recognized text", OutputOptions{
+ KeepClipboard: true,
+ AutoPasteExperiment: true,
+ AutoPasteGuard: AutoPasteGuard{OK: true, Identity: "bundle=com.apple.TextEdit pid=123"},
+ })
+ if result.OK || !result.FallbackAvailable {
+ t.Fatalf("result = %+v", result)
+ }
+ if driver.autoPasteCalled {
+ t.Fatal("auto paste called when clipboard preparation failed")
+ }
+}
+
+func TestAppStoreRouterAutoPasteExperimentRequiresGuard(t *testing.T) {
+ driver := &fakeAXDriver{
+ directResult: axinput.DirectInsertResult{
+ OK: false,
+ Method: "ax_focused_element",
+ Message: "focused element unavailable",
+ Target: "target=unknown",
+ },
+ }
+ router := &Router{driver: driver}
+
+ result := router.OutputWithOptions("recognized text", OutputOptions{
+ KeepClipboard: true,
+ AutoPasteExperiment: true,
+ AutoPasteGuard: AutoPasteGuard{OK: false},
+ })
+ if result.OK || !result.FallbackAvailable {
+ t.Fatalf("result = %+v", result)
+ }
+ if driver.autoPasteCalled {
+ t.Fatal("auto paste called without a frontmost guard")
+ }
+}
+
+func TestAppStoreRouterDoesNotAutoCopyForKnownTerminalTarget(t *testing.T) {
+ driver := &fakeAXDriver{
+ directResult: axinput.DirectInsertResult{
+ OK: false,
+ Method: "ax_focused_element",
+ Message: "focused element unavailable",
+ Target: "app=Terminal role=AXWindow",
+ },
+ }
+ router := &Router{driver: driver}
+
+ result := router.Output("recognized text", true)
+ if result.OK || result.FallbackAvailable {
+ t.Fatalf("result = %+v", result)
+ }
+ if driver.copiedText != "" {
+ t.Fatalf("copiedText = %q, want no clipboard write for terminal target", driver.copiedText)
+ }
+}
+
+func TestDirectInsertDiagnosticClassifiesFocusedElementUnavailable(t *testing.T) {
+ category, codes := directInsertDiagnostic(axinput.DirectInsertResult{
+ Message: "focused element unavailable: system=-25204 focused_app=0 focused_app_element=-25204 frontmost=-25204",
+ })
+ if category != "focused_element_unavailable" {
+ t.Fatalf("category = %q", category)
+ }
+ wantCodes := "system=-25204 focused_app=0 focused_app_element=-25204 frontmost=-25204"
+ if codes != wantCodes {
+ t.Fatalf("codes = %q, want %q", codes, wantCodes)
+ }
+}
+
+func TestDirectInsertDiagnosticDoesNotIncludeFreeText(t *testing.T) {
+ category, codes := directInsertDiagnostic(axinput.DirectInsertResult{
+ Message: "focused text state is not readable; value=-25204 range=-25205 title=SensitiveWindow transcript=PrivateText",
+ })
+ if category != "focused_text_state_unreadable" {
+ t.Fatalf("category = %q", category)
+ }
+ wantCodes := "value=-25204 range=-25205"
+ if codes != wantCodes {
+ t.Fatalf("codes = %q, want %q", codes, wantCodes)
+ }
+}
diff --git a/C1.source/privatevoice.src/internal/textoutput/router_default.go b/C1.source/privatevoice.src/internal/textoutput/router_default.go
new file mode 100644
index 0000000..171d55a
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/textoutput/router_default.go
@@ -0,0 +1,42 @@
+//go:build !darwin || !appstore
+
+package textoutput
+
+func newPlatformRouter(paster Paster) *Router {
+ return &Router{paster: paster}
+}
+
+func (r *Router) Output(text string, keepClipboard bool) Result {
+ return r.OutputWithOptions(text, OutputOptions{KeepClipboard: keepClipboard})
+}
+
+func (r *Router) OutputWithOptions(text string, options OutputOptions) Result {
+ if text == "" {
+ return r.remember(Result{OK: false, Method: "legacy_paste", Message: "text is empty"})
+ }
+ if r.paster == nil {
+ return r.remember(Result{OK: false, Text: text, Method: "legacy_paste", Message: "paster is unavailable"})
+ }
+ if err := r.paster.Paste(text, options.KeepClipboard); err != nil {
+ if typeErr := r.paster.TypeText(text); typeErr != nil {
+ return r.remember(Result{
+ OK: false,
+ Text: text,
+ Method: "legacy_type_fallback",
+ Message: "paste failed: " + err.Error() + "; type fallback failed: " + typeErr.Error(),
+ })
+ }
+ return r.remember(Result{
+ OK: true,
+ Text: text,
+ Method: "legacy_type_fallback",
+ Message: "typed through legacy fallback after paste failed: " + err.Error(),
+ })
+ }
+ return r.remember(Result{
+ OK: true,
+ Text: text,
+ Method: "legacy_clipboard_paste",
+ Message: "pasted through existing output path",
+ })
+}
diff --git a/C1.source/privatevoice.src/internal/textoutput/router_test.go b/C1.source/privatevoice.src/internal/textoutput/router_test.go
new file mode 100644
index 0000000..70d9ebe
--- /dev/null
+++ b/C1.source/privatevoice.src/internal/textoutput/router_test.go
@@ -0,0 +1,64 @@
+//go:build !darwin || !appstore
+
+package textoutput
+
+import (
+ "errors"
+ "testing"
+)
+
+type fakePaster struct {
+ pasteErr error
+ typeErr error
+ pasted string
+ typed string
+}
+
+func (p *fakePaster) Paste(text string, keepClipboard bool) error {
+ p.pasted = text
+ return p.pasteErr
+}
+
+func (p *fakePaster) TypeText(text string) error {
+ p.typed = text
+ return p.typeErr
+}
+
+func TestDefaultRouterUsesExistingPastePath(t *testing.T) {
+ paster := &fakePaster{}
+ router := NewRouter(paster)
+
+ result := router.Output("recognized text", false)
+ if !result.OK || result.Method != "legacy_clipboard_paste" {
+ t.Fatalf("result = %+v", result)
+ }
+ if paster.pasted != "recognized text" || paster.typed != "" {
+ t.Fatalf("paster = %+v", paster)
+ }
+}
+
+func TestDefaultRouterUsesLegacyTypeFallback(t *testing.T) {
+ paster := &fakePaster{pasteErr: errors.New("paste denied")}
+ router := NewRouter(paster)
+
+ result := router.Output("recognized text", false)
+ if !result.OK || result.Method != "legacy_type_fallback" {
+ t.Fatalf("result = %+v", result)
+ }
+ if paster.typed != "recognized text" {
+ t.Fatalf("typed = %q", paster.typed)
+ }
+}
+
+func TestDefaultRouterKeepsTextOnOutputFailure(t *testing.T) {
+ paster := &fakePaster{pasteErr: errors.New("paste denied"), typeErr: errors.New("type denied")}
+ router := NewRouter(paster)
+
+ result := router.Output("recognized text", false)
+ if result.OK || result.Text != "recognized text" {
+ t.Fatalf("result = %+v", result)
+ }
+ if router.LastResult().Text != "recognized text" {
+ t.Fatalf("last result = %+v", router.LastResult())
+ }
+}
diff --git a/C1.source/privatevoice.src/scripts/build-appstore-macos.sh b/C1.source/privatevoice.src/scripts/build-appstore-macos.sh
index b635ba2..3b363b3 100755
--- a/C1.source/privatevoice.src/scripts/build-appstore-macos.sh
+++ b/C1.source/privatevoice.src/scripts/build-appstore-macos.sh
@@ -10,23 +10,32 @@
# cd VoiceSnapGo
# ./scripts/build-appstore-macos.sh [arm64|x86_64]
#
+# For local QA app refresh without creating a DMG:
+# PRIVATEVOICE_APPSTORE_LOCAL_APP_ONLY=1 ./scripts/build-appstore-macos.sh arm64
+#
+# Optional candidate version override:
+# PRIVATEVOICE_APP_VERSION=2.2.3 PRIVATEVOICE_APP_BUILD=YYYYMMDD.HHMM
+#
set -euo pipefail
APP_NAME="PrivateVoice Dictation"
-LOCAL_IDENTITY="${PRIVATEVOICE_LOCAL_SIGN_IDENTITY:-PrivateVoice Local Code Signing}"
+LOCAL_IDENTITY="${PRIVATEVOICE_APPSTORE_LOCAL_SIGN_IDENTITY:-${PRIVATEVOICE_LOCAL_SIGN_IDENTITY:-}}"
+FALLBACK_LOCAL_IDENTITY="PrivateVoice Local Code Signing"
KEYCHAIN="${PRIVATEVOICE_LOCAL_KEYCHAIN:-$HOME/Library/Keychains/login.keychain-db}"
APPSTORE_MIN_SYSTEM_VERSION="${PRIVATEVOICE_APPSTORE_MIN_SYSTEM_VERSION:-13.4}"
+APP_ONLY="${PRIVATEVOICE_APPSTORE_LOCAL_APP_ONLY:-0}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
INFO_PLIST="$PROJECT_DIR/build/darwin/Info.plist"
ENTITLEMENTS="$PROJECT_DIR/build/darwin/entitlements-appstore.plist"
+PRIVACY_MANIFEST="$PROJECT_DIR/build/darwin/PrivacyInfo.xcprivacy"
GOMODCACHE="$(go env GOMODCACHE)"
SHERPA_MACOS_MODULE_VERSION="$(cd "$PROJECT_DIR" && go list -m -f '{{.Version}}' github.com/k2-fsa/sherpa-onnx-go-macos)"
-VERSION="$(defaults read "$INFO_PLIST" CFBundleShortVersionString 2>/dev/null || echo "2.1.12")"
-BUILD_ID="$(defaults read "$INFO_PLIST" CFBundleVersion 2>/dev/null || date +%Y%m%d.%H%M)"
+VERSION="${PRIVATEVOICE_APP_VERSION:-$(defaults read "$INFO_PLIST" CFBundleShortVersionString 2>/dev/null || echo "2.1.12")}"
+BUILD_ID="${PRIVATEVOICE_APP_BUILD:-$(defaults read "$INFO_PLIST" CFBundleVersion 2>/dev/null || date +%Y%m%d.%H%M)}"
TARGET_ARCH="${1:-$(uname -m)}"
@@ -38,6 +47,22 @@
fail() {
echo "ERROR: $1" >&2
exit 1
+}
+
+resolve_signing_identity() {
+ if [[ -n "$LOCAL_IDENTITY" ]]; then
+ return
+ fi
+
+ LOCAL_IDENTITY="$(
+ security find-identity -v -p codesigning "$KEYCHAIN" |
+ sed -n 's/.*"\(Apple Development:[^"]*\)".*/\1/p' |
+ head -n 1
+ )"
+
+ if [[ -z "$LOCAL_IDENTITY" ]]; then
+ LOCAL_IDENTITY="$FALLBACK_LOCAL_IDENTITY"
+ fi
}
case "$TARGET_ARCH" in
@@ -82,14 +107,25 @@
"$item"
}
+fix_binary_rpaths() {
+ local binary="$1"
+ # The sherpa-onnx macOS module injects its cache lib directory as an LC_RPATH
+ # ahead of bundle Frameworks. In a sandboxed signed app, dyld may then load the
+ # module-cache dylib with a different signing identity and crash on launch.
+ install_name_tool -delete_rpath "$SHERPA_LIB_DIR" "$binary" 2>/dev/null || true
+ install_name_tool -add_rpath @executable_path/../Frameworks "$binary" 2>/dev/null || true
+}
+
step "Preflight checks"
[[ -f "$INFO_PLIST" ]] || fail "Info.plist not found at $INFO_PLIST"
[[ -f "$ENTITLEMENTS" ]] || fail "App Store entitlements not found at $ENTITLEMENTS"
+[[ -f "$PRIVACY_MANIFEST" ]] || fail "PrivacyInfo.xcprivacy not found at $PRIVACY_MANIFEST"
[[ -f "$PROJECT_DIR/build/darwin/icon.icns" ]] || fail "icon.icns not found"
[[ -d "$SHERPA_LIB_DIR" ]] || fail "sherpa-onnx dylibs not found at $SHERPA_LIB_DIR"
ONNXRUNTIME_DYLIB="$(find "$SHERPA_LIB_DIR" -maxdepth 1 -type f -name 'libonnxruntime.*.dylib' | sort | tail -n 1)"
[[ -f "$ONNXRUNTIME_DYLIB" ]] || fail "onnxruntime dylib not found in $SHERPA_LIB_DIR"
ONNXRUNTIME_DYLIB_NAME="$(basename "$ONNXRUNTIME_DYLIB")"
+resolve_signing_identity
security find-identity -v -p codesigning "$KEYCHAIN" | grep -Fq "$LOCAL_IDENTITY" ||
fail "Local signing identity not found: $LOCAL_IDENTITY"
@@ -110,7 +146,8 @@
MACOSX_DEPLOYMENT_TARGET="$APPSTORE_MIN_SYSTEM_VERSION" \
CGO_CFLAGS="${CGO_CFLAGS:-} -mmacosx-version-min=$APPSTORE_MIN_SYSTEM_VERSION" \
CGO_LDFLAGS="${CGO_LDFLAGS:-} -mmacosx-version-min=$APPSTORE_MIN_SYSTEM_VERSION" \
- go build -tags appstore -buildvcs=false -gcflags=all="-l" -ldflags="-s -w" \
+ go build -tags appstore -buildvcs=false -gcflags=all="-l" \
+ -ldflags="-s -w -X main.appVersion=$VERSION -X main.appBuild=$BUILD_ID" \
-o "$BUILD_DIR/$APP_NAME"
step "Assembling $APP_NAME.app"
@@ -119,10 +156,15 @@
mkdir -p "$APP_BUNDLE/Contents/Resources"
cp "$INFO_PLIST" "$APP_BUNDLE/Contents/Info.plist"
+/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" \
+ "$APP_BUNDLE/Contents/Info.plist"
+/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_ID" \
+ "$APP_BUNDLE/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :LSMinimumSystemVersion $APPSTORE_MIN_SYSTEM_VERSION" \
"$APP_BUNDLE/Contents/Info.plist"
cp "$BUILD_DIR/$APP_NAME" "$APP_BUNDLE/Contents/MacOS/$APP_NAME"
cp "$PROJECT_DIR/build/darwin/icon.icns" "$APP_BUNDLE/Contents/Resources/icon.icns"
+cp "$PRIVACY_MANIFEST" "$APP_BUNDLE/Contents/Resources/PrivacyInfo.xcprivacy"
find "$PROJECT_DIR/build/darwin" -maxdepth 1 -type d -name "*.lproj" \
-exec cp -R {} "$APP_BUNDLE/Contents/Resources/" \;
cp "$SHERPA_LIB_DIR/libsherpa-onnx-c-api.dylib" "$APP_BUNDLE/Contents/Frameworks/"
@@ -131,6 +173,7 @@
step "Fixing rpaths"
install_name_tool -add_rpath @executable_path/../Frameworks \
"$APP_BUNDLE/Contents/MacOS/$APP_NAME" 2>/dev/null || true
+fix_binary_rpaths "$APP_BUNDLE/Contents/MacOS/$APP_NAME"
install_name_tool -id @rpath/libsherpa-onnx-c-api.dylib \
"$APP_BUNDLE/Contents/Frameworks/libsherpa-onnx-c-api.dylib"
install_name_tool -id "@rpath/$ONNXRUNTIME_DYLIB_NAME" \
@@ -147,18 +190,26 @@
codesign -d --entitlements :- "$APP_BUNDLE"
step "Creating POC DMG"
-DMG_TEMP="$BUILD_DIR/dmg-staging"
-rm -rf "$DMG_TEMP"
-mkdir -p "$DMG_TEMP"
-cp -R "$APP_BUNDLE" "$DMG_TEMP/"
-ln -s /Applications "$DMG_TEMP/Applications"
-hdiutil create -volname "$APP_NAME Sandbox POC" \
- -srcfolder "$DMG_TEMP" \
- -ov -format UDZO \
- "$DMG_PATH"
-rm -rf "$DMG_TEMP"
+if [[ "$APP_ONLY" == "1" ]]; then
+ echo " Skipped because PRIVATEVOICE_APPSTORE_LOCAL_APP_ONLY=1"
+else
+ DMG_TEMP="$BUILD_DIR/dmg-staging"
+ rm -rf "$DMG_TEMP"
+ mkdir -p "$DMG_TEMP"
+ cp -R "$APP_BUNDLE" "$DMG_TEMP/"
+ ln -s /Applications "$DMG_TEMP/Applications"
+ hdiutil create -volname "$APP_NAME Sandbox POC" \
+ -srcfolder "$DMG_TEMP" \
+ -ov -format UDZO \
+ "$DMG_PATH"
+ rm -rf "$DMG_TEMP"
+fi
step "App Sandbox POC build complete"
echo " App: $APP_BUNDLE"
-echo " DMG: $DMG_PATH"
+if [[ "$APP_ONLY" == "1" ]]; then
+ echo " DMG: skipped"
+else
+ echo " DMG: $DMG_PATH"
+fi
echo " Signing identity: $LOCAL_IDENTITY"
diff --git a/C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh b/C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh
index 1b3b0d7..c853052 100755
--- a/C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh
+++ b/C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh
@@ -21,6 +21,8 @@
# PRIVATEVOICE_SIGNING_KEYCHAIN
# PRIVATEVOICE_APPSTORE_MIN_SYSTEM_VERSION (default: 13.4)
# PRIVATEVOICE_MACAPPSTORE_BUILD_ROOT (default: build/macappstore)
+# PRIVATEVOICE_APP_VERSION (default: build/darwin/Info.plist CFBundleShortVersionString)
+# PRIVATEVOICE_APP_BUILD (default: build/darwin/Info.plist CFBundleVersion)
#
# Usage:
# cd privatevoice.src
@@ -37,6 +39,7 @@
APPSTORE_PROVISIONING_PROFILE="${PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE:-}"
SIGNING_KEYCHAIN="${PRIVATEVOICE_SIGNING_KEYCHAIN:-}"
APPSTORE_MIN_SYSTEM_VERSION="${PRIVATEVOICE_APPSTORE_MIN_SYSTEM_VERSION:-13.4}"
+APPSTORE_APP_SIGN_IDENTITY=""
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -44,12 +47,13 @@
INFO_PLIST="$PROJECT_DIR/build/darwin/Info.plist"
BASE_ENTITLEMENTS="$PROJECT_DIR/build/darwin/entitlements-appstore.plist"
+PRIVACY_MANIFEST="$PROJECT_DIR/build/darwin/PrivacyInfo.xcprivacy"
GOMODCACHE="$(go env GOMODCACHE)"
SHERPA_MACOS_MODULE_VERSION="$(cd "$PROJECT_DIR" && go list -m -f '{{.Version}}' github.com/k2-fsa/sherpa-onnx-go-macos)"
-VERSION="$(defaults read "$INFO_PLIST" CFBundleShortVersionString 2>/dev/null || echo "2.1.0")"
-BUILD_ID="$(defaults read "$INFO_PLIST" CFBundleVersion 2>/dev/null || date +%Y%m%d.%H%M)"
-BUNDLE_ID="$(defaults read "$INFO_PLIST" CFBundleIdentifier 2>/dev/null || echo "")"
+VERSION="${PRIVATEVOICE_APP_VERSION:-$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$INFO_PLIST" 2>/dev/null || echo "2.1.0")}"
+BUILD_ID="${PRIVATEVOICE_APP_BUILD:-$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$INFO_PLIST" 2>/dev/null || date +%Y%m%d.%H%M)}"
+BUNDLE_ID="$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$INFO_PLIST" 2>/dev/null || echo "")"
step() {
echo ""
@@ -76,6 +80,75 @@
else
security find-identity -v
fi
+}
+
+normalize_sha1() {
+ tr '[:lower:]' '[:upper:]' | tr -d ':[:space:]'
+}
+
+is_sha1_fingerprint() {
+ [[ "$1" =~ ^[0-9A-Fa-f]{40}$ ]]
+}
+
+resolve_codesigning_identity_sha1() {
+ local requested="$1"
+ local requested_sha1=""
+ local matches=()
+ local line hash name
+
+ if is_sha1_fingerprint "$requested"; then
+ requested_sha1="$(printf "%s" "$requested" | normalize_sha1)"
+ fi
+
+ while IFS= read -r line; do
+ hash="$(awk '{print $2}' <<<"$line")"
+ [[ "$hash" =~ ^[0-9A-Fa-f]{40}$ ]] || continue
+ name="${line#*\"}"
+ name="${name%\"*}"
+
+ if [[ -n "$requested_sha1" ]]; then
+ [[ "$(printf "%s" "$hash" | normalize_sha1)" == "$requested_sha1" ]] &&
+ matches+=("$(printf "%s" "$hash" | normalize_sha1)")
+ elif [[ "$name" == "$requested" ]]; then
+ matches+=("$(printf "%s" "$hash" | normalize_sha1)")
+ fi
+ done < <(security_find_code_identities)
+
+ case "${#matches[@]}" in
+ 0)
+ fail "App Store app signing identity not found: $requested"
+ ;;
+ 1)
+ printf "%s\n" "${matches[0]}"
+ ;;
+ *)
+ fail "App Store app signing identity is ambiguous: $requested. Use the 40-character SHA1 fingerprint."
+ ;;
+ esac
+}
+
+profile_developer_certificate_sha1s() {
+ local profile_plist="$1"
+ local index=0
+ local cert_der cert_sha1
+ local found=0
+
+ while :; do
+ cert_der="$(mktemp)"
+ if ! /usr/libexec/PlistBuddy -c "Print :DeveloperCertificates:$index" "$profile_plist" >"$cert_der" 2>/dev/null; then
+ rm -f "$cert_der"
+ break
+ fi
+ cert_sha1="$(openssl x509 -inform der -in "$cert_der" -noout -fingerprint -sha1 |
+ sed 's/^.*=//' | normalize_sha1)"
+ rm -f "$cert_der"
+ [[ -n "$cert_sha1" ]] || fail "Unable to read DeveloperCertificates:$index SHA1 from provisioning profile"
+ printf "%s\n" "$cert_sha1"
+ found=1
+ index=$((index + 1))
+ done
+
+ [[ "$found" -eq 1 ]] || fail "Provisioning profile has no DeveloperCertificates"
}
resolve_arch() {
@@ -109,7 +182,7 @@
codesign --force \
--options runtime \
--timestamp \
- --sign "$APPSTORE_APP_IDENTITY" \
+ --sign "$APPSTORE_APP_SIGN_IDENTITY" \
"$item"
}
@@ -119,8 +192,16 @@
--options runtime \
--timestamp \
--entitlements "$SIGNING_ENTITLEMENTS" \
- --sign "$APPSTORE_APP_IDENTITY" \
+ --sign "$APPSTORE_APP_SIGN_IDENTITY" \
"$item"
+}
+
+fix_binary_rpaths() {
+ local binary="$1"
+ # The sherpa-onnx macOS module injects its cache lib directory as an LC_RPATH
+ # ahead of bundle Frameworks. MAS candidates must load bundled dylibs.
+ install_name_tool -delete_rpath "$SHERPA_LIB_DIR" "$binary" 2>/dev/null || true
+ install_name_tool -add_rpath @executable_path/../Frameworks "$binary" 2>/dev/null || true
}
plist_set_string() {
@@ -185,13 +266,13 @@
[[ -f "$INFO_PLIST" ]] || fail "Info.plist not found at $INFO_PLIST"
[[ -n "$BUNDLE_ID" ]] || fail "CFBundleIdentifier is missing from $INFO_PLIST"
[[ -f "$BASE_ENTITLEMENTS" ]] || fail "App Store entitlements not found at $BASE_ENTITLEMENTS"
+[[ -f "$PRIVACY_MANIFEST" ]] || fail "PrivacyInfo.xcprivacy not found at $PRIVACY_MANIFEST"
[[ -f "$PROJECT_DIR/build/darwin/icon.icns" ]] || fail "icon.icns not found"
[[ -d "$SHERPA_LIB_DIR" ]] || fail "sherpa-onnx dylibs not found at $SHERPA_LIB_DIR"
ONNXRUNTIME_DYLIB="$(find "$SHERPA_LIB_DIR" -maxdepth 1 -type f -name 'libonnxruntime.*.dylib' | sort | tail -n 1)"
[[ -f "$ONNXRUNTIME_DYLIB" ]] || fail "onnxruntime dylib not found in $SHERPA_LIB_DIR"
ONNXRUNTIME_DYLIB_NAME="$(basename "$ONNXRUNTIME_DYLIB")"
-security_find_code_identities | grep -Fq "$APPSTORE_APP_IDENTITY" ||
- fail "App Store app signing identity not found: $APPSTORE_APP_IDENTITY"
+APPSTORE_APP_SIGN_IDENTITY="$(resolve_codesigning_identity_sha1 "$APPSTORE_APP_IDENTITY")"
security_find_installer_identities | grep -Fq "$APPSTORE_INSTALLER_IDENTITY" ||
fail "App Store installer signing identity not found: $APPSTORE_INSTALLER_IDENTITY"
command -v productbuild >/dev/null || fail "productbuild not found"
@@ -205,10 +286,14 @@
PROFILE_APP_IDENTIFIER="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.application-identifier" "$PROFILE_PLIST" 2>/dev/null || true)"
PROFILE_TEAM_IDENTIFIER="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.developer.team-identifier" "$PROFILE_PLIST" 2>/dev/null || true)"
PROFILE_KEYCHAIN_GROUP="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:keychain-access-groups:0" "$PROFILE_PLIST" 2>/dev/null || true)"
+PROFILE_DEVELOPER_CERT_SHA1S="$(profile_developer_certificate_sha1s "$PROFILE_PLIST")"
PROFILE_BUNDLE_ID="${PROFILE_APP_IDENTIFIER#*.}"
[[ "$PROFILE_BUNDLE_ID" == "$BUNDLE_ID" ]] ||
fail "Provisioning profile bundle ID mismatch: profile has $PROFILE_BUNDLE_ID, app has $BUNDLE_ID"
[[ -n "$PROFILE_TEAM_IDENTIFIER" ]] || fail "Provisioning profile missing com.apple.developer.team-identifier entitlement"
+if ! grep -Fxq "$APPSTORE_APP_SIGN_IDENTITY" <<<"$PROFILE_DEVELOPER_CERT_SHA1S"; then
+ fail "App Store app signing identity certificate mismatch: identity SHA1 $APPSTORE_APP_SIGN_IDENTITY is not present in provisioning profile DeveloperCertificates: $(tr '\n' ',' <<<"$PROFILE_DEVELOPER_CERT_SHA1S" | sed 's/,$//')"
+fi
cp "$BASE_ENTITLEMENTS" "$SIGNING_ENTITLEMENTS"
plist_set_string "$SIGNING_ENTITLEMENTS" "com.apple.application-identifier" "$PROFILE_APP_IDENTIFIER"
@@ -236,7 +321,8 @@
MACOSX_DEPLOYMENT_TARGET="$APPSTORE_MIN_SYSTEM_VERSION" \
CGO_CFLAGS="${CGO_CFLAGS:-} -mmacosx-version-min=$APPSTORE_MIN_SYSTEM_VERSION" \
CGO_LDFLAGS="${CGO_LDFLAGS:-} -mmacosx-version-min=$APPSTORE_MIN_SYSTEM_VERSION" \
- go build -tags appstore -buildvcs=false -gcflags=all="-l" -ldflags="-s -w" \
+ go build -tags appstore -buildvcs=false -gcflags=all="-l" \
+ -ldflags="-s -w -X main.appVersion=$VERSION -X main.appBuild=$BUILD_ID" \
-o "$BUILD_DIR/$APP_NAME"
step "Assembling $APP_NAME.app"
@@ -245,11 +331,14 @@
mkdir -p "$APP_BUNDLE/Contents/Resources"
cp "$INFO_PLIST" "$APP_BUNDLE/Contents/Info.plist"
+plist_set_string "$APP_BUNDLE/Contents/Info.plist" "CFBundleShortVersionString" "$VERSION"
+plist_set_string "$APP_BUNDLE/Contents/Info.plist" "CFBundleVersion" "$BUILD_ID"
/usr/libexec/PlistBuddy -c "Set :LSMinimumSystemVersion $APPSTORE_MIN_SYSTEM_VERSION" \
"$APP_BUNDLE/Contents/Info.plist"
cp "$APPSTORE_PROVISIONING_PROFILE" "$APP_BUNDLE/Contents/embedded.provisionprofile"
cp "$BUILD_DIR/$APP_NAME" "$APP_BUNDLE/Contents/MacOS/$APP_NAME"
cp "$PROJECT_DIR/build/darwin/icon.icns" "$APP_BUNDLE/Contents/Resources/icon.icns"
+cp "$PRIVACY_MANIFEST" "$APP_BUNDLE/Contents/Resources/PrivacyInfo.xcprivacy"
find "$PROJECT_DIR/build/darwin" -maxdepth 1 -type d -name "*.lproj" \
-exec cp -R {} "$APP_BUNDLE/Contents/Resources/" \;
cp "$SHERPA_LIB_DIR/libsherpa-onnx-c-api.dylib" "$APP_BUNDLE/Contents/Frameworks/"
@@ -264,6 +353,7 @@
step "Fixing rpaths"
install_name_tool -add_rpath @executable_path/../Frameworks \
"$APP_BUNDLE/Contents/MacOS/$APP_NAME" 2>/dev/null || true
+fix_binary_rpaths "$APP_BUNDLE/Contents/MacOS/$APP_NAME"
install_name_tool -id @rpath/libsherpa-onnx-c-api.dylib \
"$APP_BUNDLE/Contents/Frameworks/libsherpa-onnx-c-api.dylib"
install_name_tool -id "@rpath/$ONNXRUNTIME_DYLIB_NAME" \
@@ -304,5 +394,5 @@
step "Mac App Store package complete"
echo " App: $APP_BUNDLE"
echo " PKG: $PKG_PATH"
-echo " App signing identity: $APPSTORE_APP_IDENTITY"
+echo " App signing identity: $APPSTORE_APP_IDENTITY ($APPSTORE_APP_SIGN_IDENTITY)"
echo " Installer signing identity: $APPSTORE_INSTALLER_IDENTITY"
diff --git a/C1.source/privatevoice.src/scripts/verify-ax-output-boundary.sh b/C1.source/privatevoice.src/scripts/verify-ax-output-boundary.sh
new file mode 100755
index 0000000..6600b93
--- /dev/null
+++ b/C1.source/privatevoice.src/scripts/verify-ax-output-boundary.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+scan_files=(
+ "$ROOT_DIR/internal/axinput"
+ "$ROOT_DIR/internal/textoutput"
+ "$ROOT_DIR/services/text_output_service.go"
+ "$ROOT_DIR/app.go"
+ "$ROOT_DIR/frontend/src/components/settings/GeneralPage.svelte"
+)
+
+matches="$(rg -n 'InputMethod|InputMethodKit|TIS|CGEventPost|NSEvent\.addGlobalMonitor|addGlobalMonitorForEvents|CGRequestListenEventAccess|CGPreflightListenEventAccess|NSAppleEventDescriptor|AEDesc|NSAppleScript|/Library/Input Methods|~/Library/Input Methods|TISEnableInputSource|TISSelectInputSource|TestFlight|notarytool|altool|git tag|codebase-memory|release manifest' "${scan_files[@]}" || true)"
+forbidden="$(printf '%s\n' "$matches" | grep -v 'AX10DT_EXPERIMENT_ALLOW_CGEVENTPOST' || true)"
+if [ -n "$forbidden" ]; then
+ printf '%s\n' "$forbidden"
+ echo "FAIL: AX output boundary scan found a forbidden route or release action" >&2
+ exit 1
+fi
+
+echo "PASS: AX output boundary scan found no IMK/TIS, non-experiment event injection, global monitor, input-monitoring, automation, system input-method install, or release action in AX output files"
diff --git a/C1.source/privatevoice.src/services/config_service.go b/C1.source/privatevoice.src/services/config_service.go
index 5905f75..cff9328 100755
--- a/C1.source/privatevoice.src/services/config_service.go
+++ b/C1.source/privatevoice.src/services/config_service.go
@@ -99,6 +99,17 @@
return s.cfg.CopyToClipboard
}
+// SetAutoPasteExperiment enables the isolated App Store automatic paste experiment.
+func (s *ConfigService) SetAutoPasteExperiment(enabled bool) {
+ s.cfg.AutoPasteExperiment = enabled
+ config.Save(s.cfg)
+}
+
+// GetAutoPasteExperiment returns whether the isolated automatic paste experiment is enabled.
+func (s *ConfigService) GetAutoPasteExperiment() bool {
+ return s.cfg.AutoPasteExperiment
+}
+
// SetHideDockIcon sets whether the app should hide its Dock icon on macOS.
func (s *ConfigService) SetHideDockIcon(enabled bool) {
s.cfg.HideDockIcon = enabled
diff --git a/C1.source/privatevoice.src/services/config_service_test.go b/C1.source/privatevoice.src/services/config_service_test.go
new file mode 100644
index 0000000..076b64c
--- /dev/null
+++ b/C1.source/privatevoice.src/services/config_service_test.go
@@ -0,0 +1,30 @@
+package services
+
+import (
+ "path/filepath"
+ "testing"
+
+ "voicesnap/internal/config"
+)
+
+func TestConfigServiceAutoPasteExperimentLifecycle(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
+
+ cfg := config.Default()
+ service := NewConfigService(cfg)
+ if service.GetAutoPasteExperiment() {
+ t.Fatal("GetAutoPasteExperiment() = true, want false by default")
+ }
+
+ service.SetAutoPasteExperiment(true)
+ if !service.GetAutoPasteExperiment() || !cfg.AutoPasteExperiment {
+ t.Fatalf("AutoPasteExperiment not enabled: service=%t cfg=%t", service.GetAutoPasteExperiment(), cfg.AutoPasteExperiment)
+ }
+
+ service.SetAutoPasteExperiment(false)
+ if service.GetAutoPasteExperiment() || cfg.AutoPasteExperiment {
+ t.Fatalf("AutoPasteExperiment not disabled: service=%t cfg=%t", service.GetAutoPasteExperiment(), cfg.AutoPasteExperiment)
+ }
+}
diff --git a/C1.source/privatevoice.src/services/diagnostics_service.go b/C1.source/privatevoice.src/services/diagnostics_service.go
index 15f79f0..d376fb2 100644
--- a/C1.source/privatevoice.src/services/diagnostics_service.go
+++ b/C1.source/privatevoice.src/services/diagnostics_service.go
@@ -139,17 +139,18 @@
cfg = config.Default()
}
return map[string]interface{}{
- "HotkeyVK": cfg.HotkeyVK,
- "HotkeyMode": cfg.HotkeyMode,
- "AutoHide": cfg.AutoHide,
- "SoundFeedback": cfg.SoundFeedback,
- "HideDockIcon": cfg.HideDockIcon,
- "CopyToClipboard": cfg.CopyToClipboard,
- "DeviceNameSet": cfg.DeviceName != "",
- "SelectedModelID": cfg.SelectedModelID,
- "ModelSelectionMode": cfg.ModelSelectionMode,
- "LanguageMode": cfg.LanguageMode,
- "LanguageID": cfg.LanguageID,
+ "HotkeyVK": cfg.HotkeyVK,
+ "HotkeyMode": cfg.HotkeyMode,
+ "AutoHide": cfg.AutoHide,
+ "SoundFeedback": cfg.SoundFeedback,
+ "HideDockIcon": cfg.HideDockIcon,
+ "CopyToClipboard": cfg.CopyToClipboard,
+ "AutoPasteExperiment": cfg.AutoPasteExperiment,
+ "DeviceNameSet": cfg.DeviceName != "",
+ "SelectedModelID": cfg.SelectedModelID,
+ "ModelSelectionMode": cfg.ModelSelectionMode,
+ "LanguageMode": cfg.LanguageMode,
+ "LanguageID": cfg.LanguageID,
}
}
diff --git a/C1.source/privatevoice.src/services/hotkey_service.go b/C1.source/privatevoice.src/services/hotkey_service.go
index 7d33c7a..24b96e5 100755
--- a/C1.source/privatevoice.src/services/hotkey_service.go
+++ b/C1.source/privatevoice.src/services/hotkey_service.go
@@ -27,6 +27,9 @@
// GetHotkeyName returns the display name for the current hotkey.
func (s *HotkeyService) GetHotkeyName() string {
+ if config.IsHotkeyUnset(s.cfg.HotkeyVK) {
+ return ""
+ }
return hotkey.GetKeyName(s.cfg.HotkeyVK)
}
@@ -55,5 +58,8 @@
// GetKeyName returns the display name for a given VK code.
func (s *HotkeyService) GetKeyName(vk int) string {
+ if config.IsHotkeyUnset(vk) {
+ return ""
+ }
return hotkey.GetKeyName(vk)
}
diff --git a/C1.source/privatevoice.src/services/hotkey_service_test.go b/C1.source/privatevoice.src/services/hotkey_service_test.go
new file mode 100644
index 0000000..e56dbb4
--- /dev/null
+++ b/C1.source/privatevoice.src/services/hotkey_service_test.go
@@ -0,0 +1,22 @@
+package services
+
+import (
+ "testing"
+
+ "voicesnap/internal/config"
+)
+
+func TestHotkeyServiceUnsetHotkeyNameIsEmpty(t *testing.T) {
+ cfg := &config.Config{HotkeyVK: config.UnsetHotkeyVK}
+ svc := NewHotkeyService(cfg)
+
+ if got := svc.GetCurrentHotkey(); got != config.UnsetHotkeyVK {
+ t.Fatalf("GetCurrentHotkey() = %d, want unset", got)
+ }
+ if got := svc.GetHotkeyName(); got != "" {
+ t.Fatalf("GetHotkeyName() = %q, want empty unset display", got)
+ }
+ if got := svc.GetKeyName(config.UnsetHotkeyVK); got != "" {
+ t.Fatalf("GetKeyName(unset) = %q, want empty unset display", got)
+ }
+}
diff --git a/C1.source/privatevoice.src/services/text_output_service.go b/C1.source/privatevoice.src/services/text_output_service.go
new file mode 100644
index 0000000..b41adf1
--- /dev/null
+++ b/C1.source/privatevoice.src/services/text_output_service.go
@@ -0,0 +1,54 @@
+package services
+
+import (
+ "voicesnap/internal/axinput"
+ "voicesnap/internal/logger"
+ "voicesnap/internal/textoutput"
+)
+
+// TextOutputService exposes the latest production output result and explicit
+// clipboard fallback actions. It never auto-pastes fallback text.
+type TextOutputService struct {
+ router *textoutput.Router
+}
+
+func NewTextOutputService(router *textoutput.Router) *TextOutputService {
+ return &TextOutputService{router: router}
+}
+
+func (s *TextOutputService) GetLastResult() textoutput.Result {
+ if s.router == nil {
+ return textoutput.Result{OK: false, Method: "text_output_router", Message: "text output router is unavailable"}
+ }
+ return s.router.LastResult()
+}
+
+func (s *TextOutputService) PrepareLastClipboardFallback() axinput.ClipboardResult {
+ if s.router == nil {
+ return axinput.ClipboardResult{OK: false, Message: "text output router is unavailable"}
+ }
+ logger.Info("Text output fallback prepare service called")
+ result := s.router.PrepareLastClipboardFallback()
+ logger.Info(
+ "Text output fallback prepare service returned ok=%t prepared=%t token_present=%t",
+ result.OK,
+ result.Prepared,
+ result.Token != "",
+ )
+ return result
+}
+
+func (s *TextOutputService) RestoreClipboardFallback(token string) axinput.ClipboardResult {
+ if s.router == nil {
+ return axinput.ClipboardResult{OK: false, Message: "text output router is unavailable"}
+ }
+ logger.Info("Text output fallback restore service called token_present=%t", token != "")
+ result := s.router.RestoreClipboardFallback(token)
+ logger.Info(
+ "Text output fallback restore service returned ok=%t restored=%t token_present=%t",
+ result.OK,
+ result.Restored,
+ token != "",
+ )
+ return result
+}
diff --git a/CODEGRAPH.md b/CODEGRAPH.md
index 5ddd46b..c28e224 100644
--- a/CODEGRAPH.md
+++ b/CODEGRAPH.md
@@ -26,14 +26,15 @@
## Core Entrypoints
-- `C1.source/privatevoice.src/main.go`: process startup, data migration, logger, single-instance guard.
+- `C1.source/privatevoice.src/main.go`: process startup, data migration, logger, single-instance guard. Unix single-instance locking starts from the existing temp lock path, but falls back to the app support directory when sandbox runtime denies temp lock access; a genuinely held temp lock remains authoritative and must not be bypassed.
- `C1.source/privatevoice.src/app.go`: Wails app wiring, settings window, tray, recorder, hotkey loop, ASR init, paste orchestration.
- `C1.source/privatevoice.src/embed.go`: embeds frontend assets and app/tray icons.
+- `C1.source/privatevoice.src/frontend/package.json` / `package-lock.json`: frontend dependency graph. `@wailsio/runtime` is pinned to `3.0.0-alpha.79`; App Store / release candidate preparation must not use `latest` or a lockfile / `node_modules` version drift.
## Language And Model Registry
- `C1.source/privatevoice.src/internal/model/profile.go`: model and language IDs, model profile shape, install status types.
-- `C1.source/privatevoice.src/internal/model/registry.go`: language profile order, model profiles, language profiles, and normalization.
+- `C1.source/privatevoice.src/internal/model/registry.go`: language profile order, model profiles, language profiles, and normalization. Runtime model download URLs are part of the App Store / privacy evidence chain and must remain HTTPS-only; `internal/model/model_test.go` guards this invariant.
- `C1.source/privatevoice.src/internal/modelselection/selection.go`: resolves current language and model, including manual-model fallback.
- Round 6 Cantonese path:
- Language ID: `yue-HK`, displayed as `粤语`.
@@ -43,16 +44,26 @@
## Platform Modules
-- Audio: `C1.source/privatevoice.src/internal/audio/recorder.go`
+- Audio: `C1.source/privatevoice.src/internal/audio/recorder.go`; recorder state is prepared under `Recorder.mu`, but `malgo.Device.Start()` runs after releasing that mutex so miniaudio capture callbacks can enter `onData()` and fill PCM during device startup. On macOS, `C1.source/privatevoice.src/internal/audio/microphone_darwin.go` preflights / requests microphone authorization before native device initialization; denied / restricted microphone state returns `audio.ErrMicrophonePermission` and does not enter `initDevice` / native `Start()`. `Start()` registers a cancellable start intent before microphone authorization, so `Stop()` / `StopAndGetSamples()` during a pending TCC request is preserved and prevents late-granted authorization from continuing into native init/start. A separate recorder lifecycle lock keeps `Close()` from releasing the native context while `Start()` is using it. If `Stop()` / `StopAndGetSamples()` is called while native `Start()` is still in flight, or after `Start()` has entered but before a device is published, it snapshots buffered PCM immediately, records the stop intent, and cleans up the initialized / started device when the startup path returns. Native device startup logs `Audio device initialized; starting native device`, emits a 2s pending diagnostic if `ma_device_start()` does not return, and logs `Native audio device start returned` before `Recording started`.
- 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`.
+ - macOS side-modifier diagnostics now cover both Option and Command keys. `R-⌘` / `L-⌘` polling logs include HID physical state, CombinedSession physical state, event-tap tracked state, recent fallback state, aggregate modifier flag state, event flags, tap startup / disabled counters and the selected source. Side-specific hotkeys only accept reliable side-specific sources (`physicalHID`, `physicalCombined`, `eventTap`, `recent`). `aggregateCommandFlag` is diagnostic-only because macOS aggregate Command flags cannot distinguish left vs right Command.
+ - Hold mode state machine lives in `C1.source/privatevoice.src/app.go`; hold pre-capture marks pending state and schedules a cancellable activation timer on initial key-down, while recorder startup runs outside the app mutex so slow audio device startup cannot delay `holdActivationDelay` confirmation. Physical modifier keys that emit no repeated key-down event still enter recording; release-before-confirm and combination-key detection cancel pending pre-capture. Normal release waits for startup-period PCM before `StopAndGetSamples` -> ASR -> text output when native recorder startup is still in flight; if PCM never arrives before the bounded wait, it stops without ASR and reports an audio-not-ready failure instead of a false `no_voice`. Escape / combination cancellation while startup is blocked remains cancel-only and must not enter ASR.
- 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.
+ - `C1.source/privatevoice.src/internal/config/config.go`: first generated config uses `hold`; default hotkey is build-scoped. Non-App-Store builds keep the R222 platform default (`0x5C` on macOS / Windows), Windows keeps `R-Win`, and `darwin && appstore` first generated config uses `HotkeyVK=0` to represent "trigger key not set". Existing config files load through a legacy-safe fallback so missing `HotkeyVK` does not inherit the R222 new default.
+ - `C1.source/privatevoice.src/internal/config/default_hotkey_appstore_darwin.go` and `default_hotkey_default.go`: build-tag seam for the AX / MAS unset-default policy.
- Text input:
- macOS: `C1.source/privatevoice.src/internal/input/paste_darwin.go`
- Windows: `C1.source/privatevoice.src/internal/input/paste_windows.go`
+ - AX mainline integration:
+ - `C1.source/privatevoice.src/internal/textoutput/`: output router after ASR post-processing and user dictionary application.
+ - Default / Developer ID / Windows builds use the legacy `internal/input.Paster` path through `router_default.go`, preserving paste then type fallback behavior.
+ - `darwin && appstore` builds use `internal/axinput` for Accessibility focused-element direct insert and treat `AXValue` replacement plus write-after-readback verification as the only success condition. If the system-wide focused element is unavailable, the driver retries through the system focused application and the frontmost application accessibility element before reporting `target=unknown`. Direct-insert failure diagnostics are logged only as generalized categories and AX numeric error codes, never transcript, target, method, message, window title, or document title.
+ - AX failures keep the recognized text in `textoutput.Result` and expose clipboard fallback through `services.TextOutputService`; fallback does not auto-paste by default. When `CopyToClipboard=true` and the failed AX target is safe for fallback, the appstore route writes the recognized text to the pasteboard for manual paste while still reporting direct output failure; known Terminal, iTerm, Warp, shell, console, or terminal-role targets remain blocked so fallback operations cannot modify known non-writable command-line windows. `target=unknown` can still expose explicit clipboard fallback because it only writes the pasteboard and does not modify the focused app. AX-10DT adds a default-off `AutoPasteExperiment` flag for `darwin && appstore`: when explicitly enabled, the app captures a frontmost app identity at recognition pipeline start, then the router re-reads the current frontmost identity before any pasteboard write; if the identity is unavailable, changed, or terminal/console-like, the automatic experiment is cancelled before touching the pasteboard. Safe automatic experiment writes verify the pasteboard before sending exactly one synthetic Cmd+V. When `CopyToClipboard=false`, the experiment uses `PrepareClipboardFallback` / `RestoreClipboardFallback` token semantics instead of a tokenless write, so changed-clipboard restore rules stay intact. This experiment is not the default direct-insert path and is tracked by `clipboard_auto_paste_experiment`, not `directInserted=true`. The macOS AX fallback driver declares `NSPasteboardTypeString` before writing and verifies pasteboard writes by reading back the requested string before returning success or a restore token; write / clear failures return a failed result and are logged only as generalized status, booleans, token presence, and text length.
+ - `C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte` and `C1.source/privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte` expose production fallback controls through `TextOutputFallbackPanel.svelte`: refresh latest output status, explicitly prepare clipboard fallback, and restore original clipboard without overwriting a user-changed clipboard. Fallback UI shows only generalized status and action messages, never transcript text, target, method, message, window title, or document title. `app.go` emits `text-output:updated` after each production output result so an already-mounted Settings page refreshes pending fallback state without auto-pasting or exposing transcript, target, method, message, window title, or document title text in the event payload or default production output logs. Frontend fallback controls key their prepared-token state by the backend result `createdAt` and poll latest status while mounted so a missed Wails event cannot leave the button bound to an old prepared token; each new output clears the old token / prepared state so the user can prepare the latest retained text, while restore / skip-restore messages remain visible for the same output result.
+ - Forbidden AX core paths: IMK / TIS / InputMethodKit, AppleEvents / Automation, Input Monitoring, Screen Recording, event taps, `CGEventPost`, automatic Cmd+V, system input-method installation, release/tag/manifest actions.
- ASR engine:
- macOS: `C1.source/privatevoice.src/internal/engine/engine_darwin.go`
- Windows: `C1.source/privatevoice.src/internal/engine/engine_windows.go`
@@ -60,6 +71,7 @@
- Overlay:
- macOS: `C1.source/privatevoice.src/internal/overlay/overlay_darwin.go`
- Windows: `C1.source/privatevoice.src/internal/overlay/overlay_windows.go`
+ - Native ready text is produced by `hotkeyReadyText` in `C1.source/privatevoice.src/app.go`; unset trigger keys (`HotkeyVK=0` / empty display name) must show `触发键未设置` instead of concatenating an empty key into hold/tap prompts.
## Windows Preview Scope
@@ -73,6 +85,10 @@
## macOS Package Scope
- Local DMG script: `C1.source/privatevoice.src/scripts/build-local-macos.sh`.
+- App Store local sandbox QA script: `C1.source/privatevoice.src/scripts/build-appstore-macos.sh`; the appstore-local bundle must remove the sherpa-onnx Go module cache `LC_RPATH` from the main executable so dyld resolves `@rpath/libsherpa-onnx-c-api.dylib` and `@rpath/libonnxruntime.*.dylib` from `Contents/Frameworks` before any external module-cache path. Because hardened runtime library validation rejects self-signed local dylibs without a real Team ID, this script prefers an available `Apple Development:` identity for appstore-local QA signing, with `PRIVATEVOICE_APPSTORE_LOCAL_SIGN_IDENTITY` / `PRIVATEVOICE_LOCAL_SIGN_IDENTITY` as explicit overrides. For local QA App refresh without DMG / ZIP / package output, run it with `PRIVATEVOICE_APPSTORE_LOCAL_APP_ONLY=1`.
+- App Store local sandbox QA and Mac App Store package scripts support candidate version overrides through `PRIVATEVOICE_APP_VERSION` and `PRIVATEVOICE_APP_BUILD`. The override is applied to both Go binary version metadata via `-ldflags -X main.appVersion/main.appBuild` and the assembled bundle `Info.plist` (`CFBundleShortVersionString` / `CFBundleVersion`), so MAS proof candidates can use `2.2.3` plus a fresh build without prematurely changing the default source `Info.plist`.
+- App Store local sandbox QA and Mac App Store package scripts require `C1.source/privatevoice.src/build/darwin/PrivacyInfo.xcprivacy` and copy it to `Contents/Resources/PrivacyInfo.xcprivacy`. The current manifest declares `NSPrivacyAccessedAPICategoryFileTimestamp` / `C617.1` for app-owned and user-selected file metadata checks used by model, log, history, diagnostics, updater, path, and startup workflows; do not add required-reason categories without source or bundle evidence.
+- Mac App Store package script: `C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh`; before signing it resolves the requested app signing identity to a concrete SHA1 leaf fingerprint and verifies that fingerprint is present in the embedded provisioning profile `DeveloperCertificates`. This prevents building ASC candidates whose app executable or nested dylibs are signed by a same-named but profile-mismatched certificate.
- Universal DMG script: `C1.source/privatevoice.src/scripts/build-local-macos-universal.sh`.
- Monterey compatibility script: `C1.source/privatevoice.src/scripts/build-local-macos-monterey.sh`.
- Developer ID / notarization script: `C1.source/privatevoice.src/scripts/build-release-macos.sh`.
@@ -84,8 +100,11 @@
Run from `C1.source/privatevoice.src`:
```bash
+npm --prefix frontend ls --omit=dev --all
npm --prefix frontend run build
go test ./...
+go test -tags appstore ./internal/textoutput ./services
+./scripts/verify-ax-output-boundary.sh
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 ./...
./scripts/build-windows-preview.sh
./scripts/build-local-macos.sh arm64
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10A-\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/AX-10A-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..5cd5810
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10A-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,123 @@
+# AX-10A 开发自查
+
+任务 ID:`AX-10A-CODER-20260706.1556`
+
+时间:2026-07-06 16:03 +0800
+
+结论:`PM_NOTIFY_READY AX-10A`
+
+建议 Review Gate ID:`AX-10B-GATE-20260706.1556`
+
+## 目标与边界
+
+本轮目标是在 AX 主线集成 worktree 内迁移 P2 已验证的最小生产化核心:真实 ASR 后处理文本进入 AX output router;`darwin && appstore` 路径优先 Accessibility direct insert;失败时保留文本并提供显式 clipboard fallback。
+
+本轮不代表代码审核 PASS、QA PASS、MAS build PASS、TestFlight / App Store Go、发布、tag、manifest 或 codebase-memory 刷新。
+
+## 工作区
+
+- Git 根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- 分支:`feature/mas-accessibility-direct-input`
+- 起点 commit:`ac1d4fd8ef875312d11acd4e4e5935ced542c227`
+- P2 只读参考:`/Users/ar/Projects/PrivateVoice/P2-mas-accessibility-spike/03-O/C1.source/privatevoice.src`
+
+## 变更范围
+
+新增 / 修改:
+
+- `C1.source/privatevoice.src/internal/axinput/`
+ - macOS AX focused element direct insert driver。
+ - direct insert 成功条件为 `AXValue + AXSelectedTextRange` 写入后回读确认。
+ - clipboard fallback token、prepare、restore、用户中途改剪贴板后 skip restore 语义。
+ - 非 macOS no-op driver。
+- `C1.source/privatevoice.src/internal/textoutput/`
+ - 默认 router:继续调用既有 `internal/input.Paster.Paste`,失败后调用既有 `TypeText`,保持 Developer ID / Windows / 默认构建路径不变。
+ - `darwin && appstore` router:只走 AX direct insert;失败保留文本,标记 `FallbackAvailable=true`,不自动调用 legacy paste/type,不自动写剪贴板。
+ - 默认路径与 appstore 路径测试。
+- `C1.source/privatevoice.src/services/text_output_service.go`
+ - 暴露 last output result、显式 prepare fallback、显式 restore fallback。
+ - service 不自动粘贴 fallback 文本。
+- `C1.source/privatevoice.src/app.go`
+ - 初始化 `textoutput.Router`。
+ - 在 `textproc.PostProcess`、用户词典、history 写入、热键释放等待之后调用 router。
+ - 输出失败不误报成功;fallback 可用时浮窗显示 `文本已保留`。
+- `C1.source/privatevoice.src/scripts/verify-ax-output-boundary.sh`
+ - 新增 AX output 主线边界扫描。
+- `CODEGRAPH.md`
+ - 记录 AX output router、appstore build tag 路径、fallback 边界和标准验证命令。
+
+明确未迁移 / 未引入:
+
+- 未迁移 P2 `AXPOCService`。
+- 未迁移固定文本 `PrivateVoice AX POC`。
+- 未迁移权限页 POC 面板、`permissions.axPoc.*` 文案或 QA-only 控件。
+- 未引入 IMK / TIS / InputMethodKit。
+- 未引入 AppleEvents / Automation、Input Monitoring、Screen Recording、event tap、`CGEventPost`、自动 Cmd+V、Swift helper、XPC、登录项。
+- 未修改根主工作区 `/Users/ar/Projects/PrivateVoice/03-O`。
+
+## 实现策略
+
+主线输出链路现在统一经过 `internal/textoutput.Router`。
+
+默认构建、Developer ID 和 Windows 仍由 `router_default.go` 调用既有 `Paste(text, copyToClipboard)`;paste 失败时仍尝试既有 `TypeText(text)`。因此默认路径行为保持与迁移前等价。
+
+`darwin && appstore` 构建由 `router_appstore_darwin.go` 调用 `internal/axinput.Driver.DirectInsert`。AX driver 读取 focused element 的 `AXValue` 与 `AXSelectedTextRange`,计算替换后的完整 value,写回 `AXValue`,再回读验证完整 value 与预期一致。只有验证通过才返回 direct insert 成功。
+
+direct insert 失败时,router 记录真实 ASR 文本、method、target、message、permission 状态,并仅通过 `TextOutputService.PrepareLastClipboardFallback` 暴露用户显式 fallback。prepare 后只把本次文本放入剪贴板,不自动粘贴;restore 时仅当当前剪贴板仍是 fallback 文本才恢复旧值,否则跳过恢复以保护用户中途改动。
+
+## 验证命令与结果
+
+- `gofmt -w app.go internal/axinput/axinput.go internal/axinput/axinput_darwin.go internal/axinput/axinput_other.go internal/axinput/clipboard_state.go internal/axinput/clipboard_state_test.go internal/textoutput/router.go internal/textoutput/router_default.go internal/textoutput/router_test.go internal/textoutput/router_appstore_darwin.go internal/textoutput/router_appstore_darwin_test.go services/text_output_service.go`
+ - PASS。
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`
+ - PASS。
+- `./scripts/verify-ax-output-boundary.sh`
+ - PASS:未发现 IMK/TIS、event injection、global monitor、Input Monitoring、Automation、系统输入法安装或发布动作进入 AX output 文件。
+- `go test ./internal/axinput ./internal/textoutput ./services`
+ - PASS;保留既有 macOS SDK / onnxruntime 链接 warning。
+- `go test -tags appstore ./internal/textoutput ./services`
+ - PASS;保留既有 macOS SDK / onnxruntime 链接 warning。
+- `npm --prefix frontend install`
+ - PASS;原因:新 AX worktree 缺 `frontend/node_modules`,后续 build 需要按锁文件安装依赖。`node_modules` 未进入 Git diff。
+- `npm --prefix frontend run build`
+ - PASS;存在既有 Svelte a11y warning:`UpdateDialog.svelte` dialog tabindex / noninteractive listener、`ToggleSwitch.svelte` aria label。
+- `go test ./...`
+ - 首次并行执行时因 `frontend/dist` 尚未生成失败:`embed.go:8:12: pattern frontend/dist: no matching files found`。
+ - 在前端 build 成功后顺序重跑 PASS;保留既有 macOS SDK / onnxruntime 链接 warning。
+
+## Git 待审范围
+
+已用 `git add -N` 将新增文件纳入待审 diff。`git status --short --branch` 显示:
+
+- `M C1.source/privatevoice.src/app.go`
+- `A C1.source/privatevoice.src/internal/axinput/axinput.go`
+- `A C1.source/privatevoice.src/internal/axinput/axinput_darwin.go`
+- `A C1.source/privatevoice.src/internal/axinput/axinput_other.go`
+- `A C1.source/privatevoice.src/internal/axinput/clipboard_state.go`
+- `A C1.source/privatevoice.src/internal/axinput/clipboard_state_test.go`
+- `A C1.source/privatevoice.src/internal/textoutput/router.go`
+- `A C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin.go`
+- `A C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin_test.go`
+- `A C1.source/privatevoice.src/internal/textoutput/router_default.go`
+- `A C1.source/privatevoice.src/internal/textoutput/router_test.go`
+- `A C1.source/privatevoice.src/scripts/verify-ax-output-boundary.sh`
+- `A C1.source/privatevoice.src/services/text_output_service.go`
+- `M CODEGRAPH.md`
+
+Ignored local artifacts:
+
+- `C1.source/privatevoice.src/frontend/dist/`
+- `C1.source/privatevoice.src/frontend/node_modules/`
+
+## 未覆盖与风险
+
+- 本轮未做真实 GUI QA,不声明 TextEdit / Safari / Notes / Terminal 行为 PASS;交由 `AX-10C`。
+- Accessibility 未授权 / 拒绝 / 撤销 / 重新授权仍需 `AX-QA-PERMISSION`。
+- MAS sandbox build、签名、entitlements、dylib Team ID 和启动门禁仍需 `AX-MAS-BUILD-GATE`。
+- 本轮未新增生产 UI;仅提供后端 result / fallback service。正式用户入口、文案和权限页体验仍需后续产品 / 设计 / QA gate。
+- 既有 `Recognized: %s` 全文日志未在本轮改动范围内处理;如产品化隐私口径要求脱敏,应另立任务或由 AX-10B 指出。
+
+## 下一步建议
+
+请求 PMO 派发 `AX-10B` 代码审核,按 Review Gate ID `AX-10B-GATE-20260706.1556` 审核本轮主线最小实现切片。代码审核 PASS 前不得进入 QA;QA PASS 前不得声明主线合并完成;MAS build gate / 权限 gate PASS 前不得声明 TestFlight、App Store 或发布 Go。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10AB-\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/AX-10AB-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..a2ce843
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10AB-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,79 @@
+# AX-10AB 开发自查
+
+任务:`AX-10AB-CODER-20260707.2036`
+
+Review Gate 建议:`AX-10AC-GATE-20260707.2036`
+
+## 结论
+
+`PM_NOTIFY_READY AX-10AB`
+
+已在 AX mainline worktree 内完成 macOS Command 侧热键诊断 / 捕获链路补修,可派代码审核。未做主线合并、未做 MAS build、未发布、未 push、未 tag、未生成 manifest、未刷新 codebase-memory,未要求 Owner 继续物理 retry。
+
+## 起点与证据
+
+- 工作区:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- 分支:`feature/mas-accessibility-direct-input`
+- 阻塞证据:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10AA-物理右Command验证-evidence.md`
+- QA 现象:App 已有 `Accessibility permission granted`、`Keyboard event tap started`、`ASR engine ready`,但 Owner 两次真实右 Command hold 后 App 无新增 `Hotkey accepted` / `Hotkey released` / `Recording started` / `Recognized` / `text_output_done`。
+
+## 根因判断
+
+当前代码对 `flagsChanged` 与 macOS keycode `54/55` 的基础映射存在,但 `internal/hotkey/hotkey_darwin.go` 的专用诊断只覆盖左右 Option,未覆盖左右 Command。AX-10AA 失败时,App 内日志只能看到上层 `Hotkey poll state down=false`,不能区分:
+
+- HID physical key state 是否看到 `R-⌘ / L-⌘`
+- CombinedSession physical key state 是否看到 `R-⌘ / L-⌘`
+- event tap tracked state 是否更新
+- recent fallback 是否命中
+- aggregate Command flag 是否为 true
+- event tap 是否 disabled / launching / retry
+
+此外,右 Command 原逻辑只检查 HID physical、event tap tracked、recent fallback;当 macOS 在 App 进程里只暴露 CombinedSession 或 aggregate Command flag 时,状态会停在 false,无法进入 App 热键链路。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/internal/hotkey/hotkey_darwin.go`
+ - 将 Option-only 诊断数组扩展为 Option + Command:`L-Option`、`R-Option`、`L-Command`、`R-Command`。
+ - `Hotkey event tap` / `Hotkey poll` 日志新增:`source`、`physicalHID`、`physicalCombined`、`trackedDown`、`recentDown`、`aggregateFlagDown`、`flags`、`tapStarted`、`launching`、`attempts`、`failures`、`disabled`。
+ - side modifier polling 增加 CombinedSession physical key state 检查。
+ - Command 保持 side-specific source 优先;仅当 HID / Combined / event tap tracked / recent 都未命中、但系统 aggregate Command flag 为 true 时,使用最后兜底 `aggregateCommandFlag`。
+ - 将 Command 左右键加入 modifier group,避免右 Command hold 期间同组 Command 状态被误判为组合键来源。
+- `C1.source/privatevoice.src/internal/hotkey/hotkey_darwin_test.go`
+ - 新增 Darwin focused tests,覆盖 Command 诊断索引、source 文案和左右 Command modifier group。
+- `CODEGRAPH.md`
+ - 记录 macOS side-modifier 诊断链路与 `aggregateCommandFlag` 风险。
+- `K2.项目管理/AX-10AB-开发自查.md`
+ - 本自查。
+
+## 安全边界
+
+- 未修改 AX-10Y1 microphone TCC pending stop intent、late granted 不继续 native start、Escape / combination cancel 不进 ASR、空 PCM 不进 ASR等 recorder / App 状态机逻辑。
+- 未修改 AX output router / fallback UI / MAS build gate / TCC gate。
+- 未引入 IMK / TIS / InputMethodKit、event injection、Input Monitoring、Automation、系统输入法安装或 release action。
+- `aggregateCommandFlag` 是最后兜底,不能区分左 / 右 Command;后续代码审核与 QA 必须重点验证左 Command 负向。如负向失败,应由 PMO / 产品 / QA 决定是否接受该兜底或关闭为诊断-only。
+
+## 验证命令
+
+均在 `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src` 执行,除 `git diff --check` 使用 Git 根。
+
+- `gofmt -w internal/hotkey/hotkey_darwin.go internal/hotkey/hotkey_darwin_test.go`:PASS
+- `go test ./internal/hotkey`:PASS
+- `go test -race -run 'TestHoldPreCapture' .`:PASS,保留现有 macOS SDK / onnxruntime link warning
+- `go test -tags appstore ./internal/audio ./internal/axinput ./internal/textoutput ./services`:PASS,保留现有 macOS SDK / onnxruntime link warning
+- `./scripts/verify-ax-output-boundary.sh`:PASS
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`:PASS
+- `go test -race ./internal/audio`:PASS
+- `go test ./...`:PASS,保留现有 macOS SDK / onnxruntime link warning
+
+未运行 `npm --prefix frontend run build`:本轮未修改前端;现有前端 diff 属 AX-10D / AX-10D1 前置待审范围,未在 AX-10AB 扩改。
+
+## 待代码审核关注
+
+1. `aggregateCommandFlag` 是否可作为当前唯一低副作用兜底;它能解决 App 进程只见 aggregate Command flag 的场景,但不能区分左右 Command。
+2. 新增日志字段是否足够支撑下一轮 Owner 物理 retry:下一轮 App log 应至少能显示 `R-⌘` 的 `source`、HID / Combined physical、event tap tracked、recent、aggregate flag 和 event tap disabled count。
+3. 确认未回退 AX-10Y1 及 AX-10T / AX-10W 已通过的 recorder safety 与 AX output boundary。
+
+## 下一步
+
+建议 PMO 派 `AX-10AC` 代码审核。代码审核 PASS 后,再安排 Owner 物理右 Command QA retry,并要求同时观察左 Command 负向与 App 内新增 `Hotkey poll` / `Hotkey event tap` 诊断字段。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10AE-\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/AX-10AE-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..de44433
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10AE-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,81 @@
+# AX-10AE 开发自查
+
+任务:`AX-10AE-CODER-20260707.2110`
+
+Review Gate 建议:`AX-10AF-GATE-20260707.2110`
+
+## 结论
+
+`PM_NOTIFY_READY AX-10AE`
+
+已在 AX mainline worktree 内完成 side-specific Command 判定补修,可派代码审核。未发布、未主线合并、未 push、未 tag、未生成 manifest、未刷新 codebase-memory,未做 MAS build,未要求 Owner 继续物理 retry。
+
+## 起点与证据
+
+- 工作区:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- 分支:`feature/mas-accessibility-direct-input`
+- QA 失败证据:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10AD-物理右Command验证-evidence.md`
+- 代码审核证据:`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10AC-command-hotkey-diagnostics代码审核.md`
+- 前置自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10AB-开发自查.md`
+
+## 根因
+
+AX-10AB 为恢复右 Command 触发链路,将 `aggregateCommandFlag` 作为最后兜底 source 纳入 `IsKeyDown()` 的成功命中源。AX-10AD 物理 QA 证明该兜底不能区分左右 Command:配置为 `R-⌘` / `0x5C` 时,Owner 按住左 Command 也会产生 aggregate Command flag,并被 App 当作右 Command 接受,进而启动录音 / ASR / text output 尝试。
+
+因此 `aggregateCommandFlag` 只能保留为诊断字段,不能作为 side-specific `R-⌘` / `L-⌘` 的成功命中源。若未来产品希望降级为“任意 Command”,必须由 PMO / 产品另行决策,不能在 Coder 修复中默默改变默认右 Command 语义。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/internal/hotkey/hotkey_darwin.go`
+ - `IsKeyDown()` 对 side modifier 改为先读取 `sideModifierDownSource()`,再经 `sideModifierSourceAccepted()` 判定是否接受。
+ - `sideModifierSourceAccepted()` 只接受可靠侧向来源:`physicalHID`、`physicalCombined`、`eventTap`、`recent`。
+ - `aggregateCommandFlag` 继续出现在 `Hotkey poll` / `Hotkey event tap` 诊断中,但 `down=false`,不会触发 `Hotkey accepted`。
+ - C 侧旧 `isSideModifierDown()` 同步拒绝 source 5,避免后续误用重新引入该风险。
+- `C1.source/privatevoice.src/internal/hotkey/hotkey_darwin_test.go`
+ - 新增 `TestDarwinSideModifierSourceAcceptedRejectsAggregateCommandFlag`,覆盖 source 5 为 diagnostic-only。
+ - 保留 Command 诊断索引、source 文案和左右 Command modifier group 测试。
+- `CODEGRAPH.md`
+ - 更新热键说明:side-specific hotkey 只接受可靠侧向来源,`aggregateCommandFlag` 是诊断-only。
+- `K2.项目管理/AX-10AE-开发自查.md`
+ - 本自查。
+
+## 行为变化
+
+- 配置为右 Command 时:
+ - 左 Command 只有 aggregate Command flag:不触发录音 / ASR / text output。
+ - 右 Command 若只有 aggregate Command flag、没有 HID / Combined / event tap / recent 任何侧向证据:也不触发录音,只留下 `source=aggregateCommandFlag aggregateFlagDown=true down=false` 诊断。
+ - 右 Command 若出现任一可靠侧向 source:仍可触发。
+- 配置为左 Command 时同理,不允许右 Command 通过 aggregate flag 冒充左 Command。
+
+## 安全边界
+
+- 未修改 AX-10Y1 / AX-10T / AX-10W recorder safety:Microphone TCC pending release / cancel stop intent、安全 Stop、Escape / combination cancel 不进 ASR、空 PCM 不进 ASR、`audio_not_ready` 语义均未改。
+- 未修改 AX output router / clipboard fallback UI / MAS build gate / TCC gate。
+- 未引入 IMK / TIS / InputMethodKit、event injection、global monitor、Input Monitoring、Automation、系统输入法安装或 release action。
+
+## 验证命令
+
+除 `git diff --check` 外,均在 `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src` 执行。
+
+- `gofmt -w internal/hotkey/hotkey_darwin.go internal/hotkey/hotkey_darwin_test.go`:PASS
+- `go test ./internal/hotkey`:PASS
+- `go test -race -run 'TestHoldPreCapture' .`:PASS,保留既有 macOS SDK / onnxruntime link warning
+- `go test -tags appstore ./internal/audio ./internal/axinput ./internal/textoutput ./services`:PASS,保留既有 macOS SDK / onnxruntime link warning
+- `./scripts/verify-ax-output-boundary.sh`:PASS
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`:PASS
+- `go test ./...`:PASS,保留既有 macOS SDK / onnxruntime link warning
+- `go test -race ./internal/audio`:PASS
+
+未运行 `npm --prefix frontend run build`:本轮未修改前端。
+
+## 待代码审核关注
+
+1. 确认 `aggregateCommandFlag` 已从 side-specific 成功命中源中移除,只保留诊断。
+2. 确认新增测试覆盖 source 5 拒绝语义。
+3. 确认未回退 AX-10Y1 / AX-10T / AX-10W safety 与 AX output boundary。
+4. 后续 QA 若发现右 Command 只能以 aggregate flag 出现,应判定为“侧向右 Command 无可靠来源”,而不是通过 Coder 降级为任意 Command。
+
+## 下一步
+
+建议 PMO 派 `AX-10AF` 代码审核。代码审核 PASS 后,再安排 Owner 物理 retry;retry 必须覆盖右 Command 正向、左 Command 负向,并检查 App log 中 `aggregateCommandFlag` 是否只作为诊断出现、不会触发 `Hotkey accepted`。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10AK-\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/AX-10AK-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..725245e
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10AK-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,84 @@
+# AX-10AK 开发自查
+
+任务:AX-10AK-CODER-20260707
+分支:`feature/mas-accessibility-direct-input`
+工作区:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+
+## 目标与结论
+
+已按 AX-10AI / AX-10AJ / AX-10AH 口径完成最小切片:`darwin && appstore` 首次生成配置不再默认写入 `0x5C` / `R-⌘`,而是以 `HotkeyVK=0` 表达“触发键未设置”;`HotkeyMode` 仍默认 `hold`。Windows 与非 App Store 默认路径保持 R222 口径,Windows 仍默认 / 显示右 Windows。
+
+终态建议:`PM_NOTIFY_READY AX-10AK`,可进入下一棒代码审核。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/internal/config/`
+ - 新增 `UnsetHotkeyVK=0`。
+ - 新增 build-tag seam:`default_hotkey_appstore_darwin.go` / `default_hotkey_default.go`。
+ - `darwin && appstore` 新配置默认热键为 unset;非 App Store 与 Windows 默认不变。
+ - 既有配置仍通过 legacy-safe fallback 保护,旧缺字段配置不会静默继承 `R-⌘`。
+- `C1.source/privatevoice.src/internal/hotkey/hotkey.go`
+ - `HotkeyVK=0` 返回空显示名,由前端本地化为“触发键未设置”。
+- `C1.source/privatevoice.src/services/hotkey_service.go`
+ - 当前 / 指定热键为 unset 时返回空热键名。
+- `C1.source/privatevoice.src/app.go`
+ - `HotkeyVK=0` 时 hotkey poll 直接保持未激活,不调用物理按键判定,不启动录音。
+- `frontend/src/App.svelte`、`frontend/src/components/settings/GeneralPage.svelte`、`HotkeysPage.svelte`、`FloatingIndicator.svelte`
+ - 后端返回空热键名时显示未设置状态。
+ - 设置页按钮从“修改”切换为“设置”。
+ - 浮动指示器 ready 态在未设置时显示“触发键未设置”,不再拼出“按住 说话”。
+- `frontend/src/lib/stores/config.ts`、`frontend/src/lib/stores/indicator.ts`
+ - 前端初始热键状态改为 unset;Windows 前端初始展示仍保留 `R-Win`。
+- `frontend/src/lib/i18n/zh.json`、`en.json`
+ - 增加未设置文案。
+ - 将右 Command 描述收敛为实验选项,不再作为默认 / 推荐口径。
+- `CODEGRAPH.md`
+ - 记录 AX / MAS unset 默认与 build tag 边界。
+
+## 测试覆盖
+
+- `internal/config`
+ - 默认热键策略按 build tag 分流。
+ - `darwin && appstore` 新配置为 unset,mode 为 hold。
+ - Windows 仍为右侧修饰键默认。
+ - 已有配置 / 缺字段配置保护既有测试保留。
+- `services`
+ - unset 热键返回空名称,前端可稳定显示未设置状态。
+- `app.go`
+ - `TestPollHotkeyIgnoresUnsetHotkey` 覆盖 unset 时不会调用物理热键轮询、不会进入 active / recording。
+- `internal/hotkey`
+ - unset VK 显示为空;右 Command / 右 Win 显示映射原有测试保留。
+
+## 已执行门禁
+
+- `gofmt -w <changed-go-files>`:PASS。
+- `go test ./internal/config ./internal/hotkey ./services`:PASS;存在既有 macOS 链接版本 warning。
+- `go test -run 'TestPollHotkeyIgnoresUnsetHotkey|TestHoldPreCapture' .`:PASS;存在既有 macOS 链接版本 warning。
+- `go test -tags appstore ./internal/config ./internal/audio ./internal/axinput ./internal/textoutput ./services`:PASS;存在既有 macOS 链接版本 warning。
+- `./scripts/verify-ax-output-boundary.sh`:PASS。
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`:PASS。
+- `npm --prefix frontend run build`:PASS;保留既有 Svelte a11y warning,未新增阻塞。
+- `go test -race -run 'TestPollHotkeyIgnoresUnsetHotkey|TestHoldPreCapture' .`:PASS;存在既有 macOS 链接版本 warning。
+- `go test ./...`:PASS;存在既有 macOS 链接版本 warning。
+
+## 未执行项
+
+- 未执行 MAS build、TestFlight / App Store 上传、发布、tag、manifest、codebase-memory 刷新。
+- 未安排 Owner 继续 right Command 物理 retry;AX-10AH 已判定该路线不作为默认承诺。
+- 未修改 AX direct insert、clipboard fallback、ASR、录音状态机、权限策略或发布文案。
+
+## 风险与审核关注
+
+- `HotkeyVK=0` 是本轮稳定表达 unset 的哨兵值;审核需确认现有配置 JSON / 前端 / app poll 均按 unset 处理,不会误触发真实按键。
+- `darwin && appstore` build tag 仅影响 macOS App Store / AX 路线的新配置默认;非 App Store macOS 与 Windows R222 默认不变。
+- 既有 AX 主线 worktree 包含 AX-10A 至 AX-10Y1 前置待审 diff,本轮没有回退这些改动;本轮新增文件已用 `git add -N` 纳入待审范围。
+
+## 下一步建议
+
+建议 PMO 派发 AX-10AL / 同等代码审核任务,重点审查:
+
+- build tag 默认热键分流是否准确。
+- unset 状态是否贯穿配置、服务、App poll、前端显示。
+- Windows R222 右 Win 默认是否未被回滚。
+- right Command 是否仍只是实验 / 高级口径,而非默认或 QA PASS 前提。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10AO-\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/AX-10AO-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..cdcf126
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10AO-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,51 @@
+# AX-10AO 开发自查
+
+任务:AX-10AO-CODER-20260708.0315
+分支:`feature/mas-accessibility-direct-input`
+工作区:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+Review Gate ID:`AX-10AO-GATE-20260708.0315`
+
+## 背景与根因
+
+`AX-10AN` UI smoke 发现:在首页热键设置等待状态按 `Esc` 后,前端把 `Escape` 经 `mapKeyToVK()` 映射为 `0x1B`,并进入通用 `vk > 0` 保存路径,调用 `HotkeyService.SetHotkey(0x1B)`,导致配置被写成 `HotkeyVK=27`。后续 App 将 `Esc` 当作真实触发键接受,并进入录音 / ASR 链路。
+
+根因在两个前端入口的捕获逻辑一致缺少取消分支:
+
+- `frontend/src/components/settings/GeneralPage.svelte`
+- `frontend/src/components/settings/HotkeysPage.svelte`
+
+## 改动范围
+
+- `GeneralPage.svelte`
+ - 在 `keydown` 捕获处理内优先拦截 `Escape` / `Esc`。
+ - 取消时设置现有 `status.cancelled` 文案,调用 `clearHotkeyCapture()`。
+ - 不进入 `mapKeyToVK()` 保存路径,不更新 `hotkeyVK` / `hotkeyName`,不调用 `SetHotkey(0x1B)`。
+- `HotkeysPage.svelte`
+ - 做同样修复,保证两个热键设置入口行为一致。
+
+本轮未修改 Go、AX direct insert、clipboard fallback、right Command side-specific、recorder/audio、MAS build、发布脚本、tag、manifest 或 codebase-memory。
+
+## 行为确认
+
+- `Esc` 在“正在设置热键”状态下只作为取消动作。
+- 取消后保留原 `currentKeyName`,或继续显示未设置状态。
+- `clearHotkeyCapture()` 会移除页面级 keydown listener;在 `isRecording=true` 时调用 `HotkeyService.StopRecordingHotkey`,避免离开页面或取消后留下后端捕获状态。
+- 因未保存 `0x1B`,本次取消动作不会让后续 `Esc` 成为 App 热键,也不会触发录音 / ASR / text output。
+
+## 验证
+
+- `npm --prefix frontend run build`:PASS。
+ - 保留既有 Svelte a11y warning:`UpdateDialog.svelte` dialog tabindex / div listener、`ToggleSwitch.svelte` button label;非本轮新增。
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`:PASS。
+
+## 未执行项
+
+- 未运行前端单元测试:当前 `frontend/package.json` 只有 `dev` / `build` / `preview`,无测试脚本或现成前端测试框架。
+- 未运行 Go 测试 / `gofmt`:本轮未触碰 Go 文件。
+- 未执行 MAS build、TestFlight / App Store 上传、发布、tag、manifest 或 codebase-memory 刷新。
+
+## 风险与下一步
+
+- 建议代码审核重点看两个入口是否都已拦截 `Escape`,以及取消路径是否不会调用 `SetHotkey(0x1B)`。
+- 代码审核 PASS 后,建议 QA 复测:General 页和 Hotkeys 页设置等待状态按 `Esc`,确认恢复原热键或未设置状态;重复打开页面无残留捕获;取消后日志不出现 `Hotkey accepted: key=Esc`。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10AT-\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/AX-10AT-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..4da2f55
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10AT-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,53 @@
+# AX-10AT 开发自查
+
+任务:AX-10AT-CODER-20260708.0357
+分支:`feature/mas-accessibility-direct-input`
+工作区:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+
+## 背景与根因
+
+`AX-10AS` QA 在 appstore-tagged 本地 QA App 中复现:`HotkeyVK=0` / `HotkeyMode=hold` 时,Floating Indicator ready 态显示 `按住 说话`,中间触发键为空。日志负向通过,未设置状态没有触发录音 / ASR / text output。
+
+根因:真实 ready 态走原生 overlay 路径,`app.go` 在引擎 ready 后调用 `hotkeyReadyText(keyName, hotkeyMode)`;`HotkeyVK=0` 经 `hotkey.GetKeyName(0)` 返回空字符串,而 `hotkeyReadyText` 直接拼接 `按住` / `点按` + `keyName` + `说话`,导致空白拼接。前端 `FloatingIndicator.svelte` 的空 key fallback 不覆盖该原生 overlay 路径。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/app.go`
+ - `hotkeyReadyText` 在 `keyName == ""` 时返回 `触发键未设置`。
+ - 已设置热键时 hold / tap 原文案保持不变。
+- `C1.source/privatevoice.src/app_hotkey_ready_text_test.go`
+ - 新增 ready 文案回归测试。
+ - 覆盖 unset + hold、unset + tap、unset + fallback mode。
+ - 覆盖已设置热键 hold / tap / fallback mode 原文案不回退。
+- `CODEGRAPH.md`
+ - 记录原生 overlay ready 文案职责与 unset 文案约束。
+
+本轮未修改前端、right Command side-specific、AX direct insert、剪贴板 fallback、ASR、录音状态机、权限策略、MAS build、发布脚本、tag、manifest 或 codebase-memory。
+
+## 红绿验证
+
+- 先新增 `TestHotkeyReadyTextUnsetKey` 后运行:
+ - `go test -run 'TestHotkeyReadyText' .`
+ - 结果:FAIL,复现当前 `hotkeyReadyText("", "hold") = "按住说话"`、tap 为 `点按说话`。
+- 修复 `hotkeyReadyText` 后复跑同一命令:
+ - 结果:PASS。
+
+## 已执行门禁
+
+- `gofmt -w app.go app_hotkey_ready_text_test.go`:PASS。
+- `go test -run 'TestHotkeyReadyText' .`:PASS;保留既有 macOS 链接版本 warning。
+- `go test -tags appstore -run 'TestHotkeyReadyText' .`:PASS;保留既有 macOS 链接版本 warning。
+- `go test ./...`:PASS;保留既有 macOS 链接版本 warning。
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`:PASS。
+
+## 未执行项
+
+- 未运行 `npm --prefix frontend run build`:本轮未改前端。
+- 未执行 MAS build、TestFlight / App Store 上传、发布、tag、manifest 或 codebase-memory 刷新。
+- 未做真实 UI QA;需代码审核 PASS 后由 PMO 派 QA retry。
+
+## 风险与下一步
+
+- 风险:本轮只修原生 overlay ready 文案,不声明 QA PASS;仍需 QA 重新运行 appstore-tagged 本地 App 并截图确认 ready 态显示 `触发键未设置`。
+- 建议下一步:PMO 派代码审核,重点审查 `hotkeyReadyText` unset 分支、hold/tap 既有文案不回退、未设置状态负向链路未被改变。审核 PASS 后派 AX-10AS/AX-10AU QA retry。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BA-\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/AX-10BA-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..a9bc5fc
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BA-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,120 @@
+# AX-10BA 开发自查
+
+任务 ID:`AX-10BA`
+派发记录:`AX-10BA-CODER-20260708.1252`
+角色:Coder
+时间:2026-07-08 13:34 +0800
+结论:`PM_NOTIFY_READY AX-10BA`
+
+## 目标与边界
+
+目标:整理 `AX-mainline-worktree/03-O` 的 AX 主线 merge candidate diff,使其可进入 aggregate code review。
+
+本轮不做:
+
+- 不合并到主线 `main`。
+- 不 push。
+- 不生成 MAS build、Developer ID 包、DMG、ZIP、archive 或发布候选。
+- 不生成 tag、manifest、SHA256。
+- 不刷新 codebase-memory。
+- 不修改主工作区 `/Users/ar/Projects/PrivateVoice/03-O` 的源码。
+
+## 起点核验
+
+- 根台账:`/Users/ar/Projects/PrivateVoice/02-P/进度地图.md`
+- PMO 收口:`/Users/ar/Projects/PrivateVoice/02-P/当前阶段/AX-10AW-主线集成收口与下一阶段决策.md`
+- 架构复核:`/Users/ar/Projects/PrivateVoice/02-P/架构/AX-10AX-主线合并准备架构复核.md`
+- 产品口径:`/Users/ar/Projects/PrivateVoice/02-P/产品/AX-10AY-渠道与产品口径.md`
+- 发布门禁规划:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10AZ-发布准备与版本门禁.md`
+
+只读核验:
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/03-O status --short --branch
+git -C /Users/ar/Projects/PrivateVoice/03-O rev-parse HEAD
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O status --short --branch
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O rev-parse HEAD
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O merge-base HEAD main
+```
+
+结果摘要:
+
+- 主工作区分支:`main`
+- 主工作区 HEAD:`2cf15d05f2f18e6b0851f9e6cfd1a5bf9dd21887`
+- AX worktree 分支:`feature/mas-accessibility-direct-input`
+- AX worktree HEAD / merge-base:`ac1d4fd8ef875312d11acd4e4e5935ced542c227`
+- 主工作区存在未跟踪发布规划文档 `K3.运营与发布资料/AX-10AZ-发布准备与版本门禁.md`;本轮未触碰主工作区,且 merge candidate 以 `main` HEAD 为基线,不把该未跟踪文件纳入。
+
+## 处理项
+
+AX-10AX 指出的基线漂移已处理:
+
+- 漂移文件:`K2.项目管理/BR-AX-MAINLINE-分支创建记录.md`
+- 问题:AX worktree 基于 `ac1d4fd8`,当前 `main@2cf15d0` 新增了该文件;AX merge candidate 相对 `main` 曾显示该文件被删除。
+- 处理:在 AX worktree 同路径补入 current `main` 内容,并执行 `git add -N` 纳入待审范围,避免后续 aggregate diff / 提交范围漏掉该保留文件。
+
+核验证据:
+
+```bash
+cmp -s /Users/ar/Projects/PrivateVoice/03-O/K2.项目管理/BR-AX-MAINLINE-分支创建记录.md /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/BR-AX-MAINLINE-分支创建记录.md; echo $?
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --name-status main -- 'K2.项目管理/BR-AX-MAINLINE-分支创建记录.md'
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --name-status main | rg '^D'
+```
+
+结果摘要:
+
+- `cmp` 返回 `0`,AX worktree 文件内容与 current main 一致。
+- 指定文件相对 `main` 无 diff 输出。
+- 相对 `main` 的删除项扫描无输出;`rg '^D'` 返回 `1`,表示未匹配删除项。
+
+## Merge Candidate 范围
+
+相对 current `main` 的 diff 当前为:
+
+- `60 files changed, 5746 insertions(+), 158 deletions(-)`
+- 范围集中在 AX direct output、未设置触发键、hotkey / audio safety、前端设置 / indicator、测试、边界扫描脚本、CODEGRAPH 和 AX-10 自查证据。
+- `K2.项目管理/BR-AX-MAINLINE-分支创建记录.md` 已保留,不再作为删除项出现。
+
+## 验证命令
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --name-status main -- 'K2.项目管理/BR-AX-MAINLINE-分支创建记录.md'
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --name-status main | rg '^D'
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --stat main
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --name-only main | rg '(^|/)(node_modules|dist|build|\.vite|coverage)(/|$)|\.(dmg|zip|app|xcarchive|log|png|jpg|jpeg|mov|mp4|DS_Store)$'
+```
+
+结果:
+
+- `git diff --check`:PASS,无输出。
+- `BR-AX-MAINLINE-分支创建记录.md` 相对 `main`:无 diff 输出。
+- 删除项扫描:无删除项;`rg '^D'` 返回 `1`。
+- `git diff --stat main`:`60 files changed, 5746 insertions(+), 158 deletions(-)`。
+- 构建产物 / 缓存 / 截图 / 临时日志扫描:无输出;`rg` 返回 `1`,表示未匹配。
+
+本轮未跑 Go / frontend 测试,原因:
+
+- 本轮未改业务源码、前端源码、构建脚本或测试逻辑。
+- 唯一 merge-prep 改动是保留 current main 的 K2 分支创建记录,并新增本自查。
+- 代码 / 前端完整门禁应由下一棒 aggregate code review / merge-candidate QA 按最终待审 diff 统一执行。
+
+## 未做与风险
+
+- 未合并 `main`,未创建 commit。
+- 未 push、未发布、未 tag、未生成 manifest / SHA256、未刷新 codebase-memory。
+- 未生成 MAS build、TestFlight / App Store 上传、Developer ID notarization 或正式发布包。
+- `AX-QA-PERMISSION`、`AX-MAS-BUILD-GATE`、`AX-APPSTORE-METADATA-GATE`、`AX-OWNER-CHANNEL-DECISION` 仍按台账继续阻断后续合并完成 / 发布链路。
+- 主工作区当前有未跟踪 `AX-10AZ` 发布规划文档;本轮未触碰。若后续主线合并或发布冻结要消费该文件,应由 PMO / 发布任务单独处理。
+
+## 建议下一步
+
+建议 PMO 派 aggregate code review,例如 `AX-10BB`:
+
+- 审查 merge candidate 全量 diff。
+- 重点确认不删除 `BR-AX-MAINLINE-分支创建记录.md`。
+- 复核 Windows / 非 App Store R222 默认右侧修饰键未回滚。
+- 复核 `darwin && appstore` 未设置触发键、Esc 取消、Floating Indicator、AX output / clipboard fallback、audio / recorder safety 边界未互相回退。
+- 复核没有构建产物、缓存、截图、临时日志、用户配置混入。
+
+复审 PASS 后再由 PMO 派 merge-candidate QA;本轮不代表主线合并完成、QA PASS、MAS build PASS 或发布许可。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BF-\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/AX-10BF-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..6454dfd
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BF-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,115 @@
+# AX-10BF 开发自查
+
+任务 ID:`AX-10BF`
+派发记录:`AX-10BF-CODER-FALLBACK-REDISPATCH2-20260708.1721`
+角色:Coder
+时间:2026-07-08 17:31 +0800
+结论:`PM_NOTIFY_READY AX-10BF`
+
+## 目标与边界
+
+目标:修复 AX-10BE 暴露的 Terminal / 不可写目标输出失败后,后端已有 `fallback_available=true` 但生产 UI 仍显示 `暂无需要处理的转写结果`,导致 pending text / clipboard fallback 无法验收的问题。
+
+本轮不做:
+
+- 不改变 TextEdit / Safari direct insert 成功路径。
+- 不让 Terminal / 不可写目标误判 direct insert 成功。
+- 不自动粘贴、不自动 Cmd+V、不引入 IMK / TIS、AppleEvents、Input Monitoring、CGEvent 或 release action。
+- 不合并 main、不 push、不发布、不 tag、不生成 manifest、不刷新 codebase-memory。
+- 不生成 MAS build、DMG、ZIP、archive、TestFlight / App Store 上传。
+
+## 事实来源
+
+- 根台账:`/Users/ar/Projects/PrivateVoice/02-P/进度地图.md`
+- QA evidence:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10BE-permission-output-matrix-QA-evidence.md`
+- 工作区:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+
+AX-10BE 关键证据:
+
+- TextEdit direct insert:PASS。
+- Safari textarea direct insert:PASS。
+- Terminal / 不可写目标未误上屏:PASS。
+- 日志有 `Recognized: Private voice terminal.` 和 `text_output_done ... fallback_available=true`。
+- 失败点:生产 UI 的 `直接输入与备用输入` 区域显示 `暂无需要处理的转写结果`,无法继续 QA fallback prepare / restore / skip restore。
+
+## 根因判断
+
+`internal/textoutput.Router` 已正确保存失败结果,`services.TextOutputService.GetLastResult()` / `PrepareLastClipboardFallback()` 也已有能力暴露保留文本和显式 fallback。
+
+缺口在 UI 同步:`GeneralPage.svelte` 只在组件 `onMount` 和手动点击刷新时读取一次 `TextOutputService.GetLastResult()`。如果设置页已经挂载,Terminal 输出失败发生后没有事件触发刷新,因此 UI 会保留旧的“无结果”状态。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/app.go`
+ - 新增事件名 `text-output:updated`。
+ - 在 `a.outputRouter.Output(...)` 之后调用 `emitTextOutputUpdated(output)`。
+ - 事件 payload 只包含状态字段:`ok`、`method`、`target`、`needsPermission`、`directInserted`、`fallbackAvailable`、`hasText`。
+ - 不在事件 payload 中发送转写正文,前端仍通过 `TextOutputService.GetLastResult()` 拉取权威状态。
+
+- `C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte`
+ - 引入 `Events`。
+ - `onMount` 订阅 `text-output:updated`,事件到达时调用现有 `refreshTextOutput()`。
+ - 继续使用现有显式按钮执行 `PrepareLastClipboardFallback` / `RestoreClipboardFallback`;不自动写剪贴板,不自动粘贴。
+
+- `C1.source/privatevoice.src/app_text_output_event_test.go`
+ - 新增聚焦测试,确认 fallback 可用状态通过事件 payload 暴露,且 payload 不包含转写正文。
+
+- `CODEGRAPH.md`
+ - 记录生产 fallback 控件依赖 `text-output:updated` 事件刷新 pending fallback state,fallback 操作仍是显式用户动作。
+
+## 行为影响
+
+- TextEdit / Safari 成功 direct insert:仍由 `TextOutputRouter` 判定成功,不改变插入路径。
+- Terminal / 不可写目标:仍保持 direct insert 失败,不误上屏;失败结果会触发 UI 刷新并显示保留文本 / fallback 可用状态。
+- clipboard fallback:仍需用户点击 `使用剪贴板备用输入`;prepare / restore / skip restore 语义不变。
+
+## 验证命令
+
+```bash
+gofmt -w app.go app_text_output_event_test.go
+go test -run 'TestTextOutputUpdatePayload' .
+go test -tags appstore -run 'TestTextOutputUpdatePayload' .
+npm --prefix frontend run build
+go test ./internal/textoutput ./services
+go test -tags appstore ./internal/textoutput ./services
+./scripts/verify-ax-output-boundary.sh
+go test ./...
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+结果:
+
+- `gofmt`:PASS。
+- `go test -run 'TestTextOutputUpdatePayload' .`:PASS。
+- `go test -tags appstore -run 'TestTextOutputUpdatePayload' .`:PASS。
+- `npm --prefix frontend run build`:PASS;仍有既有 Svelte a11y warnings,涉及 `UpdateDialog.svelte` 和 `ToggleSwitch.svelte`,非本轮改动。
+- `go test ./internal/textoutput ./services`:PASS。
+- `go test -tags appstore ./internal/textoutput ./services`:PASS。
+- `./scripts/verify-ax-output-boundary.sh`:PASS。
+- `go test ./...`:PASS。
+- `git diff --check`:PASS。
+
+Go 测试期间仍出现既有 macOS linker warnings:
+
+- object file built for newer `macOS` version than linked target `11.0`。
+- `libonnxruntime.1.24.4.dylib` built for newer version `14.0`。
+
+这些 warning 未导致测试失败,本轮未处理工具链 / dylib deployment target。
+
+## 未做与风险
+
+- 未做真实 Terminal QA;需要代码审核 PASS 后由 PMO 派 QA retry。
+- 未构建 QA App、MAS build、DMG、ZIP 或 release archive。
+- 未修改 AX driver、ASR、录音状态机、权限策略、right Command、默认热键策略或发布链路。
+
+## 建议下一步
+
+建议 PMO 派代码审核,例如 `AX-10BG`:
+
+- 审查 `text-output:updated` 事件发出点是否覆盖 success / failure。
+- 审查前端订阅是否能让已挂载 General 页刷新 pending fallback state。
+- 审查事件 payload 不泄露转写正文。
+- 审查没有自动粘贴或禁用边界回退。
+
+审核 PASS 后派 QA retry,重点复测 Terminal / 不可写目标失败后的生产 fallback prepare / restore / skip restore。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BH-\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/AX-10BH-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..c297f09
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BH-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,62 @@
+# AX-10BH 开发自查
+
+- 任务:AX-10BH-CODER-20260708.1751
+- Review Gate:AX-10BG-GATE-20260708.1729
+- 结论:AX-10BG must-fix 已按最小范围处理,等待同 Gate 复审。
+
+## 背景
+
+AX-10BG 代码审核确认 AX-10BF 的 `text-output:updated` 刷新机制方向正确,但事件 payload 仍包含 `target`。AX target 可能拼入窗口标题、文档标题或站点标题,因此不满足“事件只作为刷新信号,不携带敏感文本”的门禁。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/app.go`
+ - `textOutputUpdatePayload()` 移除 `method` 和 `target` 字符串字段。
+ - 事件 payload 只保留非敏感布尔状态:`ok`、`needsPermission`、`directInserted`、`fallbackAvailable`、`hasText`。
+- `C1.source/privatevoice.src/app_text_output_event_test.go`
+ - 更新 focused test,使用带敏感标题的 `Target` 样例。
+ - 断言事件 payload 不包含 `text`、`transcript`、`target`、`method`、`message`。
+- `CODEGRAPH.md`
+ - 更新 AX output 事件职责说明,明确事件不携带 transcript / target / window title / document title。
+- `K2.项目管理/AX-10BH-开发自查.md`
+ - 本自查。
+
+## 行为说明
+
+- `GeneralPage.svelte` 仍只通过 `text-output:updated` 触发 `TextOutputService.GetLastResult()` 刷新,不依赖事件 payload。
+- Terminal / non-writable target 的 direct insert 负向不变。
+- clipboard fallback 仍必须由用户显式点击 prepare / restore / skip-restore 相关入口,不新增自动粘贴或自动 Cmd+V。
+- 未修改 AX direct insert、录音、ASR、权限、发布脚本、tag、manifest、codebase-memory。
+
+## 验证记录
+
+```bash
+gofmt -w app.go app_text_output_event_test.go
+go test -run 'TestTextOutputUpdatePayload' .
+go test -tags appstore -run 'TestTextOutputUpdatePayload' .
+go test ./internal/textoutput ./services
+go test -tags appstore ./internal/textoutput ./services
+./scripts/verify-ax-output-boundary.sh
+go test ./...
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+结果:
+
+- `gofmt` PASS。
+- focused Go test PASS;appstore-tagged focused test PASS。
+- `go test ./internal/textoutput ./services` PASS;appstore-tagged 相关包测试 PASS。
+- `./scripts/verify-ax-output-boundary.sh` PASS。
+- `go test ./...` PASS。
+- `git diff --check` PASS。
+- Go 测试仍有项目既有 macOS linker warning:对象文件目标版本高于 macOS 11.0,`libonnxruntime.1.24.4.dylib` 标记 macOS 14.0;命令退出码为 0。
+
+## 未覆盖与风险
+
+- 本轮未改前端文件,未重跑 `npm --prefix frontend run build`;AX-10BF 已跑过 frontend build,本轮修复只收窄 Go 事件 payload 和测试断言。
+- 本轮不做真实 QA retry、Terminal/TextEdit/Safari 上屏验证;需代码复审 PASS 后由 PMO 派发 QA。
+- 本轮未生成 MAS/DMG/ZIP/archive,未合并 main,未 push,未 tag,未生成 manifest,未刷新 codebase-memory。
+
+## 建议下一步
+
+PMO 派代码审核员对 `AX-10BG-GATE-20260708.1729` 做同 Gate 复审。复审重点:`text-output:updated` payload 不包含 `text` / `target` / 窗口标题 / 文档标题等敏感文本,且 General 页仍通过事件刷新 pending fallback 状态。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BK-\346\234\254\345\234\260QA\346\236\204\345\273\272\350\207\252\346\237\245.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BK-\346\234\254\345\234\260QA\346\236\204\345\273\272\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..6ab9dac
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BK-\346\234\254\345\234\260QA\346\236\204\345\273\272\350\207\252\346\237\245.md"
@@ -0,0 +1,98 @@
+# AX-10BK 本地 QA 构建自查
+
+- 任务:AX-10BK-CODER-QA-APP-20260708.1829
+- 目标:为 AX-10BJ QA retry 生成包含 AX-10BF / AX-10BH 修复的本地 QA App。
+- 结论:已生成 appstore-tagged、本地签名、仅 App bundle 形式的本地 QA App,可交给 QA retry。
+
+## 输入证据
+
+- QA blocker:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10BJ-Terminal-fallback-QA-retry-evidence.md`
+- 代码审核 PASS:`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10BI-Terminal-fallback事件payload复审.md`
+- AX-10BF 自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10BF-开发自查.md`
+- AX-10BH 自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10BH-开发自查.md`
+
+## 构建产物
+
+- QA App 路径:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/appstore-local/arm64/PrivateVoice Dictation.app`
+- App mtime:`2026-07-08 18:31:20 +0800`
+- Executable mtime:`2026-07-08 18:31:21 +0800`
+- Bundle ID:`com.shanghai3168.privatevoicedictation`
+- Version:`2.2.2`
+- Build:`20260703.0335`
+- LSMinimumSystemVersion:`13.4`
+- 运行 / 签名口径:`go build -tags appstore`,本地 `PrivateVoice Local Code Signing` 签名,App Sandbox entitlements。
+
+该 mtime 晚于 AX-10BF / AX-10BH 修复与自查时间,可作为 AX-10BI PASS 后的 QA retry 对象。
+
+## 实际执行命令
+
+本轮没有直接运行 `scripts/build-appstore-macos.sh`,因为该脚本会创建 DMG;本任务边界禁止 DMG / ZIP / archive。实际执行为同脚本的 App-only 子集:frontend build、`go build -tags appstore`、组装 `.app`、本地签名、签名验证,停止在 DMG 创建前。
+
+关键命令:
+
+```bash
+npm run build
+CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 MACOSX_DEPLOYMENT_TARGET=13.4 CGO_CFLAGS="${CGO_CFLAGS:-} -mmacosx-version-min=13.4" CGO_LDFLAGS="${CGO_LDFLAGS:-} -mmacosx-version-min=13.4" go build -tags appstore -buildvcs=false -gcflags=all="-l" -ldflags="-s -w" -o build/appstore-local/arm64/PrivateVoice\ Dictation
+codesign --force --options runtime --timestamp=none --entitlements build/darwin/entitlements-appstore.plist --sign "PrivateVoice Local Code Signing" build/appstore-local/arm64/PrivateVoice\ Dictation.app
+codesign --verify --deep --strict --verbose=2 build/appstore-local/arm64/PrivateVoice\ Dictation.app
+```
+
+## 产物核验
+
+```bash
+stat -f 'app_mtime=%Sm path=%N' -t '%Y-%m-%d %H:%M:%S %z' build/appstore-local/arm64/PrivateVoice\ Dictation.app
+stat -f 'binary_mtime=%Sm path=%N' -t '%Y-%m-%d %H:%M:%S %z' build/appstore-local/arm64/PrivateVoice\ Dictation.app/Contents/MacOS/PrivateVoice\ Dictation
+defaults read build/appstore-local/arm64/PrivateVoice\ Dictation.app/Contents/Info.plist CFBundleIdentifier
+defaults read build/appstore-local/arm64/PrivateVoice\ Dictation.app/Contents/Info.plist CFBundleShortVersionString
+defaults read build/appstore-local/arm64/PrivateVoice\ Dictation.app/Contents/Info.plist CFBundleVersion
+defaults read build/appstore-local/arm64/PrivateVoice\ Dictation.app/Contents/Info.plist LSMinimumSystemVersion
+codesign --verify --deep --strict --verbose=2 build/appstore-local/arm64/PrivateVoice\ Dictation.app
+codesign -dvv build/appstore-local/arm64/PrivateVoice\ Dictation.app
+codesign -d --entitlements :- build/appstore-local/arm64/PrivateVoice\ Dictation.app
+find build/appstore-local -maxdepth 3 \( -name '*.dmg' -o -name '*.zip' -o -name '*.pkg' -o -name '*.xcarchive' \) -print
+```
+
+结果:
+
+- `codesign --verify --deep --strict --verbose=2` PASS:`valid on disk` / `satisfies its Designated Requirement`。
+- `codesign -dvv`:`Identifier=com.shanghai3168.privatevoicedictation`,`Authority=PrivateVoice Local Code Signing`,`TeamIdentifier=not set`。
+- Entitlements:`com.apple.security.app-sandbox=true`、`com.apple.security.device.audio-input=true`、`com.apple.security.files.user-selected.read-write=true`、`com.apple.security.network.client=true`。
+- 发布类产物扫描无命中:未生成 `.dmg` / `.zip` / `.pkg` / `.xcarchive`。
+
+## 补充验证
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+go test -tags appstore ./internal/config ./internal/textoutput ./services
+go test -run 'TestTextOutputUpdatePayload' .
+go test -tags appstore -run 'TestTextOutputUpdatePayload' .
+./scripts/verify-ax-output-boundary.sh
+```
+
+结果:
+
+- `git diff --check` PASS。
+- appstore-tagged config / textoutput / services tests PASS。
+- `TestTextOutputUpdatePayload` focused tests PASS,含 appstore tag。
+- AX output boundary scan PASS。
+- Go 测试仍有既有 macOS linker warning,命令退出码为 0。
+- frontend build PASS,但仍有既有 Svelte a11y warning,涉及 `UpdateDialog.svelte` 和 `ToggleSwitch.svelte`。
+
+## 未执行项
+
+- 未运行 `scripts/build-appstore-macos.sh`,避免生成 DMG。
+- 未生成 MAS build、DMG、ZIP、archive、pkg、manifest、SHA256。
+- 未上传 TestFlight / App Store。
+- 未发布、未 tag、未合并 main、未 push、未刷新 codebase-memory。
+- 未启动 App、未改用户配置、未执行 Terminal / TextEdit / Safari 真实 QA。
+
+## 建议下一步
+
+PMO 可派 QA 继续 AX-10BJ retry。建议 QA 使用上述 QA App 路径,重点覆盖:
+
+1. Terminal / non-writable target 不误 direct insert、不自动粘贴。
+2. AX failure 后 pending text 在生产 UI 可见。
+3. clipboard fallback prepare。
+4. normal restore。
+5. 用户中途改剪贴板后的 skip restore。
+6. `text-output:updated` 不暴露 transcript / target / method / message / 窗口标题 / 文档标题。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BM-\346\234\254\345\234\260QA\346\236\204\345\273\272rpath\344\277\256\345\244\215\350\207\252\346\237\245.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BM-\346\234\254\345\234\260QA\346\236\204\345\273\272rpath\344\277\256\345\244\215\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..6bea67e
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BM-\346\234\254\345\234\260QA\346\236\204\345\273\272rpath\344\277\256\345\244\215\350\207\252\346\237\245.md"
@@ -0,0 +1,137 @@
+# AX-10BM 本地 QA 构建 rpath 修复自查
+
+任务:`AX-10BM-CODER-QA-APP-RPATH-20260708.1841`
+终态:`PM_NOTIFY_READY AX-10BM`
+时间:2026-07-08 18:50 +0800
+
+## 目标与边界
+
+- 目标:修复 AX-10BK `appstore-local` QA App 启动即崩的 dylib rpath / install name / signing 问题,并重新生成 App-only 本地 QA App,供 `AX-10BL` Terminal / non-writable fallback retry 使用。
+- 边界:未做 MAS build、DMG、ZIP、pkg、archive、manifest、SHA256、TestFlight / App Store 上传、发布、tag、push、主线合并或 codebase-memory 刷新。
+- 未改 AX output、fallback、ASR、热键捕获、权限策略等业务功能。唯一运行时源码补修是 sandbox QA App 启动锁路径 fallback,原因是 rpath/signing 修复后 App 已不再 dyld 崩溃,但 sandbox 启动时无法稳定使用原 `os.TempDir()` single-instance lock,导致启动被误判为已有实例。
+
+## 关键改动
+
+- `C1.source/privatevoice.src/scripts/build-appstore-macos.sh`
+ - 删除主二进制中的 sherpa-onnx Go module cache `LC_RPATH`,确保 `@executable_path/../Frameworks` 优先生效。
+ - 默认优先使用可用 `Apple Development:` 身份签 appstore-local QA App;仍保留 `PRIVATEVOICE_APPSTORE_LOCAL_SIGN_IDENTITY` / `PRIVATEVOICE_LOCAL_SIGN_IDENTITY` 覆盖口径。
+ - 新增 `PRIVATEVOICE_APPSTORE_LOCAL_APP_ONLY=1`,用于只刷新本地 QA `.app`,跳过 DMG 生成。
+- `C1.source/privatevoice.src/internal/singleinstance/singleinstance_unix.go`
+ - 保持 temp lock 可用时的原 single-instance 语义。
+ - 如果 temp lock 打开或加锁被 sandbox / 系统拒绝,则回退到 App Support 目录锁。
+ - 如果 temp lock 已被真实实例持有,则不走 fallback,避免绕过双实例保护。
+- `C1.source/privatevoice.src/internal/singleinstance/singleinstance_unix_test.go`
+ - 覆盖主锁路径不可打开时 fallback 可用。
+ - 覆盖主锁已持有时不会绕过到 fallback。
+- `CODEGRAPH.md`
+ - 补充 appstore-local QA rpath / Apple Development signing / App-only build 开关。
+ - 补充 Unix single-instance sandbox lock fallback 口径。
+
+## QA App 产物
+
+- App 路径:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/appstore-local/arm64/PrivateVoice Dictation.app`
+- App mtime:`2026-07-08 18:49:29 +0800`
+- 主二进制 mtime:`2026-07-08 18:49:29 +0800`
+- Bundle ID:`com.shanghai3168.privatevoicedictation`
+- Version / Build:`2.2.2` / `20260703.0335`
+- 签名身份:`Apple Development: junfeng zhang (9MYR6D5R5U)`
+- TeamIdentifier:`CR3J54M8BQ`
+- 运行口径:`-tags appstore`,App Sandbox entitlements 保留。
+
+## rpath / signing 证据
+
+命令:
+
+```bash
+otool -l "build/appstore-local/arm64/PrivateVoice Dictation.app/Contents/MacOS/PrivateVoice Dictation"
+```
+
+关键结果:
+
+```text
+LC_RPATH:
+ path @executable_path/../Frameworks (offset 12)
+```
+
+结论:主二进制不再包含 `/Users/ar/go/pkg/mod/github.com/k2-fsa/sherpa-onnx-go-macos@.../lib/aarch64-apple-darwin` 外部 module cache rpath。
+
+命令:
+
+```bash
+codesign -dv --verbose=4 "build/appstore-local/arm64/PrivateVoice Dictation.app"
+codesign -dv --verbose=4 "build/appstore-local/arm64/PrivateVoice Dictation.app/Contents/Frameworks/libonnxruntime.1.24.4.dylib"
+codesign -dv --verbose=4 "build/appstore-local/arm64/PrivateVoice Dictation.app/Contents/Frameworks/libsherpa-onnx-c-api.dylib"
+```
+
+关键结果:
+
+```text
+App Authority=Apple Development: junfeng zhang (9MYR6D5R5U)
+App TeamIdentifier=CR3J54M8BQ
+libonnxruntime.1.24.4.dylib TeamIdentifier=CR3J54M8BQ
+libsherpa-onnx-c-api.dylib TeamIdentifier=CR3J54M8BQ
+```
+
+## 启动稳定性证据
+
+命令:
+
+```bash
+open -n "/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/appstore-local/arm64/PrivateVoice Dictation.app"
+sleep 8
+ps ax -o pid,etime,command | rg -F "/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/appstore-local/arm64/PrivateVoice Dictation.app/Contents/MacOS/PrivateVoice Dictation"
+sleep 20
+ps ax -o pid,etime,command | rg -F "/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/appstore-local/arm64/PrivateVoice Dictation.app/Contents/MacOS/PrivateVoice Dictation"
+```
+
+关键结果:
+
+```text
+8 秒后:进程存在,PID 48864,etime 00:08
+约 45 秒后:进程仍存在,PID 48864,etime 00:45
+```
+
+Crash report 核验:
+
+```text
+最新 PrivateVoice Dictation crash report:
+2026-07-08 18:43:40 +0800 /Users/ar/Library/Logs/DiagnosticReports/PrivateVoice Dictation-2026-07-08-184340.ips
+```
+
+结论:最新 crash report 早于 18:49:29 修复后 App-only QA 构建;修复后启动核验未新增 `PrivateVoice Dictation-*.ips`。启动验证完成后已关闭本地 QA App 进程。
+
+## 构建与验证命令
+
+```bash
+gofmt -w internal/singleinstance/singleinstance_unix.go internal/singleinstance/singleinstance_unix_test.go
+go test ./internal/singleinstance
+PRIVATEVOICE_APPSTORE_LOCAL_APP_ONLY=1 PRIVATEVOICE_APPSTORE_LOCAL_SIGN_IDENTITY='Apple Development: junfeng zhang (9MYR6D5R5U)' ./scripts/build-appstore-macos.sh arm64
+codesign --verify --deep --strict --verbose=2 "build/appstore-local/arm64/PrivateVoice Dictation.app"
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+./scripts/verify-ax-output-boundary.sh
+go test ./...
+find build/appstore-local -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.pkg' -o -name '*.xcarchive' \) -print
+```
+
+结果:
+
+- `go test ./internal/singleinstance`:PASS。
+- `PRIVATEVOICE_APPSTORE_LOCAL_APP_ONLY=1 ... ./scripts/build-appstore-macos.sh arm64`:PASS;前端 `npm run build` 随脚本执行 PASS,仅保留既有 Svelte a11y warnings;Go appstore build PASS,仅有既有 macOS deployment target link warning。
+- `codesign --verify --deep --strict --verbose=2`:PASS。
+- `git diff --check`:PASS。
+- `./scripts/verify-ax-output-boundary.sh`:PASS。
+- `go test ./...`:PASS;保留既有 macOS linker deployment target warnings。
+- 发布类产物扫描:无 `.dmg` / `.zip` / `.pkg` / `.xcarchive` 输出。
+
+## 未跑项与风险
+
+- 未执行 MAS build、DMG、ZIP、pkg、archive、manifest、SHA256、TestFlight / App Store 上传、发布、tag、push、主线合并、codebase-memory 刷新,符合本任务边界。
+- 未执行 Terminal / non-writable fallback QA 矩阵;本轮只提供可启动且包含 AX-10BF / AX-10BH 修复的 App-only 本地 QA App,真实 QA retry 仍应由 PMO 派发。
+- App-only QA App 使用 Apple Development 身份签名,适用于本地 QA 启动 / 运行门禁,不代表 MAS 分发签名或 App Store Go。
+
+## 建议下一步
+
+- PMO 可派发 `AX-10BL` 同项 QA retry,使用上述 App 路径复测 Terminal / non-writable fallback prepare、normal restore、skip restore。
+- 代码审核如需要,关注点集中在:
+ - `build-appstore-macos.sh` 的 rpath 修复、Apple Development signing 选择和 App-only 开关。
+ - `singleinstance_unix.go` sandbox fallback 是否只处理锁访问失败、不绕过真实已持有锁。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BN-\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/AX-10BN-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..a7b081e
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BN-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,119 @@
+# AX-10BN 开发自查
+
+任务:`AX-10BN-CODER-FALLBACK-NONWRITABLE-20260708.1956`
+角色:Coder
+终态:`PM_NOTIFY_READY AX-10BN`
+时间:2026-07-08
+
+## 目标与边界
+
+目标:修复 `AX-10BL` QA retry 暴露的 Terminal / non-writable target fallback 操作后目标窗口被修改的问题,确保:
+
+- Terminal / shell / console / non-writable target 不被 AX direct insert 修改。
+- fallback prepare / restore / skip restore 不自动粘贴、不自动修改目标窗口内容。
+- pending fallback UI 和已保留文本仍可见。
+- `text-output:updated` payload 隐私边界不回退,不暴露 transcript / target / method / message / 窗口标题 / 文档标题。
+
+本轮不做:
+
+- 不改热键、ASR、录音状态机、发布脚本、右 Command 识别、MAS build gate。
+- 不合并 main、不 push、不 tag、不发布、不生成 MAS / DMG / ZIP / archive / manifest / SHA256、不刷新 codebase-memory。
+
+## 输入证据
+
+- QA 失败证据:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10BL2-Terminal-fallback-QA-retry-evidence.md`
+- AX-10BF 自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10BF-开发自查.md`
+- AX-10BH 自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10BH-开发自查.md`
+- AX-10BI 代码审核 PASS:`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10BI-Terminal-fallback事件payload复审.md`
+
+## 根因判断
+
+QA 关键证据:
+
+- 物理 `Ctrl` / hold 后,Terminal marker 后未立即出现识别文本,说明 direct insert 当时未误报成功,也未立即改写 Terminal。
+- App 日志显示 `text_output_done result=error method="ax_focused_element" ... fallback_available=true`,识别文本被保留。
+- 后续完成 fallback prepare / restore / skip restore 后,Terminal marker 后出现 `Private voice terminal for back.`。
+
+代码侧风险点:
+
+1. `internal/axinput/axinput_darwin.go` 的 AX direct insert 失败分支曾尝试写 `kAXSelectedTextAttribute`,再返回失败。这不是 AX-9/AX-10 口径要求的 `AXValue + AXSelectedTextRange` 可验证路径,存在“失败但目标被改写”的风险。
+2. `internal/textoutput.Router.PrepareLastClipboardFallback()` 对所有失败目标都允许写系统剪贴板。真实 QA 证明,在 Terminal / non-writable target 场景下,即使代码没有显式 Cmd+V,这条 clipboard prepare 流程仍可能通过焦点 / 辅助功能操作链路使命令行窗口出现剪贴板文本。因此对 shell / console / unknown target,不能继续暴露可执行 prepare。
+
+本轮判断为代码安全边界不足,不是产品 / 架构口径冲突。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/internal/axinput/axinput_darwin.go`
+ - 移除 direct insert 失败分支中的 `kAXSelectedTextAttribute` 写入尝试。
+ - direct insert 成功条件收敛为 `AXValue` replacement + 回读验证。
+ - 读取不到 value / selected range 或 value 不可写时,只返回失败诊断,不修改目标。
+
+- `C1.source/privatevoice.src/internal/textoutput/router.go`
+ - `PrepareLastClipboardFallback()` 新增 `clipboardFallbackSafeForTarget()` guard。
+ - 当失败目标为 `target=unknown` / 空 target / Terminal / iTerm / Warp / shell / console / terminal-role 时,返回 `clipboard fallback is disabled for this non-writable target`,不调用 driver 写剪贴板。
+ - 非 shell/console 的可识别失败目标仍保留显式 clipboard fallback。
+
+- `C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin_test.go`
+ - 更新正向 fallback 测试为非 shell 可识别目标。
+ - 新增 Terminal target 被阻止测试。
+ - 新增 `target=unknown` 被阻止测试。
+
+- `CODEGRAPH.md`
+ - 记录 AX direct insert 成功条件只接受 `AXValue` replacement + 回读验证。
+ - 记录 shell / console / unknown target 禁用 clipboard fallback prepare,以防 fallback 操作改写非可写命令行窗口。
+
+## 行为影响
+
+- TextEdit / Safari 等可写目标 direct insert 成功路径不变。
+- 对可识别、非 shell/console 的失败目标,pending text 和显式 clipboard fallback 保留。
+- 对 Terminal / shell / console / unknown target,pending text 仍保留在 `TextOutputResult`,生产 UI 仍可显示失败文本和状态;但点击 prepare 不会写剪贴板,避免 fallback 操作修改目标窗口内容。
+- `text-output:updated` payload 未改动,仍只含非敏感布尔字段。
+- 未增加 IMK / TIS / AppleEvents / Automation / Input Monitoring / Screen Recording / CGEvent / 自动 Cmd+V / release action。
+
+## 验证命令与结果
+
+```bash
+gofmt -w internal/axinput/axinput_darwin.go internal/textoutput/router.go internal/textoutput/router_appstore_darwin_test.go
+go test -tags appstore ./internal/textoutput
+go test ./internal/textoutput
+go test ./internal/textoutput ./services
+go test -tags appstore ./internal/textoutput ./services
+go test -tags appstore ./internal/axinput ./internal/textoutput ./services
+./scripts/verify-ax-output-boundary.sh
+go test ./...
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+结果:
+
+- `gofmt`:PASS。
+- `go test -tags appstore ./internal/textoutput`:PASS。
+- `go test ./internal/textoutput`:PASS。
+- `go test ./internal/textoutput ./services`:PASS。
+- `go test -tags appstore ./internal/textoutput ./services`:PASS。
+- `go test -tags appstore ./internal/axinput ./internal/textoutput ./services`:PASS。
+- `./scripts/verify-ax-output-boundary.sh`:PASS。
+- `go test ./...`:PASS。
+- `git diff --check`:PASS。
+
+Go 测试仍输出既有 macOS linker warning:
+
+- object file built for newer `macOS` version than linked target `11.0`。
+- `libonnxruntime.1.24.4.dylib` built for newer version `14.0`。
+
+上述 warning 未导致测试失败,本轮不处理工具链 / dylib deployment target。
+
+## 未跑项
+
+- 未跑 `npm --prefix frontend run build`:本轮未修改前端。
+- 未生成新的本地 QA App:本轮输出可审 diff;如代码复审 PASS,PMO 可决定是否沿用 AX-10BM 的 App-only 构建流程刷新 QA App。
+- 未执行真实 Terminal / non-writable fallback QA retry;需要复审 PASS 后由 PMO 派 QA。
+
+## 风险与下一步
+
+- 风险:Terminal / unknown target 上,prepare 被禁用后 QA 不能再用 Terminal 目标验证 clipboard prepare / restore / skip restore;这些子路径应在非 shell/console 的可识别失败目标上继续验证。Terminal 目标的验收重点应收敛为 pending 文本可见、prepare 不写剪贴板、不改目标窗口。
+- 建议下一步:PMO 派代码审核,建议 Review Gate `AX-10BO-GATE-20260708.1956`。复审重点检查:
+ - direct insert 失败路径不再写 `kAXSelectedTextAttribute`。
+ - Terminal / unknown target prepare 被拒绝且 driver 未被调用。
+ - 非 shell/console 目标的 fallback prepare / restore / skip restore 能力未被整体移除。
+ - `text-output:updated` payload 隐私边界未回退。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BP-\346\234\254\345\234\260QA\346\236\204\345\273\272\345\210\267\346\226\260\350\207\252\346\237\245.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BP-\346\234\254\345\234\260QA\346\236\204\345\273\272\345\210\267\346\226\260\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..f862952
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BP-\346\234\254\345\234\260QA\346\236\204\345\273\272\345\210\267\346\226\260\350\207\252\346\237\245.md"
@@ -0,0 +1,162 @@
+# AX-10BP 本地 QA 构建刷新自查
+
+任务:`AX-10BP-CODER-QA-APP-REFRESH-20260708.2020`
+终态:`PM_NOTIFY_READY AX-10BP`
+时间:2026-07-08 20:22 +0800
+
+## 目标与边界
+
+- 目标:在 `AX-10BO` 代码审核 PASS 后,刷新包含 `AX-10BN` Terminal / non-writable fallback 边界修复的本地 `appstore-local` QA App,供下一轮 QA retry 使用。
+- 渠道:本地 QA App-only 构建,不是 MAS build,不是可分发发布包。
+- 未做:DMG、ZIP、pkg、archive、manifest、SHA256、TestFlight / App Store 上传、发布、tag、push、主线合并、codebase-memory 刷新。
+- 未执行真实 Terminal / TextEdit / Safari QA;后续 QA retry 由 PMO 另派。
+
+## 输入依据
+
+- 代码审核 PASS:`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10BO-Terminal-nonwritable-fallback边界代码审核.md`
+- Coder 自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10BN-开发自查.md`
+- QA 失败输入:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10BL2-Terminal-fallback-QA-retry-evidence.md`
+- 上次可启动 QA App 经验:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10BM-本地QA构建rpath修复自查.md`
+
+## 构建命令
+
+源码根:
+
+```bash
+cd /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src
+```
+
+执行:
+
+```bash
+PRIVATEVOICE_APPSTORE_LOCAL_APP_ONLY=1 PRIVATEVOICE_APPSTORE_LOCAL_SIGN_IDENTITY='Apple Development: junfeng zhang (9MYR6D5R5U)' ./scripts/build-appstore-macos.sh arm64
+```
+
+结果:
+
+- PASS。
+- 脚本输出 `DMG: skipped`。
+- 前端 `npm run build` 随脚本执行 PASS;仍有既有 Svelte a11y warnings:`UpdateDialog.svelte` dialog tabindex / noninteractive listener、`ToggleSwitch.svelte` button label。
+- Go `-tags appstore` build PASS;仍有既有 `libonnxruntime.1.24.4.dylib` macOS 14.0 minimum version warning。
+
+## QA App 产物
+
+- App 路径:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/appstore-local/arm64/PrivateVoice Dictation.app`
+- App mtime:`2026-07-08 20:21:30 +0800`
+- 主二进制 mtime:`2026-07-08 20:21:31 +0800`
+- 旧 App mtime:`2026-07-08 18:49:29 +0800`
+- Bundle ID:`com.shanghai3168.privatevoicedictation`
+- Version / Build:`2.2.2` / `20260703.0335`
+- 签名身份:`Apple Development: junfeng zhang (9MYR6D5R5U)`
+- TeamIdentifier:`CR3J54M8BQ`
+- 运行口径:`-tags appstore`,App Sandbox entitlements 保留。
+
+结论:App mtime / 主二进制 mtime 晚于 `AX-10BN` 修复和 `AX-10BO` PASS,本轮没有沿用 AX-10BM 旧 App。
+
+## rpath / signing / entitlements
+
+`otool -l` 关键结果:
+
+```text
+LC_RPATH:
+ path @executable_path/../Frameworks (offset 12)
+```
+
+结论:主二进制不包含 Go module cache rpath,运行时应优先解析 `Contents/Frameworks` 内 dylib。
+
+App 签名摘要:
+
+```text
+Authority=Apple Development: junfeng zhang (9MYR6D5R5U)
+Authority=Apple Worldwide Developer Relations Certification Authority
+Authority=Apple Root CA
+TeamIdentifier=CR3J54M8BQ
+Runtime Version=26.5.0
+```
+
+Frameworks dylib 签名摘要:
+
+```text
+libonnxruntime.1.24.4.dylib TeamIdentifier=CR3J54M8BQ
+libsherpa-onnx-c-api.dylib TeamIdentifier=CR3J54M8BQ
+```
+
+Entitlements:
+
+```xml
+<key>com.apple.security.app-sandbox</key><true/>
+<key>com.apple.security.device.audio-input</key><true/>
+<key>com.apple.security.files.user-selected.read-write</key><true/>
+<key>com.apple.security.network.client</key><true/>
+```
+
+签名验证:
+
+```bash
+codesign --verify --deep --strict --verbose=2 "build/appstore-local/arm64/PrivateVoice Dictation.app"
+```
+
+结果:PASS,`valid on disk`,`satisfies its Designated Requirement`。
+
+## 启动稳定性
+
+启动命令:
+
+```bash
+open -n "/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/appstore-local/arm64/PrivateVoice Dictation.app"
+```
+
+进程核验:
+
+```text
+8 秒后:PID 53347,etime 00:08
+28 秒后:PID 53347,etime 00:28
+```
+
+Crash report 核验:
+
+```text
+启动前 latest crash:2026-07-08 18:43:40 +0800 /Users/ar/Library/Logs/DiagnosticReports/PrivateVoice Dictation-2026-07-08-184340.ips
+启动后 latest crash:2026-07-08 18:43:40 +0800 /Users/ar/Library/Logs/DiagnosticReports/PrivateVoice Dictation-2026-07-08-184340.ips
+```
+
+结论:本轮 20:21 QA App 启动未新增 crash report。启动门禁完成后已关闭本地 QA App 进程。
+
+## 其它门禁
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+结果:PASS。
+
+发布类产物扫描:
+
+```bash
+find build/appstore-local -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.pkg' -o -name '*.xcarchive' \) -print
+```
+
+结果:无输出。
+
+Git 状态扫描:
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O status --short -- C1.source/privatevoice.src/build C1.source/privatevoice.src/frontend/dist
+```
+
+结果:无输出,构建产物 / frontend dist 未进入 Git 待审状态。
+
+## 未跑项与风险
+
+- 未执行真实 Terminal / TextEdit / Safari QA;本轮只刷新本地 QA App 并完成启动 / 签名 / rpath 门禁。
+- 未跑额外 Go 测试:`AX-10BN` 与 `AX-10BO` 已完成代码复审门禁,本轮未改业务源码,只执行 App-only 构建和运行门禁。
+- 该 App 使用 Apple Development 签名,仅代表本地 QA 可启动对象,不代表 MAS build、TestFlight / App Store Go 或正式发布。
+
+## 建议下一步
+
+- PMO 可派发 Terminal / non-writable fallback QA retry,使用本自查记录的 QA App 路径。
+- QA retry 重点按 `AX-10BO` 建议覆盖:
+ - Terminal / unknown target fallback prepare 不可用。
+ - prepare 不写系统剪贴板、不修改 Terminal / shell 窗口。
+ - pending 文本可见。
+ - 非 shell 可识别失败目标的 explicit prepare / restore / skip restore 正向路径。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BW-MAS-candidate-retry-\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/AX-10BW-MAS-candidate-retry-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..4599792
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BW-MAS-candidate-retry-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,287 @@
+# AX-10BW MAS candidate retry 开发自查
+
+任务:`AX-10BW-RETRY-20260709.1217`
+时间:2026-07-09 12:31 +0800
+终态:`BLOCKED AX-10BW`
+
+## 目标与边界
+
+本轮目标是在 `AX-mainline-worktree/03-O` 内重试 AX-10BW 最小 MAS candidate 构建链路。此前 blocker 为缺少正式 Bundle ID 对应的 Mac App Store provisioning profile;PMO / Owner 已安装并复验新 profile 后重新派发。
+
+本轮遵守边界:
+- 未合并 main。
+- 未上传 TestFlight / App Store / ASC。
+- 未发布、未打 tag。
+- 未生成正式 release manifest / SHA256。
+- 未刷新 codebase-memory。
+- 未使用 Apple Development、不匹配 profile、TagLauncher profile 或 proof Bundle ID 冒充正式 Bundle ID MAS candidate。
+
+## 起点
+
+工作区:
+- Git 根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- 分支:`feature/mas-accessibility-direct-input`
+- HEAD:`ac1d4fd8ef875312d11acd4e4e5935ced542c227`
+- 目标 Bundle ID:`com.shanghai3168.privatevoicedictation`
+
+Profile:
+- 路径:`/Users/ar/Library/MobileDevice/Provisioning Profiles/b517d167-ab12-4123-b735-d670f98c542a.provisionprofile`
+- Name:`PrivateVoice Dictation Mac App Store`
+- UUID:`b517d167-ab12-4123-b735-d670f98c542a`
+- Platform:`OSX`
+- Team ID:`CR3J54M8BQ`
+- application-identifier:`CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+- ExpirationDate:`2027-06-04 21:48:21 +0800`
+
+Signing identities:
+- App:`3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+- Installer:`3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+
+## 本轮改动
+
+修改文件:
+- `C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh`
+
+修改原因:
+- 首次 retry 构建出的 MAS candidate 主二进制仍保留 Go module cache 的 `LC_RPATH`,且该外部 rpath 排在 `@executable_path/../Frameworks` 前。
+- 这会复发 AX-10BM 已修过的 dylib 加载风险:dyld 可能优先加载 Go module cache 中不同签名上下文的 `libsherpa-onnx-c-api.dylib` / `libonnxruntime`。
+
+最小修复:
+- 将 appstore-local 脚本已有的 `fix_binary_rpaths()` 语义移植到 MAS pkg 脚本。
+- 在签名前对主二进制删除 `$SHERPA_LIB_DIR` rpath,并确保保留 `@executable_path/../Frameworks`。
+- 未改业务逻辑、AX output、fallback、ASR、热键、权限文案、发布上传逻辑。
+
+## 构建命令与产物
+
+最终有效构建命令:
+
+```bash
+PRIVATEVOICE_APPSTORE_APP_IDENTITY='3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)' \
+PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY='3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)' \
+PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE='/Users/ar/Library/MobileDevice/Provisioning Profiles/b517d167-ab12-4123-b735-d670f98c542a.provisionprofile' \
+PRIVATEVOICE_APP_VERSION='2.2.3' \
+PRIVATEVOICE_APP_BUILD='20260709.1220' \
+./scripts/build-macappstore-pkg-macos.sh arm64
+```
+
+结果:构建成功,生成本地 MAS candidate 产物。
+
+App:
+- `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app`
+- App mtime:`2026-07-09 12:20:40 +0800`
+- Executable mtime:`2026-07-09 12:20:42 +0800`
+
+PKG:
+- `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice-Dictation-2.2.3-build20260709.1220-arm64-macappstore.pkg`
+- PKG mtime:`2026-07-09 12:20:44 +0800`
+
+Bundle metadata:
+- `CFBundleIdentifier = com.shanghai3168.privatevoicedictation`
+- `CFBundleShortVersionString = 2.2.3`
+- `CFBundleVersion = 20260709.1220`
+- `LSMinimumSystemVersion = 13.4`
+- `LSApplicationCategoryType = public.app-category.productivity`
+
+Privacy manifest:
+- `Contents/Resources/PrivacyInfo.xcprivacy` 存在。
+- `Info.plist` 与 `PrivacyInfo.xcprivacy` lint PASS。
+
+## 通过项
+
+### Profile / entitlements 对齐
+
+Embedded profile 解码核验:
+- Name:`PrivateVoice Dictation Mac App Store`
+- UUID:`b517d167-ab12-4123-b735-d670f98c542a`
+- Platform:`OSX`
+- Team ID:`CR3J54M8BQ`
+- application-identifier:`CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+- team entitlement:`CR3J54M8BQ`
+
+Signed app entitlements:
+- `com.apple.application-identifier = CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+- `com.apple.developer.team-identifier = CR3J54M8BQ`
+- `com.apple.security.app-sandbox = true`
+- `com.apple.security.device.audio-input = true`
+- `com.apple.security.files.user-selected.read-write = true`
+- `com.apple.security.network.client = true`
+- `keychain-access-groups = CR3J54M8BQ.*`
+
+未出现:
+- `com.apple.security.cs.disable-library-validation`
+- AppleEvents / Automation entitlement
+- Input Monitoring / Screen Recording entitlement
+
+### Signing / package
+
+```bash
+codesign --verify --deep --strict --verbose=2 "$APP"
+```
+
+结果:PASS,App valid on disk and satisfies its Designated Requirement。
+
+```bash
+codesign -dvvv "$APP"
+```
+
+结果摘要:
+- `Identifier=com.shanghai3168.privatevoicedictation`
+- `Authority=3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+- `TeamIdentifier=CR3J54M8BQ`
+
+```bash
+pkgutil --check-signature "$PKG"
+```
+
+结果:Package signed,证书链包含:
+- `3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+- Apple Worldwide Developer Relations Certification Authority
+- Apple Root CA
+
+### RPATH / dylib
+
+主二进制 `LC_RPATH`:
+
+```text
+@executable_path/../Frameworks
+```
+
+外部 rpath 扫描:
+- `/Users`
+- `/opt/homebrew`
+- `go/pkg/mod`
+- `Homebrew`
+
+结果:`forbidden-count = 0`
+
+Bundled dylib install names / Team ID:
+- `libsherpa-onnx-c-api.dylib`
+ - install name:`@rpath/libsherpa-onnx-c-api.dylib`
+ - Team ID:`CR3J54M8BQ`
+- `libonnxruntime.1.24.4.dylib`
+ - install name:`@rpath/libonnxruntime.1.24.4.dylib`
+ - Team ID:`CR3J54M8BQ`
+
+### Package payload permissions
+
+`pkgutil --expand-full` 后抽样:
+- 主二进制:`-rwxr-xr-x ar:staff`
+- `PrivacyInfo.xcprivacy`:`-rw-r--r-- ar:staff`
+- bundled dylib:`-rw-r--r-- ar:staff`
+
+### 边界扫描 / Git
+
+```bash
+bash -n ./scripts/build-macappstore-pkg-macos.sh
+```
+
+结果:PASS。
+
+```bash
+./scripts/verify-ax-output-boundary.sh
+```
+
+结果:PASS。未发现 IMK/TIS、event injection、global monitor、input-monitoring、automation、system input-method install 或 release action。
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+结果:PASS。
+
+发布类产物扫描:
+
+```bash
+find /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.xcarchive' -o -name '*manifest*' -o -name '*SHA256*' \) -print
+```
+
+结果:空。`build/macappstore` 下本轮只生成允许的 local MAS candidate `.app` / `.pkg`,未生成 DMG / ZIP / xcarchive / formal manifest / SHA256。
+
+## 阻断项
+
+Launch smoke 未通过。
+
+执行:
+
+```bash
+open -n "$APP"
+```
+
+结果:
+
+```text
+The application cannot be opened for an unexpected reason, error=Error Domain=RBSRequestErrorDomain Code=5 "Launch failed."
+NSUnderlyingError=NSPOSIXErrorDomain Code=163 "Launchd job spawn failed"
+```
+
+直接执行主二进制也未能保持进程:
+
+```bash
+"$APP/Contents/MacOS/PrivateVoice Dictation"
+```
+
+结果:进程退出,无稳定运行进程。
+
+新增 crash report:
+- 未发现新增 `PrivateVoice` / `VoiceSnap` crash report。
+
+系统日志关键证据:
+
+```text
+taskgated-helper: embedded provisioning profile not valid ... Only Development Provisioning Profiles can be installed in System Settings. Production Provisioning Profiles are imported within Xcode.
+taskgated-helper: Disallowing com.shanghai3168.privatevoicedictation because no eligible provisioning profiles found
+amfid: ... not valid ... No matching profile found
+AppleMobileFileIntegrity: Code has restricted entitlements, but the validation of its code signature failed.
+CoreServicesUIAgent: LAUNCH ... RBSRequestErrorDomain Code=5 "Launch failed."
+```
+
+`spctl`:
+
+```bash
+spctl -a -vvv --type execute "$APP"
+spctl -a -vvv --type install "$PKG"
+```
+
+结果:
+- App:`rejected`
+- PKG:`rejected`
+- origin 分别为 `3rd Party Mac Developer Application...` 和 `3rd Party Mac Developer Installer...`
+
+判断:
+- 这不是业务崩溃;没有新增 crash report。
+- 本地 build / signing / pkg / rpath 已形成,但本机 AMFI / taskgated 不接受该 production provisioning profile 作为本地可运行 profile,导致 MAS distribution candidate 无法完成本地 launch smoke。
+- AX-10BT / AX-10BU 要求 MAS candidate 启动 smoke,因此本轮不能声明 `PM_NOTIFY_READY AX-10BW`。
+
+## 未执行项
+
+未上传 TestFlight / App Store / ASC。
+
+未做真实 App Review / Transporter 平台反馈。
+
+未做 AX-10BY QA 矩阵,因为 `AX-10BW` launch smoke 未通过。
+
+未生成 release manifest / SHA256 / tag / codebase-memory。
+
+未运行全量 `go test ./...`。本轮代码改动仅限 MAS build shell script,已执行 `bash -n`、真实构建、签名、pkg、rpath、boundary 和 `git diff --check`。
+
+## 风险与建议下一步
+
+当前建议终态:`BLOCKED AX-10BW`。
+
+已经完成:
+- 正式 Bundle ID profile blocker 已解除。
+- 本地 MAS candidate `.app` / `.pkg` 可生成。
+- version/build 注入已生效:`2.2.3 / 20260709.1220`。
+- App signing、installer signing、embedded profile、signed entitlements、pkg signature、rpath、dylib Team ID 和 AX output boundary 均通过本地核验。
+
+剩余 blocker:
+- MAS distribution signed App 在本机 launch smoke 被 AMFI / taskgated 拒绝,错误为 `No matching profile found` / production profile not eligible for System Settings local profile validation。
+
+建议 PMO 下一步:
+1. 派 `AX-10BX` 代码审核只审构建脚本与 evidence,结论可为 `CHANGES_REQUESTED` 或 `BLOCKED-NEEDS-RELEASE-ARCH`,但不应进入 QA。
+2. 或先派架构 / 发布复判:本地 MAS candidate launch smoke 是否应改为 Transporter / ASC processing 前置验证,还是必须通过另一种 Apple 官方允许的本地安装 / 测试方式。
+3. 若要继续本地运行验证,需明确是否允许创建单独的 development-signed QA artifact;该 artifact 不能替代 MAS distribution candidate。
+4. 不建议 Coder 继续盲修 entitlements 或 profile;当前脚本层面已满足 bundle id / profile / entitlements / rpath /签名的静态与 package 门禁。
+
+终态:`BLOCKED AX-10BW`
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BW-MAS-candidate\346\236\204\345\273\272\350\207\252\346\237\245.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BW-MAS-candidate\346\236\204\345\273\272\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..2e02995
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BW-MAS-candidate\346\236\204\345\273\272\350\207\252\346\237\245.md"
@@ -0,0 +1,143 @@
+# AX-10BW MAS candidate 构建自查
+
+任务:`AX-10BW-CODER-20260708.2219`
+时间:2026-07-08 23:28 +0800
+结论:`BLOCKED AX-10BW`
+
+## 目标与边界
+
+目标是在 AX mainline worktree 内生成本地 MAS candidate `.app` / `.pkg`,或在缺少真实 MAS 构建条件时明确阻断。
+
+本轮遵守边界:
+- 不合并 main。
+- 不上传 TestFlight / App Store。
+- 不发布、不打 tag。
+- 不生成正式 release manifest / SHA256。
+- 不刷新 codebase-memory。
+- 不用 Apple Development 签名伪装 MAS candidate。
+- 不写入 provisioning profile 原文、证书私钥、secret 或敏感凭据。
+
+## 起点
+
+输入证据:
+- Gate Spec:`/Users/ar/Projects/PrivateVoice/02-P/当前阶段/AX-10BT-AppStore可行性证明GateSpec.md`
+- 架构复核:`/Users/ar/Projects/PrivateVoice/02-P/架构/AX-10BU-MAS候选构建与审核风险架构复核.md`
+- 发布 / 版本门禁:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10BV-AppStore提交资料与版本门禁.md`
+- 既有 appstore-local 证据:`K2.项目管理/AX-10BM-本地QA构建rpath修复自查.md`、`K2.项目管理/AX-10BP-本地QA构建刷新自查.md`
+
+工作区:
+- Git 根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- 分支:`feature/mas-accessibility-direct-input`
+- HEAD:`ac1d4fd8ef875312d11acd4e4e5935ced542c227`
+- 现有工作区包含 AX-10 系列待审 diff;本轮未修改生产源码。
+
+## 预期版本
+
+AX-10BV 要求本地 MAS candidate 使用:
+- Marketing version:`2.2.3`
+- Build:`YYYYMMDD.HHMM`,本轮候选 build 值应为 `20260708.2328`
+
+当前源码 `build/darwin/Info.plist` 仍为:
+- `CFBundleIdentifier = com.shanghai3168.privatevoicedictation`
+- `CFBundleShortVersionString = 2.2.2`
+- `CFBundleVersion = 20260703.0335`
+
+因 provisioning profile 已在预检阶段阻断,本轮未进入脚本改造或构建阶段,未改写版本号。
+
+## 签名与 profile 预检
+
+本机可用身份摘要:
+- App Store app identity:`3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`,存在。
+- App Store installer identity:`3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`,存在。
+- Apple Development identity 也存在,但按 AX-10BW 明确禁止,不作为 MAS candidate 替代。
+
+本机发现的 provisioning profile:
+- `/Users/ar/Library/MobileDevice/Provisioning Profiles/TagLauncher_Mac_App_Store_20260707.provisionprofile`
+
+仅读取安全元数据后的结果:
+- Profile name:`TagLauncher Mac App Store 20260707`
+- Team ID:`CR3J54M8BQ`
+- App ID entitlement:`CR3J54M8BQ.com.taglauncher.app`
+- Team entitlement:`CR3J54M8BQ`
+- Expiration:2027-07-07 13:17:38 +0800
+
+阻断点:
+- AX-10BW 默认目标 Bundle ID 必须使用当前正式 Bundle ID:`com.shanghai3168.privatevoicedictation`。
+- 当前唯一 profile 的 bundle id 是 `com.taglauncher.app`。
+- `scripts/build-macappstore-pkg-macos.sh` 也会校验 `PROFILE_BUNDLE_ID == BUNDLE_ID`;该条件当前必然失败。
+- 任务明确要求:如果没有真实 App Store profile / identity / installer signing 条件,必须 `BLOCKED AX-10BW`,不能使用 Apple Development 或不匹配 profile 伪装 MAS candidate。
+
+## 构建结果
+
+未生成本轮 MAS candidate:
+- App bundle path:未生成。
+- pkg path:未生成。
+- `codesign --verify --deep --strict`:未执行,因为没有 candidate App。
+- `pkgutil --check-signature`:未执行,因为没有 candidate pkg。
+- embedded provisioning profile:未嵌入,因为 profile bundle id 不匹配。
+- signed entitlements:未生成,因为未进入签名阶段。
+- 主二进制 `LC_RPATH`:未检查本轮 MAS candidate,因为没有 candidate App。
+- dylib install name / signing / Team ID:未检查本轮 MAS candidate,因为没有 candidate App。
+- launch smoke:未执行,因为没有 candidate App。
+
+`build/macappstore` 当前为空 / 不存在有效本轮产物。
+
+## 已执行命令与结果
+
+```bash
+security find-identity -v
+```
+结果:发现 App Store Application identity 与 App Store Installer identity,Team ID 均为 `CR3J54M8BQ`。
+
+```bash
+find "$HOME/Library/MobileDevice/Provisioning Profiles" -maxdepth 1 \( -name '*.provisionprofile' -o -name '*.mobileprovision' \) -print
+```
+结果:仅发现 `TagLauncher_Mac_App_Store_20260707.provisionprofile`。
+
+```bash
+security cms -D -i "$PROFILE" > "$TMP"
+/usr/libexec/PlistBuddy -c 'Print :Entitlements:com.apple.application-identifier' "$TMP"
+/usr/libexec/PlistBuddy -c 'Print :Entitlements:com.apple.developer.team-identifier' "$TMP"
+/usr/libexec/PlistBuddy -c 'Print :TeamIdentifier:0' "$TMP"
+```
+结果:profile App ID 为 `CR3J54M8BQ.com.taglauncher.app`,Team ID 为 `CR3J54M8BQ`;与目标 bundle id 不匹配。
+
+```bash
+/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' build/darwin/Info.plist
+/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' build/darwin/Info.plist
+/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' build/darwin/Info.plist
+```
+结果:`com.shanghai3168.privatevoicedictation` / `2.2.2` / `20260703.0335`。
+
+```bash
+./scripts/verify-ax-output-boundary.sh
+```
+结果:PASS。AX output boundary scan 未发现 IMK/TIS、event injection、global monitor、input-monitoring、automation、system input-method install 或 release action。
+
+```bash
+find /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore -maxdepth 3 -print 2>/dev/null || true
+```
+结果:无本轮 `build/macappstore` 产物。
+
+```bash
+find /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.pkg' -o -name '*.xcarchive' -o -name '*manifest*' -o -name '*SHA256*' \) -print
+```
+结果:发现历史本地 DMG / Windows manifest / 历史 SHA256SUMS;未生成本轮 MAS `.pkg`、`.xcarchive`、正式 manifest 或 SHA256。
+
+`git diff --check` 将在写入本自查后执行并记录于终态回报。
+
+## 风险与下一步归属
+
+当前是真 blocker,不是构建脚本 bug:
+- 构建脚本已具备真实 MAS app identity、installer identity 和 profile bundle id 对齐校验。
+- 本机缺少匹配 `com.shanghai3168.privatevoicedictation` 的 Mac App Store provisioning profile。
+- 如果改用 `com.taglauncher.app` 或 proof Bundle ID,会违反 AX-10BW 的默认 Bundle ID 约束,并触发 Owner / PMO 决策。
+
+建议下一步:
+1. PMO / Owner / 发布侧在 Apple Developer / App Store Connect 中准备正式 Bundle ID `com.shanghai3168.privatevoicedictation` 对应的 Mac App Store provisioning profile。
+2. 确认该 profile 的 App ID、Team ID、entitlements 与 App Store distribution identity 对齐。
+3. 凭据就绪后重新派 Coder 继续同一 MAS candidate build gate。
+4. 如必须使用 proof Bundle ID 或 `com.taglauncher.app`,应先由 PMO / Owner 做明确 `decision-needed` 裁决,不应由 Coder 自行切换。
+
+终态:`BLOCKED AX-10BW`
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BW-MODEL-URL-\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/AX-10BW-MODEL-URL-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..8feee8f
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BW-MODEL-URL-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,56 @@
+# AX-10BW-MODEL-URL 开发自查
+
+- 时间:2026-07-09 00:35 +0800
+- 角色:Coder
+- 结论:`PM_NOTIFY_READY AX-10BW-MODEL-URL`
+
+## 目标
+
+在 `AX-10BW-PROFILE` 仍阻塞 MAS candidate 构建期间,关闭默认模型下载源中的明文 HTTP 风险,并去除已实测不可用的 SenseVoice 旧 fallback。该任务只收敛 AX worktree 的模型下载源与证据链,不解除 provisioning profile blocker,不构建 MAS candidate,不上传 TestFlight / App Store,不合并 main,不发布,不打 tag,不生成 manifest / SHA256,不刷新 codebase-memory。
+
+## 起点
+
+- `C1.source/privatevoice.src/internal/model/registry.go` 中默认 SenseVoice 第一下载源为明文 maikami URL。
+- 同一 profile 的旧 ModelScope SenseVoice fallback 经核验不可用。
+- `03-O/K3.运营与发布资料/AX-10BW-ContentRights与第三方依赖清单.md` 已把该 HTTP 源列为上传前风险。
+
+## 外部源只读核验
+
+- `curl -I -L --max-time 20 https://www.maikami.com/voicesnap/sensevoice.zip`:HTTP 200,`content-type=application/zip`,`content-length=160080017`,支持 `accept-ranges=bytes`。
+- `curl -L --max-time 30 --range 0-0 https://www.maikami.com/voicesnap/sensevoice.zip`:HTTP 206,下载 1 byte。
+- 旧 ModelScope SenseVoice fallback 的 HEAD 核验:HTTP 404。
+- 旧 ModelScope SenseVoice fallback 的 Range GET 核验:HTTP 404。
+- `curl -I -L --max-time 30 https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2`:跳转后 HTTP 200,`content-length=163002883`。
+- `curl -L --retry 2 --retry-delay 2 --max-time 45 --range 0-0 https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2`:HTTP 206,下载 1 byte。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/internal/model/registry.go`
+ - SenseVoice 默认下载源从明文 maikami URL 改为 HTTPS maikami。
+ - 旧 ModelScope fallback 实测 404,替换为同名 sherpa-onnx GitHub release HTTPS URL。
+- `C1.source/privatevoice.src/internal/model/model_test.go`
+ - 新增 `TestModelDownloadURLsUseHTTPS`,遍历 `ListModelProfiles()`,禁止空下载 URL 和非 HTTPS 下载 URL。
+- `CODEGRAPH.md`
+ - 记录模型下载 URL 属于 App Store / privacy evidence chain,必须保持 HTTPS-only,并由 `internal/model/model_test.go` 保护。
+- `03-O/K3.运营与发布资料/AX-10BW-ContentRights与第三方依赖清单.md`
+ - 更新 SenseVoice 下载源风险状态:HTTP 源已移除,旧 ModelScope 404 fallback 已替换为 GitHub HTTPS release。
+ - 明确本 gate 不替代模型权利、SHA256、attribution 审查。
+
+## 验证
+
+- `gofmt -w C1.source/privatevoice.src/internal/model/registry.go C1.source/privatevoice.src/internal/model/model_test.go`:PASS。
+- 对源码、CODEGRAPH 和 Content Rights 文档执行旧 HTTP / 旧 ModelScope SenseVoice 风险串搜索:无输出,`rg` 返回 1,表示未命中目标风险串。
+- `go test ./internal/model`:PASS。
+- `go test -tags appstore ./internal/model`:PASS。
+- `go test ./internal/model ./services`:PASS;保留既有 macOS linker deployment warning。
+- `go test -tags appstore ./internal/model ./services`:PASS;保留既有 macOS linker deployment warning。
+- `./scripts/verify-ax-output-boundary.sh`:PASS。
+- `go test ./...`:PASS;保留既有 macOS linker deployment warning。
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check -- C1.source/privatevoice.src/internal/model/registry.go C1.source/privatevoice.src/internal/model/model_test.go CODEGRAPH.md`:PASS。
+- `git -C /Users/ar/Projects/PrivateVoice/03-O diff --check -- K3.运营与发布资料/AX-10BW-ContentRights与第三方依赖清单.md`:PASS。
+
+## 剩余风险
+
+- `AX-10BW-PROFILE` 仍阻塞 MAS candidate 构建:本机仍缺少 `com.shanghai3168.privatevoicedictation` 对应 Mac App Store provisioning profile。
+- 本轮只关闭下载 URL 协议 / 可达性风险;不证明 maikami mirror 或 GitHub release 的模型权利、商业使用、SHA256、attribution 已满足 App Store `Content Rights`。
+- 最终 MAS candidate 仍需 `AX-10BW-MODEL-RIGHTS` / SBOM / license notice / privacy manifest / required reason API / bundle 级扫描。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BW-NPM-PIN-\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/AX-10BW-NPM-PIN-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..fbbd38c
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BW-NPM-PIN-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,101 @@
+# AX-10BW-NPM-PIN 开发自查
+
+- 任务 ID:`AX-10BW-NPM-PIN`
+- Review Gate:`AX-10BW-NPM-PIN-GATE-20260709.0019`
+- 时间:2026-07-09 00:19 +0800
+- 角色:Coder
+- 状态:`PM_NOTIFY_READY AX-10BW-NPM-PIN`
+
+## 目标
+
+关闭 `AX-10BW-ContentRights与第三方依赖清单.md` 中记录的前端依赖漂移风险:`@wailsio/runtime` 不得继续使用 `latest`,`package.json`、`package-lock.json` 和本地 `node_modules` 必须收敛到同一精确版本,为后续 MAS candidate SBOM / license / privacy 证明链提供可复现输入。
+
+本轮不解除 `AX-10BW-PROFILE` blocker,不构建 MAS candidate,不上传 TestFlight / App Store,不合并 `main`,不发布,不打 tag,不生成 manifest / SHA256,不刷新 codebase-memory。
+
+## 起点
+
+只读核验发现:
+
+```text
+frontend/package.json: "@wailsio/runtime": "latest"
+frontend/package-lock.json root dependency: "latest"
+frontend/package-lock.json package version: 3.0.0-alpha.79
+frontend/node_modules installed version: 3.0.0-alpha.96
+npm --prefix frontend ls --omit=dev --all: ELSPROBLEMS
+```
+
+这会导致上传候选无法证明前端 runtime 依赖的确定版本,也会影响 SBOM、license inventory 和 App Privacy / third-party dependency 复核。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/frontend/package.json`
+ - 将 `@wailsio/runtime` 从 `latest` 改为 `3.0.0-alpha.79`。
+- `C1.source/privatevoice.src/frontend/package-lock.json`
+ - 将根依赖声明从 `latest` 改为 `3.0.0-alpha.79`。
+- `CODEGRAPH.md`
+ - 记录前端 runtime 版本 pin 规则。
+ - 将 `npm --prefix frontend ls --omit=dev --all` 纳入标准验证命令。
+
+## 未改范围
+
+- 未升级 Wails Go module。
+- 未升级 `@wailsio/runtime` 到本机曾安装的 `3.0.0-alpha.96`。
+- 未修改前端业务逻辑、样式、文案或用户可见交互。
+- 未修改 App Store profile、签名、版本号、Bundle ID、MAS build 脚本或发布产物。
+- 未改 `frontend/dist`。
+
+## 验证
+
+### 版本一致性
+
+```text
+package.json dependency: 3.0.0-alpha.79
+package-lock root dependency: 3.0.0-alpha.79
+package-lock runtime package: 3.0.0-alpha.79
+node_modules installed package: 3.0.0-alpha.79
+```
+
+### npm dependency tree
+
+```bash
+npm --prefix frontend ls --omit=dev --all
+```
+
+结果:PASS。
+
+```text
+privatevoice-dictation-frontend@2.2.2 .../frontend
+└── @wailsio/runtime@3.0.0-alpha.79
+```
+
+### frontend build
+
+```bash
+npm --prefix frontend run build
+```
+
+结果:PASS。
+
+保留既有 Svelte a11y warnings:
+
+- `src/components/update/UpdateDialog.svelte` dialog focus / non-interactive listener warning。
+- `src/components/shared/ToggleSwitch.svelte` button label warning。
+
+这些 warning 是既有问题,本轮未扩大范围。
+
+## 风险与后续
+
+- 本轮只关闭 `@wailsio/runtime` 版本漂移,不等于完整 SBOM / license gate 完成。
+- `AX-10BW-SBOM` 仍需基于最终 MAS candidate bundle 生成 Go modules、npm packages、embedded dylib、frontend bundle 的完整依赖清单。
+- `AX-10BW-PROFILE` 仍是 MAS candidate build 的硬阻塞:本机仍缺 `com.shanghai3168.privatevoicedictation` 对应 Mac App Store provisioning profile。
+- 代码复审 PASS 前,不得把本轮配置变更标记为最终 release-ready。
+
+## 建议下一步
+
+PMO 派代码审核复核 `AX-10BW-NPM-PIN-GATE-20260709.0019`,重点确认:
+
+1. `latest` 已彻底移除。
+2. `package.json` / `package-lock.json` / installed runtime 版本一致。
+3. `npm ls` 与 `npm run build` 的验证范围足以覆盖本轮依赖收敛。
+4. 未引入前端业务行为变更、构建产物或发布动作。
+
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BW-PRIVACY-COPY-\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/AX-10BW-PRIVACY-COPY-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..4fc108f
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BW-PRIVACY-COPY-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,98 @@
+# AX-10BW-PRIVACY-COPY 开发自查
+
+- 任务 ID:`AX-10BW-PRIVACY-COPY`
+- 时间:2026-07-09 00:28 +0800
+- 角色:Coder / PMO
+- 状态:`PM_NOTIFY_READY AX-10BW-PRIVACY-COPY`
+
+## 目标
+
+关闭 AX App Store 可行性证明中已识别的源码内 About 页强隐私 / 强离线承诺风险:在最终 MAS candidate 尚未完成网络、模型下载、诊断、日志、第三方 SDK 和 App Privacy 核验前,App 内文案不得继续写 `fully offline`、`Data never leaves the device`、`数据永不出设备` 等绝对承诺。
+
+本轮只做 About 页中英文文案最小降级,不改功能逻辑、不改权限、不改模型下载、不改网络 entitlement、不改构建脚本,不构建 MAS candidate,不上传 TestFlight / App Store,不合并 `main`,不发布,不打 tag,不生成 manifest / SHA256,不刷新 codebase-memory。
+
+## 起点
+
+上传前资料清单已记录:
+
+- Apple App Privacy 口径要求覆盖 App 及集成第三方代码的数据实践。
+- Privacy Manifest / required reason API 是上传前 gate。
+- AX worktree About 页旧文案包含:
+ - `This app runs fully offline.`
+ - `Data never leaves the device.`
+ - `本应用完全离线运行`
+ - `数据永不出设备`
+- 当前 AX route 仍有模型下载、support / privacy URL、update check、diagnostics export、`com.apple.security.network.client=true` 等待最终候选证据闭合的事项。
+
+因此旧文案不适合直接进入 App Store candidate。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/frontend/src/lib/i18n/en.json`
+ - `about.tagline` 从 `Private offline` 降级为 `Local-first`。
+ - `about.offlineText` 改为本地模型处理为设计目标,同时说明模型下载、支持页、更新检查、诊断导出等可选操作可能使用网络或本地文件。
+ - `about.noDataText` 改为“不会发送给我们用于识别”,并说明设置、词库、模型、日志、诊断和可选历史记录保存在本机,除非用户主动导出或分享。
+- `C1.source/privatevoice.src/frontend/src/lib/i18n/zh.json`
+ - 对应中文文案改为 `本地优先`、本地模型处理、可选操作可能使用网络 / 本地文件、用户主动导出或分享的保守口径。
+
+## 未改范围
+
+- 未修改 `AboutPage.svelte` 布局、样式或交互。
+- 未修改模型下载、更新检查、诊断导出、日志记录、历史记录、词库或剪贴板行为。
+- 未修改 `entitlements-appstore.plist` 中的 network entitlement。
+- 未修改隐私政策公开页面。
+- 未修改 App Store Connect metadata 草案字段。
+- 未生成 `PrivacyInfo.xcprivacy`。
+- 未构建 MAS candidate / DMG / pkg / ZIP / archive。
+
+## 验证
+
+### JSON 格式
+
+```bash
+node -e 'JSON.parse(require("fs").readFileSync("C1.source/privatevoice.src/frontend/src/lib/i18n/en.json","utf8")); JSON.parse(require("fs").readFileSync("C1.source/privatevoice.src/frontend/src/lib/i18n/zh.json","utf8")); console.log("i18n json ok")'
+```
+
+结果:PASS。
+
+### 强承诺残留搜索
+
+```bash
+rg -n 'fully offline|Data never leaves|数据永不|完全离线|does not collect, upload, or store|不会收集、上传或存储|Private offline|私密离线' C1.source/privatevoice.src/frontend/src/lib/i18n C1.source/privatevoice.src/frontend/src/components/settings/AboutPage.svelte
+```
+
+结果:无命中,`rg` 退出码 1,符合预期。
+
+### 前端构建
+
+```bash
+npm --prefix C1.source/privatevoice.src/frontend run build
+```
+
+结果:PASS。
+
+保留既有 Svelte a11y warnings:
+
+- `src/components/update/UpdateDialog.svelte` dialog focus / non-interactive listener warning。
+- `src/components/shared/ToggleSwitch.svelte` button label warning。
+
+这些 warning 是既有问题,本轮未扩大范围。
+
+### 变更范围
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O status --short -- 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/dist C1.source/privatevoice.src/frontend/node_modules
+```
+
+结果:只显示 `en.json` / `zh.json` 修改;未显示 `dist` / `node_modules` 待提交变更。
+
+## 风险与后续
+
+- 本轮只降低 App 内 About 页源码文案风险,不等于 App Privacy 问卷、隐私政策公开页面、Review Notes、截图素材、support URL 页面内容已经最终通过。
+- 最终 MAS candidate 仍需用实际 build 证据核验网络行为、模型下载、diagnostics export、日志内容、第三方 SDK、privacy manifest / required reason API。
+- 如果最终候选证明“完全离线 / 数据永不出设备”确实成立,可以由产品 / 发布另行裁决是否恢复强口径;在当前证据不足阶段不应恢复。
+- `AX-10BW-PROFILE` 仍是 MAS candidate build 的硬阻塞。
+
+## 建议下一步
+
+PMO / 发布侧复核 `AX-10BW-PRIVACY-COPY` 文案是否符合产品和法律口径;若接受,则将 `AX-10BW-NETWORK-CLAIMS` 标为源码 About 强承诺已降级、最终 MAS candidate 行为仍待验证。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BW-PRIVACY-MANIFEST-\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/AX-10BW-PRIVACY-MANIFEST-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..734258e
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BW-PRIVACY-MANIFEST-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,69 @@
+# AX-10BW-PRIVACY-MANIFEST 开发自查
+
+- 任务 ID:`AX-10BW-PRIVACY-MANIFEST`
+- 时间:2026-07-09 00:42 +0800
+- 角色:Coder / PMO
+- 状态:`READY_FOR_CODE_REVIEW`
+
+## 目标
+
+在 `AX-10BW-PROFILE` 仍因正式 Bundle ID Mac App Store provisioning profile 缺失而阻塞期间,先关闭不依赖 profile 的 App Store privacy manifest / required reason API 源码级缺口。
+
+本轮不构建 MAS candidate、不上传 TestFlight / App Store、不合并 main、不发布、不打 tag、不生成 manifest / SHA256、不刷新 codebase-memory。
+
+## 当前起点
+
+- `AX-10BW` 最小 MAS candidate 构建已在 provisioning profile 预检阶段阻断。
+- `AX-10BW-ContentRights与第三方依赖清单.md` 记录上传前仍缺 `PrivacyInfo.xcprivacy` / required reason API 结论。
+- 源码扫描显示当前 Go 代码多处使用 `os.Stat()` / `FileInfo.Stat()` 读取文件元数据,覆盖模型、日志、历史、诊断、更新、路径和启动项等工作流。
+- 本轮未发现项目源码直接使用 `NSUserDefaults` / `UserDefaults` / `CFPreferences`、磁盘空间 API 或系统启动时间 API 的证据。
+
+## 关键改动
+
+1. 新增 `C1.source/privatevoice.src/build/darwin/PrivacyInfo.xcprivacy`。
+ - `NSPrivacyTracking=false`
+ - `NSPrivacyTrackingDomains=[]`
+ - `NSPrivacyCollectedDataTypes=[]`
+ - `NSPrivacyAccessedAPITypes` 仅声明 `NSPrivacyAccessedAPICategoryFileTimestamp`,reason 为 `C617.1`
+2. `C1.source/privatevoice.src/scripts/build-appstore-macos.sh`
+ - 新增 `PRIVACY_MANIFEST` 路径。
+ - App Store local sandbox QA build 预检要求存在 `PrivacyInfo.xcprivacy`。
+ - 组装 `.app` 时复制到 `Contents/Resources/PrivacyInfo.xcprivacy`。
+3. `C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh`
+ - 新增同样的 manifest 预检和 bundle 资源复制。
+ - 正式 MAS pkg 脚本不会在缺清单时继续构建。
+4. `CODEGRAPH.md`
+ - 记录 App Store local sandbox QA 与 MAS pkg 脚本的 privacy manifest 责任。
+ - 明确不得在没有源码或 bundle 证据时扩展 required reason API 类别。
+
+## 选择理由
+
+- `FileTimestamp / C617.1` 有源码证据支撑:项目需要检查模型目录、下载文件、日志、历史目录、诊断输出、更新临时文件、路径迁移和启动项文件是否存在及状态。
+- 未声明 `UserDefaults`:本轮项目源码扫描未发现直接使用 `NSUserDefaults` / `UserDefaults` / `CFPreferences`。
+- 未声明 `SystemBootTime`:本轮项目源码扫描未发现系统启动时间 API。
+- 未声明 collected data:privacy manifest 只反映当前源码级清单准备;最终 App Privacy 数据收集口径仍必须由实际 MAS candidate 的网络、日志、诊断、历史和第三方代码证据决定。
+
+## 已跑验证
+
+```text
+plutil -lint C1.source/privatevoice.src/build/darwin/PrivacyInfo.xcprivacy
+plutil -p C1.source/privatevoice.src/build/darwin/PrivacyInfo.xcprivacy
+bash -n C1.source/privatevoice.src/scripts/build-appstore-macos.sh
+bash -n C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh
+rg -n "PRIVACY_MANIFEST|PrivacyInfo\\.xcprivacy|NSPrivacyAccessedAPICategoryFileTimestamp|C617\\.1" C1.source/privatevoice.src/scripts/build-appstore-macos.sh C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh C1.source/privatevoice.src/build/darwin/PrivacyInfo.xcprivacy CODEGRAPH.md
+```
+
+结果:均 PASS。`plutil -p` 显示 manifest 仅包含 `FileTimestamp / C617.1`、空 collected data、tracking=false 和空 tracking domains。
+
+## 未跑项
+
+- 未运行 `scripts/build-macappstore-pkg-macos.sh`:仍缺匹配 `com.shanghai3168.privatevoicedictation` 的 Mac App Store provisioning profile。
+- 未运行完整 appstore-local 构建:本轮先做源码 / 脚本 gate,不生成新的 QA App 产物。
+- 未执行 bundle 级 privacy manifest / required reason API 扫描:必须等真实 MAS candidate 或明确的本地 QA App refresh 后再做。
+- 未做 ASC / TestFlight / App Store 上传。
+
+## 风险
+
+- `PrivacyInfo.xcprivacy` 只关闭源码级缺口,不替代最终 `.app` bundle 扫描。
+- Go runtime、Wails、WebView、内嵌 dylib 或第三方代码可能在最终 bundle 中触发额外 required reason API 或 privacy manifest 要求;需在 `AX-10BY` / 上传前 gate 中复核。
+- App Privacy 问卷仍需按最终候选行为填写,不能因为本清单 `NSPrivacyCollectedDataTypes=[]` 就直接声明无数据收集。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BW-VERSION-INJECTION-\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/AX-10BW-VERSION-INJECTION-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..08268f2
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10BW-VERSION-INJECTION-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,81 @@
+# AX-10BW-VERSION-INJECTION 开发自查
+
+- 任务 ID:`AX-10BW-VERSION-INJECTION`
+- 时间:2026-07-09 02:08 +0800
+- 角色:Coder / PMO
+- 状态:`PM_NOTIFY_READY / READY_FOR_REVIEW`
+
+## 背景
+
+`AX-10BV` 发布侧要求:如果 AX 进入 MAS-first 可行性 candidate,建议至少使用 marketing version `2.2.3` 和新的 build `YYYYMMDD.HHMM`,不得复用 `2.2.2 / 20260703.0335` 四包、tag、manifest 或 SHA256。
+
+当前 `AX-10BW-PROFILE` 仍阻塞真实 MAS candidate 构建,但 profile-independent 检查发现:
+
+- `build/darwin/Info.plist` 默认仍是 `2.2.2 / 20260703.0335`。
+- `app.go` 内 `appVersion` / `appBuild` 也硬编码为 `2.2.2 / 20260703.0335`。
+- `build-appstore-macos.sh` 与 `build-macappstore-pkg-macos.sh` 均从 `Info.plist` 读取版本,未提供 candidate 构建覆盖能力。
+
+如果 profile 到位后直接跑 MAS 构建,容易生成版本不合 gate 的候选,或出现 App 内版本与 bundle `Info.plist` 不一致。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/app.go`
+ - 将 `appVersion` / `appBuild` 从 const 改为 package-level string var,允许 `go build -ldflags -X main.appVersion=... -X main.appBuild=...` 注入。
+ - 增加 `appDisplayVersion()`,保证 About / diagnostics 展示按当前变量实时组合。
+- `C1.source/privatevoice.src/app_version_test.go`
+ - 新增 `TestAppDisplayVersionUsesVersionAndBuild`,锁定 display version 由 version + build 变量组合。
+- `C1.source/privatevoice.src/scripts/build-appstore-macos.sh`
+ - 支持 `PRIVATEVOICE_APP_VERSION` / `PRIVATEVOICE_APP_BUILD`。
+ - Go binary 通过 ldflags 注入版本。
+ - 组装 `.app` 时写入 `CFBundleShortVersionString` / `CFBundleVersion`。
+- `C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh`
+ - 支持同样的 `PRIVATEVOICE_APP_VERSION` / `PRIVATEVOICE_APP_BUILD`。
+ - Go binary 与 bundle `Info.plist` 使用同一版本输入。
+- `CODEGRAPH.md`
+ - 记录 App Store candidate version override 口径。
+
+## 使用口径
+
+profile 解除后,MAS candidate 构建应显式传入:
+
+```bash
+PRIVATEVOICE_APP_VERSION=2.2.3 \
+PRIVATEVOICE_APP_BUILD=YYYYMMDD.HHMM \
+PRIVATEVOICE_APPSTORE_APP_IDENTITY="Apple Distribution: ..." \
+PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY="3rd Party Mac Developer Installer: ..." \
+PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE="/path/to/profile.provisionprofile" \
+ ./scripts/build-macappstore-pkg-macos.sh arm64
+```
+
+本轮没有修改默认 `Info.plist` 版本,也没有冻结正式版本;默认开发源仍保留 `2.2.2 / 20260703.0335`,避免在缺 profile、缺模型权利决策时提前制造 release freeze。
+
+## 已跑验证
+
+- `gofmt -w app.go`:PASS。
+- `bash -n scripts/build-appstore-macos.sh`:PASS。
+- `bash -n scripts/build-macappstore-pkg-macos.sh`:PASS。
+- `go test -run '^$' .`:PASS,保留既有 macOS linker deployment warning。
+- `go test -run 'TestAppDisplayVersionUsesVersionAndBuild' .`:PASS,保留既有 warning。
+- `go test -run '^$' -ldflags='-X main.appVersion=2.2.3 -X main.appBuild=20260709.0200' .`:PASS,保留既有 warning。
+- `PRIVATEVOICE_APPSTORE_LOCAL_APP_ONLY=1 PRIVATEVOICE_APP_VERSION=2.2.3 PRIVATEVOICE_APP_BUILD=20260709.0143 ./scripts/build-appstore-macos.sh arm64`:PASS;frontend build PASS(仅既有 Svelte a11y warning),Go build PASS(仅既有 `libonnxruntime.1.24.4.dylib` deployment target warning),本地 Apple Development 签名 PASS,`DMG: skipped`。
+- `plutil -p build/appstore-local/arm64/PrivateVoice\ Dictation.app/Contents/Info.plist | rg 'CFBundleShortVersionString|CFBundleVersion|CFBundleIdentifier|LSMinimumSystemVersion'`:PASS,`CFBundleIdentifier=com.shanghai3168.privatevoicedictation`,`CFBundleShortVersionString=2.2.3`,`CFBundleVersion=20260709.0143`,`LSMinimumSystemVersion=13.4`。
+- `strings build/appstore-local/arm64/PrivateVoice\ Dictation.app/Contents/MacOS/PrivateVoice\ Dictation | rg '2\.2\.3|20260709\.0143'`:PASS,主二进制包含注入版本与 build。
+- `codesign --verify --deep --strict --verbose=2 build/appstore-local/arm64/PrivateVoice\ Dictation.app`:PASS,`valid on disk` / `satisfies its Designated Requirement`。
+- `otool -l build/appstore-local/arm64/PrivateVoice\ Dictation.app/Contents/MacOS/PrivateVoice\ Dictation | rg -A2 LC_RPATH`:PASS,主二进制仅保留 `@executable_path/../Frameworks`。
+- `find build/appstore-local -type f \( -name '*.dmg' -o -name '*.pkg' -o -name '*.zip' -o -name '*.xcarchive' \) -print`:无输出,未生成发布类产物。
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check -- C1.source/privatevoice.src/app.go C1.source/privatevoice.src/app_version_test.go C1.source/privatevoice.src/scripts/build-appstore-macos.sh C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh CODEGRAPH.md`:PASS。
+
+## 未执行 / 边界
+
+- 未执行真实 MAS build:`AX-10BW-PROFILE` 仍缺匹配 `com.shanghai3168.privatevoicedictation` 的 Mac App Store provisioning profile。
+- 已生成本地 appstore-local App-only bundle,用于验证 version override、bundle `Info.plist`、rpath 与本地签名;它使用 Apple Development 签名,不是 MAS candidate,不可上传。
+- 未生成真实 MAS candidate `.pkg` / `.dmg` / `.zip` / `.xcarchive` / manifest / SHA256。
+- 未上传 TestFlight / App Store。
+- 未合并 main、未发布、未 tag、未刷新 codebase-memory。
+- 未把默认源码版本直接改为 `2.2.3`;这应在 candidate source freeze 或正式 release freeze 时统一处理。
+
+## 风险与后续
+
+1. 该改动只关闭“构建链路可注入 candidate version”的准备缺口,不代表 `AX-10BW` MAS candidate build PASS。
+2. profile 到位后,构建日志必须记录 `PRIVATEVOICE_APP_VERSION`、`PRIVATEVOICE_APP_BUILD`、bundle `Info.plist` 和 App 内 diagnostics 版本一致性。
+3. 真实 candidate 若决定冻结版本,应另开 source-freeze / release-freeze gate,统一更新默认 `Info.plist`、`app.go` 默认值、release manifest、codebase-memory 和 tag。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CA-MAS-diff-closure-\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/AX-10CA-MAS-diff-closure-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..5b753e5
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CA-MAS-diff-closure-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,147 @@
+# AX-10CA MAS diff closure 开发自查
+
+任务:`AX-10CA-CODER-MAS-DIFF-CLOSURE-20260709.1231`
+时间:2026-07-09 12:37 +0800
+终态:`PM_NOTIFY_READY AX-10CA`
+
+## 目标
+
+处理 `AX-10BX` 代码审核 `CHANGES_REQUESTED` 中的 MAS 构建脚本证据链自包含性问题。
+
+本轮不做:
+- 不改业务逻辑。
+- 不继续修 AMFI / taskgated launch smoke。
+- 不上传 TestFlight / App Store / ASC。
+- 不发布、不合 main、不 tag。
+- 不生成正式 release manifest / SHA256。
+- 不刷新 codebase-memory。
+- 不重新生成 release 包。
+
+## 起点
+
+输入证据:
+- 代码审核:`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10BX-MAS-candidate脚本与证据代码审核.md`
+- Coder retry 自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10BW-MAS-candidate-retry-开发自查.md`
+- 发布复判:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10BY-MAS-production-candidate验证路径复判.md`
+
+工作区:
+- Git 根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- 分支:`feature/mas-accessibility-direct-input`
+
+## 处理结果
+
+### `PrivacyInfo.xcprivacy`
+
+路径:
+- `C1.source/privatevoice.src/build/darwin/PrivacyInfo.xcprivacy`
+
+处理:
+- 纳入 Git 待审范围:已执行 `git add -N`。
+
+原因:
+- `scripts/build-macappstore-pkg-macos.sh` 已强依赖该文件并在 preflight 中要求存在。
+- MAS candidate bundle 会复制它到 `Contents/Resources/PrivacyInfo.xcprivacy`。
+- 若不纳入待审范围,脚本提交后在干净环境会直接 preflight fail,AX-10BW 构建证据不可复现。
+
+内容摘要:
+- `NSPrivacyTracking=false`
+- `NSPrivacyCollectedDataTypes=[]`
+- `NSPrivacyTrackingDomains=[]`
+- `NSPrivacyAccessedAPITypes` 声明 `NSPrivacyAccessedAPICategoryFileTimestamp`,reason `C617.1`
+
+### `app_version_test.go`
+
+路径:
+- `C1.source/privatevoice.src/app_version_test.go`
+
+处理:
+- 纳入 Git 待审范围:已执行 `git add -N`。
+
+原因:
+- 文件内容为 `TestAppDisplayVersionUsesVersionAndBuild`,覆盖 `appVersion` / `appBuild` 注入后 `appDisplayVersion()` 展示语义。
+- `AX-10BW-VERSION-INJECTION-开发自查.md` 已明确把该文件列为版本注入改动范围。
+- 因此它不是本地临时文件,应作为 AX-10BW version injection / MAS candidate 证据链的一部分纳入待审范围。
+
+## 已执行命令
+
+```bash
+gofmt -w C1.source/privatevoice.src/app_version_test.go
+```
+
+结果:PASS,无实质格式改动。
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O add -N \
+ C1.source/privatevoice.src/build/darwin/PrivacyInfo.xcprivacy \
+ C1.source/privatevoice.src/app_version_test.go
+```
+
+结果:两文件已纳入 intent-to-add 待审范围。
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O ls-files --others --exclude-standard -- \
+ C1.source/privatevoice.src/build/darwin/PrivacyInfo.xcprivacy \
+ C1.source/privatevoice.src/app_version_test.go
+```
+
+结果:无输出,证明这两个文件不再处于未跟踪列表。
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O status --short -- \
+ C1.source/privatevoice.src/build/darwin/PrivacyInfo.xcprivacy \
+ C1.source/privatevoice.src/app_version_test.go
+```
+
+结果:
+
+```text
+ A C1.source/privatevoice.src/app_version_test.go
+ A C1.source/privatevoice.src/build/darwin/PrivacyInfo.xcprivacy
+```
+
+```bash
+plutil -lint C1.source/privatevoice.src/build/darwin/PrivacyInfo.xcprivacy
+```
+
+结果:PASS。
+
+```bash
+bash -n C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh
+```
+
+结果:PASS。
+
+```bash
+go test -run 'TestAppDisplayVersionUsesVersionAndBuild' .
+```
+
+结果:PASS。输出包含既有 macOS linker warnings,不影响测试通过。
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+结果:PASS。
+
+## 未执行项
+
+- 未重新生成 MAS `.app` / `.pkg`:本轮只关闭待审 diff 自包含性,不需要重复构建。
+- 未运行全量 `go test ./...`:本轮没有改业务 Go 代码,只纳入已有聚焦测试与 privacy manifest。
+- 未运行 frontend build:本轮未改前端。
+- 未上传、未发布、未合 main、未 tag、未 manifest / SHA256、未刷新 codebase-memory。
+
+## 风险与下一步
+
+`AX-10BX` must-fix 已处理:
+- `PrivacyInfo.xcprivacy` 不再是未跟踪的隐式构建依赖。
+- `app_version_test.go` 不再是未跟踪的游离测试文件。
+
+剩余风险:
+- `AX-10BW` 的 production-signed local launch smoke 仍按 `AX-10BY` 发布复判归入 Apple 平台验证路径问题,不属于本轮 Coder 继续盲修范围。
+
+建议下一步:
+- PMO 派代码审核员对同一 `AX-10BX` Gate 做复审。
+- 若复审 PASS,再由 PMO / 发布 / Owner 决定是否进入 ASC / Transporter / TestFlight 平台反馈路径。
+
+终态:`PM_NOTIFY_READY AX-10CA`
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CC-MAS-platform-feedback-candidate-\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/AX-10CC-MAS-platform-feedback-candidate-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..32b4697
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CC-MAS-platform-feedback-candidate-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,127 @@
+# AX-10CC MAS platform-feedback candidate 开发自查
+
+任务:AX-10CC MAS platform-feedback candidate 刷新构建
+角色:Coder
+终态建议:PM_NOTIFY_READY AX-10CC
+日期:2026-07-09
+
+## 1. 目标与边界
+
+本轮目标是在 AX mainline worktree 内生成新的 MAS platform-feedback candidate,用于后续 PMO / 发布路径走 Transporter / App Store Connect / TestFlight 平台反馈。marketing version 继续使用 `2.2.3`,build 使用本轮新值 `20260709.1336`,不复用旧候选 `2.2.3 / 20260709.1220`。
+
+本轮未执行:上传 Transporter / App Store Connect、TestFlight、主线合并、发布、tag、正式 release manifest / SHA256SUMS、codebase-memory 刷新、public release。
+
+按 AX-10BY 口径,production-signed MAS artifact 的普通本地 `open -n` / 直接执行会受 AMFI / taskgated 平台边界影响;本轮未把 local runtime smoke 作为继续盲修项。
+
+## 2. 上传源证据
+
+- Worktree:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- Branch:`feature/mas-accessibility-direct-input`
+- HEAD:`ac1d4fd8ef875312d11acd4e4e5935ced542c227`
+- 前置结论:
+ - `AX-10BY RELEASE_PLAN PASS`
+ - `AX-10CB REVIEW_RESULT PASS`
+- 当前源码状态:AX 主线集成累计待审 diff,包含 AX 输出、热键未设置状态、fallback、MAS 构建脚本、`PrivacyInfo.xcprivacy`、`app_version_test.go` 等候选范围。该候选是 platform-feedback 构建证据,不是 clean main release freeze。
+- 已知未跟踪过程文档:`AX-10BW-MODEL-URL-*`、`AX-10BW-NPM-PIN-*`、`AX-10BW-PRIVACY-COPY-*`、`AX-10BW-PRIVACY-MANIFEST-*`、`AX-10BW-VERSION-INJECTION-*`。本轮未修改这些既有过程文档。
+
+## 3. 构建命令
+
+在 `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src` 执行:
+
+```bash
+PRIVATEVOICE_APPSTORE_APP_IDENTITY='3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)' PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY='3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)' PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE='/Users/ar/Library/MobileDevice/Provisioning Profiles/b517d167-ab12-4123-b735-d670f98c542a.provisionprofile' PRIVATEVOICE_APP_VERSION='2.2.3' PRIVATEVOICE_APP_BUILD='20260709.1336' ./scripts/build-macappstore-pkg-macos.sh arm64
+```
+
+结果:PASS。
+
+构建过程中有两个非阻断提示:
+- 前端 build 存在既有 Svelte a11y warning,位置为 `UpdateDialog.svelte` / `ToggleSwitch.svelte`。
+- Go 链接提示 `libonnxruntime.1.24.4.dylib` 构建目标版本高于当前 macOS 13.4 链接目标。产物签名与包校验仍通过。
+
+## 4. 候选产物
+
+- App:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app`
+- PKG:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice-Dictation-2.2.3-build20260709.1336-arm64-macappstore.pkg`
+- App mtime:`2026-07-09 13:36:56 +0800`
+- Executable mtime:`2026-07-09 13:36:58 +0800`
+- PKG mtime:`2026-07-09 13:37:00 +0800`
+- PKG SHA256 候选证据:`865046df17d4242923a81d9a9e5d8a4f2e1d7d7537bad026b430ee2f552f011c`
+
+该 SHA256 仅作为本轮 candidate 单文件证据,不是正式 release manifest / SHA256SUMS。
+
+## 5. Bundle / version / profile / signing
+
+产物 `Info.plist`:
+- `CFBundleIdentifier`:`com.shanghai3168.privatevoicedictation`
+- `CFBundleShortVersionString`:`2.2.3`
+- `CFBundleVersion`:`20260709.1336`
+- `LSMinimumSystemVersion`:`13.4`
+- `LSApplicationCategoryType`:`public.app-category.productivity`
+
+签名:
+- Application identity:`3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+- Installer identity:`3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+- TeamIdentifier:`CR3J54M8BQ`
+
+embedded provisioning profile:
+- Path:`Contents/embedded.provisionprofile`
+- Name:`PrivateVoice Dictation Mac App Store`
+- UUID:`b517d167-ab12-4123-b735-d670f98c542a`
+- Platform:`OSX`
+- TeamIdentifier:`CR3J54M8BQ`
+- application-identifier:`CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+- ExpirationDate:`2027-06-04 21:48:21 +0800`
+
+signed entitlements:
+- `com.apple.application-identifier=CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+- `com.apple.developer.team-identifier=CR3J54M8BQ`
+- `com.apple.security.app-sandbox=true`
+- `com.apple.security.device.audio-input=true`
+- `com.apple.security.files.user-selected.read-write=true`
+- `com.apple.security.network.client=true`
+- `keychain-access-groups=CR3J54M8BQ.*`
+
+## 6. 验证结果
+
+已执行并通过:
+
+```bash
+codesign --verify --deep --strict --verbose=2 "/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app"
+pkgutil --check-signature "/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice-Dictation-2.2.3-build20260709.1336-arm64-macappstore.pkg"
+plutil -lint "/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app/Contents/Info.plist" "/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app/Contents/Resources/PrivacyInfo.xcprivacy"
+bash -n ./scripts/build-macappstore-pkg-macos.sh
+./scripts/verify-ax-output-boundary.sh
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+关键核验摘要:
+- `codesign --verify --deep --strict`:PASS。
+- `pkgutil --check-signature`:PASS,origin 为 `3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`。
+- embedded profile 与 signed entitlements 的 Team ID / application identifier 对齐。
+- `PrivacyInfo.xcprivacy` 已进入 bundle 并通过 `plutil -lint`。
+- 主二进制 `LC_RPATH` 仅为 `@executable_path/../Frameworks`;未发现外部 `/Users` / Go module cache rpath。
+- 主二进制链接 `@rpath/libsherpa-onnx-c-api.dylib` 与 `@rpath/libonnxruntime.1.24.4.dylib`。
+- `libsherpa-onnx-c-api.dylib` install name 为 `@rpath/libsherpa-onnx-c-api.dylib`,TeamIdentifier `CR3J54M8BQ`。
+- `libonnxruntime.1.24.4.dylib` install name 为 `@rpath/libonnxruntime.1.24.4.dylib`,TeamIdentifier `CR3J54M8BQ`。
+- package payload 权限核验:主二进制 `-rwxr-xr-x`,PrivacyInfo 与 dylibs 为 `-rw-r--r--`。
+- AX output boundary scan:PASS,未发现 IMK/TIS、event injection、global monitor、Input Monitoring、Automation、系统输入法安装或 release action。
+- 发布类产物扫描:未发现 `.dmg` / `.zip` / `.pkg` 之外的 `.xcarchive` / manifest / SHA256SUMS;本轮 `.pkg` 仅作为本地 MAS platform-feedback candidate evidence。
+
+## 7. 未执行项与原因
+
+- 未执行 Transporter / App Store Connect / TestFlight 上传:本轮任务明确禁止。
+- 未执行 production-signed MAS app 普通本地 runtime QA:按 AX-10BY,AMFI / taskgated 拒绝属于平台边界,记录为 N/A。
+- 未生成正式 release manifest / SHA256SUMS:本轮禁止。
+- 未打 tag、未 push、未合并 main、未刷新 codebase-memory。
+
+## 8. 风险与下一步
+
+剩余风险:
+- 当前 candidate 来源是 AX worktree HEAD 加累计待审 diff,不是 clean main release freeze;后续如进入正式提交或发布链路,应由 PMO / 发布角色决定是否先形成可回滚提交、冻结 commit 并刷新 codebase-memory。
+- `libonnxruntime` macOS 版本链接 warning 仍需发布 / 平台反馈路径关注,但本轮签名、rpath、pkg、profile、entitlements 门禁已通过。
+- Production-signed artifact 仍需通过 Apple 平台反馈路径判断,而不是本地普通启动 smoke 判断。
+
+建议下一步:
+- PMO 可派发布 / 平台反馈角色基于本候选 `.pkg` 走 Transporter / ASC / TestFlight 平台反馈路径。
+- 若平台要求源码冻结或正式 manifest,应另行派发冻结 / 发布准备任务;本轮不代表发布完成。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CG-MAS-signing-profile-cert-fix-retry-\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/AX-10CG-MAS-signing-profile-cert-fix-retry-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..70bb869
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CG-MAS-signing-profile-cert-fix-retry-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,160 @@
+# AX-10CG MAS signing / profile certificate fix retry 开发自查
+
+任务:AX-10CG-RETRY-MAS-SIGNING-PROFILE-20260709.1612
+角色:Coder / 发布工程
+终态建议:PM_NOTIFY_READY AX-10CG
+日期:2026-07-09
+
+## 1. 目标与边界
+
+本轮目标是在 Owner 重新生成并安装正式 Bundle ID Mac App Store provisioning profile 后,重跑 AX-10CG,生成新的 MAS platform-feedback candidate,并完成本地签名 / profile / pkg / rpath / boundary gate。
+
+本轮明确不做:上传 ASC / Transporter / TestFlight、提交 Beta App Review / App Review / external TestFlight、公开发布、主线合并、tag、正式 release manifest / SHA256SUMS、codebase-memory 刷新、泄露 Apple secret。
+
+## 2. 当前源码与输入
+
+- Worktree:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- Branch:`feature/mas-accessibility-direct-input`
+- HEAD:`ac1d4fd8ef875312d11acd4e4e5935ced542c227`
+- 上轮 AX-10CG 自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10CG-MAS-signing-profile-cert-fix-开发自查.md`
+- Apple 反馈记录:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10CD-Apple平台反馈提交记录.md`
+- AX-10CD validate 日志:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10CD-platform-feedback-logs/20260709-AX10CD-retry2-validate.log`
+
+本轮使用的新 profile:
+- Source:`/Users/ar/Projects/PrivateVoice/02-P/X-vip/PrivateVoice_Dictation_Mac_App_Store_20260709_B845.provisionprofile`
+- Installed:`/Users/ar/Library/MobileDevice/Provisioning Profiles/4ec46bcc-c284-4935-834c-c31efeab62bd.provisionprofile`
+- Name:`PrivateVoice Dictation Mac App Store 20260709 B845`
+- UUID:`4ec46bcc-c284-4935-834c-c31efeab62bd`
+- App ID:`CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+- Team ID:`CR3J54M8BQ`
+- `DeveloperCertificates[0]` SHA1:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`
+- `DeveloperCertificates[0]` SHA256:`C2:56:8A:1F:B6:A2:EE:63:E2:98:31:A2:BE:FF:32:1E:9C:24:05:FD:8C:CE:6E:BE:70:B6:67:F8:26:73:C2:C8`
+- Certificate subject:`UID=CR3J54M8BQ, CN=3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ), OU=CR3J54M8BQ, O=Hainan Wanxing Technology Co., Ltd., C=US`
+- Certificate validity:`2026-07-07 05:17:39 GMT` to `2027-07-07 05:17:38 GMT`
+
+本机可用 MAS Application identity:
+- SHA1:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`
+- Subject:`3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+
+## 3. 构建命令
+
+在 `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src` 执行:
+
+```bash
+PRIVATEVOICE_APPSTORE_APP_IDENTITY='3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)' PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY='3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)' PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE='/Users/ar/Library/MobileDevice/Provisioning Profiles/4ec46bcc-c284-4935-834c-c31efeab62bd.provisionprofile' PRIVATEVOICE_APP_VERSION='2.2.3' PRIVATEVOICE_APP_BUILD='20260709.1613' ./scripts/build-macappstore-pkg-macos.sh arm64
+```
+
+结果:PASS。
+
+构建输出中的非阻断提示:
+- 前端 build 保留既有 Svelte a11y warning:`UpdateDialog.svelte` / `ToggleSwitch.svelte`。
+- Go 链接保留既有 warning:以 macOS 13.4 目标链接 `libonnxruntime.1.24.4.dylib`,该 dylib 构建目标为 macOS 14.0。
+
+## 4. 新候选产物
+
+- App:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app`
+- PKG:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice-Dictation-2.2.3-build20260709.1613-arm64-macappstore.pkg`
+- App mtime:`2026-07-09 16:13:52 +0800`
+- Executable mtime:`2026-07-09 16:13:54 +0800`
+- PKG mtime:`2026-07-09 16:13:56 +0800`
+- Version:`2.2.3`
+- Build:`20260709.1613`
+- Bundle ID:`com.shanghai3168.privatevoicedictation`
+- PKG SHA256:`1ac5bb51c16fab0f50fad66c8a73e9d23a36c0e0683dbf9544a5419b5a1e1f1b`
+
+该 SHA256 仅作为本轮 candidate 单文件证据,不是正式 release manifest / SHA256SUMS。
+
+## 5. 证书一致性结论
+
+PASS:embedded provisioning profile 的 `DeveloperCertificates[0]` leaf fingerprint 与以下 executable code 的实际 signing leaf fingerprint 完全一致:
+- `Contents/MacOS/PrivateVoice Dictation`
+- `Contents/Frameworks/libsherpa-onnx-c-api.dylib`
+- `Contents/Frameworks/libonnxruntime.1.24.4.dylib`
+
+一致 fingerprint:
+- SHA1:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`
+- SHA256:`C2:56:8A:1F:B6:A2:EE:63:E2:98:31:A2:BE:FF:32:1E:9C:24:05:FD:8C:CE:6E:BE:70:B6:67:F8:26:73:C2:C8`
+- Serial:`2177F892FF01BC726700BADA08671949`
+
+构建脚本输出也确认本轮 app signing identity 使用:
+
+```text
+3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ) (B845194A3A7FB70F526438A59B0604350D395518)
+```
+
+## 6. 签名 / entitlements / package gate
+
+已执行并通过:
+
+```bash
+codesign --verify --deep --strict --verbose=2 "/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app"
+pkgutil --check-signature "/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice-Dictation-2.2.3-build20260709.1613-arm64-macappstore.pkg"
+plutil -lint "/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app/Contents/Info.plist" "/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app/Contents/Resources/PrivacyInfo.xcprivacy"
+```
+
+结果摘要:
+- `codesign --verify --deep --strict`:PASS。
+- App TeamIdentifier:`CR3J54M8BQ`。
+- App Authority:`3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)` -> Apple WWDR -> Apple Root CA。
+- `pkgutil --check-signature`:PASS;installer identity 为 `3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`。
+- signed entitlements 与 profile 关键项对齐:
+ - `com.apple.application-identifier=CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+ - `com.apple.developer.team-identifier=CR3J54M8BQ`
+ - `com.apple.security.app-sandbox=true`
+ - `com.apple.security.device.audio-input=true`
+ - `com.apple.security.files.user-selected.read-write=true`
+ - `com.apple.security.network.client=true`
+ - `keychain-access-groups=CR3J54M8BQ.*`
+
+## 7. RPATH / dylib / bundle gate
+
+已核验:
+- 主二进制 `LC_RPATH`:仅 `@executable_path/../Frameworks`。
+- forbidden rpath count:`0`;未发现 `/Users` / Go module cache / sherpa module cache。
+- 主二进制链接:
+ - `@rpath/libsherpa-onnx-c-api.dylib`
+ - `@rpath/libonnxruntime.1.24.4.dylib`
+- `libsherpa-onnx-c-api.dylib` install name:`@rpath/libsherpa-onnx-c-api.dylib`;TeamIdentifier `CR3J54M8BQ`。
+- `libonnxruntime.1.24.4.dylib` install name:`@rpath/libonnxruntime.1.24.4.dylib`;TeamIdentifier `CR3J54M8BQ`。
+- `PrivacyInfo.xcprivacy` 已进入 bundle 并通过 lint;内容包含 `NSPrivacyAccessedAPICategoryFileTimestamp` / `C617.1`,`NSPrivacyTracking=false`,`NSPrivacyCollectedDataTypes=[]`。
+
+## 8. Package payload 权限
+
+`pkgutil --expand-full` 后核验目标文件权限:
+
+- `Contents/MacOS/PrivateVoice Dictation`:`-rwxr-xr-x ar:staff`
+- `Contents/Resources/PrivacyInfo.xcprivacy`:`-rw-r--r-- ar:staff`
+- `Contents/Frameworks/libsherpa-onnx-c-api.dylib`:`-rw-r--r-- ar:staff`
+- `Contents/Frameworks/libonnxruntime.1.24.4.dylib`:`-rw-r--r-- ar:staff`
+
+## 9. 其他门禁
+
+已执行并通过:
+
+```bash
+./scripts/verify-ax-output-boundary.sh
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+结果:
+- AX output boundary scan:PASS,未发现 IMK/TIS、event injection、global monitor、Input Monitoring、Automation、系统输入法安装或 release action。
+- `git diff --check`:PASS。
+- 发布类产物扫描:未发现新增 `.dmg` / `.zip` / `.xcarchive` / formal manifest / SHA256SUMS。
+
+## 10. 未执行项
+
+- 未上传 ASC / Transporter / TestFlight。
+- 未提交 Beta App Review / App Review / external TestFlight。
+- 未合并 main。
+- 未公开发布。
+- 未打 tag。
+- 未生成正式 release manifest / SHA256SUMS。
+- 未刷新 codebase-memory。
+- 未执行 production-signed local runtime QA;该路径仍按 AX-10BY 记录为 N/A / Apple 平台边界。
+
+## 11. 建议下一步
+
+建议 PMO 判定 `PM_NOTIFY_READY AX-10CG`,派发布 / 平台反馈角色基于新 PKG `PrivateVoice-Dictation-2.2.3-build20260709.1613-arm64-macappstore.pkg` 重跑 Apple `validate-app` / Transporter / ASC 平台反馈路径。
+
+本轮 READY 只代表本地签名 / profile 证书一致性与 build-chain gate 通过,不代表 ASC validate PASS、TestFlight 可用、App Review PASS、主线合并或正式发布完成。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CG-MAS-signing-profile-cert-fix-\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/AX-10CG-MAS-signing-profile-cert-fix-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..65fe209
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CG-MAS-signing-profile-cert-fix-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,128 @@
+# AX-10CG MAS signing / profile certificate 修复自查
+
+任务:AX-10CG / CODER MAS SIGNING PROFILE CERT FIX
+角色:Coder
+终态建议:BLOCKED AX-10CG
+日期:2026-07-09
+
+## 1. 目标与边界
+
+本轮目标是修复 AX-10CD Retry2 Apple `validate-app` 返回的 `90284 Invalid Code Signing`,并重新生成新的 MAS platform-feedback candidate。完成标准是 embedded provisioning profile 的 `DeveloperCertificates[0]` leaf fingerprint 与 App 主程序及 nested executable code 的实际签名 leaf fingerprint 一致。
+
+本轮明确不做:上传 ASC / Transporter / TestFlight、Beta App Review、App Review、公开发布、主线合并、tag、正式 release manifest / SHA256SUMS、codebase-memory 刷新、泄露 Apple secret。
+
+## 2. 输入证据
+
+- Apple 平台反馈记录:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10CD-Apple平台反馈提交记录.md`
+- validate 日志:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10CD-platform-feedback-logs/20260709-AX10CD-retry2-validate.log`
+- 证书对比日志:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10CD-platform-feedback-logs/20260709-AX10CD-retry2-cert-compare.log`
+- dylib 证书对比日志:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10CD-platform-feedback-logs/20260709-AX10CD-retry2-dylib-cert-compare.log`
+- 失败候选:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice-Dictation-2.2.3-build20260709.1336-arm64-macappstore.pkg`
+
+Apple 平台反馈结论:`validate-app` 返回 `90284 Invalid Code Signing`,点名以下 executable code 必须使用 provisioning profile 内证书签名:
+- `PrivateVoice Dictation.app/Contents/MacOS/PrivateVoice Dictation`
+- `PrivateVoice Dictation.app/Contents/Frameworks/libsherpa-onnx-c-api.dylib`
+- `PrivateVoice Dictation.app/Contents/Frameworks/libonnxruntime.1.24.4.dylib`
+
+## 3. 根因判断
+
+当前 PrivateVoice Mac App Store provisioning profile 绑定的 app signing leaf certificate 与本机可用 app signing identity 不一致。
+
+PrivateVoice MAS profile:
+- Path:`/Users/ar/Library/MobileDevice/Provisioning Profiles/b517d167-ab12-4123-b735-d670f98c542a.provisionprofile`
+- Name:`PrivateVoice Dictation Mac App Store`
+- UUID:`b517d167-ab12-4123-b735-d670f98c542a`
+- App ID:`CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+- TeamIdentifier:`CR3J54M8BQ`
+- `DeveloperCertificates[0]` subject:`UID=CR3J54M8BQ, CN=3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ), OU=CR3J54M8BQ, O=Hainan Wanxing Technology Co., Ltd., C=CN`
+- `DeveloperCertificates[0]` serial:`029291962F783D6C2C0945734091F5D1`
+- `DeveloperCertificates[0]` SHA1:`0D:7D:54:84:FD:E4:EC:1C:A2:C9:22:5F:1E:71:E6:5A:25:6A:1E:7C`
+- `DeveloperCertificates[0]` SHA256:`90:9A:C4:21:A1:1E:81:43:F5:5D:95:46:B5:9A:4B:49:BC:54:54:42:26:36:46:5A:06:D7:8A:34:4A:2B:CC:37`
+
+本机可用 app signing identity:
+- Identity:`3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+- `security find-identity -v -p codesigning` SHA1:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`
+- Subject:`UID=CR3J54M8BQ, CN=3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ), OU=CR3J54M8BQ, O=Hainan Wanxing Technology Co., Ltd., C=US`
+- Serial:`2177F892FF01BC726700BADA08671949`
+- SHA256:`C2:56:8A:1F:B6:A2:EE:63:E2:98:31:A2:BE:FF:32:1E:9C:24:05:FD:8C:CE:6E:BE:70:B6:67:F8:26:73:C2:C8`
+
+本机安装的另一个 profile `TagLauncher Mac App Store 20260707` 绑定了可用 identity `B8:45...55:18`,但它的 App ID 是 `CR3J54M8BQ.com.taglauncher.app`,不能用于正式 Bundle ID `com.shanghai3168.privatevoicedictation`,本轮未切换也不得切换。
+
+结论:AX-10CD 的失败不是单纯脚本 rpath、pkg、entitlements 或上传凭据问题,而是 PrivateVoice MAS profile 绑定了当前 keychain 不具备可用私钥 identity 的 leaf certificate。没有与 `0D:7D...1E:7C` 匹配的 codesigning identity 时,无法生成符合 Apple `validate-app` 要求的新 MAS candidate。
+
+## 4. 本轮改动
+
+已修改:
+- `C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh`
+- `CODEGRAPH.md`
+
+脚本修复:
+- 新增 codesigning identity SHA1 解析。
+- 新增 provisioning profile `DeveloperCertificates` SHA1 解析。
+- 在签名前强制检查 app signing identity leaf SHA1 是否存在于 profile `DeveloperCertificates`。
+- 使用解析后的 40 位 SHA1 fingerprint 作为 `codesign --sign` 参数,避免同名证书歧义。
+- 如果 identity 与 profile 不一致,在清理旧 build / 重新构建前 fail fast,避免再次生成会被 Apple `90284` 拒绝的候选包。
+
+`CODEGRAPH.md` 已补充 Mac App Store package script 的 profile / identity certificate preflight 职责。
+
+## 5. 新 candidate 生成结果
+
+新 MAS platform-feedback candidate:未生成。
+
+阻塞原因:修复后的 preflight 正确阻断当前环境:
+
+```text
+ERROR: App Store app signing identity certificate mismatch: identity SHA1 B845194A3A7FB70F526438A59B0604350D395518 is not present in provisioning profile DeveloperCertificates: 0D7D5484FDE4EC1CA2C9225F1E71E65A256A1E7C
+```
+
+该 fail 发生在 `Cleaning old Mac App Store build` 之前,因此没有覆盖旧 AX-10CC build 输出。
+
+## 6. 已执行验证
+
+```bash
+bash -n /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh
+```
+
+结果:PASS。
+
+```bash
+PRIVATEVOICE_APPSTORE_APP_IDENTITY='3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)' PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY='3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)' PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE='/Users/ar/Library/MobileDevice/Provisioning Profiles/b517d167-ab12-4123-b735-d670f98c542a.provisionprofile' PRIVATEVOICE_APP_VERSION='2.2.3' PRIVATEVOICE_APP_BUILD='20260709.1515' /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh arm64
+```
+
+结果:BLOCKED,profile / identity leaf SHA1 mismatch。未进入 build / sign / package。
+
+只读核验:
+- `security find-identity -v -p codesigning`:当前只有一个 `3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)` 可用 identity,SHA1 为 `B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`。
+- `/Users/ar/Library/MobileDevice/Provisioning Profiles/b517d167-ab12-4123-b735-d670f98c542a.provisionprofile`:正式 PrivateVoice Bundle ID profile,`DeveloperCertificates[0]` SHA1 为 `0D:7D:54:84:FD:E4:EC:1C:A2:C9:22:5F:1E:71:E6:5A:25:6A:1E:7C`。
+
+补充门禁:
+- `git diff --check`:PASS。
+- `./scripts/verify-ax-output-boundary.sh`:PASS。
+- 发布类产物扫描:未发现新的 `.dmg` / `.zip` / `.xcarchive` / formal manifest / SHA256SUMS。
+
+由于没有生成新 candidate,`codesign --verify` / `pkgutil --check-signature` / rpath / nested dylib Team ID 对新产物不适用。
+
+## 7. 未执行项与原因
+
+- 未生成新 `.app` / `.pkg`:缺少与 PrivateVoice MAS profile `DeveloperCertificates[0]` 匹配的可用 app signing identity / private key。
+- 未上传 ASC / Transporter / TestFlight:本轮明确禁止,且本地证书一致性 gate 未通过。
+- 未执行 `codesign --verify` / `pkgutil --check-signature` 对新候选:新候选未生成。
+- 未执行 production-signed local runtime QA:本轮不做,且 AX-10BY 已判定该路径不适合作为 production MAS candidate gate。
+- 未生成 DMG / ZIP / xcarchive / formal manifest / SHA256SUMS / tag / codebase-memory。
+
+## 8. 阻塞解除条件
+
+需要 PMO / Owner / 证书管理员提供其中之一:
+
+1. 安装与当前 PrivateVoice MAS profile `DeveloperCertificates[0]` 匹配的 app signing certificate 私钥 identity:
+ - SHA1:`0D:7D:54:84:FD:E4:EC:1C:A2:C9:22:5F:1E:71:E6:5A:25:6A:1E:7C`
+ - Subject country:`C=CN`
+2. 或在 Apple Developer Portal 为正式 Bundle ID `com.shanghai3168.privatevoicedictation` 重新生成 Mac App Store provisioning profile,使其 `DeveloperCertificates` 包含当前本机可用 app signing identity:
+ - SHA1:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`
+ - Subject country:`C=US`
+
+解除后可重新运行同一构建脚本;脚本会在构建前验证 profile / identity leaf fingerprint 是否一致。
+
+## 9. 建议下一步
+
+建议 PMO 判定 `BLOCKED AX-10CG`,归属 PMO / Owner / 证书管理员解除 profile 与 signing identity 不一致问题。解除后续派 Coder 重跑 AX-10CG,同步生成新 build `YYYYMMDD.HHMM` 的 MAS platform-feedback candidate 并完成本地签名 / profile / pkg / rpath / boundary gate。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CK-\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/AX-10CK-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..1f52c65
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CK-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,94 @@
+# AX-10CK 开发自查
+
+任务:`AX-10CK-CODER-TESTFLIGHT-AX-OUTPUT-20260709.1738`
+
+工作区:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+
+源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+
+## 目标与边界
+
+- 目标:定位并最小修复 TestFlight build `2.2.3 / 20260709.1613` 中 ASR 成功但 AX 输出失败、fallback 不可用 / 不可见的问题。
+- 不做:不生成或上传 TestFlight / ASC 包,不合并 main,不发布,不 tag,不生成正式 manifest / SHA256SUMS,不刷新 codebase-memory。
+- 本轮结果只代表可审 diff;真实 TestFlight retry 仍需 PMO 另派 candidate build 与 QA。
+
+## 输入证据
+
+- TestFlight 运行对象:`/Applications/PrivateVoice Dictation 2.app/Contents/MacOS/PrivateVoice Dictation`
+- Bundle ID:`com.shanghai3168.privatevoicedictation`
+- Version/build:`2.2.3 / 20260709.1613`
+- Receipt:`_MASReceipt/receipt` 存在
+- 签名:`TestFlight Beta Distribution`,TeamIdentifier=`CR3J54M8BQ`
+- 容器日志:`/Users/ar/Library/Containers/com.shanghai3168.privatevoicedictation/Data/Library/Application Support/PrivateVoice Input/app.log`
+- 旧非容器日志:`/Users/ar/Library/Application Support/PrivateVoice Input/app.log`
+
+## 根因判断
+
+1. TestFlight 失败不是麦克风 / ASR 问题。容器日志显示热键 hold 被接受,录音开始 / 结束正常,`Microphone permission ready state=granted`,SenseVoice CoreML 识别成功并写入 history。
+2. 失败点在 AX 输出阶段:日志多次出现 `PERF text_output_done ... result=error method="ax_focused_element" ... fallback_available=true`,错误为 `focused element unavailable: -25204` / `target=unknown`。
+3. App 名称 `/Applications/PrivateVoice Dictation 2.app` 目前没有证据表明破坏 TCC/AX trust:同一运行期日志已经出现 `Accessibility permission granted`,Keyboard event tap 也已启动并捕获热键。问题更集中在 TestFlight/MAS sandbox 运行态下,system-wide focused element 查询可返回 `kAXErrorCannotComplete(-25204)`。
+4. 现有 fallback 有两个不一致:
+ - `darwin && appstore` 路由对 AX direct failure 总是写 `FallbackAvailable=true`。
+ - 但 `PrepareLastClipboardFallback()` 又阻断 `target=unknown`,所以 TestFlight 中显示 / 日志承诺的 fallback 实际不可 prepare。
+5. 现有生产 fallback 入口只在 General 设置页中;Owner 当前观察点是权限页,权限全绿后即使 backend 保留文本,用户也看不到 pending fallback 操作入口。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/internal/axinput/axinput_darwin.go`
+ - 当 system-wide `kAXFocusedUIElementAttribute` 查询失败时,增加前台 App `AXUIElementCreateApplication(pid)` 的 focused element 回退查询。
+ - 失败消息从单一错误码扩展为 `system=<err> frontmost=<err>`,便于下一轮 TestFlight 日志判别是 system-wide 失败还是前台 App 失败。
+- `C1.source/privatevoice.src/internal/textoutput/router.go`
+ - `target=unknown` 不再阻断显式 clipboard fallback。
+ - Terminal / iTerm / Warp / shell / console / terminal-role 等明确非可写目标继续阻断,避免修改命令行窗口。
+- `C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin.go`
+ - AX direct failure 后的 `FallbackAvailable` 改为按实际 prepare 策略计算,避免 Terminal 等目标继续显示可用 fallback。
+- `C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin_test.go`
+ - 更新 Terminal 负向:`FallbackAvailable=false`,prepare 不写剪贴板。
+ - 更新 unknown target:允许显式 prepare,确保 TestFlight `target=unknown` 时用户有可控备用路径。
+- `C1.source/privatevoice.src/frontend/src/components/settings/TextOutputFallbackPanel.svelte`
+ - 新增生产 fallback 面板,复用 `TextOutputService.GetLastResult()` / `PrepareLastClipboardFallback()` / `RestoreClipboardFallback()`。
+ - 只通过 `text-output:updated` 事件触发刷新,不依赖事件 payload,不暴露 transcript / target / 窗口标题 / 文档标题。
+- `C1.source/privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte`
+ - 在权限页加入生产 fallback 面板,确保用户在权限页看到“文本已保留 / 使用剪贴板备用输入 / 恢复剪贴板”路径。
+- `CODEGRAPH.md`
+ - 更新 AX focused element 回退查询、unknown target fallback、权限页 fallback 入口说明。
+
+## 边界说明
+
+- 未引入 IMK / TIS / InputMethodKit。
+- 未引入 AppleEvents / Automation / Input Monitoring / Screen Recording。
+- 未引入 event injection / `CGEventPost` / 自动 Cmd+V / 自动粘贴。
+- Clipboard fallback 仍必须用户显式触发;prepare 只写剪贴板,不修改目标窗口;restore 仍保留用户中途改剪贴板时跳过恢复语义。
+- Terminal / shell / console 等明确非可写目标仍不可 prepare fallback。
+
+## 验证命令
+
+已通过:
+
+```bash
+gofmt -w internal/axinput/axinput_darwin.go internal/textoutput/router.go internal/textoutput/router_appstore_darwin.go internal/textoutput/router_appstore_darwin_test.go
+go test -tags appstore ./internal/textoutput ./services
+go test ./internal/axinput
+./scripts/verify-ax-output-boundary.sh
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+npm --prefix frontend run build
+go test ./...
+```
+
+说明:
+
+- `npm --prefix frontend run build` 通过;输出中仍有既有 Svelte a11y warnings,未导致 build fail。
+- Go 测试存在 macOS 链接版本 warning,命令退出码为 0。
+
+## 未验证项与风险
+
+- 未重新生成 TestFlight build,未上传 ASC / TestFlight;因此不能声明 TestFlight QA PASS。
+- 前台 App focused element fallback 是否能解决 TestFlight `-25204` 需要下一轮 signed candidate / TestFlight retry 证实。
+- `target=unknown` 允许显式 clipboard prepare 是为了给 TestFlight focused element 不可见时保留用户可控路径;它不会自动粘贴,但用户手动粘贴后的目标内容变化属于显式 fallback 操作,应由 QA 用 TestFlight 流程复测。
+
+## 建议下一步
+
+- 建议进入代码审核:重点审查 AX focused element frontmost fallback、unknown target fallback 语义、Terminal 负向是否仍被阻断、事件 payload 隐私边界是否保持。
+- 代码审核 PASS 后,由 PMO 派本地 / TestFlight candidate build 任务,再安排 Owner 复测 TextEdit / Safari 正向、Terminal / non-writable 负向、unknown target fallback prepare / restore / skip restore。
+
+终态建议:`PM_NOTIFY_READY AX-10CK`
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CM-\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/AX-10CM-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..0483e7d
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CM-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,71 @@
+# AX-10CM 开发自查
+
+任务:`AX-10CM-CODER-TESTFLIGHT-FALLBACK-PRIVACY-20260709.1806`
+
+沿用 Review Gate:AX-10CL 同 Gate 复审
+
+工作区:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+
+源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+
+## 目标与边界
+
+- 目标:处理 AX-10CL `CHANGES_REQUESTED` must-fix,关闭生产 fallback 面板直接展示最近转写正文的隐私暴露。
+- 不做:不改 AX direct insert,不改 `target=unknown` / Terminal 等 fallback 后端语义,不改 ASR / 热键 / 权限 / 发布脚本。
+- 不生成 TestFlight / MAS / DMG / ZIP / archive,不上传 ASC,不合 main,不 push,不 tag,不生成 manifest / SHA256SUMS,不刷新 codebase-memory。
+
+## 输入证据
+
+- 代码审核:`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10CL-TestFlight-AX-output代码审核.md`
+- AX-10CK 自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10CK-开发自查.md`
+- QA 输入:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10CJ-internal-TestFlight-QA-evidence.md`
+
+## 修复内容
+
+- `C1.source/privatevoice.src/frontend/src/components/settings/TextOutputFallbackPanel.svelte`
+ - 删除 `retainedTextPreview()`。
+ - 删除 `.retained-text` 正文预览 DOM 和样式。
+ - `TextOutputResult` 前端接口不再声明 / 消费 `text`、`target`、`method`、`message`。
+ - 状态判断改为只基于 `ok`、`needsPermission`、`directInserted`、`fallbackAvailable`、`createdAt` 等非正文状态字段。
+- `C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte`
+ - 同步删除既有生产 fallback 区域的正文预览,避免 General 页和 Permissions 页隐私口径不一致。
+ - 同步移除前端接口里的敏感字段消费。
+- `CODEGRAPH.md`
+ - 更新生产 fallback UI 说明:只显示泛化状态和操作消息,不显示 transcript、target、method、message、窗口标题或文档标题。
+
+## 保持不变的边界
+
+- `PrepareLastClipboardFallback()` / `RestoreClipboardFallback()` 行为未改。
+- Terminal / iTerm / Warp / shell / console / terminal-role 负向边界未改。
+- `text-output:updated` 事件 payload 未改,仍不携带 transcript / target / method / message。
+- Clipboard fallback 仍需要用户显式点击;不自动粘贴,不自动修改目标窗口内容。
+
+## 验证命令
+
+已通过:
+
+```bash
+rg -n "retainedTextPreview|retained-text|result\\?\\.text|textOutput\\?\\.text|\\.text\\?\\.trim|target\\?:|method\\?:|message\\?:|target=|AXWindow|AXTitle" frontend/src/components/settings/TextOutputFallbackPanel.svelte frontend/src/components/settings/GeneralPage.svelte frontend/src/components/settings/PermissionsPage.svelte
+npm --prefix frontend run build
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+./scripts/verify-ax-output-boundary.sh
+```
+
+结果:
+
+- 敏感字段静态搜索无命中;`rg` 退出码为 1,表示未匹配。
+- `npm --prefix frontend run build` PASS;仍有既有 Svelte a11y warnings,未导致失败。
+- `git diff --check` PASS。
+- `./scripts/verify-ax-output-boundary.sh` PASS。
+
+## 未运行项
+
+- 未运行 Go 测试:本轮未修改 Go / 后端行为。
+- 未生成候选包,未上传 TestFlight,未做真实 QA retry。
+
+## 风险与下一步
+
+- 本轮关闭 UI transcript 展示后,fallback 面板只告诉用户有待处理文本 / 可使用剪贴板备用输入;用户需要点击显式 fallback 才会把文本放入剪贴板。
+- 建议 PMO 派 AX-10CL 同 Gate 复审;复审 PASS 后再派 candidate build / TestFlight retry。
+
+终态建议:`PM_NOTIFY_READY AX-10CM`
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CN-TestFlight-candidate-\346\236\204\345\273\272\350\207\252\346\237\245.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CN-TestFlight-candidate-\346\236\204\345\273\272\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..c95b020
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CN-TestFlight-candidate-\346\236\204\345\273\272\350\207\252\346\237\245.md"
@@ -0,0 +1,126 @@
+# AX-10CN TestFlight candidate 构建自查
+
+任务:`AX-10CN-CODER-TESTFLIGHT-CANDIDATE-20260709.1814`
+
+渠道:Mac App Store / App Store Connect / TestFlight 内部测试上传候选
+
+工作区:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+
+源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+
+## 目标与边界
+
+- 目标:基于 AX-10CL 复审 PASS 后的 AX worktree 生成新的 MAS/TestFlight `.app` / `.pkg` 上传候选包,供后续发布线程上传 App Store Connect / TestFlight。
+- Marketing version:`2.2.3`
+- Build:`20260709.1815`
+- 不做:不上传 ASC / Transporter / TestFlight,不提交 Beta App Review / App Review / external TestFlight / public release,不合 main,不 push,不 tag,不生成正式 release manifest / SHA256SUMS,不刷新 codebase-memory,不泄露 Apple secret。
+
+## 输入证据
+
+- AX-10CK 自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10CK-开发自查.md`
+- AX-10CM 自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10CM-开发自查.md`
+- AX-10CL 复审 PASS:`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10CL-TestFlight-fallback隐私复审.md`
+- 上一版上传记录:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10CD-Apple平台反馈提交记录.md`
+
+## 构建源状态
+
+- Branch:`feature/mas-accessibility-direct-input`
+- HEAD:`ac1d4fd8ef875312d11acd4e4e5935ced542c227`
+- 工作区状态:存在 AX 主线集成累计待审 diff;本轮未创建 source-freeze commit,原因是当前任务明确只生成下一轮 TestFlight 上传候选,不做正式 release freeze / tag / manifest,且 AX-10CL 已对当前候选代码链路复审 PASS。
+- `git diff --name-only | wc -l`:`89`
+- `git ls-files --others --exclude-standard | wc -l`:`5`,均为 `K2.项目管理/AX-10BW-*` 过程文档,不参与本轮构建输入。
+- 结论:本候选可通过 branch + HEAD + dirty diff + 自查路径追溯;不是 clean main release,也不是正式可回滚发布包。
+
+## 产物
+
+- App:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app`
+- PKG:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice-Dictation-2.2.3-build20260709.1815-arm64-macappstore.pkg`
+- PKG SHA256:`0f4f3345050e91a0a86fc41e0d9e5aade2e7a12d16a15a6fafc5864a4d2e8783`
+- PKG mtime:`2026-07-09 18:15:29`
+- Bundle ID:`com.shanghai3168.privatevoicedictation`
+- CFBundleShortVersionString:`2.2.3`
+- CFBundleVersion:`20260709.1815`
+
+## Profile / signing
+
+- Provisioning profile:`/Users/ar/Library/MobileDevice/Provisioning Profiles/4ec46bcc-c284-4935-834c-c31efeab62bd.provisionprofile`
+- Embedded profile UUID:`4ec46bcc-c284-4935-834c-c31efeab62bd`
+- Embedded profile name:`PrivateVoice Dictation Mac App Store 20260709 B845`
+- Profile Team ID:`CR3J54M8BQ`
+- Profile App ID:`CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+- Profile DeveloperCertificates[0] SHA1:`B845194A3A7FB70F526438A59B0604350D395518`
+- App signing identity:`3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+- App / main executable / nested dylib leaf SHA1:`B845194A3A7FB70F526438A59B0604350D395518`
+- Installer identity:`3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+- Cert一致性结论:PASS,embedded profile certificate 与 App bundle、`Contents/MacOS/PrivateVoice Dictation`、`Contents/Frameworks/libsherpa-onnx-c-api.dylib`、`Contents/Frameworks/libonnxruntime.1.24.4.dylib` 的 signing leaf fingerprint 均一致。
+
+## Entitlements / profile 对齐
+
+Profile entitlements:
+
+```text
+keychain-access-groups = CR3J54M8BQ.*
+com.apple.application-identifier = CR3J54M8BQ.com.shanghai3168.privatevoicedictation
+com.apple.developer.team-identifier = CR3J54M8BQ
+```
+
+Signed entitlements:
+
+```text
+com.apple.application-identifier = CR3J54M8BQ.com.shanghai3168.privatevoicedictation
+com.apple.developer.team-identifier = CR3J54M8BQ
+com.apple.security.app-sandbox = true
+com.apple.security.device.audio-input = true
+com.apple.security.files.user-selected.read-write = true
+com.apple.security.network.client = true
+keychain-access-groups = CR3J54M8BQ.*
+```
+
+结论:App ID、Team ID、keychain group 与 profile 对齐;sandbox / audio / network / user-selected file entitlements 来自项目 App Store entitlements 并已签入 App。
+
+## 本地 gate 结果
+
+已通过:
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+./scripts/verify-ax-output-boundary.sh
+plutil -lint build/darwin/PrivacyInfo.xcprivacy
+PRIVATEVOICE_APP_VERSION=2.2.3 PRIVATEVOICE_APP_BUILD=20260709.1815 PRIVATEVOICE_APPSTORE_APP_IDENTITY=B845194A3A7FB70F526438A59B0604350D395518 PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY='3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)' PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE='/Users/ar/Library/MobileDevice/Provisioning Profiles/4ec46bcc-c284-4935-834c-c31efeab62bd.provisionprofile' ./scripts/build-macappstore-pkg-macos.sh arm64
+codesign --verify --deep --strict --verbose=2 build/macappstore/arm64/PrivateVoice\ Dictation.app
+pkgutil --check-signature build/macappstore/arm64/PrivateVoice-Dictation-2.2.3-build20260709.1815-arm64-macappstore.pkg
+```
+
+补充核验:
+
+- `plutil -extract CFBundleIdentifier raw`:`com.shanghai3168.privatevoicedictation`
+- `plutil -extract CFBundleShortVersionString raw`:`2.2.3`
+- `plutil -extract CFBundleVersion raw`:`20260709.1815`
+- `build/macappstore/arm64/PrivateVoice Dictation.app/Contents/Resources/PrivacyInfo.xcprivacy: OK`
+- `otool -l Contents/MacOS/PrivateVoice Dictation`:LC_RPATH 仅 `@executable_path/../Frameworks`,未见 `/Users` 或 Go module cache。
+- `otool -D libsherpa-onnx-c-api.dylib`:`@rpath/libsherpa-onnx-c-api.dylib`
+- `otool -D libonnxruntime.1.24.4.dylib`:`@rpath/libonnxruntime.1.24.4.dylib`
+- Package payload 权限:
+ - `.app` 目录:`drwxr-xr-x`
+ - 主程序:`-rwxr-xr-x`
+ - `PrivacyInfo.xcprivacy`:`-rw-r--r--`
+ - dylibs:`-rw-r--r--`
+- 新建时间窗口发布类产物扫描:
+ - 未新增 `.dmg` / `.zip` / `.xcarchive` / `SHA256SUMS*`。
+ - `build/macappstore/arm64` 下未生成 formal manifest / SHA256SUMS。
+ - 扫描可见历史 `build/local/*.dmg` 与 `C2.builds/*/SHA256SUMS.txt` 是既有旧产物,不是本轮生成。
+
+## 未执行项
+
+- 未上传 ASC / Transporter / TestFlight。
+- 未执行 Apple `validate-app`;本轮定位为 Coder 生成上传候选,平台验证 / 上传由后续发布线程执行。
+- 未做 production-signed local launch smoke;AX-10BY 已判定 MAS production-signed artifact 普通本地 `open -n` 可被 AMFI / taskgated 拒绝,TestFlight 运行反馈以平台上传后为准。
+- 未生成正式 release manifest / SHA256SUMS,未打 tag,未刷新 codebase-memory。
+
+## 建议下一步
+
+- PMO 派发布线程执行 App Store Connect / Transporter upload。
+- 上传前建议发布线程再次核对 PKG SHA256:`0f4f3345050e91a0a86fc41e0d9e5aade2e7a12d16a15a6fafc5864a4d2e8783`。
+- 上传成功并完成 ASC processing 后,安排 Owner 通过 TestFlight 安装 `2.2.3 / 20260709.1815` 复测 AX direct insert 与 fallback。
+
+终态建议:`PM_NOTIFY_READY AX-10CN`
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CQ-\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/AX-10CQ-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..2c28882
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CQ-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,93 @@
+# AX-10CQ 开发自查
+
+- 任务:`AX-10CQ-CODER`
+- 时间:2026-07-09 18:47 CST
+- Worktree:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- 分支:`feature/mas-accessibility-direct-input`
+- 输入证据:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10CP-TestFlight-runtime-output-fail-evidence.md`
+
+## 核心结论
+
+AX-10CP 证明 TestFlight build `2.2.3 / 20260709.1815` 的热键、麦克风、录音、ASR、历史写入均可达,失败集中在输出阶段:MAS/TestFlight runtime 下 system-wide focused element 与 frontmost app focused element 均返回 `AXError -25204`,后端已保留文本并返回 `fallback_available=true`。
+
+本轮未盲修 AX direct insert 查询路径,也未把不可写目标改成自动粘贴。最小修复聚焦生产 fallback 可用性:当新的输出结果到达时,前端 fallback 面板按后端 `createdAt` 识别新结果并清除旧的 prepare token / prepared 状态,使 `使用剪贴板备用输入` 能为每一次新的识别结果重新 prepare 当前保留文本。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/frontend/src/components/settings/TextOutputFallbackPanel.svelte`
+ - 增加 `lastOutputCreatedAt`。
+ - `refreshTextOutput()` 根据 `createdAt` 判断新输出。
+ - 新输出到达时清空旧 `fallbackToken`、`fallbackPrepared`、`outputActionText`,重新启用 prepare。
+ - 同一输出内的 restore / skip-restore 结果仍保持可见。
+- `C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte`
+ - 同步上述 fallback 状态生命周期,避免首页 / 设置总览入口与权限页入口行为不一致。
+- `C1.source/privatevoice.src/frontend/src/lib/i18n/zh.json`
+- `C1.source/privatevoice.src/frontend/src/lib/i18n/en.json`
+ - 将剪贴板设置文案从“识别内容保存到剪贴板”收敛为“剪贴板保留 / Clipboard Retention”。
+ - 明确 App Store direct input 失败时使用下方显式 fallback 按钮,避免把 `CopyToClipboard=true` 误读为 appstore route 会自动写剪贴板。
+- `CODEGRAPH.md`
+ - 补充 fallback token 以 `createdAt` 为结果边界的职责说明。
+
+## 保持边界
+
+- 未修改 AX direct insert、AXValue 写入、Terminal / iTerm / Warp / shell / console 负向策略。
+- 未修改 ASR、录音、热键、权限、发布脚本、MAS/TestFlight 构建链路。
+- 未把 fallback 改成自动 Cmd+V / 自动粘贴 / 自动修改目标窗口。
+- fallback UI 仍只显示泛化状态与操作结果,不渲染 transcript、target、method、message、窗口标题或文档标题。
+- `text-output:updated` 事件仍只作为刷新信号,不携带 transcript / target 等敏感文本。
+
+## 验证命令与结果
+
+在 `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src` 执行:
+
+```bash
+npm --prefix frontend run build
+```
+
+结果:PASS。Vite build 成功;仍有既有 Svelte a11y warnings,未由本轮引入。
+
+```bash
+./scripts/verify-ax-output-boundary.sh
+```
+
+结果:PASS。未发现 IMK/TIS、event injection、global monitor、Input Monitoring、Automation、系统输入法安装或 release action。
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+结果:PASS。
+
+```bash
+rg -n "retainedTextPreview|retained-text|result\\?\\.text|textOutput\\?\\.text|\\.text\\?\\.trim|target\\?:|method\\?:|message\\?:|windowTitle|documentTitle|transcript" frontend/src/components/settings/TextOutputFallbackPanel.svelte frontend/src/components/settings/GeneralPage.svelte frontend/src/components/settings/PermissionsPage.svelte
+```
+
+结果:PASS,无命中。
+
+```bash
+go test ./internal/textoutput ./services
+```
+
+结果:PASS。`services` 链接阶段有既有 macOS 版本 / onnxruntime dylib warning。
+
+```bash
+go test -tags appstore ./internal/textoutput ./services
+```
+
+结果:PASS。`services` 链接阶段有既有 macOS 版本 / onnxruntime dylib warning。
+
+## 未跑项
+
+- 未运行 `go test ./...`:本轮只改前端 fallback 状态、i18n 文案、CODEGRAPH;已补跑 textoutput / services 聚焦测试与 appstore-tagged 聚焦测试。
+- 未运行 `gofmt`:本轮无 Go 文件变更。
+- 未生成 MAS/TestFlight candidate,未上传 ASC/TestFlight。
+
+## 风险与下一步
+
+- TestFlight direct AX 仍可能继续返回 `AXError -25204`。本轮可审 diff 先保证失败后的显式 fallback 对每次新识别结果可重复使用,并保持隐私 / 不可写目标边界。
+- 建议下一步:派 `AX-10CR` 代码审核,重点审查前端 fallback 状态生命周期、敏感字段不渲染、Terminal/non-writable 负向边界是否未回退。审核 PASS 后再派候选构建 / TestFlight 上传任务。
+
+## 终态
+
+`PM_NOTIFY_READY AX-10CQ`
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CS-TestFlight-candidate-\346\236\204\345\273\272\350\207\252\346\237\245.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CS-TestFlight-candidate-\346\236\204\345\273\272\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..0c07be3
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CS-TestFlight-candidate-\346\236\204\345\273\272\350\207\252\346\237\245.md"
@@ -0,0 +1,139 @@
+# AX-10CS TestFlight candidate 构建自查
+
+- 任务:`AX-10CS-CODER-TESTFLIGHT-CANDIDATE-20260709.1917`
+- 时间:2026-07-09 19:22 CST
+- 渠道:Mac App Store / App Store Connect / TestFlight 内部测试上传候选
+- Worktree:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- 分支:`feature/mas-accessibility-direct-input`
+- HEAD:`ac1d4fd8ef875312d11acd4e4e5935ced542c227`
+
+## 目标与边界
+
+- 目标:基于 AX-10CR PASS 后的 AX worktree 生成新的 MAS/TestFlight `.app` / `.pkg` 上传候选,包含 AX-10CQ fallback 状态生命周期修复。
+- Marketing version:`2.2.3`
+- Build:`20260709.1919`
+- 不做:不上传 ASC / Transporter / TestFlight,不提交 Beta App Review / App Review / external TestFlight / public release,不合 main,不 push,不 tag,不生成正式 release manifest / SHA256SUMS,不刷新 codebase-memory,不泄露 Apple secret。
+
+## 输入证据
+
+- AX-10CP QA 失败证据:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10CP-TestFlight-runtime-output-fail-evidence.md`
+- AX-10CQ Coder 自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10CQ-开发自查.md`
+- AX-10CR 代码审核 PASS:`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10CR-TestFlight-fallback状态生命周期代码审核.md`
+- 上一轮候选参考:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10CN-TestFlight-candidate-构建自查.md`
+
+## 构建源状态
+
+- Branch:`feature/mas-accessibility-direct-input`
+- HEAD:`ac1d4fd8ef875312d11acd4e4e5935ced542c227`
+- 工作区状态:存在 AX 主线集成累计待审 diff;本轮未创建 source-freeze commit,原因是当前任务明确只生成下一轮 TestFlight upload candidate,不做正式 release freeze / tag / manifest,且 AX-10CR 已对 AX-10CQ 修复复审 PASS。
+- `git diff --name-only | wc -l`:`91`
+- `git ls-files --others --exclude-standard | wc -l`:`5`,均为 `K2.项目管理/AX-10BW-*` 过程文档,不参与本轮构建输入。
+- 结论:本候选通过 branch + HEAD + dirty diff + AX-10CS 自查追溯;不是 clean main release,也不是正式可回滚发布包。
+
+## 产物
+
+- App:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app`
+- PKG:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice-Dictation-2.2.3-build20260709.1919-arm64-macappstore.pkg`
+- App mtime:`2026-07-09 19:19:22 CST`
+- PKG mtime:`2026-07-09 19:19:25 CST`
+- PKG SHA256:`320f524600a5a418025bc47069a22ed48363f11f250286806a47ed56377870dc`
+- Bundle ID:`com.shanghai3168.privatevoicedictation`
+- CFBundleShortVersionString:`2.2.3`
+- CFBundleVersion:`20260709.1919`
+
+## Profile / signing
+
+- Provisioning profile:`/Users/ar/Library/MobileDevice/Provisioning Profiles/4ec46bcc-c284-4935-834c-c31efeab62bd.provisionprofile`
+- Embedded profile UUID:`4ec46bcc-c284-4935-834c-c31efeab62bd`
+- Embedded profile name:`PrivateVoice Dictation Mac App Store 20260709 B845`
+- Profile Team ID:`CR3J54M8BQ`
+- Profile App ID:`CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+- Profile DeveloperCertificates[0] SHA1:`B845194A3A7FB70F526438A59B0604350D395518`
+- App signing identity:`3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+- Installer identity:`3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+- App bundle / main executable / nested dylib leaf SHA1:`B845194A3A7FB70F526438A59B0604350D395518`
+- Cert 一致性结论:PASS。Embedded profile certificate 与 App bundle、`Contents/MacOS/PrivateVoice Dictation`、`Contents/Frameworks/libsherpa-onnx-c-api.dylib`、`Contents/Frameworks/libonnxruntime.1.24.4.dylib` 的 signing leaf fingerprint 均一致。
+
+## Entitlements / profile 对齐
+
+Profile entitlements:
+
+```text
+keychain-access-groups = CR3J54M8BQ.*
+com.apple.application-identifier = CR3J54M8BQ.com.shanghai3168.privatevoicedictation
+com.apple.developer.team-identifier = CR3J54M8BQ
+```
+
+Signed entitlements:
+
+```text
+com.apple.application-identifier = CR3J54M8BQ.com.shanghai3168.privatevoicedictation
+com.apple.developer.team-identifier = CR3J54M8BQ
+com.apple.security.app-sandbox = true
+com.apple.security.device.audio-input = true
+com.apple.security.files.user-selected.read-write = true
+com.apple.security.network.client = true
+keychain-access-groups = CR3J54M8BQ.*
+```
+
+结论:App ID、Team ID、keychain group 与 profile 对齐;sandbox / audio / network / user-selected file entitlements 来自项目 App Store entitlements 并已签入 App。
+
+## 本地 gate 结果
+
+已通过:
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+bash -n scripts/build-macappstore-pkg-macos.sh
+plutil -lint build/darwin/PrivacyInfo.xcprivacy
+./scripts/verify-ax-output-boundary.sh
+PRIVATEVOICE_APP_VERSION=2.2.3 PRIVATEVOICE_APP_BUILD=20260709.1919 PRIVATEVOICE_APPSTORE_APP_IDENTITY=B845194A3A7FB70F526438A59B0604350D395518 PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY='3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)' PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE='/Users/ar/Library/MobileDevice/Provisioning Profiles/4ec46bcc-c284-4935-834c-c31efeab62bd.provisionprofile' ./scripts/build-macappstore-pkg-macos.sh arm64
+codesign --verify --deep --strict --verbose=2 build/macappstore/arm64/PrivateVoice\ Dictation.app
+pkgutil --check-signature build/macappstore/arm64/PrivateVoice-Dictation-2.2.3-build20260709.1919-arm64-macappstore.pkg
+```
+
+补充核验:
+
+- `plutil -extract CFBundleIdentifier raw`:`com.shanghai3168.privatevoicedictation`
+- `plutil -extract CFBundleShortVersionString raw`:`2.2.3`
+- `plutil -extract CFBundleVersion raw`:`20260709.1919`
+- `build/macappstore/arm64/PrivateVoice Dictation.app/Contents/Resources/PrivacyInfo.xcprivacy: OK`
+- `otool -l Contents/MacOS/PrivateVoice Dictation`:LC_RPATH 仅 `@executable_path/../Frameworks`,未见 `/Users` 或 Go module cache。
+- `otool -D libsherpa-onnx-c-api.dylib`:`@rpath/libsherpa-onnx-c-api.dylib`
+- `otool -D libonnxruntime.1.24.4.dylib`:`@rpath/libonnxruntime.1.24.4.dylib`
+- Dylib Team ID:`CR3J54M8BQ`
+- App bundle quarantine xattr:未见 `com.apple.quarantine`
+- Package payload 权限:
+ - `.app` 目录:`drwxr-xr-x`
+ - 主程序:`-rwxr-xr-x`
+ - `PrivacyInfo.xcprivacy`:`-rw-r--r--`
+ - dylibs:`-rw-r--r--`
+- 新建时间窗口发布类产物扫描:
+ - 新增预期产物仅 `.pkg`:`build/macappstore/arm64/PrivateVoice-Dictation-2.2.3-build20260709.1919-arm64-macappstore.pkg`
+ - 未新增 `.dmg` / `.zip` / `.xcarchive` / formal manifest / `SHA256SUMS*`。
+- `git status --short -- build C1.source/privatevoice.src/build C1.source/privatevoice.src/frontend/dist`:未显示本轮构建产物进入 Git 待审范围;仅既有待审 `C1.source/privatevoice.src/build/darwin/PrivacyInfo.xcprivacy`。
+
+## 构建输出备注
+
+- 构建脚本内 `npm --prefix frontend run build` PASS;仍有既有 Svelte a11y warnings。
+- Go appstore build PASS;链接阶段仍有既有 onnxruntime macOS 版本 warning。
+- `pkgutil --check-signature` 输出 `Status: signed by a developer certificate issued by Apple (Development)`,证书链显示实际 installer identity 为 `3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`。
+
+## 未执行项
+
+- 未上传 ASC / Transporter / TestFlight。
+- 未提交 Beta App Review / App Review / external TestFlight / public release。
+- 未执行 Apple `validate-app`;本轮定位为 Coder 生成上传候选,平台验证 / 上传由后续发布线程执行。
+- 未做 production-signed local launch smoke;此前 AX-10BY 已判定 MAS production-signed artifact 普通本地 `open -n` 可被 AMFI / taskgated 拒绝,TestFlight 运行反馈以平台上传后为准。
+- 未生成正式 release manifest / SHA256SUMS,未打 tag,未刷新 codebase-memory。
+
+## 建议下一步
+
+- PMO 派发布线程执行 App Store Connect / Transporter upload。
+- 上传前建议发布线程再次核对 PKG SHA256:`320f524600a5a418025bc47069a22ed48363f11f250286806a47ed56377870dc`。
+- 上传成功并完成 ASC processing 后,安排 Owner 通过 TestFlight 安装 `2.2.3 / 20260709.1919` 复测 AX direct insert 与 fallback 状态生命周期,重点验证连续两次以上识别失败后“使用剪贴板备用输入”每次都能 prepare 最新文本。
+
+## 终态
+
+`PM_NOTIFY_READY AX-10CS`
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CV-\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/AX-10CV-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..44ef88e
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CV-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,80 @@
+# AX-10CV 开发自查
+
+任务:`AX-10CV-CODER-TESTFLIGHT-RUNTIME-OUTPUT-20260710.1140`
+
+工作区:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+
+源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+
+分支:`feature/mas-accessibility-direct-input`
+
+## 目标与边界
+
+目标是修复 internal TestFlight build `2.2.3 / 20260709.1919` 中 ASR 已产出后无法上屏、且显式剪贴板 fallback 不能形成可粘贴正确内容的问题。
+
+本轮不生成 MAS / TestFlight candidate,不上传 ASC / TestFlight,不合并 main,不 push,不 tag,不生成正式 manifest / SHA256SUMS,不刷新 codebase-memory。
+
+## 输入证据
+
+- QA 证据:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10CU-TestFlight-1919-runtime-output-fail-evidence.md`
+- 既有证据:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10CP-TestFlight-runtime-output-fail-evidence.md`
+- 前置自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10CQ-开发自查.md`
+- 前置审核:`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10CR-TestFlight-fallback状态生命周期代码审核.md`
+- 最新上传记录:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10CT-TestFlight-upload记录.md`
+- 运行日志:`/Users/ar/Library/Containers/com.shanghai3168.privatevoicedictation/Data/Library/Application Support/PrivateVoice Input/app.log`
+
+证据口径:近期 TestFlight 运行中热键、录音、ASR 均可达;AX direct output 仍返回 focused element unavailable,且 fallback 可用。但日志中没有后端 fallback prepare / clipboard write 成功证据,不能判为用户误操作。
+
+## 根因判断
+
+1. TestFlight / MAS sandbox 下 AX direct insert 仍在 system-wide focused element 与 frontmost application focused element 两条路径均失败,错误码为 `-25204`。本轮没有继续盲改 AX 查询路径;下一版仍需 QA 观察 direct AX 是否可恢复。
+2. 显式 fallback 的关键缺口在 macOS AX clipboard driver:`PrepareClipboardFallback` 之前调用 pasteboard 写入后没有验证返回值和回读内容,Go 层会直接生成 restore token 并向前端报告 prepare 成功。若 TestFlight / sandbox / pasteboard 写入失败,UI 可能显示可用但用户 `Cmd+V` 粘贴不到正确内容。
+3. 后端缺少隐私安全的 fallback prepare / restore 链路日志,无法区分按钮点击、Wails service、router、token、pasteboard write/readback 哪一层失败。
+4. 旧默认日志会输出识别正文,以及 output method / target / message。AX-10CV 明确要求生产 UI / event payload / 默认日志不暴露 transcript、target、method、message,本轮一并收敛该输出阶段日志。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/internal/axinput/axinput_darwin.go`
+ - `pvSetClipboardText` 改为返回成功 / 失败,并在写入后回读 `NSPasteboardTypeString` 确认等于请求文本。
+ - `pvClearClipboard` 改为返回成功 / 失败,并确认清空后无 string 内容。
+ - `PrepareClipboardFallback` 只有在写入并回读确认成功后才创建 restore token;写入失败返回 failed result,不再假成功。
+ - `RestoreClipboardFallback` 恢复原剪贴板或清空剪贴板时同样检查写入 / 清空结果,失败时返回 failed result。
+- `C1.source/privatevoice.src/internal/axinput/clipboard_fallback_darwin_test.go`
+ - 新增 macOS driver focused tests,覆盖 pasteboard 写入失败不创建 token、写入成功才创建 token、restore 写回失败会报告失败。
+- `C1.source/privatevoice.src/internal/textoutput/router.go`
+ - 增加隐私安全日志,记录 fallback prepare / restore 的 generalized status、bool、token presence、text length;不记录正文、target、method、message。
+- `C1.source/privatevoice.src/services/text_output_service.go`
+ - 增加 Wails service 层 prepare / restore 入口与返回日志,便于确认按钮点击是否到达后端;日志不含敏感文本。
+- `C1.source/privatevoice.src/app.go`
+ - 识别完成日志改为只记录 `text_runes`。
+ - text output done / failed 日志去掉 method、target、message,仅记录 `fallback_available`、`direct_inserted`、`needs_permission`、`text_runes` 等非敏感字段。
+- `CODEGRAPH.md`
+ - 更新 AX output / fallback 职责说明,记录 pasteboard 回读验证和隐私安全日志口径。
+
+## 验证命令与结果
+
+- `gofmt -w app.go internal/axinput/axinput_darwin.go internal/axinput/clipboard_fallback_darwin_test.go internal/textoutput/router.go services/text_output_service.go`:PASS
+- `go test ./internal/axinput`:PASS
+- `go test ./internal/textoutput ./services`:PASS;有既有 macOS linker warning,不影响退出码。
+- `go test -tags appstore ./internal/axinput ./internal/textoutput ./services`:PASS;有既有 macOS linker warning,不影响退出码。
+- `go test -race ./internal/axinput`:PASS
+- `go test -race -tags appstore ./internal/axinput ./internal/textoutput ./services`:PASS;有既有 macOS linker warning,不影响退出码。
+- `go test ./...`:PASS;有既有 macOS linker warning,不影响退出码。
+- `npm --prefix frontend run build`:PASS;有既有 Svelte a11y warning。
+- `./scripts/verify-ax-output-boundary.sh`:PASS
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`:PASS
+- 静态敏感字段检查:`rg -n "Recognized:|Text output failed: method=|text_output_done .*method=|target=.*message=|transcript|window title|document title" app.go internal/axinput internal/textoutput services frontend/src/components/settings/TextOutputFallbackPanel.svelte` 无命中。
+
+## 风险与未覆盖
+
+- 本轮没有生成新的 TestFlight candidate,也没有在 TestFlight sandbox 运行态实际点击 fallback;这需要代码审核 PASS 后由 PMO 派后续 candidate build / TestFlight 上传 / Owner retry。
+- AX direct insert 在 TestFlight 中仍可能继续因 `-25204` 失败;本轮重点是让 direct 失败后的显式 fallback 可验证、可失败回报、可重复使用。
+- 当前 fallback 仍是用户显式动作:prepare 只写剪贴板,不自动粘贴;Terminal / shell / console 等已识别不可写目标仍不自动写入或自动粘贴。
+
+## 结论
+
+`PM_NOTIFY_READY AX-10CV`
+
+可进入代码审核。建议 Review Gate:`AX-10CW-GATE-20260710.1140`。
+
+下一步建议:PMO 派代码审核员审查 AX-10CV diff;审核 PASS 后再生成新的 TestFlight candidate,验证 fallback prepare 是否在 TestFlight sandbox 下产生后端成功日志,并确认用户 `Cmd+V` 能粘贴最新保留文本。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CX-TestFlight-candidate-\346\236\204\345\273\272\350\207\252\346\237\245.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CX-TestFlight-candidate-\346\236\204\345\273\272\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..37914f3
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10CX-TestFlight-candidate-\346\236\204\345\273\272\350\207\252\346\237\245.md"
@@ -0,0 +1,112 @@
+# AX-10CX TestFlight Candidate 构建自查
+
+任务:`AX-10CX-CODER-TESTFLIGHT-CANDIDATE-20260710.1200`
+
+工作区:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+
+源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+
+分支:`feature/mas-accessibility-direct-input`
+
+HEAD:`ac1d4fd8ef875312d11acd4e4e5935ced542c227`
+
+## 目标与边界
+
+目标是在 AX-10CW `REVIEW_RESULT AX-10CW PASS` 后生成新的 MAS / TestFlight upload candidate,用于验证 AX-10CV clipboard fallback 修复。
+
+本轮只生成本地 `.app` / `.pkg` 上传候选,不上传 ASC / Transporter / TestFlight,不提交 Beta App Review / App Review,不开放 external TestFlight,不 public release,不合并 main,不 push,不 tag,不生成正式 release manifest / SHA256SUMS,不刷新 codebase-memory。
+
+Production-signed local launch smoke 按 AX-10BY 口径为 N/A,本轮未执行。
+
+## 输入证据
+
+- AX-10CW 代码审核:`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10CW-TestFlight-runtime-output-fallback代码审核.md`
+- AX-10CV 自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10CV-开发自查.md`
+- AX-10CU QA 输入:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10CU-TestFlight-1919-runtime-output-fail-evidence.md`
+- 最新上传记录输入:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10CT-TestFlight-upload记录.md`
+
+## Candidate 信息
+
+- Marketing version:`2.2.3`
+- Build:`20260710.1200`
+- Bundle ID:`com.shanghai3168.privatevoicedictation`
+- Team ID:`CR3J54M8BQ`
+- Profile:`PrivateVoice Dictation Mac App Store 20260709 B845`
+- Profile UUID:`4ec46bcc-c284-4935-834c-c31efeab62bd`
+- App signing identity SHA1:`B845194A3A7FB70F526438A59B0604350D395518`
+- Installer identity:`3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+- App path:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app`
+- PKG path:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice-Dictation-2.2.3-build20260710.1200-arm64-macappstore.pkg`
+- PKG SHA256:`3379439e72418e489735355489b47d229f716253475e147d586cdcf1666c84eb`
+
+## 构建命令
+
+```bash
+PRIVATEVOICE_APP_VERSION=2.2.3 PRIVATEVOICE_APP_BUILD=20260710.1200 PRIVATEVOICE_APPSTORE_APP_IDENTITY=B845194A3A7FB70F526438A59B0604350D395518 PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY="3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)" PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE="/Users/ar/Library/MobileDevice/Provisioning Profiles/4ec46bcc-c284-4935-834c-c31efeab62bd.provisionprofile" bash -n ./scripts/build-macappstore-pkg-macos.sh
+PRIVATEVOICE_APP_VERSION=2.2.3 PRIVATEVOICE_APP_BUILD=20260710.1200 PRIVATEVOICE_APPSTORE_APP_IDENTITY=B845194A3A7FB70F526438A59B0604350D395518 PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY="3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)" PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE="/Users/ar/Library/MobileDevice/Provisioning Profiles/4ec46bcc-c284-4935-834c-c31efeab62bd.provisionprofile" ./scripts/build-macappstore-pkg-macos.sh arm64
+```
+
+结果:PASS。构建过程中 `npm run build` PASS,存在既有 Svelte a11y warning;Go appstore build PASS,存在既有 `libonnxruntime` minimum macOS linker warning。
+
+## 本地 Gate 结果
+
+- `bash -n C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh`:PASS
+- Info.plist:
+ - `CFBundleIdentifier=com.shanghai3168.privatevoicedictation`
+ - `CFBundleShortVersionString=2.2.3`
+ - `CFBundleVersion=20260710.1200`
+- Embedded profile:
+ - UUID:`4ec46bcc-c284-4935-834c-c31efeab62bd`
+ - Name:`PrivateVoice Dictation Mac App Store 20260709 B845`
+ - App ID:`CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+ - Team ID:`CR3J54M8BQ`
+ - DeveloperCertificates[0] SHA1:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`
+- Signing leaf consistency:
+ - App bundle leaf:`B845194A3A7FB70F526438A59B0604350D395518`,matches profile:yes
+ - Main executable leaf:`B845194A3A7FB70F526438A59B0604350D395518`,matches profile:yes
+ - `libsherpa-onnx-c-api.dylib` leaf:`B845194A3A7FB70F526438A59B0604350D395518`,matches profile:yes
+ - `libonnxruntime.1.24.4.dylib` leaf:`B845194A3A7FB70F526438A59B0604350D395518`,matches profile:yes
+- `codesign --verify --deep --strict --verbose=2 <App>`:PASS
+- Signed entitlements:
+ - `com.apple.application-identifier=CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+ - `com.apple.developer.team-identifier=CR3J54M8BQ`
+ - `com.apple.security.app-sandbox=true`
+ - `com.apple.security.device.audio-input=true`
+ - `com.apple.security.network.client=true`
+ - `com.apple.security.files.user-selected.read-write=true`
+ - `keychain-access-groups=CR3J54M8BQ.*`
+- `pkgutil --check-signature <PKG>`:PASS
+- Main binary `LC_RPATH`:only `@executable_path/../Frameworks`;no `/Users` / Go module cache path.
+- Dylib install names:
+ - `libsherpa-onnx-c-api.dylib`:`@rpath/libsherpa-onnx-c-api.dylib`
+ - `libonnxruntime.1.24.4.dylib`:`@rpath/libonnxruntime.1.24.4.dylib`
+- Dylib Team ID / signing chain:
+ - both nested dylibs signed by `3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+ - TeamIdentifier:`CR3J54M8BQ`
+- `plutil -lint` for source and bundled `PrivacyInfo.xcprivacy`:PASS
+- PrivacyInfo bundle presence:PASS,`Contents/Resources/PrivacyInfo.xcprivacy` exists.
+- Package payload permissions:PASS;sample shows app directories `drwxr-xr-x`, main executable `-rwxr-xr-x`, resource/profile files `-rw-r--r--`; owner-only files: none; non-searchable dirs: none.
+- `./scripts/verify-ax-output-boundary.sh`:PASS
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`:PASS
+- 发布类产物扫描:PASS;未新增 `.dmg` / `.zip` / `.xcarchive` / formal manifest / `SHA256SUMS`。
+
+## 工作区说明
+
+当前 worktree 仍包含 AX 主线集成以来的待审源码 / 文档 diff,包括 AX-10CV 修复和既有前置 AX diff。本轮没有提交、没有 tag、没有 push。候选构建源为当前 AX worktree 状态,不是正式可回滚发布冻结。
+
+## 未执行项
+
+- 未上传 ASC / Transporter / TestFlight。
+- 未提交 Beta App Review / App Review。
+- 未开放 external TestFlight。
+- 未 public release。
+- 未合 main、未 push、未 tag、未生成正式 release manifest / SHA256SUMS、未刷新 codebase-memory。
+- 未执行 production-signed local launch smoke;按 AX-10BY 为 N/A。
+
+## 结论
+
+`PM_NOTIFY_READY AX-10CX`
+
+新的 MAS / TestFlight upload candidate 已生成,本地 build-chain / signing / profile / pkg / rpath / privacy / boundary gate PASS。
+
+建议下一步:PMO 派发布线程执行 Apple validate / upload / processing;上传成功并进入 internal TestFlight 可测状态后,再派 Owner retry 验证 AX-10CV clipboard fallback 修复。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10D-\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/AX-10D-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..93d7d90
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10D-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,146 @@
+# AX-10D 开发自查
+
+任务 ID:`AX-10D-CODER-20260706.1635`
+
+时间:2026-07-06 16:42 +0800
+
+结论:`PM_NOTIFY_READY AX-10D`
+
+Review Gate ID:`AX-10E-GATE-20260706.1635`
+
+## 目标与边界
+
+本轮目标是处理 `AX-10C` QA PARTIAL 后的主线可验收性缺口:补齐生产可接受的显式 clipboard fallback 操作入口,并给 QA 一个稳定、可审计的操作路径覆盖 prepare / restore / skip restore。
+
+本轮未处理 `AX-MAS-BUILD-GATE`,未处理 `AX-QA-PERMISSION` 的 TCC 未授权 / 撤销切换,未发布、未 push、未 tag、未生成 manifest、未刷新 codebase-memory。
+
+## 工作区
+
+- Git 根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- 分支:`feature/mas-accessibility-direct-input`
+- HEAD:`ac1d4fd8ef875312d11acd4e4e5935ced542c227`
+- 前置:
+ - `AX-10A` 自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10A-开发自查.md`
+ - `AX-10B` 代码审核 PASS:`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10B-主线AX输出代码审核.md`
+ - `AX-10C` QA evidence:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10C-主线AX输出QA-evidence.md`
+
+## 变更文件
+
+本轮新增 / 修改重点:
+
+- `C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte`
+ - 新增生产化 fallback 状态面板。
+ - 调用 `TextOutputService.GetLastResult` 刷新最近输出结果。
+ - 调用 `TextOutputService.PrepareLastClipboardFallback` 显式准备剪贴板备用输入。
+ - 调用 `TextOutputService.RestoreClipboardFallback` 显式恢复原剪贴板。
+ - 用户中途改剪贴板时,后端返回 restore skipped,前端显示 `已保留你当前的剪贴板`。
+ - 普通 UI 只显示可理解状态,不显示 pid、role、AXValue、selectedText 等 raw AX 诊断。
+- `C1.source/privatevoice.src/frontend/src/lib/i18n/zh.json`
+ - 新增中文生产文案。
+- `C1.source/privatevoice.src/frontend/src/lib/i18n/en.json`
+ - 新增英文生产文案;其他语言按现有 i18n fallback 机制回退英文。
+- `C1.source/privatevoice.src/scripts/verify-ax-output-boundary.sh`
+ - 扫描范围扩展到 `GeneralPage.svelte`,覆盖本轮新增 UI。
+- `CODEGRAPH.md`
+ - 记录生产 fallback 控件位置和边界。
+
+AX-10A 已形成且仍在待审 diff 中的基础文件:
+
+- `C1.source/privatevoice.src/app.go`
+- `C1.source/privatevoice.src/internal/axinput/*`
+- `C1.source/privatevoice.src/internal/textoutput/*`
+- `C1.source/privatevoice.src/services/text_output_service.go`
+- `C1.source/privatevoice.src/scripts/verify-ax-output-boundary.sh`
+- `CODEGRAPH.md`
+- `K2.项目管理/AX-10A-开发自查.md`
+
+## 对 AX-10C PARTIAL 的处理
+
+1. `frontend 没有可操作的生产 clipboard fallback 入口`
+ - 已在 `GeneralPage.svelte` 增加生产入口:`刷新状态`、`使用剪贴板备用输入`、`恢复原剪贴板`。
+ - 不使用 P2 的固定文本、POC 面板或调试命名。
+
+2. `prepare / restore / skip restore 无法端到端验证`
+ - QA 可在 direct insert 失败后打开设置首页,点击 `刷新状态`,看到 `文本已保留,可使用剪贴板备用输入`。
+ - 点击 `使用剪贴板备用输入` 后,后端把本次转写文本放入剪贴板,并返回 token。
+ - 普通 restore:不改剪贴板,点击 `恢复原剪贴板`,应显示 `已恢复原剪贴板`。
+ - skip restore:点击 `使用剪贴板备用输入` 后,QA 手动复制任意新文本,再点击 `恢复原剪贴板`,应显示 `已保留你当前的剪贴板`,并且不覆盖用户新剪贴板。
+
+3. `失败态必须保留文本,并让用户看到可理解状态`
+ - 失败且 fallback 可用时显示 `文本已保留,可使用剪贴板备用输入`。
+ - 面板显示本次保留文本的短预览,方便用户 / QA 确认不是固定 POC 文本。
+ - 不显示 raw AX 诊断;raw 诊断仍在后端日志和 service result 中供 QA / review 深查。
+
+4. `稳定 QA 操作路径`
+ - QA 不需要调用 P2 spike App,不需要固定 POC 文本,不需要猜旧 `/Applications` App。
+ - QA 使用 AX worktree local appstore-tagged QA build,并确认运行对象路径为 `AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/local/arm64/PrivateVoice Dictation.app` 或后续同 worktree build 产物。
+ - 用真实 ASR 链路触发 direct insert;若目标不可写或焦点不支持 AX 写入,打开设置首页刷新状态并执行 fallback 矩阵。
+
+5. `默认 / Developer ID / Windows 路径保持不变`
+ - 本轮只改前端 fallback UI、i18n、边界脚本和 CODEGRAPH。
+ - Go router 仍保持 AX-10A 设计:默认 / Developer ID / Windows 走 legacy paste/type;`darwin && appstore` 才走 AX direct insert + explicit fallback。
+
+## QA 操作路径
+
+建议 QA retry 使用以下路径:
+
+1. 从 AX worktree 源码根构建 / 启动 appstore-tagged local QA App,避免旧 `/Applications` App 或 P2 spike App。
+2. 确认系统 Accessibility 当前授权态;未授权 / 撤销仍归 `AX-QA-PERMISSION`,本轮不覆盖。
+3. TextEdit 正向:
+ - 打开 TextEdit 文本区,输入可辨认前缀。
+ - 用真实热键 / 真实 ASR 触发转写。
+ - 观察目标内容是否出现真实 ASR 文本;成功时设置页刷新状态应为 `已插入到当前输入位置`。
+4. 第二可写目标:
+ - 使用 Safari textarea 或等价可写文本区域重复第 3 步。
+5. Terminal 负向:
+ - 聚焦 Terminal / shell。
+ - 用真实 ASR 触发输出。
+ - PASS 口径:不误报 direct insert 成功;设置页刷新后显示文本保留 / fallback 可用。
+6. Fallback prepare / restore:
+ - 在失败保留状态点击 `使用剪贴板备用输入`。
+ - 手动粘贴确认剪贴板内容为本次真实 ASR 文本。
+ - 不改剪贴板时点击 `恢复原剪贴板`,确认恢复状态。
+7. Fallback skip restore:
+ - 再次制造失败保留状态并点击 `使用剪贴板备用输入`。
+ - QA 手动复制一段新文本。
+ - 点击 `恢复原剪贴板`,确认 UI 显示 `已保留你当前的剪贴板`,并验证新剪贴板没有被覆盖。
+
+## 验证命令与结果
+
+在 `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src` 执行:
+
+- `gofmt -w app.go internal/axinput/axinput.go internal/axinput/axinput_darwin.go internal/axinput/axinput_other.go internal/axinput/clipboard_state.go internal/axinput/clipboard_state_test.go internal/textoutput/router.go internal/textoutput/router_default.go internal/textoutput/router_test.go internal/textoutput/router_appstore_darwin.go internal/textoutput/router_appstore_darwin_test.go services/text_output_service.go`
+ - PASS。
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`
+ - PASS。
+- `./scripts/verify-ax-output-boundary.sh`
+ - PASS;扫描范围包含 `GeneralPage.svelte`,未发现 IMK/TIS、event injection、global monitor、Input Monitoring、Automation、系统输入法安装或发布动作。
+- `go test ./internal/axinput ./internal/textoutput ./services`
+ - PASS;保留既有 macOS SDK / onnxruntime link warning。
+- `go test -tags appstore ./internal/axinput ./internal/textoutput ./services`
+ - PASS;保留既有 macOS SDK / onnxruntime link warning。
+- `npm --prefix frontend run build`
+ - PASS;保留既有 Svelte a11y warning:`UpdateDialog.svelte` dialog tabindex / noninteractive listener、`ToggleSwitch.svelte` aria label。
+- `go test ./...`
+ - PASS;保留既有 macOS SDK / onnxruntime link warning。
+
+## Git / 待审范围说明
+
+本轮未提交、未 push。新增自查已通过 `git add -N` 纳入待审 diff。
+
+`frontend/dist/` 与 `frontend/node_modules/` 是本地验证产物,仍为 ignored,不进入待审 diff。
+
+## 未做事项与风险
+
+- 本轮未运行真实 GUI QA,不声明 `AX-10F` PASS。
+- 本轮未处理 `AX-MAS-BUILD-GATE`。
+- 本轮未处理 Accessibility 未授权 / 撤销 / 重新授权真实 TCC 切换,仍归 `AX-QA-PERMISSION`。
+- 本轮未新增 fallback 开关配置;当前最小范围只补齐显式操作入口和状态。若产品要求用户可关闭 fallback,需要另立配置 / 设置项任务或在 AX-10E 中明确为 must-fix。
+- 既有主应用 Input Monitoring / 全局热键路径仍属于触发路径,不是本轮 AX output fallback 路径;本轮未改默认 / Developer ID / Windows 行为。
+
+## 终态
+
+`PM_NOTIFY_READY AX-10D`
+
+请求 PMO 派发 `AX-10E` 代码审核,Review Gate ID:`AX-10E-GATE-20260706.1635`。代码审核 PASS 前不得进入 `AX-10F` QA retry。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10D1-\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/AX-10D1-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..788e5f7
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10D1-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,107 @@
+# AX-10D1 开发自查
+
+任务 ID:`AX-10D1-CODER-20260706.1648`
+
+时间:2026-07-06 16:51 +0800
+
+结论:`PM_NOTIFY_READY AX-10D1`
+
+复审 Gate:`AX-10E-GATE-20260706.1635`
+
+## 目标与边界
+
+本轮只处理 `AX-10E` 代码审核 `CHANGES_REQUESTED` 的两个 must-fix:
+
+1. restore / skip restore 完成后的提示不能被自动刷新立即清空。
+2. 已 prepare 且未 restore 前,不能再次 prepare 覆盖当前 restore token。
+
+本轮未扩大 UI,未处理 `AX-MAS-BUILD-GATE`,未处理 `AX-QA-PERMISSION`,未发布、未 push、未 tag、未生成 manifest、未刷新 codebase-memory。
+
+## 工作区
+
+- Git 根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- 分支:`feature/mas-accessibility-direct-input`
+- 前置自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10D-开发自查.md`
+- 审核证据:`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10E-主线AX输出fallback补修代码审核.md`
+
+## 变更文件
+
+- `C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte`
+ - `refreshTextOutput()` 新增 `preserveActionText` 参数。
+ - `restoreClipboardFallback()` 完成后调用 `refreshTextOutput(true)`,刷新后端最新状态但保留本次 restore / skip restore / restore failed 提示。
+ - `prepareClipboardFallback()` 在 `fallbackPrepared=true` 时直接返回。
+ - `使用剪贴板备用输入` 按钮在 `fallbackPrepared=true` 时禁用,要求用户先恢复或触发下一轮输出状态。
+ - 手动 `刷新状态` 按钮改为显式无参调用 `refreshTextOutput()`,因此用户主动刷新会按预期清理旧动作提示。
+
+## Must-Fix 说明
+
+### 1. restore / skip restore 提示不会被自动刷新清空
+
+修复后,restore 流程先根据后端结果设置:
+
+- `textOutputClipboardRestored`
+- `textOutputClipboardKept`
+- `textOutputClipboardRestoreFailed`
+
+随后 finally 中只执行 `refreshTextOutput(true)`。该刷新会更新 `textOutput` 和 `outputStatusText`,但不会清空 `outputActionText`。因此普通 restore 的 `已恢复原剪贴板`、skip restore 的 `已保留你当前的剪贴板`、异常时的 `未能确认剪贴板已恢复` 会保持可见。
+
+这些提示会在用户下一次手动刷新、下一次 prepare 或下一次输出状态操作时被新的动作状态替换或清理,满足 AX-10E 要求。
+
+### 2. 重复 prepare 不会覆盖 restore token
+
+修复后,前端有两层保护:
+
+- `prepareClipboardFallback()` 开头在 `fallbackPrepared=true` 时直接返回,不再调用后端 prepare。
+- UI 按钮禁用条件加入 `fallbackPrepared`,已有未恢复 token 时无法再次点击 `使用剪贴板备用输入`。
+
+因此第一次 prepare 返回的 `fallbackToken` 不会被第二次 prepare 覆盖,用户仍可通过当前 UI 执行 `恢复原剪贴板`,不会丢失原剪贴板恢复入口。
+
+## QA 验证路径
+
+普通 restore:
+
+1. 制造一次 AX direct insert 失败并保留文本。
+2. 打开设置页,点击 `刷新状态`,确认 fallback 可用。
+3. 点击 `使用剪贴板备用输入`,确认按钮进入已 prepare 状态且 `使用剪贴板备用输入` 变为不可用。
+4. 不修改剪贴板,点击 `恢复原剪贴板`。
+5. 验证 UI 持续显示 `已恢复原剪贴板`,不会被自动刷新立即清空。
+
+skip restore:
+
+1. 再次制造失败保留状态。
+2. 点击 `使用剪贴板备用输入`。
+3. 手动复制任意新文本,模拟用户中途改剪贴板。
+4. 点击 `恢复原剪贴板`。
+5. 验证 UI 持续显示 `已保留你当前的剪贴板`,且用户新剪贴板没有被覆盖。
+
+重复 prepare 防护:
+
+1. 点击 `使用剪贴板备用输入` 后,在 `恢复原剪贴板` 前观察该按钮为禁用状态。
+2. 确认无法通过 UI 重复 prepare 覆盖当前 token。
+
+## 验证命令与结果
+
+在 `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src` 执行:
+
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`
+ - PASS。
+- `./scripts/verify-ax-output-boundary.sh`
+ - PASS:未发现 IMK/TIS、event injection、global monitor、Input Monitoring、Automation、系统输入法安装或发布动作。
+- `npm --prefix frontend run build`
+ - PASS;保留既有 Svelte a11y warning:`UpdateDialog.svelte` dialog tabindex / noninteractive listener、`ToggleSwitch.svelte` aria label。
+
+本轮无 Go 文件变更,因此未运行 `gofmt` 和 Go 聚焦测试;AX-10D 的 Go 门禁结果仍由前置自查记录。
+
+## 未做事项与风险
+
+- 本轮未运行真实 GUI QA,不声明 `AX-10F` PASS。
+- 本轮未处理 `AX-MAS-BUILD-GATE`。
+- 本轮未处理 Accessibility 未授权 / 撤销 / 重新授权真实 TCC 切换,仍归 `AX-QA-PERMISSION`。
+- 本轮未发布、未 push、未 tag、未生成 manifest、未刷新 codebase-memory。
+
+## 终态
+
+`PM_NOTIFY_READY AX-10D1`
+
+请求 PMO 将 `AX-10E-GATE-20260706.1635` 交代码审核员同项复审;复审 PASS 前不得进入 `AX-10F` QA retry。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DA-TestFlight-1200-fallback-real-paste-\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/AX-10DA-TestFlight-1200-fallback-real-paste-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..95bf635
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DA-TestFlight-1200-fallback-real-paste-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,89 @@
+# AX-10DA TestFlight 1200 fallback real paste 开发自查
+
+任务:`AX-10DA-CODER-TESTFLIGHT-1200-FALLBACK-REAL-PASTE-20260710.1235`
+
+终态:`PM_NOTIFY_READY AX-10DA`
+
+## 目标与边界
+
+- 目标:修复 internal TestFlight `2.2.3 / 20260710.1200` 中 direct AX 失败后,显式或配置驱动的 clipboard fallback 无法让用户 `Cmd+V` 粘贴最新识别文本的问题。
+- 完成标准:形成可审 diff。下一版在 direct 上屏失败时,至少能稳定通过用户可控的剪贴板备用输入链路把最新识别文本放入系统 pasteboard,供用户手动粘贴。
+- 不做:不生成 MAS/TestFlight candidate,不上传 ASC/TestFlight,不合 main,不 push,不 tag,不生成 manifest/SHA256,不刷新 codebase-memory;不改 AX direct insert、ASR、热键、权限策略或发布链路。
+- 隐私边界:新增日志只记录泛化状态、布尔值和 `text_runes`,不记录 transcript、target、method、message、窗口标题或文档标题。
+
+## 输入证据
+
+- QA 失败证据:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10CZ-TestFlight-1200-output-fallback-fail-evidence.md`
+- 上游自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10CV-开发自查.md`
+- 代码审核证据:`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10CW-TestFlight-runtime-output-fallback代码审核.md`
+- 上传记录:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10CY-TestFlight-upload记录.md`
+- 运行日志:`/Users/ar/Library/Containers/com.shanghai3168.privatevoicedictation/Data/Library/Application Support/PrivateVoice Input/app.log`
+
+## 根因判断
+
+1. appstore 输出路由在 direct AX 失败时只保存 `TextOutput.Result`,即使用户配置 `CopyToClipboard=true`,也不会把识别文本写入系统 pasteboard。因此 Owner 未点击 fallback 直接 `Cmd+V` 时,剪贴板没有新识别文本是符合旧代码行为的。
+2. 显式 fallback 的 macOS 写入路径用 `clearContents` 后直接 `setString:forType:`,缺少 `declareTypes:@[NSPasteboardTypeString] owner:nil`。这会让 App 内回读成功的证据不够强,不能闭合“其它 App 可见的系统 pasteboard 类型声明”链路。
+3. 前端 fallback 面板依赖 `text-output:updated` 事件刷新 `createdAt` 和 prepared/token 状态;AX-10CZ 日志显示后续用户点击后没有新的 service call,符合事件漏送或页面状态停留在旧 `fallbackPrepared=true` 后按钮不再调用后端的风险。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/internal/axinput/axinput.go`
+ - Driver 增加 `CopyTextToClipboard(text)`,用于无 restore token 的配置驱动 clipboard 写入。
+- `C1.source/privatevoice.src/internal/axinput/axinput_darwin.go`
+ - `pvSetClipboardText` 在写入前声明 `NSPasteboardTypeString`。
+ - 实现 `CopyTextToClipboard`,写入成功后不生成 restore token。
+- `C1.source/privatevoice.src/internal/axinput/axinput_other.go`
+ - 非 macOS fallback 返回 unsupported。
+- `C1.source/privatevoice.src/internal/axinput/clipboard_fallback_darwin_test.go`
+ - 增加无 restore token 的 clipboard 写入测试。
+- `C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin.go`
+ - direct AX 失败、`CopyToClipboard=true` 且目标通过 fallback 安全检查时,写入 pasteboard 供用户手动粘贴。
+ - 已知 Terminal/iTerm/Warp/shell/console/terminal-role 目标仍不自动写入。
+- `C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin_test.go`
+ - 覆盖 `CopyToClipboard=true` direct 失败后写剪贴板。
+ - 覆盖 known Terminal target 不自动写剪贴板。
+- `C1.source/privatevoice.src/frontend/src/components/settings/TextOutputFallbackPanel.svelte`
+ - 面板挂载时每秒拉取一次最新 output 状态,兜底 Wails 事件漏送,避免按钮继续绑定旧 prepared token。
+- `C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte`
+ - 同步 General 页内 fallback 面板的轮询兜底。
+- `CODEGRAPH.md`
+ - 更新 appstore clipboard fallback 与前端状态刷新职责说明。
+
+## 安全边界
+
+- 不自动粘贴,不模拟 Cmd+V,不修改目标窗口内容。
+- Terminal / iTerm / Warp / shell / console / terminal-role 已知目标仍阻断 clipboard fallback 自动写入。
+- `target=unknown` 沿用既有口径:允许 clipboard fallback,因为该操作只写 pasteboard,不修改 focused app。
+- `text-output:updated` payload 未新增敏感字段;生产 UI 仍不渲染 transcript、target、method、message、窗口标题或文档标题。
+- `CopyToClipboard=true` 现在只在 safe target 下写 pasteboard;direct output 仍报告失败,避免把“已写剪贴板”误报成“已上屏”。
+
+## 验证结果
+
+- `gofmt -w internal/axinput/axinput.go internal/axinput/axinput_other.go internal/axinput/axinput_darwin.go internal/axinput/clipboard_fallback_darwin_test.go internal/textoutput/router_appstore_darwin.go internal/textoutput/router_appstore_darwin_test.go`:PASS
+- `go test -tags appstore ./internal/axinput ./internal/textoutput ./services`:PASS
+- `go test ./internal/textoutput ./services`:PASS
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`:PASS
+- `./scripts/verify-ax-output-boundary.sh`:PASS
+- `npm --prefix frontend run build`:PASS;保留既有 Svelte a11y warning,非本轮引入。
+- `go test ./...`:PASS
+- `go test -race -tags appstore ./internal/axinput ./internal/textoutput ./services`:PASS
+- `go test -race -run 'TestHoldPreCapture' .`:PASS
+- `go test -run 'TestAppStoreRouterCopiesToClipboardWhenKeepClipboardEnabledAfterAXFailure|TestAppStoreRouterDoesNotAutoCopyForKnownTerminalTarget' -tags appstore ./internal/textoutput && go test -run 'TestCopyTextToClipboardWritesWithoutRestoreToken' -tags appstore ./internal/axinput`:PASS
+
+## 未通过 / 未采用的扩展门禁
+
+- `go test -tags appstore ./...`:FAIL。失败集中在根包 hold pre-capture 集成测试,例如 `TestHoldPreCaptureReleaseAfterConfirmRunsRecognitionWhileRecorderStartBlocked` 等等待条件超时;日志显示识别和 text output 已走到 error/fallback 分支。该门禁不是本轮最低要求,且 `go test ./...`、appstore 聚焦包、race 聚焦包均已通过。建议后续单独评估根包 appstore-tag 集成测试是否需要从“AX direct success”改为“fallback 可用”口径。
+
+## 剩余风险
+
+- direct AX 在 TestFlight/MAS 下仍可能继续返回 `system=-25204 frontmost=-25204`;本轮目标不是修 direct AX,而是保证 fallback/pasteboard 可用。
+- 本轮无法在当前线程内替代 Owner 的 TestFlight sandbox 手工验证;系统 pasteboard 对其它 App 可见性需通过下一版 TestFlight retry 最终确认。
+- `CopyToClipboard=true` 自动写 pasteboard会改变“direct 失败后剪贴板是否有文本”的运行结果,但仍不自动粘贴、不修改目标窗口,且沿用用户已开启复制到剪贴板的配置语义。
+
+## 建议下一步
+
+1. 派代码审核员审核 AX-10DA diff,重点关注:
+ - `CopyToClipboard=true` 的 appstore direct-failure 行为是否符合产品口径。
+ - `target=unknown` 下自动写 pasteboard 是否保持 non-writable 安全边界。
+ - `NSPasteboardTypeString` 声明是否足以闭合 TestFlight pasteboard 可见性证据链。
+2. 审核 PASS 后再派生成新的 TestFlight candidate,随后由 Owner 复测 direct 失败时的 `Cmd+V` 与显式 fallback。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DC-TestFlight-candidate-\346\236\204\345\273\272\350\207\252\346\237\245.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DC-TestFlight-candidate-\346\236\204\345\273\272\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..c5545b5
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DC-TestFlight-candidate-\346\236\204\345\273\272\350\207\252\346\237\245.md"
@@ -0,0 +1,129 @@
+# AX-10DC TestFlight Candidate 构建自查
+
+任务:`AX-10DC-CODER-TESTFLIGHT-CANDIDATE-20260710.1302`
+
+终态:`PM_NOTIFY_READY AX-10DC`
+
+## 目标与边界
+
+- 目标:基于 AX-10DB `REVIEW_RESULT AX-10DB PASS` 后的 AX mainline worktree,生成包含 AX-10DA TestFlight 1200 output / fallback / pasteboard 修复的新 MAS / TestFlight upload candidate。
+- 渠道:Mac App Store / internal TestFlight upload candidate,本轮只生成本地 `.app` / `.pkg` 和本地 gate 证据。
+- 明确不做:不上传 ASC / Transporter / TestFlight;不提交 Beta App Review / App Review;不开放 external / public;不合 main;不 push;不 tag;不生成正式 release manifest / SHA256SUMS;不刷新 codebase-memory;不做 production-signed local launch smoke(沿用 AX-10BY 口径为 N/A / Apple platform boundary)。
+
+## 输入证据
+
+- Coder 自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10DA-TestFlight-1200-fallback-real-paste-开发自查.md`
+- Code review PASS:`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10DB-TestFlight-1200-fallback-real-paste代码审核.md`
+- Owner / PMO 失败证据:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10CZ-TestFlight-1200-output-fallback-fail-evidence.md`
+- 上一版上传 build:`2.2.3 / 20260710.1200`,本轮未复用。
+
+## 构建源
+
+- Worktree:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- 分支:`feature/mas-accessibility-direct-input`
+- HEAD:`ac1d4fd8ef875312d11acd4e4e5935ced542c227`
+- 工作区状态:包含 AX 主线集成以来的待审源码 / 文档 diff,以及本轮 AX-10DC 自查。本轮未提交、未 tag、未 push。
+- 构建口径:候选构建源为当前 AX worktree 状态,包含 AX-10DA 修复;不是正式可回滚发布冻结。
+
+## Candidate 信息
+
+- Marketing version:`2.2.3`
+- Build:`20260710.1304`
+- Bundle ID:`com.shanghai3168.privatevoicedictation`
+- Team ID:`CR3J54M8BQ`
+- Profile name:`PrivateVoice Dictation Mac App Store 20260709 B845`
+- Profile UUID:`4ec46bcc-c284-4935-834c-c31efeab62bd`
+- Profile App ID:`CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+- Profile DeveloperCertificates[0] SHA1:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`
+- App signing identity SHA1:`B845194A3A7FB70F526438A59B0604350D395518`
+- Installer identity:`3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+- App path:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app`
+- App mtime:`2026-07-10 13:04:27 +0800`
+- Main executable mtime:`2026-07-10 13:04:29 +0800`
+- PKG path:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice-Dictation-2.2.3-build20260710.1304-arm64-macappstore.pkg`
+- PKG mtime:`2026-07-10 13:04:30 +0800`
+- PKG SHA256:`821cc6bdd1a356f223544503460cf69485f7e326fcafd0add2792974c0a65d1e`
+
+## 构建命令
+
+```bash
+PRIVATEVOICE_APP_VERSION=2.2.3 PRIVATEVOICE_APP_BUILD=20260710.1304 PRIVATEVOICE_APPSTORE_APP_IDENTITY=B845194A3A7FB70F526438A59B0604350D395518 PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY="3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)" PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE="/Users/ar/Library/MobileDevice/Provisioning Profiles/4ec46bcc-c284-4935-834c-c31efeab62bd.provisionprofile" bash -n ./scripts/build-macappstore-pkg-macos.sh
+```
+
+结果:PASS。
+
+```bash
+PRIVATEVOICE_APP_VERSION=2.2.3 PRIVATEVOICE_APP_BUILD=20260710.1304 PRIVATEVOICE_APPSTORE_APP_IDENTITY=B845194A3A7FB70F526438A59B0604350D395518 PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY="3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)" PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE="/Users/ar/Library/MobileDevice/Provisioning Profiles/4ec46bcc-c284-4935-834c-c31efeab62bd.provisionprofile" ./scripts/build-macappstore-pkg-macos.sh arm64
+```
+
+结果:PASS。构建过程中:
+
+- `npm run build` PASS,保留既有 Svelte a11y warning(`UpdateDialog.svelte` dialog tabindex / non-interactive listener、`ToggleSwitch.svelte` button label)。
+- Go appstore build PASS,保留既有 `libonnxruntime` minimum macOS linker warning。
+- 脚本完成 app assembly、rpath fix、dylib/main/app signing、codesign verify、productbuild 和 pkg signature verify。
+
+## 本地 Gate 结果
+
+- Info.plist:
+ - `CFBundleIdentifier=com.shanghai3168.privatevoicedictation`
+ - `CFBundleShortVersionString=2.2.3`
+ - `CFBundleVersion=20260710.1304`
+ - `LSMinimumSystemVersion=13.4`
+- Embedded profile:
+ - UUID:`4ec46bcc-c284-4935-834c-c31efeab62bd`
+ - Name:`PrivateVoice Dictation Mac App Store 20260709 B845`
+ - App ID:`CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+ - Team ID:`CR3J54M8BQ`
+ - DeveloperCertificates[0] SHA1:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`
+- Signing leaf consistency:
+ - App bundle leaf:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`,matches profile:yes
+ - Main executable leaf:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`,matches profile:yes
+ - `libsherpa-onnx-c-api.dylib` leaf:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`,matches profile:yes
+ - `libonnxruntime.1.24.4.dylib` leaf:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`,matches profile:yes
+- `codesign --verify --deep --strict --verbose=2 <App>`:PASS
+- Signed entitlements:
+ - `com.apple.application-identifier=CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+ - `com.apple.developer.team-identifier=CR3J54M8BQ`
+ - `com.apple.security.app-sandbox=true`
+ - `com.apple.security.device.audio-input=true`
+ - `com.apple.security.network.client=true`
+ - `com.apple.security.files.user-selected.read-write=true`
+ - `keychain-access-groups=CR3J54M8BQ.*`
+- `pkgutil --check-signature <PKG>`:PASS
+- Main binary `LC_RPATH`:only `@executable_path/../Frameworks`;no `/Users` / Go module cache path.
+- Dylib install names:
+ - `libsherpa-onnx-c-api.dylib`:`@rpath/libsherpa-onnx-c-api.dylib`
+ - `libonnxruntime.1.24.4.dylib`:`@rpath/libonnxruntime.1.24.4.dylib`
+- Dylib Team ID / signing chain:
+ - both nested dylibs signed by `3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+ - TeamIdentifier:`CR3J54M8BQ`
+- `plutil -lint build/darwin/PrivacyInfo.xcprivacy build/macappstore/arm64/PrivateVoice Dictation.app/Contents/Resources/PrivacyInfo.xcprivacy`:PASS
+- PrivacyInfo bundle presence:PASS,`Contents/Resources/PrivacyInfo.xcprivacy` exists.
+- Package payload permissions:PASS;sample shows app directories `drwxr-xr-x`, main executable `-rwxr-xr-x`, resource/profile/dylib files readable by普通用户;owner-only unreadable files / non-searchable dirs:none.
+- `./scripts/verify-ax-output-boundary.sh`:PASS
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`:PASS
+- 发布类产物扫描:
+ - `find build/macappstore -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.xcarchive' -o -name 'SHA256SUMS*' -o -name '*manifest*' \) -print`:PASS,无输出。
+ - `find build -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.xcarchive' -o -name 'SHA256SUMS*' \) -newermt '2026-07-10 13:00:00' -print`:PASS,无输出。
+
+## 未执行项
+
+- 未上传 ASC / Transporter / TestFlight。
+- 未提交 Beta App Review / App Review。
+- 未开放 external TestFlight。
+- 未 public release。
+- 未合 main、未 push、未 tag、未生成正式 release manifest / SHA256SUMS、未刷新 codebase-memory。
+- 未执行 production-signed local launch smoke;按 AX-10BY 口径为 N/A / expected Apple platform boundary。
+
+## 风险与建议
+
+- 该 candidate 仍来自未提交 AX mainline worktree diff,不是正式可回滚 release freeze;用于下一步上传 / TestFlight runtime 验证。
+- 下一步应由 PMO 派发布线程执行 Apple validate / upload / processing,并在 ASC / TestFlight 可用后安排 Owner retry。
+- Owner retry 重点:direct AX 失败时 `CopyToClipboard=true` 是否允许直接 `Cmd+V` 粘贴最新识别文本;显式“使用剪贴板备用输入”是否每次新识别都能刷新并写入最新文本;Terminal / shell / console 负向仍不得自动写入或自动粘贴。
+
+## 终态
+
+`PM_NOTIFY_READY AX-10DC`
+
+新的 MAS / TestFlight upload candidate 已生成,本地 build-chain / signing / profile / pkg / rpath / privacy / boundary gate PASS。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DF-TestFlight-1304-direct-insert-\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/AX-10DF-TestFlight-1304-direct-insert-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..95f665e
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DF-TestFlight-1304-direct-insert-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,106 @@
+# AX-10DF TestFlight 1304 direct insert 开发自查
+
+任务:`AX-10DF-CODER-TESTFLIGHT-1304-DIRECT-INSERT-20260710.1348`
+
+Review / 后续建议 Gate:建议 PMO 派发代码审核后,再生成新 TestFlight candidate 复测。
+
+## 目标与边界
+
+- 目标:处理 internal TestFlight `2.2.3 / 20260710.1304` 中 direct insert 仍失败的问题,建立可审计、非敏感的 direct insert 诊断闭环,并做最小修复尝试。
+- 保持边界:不构建 MAS / TestFlight candidate,不上传 ASC / TestFlight,不合并 main,不 push,不 tag,不生成正式 manifest / SHA256SUMS,不刷新 codebase-memory。
+- 保护项:不回退 AX-10DA 已恢复的 `CopyToClipboard=true` / 显式 clipboard fallback 行为;不引入自动 Cmd+V、自动粘贴、模拟键盘粘贴;不放开 Terminal / shell / console 等 non-writable 负向边界。
+- 隐私边界:不记录 transcript、target、method、原始 message、窗口标题、文档标题或应用内文本;新增诊断只记录泛化 category、AX 数字错误码、布尔值和文本长度。
+
+## 输入证据
+
+- QA 证据:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10DE-TestFlight-1304-direct-insert-fail-clipboard-pass-evidence.md`
+- 上传记录:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10DD-TestFlight-upload记录.md`
+- 上游 fallback 修复自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10DA-TestFlight-1200-fallback-real-paste-开发自查.md`
+- 1304 candidate 构建自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10DC-TestFlight-candidate-构建自查.md`
+- 运行日志:`/Users/ar/Library/Containers/com.shanghai3168.privatevoicedictation/Data/Library/Application Support/PrivateVoice Input/app.log`
+
+## 根因判断
+
+现有 1304 证据显示热键、录音、ASR 和 clipboard fallback 均可达;失败集中在 App Store / TestFlight runtime 下的 AX direct insert。历史同类日志出现 `focused element unavailable` 且 system-wide 与 frontmost application focused-element 查询均返回 `-25204`。因此本轮判断 direct insert 失败点不是 ASR、麦克风、剪贴板或 fallback,而是 AX focused element 获取阶段在 TestFlight/MAS runtime 下不稳定或不可得。
+
+当前代码原路径只尝试:
+
+1. system-wide `kAXFocusedUIElementAttribute`
+2. `NSWorkspace.frontmostApplication` 对应 app 的 `kAXFocusedUIElementAttribute`
+
+这缺少 system-wide `kAXFocusedApplicationAttribute` -> focused app -> focused element 的中间查询路径,也缺少短重试和分阶段错误码记录,导致无法区分 focused application 不可得、focused element 不可得、AXValue 不可写或写后回读失败。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/internal/axinput/axinput_darwin.go`
+ - 新增 `pvAXCopyBestFocusedElement`:按 system-wide focused element、system focused application focused element、frontmost application focused element 三段查询。
+ - 对 AX messaging 设置 1s timeout,direct insert 前做 3 次短重试,每次间隔 50ms。
+ - focused element 不可得时返回 `system` / `focused_app` / `focused_app_element` / `frontmost` 四段 AX 数字错误码。
+ - 从 AX target summary 中移除 `title`,避免窗口 / 文档标题进入后端 result。
+- `C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin.go`
+ - direct insert 失败时新增脱敏诊断日志:只记录 `category`、可白名单提取的 AX 数字错误码、`needs_permission`、`inserted`、`text_runes`。
+ - 不记录 direct method、target、原始 message 或 transcript。
+- `C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin_test.go`
+ - 增加 direct insert 诊断分类测试。
+ - 增加自由文本过滤测试,确保 `title=` / `transcript=` 等字段不会进入 codes 摘要。
+- `CODEGRAPH.md`
+ - 更新 AX output 职责说明:system focused application 查询路径与脱敏诊断边界。
+
+## 保留行为
+
+- AX direct insert 成功条件仍是 `AXValue` replacement 且写后回读确认。
+- Direct insert 失败时,`CopyToClipboard=true` 仍按 AX-10DA 路径写入并回读确认 pasteboard,供用户手动 `Cmd+V`。
+- 显式 clipboard fallback 仍由用户点击触发,不自动粘贴。
+- Known Terminal / iTerm / Warp / shell / console / terminal-role target 仍阻止 clipboard fallback,避免修改 non-writable / command-line target。
+- 本轮未改前端 UI、录音 / ASR、权限策略、发布脚本或构建脚本。
+
+## 验证命令与结果
+
+在 `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src` 执行:
+
+```bash
+gofmt -w internal/axinput/axinput_darwin.go internal/textoutput/router_appstore_darwin.go internal/textoutput/router_appstore_darwin_test.go
+```
+
+结果:PASS。
+
+```bash
+go test -tags appstore ./internal/axinput ./internal/textoutput ./services
+```
+
+结果:PASS。输出包含既有 macOS link warning:部分 object / dylib built for newer macOS version than link target,但测试退出码为 0。
+
+```bash
+./scripts/verify-ax-output-boundary.sh
+```
+
+结果:PASS。
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+结果:PASS。
+
+```bash
+go test ./...
+```
+
+结果:PASS。输出包含既有 macOS link warning:部分 object / dylib built for newer macOS version than link target,但测试退出码为 0。
+
+## 未执行项
+
+- 未运行 `npm --prefix frontend run build`:本轮未修改前端源码、package 或 i18n。
+- 未生成 MAS / TestFlight candidate:本任务明确禁止构建上传候选。
+- 未做 TestFlight runtime 真实 direct insert 复测:需要代码审核 PASS 后由 PMO 派发 candidate 构建与 Owner / QA 复测。
+
+## 风险与下一步
+
+- 本轮是最小修复和诊断增强,不能在本地证明 TestFlight direct insert 一定恢复。若新 TestFlight candidate 仍返回 `focused_element_unavailable` 且 `system` / `focused_app` / `focused_app_element` / `frontmost` 均为平台失败码,应由 PMO / 产品 / 架构判断是否将 MAS/TestFlight direct insert 标为平台边界,产品路径转为显式 clipboard fallback。
+- 若新诊断显示 focused element 可得但 `AXValue` 不可读、不可写或回读失败,下一轮应针对具体 category 做目标化修复,仍不得使用自动 Cmd+V 或 event injection。
+
+## 终态建议
+
+`PM_NOTIFY_READY AX-10DF`
+
+建议 PMO 下一步派发代码审核;审核通过后再生成新的 TestFlight candidate 做 direct insert / clipboard fallback 复测。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DH-TestFlight-candidate-\346\236\204\345\273\272\350\207\252\346\237\245.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DH-TestFlight-candidate-\346\236\204\345\273\272\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..5a620dc
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DH-TestFlight-candidate-\346\236\204\345\273\272\350\207\252\346\237\245.md"
@@ -0,0 +1,157 @@
+# AX-10DH TestFlight Candidate 构建自查
+
+任务:`AX-10DH-CODER-TESTFLIGHT-CANDIDATE-20260710.1416`
+
+终态建议:`PM_NOTIFY_READY AX-10DH`
+
+## 目标与边界
+
+- 目标:基于 AX-10DG `REVIEW_RESULT AX-10DG PASS` 后的 AX mainline worktree,生成新的 MAS / TestFlight upload candidate `.app` / `.pkg`,用于后续上传线程继续 TestFlight 验证。
+- 渠道:Mac App Store / internal TestFlight upload candidate。本轮只生成本地 candidate 和本地 gate 证据。
+- 明确不做:不上传 ASC / Transporter / TestFlight;不执行 Apple `validate-app`;不提交 Beta App Review / App Review;不开放 external TestFlight / public release;不合 main;不 push;不 tag;不生成正式 release manifest / SHA256SUMS;不刷新 codebase-memory;不改源码行为。
+- production-signed local launch smoke:按 AX-10BY 口径记录为 N/A,不冒充 runtime QA PASS。
+
+## 输入证据
+
+- AX-10DF 自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10DF-TestFlight-1304-direct-insert-开发自查.md`
+- AX-10DG 代码审核 PASS:`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10DG-TestFlight-1304-direct-insert代码审核.md`
+- Owner / PMO 复测证据:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10DE-TestFlight-1304-direct-insert-fail-clipboard-pass-evidence.md`
+- 前置上传记录:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10DD-TestFlight-upload记录.md`
+
+## 构建源
+
+- Worktree:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- 分支:`feature/mas-accessibility-direct-input`
+- HEAD:`ac1d4fd8ef875312d11acd4e4e5935ced542c227`
+- 工作区状态:包含 AX 主线集成以来的待审源码 / 文档 diff,以及 AX-10DF / AX-10DH 自查;本轮未 commit、未 tag、未 push。
+- 构建口径:候选构建源为当前 AX worktree 状态,包含 AX-10DF direct insert 修复;不是正式可回滚 release freeze。
+
+## 临时证书文件处理
+
+AX-10DG 指出的未跟踪文件:
+
+- `C1.source/privatevoice.src/codesign0`
+- `C1.source/privatevoice.src/codesign1`
+- `C1.source/privatevoice.src/codesign2`
+
+只读识别结果:`file` 显示为 `Certificate, Version=3`;`openssl x509 -noout -subject -fingerprint -sha1` 证明它们是公开证书链:
+
+- 3rd Party Mac Developer Application leaf:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`
+- Apple Worldwide Developer Relations Certification Authority
+- Apple Root CA
+
+处理:已从源码根清理,`git status --short -- C1.source/privatevoice.src/codesign0 C1.source/privatevoice.src/codesign1 C1.source/privatevoice.src/codesign2` 无输出;未进入 Git、package、manifest、SHA256 或发布产物。
+
+## Candidate 信息
+
+- Marketing version:`2.2.3`
+- Build:`20260710.1418`
+- Bundle ID:`com.shanghai3168.privatevoicedictation`
+- Team ID:`CR3J54M8BQ`
+- Profile name:`PrivateVoice Dictation Mac App Store 20260709 B845`
+- Profile UUID:`4ec46bcc-c284-4935-834c-c31efeab62bd`
+- Profile App ID:`CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+- Profile DeveloperCertificates[0] SHA1:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`
+- App signing identity SHA1:`B845194A3A7FB70F526438A59B0604350D395518`
+- Installer identity:`3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+- App path:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app`
+- App mtime:`2026-07-10 14:18:43 +0800`
+- Main executable mtime:`2026-07-10 14:18:45 +0800`
+- PKG path:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice-Dictation-2.2.3-build20260710.1418-arm64-macappstore.pkg`
+- PKG mtime:`2026-07-10 14:18:47 +0800`
+- PKG SHA256:`bfd47a088ee999d5d9573f618f25de3d77496f389bec5fe5836848f3c6363db5`
+
+## 构建命令
+
+```bash
+PRIVATEVOICE_APP_VERSION=2.2.3 PRIVATEVOICE_APP_BUILD=20260710.1418 PRIVATEVOICE_APPSTORE_APP_IDENTITY=B845194A3A7FB70F526438A59B0604350D395518 PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY="3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)" PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE="/Users/ar/Library/MobileDevice/Provisioning Profiles/4ec46bcc-c284-4935-834c-c31efeab62bd.provisionprofile" bash -n C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh
+```
+
+结果:PASS。
+
+```bash
+PRIVATEVOICE_APP_VERSION=2.2.3 PRIVATEVOICE_APP_BUILD=20260710.1418 PRIVATEVOICE_APPSTORE_APP_IDENTITY=B845194A3A7FB70F526438A59B0604350D395518 PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY="3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)" PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE="/Users/ar/Library/MobileDevice/Provisioning Profiles/4ec46bcc-c284-4935-834c-c31efeab62bd.provisionprofile" ./scripts/build-macappstore-pkg-macos.sh arm64
+```
+
+结果:PASS。构建过程中:
+
+- `npm run build` PASS,保留既有 Svelte a11y warning:`UpdateDialog.svelte` dialog tabindex / non-interactive listener、`ToggleSwitch.svelte` button label。
+- Go appstore build PASS,保留既有 `libonnxruntime` minimum macOS linker warning。
+- 脚本完成 app assembly、rpath fix、dylib / main / app signing、codesign verify、productbuild 和 pkg signature verify。
+
+## 本地 Gate 结果
+
+- Source plist lint:`plutil -lint build/darwin/Info.plist build/darwin/PrivacyInfo.xcprivacy` PASS。
+- Candidate Info.plist:
+ - `CFBundleIdentifier=com.shanghai3168.privatevoicedictation`
+ - `CFBundleShortVersionString=2.2.3`
+ - `CFBundleVersion=20260710.1418`
+ - `LSMinimumSystemVersion=13.4`
+ - `plutil -lint <App>/Contents/Info.plist` PASS。
+- Embedded profile:
+ - UUID:`4ec46bcc-c284-4935-834c-c31efeab62bd`
+ - Name:`PrivateVoice Dictation Mac App Store 20260709 B845`
+ - App ID:`CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+ - Team ID:`CR3J54M8BQ`
+ - DeveloperCertificates[0] SHA1:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`
+- Signing leaf consistency:
+ - App bundle leaf:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`,matches profile:yes
+ - Main executable leaf:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`,matches profile:yes
+ - `libsherpa-onnx-c-api.dylib` leaf:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`,matches profile:yes
+ - `libonnxruntime.1.24.4.dylib` leaf:`B8:45:19:4A:3A:7F:B7:0F:52:64:38:A5:9B:06:04:35:0D:39:55:18`,matches profile:yes
+- `codesign --verify --deep --strict --verbose=2 <App>`:PASS。
+- Signed entitlements:
+ - `com.apple.application-identifier=CR3J54M8BQ.com.shanghai3168.privatevoicedictation`
+ - `com.apple.developer.team-identifier=CR3J54M8BQ`
+ - `com.apple.security.app-sandbox=true`
+ - `com.apple.security.device.audio-input=true`
+ - `com.apple.security.network.client=true`
+ - `com.apple.security.files.user-selected.read-write=true`
+ - `keychain-access-groups=CR3J54M8BQ.*`
+- `pkgutil --check-signature <PKG>`:PASS。
+- Main binary `LC_RPATH`:only `@executable_path/../Frameworks`;无 `/Users` / Go module cache path。
+- Dylib install names:
+ - `libsherpa-onnx-c-api.dylib`:`@rpath/libsherpa-onnx-c-api.dylib`
+ - `libonnxruntime.1.24.4.dylib`:`@rpath/libonnxruntime.1.24.4.dylib`
+- Dylib Team ID / signing chain:
+ - both nested dylibs signed by `3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`
+ - TeamIdentifier:`CR3J54M8BQ`
+- PrivacyInfo:
+ - bundle presence:`<App>/Contents/Resources/PrivacyInfo.xcprivacy` exists
+ - `plutil -lint <App>/Contents/Resources/PrivacyInfo.xcprivacy` PASS
+- Package payload permissions:PASS;expanded payload sample:
+ - app dirs:`drwxr-xr-x`
+ - main executable:`-rwxr-xr-x`
+ - PrivacyInfo / dylib files:`-rw-r--r--`
+ - owner-only unreadable files / non-searchable dirs:none observed
+- `./scripts/verify-ax-output-boundary.sh`:PASS。
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`:PASS。
+- 发布类产物扫描:
+ - `find build/macappstore -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.xcarchive' -o -name 'SHA256SUMS*' -o -name '*manifest*' \) -print`:PASS,无输出。
+ - `find build -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.xcarchive' -o -name 'SHA256SUMS*' \) -newermt '2026-07-10 14:16:00' -print`:PASS,无输出。
+
+## 未执行项
+
+- 未上传 ASC / Transporter / TestFlight。
+- 未执行 Apple `validate-app`。
+- 未提交 Beta App Review / App Review。
+- 未开放 external TestFlight / public release。
+- 未合 main、未 push、未 tag、未生成正式 release manifest / SHA256SUMS、未刷新 codebase-memory。
+- 未执行 production-signed local launch smoke;按 AX-10BY 口径为 N/A / Apple platform boundary。
+
+## 风险与建议
+
+- 该 candidate 来自未提交 AX mainline worktree diff,不是正式可回滚 release freeze;仅用于下一步上传 / TestFlight runtime 验证。
+- 下一步建议由 PMO 派发布线程执行 Apple validate / upload / processing,并在 ASC / TestFlight 可用后安排 Owner retry。
+- Owner retry 重点:
+ - direct AX 是否在 TextEdit / 微信 / 普通文本输入框恢复;
+ - 若 direct 仍失败,脱敏 direct insert category / AX error codes 是否能定位平台边界或下一轮修复点;
+ - `CopyToClipboard=true` 和显式 clipboard fallback 是否仍能让用户 `Cmd+V` 粘贴最新识别文本;
+ - Terminal / shell / console 等 non-writable 目标仍不得自动写入或自动粘贴。
+
+## 终态
+
+`PM_NOTIFY_READY AX-10DH`
+
+新的 MAS / TestFlight upload candidate 已生成,本地 build-chain / signing / profile / pkg / rpath / privacy / boundary gate PASS。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DR-\347\233\264\346\216\245\344\270\212\345\261\217\346\240\271\345\233\240\350\260\203\346\237\245\344\270\216\344\277\256\345\244\215\350\207\252\346\237\245.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DR-\347\233\264\346\216\245\344\270\212\345\261\217\346\240\271\345\233\240\350\260\203\346\237\245\344\270\216\344\277\256\345\244\215\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..1e9e451
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DR-\347\233\264\346\216\245\344\270\212\345\261\217\346\240\271\345\233\240\350\260\203\346\237\245\344\270\216\344\277\256\345\244\215\350\207\252\346\237\245.md"
@@ -0,0 +1,162 @@
+# AX-10DR 直接上屏根因调查与修复自查
+
+- 任务:`AX-10DR-CODER-DIRECT-INSERT-ROOT-CAUSE-20260710.1719`
+- 角色:Coder
+- 时间:2026-07-10 17:31 +0800
+- Worktree:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- 终态:`BLOCKED AX-10DR`
+
+## 目标与边界
+
+本轮目标是调查并尽量修复 MAS / TestFlight direct insert 全目标失败问题。Owner 新口径要求 direct insert 恢复为输入法核心路径,不能把每次手动 `Cmd+V` 作为最终体验。
+
+本轮未生成 MAS / TestFlight candidate,未上传 ASC / TestFlight,未提交 Beta App Review / App Review,未合并 main,未 push,未 tag,未生成正式 manifest / SHA256SUMS,未刷新 codebase-memory。
+
+本轮没有修改 AX direct insert 生产源码。结论是:当前证据不足以支持继续做源码盲修;TestFlight / MAS sandbox 运行态下的跨进程 AX focused-element 获取已经到达平台 / 架构边界,需要 PMO / 架构 / Owner 决策下一步。
+
+## 读取证据
+
+- 当前 QA 输入:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10DJ-TestFlight-1418-direct-insert-fail-clipboard-pass-evidence.md`
+- 当前 QA closeout:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10DO-TestFlight-1418-clipboard-fallback-closeout-evidence.md`
+- 运行日志:`/Users/ar/Library/Containers/com.shanghai3168.privatevoicedictation/Data/Library/Application Support/PrivateVoice Input/app.log`
+- P2 POC 自查:`/Users/ar/Projects/PrivateVoice/P2-mas-accessibility-spike/03-O/K2.项目管理/AX-3-开发自查.md`
+- P2 direct insert 补修:`/Users/ar/Projects/PrivateVoice/P2-mas-accessibility-spike/03-O/K2.项目管理/AX-5D-开发自查.md`
+- P2 ASR -> AX router:`/Users/ar/Projects/PrivateVoice/P2-mas-accessibility-spike/03-O/K2.项目管理/AX-8A-开发自查.md`
+- P2 QA:`/Users/ar/Projects/PrivateVoice/P2-mas-accessibility-spike/02-P/QA/AX-5F-QA-evidence.md`、`/Users/ar/Projects/PrivateVoice/P2-mas-accessibility-spike/02-P/QA/AX-8C-QA-evidence.md`
+- AX-10 runtime 证据:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10CP-TestFlight-runtime-output-fail-evidence.md`、`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10DE-TestFlight-1304-direct-insert-fail-clipboard-pass-evidence.md`
+- AX-10 Coder / review:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10CQ-开发自查.md`、`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10DF-TestFlight-1304-direct-insert-开发自查.md`、`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10DG-TestFlight-1304-direct-insert代码审核.md`
+- 旧边界口径:`/Users/ar/Projects/PrivateVoice/02-P/架构/AX-10DK-direct-insert边界决策.md`、`/Users/ar/Projects/PrivateVoice/02-P/产品/AX-10DL-direct-best-effort与clipboard-fallback产品口径.md`
+
+## 当前失败边界
+
+TestFlight build `2.2.3 / 20260710.1418` 的日志和 QA 证据一致:
+
+- 热键、麦克风、录音、ASR 均可达。
+- `Accessibility permission granted` 已出现。
+- 多个可写目标 App 覆盖后,direct insert 每轮仍失败。
+- 失败分类稳定为 `focused_element_unavailable`。
+- AX 数字错误码稳定为 `system=-25204 focused_app=-25204 focused_app_element=-25200 frontmost=-25204`。
+- `copy_to_clipboard=true` 与显式 clipboard fallback 均可写剪贴板,用户手动 `Cmd+V` 正确。
+
+这说明失败点不在 ASR、热键、录音、剪贴板、Wails event bridge 或 fallback token 生命周期,而集中在 MAS / TestFlight runtime 下跨进程获取 focused AX element。
+
+## 与历史可工作路径的差异
+
+P2 `AX-8C` 的 TextEdit / Safari direct insert PASS 不是 MAS / TestFlight 运行态证据:
+
+- 运行对象是 `build/local/arm64/PrivateVoice Dictation.app`。
+- 构建命令是 `PRIVATEVOICE_GO_BUILD_TAGS=appstore PRIVATEVOICE_LOCAL_BUILD_FLAVOR=ax8c-appstore ./scripts/build-local-macos.sh arm64`。
+- 签名为本地签名,`TeamIdentifier=not set`。
+- entitlements 不含 `com.apple.security.app-sandbox=true`;只有 audio input 与 hardened runtime 本地例外。
+- 同一证据明确记录 sandbox POC local build 启动失败,未作为真实运行 QA 对象。
+
+当前 TestFlight 运行对象不同:
+
+- Bundle ID:`com.shanghai3168.privatevoicedictation`
+- Version / build:`2.2.3 / 20260710.1418`
+- 签名 / runtime:TestFlight / MAS 分发路径。
+- 主二进制 entitlements 包含 `com.apple.security.app-sandbox=true`、`com.apple.application-identifier=CR3J54M8BQ.com.shanghai3168.privatevoicedictation`、Team ID `CR3J54M8BQ`。
+
+因此,历史 P2 PASS 只能证明非 MAS / 非 sandbox 本地运行形态下 AXValue replacement 可用,不能证明 MAS / TestFlight sandbox 下 direct insert 可用。
+
+## 根因判断
+
+本轮排查的根因假设如下。
+
+### 1. 不是重试次数不足
+
+AX-10DF 已把 focused element 获取扩展为三段查询:
+
+1. system-wide `kAXFocusedUIElementAttribute`
+2. system-wide `kAXFocusedApplicationAttribute` -> focused app -> `kAXFocusedUIElementAttribute`
+3. `NSWorkspace.frontmostApplication` -> app element -> `kAXFocusedUIElementAttribute`
+
+并加入 3 次 50ms retry 和 1s AX messaging timeout。AX-10DJ 仍稳定返回 `system=-25204 focused_app=-25204 focused_app_element=-25200 frontmost=-25204`,说明不是单纯时机抖动。
+
+### 2. 不是 overlay 抢焦点的主要问题
+
+`internal/overlay/overlay_darwin.go` 中 overlay 是 `NSWindowStyleMaskNonactivatingPanel`,`canBecomeKeyWindow` / `canBecomeMainWindow` 均返回 `NO`,视觉窗口 `ignoresMouseEvents=YES`,拖拽窗口也使用 non-activating panel。
+
+如果是 settings / overlay 抢焦点,诊断更可能指向 PrivateVoice 自身或某个可识别的 AX target;当前 TestFlight 失败是在 system / focused app / frontmost 三段均无法取得 focused element,目标仍为 unknown。
+
+### 3. 不是 TCC 未授权
+
+日志显示 `Accessibility permission granted`,且错误不是权限缺失分支。当前表现是 AX trusted 通过,但跨进程 focused AX element 查询在 MAS / TestFlight sandbox runtime 下返回 `-25204`。这说明 TCC trust 与“沙盒内可用跨进程 AX focused-element 写入能力”不是同一件事。
+
+### 4. 最一致的解释是 MAS / TestFlight sandbox 跨进程 AX focused-element 边界
+
+证据链:
+
+- 非 sandbox 本地 P2 appstore-tagged QA App 可在 TextEdit / Safari direct insert。
+- MAS / TestFlight sandbox App 在 Codex、微信、TextEdit、Mole、Arc 等多个目标中均无法取得 focused element。
+- 当前 entitlements 明确包含 `com.apple.security.app-sandbox=true`。
+- clipboard / pasteboard 路径可用,说明并非 App 整体不可运行或权限页误判。
+- 不可写 Terminal 负向边界仍保持,不存在自动粘贴替代 direct 的可接受路线。
+
+本轮没有找到一个能在不破坏隐私、不可写目标负向、安全边界和 App Store 约束的前提下继续修复 direct insert 的源码切片。
+
+## 未采用的盲修方案
+
+- 继续增加 retry / sleep:AX-10DF 后错误码稳定,不是抖动证据。
+- 递归扫描 frontmost window 找任意可写 `AXTextArea`:无法证明命中的是用户光标所在控件,有写错目标风险,不符合输入法核心路径。
+- 自动 Cmd+V / CGEvent / event injection / AppleEvents / Input Monitoring:违反当前 AX / MAS 边界和 Terminal / shell / console 负向要求。
+- 移除 sandbox entitlement:不是 MAS / TestFlight 正式 Bundle ID 可接受形态。
+- 把 clipboard fallback 伪装为 direct insert PASS:违反 Owner 当前目标和 QA 事实。
+
+## 建议下一步
+
+建议 PMO 不再派 Coder 做同一路径盲修,先进入 Owner / 架构决策:
+
+1. 若 Owner 坚持 direct insert 必须是核心体验,建议评估 Developer ID / 非 sandbox 分发路线,并明确这会从 Mac App Store 路线切出。
+2. 若继续 MAS / TestFlight,建议把 direct insert 标为当前平台阻塞,向 Apple DTS / Feedback Assistant 提交最小复现与 `-25204` 证据,等待官方边界结论。
+3. 若必须留在 App Store,又不能接受手动 `Cmd+V`,需要另立新架构 spike;但 P1 IMK 路线已有二阶段 No-Go 证据,不能无条件回滚。
+4. 当前 TestFlight 可继续用于 clipboard fallback 扩测,但这与 Owner 本轮“direct insert 必须恢复为核心路径”的完成标准冲突。
+
+## 验证命令与结果
+
+在 `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src` 执行:
+
+```bash
+go test -tags appstore ./internal/axinput ./internal/textoutput ./services
+```
+
+结果:PASS。`services` 链接阶段有既有 macOS / onnxruntime warning,退出码为 0。
+
+```bash
+go test ./internal/axinput ./internal/textoutput ./services
+```
+
+结果:PASS。`services` 链接阶段有既有 macOS / onnxruntime warning,退出码为 0。
+
+```bash
+./scripts/verify-ax-output-boundary.sh
+```
+
+结果:PASS。未发现 IMK/TIS、event injection、global monitor、Input Monitoring、Automation、系统输入法安装或 release action。
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+结果:PASS。
+
+```bash
+go test ./...
+```
+
+结果:FAIL。失败点为 `internal/model` 测试使用 `httptest.NewServer` 监听本地端口,当前沙盒拒绝 `listen tcp6 [::1]:0`,与 AX direct insert 无关。其他已输出包包括根包、`internal/audio`、`internal/axinput`、`internal/textoutput`、`services` 等均已通过或 cached 通过。
+
+## 未执行项
+
+- 未运行 `gofmt`:本轮未修改 Go 源码。
+- 未运行前端 build:本轮未修改前端源码。
+- 未生成 MAS / TestFlight candidate。
+- 未上传 ASC / TestFlight。
+- 未做 Owner 物理 retry;本轮任务明确要求先给出根因 / 修复终态。
+
+## 终态
+
+`BLOCKED AX-10DR`
+
+核心结论:当前 MAS / TestFlight sandbox 形态下,PrivateVoice 已在多个目标稳定失败于跨进程 AX focused-element 获取,且历史 direct insert PASS 只覆盖非 sandbox 本地 QA App。没有可审计、低风险、App Store-safe 的源码最小修复可继续推进;下一步需要 PMO / 架构 / Owner 在 Developer ID 非 sandbox、Apple 官方反馈、或接受 MAS clipboard fallback 路线之间做决策。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DT-\350\207\252\345\212\250\347\262\230\350\264\264\345\256\236\351\252\214\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/AX-10DT-\350\207\252\345\212\250\347\262\230\350\264\264\345\256\236\351\252\214\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..58654a1
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DT-\350\207\252\345\212\250\347\262\230\350\264\264\345\256\236\351\252\214\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,178 @@
+# AX-10DT 自动粘贴实验开发自查
+
+任务:`AX-10DT-EXPERIMENT-AUTO-PASTE-20260711.1316`
+
+终态建议:`PM_NOTIFY_READY AX-10DT`
+
+## 目标与边界
+
+- 本轮只在 AX mainline worktree 内实现 `darwin && appstore` 的自动 Cmd+V 隔离实验切片,用于验证 MAS/TestFlight sandbox 在剪贴板 fallback 写入成功后是否允许安全自动粘贴。
+- 本轮不声明修复 AX focused-element direct insert;自动粘贴成功时结果方法为 `clipboard_auto_paste_experiment`,`directInserted=false`。
+- 默认关闭。只有用户在设置页显式打开“自动粘贴实验”后,才会在 appstore 输出路径 direct insert 失败后尝试。
+- 未生成 TestFlight / MAS candidate,未上传 ASC/TestFlight,未合 main,未 push,未 tag,未生成 manifest / SHA256SUMS,未刷新 codebase-memory。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/internal/config/config.go`
+ - 新增 `AutoPasteExperiment` 配置字段,默认 `false`。
+- `C1.source/privatevoice.src/services/config_service.go`
+ - 新增 `GetAutoPasteExperiment` / `SetAutoPasteExperiment`。
+- `C1.source/privatevoice.src/services/diagnostics_service.go`
+ - 在脱敏配置诊断中加入 `AutoPasteExperiment` 布尔字段。
+- `C1.source/privatevoice.src/frontend/src/lib/stores/config.ts`
+ - 新增 `autoPasteExperiment` store。
+- `C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte`
+ - 新增“自动粘贴实验”开关;加载 / 保存走 `ConfigService`。
+- `C1.source/privatevoice.src/frontend/src/lib/i18n/zh.json`
+- `C1.source/privatevoice.src/frontend/src/lib/i18n/en.json`
+ - 新增实验开关文案。
+- `C1.source/privatevoice.src/app.go`
+ - 识别流程开始时抓取一次前台 app identity guard;输出阶段交给 textoutput router 校验。
+ - 日志只记录 `ok` / `identity_present` 等泛化状态,不记录应用名、窗口标题、文档标题或 transcript。
+- `C1.source/privatevoice.src/internal/axinput/axinput.go`
+ - 扩展 Driver:前台 app identity、guarded one-shot auto paste。
+- `C1.source/privatevoice.src/internal/axinput/axinput_darwin.go`
+ - 新增前台 app identity 获取;仅在 identity 未变化时发送一次 Cmd+V。
+ - 不读取 AX 树,不记录 transcript、target title、window/document title。
+- `C1.source/privatevoice.src/internal/axinput/axinput_other.go`
+ - 补非 macOS stub。
+- `C1.source/privatevoice.src/internal/textoutput/router.go`
+ - 新增 `OutputOptions` / `AutoPasteGuard`。
+ - 新增前台身份安全检查,阻断 Terminal/iTerm/Warp/shell/console 类目标。
+- `C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin.go`
+ - direct insert 失败后,在 fallback 安全且实验开关开启时,先写剪贴板并回读确认,再校验前台身份未变,最后只发一次 Cmd+V。
+ - 开关关闭时保持现有手动 Cmd+V / 显式 fallback 行为。
+- `C1.source/privatevoice.src/internal/textoutput/router_default.go`
+ - 保持非 appstore 路径兼容 `OutputWithOptions`,不改变 legacy paste/type 行为。
+- `C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin_test.go`
+ - 覆盖实验开关 off/on、剪贴板写入失败、guard 缺失、Terminal identity 负向、`CopyToClipboard=false` 但实验开关开启时仍可临时写剪贴板并尝试自动粘贴。
+- `C1.source/privatevoice.src/internal/config/config_test.go`
+ - 覆盖默认关闭。
+- `C1.source/privatevoice.src/services/config_service_test.go`
+ - 覆盖 ConfigService 开关生命周期。
+- `C1.source/privatevoice.src/scripts/verify-ax-output-boundary.sh`
+ - 保持 AX 输出边界扫描;只允许带 `AX10DT_EXPERIMENT_ALLOW_CGEVENTPOST` 标记的实验 Cmd+V 两行,其他事件注入仍 fail。
+- `CODEGRAPH.md`
+ - 补充 AX-10DT 实验路径职责与非默认边界。
+
+## 安全守卫
+
+- 自动粘贴默认关闭。
+- direct insert 失败后只在 fallback safe target 下进入实验路径。
+- 先写入并回读确认 pasteboard,再发送一次 Cmd+V。
+- 识别流程开始抓取 frontmost identity;发送 Cmd+V 前由 native 层重新读取并要求完全一致。
+- Terminal/iTerm/iTerm2/Warp/shell/console/terminal role/subrole 类目标阻断自动粘贴。
+- 不递归扫描 AX 树,不使用 AppleEvents,不新增自动按键循环。
+- 不记录 transcript、target 原文、method/message 原文、窗口标题、文档标题。
+- 显式 fallback 的 restore / skip restore 继续使用现有 token 与 changed-clipboard 规则;本轮未修改该语义。
+
+## 验证命令与结果
+
+- `gofmt -w app.go internal/config/config.go internal/config/config_test.go internal/axinput/axinput.go internal/axinput/axinput_darwin.go internal/axinput/axinput_other.go internal/textoutput/router.go internal/textoutput/router_appstore_darwin.go internal/textoutput/router_appstore_darwin_test.go internal/textoutput/router_default.go services/config_service.go services/config_service_test.go services/diagnostics_service.go`
+ - PASS。
+- `GOCACHE=/private/tmp/privatevoice-go-build-cache go test -tags appstore ./internal/textoutput ./internal/config ./services`
+ - PASS;`services` 链接阶段有既有 macOS SDK 版本 warning。
+- `GOCACHE=/private/tmp/privatevoice-go-build-cache go test -tags appstore -run 'TestAppStoreRouterAutoPasteExperiment|TestConfigServiceAutoPasteExperiment|TestDefaultLanguageConfig' ./internal/textoutput ./internal/config ./services`
+ - PASS;`services` 链接阶段有既有 macOS SDK 版本 warning。
+- `GOCACHE=/private/tmp/privatevoice-go-build-cache go test ./internal/textoutput ./internal/config ./services`
+ - PASS;`services` 链接阶段有既有 macOS SDK 版本 warning。
+- `GOCACHE=/private/tmp/privatevoice-go-build-cache go test -race -run 'TestHoldPreCapture' .`
+ - PASS;链接阶段有既有 macOS SDK 版本 warning。
+- `GOCACHE=/private/tmp/privatevoice-go-build-cache go test -tags appstore ./internal/audio ./internal/axinput ./internal/textoutput ./services`
+ - PASS;链接阶段有既有 macOS SDK 版本 warning。
+- `GOCACHE=/private/tmp/privatevoice-go-build-cache go test -race -tags appstore ./internal/textoutput`
+ - PASS。
+- `npm --prefix frontend run build`
+ - PASS;保留既有 Svelte a11y warnings:`UpdateDialog.svelte` dialog tabindex / noninteractive listener、`ToggleSwitch.svelte` aria-label。
+- `./scripts/verify-ax-output-boundary.sh`
+ - PASS。
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`
+ - PASS。
+
+## 未通过 / 未覆盖
+
+- `GOCACHE=/private/tmp/privatevoice-go-build-cache go test ./...`
+ - PARTIAL:除 `internal/model` 外已执行包通过;`internal/model.TestDownloadProfileFallsBackWhenPrimaryArchiveInvalid` 在沙盒内因 `httptest.NewServer` 监听本地端口被拒绝而 panic:`listen tcp6 [::1]:0: bind: operation not permitted`。
+ - 该失败与 AX-10DT 输出 / fallback / auto paste 路径无关,建议在允许本地监听的 QA/CI 环境补跑全量。
+- 未构建或上传 TestFlight candidate;符合本任务边界。
+- 未执行真实 MAS/TestFlight 自动 Cmd+V QA;本轮只提供可审 diff 和本地门禁,后续需经代码审核后由 PMO 决定是否构建实验 candidate 并安排 Owner 五组人工 QA。
+
+## 风险与建议
+
+- 自动 Cmd+V 属于事件注入实验路径;即使本地代码门禁通过,MAS/TestFlight runtime 仍可能因 TCC / sandbox / target app 策略拒绝或表现不一致。
+- 本轮仅用 frontmost app bundle/pid identity 做窗口切换守卫;无法证明同一进程内焦点从输入框切到另一个区域的情况。该限制已通过 target/identity 守卫和单次 paste 降低风险,但仍需真实 QA 验证。
+- Terminal/iTerm/Warp/shell/console 类目标已阻断自动粘贴;未知 terminal-like app 仍可能需要后续补充 bundle id/role 规则。
+- 如果代码审核通过,建议下一步派 `AX-10DU` 代码审核;通过后再派本地 / TestFlight 实验 candidate 构建,不要直接进入正式发布链路。
+
+## AX-10DV 补修记录
+
+任务:`AX-10DV-CODER-AUTO-PASTE-FIX-20260711.1341`
+
+来源:`AX-10DU` 代码审核 `CHANGES_REQUESTED`,证据 `/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10DU-自动粘贴实验代码审核.md`。
+
+终态建议:`PM_NOTIFY_READY AX-10DV`,沿用 `AX-10DU` 同项复审。
+
+### 补修目标与边界
+
+- 只处理自动 Cmd+V 隔离实验的两个 must-fix:Terminal / terminal-like 当前前台身份必须在写剪贴板前阻断;`AutoPasteExperiment=true` 且 `CopyToClipboard=false` 时不得无 token 覆盖用户剪贴板。
+- 不声明 direct insert 已修复;实验成功仍记录为 `clipboard_auto_paste_experiment`,`directInserted=false`。
+- 不改 AX focused-element direct insert、ASR、录音、权限策略、发布脚本或 MAS/TestFlight candidate。
+- 未构建或上传 candidate,未合 main,未 push,未 tag,未生成 manifest / SHA256SUMS,未刷新 codebase-memory。
+
+### 补修文件
+
+- `C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin.go`
+ - 自动实验路径现在在任何 pasteboard 写入前重新读取当前 frontmost identity。
+ - 如果 current identity 不可得、与识别开始时 guard 不一致,或是 Terminal / iTerm / Warp / shell / console / terminal-like,立即取消自动实验,不写剪贴板、不发送 Cmd+V。
+ - 自动实验失败 / 取消时不再落回 `KeepClipboard` 的普通自动复制,避免出现“先因 guard/Terminal 阻断,再写剪贴板”的路径。
+ - `CopyToClipboard=false` 时改用 `PrepareClipboardFallback(text)` 获取 restore token,再执行 guarded auto paste,最后调用 `RestoreClipboardFallback(token)`;若 token 不可得则取消自动实验。若 restore 未完成,不声称自动实验成功。
+- `C1.source/privatevoice.src/internal/textoutput/router_appstore_darwin_test.go`
+ - 补充 Terminal current identity 写前阻断测试。
+ - 补充 `CopyToClipboard=false` 下 token restore、frontmost changed 后 restore、token 不可得取消、用户剪贴板已变更 / restore skip 不报成功等测试。
+ - 保留并加强 toggle off/on、clipboard write/readback failure、identity guard、Terminal negative 覆盖。
+- `CODEGRAPH.md`
+ - 更新 AX text output / auto paste 实验职责:自动实验必须在写剪贴板前复核当前身份;`CopyToClipboard=false` 必须走 fallback token / restore 语义。
+- `K2.项目管理/AX-10DT-自动粘贴实验开发自查.md`
+ - 追加本补修记录。
+
+### 修复说明
+
+- Terminal 负向顺序:`autoPasteClipboardExperiment()` 进入后先检查 `AutoPasteGuard`,再调用 `FrontmostAppIdentity()` 读取当前前台身份;只有当前身份存在、与 guard 完全一致、且 `autoPasteSafeForIdentity()` 允许时,才会进入 `CopyTextToClipboard()` 或 `PrepareClipboardFallback()`。因此 `target=unknown + 当前前台 Terminal` 不会触碰 pasteboard。
+- Clipboard restore:`CopyToClipboard=false` 不再调用无 token 的 `CopyTextToClipboard()`。自动实验临时写剪贴板必须通过 `PrepareClipboardFallback()` 取得 token;粘贴尝试后必须调用 `RestoreClipboardFallback(token)`。如果用户中途修改剪贴板,restore 结果不会覆盖用户新剪贴板;本轮在该情况下不把自动实验结果报告为成功。
+- Fallback 保留:自动实验取消 / 失败时,`Result.Text` 和 `FallbackAvailable` 仍保留,供现有显式 fallback UI 处理;不会自动粘贴,也不会自动写 known terminal-like 目标。
+- 隐私边界:新增日志只记录状态、布尔值、token presence、`text_runes`,不记录 transcript、target 原文、method/message 原文、窗口标题或文档标题。
+
+### AX-10DV 验证命令与结果
+
+- `gofmt -w internal/textoutput/router_appstore_darwin.go internal/textoutput/router_appstore_darwin_test.go`
+ - PASS。
+- `GOCACHE=/private/tmp/privatevoice-go-build-cache go test -tags appstore -run 'TestAppStoreRouterAutoPasteExperiment' ./internal/textoutput`
+ - PASS。
+- `GOCACHE=/private/tmp/privatevoice-go-build-cache go test -tags appstore ./internal/textoutput`
+ - PASS。
+- `GOCACHE=/private/tmp/privatevoice-go-build-cache go test ./internal/textoutput`
+ - PASS。
+- `GOCACHE=/private/tmp/privatevoice-go-build-cache go test -race -tags appstore ./internal/textoutput`
+ - PASS。
+- `GOCACHE=/private/tmp/privatevoice-go-build-cache go test -tags appstore ./internal/textoutput ./internal/config ./services`
+ - PASS;`services` 链接阶段仍有既有 macOS SDK 版本 warning。
+- `GOCACHE=/private/tmp/privatevoice-go-build-cache go test -tags appstore ./internal/audio ./internal/axinput ./internal/textoutput ./services`
+ - PASS;`services` 链接阶段仍有既有 macOS SDK 版本 warning。
+- `GOCACHE=/private/tmp/privatevoice-go-build-cache go test -race -run 'TestHoldPreCapture' .`
+ - PASS;链接阶段仍有既有 macOS SDK 版本 warning。
+- `./scripts/verify-ax-output-boundary.sh`
+ - PASS;未放宽事件注入边界扫描。
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`
+ - PASS。
+
+### AX-10DV 未跑项 / 剩余风险
+
+- 未重跑 `npm --prefix frontend run build`:AX-10DV 未修改前端文件;AX-10DT 原始自查已记录前端 build PASS。
+- 未重跑 `go test ./...`:AX-10DT 原始自查已记录该命令在沙盒内因 `internal/model` 的 `httptest.NewServer` 本地端口监听被拒绝而不能完整通过;本轮补修范围为 `internal/textoutput`,已补 appstore / race / 相关包聚焦门禁。
+- 自动 Cmd+V 仍是 MAS/TestFlight 隔离实验,不是 direct insert 修复;真实可用性、TCC 行为和目标 App 接受合成 Cmd+V 的情况仍需代码审核 PASS 后由 PMO 决定是否构建实验 candidate 并安排人工 QA。
+- 当前守卫能判断前台 app identity 变化和 terminal-like identity,但不能证明同一进程内焦点区域没有变化;这仍是实验路径的残余风险。
+
+### AX-10DV 建议下一步
+
+- 派代码审核员对 `AX-10DU` 同 Gate 复审,重点检查写剪贴板前阻断顺序、`CopyToClipboard=false` 的 token / restore 语义、Terminal negative、隐私日志和 boundary scan。
+- 复审 PASS 后再由 PMO 判断是否派本地 / TestFlight 实验 candidate 构建;不要跳过代码审核直接进入 QA 或发布链路。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DW-\350\207\252\345\212\250\347\262\230\350\264\264\345\256\236\351\252\214candidate\346\236\204\345\273\272\350\207\252\346\237\245.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DW-\350\207\252\345\212\250\347\262\230\350\264\264\345\256\236\351\252\214candidate\346\236\204\345\273\272\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..73b0147
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10DW-\350\207\252\345\212\250\347\262\230\350\264\264\345\256\236\351\252\214candidate\346\236\204\345\273\272\350\207\252\346\237\245.md"
@@ -0,0 +1,121 @@
+# AX-10DW 自动粘贴实验 candidate 构建自查
+
+任务:`AX-10DW-CODER-AUTO-PASTE-CANDIDATE-20260711.1357`
+
+终态:`BLOCKED AX-10DW`
+
+## 目标与边界
+
+- 目标:基于 `AX-10DT` 自动 Cmd+V 实验、`AX-10DV` must-fix 修复和 `AX-10DU` 同 Gate 复审 PASS,生成新的 MAS / TestFlight 本地 App / PKG candidate。
+- 初次计划 version/build:`2.2.3 / 20260711.1400`,晚于现有正式 TestFlight `2.2.3 / 20260710.1418`。
+- Owner signing blocker 解除后续跑计划 version/build:`2.2.3 / 20260711.1522`,同样晚于现有正式 TestFlight `2.2.3 / 20260710.1418`。
+- Retry2 计划 version/build:`2.2.3 / 20260711.1549`,晚于现有正式 TestFlight `2.2.3 / 20260710.1418` 和前两次失败尝试 `20260711.1400` / `20260711.1522`。
+- 本轮不上传 ASC / Transporter / TestFlight,不执行 Apple `validate-app`,不提交 Beta / App Review,不合 main,不 push,不 tag,不生成正式 release manifest / SHA256SUMS,不刷新 codebase-memory。
+
+## 当前起点
+
+- Worktree:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- Source root:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- Branch:`feature/mas-accessibility-direct-input`
+- HEAD:`ac1d4fd8ef875312d11acd4e4e5935ced542c227`
+- Coder 自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10DT-自动粘贴实验开发自查.md`
+- Review PASS:`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10DU-自动粘贴实验代码审核.md`
+- Profile path:`/Users/ar/Library/MobileDevice/Provisioning Profiles/4ec46bcc-c284-4935-834c-c31efeab62bd.provisionprofile`
+- Profile source:`/Users/ar/Projects/PrivateVoice/02-P/X-vip/PrivateVoice_Dictation_Mac_App_Store_20260709_B845.provisionprofile`
+
+## 本轮实际处理
+
+- 未生成新的 App / PKG candidate。
+- 发现并修复 `C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh` 的 plist 读取兼容性问题:
+ - 当前环境下 `defaults read <Info.plist> CFBundleIdentifier` 返回 key missing。
+ - 同一文件通过 `PlistBuddy` 可正常读取 `CFBundleIdentifier=com.shanghai3168.privatevoicedictation`、`CFBundleShortVersionString=2.2.2`、`CFBundleVersion=20260703.0335`。
+ - 脚本已改为使用 `/usr/libexec/PlistBuddy` 读取 `CFBundleIdentifier`、默认 version 和默认 build;该修改只影响构建脚本 preflight,不改变业务行为。
+- 重跑 MAS build preflight 后,构建被签名身份阻断:
+ - `security find-identity -v -p codesigning`:`0 valid identities found`
+ - `security find-identity -v /Users/ar/Library/Keychains/login.keychain-db`:`0 valid identities found`
+ - `security find-certificate -a -Z -c '3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)' /Users/ar/Library/Keychains/login.keychain-db`:无输出
+ - `security find-certificate -a -Z -c '3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)' /Users/ar/Library/Keychains/login.keychain-db`:无输出
+ - 构建脚本报错:`ERROR: App Store app signing identity not found: B845194A3A7FB70F526438A59B0604350D395518`
+- Owner 回报 blocker 已解除后,本线程再次只读 / 探测核验:
+ - `security find-identity -v -p codesigning`:仍返回 `0 valid identities found`
+ - `security find-identity -v`:仍返回 `0 valid identities found`
+ - `security find-identity -v -p codesigning /Users/ar/Library/Keychains/login.keychain-db`:仍返回 `0 valid identities found`
+ - `security find-identity -v -p codesigning /Library/Keychains/System.keychain`:仍返回 `0 valid identities found`
+ - `security find-identity -v -p codesigning /Users/ar/Library/Keychains/F1EF29DB-0B65-56A5-BDFB-132EF8BD2CE0/keychain-2.db`:仍返回 `0 valid identities found`
+ - `codesign --force --sign B845194A3A7FB70F526438A59B0604350D395518 /private/tmp/privatevoice-codesign-probe`:返回 `B845194A3A7FB70F526438A59B0604350D395518: no identity found`
+ - `strings /Users/ar/Library/Keychains/login.keychain-db | rg '3rd Party Mac Developer|CR3J54M8BQ|Hainan|B845194|2A2972'` 能看到 MAS Application / Installer 公开证书名称,但 `security` / `codesign` 仍无法把它们解析为可用 signing identity,说明当前执行会话无法实际使用对应私钥签名。
+- Owner 第二次回报其交互式 Terminal 可见签名身份后,本线程 retry2 再次核验:
+ - `whoami && echo HOME=$HOME && date +%Y%m%d.%H%M`:`ar` / `/Users/ar` / `20260711.1549`。
+ - `security find-identity -v -p codesigning`:仍返回 `0 valid identities found`,未见 `B845194A3A7FB70F526438A59B0604350D395518`。
+ - `security find-identity -v`:仍返回 `0 valid identities found`,未见 `2A2972E4606F00F4AB883B14416071CF6153611C`。
+ - `security default-keychain`:`/Users/ar/Library/Keychains/login.keychain-db`。
+ - `security list-keychains` 初始仍报 `SecKeychainCopySearchList: One or more parameters passed to a function were not valid`。
+ - 执行最小 search list 修正 `security list-keychains -d user -s /Users/ar/Library/Keychains/login.keychain-db` 后,`security list-keychains -d user` 能列出 login keychain,但 `security find-identity -v -p codesigning` 和 `security find-identity -v` 仍均为 `0 valid identities found`。
+ - 按派发要求,retry2 未继续构建;判定为当前 Codex execution session keychain visibility blocker。
+
+## 执行命令与结果
+
+- `git status --short --branch`
+ - PASS;当前在 `feature/mas-accessibility-direct-input`,存在 AX mainline 累计待审 diff。
+- `git rev-parse --show-toplevel && git rev-parse --abbrev-ref HEAD && git rev-parse HEAD`
+ - PASS:Git root `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`;branch `feature/mas-accessibility-direct-input`;HEAD `ac1d4fd8ef875312d11acd4e4e5935ced542c227`。
+- `/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' -c 'Print :CFBundleShortVersionString' -c 'Print :CFBundleVersion' C1.source/privatevoice.src/build/darwin/Info.plist`
+ - PASS:Bundle ID `com.shanghai3168.privatevoicedictation`;source plist 默认 version/build 仍为 `2.2.2 / 20260703.0335`,本轮 candidate 计划通过 build script env 注入 `2.2.3 / 20260711.1400`。
+- `defaults read /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/darwin/Info.plist CFBundleIdentifier`
+ - FAIL:`The domain/default pair ... does not exist`;已用 `PlistBuddy` 替换脚本读取。
+- `cd C1.source/privatevoice.src && bash -n ./scripts/build-macappstore-pkg-macos.sh`
+ - PASS。
+- `cd C1.source/privatevoice.src && PRIVATEVOICE_APP_VERSION=2.2.3 PRIVATEVOICE_APP_BUILD=20260711.1400 PRIVATEVOICE_APPSTORE_APP_IDENTITY=B845194A3A7FB70F526438A59B0604350D395518 PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY='3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)' PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE='/Users/ar/Library/MobileDevice/Provisioning Profiles/4ec46bcc-c284-4935-834c-c31efeab62bd.provisionprofile' PRIVATEVOICE_SIGNING_KEYCHAIN='/Users/ar/Library/Keychains/login.keychain-db' ./scripts/build-macappstore-pkg-macos.sh arm64`
+ - BLOCKED:preflight 报 `ERROR: App Store app signing identity not found: B845194A3A7FB70F526438A59B0604350D395518`。
+- `cd C1.source/privatevoice.src && PRIVATEVOICE_APP_VERSION=2.2.3 PRIVATEVOICE_APP_BUILD=20260711.1522 PRIVATEVOICE_APPSTORE_APP_IDENTITY=B845194A3A7FB70F526438A59B0604350D395518 PRIVATEVOICE_APPSTORE_INSTALLER_IDENTITY='3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)' PRIVATEVOICE_APPSTORE_PROVISIONING_PROFILE='/Users/ar/Library/MobileDevice/Provisioning Profiles/4ec46bcc-c284-4935-834c-c31efeab62bd.provisionprofile' ./scripts/build-macappstore-pkg-macos.sh arm64`
+ - BLOCKED:preflight 仍报 `ERROR: App Store app signing identity not found: B845194A3A7FB70F526438A59B0604350D395518`。
+- `cp /bin/echo /private/tmp/privatevoice-codesign-probe && codesign --force --sign B845194A3A7FB70F526438A59B0604350D395518 /private/tmp/privatevoice-codesign-probe`
+ - BLOCKED:`B845194A3A7FB70F526438A59B0604350D395518: no identity found`。
+- `security list-keychains -d user -s /Users/ar/Library/Keychains/login.keychain-db && security list-keychains -d user && security find-identity -v -p codesigning && security find-identity -v`
+ - BLOCKED:search list 修正后仍 `0 valid identities found` / `0 valid identities found`。
+- `cd C1.source/privatevoice.src && plutil -lint build/darwin/Info.plist build/darwin/PrivacyInfo.xcprivacy build/darwin/entitlements-appstore.plist`
+ - PASS。
+- `cd C1.source/privatevoice.src && ./scripts/verify-ax-output-boundary.sh`
+ - PASS;未放宽 AX output boundary scan。
+- `git diff --check`
+ - PASS。
+- `find C1.source/privatevoice.src/build/macappstore -maxdepth 3 -type f -name '*.pkg' -print -exec stat -f '%Sm %N' -t '%Y-%m-%d %H:%M:%S %z' {} \;`
+ - PASS;仅发现旧候选 `PrivateVoice-Dictation-2.2.3-build20260710.1418-arm64-macappstore.pkg`,mtime `2026-07-10 14:18:47 +0800`。
+- `find C1.source/privatevoice.src/build -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.pkg' -o -name '*.xcarchive' -o -name 'SHA256SUMS*' -o -name '*manifest*' \) -maxdepth 5 -print`
+ - PASS / evidence:未生成本轮 `20260711.1400`、`20260711.1522` 或 `20260711.1549` pkg;扫描显示历史 DMG、旧 `20260710.1418` MAS pkg 和既有 `build/windows/wails.exe.manifest`。本轮未生成新的 DMG / ZIP / xcarchive / formal manifest / SHA256SUMS。
+
+## 未执行门禁
+
+以下门禁依赖成功生成 `.app` / `.pkg`,因签名身份 preflight 阻断未执行:
+
+- MAS build script 完整打包 PASS。
+- App path / PKG path / PKG SHA256。
+- Assembled Info.plist version/build 核验。
+- Embedded profile Team / App ID / entitlements 对齐。
+- `codesign --verify --deep --strict --verbose=2`。
+- `pkgutil --check-signature`。
+- 主二进制 `LC_RPATH`、dylib install names、nested dylib signing chain。
+- Package payload permissions。
+
+## 安全 / 范围确认
+
+- 未上传 ASC / Transporter / TestFlight。
+- 未执行 Apple `validate-app`。
+- 未提交 Beta / App Review。
+- 未合 main,未 push,未 tag。
+- 未生成正式 release manifest / SHA256SUMS。
+- 未刷新 codebase-memory。
+- 未回退 AX-10DT / AX-10DV 自动粘贴实验代码。
+- 未修改业务功能;唯一源码改动是 MAS package 构建脚本从 `defaults read` 切换到 `PlistBuddy` 读取 source plist。
+
+## 阻塞结论
+
+`BLOCKED AX-10DW`:当前 Codex execution session 无法生成新的 MAS / TestFlight 本地 candidate,因为 `security` / `codesign` 均无法解析可用 MAS Application / Installer signing identity。Owner 交互式 Terminal 可见身份,但本会话中 `security find-identity` 仍返回 `0 valid identities found`;search list 修正到 login keychain 后仍无效。脚本无法找到 profile 对应的 `B845194A3A7FB70F526438A59B0604350D395518` signing identity;临时二进制签名探测同样返回 `no identity found`。
+
+需要 PMO / Owner / 发布负责人恢复或确认以下条件后续派:
+
+- Keychain 中可见 `3rd Party Mac Developer Application: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`,且 leaf SHA1 为 `B845194A3A7FB70F526438A59B0604350D395518`。
+- Keychain 中可见 `3rd Party Mac Developer Installer: Hainan Wanxing Technology Co., Ltd. (CR3J54M8BQ)`。
+- `security find-identity -v -p codesigning` 或脚本指定的 `PRIVATEVOICE_SIGNING_KEYCHAIN` 能返回上述可用身份。
+
+解除后建议在能实际执行 `security find-identity -v -p codesigning` 并看见 `B845194A3A7FB70F526438A59B0604350D395518` 的同一 execution session 中重跑 `AX-10DW` candidate build;build 必须继续使用新的当前时间 build 号,不复用 `20260711.1400`、`20260711.1522` 或 `20260711.1549`。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10EB-\347\255\276\345\220\215\350\257\201\346\215\256\345\267\256\345\274\202\350\260\203\345\222\214\350\207\252\346\237\245.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10EB-\347\255\276\345\220\215\350\257\201\346\215\256\345\267\256\345\274\202\350\260\203\345\222\214\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..5aca923
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10EB-\347\255\276\345\220\215\350\257\201\346\215\256\345\267\256\345\274\202\350\260\203\345\222\214\350\207\252\346\237\245.md"
@@ -0,0 +1,235 @@
+# AX-10EB 签名证据差异调和自查
+
+- 任务 ID:`AX-10EB-CODER-SIGNATURE-EVIDENCE-RECONCILIATION-20260711`
+- 角色:Coder
+- 调查时间:2026-07-11 18:00-18:20 +0800
+- 终态:`PM_NOTIFY_READY AX-10EB`
+
+## 目标与边界
+
+目标:只读调和 AX-10EA 发布线程 `BLOCKED AX-10EA SIGNATURE` 与 PMO 当前会话对同一绝对路径曾复核 PASS 的冲突,优先验证 Codex 升级后的执行环境回归假设。
+
+边界:本轮未上传、未执行 Apple validate-app、未重建候选、未修改业务源码、未合 main、未 push、未 tag、未生成正式 manifest / SHA256SUMS、未刷新 codebase-memory。
+
+## 输入证据
+
+- AX-10EA 发布记录:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10EA-TestFlight-reverify-upload记录.md`
+- AX-10DY 发布记录:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10DY-TestFlight-upload记录.md`
+- 台账记录:`/Users/ar/Projects/PrivateVoice/02-P/进度地图.md`
+- AX-10EA 日志目录:`/Users/ar/Projects/PrivateVoice/03-O/K3.运营与发布资料/AX-10CD-platform-feedback-logs/`
+
+## 当前 Codex 执行环境
+
+命令与结果摘要:
+
+```bash
+whoami
+pwd
+printenv
+/usr/bin/security default-keychain
+/usr/bin/security list-keychains -d user
+/usr/bin/security find-identity -v -p codesigning
+/usr/bin/security find-identity -v
+```
+
+结果:
+
+- `whoami`:`ar`
+- `HOME`:`/Users/ar`
+- `PWD`:`/Users/ar/Projects/PrivateVoice`
+- `__CFBundleIdentifier`:`com.openai.codex`
+- `CODEX_SANDBOX`:`seatbelt`
+- `CODEX_SANDBOX_NETWORK_DISABLED`:`1`
+- `CODEX_INTERNAL_ORIGINATOR_OVERRIDE`:`Codex Desktop`
+- `CODEX_THREAD_ID`:`019f23ee-6eaf-7d90-a294-5acaf7a1f90d`
+- `security default-keychain`:`/Users/ar/Library/Keychains/login.keychain-db`
+- `security list-keychains -d user`:`/Users/ar/Library/Keychains/login.keychain-db`
+- `security find-identity -v -p codesigning`:`0 valid identities found`
+- `security find-identity -v`:`0 valid identities found`
+- 指定 keychain 复核:
+ - `security find-identity -v -p codesigning /Users/ar/Library/Keychains/login.keychain-db`:`0 valid identities found`
+ - `security find-identity -v /Users/ar/Library/Keychains/login.keychain-db`:`0 valid identities found`
+ - `security find-identity -v -p codesigning /Library/Keychains/System.keychain`:`0 valid identities found`
+ - `security find-identity -v /Library/Keychains/System.keychain`:`0 valid identities found`
+
+Owner 交互式 Terminal 输入事实:
+
+- `security find-identity -v -p codesigning` 可见 App signing identity `B845194A3A7FB70F526438A59B0604350D395518`。
+- `security find-identity -v` 可见 Installer signing identity `2A2972E4606F00F4AB883B14416071CF6153611C`。
+
+判断:当前 Codex 执行会话与 Owner 交互式 Terminal 在同一用户 / 同一 HOME 下存在 Keychain signing identity 可见性差异。该差异足以阻断 Codex 侧重新签名 / 构建 MAS candidate,也支持“Codex 升级后执行环境回归”作为首要假设。
+
+## Codex host / runtime 可读版本
+
+命令:
+
+```bash
+find /Applications /Users/ar/Applications -maxdepth 2 -name '*Codex*.app' -o -name '*ChatGPT*.app'
+/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' /Applications/ChatGPT.app/Contents/Info.plist
+/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' /Applications/ChatGPT.app/Contents/Info.plist
+/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' /Applications/ChatGPT.app/Contents/Info.plist
+stat -f '%Sm %N' -t '%Y-%m-%d %H:%M:%S %z' /Applications/ChatGPT.app /Applications/ChatGPT.app/Contents/Info.plist
+```
+
+结果:
+
+- 未发现 `/Applications/Codex.app`;发现 `/Applications/ChatGPT.app`。
+- `/Applications/ChatGPT.app` 的 `CFBundleIdentifier`:`com.openai.codex`
+- `CFBundleShortVersionString`:`26.707.31428`
+- `CFBundleVersion`:`5059`
+- App bundle mtime:`2026-07-10 07:55:10 +0800`
+- Info.plist mtime:`2026-07-10 07:55:03 +0800`
+
+判断:当前 Codex host 可读版本与 Owner “昨天 Codex 升级”反馈在时间上吻合;但仅凭本轮证据不能单独证明升级是唯一根因,只能证明升级后的当前 Codex session 存在 Keychain identity 不可见和 sandbox/provenance 相关差异。
+
+## 1656 候选状态无法再按同一文件复核
+
+AX-10EA 原候选:
+
+- App:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app`
+- PKG:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice-Dictation-2.2.3-build20260711.1656-arm64-macappstore.pkg`
+- AX-10EA 记录的 PKG SHA256:`34ef52fe9f597879a36c6cb39effabc259922f9d794bc6425924fff7a7af213b`
+- AX-10EA 记录的 App mtime:`2026-07-11 16:56:13 +0800`
+- AX-10EA 记录的 PKG mtime:`2026-07-11 16:56:17 +0800`
+
+本轮当前状态复核:
+
+```bash
+find /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64 -maxdepth 2 -print
+```
+
+结果:
+
+- 当前目录已出现 `PrivateVoice-Dictation-2.2.3-build20260711.1807-arm64-macappstore.pkg`。
+- 原 `PrivateVoice-Dictation-2.2.3-build20260711.1656-arm64-macappstore.pkg` 当前不存在。
+- 当前 App / 主程序 / dylib mtime 均为 `2026-07-11 18:07`。
+
+判断:AX-10EA 原始 `1656` PKG 已不在原绝对路径,当前 App 也已被 `1807` 构建覆盖。因此不能再把当前路径称为 AX-10EA 同一静态候选复核。此前 PMO PASS 与发布线程 FAIL 的冲突,必须回到当时保留的原始日志 / SHA / raw stdout stderr 处理,当前 live build 目录已不具备同一文件复核条件。
+
+## 当前 1807 文件签名复核
+
+当前候选:
+
+- App:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app`
+- PKG:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice-Dictation-2.2.3-build20260711.1807-arm64-macappstore.pkg`
+- Version / build:`2.2.3 / 20260711.1807`
+- PKG SHA256:`f49d0011a7fe798c5eeafd30e3b1dd5254c7774efbd739d1a5699c7594654f8e`
+
+命令:
+
+```bash
+/usr/bin/codesign --verify --deep --strict --verbose=2 "/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app"
+/usr/bin/codesign --verify --strict --verbose=2 "/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app/Contents/MacOS/PrivateVoice Dictation"
+/usr/bin/codesign --verify --strict --verbose=2 "/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app/Contents/Frameworks/libsherpa-onnx-c-api.dylib"
+/usr/bin/codesign --verify --strict --verbose=2 "/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice Dictation.app/Contents/Frameworks/libonnxruntime.1.24.4.dylib"
+```
+
+结果:全部 FAIL,均为 `invalid signature (code or signature have been modified)`。
+
+PKG payload 展开命令:
+
+```bash
+pkgutil --expand-full "/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/build/macappstore/arm64/PrivateVoice-Dictation-2.2.3-build20260711.1807-arm64-macappstore.pkg" /private/tmp/privatevoice-ax10eb-pkg-1807/pkg
+```
+
+payload App:
+
+`/private/tmp/privatevoice-ax10eb-pkg-1807/pkg/com.shanghai3168.privatevoicedictation.pkg/Payload/PrivateVoice Dictation.app`
+
+对 payload App、主程序和两个 dylib 执行相同 `codesign --verify`:全部 FAIL,均为 `invalid signature (code or signature have been modified)`。
+
+判断:当前 `1807` 的失败不是 PKG 展开方式造成,PKG payload 与外层 App 一致无效。
+
+## 当前 1807 的 profile / xattr 证据
+
+命令:
+
+```bash
+/usr/bin/security cms -D -i "<App>/Contents/embedded.provisionprofile"
+xattr -lr "<App>"
+```
+
+结果:
+
+- `embedded.provisionprofile` 解码失败:`security: problem decoding`。
+- App 内主要文件和目录均存在 `com.apple.provenance` 扩展属性,包括:
+ - `Contents/MacOS/PrivateVoice Dictation`
+ - `Contents/Frameworks/libsherpa-onnx-c-api.dylib`
+ - `Contents/Frameworks/libonnxruntime.1.24.4.dylib`
+ - `Contents/_CodeSignature/CodeResources`
+ - `Contents/embedded.provisionprofile`
+
+判断:当前产物存在沙盒 / provenance 相关痕迹。由于构建脚本在签名前后均执行 `xattr -cr`,当前产物仍带 `com.apple.provenance` 说明该属性可能由当前执行环境或后续文件操作重新附加。该证据与 Codex sandbox 环境相关,但本轮未做破坏性清除验证,因此不把它单独认定为唯一签名失败根因。
+
+## 构建脚本复核
+
+文件:
+
+`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/scripts/build-macappstore-pkg-macos.sh`
+
+关键顺序:
+
+1. 构建 Go binary。
+2. 组装 App。
+3. 签名前 `strip_extended_attributes` / `remove_appledouble_files`。
+4. `install_name_tool` 修 rpath / install name。
+5. 依次签名:
+ - `libonnxruntime.1.24.4.dylib`
+ - `libsherpa-onnx-c-api.dylib`
+ - `Contents/MacOS/PrivateVoice Dictation`
+ - App bundle
+6. 签名后再次 `strip_extended_attributes` / `remove_appledouble_files`。
+7. 立即执行 `codesign -vvv --deep --strict "$APP_BUNDLE"`。
+8. `productbuild --component "$APP_BUNDLE" /Applications "$PKG_PATH"`。
+
+判断:脚本设计上有“签名后立即 verify”的 gate;如果该 gate 在 Owner Terminal 中确实 PASS,而发布线程稍后 FAIL,则应优先怀疑:构建输出目录被后续构建覆盖、执行环境差异导致验证结果不同,或签名后有额外系统属性 / 文件状态变化。当前 live 目录已被 `1807` 覆盖,无法再复核 `1656` 当时文件。
+
+## 根因判断
+
+本轮可以证明:
+
+1. 当前 Codex host 版本为 `26.707.31428`,mtime `2026-07-10 07:55 +0800`,与 Owner 反馈的升级时间吻合。
+2. 当前 Codex 执行环境是 sandboxed:`CODEX_SANDBOX=seatbelt`,`CODEX_SANDBOX_NETWORK_DISABLED=1`。
+3. 当前 Codex 执行环境无法看到 Owner Terminal 可见的 MAS Application / Installer signing identities:`security find-identity` 全部 `0 valid identities found`。
+4. AX-10EA 的 `1656` 原 PKG 已从 live build 目录消失,当前 live App / PKG 已是 `1807`,因此当前无法对 AX-10EA 同一静态文件复核。
+5. 当前 `1807` App、主程序、两个 dylib、PKG payload 均签名无效,且 `embedded.provisionprofile` 无法解码,不能上传。
+
+本轮不能完全证明:
+
+- 不能仅凭当前证据证明“Codex 升级”是唯一根因,因为 `1656` 原文件已被覆盖,且缺少 PMO PASS 当时的 raw stdout / stderr / SHA 旁证。
+- 不能证明当前 `com.apple.provenance` 是唯一导致 `invalid signature` 的原因;只能证明它存在于当前无效产物中,并且与 sandboxed host 相关性较高。
+
+## 最快恢复方式
+
+建议不要让当前 Codex sandbox session 生成或最终验证 MAS/TestFlight candidate。最快恢复路径:
+
+1. 由 Owner 在可见 B845 / 2A2972 identity 的交互式 Terminal 中执行构建,不通过 Codex sandbox host。
+2. 新 build 必须大于 `20260711.1656`,且不得复用当前失败的 `20260711.1807`;建议使用执行时实际 `YYYYMMDD.HHMM`。
+3. 构建脚本完成后,立即在同一 Terminal、同一用户、同一绝对路径执行并保存 raw stdout / stderr:
+ - `/usr/bin/security find-identity -v -p codesigning`
+ - `/usr/bin/security find-identity -v`
+ - `/usr/bin/codesign --verify --deep --strict --verbose=2 <App>`
+ - `/usr/bin/codesign --verify --strict --verbose=2 <App>/Contents/MacOS/PrivateVoice Dictation`
+ - `/usr/bin/codesign --verify --strict --verbose=2 <App>/Contents/Frameworks/libsherpa-onnx-c-api.dylib`
+ - `/usr/bin/codesign --verify --strict --verbose=2 <App>/Contents/Frameworks/libonnxruntime.1.24.4.dylib`
+ - `/usr/bin/security cms -D -i <App>/Contents/embedded.provisionprofile`
+ - `/usr/bin/codesign -d --entitlements :- <App>`
+ - `/usr/sbin/pkgutil --check-signature <PKG>`
+4. 展开 PKG 到 `/private/tmp` 后,对 payload App / 主程序 / 两个 dylib 重复 `codesign --verify`。
+5. 在所有签名 / profile / payload 验证 PASS 后,再计算 PKG SHA256。
+6. 将最终 App / PKG 复制到不可复用的 evidence freeze 目录,例如:
+ - `03-O/K3.运营与发布资料/AX-10EB-signature-recovery-candidate/build<YYYYMMDD.HHMM>/`
+ 而不是只保留在可被下一次构建覆盖的 `build/macappstore/arm64/`。
+7. 发布线程后续只验证 freeze 目录里的 App / PKG,不再验证 live build 目录。
+
+建议构建链最小补强:
+
+- 在 MAS build 脚本或外层发布脚本中新增 `xattr -lr <App>` 记录,并在最终签名验证前后明确检查 / 清理 `com.apple.provenance`;如清理失败,应 fail fast。
+- 在 `codesign PASS` 与 `SHA256` 之间禁止任何会改写 App bundle 的步骤。
+- 将 App / PKG freeze copy 和 SHA256 放在签名验证之后,并让发布线程使用 freeze copy。
+
+## 终态回报
+
+`PM_NOTIFY_READY AX-10EB`
+
+核心结论:AX-10EA 的同路径证据冲突不能再用当前 live 目录复核,因为 `1656` PKG 已被 `1807` 覆盖;当前 Codex session 确认存在 Keychain signing identity 可见性回归,与 Owner Terminal 不一致,并且当前 `1807` App / PKG payload 签名无效。最快恢复是由 Owner 在交互式 Terminal 生成新 build,在同一 shell 中立即验证 App、payload、主程序、dylib、profile,再 freeze 到不可被后续构建覆盖的证据目录后计算 SHA256。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10H-\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/AX-10H-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..3684e3e
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10H-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,106 @@
+# AX-10H 开发自查
+
+任务 ID:`AX-10H-CODER-20260706.1739` / `AX-10H-CODER-RESUME-20260706.2131`
+
+时间:2026-07-06 21:36 +0800
+
+结论:`PM_NOTIFY_READY AX-10H`
+
+Review Gate ID:`AX-10I-GATE-20260706.1739`
+
+## 目标与边界
+
+本轮目标是修复 AX 主线 worktree 中真实物理右 Command / hold 模式无法进入录音的问题:`holdActivationDelay` 后应自动确认 hold pre-capture,不再依赖后续 key-down / 轮询事件。
+
+本轮只处理 hold pre-capture 状态机、相关测试和自查;未处理 AX fallback UI、TextOutputRouter、MAS build gate、TCC 权限 gate、发布、push、tag、manifest 或 codebase-memory。
+
+## 工作区
+
+- Git 根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- 分支:`feature/mas-accessibility-direct-input`
+- 前置 evidence:
+ - `AX-10F` QA:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10F-主线AX输出QA-retry-evidence.md`
+ - `AX-10G` 物理验证:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10G-物理热键验证-evidence.md`
+
+## 根因
+
+`AX-10G` 证明物理右 Command 被 App 捕获,日志出现:
+
+- `Hotkey accepted: key=R-⌘ vk=0x5C mode=hold`
+- `Hotkey action: start hold pre-capture`
+
+但 `confirmHoldPreCaptureLocked()` 只在 `pollHoldHotkeyLocked()` 后续再次进入 `isDown && hotkeyActive` 分支时触发。真实物理右 Command 长按不保证持续产生新的 `isDown=true` 轮询边沿,因此 pending 一直停到 release;release 分支取消为 `released before activation`,无法产生真实 ASR 文本。
+
+## 修复文件与逻辑
+
+- `C1.source/privatevoice.src/app.go`
+ - 新增 `holdActivationTimer` / `holdActivationSeq`。
+ - `startHoldPreCaptureLocked()` 成功启动预捕获后安排 `time.AfterFunc(holdActivationDelay, ...)`。
+ - timer 回调在 `a.mu` 内重新检查:
+ - 当前 timer 序号仍有效;
+ - app context 未取消;
+ - `hotkeyActive && isHoldRecordingPending && isRecording`;
+ - engine 仍 ready;
+ - 未检测到组合键。
+ - 条件满足时调用 `confirmHoldPreCaptureLocked()`,因此即使没有后续 key-down 事件也能进入 recording。
+ - release-before-confirm、组合键取消、Escape 取消、confirm 都会通过 `cancelHoldActivationTimerLocked()` 失效 pending timer,避免 stale timer 误确认下一次按键。
+ - 为测试隔离真实音频设备,将 `App.recorder` 收敛为 App 内部最小 `appRecorder` 接口;生产仍由 `audio.NewRecorder()` 创建真实 recorder,并继续把具体 `*audio.Recorder` 传给 `AudioService`。
+- `C1.source/privatevoice.src/app_live_caption_test.go`
+ - 新增 fake recorder / hotkey listener / overlay,避免单测触碰真实音频设备或 Wails screen。
+ - 新增 hold pre-capture 状态机测试。
+- `CODEGRAPH.md`
+ - 记录 hold pre-capture 状态机现在由可取消 activation timer 自动确认。
+
+## 测试覆盖
+
+新增 / 更新测试覆盖:
+
+- `TestHoldPreCaptureAutoConfirmsWithoutAdditionalKeyDown`
+ - 模拟一次 hold key-down 后不再发送后续 key-down 事件;
+ - 等待 timer 自动 confirm;
+ - 断言 recording 已确认、pending 关闭、录音设备只 start 一次、indicator 进入 recording。
+- `TestHoldPreCaptureReleaseBeforeActivationCancelsTimer`
+ - key-down 后立即 release;
+ - 等待超过 `holdActivationDelay`;
+ - 断言 pending / recording 不会被 stale timer 恢复,recorder 已 stop。
+- `TestHoldPreCaptureCombinationBeforeActivationCancels`
+ - key-down 后在 activation 前模拟其他键;
+ - timer 回调检测组合键并取消预捕获;
+ - 断言 combination 标记、recording 关闭、recorder stop。
+
+## 不破坏说明
+
+- tap mode:本轮没有改 `pollTapHotkeyLocked()`、`startTapRecordingLocked()` 或 tap stop 语义;聚焦测试包含既有 tap/recording guard,完整 `go test ./...` 通过。
+- combination cancel:timer 回调也执行 `IsAnyOtherKeyPressedSince()` 检查,补足“没有后续 key-down 轮询”时的组合键取消;既有轮询分支仍保留。
+- release-before-confirm:release 分支继续调用 `cancelHoldPreCaptureLocked("released before activation")`,并额外失效 timer,避免 release 后被 timer 确认。
+- AX output / fallback:本轮未修改 `internal/textoutput`、`internal/axinput`、`services.TextOutputService` 或前端 fallback UI;前置 AX-10A / AX-10D / AX-10D1 diff 保持原样。
+
+## 验证命令与结果
+
+在 `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src` 执行:
+
+- `gofmt -w app.go app_live_caption_test.go`
+ - PASS。
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`
+ - PASS。
+- `go test -run 'TestHoldPreCapture|TestShouldStartHoldPreCapture|TestStartRecordingIgnored|TestStartTapRecordingIgnored' .`
+ - PASS;保留既有 macOS SDK / onnxruntime link warning。
+- `go test -race -run 'TestHoldPreCapture' .`
+ - PASS;保留既有 macOS SDK / onnxruntime link warning。
+- `go test ./...`
+ - PASS;保留既有 macOS SDK / onnxruntime link warning。
+
+本轮未改前端文件;前置 diff 中的前端 fallback UI 已在 AX-10D / AX-10D1 自查中通过 `npm --prefix frontend run build`,本轮未重复运行 frontend build。
+
+## 未做事项与风险
+
+- 本轮未运行真实物理右 Command QA,不声明 `AX-10F` PASS。
+- timer 修复后仍需代码审核 `AX-10I` 和 QA retry,重点复测 AX-10G 物理右 Command hold 能否在按住期间出现 `Hold pre-capture confirmed`、`Recognized`、`text_output_done`。
+- 本轮未发布、未 push、未 tag、未生成 manifest、未刷新 codebase-memory。
+
+## 终态
+
+`PM_NOTIFY_READY AX-10H`
+
+请求 PMO 派发 `AX-10I` 代码审核,Review Gate ID:`AX-10I-GATE-20260706.1739`。代码审核 PASS 前不得进入 QA retry。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10H1-\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/AX-10H1-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..e047dc1
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10H1-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,85 @@
+# AX-10H1 开发自查
+
+任务 ID:`AX-10H1-CODER-20260706.2141`
+
+时间:2026-07-06 21:46 +0800
+
+结论:`PM_NOTIFY_READY AX-10H1`
+
+复审 Gate:`AX-10I-GATE-20260706.1739`
+
+## 目标与边界
+
+本轮只处理 `AX-10I` 代码审核 `CHANGES_REQUESTED` 的 must-fix:AX-10H 的 activation timer 在 confirm hold pre-capture 前必须复核当前物理热键仍处于按下状态,避免用户已在 `holdActivationDelay` 前释放、但 release poll 尚未处理时误确认录音。
+
+本轮未扩大到 AX fallback UI、TextOutputRouter、MAS build gate、TCC gate、发布、push、tag、manifest 或 codebase-memory。
+
+## 工作区
+
+- Git 根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- 分支:`feature/mas-accessibility-direct-input`
+- 前置自查:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/K2.项目管理/AX-10H-开发自查.md`
+- 审核证据:`/Users/ar/Projects/PrivateVoice/02-P/代码审核/AX-10I-物理hold预捕获代码审核.md`
+
+## Must-Fix 修复说明
+
+### timer confirm 前复核当前物理键态
+
+修复文件:`C1.source/privatevoice.src/app.go`
+
+`scheduleHoldActivationTimerLocked()` 的 timer 回调在确认前新增当前键态检查:
+
+- 若 `a.hk == nil` 或 `!a.hk.IsKeyDown(a.cfg.HotkeyVK)`,调用 `cancelHoldPreCaptureLocked("released before activation")`。
+- 只有当前物理热键仍按下、pending 仍有效、engine ready、未检测到组合键时,才调用 `confirmHoldPreCaptureLocked()`。
+
+因此,`hotkeyActive` 不再是 release-before-confirm 的唯一依据;即使 release poll 尚未跑到,只要当前物理键态已经 up,timer 也会取消 pre-capture,不会误 confirm。
+
+### 单测补充
+
+修复文件:`C1.source/privatevoice.src/app_live_caption_test.go`
+
+- `fakeHotkeyListener` 增加可控 `down` 状态,`IsKeyDown()` 返回当前 fake 物理键态。
+- `TestHoldPreCaptureTimerCancelsWhenKeyReleasedBeforePoll` 覆盖审核员指出的窗口:
+ 1. fake listener 先置为 down;
+ 2. 调用 `pollHoldHotkeyLocked(true, true)` 启动 pre-capture;
+ 3. fake listener 置为 up;
+ 4. 不调用 `pollHoldHotkeyLocked(false, true)`;
+ 5. 等待超过 `holdActivationDelay`;
+ 6. 断言 pre-capture 被 timer 取消、`isRecording=false`、`isHoldRecordingPending=false`、recorder stop 一次,且没有进入 recording indicator。
+
+既有测试同步设置 fake key down,保证自动 confirm、release-before-confirm poll 路径和组合键取消路径仍表达真实键态。
+
+## 不破坏说明
+
+- AX-10A / AX-10D / AX-10D1 / AX-10H 既有改动未回退。
+- tap mode 未改动;本轮只触碰 hold pre-capture timer 和对应测试。
+- combination cancel 保持:timer 回调仍在 confirm 前执行 `IsAnyOtherKeyPressedSince()`。
+- AX output / fallback 未改动;本轮未改 `internal/textoutput`、`internal/axinput`、`services.TextOutputService` 或前端 fallback UI。
+
+## 验证命令与结果
+
+在 `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src` 执行:
+
+- `gofmt -w app.go app_live_caption_test.go`
+ - PASS。
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`
+ - PASS。
+- `go test -run 'TestHoldPreCapture|TestShouldStartHoldPreCapture|TestStartRecordingIgnored|TestStartTapRecordingIgnored' .`
+ - PASS;保留既有 macOS SDK / onnxruntime link warning。
+- `go test -race -run 'TestHoldPreCapture' .`
+ - PASS;保留既有 macOS SDK / onnxruntime link warning。
+- `go test ./...`
+ - PASS;保留既有 macOS SDK / onnxruntime link warning。
+
+## 未做事项与风险
+
+- 本轮未运行真实物理右 Command QA,不声明 `AX-10J` QA PASS。
+- 本轮未发布、未 push、未 tag、未生成 manifest、未刷新 codebase-memory。
+- 代码审核 PASS 后仍需 QA retry 复测物理右 Command hold、真实 ASR、TextOutputRouter、AX direct insert 和 fallback 矩阵。
+
+## 终态
+
+`PM_NOTIFY_READY AX-10H1`
+
+请求 PMO 将 `AX-10I-GATE-20260706.1739` 交代码审核员同项复审;复审 PASS 前不得进入 `AX-10J` QA retry。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10L-\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/AX-10L-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..33c7125
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10L-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,102 @@
+# AX-10L 开发自查
+
+任务 ID:`AX-10L-CODER-20260706.2222`
+
+时间:2026-07-06 22:33 +0800
+
+结论:`PM_NOTIFY_READY AX-10L`
+
+建议 Review Gate ID:`AX-10M-GATE-20260706.2222`
+
+## 目标与边界
+
+本轮目标是修复 AX 主线 worktree 真实运行中的 hold pre-capture 激活延迟:真实物理右 Command hold 在 `Hotkey action: start hold pre-capture` 后,应按 `holdActivationDelay=180ms` 级别自动 confirm,而不是等到底层音频设备启动返回后才继续状态机。
+
+本轮只处理 hold pre-capture 激活 timer / hotkey loop / recorder 启动相关最小修复和测试;未处理 AX fallback UI、MAS build gate、TCC gate、发布、push、tag、manifest、codebase-memory、P1/P2 旧 spike 或 R222。
+
+## 工作区
+
+- Git 根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- 分支:`feature/mas-accessibility-direct-input`
+- 前置审核:`AX-10I PASS`
+- 输入证据:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10K-物理热键验证-evidence.md`
+
+## 根因判断
+
+AX-10K Attempt 3 的有效失败证据显示:
+
+- `22:18:55 Hotkey action: start hold pre-capture`
+- `22:20:53 Recording started (16kHz/16-bit/mono)`
+- `22:20:53 Hold pre-capture started`
+- 随后立即 `released before activation`
+
+这说明 AX-10H/H1 的 activation timer 并非单纯没有创建,而是被同步音频启动路径挡住了:`startHoldPreCaptureLocked()` 在持有 `App.mu` 时同步调用 `recorder.Start()`,而 `Hold pre-capture started` 日志位于 `recorder.Start()` 返回之后。真实运行中 `recorder.Start()` 卡住约 118 秒,timer goroutine 即使到期也无法拿到 `App.mu` 执行 confirm。
+
+## 修复文件与逻辑
+
+- `C1.source/privatevoice.src/app.go`
+ - `startHoldPreCaptureLocked()` 不再同步等待 `recorder.Start()`。
+ - 初始 key-down 时先设置 `isRecording=true`、`isHoldRecordingPending=true`、`isHoldRecorderStarting=true`,并立即安排 activation timer。
+ - 真实 recorder 启动改由 `startHoldPreCaptureRecorder()` goroutine 执行,避免音频设备启动阻塞 hotkey 状态机和 timer。
+ - timer 回调仍在 `App.mu` 内复核 sequence、context、当前物理键态、engine ready 和组合键,再调用 `confirmHoldPreCaptureLocked()`。
+ - 如果 release / combination / Escape 在 recorder 启动完成前取消 pending pre-capture,状态先取消;recorder goroutine 后续返回时会看到 `isRecording=false` 并 stop newly-started recorder,避免泄漏音频设备。
+ - `cancelHoldPreCaptureLocked()` 在 recorder 仍处于 starting 时不再同步 stop,避免 release/cancel 路径再次被底层 audio start 卡住。
+- `C1.source/privatevoice.src/app_live_caption_test.go`
+ - fake recorder 增加 `startBlock`,可模拟 `recorder.Start()` 卡住。
+ - 新增 `TestHoldPreCaptureTimerConfirmsWhileRecorderStartBlocked`,覆盖真实 hotkey loop / timer 调度路径:启动 pre-capture 后 fake recorder start 被阻塞,但 timer 仍在 180ms 级别 confirm,并把 indicator 置为 recording。
+- `CODEGRAPH.md`
+ - 记录 hold pre-capture 状态机现在先排 activation timer,recorder startup 在 App mutex 外运行,慢音频设备启动不能延迟 confirm。
+
+## 测试覆盖
+
+本轮新增可信覆盖:
+
+- `TestHoldPreCaptureTimerConfirmsWhileRecorderStartBlocked`
+ - 使用真实 `pollHoldHotkeyLocked(true, true)` 入口启动 hold pre-capture;
+ - fake recorder 的 `Start()` 被通道阻塞;
+ - 测试等待 activation timer 自动 confirm;
+ - 断言 pending 关闭、`hotkeyActive` 仍 true、indicator 为 recording;
+ - 最后释放 fake recorder start,并等待 `isHoldRecorderStarting=false`。
+
+前置 AX-10H/H1 测试仍覆盖:
+
+- 无后续 key-down 事件也能自动 confirm。
+- release-before-confirm 已被 poll 处理时会取消。
+- 物理键已松开但 release poll 未处理时,timer 会读取当前 key state 并取消。
+- combination key 在 activation 前会取消。
+
+## 验证命令与结果
+
+在 `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src` 执行:
+
+- `gofmt -w app.go app_live_caption_test.go`
+ - PASS。
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`
+ - PASS。
+- `go test -run 'TestHoldPreCapture|TestShouldStartHoldPreCapture|TestStartRecordingIgnored|TestStartTapRecordingIgnored' .`
+ - PASS;保留既有 macOS SDK / onnxruntime link warning。
+- `go test -race -run 'TestHoldPreCapture' .`
+ - PASS;保留既有 macOS SDK / onnxruntime link warning。
+- `go test ./...`
+ - PASS;保留既有 macOS SDK / onnxruntime link warning。
+
+本轮未改前端,未重复运行 `npm --prefix frontend run build`。前置 AX fallback UI 的 frontend build 证据见 AX-10D / AX-10D1 自查。
+
+## 剩余风险
+
+- 本轮未运行真实物理右 Command QA,不声明 AX-10K / AX-10N QA PASS。
+- 修复后预期 QA 日志应在 `Hotkey action: start hold pre-capture` 后约 180ms 出现 `Hotkey action: confirm hold pre-capture ...` 和 `Hold pre-capture confirmed`。如果底层 `Recording started` 仍被系统延迟到松手之后,新的证据会把问题收敛为 audio recorder startup / macOS device activation 延迟,而不是 hotkey timer 被 App mutex 卡住。
+- 真实 ASR / TextOutputRouter / AX direct insert / fallback 矩阵仍需后续 QA retry。
+
+## 未做事项
+
+- 未发布、未 push、未 tag、未生成 manifest、未刷新 codebase-memory。
+- 未处理 MAS build gate / TCC gate / App Store metadata gate。
+- 未触碰 P1/P2 旧 spike、R222 或根主工作区。
+
+## 终态
+
+`PM_NOTIFY_READY AX-10L`
+
+请求 PMO 派发 AX-10M 代码审核,建议 Review Gate ID:`AX-10M-GATE-20260706.2222`。代码审核 PASS 前不得进入 QA retry。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10L1-\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/AX-10L1-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..37d4f84
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10L1-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,121 @@
+# AX-10L1 开发自查
+
+任务:`AX-10L1-CODER-20260706.2306`
+
+时间:2026-07-06 23:09 +0800
+
+Review Gate:`AX-10M-GATE-20260706.2222`
+
+结论:`PM_NOTIFY_READY AX-10L1`
+
+## 目标与范围
+
+本轮只处理 `AX-10M` 代码审核退回的 AX-10L hold pre-capture 异步 recorder start 新增状态机窗口:
+
+1. timer 已 confirm、`recorder.Start()` 仍 starting 时用户 release 的 stop / recognition 顺序。
+2. timer 已 confirm、`recorder.Start()` 之后 late error 的 UI / live caption / timer 清理。
+
+未处理 AX fallback UI、TextOutputRouter、MAS build gate、TCC gate、发布、push、tag、manifest、codebase-memory、P1 / P2 旧 spike、R222。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/app.go`
+ - 新增 `isHoldStopPending` 状态,用于记录 recorder start 未完成时的 release / cancel。
+ - `stopRecordingLocked()` 在 `isHoldRecorderStarting=true` 时不再进入 `StopAndGetSamples()` / ASR pipeline,改为标记 pending stop,停止 live caption,显示取消态,等待 `recorder.Start()` 返回。
+ - `startHoldPreCaptureRecorder()` 在 `Start()` 成功返回后,如发现 pending stop 或已取消,执行 `recorder.Stop()` 并恢复状态,不进入识别 pipeline。
+ - `startHoldPreCaptureRecorder()` 在 `Start()` late error 时统一清理 `isRecording`、`isHoldRecordingPending`、`isHoldRecorderStarting`、`isHoldStopPending`、activation timer、live caption,并显示 `录音启动失败` 错误态,recording timer 因 `isRecording=false` 自动退出。
+ - Escape / release-before-confirm / combination cancel 路径同步清理或转为 pending stop,避免 recorder start goroutine 晚返回后重复 stop / 泄漏。
+- `C1.source/privatevoice.src/app_live_caption_test.go`
+ - 扩展 fake recorder,支持阻塞 `Start()`、late error、记录 `StopAndGetSamples()` 调用。
+ - 新增 `TestHoldPreCaptureReleaseAfterConfirmWaitsForRecorderStart`,覆盖 timer confirm 后 release 但 recorder start 仍阻塞的窗口。
+ - 新增 `TestHoldPreCaptureRecorderStartLateErrorCleansConfirmedState`,覆盖 timer confirm 后 `Start()` late error 的状态与 UI / live caption 清理。
+
+## Must-fix 对应
+
+### 1. release-after-confirm while recorder starting
+
+当前语义:
+
+- timer confirm 后可以进入 recording UI / live caption / recording timer。
+- 如果用户 release 时 `recorder.Start()` 仍未返回,`stopRecordingLocked(false)` 只设置 `isHoldStopPending=true`,立即停止 live caption 并显示取消态。
+- 在 `recorder.Start()` 返回前,不调用 `StopAndGetSamples()`,不进入 ASR pipeline。
+- `recorder.Start()` 成功返回后,如果发现 pending stop,则调用 `recorder.Stop()`,清理 `isHoldRecorderStarting` / `isHoldStopPending`,恢复可再次触发状态。
+
+测试断言:
+
+- Start 返回前 `StopAndGetSamples()==0`。
+- release 后 `isHoldStopPending=true`。
+- Start 返回后 `isRecording=false`、`isHoldRecorderStarting=false`、`isHoldStopPending=false`。
+- 最终只调用 `recorder.Stop()`,不调用 `StopAndGetSamples()`。
+
+### 2. late recorder.Start error after confirm
+
+当前语义:
+
+- `recorder.Start()` late error 时在锁内完整回退状态。
+- 取消 activation timer。
+- 调用 `stopLiveCaptionLocked()`,避免 live caption 残留。
+- 设置 indicator 错误态 `录音启动失败` 并延迟隐藏。
+- recording timer 读取到 `isRecording=false` 后退出。
+
+测试断言:
+
+- late error 后 `isRecording=false`、`isHoldRecorderStarting=false`、`liveCaptionCancel=nil`。
+- live caption cancel 回调被调用。
+- indicator 最终为 `overlay.StatusError`。
+
+## 验证命令
+
+在 `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src` 执行:
+
+```bash
+gofmt -w app.go app_live_caption_test.go
+```
+
+结果:PASS。
+
+```bash
+gofmt -l app.go app_live_caption_test.go
+```
+
+结果:PASS,无输出。
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+结果:PASS,无输出。
+
+```bash
+go test -run 'TestHoldPreCapture|TestShouldStartHoldPreCapture|TestStartRecordingIgnored|TestStartTapRecordingIgnored' .
+```
+
+结果:PASS。保留既有 macOS SDK / onnxruntime link warning。
+
+```bash
+go test -race -run 'TestHoldPreCapture' .
+```
+
+结果:PASS。保留既有 macOS SDK / onnxruntime link warning。
+
+```bash
+go test ./...
+```
+
+结果:PASS。保留既有 macOS SDK / onnxruntime link warning。
+
+## 风险与未覆盖
+
+- 本轮未做真实物理右 Command QA;修复通过单元测试和 race 聚焦测试验证,真实录音链路仍需后续 QA retry。
+- 既有 macOS SDK / onnxruntime link warning 仍存在,但本轮所有 Go 测试退出码为 0;该 warning 仍属于后续发布 / 兼容性 gate 风险,不是 AX-10L1 新增问题。
+- AX fallback UI、MAS build gate、TCC 授权撤销 / 切换、发布链路均未触碰。
+
+## 复审建议
+
+建议沿用同一 Review Gate `AX-10M-GATE-20260706.2222` 复审。复审重点:
+
+- `isHoldStopPending` 是否完整覆盖 recorder start 未完成时的 release / cancel。
+- `Start()` late error 分支是否足够清理 live caption、recording timer 相关状态和 indicator。
+- 新增测试是否覆盖 AX-10M 两个 must-fix。
+
+未发布、未 push、未 tag、未生成 manifest、未刷新 codebase-memory。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10L2-\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/AX-10L2-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..3ceeece
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10L2-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,104 @@
+# AX-10L2 开发自查
+
+任务:`AX-10L2-CODER-20260707.1206`
+
+时间:2026-07-07 12:08 +0800
+
+Review Gate:`AX-10M-GATE-20260706.2222`
+
+结论:`PM_NOTIFY_READY AX-10L2`
+
+## 目标与范围
+
+本轮只处理 `AX-10M` 同 Gate 复审退回的剩余 must-fix:
+
+- timer 已 confirm、`recorder.Start()` 仍 starting 时,用户走 Escape / cancel 路径仍同步 `recorder.Stop()` 的状态机窗口。
+
+未处理 AX fallback UI、TextOutputRouter、MAS build gate、TCC gate、发布、push、tag、manifest、codebase-memory、P1 / P2 旧 spike、R222。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/app.go`
+ - 在 `pollHotkey()` Escape 分支中新增 `isHoldRecorderStarting=true` 的专门处理。
+ - confirmed 后 Escape cancel 不再把 `isHoldRecorderStarting=false`,也不在 `App.mu` 内同步调用 `recorder.Stop()`。
+ - Escape cancel 改为设置 `isHoldStopPending=true`、`isRecording=false`、清理 activation timer、停止 live caption、显示取消态,并立即返回。
+ - `recorder.Start()` 晚返回后复用 AX-10L1 的 deferred-stop 统一收口:清理状态、调用一次 `recorder.Stop()`、不进入 `StopAndGetSamples()` / ASR pipeline。
+- `C1.source/privatevoice.src/app_live_caption_test.go`
+ - 新增 `TestHoldPreCaptureEscapeAfterConfirmDefersStopUntilRecorderStart`。
+ - 扩展 `fakeHotkeyListener`,支持独立模拟 Escape 键态,避免测试中把主热键按下误判为 Escape。
+
+## Must-fix 对应
+
+### confirmed 后 Escape cancel while recorder starting
+
+当前语义:
+
+- timer confirm 后可以进入 recording UI / live caption / recording timer。
+- 如果用户按 Escape 且 `recorder.Start()` 仍未返回,`pollHotkey()` 只设置 pending stop,清理 live caption 和 UI 取消态,并快速返回。
+- 在 `recorder.Start()` 返回前,不调用 `recorder.Stop()`,不调用 `StopAndGetSamples()`,不进入 ASR pipeline。
+- `recorder.Start()` 成功晚返回后,由 `startHoldPreCaptureRecorder()` 发现 `!isRecording || isHoldStopPending`,执行一次 `recorder.Stop()` 并清理 `isHoldRecorderStarting` / `isHoldStopPending`。
+
+测试断言:
+
+- Start 阻塞期间 Escape cancel 不等待 blocked recorder start。
+- Start 返回前 `recorder.Stop()==0`。
+- Start 返回前 `StopAndGetSamples()==0`。
+- Escape 后 `isHoldStopPending=true`,live caption cancel 被调用并清空。
+- Start 返回后 `isRecording=false`、`isHoldRecorderStarting=false`、`isHoldStopPending=false`。
+- 最终 `recorder.Stop()==1`,`StopAndGetSamples()==0`,indicator 为 `overlay.StatusCancelled`。
+
+## 验证命令
+
+在 `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src` 执行:
+
+```bash
+gofmt -w app.go app_live_caption_test.go
+```
+
+结果:PASS。
+
+```bash
+gofmt -l app.go app_live_caption_test.go
+```
+
+结果:PASS,无输出。
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+结果:PASS,无输出。
+
+```bash
+go test -run 'TestHoldPreCapture|TestShouldStartHoldPreCapture|TestStartRecordingIgnored|TestStartTapRecordingIgnored' .
+```
+
+结果:PASS。保留既有 macOS SDK / onnxruntime link warning。
+
+```bash
+go test -race -run 'TestHoldPreCapture' .
+```
+
+结果:PASS。保留既有 macOS SDK / onnxruntime link warning。
+
+```bash
+go test ./...
+```
+
+结果:PASS。保留既有 macOS SDK / onnxruntime link warning。
+
+## 风险与未覆盖
+
+- 本轮未做真实物理右 Command QA;修复通过单元测试和 race 聚焦测试验证,真实录音链路仍需后续 QA retry。
+- 既有 macOS SDK / onnxruntime link warning 仍存在,但本轮所有 Go 测试退出码为 0;该 warning 仍属于后续发布 / 兼容性 gate 风险,不是 AX-10L2 新增问题。
+- AX fallback UI、MAS build gate、TCC 授权撤销 / 切换、发布链路均未触碰。
+
+## 复审建议
+
+建议沿用同一 Review Gate `AX-10M-GATE-20260706.2222` 复审。复审重点:
+
+- Escape confirmed-cancel 是否完整进入 `isHoldStopPending` deferred-stop 语义。
+- Start 返回前是否不再同步 `recorder.Stop()`、不进入 `StopAndGetSamples()` / ASR。
+- 新增测试是否覆盖 AX-10M 复审剩余 must-fix。
+
+未发布、未 push、未 tag、未生成 manifest、未刷新 codebase-memory。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10P-\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/AX-10P-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..c239461
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10P-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,124 @@
+# AX-10P 开发自查
+
+任务:`AX-10P-CODER-20260707.1315`
+
+时间:2026-07-07 13:20 +0800
+
+Review Gate:`AX-10P-GATE-20260707.1315`
+
+结论:`PM_NOTIFY_READY AX-10P`
+
+## 目标与范围
+
+本轮只处理 AX mainline worktree 中真实物理右 Command hold 下录音启动 / deferred-stop 链路无法进入 `Recognized` / `text_output_done` 的问题。
+
+输入证据:`/Users/ar/Projects/PrivateVoice/02-P/QA/AX-10O-物理右Command验证-evidence.md`
+
+未处理 AX fallback UI、MAS build gate、TCC gate、发布、push、tag、manifest、codebase-memory、P1 / P2 旧 spike、R222。
+
+## 根因判断
+
+AX-10O 证明:
+
+- Accessibility granted。
+- Keyboard event tap started。
+- ASR engine ready。
+- 真实右 Command 已 accepted。
+- `Hold pre-capture confirmed` 已在 180ms 级别出现。
+- Owner release 时 `recorder.Start()` 仍未返回,进入 deferred-stop;Round 2 release 后约 150 秒才出现 `Recording started`,Round 3 等约 90 秒仍无 `Recognized` / `text_output_done`,停止 App 时才出现 `Recording started` / `Stopping audio device`。
+
+源码根因:
+
+- `internal/audio/recorder.go` 原 `Recorder.Start()` 在持有 `Recorder.mu` 时执行 `malgo.InitDevice()` 和 `device.Start()`。
+- miniaudio capture callback `onData()` 第一件事也是获取 `Recorder.mu`。
+- 如果真实设备的 `device.Start()` 等待首个回调 / 设备启动确认,回调会被 `Recorder.mu` 阻塞,`device.Start()` 也无法返回,形成自阻塞。
+- AX-10L/L1/L2 的 app 状态机避免了热键 loop 阻塞,但 release while starting 只能 deferred-stop;一旦底层 Start 长时间不返回,就无法进入 stop / samples / ASR / text output。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/internal/audio/recorder.go`
+ - `Recorder.Start()` 只在锁内读取 / 重置 recorder 状态、复制 `ctx` 和 `preferredDevice`。
+ - `malgo.InitDevice()` 和 `device.Start()` 不再在持有 `Recorder.mu` 时执行。
+ - `InitDevice()` 成功后先在锁内挂上 `r.device` 并设置 `r.isRecording=true`,再释放锁调用 `device.Start()`,允许启动期间的 `onData()` 回调进入并写入 PCM。
+ - `device.Start()` error 时,只在当前 device 仍属于 recorder 时回滚 `r.device` / `r.isRecording` 并 `Uninit()`,避免与 Stop / Close 双重释放。
+ - `ctx == nil` 时返回明确错误 `audio context is not initialized`。
+- `C1.source/privatevoice.src/app_live_caption_test.go`
+ - 新增 `TestHoldPreCaptureReleaseAfterRecorderStartRunsRecognitionAndOutput`,覆盖 recorder start 在按住期间完成后,release 进入 `StopAndGetSamples()`、ASR `Recognize()` 和 text output router。
+ - 扩展 fake recorder,支持返回 samples / voice activity。
+ - 扩展 fake engine / fake paster,验证识别文本能进入 output path。
+- `CODEGRAPH.md`
+ - 更新 Audio 与 hold pre-capture 说明,记录 `device.Start()` 不持有 `Recorder.mu` 的保护行为,以及 release 后正常识别路径 / starting 状态 deferred cancel 语义。
+
+## 验证命令
+
+在 `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src` 执行:
+
+```bash
+gofmt -w app_live_caption_test.go internal/audio/recorder.go
+```
+
+结果:PASS。
+
+```bash
+gofmt -l app_live_caption_test.go internal/audio/recorder.go
+```
+
+结果:PASS,无输出。
+
+```bash
+go test -run 'TestHoldPreCaptureReleaseAfterRecorderStartRunsRecognitionAndOutput|TestHoldPreCapture|TestShouldStartHoldPreCapture|TestStartRecordingIgnored|TestStartTapRecordingIgnored' .
+```
+
+结果:PASS。保留既有 macOS SDK / onnxruntime link warning。
+
+```bash
+go test ./internal/audio
+```
+
+结果:PASS。
+
+```bash
+go test -race -run 'TestHoldPreCapture' .
+```
+
+结果:PASS。保留既有 macOS SDK / onnxruntime link warning。
+
+```bash
+go test ./...
+```
+
+结果:PASS。保留既有 macOS SDK / onnxruntime link warning。
+
+```bash
+go test -tags appstore ./internal/textoutput ./services
+```
+
+结果:PASS。`services` 保留既有 macOS SDK / onnxruntime link warning。
+
+```bash
+./scripts/verify-ax-output-boundary.sh
+```
+
+结果:PASS。未发现 IMK / TIS、event injection、global monitor、Input Monitoring、Automation、系统输入法安装或 release action。
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+结果:PASS,无输出。
+
+## 剩余风险
+
+- 本轮未做真实物理右 Command QA;修复通过代码审查级根因分析和自动化回归测试验证。真实 `Recognized` / `text_output_done` / TextEdit 上屏仍需 PMO 后续派发 QA / Owner 物理验证。
+- 如果真实环境中 `device.Start()` 仍因系统音频层问题长时间不返回,本轮会让 `onData()` 不再被 `Recorder.mu` 自阻塞,但仍需要 QA 日志确认是否出现 `Recording started`、`PERF audio_stop_done`、`Recognized`、`text_output_done`。
+- 既有 macOS SDK / onnxruntime link warning 仍存在,但本轮所有 Go 测试退出码为 0;该 warning 仍属于后续发布 / 兼容性 gate 风险,不是 AX-10P 新增问题。
+
+## 复审建议
+
+建议进入 `AX-10P-GATE-20260707.1315` 代码审核。复审重点:
+
+- `Recorder.Start()` 是否已解除持有 `Recorder.mu` 调用 `device.Start()` 的自阻塞风险。
+- `device.Start()` error / Stop / Close 交错时是否避免双重释放或状态泄漏。
+- 新增 app 级测试是否覆盖 release 后进入 `StopAndGetSamples()` / ASR / text output 的目标链路。
+
+未发布、未 push、未 tag、未生成 manifest、未刷新 codebase-memory。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10P1-\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/AX-10P1-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..99cf08d
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10P1-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,121 @@
+# AX-10P1 开发自查
+
+任务:`AX-10P1-CODER-20260707.1408`
+
+Review Gate:`AX-10P-GATE-20260707.1315` 同 Gate 复审
+
+工作区:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+
+源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+
+分支:`feature/mas-accessibility-direct-input`
+
+## 目标与边界
+
+本轮只处理 `AX-10Q` 代码审核 `CHANGES_REQUESTED` 的两个 recorder native lifecycle must-fix:
+
+1. `Recorder.Start()` 解锁后使用 native context 时,`Close()` 不得并发释放同一个 context。
+2. `Recorder.Start()` 发布 device 后、`device.Start()` 返回前,`Stop()` / `Close()` 不得并发 stop / uninit 正在启动的 native device;`Close()` 已请求时不得返回 stale success。
+
+未处理 AX fallback UI、MAS build gate、TCC gate、发布、push、tag、manifest、codebase-memory、P1/P2 旧 spike、R222。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/internal/audio/recorder.go`
+ - 将 `malgo.AllocatedContext` 与 `malgo.Device` 包装为 audio 包内部最小接口,便于测试 native lifecycle 交错。
+ - 新增 `Recorder.lifecycleMu` 串行 native context / device 的 `devices`、`initDevice`、`Start`、`Stop`、`Uninit`、`Free` 生命周期窗口。
+ - `Start()` 仍不持 `Recorder.mu` 调用 `device.Start()`,保持 AX-10P 解除 callback 自阻塞的方向。
+ - `Close()` 先设置 `closing=true`,再等待 lifecycle 临界区;`Start()` 在 `device.Start()` 返回后复核 `closing` / current device,若 Close 已请求则 stop/uninit 并返回错误,不记录 stale success。
+ - `Stop()` / `StopAndGetSamples()` 等待 lifecycle 临界区退出后再 detach device,避免并发 uninit 正在 `Start()` 的 device。
+ - `ListInputDevices()` / `GetDeviceName()` 也进入 lifecycle 临界区,避免并发 `Close()` 释放 context。
+- `C1.source/privatevoice.src/internal/audio/recorder_test.go`
+ - 新增 fake recorder context / device,覆盖 blocked native Start 交错。
+ - 新增测试:
+ - `TestCloseWaitsForBlockedStartBeforeFreeingContext`
+ - `TestStopWaitsForBlockedStartBeforeUninitializingDevice`
+ - `TestStopWaitsForStartErrorCleanup`
+- `CODEGRAPH.md`
+ - 更新 audio 章节,记录 `Recorder.mu` 与 native lifecycle lock 的分工。
+
+## Must-fix 对应
+
+1. context 生命周期:
+ - `Start()` 在 `lifecycleMu` 内复制并使用 `ctx`,`Close()` 必须等待该临界区退出后才执行 `ctx.free()`。
+ - `Close()` 先设置 `closing=true`,使尚未进入或刚完成 native start 的 `Start()` 安全退出。
+ - 测试 `TestCloseWaitsForBlockedStartBeforeFreeingContext` 断言 blocked `Start()` 期间 `Close()` 不返回、context 不 free、device 不 uninit;Start 放行后返回错误并只清理一次。
+
+2. device start / stop / uninit 生命周期:
+ - `Stop()` / `StopAndGetSamples()` 在 detach 前等待 `lifecycleMu`,不会对正在 `device.Start()` 的 device 执行 stop/uninit。
+ - `Start()` 成功返回后复核 `r.device == device && r.isRecording && !r.closing`;如果 Close 已请求或 device 不再 current,执行安全收口并返回错误。
+ - 测试 `TestStopWaitsForBlockedStartBeforeUninitializingDevice` 断言 blocked `Start()` 期间 Stop 不返回且不 stop/uninit;Start 返回后才异步 stop/uninit。
+ - 测试 `TestStopWaitsForStartErrorCleanup` 断言 Start late error 与 Stop 交错时只由 Start error 分支清理一次。
+
+## 验证命令与结果
+
+在 `C1.source/privatevoice.src` 执行:
+
+```bash
+gofmt -w internal/audio/recorder.go internal/audio/recorder_test.go
+```
+
+结果:PASS。
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+结果:PASS,无输出。
+
+```bash
+go test -run 'TestCloseWaitsForBlockedStartBeforeFreeingContext|TestStopWaitsForBlockedStartBeforeUninitializingDevice|TestStopWaitsForStartErrorCleanup' ./internal/audio
+```
+
+结果:PASS。
+
+```bash
+go test ./internal/audio
+```
+
+结果:PASS。
+
+```bash
+go test -race ./internal/audio
+```
+
+结果:PASS。
+
+```bash
+go test -run 'TestHoldPreCaptureReleaseAfterRecorderStartRunsRecognitionAndOutput|TestHoldPreCapture|TestShouldStartHoldPreCapture|TestStartRecordingIgnored|TestStartTapRecordingIgnored' .
+```
+
+结果:PASS;保留既有 macOS SDK / onnxruntime link warning。
+
+```bash
+go test -race -run 'TestHoldPreCapture' .
+```
+
+结果:PASS;保留既有 macOS SDK / onnxruntime link warning。
+
+```bash
+go test ./...
+```
+
+结果:PASS;保留既有 macOS SDK / onnxruntime link warning。
+
+```bash
+./scripts/verify-ax-output-boundary.sh
+```
+
+结果:PASS。
+
+## 风险与复审关注
+
+- 本轮通过 fake native context / device 覆盖锁边界和清理顺序;真实 malgo native 行为仍需后续 QA / Owner 物理右 Command retry 复验。
+- `Stop()` / `StopAndGetSamples()` 现在会等待正在进行的 native Start 离开 lifecycle 临界区后再 detach;AX hold 路径已有 deferred-stop 保护,普通调用路径如遇底层长期卡住会等待同一底层问题,但不会再并发 uninit 造成 native 生命周期不一致。
+- 既有 macOS SDK / onnxruntime link warning 未由本轮引入,仍归后续发布 / 兼容性 gate。
+
+## 结论
+
+`AX-10Q` must-fix 已处理,可请求同一 Review Gate `AX-10P-GATE-20260707.1315` 复审。
+
+未发布、未 push、未 tag、未生成 manifest、未刷新 codebase-memory。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10S-\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/AX-10S-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..36f4013
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10S-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,117 @@
+# AX-10S 开发自查
+
+任务:`AX-10S-CODER-20260707.1515`
+
+工作区:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+
+源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+
+分支:`feature/mas-accessibility-direct-input`
+
+建议 Review Gate:`AX-10T-GATE-20260707.1515`
+
+## 目标与边界
+
+本轮处理 `AX-10R` 物理右 Command retry 的 blocker:真实运行中 `Hold pre-capture confirmed` 已出现,但 release 后仍进入 `Hold recording stop deferred until recorder start completes`,超过 60 秒没有 `Recording started` / `Recognized` / `text_output_done`。
+
+本轮只修改 recorder start / release / cancellation / cleanup 链路和测试证据;未处理 AX fallback UI、MAS build gate、TCC gate、发布、push、tag、manifest、codebase-memory、P1/P2 旧 spike、R222。
+
+## 根因判断
+
+`AX-10P1` 解决的是 native context / device lifecycle 并发收口:`Close()` 不再释放 `Start()` 正在使用的 context,`Stop()` / `Close()` 不再 uninit 正在 `device.Start()` 的 native device。
+
+但 `AX-10P1` 没解决另一类问题:`malgo.Device.Start()` 本身会等待 backend worker thread 真正启动;真实环境里该调用仍可能长时间不返回。App hold 状态机在 release 时看到 `isHoldRecorderStarting=true`,只设置 deferred-stop 并等待 `Start()` 返回后才进入 `StopAndGetSamples()` / ASR / text output。因此只要 native `Start()` 不返回,用户 release 后就永久卡在 deferred-stop,完全没有 `Recognized` / `text_output_done`。
+
+本轮没有选择“超时后并发 Uninit 正在 `ma_device_start` 的 native device”,因为这会重新引入 AX-10Q 刚审掉的 native 生命周期风险。最小修复是:正常 release 不再等待 native `Start()` 返回;recorder 在 native start in-flight 时允许快照已缓存 PCM 并标记 device 待清理,Start 未来返回后再统一 stop / uninit。Escape / 组合键取消仍保持 cancel-only,不进入 ASR。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/app.go`
+ - 正常 hold release 遇到 `isHoldRecorderStarting=true` 时,不再 deferred-stop 等待 native Start。
+ - 新路径记录 `Hold recording stop proceeds while recorder start is pending`,立即进入 `stopRecorderAfterReleaseTailCapture()` -> `recognizeAndPaste()`。
+ - Escape / 组合键 cancel 仍记录 `Hold recording stop deferred until recorder start completes`,不进入 `StopAndGetSamples()` / ASR。
+ - recorder Start 后续返回时,如正常 release pipeline 已经接管,清 `isHoldRecorderStarting` 并退出,不再覆盖 UI 为 cancelled。
+- `C1.source/privatevoice.src/internal/audio/recorder.go`
+ - 新增 `isStarting` / `stopAfterStart` 状态。
+ - `Stop()` / `StopAndGetSamples()` 在 native Start in-flight 时不等待 lifecycle,不调用 Stop / Uninit;仅快照 PCM、清 buffer、标记 `stopAfterStart=true`。
+ - `device.Start()` 返回后如看到 `stopAfterStart`,再同步 stop / uninit native device,避免并发释放 in-flight device。
+ - 增加可审计日志,便于 QA 区分 “release 快照 PCM” 与 “native cleanup 延后”。
+- `C1.source/privatevoice.src/app_live_caption_test.go`
+ - 将正常 release while recorder-start-blocked 的测试改为要求 ASR / text output,而不是 deferred-stop。
+ - 保留 Escape cancel while recorder-start-blocked 不进 ASR 的测试。
+- `C1.source/privatevoice.src/internal/audio/recorder_test.go`
+ - 新增 `TestStopAndGetSamplesWhileStartBlockedReturnsBufferedPCM`。
+ - 更新 Stop while start blocked 测试语义为:Stop 快速返回,不提前 stop/uninit;Start 返回后统一 cleanup。
+- `CODEGRAPH.md`
+ - 更新 audio / hold state machine 当前事实。
+
+## 验证命令与结果
+
+在 `C1.source/privatevoice.src` 执行:
+
+```bash
+gofmt -w app.go app_live_caption_test.go internal/audio/recorder.go internal/audio/recorder_test.go
+```
+
+结果:PASS。
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+结果:PASS,无输出。
+
+```bash
+go test -run 'TestStopAndGetSamplesWhileStartBlockedReturnsBufferedPCM|TestCloseWaitsForBlockedStartBeforeFreeingContext|TestStopWhileStartBlockedDefersDeviceCleanup|TestStopWhileStartBlockedLetsStartErrorCleanup' ./internal/audio
+```
+
+结果:PASS。
+
+```bash
+go test ./internal/audio
+```
+
+结果:PASS。
+
+```bash
+go test -race ./internal/audio
+```
+
+结果:PASS。
+
+```bash
+go test -run 'TestHoldPreCaptureReleaseAfterConfirmRunsRecognitionWhileRecorderStartBlocked|TestHoldPreCaptureReleaseAfterRecorderStartRunsRecognitionAndOutput|TestHoldPreCaptureEscapeAfterConfirmDefersStopUntilRecorderStart|TestHoldPreCapture|TestShouldStartHoldPreCapture|TestStartRecordingIgnored|TestStartTapRecordingIgnored' .
+```
+
+结果:PASS;保留既有 macOS SDK / onnxruntime link warning。
+
+```bash
+go test -race -run 'TestHoldPreCapture' .
+```
+
+结果:PASS;保留既有 macOS SDK / onnxruntime link warning。
+
+```bash
+go test ./...
+```
+
+结果:PASS;保留既有 macOS SDK / onnxruntime link warning。
+
+```bash
+./scripts/verify-ax-output-boundary.sh
+```
+
+结果:PASS。
+
+## 风险与复审关注
+
+- 本轮修复的是 app / recorder 状态机不能因为 native `Start()` 长时间不返回而阻断 release pipeline;真实 `malgo.Device.Start()` 为什么在 AX-10R 环境长期不返回,仍需通过下一轮物理 QA 日志确认是否只表现为 delayed return。
+- 如果 native Start in-flight 期间已经有 PCM callback 写入 buffer,下一轮 release 应推进到 ASR / text output;如果底层完全没有 callback,下一轮至少应出现 `audio_stop_done` / `recognize_pipeline_input` / `no_voice` 等可诊断结果,而不是继续沉默卡住。
+- `Close()` 仍等待 lifecycle 临界区以保护 native context,不在本轮改成强制并发 Uninit;如 QA 发现退出 App 仍因底层 Start 永久阻塞,需要单独 gate 评估是否接受更高风险的 native abort 策略。
+- 既有 macOS SDK / onnxruntime link warning 未由本轮引入,仍归后续发布 / 兼容性 gate。
+
+## 结论
+
+`AX-10S` 已形成可审 diff,建议派 `AX-10T` 代码审核。
+
+未发布、未 push、未 tag、未生成 manifest、未刷新 codebase-memory。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10S1-\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/AX-10S1-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..7446288
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10S1-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,141 @@
+# AX-10S1 开发自查
+
+任务:`AX-10S1-CODER-20260707.1532`
+
+Review Gate:`AX-10T-GATE-20260707.1515`
+
+角色:Coder
+
+终态建议:`PM_NOTIFY_READY AX-10S1`
+
+## 目标与边界
+
+本轮只处理 `AX-10T` 对 `AX-10S` 的两个 must-fix:
+
+1. `Recorder.Start()` 已进入但尚未设置 `isStarting` / 尚未发布 `device` 时,`Stop()` / `StopAndGetSamples()` 的 stop intent 不得丢失。
+2. normal hold release pipeline 已接管后,late `Recorder.Start()` error 不得把 UI / indicator 覆盖成 `录音启动失败`。
+
+未处理范围:fallback UI 新功能、MAS build gate、TCC gate、发布、push、tag、manifest、codebase-memory、P1 / P2 旧 spike、R222、Owner 物理 retry。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/internal/audio/recorder.go`
+ - 新增 `startRequested` 状态,用于表达 `Start()` 已进入但 native device 尚未 init / publish 的窗口。
+ - `Stop()` / `StopAndGetSamples()` 在 `startRequested && !isStarting` 时记录 stop intent、快照 PCM,并等待 `Start()` 路径返回后统一清理。
+ - 如果 stop intent 到达早于 device publish,`ctx.initDevice()` 后不再调用 `device.Start()`,只 `Uninit()` 已初始化但未启动的 device,并清理 recorder 状态。
+- `C1.source/privatevoice.src/app.go`
+ - `startHoldPreCaptureRecorder()` 的 error 分支区分三类状态:normal release pipeline 已接管、cancel pending、仍处于录音启动态。
+ - normal release pipeline 或 Escape / combination cancel 已接管后,late Start error 只做状态收口和日志,不覆盖 indicator / live caption 为启动失败。
+- `C1.source/privatevoice.src/internal/audio/recorder_test.go`
+ - 新增 `TestStopAndGetSamplesBeforeDevicePublishDefersIntentAndCleansInitializedDevice`。
+ - 新增 `TestStopBeforeDevicePublishDefersIntentAndCleansInitializedDevice`。
+ - 扩展 fake recorder context 支持阻塞 `initDevice`,覆盖 `isStarting` 尚未设置前的窗口。
+- `C1.source/privatevoice.src/app_live_caption_test.go`
+ - 新增 `TestHoldPreCaptureReleaseAfterConfirmRunsRecognitionBeforeRecorderStartEntered`。
+ - 新增 `TestHoldPreCaptureReleasePipelineSuppressesLateRecorderStartError`。
+ - fake app recorder 增加 `preStartBlock`,覆盖 app 已确认 hold 但 recorder `Start()` 尚未进入的窗口。
+- `CODEGRAPH.md`
+ - 更新 Audio 节点,记录 pre-publish stop intent 与 native cleanup 语义。
+
+本轮未回退或改写 AX-10A / AX-10D / AX-10D1 / AX-10H / AX-10L / AX-10P / AX-10S 前置待审 diff。
+
+## Must-fix 对应
+
+### 1. Start pre-publish 窗口 stop intent 丢失
+
+修复策略:
+
+- `Start()` 进入后立即在 `Recorder.mu` 下置 `startRequested=true`。
+- `Stop()` / `StopAndGetSamples()` 即使看不到 `device` / `isStarting`,也能通过 `startRequested` 识别 startup in-flight,设置 `stopAfterStart=true` 并快照 PCM。
+- 若 stop intent 到达时 native device 尚未发布,`ctx.initDevice()` 返回后执行 `device.Uninit()`,不再启动 native device,不泄漏 recording state。
+
+测试覆盖:
+
+- `TestStopAndGetSamplesBeforeDevicePublishDefersIntentAndCleansInitializedDevice`
+- `TestStopBeforeDevicePublishDefersIntentAndCleansInitializedDevice`
+- 既有 `TestStopAndGetSamplesWhileStartBlockedReturnsBufferedPCM`
+- 既有 `TestStopWhileStartBlockedDefersDeviceCleanup`
+
+### 2. normal release pipeline 接管后的 late Start error UI 覆盖
+
+修复策略:
+
+- app 层以 `isHoldRecorderStarting && !isRecording && !isHoldRecordingPending && !isHoldStopPending` 判定 normal release pipeline 已接管。
+- 该状态下 Start late error 不再设置 `overlay.StatusError / 录音启动失败`,避免覆盖 ASR / text output / no_voice 管线结果。
+- `isHoldStopPending=true` 的 Escape / combination cancel 路径也不再误报启动失败,保持取消态。
+
+测试覆盖:
+
+- `TestHoldPreCaptureReleasePipelineSuppressesLateRecorderStartError`
+- `TestHoldPreCaptureReleaseAfterConfirmRunsRecognitionBeforeRecorderStartEntered`
+- 既有 `TestHoldPreCaptureEscapeAfterConfirmDefersStopUntilRecorderStart`
+
+## 验证命令
+
+执行目录:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+
+```bash
+gofmt -w app.go app_live_caption_test.go internal/audio/recorder.go internal/audio/recorder_test.go
+```
+
+结果:PASS。
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+结果:PASS,无输出。
+
+```bash
+go test -run 'TestStopBeforeDevicePublishDefersIntentAndCleansInitializedDevice|TestStopAndGetSamplesBeforeDevicePublishDefersIntentAndCleansInitializedDevice|TestStopAndGetSamplesWhileStartBlockedReturnsBufferedPCM|TestStopWhileStartBlockedDefersDeviceCleanup|TestStopWhileStartBlockedLetsStartErrorCleanup' ./internal/audio
+```
+
+结果:PASS。
+
+```bash
+go test -run 'TestHoldPreCaptureReleaseAfterConfirmRunsRecognitionBeforeRecorderStartEntered|TestHoldPreCaptureReleasePipelineSuppressesLateRecorderStartError|TestHoldPreCaptureReleaseAfterConfirmRunsRecognitionWhileRecorderStartBlocked|TestHoldPreCaptureEscapeAfterConfirmDefersStopUntilRecorderStart|TestHoldPreCaptureRecorderStartLateErrorCleansConfirmedState' .
+```
+
+结果:PASS;保留既有 macOS SDK / onnxruntime link warning,退出码为 0。
+
+```bash
+go test -race ./internal/audio
+```
+
+结果:PASS。
+
+```bash
+go test -race -run 'TestHoldPreCapture' .
+```
+
+结果:PASS;保留既有 macOS SDK / onnxruntime link warning,退出码为 0。
+
+```bash
+go test ./...
+```
+
+结果:PASS;保留既有 macOS SDK / onnxruntime link warning,退出码为 0。
+
+```bash
+./scripts/verify-ax-output-boundary.sh
+```
+
+结果:PASS,未发现 IMK / TIS、event injection、global monitor、Input Monitoring、Automation、系统输入法安装或 release action。
+
+## 未跑门禁
+
+- 未运行 `npm --prefix frontend run build`:本轮 AX-10S1 未改前端;当前前端 diff 属于 AX-10D / AX-10D1 前置待审范围,本轮只处理 app/audio 状态机和测试。
+- 未做真实物理右 Command QA:复审 PASS 前不得派 Owner 物理 retry,本轮只返回可复审 diff。
+
+## 风险与说明
+
+- 如果底层 native `device.Start()` 永久不返回,`Close()` 仍会等待 recorder lifecycle,这属于 AX-10S 已记录的 native abort 能力边界;本轮不新增并发 abort。
+- pre-publish stop intent 到达后,如果 device 尚未启动,本轮选择 `Uninit()` 而非先 `Start()` 再 `Stop()`,避免在用户已 release 后继续启动音频设备。
+- normal release 才进入 `StopAndGetSamples -> ASR -> text output`;Escape / combination cancel 仍不进入 ASR。
+- 未发布、未 push、未 tag、未 manifest、未刷新 codebase-memory。
+
+## PMO 回报
+
+终态应主动回报 PrivateVoice PMO 主会话 / `02-P/进度地图.md` PMO Watch Queue。
+
+建议下一步:PMO 派 `AX-10T` 同 Gate 复审,继续使用 `AX-10T-GATE-20260707.1515`。复审 PASS 前不得声明 QA ready,不得派 Owner 物理 retry。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10V-\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/AX-10V-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..e49931b
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10V-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,119 @@
+# AX-10V 开发自查
+
+任务:`AX-10V-CODER-20260707.1649`
+
+工作区:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+
+源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+
+分支:`feature/mas-accessibility-direct-input`
+
+## 目标与结论
+
+AX-10U 物理右 Command retry 已证明 hotkey 捕获、hold timer confirm、Accessibility、ASR engine ready 均可达;新 blocker 是 normal release 发生在 audio device native start 完成前时,旧路径立即 snapshot 到空 PCM,进入 `samples=0 audio_ms=0 has_voice=false` 和 `pipeline_done result=no_voice`,TextEdit 无上屏。
+
+本轮改动把 release-before-audio-start 的 normal hold 路径从“立即空样本 ASR”改成:
+
+1. release 后如果 recorder start 仍 in-flight,先等待启动期 PCM。
+2. 看到 PCM 后再进入 `StopAndGetSamples -> ASR -> text output`。
+3. 如果 bounded wait 内仍没有 PCM,则调用 `Stop()` 收口 recorder,显示 `录音未就绪`,并记录 `audio_not_ready`,不进入 ASR、不产生误导性的 `no_voice`。
+
+结论:代码侧已完成可审 diff,可交后续代码审核。真实物理右 Command 上屏仍需 PMO 在审核后安排 Owner QA retry;本自查不声明 QA PASS。
+
+## 根因判断
+
+AX-10S1 / AX-10T 已修复 recorder start 长时间不返回导致 release 永久等待的问题,允许 release 在 start pending 时继续推进并 snapshot buffered PCM。
+
+AX-10U 暴露的剩余窗口是:真实物理 hold 中 release 发生得早于 native audio device start 回调产出 PCM。此时 snapshot 是安全的,但可能为空;旧路径把“设备还没有可用 PCM”误归类为“用户没有说话”,导致 `no_voice`,没有给出正确等待 / 重试语义。
+
+本轮不回退 AX-10T 的安全口径:pre-publish stop intent 不丢失;late Start error 不覆盖 normal release pipeline;Escape / combination cancel 仍不得进入 ASR。
+
+## 改动范围
+
+- `C1.source/privatevoice.src/app.go`
+ - 新增 `holdStartupSampleWaitTimeout` / `holdStartupSamplePollInterval`。
+ - 新增 `waitForHoldStartupSamples()`,normal hold release 在 recorder start pending 时轮询 `ReadSamplesSince(0)`,等待启动期 PCM。
+ - `stopRecorderAfterReleaseTailCapture()` 增加 `waitForStartupSamples` 参数与 `ok` 返回值。
+ - start pending 且无 PCM 超时后执行 `Stop()` 收口,清掉 `isStoppingRecording`,记录 `PERF audio_stop_done ... result=audio_not_ready`,调用方显示 `录音未就绪` 并停止,不进入 ASR。
+ - tap mode 与普通已启动 hold release 不改变原有行为。
+- `C1.source/privatevoice.src/app_live_caption_test.go`
+ - 新增 release-before-startup-samples 聚焦测试。
+ - 新增 startup sample wait timeout 后不进入 ASR 的聚焦测试。
+ - 扩展 fake recorder 的 `ReadSamplesSince()`,可模拟 start pending 时暂无 PCM、随后 PCM 到达。
+- `CODEGRAPH.md`
+ - 更新 hold 状态机说明:normal release 在 native recorder startup in-flight 时先等待启动期 PCM;超时进入 audio-not-ready,不再误报 `no_voice`。
+
+未修改前端、AX fallback UI、MAS build gate、TCC gate、发布、tag、manifest、codebase-memory、P1/P2 旧 spike、R222。
+
+## 保留的边界
+
+- 不引入 IMK / TIS / InputMethodKit。
+- 不引入 AppleEvents / Automation。
+- 不引入 Input Monitoring / Screen Recording。
+- 不引入 event injection、`CGEventPost`、自动 Cmd+V、自动粘贴。
+- Escape / combination cancel 在 recorder start pending 时仍是 cancel-only,不进入 ASR。
+- Normal release 才能进入 `StopAndGetSamples -> ASR -> text output`。
+
+## 验证命令与结果
+
+已执行:
+
+```bash
+gofmt -w app.go app_live_caption_test.go
+```
+
+结果:PASS。
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check
+```
+
+结果:PASS。
+
+```bash
+go test -run 'TestHoldPreCaptureReleaseWaitsForStartupSamplesBeforeRecognition|TestHoldPreCaptureReleaseWithoutStartupSamplesReportsAudioNotReady|TestHoldPreCaptureReleaseAfterConfirmRunsRecognitionWhileRecorderStartBlocked|TestHoldPreCaptureReleaseAfterConfirmRunsRecognitionBeforeRecorderStartEntered|TestHoldPreCaptureEscapeAfterConfirmDefersStopUntilRecorderStart' .
+```
+
+结果:PASS。输出包含既有 macOS SDK / onnxruntime 链接版本 warning。
+
+```bash
+go test -race ./internal/audio
+```
+
+结果:PASS。
+
+```bash
+go test -race -run 'TestHoldPreCapture' .
+```
+
+结果:PASS。输出包含既有 macOS SDK / onnxruntime 链接版本 warning。
+
+```bash
+go test ./...
+```
+
+结果:PASS。输出包含既有 macOS SDK / onnxruntime 链接版本 warning。
+
+```bash
+./scripts/verify-ax-output-boundary.sh
+```
+
+结果:PASS。
+
+未执行:
+
+```bash
+npm --prefix frontend run build
+```
+
+原因:AX-10V 未改前端文件;本轮风险集中在 Go hold / recorder 状态机。前置 AX-10D / AX-10D1 的前端 diff 保持不动。
+
+## 风险与未覆盖项
+
+- 真实物理右 Command + macOS audio stack 行为仍需 Owner QA retry。单元测试已覆盖 release 早于 startup PCM、PCM 后到、无 PCM 超时三个关键窗口,但不能替代真实设备验证。
+- 当前 bounded wait 为 `2s`,用于避免再次出现 indefinite wait。若后续 QA 仍观察到真实设备 2s 内无 PCM,需要 PMO / 架构判断是继续调参、切换录音启动时机,还是判定为更底层 audio device 启动可靠性问题。
+- 若 native start 已完成但仍无 PCM,本轮保持既有 no_voice 语义;AX-10V 修复的是“start 仍 pending 时空快照误报 no_voice”的窗口。
+
+## 建议下一步
+
+`PM_NOTIFY_READY AX-10V`。建议 PMO 派代码审核员审本轮 AX-10V diff;审核 PASS 后再安排 Owner 物理右 Command QA retry。未通过代码审核和 QA retry 前,不得声明 QA ready、不得主线合并、不得 MAS build、不得发布。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10Y-\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/AX-10Y-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..22eca5a
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10Y-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,98 @@
+# AX-10Y 开发自查
+
+- 任务 ID:AX-10Y-CODER-20260707.1752
+- Review Gate 建议:AX-10Y-GATE-20260707.1752
+- 分支:feature/mas-accessibility-direct-input
+- Git 根:/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O
+- 源码根:/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src
+- 终态建议:PM_NOTIFY_READY AX-10Y
+
+## 目标与范围
+
+本轮只处理 AX mainline worktree 中真实物理右 Command hold confirmed 后 recorder/audio startup/sample flow 长期无 PCM、无 `Recording started` 的根因定位与最小补修。
+
+本轮未处理 AX fallback UI、MAS build gate、TCC 撤销/授权切换 QA gate、IMK/TIS、发布、tag、manifest、codebase-memory、P1/P2 旧 spike、R222。
+
+## 根因判断
+
+AX-10X 证据显示:
+
+- 右 Command 已被真实捕获并 accepted。
+- `Hold pre-capture confirmed` 已出现。
+- release 后进入 `hold_starting_release`,随后 bounded wait 返回 `audio_not_ready`。
+- 没有 `Recording started`、没有 PCM、没有 `Recognized`、没有 `text_output_done`。
+
+代码路径复核后判断:
+
+1. `audio_not_ready` 日志说明 `Recorder.Start()` 已至少进入 native device start 窗口,并发布了 `isStarting=true`;否则 `StopAndGetSamples()` 不会报告 audio device start pending。
+2. `Recording started` 只会在 `device.Start()` 返回成功后打印。AX-10X 缺失该日志,说明真实阻塞点在 native `ma_device_start()` / CoreAudio backend start 返回前,而不是 hold timer、AX direct insert 或 ASR 后处理。
+3. AX-10X 只证明 Accessibility granted,没有证明 Microphone TCC granted。当前旧路径在 native device start 前没有显式麦克风授权预检/请求;如果首次录音或 TCC 状态异常,CoreAudio/miniaudio start 可能进入长时间等待,表现为用户真实口述 11 秒仍无 PCM。
+4. AX-10V 已阻止空 PCM 误进 ASR,本轮保留该安全边界;本轮改动把麦克风授权状态前置为可审计条件,并补充 native start 分段日志,避免后续 QA 继续面对无因黑盒。
+
+## 改动范围
+
+- `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/internal/audio/recorder.go`
+ - 在 native recorder 初始化前执行麦克风授权预检/请求。
+ - 新增 `ErrMicrophonePermission`,denied/restricted/not granted 时直接返回,不进入 native device init/start。
+ - 增加 recorder start 分段日志:start requested、device initialized、native start pending watchdog、native start returned。
+- `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/internal/audio/microphone_darwin.go`
+ - 使用 AVFoundation 查询/请求 Microphone 授权。
+- `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/internal/audio/microphone_other.go`
+ - 非 Darwin 平台保持 granted no-op,避免影响 Windows / Linux 测试。
+- `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/app.go`
+ - recorder start 返回 `audio.ErrMicrophonePermission` 时,录音状态显示为 `麦克风权限`,不误报普通录音启动失败。
+- `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/internal/audio/recorder_test.go`
+ - 测试中默认 stub 麦克风授权为 granted,避免本地测试触发真实 TCC。
+ - 新增 denied 场景,断言不进入 native device init/start,状态不泄漏。
+- `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/app_live_caption_test.go`
+ - 收敛 hold pre-capture 相关测试的异步断言,避免在错误状态尚未落定时读取 indicator。
+- `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/CODEGRAPH.md`
+ - 更新 audio recorder / microphone preflight / native start diagnostics 说明。
+
+## 保留边界
+
+- 空 PCM 仍不得进入 ASR;`audio_not_ready` 不伪装为 `no_voice`。
+- Escape / combination cancel 仍不进入 ASR。
+- AX-10T / AX-10W 已通过的 stop intent、late error、release-before-audio-start 语义未回退。
+- 未引入 IMK/TIS、event injection、global monitor、Input Monitoring、Automation 或 release action 上屏。
+- 未改 AX fallback UI、默认 Developer ID / Windows 输出路径、发布或 manifest。
+
+## 验证命令
+
+- `gofmt -w app.go internal/audio/recorder.go internal/audio/recorder_test.go internal/audio/microphone_darwin.go internal/audio/microphone_other.go app_live_caption_test.go`
+ - 结果:PASS。
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`
+ - 结果:PASS。
+- `go test ./internal/audio`
+ - 结果:PASS。
+- `go test -race ./internal/audio`
+ - 结果:PASS。
+- `go test -race -run 'TestHoldPreCapture' .`
+ - 结果:PASS;有现有 macOS SDK / onnxruntime 链接 warning,不影响退出码。
+- `go test ./...`
+ - 结果:PASS;有现有 macOS SDK / onnxruntime 链接 warning,不影响退出码。
+- `go test -tags appstore ./internal/audio ./internal/axinput ./internal/textoutput ./services`
+ - 结果:PASS;有现有 macOS SDK / onnxruntime 链接 warning,不影响退出码。
+- `./scripts/verify-ax-output-boundary.sh`
+ - 结果:PASS。
+
+本轮未运行 `npm --prefix frontend run build`,原因:AX-10Y 没有改前端文件;worktree 中保留的前置 AX-10D/AX-10D1 前端 diff 不属于本轮改动。
+
+## 剩余风险
+
+本轮不能替代 Owner 物理 QA。若下一轮 QA 日志显示:
+
+- `Microphone permission ready state=granted`
+- `Audio device initialized; starting native device`
+- `Native audio device start still pending after 2000ms`
+- 仍无 PCM / `Recording started`
+
+则麦克风授权不是剩余根因,问题应升级为 CoreAudio/miniaudio backend start 长时间不返回或具体音频设备兼容性问题,建议 PMO 派架构 / Coder 评估替换录音 backend 或增加更底层 CoreAudio 诊断切片。
+
+若下一轮 QA 日志显示麦克风状态为 `denied` / `restricted` / 长期 `notDetermined`,则下一步归属应转为权限 gate / QA 授权路径,而不是 AX output router。
+
+## PMO 回报
+
+建议终态:`PM_NOTIFY_READY AX-10Y`。
+
+建议下一步:PMO 派 AX-10Y 代码审核;审核 PASS 后再安排 Owner 物理右 Command QA retry。本轮不声明 QA ready、不主线合并、不 MAS build、不发布。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10Y1-\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/AX-10Y1-\345\274\200\345\217\221\350\207\252\346\237\245.md"
new file mode 100644
index 0000000..3ee4731
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/AX-10Y1-\345\274\200\345\217\221\350\207\252\346\237\245.md"
@@ -0,0 +1,126 @@
+# AX-10Y1 开发自查
+
+- 任务 ID:AX-10Y1-CODER-20260707.1844
+- Review Gate:AX-10Y-GATE-20260707.1752
+- 分支:feature/mas-accessibility-direct-input
+- Git 根:/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O
+- 源码根:/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src
+- 终态建议:PM_NOTIFY_READY AX-10Y1
+
+## 目标与范围
+
+本轮只处理 AX-10Z 审核 must-fix:microphone authorization / TCC request pending 期间,用户 release / Escape / combination cancel 产生的 stop intent 不能丢失;授权后若 stop / cancel 已接管,不得继续 native init/start,不得泄漏麦克风占用或让 UI 与 recorder 状态不一致。
+
+本轮未做真实物理 QA,未发布、未 push、未 tag、未生成 manifest、未刷新 codebase-memory,未做主线合并,未处理 AX fallback UI、MAS build gate、TCC QA gate、P1/P2 旧 spike 或 R222。
+
+## 根因确认
+
+AX-10Y 把 Microphone TCC preflight 加到 `Recorder.Start()` 开头,但旧实现是在 `ensureMicrophoneAuthorization()` 返回后才设置 `startRequested`。因此首次授权弹窗或系统权限处理挂起时,`Stop()` / `StopAndGetSamples()` 看不到 start intent,无法设置 `stopAfterStart`。如果用户此时松开右 Command 或取消,授权随后返回 granted 时 recorder 仍可能进入 `initDevice` / native `Start()`。
+
+该问题是 AX-10Y 新增 microphone preflight 后暴露的新状态机窗口,代码审核员判断成立。
+
+## 修复说明
+
+### Recorder 层
+
+文件:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/internal/audio/recorder.go`
+
+- `Start()` 在进入 `ensureMicrophoneAuthorization()` 前先在 `Recorder.mu` 下登记 start intent:
+ - reset buffer / volume state;
+ - `startRequested=true`;
+ - `stopAfterStart=false`;
+ - snapshot `ctx` 与 `preferredDevice`。
+- 授权返回后先复核 `stopAfterStart` / `closing`:
+ - 若 pending TCC 期间已有 `Stop()` / `StopAndGetSamples()` / `Close()` 接管,则清理 `startRequested` / `stopAfterStart` / `isRecording` 并直接返回,不进入 `initDevice` / native `Start()`。
+ - denied / restricted / timeout / not granted 仍返回 `audio.ErrMicrophonePermission`,并清理中间状态。
+- 维持 AX-10T / AX-10W 既有边界:native start in-flight、pre-publish stop intent、late error cleanup 语义不回退。
+
+### App hold cancel 层
+
+文件:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/app.go`
+
+- Escape 在 `isHoldRecorderStarting=true` 时除了设置 App 层 pending cancel,也立即调用 `recorder.Stop()`,让 recorder 层在 microphone authorization pending 或 native start pending 时都能记录 stop intent。
+- `cancelHoldPreCaptureLocked()` 在 recorder starting 状态也调用 `recorder.Stop()`,覆盖 release-before-confirm / combination cancel 等取消路径。
+- `startHoldPreCaptureRecorder()` 的 late success 分支避免对已经记录 pending stop 的取消路径重复 Stop;仍保持 normal release pipeline 不进入 cancel 语义。
+
+### 测试覆盖
+
+文件:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/internal/audio/recorder_test.go`
+
+- `TestStopWhileMicrophoneAuthorizationPendingPreventsNativeStart`
+ - microphone authorization pending 时调用 `Stop()`;
+ - 授权随后返回 granted;
+ - 断言不进入 `initDevice` / `device.Start()`,状态不泄漏。
+- `TestStopAndGetSamplesWhileMicrophoneAuthorizationPendingPreventsNativeStart`
+ - microphone authorization pending 时调用 `StopAndGetSamples()`;
+ - 授权随后返回 granted;
+ - 断言返回空 samples,不进入 `initDevice` / `device.Start()`,状态不泄漏。
+
+文件:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src/app_live_caption_test.go`
+
+- `TestHoldPreCaptureReleaseWhileRecorderPermissionPendingDoesNotRecognize`
+ - hold confirmed 后 recorder Start 仍 pending;
+ - normal release 进入 `audio_not_ready`;
+ - late Start success 后不进入 ASR、不输出文本、不保留 `isHoldRecorderStarting`。
+- `TestHoldPreCaptureEscapeWhileRecorderPermissionPendingStopsLateStart`
+ - hold confirmed 后 permission pending;
+ - Escape cancel 立即记录 recorder Stop;
+ - late Start success 后不进 ASR、不重复 Stop、不保留 starting 状态。
+- `TestHoldPreCaptureCombinationWhileRecorderPermissionPendingStopsLateStart`
+ - pre-capture pending / permission pending 窗口中 combination cancel;
+ - late Start success 后不进 ASR、不泄漏 starting / pending stop。
+- 更新 `TestHoldPreCaptureEscapeAfterConfirmDefersStopUntilRecorderStart`:
+ - 旧口径要求 Start 返回前 `Stop=0`;
+ - AX-10Y1 新安全口径要求 Escape cancel 立即记录 recorder stop intent,因此改为 `Stop=1`,并继续断言不进入 `StopAndGetSamples` / ASR。
+
+## 保留边界
+
+- 正常 release pipeline 仍只有 non-cancel release 可进入 `StopAndGetSamples -> ASR -> text output`。
+- Escape / combination cancel 不进入 ASR。
+- 空 PCM / `audio_not_ready` 不进入 ASR,不误报 `no_voice`。
+- late Start error 不覆盖已接管 UI 状态。
+- 未引入 IMK/TIS、event injection、global monitor、Input Monitoring、Automation、系统输入法安装或 release action。
+- 未改 AX fallback UI、Developer ID / Windows 默认输出路径、发布链路。
+
+## 验证命令
+
+- `gofmt -w C1.source/privatevoice.src/internal/audio/recorder.go C1.source/privatevoice.src/internal/audio/recorder_test.go C1.source/privatevoice.src/app.go C1.source/privatevoice.src/app_live_caption_test.go`
+ - 结果:PASS。
+- `git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O diff --check`
+ - 结果:PASS。
+- `go test ./internal/audio`
+ - 结果:PASS。
+- `go test -run 'TestStop.*MicrophoneAuthorizationPending|TestStopAndGetSamplesWhileMicrophoneAuthorizationPending' ./internal/audio`
+ - 结果:PASS。
+- `go test -run 'TestHoldPreCapture.*PermissionPending|TestHoldPreCaptureEscapeAfterConfirmDefersStopUntilRecorderStart|TestHoldPreCaptureReleaseWithoutStartupSamplesReportsAudioNotReady|TestHoldPreCaptureCombinationBeforeActivationCancels|TestHoldPreCaptureTimerCancelsWhenKeyReleasedBeforePoll' .`
+ - 结果:PASS;有既有 macOS SDK / onnxruntime link warning,不影响退出码。
+- `go test -race ./internal/audio`
+ - 结果:PASS。
+- `go test -race -run 'TestHoldPreCapture' .`
+ - 结果:PASS;有既有 macOS SDK / onnxruntime link warning,不影响退出码。
+- `go test ./...`
+ - 结果:PASS;有既有 macOS SDK / onnxruntime link warning,不影响退出码。
+- `go test -tags appstore ./internal/audio ./internal/axinput ./internal/textoutput ./services`
+ - 结果:PASS;有既有 macOS SDK / onnxruntime link warning,不影响退出码。
+- `./scripts/verify-ax-output-boundary.sh`
+ - 结果:PASS。
+
+未运行 `npm --prefix frontend run build`,原因:AX-10Y1 未改前端文件;worktree 中前端 diff 属于前置 AX-10D / AX-10D1 fallback UI 补修,不属于本轮 must-fix。
+
+## 风险与下一步
+
+本轮修复的是 AX-10Z 指出的状态机安全窗口,不代表 AX-10X 物理右 Command QA 已通过。若复审 PASS,建议 PMO 继续派同 Gate 代码复审;代码审核 PASS 后再安排 Owner 物理 QA retry。
+
+下一轮 QA 仍需重点观察:
+
+- 首次 Microphone TCC 弹窗 / 授权结果;
+- `Microphone permission ready state=...`;
+- 是否进入 `Audio device initialized; starting native device`;
+- 是否出现 `Native audio device start still pending after 2000ms`;
+- 是否出现 `Recording started`、PCM、`Recognized`、`text_output_done`。
+
+## PMO 回报
+
+建议终态:`PM_NOTIFY_READY AX-10Y1`。
+
+建议下一步:PMO 派 `AX-10Z` 同 Gate 复审;复审 PASS 前不得 Owner 物理 QA retry、主线合并、MAS build、发布、tag、manifest 或刷新 codebase-memory。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/BR-AX-MAINLINE-\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-AX-MAINLINE-\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..306df78
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/BR-AX-MAINLINE-\345\210\206\346\224\257\345\210\233\345\273\272\350\256\260\345\275\225.md"
@@ -0,0 +1,78 @@
+# BR-AX-MAINLINE 分支创建记录
+
+任务 ID:`BR-AX-MAINLINE-BRANCH-20260706.1551`
+父任务:`BR-AX-MAINLINE`
+角色:Coder
+时间:2026-07-06 15:58 +0800
+结论:`PM_NOTIFY_READY BR-AX-MAINLINE`
+
+## 目标与边界
+
+- 目标:只创建 AX 主线集成的干净 feature 分支 / worktree,为后续 `AX-10A` 实现切片做准备。
+- 未做:未写 AX 实现代码,未迁移 P2 代码,未构建,未测试,未发布,未 push,未 tag,未生成 manifest,未刷新 codebase-memory。
+- 后续实现任务:`AX-10A`,必须由 PMO 单独派发后才能开始。
+
+## 起点
+
+- Git 根:`/Users/ar/Projects/PrivateVoice/03-O`
+- 起点分支:本地 `main`
+- 起点 commit:`ac1d4fd8ef875312d11acd4e4e5935ced542c227`
+- R222 代码 baseline / merge commit:`7cec9cadc16b6daf902c89aca770f37f1843099b`
+- `boborobots/main`:`fecea2c7a4ecc48b2354868894e78be361a2ccf1`
+- 本地 `main` 状态:相对 `boborobots/main` ahead 3,未 push。
+
+## 分支与 worktree
+
+- 分支名:`feature/mas-accessibility-direct-input`
+- worktree 根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O`
+- 实际源码根:`/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O/C1.source/privatevoice.src`
+- 创建命令:
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/03-O worktree add -b feature/mas-accessibility-direct-input /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O ac1d4fd8ef875312d11acd4e4e5935ced542c227
+```
+
+## 核验命令
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/03-O status --short --branch
+git -C /Users/ar/Projects/PrivateVoice/03-O rev-parse --abbrev-ref HEAD
+git -C /Users/ar/Projects/PrivateVoice/03-O rev-parse HEAD
+git -C /Users/ar/Projects/PrivateVoice/03-O branch --list feature/mas-accessibility-direct-input
+git -C /Users/ar/Projects/PrivateVoice/03-O worktree list
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O status --short --branch
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O rev-parse --abbrev-ref HEAD
+git -C /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O rev-parse HEAD
+```
+
+核验结果:
+
+- 主工作区:`main@ac1d4fd8ef875312d11acd4e4e5935ced542c227`
+- 新 worktree:`feature/mas-accessibility-direct-input@ac1d4fd8ef875312d11acd4e4e5935ced542c227`
+- 目标分支创建前不存在。
+- 目标 worktree 路径创建前不存在。
+- 已同步项目分支地图:`/Users/ar/Projects/PrivateVoice/项目分支地图.drawio`
+
+## 生命周期与清理条件
+
+- 生命周期:用于 AX 主线集成任务链,至少覆盖 `AX-10A` Coder、`AX-10B` 代码审核、`AX-10C` QA 和后续 PMO 收口。
+- 清理条件:AX 主线集成完成并由 PMO / Owner 确认不再需要本 worktree;清理前必须确认 worktree 内无未提交 / 未归档改动。
+- 清理建议命令:
+
+```bash
+git -C /Users/ar/Projects/PrivateVoice/03-O worktree remove /Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O
+```
+
+如需删除分支,必须另由 PMO / Owner 明确,并先确认已合并或保留可恢复记录。
+
+## 禁止事项
+
+- 不在本任务中开始 `AX-10A`。
+- 不迁移 P2 spike 代码。
+- 不写 AX 实现代码。
+- 不构建、不测试、不生成包。
+- 不 push、不发布、不 tag、不生成 manifest、不刷新 codebase-memory。
+
+## 下一步
+
+等待 PMO 单独派发 `AX-10A`。`AX-10A` 必须在 `/Users/ar/Projects/PrivateVoice/AX-mainline-worktree/03-O` 内工作,且不得搬入 P2 dev-only POC 控件。
--
Gitblit v1.9.3