Ariver
2026-05-18 aa6d716dff8ea05a57500aab74775292df7954b2
feat: add permission status panel
6 files modified
5 files added
523 ■■■■■ changed files
TODO.md 9 ●●●●● patch | view | raw | blame | history
VoiceSnapGo/app.go 4 ●●● patch | view | raw | blame | history
VoiceSnapGo/build/darwin/Info.plist 2 ●●● patch | view | raw | blame | history
VoiceSnapGo/frontend/src/components/settings/PermissionsPage.svelte 273 ●●●●● patch | view | raw | blame | history
VoiceSnapGo/frontend/src/components/settings/SettingsWindow.svelte 9 ●●●●● patch | view | raw | blame | history
VoiceSnapGo/frontend/src/lib/i18n/en.json 25 ●●●●● patch | view | raw | blame | history
VoiceSnapGo/frontend/src/lib/i18n/zh.json 25 ●●●●● patch | view | raw | blame | history
VoiceSnapGo/internal/permissions/permissions.go 15 ●●●●● patch | view | raw | blame | history
VoiceSnapGo/internal/permissions/permissions_darwin.go 124 ●●●●● patch | view | raw | blame | history
VoiceSnapGo/internal/permissions/permissions_other.go 15 ●●●●● patch | view | raw | blame | history
VoiceSnapGo/services/permission_service.go 22 ●●●●● patch | view | raw | blame | history
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 未启动时自动重试。
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),
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>
VoiceSnapGo/frontend/src/components/settings/PermissionsPage.svelte
New file
@@ -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>
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'}
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",
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": "当前热键",
VoiceSnapGo/internal/permissions/permissions.go
New file
@@ -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"
)
VoiceSnapGo/internal/permissions/permissions_darwin.go
New file
@@ -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()
}
VoiceSnapGo/internal/permissions/permissions_other.go
New file
@@ -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) {}
VoiceSnapGo/services/permission_service.go
New file
@@ -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)
}