From abdb22f7391d4128a4f89d8b668781061aedf86d Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Tue, 30 Jun 2026 03:15:49 +0800
Subject: [PATCH] Add Windows preview build

---
 privatevoice.src/internal/model/build_capabilities.go                 |   15 +-
 privatevoice.src/scripts/build-windows-preview.sh                     |   86 +++++++++++++++++
 privatevoice.src/internal/model/build_capabilities_monterey.go        |    7 +
 CHANGELOG.md                                                          |    7 +
 CODEGRAPH.md                                                          |   61 ++++++++++++
 privatevoice.src/services/engine_service_windows_test.go              |   43 ++++++++
 privatevoice.src/services/engine_service_qwen3_default_test.go        |    2 
 privatevoice.src/services/engine_service.go                           |    8 +
 privatevoice.src/internal/model/build_capabilities_default.go         |    6 
 privatevoice.src/internal/model/build_capabilities_windows.go         |   10 ++
 privatevoice.src/frontend/src/components/settings/LanguagePage.svelte |    3 
 privatevoice.src/frontend/src/lib/i18n/zh.json                        |    3 
 privatevoice.src/frontend/src/lib/i18n/en.json                        |    3 
 13 files changed, 238 insertions(+), 16 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 254d47c..3ebcf88 100755
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,13 @@
 - build: `20260629.2014`
 - 说明: 本版本为性能回退排查诊断候选包,用于真实使用几天后分析慢在识别、尾部等待还是粘贴链路。
 
+### Windows 技术预览
+
+- **新增 Windows 预览构建脚本**:`privatevoice.src/scripts/build-windows-preview.sh` 可生成 Windows x64 预览目录和 zip。
+- **预览版只开放 SenseVoice**:Windows build 中其他模型仍可见但置灰禁用,避免用户选择尚未验证的模型导致启动或识别失败。
+- **打包 sherpa/onnxruntime 运行时 DLL**:预览包包含 `onnxruntime.dll`、`sherpa-onnx-c-api.dll`、`sherpa-onnx-cxx-api.dll`。
+- **交付边界**:该产物是 Windows 技术预览,不是已签名安装器,也未完成 Windows 10/11 真机 QA。
+
 ---
 
 ## v2.1.37 (2026-06-29)
diff --git a/CODEGRAPH.md b/CODEGRAPH.md
new file mode 100644
index 0000000..3e58681
--- /dev/null
+++ b/CODEGRAPH.md
@@ -0,0 +1,61 @@
+# CODEGRAPH
+
+## Project
+
+- Product: Wails v3 / Go desktop dictation app.
+- Current branch purpose: Windows technical preview based on `v2.1.38-build20260629.2014-diagnostic`.
+- Protected macOS behavior: existing macOS App Store / local DMG build lines are not changed by this branch.
+
+## Core Entrypoints
+
+- `privatevoice.src/main.go`: process startup, data migration, logger, single-instance guard.
+- `privatevoice.src/app.go`: Wails app wiring, settings window, tray, recorder, hotkey loop, ASR init, paste orchestration.
+- `privatevoice.src/embed.go`: embeds frontend assets and app/tray icons.
+
+## Platform Modules
+
+- Audio: `privatevoice.src/internal/audio/recorder.go`
+  - Uses `github.com/gen2brain/malgo`.
+  - Cross-platform capture target: 16 kHz, 16-bit, mono.
+- Hotkey:
+  - macOS: `privatevoice.src/internal/hotkey/hotkey_darwin.go`
+  - Windows: `privatevoice.src/internal/hotkey/hotkey_windows.go`
+- Text input:
+  - macOS: `privatevoice.src/internal/input/paste_darwin.go`
+  - Windows: `privatevoice.src/internal/input/paste_windows.go`
+- ASR engine:
+  - macOS: `privatevoice.src/internal/engine/engine_darwin.go`
+  - Windows: `privatevoice.src/internal/engine/engine_windows.go`
+- Overlay:
+  - macOS: `privatevoice.src/internal/overlay/overlay_darwin.go`
+  - Windows: `privatevoice.src/internal/overlay/overlay_windows.go`
+
+## Windows Preview Scope
+
+- Windows preview supports `SenseVoice` only.
+- Non-SenseVoice models remain visible in settings but are disabled through `model.IsModelSupportedInCurrentBuild`.
+- Windows unsupported reason: `windows_preview_unsupported`.
+- Build script: `privatevoice.src/scripts/build-windows-preview.sh`.
+- Runtime package includes:
+  - `PrivateVoice Dictation.exe`
+  - `onnxruntime.dll`
+  - `sherpa-onnx-c-api.dll`
+  - `sherpa-onnx-cxx-api.dll`
+  - `README-WINDOWS-PREVIEW.txt`
+
+## Standard Verification Commands
+
+Run from `privatevoice.src`:
+
+```bash
+npm --prefix frontend run build
+go test ./...
+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
+```
+
+## Known Gaps
+
+- Windows runtime QA must still be performed on Windows 10/11 hardware.
+- The Windows preview zip is unsigned and not an installer.
+- SmartScreen / Defender prompts are expected for local testing.
diff --git a/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte b/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
index 5d07007..d486394 100644
--- a/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
+++ b/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
@@ -184,6 +184,9 @@
     if (option.unsupportedReason === 'requires_macos_14') {
       return t('models.qwen3RequiresMacOS14')
     }
