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