Merge pull request #110 from appergb/fix/asr-seq-race-and-ui-rework
fix(asr): 修 Volcengine 序号竞态 + UI 重做
This commit is contained in:
16 files changed
+755
-285
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openless-app",
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openless-app",
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.7",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-shell": "^2.0.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openless-app",
|
||||
"private": true,
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -3250,7 +3250,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openless"
|
||||
version = "1.2.6"
|
||||
version = "1.2.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openless"
|
||||
version = "1.2.6"
|
||||
version = "1.2.7"
|
||||
description = "OpenLess — local voice input that types where your cursor is"
|
||||
authors = ["OpenLess"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -497,46 +497,45 @@ impl VolcengineStreamingASR {
|
||||
|
||||
impl AudioConsumer for VolcengineStreamingASR {
|
||||
fn consume_pcm_chunk(&self, pcm: &[u8]) {
|
||||
let runtime = {
|
||||
// 一次性把就绪 chunk 全部 drain 出来(同一把 state 锁内分配 seq,保证 seq 单调)。
|
||||
// 然后 spawn 一个串行 send 的 task —— 不要每块一个 spawn,否则 burst flush 时多
|
||||
// 个 task 异步竞争 writer 锁,发送顺序和 seq 顺序对不上,服务端会报
|
||||
// "autoAssignedSequence (N) mismatch sequence in request (N+1)" 直接断连。
|
||||
let (runtime, chunks) = {
|
||||
let mut st = self.state.lock();
|
||||
if !st.is_connected {
|
||||
return;
|
||||
}
|
||||
st.pending_audio.extend_from_slice(pcm);
|
||||
st.runtime.clone()
|
||||
|
||||
let mut out: Vec<(i32, Vec<u8>)> = Vec::new();
|
||||
while st.pending_audio.len() >= TARGET_AUDIO_CHUNK_BYTES {
|
||||
let chunk: Vec<u8> = st.pending_audio.drain(..TARGET_AUDIO_CHUNK_BYTES).collect();
|
||||
let seq = st.next_sequence;
|
||||
st.next_sequence += 1;
|
||||
st.bytes_sent += chunk.len();
|
||||
st.frames_sent += 1;
|
||||
out.push((seq, chunk));
|
||||
}
|
||||
(st.runtime.clone(), out)
|
||||
};
|
||||
|
||||
if chunks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(runtime) = runtime else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Drain as many full chunks as we have, spawning one send per chunk.
|
||||
loop {
|
||||
let chunk_and_seq = {
|
||||
let mut st = self.state.lock();
|
||||
if st.pending_audio.len() < TARGET_AUDIO_CHUNK_BYTES {
|
||||
None
|
||||
} else {
|
||||
let chunk: Vec<u8> =
|
||||
st.pending_audio.drain(..TARGET_AUDIO_CHUNK_BYTES).collect();
|
||||
let seq = st.next_sequence;
|
||||
st.next_sequence += 1;
|
||||
st.bytes_sent += chunk.len();
|
||||
st.frames_sent += 1;
|
||||
Some((chunk, seq))
|
||||
}
|
||||
};
|
||||
|
||||
let Some((chunk, seq)) = chunk_and_seq else {
|
||||
break;
|
||||
};
|
||||
|
||||
let writer = Arc::clone(&self.writer);
|
||||
// pending_sends + Notify 让 send_last_frame 知道何时所有 chunk 都已发出。
|
||||
self.pending_sends.fetch_add(1, Ordering::SeqCst);
|
||||
let pending = Arc::clone(&self.pending_sends);
|
||||
let notify = Arc::clone(&self.send_done);
|
||||
runtime.spawn(async move {
|
||||
// pending_sends + Notify 让 send_last_frame 知道何时所有 chunk 都已发出。
|
||||
// 单 task 内串行 send,所以一次性 +N、收尾 -N。
|
||||
let count = chunks.len();
|
||||
self.pending_sends.fetch_add(count, Ordering::SeqCst);
|
||||
let writer = Arc::clone(&self.writer);
|
||||
let pending = Arc::clone(&self.pending_sends);
|
||||
let notify = Arc::clone(&self.send_done);
|
||||
runtime.spawn(async move {
|
||||
for (seq, chunk) in chunks {
|
||||
let frame = frame::build(
|
||||
MessageType::AudioOnlyRequest,
|
||||
Flags::PositiveSequence,
|
||||
@@ -548,11 +547,11 @@ impl AudioConsumer for VolcengineStreamingASR {
|
||||
// 把丢帧错误顶到日志里,定位"为什么服务端只收到 100ms"
|
||||
log::error!("[asr] audio frame seq={} send 失败: {}", seq, e);
|
||||
}
|
||||
if pending.fetch_sub(1, Ordering::SeqCst) == 1 {
|
||||
notify.notify_waiters();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if pending.fetch_sub(count, Ordering::SeqCst) == count {
|
||||
notify.notify_waiters();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ fn default_true() -> bool {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct UserPreferences {
|
||||
pub hotkey: HotkeyBinding,
|
||||
pub default_mode: PolishMode,
|
||||
@@ -158,7 +158,7 @@ impl HotkeyAdapterKind {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct HotkeyBinding {
|
||||
pub trigger: HotkeyTrigger,
|
||||
pub mode: HotkeyMode,
|
||||
@@ -213,7 +213,7 @@ impl HotkeyCapability {
|
||||
supports_side_specific_modifiers: true,
|
||||
explicit_fallback_available: false,
|
||||
status_hint: Some(
|
||||
"默认建议使用“右 Control + 按住说话”;若无响应,可在权限页查看 hook 安装状态。"
|
||||
"默认建议使用“右 Control + 切换式说话”;若更习惯按住说话,可在录音设置里切回。若无响应,可在权限页查看 hook 安装状态。"
|
||||
.into(),
|
||||
),
|
||||
};
|
||||
@@ -287,7 +287,7 @@ impl Default for HotkeyBinding {
|
||||
{
|
||||
Self {
|
||||
trigger: HotkeyTrigger::RightControl,
|
||||
mode: HotkeyMode::Hold,
|
||||
mode: HotkeyMode::Toggle,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenLess",
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.7",
|
||||
"identifier": "com.openless.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -26,7 +26,8 @@
|
||||
"shadow": true,
|
||||
"hiddenTitle": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"visible": true
|
||||
"visible": true,
|
||||
"acceptFirstMouse": true
|
||||
},
|
||||
{
|
||||
"label": "capsule",
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// 自动更新共用模块 — Settings 的"关于"section 和 footer 按钮共用同一套
|
||||
// 状态机 + 对话框 UI。两边各自调用 useAutoUpdate(),dialog 渲染条件相同。
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Update, DownloadEvent } from '@tauri-apps/plugin-updater';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isTauri, restartApp } from '../lib/ipc';
|
||||
import { Btn } from '../pages/_atoms';
|
||||
|
||||
export type UpdateStatus =
|
||||
| 'idle'
|
||||
| 'checking'
|
||||
| 'available'
|
||||
| 'none'
|
||||
| 'downloading'
|
||||
| 'installing'
|
||||
| 'downloaded'
|
||||
| 'error';
|
||||
|
||||
export interface UseAutoUpdate {
|
||||
status: UpdateStatus;
|
||||
version: string;
|
||||
progress: number | null;
|
||||
downloaded: number;
|
||||
contentLength: number | null;
|
||||
checking: boolean;
|
||||
busy: boolean;
|
||||
/** 触发"检查更新"。如果发现新版本,状态变为 'available',需要 caller 渲染对话框让用户确认下载。 */
|
||||
checkForUpdates: () => Promise<void>;
|
||||
/** 用户在对话框里确认 → 下载 + 安装。完成后状态变为 'downloaded',等用户点重启。 */
|
||||
installUpdate: () => Promise<void>;
|
||||
/** 关闭对话框(仅在非 busy 状态可用)。 */
|
||||
dismissDialog: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAutoUpdate(): UseAutoUpdate {
|
||||
const updateRef = useRef<Update | null>(null);
|
||||
const [status, setStatus] = useState<UpdateStatus>('idle');
|
||||
const [version, setVersion] = useState('');
|
||||
const [downloaded, setDownloaded] = useState(0);
|
||||
const [contentLength, setContentLength] = useState<number | null>(null);
|
||||
|
||||
const checking = status === 'checking';
|
||||
const busy = status === 'downloading' || status === 'installing';
|
||||
const progress = contentLength && contentLength > 0
|
||||
? Math.min(100, Math.round((downloaded / contentLength) * 100))
|
||||
: null;
|
||||
|
||||
const closeUpdate = async () => {
|
||||
const current = updateRef.current;
|
||||
updateRef.current = null;
|
||||
if (current) {
|
||||
try {
|
||||
await current.close();
|
||||
} catch (error) {
|
||||
console.warn('[updater] failed to close update resource', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => { void closeUpdate(); };
|
||||
}, []);
|
||||
|
||||
const resetProgress = () => {
|
||||
setDownloaded(0);
|
||||
setContentLength(null);
|
||||
};
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
setStatus('checking');
|
||||
setVersion('');
|
||||
resetProgress();
|
||||
await closeUpdate();
|
||||
try {
|
||||
if (!isTauri) {
|
||||
setStatus('none');
|
||||
return;
|
||||
}
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
const next = await check();
|
||||
updateRef.current = next;
|
||||
setVersion(next?.version ?? '');
|
||||
setStatus(next ? 'available' : 'none');
|
||||
} catch (error) {
|
||||
console.error('[updater] failed to check update', error);
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const installUpdate = async () => {
|
||||
const update = updateRef.current;
|
||||
if (!update) return;
|
||||
resetProgress();
|
||||
setStatus('downloading');
|
||||
try {
|
||||
await update.download((event: DownloadEvent) => {
|
||||
if (event.event === 'Started') {
|
||||
resetProgress();
|
||||
setContentLength(event.data.contentLength ?? null);
|
||||
} else if (event.event === 'Progress') {
|
||||
setDownloaded(value => value + event.data.chunkLength);
|
||||
} else if (event.event === 'Finished') {
|
||||
setStatus('installing');
|
||||
}
|
||||
});
|
||||
setStatus('installing');
|
||||
await update.install();
|
||||
await closeUpdate();
|
||||
setStatus('downloaded');
|
||||
} catch (error) {
|
||||
console.error('[updater] failed to install update', error);
|
||||
await closeUpdate();
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const dismissDialog = async () => {
|
||||
if (busy) return;
|
||||
await closeUpdate();
|
||||
setStatus('idle');
|
||||
setVersion('');
|
||||
resetProgress();
|
||||
};
|
||||
|
||||
return {
|
||||
status,
|
||||
version,
|
||||
progress,
|
||||
downloaded,
|
||||
contentLength,
|
||||
checking,
|
||||
busy,
|
||||
checkForUpdates,
|
||||
installUpdate,
|
||||
dismissDialog,
|
||||
};
|
||||
}
|
||||
|
||||
export function isDialogStatus(status: UpdateStatus): status is 'available' | 'downloading' | 'installing' | 'downloaded' {
|
||||
return status === 'available' || status === 'downloading' || status === 'installing' || status === 'downloaded';
|
||||
}
|
||||
|
||||
export function UpdateDialog({
|
||||
status,
|
||||
version,
|
||||
progress,
|
||||
downloaded,
|
||||
contentLength,
|
||||
onInstall,
|
||||
onClose,
|
||||
}: {
|
||||
status: 'available' | 'downloading' | 'installing' | 'downloaded';
|
||||
version: string;
|
||||
progress: number | null;
|
||||
downloaded: number;
|
||||
contentLength: number | null;
|
||||
onInstall: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const downloading = status === 'downloading';
|
||||
const installing = status === 'installing';
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.18)', display: 'grid', placeItems: 'center', zIndex: 40 }}>
|
||||
<div style={{ width: 360, borderRadius: 16, background: 'var(--ol-surface)', border: '0.5px solid var(--ol-line-strong)', boxShadow: '0 18px 42px rgba(0,0,0,0.18)', padding: 18 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 650, marginBottom: 8 }}>{t(`settings.about.updateDialog.${status}.title`)}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6, marginBottom: 14 }}>
|
||||
{t(`settings.about.updateDialog.${status}.desc`, { version })}
|
||||
</div>
|
||||
{(downloading || installing || status === 'downloaded') && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ height: 8, borderRadius: 999, background: 'var(--ol-surface-2)', overflow: 'hidden', border: '0.5px solid var(--ol-line)' }}>
|
||||
<div style={{ height: '100%', width: `${status === 'downloaded' || installing ? 100 : progress ?? 8}%`, background: 'var(--ol-blue)', transition: 'width 0.18s ease-out' }} />
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--ol-ink-4)' }}>
|
||||
{installing
|
||||
? t('settings.about.updateDialog.installingLabel')
|
||||
: progress === null
|
||||
? t('settings.about.updateDialog.progressUnknown', { downloaded: formatBytes(downloaded) })
|
||||
: t('settings.about.updateDialog.progress', { progress, downloaded: formatBytes(downloaded), total: formatBytes(contentLength ?? 0) })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
{status === 'available' && <Btn size="sm" onClick={onClose}>{t('common.cancel')}</Btn>}
|
||||
{status === 'available' && <Btn variant="blue" size="sm" onClick={onInstall}>{t('settings.about.updateDialog.install')}</Btn>}
|
||||
{(downloading || installing) && <Btn size="sm" disabled>{installing ? t('settings.about.updateDialog.installingLabel') : t('settings.about.updateDialog.downloadingLabel')}</Btn>}
|
||||
{status === 'downloaded' && <Btn size="sm" onClick={onClose}>{t('settings.about.updateDialog.later')}</Btn>}
|
||||
{status === 'downloaded' && <Btn variant="blue" size="sm" onClick={restartApp}>{t('settings.about.updateDialog.restartNow')}</Btn>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return '0 B';
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
//
|
||||
// Ported verbatim from design_handoff_openless/variants.jsx::FloatingShell.
|
||||
|
||||
import { useEffect, useMemo, useState, type ComponentType } from 'react';
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type ComponentType, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isDialogStatus, UpdateDialog, useAutoUpdate } from './AutoUpdate';
|
||||
import { Icon } from './Icon';
|
||||
import { WindowChrome, detectOS, type OS } from './WindowChrome';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
@@ -14,7 +15,13 @@ import { History } from '../pages/History';
|
||||
import { Vocab } from '../pages/Vocab';
|
||||
import { Style } from '../pages/Style';
|
||||
import { APP_VERSION_LABEL } from '../lib/appVersion';
|
||||
import {
|
||||
HOTKEY_MODE_MIGRATION_ACK_KEY,
|
||||
HOTKEY_MODE_MIGRATION_DEFERRED_KEY,
|
||||
shouldShowHotkeyModeMigrationPrompt,
|
||||
} from '../lib/hotkeyMigration';
|
||||
import { getHotkeyTriggerLabel } from '../lib/hotkey';
|
||||
import { applyFontScale, readFontScale } from '../lib/fontScale';
|
||||
import { getCredentials, openExternal } from '../lib/ipc';
|
||||
import { OL_DATA } from '../lib/mockData';
|
||||
import {
|
||||
@@ -39,6 +46,9 @@ const NAV_BASE: Array<Omit<NavItem, 'name'>> = [
|
||||
{ id: 'style', icon: 'style', cmp: Style },
|
||||
];
|
||||
|
||||
const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases';
|
||||
const HELP_DOCS_URL = 'https://github.com/appergb/openless#readme';
|
||||
|
||||
interface FloatingShellProps {
|
||||
os?: OS;
|
||||
initialTab?: AppTab;
|
||||
@@ -59,7 +69,29 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
const { currentTab, setCurrentTab, settingsOpen, setSettingsOpen } = useAppState(initialTab, initialSettings);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionId | undefined>();
|
||||
const [providerPromptOpen, setProviderPromptOpen] = useState(false);
|
||||
const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false);
|
||||
const [helpPopoverOpen, setHelpPopoverOpen] = useState(false);
|
||||
const { hotkey } = useHotkeySettings();
|
||||
|
||||
// 字体档位 — 启动时按 localStorage 应用一次;之后改动来自 Settings 的"个性化"section。
|
||||
useEffect(() => {
|
||||
applyFontScale(readFontScale());
|
||||
}, []);
|
||||
|
||||
// help popover 打开时,点击其他位置自动关闭
|
||||
useEffect(() => {
|
||||
if (!helpPopoverOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Element | null;
|
||||
if (target && target.closest('[data-ol-footer-popover]')) return;
|
||||
setHelpPopoverOpen(false);
|
||||
};
|
||||
const id = window.setTimeout(() => document.addEventListener('mousedown', onDown), 0);
|
||||
return () => {
|
||||
window.clearTimeout(id);
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
};
|
||||
}, [helpPopoverOpen]);
|
||||
const NAV = useMemo<NavItem[]>(
|
||||
() => NAV_BASE.map(b => ({ ...b, name: t(`nav.${b.id}`) })),
|
||||
[t],
|
||||
@@ -80,11 +112,24 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const acknowledgedValue = window.localStorage.getItem(HOTKEY_MODE_MIGRATION_ACK_KEY);
|
||||
const deferredValue = window.sessionStorage.getItem(HOTKEY_MODE_MIGRATION_DEFERRED_KEY);
|
||||
if (shouldShowHotkeyModeMigrationPrompt(acknowledgedValue, deferredValue)) {
|
||||
setHotkeyModePromptOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const rememberProviderPrompt = () => {
|
||||
window.sessionStorage.setItem(PROVIDER_SETUP_PROMPT_DEFERRED_KEY, '1');
|
||||
setProviderPromptOpen(false);
|
||||
};
|
||||
|
||||
const deferHotkeyModePrompt = () => {
|
||||
window.sessionStorage.setItem(HOTKEY_MODE_MIGRATION_DEFERRED_KEY, '1');
|
||||
setHotkeyModePromptOpen(false);
|
||||
};
|
||||
|
||||
const openSettings = (section?: SettingsSectionId) => {
|
||||
setSettingsInitialSection(section);
|
||||
setSettingsOpen(true);
|
||||
@@ -95,6 +140,12 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
openSettings('providers');
|
||||
};
|
||||
|
||||
const openHotkeyRecordingSettings = () => {
|
||||
window.localStorage.setItem(HOTKEY_MODE_MIGRATION_ACK_KEY, '1');
|
||||
setHotkeyModePromptOpen(false);
|
||||
openSettings('recording');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0, paddingTop: os === 'mac' ? 36 : 0 }}>
|
||||
|
||||
@@ -109,13 +160,13 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
zIndex: 1,
|
||||
}}>
|
||||
|
||||
{/* Sidebar — inside the raised console */}
|
||||
{/* Sidebar — 透明地坐在外层磨砂底板上,让 LOGO/导航/快捷键/BETA/footer 共用同一片磨砂玻璃 */}
|
||||
<aside
|
||||
style={{
|
||||
width: 188,
|
||||
flexShrink: 0,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
background: 'linear-gradient(180deg, rgba(247,247,250,0.85) 0%, rgba(247,247,250,0.5) 100%)',
|
||||
background: 'transparent',
|
||||
padding: '14px 10px 12px',
|
||||
}}>
|
||||
|
||||
@@ -170,19 +221,13 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* shortcut hint */}
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 10px 8px',
|
||||
borderTop: '0.5px dashed var(--ol-line)',
|
||||
marginTop: 8,
|
||||
}}>
|
||||
|
||||
{/* shortcut hint — 不要 dashed 边框,否则会切断"整片磨砂玻璃"的视觉 */}
|
||||
<div style={{ padding: '10px 10px 6px', marginTop: 6 }}>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--ol-ink-4)', marginBottom: 6, letterSpacing: '0.02em' }}>{t('shell.shortcutLabel')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'var(--ol-ink-2)' }}>
|
||||
<kbd style={{
|
||||
padding: '2px 7px', fontSize: 10.5,
|
||||
background: '#fff', borderRadius: 5,
|
||||
background: 'rgba(255,255,255,0.7)', borderRadius: 5,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
fontFamily: 'var(--ol-font-mono)', color: 'var(--ol-ink)',
|
||||
boxShadow: '0 1px 0 rgba(0,0,0,.04)',
|
||||
@@ -191,55 +236,57 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* trial / status */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
background: 'linear-gradient(160deg, rgba(37,99,235,0.08) 0%, rgba(37,99,235,0.02) 100%)',
|
||||
border: '0.5px solid rgba(37,99,235,0.15)',
|
||||
}}>
|
||||
|
||||
{/* BETA 区域 — 去掉描边和实色背景,让它和底部 footer 一起浮在磨砂玻璃上 */}
|
||||
<div style={{ marginTop: 8, padding: '10px 10px 4px' }}>
|
||||
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--ol-blue)', letterSpacing: '0.04em', textTransform: 'uppercase' }}>{t('shell.betaTag')}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-2)', marginTop: 4, lineHeight: 1.5 }}>{t('shell.betaNote')}</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content — inset white card sitting on the frosted backplate */}
|
||||
{/* Main content — inset white card sitting on the frosted backplate.
|
||||
内卡圆角与外层窗口(WindowChrome 20/14)对齐,避免视觉上"两个不一致的圆角"。 */}
|
||||
<div style={{ flex: 1, minWidth: 0, padding: '6px 8px 6px 0', display: 'flex' }}>
|
||||
<main
|
||||
style={{
|
||||
flex: 1, minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
background: 'var(--ol-surface)',
|
||||
borderRadius: 12,
|
||||
borderRadius: os === 'mac' ? 20 : 14,
|
||||
border: '0.5px solid rgba(0,0,0,0.06)',
|
||||
boxShadow: '0 1px 0 rgba(255,255,255,0.8) inset, 0 8px 24px -12px rgba(15,17,22,0.10), 0 2px 6px -2px rgba(15,17,22,0.06)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '24px 28px 32px', flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
{/* key={currentTab} 让每次切换重挂这棵子树 → ol-page-fade keyframe 重新触发 */}
|
||||
<div
|
||||
key={currentTab}
|
||||
style={{
|
||||
animation: 'ol-page-fade 0.18s ease-out',
|
||||
}}
|
||||
>
|
||||
{currentTab === 'overview' ? (
|
||||
<Overview onOpenHistory={() => setCurrentTab('history')} />
|
||||
) : (
|
||||
<Page />
|
||||
)}
|
||||
</div>
|
||||
{/* key={currentTab} 让每次切换重挂这棵子树 → ol-page-slide keyframe 重新触发。
|
||||
padding + overflow:auto 直接挂在这棵 wrapper 上:
|
||||
- 自然高度的页(Overview / Vocab / Style)—— 整页内容超出时 wrapper 出现滚动条
|
||||
- 用 height:100% 撑满的页(History 左右双列)—— 100% 能解析到 wrapper 的固定高度,
|
||||
两列内部各自的 overflow:auto 才能独立滚动 */}
|
||||
<div
|
||||
key={currentTab}
|
||||
style={{
|
||||
flex: 1, minHeight: 0,
|
||||
overflow: 'auto',
|
||||
padding: '24px 28px 32px',
|
||||
// 苹果"spring out"风格的曲线:开始快、收尾顺滑,符合人体直觉
|
||||
animation: 'ol-page-slide 0.32s cubic-bezier(0.32, 0.72, 0, 1) both',
|
||||
willChange: 'opacity, transform',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{currentTab === 'overview' ? (
|
||||
<Overview onOpenHistory={() => setCurrentTab('history')} />
|
||||
) : (
|
||||
<Page />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer — sits on frosted outer, like Typeless */}
|
||||
{/* Footer — 透明地坐在外层磨砂底板上,跟 sidebar 同一片磨砂玻璃 */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
@@ -256,29 +303,21 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
<FooterIcon name="user" tip={t('shell.footer.account')} onClick={() => openSettings('providers')} />
|
||||
<FooterIcon name="mail" tip={t('shell.footer.feedback')} onClick={() => openExternal('https://github.com/appergb/openless/issues')} />
|
||||
<FooterIcon name="settings" tip={t('shell.footer.settings')} active={settingsOpen} onClick={() => openSettings()} />
|
||||
<FooterIcon name="help" tip={t('shell.footer.help')} onClick={() => openExternal('https://github.com/appergb/openless#readme')} />
|
||||
|
||||
{/* 问号 — 点击展开版本说明 popover */}
|
||||
<FooterIconWithPopover
|
||||
name="help"
|
||||
tip={t('shell.footer.help')}
|
||||
open={helpPopoverOpen}
|
||||
onToggle={() => setHelpPopoverOpen(o => !o)}
|
||||
>
|
||||
<HelpPopoverBody />
|
||||
</FooterIconWithPopover>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<span style={{ fontFamily: 'var(--ol-font-sans)' }}>{t('shell.footer.version', { version: APP_VERSION_LABEL })}</span>
|
||||
<button
|
||||
onClick={() => openExternal('https://github.com/appergb/openless/releases')}
|
||||
style={{
|
||||
color: 'var(--ol-blue)',
|
||||
marginLeft: 8,
|
||||
textDecoration: 'none',
|
||||
fontWeight: 500,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
cursor: 'default',
|
||||
padding: 0,
|
||||
transition: 'opacity 0.12s ease-out',
|
||||
}}
|
||||
>
|
||||
{t('shell.footer.checkUpdates')}
|
||||
</button>
|
||||
<FooterAutoUpdateButton />
|
||||
</div>
|
||||
|
||||
{/* Settings modal — rendered inside this window */}
|
||||
@@ -291,24 +330,33 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
/>
|
||||
}
|
||||
|
||||
{providerPromptOpen && (
|
||||
{providerPromptOpen ? (
|
||||
<ProviderSetupPrompt
|
||||
onLater={rememberProviderPrompt}
|
||||
onOpenSettings={openProviderSettings}
|
||||
/>
|
||||
)}
|
||||
) : hotkeyModePromptOpen ? (
|
||||
<HotkeyModeMigrationPrompt
|
||||
onLater={deferHotkeyModePrompt}
|
||||
onOpenSettings={openHotkeyRecordingSettings}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* tab 切换 + provider prompt 公用的入场关键帧 */}
|
||||
{/* tab 切换 + provider prompt + footer popover 公用的入场关键帧 */}
|
||||
<style>{`
|
||||
@keyframes ol-page-fade {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@keyframes ol-page-slide {
|
||||
from { opacity: 0; transform: translate3d(14px, 0, 0); }
|
||||
to { opacity: 1; transform: translate3d(0, 0, 0); }
|
||||
}
|
||||
@keyframes ol-prompt-fade { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes ol-prompt-pop {
|
||||
from { opacity: 0; transform: translateY(6px) scale(.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes ol-popover-pop {
|
||||
from { opacity: 0; transform: translateY(6px) scale(.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
@@ -407,6 +455,99 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void;
|
||||
);
|
||||
}
|
||||
|
||||
function HotkeyModeMigrationPrompt({ onLater, onOpenSettings }: { onLater: () => void; onOpenSettings: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 70,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 28,
|
||||
background: 'rgba(15,17,22,0.28)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
WebkitBackdropFilter: 'blur(2px)',
|
||||
animation: 'ol-prompt-fade 0.18s ease-out',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 380,
|
||||
borderRadius: 12,
|
||||
background: 'var(--ol-surface)',
|
||||
border: '0.5px solid rgba(0,0,0,.08)',
|
||||
boxShadow: '0 24px 70px -24px rgba(15,17,22,.38), 0 0 0 0.5px rgba(0,0,0,.06)',
|
||||
padding: 20,
|
||||
animation: 'ol-prompt-pop 0.22s cubic-bezier(.2,.9,.3,1.1)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
background: 'rgba(37,99,235,0.10)',
|
||||
color: 'var(--ol-blue)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon name="mic" size={17} />
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink)' }}>{t('shell.hotkeyModePrompt.title')}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.55 }}>
|
||||
{t('shell.hotkeyModePrompt.body')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 18 }}>
|
||||
<button
|
||||
onClick={onLater}
|
||||
style={{
|
||||
height: 32,
|
||||
padding: '0 13px',
|
||||
borderRadius: 8,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
background: 'var(--ol-surface)',
|
||||
color: 'var(--ol-ink-3)',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 500,
|
||||
cursor: 'default',
|
||||
transition: 'background 0.12s ease-out, border-color 0.12s ease-out',
|
||||
}}
|
||||
>
|
||||
{t('shell.hotkeyModePrompt.later')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
style={{
|
||||
height: 32,
|
||||
padding: '0 14px',
|
||||
borderRadius: 8,
|
||||
border: 0,
|
||||
background: 'var(--ol-ink)',
|
||||
color: '#fff',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 500,
|
||||
cursor: 'default',
|
||||
transition: 'background 0.12s ease-out, transform 0.08s ease-out',
|
||||
}}
|
||||
>
|
||||
{t('shell.hotkeyModePrompt.openSettings')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FooterIconProps {
|
||||
name: string;
|
||||
tip: string;
|
||||
@@ -415,14 +556,23 @@ interface FooterIconProps {
|
||||
}
|
||||
|
||||
function FooterIcon({ name, tip, active, onClick }: FooterIconProps) {
|
||||
const [hover, setHover] = useState(false);
|
||||
// 选中(active)= popover 打开,深灰;hover = 浅灰;其它 = 透明
|
||||
const background = active
|
||||
? 'rgba(0,0,0,0.10)'
|
||||
: hover
|
||||
? 'rgba(0,0,0,0.05)'
|
||||
: 'transparent';
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
title={tip}
|
||||
style={{
|
||||
width: 30, height: 30, borderRadius: 7, border: 0,
|
||||
background: active ? 'rgba(0,0,0,0.06)' : 'transparent',
|
||||
color: active ? 'var(--ol-ink)' : 'var(--ol-ink-4)',
|
||||
background,
|
||||
color: active ? 'var(--ol-ink)' : hover ? 'var(--ol-ink-2)' : 'var(--ol-ink-4)',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'default',
|
||||
transition: 'background 0.12s ease-out, color 0.12s ease-out',
|
||||
@@ -431,3 +581,133 @@ function FooterIcon({ name, tip, active, onClick }: FooterIconProps) {
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 把 footer icon 和它的 popover 绑在同一个相对定位容器里,popover 锚定在按钮正上方。
|
||||
function FooterIconWithPopover({
|
||||
name, tip, open, onToggle, children,
|
||||
}: {
|
||||
name: string;
|
||||
tip: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div data-ol-footer-popover style={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<FooterIcon name={name} tip={tip} active={open} onClick={onToggle} />
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(100% + 8px)',
|
||||
left: 0,
|
||||
zIndex: 80,
|
||||
minWidth: 220,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
background: 'rgba(255,255,255,0.96)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
border: '0.5px solid rgba(0,0,0,0.08)',
|
||||
boxShadow: '0 18px 50px -22px rgba(15,17,22,0.32), 0 0 0 0.5px rgba(0,0,0,0.05)',
|
||||
animation: 'ol-popover-pop 0.18s cubic-bezier(0.32, 0.72, 0, 1) both',
|
||||
transformOrigin: 'bottom left',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpPopoverBody() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div style={{ minWidth: 240 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||
<img src="AppIcon.png" alt="" style={{ width: 26, height: 26, borderRadius: 6, boxShadow: '0 1px 2px rgba(0,0,0,.1), 0 0 0 0.5px rgba(0,0,0,.06)' }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ol-ink)' }}>OpenLess</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', fontFamily: 'var(--ol-font-mono)', marginTop: 1 }}>{APP_VERSION_LABEL}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-3)', lineHeight: 1.55, marginBottom: 10 }}>
|
||||
{t('shell.footer.helpPopover.tagline')}
|
||||
</div>
|
||||
<button onClick={() => openExternal(RELEASE_NOTES_URL)} style={popoverLinkStyle}>
|
||||
{t('shell.footer.helpPopover.releaseNotes')}
|
||||
</button>
|
||||
<button onClick={() => openExternal(HELP_DOCS_URL)} style={popoverLinkStyle}>
|
||||
{t('shell.footer.helpPopover.docs')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const popoverLinkStyle: CSSProperties = {
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
color: 'var(--ol-blue)',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
cursor: 'default',
|
||||
textAlign: 'left',
|
||||
padding: '6px 4px',
|
||||
};
|
||||
|
||||
// Footer 的"检查更新"按钮 — 复用 Settings 页面的 useAutoUpdate hook + UpdateDialog 窗口,
|
||||
// 跟"关于"section 走完全相同的状态机和确认对话框。按钮本身只显示触发文案 + 简短状态。
|
||||
function FooterAutoUpdateButton() {
|
||||
const { t } = useTranslation();
|
||||
const u = useAutoUpdate();
|
||||
|
||||
const inlineHint = u.status === 'none'
|
||||
? t('settings.about.upToDate')
|
||||
: u.status === 'error'
|
||||
? t('settings.about.updateError')
|
||||
: null;
|
||||
const inlineHintColor = u.status === 'error' ? 'var(--ol-err)' : 'var(--ol-ink-4)';
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={u.checkForUpdates}
|
||||
disabled={u.checking || u.busy}
|
||||
style={{
|
||||
color: 'var(--ol-blue)',
|
||||
marginLeft: 8,
|
||||
textDecoration: 'none',
|
||||
fontWeight: 500,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
cursor: 'default',
|
||||
padding: 0,
|
||||
opacity: u.checking || u.busy ? 0.7 : 1,
|
||||
transition: 'opacity 0.12s ease-out',
|
||||
}}
|
||||
>
|
||||
{u.checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn')}
|
||||
</button>
|
||||
{inlineHint && (
|
||||
<span style={{ marginLeft: 8, color: inlineHintColor, fontSize: 11 }}>{inlineHint}</span>
|
||||
)}
|
||||
{isDialogStatus(u.status) && (
|
||||
<UpdateDialog
|
||||
status={u.status}
|
||||
version={u.version}
|
||||
progress={u.progress}
|
||||
downloaded={u.downloaded}
|
||||
contentLength={u.contentLength}
|
||||
onInstall={u.installUpdate}
|
||||
onClose={u.dismissDialog}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from './Icon';
|
||||
import { AboutUpdateControl, Settings as SettingsContent, type SettingsSectionId } from '../pages/Settings';
|
||||
import { Row } from './ui/Row';
|
||||
import { readFontScale, setFontScale, type FontScaleId } from '../lib/fontScale';
|
||||
import { openExternal } from '../lib/ipc';
|
||||
import {
|
||||
FOLLOW_SYSTEM,
|
||||
@@ -186,11 +187,52 @@ function PersonalizeSection() {
|
||||
window.localStorage.setItem('ol.glassBlur', String(blur));
|
||||
}, [blur]);
|
||||
|
||||
const [fontScale, setFontScaleState] = useState<FontScaleId>(() => readFontScale());
|
||||
const applyFontScaleChoice = (next: FontScaleId) => {
|
||||
setFontScaleState(next);
|
||||
setFontScale(next);
|
||||
};
|
||||
const fontOptions: Array<[FontScaleId, string]> = [
|
||||
['small', t('modal.personalize.fontSmall')],
|
||||
['medium', t('modal.personalize.fontMedium')],
|
||||
['large', t('modal.personalize.fontLarge')],
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Row label={t('modal.personalize.language')}>
|
||||
<LanguagePicker />
|
||||
</Row>
|
||||
<Row label={t('modal.personalize.font')} desc={t('modal.personalize.fontDesc')}>
|
||||
<div style={{ display: 'flex', gap: 4, padding: 2, background: 'rgba(0,0,0,0.04)', borderRadius: 8 }}>
|
||||
{fontOptions.map(([id, label]) => {
|
||||
const selected = fontScale === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => applyFontScaleChoice(id)}
|
||||
style={{
|
||||
minWidth: 64,
|
||||
height: 28,
|
||||
border: 0,
|
||||
borderRadius: 6,
|
||||
background: selected ? '#fff' : 'transparent',
|
||||
color: selected ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12,
|
||||
fontWeight: selected ? 600 : 500,
|
||||
cursor: 'default',
|
||||
boxShadow: selected ? '0 1px 2px rgba(0,0,0,.06), 0 0 0 0.5px rgba(0,0,0,.06)' : 'none',
|
||||
transition: 'background 0.12s ease-out, color 0.12s ease-out, box-shadow 0.12s ease-out',
|
||||
padding: '0 12px',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Row>
|
||||
<Row label={t('modal.personalize.blur')} desc={t('modal.personalize.blurDesc')}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
<input
|
||||
|
||||
@@ -50,7 +50,11 @@ export const en: typeof zhCN = {
|
||||
settings: 'Settings',
|
||||
help: 'Help',
|
||||
version: 'Version {{version}}',
|
||||
checkUpdates: 'Check for updates',
|
||||
helpPopover: {
|
||||
tagline: 'Local-first voice input layer',
|
||||
releaseNotes: 'Release notes ↗',
|
||||
docs: 'Help center ↗',
|
||||
},
|
||||
},
|
||||
providerPrompt: {
|
||||
title: 'Set up speech providers',
|
||||
@@ -58,6 +62,12 @@ export const en: typeof zhCN = {
|
||||
later: 'Later',
|
||||
openSettings: 'Open Settings',
|
||||
},
|
||||
hotkeyModePrompt: {
|
||||
title: 'Review your recording mode',
|
||||
body: 'This version now defaults to Toggle. If you changed the hotkey trigger mode before, please open Recording settings and confirm it once. This update also adjusts how the hotkey mode preference is read; if you prefer push-to-talk, switch it back manually.',
|
||||
later: 'Remind me later',
|
||||
openSettings: 'Open Recording',
|
||||
},
|
||||
},
|
||||
onboarding: {
|
||||
welcome: 'Welcome to OpenLess',
|
||||
@@ -172,6 +182,8 @@ export const en: typeof zhCN = {
|
||||
modeDesc: 'Toggle = press once to start, again to stop. Push-to-talk = hold to record, release to stop.',
|
||||
modeToggle: 'Toggle',
|
||||
modeHold: 'Push-to-talk',
|
||||
migrationNoticeTitle: 'Default recording mode is now Toggle',
|
||||
migrationNoticeDesc: 'If you changed the hotkey trigger mode before, please confirm it here once. This update changes both the default value and the preference-reading path; if you prefer push-to-talk, switch it back manually.',
|
||||
capsuleLabel: 'Recording capsule',
|
||||
capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording / transcribing.',
|
||||
},
|
||||
@@ -315,6 +327,11 @@ export const en: typeof zhCN = {
|
||||
appearanceLight: 'Light',
|
||||
appearanceDark: 'Dark',
|
||||
language: 'Interface language',
|
||||
font: 'Font size',
|
||||
fontDesc: 'Scale the entire UI font size — applies instantly.',
|
||||
fontSmall: 'Small',
|
||||
fontMedium: 'Medium',
|
||||
fontLarge: 'Large',
|
||||
blur: 'Glass blur intensity',
|
||||
blurDesc: 'Affects the inner backdrop-filter strength (the macOS system frosted layer can not be tuned at runtime).',
|
||||
startupOpen: 'On launch',
|
||||
|
||||
@@ -48,7 +48,11 @@ export const zhCN = {
|
||||
settings: '设置',
|
||||
help: '帮助',
|
||||
version: '版本 {{version}}',
|
||||
checkUpdates: '检查更新',
|
||||
helpPopover: {
|
||||
tagline: '本地驱动的语音输入层',
|
||||
releaseNotes: '查看发布日志 ↗',
|
||||
docs: '帮助中心 ↗',
|
||||
},
|
||||
},
|
||||
providerPrompt: {
|
||||
title: '设置语音提供商',
|
||||
@@ -56,6 +60,12 @@ export const zhCN = {
|
||||
later: '稍后',
|
||||
openSettings: '去设置',
|
||||
},
|
||||
hotkeyModePrompt: {
|
||||
title: '检查录音方式',
|
||||
body: '本版本默认改为“切换式说话”。如果你之前改过快捷键触发方式,请到“录音”里手动确认一次。本次更新同时调整了快捷键方式的读取逻辑;如果你更习惯按住说话,可以重新切回“按住说话”。',
|
||||
later: '稍后提醒',
|
||||
openSettings: '去录音设置',
|
||||
},
|
||||
},
|
||||
onboarding: {
|
||||
welcome: '欢迎使用 OpenLess',
|
||||
@@ -170,6 +180,8 @@ export const zhCN = {
|
||||
modeDesc: '切换式 = 按一次开始、再按一次结束;按住说话 = 按住开始、松开结束。',
|
||||
modeToggle: '切换式',
|
||||
modeHold: '按住说话',
|
||||
migrationNoticeTitle: '默认已改为切换式说话',
|
||||
migrationNoticeDesc: '如果你之前改过快捷键触发方式,请在这里手动确认一次。本次更新调整了快捷键方式的默认值与读取逻辑;如果你更习惯按住说话,可以重新切回“按住说话”。',
|
||||
capsuleLabel: '录音胶囊',
|
||||
capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。',
|
||||
},
|
||||
@@ -313,6 +325,11 @@ export const zhCN = {
|
||||
appearanceLight: '浅色',
|
||||
appearanceDark: '深色',
|
||||
language: '界面语言',
|
||||
font: '字体大小',
|
||||
fontDesc: '整体缩放界面字号,立即生效。',
|
||||
fontSmall: '小',
|
||||
fontMedium: '中',
|
||||
fontLarge: '大',
|
||||
blur: '毛玻璃强度',
|
||||
blurDesc: '影响窗口内层 backdrop-filter 强度(macOS 系统磨砂层无法运行时调)。',
|
||||
startupOpen: '启动时打开',
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// 全局字体大小档位 — 通过 documentElement.style.zoom 整体缩放(WebKit/Tauri 支持)。
|
||||
// localStorage 是单一事实源;窗口启动时读一次应用,Settings 的"个性化"里改了就直接覆盖。
|
||||
|
||||
export type FontScaleId = 'small' | 'medium' | 'large';
|
||||
|
||||
export const FONT_SCALE_VALUES: Record<FontScaleId, number> = {
|
||||
small: 0.9,
|
||||
medium: 1.0,
|
||||
large: 1.1,
|
||||
};
|
||||
|
||||
const FONT_SCALE_KEY = 'ol-font-scale';
|
||||
|
||||
export function readFontScale(): FontScaleId {
|
||||
try {
|
||||
const v = window.localStorage.getItem(FONT_SCALE_KEY);
|
||||
if (v === 'small' || v === 'medium' || v === 'large') return v;
|
||||
} catch { /* localStorage 不可用:忽略,落回默认 */ }
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
export function applyFontScale(id: FontScaleId): void {
|
||||
const scale = FONT_SCALE_VALUES[id];
|
||||
// CSS zoom 不在 W3C 标准里但 WebKit/Blink 都支持;Tauri 桌面端走 Wry/WebKit,没问题。
|
||||
(document.documentElement.style as CSSStyleDeclaration & { zoom?: string }).zoom = String(scale);
|
||||
}
|
||||
|
||||
export function setFontScale(id: FontScaleId): void {
|
||||
applyFontScale(id);
|
||||
try { window.localStorage.setItem(FONT_SCALE_KEY, id); } catch { /* 忽略 */ }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { APP_VERSION } from './appVersion';
|
||||
|
||||
export const HOTKEY_MODE_MIGRATION_NOTICE_VERSION = '1.2.7';
|
||||
export const HOTKEY_MODE_MIGRATION_ACK_KEY = `ol.hotkeyModeMigrationAck:${HOTKEY_MODE_MIGRATION_NOTICE_VERSION}`;
|
||||
export const HOTKEY_MODE_MIGRATION_DEFERRED_KEY = `ol.hotkeyModeMigrationDeferred:${HOTKEY_MODE_MIGRATION_NOTICE_VERSION}`;
|
||||
|
||||
export function isHotkeyModeMigrationNoticeActive(): boolean {
|
||||
return APP_VERSION === HOTKEY_MODE_MIGRATION_NOTICE_VERSION;
|
||||
}
|
||||
|
||||
export function shouldShowHotkeyModeMigrationPrompt(
|
||||
acknowledgedValue: string | null,
|
||||
deferredValue: string | null,
|
||||
): boolean {
|
||||
return isHotkeyModeMigrationNoticeActive() && acknowledgedValue !== '1' && deferredValue !== '1';
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export async function invokeOrMock<T>(
|
||||
|
||||
// ── Mock fixtures ──────────────────────────────────────────────────────
|
||||
const mockSettings: UserPreferences = {
|
||||
hotkey: { trigger: 'rightControl', mode: 'hold' },
|
||||
hotkey: { trigger: 'rightControl', mode: 'toggle' },
|
||||
defaultMode: 'structured',
|
||||
enabledModes: ['raw', 'light', 'structured', 'formal'],
|
||||
launchAtLogin: false,
|
||||
@@ -52,7 +52,7 @@ const mockHotkeyCapability: HotkeyCapability = {
|
||||
supportsModifierOnlyTrigger: true,
|
||||
supportsSideSpecificModifiers: true,
|
||||
explicitFallbackAvailable: false,
|
||||
statusHint: '默认建议使用“右 Control + 按住说话”;若无响应,可在权限页查看 hook 安装状态。',
|
||||
statusHint: '默认建议使用“右 Control + 切换式说话”;若更习惯按住说话,可在录音设置里切回。若无响应,可在权限页查看 hook 安装状态。',
|
||||
};
|
||||
|
||||
const mockCredentialsStatus: CredentialsStatus = {
|
||||
|
||||
@@ -3,22 +3,21 @@
|
||||
// keep their inline-style literals 1:1 with the source JSX.
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import type { Update, DownloadEvent } from '@tauri-apps/plugin-updater';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoUpdate';
|
||||
import { APP_VERSION_LABEL } from '../lib/appVersion';
|
||||
import { isHotkeyModeMigrationNoticeActive } from '../lib/hotkeyMigration';
|
||||
import { getHotkeyStartStopLabel, getHotkeyTriggerLabel } from '../lib/hotkey';
|
||||
import {
|
||||
checkAccessibilityPermission,
|
||||
checkMicrophonePermission,
|
||||
getHotkeyStatus,
|
||||
isTauri,
|
||||
openExternal,
|
||||
openSystemSettings,
|
||||
readCredential,
|
||||
requestAccessibilityPermission,
|
||||
requestMicrophonePermission,
|
||||
restartApp,
|
||||
setActiveAsrProvider,
|
||||
setActiveLlmProvider,
|
||||
setCredential,
|
||||
@@ -146,6 +145,25 @@ function RecordingSection() {
|
||||
<Card>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('settings.recording.title')}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 6 }}>{t('settings.recording.desc')}</div>
|
||||
{isHotkeyModeMigrationNoticeActive() && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
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>
|
||||
)}
|
||||
<SettingRow label={t('settings.recording.hotkeyLabel')} desc={hotkeyDesc}>
|
||||
<select
|
||||
value={prefs.hotkey.trigger}
|
||||
@@ -791,190 +809,37 @@ function AboutSection() {
|
||||
);
|
||||
}
|
||||
|
||||
type UpdateStatus = 'idle' | 'checking' | 'available' | 'none' | 'downloading' | 'installing' | 'downloaded' | 'error';
|
||||
|
||||
export function AboutUpdateControl({ tagline }: { tagline: string }) {
|
||||
const { t } = useTranslation();
|
||||
const updateRef = useRef<Update | null>(null);
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
|
||||
const [version, setVersion] = useState('');
|
||||
const [downloaded, setDownloaded] = useState(0);
|
||||
const [contentLength, setContentLength] = useState<number | null>(null);
|
||||
|
||||
const checking = updateStatus === 'checking';
|
||||
const busy = updateStatus === 'downloading' || updateStatus === 'installing';
|
||||
const progress = contentLength && contentLength > 0 ? Math.min(100, Math.round((downloaded / contentLength) * 100)) : null;
|
||||
|
||||
const closeUpdate = async () => {
|
||||
const current = updateRef.current;
|
||||
updateRef.current = null;
|
||||
if (current) {
|
||||
try {
|
||||
await current.close();
|
||||
} catch (error) {
|
||||
console.warn('[settings] failed to close update resource', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
void closeUpdate();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resetProgress = () => {
|
||||
setDownloaded(0);
|
||||
setContentLength(null);
|
||||
};
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
setUpdateStatus('checking');
|
||||
setVersion('');
|
||||
resetProgress();
|
||||
await closeUpdate();
|
||||
try {
|
||||
if (!isTauri) {
|
||||
setUpdateStatus('none');
|
||||
return;
|
||||
}
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
const next = await check();
|
||||
updateRef.current = next;
|
||||
setVersion(next?.version ?? '');
|
||||
setUpdateStatus(next ? 'available' : 'none');
|
||||
} catch (error) {
|
||||
console.error('[settings] failed to check update', error);
|
||||
setUpdateStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const installUpdate = async () => {
|
||||
const update = updateRef.current;
|
||||
if (!update) return;
|
||||
resetProgress();
|
||||
setUpdateStatus('downloading');
|
||||
try {
|
||||
await update.download((event: DownloadEvent) => {
|
||||
if (event.event === 'Started') {
|
||||
resetProgress();
|
||||
setContentLength(event.data.contentLength ?? null);
|
||||
} else if (event.event === 'Progress') {
|
||||
setDownloaded(value => value + event.data.chunkLength);
|
||||
} else if (event.event === 'Finished') {
|
||||
setUpdateStatus('installing');
|
||||
}
|
||||
});
|
||||
setUpdateStatus('installing');
|
||||
await update.install();
|
||||
await closeUpdate();
|
||||
setUpdateStatus('downloaded');
|
||||
} catch (error) {
|
||||
console.error('[settings] failed to install update', error);
|
||||
await closeUpdate();
|
||||
setUpdateStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const dismissUpdateDialog = async () => {
|
||||
if (busy) return;
|
||||
await closeUpdate();
|
||||
setUpdateStatus('idle');
|
||||
setVersion('');
|
||||
resetProgress();
|
||||
};
|
||||
|
||||
const u = useAutoUpdate();
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>{tagline} · {APP_VERSION_LABEL}</span>
|
||||
<Btn variant="ghost" size="sm" onClick={checkForUpdates} disabled={checking || busy}>
|
||||
{checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn')}
|
||||
<Btn variant="ghost" size="sm" onClick={u.checkForUpdates} disabled={u.checking || u.busy}>
|
||||
{u.checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn')}
|
||||
</Btn>
|
||||
</div>
|
||||
{(updateStatus === 'none' || updateStatus === 'error') && (
|
||||
<div style={{ fontSize: 11, color: updateStatus === 'error' ? 'var(--ol-err)' : 'var(--ol-ink-4)', marginTop: 4 }}>
|
||||
{updateStatus === 'none' ? t('settings.about.upToDate') : t('settings.about.updateError')}
|
||||
{(u.status === 'none' || u.status === 'error') && (
|
||||
<div style={{ fontSize: 11, color: u.status === 'error' ? 'var(--ol-err)' : 'var(--ol-ink-4)', marginTop: 4 }}>
|
||||
{u.status === 'none' ? t('settings.about.upToDate') : t('settings.about.updateError')}
|
||||
</div>
|
||||
)}
|
||||
{(updateStatus === 'available' || updateStatus === 'downloading' || updateStatus === 'installing' || updateStatus === 'downloaded') && (
|
||||
{isDialogStatus(u.status) && (
|
||||
<UpdateDialog
|
||||
status={updateStatus}
|
||||
version={version}
|
||||
progress={progress}
|
||||
downloaded={downloaded}
|
||||
contentLength={contentLength}
|
||||
onInstall={installUpdate}
|
||||
onRestart={restartApp}
|
||||
onClose={dismissUpdateDialog}
|
||||
status={u.status}
|
||||
version={u.version}
|
||||
progress={u.progress}
|
||||
downloaded={u.downloaded}
|
||||
contentLength={u.contentLength}
|
||||
onInstall={u.installUpdate}
|
||||
onClose={u.dismissDialog}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateDialog({
|
||||
status,
|
||||
version,
|
||||
progress,
|
||||
downloaded,
|
||||
contentLength,
|
||||
onInstall,
|
||||
onRestart,
|
||||
onClose,
|
||||
}: {
|
||||
status: 'available' | 'downloading' | 'installing' | 'downloaded';
|
||||
version: string;
|
||||
progress: number | null;
|
||||
downloaded: number;
|
||||
contentLength: number | null;
|
||||
onInstall: () => void;
|
||||
onRestart: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const downloading = status === 'downloading';
|
||||
const installing = status === 'installing';
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.18)', display: 'grid', placeItems: 'center', zIndex: 40 }}>
|
||||
<div style={{ width: 360, borderRadius: 16, background: 'var(--ol-surface)', border: '0.5px solid var(--ol-line-strong)', boxShadow: '0 18px 42px rgba(0,0,0,0.18)', padding: 18 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 650, marginBottom: 8 }}>{t(`settings.about.updateDialog.${status}.title`)}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6, marginBottom: 14 }}>
|
||||
{t(`settings.about.updateDialog.${status}.desc`, { version })}
|
||||
</div>
|
||||
{(downloading || installing || status === 'downloaded') && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ height: 8, borderRadius: 999, background: 'var(--ol-surface-2)', overflow: 'hidden', border: '0.5px solid var(--ol-line)' }}>
|
||||
<div style={{ height: '100%', width: `${status === 'downloaded' || installing ? 100 : progress ?? 8}%`, background: 'var(--ol-blue)', transition: 'width 0.18s ease-out' }} />
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--ol-ink-4)' }}>
|
||||
{installing
|
||||
? t('settings.about.updateDialog.installingLabel')
|
||||
: progress === null
|
||||
? t('settings.about.updateDialog.progressUnknown', { downloaded: formatBytes(downloaded) })
|
||||
: t('settings.about.updateDialog.progress', { progress, downloaded: formatBytes(downloaded), total: formatBytes(contentLength ?? 0) })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
{status === 'available' && <Btn size="sm" onClick={onClose}>{t('common.cancel')}</Btn>}
|
||||
{status === 'available' && <Btn variant="blue" size="sm" onClick={onInstall}>{t('settings.about.updateDialog.install')}</Btn>}
|
||||
{(downloading || installing) && <Btn size="sm" disabled>{installing ? t('settings.about.updateDialog.installingLabel') : t('settings.about.updateDialog.downloadingLabel')}</Btn>}
|
||||
{status === 'downloaded' && <Btn size="sm" onClick={onClose}>{t('settings.about.updateDialog.later')}</Btn>}
|
||||
{status === 'downloaded' && <Btn variant="blue" size="sm" onClick={onRestart}>{t('settings.about.updateDialog.restartNow')}</Btn>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return '0 B';
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function HotkeyStatusPill({ status }: { status: HotkeyStatus | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (!status) {
|
||||
|
||||
Reference in new issue
Block a user