* feat(capsule): 录音胶囊样式可选——流光 Siri 风格 / Openless 默认风格 设置 → 录音与输入新增「胶囊样式」下拉(保留显示开关): - UserPreferences.capsule_style('siri' 默认 / 'classic')随 capsule:state payload 下发,切换后下一次录音即生效 - Capsule.tsx 双样式分支:siri 保留 SiriGL 光效舞台;classic 恢复 1.3.14 经典毛玻璃药丸(音量条 + 取消/确认按钮 + thinking 扫光 + 翻译徽章), 取消/确认改走 lib/ipc/dictation.ts,退出动画时长按样式区分 - 预览支持 ?style=classic;i18n 五语言新增胶囊样式文案 * fix(settings): 选区润色行适配重构后的 ShortcutRecorder props beta HEAD 在快捷键录入交互重构(e12c8dcc)合并后遗留的构建破坏: SelectionPolishSection 仍传已删除的 alignRecordButton / modifierPresets, tsc 直接报错。改为新 API(value 可空 + onReset 恢复默认快捷键, 等价于旧「启用」按钮),恢复 beta 可构建状态。 * fix(capsule): 评审修复——经典药丸按钮可点 + 音频线程零偏好锁 - 经典样式(Openless 默认)下胶囊窗口长期 set_ignore_cursor_events(true) (纯光效舞台时代遗留),✕/✓ 按钮实际不可点。现在主线程闭包按 「classic && visible && 非选区润色 && 录音/转写/润色中」关闭鼠标穿透, 终态 toast / 隐藏 / Siri / 选区润色保持穿透;值不变时不重复调用 set_ignore_cursor_events - emit_capsule 在音频回调线程 ~30Hz 被调,原先直接读 prefs(整份克隆 + 偏好锁被 set() 跨磁盘 I/O 持有)。改为 Inner 上 AtomicU8 样式缓存, 主线程闭包每帧从 prefs 同步(与 show_capsule 同源读取),音频线程零开销 - 恢复「录音胶囊」行的 desc 文案(beta 原有,重构时被误删) - Capsule.tsx 退出动画注释同步为按样式区分的常量名 * docs(capsule): 注明入场帧 payload 可能滞后一帧样式(防误改回音频线程读 prefs) --------- Co-authored-by: sim <sim@local>
511 lines
21 KiB
TypeScript
511 lines
21 KiB
TypeScript
// 通用 → 录音与输入:录音快捷键 / 方式 / 麦克风 / 胶囊 / 静音,
|
||
// 外加「插入与剪贴板」「启动」两个折叠组。流式输入也并到这里(属于插入行为)。
|
||
// 自 Settings.tsx 的 RecordingSection 拆出,录音相关逻辑零改动。
|
||
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { ShortcutRecorder } from '../../components/ShortcutRecorder';
|
||
import { playRecordStartCue } from '../../lib/audioCue';
|
||
import { defaultDictationHotkey } from '../../lib/hotkey';
|
||
import { emitSaved } from '../../lib/savedEvent';
|
||
import { isHotkeyModeMigrationNoticeActive } from '../../lib/hotkeyMigration';
|
||
import {
|
||
isTauri,
|
||
listMicrophoneDevices,
|
||
setDictationHotkey,
|
||
} from '../../lib/ipc';
|
||
import { getPlatformCapabilities } from '../../lib/platform';
|
||
import type {
|
||
CapsuleStyle,
|
||
HotkeyMode,
|
||
MicrophoneDevice,
|
||
PasteShortcut,
|
||
PlatformCapabilities,
|
||
UserPreferences,
|
||
WindowsInsertionMode,
|
||
WindowsSendInputNewlineMode,
|
||
} from '../../lib/types';
|
||
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
|
||
import { SelectLite } from '../../components/ui/SelectLite';
|
||
import { Card, Collapsible } from '../_atoms';
|
||
import { SectionTitle, SettingRow, Toggle, inputStyle, segmentedTrackStyle } from './shared';
|
||
import { MicrophoneSelect } from './MicrophoneSelect';
|
||
import { detectOS } from '../../components/WindowChrome';
|
||
|
||
async function autostartIsEnabled(): Promise<boolean> {
|
||
const { invoke } = await import('@tauri-apps/api/core');
|
||
return invoke<boolean>('plugin:autostart|is_enabled');
|
||
}
|
||
|
||
async function autostartEnable(): Promise<void> {
|
||
const { invoke } = await import('@tauri-apps/api/core');
|
||
await invoke('plugin:autostart|enable');
|
||
}
|
||
|
||
async function autostartDisable(): Promise<void> {
|
||
const { invoke } = await import('@tauri-apps/api/core');
|
||
await invoke('plugin:autostart|disable');
|
||
}
|
||
|
||
export function RecordingInputSection() {
|
||
const { t } = useTranslation();
|
||
const os = detectOS();
|
||
const { prefs, capability, updatePrefs: savePrefs, refresh } = useHotkeySettings();
|
||
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null);
|
||
const [microphoneDevices, setMicrophoneDevices] = useState<MicrophoneDevice[]>([]);
|
||
const [microphoneDevicesLoaded, setMicrophoneDevicesLoaded] = useState(false);
|
||
const [microphoneDevicesError, setMicrophoneDevicesError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
void getPlatformCapabilities().then(setPlatformCaps);
|
||
}, []);
|
||
|
||
// 兼容旧 Windows 配置:Shift+Insert 仍保留在跨平台类型/后端中供 Linux 使用,
|
||
// 但 Windows 已不再提供该选项;进入设置时迁移为 Ctrl+V,避免下拉框无匹配值。
|
||
useEffect(() => {
|
||
if (os !== 'win' || prefs?.pasteShortcut !== 'shiftInsert') return;
|
||
void savePrefs(current => {
|
||
if (current.pasteShortcut !== 'shiftInsert') return current;
|
||
return { ...current, pasteShortcut: 'ctrlV' };
|
||
}).catch(error => {
|
||
console.warn('[settings] migrate Windows paste shortcut failed', error);
|
||
});
|
||
}, [os, prefs?.pasteShortcut, savePrefs]);
|
||
|
||
const loadMicrophoneDevices = useCallback(async (
|
||
signal?: { cancelled: boolean },
|
||
options: { showLoading?: boolean } = {},
|
||
) => {
|
||
if (options.showLoading ?? true) {
|
||
setMicrophoneDevicesLoaded(false);
|
||
}
|
||
setMicrophoneDevicesError(null);
|
||
try {
|
||
const devices = await listMicrophoneDevices();
|
||
if (signal?.cancelled) return;
|
||
setMicrophoneDevices(devices);
|
||
setMicrophoneDevicesLoaded(true);
|
||
} catch (err) {
|
||
console.error('[settings] list microphone devices failed', err);
|
||
if (signal?.cancelled) return;
|
||
setMicrophoneDevices([]);
|
||
setMicrophoneDevicesError(err instanceof Error ? err.message : String(err));
|
||
setMicrophoneDevicesLoaded(true);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const signal = { cancelled: false };
|
||
void loadMicrophoneDevices(signal);
|
||
return () => {
|
||
signal.cancelled = true;
|
||
};
|
||
}, [loadMicrophoneDevices]);
|
||
|
||
useEffect(() => {
|
||
if (!isTauri) return;
|
||
let cancelled = false;
|
||
let unlisten: (() => void) | undefined;
|
||
async function listenForDeviceChanges() {
|
||
const { listen } = await import('@tauri-apps/api/event');
|
||
if (cancelled) return;
|
||
const stopListening = await listen('microphone:devices-changed', () => {
|
||
void loadMicrophoneDevices(undefined, { showLoading: false });
|
||
});
|
||
if (cancelled) {
|
||
stopListening();
|
||
return;
|
||
}
|
||
unlisten = stopListening;
|
||
}
|
||
void listenForDeviceChanges();
|
||
return () => {
|
||
cancelled = true;
|
||
unlisten?.();
|
||
};
|
||
}, [loadMicrophoneDevices]);
|
||
|
||
const saveKeyboardListAffectingPrefs = useCallback(async (nextPrefs: UserPreferences) => {
|
||
try {
|
||
await savePrefs(nextPrefs);
|
||
} catch (error) {
|
||
console.error('[settings] keyboard list visibility pref save failed', error);
|
||
emitSaved('failed', t('settings.recording.windowsShowOpenlessInKeyboardListError'));
|
||
await refresh();
|
||
}
|
||
}, [savePrefs, refresh, t]);
|
||
|
||
if (!prefs || !capability) {
|
||
return (
|
||
<Card>
|
||
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
const isAndroid = platformCaps?.platform === 'android';
|
||
const showDesktopHotkey = platformCaps?.supportsDesktopHotkey === true;
|
||
const showDesktopInsert = showDesktopHotkey && os !== 'linux';
|
||
const showDesktopStartup = showDesktopHotkey;
|
||
const effectivePasteShortcut = os === 'win' && prefs.pasteShortcut === 'shiftInsert'
|
||
? 'ctrlV'
|
||
: prefs.pasteShortcut;
|
||
|
||
const onModeChange = (mode: HotkeyMode) =>
|
||
savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, mode } });
|
||
const onShowCapsuleChange = (showCapsule: boolean) =>
|
||
savePrefs({ ...prefs, showCapsule });
|
||
const onMuteDuringRecordingChange = (muteDuringRecording: boolean) =>
|
||
savePrefs({ ...prefs, muteDuringRecording });
|
||
const onAudioCueChange = (audioCueOnRecord: boolean) =>
|
||
savePrefs({ ...prefs, audioCueOnRecord });
|
||
const onMicrophoneDeviceChange = (microphoneDeviceName: string) =>
|
||
savePrefs({ ...prefs, microphoneDeviceName });
|
||
const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) =>
|
||
savePrefs({ ...prefs, restoreClipboardAfterPaste });
|
||
const onPasteShortcutChange = (pasteShortcut: PasteShortcut) =>
|
||
savePrefs({ ...prefs, pasteShortcut });
|
||
const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) =>
|
||
savePrefs({ ...prefs, allowNonTsfInsertionFallback });
|
||
const onWindowsInsertionModeChange = (windowsInsertionMode: WindowsInsertionMode) =>
|
||
void saveKeyboardListAffectingPrefs({
|
||
...prefs,
|
||
windowsInsertionMode,
|
||
windowsSendInputInsertionOnly: windowsInsertionMode === 'sendInput',
|
||
});
|
||
const onWindowsSendInputNewlineModeChange = (windowsSendInputNewlineMode: WindowsSendInputNewlineMode) =>
|
||
savePrefs({ ...prefs, windowsSendInputNewlineMode });
|
||
const onWindowsShowOpenlessInKeyboardListChange = (
|
||
windowsShowOpenlessInKeyboardList: boolean,
|
||
) => void saveKeyboardListAffectingPrefs({ ...prefs, windowsShowOpenlessInKeyboardList });
|
||
const onStartMinimizedChange = (startMinimized: boolean) =>
|
||
savePrefs({ ...prefs, startMinimized });
|
||
const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) =>
|
||
savePrefs({ ...prefs, autoUpdateCheck });
|
||
|
||
const choices: Array<[HotkeyMode, string]> = [
|
||
['toggle', t('settings.recording.modeToggle')],
|
||
['hold', t('settings.recording.modeHold')],
|
||
['auto', t('settings.recording.modeAuto')],
|
||
];
|
||
const preferredMicrophoneAvailable = Boolean(
|
||
prefs.microphoneDeviceName
|
||
&& microphoneDevices.some(device => device.name === prefs.microphoneDeviceName),
|
||
);
|
||
const effectiveMicrophoneDeviceName = prefs.microphoneDeviceName
|
||
&& (!microphoneDevicesLoaded || preferredMicrophoneAvailable)
|
||
? prefs.microphoneDeviceName
|
||
: '';
|
||
|
||
return (
|
||
<>
|
||
<Card>
|
||
<SectionTitle hint={t('settings.recording.desc')}>
|
||
{t('settings.recording.title')}
|
||
</SectionTitle>
|
||
{isHotkeyModeMigrationNoticeActive() && showDesktopHotkey && (
|
||
<div
|
||
style={{
|
||
marginTop: 4,
|
||
marginBottom: 8,
|
||
padding: '12px 14px',
|
||
borderRadius: 10,
|
||
background: 'rgba(37,99,235,0.08)',
|
||
border: '0.5px solid rgba(37,99,235,0.18)',
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--ol-blue)', marginBottom: 4 }}>
|
||
{t('settings.recording.migrationNoticeTitle')}
|
||
</div>
|
||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-3)', lineHeight: 1.55 }}>
|
||
{t('settings.recording.migrationNoticeDesc')}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{showDesktopHotkey && (
|
||
<SettingRow label={t('settings.recording.hotkeyLabel')}>
|
||
<ShortcutRecorder
|
||
value={prefs.dictationHotkey}
|
||
sideSpecificModifiers
|
||
// 录音快捷键是核心热键,Rust 端不接受 null,不可停用——置灰并提示。
|
||
disableDisabled
|
||
disableHint={t('settings.recording.comboDisableHint')}
|
||
onSave={async binding => {
|
||
await setDictationHotkey(binding);
|
||
await savePrefs({ ...prefs, dictationHotkey: binding });
|
||
}}
|
||
onReset={async () => {
|
||
const binding = defaultDictationHotkey();
|
||
await setDictationHotkey(binding);
|
||
await savePrefs({ ...prefs, dictationHotkey: binding });
|
||
}}
|
||
/>
|
||
</SettingRow>
|
||
)}
|
||
{showDesktopHotkey && (
|
||
<SettingRow label={t('settings.recording.modeLabel')} desc={t('settings.recording.modeDesc')}>
|
||
<div style={segmentedTrackStyle}>
|
||
{choices.map(([v, l]) => (
|
||
<button
|
||
key={v}
|
||
onClick={() => onModeChange(v)}
|
||
style={{
|
||
padding: '5px 14px', fontSize: 12, fontWeight: 500,
|
||
border: 0, borderRadius: 6, fontFamily: 'inherit',
|
||
background: prefs.hotkey.mode === v ? 'var(--ol-segmented-active-bg)' : 'transparent',
|
||
color: prefs.hotkey.mode === v ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
|
||
boxShadow: prefs.hotkey.mode === v ? 'var(--ol-segmented-active-shadow)' : 'none',
|
||
cursor: 'default',
|
||
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)',
|
||
}}
|
||
>
|
||
{l}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</SettingRow>
|
||
)}
|
||
<SettingRow label={t('settings.recording.microphoneLabel')} desc={t('settings.recording.microphoneDesc')}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||
<MicrophoneSelect
|
||
devices={microphoneDevices}
|
||
selectedName={effectiveMicrophoneDeviceName}
|
||
onSelect={onMicrophoneDeviceChange}
|
||
onOpen={() => { void loadMicrophoneDevices(undefined, { showLoading: false }); }}
|
||
/>
|
||
{microphoneDevicesError && (
|
||
<div style={{ fontSize: 11, color: 'var(--ol-err)', lineHeight: 1.5 }}>
|
||
{t('settings.recording.microphoneLoadError', { message: microphoneDevicesError })}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</SettingRow>
|
||
{os !== 'linux' && !isAndroid && (
|
||
<SettingRow label={t('settings.recording.capsuleLabel')} desc={t('settings.recording.capsuleDesc')}>
|
||
<Toggle on={prefs.showCapsule} onToggle={onShowCapsuleChange} />
|
||
</SettingRow>
|
||
)}
|
||
{os !== 'linux' && !isAndroid && (
|
||
<SettingRow label={t('settings.recording.capsuleStyleLabel')}>
|
||
<SelectLite
|
||
value={prefs.capsuleStyle ?? 'siri'}
|
||
onChange={next => savePrefs({ ...prefs, capsuleStyle: next as CapsuleStyle })}
|
||
options={[
|
||
{ value: 'siri', label: t('settings.recording.capsuleStyleSiri') },
|
||
{ value: 'classic', label: t('settings.recording.capsuleStyleClassic') },
|
||
]}
|
||
ariaLabel={t('settings.recording.capsuleStyleLabel')}
|
||
style={{ ...inputStyle, maxWidth: 220 }}
|
||
/>
|
||
</SettingRow>
|
||
)}
|
||
<SettingRow label={t('settings.recording.muteDuringRecordingLabel')} desc={t('settings.recording.muteDuringRecordingDesc')}>
|
||
<Toggle on={prefs.muteDuringRecording} onToggle={onMuteDuringRecordingChange} />
|
||
</SettingRow>
|
||
<SettingRow
|
||
label={t('settings.recording.audioCueLabel')}
|
||
desc={t('settings.recording.audioCueDesc')}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<Toggle on={prefs.audioCueOnRecord} onToggle={onAudioCueChange} />
|
||
<button
|
||
type="button"
|
||
onClick={() => playRecordStartCue()}
|
||
style={{
|
||
padding: '5px 12px',
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
fontFamily: 'inherit',
|
||
border: '0.5px solid var(--ol-line-strong)',
|
||
borderRadius: 8,
|
||
background: 'var(--ol-surface-2)',
|
||
color: 'var(--ol-ink-2)',
|
||
cursor: 'default',
|
||
transition: 'background 0.16s var(--ol-motion-quick)',
|
||
}}
|
||
>
|
||
{t('settings.recording.audioCuePreview')}
|
||
</button>
|
||
</div>
|
||
</SettingRow>
|
||
{os === 'linux' && (
|
||
<SettingRow label={t('settings.advanced.streamingInsertLabel')}>
|
||
<Toggle
|
||
on={!!prefs.streamingInsert}
|
||
onToggle={(next) => void savePrefs({ ...prefs, streamingInsert: next })}
|
||
/>
|
||
</SettingRow>
|
||
)}
|
||
</Card>
|
||
|
||
{/* ─── 插入与剪贴板(折叠,仅 macOS / Windows) ──────────────── */}
|
||
{showDesktopInsert && (
|
||
<Collapsible title={t('settings.recording.insertGroupTitle')}>
|
||
<SettingRow label={t('settings.recording.restoreClipboardLabel')} desc={t('settings.recording.restoreClipboardDesc')}>
|
||
<Toggle on={prefs.restoreClipboardAfterPaste} onToggle={onRestoreClipboardChange} />
|
||
</SettingRow>
|
||
{capability.adapter !== 'macEventTap' && (
|
||
<SettingRow label={t('settings.recording.pasteShortcutLabel')} desc={t('settings.recording.pasteShortcutDesc')}>
|
||
<SelectLite
|
||
value={effectivePasteShortcut}
|
||
onChange={next => onPasteShortcutChange(next as PasteShortcut)}
|
||
options={[
|
||
{ value: 'ctrlV', label: t('settings.recording.pasteShortcutCtrlV') },
|
||
{ value: 'ctrlShiftV', label: t('settings.recording.pasteShortcutCtrlShiftV') },
|
||
// 这个「粘贴与剪贴板」组只在 Windows 出现(showDesktopInsert 已排除 Linux,mac 走
|
||
// macEventTap 不显示本行)。Shift+Insert 是 xterm/urxvt 等 X11 终端的粘贴组合,
|
||
// 放在 Windows 上纯属误导,故不再作为选项(issue #786)。
|
||
]}
|
||
ariaLabel={t('settings.recording.pasteShortcutLabel')}
|
||
style={{ ...inputStyle, maxWidth: 220 }}
|
||
/>
|
||
</SettingRow>
|
||
)}
|
||
{capability.adapter === 'windowsLowLevel' && (
|
||
<SettingRow
|
||
label={t('settings.recording.windowsInsertionModeLabel')}
|
||
desc={t('settings.recording.windowsInsertionModeDesc')}
|
||
>
|
||
<SelectLite
|
||
value={prefs.windowsInsertionMode ?? (prefs.windowsSendInputInsertionOnly ? 'sendInput' : 'tsf')}
|
||
onChange={next => onWindowsInsertionModeChange(next as WindowsInsertionMode)}
|
||
options={[
|
||
{ value: 'tsf', label: t('settings.recording.windowsInsertionModeTsf') },
|
||
{ value: 'sendInput', label: t('settings.recording.windowsInsertionModeSendInput') },
|
||
{ value: 'paste', label: t('settings.recording.windowsInsertionModePaste') },
|
||
]}
|
||
ariaLabel={t('settings.recording.windowsInsertionModeLabel')}
|
||
style={{ ...inputStyle, maxWidth: 260 }}
|
||
/>
|
||
</SettingRow>
|
||
)}
|
||
{capability.adapter === 'windowsLowLevel'
|
||
&& (prefs.windowsInsertionMode === 'sendInput' || prefs.windowsSendInputInsertionOnly) && (
|
||
<SettingRow
|
||
label={t('settings.recording.windowsSendInputNewlineModeLabel')}
|
||
desc={t('settings.recording.windowsSendInputNewlineModeDesc')}
|
||
>
|
||
<SelectLite
|
||
value={prefs.windowsSendInputNewlineMode ?? 'enter'}
|
||
onChange={next => onWindowsSendInputNewlineModeChange(next as WindowsSendInputNewlineMode)}
|
||
options={[
|
||
{ value: 'enter', label: t('settings.recording.windowsSendInputNewlineModeEnter') },
|
||
{ value: 'shiftEnter', label: t('settings.recording.windowsSendInputNewlineModeShiftEnter') },
|
||
{ value: 'crlf', label: t('settings.recording.windowsSendInputNewlineModeCrLf') },
|
||
]}
|
||
ariaLabel={t('settings.recording.windowsSendInputNewlineModeLabel')}
|
||
style={{ ...inputStyle, maxWidth: 260 }}
|
||
/>
|
||
</SettingRow>
|
||
)}
|
||
{capability.adapter === 'windowsLowLevel'
|
||
&& (prefs.windowsInsertionMode === 'sendInput' || prefs.windowsSendInputInsertionOnly) && (
|
||
<SettingRow
|
||
label={t('settings.recording.windowsShowOpenlessInKeyboardListLabel')}
|
||
desc={t('settings.recording.windowsShowOpenlessInKeyboardListDesc')}
|
||
>
|
||
<Toggle
|
||
on={prefs.windowsShowOpenlessInKeyboardList}
|
||
onToggle={(next) => void onWindowsShowOpenlessInKeyboardListChange(next)}
|
||
/>
|
||
</SettingRow>
|
||
)}
|
||
{capability.adapter === 'windowsLowLevel' && (
|
||
<SettingRow label={t('settings.recording.allowNonTsfFallbackLabel')}>
|
||
<Toggle
|
||
on={prefs.allowNonTsfInsertionFallback}
|
||
onToggle={onAllowNonTsfFallbackChange}
|
||
/>
|
||
</SettingRow>
|
||
)}
|
||
{/* 流式输入:润色 SSE 一边到达一边模拟键盘逐字落到光标,降低感知延迟。
|
||
不满足条件时自动回落一次性插入。属于「插入行为」,故归到本组。 */}
|
||
<SettingRow label={t('settings.advanced.streamingInsertLabel')}>
|
||
<Toggle
|
||
on={!!prefs.streamingInsert}
|
||
onToggle={(next) => void savePrefs({ ...prefs, streamingInsert: next })}
|
||
/>
|
||
</SettingRow>
|
||
<SettingRow label={t('settings.advanced.streamingInsertSaveClipboardLabel')}>
|
||
<Toggle
|
||
on={!!prefs.streamingInsertSaveClipboard}
|
||
onToggle={(next) => void savePrefs({ ...prefs, streamingInsertSaveClipboard: next })}
|
||
/>
|
||
</SettingRow>
|
||
</Collapsible>
|
||
)}
|
||
{/* ─── 启动(折叠) ──────────────────────────────────────────── */}
|
||
{showDesktopStartup && (
|
||
<Collapsible title={t('settings.recording.startupGroupTitle')}>
|
||
<AutostartRow />
|
||
<SettingRow label={t('settings.recording.startMinimizedLabel')}>
|
||
<Toggle on={prefs.startMinimized} onToggle={onStartMinimizedChange} />
|
||
</SettingRow>
|
||
<SettingRow label={t('settings.recording.autoUpdateCheckLabel')}>
|
||
<Toggle on={prefs.autoUpdateCheck} onToggle={onAutoUpdateCheckChange} />
|
||
</SettingRow>
|
||
</Collapsible>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// 不存进 prefs:autostart 状态由 OS 持有(mac LaunchAgent plist / linux .desktop /
|
||
// windows HKCU\Run),prefs 缓存反而会与 OS 真相不一致。issue #194。
|
||
function AutostartRow() {
|
||
const { t } = useTranslation();
|
||
const [enabled, setEnabled] = useState(false);
|
||
const [loaded, setLoaded] = useState(false);
|
||
// 切 plist / 注册表失败时给用户看的错误。null = 没有失败/上次操作已成功。
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!isTauri) {
|
||
setLoaded(true);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
autostartIsEnabled()
|
||
.then((v: boolean) => {
|
||
if (!cancelled) {
|
||
setEnabled(v);
|
||
setLoaded(true);
|
||
}
|
||
})
|
||
.catch((err: unknown) => {
|
||
console.error('[autostart] isEnabled failed', err);
|
||
if (!cancelled) setLoaded(true);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
const onToggle = async (next: boolean) => {
|
||
setEnabled(next);
|
||
setError(null);
|
||
try {
|
||
if (!isTauri) return;
|
||
if (next) await autostartEnable();
|
||
else await autostartDisable();
|
||
} catch (err) {
|
||
console.error('[autostart] toggle failed', err);
|
||
setEnabled(!next);
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
}
|
||
};
|
||
|
||
return (
|
||
<SettingRow label={t('settings.recording.startupAtBoot')}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||
{loaded ? <Toggle on={enabled} onToggle={onToggle} /> : null}
|
||
{error && (
|
||
<div style={{ fontSize: 11, color: 'var(--ol-err)', marginTop: 4, lineHeight: 1.5 }}>
|
||
{t('settings.recording.startupAtBootError', { message: error })}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</SettingRow>
|
||
);
|
||
}
|