+    if (option.unsupportedReason === 'windows_preview_unsupported') {
+      return t('models.windowsPreviewUnsupported')
+    }
     return t('settings.modelUnavailable')
   }
 
diff --git a/privatevoice.src/frontend/src/lib/i18n/en.json b/privatevoice.src/frontend/src/lib/i18n/en.json
index 6aa47d2..7f8aa66 100755
--- a/privatevoice.src/frontend/src/lib/i18n/en.json
+++ b/privatevoice.src/frontend/src/lib/i18n/en.json
@@ -92,7 +92,8 @@
     "japaneseDesc": "Dedicated Japanese offline model based on ReazonSpeech.",
     "koreanDesc": "Dedicated Korean offline model for Korean dictation.",
     "qwen3Desc": "Higher quality Chinese offline model for long Chinese dictation, technical terms, and mixed Chinese-English input.",
-    "qwen3RequiresMacOS14": "Qwen3-ASR requires macOS 14 or later. This compatibility build does not support downloading it."
+    "qwen3RequiresMacOS14": "Qwen3-ASR requires macOS 14 or later. This compatibility build does not support downloading it.",
+    "windowsPreviewUnsupported": "The Windows preview currently supports SenseVoice only. This model is not available yet."
   },
   "permissions": {
     "title": "Permission Check",
diff --git a/privatevoice.src/frontend/src/lib/i18n/zh.json b/privatevoice.src/frontend/src/lib/i18n/zh.json
index 8f29858..5d2e8c5 100755
--- a/privatevoice.src/frontend/src/lib/i18n/zh.json
+++ b/privatevoice.src/frontend/src/lib/i18n/zh.json
@@ -92,7 +92,8 @@
     "japaneseDesc": "基于 ReazonSpeech 的日语专项离线模型。",
     "koreanDesc": "韩语专项离线模型,适合韩语听写。",
     "qwen3Desc": "更高质量的中文离线模型,适合中文长句、技术词和中英混输。",
-    "qwen3RequiresMacOS14": "Qwen3-ASR 需要 macOS 14 或更高版本,本兼容包暂不支持下载。"
+    "qwen3RequiresMacOS14": "Qwen3-ASR 需要 macOS 14 或更高版本,本兼容包暂不支持下载。",
+    "windowsPreviewUnsupported": "Windows 预览版当前仅支持 SenseVoice,暂不支持此模型。"
   },
   "permissions": {
     "title": "权限检查",
diff --git a/privatevoice.src/internal/model/build_capabilities.go b/privatevoice.src/internal/model/build_capabilities.go
index 1e75db3..7c27a62 100644
--- a/privatevoice.src/internal/model/build_capabilities.go
+++ b/privatevoice.src/internal/model/build_capabilities.go
@@ -1,17 +1,14 @@
 package model
 
-const UnsupportedReasonRequiresMacOS14 = "requires_macos_14"
+const (
+	UnsupportedReasonRequiresMacOS14 = "requires_macos_14"
+	UnsupportedReasonWindowsPreview  = "windows_preview_unsupported"
+)
 
 func IsModelSupportedInCurrentBuild(modelID string) bool {
-	if modelID == Qwen3ASRModelID {
-		return qwen3ASRSupportedInCurrentBuild
-	}
-	return true
+	return ModelUnsupportedReason(modelID) == ""
 }
 
 func ModelUnsupportedReason(modelID string) string {
-	if modelID == Qwen3ASRModelID && !IsModelSupportedInCurrentBuild(modelID) {
-		return UnsupportedReasonRequiresMacOS14
-	}
-	return ""
+	return modelUnsupportedReasonInCurrentBuild(modelID)
 }
diff --git a/privatevoice.src/internal/model/build_capabilities_default.go b/privatevoice.src/internal/model/build_capabilities_default.go
index 2314d90..5f0a81a 100644
--- a/privatevoice.src/internal/model/build_capabilities_default.go
+++ b/privatevoice.src/internal/model/build_capabilities_default.go
@@ -1,5 +1,7 @@
-//go:build !monterey_compat
+//go:build !monterey_compat && !windows
 
 package model
 
-const qwen3ASRSupportedInCurrentBuild = true
+func modelUnsupportedReasonInCurrentBuild(modelID string) string {
+	return ""
+}
diff --git a/privatevoice.src/internal/model/build_capabilities_monterey.go b/privatevoice.src/internal/model/build_capabilities_monterey.go
index 63e944d..1de0e80 100644
--- a/privatevoice.src/internal/model/build_capabilities_monterey.go
+++ b/privatevoice.src/internal/model/build_capabilities_monterey.go
@@ -2,4 +2,9 @@
 
 package model
 
-const qwen3ASRSupportedInCurrentBuild = false
+func modelUnsupportedReasonInCurrentBuild(modelID string) string {
+	if modelID == Qwen3ASRModelID {
+		return UnsupportedReasonRequiresMacOS14
+	}
+	return ""
+}
diff --git a/privatevoice.src/internal/model/build_capabilities_windows.go b/privatevoice.src/internal/model/build_capabilities_windows.go
new file mode 100644
index 0000000..67de4d4
--- /dev/null
+++ b/privatevoice.src/internal/model/build_capabilities_windows.go
@@ -0,0 +1,10 @@
+//go:build windows
+
+package model
+
+func modelUnsupportedReasonInCurrentBuild(modelID string) string {
+	if modelID == DefaultModelID {
+		return ""
+	}
+	return UnsupportedReasonWindowsPreview
+}
diff --git a/privatevoice.src/scripts/build-windows-preview.sh b/privatevoice.src/scripts/build-windows-preview.sh
new file mode 100755
index 0000000..b12d503
--- /dev/null
+++ b/privatevoice.src/scripts/build-windows-preview.sh
@@ -0,0 +1,86 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT_DIR"
+
+APP_VERSION="2.1.38"
+APP_BUILD="20260629.2014"
+ARCH="${1:-amd64}"
+
+if [[ "$ARCH" != "amd64" ]]; then
+  echo "Only amd64 is supported for the Windows preview build." >&2
+  exit 2
+fi
+
+for tool in go npm x86_64-w64-mingw32-gcc x86_64-w64-mingw32-g++; do
+  if ! command -v "$tool" >/dev/null 2>&1; then
+    echo "Missing required tool: $tool" >&2
+    exit 127
+  fi
+done
+
+OUT_ROOT="$ROOT_DIR/build/windows-preview"
+OUT_DIR="$OUT_ROOT/PrivateVoice-Dictation-${APP_VERSION}-build${APP_BUILD}-windows-${ARCH}-preview"
+ZIP_PATH="$OUT_ROOT/PrivateVoice-Dictation-${APP_VERSION}-build${APP_BUILD}-windows-${ARCH}-preview.zip"
+
+rm -rf "$OUT_DIR" "$ZIP_PATH"
+mkdir -p "$OUT_DIR"
+
+echo "Building frontend..."
+npm --prefix frontend install
+npm --prefix frontend run build
+
+SHERPA_WINDOWS_DIR="$(go list -m -f '{{.Dir}}' github.com/k2-fsa/sherpa-onnx-go-windows)"
+SHERPA_DLL_DIR="$SHERPA_WINDOWS_DIR/lib/x86_64-pc-windows-gnu"
+for dll in onnxruntime.dll sherpa-onnx-c-api.dll sherpa-onnx-cxx-api.dll; do
+  if [[ ! -f "$SHERPA_DLL_DIR/$dll" ]]; then
+    echo "Missing sherpa runtime DLL: $SHERPA_DLL_DIR/$dll" >&2
+    exit 3
+  fi
+done
+
+echo "Building Windows executable..."
+GOOS=windows \
+GOARCH=amd64 \
+CGO_ENABLED=1 \
+CC=x86_64-w64-mingw32-gcc \
+CXX=x86_64-w64-mingw32-g++ \
+go build \
+  -buildvcs=false \
+  -ldflags="-H windowsgui" \
+  -o "$OUT_DIR/PrivateVoice Dictation.exe" \
+  .
+
+cp "$SHERPA_DLL_DIR/onnxruntime.dll" "$OUT_DIR/"
+cp "$SHERPA_DLL_DIR/sherpa-onnx-c-api.dll" "$OUT_DIR/"
+cp "$SHERPA_DLL_DIR/sherpa-onnx-cxx-api.dll" "$OUT_DIR/"
+
+cat >"$OUT_DIR/README-WINDOWS-PREVIEW.txt" <<EOF
+PrivateVoice Dictation Windows Preview
+Version: ${APP_VERSION}
+Build: ${APP_BUILD}
+Architecture: Windows x64
+
+Scope:
+- Technical preview based on PrivateVoice Dictation ${APP_VERSION}.
+- The first Windows preview supports SenseVoice Chinese recognition only.
+- Other models are visible but disabled in this preview build.
+
+How to run:
+1. Extract the whole folder.
+2. Keep the EXE and DLL files in the same directory.
+3. Run "PrivateVoice Dictation.exe".
+4. Download/install the SenseVoice model if prompted.
+5. Use the configured hotkey and verify text input in Notepad or a browser text field.
+
+Known limitations:
+- This is not a signed Windows installer.
+- Real Windows audio, hotkey, paste, tray, and WebView2 behavior still need QA on Windows 10/11.
+- If Windows Defender or SmartScreen blocks it, allow it only for local testing.
+EOF
+
+(cd "$OUT_ROOT" && zip -qr "$(basename "$ZIP_PATH")" "$(basename "$OUT_DIR")")
+
+echo "Built: $OUT_DIR"
+echo "Zip:   $ZIP_PATH"
diff --git a/privatevoice.src/services/engine_service.go b/privatevoice.src/services/engine_service.go
index f7608d5..ecfb3ac 100755
--- a/privatevoice.src/services/engine_service.go
+++ b/privatevoice.src/services/engine_service.go
@@ -373,8 +373,14 @@
 	if err != nil {
 		return model.ModelProfile{}, err
 	}
-	if !model.IsModelSupportedInCurrentBuild(profile.ID) {
+	switch model.ModelUnsupportedReason(profile.ID) {
+	case "":
+	case model.UnsupportedReasonRequiresMacOS14:
 		return model.ModelProfile{}, fmt.Errorf("model %s requires macOS 14 or later", profile.ID)
+	case model.UnsupportedReasonWindowsPreview:
+		return model.ModelProfile{}, fmt.Errorf("model %s is not supported in the Windows preview build; use SenseVoice", profile.ID)
+	default:
+		return model.ModelProfile{}, fmt.Errorf("model %s is not supported in this build", profile.ID)
 	}
 
 	current := s.currentModel()
diff --git a/privatevoice.src/services/engine_service_qwen3_default_test.go b/privatevoice.src/services/engine_service_qwen3_default_test.go
index 9293685..0e4997e 100644
--- a/privatevoice.src/services/engine_service_qwen3_default_test.go
+++ b/privatevoice.src/services/engine_service_qwen3_default_test.go
@@ -1,4 +1,4 @@
-//go:build !monterey_compat
+//go:build !monterey_compat && !windows
 
 package services
 
diff --git a/privatevoice.src/services/engine_service_windows_test.go b/privatevoice.src/services/engine_service_windows_test.go
new file mode 100644
index 0000000..9700ba3
--- /dev/null
+++ b/privatevoice.src/services/engine_service_windows_test.go
@@ -0,0 +1,43 @@
+//go:build windows
+
+package services
+
+import (
+	"strings"
+	"testing"
+
+	"voicesnap/internal/config"
+	"voicesnap/internal/language"
+	"voicesnap/internal/model"
+	"voicesnap/internal/modelselection"
+)
+
+func TestWindowsPreviewShowsButDisablesNonSenseVoiceModels(t *testing.T) {
+	cfg := config.Default()
+	cfg.LanguageMode = config.LanguageModeManual
+	cfg.LanguageID = model.DefaultLanguageID
+	service := NewEngineService(cfg)
+	profile, err := model.GetModelProfile(model.Qwen3ASRModelID)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	status := service.modelStatusMap(profile, modelselection.CurrentModel{
+		ModelID:          model.DefaultModelID,
+		Profile:          model.DefaultModelProfile(),
+		SelectionMode:    config.ModelSelectionModeAuto,
+		LanguageSettings: language.Resolve(config.LanguageModeManual, model.DefaultLanguageID, nil),
+	})
+
+	if got := status["supportedInBuild"]; got != false {
+		t.Fatalf("supportedInBuild = %v, want false", got)
+	}
+	if got := status["unsupportedReason"]; got != model.UnsupportedReasonWindowsPreview {
+		t.Fatalf("unsupportedReason = %v, want %s", got, model.UnsupportedReasonWindowsPreview)
+	}
+	if _, err := service.allowedModelProfile(model.Qwen3ASRModelID); err == nil {
+		t.Fatal("expected qwen3-asr to be rejected in Windows preview build")
+	} else if !strings.Contains(err.Error(), "Windows preview") {
+		t.Fatalf("error = %q, want Windows preview reason", err.Error())
+	}
+}

--
Gitblit v1.9.3