From aa6d716dff8ea05a57500aab74775292df7954b2 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Mon, 18 May 2026 15:49:59 +0800
Subject: [PATCH] feat: add permission status panel

---
 VoiceSnapGo/frontend/src/components/settings/PermissionsPage.svelte |  273 ++++++++++++++++++++++++++++++
 TODO.md                                                             |    9 +
 VoiceSnapGo/internal/permissions/permissions.go                     |   15 +
 VoiceSnapGo/internal/permissions/permissions_other.go               |   15 +
 VoiceSnapGo/app.go                                                  |    4 
 VoiceSnapGo/frontend/src/components/settings/SettingsWindow.svelte  |    9 +
 VoiceSnapGo/build/darwin/Info.plist                                 |    2 
 VoiceSnapGo/frontend/src/lib/i18n/zh.json                           |   25 ++
 VoiceSnapGo/frontend/src/lib/i18n/en.json                           |   25 ++
 VoiceSnapGo/services/permission_service.go                          |   22 ++
 VoiceSnapGo/internal/permissions/permissions_darwin.go              |  124 +++++++++++++
 11 files changed, 521 insertions(+), 2 deletions(-)

diff --git a/TODO.md b/TODO.md
index aa3205e..f39bdcf 100644
--- a/TODO.md
+++ b/TODO.md
@@ -17,6 +17,15 @@
 
 ## Done
 
+- [2026-05-18] 增加权限检查设置面板。
+  - 新增“权限”设置页,清晰列出麦克风、辅助功能、输入监控三个权限。
+  - 权限状态由后端调用 macOS 原生 API 检查;已授权显示绿灯,未授权显示红灯。
+  - 支持刷新状态和“去授权”按钮,点击后请求系统授权并打开对应系统设置页。
+  - build: `20260518.1541`
+  - 输出: `VoiceSnapGo/build/local/arm64/VoiceSnap.app`
+  - 输出: `VoiceSnapGo/build/local/arm64/VoiceSnap-2.1.1-build20260518.1541-arm64-local.dmg`
+  - 兼容输出: `VoiceSnapGo/build/local/arm64/VoiceSnap-2.1.1-arm64-local.dmg` 已替换为同一份新包。
+  - 已安装到 `/Applications/VoiceSnap.app`;启动日志验证 build `20260518.1541`。
 - [2026-05-18] 修复 VoiceSnap 设置窗口不可见时右 Alt 全局热键无法触发的问题。
   - 观察: 设置窗口在前台可触发,窗口隐藏或被遮挡后按右 Alt 没有热键日志。
   - 修复: macOS 热键监听改为专用线程运行,并在 event tap 未启动时自动重试。
