fix(ui+network): 更新/登录/网络检测/市场加载四项修复
1. 更新检查:超时从 8s 提至 15s,error 态展示具体错误原因 + 重试按钮 2. GitHub 登录:device flow 两个 HTTP 请求加 15s timeout,防网络差时永远卡住 3. 网络连通性:新增 check_network 命令(HEAD apic.openless.top + github.com), PermissionsSection 从硬编码"可用"改为真实探测 + 30s 轮询 + 离线重试 4. Marketplace:加载失败卡片增加重试按钮 5. i18n:五语言新增 networkOffline / settings.about.retryBtn Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
ce6818cc6f
commit
c729fc59e9
12 files changed
+135
-10
No files matched your search
@@ -474,6 +474,38 @@ async fn resolve_beta_manifest_endpoints() -> Result<Vec<url::Url>, String> {
|
||||
Ok(vec![mirror_url, direct_url])
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkCheckResult {
|
||||
pub online: bool,
|
||||
pub latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_network() -> NetworkCheckResult {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build();
|
||||
let client = match client {
|
||||
Ok(c) => c,
|
||||
Err(_) => return NetworkCheckResult { online: false, latency_ms: None },
|
||||
};
|
||||
let start = std::time::Instant::now();
|
||||
let endpoints = [
|
||||
"https://apic.openless.top/health",
|
||||
"https://github.com",
|
||||
];
|
||||
for url in &endpoints {
|
||||
if let Ok(resp) = client.head(*url).send().await {
|
||||
if resp.status().is_success() || resp.status().is_redirection() {
|
||||
let ms = start.elapsed().as_millis() as u64;
|
||||
return NetworkCheckResult { online: true, latency_ms: Some(ms) };
|
||||
}
|
||||
}
|
||||
}
|
||||
NetworkCheckResult { online: false, latency_ms: None }
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_hotkey_status(coord: CoordinatorState<'_>) -> HotkeyStatus {
|
||||
coord.hotkey_status()
|
||||
@@ -2812,7 +2844,10 @@ pub struct GithubDeviceStartResponse {
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_start() -> Result<GithubDeviceStartResponse, String> {
|
||||
let client_id = get_github_oauth_client_id()?;
|
||||
let client = reqwest::Client::new();
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("build http client: {e}"))?;
|
||||
let resp = client
|
||||
.post("https://github.com/login/device/code")
|
||||
.header("Accept", "application/json")
|
||||
@@ -2857,7 +2892,10 @@ pub async fn github_device_flow_poll(
|
||||
device_code: String,
|
||||
) -> Result<GithubDevicePollResult, String> {
|
||||
let client_id = get_github_oauth_client_id()?;
|
||||
let client = reqwest::Client::new();
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("build http client: {e}"))?;
|
||||
let token_resp = client
|
||||
.post("https://github.com/login/oauth/access_token")
|
||||
.header("Accept", "application/json")
|
||||
|
||||
@@ -279,6 +279,7 @@ pub fn run() {
|
||||
commands::set_update_channel,
|
||||
commands::fetch_latest_beta_release,
|
||||
commands::app_check_update_with_channel,
|
||||
commands::check_network,
|
||||
commands::get_hotkey_status,
|
||||
commands::get_hotkey_capability,
|
||||
commands::set_shortcut_recording_active,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { isTauri, restartApp } from '../lib/ipc';
|
||||
import { Btn } from '../pages/_atoms';
|
||||
|
||||
const UPDATE_CHECK_TIMEOUT_MS = 8_000; // 缩短超时,让镜像站慢的情况能更快 fallback
|
||||
const UPDATE_CHECK_TIMEOUT_MS = 15_000;
|
||||
|
||||
interface AppUpdateMetadata {
|
||||
rid: number;
|
||||
@@ -44,6 +44,7 @@ export interface UseAutoUpdate {
|
||||
contentLength: number | null;
|
||||
checking: boolean;
|
||||
busy: boolean;
|
||||
errorMessage: string | null;
|
||||
/** 触发"检查更新"。如果发现新版本,状态变为 'available',需要 caller 渲染对话框让用户确认下载。 */
|
||||
checkForUpdates: () => Promise<void>;
|
||||
/** 用户在对话框里确认 → 下载 + 安装。完成后状态变为 'downloaded',等用户点重启。 */
|
||||
@@ -58,6 +59,7 @@ export function useAutoUpdate(): UseAutoUpdate {
|
||||
const [version, setVersion] = useState('');
|
||||
const [downloaded, setDownloaded] = useState(0);
|
||||
const [contentLength, setContentLength] = useState<number | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const checking = status === 'checking';
|
||||
const busy = status === 'downloading' || status === 'installing';
|
||||
@@ -89,6 +91,7 @@ export function useAutoUpdate(): UseAutoUpdate {
|
||||
const checkForUpdates = async () => {
|
||||
setStatus('checking');
|
||||
setVersion('');
|
||||
setErrorMessage(null);
|
||||
resetProgress();
|
||||
await closeUpdate();
|
||||
try {
|
||||
@@ -120,6 +123,8 @@ export function useAutoUpdate(): UseAutoUpdate {
|
||||
setStatus('available');
|
||||
} catch (error) {
|
||||
console.error('[updater] failed to check update', error);
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(msg);
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
@@ -146,6 +151,8 @@ export function useAutoUpdate(): UseAutoUpdate {
|
||||
setStatus('downloaded');
|
||||
} catch (error) {
|
||||
console.error('[updater] failed to install update', error);
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(msg);
|
||||
await closeUpdate();
|
||||
setStatus('error');
|
||||
}
|
||||
@@ -167,6 +174,7 @@ export function useAutoUpdate(): UseAutoUpdate {
|
||||
contentLength,
|
||||
checking,
|
||||
busy,
|
||||
errorMessage,
|
||||
checkForUpdates,
|
||||
installUpdate,
|
||||
dismissDialog,
|
||||
|
||||
@@ -682,6 +682,7 @@ export const en: typeof zhCN = {
|
||||
networkLabel: 'Network',
|
||||
networkDesc: 'Required for cloud ASR / LLM calls. Disable for local-only mode.',
|
||||
networkOk: 'Available',
|
||||
networkOffline: 'Unavailable',
|
||||
checking: 'Checking…',
|
||||
granted: 'Granted',
|
||||
notApplicable: 'Not required',
|
||||
@@ -753,6 +754,7 @@ export const en: typeof zhCN = {
|
||||
checkingUpdate: 'Checking…',
|
||||
upToDate: 'You are already on the latest version.',
|
||||
updateError: 'Update check or install failed. Please try again later.',
|
||||
retryBtn: 'Retry',
|
||||
openReleases: 'Open Releases',
|
||||
source: 'Source',
|
||||
docs: 'Docs',
|
||||
|
||||
@@ -684,6 +684,7 @@ export const ja: typeof zhCN = {
|
||||
networkLabel: 'ネットワーク',
|
||||
networkDesc: 'クラウド ASR / LLM 呼び出しに必要。ローカルモードでは無効化可能。',
|
||||
networkOk: '利用可能',
|
||||
networkOffline: '利用不可',
|
||||
checking: '確認中…',
|
||||
granted: '許可済み',
|
||||
notApplicable: '権限不要',
|
||||
@@ -755,6 +756,7 @@ export const ja: typeof zhCN = {
|
||||
checkingUpdate: '確認中…',
|
||||
upToDate: '現在最新バージョンです。',
|
||||
updateError: '確認またはアップデートに失敗しました。後で再試行してください。',
|
||||
retryBtn: '再試行',
|
||||
openReleases: 'Releases を開く',
|
||||
source: 'ソース',
|
||||
docs: 'ドキュメント',
|
||||
|
||||
@@ -684,6 +684,7 @@ export const ko: typeof zhCN = {
|
||||
networkLabel: '네트워크',
|
||||
networkDesc: '클라우드 ASR / LLM 호출에 필요. 로컬 모드에서는 비활성화 가능.',
|
||||
networkOk: '사용 가능',
|
||||
networkOffline: '사용 불가',
|
||||
checking: '확인 중…',
|
||||
granted: '허용됨',
|
||||
notApplicable: '권한 불필요',
|
||||
@@ -755,6 +756,7 @@ export const ko: typeof zhCN = {
|
||||
checkingUpdate: '확인 중…',
|
||||
upToDate: '현재 최신 버전입니다.',
|
||||
updateError: '확인 또는 업데이트에 실패했습니다. 잠시 후 다시 시도하세요.',
|
||||
retryBtn: '다시 시도',
|
||||
openReleases: 'Releases 열기',
|
||||
source: '소스',
|
||||
docs: '문서',
|
||||
|
||||
@@ -680,6 +680,7 @@ export const zhCN = {
|
||||
networkLabel: '网络',
|
||||
networkDesc: '云端 ASR / LLM 必需,本地模式可关。',
|
||||
networkOk: '可用',
|
||||
networkOffline: '不可用',
|
||||
checking: '检查中…',
|
||||
granted: '已授权',
|
||||
notApplicable: '无需授权',
|
||||
@@ -751,6 +752,7 @@ export const zhCN = {
|
||||
checkingUpdate: '检查中…',
|
||||
upToDate: '当前已是最新版本。',
|
||||
updateError: '检查或更新失败,请稍后重试。',
|
||||
retryBtn: '重试',
|
||||
openReleases: '打开 Releases',
|
||||
source: '源码',
|
||||
docs: '文档',
|
||||
|
||||
@@ -682,6 +682,7 @@ export const zhTW: typeof zhCN = {
|
||||
networkLabel: '網絡',
|
||||
networkDesc: '雲端 ASR / LLM 調用所必需。本地模式可關閉。',
|
||||
networkOk: '可用',
|
||||
networkOffline: '不可用',
|
||||
checking: '檢查中…',
|
||||
granted: '已授權',
|
||||
notApplicable: '無需授權',
|
||||
@@ -753,6 +754,7 @@ export const zhTW: typeof zhCN = {
|
||||
checkingUpdate: '檢查中…',
|
||||
upToDate: '當前已是最新版本。',
|
||||
updateError: '檢查或更新失敗,請稍後重試。',
|
||||
retryBtn: '重試',
|
||||
openReleases: '打開 Releases',
|
||||
source: '源碼',
|
||||
docs: '文檔',
|
||||
|
||||
@@ -511,6 +511,18 @@ export function getWindowsImeStatus(): Promise<WindowsImeStatus> {
|
||||
return invokeOrMock('get_windows_ime_status', undefined, () => mockWindowsImeStatus);
|
||||
}
|
||||
|
||||
export interface NetworkCheckResult {
|
||||
online: boolean;
|
||||
latencyMs: number | null;
|
||||
}
|
||||
|
||||
export function checkNetwork(): Promise<NetworkCheckResult> {
|
||||
return invokeOrMock<NetworkCheckResult>('check_network', undefined, () => ({
|
||||
online: true,
|
||||
latencyMs: 42,
|
||||
}));
|
||||
}
|
||||
|
||||
export function listMicrophoneDevices(): Promise<MicrophoneDevice[]> {
|
||||
return invokeOrMock('list_microphone_devices', undefined, () => mockMicrophoneDevices);
|
||||
}
|
||||
|
||||
@@ -580,8 +580,13 @@ export function Marketplace() {
|
||||
|
||||
{loadError && (
|
||||
<Card padding={16} style={{ marginBottom: 12, borderColor: 'var(--ol-err)' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-err)' }}>
|
||||
{t('marketplace.loadFailed', { err: loadError })}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-err)', flex: 1, wordBreak: 'break-word' }}>
|
||||
{t('marketplace.loadFailed', { err: loadError })}
|
||||
</div>
|
||||
<Btn variant="blue" size="sm" onClick={() => void refresh()}>
|
||||
{t('common.retry') ?? '重试'}
|
||||
</Btn>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -17,9 +17,24 @@ export function AboutUpdateControl({ tagline }: { tagline: string }) {
|
||||
{u.checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn')}
|
||||
</Btn>
|
||||
</div>
|
||||
{(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')}
|
||||
{u.status === 'none' && (
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 4 }}>
|
||||
{t('settings.about.upToDate')}
|
||||
</div>
|
||||
)}
|
||||
{u.status === 'error' && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-err)' }}>
|
||||
{t('settings.about.updateError')}
|
||||
</div>
|
||||
{u.errorMessage && (
|
||||
<div style={{ fontSize: 10.5, color: 'var(--ol-ink-4)', marginTop: 2, wordBreak: 'break-word' }}>
|
||||
{u.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
<Btn variant="ghost" size="sm" onClick={u.checkForUpdates} style={{ marginTop: 4 }}>
|
||||
{t('settings.about.retryBtn') ?? t('common.retry') ?? '重试'}
|
||||
</Btn>
|
||||
</div>
|
||||
)}
|
||||
{isDialogStatus(u.status) && (
|
||||
|
||||
@@ -7,12 +7,14 @@ import { Icon } from '../../components/Icon';
|
||||
import {
|
||||
checkAccessibilityPermission,
|
||||
checkMicrophonePermission,
|
||||
checkNetwork,
|
||||
getHotkeyStatus,
|
||||
getWindowsImeStatus,
|
||||
openSystemSettings,
|
||||
requestAccessibilityPermission,
|
||||
requestMicrophonePermission,
|
||||
} from '../../lib/ipc';
|
||||
import type { NetworkCheckResult } from '../../lib/ipc';
|
||||
import type {
|
||||
HotkeyCapability,
|
||||
HotkeyStatus,
|
||||
@@ -30,6 +32,7 @@ export function PermissionsSection() {
|
||||
const [microphone, setMicrophone] = useState<PermissionStatus | 'loading'>('loading');
|
||||
const [hotkey, setHotkey] = useState<HotkeyStatus | null>(null);
|
||||
const [windowsIme, setWindowsIme] = useState<WindowsImeStatus | null>(null);
|
||||
const [network, setNetwork] = useState<NetworkCheckResult | null>(null);
|
||||
const { capability } = useHotkeySettings();
|
||||
|
||||
const refreshPermissions = async () => {
|
||||
@@ -49,22 +52,34 @@ export function PermissionsSection() {
|
||||
setWindowsIme(await getWindowsImeStatus());
|
||||
};
|
||||
|
||||
const refreshNetwork = async () => {
|
||||
try {
|
||||
setNetwork(await checkNetwork());
|
||||
} catch {
|
||||
setNetwork({ online: false, latencyMs: null });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshPermissions();
|
||||
refreshHotkey();
|
||||
refreshWindowsIme();
|
||||
refreshNetwork();
|
||||
const hotkeyId = window.setInterval(refreshHotkey, 1000);
|
||||
// 麦克风检查会短暂打开输入流,避免每秒探测导致隐私指示器频繁闪烁。
|
||||
const permissionId = window.setInterval(refreshPermissions, 10000);
|
||||
const networkId = window.setInterval(refreshNetwork, 30000);
|
||||
const onFocus = () => {
|
||||
refreshPermissions();
|
||||
refreshHotkey();
|
||||
refreshWindowsIme();
|
||||
refreshNetwork();
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => {
|
||||
window.clearInterval(hotkeyId);
|
||||
window.clearInterval(permissionId);
|
||||
window.clearInterval(networkId);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, []);
|
||||
@@ -157,8 +172,18 @@ export function PermissionsSection() {
|
||||
</SettingRow>
|
||||
)}
|
||||
<SettingRow label={t('settings.permissions.networkLabel')} desc={t('settings.permissions.networkDesc')}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
|
||||
<Pill tone="ok"><Icon name="check" size={11} />{t('settings.permissions.networkOk')}</Pill>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%' }}>
|
||||
{network && network.latencyMs != null && (
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
|
||||
{network.latencyMs}ms
|
||||
</span>
|
||||
)}
|
||||
<NetworkStatusPill status={network} />
|
||||
{network && !network.online && (
|
||||
<Btn variant="ghost" size="sm" onClick={refreshNetwork}>
|
||||
{t('common.retry') ?? '重试'}
|
||||
</Btn>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</Card>
|
||||
@@ -207,6 +232,17 @@ function WindowsImeStatusPill({ status }: { status: WindowsImeStatus | null }) {
|
||||
return <Pill tone="outline">{t('settings.permissions.windowsImeUnavailable')}</Pill>;
|
||||
}
|
||||
|
||||
function NetworkStatusPill({ status }: { status: NetworkCheckResult | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (!status) {
|
||||
return <Pill tone="default">{t('settings.permissions.checking')}</Pill>;
|
||||
}
|
||||
if (status.online) {
|
||||
return <Pill tone="ok"><Icon name="check" size={11} />{t('settings.permissions.networkOk')}</Pill>;
|
||||
}
|
||||
return <Pill tone="outline">{t('settings.permissions.networkOffline') ?? '不可用'}</Pill>;
|
||||
}
|
||||
|
||||
function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus['adapter']) {
|
||||
if (adapter === 'macEventTap') return i18n.t('hotkey.adapter.macEventTap');
|
||||
if (adapter === 'windowsLowLevel') return i18n.t('hotkey.adapter.windowsLowLevel');
|
||||
|
||||
Reference in new issue
Block a user