feat: add permission status panel
6 files modified
5 files added
| | |
| | | |
| | | ## 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 未启动时自动重试。 |
| | |
| | | |
| | | const ( |
| | | appVersion = "2.1.1" |
| | | appBuild = "20260518.0230" |
| | | appBuild = "20260518.1541" |
| | | appDisplayVersion = appVersion + " (build " + appBuild + ")" |
| | | appName = "VoiceSnap" |
| | | |
| | |
| | | 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) |
| | |
| | | application.NewService(configService), |
| | | application.NewService(engineService), |
| | | application.NewService(hotkeyService), |
| | | application.NewService(permissionService), |
| | | application.NewService(updaterService), |
| | | application.NewService(audioService), |
| | | application.NewService(historyService), |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| | |
| | | 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' |
| | |
| | | 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') }, |
| | | ] |
| | |
| | | <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"/> |
| | |
| | | <GeneralPage /> |
| | | {:else if page === 'userdict'} |
| | | <UserDictPage /> |
| | | {:else if page === 'permissions'} |
| | | <PermissionsPage /> |
| | | {:else if page === 'history'} |
| | | <HistoryPage /> |
| | | {:else if page === 'about'} |
| | |
| | | "nav": { |
| | | "home": "Home", |
| | | "userdict": "Dictionary", |
| | | "permissions": "Permissions", |
| | | "history": "History", |
| | | "about": "About" |
| | | }, |
| | |
| | | "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", |
| | |
| | | "nav": { |
| | | "home": "首页", |
| | | "userdict": "词库", |
| | | "permissions": "权限", |
| | | "history": "历史", |
| | | "about": "关于" |
| | | }, |
| | |
| | | "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": "当前热键", |
| New file |
| | |
| | | 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" |
| | | ) |
| New file |
| | |
| | | //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() |
| | | } |
| New file |
| | |
| | | //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) {} |
| New file |
| | |
| | | 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) |
| | | } |