diff --git a/VoiceSnapGo/app.go b/VoiceSnapGo/app.go
index ceab12c..1e9e57d 100755
--- a/VoiceSnapGo/app.go
+++ b/VoiceSnapGo/app.go
@@ -27,7 +27,7 @@
 
 const (
 	appVersion        = "2.1.1"
-	appBuild          = "20260518.0230"
+	appBuild          = "20260518.1541"
 	appDisplayVersion = appVersion + " (build " + appBuild + ")"
 	appName           = "VoiceSnap"
 
@@ -108,6 +108,7 @@
 	configService := services.NewConfigService(app.cfg)
 	engineService := services.NewEngineService()
 	hotkeyService := services.NewHotkeyService(app.cfg)
+	permissionService := services.NewPermissionService()
 	updaterService := services.NewUpdaterService(appVersion)
 	audioService := services.NewAudioService(app.recorder, app.cfg)
 	historyService := services.NewHistoryService(app.history)
@@ -122,6 +123,7 @@
 			application.NewService(configService),
 			application.NewService(engineService),
 			application.NewService(hotkeyService),
+			application.NewService(permissionService),
 			application.NewService(updaterService),
 			application.NewService(audioService),
 			application.NewService(historyService),
diff --git a/VoiceSnapGo/build/darwin/Info.plist b/VoiceSnapGo/build/darwin/Info.plist
index 0039b15..a25e807 100755
--- a/VoiceSnapGo/build/darwin/Info.plist
+++ b/VoiceSnapGo/build/darwin/Info.plist
@@ -17,7 +17,7 @@
     <key>CFBundleShortVersionString</key>
     <string>2.1.1</string>
     <key>CFBundleVersion</key>
-    <string>20260518.0230</string>
+    <string>20260518.1541</string>
     <key>LSMinimumSystemVersion</key>
     <string>11.0</string>
     <key>NSMicrophoneUsageDescription</key>
diff --git a/VoiceSnapGo/frontend/src/components/settings/PermissionsPage.svelte b/VoiceSnapGo/frontend/src/components/settings/PermissionsPage.svelte
new file mode 100644
index 0000000..821bcf9
--- /dev/null
+++ b/VoiceSnapGo/frontend/src/components/settings/PermissionsPage.svelte
@@ -0,0 +1,273 @@
+<script lang="ts">
+  import { onMount } from 'svelte'
+  import { Call } from '@wailsio/runtime'
+  import { t } from '../../lib/i18n'
+
+  interface PermissionStatus {
+    id: string
+    granted: boolean
+    state: string
+    required: boolean
+  }
+
+  const permissionOrder = ['microphone', 'accessibility', 'inputMonitoring']
+
+  let statuses = $state<PermissionStatus[]>([])
+  let loading = $state(true)
+
+  onMount(() => {
+    refresh()
+    const timer = window.setInterval(refresh, 2500)
+    return () => window.clearInterval(timer)
+  })
+
+  async function refresh() {
+    try {
+      const result: any = await Call.ByName('voicesnap/services.PermissionService.GetStatuses')
+      statuses = sortStatuses(result || [])
+    } catch {
+      statuses = []
+    } finally {
+      loading = false
+    }
+  }
+
+  async function requestPermission(id: string) {
+    try {
+      await Call.ByName('voicesnap/services.PermissionService.Request', id)
+    } catch {
+      try {
+        await Call.ByName('voicesnap/services.PermissionService.OpenSettings', id)
+      } catch {}
+    }
+    window.setTimeout(refresh, 800)
+  }
+
+  function sortStatuses(items: PermissionStatus[]) {
+    return [...items].sort((a, b) => permissionOrder.indexOf(a.id) - permissionOrder.indexOf(b.id))
+  }
+
+  function titleFor(id: string) {
+    return t(`permissions.${id}.title`)
+  }
+
+  function descFor(id: string) {
+    return t(`permissions.${id}.desc`)
+  }
+
+  function stateText(item: PermissionStatus) {
+    if (item.granted) return t('permissions.granted')
+    if (loading) return t('permissions.checking')
+    return t('permissions.missing')
+  }
+
+  function allGranted() {
+    return statuses.length > 0 && statuses.every(item => item.granted)
+  }
+</script>
+
+<div class="page">
+  <div class="header">
+    <h1 class="page-title">{t('permissions.title')}</h1>
+    <p class="page-subtitle">{t('permissions.subtitle')}</p>
+  </div>
+
+  <div class="summary" class:ready={allGranted()}>
+    <span class="summary-dot" class:ready={allGranted()}></span>
+    <div class="summary-text">
+      <span class="summary-title">{allGranted() ? t('permissions.allGranted') : t('permissions.needsAttention')}</span>
+      <span class="summary-desc">{t('permissions.summaryDesc')}</span>
+    </div>
+    <button class="refresh-btn" onclick={refresh}>{t('permissions.refresh')}</button>
+  </div>
+
+  <div class="permission-list">
+    {#each statuses as item}
+      <div class="permission-row">
+        <div class="status-light" class:granted={item.granted}>
+          {#if item.granted}
+            <svg width="15" height="12" viewBox="0 0 15 12" fill="none">
+              <path d="M1.5 6L5.5 10L13.5 1.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+            </svg>
+          {:else}
+            <span></span>
+          {/if}
+        </div>
+        <div class="permission-info">
+          <div class="permission-heading">
+            <span class="permission-title">{titleFor(item.id)}</span>
+            <span class="permission-state" class:granted={item.granted}>{stateText(item)}</span>
+          </div>
+          <p class="permission-desc">{descFor(item.id)}</p>
+        </div>
+        {#if !item.granted}
+          <button class="grant-btn" onclick={() => requestPermission(item.id)}>
+            {t('permissions.grant')}
+          </button>
+        {/if}
+      </div>
+    {/each}
+  </div>
+</div>
+
+<style>
+  .page {
+    padding: var(--spacing-lg);
+  }
+
+  .header {
+    margin-bottom: var(--spacing-xl);
+  }
+
+  .page-title {
+    font-size: 24px;
+    font-weight: 700;
+    letter-spacing: 0;
+  }
+
+  .page-subtitle {
+    margin-top: 6px;
+    font-size: var(--font-size-sm);
+    color: var(--color-secondary-label);
+  }
+
+  .summary {
+    display: flex;
+    align-items: center;
+    gap: var(--spacing-md);
+    background: var(--color-bg-grouped-secondary);
+    border-radius: var(--radius-md);
+    padding: var(--spacing-lg);
+    margin-bottom: var(--spacing-md);
+  }
+
+  .summary.ready {
+    background: rgba(52, 199, 89, 0.1);
+  }
+
+  .summary-dot {
+    width: 12px;
+    height: 12px;
+    border-radius: 50%;
+    background: var(--color-red);
+    box-shadow: 0 0 0 5px rgba(255, 59, 48, 0.1);
+    flex: 0 0 auto;
+  }
+
+  .summary-dot.ready {
+    background: var(--color-green);
+    box-shadow: 0 0 0 5px rgba(52, 199, 89, 0.14);
+  }
+
+  .summary-text {
+    display: flex;
+    flex: 1;
+    min-width: 0;
+    flex-direction: column;
+    gap: 3px;
+  }
+
+  .summary-title {
+    font-size: var(--font-size-lg);
+    font-weight: 650;
+  }
+
+  .summary-desc {
+    font-size: var(--font-size-sm);
+    color: var(--color-secondary-label);
+  }
+
+  .refresh-btn,
+  .grant-btn {
+    border: none;
+    border-radius: var(--radius-sm);
+    background: rgba(0, 122, 255, 0.1);
+    color: var(--color-blue);
+    cursor: pointer;
+    font-size: var(--font-size-sm);
+    font-weight: 600;
+    padding: 8px 12px;
+    transition: opacity var(--transition-fast);
+    white-space: nowrap;
+  }
+
+  .refresh-btn:hover,
+  .grant-btn:hover {
+    opacity: 0.72;
+  }
+
+  .permission-list {
+    background: var(--color-bg-grouped-secondary);
+    border-radius: var(--radius-md);
+    overflow: hidden;
+  }
+
+  .permission-row {
+    display: grid;
+    grid-template-columns: 34px minmax(0, 1fr) auto;
+    align-items: center;
+    gap: var(--spacing-md);
+    padding: var(--spacing-lg);
+    border-bottom: 1px solid var(--color-separator);
+  }
+
+  .permission-row:last-child {
+    border-bottom: none;
+  }
+
+  .status-light {
+    width: 26px;
+    height: 26px;
+    border-radius: 50%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: var(--color-red);
+    color: white;
+  }
+
+  .status-light span {
+    width: 8px;
+    height: 8px;
+    border-radius: 50%;
+    background: white;
+  }
+
+  .status-light.granted {
+    background: var(--color-green);
+  }
+
+  .permission-info {
+    min-width: 0;
+  }
+
+  .permission-heading {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: var(--spacing-md);
+    margin-bottom: 5px;
+  }
+
+  .permission-title {
+    font-size: var(--font-size-lg);
+    font-weight: 650;
+  }
+
+  .permission-state {
+    color: var(--color-red);
+    font-size: var(--font-size-sm);
+    font-weight: 600;
+    white-space: nowrap;
+  }
+
+  .permission-state.granted {
+    color: var(--color-green);
+  }
+
+  .permission-desc {
+    color: var(--color-secondary-label);
+    font-size: var(--font-size-sm);
+    line-height: 1.45;
+  }
+</style>
diff --git a/VoiceSnapGo/frontend/src/components/settings/SettingsWindow.svelte b/VoiceSnapGo/frontend/src/components/settings/SettingsWindow.svelte
index 5c6d0dc..10ee80c 100755
--- a/VoiceSnapGo/frontend/src/components/settings/SettingsWindow.svelte
+++ b/VoiceSnapGo/frontend/src/components/settings/SettingsWindow.svelte
@@ -2,6 +2,7 @@
   import { currentPage } from '../../lib/stores/app'
   import { t } from '../../lib/i18n'
   import GeneralPage from './GeneralPage.svelte'
+  import PermissionsPage from './PermissionsPage.svelte'
   import HistoryPage from './HistoryPage.svelte'
   import UserDictPage from './UserDictPage.svelte'
   import AboutPage from './AboutPage.svelte'
@@ -9,6 +10,7 @@
   const navItems = [
     { id: 'general', label: () => t('nav.home') },
     { id: 'userdict', label: () => t('nav.userdict') },
+    { id: 'permissions', label: () => t('nav.permissions') },
     { id: 'history', label: () => t('nav.history') },
     { id: 'about', label: () => t('nav.about') },
   ]
@@ -54,6 +56,11 @@
                 <path d="M3 12.5h5"/>
                 <path d="M11 11l1.2 1.2L14.5 10"/>
               </svg>
+            {:else if item.id === 'permissions'}
+              <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M8 2L3.5 4v3.2c0 2.9 1.8 5.1 4.5 6.3 2.7-1.2 4.5-3.4 4.5-6.3V4L8 2Z"/>
+                <path d="M5.8 7.9l1.4 1.4 3-3.2"/>
+              </svg>
             {:else if item.id === 'about'}
               <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round">
                 <circle cx="8" cy="8" r="6"/>
@@ -74,6 +81,8 @@
       <GeneralPage />
     {:else if page === 'userdict'}
       <UserDictPage />
+    {:else if page === 'permissions'}
+      <PermissionsPage />
     {:else if page === 'history'}
       <HistoryPage />
     {:else if page === 'about'}
diff --git a/VoiceSnapGo/frontend/src/lib/i18n/en.json b/VoiceSnapGo/frontend/src/lib/i18n/en.json
index 4b0996c..96aa055 100755
--- a/VoiceSnapGo/frontend/src/lib/i18n/en.json
+++ b/VoiceSnapGo/frontend/src/lib/i18n/en.json
@@ -5,6 +5,7 @@
   "nav": {
     "home": "Home",
     "userdict": "Dictionary",
+    "permissions": "Permissions",
     "history": "History",
     "about": "About"
   },
@@ -46,6 +47,30 @@
     "hideDockIcon": "Hide Dock Icon",
     "hideDockIconDesc": "Show VoiceSnap only in the menu bar"
   },
+  "permissions": {
+    "title": "Permission Check",
+    "subtitle": "VoiceSnap needs these permissions for recording, global hotkeys, and automatic text input",
+    "refresh": "Refresh",
+    "grant": "Grant",
+    "granted": "Granted",
+    "missing": "Missing",
+    "checking": "Checking",
+    "allGranted": "All permissions are ready",
+    "needsAttention": "Some permissions need attention",
+    "summaryDesc": "This turns green automatically once permissions are granted",
+    "microphone": {
+      "title": "Microphone",
+      "desc": "Records your speech before recognition can start."
+    },
+    "accessibility": {
+      "title": "Accessibility",
+      "desc": "Types recognized text into the active field and supports simulated paste/typing."
+    },
+    "inputMonitoring": {
+      "title": "Input Monitoring",
+      "desc": "Listens for the global right Alt hotkey while other apps are in front."
+    }
+  },
   "hotkeys": {
     "title": "Trigger Hotkey",
     "current": "Current Hotkey",
diff --git a/VoiceSnapGo/frontend/src/lib/i18n/zh.json b/VoiceSnapGo/frontend/src/lib/i18n/zh.json
index 9f3b2e1..c15a30a 100755
--- a/VoiceSnapGo/frontend/src/lib/i18n/zh.json
+++ b/VoiceSnapGo/frontend/src/lib/i18n/zh.json
@@ -5,6 +5,7 @@
   "nav": {
     "home": "首页",
     "userdict": "词库",
+    "permissions": "权限",
     "history": "历史",
     "about": "关于"
   },
@@ -46,6 +47,30 @@
     "hideDockIcon": "隐藏 Dock 图标",
     "hideDockIconDesc": "只在菜单栏显示 VoiceSnap 图标"
   },
+  "permissions": {
+    "title": "权限检查",
+    "subtitle": "VoiceSnap 需要这些权限才能稳定完成录音、全局热键和自动上屏",
+    "refresh": "刷新",
+    "grant": "去授权",
+    "granted": "已授权",
+    "missing": "未授权",
+    "checking": "检查中",
+    "allGranted": "所有权限已就绪",
+    "needsAttention": "有权限需要处理",
+    "summaryDesc": "授权完成后这里会自动变成绿灯",
+    "microphone": {
+      "title": "麦克风",
+      "desc": "用于录制你的语音,这是开始识别前必须拿到的权限。"
+    },
+    "accessibility": {
+      "title": "辅助功能",
+      "desc": "用于把识别结果自动输入到当前文本框,也用于模拟粘贴和键盘输入。"
+    },
+    "inputMonitoring": {
+      "title": "输入监控",
+      "desc": "用于在 VoiceSnap 窗口不可见、其他 App 正在前台时监听右 Alt 全局热键。"
+    }
+  },
   "hotkeys": {
     "title": "触发热键",
     "current": "当前热键",
diff --git a/VoiceSnapGo/internal/permissions/permissions.go b/VoiceSnapGo/internal/permissions/permissions.go
new file mode 100644
index 0000000..e9cf138
--- /dev/null
+++ b/VoiceSnapGo/internal/permissions/permissions.go
@@ -0,0 +1,15 @@
+package permissions
+
+// Status is the backend view of a system permission.
+type Status struct {
+	ID       string `json:"id"`
+	Granted  bool   `json:"granted"`
+	State    string `json:"state"`
+	Required bool   `json:"required"`
+}
+
+const (
+	IDMicrophone      = "microphone"
+	IDAccessibility   = "accessibility"
+	IDInputMonitoring = "inputMonitoring"
+)
diff --git a/VoiceSnapGo/internal/permissions/permissions_darwin.go b/VoiceSnapGo/internal/permissions/permissions_darwin.go
new file mode 100644
index 0000000..6879a75
--- /dev/null
+++ b/VoiceSnapGo/internal/permissions/permissions_darwin.go
@@ -0,0 +1,124 @@
+//go:build darwin
+
+package permissions
+
+/*
+#cgo CFLAGS: -x objective-c
+#cgo LDFLAGS: -framework ApplicationServices -framework AVFoundation
+#include <ApplicationServices/ApplicationServices.h>
+#import <AVFoundation/AVFoundation.h>
+
+static int accessibilityGranted(void) {
+	return AXIsProcessTrusted() ? 1 : 0;
+}
+
+static void requestAccessibility(void) {
+	NSDictionary* opts = @{(__bridge NSString*)kAXTrustedCheckOptionPrompt: @YES};
+	AXIsProcessTrustedWithOptions((__bridge CFDictionaryRef)opts);
+}
+
+static int inputMonitoringGranted(void) {
+	return CGPreflightListenEventAccess() ? 1 : 0;
+}
+
+static void requestInputMonitoring(void) {
+	CGRequestListenEventAccess();
+}
+
+static int microphoneStatus(void) {
+	return (int)[AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio];
+}
+
+static int requestMicrophone(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"
+
+import "os/exec"
+
+// Statuses returns the current macOS privacy permission state.
+func Statuses() []Status {
+	return []Status{
+		{
+			ID:       IDMicrophone,
+			Granted:  C.microphoneStatus() == 3,
+			State:    microphoneState(int(C.microphoneStatus())),
+			Required: true,
+		},
+		{
+			ID:       IDAccessibility,
+			Granted:  C.accessibilityGranted() != 0,
+			State:    grantedState(C.accessibilityGranted() != 0),
+			Required: true,
+		},
+		{
+			ID:       IDInputMonitoring,
+			Granted:  C.inputMonitoringGranted() != 0,
+			State:    grantedState(C.inputMonitoringGranted() != 0),
+			Required: true,
+		},
+	}
+}
+
+// Request asks macOS for a permission where a public prompt API exists,
+// then opens the matching System Settings pane.
+func Request(id string) {
+	switch id {
+	case IDMicrophone:
+		C.requestMicrophone()
+		openSettings(id)
+	case IDAccessibility:
+		C.requestAccessibility()
+		openSettings(id)
+	case IDInputMonitoring:
+		C.requestInputMonitoring()
+		openSettings(id)
+	}
+}
+
+func OpenSettings(id string) {
+	openSettings(id)
+}
+
+func microphoneState(status int) string {
+	switch status {
+	case 0:
+		return "notDetermined"
+	case 1:
+		return "restricted"
+	case 2:
+		return "denied"
+	case 3:
+		return "granted"
+	default:
+		return "unknown"
+	}
+}
+
+func grantedState(granted bool) string {
+	if granted {
+		return "granted"
+	}
+	return "denied"
+}
+
+func openSettings(id string) {
+	url := "x-apple.systempreferences:com.apple.preference.security"
+	switch id {
+	case IDMicrophone:
+		url = "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
+	case IDAccessibility:
+		url = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
+	case IDInputMonitoring:
+		url = "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"
+	}
+	_ = exec.Command("open", url).Start()
+}
diff --git a/VoiceSnapGo/internal/permissions/permissions_other.go b/VoiceSnapGo/internal/permissions/permissions_other.go
new file mode 100644
index 0000000..ccb9e70
--- /dev/null
+++ b/VoiceSnapGo/internal/permissions/permissions_other.go
@@ -0,0 +1,15 @@
+//go:build !darwin
+
+package permissions
+
+func Statuses() []Status {
+	return []Status{
+		{ID: IDMicrophone, Granted: true, State: "granted", Required: true},
+		{ID: IDAccessibility, Granted: true, State: "granted", Required: false},
+		{ID: IDInputMonitoring, Granted: true, State: "granted", Required: false},
+	}
+}
+
+func Request(id string) {}
+
+func OpenSettings(id string) {}
diff --git a/VoiceSnapGo/services/permission_service.go b/VoiceSnapGo/services/permission_service.go
new file mode 100644
index 0000000..dc0cd76
--- /dev/null
+++ b/VoiceSnapGo/services/permission_service.go
@@ -0,0 +1,22 @@
+package services
+
+import "voicesnap/internal/permissions"
+
+// PermissionService exposes macOS privacy permission checks to the UI.
+type PermissionService struct{}
+
+func NewPermissionService() *PermissionService {
+	return &PermissionService{}
+}
+
+func (s *PermissionService) GetStatuses() []permissions.Status {
+	return permissions.Statuses()
+}
+
+func (s *PermissionService) Request(id string) {
+	permissions.Request(id)
+}
+
+func (s *PermissionService) OpenSettings(id string) {
+	permissions.OpenSettings(id)
+}

--
Gitblit v1.9.3