<script lang="ts">
|
import { onMount } from 'svelte'
|
import { Call } from '@wailsio/runtime'
|
import TextOutputFallbackPanel from './TextOutputFallbackPanel.svelte'
|
import { t } from '../../lib/i18n'
|
|
interface PermissionStatus {
|
id: string
|
granted: boolean
|
state: string
|
required: boolean
|
}
|
|
const permissionOrder = ['microphone', 'accessibility', 'inputMonitoring', 'backgroundActivity']
|
|
let statuses = $state<PermissionStatus[]>([])
|
let loading = $state(true)
|
let refreshTimeout: number | null = null
|
|
onMount(() => {
|
refresh()
|
const timer = window.setInterval(refresh, 2500)
|
return () => {
|
window.clearInterval(timer)
|
if (refreshTimeout !== null) {
|
window.clearTimeout(refreshTimeout)
|
refreshTimeout = null
|
}
|
}
|
})
|
|
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 {}
|
}
|
if (refreshTimeout !== null) {
|
window.clearTimeout(refreshTimeout)
|
}
|
refreshTimeout = window.setTimeout(() => {
|
refreshTimeout = null
|
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.required) return item.granted ? t('permissions.enabled') : t('permissions.disabled')
|
if (item.granted) return t('permissions.granted')
|
if (loading) return t('permissions.checking')
|
return t('permissions.missing')
|
}
|
|
function allGranted() {
|
const required = statuses.filter(item => item.required)
|
return required.length > 0 && required.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>
|
|
<TextOutputFallbackPanel />
|
|
<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)}
|
{#if !item.required}
|
<span class="optional-badge">{t('permissions.optional')}</span>
|
{/if}
|
</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: 22px;
|
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-base);
|
font-weight: 500;
|
}
|
|
.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: 500;
|
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 {
|
display: inline-flex;
|
align-items: center;
|
gap: 8px;
|
font-size: var(--font-size-base);
|
font-weight: 500;
|
}
|
|
.optional-badge {
|
border-radius: 999px;
|
background: var(--color-bg-secondary);
|
color: var(--color-secondary-label);
|
font-size: var(--font-size-xs);
|
font-weight: 500;
|
padding: 2px 7px;
|
}
|
|
.permission-state {
|
color: var(--color-red);
|
font-size: var(--font-size-sm);
|
font-weight: 500;
|
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>
|