From 5113eaf245c60ea86ce7b1a484ed3bbdd8ebacfa Mon Sep 17 00:00:00 2001 From: Chris233 Date: Wed, 9 Sep 2026 14:57:35 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(providers):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=AD=97=E6=AE=B5=E3=80=81=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E4=B8=8E=E5=BC=82=E6=AD=A5=E4=BF=9D=E5=AD=98=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=20(#1044)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(providers): unify model fields and isolate asynchronous updates * fix(providers): preserve pending credential drafts --- .../openless-core/src/provider_rules.rs | 13 + .../app/src/components/SettingsModal.tsx | 15 +- .../app/src/components/ui/SelectLite.tsx | 8 +- .../app/src/lib/ipc/asr-credentials.ts | 8 +- .../lib/ipc/mock-provider-descriptors.json | 48 ++ openless-all/app/src/lib/ipc/providers.ts | 23 +- .../app/src/pages/settings/ChannelList.tsx | 16 +- .../pages/settings/LlmProtocolFields.test.ts | 6 +- .../src/pages/settings/LlmProtocolFields.tsx | 21 +- .../app/src/pages/settings/ProviderForm.tsx | 46 ++ .../pages/settings/ProvidersSection.test.ts | 18 +- .../src/pages/settings/ProvidersSection.tsx | 745 +++++------------- .../pages/settings/credentialDraft.test.ts | 91 +++ .../app/src/pages/settings/credentialDraft.ts | 64 ++ .../src/pages/settings/modelCatalog.test.ts | 47 ++ .../app/src/pages/settings/modelCatalog.ts | 23 + 16 files changed, 590 insertions(+), 602 deletions(-) create mode 100644 openless-all/app/src/lib/ipc/mock-provider-descriptors.json create mode 100644 openless-all/app/src/pages/settings/ProviderForm.tsx create mode 100644 openless-all/app/src/pages/settings/credentialDraft.test.ts create mode 100644 openless-all/app/src/pages/settings/credentialDraft.ts create mode 100644 openless-all/app/src/pages/settings/modelCatalog.test.ts create mode 100644 openless-all/app/src/pages/settings/modelCatalog.ts diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index 50aad94a..a2efeb6a 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -1084,6 +1084,19 @@ mod tests { } } + #[test] + fn browser_provider_descriptor_fixture_matches_core() { + let fixture: Vec = serde_json::from_str(include_str!( + "../../../src/lib/ipc/mock-provider-descriptors.json" + )) + .unwrap(); + let actual = [ProviderKind::Llm, ProviderKind::Asr, ProviderKind::Omni] + .into_iter() + .flat_map(provider_descriptors) + .collect::>(); + assert_eq!(fixture, actual); + } + #[test] fn secret_like_volc_resource_ids_are_not_attributed() { assert_eq!( diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index e8cf8b8c..e36e08e4 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -8,6 +8,7 @@ // // 设计原则:每个可见控件都必须可用。 +import { ProviderLeaveContext, useProviderForm } from '../pages/settings/ProviderForm'; import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from './Icon'; @@ -57,6 +58,8 @@ const LINK_ITEMS: ModalNavItem[] = [ ]; export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) { + const providerForm = useProviderForm(); + const close = () => { void providerForm.finish(onClose); }; const { t } = useTranslation(); const mobile = useMobileLayout(); const conservative = useConservativeLayout(); @@ -78,8 +81,9 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett }, [section, mobile]); return ( +
+
); } diff --git a/openless-all/app/src/components/ui/SelectLite.tsx b/openless-all/app/src/components/ui/SelectLite.tsx index 01bb432c..a502c96f 100644 --- a/openless-all/app/src/components/ui/SelectLite.tsx +++ b/openless-all/app/src/components/ui/SelectLite.tsx @@ -35,6 +35,8 @@ export interface SelectOption { value: string; label: string; disabled?: boolean; + /** 搜索期间仍保留操作项,例如自定义输入入口。 */ + alwaysVisible?: boolean; /** 可选:渲染在选项标签右侧、勾选标记左侧(如麦克风音量条)。 */ trailing?: ReactNode; } @@ -111,7 +113,7 @@ export function SelectLite({ const needle = query.trim().toLocaleLowerCase(); if (!needle) return options; return options.filter(option => ( - option.label.toLocaleLowerCase().includes(needle) + option.alwaysVisible || option.label.toLocaleLowerCase().includes(needle) || option.value.toLocaleLowerCase().includes(needle) )); }, [options, query]); @@ -263,6 +265,7 @@ export function SelectLite({ }; const selectIndex = (index: number) => { + if (disabled) return; const option = filteredOptions[index]; if (!option || option.disabled) return; onChange(option.value); @@ -319,6 +322,7 @@ export function SelectLite({ }; const handleSearchKeyDown = (event: ReactKeyboardEvent) => { + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; if (event.key === 'Escape') { event.preventDefault(); closeMenu(); @@ -459,7 +463,7 @@ export function SelectLite({ role="listbox" style={{ maxHeight: searchable ? 274 : 272, overflowY: 'auto' }} > - {filteredOptions.length === 0 && ( + {!filteredOptions.some(option => !option.alwaysVisible) && (
{ + if (!isTauri) { + const descriptor = (await listProviderDescriptors(kind)).find(item => item.providerType === providerType) + if (descriptor?.staticModels.length) return { models: descriptor.staticModels } + } if (!isTauri && providerType === "orcarouter" && (kind === "llm" || kind === "asr")) { const endpointAccount = kind === "llm" ? "ark.endpoint" : "asr.endpoint" const endpoint = mockCredentialValues.get(`${channelId ?? ''}:${endpointAccount}`) @@ -124,6 +128,6 @@ export async function listProviderModels( models: kind === "llm" ? ["gpt-4o", "deepseek-v4-flash", "deepseek-v4-pro"] - : ["whisper-1"], + : kind === "omni" ? ["gpt-4o-audio-preview", "qwen3-omni-flash"] : ["whisper-1"], })) } diff --git a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json new file mode 100644 index 00000000..f991e8fb --- /dev/null +++ b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json @@ -0,0 +1,48 @@ +[ + {"kind":"llm","providerType":"ark","labelKey":"ark","defaultEndpoint":"https://ark.cn-beijing.volces.com/api/v3","defaultModel":"deepseek-v3-2","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"deepseek","labelKey":"deepseek","defaultEndpoint":"https://api.deepseek.com/v1","defaultModel":"deepseek-v4-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"siliconflow","labelKey":"siliconflow","defaultEndpoint":"https://api.siliconflow.cn/v1","defaultModel":"Qwen/Qwen2.5-7B-Instruct","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"atlascloud","labelKey":"atlascloud","defaultEndpoint":"https://api.atlascloud.ai/v1","defaultModel":"qwen/qwen3.5-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"openai","labelKey":"openai","defaultEndpoint":"https://api.openai.com/v1","defaultModel":"gpt-4o","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"gemini","labelKey":"gemini","defaultEndpoint":"https://generativelanguage.googleapis.com/v1beta","defaultModel":"gemini-2.5-flash","authRequirement":"api_key","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"llm","providerType":"codex_oauth","labelKey":"codexOAuth","defaultEndpoint":null,"defaultModel":"gpt-5.5","authRequirement":"o_auth","validationProbe":"llm_text","staticModels":["gpt-5.5","gpt-5.3-codex","gpt-5.4","gpt-5.5"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"llm","providerType":"mimo","labelKey":"mimo","defaultEndpoint":"https://api.xiaomimimo.com/v1","defaultModel":"xiaomi/mimo-v2-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"cometapi","labelKey":"cometapi","defaultEndpoint":"https://api.cometapi.com/v1","defaultModel":"gpt-4o","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"openrouterFree","labelKey":"openrouterFree","defaultEndpoint":"https://openrouter.ai/api/v1","defaultModel":"qwen/qwen3-coder:free","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"orcarouter","labelKey":"orcarouter","defaultEndpoint":"https://api.orcarouter.ai/v1","defaultModel":"orcarouter/fusion-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"alibabaCoding","labelKey":"alibabaCoding","defaultEndpoint":"https://coding-intl.dashscope.aliyuncs.com/v1","defaultModel":"qwen3-coder-plus","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"codingPlanX","labelKey":"codingPlanX","defaultEndpoint":"https://api.codingplanx.ai/v1","defaultModel":"gpt-5-mini","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"minimax","labelKey":"minimax","defaultEndpoint":"https://api.minimaxi.com/v1","defaultModel":"MiniMax-M3","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"stepfun","labelKey":"stepfun","defaultEndpoint":"https://api.stepfun.com/v1","defaultModel":"step-1o-turbo-vision","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"opencode","labelKey":"opencode","defaultEndpoint":"https://opencode.ai/zen/v1","defaultModel":"deepseek-v4-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"custom","labelKey":"customChatCompletions","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"custom_responses","labelKey":"customResponses","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"responses","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"custom_messages","labelKey":"customMessages","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"messages","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"asr","providerType":"volcengine","labelKey":"asrVolcengine","defaultEndpoint":null,"defaultModel":null,"authRequirement":"volcengine","validationProbe":"asr_silence_allows_no_final","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"elevenlabs","labelKey":"asrElevenLabs","defaultEndpoint":"https://api.elevenlabs.io/v1","defaultModel":"scribe_v2","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":["scribe_v2"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"bailian","labelKey":"asrBailian","defaultEndpoint":"wss://dashscope.aliyuncs.com/api-ws/v1/inference/","defaultModel":"fun-asr-realtime","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":["fun-asr-realtime","fun-asr-flash-8k-realtime","qwen3-asr-flash-realtime","qwen3-asr-flash-realtime-2026-02-10","qwen3-asr-flash-realtime-2025-10-27","qwen-audio-3.0-asr-flash","fun-asr-flash-2026-06-15","qwen3-asr-flash","fun-asr","fun-asr-2025-11-07","fun-asr-2025-08-25","fun-asr-mtl","fun-asr-mtl-2025-08-25","paraformer-v2"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"bailian-qwen3-realtime","labelKey":"asrBailianQwen3","defaultEndpoint":"wss://dashscope.aliyuncs.com/api-ws/v1/realtime","defaultModel":"qwen3-asr-flash-realtime","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":["qwen3-asr-flash-realtime","qwen3-asr-flash-realtime-2026-02-10","qwen3-asr-flash-realtime-2025-10-27"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"bailian-fun-asr-flash","labelKey":"asrBailianFunAsrFlash","defaultEndpoint":"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation","defaultModel":"fun-asr-flash-2026-06-15","authRequirement":"api_key","validationProbe":"asr_non_silent","staticModels":["qwen-audio-3.0-asr-flash","fun-asr-flash-2026-06-15"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"siliconflow","labelKey":"asrSiliconflow","defaultEndpoint":"https://api.siliconflow.cn/v1","defaultModel":"FunAudioLLM/SenseVoiceSmall","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"stepfun","labelKey":"asrStepfun","defaultEndpoint":"https://api.stepfun.com/v1","defaultModel":"stepaudio-2.5-asr","authRequirement":"api_key","validationProbe":"stepfun_no_speech","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"zhipu","labelKey":"asrZhipu","defaultEndpoint":"https://open.bigmodel.cn/api/paas/v4","defaultModel":"glm-asr-2512","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"groq","labelKey":"asrGroq","defaultEndpoint":"https://api.groq.com/openai/v1","defaultModel":"whisper-large-v3-turbo","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"whisper","labelKey":"asrWhisper","defaultEndpoint":"https://api.openai.com/v1","defaultModel":"whisper-1","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"openrouter","labelKey":"asrOpenrouter","defaultEndpoint":"https://openrouter.ai/api/v1","defaultModel":"openai/whisper-large-v3-turbo","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"orcarouter","labelKey":"orcarouter","defaultEndpoint":"https://api.orcarouter.ai/v1","defaultModel":"google/gemini-2.5-flash","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"zenmux","labelKey":"asrZenmux","defaultEndpoint":"https://zenmux.ai/api/v1","defaultModel":"qwen/qwen3-asr-flash","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"openai-compatible","labelKey":"asrOpenAiCompatible","defaultEndpoint":null,"defaultModel":null,"authRequirement":"endpoint_model_optional_api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"xiaomi-mimo-asr","labelKey":"asrXiaomiMimo","defaultEndpoint":"https://api.xiaomimimo.com/v1","defaultModel":"mimo-v2.5-asr","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":["mimo-v2.5-asr"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"iflytek","labelKey":"asrIflytek","defaultEndpoint":null,"defaultModel":null,"authRequirement":"xfyun","validationProbe":"asr_silence_allows_no_final","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"foundry-local-whisper","labelKey":"asrFoundryLocalWhisper","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"local-whisper","labelKey":"asrLocalWhisper","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"sherpa-onnx-local","labelKey":"asrSherpaOnnxLocal","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"local-qwen3-mlx","labelKey":"asrLocalQwen3Mlx","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"local-qwen3-c","labelKey":"asrLocalQwen3C","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"local-qwen3","labelKey":"asrLocalQwen3","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"apple-speech","labelKey":"asrAppleSpeech","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"omni","providerType":"openai","labelKey":"omniOpenai","defaultEndpoint":"https://api.openai.com/v1","defaultModel":"gpt-4o-audio-preview","authRequirement":"api_key","validationProbe":"omni_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"omni","providerType":"gemini","labelKey":"omniGemini","defaultEndpoint":"https://generativelanguage.googleapis.com/v1beta","defaultModel":"gemini-2.5-flash","authRequirement":"api_key","validationProbe":"omni_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"omni","providerType":"dashscope-omni","labelKey":"omniDashscope","defaultEndpoint":"https://dashscope.aliyuncs.com/compatible-mode/v1","defaultModel":"qwen3-omni-flash","authRequirement":"api_key","validationProbe":"omni_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"omni","providerType":"custom","labelKey":"custom","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key","validationProbe":"omni_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]} +] diff --git a/openless-all/app/src/lib/ipc/providers.ts b/openless-all/app/src/lib/ipc/providers.ts index 8387f284..f536d8ce 100644 --- a/openless-all/app/src/lib/ipc/providers.ts +++ b/openless-all/app/src/lib/ipc/providers.ts @@ -1,3 +1,4 @@ +import mockDescriptors from './mock-provider-descriptors.json' import { invokeOrMock } from './shared' export type ProviderKind = 'asr' | 'llm' | 'omni' @@ -27,23 +28,7 @@ export interface ProviderDescriptor { /** Core owns protocol, defaults, and credential requirements. */ export function listProviderDescriptors(kind: ProviderKind): Promise { - return invokeOrMock('list_provider_descriptors', { kind }, () => kind === 'llm' ? [ - ['opencode', 'opencode', 'chat_completions'], - ['orcarouter', 'orcarouter', 'chat_completions'], - ['custom', 'customChatCompletions', 'chat_completions'], - ['custom_responses', 'customResponses', 'responses'], - ['custom_messages', 'customMessages', 'messages'], - ].map(([providerType, labelKey, format]) => ({ - kind, providerType, labelKey, - defaultEndpoint: providerType === 'orcarouter' ? 'https://api.orcarouter.ai/v1' : providerType === 'opencode' ? 'https://opencode.ai/zen/v1' : null, - defaultModel: providerType === 'orcarouter' ? 'orcarouter/fusion-flash' : providerType === 'opencode' ? 'deepseek-v4-flash' : null, - authRequirement: 'api_key_unless_custom_endpoint', validationProbe: 'llm_text', staticModels: [], - defaultRequestFormat: format as LlmRequestFormat, - supportedRequestFormats: ['chat_completions', 'responses', 'messages'], - })) : kind === 'asr' ? [{ - kind, providerType: 'orcarouter', labelKey: 'orcarouter', - defaultEndpoint: 'https://api.orcarouter.ai/v1', defaultModel: 'google/gemini-2.5-flash', - authRequirement: 'api_key', validationProbe: 'asr_silence', staticModels: [], - defaultRequestFormat: null, supportedRequestFormats: [], - }] : []) + // Snapshot of Core provider_rules::provider_descriptors (all three kinds). + return invokeOrMock('list_provider_descriptors', { kind }, + () => (mockDescriptors as ProviderDescriptor[]).filter(descriptor => descriptor.kind === kind)) } diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index b13b97d3..7f5e23e6 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -30,6 +30,7 @@ import { type Channel, type ProviderDescriptor, } from '../../lib/ipc'; +import { ProviderFormContext, useProviderForm } from './ProviderForm'; import { emitSaved } from '../../lib/savedEvent'; import { useMobileLayout, useReadableLayout, useConservativeLayout } from '../../lib/useMobileLayout'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; @@ -733,6 +734,7 @@ function ChannelModal({ onUserMutation: () => void; }) { const { t } = useTranslation(); + const form = useProviderForm(); const [name, setName] = useState(channel.name); const [providerType, setProviderType] = useState(channel.providerType); const [changingProvider, setChangingProvider] = useState(false); @@ -810,7 +812,8 @@ function ChannelModal({ const isLocalEngine = descriptor?.authRequirement === 'none'; return ( - + + void form.finish(onClose)} width={mobile ? 'min(560px, 100%)' : 'min(600px, 100%)'}>
{t(isDraft ? 'settings.channels.createTitle' : 'settings.channels.editTitle')}
@@ -818,8 +821,8 @@ function ChannelModal({ void changeProvider(next)} + disabled={changingProvider || form.leaving} + onChange={next => void form.finish(() => changeProvider(next))} options={presets.map(p => ({ value: p.id, label: t(`settings.providers.presets.${p.nameKey}`), @@ -841,6 +844,7 @@ function ChannelModal({ /> {/* key 决定:换供应商时整组凭据字段重挂载,读的是新厂商对应的槽位。 */} +
{!changingProvider && void onChanged()} onUserMutation={onUserMutation} />} +
{isLocalEngine && (
@@ -863,7 +868,7 @@ function ChannelModal({ {t('settings.channels.deleteConfirm')} - @@ -873,9 +878,10 @@ function ChannelModal({ {t('settings.channels.delete')} )} - +
+
); } diff --git a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts index f6192f9e..f36e915c 100644 --- a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts @@ -24,7 +24,11 @@ assert(opencode?.defaultRequestFormat === 'chat_completions' && opencode.defaultEndpoint === 'https://opencode.ai/zen/v1' && opencode.defaultModel === 'deepseek-v4-flash', 'OpenCode browser preset must retain Core defaults'); assert(presets.find(p => p.id === 'custom_messages')?.defaultRequestFormat === 'messages', 'Picker must retain Core protocol defaults'); -for (const preset of presets) assert(preset.supportedRequestFormats?.length === 3, 'All compatibility presets allow switching'); +for (const preset of presets) { + assert(preset.supportedRequestFormats?.length === (preset.defaultRequestFormat ? 3 : 0), 'Picker must retain Core protocol capabilities'); +} +assert(presets.find(p => p.id === 'gemini')?.defaultRequestFormat == null, 'Native Gemini must not gain compatibility formats'); +assert(presets.find(p => p.id === 'codex_oauth')?.staticModels?.length, 'OAuth static models must be available without an API key'); const first = await createChannel('llm', 'opencode', 'first'); const second = await createChannel('llm', 'custom', 'second'); diff --git a/openless-all/app/src/pages/settings/LlmProtocolFields.tsx b/openless-all/app/src/pages/settings/LlmProtocolFields.tsx index ed3b99dd..f238fe6a 100644 --- a/openless-all/app/src/pages/settings/LlmProtocolFields.tsx +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.tsx @@ -1,10 +1,11 @@ -import { useEffect, useRef, useState } from 'react'; +import { useContext, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { SelectLite } from '../../components/ui/SelectLite'; import { readCredential, setCredential } from '../../lib/ipc'; import type { LlmRequestFormat } from '../../lib/ipc/providers'; import { emitSaved } from '../../lib/savedEvent'; import { SettingRow, inputStyle } from './shared'; +import { ProviderFormContext } from './ProviderForm'; const accounts = ['ark.request_format', 'ark.messages_thinking', 'ark.max_tokens', 'ark.thinking_budget'] as const; type Account = typeof accounts[number]; @@ -35,6 +36,7 @@ export function LlmProtocolFields({ channelId, defaultFormat, formats, onUserMut onSaved?: (changedAccounts: readonly Account[]) => void; }) { const { t } = useTranslation(); + const form = useContext(ProviderFormContext); const [values, setValues] = useState(emptyValues); const [saved, setSaved] = useState(emptyValues); const [loaded, setLoaded] = useState(false); @@ -42,6 +44,8 @@ export function LlmProtocolFields({ channelId, defaultFormat, formats, onUserMut const [error, setError] = useState<'read' | 'save' | null>(null); const mounted = useRef(true); const writing = useRef(false); + const pendingWrite = useRef>(Promise.resolve(true)); + const flushRef = useRef<() => Promise>(() => Promise.resolve(true)); const dirty = accounts.some(account => values[account] !== saved[account]); const validation = protocolValidationError(values, defaultFormat); @@ -60,11 +64,14 @@ export function LlmProtocolFields({ channelId, defaultFormat, formats, onUserMut useEffect(() => { onBlockedChange('protocol', !loaded || saving || dirty || error !== null || validation !== null); }, [loaded, saving, dirty, error, validation, onBlockedChange]); + useEffect(() => form?.register('protocol', () => flushRef.current()), [form?.register]); - const save = async (next: ProtocolValues) => { - if (!loaded || writing.current || protocolValidationError(next, defaultFormat)) return; + const save = (next: ProtocolValues): Promise => { + if (writing.current) return pendingWrite.current; + if (!loaded || protocolValidationError(next, defaultFormat)) return Promise.resolve(false); writing.current = true; setSaving(true); setError(null); + pendingWrite.current = (async () => { try { const changedAccounts = accounts.filter(account => next[account] !== saved[account]); for (const account of changedAccounts) { @@ -75,15 +82,21 @@ export function LlmProtocolFields({ channelId, defaultFormat, formats, onUserMut emitSaved('saved', t('common.saved')); onSaved?.(changedAccounts); } + return true; } catch { if (mounted.current) { setError('save'); emitSaved('failed', t('common.operationFailed')); } + return false; } finally { writing.current = false; if (mounted.current) setSaving(false); } + })(); + return pendingWrite.current; }; + flushRef.current = () => writing.current ? pendingWrite.current : dirty ? save(values) : Promise.resolve(true); const change = (account: Account, value: string, immediate = false) => { + if (!loaded || writing.current || form?.leaving) return; onUserMutation(); const next = { ...values, [account]: value }; setValues(next); @@ -91,7 +104,7 @@ export function LlmProtocolFields({ channelId, defaultFormat, formats, onUserMut }; const format = values['ark.request_format'] || defaultFormat; const mode = values['ark.messages_thinking'] || 'adaptive'; - const disabled = !loaded || saving; + const disabled = !loaded || saving || form?.leaving; const numberField = (account: Account, label: string, placeholder: string) => ( Promise) => () => void) | null>(null); + +/** 同一表单的写入在关闭/切换前收敛;版本只含计数,不携带凭据内容。 */ +export function useProviderForm() { + const parent = useContext(ProviderLeaveContext); + const id = useId(); + const version = useRef(0); + const [revision, setRevision] = useState(0); + const [blocked, setBlocked] = useState>({}); + const blockers = useRef>({}); + const flushers = useRef(new Map Promise>()); + const busy = useRef(false); + const [leaving, setLeaving] = useState(false); + const invalidate = useCallback((account = '') => { + if (!['ark.model_id', 'asr.model', 'omni.model'].includes(account)) version.current += 1; + setRevision(value => value + 1); + }, []); + const track = useCallback((account: string, next: boolean) => { + blockers.current[account] = next; + setBlocked(previous => previous[account] === next ? previous : { ...previous, [account]: next }); + }, []); + const register = useCallback((account: string, flush: () => Promise) => { + flushers.current.set(account, flush); + return () => { flushers.current.delete(account); delete blockers.current[account]; + setBlocked(previous => { const next = { ...previous }; delete next[account]; return next; }); }; + }, []); + const finish = useCallback(async (action: () => void | Promise) => { + if (busy.current) return false; + busy.current = true; + setLeaving(true); + invalidate(); + try { + const results = await Promise.all([...flushers.current.values()].map(flush => flush())); + if (!results.every(Boolean) || Object.entries(blockers.current).some(([account, blocked]) => blocked && !flushers.current.has(account))) return false; + await action(); + return true; + } finally { busy.current = false; setLeaving(false); } + }, [invalidate]); + useEffect(() => parent?.(id, () => finish(() => undefined)), [parent, id, finish]); + return useMemo(() => ({ version, revision, blocked, leaving, invalidate, track, register, finish }), + [revision, blocked, leaving, invalidate, track, register, finish]); +} + +export const ProviderFormContext = createContext | null>(null); diff --git a/openless-all/app/src/pages/settings/ProvidersSection.test.ts b/openless-all/app/src/pages/settings/ProvidersSection.test.ts index 88b964f1..a89e8a41 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.test.ts +++ b/openless-all/app/src/pages/settings/ProvidersSection.test.ts @@ -1,4 +1,4 @@ -import { LLM_LABELS, prioritizeOrcaRouterModels } from './ProvidersSection'; +import { LLM_LABELS } from './ProvidersSection'; import { ASR_LABELS } from './shared'; import { presetsFor } from './ChannelList'; import { filterOrcaRouterModels } from '../../lib/ipc/asr-credentials'; @@ -41,22 +41,6 @@ if (coreAsr.length !== 1 || coreAsr[0].authRequirement !== 'endpoint_model_optio throw new Error('Core provider descriptor must replace the browser fallback in the channel picker'); } -const prioritizedOrcaRouterModels = prioritizeOrcaRouterModels([ - 'openai/gpt-5-mini', - 'orcarouter/fusion-mini', - 'anthropic/claude-haiku-4.5', - 'orcarouter/fusion-flash', -]); - -if (prioritizedOrcaRouterModels.join(',') !== [ - 'orcarouter/fusion-flash', - 'orcarouter/fusion-mini', - 'anthropic/claude-haiku-4.5', - 'openai/gpt-5-mini', -].join(',')) { - throw new Error(`unexpected OrcaRouter model ordering: ${prioritizedOrcaRouterModels.join(',')}`); -} - const protocolCatalog = [ { id: 'model/chat', supported_endpoint_types: ['openai'] }, { id: 'model/responses', supported_endpoint_types: ['openai-response'] }, diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index a599b73e..514081a5 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -1,7 +1,7 @@ // 服务 → AI 提供商:LLM 润色模型 + ASR 语音转写两张卡片。 // 自 Settings.tsx 整体迁出,逻辑零改动;i18n key 全部保持 `settings.providers.*`。 -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../../components/Icon'; import { detectOS } from '../../components/WindowChrome'; @@ -15,6 +15,9 @@ import { validateProviderCredentials, type ProviderDescriptor, } from '../../lib/ipc'; +import { ModelCatalog } from './modelCatalog'; +import { CredentialDraft } from './credentialDraft'; +import { ProviderFormContext, useProviderForm } from './ProviderForm'; import { LlmProtocolFields } from './LlmProtocolFields'; import { emitSaved } from '../../lib/savedEvent'; import { useLayoutStack, useConservativeLayout } from '../../lib/useMobileLayout'; @@ -106,6 +109,53 @@ const ASR_DEFAULT_RESOURCE_ID = 'volc.seedasr.sauc.duration'; /** 模型预设下拉里的「自定义模型…」哨兵值:选中即切回输入框手输。 */ const CUSTOM_MODEL_OPTION_VALUE = '__custom_model__'; +function useRegisteredCredentialDraft( + account: string, + provider?: string, + onUserMutation?: () => void, + onBlockedChange?: (account: string, blocked: boolean) => void, + enabled = true, +) { + const { t } = useTranslation(); + const form = useContext(ProviderFormContext); + const draft = useMemo(() => new CredentialDraft( + () => readCredential(account, provider), value => setCredential(account, value, provider), + ), [account, provider]); + const state = useSyncExternalStore(draft.subscribe, draft.snapshot, draft.snapshot); + const blocked = !state.loaded || state.dirty || state.status === 'saving' + || state.status === 'readError' || state.status === 'saveError'; + + useEffect(() => { + if (!enabled) return; + void draft.load(); + return () => draft.dispose(); + }, [draft, enabled]); + useEffect(() => { + if (!enabled) return; + form?.track(account, blocked); + onBlockedChange?.(account, blocked); + }, [account, blocked, enabled, form?.track, onBlockedChange]); + useEffect(() => enabled ? form?.register(account, draft.flush) : undefined, + [account, draft, enabled, form?.register]); + useEffect(() => { + if (!enabled) return; + if (state.status === 'saving') emitSaved('saving', t('common.saving')); + if (state.status === 'saved') emitSaved('saved', t('common.saved')); + if (state.status === 'saveError') emitSaved('failed', t('common.operationFailed')); + }, [enabled, state.status, t]); + + const edit = (value: string) => { + if (!enabled || !state.loaded) return false; + form?.track(account, true); + form?.invalidate(account); + onUserMutation?.(); + draft.edit(value); + return true; + }; + + return { ...state, draft, edit, disabled: !enabled || !state.loaded || form?.leaving }; +} + /** * 一张渠道卡片的凭据字段区(编辑弹窗的主体)。 * @@ -135,30 +185,24 @@ export function ChannelCredentialFields({ const baseLayoutStack = useLayoutStack(); const conservative = useConservativeLayout(); const layoutStack = conservative || baseLayoutStack; - const [llmModelRevision, setLlmModelRevision] = useState(0); + const form = useContext(ProviderFormContext); const [configRevision, setConfigRevision] = useState(0); - const [orcarouterCatalogRevision, setOrcarouterCatalogRevision] = useState(0); + const [blockedFields, setBlockedFields] = useState>({}); const trackField = useCallback((account: string, blocked: boolean) => { + form?.track(account, blocked); setBlockedFields(previous => previous[account] === blocked ? previous : { ...previous, [account]: blocked }); - }, []); + }, [form?.track]); const onLlmMutation = () => { onUserMutation?.(); setConfigRevision(value => value + 1); }; - const [asrModelRevision, setAsrModelRevision] = useState(0); + const unifiedBailian = providerType === 'bailian'; const [bailianModel, setBailianModel] = useState(''); - const [volcengineAuthMode, setVolcengineAuthMode] = useState<'app_id_token' | 'api_key'>('app_id_token'); - - useEffect(() => { - if (providerType === 'volcengine') { - readCredential('volcengine.auth_mode', channelId) - .then(v => { - if (v === 'api_key') setVolcengineAuthMode('api_key'); - else setVolcengineAuthMode('app_id_token'); - }) - .catch(() => setVolcengineAuthMode('app_id_token')); - } - }, [providerType, channelId]); + const volcengineAuth = useRegisteredCredentialDraft( + 'volcengine.auth_mode', channelId, onUserMutation, undefined, + descriptor?.authRequirement === 'volcengine', + ); + const volcengineAuthMode = volcengineAuth.value === 'api_key' ? 'api_key' : 'app_id_token'; useEffect(() => { if (!unifiedBailian) setBailianModel(''); @@ -191,13 +235,8 @@ export function ChannelCredentialFields({ <> {!!descriptor.supportedRequestFormats?.length && descriptor.defaultRequestFormat && ( { - if (providerType === 'orcarouter' && changedAccounts.includes('ark.request_format')) { - setOrcarouterCatalogRevision(value => value + 1); - } - onTested?.(); - }} /> + formats={descriptor.supportedRequestFormats} onUserMutation={() => { form?.invalidate(); onLlmMutation(); }} + onBlockedChange={trackField} onSaved={() => onTested?.()} /> )} {codexOAuthSelected ? (
@@ -234,28 +273,13 @@ export function ChannelCredentialFields({ )} )} - {providerType === 'orcarouter' ? ( - } /> - ) : ( - - )} - /> - )} - setLlmModelRevision(v => v + 1)} onTested={onTested} - onUserMutation={onUserMutation} showFetchModels={providerType !== 'orcarouter'} /> + trailing={} /> + ); } @@ -269,20 +293,13 @@ export function ChannelCredentialFields({ { - onUserMutation?.(); - const mode = v as 'app_id_token' | 'api_key'; - const prev = volcengineAuthMode; - setVolcengineAuthMode(mode); - try { - await setCredential('volcengine.auth_mode', mode, channelId); - } catch (error) { - // 写入失败必须回滚 UI 并提示:否则模式看着已切换、重启后却静默回退, - // 配合独立 API Key 槽会造成「Key 存在但模式不对」的混乱。 - console.error('[settings] failed to save volcengine auth mode', error); - setVolcengineAuthMode(prev); - emitSaved('failed', t('common.operationFailed')); - } + disabled={volcengineAuth.disabled} + onChange={v => { + const save = async () => { + if (volcengineAuth.edit(v)) await volcengineAuth.draft.flush(); + }; + if (form) void form.finish(save); + else void save(); }} options={[ { value: 'app_id_token', label: t('settings.providers.volcengineAuthModeAppIdToken') }, @@ -291,6 +308,12 @@ export function ChannelCredentialFields({ ariaLabel={t('settings.providers.volcengineAuthModeLabel')} style={{ ...inputStyle, width: '100%', maxWidth: layoutStack ? '100%' : 260 }} /> + {volcengineAuth.status === 'readError' && ( + {t('settings.providers.readFailed')} + )} + {volcengineAuth.status === 'saveError' && ( + + )} {/* 两种模式使用各自独立的凭据槽位:旧版 Access Token(volcengine.access_key) 与方舟 API Key(volcengine.api_key)互不预填,切换模式不会残留混淆。 */} @@ -318,8 +341,8 @@ export function ChannelCredentialFields({ ? t('settings.providers.volcengineApiKeyNote') : t('settings.providers.volcengineMappingNote')}
- setAsrModelRevision(v => v + 1)} onTested={onTested} + ); @@ -335,8 +358,8 @@ export function ChannelCredentialFields({
{t('settings.providers.xfyunNote')}
- setAsrModelRevision(v => v + 1)} onTested={onTested} + ); @@ -352,29 +375,6 @@ export function ChannelCredentialFields({ ); } - if (providerType === 'orcarouter') { - return ( - <> - - - - setAsrModelRevision(v => v + 1)} onTested={onTested} - onUserMutation={onUserMutation} showFetchModels={false} /> - - ); - } - return ( <> - ({ value: model, label: model })) - : undefined} /> + onValueChange={unifiedBailian ? setBailianModel : undefined} /> {unifiedBailian && ( - + )} {unifiedBailian && bailianModelSupportsVocabulary(bailianModel) && ( <> @@ -422,12 +417,12 @@ export function ChannelCredentialFields({ {t('settings.providers.zenmuxVocabularyNote')}
)} - {/* 统一百炼「拉取模型」只写 model,不覆盖用户选择的区域或工作空间 endpoint。 */} - setAsrModelRevision(v => v + 1)} onTested={onTested} + {/* 连通性测试与目录拉取分开,不回填模型或 endpoint。 */} + {(providerType === 'openai-compatible' || providerType === 'zenmux') && ( - + )} ); @@ -438,41 +433,25 @@ export function ChannelCredentialFields({ // zenmux 暴露 enable_itn(数字归一化)开关,verbose_json / 分片对其无意义。 function AsrAdvancedOptions({ provider, + providerType, onUserMutation, }: { provider: string; + providerType: 'openai-compatible' | 'zenmux'; onUserMutation?: () => void; }) { const { t } = useTranslation(); - const [verboseJson, setVerboseJson] = useState(false); + const advanced = useRegisteredCredentialDraft('asr.advanced_config', provider, onUserMutation); + const config = useMemo(() => parseAdvancedAsrConfig(advanced.loaded ? advanced.value : null), + [advanced.loaded, advanced.value]); const [chunkDraft, setChunkDraft] = useState(''); - const [enableItn, setEnableItn] = useState(true); - const [status, setStatus] = useState<'idle' | 'saving' | 'error'>('idle'); - const [error, setError] = useState(''); + const chunkInitialized = useRef(false); useEffect(() => { - let cancelled = false; - setStatus('idle'); - setError(''); - void (async () => { - try { - const raw = await readCredential('asr.advanced_config', provider); - if (cancelled) return; - const config = parseAdvancedAsrConfig(raw); - setVerboseJson(config.verboseJson); - setChunkDraft(config.chunkDurationMs ? String(config.chunkDurationMs) : ''); - setEnableItn(config.enableItn); - } catch (err) { - if (!cancelled) { - setStatus('error'); - setError(err instanceof Error ? err.message : String(err)); - } - } - })(); - return () => { - cancelled = true; - }; - }, [provider]); + if (!advanced.loaded || (chunkInitialized.current && advanced.status !== 'saved')) return; + chunkInitialized.current = true; + setChunkDraft(config.chunkDurationMs ? String(config.chunkDurationMs) : ''); + }, [advanced.loaded, advanced.status, config.chunkDurationMs]); const parseChunkDraft = (draft: string): number | null => { const value = Number(draft); @@ -480,32 +459,20 @@ function AsrAdvancedOptions({ return Math.floor(value); }; - const save = async (partial: { + const edit = (partial: { verboseJson?: boolean chunkDurationMs?: number | null enableItn?: boolean }) => { - onUserMutation?.(); - setStatus('saving'); - setError(''); const next: AdvancedAsrConfig = { - verboseJson: partial.verboseJson ?? verboseJson, + verboseJson: partial.verboseJson ?? config.verboseJson, chunkDurationMs: partial.chunkDurationMs !== undefined ? partial.chunkDurationMs : parseChunkDraft(chunkDraft), - enableItn: partial.enableItn ?? enableItn, + enableItn: partial.enableItn ?? config.enableItn, }; - try { - await setCredential('asr.advanced_config', serializeAdvancedAsrConfig(next), provider); - setVerboseJson(next.verboseJson); - setChunkDraft(next.chunkDurationMs ? String(next.chunkDurationMs) : ''); - setEnableItn(next.enableItn); - setStatus('idle'); - } catch (err) { - setStatus('error'); - setError(err instanceof Error ? err.message : String(err)); - } + return advanced.edit(serializeAdvancedAsrConfig(next)); }; return ( @@ -521,12 +488,15 @@ function AsrAdvancedOptions({ > {t('settings.providers.asrAdvancedNote')} - {provider === 'zenmux' ? ( +
+ {providerType === 'zenmux' ? ( - void save({ enableItn: next })} /> + { + if (edit({ enableItn: next })) void advanced.draft.flush(); + }} /> ) : ( <> @@ -534,7 +504,9 @@ function AsrAdvancedOptions({ label={t('settings.providers.asrAdvancedVerboseJsonLabel')} desc={t('settings.providers.asrAdvancedVerboseJsonHint')} > - void save({ verboseJson: next })} /> + { + if (edit({ verboseJson: next })) void advanced.draft.flush(); + }} /> setChunkDraft(e.target.value)} - onBlur={() => void save({ chunkDurationMs: parseChunkDraft(chunkDraft) })} + disabled={advanced.disabled} + onChange={(event) => { + const value = event.target.value; + setChunkDraft(value); + if (edit({ chunkDurationMs: parseChunkDraft(value) })) advanced.draft.schedule(); + }} + onBlur={() => void advanced.draft.flush()} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} @@ -557,11 +533,15 @@ function AsrAdvancedOptions({ )} - {status === 'error' && ( +
+ {advanced.status === 'readError' && (
- {t('common.operationFailed')}: {error} + {t('settings.providers.readFailed')}
)} + {advanced.status === 'saveError' && ( + + )} ); } @@ -593,22 +573,10 @@ function bailianModelSupportsVocabulary(model: string): boolean { } // 模型框下的一行协议提示,解决「三种模型看不出区别」——告诉用户当前模型是实时还是 -// 录音文件、行为差异如何。随 asrModelRevision(拉取/选择模型时)与挂载时重读 asr.model。 +// 录音文件、行为差异如何。只使用当前渠道模型字段的值。 function BailianProtocolHint({ currentModel }: { currentModel: string }) { const { t } = useTranslation(); - const [model, setModel] = useState(''); - - useEffect(() => { - let cancelled = false; - readCredential('asr.model') - .then(v => { if (!cancelled) setModel(v || 'fun-asr-realtime'); }) - .catch(() => { /* 读失败按默认实时提示 */ }); - return () => { cancelled = true; }; - }, []); - - useEffect(() => { - setModel(currentModel || 'fun-asr-realtime'); - }, [currentModel]); + const model = currentModel || 'fun-asr-realtime'; const protocol = bailianModelProtocol(model); const hint = protocol === 'realtime' @@ -626,187 +594,43 @@ function BailianProtocolHint({ currentModel }: { currentModel: string }) { type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error'; -/** - * OrcaRouter exposes a large, changing catalog. Keep its own router models at - * the top, then sort the remaining vendor/model ids for predictable scanning. - */ -export function prioritizeOrcaRouterModels(models: string[]): string[] { - return [...models].sort((left, right) => { - const leftOwn = left.startsWith('orcarouter/'); - const rightOwn = right.startsWith('orcarouter/'); - if (leftOwn !== rightOwn) return leftOwn ? -1 : 1; - return left.localeCompare(right); - }); -} - -function CatalogModelField({ - kind, - provider, - baseUrl, - defaultModel, - trailing, - onUserMutation, - onBlockedChange, -}: { - kind: 'llm' | 'asr'; - provider: string; - baseUrl: string; - defaultModel: string; - trailing?: ReactNode; - onUserMutation?: () => void; - onBlockedChange?: (account: string, blocked: boolean) => void; +/** 目录只提供候选;CredentialField 是选择与手填唯一的写入入口。 */ +function ModelField({ kind, providerType, staticModels = [], ...field }: Omit & { + kind: 'llm' | 'asr' | 'omni'; providerType: string; staticModels?: string[]; }) { const { t } = useTranslation(); - const baseLayoutStack = useLayoutStack(); - const conservative = useConservativeLayout(); - const layoutStack = conservative || baseLayoutStack; - const [models, setModels] = useState([]); - const [selectedModel, setSelectedModel] = useState(''); - const [status, setStatus] = useState('loading'); - const [message, setMessage] = useState(t('settings.providers.loadingModels')); - const requestRef = useRef(0); - const endpointAccount = kind === 'llm' ? 'ark.endpoint' : 'asr.endpoint'; - const modelAccount = kind === 'llm' ? 'ark.model_id' : 'asr.model'; - - useEffect(() => { - onBlockedChange?.(modelAccount, status !== 'success'); - }, [modelAccount, status, onBlockedChange]); - - const loadModels = async (initialize: boolean) => { - const requestId = ++requestRef.current; - setStatus('loading'); - setMessage(t('settings.providers.loadingModels')); - try { - // A newly created/migrated channel may not have received its preset yet. - // Fill only an empty endpoint here; an explicit endpoint edit remains - // respected, matching the behavior of the other named providers. - if (initialize) { - const endpoint = await readCredential(endpointAccount, provider); - if (!endpoint?.trim()) { - await setCredential(endpointAccount, baseUrl, provider); - } - } - const [savedModel, result] = await Promise.all([ - readCredential(modelAccount, provider), - listProviderModels(kind, provider, 'orcarouter'), - ]); - if (requestId !== requestRef.current) return; - const nextModels = prioritizeOrcaRouterModels(result.models); - setModels(nextModels); - if (nextModels.length === 0) { - setSelectedModel(''); - setStatus('empty'); - setMessage(t('settings.providers.modelsEmpty')); - return; - } - - const current = savedModel?.trim() ?? ''; - const nextModel = nextModels.includes(current) - ? current - : nextModels.includes(defaultModel) - ? defaultModel - : nextModels[0]; - if (nextModel !== current) { - await setCredential(modelAccount, nextModel, provider); - } - if (requestId !== requestRef.current) return; - setSelectedModel(nextModel); - setStatus('success'); - setMessage(t('settings.providers.modelsLoaded', { count: nextModels.length })); - } catch (error) { - if (requestId !== requestRef.current) return; - setModels([]); - setStatus('error'); - setMessage(providerErrorMessage(error, t)); - } + const form = useContext(ProviderFormContext); + const loader = useMemo(() => new ModelCatalog(), [kind, field.provider, providerType]); + const catalog = useSyncExternalStore(loader.subscribe, loader.snapshot, loader.snapshot); + const version = form?.version.current ?? 0; + useEffect(() => () => loader.invalidate(), [loader]); + const loadModels = async () => { + if (form?.leaving || Object.values(form?.blocked ?? {}).some(Boolean)) return; + await loader.load(version, () => listProviderModels(kind, field.provider, providerType), + () => form?.version.current ?? 0); }; - - useEffect(() => { - void loadModels(true); - return () => { - requestRef.current += 1; - }; - // The channel id defines the credential scope; changing it must reload the catalog. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [provider]); - - const applyModel = async (model: string) => { - onUserMutation?.(); - setStatus('loading'); - setMessage(t('common.saving')); - try { - await setCredential(modelAccount, model, provider); - setSelectedModel(model); - setStatus('success'); - setMessage(t('settings.providers.modelSaved', { model })); - emitSaved('saved', t('common.saved')); - } catch (error) { - setStatus('error'); - setMessage(providerErrorMessage(error, t)); - emitSaved('failed', t('common.operationFailed')); - } - }; - - return ( - -
-
- void applyModel(model)} - options={models.map(model => ({ value: model, label: model }))} - placeholder={status === 'loading' - ? t('settings.providers.loadingModels') - : t('settings.providers.selectModel')} - disabled={status === 'loading' || models.length === 0} - searchable - searchPlaceholder={t('settings.providers.searchModels')} - emptyMessage={t('settings.providers.noMatchingModels')} - ariaLabel={t('settings.providers.selectModel')} - style={{ - flex: layoutStack ? '1 1 100%' : 1, - width: '100%', - minWidth: 0, - maxWidth: '100%', - fontFamily: 'var(--ol-font-mono)', - }} - /> - - {trailing} -
- - {message} - - - {t(kind === 'asr' - ? 'settings.providers.orcarouterAsrCatalogHint' - : 'settings.providers.orcarouterCatalogHint')} - -
-
- ); + const active = catalog?.version === version ? catalog : null; + const models = [...new Set([...staticModels, ...(active?.models ?? [])])].sort((a, b) => a.localeCompare(b)); + const message = !active ? '' : active.status === 'loading' ? t('settings.providers.loadingModels') + : active.status === 'error' ? providerErrorMessage(active.error, t) + : active.models.length ? t('settings.providers.modelsLoaded', { count: active.models.length }) : t('settings.providers.modelsEmpty'); + return ({ value, label: value }))} + below={<> + + {message && {message}} + } />; } -function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested, onUserMutation, showFetchModels = true, disabled = false }: { disabled?: boolean; kind: 'llm' | 'asr' | 'omni'; modelAccount: string; provider?: string; onModelSelected: () => void; onTested?: () => void; onUserMutation?: () => void; showFetchModels?: boolean }) { +function ProviderTools({ kind, provider, onTested, onUserMutation, disabled = false }: { disabled?: boolean; kind: 'llm' | 'asr' | 'omni'; provider?: string; onTested?: () => void; onUserMutation?: () => void }) { const { t } = useTranslation(); const baseLayoutStack = useLayoutStack(); const conservative = useConservativeLayout(); const layoutStack = conservative || baseLayoutStack; const mounted = useRef(true); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); - const [models, setModels] = useState([]); - const [selectedModel, setSelectedModel] = useState(''); const [status, setStatus] = useState('idle'); const [message, setMessage] = useState(''); @@ -832,8 +656,6 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested const validate = async () => { if (disabled) return; onUserMutation?.(); - setModels([]); - setSelectedModel(''); setResult('loading', t('settings.providers.validating')); const started = performance.now(); try { @@ -861,57 +683,11 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested } }; - const loadModels = async () => { - if (disabled) return; - onUserMutation?.(); - setResult('loading', t('settings.providers.loadingModels')); - try { - const result = await listProviderModels(kind, provider); - setModels(result.models); - if (result.models.length === 0) { - setResult('empty', t('settings.providers.modelsEmpty')); - } else { - setSelectedModel(''); - setResult('success', t('settings.providers.modelsLoaded', { count: result.models.length })); - } - } catch (error) { - setModels([]); - setResult('error', providerErrorMessage(error, t)); - } - }; - - const applyModel = async (model: string) => { - onUserMutation?.(); - setResult('loading', t('common.saving')); - try { - await setCredential(modelAccount, model, provider); - setSelectedModel(model); - onModelSelected(); - setResult('success', t('settings.providers.modelSaved', { model })); - } catch (error) { - setResult('error', providerErrorMessage(error, t)); - } - }; - return (
- {showFetchModels && ( - - )} - {showFetchModels && models.length > 0 && ( - ({ value: model, label: model }))} - placeholder={t('settings.providers.selectModel')} - ariaLabel={t('settings.providers.selectModel')} - style={{ flex: layoutStack ? '1 1 100%' : '1 1 180px', maxWidth: layoutStack ? '100%' : 220, minWidth: 0 }} - /> - )}
{message && ( @@ -955,8 +731,6 @@ function providerErrorMessage(error: unknown, t: ReturnType void; label: string; @@ -972,156 +746,36 @@ interface CredentialFieldProps { onUserMutation?: () => void; /** 提供则渲染为下拉(预设选择)代替输入框;当前值不在预设里时附加为自定义项。 */ options?: SelectOption[]; + below?: ReactNode; } -function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange, onUserMutation, options, onBlockedChange }: CredentialFieldProps) { +function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange, onUserMutation, options, onBlockedChange, below }: CredentialFieldProps) { const { t } = useTranslation(); const baseLayoutStack = useLayoutStack(); const conservative = useConservativeLayout(); const layoutStack = conservative || baseLayoutStack; - const [value, setValue] = useState(''); + const credential = useRegisteredCredentialDraft(account, provider, onUserMutation, onBlockedChange); + const { draft, value, loaded, status, disabled } = credential; const [revealed, setRevealed] = useState(false); - const [loaded, setLoaded] = useState(false); - const [dirty, setDirty] = useState(false); - const [status, setStatus] = useState('idle'); - // 预设下拉的「自定义模型…」逃生口:选中后切回输入框,保证后端支持的任意模型名都能手输。 const [customModelMode, setCustomModelMode] = useState(false); - useEffect(() => { - onBlockedChange?.(account, !loaded || dirty || status === 'saving' || status === 'readError' || status === 'saveError'); - }, [account, loaded, dirty, status, onBlockedChange]); + const composing = useRef(false); + useEffect(() => { if (loaded) onValueChange?.(value); }, [loaded, value, onValueChange]); - const debounceRef = useRef(null); - const statusRef = useRef(null); - const mountedRef = useRef(true); - const editRevision = useRef(0); - const saveQueue = useRef>(Promise.resolve()); - const markMutation = () => { editRevision.current += 1; onUserMutation?.(); }; - - useEffect(() => { - let cancelled = false; - setLoaded(false); - setDirty(false); - setStatus('idle'); - setValue(''); - onValueChange?.(''); - if (debounceRef.current) { - clearTimeout(debounceRef.current); - debounceRef.current = null; - } - readCredential(account, provider) - .then(v => { - if (cancelled) return; - setValue(v ?? ''); - onValueChange?.(v ?? ''); - setLoaded(true); - }) - .catch(error => { - if (cancelled) return; - console.error('[settings] failed to read credential', account, error); - onValueChange?.(''); - setLoaded(true); - setStatus('readError'); - }); - return () => { - cancelled = true; - }; - }, [account, provider, onValueChange]); - - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - if (debounceRef.current) clearTimeout(debounceRef.current); - if (statusRef.current) clearTimeout(statusRef.current); - }; - }, []); - - // 改造:除 readError(持续错误,留在输入旁标识字段不可用)外,所有 saving / saved / - // saveError / copied / copyError 一律发到右上角 SavedToast。原内联文案太挤、跟其它 - // 页面 toast 风格不统一。 - const showTemporaryStatus = (next: CredentialFieldStatus) => { - if (next === 'saving') { - emitSaved('saving', t('common.saving')); - } else if (next === 'saved') { - emitSaved('saved', t('common.saved')); - } else if (next === 'saveError') { - emitSaved('failed', t('common.operationFailed')); - } else if (next === 'copied') { - emitSaved('saved', t('common.copied')); - } else if (next === 'copyError') { - emitSaved('failed', t('common.operationFailed')); - } - setStatus(next); - if (statusRef.current) clearTimeout(statusRef.current); - statusRef.current = window.setTimeout(() => setStatus('idle'), 1600); + const change = (next: string, immediate = false) => { + if (!credential.edit(next)) return; + if (immediate) void draft.flush(); + else if (!composing.current) draft.schedule(); }; - - const save = async (v: string, force = false) => { - if (!loaded || (!dirty && !force)) return; - if (!mountedRef.current) return; - const revision = editRevision.current; - setStatus('saving'); - emitSaved('saving', t('common.saving')); - try { - // 按编辑顺序写入,旧请求完成不能把新值标记为已保存。 - const write = saveQueue.current.catch(() => undefined).then(() => setCredential(account, v, provider)); - saveQueue.current = write; - await write; - if (!mountedRef.current || revision !== editRevision.current) return; - setDirty(false); - showTemporaryStatus('saved'); - } catch (error) { - if (!mountedRef.current || revision !== editRevision.current) return; - console.error('[settings] failed to save credential', account, error); - showTemporaryStatus('saveError'); - } - }; - - const handleChange = (e: React.ChangeEvent) => { - markMutation(); - const v = e.target.value; - setValue(v); - onValueChange?.(v); - if (!loaded) return; - setDirty(true); - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = window.setTimeout(() => save(v, true), 300); - }; - - const onBlur = () => { - if (!loaded || !dirty) return; - if (debounceRef.current) { - clearTimeout(debounceRef.current); - debounceRef.current = null; - } - void save(value, true); - }; - - const fillDefault = async () => { - if (!loaded || !defaultValue) return; - markMutation(); - setValue(defaultValue); - onValueChange?.(defaultValue); - setDirty(true); - await save(defaultValue, true); - }; - + const onBlur = () => { if (!composing.current) void draft.flush(); }; const onCopy = async () => { if (!value || !loaded) return; try { - if (!navigator.clipboard?.writeText) { - throw new Error('Clipboard API unavailable'); - } await navigator.clipboard.writeText(value); - showTemporaryStatus('copied'); - } catch (error) { - console.error('[settings] failed to copy credential', account, error); - showTemporaryStatus('copyError'); - } + emitSaved('saved', t('common.copied')); + } catch { emitSaved('failed', t('common.operationFailed')); } }; const inputType = mask && !revealed ? 'password' : 'text'; - const disabled = !loaded; const showInsecureEndpointWarning = (account === 'ark.endpoint' || account === 'asr.endpoint' || account === 'omni.endpoint') && value.trim().toLowerCase().startsWith('http://'); @@ -1129,7 +783,7 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
- {options && !customModelMode ? ( + {!!options?.length && !customModelMode ? ( { @@ -1138,18 +792,16 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de setCustomModelMode(true); return; } - markMutation(); - setValue(v); - onValueChange?.(v); - if (!loaded) return; - setDirty(true); - void save(v, true); + change(v, true); }} options={[ ...(value && !options.some(o => o.value === value) ? [{ value, label: value }] : []), ...options, - { value: CUSTOM_MODEL_OPTION_VALUE, label: t('settings.providers.customModelLabel', 'Custom model…') }, + { value: CUSTOM_MODEL_OPTION_VALUE, label: t('settings.providers.customModelLabel'), alwaysVisible: true }, ]} + searchable + searchPlaceholder={t('settings.providers.searchModels')} + emptyMessage={t('settings.providers.noMatchingModels')} placeholder={loaded ? placeholder : t('common.loading')} disabled={disabled} ariaLabel={label} @@ -1160,13 +812,16 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de type={inputType} value={value} placeholder={loaded ? placeholder : t('common.loading')} - onChange={handleChange} + aria-label={label} + onChange={event => { if (options) setCustomModelMode(true); change(event.target.value); }} + onCompositionStart={() => { composing.current = true; draft.pause(); }} + onCompositionEnd={() => { composing.current = false; draft.schedule(); }} onBlur={onBlur} disabled={disabled} style={{ ...inputStyle, flex: layoutStack ? '1 1 180px' : 1, minWidth: 0, maxWidth: '100%', fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }} /> )} - {options && customModelMode && ( + {!!options?.length && customModelMode && ( )} {defaultValue && !value && loaded && ( - )} @@ -1215,6 +870,8 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de )}
+ {below &&
{below}
} + {status === 'saveError' && } {showInsecureEndpointWarning && ( {t('settings.providers.endpointHttpWarning')} @@ -1262,7 +919,7 @@ export function OmniChannelSection() { const [omniProvider, setOmniProvider] = useState('custom'); const [committedOmniProvider, setCommittedOmniProvider] = useState('custom'); const omniSwitchSeqRef = useRef(0); - const [omniModelRevision, setOmniModelRevision] = useState(0); + const form = useProviderForm(); useEffect(() => { void listProviderDescriptors('omni') @@ -1275,15 +932,16 @@ export function OmniChannelSection() { nameKey: descriptor.labelKey, baseUrl: descriptor.defaultEndpoint ?? '', modelPlaceholder: descriptor.defaultModel ?? '', + staticModels: descriptor.staticModels, })), [descriptors]); useEffect(() => { - if (!prefs) return; + if (!prefs || form.leaving) return; const knownOmni = omniPresets.find(x => x.id === prefs.activeOmniProvider); const omniId = knownOmni ? knownOmni.id : 'custom'; setOmniProvider(omniId); setCommittedOmniProvider(omniId); - }, [prefs, omniPresets]); + }, [prefs?.activeOmniProvider, omniPresets, form.leaving]); // 与 LLM 卡同语义:受控下拉立即反馈 + committed 控制 CredentialField remount // + seq 守卫防 stale 覆盖,只是凭据落到 omni.* 槽。 @@ -1341,7 +999,7 @@ export function OmniChannelSection() { const omniPreset = omniPresets.find(p => p.id === committedOmniProvider); return ( - <> +
( +
}
)} - {error &&
{error}
} + {error &&
{nativeError ? t(`macDictationKey.${nativeError}`) : error}
}
); } @@ -426,6 +453,8 @@ function modifierPrimaryFromCode(code: string, key: string): string { } function primaryFromKeyboardEvent(e: KeyboardEvent): string { + const functionKey = functionKeyPrimaryFromEvent(e); + if (functionKey) return functionKey; const printable = primaryFromPrintableCode(e.code); if (printable) return printable; if (e.key.length === 1) return e.key; diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 6d25f7c8..0b073f21 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -4,6 +4,14 @@ import type { zhCN } from './zh-CN'; // Type-level guarantee that en mirrors the zh-CN shape. export const en: typeof zhCN = { + macDictationKey: { + Changed: 'The shortcut changed while saving. Please try again.', + label: "Mac Dictation key", + description: "Replaces the current dictation shortcut with the microphone key. Quitting OpenLess releases it to macOS.", + Permission: "Allow OpenLess in macOS Privacy & Security → Accessibility, then retry.", + Busy: "Finish the current dictation before changing its shortcut.", + Unavailable: "Could not activate this shortcut. The saved binding is unchanged; retry or choose another key.", + }, app: { name: 'OpenLess', tagline: 'Speak naturally, write perfectly', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index b49e8e2b..c8d176de 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -2,6 +2,14 @@ // 添加新 key 时,必须同步更新 en.ts,否则首次切换到 English 会回落到中文残留。 export const zhCN = { + macDictationKey: { + Changed: '保存期间快捷键已改变,请重试。', + label: "Mac 听写键", + description: "用麦克风图标键替换当前听写快捷键。退出 OpenLess 后,此键交回 macOS。", + Permission: "请在 macOS「隐私与安全性 → 辅助功能」中允许 OpenLess 后重试。", + Busy: "请先结束当前听写,再更改快捷键。", + Unavailable: "无法启用此快捷键,已保存的绑定未改变。请重试或选择其他键。", + }, app: { name: 'OpenLess', tagline: '自然说话,完美书写', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 3d60d311..9ce75771 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -4,6 +4,14 @@ import type { zhCN } from './zh-CN'; // 新增 key 時,必須同步更新 en.ts,避免切換到 English 後出現中文殘留。 export const zhTW: typeof zhCN = { + macDictationKey: { + Changed: '儲存期間快捷鍵已變更,請重試。', + label: "Mac 聽寫鍵", + description: "用麥克風圖示鍵替換目前的聽寫快捷鍵。結束 OpenLess 後,此鍵交回 macOS。", + Permission: "請在 macOS「隱私權與安全性 → 輔助使用」中允許 OpenLess 後重試。", + Busy: "請先結束目前的聽寫,再變更快捷鍵。", + Unavailable: "無法啟用此快捷鍵,已儲存的綁定未變更。請重試或選擇其他鍵。", + }, app: { name: 'OpenLess', tagline: '自然說話,完美書寫', diff --git a/openless-all/app/src/lib/hotkey.ts b/openless-all/app/src/lib/hotkey.ts index 1196e018..bf09a0b5 100644 --- a/openless-all/app/src/lib/hotkey.ts +++ b/openless-all/app/src/lib/hotkey.ts @@ -368,6 +368,7 @@ function formatPrimary(primary: string): string { const isMac = currentPlatform().isMac; if (isMac) { switch (trimmed.toLowerCase()) { + case 'macdictationkey': return i18n.t('macDictationKey.label'); case 'space': return '\u2423'; case 'enter': case 'return': return '\u21A9'; diff --git a/openless-all/app/src/lib/hotkeyRecorder.test.ts b/openless-all/app/src/lib/hotkeyRecorder.test.ts index a1a5841a..d9d8b333 100644 --- a/openless-all/app/src/lib/hotkeyRecorder.test.ts +++ b/openless-all/app/src/lib/hotkeyRecorder.test.ts @@ -1,5 +1,6 @@ import { createHotkeyRecorderState, + functionKeyPrimaryFromEvent, orderHotkeyCodes, updateHotkeyRecorderState, } from './hotkeyRecorder'; @@ -83,3 +84,11 @@ function apply( } assertDeepEqual(orderHotkeyCodes(['Mouse4', 'ControlLeft']), ['ControlLeft', 'Mouse4'], 'orders mouse after modifiers'); + +for (let i = 1; i <= 20; i++) { + assertEqual(functionKeyPrimaryFromEvent({ code: `F${i}`, key: `F${i}` }), `F${i}`, 'function key'); +} +assertEqual(functionKeyPrimaryFromEvent({ code: 'F20', key: '\uF717' }), 'F20', 'WebKit private-use key'); +assertEqual(functionKeyPrimaryFromEvent({ code: 'F20', key: 'Unidentified' }), 'F20', 'physical F20'); +assertEqual(functionKeyPrimaryFromEvent({ code: '', key: 'F20' }), 'F20', 'named F20 fallback'); +assertEqual(functionKeyPrimaryFromEvent({ code: 'KeyA', key: 'a' }), null, 'printable key preserved'); diff --git a/openless-all/app/src/lib/hotkeyRecorder.ts b/openless-all/app/src/lib/hotkeyRecorder.ts index 8ab692f5..8473eedc 100644 --- a/openless-all/app/src/lib/hotkeyRecorder.ts +++ b/openless-all/app/src/lib/hotkeyRecorder.ts @@ -68,3 +68,11 @@ const HOTKEY_CODE_ORDER = [ 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', 'NumpadDecimal', 'NumpadEnter', 'Mouse4', 'Mouse5', ]; + +/** Prefer physical function-key codes; WebKit can expose a private-use key value. */ +export function functionKeyPrimaryFromEvent(event: { code: string; key: string }): string | null { + const supported = /^F([1-9]|1[0-9]|20)$/; + if (supported.test(event.code)) return event.code; + if (supported.test(event.key)) return event.key; + return null; +} diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index 8fc63cc7..12639d28 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -265,17 +265,18 @@ export function RecordingInputSection() { { await setDictationHotkey(binding); - await savePrefs({ ...prefs, dictationHotkey: binding }); + await refresh(); }} onReset={async () => { const binding = defaultDictationHotkey(); await setDictationHotkey(binding); - await savePrefs({ ...prefs, dictationHotkey: binding }); + await refresh(); }} /> diff --git a/openless-all/app/src/pages/settings/ShortcutsSection.tsx b/openless-all/app/src/pages/settings/ShortcutsSection.tsx index 84daa1bb..a11f4da1 100644 --- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -30,7 +30,7 @@ import { detectOS } from '../../components/WindowChrome'; export function ShortcutsSection() { const { t } = useTranslation(); const os = detectOS(); - const { prefs, hotkey, updatePrefs: savePrefs } = useHotkeySettings(); + const { prefs, hotkey, refresh, updatePrefs: savePrefs } = useHotkeySettings(); const [platformCaps, setPlatformCaps] = useState(null); const [stylePacks, setStylePacks] = useState([]); // 新增行的草稿状态:先选风格包、再录快捷键,两者齐了才真正落库。 @@ -101,12 +101,13 @@ export function ShortcutsSection() { { await setDictationHotkey(binding); - await savePrefs({ ...prefs, dictationHotkey: binding }); + await refresh(); }} />
From 882fac63f083886f33443e1c29aca34e4f200be4 Mon Sep 17 00:00:00 2001 From: zhou zhichao <69954637+zhou-zhichao@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:36:12 +0200 Subject: [PATCH 4/4] fix(remote-input): preserve phone trust across restarts and IP changes (#1037) * fix(remote-input): preserve phone trust with a persistent local CA * fix(remote-input): disclose LAN certificate MITM risk --------- Co-authored-by: Chris233 --- .github/workflows/ci.yml | 3 + docs/remote-input-certificates.md | 92 ++++ openless-all/app/Cargo.lock | 112 ++++ openless-all/app/linux-egui/Cargo.toml | 7 +- .../app/linux-egui/src/remote_input.rs | 99 +--- openless-all/app/src-tauri/Cargo.lock | 95 +++- openless-all/app/src-tauri/Cargo.toml | 5 +- .../app/src-tauri/backend-tests/Cargo.lock | 184 +++++++ .../app/src-tauri/backend-tests/Cargo.toml | 11 + .../backend-tests/tests/remote_tls.rs | 3 + .../src-tauri/src/remote_server/assets/app.js | 62 ++- .../src/remote_server/assets/index.html | 16 +- .../src/remote_server/assets/style.css | 2 + .../app/src-tauri/src/remote_server/mod.rs | 183 ++----- .../src/remote_server/tls_identity.rs | 506 ++++++++++++++++++ openless-all/app/src/i18n/en.ts | 6 +- openless-all/app/src/i18n/ja.ts | 6 +- openless-all/app/src/i18n/ko.ts | 6 +- openless-all/app/src/i18n/zh-CN.ts | 6 +- openless-all/app/src/i18n/zh-TW.ts | 6 +- .../src/pages/settings/RemoteInputSection.tsx | 13 +- 21 files changed, 1148 insertions(+), 275 deletions(-) create mode 100644 docs/remote-input-certificates.md create mode 100644 openless-all/app/src-tauri/backend-tests/tests/remote_tls.rs create mode 100644 openless-all/app/src-tauri/src/remote_server/tls_identity.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 591589e5..b6515e72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,6 +185,9 @@ jobs: - name: Test Core run: cargo test --locked -p openless-core + - name: Test remote input TLS identity and Unix key permissions + run: cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --test remote_tls + cross-platform: name: ${{ matrix.label }} checks strategy: diff --git a/docs/remote-input-certificates.md b/docs/remote-input-certificates.md new file mode 100644 index 00000000..229019bf --- /dev/null +++ b/docs/remote-input-certificates.md @@ -0,0 +1,92 @@ +# Remote input: one-time phone certificate setup + +Remote input needs HTTPS for browser microphone access. OpenLess generates a +private certificate authority (CA) on each computer and a separate server +certificate covering that computer's LAN addresses. The phone installs the +public CA certificate. The CA private key stays in the computer's user data. + +## iPhone and iPad + +1. Enable remote input on the computer. Use the address shown in settings, on + the same network as the computer. Settings also has a **Copy iPhone + certificate link** button for each address. +2. Open the address in Safari. On the initial certificate warning, check the + address against the computer, then use **Show Details → Visit This Website** + to reach your own computer's setup page. This exception is only a bootstrap + step, not the persistent trust setup. +3. Expand **First-time setup: trust this computer** and choose **iPhone: + download profile**. Alternatively, open the copied `/cert.mobileconfig` link + directly in Safari. +4. Install the downloaded profile in **Settings → General → VPN & Device + Management**. +5. In **Settings → General → About → Certificate Trust Settings**, enable full + trust for **OpenLess Remote Input CA**. Return to Safari and reload the page. +6. Enter the pairing code and allow microphone access when Safari asks. + +Installing a profile and enabling full SSL trust are separate steps. Apple +requires the latter for profiles downloaded from a website; a desktop app +cannot silently approve it on a personal iPhone. See +[Apple's certificate trust instructions](https://support.apple.com/en-gb/102390). +This setup removes certificate warnings after trust is established; browser +microphone permissions and the pairing code remain separate controls. + +Only install a CA from your own computer. A CA can issue certificates, so its +private key is sensitive. Remove the OpenLess profile from the phone when you +no longer use it. The certificate fingerprint in each profile identifier keeps +profiles for different computers from replacing one another. + +## Android + +Download `/cert.cer` using the **Android: download CA** link. Install it through +the system's **Install a certificate → CA certificate** settings, then return +to the browser. Menu names and browser support for user-installed CAs vary by +device. The download contains only the public root certificate. + +## What OpenLess automates + +- Creates and atomically persists a unique CA and server identity on first use. +- Reuses the same CA and server certificate after restarts and upgrades that + preserve application data. +- Reissues the server certificate when required LAN or virtual-adapter IPs + change, keeping the same CA and therefore the phone's trust. +- Renews the server certificate on service startup when less than 30 days + remain. Leaves are valid for at most 366 days including clock-skew allowance; + a continuously running service must restart before its leaf expires. +- Serves the public CA as a `.cer` file or iOS configuration profile on both + Tauri desktop and Linux egui hosts. The private keys are never downloaded. +- Refuses to start with a damaged, unreadable, mismatched, or expiring CA rather + than silently replacing the phone's trust anchor. + +## Upgrades, reinstalling, and recovery + +Older releases used a directly self-signed leaf (`remote-cert-v4.der`) and +regenerated it whenever a newly observed IP was absent from a sidecar SAN list. +Even a virtual-adapter change could invalidate the certificate trusted by the +phone. Some releases also hid certificate setup and packaged the non-CA leaf +as a root profile. Upgrading from this format requires the one-time setup above. +Old files are left intact for rollback; they are not promoted to a CA. + +Keep `remote-tls-identity-v1.json` with the application's user configuration +when backing up or reinstalling. On Windows it is in +`%APPDATA%\com.openless.app`; on Tauri macOS it is in the application's config +directory; on Linux egui it is in the host data directory's `remote-input` +subdirectory. This file contains private keys: do not publish it, send it to a +phone, or copy it to another computer. Unix files are owner-readable/writable +only; Windows files inherit the user's application-data ACL. + +If this file is damaged, restore the computer's own backup. If it is lost, or +the CA expires (ten years), explicitly back up/remove the old identity while +OpenLess is stopped, restart, and install/trust the newly generated CA on each +phone. Deleting all application data necessarily loses the old trust identity. + +## Regression checks + +```sh +cargo test --manifest-path openless-all/app/src-tauri/backend-tests/Cargo.toml --test remote_tls +node openless-all/app/scripts/remote-input-audio-queue.test.mjs +``` + +The TLS tests exercise real rustls handshakes with only the downloaded CA as a +trust anchor, IP changes, restart reuse, renewal, corrupted keys, persistence +failure, migration, and separation between two computers. iOS installation and +microphone recording still require a physical-device smoke test. diff --git a/openless-all/app/Cargo.lock b/openless-all/app/Cargo.lock index 646d0d38..a4b807be 100644 --- a/openless-all/app/Cargo.lock +++ b/openless-all/app/Cargo.lock @@ -170,6 +170,45 @@ dependencies = [ "libloading", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -978,6 +1017,20 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2254,6 +2307,12 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -3080,6 +3139,15 @@ dependencies = [ "cc", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3122,6 +3190,7 @@ version = "0.1.0" dependencies = [ "arboard", "axum", + "base64", "cpal", "dbus", "eframe", @@ -3137,9 +3206,13 @@ dependencies = [ "rustls", "serde", "serde_json", + "sha2", + "tempfile", + "time", "tokio", "tokio-rustls", "uuid", + "x509-parser", ] [[package]] @@ -3638,6 +3711,7 @@ dependencies = [ "ring", "rustls-pki-types", "time", + "x509-parser", "yasna", ] @@ -3819,6 +3893,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "0.38.44" @@ -4396,6 +4479,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -4404,6 +4488,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -5698,6 +5792,24 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/openless-all/app/linux-egui/Cargo.toml b/openless-all/app/linux-egui/Cargo.toml index 2cb80cf7..1e35030d 100644 --- a/openless-all/app/linux-egui/Cargo.toml +++ b/openless-all/app/linux-egui/Cargo.toml @@ -27,6 +27,11 @@ libc = "0.2" axum = { version = "0.7", default-features = false, features = ["ws", "http1", "tokio"] } hyper-util = { version = "0.1", features = ["tokio", "server-auto", "server", "http1"] } local-ip-address = "0.6" -rcgen = "0.13" +base64 = "0.22" +sha2 = "0.10" +rcgen = { version = "0.13", features = ["x509-parser"] } +x509-parser = { version = "0.16", features = ["verify"] } +time = "0.3" +tempfile = "3" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] } diff --git a/openless-all/app/linux-egui/src/remote_input.rs b/openless-all/app/linux-egui/src/remote_input.rs index c2be6247..070579d9 100644 --- a/openless-all/app/linux-egui/src/remote_input.rs +++ b/openless-all/app/linux-egui/src/remote_input.rs @@ -289,71 +289,8 @@ fn access_urls(port: u16) -> Vec { } #[cfg(target_os = "linux")] -fn load_or_generate_certificate( - directory: &std::path::Path, - sans: &[String], -) -> Result<(Vec, rustls::pki_types::PrivateKeyDer<'static>), BackendError> { - use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; - - let cert_path = directory.join("remote-cert-v4.der"); - let key_path = directory.join("remote-key-v4.der"); - let sans_path = directory.join("remote-cert-sans-v4.txt"); - if let (Ok(cert), Ok(key), Ok(saved)) = ( - std::fs::read(&cert_path), - std::fs::read(&key_path), - std::fs::read_to_string(&sans_path), - ) { - let saved = saved.lines().collect::>(); - if sans.iter().all(|value| saved.contains(value.as_str())) { - return Ok((cert, PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key)))); - } - } - let mut params = rcgen::CertificateParams::new(sans.to_vec()) - .map_err(|error| remote_platform_error(format!("invalid TLS names: {error}")))?; - let mut name = rcgen::DistinguishedName::new(); - name.push(rcgen::DnType::CommonName, "OpenLess Remote Input"); - params.distinguished_name = name; - params - .extended_key_usages - .push(rcgen::ExtendedKeyUsagePurpose::ServerAuth); - let key = rcgen::KeyPair::generate() - .map_err(|error| remote_platform_error(format!("TLS key generation failed: {error}")))?; - let cert = params - .self_signed(&key) - .map_err(|error| remote_platform_error(format!("TLS certificate failed: {error}")))?; - let cert_der = cert.der().as_ref().to_vec(); - let key_der = key.serialize_der(); - std::fs::create_dir_all(directory) - .map_err(|error| remote_platform_error(format!("TLS directory failed: {error}")))?; - std::fs::write(&cert_path, &cert_der) - .map_err(|error| remote_platform_error(format!("TLS certificate save failed: {error}")))?; - std::fs::write(&key_path, &key_der) - .map_err(|error| remote_platform_error(format!("TLS key save failed: {error}")))?; - std::fs::write(&sans_path, sans.join("\n")) - .map_err(|error| remote_platform_error(format!("TLS names save failed: {error}")))?; - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)) - .map_err(|error| remote_platform_error(format!("TLS key permissions failed: {error}")))?; - Ok(( - cert_der, - PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)), - )) -} - -#[cfg(target_os = "linux")] -fn tls_config( - cert: Vec, - key: rustls::pki_types::PrivateKeyDer<'static>, -) -> Result, BackendError> { - let provider = Arc::new(rustls::crypto::ring::default_provider()); - rustls::ServerConfig::builder_with_provider(provider) - .with_safe_default_protocol_versions() - .map_err(|error| remote_platform_error(format!("TLS protocol failed: {error}")))? - .with_no_client_auth() - .with_single_cert(vec![rustls::pki_types::CertificateDer::from(cert)], key) - .map(Arc::new) - .map_err(|error| remote_platform_error(format!("TLS certificate failed: {error}"))) -} +#[path = "../../src-tauri/src/remote_server/tls_identity.rs"] +mod tls_identity; #[cfg(target_os = "linux")] fn router(state: Arc) -> Router { @@ -387,14 +324,32 @@ fn router(state: Arc) -> Router { "/cert.cer", get(|State(state): State>| async move { ( - [( - axum::http::header::CONTENT_TYPE, - "application/x-x509-ca-cert", - )], + [ + ( + axum::http::header::CONTENT_TYPE, + "application/x-x509-ca-cert", + ), + (axum::http::header::CACHE_CONTROL, "no-store"), + ], state.cert_der.clone(), ) }), ) + .route( + "/cert.mobileconfig", + get(|State(state): State>| async move { + ( + [ + ( + axum::http::header::CONTENT_TYPE, + "application/x-apple-aspen-config", + ), + (axum::http::header::CACHE_CONTROL, "no-store"), + ], + tls_identity::mobileconfig(&state.cert_der), + ) + }), + ) .route("/ws", get(websocket_upgrade)) .with_state(state) } @@ -444,8 +399,10 @@ async fn start_server( ) -> Result { let mut sans = vec!["localhost".to_string(), "127.0.0.1".to_string()]; sans.extend(local_lan_ipv4s()); - let (cert_der, key) = load_or_generate_certificate(&data_dir.join("remote-input"), &sans)?; - let acceptor = TlsAcceptor::from(tls_config(cert_der.clone(), key)?); + let identity = tls_identity::load_or_create(&data_dir.join("remote-input"), &sans) + .map_err(remote_platform_error)?; + let cert_der = identity.trust_cert; + let acceptor = TlsAcceptor::from(identity.server_config); let listener = TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port))) .await .map_err(|error| remote_platform_error(format!("remote input bind failed: {error}")))?; diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 46e9fe79..6cacfb09 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -180,6 +180,45 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -1394,6 +1433,20 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -4147,6 +4200,15 @@ dependencies = [ "cc", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -4260,7 +4322,9 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tempfile", "thiserror 1.0.69", + "time", "tokenizers", "tokio", "tokio-rustls", @@ -4272,6 +4336,7 @@ dependencies = [ "window-vibrancy 0.7.1", "windows 0.58.0", "winreg 0.52.0", + "x509-parser", "zip 2.4.2", ] @@ -5034,6 +5099,7 @@ dependencies = [ "ring", "rustls-pki-types", "time", + "x509-parser", "yasna", ] @@ -5331,6 +5397,15 @@ dependencies = [ "transpose", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" @@ -6769,7 +6844,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -8886,6 +8961,24 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 81385425..8a73e8b6 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -81,7 +81,10 @@ tauri-plugin-autostart = "2" global-hotkey = "0.6" enigo = "0.3" arboard = { version = "3", features = ["wayland-data-control"] } -rcgen = "^0.13" +rcgen = { version = "0.13", features = ["x509-parser"] } +x509-parser = { version = "0.16", features = ["verify"] } +time = "0.3" +tempfile = "3" local-ip-address = "^0.6" rustls = { version = "^0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } tokio-rustls = { version = "^0.26", default-features = false, features = ["ring", "tls12", "logging"] } diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.lock b/openless-all/app/src-tauri/backend-tests/Cargo.lock index e95ee7d3..a4041cda 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.lock +++ b/openless-all/app/src-tauri/backend-tests/Cargo.lock @@ -52,6 +52,45 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -303,6 +342,20 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -840,6 +893,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -932,6 +991,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -973,12 +1038,41 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -988,6 +1082,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -998,8 +1101,18 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" name = "openless-backend-tests" version = "0.1.0" dependencies = [ + "base64", "openless-core", + "rcgen", + "rustls", + "serde", + "serde_json", + "sha2", + "tempfile", + "time", "tokio", + "uuid", + "x509-parser", ] [[package]] @@ -1065,6 +1178,16 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1331,6 +1454,20 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1470,6 +1607,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1809,6 +1955,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -1817,6 +1964,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -2358,6 +2515,24 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -2377,6 +2552,15 @@ dependencies = [ "lzma-sys", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.toml b/openless-all/app/src-tauri/backend-tests/Cargo.toml index 9a307a76..4588a126 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.toml +++ b/openless-all/app/src-tauri/backend-tests/Cargo.toml @@ -8,3 +8,14 @@ publish = false [dependencies] openless-core = { path = "../../crates/openless-core" } tokio = { version = "1", features = ["rt-multi-thread"] } + +rcgen = { version = "0.13", features = ["x509-parser"] } +x509-parser = { version = "0.16", features = ["verify"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +time = "0.3" +tempfile = "3" +base64 = "0.22" +sha2 = "0.10" +uuid = { version = "1", features = ["v4"] } diff --git a/openless-all/app/src-tauri/backend-tests/tests/remote_tls.rs b/openless-all/app/src-tauri/backend-tests/tests/remote_tls.rs new file mode 100644 index 00000000..a2c4ea46 --- /dev/null +++ b/openless-all/app/src-tauri/backend-tests/tests/remote_tls.rs @@ -0,0 +1,3 @@ +// Exercise the production TLS implementation without linking the desktop UI. +#[path = "../../src/remote_server/tls_identity.rs"] +mod tls_identity; diff --git a/openless-all/app/src-tauri/src/remote_server/assets/app.js b/openless-all/app/src-tauri/src/remote_server/assets/app.js index 66554286..e515a58c 100644 --- a/openless-all/app/src-tauri/src/remote_server/assets/app.js +++ b/openless-all/app/src-tauri/src/remote_server/assets/app.js @@ -26,7 +26,7 @@ offlineTitle: '连接已断开', offlineSub: '与电脑的连接已中断。', btnReconnect: '重新连接', - certTip: '首次访问浏览器会提示“连接不安全”(本地自签名证书)。Android Chrome:点“高级”→“继续前往”;iOS Safari:点“显示详情”→“访问此网站”。', + certTip: "如遇证书提示,请核对地址与电脑显示一致,再按“首次设置:信任此电脑”完成安装和完全信任。", tipToggle: '点击大按钮开始录音,再次点击结束并识别。', tipHold: '按住大按钮说话,松开结束并识别。', labelToggleIdle: '点击开始', @@ -57,10 +57,12 @@ pcmQueueOverflow: '❌ 音频缓存已满,请重试。', micUnknown: '❌ 无法启动录音{name}。', errGeneric: '发生错误', - helpTitle: '连不上?多半是手机没信任证书', - helpAndroid: '① 安卓 / 一般情况:用浏览器无痕模式打开本页,出现“不安全”警告时选“继续前往”,再输入配对码连接。', - helpIos: '② iOS Safari:用无痕模式打开本页,出现“不安全”提示时点“显示详情 → 访问此网站”,再输入配对码连接(无需安装证书)。', - helpDownloadCert: '⬇ 下载并安装证书', + helpTitle: "首次设置:信任此电脑", + helpAndroid: "安卓:下载 CA 证书,在系统设置的“安装证书 → CA 证书”中安装,再回到本页。菜单名称因设备而异。", + helpIos: "iPhone / iPad:下载描述文件,打开“设置 → 通用 → VPN 与设备管理”安装;再到“通用 → 关于本机 → 证书信任设置”,为 OpenLess Remote Input CA 打开“完全信任”,返回 Safari 刷新。每台电脑只需设置一次;旧版证书需要重新安装。", + helpDownloadCert: "↓ iPhone:下载描述文件", + helpDownloadAndroid: "↓ 安卓:下载 CA 证书", + helpTrustWarning: "首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。", helpCopyLink: '⧉ 复制链接', helpCopied: '已复制 ✓', copy: '复制', @@ -79,7 +81,7 @@ offlineTitle: '連線已中斷', offlineSub: '與電腦的連線已中斷。', btnReconnect: '重新連線', - certTip: '首次造訪瀏覽器會提示「連線不安全」(本機自簽憑證)。Android Chrome:點「進階」→「繼續前往」;iOS Safari:點「顯示詳細資訊」→「瀏覽此網站」。', + certTip: "如遇憑證提示,請核對位址與電腦顯示一致,再依「首次設定:信任這台電腦」完成安裝與完全信任。", tipToggle: '點擊大按鈕開始錄音,再次點擊結束並辨識。', tipHold: '按住大按鈕說話,放開結束並辨識。', labelToggleIdle: '點擊開始', @@ -110,10 +112,12 @@ pcmQueueOverflow: '❌ 音訊暫存已滿,請重試。', micUnknown: '❌ 無法啟動錄音{name}。', errGeneric: '發生錯誤', - helpTitle: '連不上?多半是手機沒信任憑證', - helpAndroid: '① 安卓 / 一般情況:用瀏覽器無痕模式開啟本頁,出現“不安全”警告時選“繼續前往”,再輸入配對碼連線。', - helpIos: '② iOS Safari:用無痕模式開啟本頁,出現“不安全”提示時點“顯示詳細資訊 → 瀏覽此網站”,再輸入配對碼連線(無需安裝憑證)。', - helpDownloadCert: '⬇ 下載並安裝憑證', + helpTitle: "首次設定:信任這台電腦", + helpAndroid: "Android:下載 CA 憑證,在系統設定的「安裝憑證 → CA 憑證」中安裝,再返回本頁。選單名稱依裝置而異。", + helpIos: "iPhone / iPad:下載描述檔,開啟「設定 → 一般 → VPN 與裝置管理」安裝;再到「一般 → 關於本機 → 憑證信任設定」,為 OpenLess Remote Input CA 開啟「完全信任」,返回 Safari 重新整理。每台電腦只需設定一次;舊版憑證需要重新安裝。", + helpDownloadCert: "↓ iPhone:下載描述檔", + helpDownloadAndroid: "↓ Android:下載 CA 憑證", + helpTrustWarning: "首次憑證下載無法驗證電腦身分,惡意區域網路裝置可能透過中間人攻擊替換根憑證。僅在可信任的家庭或私人網路中安裝,請勿在公共或共享網路操作。根憑證能簽發憑證,私密金鑰保存在這台電腦;不再使用時請從手機移除。", helpCopyLink: '⧉ 複製連結', helpCopied: '已複製 ✓', copy: '複製', @@ -132,7 +136,7 @@ offlineTitle: 'Disconnected', offlineSub: 'The connection to your computer was lost.', btnReconnect: 'Reconnect', - certTip: 'On first visit the browser will warn "Not secure" (local self-signed certificate). Android Chrome: tap "Advanced" → "Proceed"; iOS Safari: tap "Show Details" → "visit this website".', + certTip: "If a certificate warning appears, check that the address matches your computer, then follow “First-time setup: trust this computer” to install and fully trust the certificate.", tipToggle: 'Tap the big button to start recording, tap again to finish and transcribe.', tipHold: 'Hold the big button to talk, release to finish and transcribe.', labelToggleIdle: 'Tap to start', @@ -163,10 +167,12 @@ pcmQueueOverflow: '❌ Audio buffer is full. Please try again.', micUnknown: '❌ Could not start recording{name}.', errGeneric: 'An error occurred', - helpTitle: "Can't connect? The phone probably doesn't trust the certificate", - helpAndroid: '① Android / general: open this page in an incognito tab, choose "Proceed" on the "Not secure" warning, then enter the pairing code.', - helpIos: '② iOS Safari: open this page in an incognito tab; on the "Not Private" warning tap "Show Details → visit this website", then enter the code (no certificate install needed).', - helpDownloadCert: '⬇ Download & install cert', + helpTitle: "First-time setup: trust this computer", + helpAndroid: "Android: download the CA certificate, install it under system Settings → Install a certificate → CA certificate, then return here. Menu names vary by device.", + helpIos: "iPhone / iPad: download the profile and install it in Settings → General → VPN & Device Management. Then enable full trust for OpenLess Remote Input CA in General → About → Certificate Trust Settings and reload Safari. Set up once per computer; certificates from older versions need this one-time replacement.", + helpDownloadCert: "↓ iPhone: download profile", + helpDownloadAndroid: "↓ Android: download CA", + helpTrustWarning: "The initial certificate download cannot verify the computer's identity; a malicious device on the LAN could replace the root certificate in a man-in-the-middle attack. Install it only on a trusted home or private network, never on a public or shared network. The root CA can issue certificates and its private key stays on this computer; remove it from your phone when no longer needed.", helpCopyLink: '⧉ Copy link', helpCopied: 'Copied ✓', copy: 'Copy', @@ -185,7 +191,7 @@ offlineTitle: '接続が切断されました', offlineSub: 'パソコンとの接続が切断されました。', btnReconnect: '再接続', - certTip: '初回アクセス時、ブラウザに「保護されていません」と表示されます(ローカル自己署名証明書)。Android Chrome:「詳細設定」→「アクセスする」、iOS Safari:「詳細を表示」→「このWebサイトを閲覧」をタップしてください。', + certTip: "証明書の警告が表示されたら、アドレスがコンピュータの表示と一致することを確認し、「初回設定:このコンピュータを信頼」の手順でインストールと完全な信頼を行ってください。", tipToggle: '大きいボタンをタップして録音開始、もう一度タップで終了して認識します。', tipHold: '大きいボタンを長押しして話し、離すと終了して認識します。', labelToggleIdle: 'タップで開始', @@ -216,10 +222,12 @@ pcmQueueOverflow: '❌ 音声バッファがいっぱいです。もう一度お試しください。', micUnknown: '❌ 録音を開始できませんでした{name}。', errGeneric: 'エラーが発生しました', - helpTitle: '接続できない?多くは証明書が信頼されていません', - helpAndroid: '① Android / 一般:ブラウザのシークレットモードで本ページを開き、「保護されていません」で「アクセスする」を選び、ペアリングコードを入力。', - helpIos: '② iOS Safari:シークレットモードで本ページを開き、「安全ではありません」で「詳細を表示 → このWebサイトにアクセス」をタップしてコードを入力(証明書のインストール不要)。', - helpDownloadCert: '⬇ 証明書をインストール', + helpTitle: "初回設定:このコンピュータを信頼", + helpAndroid: "Android:CA 証明書をダウンロードし、システム設定の「証明書のインストール → CA 証明書」でインストールしてから戻ってください。項目名は端末により異なります。", + helpIos: "iPhone / iPad:プロファイルをダウンロードし、「設定 → 一般 → VPN とデバイス管理」でインストールします。次に「一般 → 情報 → 証明書信頼設定」で OpenLess Remote Input CA を完全に信頼し、Safari を再読み込みしてください。各コンピュータで一度だけ必要です。旧バージョンの証明書は一度入れ替えてください。", + helpDownloadCert: "↓ iPhone:プロファイルをダウンロード", + helpDownloadAndroid: "↓ Android:CA をダウンロード", + helpTrustWarning: "初回の証明書ダウンロードではコンピューターの身元を確認できず、LAN 上の悪意あるデバイスが中間者攻撃でルート証明書を置き換える可能性があります。信頼できる家庭内またはプライベートネットワークでのみインストールし、公共または共有ネットワークでは操作しないでください。ルート CA は証明書を発行でき、秘密鍵はこのコンピューターに保存されます。不要になったらスマートフォンから削除してください。", helpCopyLink: '⧉ リンクをコピー', helpCopied: 'コピーしました ✓', copy: 'コピー', @@ -238,7 +246,7 @@ offlineTitle: '연결이 끊겼습니다', offlineSub: '컴퓨터와의 연결이 끊겼습니다.', btnReconnect: '다시 연결', - certTip: '처음 접속하면 브라우저에 "안전하지 않음" 경고가 표시됩니다(로컬 자체 서명 인증서). Android Chrome: "고급" → "계속 진행"; iOS Safari: "세부정보 표시" → "이 웹사이트 방문"을 탭하세요.', + certTip: "인증서 경고가 나타나면 주소가 컴퓨터에 표시된 주소와 일치하는지 확인한 뒤 “최초 설정: 이 컴퓨터 신뢰”에 따라 인증서를 설치하고 완전히 신뢰하세요.", tipToggle: '큰 버튼을 탭하여 녹음을 시작하고, 다시 탭하면 종료 후 인식합니다.', tipHold: '큰 버튼을 길게 눌러 말하고, 떼면 종료 후 인식합니다.', labelToggleIdle: '탭하여 시작', @@ -269,10 +277,12 @@ pcmQueueOverflow: '❌ 오디오 버퍼가 가득 찼습니다. 다시 시도하세요.', micUnknown: '❌ 녹음을 시작할 수 없습니다{name}.', errGeneric: '오류가 발생했습니다', - helpTitle: '연결이 안 되나요? 대개 인증서를 신뢰하지 않아서입니다', - helpAndroid: '① Android / 일반: 시크릿 모드로 이 페이지를 열고 "안전하지 않음" 경고에서 "계속"을 선택한 뒤 페어링 코드를 입력하세요.', - helpIos: '② iOS Safari: 시크릿 모드로 이 페이지를 열고 "안전하지 않음" 경고에서 "세부사항 표시 → 이 웹사이트 방문"을 누른 뒤 코드를 입력하세요(인증서 설치 불필요).', - helpDownloadCert: '⬇ 인증서 설치', + helpTitle: "최초 설정: 이 컴퓨터 신뢰", + helpAndroid: "Android: CA 인증서를 다운로드하고 시스템 설정 → 인증서 설치 → CA 인증서에서 설치한 뒤 돌아오세요. 메뉴 이름은 기기마다 다릅니다.", + helpIos: "iPhone / iPad: 프로파일을 다운로드하고 설정 → 일반 → VPN 및 기기 관리에서 설치하세요. 일반 → 정보 → 인증서 신뢰 설정에서 OpenLess Remote Input CA를 완전히 신뢰한 뒤 Safari를 새로고침하세요. 컴퓨터마다 한 번만 설정하면 됩니다. 이전 버전의 인증서는 한 번 교체해야 합니다.", + helpDownloadCert: "↓ iPhone: 프로파일 다운로드", + helpDownloadAndroid: "↓ Android: CA 다운로드", + helpTrustWarning: "최초 인증서 다운로드에서는 컴퓨터의 신원을 확인할 수 없으며, LAN의 악성 기기가 중간자 공격으로 루트 인증서를 바꿀 수 있습니다. 신뢰할 수 있는 가정용 또는 사설 네트워크에서만 설치하고 공용 또는 공유 네트워크에서는 진행하지 마세요. 루트 CA는 인증서를 발급할 수 있고 개인 키는 이 컴퓨터에 저장됩니다. 더 이상 사용하지 않으면 휴대폰에서 제거하세요.", helpCopyLink: '⧉ 링크 복사', helpCopied: '복사됨 ✓', copy: '복사', @@ -838,7 +848,7 @@ } if (copyCertBtn) { copyCertBtn.addEventListener('click', function () { - var url = location.origin + '/cert.cer'; + var url = location.origin + '/cert.mobileconfig'; var ok = function () { copyCertBtn.textContent = L.helpCopied; setTimeout(function () { copyCertBtn.textContent = L.helpCopyLink; }, 1500); diff --git a/openless-all/app/src-tauri/src/remote_server/assets/index.html b/openless-all/app/src-tauri/src/remote_server/assets/index.html index 05c26cbb..14f6a12f 100644 --- a/openless-all/app/src-tauri/src/remote_server/assets/index.html +++ b/openless-all/app/src-tauri/src/remote_server/assets/index.html @@ -35,16 +35,18 @@
- - + diff --git a/openless-all/app/src-tauri/src/remote_server/assets/style.css b/openless-all/app/src-tauri/src/remote_server/assets/style.css index 3b3ab4fd..36a45958 100644 --- a/openless-all/app/src-tauri/src/remote_server/assets/style.css +++ b/openless-all/app/src-tauri/src/remote_server/assets/style.css @@ -243,7 +243,9 @@ body { font-weight: 600; color: var(--ink-2); margin-bottom: 10px; + cursor: pointer; } +.help:not([open]) .help-title { margin-bottom: 0; } .help-step { font-size: 12.5px; color: var(--ink-3); diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs index 6c2352fe..e78df6e9 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -5,9 +5,10 @@ //! 手机录音以 16k/单声道/16-bit LE PCM 经 WebSocket 实时推回 PC,并通过共享 //! [`openless_core::OpenLessBackend`] 的 external-audio seam 进入同一听写管线。 //! -//! 关键约束:浏览器 `getUserMedia` 仅在安全上下文可用,所以必须 HTTPS。证书用 -//! rcgen 自签名(SAN 含本机局域网 IP),手机首次访问需手动信任。TLS 走 ring -//! 后端(与项目 reqwest/tungstenite 一致,避免 aws-lc-sys 的 C 编译依赖)。 +//! Browser microphone access requires a secure context, so this server uses HTTPS. +//! A persistent per-installation CA signs a separate LAN server certificate. +//! Phones install and trust the CA once. TLS uses ring +//! (matching reqwest/tungstenite and avoiding an additional aws-lc-sys dependency). use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; @@ -172,106 +173,13 @@ pub fn access_urls(port: u16) -> Vec { // ───────────────────────── TLS ───────────────────────── -/// 自签名证书:持久化到磁盘并跨重启复用。否则每次启动证书都变 —— 手机(尤其 iOS -/// Safari)上一次信任过的证书立刻失效,wss 握手静默挂起,表现为"连接中"卡死。仅当 -/// 磁盘无证书 / 解析失败 / 当前局域网 IP 不在已存 SAN 列表里(换了网络)时才重新生成。 -/// 返回 (证书 DER 原始字节, 私钥)。 -fn load_or_generate_cert( - dir: Option<&std::path::Path>, - sans: &[String], -) -> Result<(Vec, rustls::pki_types::PrivateKeyDer<'static>), String> { - use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; - // 文件名带 schema 版本:证书结构变更(v4 改回非 CA 服务器证书)时旧文件自动失效、重新生成。 - const CERT_FILE: &str = "remote-cert-v4.der"; - const KEY_FILE: &str = "remote-key-v4.der"; - const SANS_FILE: &str = "remote-cert-sans-v4.txt"; - if let Some(dir) = dir { - if let (Ok(cert), Ok(key), Ok(saved)) = ( - std::fs::read(dir.join(CERT_FILE)), - std::fs::read(dir.join(KEY_FILE)), - std::fs::read_to_string(dir.join(SANS_FILE)), - ) { - let saved_set: std::collections::HashSet<&str> = saved.lines().collect(); - // 当前需要的 SAN 都在已存证书里 → 复用,证书保持稳定(手机信任一次长期有效)。 - if sans.iter().all(|s| saved_set.contains(s.as_str())) { - log::info!("[remote-input] reusing persisted self-signed server cert"); - return Ok((cert, PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key)))); - } - } - } - // 生成自签名服务器证书(SAN 含本机各局域网 IP)。主路径是浏览器页面级 - // “继续访问/访问此网站”例外;/cert.cer 与 /cert.mobileconfig 是手机系统级 - // 安装信任的兜底(部分浏览器的 wss 不复用页面级例外时使用)。 - let (cert_der, key_der) = { - use rcgen::{ - CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, KeyPair, - KeyUsagePurpose, - }; - let mut params = - CertificateParams::new(sans.to_vec()).map_err(|e| format!("rcgen params: {e}"))?; - // 关键:做成普通服务器证书(非 CA,rcgen 默认即 NoCa)。iOS Safari 用页面级 - // “访问此网站”即可信任、无需安装证书 —— 这正是之前一直能用的方式。把证书做成 CA - // 反而会让 iOS 拒绝页面级例外(CA 不能直接当服务器证书),导致一直超时。 - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "OpenLess Remote Input"); - dn.push(DnType::OrganizationName, "OpenLess"); - params.distinguished_name = dn; - params.key_usages.push(KeyUsagePurpose::DigitalSignature); - params - .extended_key_usages - .push(ExtendedKeyUsagePurpose::ServerAuth); - let key_pair = KeyPair::generate().map_err(|e| format!("rcgen keypair: {e}"))?; - let cert = params - .self_signed(&key_pair) - .map_err(|e| format!("rcgen self_signed: {e}"))?; - (cert.der().as_ref().to_vec(), key_pair.serialize_der()) - }; - if let Some(dir) = dir { - let _ = std::fs::create_dir_all(dir); - let _ = std::fs::write(dir.join(CERT_FILE), &cert_der); - let _ = std::fs::write(dir.join(KEY_FILE), &key_der); - // 私钥收紧为 0600:app 配置目录通常已是用户私有,但多用户/共享主机上 - // 默认 umask 可能给到组/其他用户可读。Windows 下 %APPDATA% 的 ACL - // 本身仅限本用户,无对应权限位可设。 - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions( - dir.join(KEY_FILE), - std::fs::Permissions::from_mode(0o600), - ); - } - let _ = std::fs::write(dir.join(SANS_FILE), sans.join("\n")); - log::info!("[remote-input] generated new self-signed server cert (SAN={sans:?})"); - } - Ok(( - cert_der, - PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)), - )) -} - -fn build_server_config( - cert_der: Vec, - key_der: rustls::pki_types::PrivateKeyDer<'static>, -) -> Result, String> { - let provider = Arc::new(rustls::crypto::ring::default_provider()); - let config = rustls::ServerConfig::builder_with_provider(provider) - .with_safe_default_protocol_versions() - .map_err(|e| format!("tls protocol: {e}"))? - .with_no_client_auth() - .with_single_cert( - vec![rustls::pki_types::CertificateDer::from(cert_der)], - key_der, - ) - .map_err(|e| format!("tls cert: {e}"))?; - Ok(Arc::new(config)) -} +mod tls_identity; // ───────────────────────── 启动 ───────────────────────── struct WsState { backend: Arc, - /// 自签名证书的 DER 原始字节,供 /cert.cer 下载给手机安装信任。 + /// Public CA certificate offered for phone installation; never the server leaf or key. cert_der: Vec, /// 服务关停广播的接收端,每条 WS 连接 clone 一份并在主循环 select 监听。 conn_shutdown_rx: tokio::sync::watch::Receiver, @@ -336,10 +244,13 @@ fn build_router(state: Arc) -> Router { "/cert.cer", get(|State(state): State>| async move { ( - [( - axum::http::header::CONTENT_TYPE, - "application/x-x509-ca-cert", - )], + [ + ( + axum::http::header::CONTENT_TYPE, + "application/x-x509-ca-cert", + ), + (axum::http::header::CACHE_CONTROL, "no-store"), + ], state.cert_der.clone(), ) }), @@ -372,53 +283,17 @@ async fn index_handler(State(state): State>) -> impl IntoResponse { ) } -/// 极简标准 base64:构造 .mobileconfig 时把证书 DER 编码进 XML,避免引入额外依赖。 -fn base64_encode(data: &[u8]) -> String { - const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut out = String::with_capacity((data.len() + 2) / 3 * 4); - for chunk in data.chunks(3) { - let b0 = chunk[0]; - let b1 = *chunk.get(1).unwrap_or(&0); - let b2 = *chunk.get(2).unwrap_or(&0); - out.push(T[(b0 >> 2) as usize] as char); - out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); - out.push(if chunk.len() > 1 { - T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char - } else { - '=' - }); - out.push(if chunk.len() > 2 { - T[(b2 & 0x3f) as usize] as char - } else { - '=' - }); - } - out -} - -/// iOS 配置描述文件:把证书包成 .mobileconfig。Safari 点击后凭 content-type -/// (application/x-apple-aspen-config) 直接进入“安装描述文件”流程,比裸 .cer 顺滑、 -/// 也不会把当前页面导航走。安装后仍需到「设置→通用→关于本机→证书信任设置」打开完全信任。 -/// -/// 安全边界:PayloadType `com.apple.security.root` 只是 iOS 安装证书的固定入口, -/// 证书本身是非 CA 的纯服务器证书(rcgen NoCa + EKU=ServerAuth,见 -/// load_or_generate_cert)——不含签发能力,无法用来给其他域名签证书做 MITM。 -/// 信任它的影响范围仅限「持有本机私钥者可冒充 SAN 里列出的本机局域网 IP」, -/// 私钥只存在用户 PC 的应用配置目录。设置页 certTrustWarning 同步向用户说明。 +// iOS still requires the user to install the profile and enable full trust. async fn mobileconfig_handler(State(state): State>) -> impl IntoResponse { - let b64 = base64_encode(&state.cert_der); - let xml = format!( - r#" - -PayloadContentPayloadCertificateFileNameopenless.cerPayloadContent{b64}PayloadTypecom.apple.security.rootPayloadIdentifiercom.openless.remote-input.certPayloadUUIDA1B2C3D4-0001-4000-8000-000000000001PayloadVersion1PayloadDisplayNameOpenLess Remote Input CertificatePayloadDisplayNameOpenLess Remote InputPayloadIdentifiercom.openless.remote-inputPayloadTypeConfigurationPayloadUUIDA1B2C3D4-0002-4000-8000-000000000002PayloadVersion1"#, - b64 = b64 - ); ( - [( - axum::http::header::CONTENT_TYPE, - "application/x-apple-aspen-config", - )], - xml, + [ + ( + axum::http::header::CONTENT_TYPE, + "application/x-apple-aspen-config", + ), + (axum::http::header::CACHE_CONTROL, "no-store"), + ], + tls_identity::mobileconfig(&state.cert_der), ) } @@ -428,11 +303,15 @@ pub async fn start(cfg: RemoteServerConfig) -> Result, + ca_key: Vec, + leaf_cert: Vec, + leaf_key: Vec, +} + +pub(super) struct TlsIdentity { + /// Only this public CA certificate is offered for phone installation. + pub trust_cert: Vec, + pub server_config: Arc, +} + +fn key_der(bytes: Vec) -> PrivateKeyDer<'static> { + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(bytes)) +} + +fn matching_key(cert: &[u8], key: &[u8]) -> Result<(), String> { + rustls::sign::CertifiedKey::from_der( + vec![CertificateDer::from(cert.to_vec())], + key_der(key.to_vec()), + &rustls::crypto::ring::default_provider(), + ) + .map(|_| ()) + .map_err(|error| format!("remote TLS certificate/key mismatch: {error}")) +} + +fn parse_cert(cert: &[u8]) -> Result { + CertificateParams::from_ca_cert_der(&CertificateDer::from(cert)) + .map_err(|error| format!("invalid remote TLS certificate: {error}")) +} + +fn read_identity(path: &Path) -> Result, String> { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("cannot inspect remote TLS identity: {error}")), + }; + if !metadata.is_file() || metadata.len() > MAX_IDENTITY_BYTES { + return Err("remote TLS identity must be a regular file under 64 KiB".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("cannot protect remote TLS identity: {error}"))?; + } + let mut bytes = Vec::new(); + std::fs::File::open(path) + .and_then(|file| file.take(MAX_IDENTITY_BYTES + 1).read_to_end(&mut bytes)) + .map_err(|error| format!("cannot read remote TLS identity: {error}"))?; + if bytes.len() as u64 > MAX_IDENTITY_BYTES { + return Err("remote TLS identity exceeds 64 KiB".into()); + } + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| "remote TLS identity is damaged; restore it from backup instead of replacing phone trust".into()) +} + +fn save_identity( + path: &Path, + identity: &StoredIdentity, + first_install: bool, +) -> Result<(), String> { + let directory = path.parent().ok_or("remote TLS directory is unavailable")?; + let bytes = serde_json::to_vec(identity).map_err(|error| error.to_string())?; + // tempfile creates private files (0600 on Unix). Windows inherits the user's + // application-data ACL. Keep the key and certificates in one atomic bundle. + let mut file = tempfile::NamedTempFile::new_in(directory) + .map_err(|error| format!("cannot create remote TLS identity: {error}"))?; + file.write_all(&bytes) + .and_then(|_| file.as_file().sync_all()) + .map_err(|error| format!("cannot write remote TLS identity: {error}"))?; + if first_install { + file.persist_noclobber(path) + } else { + file.persist(path) + } + .map_err(|error| format!("cannot persist remote TLS identity: {error}"))?; + Ok(()) +} + +fn create_ca(now: OffsetDateTime) -> Result { + let mut params = CertificateParams::default(); + let mut name = DistinguishedName::new(); + name.push(DnType::CommonName, "OpenLess Remote Input CA"); + name.push(DnType::OrganizationName, "OpenLess"); + params.distinguished_name = name; + params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + params.not_before = now - Duration::days(1); + params.not_after = now + Duration::days(3650); + let key = KeyPair::generate().map_err(|error| error.to_string())?; + let cert = params + .self_signed(&key) + .map_err(|error| error.to_string())?; + Ok(StoredIdentity { + ca_cert: cert.der().to_vec(), + ca_key: key.serialize_der(), + leaf_cert: Vec::new(), + leaf_key: Vec::new(), + }) +} + +fn valid_leaf(identity: &StoredIdentity, sans: &[String], now: OffsetDateTime) -> bool { + let Ok(params) = parse_cert(&identity.leaf_cert) else { + return false; + }; + let Ok(required) = CertificateParams::new(sans.to_vec()) else { + return false; + }; + if !matches!(params.is_ca, IsCa::NoCa | IsCa::ExplicitNoCa) + || params.not_before > now + || params.not_after < now + Duration::days(30) + || !required + .subject_alt_names + .iter() + .all(|san| params.subject_alt_names.contains(san)) + || matching_key(&identity.leaf_cert, &identity.leaf_key).is_err() + { + return false; + } + // Validate the cached leaf against the stored trust anchor, not a sidecar + // list of SANs. A stale or mismatched leaf must never reach the listener. + use rustls::client::danger::ServerCertVerifier; + let mut roots = rustls::RootCertStore::empty(); + if roots + .add(CertificateDer::from(identity.ca_cert.clone())) + .is_err() + { + return false; + } + let Ok(verifier) = rustls::client::WebPkiServerVerifier::builder_with_provider( + Arc::new(roots), + Arc::new(rustls::crypto::ring::default_provider()), + ) + .build() else { + return false; + }; + let Some(name) = sans + .first() + .and_then(|name| rustls::pki_types::ServerName::try_from(name.clone()).ok()) + else { + return false; + }; + let Ok(timestamp) = u64::try_from(now.unix_timestamp()) else { + return false; + }; + verifier + .verify_server_cert( + &CertificateDer::from(identity.leaf_cert.as_slice()), + &[], + &name, + &[], + rustls::pki_types::UnixTime::since_unix_epoch(std::time::Duration::from_secs( + timestamp, + )), + ) + .is_ok() +} + +pub(super) fn load_or_create(directory: &Path, sans: &[String]) -> Result { + load_at(directory, sans, OffsetDateTime::now_utc()) +} + +fn load_at(directory: &Path, sans: &[String], now: OffsetDateTime) -> Result { + if sans.is_empty() { + return Err("remote TLS requires at least one server name".into()); + } + std::fs::create_dir_all(directory) + .map_err(|error| format!("cannot create remote TLS directory: {error}"))?; + let path = directory.join(IDENTITY_FILE); + let saved = read_identity(&path)?; + let first_install = saved.is_none(); + let mut identity = match saved { + Some(identity) => identity, + None => create_ca(now)?, + }; + matching_key(&identity.ca_cert, &identity.ca_key)?; + let (remainder, ca_x509) = x509_parser::parse_x509_certificate(&identity.ca_cert) + .map_err(|_| "invalid remote TLS CA encoding")?; + if !remainder.is_empty() + || ca_x509.issuer() != ca_x509.subject() + || ca_x509.verify_signature(None).is_err() + { + return Err("remote TLS CA signature is invalid; restore the identity from backup".into()); + } + let ca_params = parse_cert(&identity.ca_cert)?; + if !matches!(ca_params.is_ca, IsCa::Ca(_)) + || !ca_params.key_usages.contains(&KeyUsagePurpose::KeyCertSign) + || ca_params.not_before > now + || ca_params.not_after <= now + Duration::days(30) + { + return Err("remote TLS CA is invalid or expires soon; restore a valid identity or explicitly reset it and trust the new certificate on each phone".into()); + } + if !valid_leaf(&identity, sans, now) { + let ca_key = KeyPair::try_from(&key_der(identity.ca_key.clone())) + .map_err(|error| error.to_string())?; + let ca_expiry = ca_params.not_after; + let ca = ca_params + .self_signed(&ca_key) + .map_err(|error| error.to_string())?; + let key = KeyPair::generate().map_err(|error| error.to_string())?; + let mut params = + CertificateParams::new(sans.to_vec()).map_err(|error| error.to_string())?; + let mut name = DistinguishedName::new(); + name.push(DnType::CommonName, "OpenLess Remote Input"); + name.push(DnType::OrganizationName, "OpenLess"); + params.distinguished_name = name; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + params.not_before = now - Duration::days(1); + params.not_after = (now + Duration::days(365)).min(ca_expiry); + params.use_authority_key_identifier_extension = true; + let cert = params + .signed_by(&key, &ca, &ca_key) + .map_err(|error| error.to_string())?; + identity.leaf_cert = cert.der().to_vec(); + identity.leaf_key = key.serialize_der(); + if !valid_leaf(&identity, sans, now) { + return Err("generated remote TLS certificate failed validation".into()); + } + save_identity(&path, &identity, first_install)?; + } + let config = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .map_err(|error| error.to_string())? + .with_no_client_auth() + .with_single_cert( + vec![CertificateDer::from(identity.leaf_cert)], + key_der(identity.leaf_key), + ) + .map_err(|error| format!("remote TLS config: {error}"))?; + Ok(TlsIdentity { + trust_cert: identity.ca_cert, + server_config: Arc::new(config), + }) +} + +/// The profile contains a public CA only. IDs include its fingerprint so two +/// computers can be trusted without replacing each other's iOS profiles. +pub(super) fn mobileconfig(cert: &[u8]) -> String { + use base64::Engine; + use sha2::{Digest, Sha256}; + let fingerprint = format!("{:x}", Sha256::digest(cert)); + let b64 = base64::engine::general_purpose::STANDARD.encode(cert); + format!( + r#" + + +PayloadContent +PayloadCertificateFileNameopenless-ca.cer +PayloadContent{b64} +PayloadTypecom.apple.security.root +PayloadIdentifiercom.openless.remote-input.{fingerprint}.cert +PayloadUUID{cert_uuid} +PayloadVersion1 +PayloadDisplayNameOpenLess Remote Input CA ({short}) + +PayloadDisplayNameOpenLess Remote Input ({short}) +PayloadDescriptionTrust only a profile downloaded from your own computer. After installation, enable full trust in Settings > General > About > Certificate Trust Settings. This CA can issue certificates; remove this profile when you stop using remote input. +PayloadIdentifiercom.openless.remote-input.{fingerprint} +PayloadTypeConfiguration +PayloadUUID{profile_uuid} +PayloadVersion1 +"#, + short = &fingerprint[..8], + cert_uuid = uuid::Uuid::new_v4(), + profile_uuid = uuid::Uuid::new_v4(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names() -> Vec { + vec!["localhost".into(), "127.0.0.1".into(), "192.168.1.2".into()] + } + + fn stored(directory: &Path) -> StoredIdentity { + read_identity(&directory.join(IDENTITY_FILE)) + .unwrap() + .unwrap() + } + + fn verify_server(identity: &TlsIdentity, trusted_ca: &[u8], name: &str) -> bool { + let mut roots = rustls::RootCertStore::empty(); + roots + .add(CertificateDer::from(trusted_ca.to_vec())) + .unwrap(); + let client_config = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .unwrap() + .with_root_certificates(roots) + .with_no_client_auth(); + let mut client = rustls::ClientConnection::new( + Arc::new(client_config), + name.to_owned().try_into().unwrap(), + ) + .unwrap(); + let mut server = + rustls::ServerConnection::new(Arc::clone(&identity.server_config)).unwrap(); + for _ in 0..10 { + let mut bytes = Vec::new(); + client.write_tls(&mut bytes).unwrap(); + server.read_tls(&mut bytes.as_slice()).unwrap(); + if server.process_new_packets().is_err() { + return false; + } + bytes.clear(); + server.write_tls(&mut bytes).unwrap(); + client.read_tls(&mut bytes.as_slice()).unwrap(); + if client.process_new_packets().is_err() { + return false; + } + if !client.is_handshaking() && !server.is_handshaking() { + return true; + } + } + false + } + + #[test] + fn restart_reuses_identity_and_tls_works_with_only_the_downloaded_ca() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create(dir.path(), &names()).unwrap(); + let before = std::fs::read(dir.path().join(IDENTITY_FILE)).unwrap(); + let second = load_or_create(dir.path(), &names()).unwrap(); + assert_eq!( + before, + std::fs::read(dir.path().join(IDENTITY_FILE)).unwrap() + ); + assert_eq!(first.trust_cert, second.trust_cert); + for name in names() { + assert!(verify_server(&second, &first.trust_cert, &name)); + } + assert!(!verify_server(&second, &first.trust_cert, "192.168.1.99")); + let saved = stored(dir.path()); + let ca = parse_cert(&saved.ca_cert).unwrap(); + let leaf = parse_cert(&saved.leaf_cert).unwrap(); + assert!(matches!( + ca.is_ca, + IsCa::Ca(BasicConstraints::Constrained(0)) + )); + assert!(matches!(leaf.is_ca, IsCa::NoCa | IsCa::ExplicitNoCa)); + assert!(leaf + .extended_key_usages + .contains(&ExtendedKeyUsagePurpose::ServerAuth)); + assert!(leaf.not_after - leaf.not_before <= Duration::days(366)); + } + + #[test] + fn virtual_adapter_and_lan_changes_preserve_phone_trust() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create(dir.path(), &names()).unwrap(); + let before = stored(dir.path()); + let mut changed = names(); + changed.push("172.26.112.1".into()); + changed.push("192.168.2.3".into()); + let second = load_or_create(dir.path(), &changed).unwrap(); + let after = stored(dir.path()); + assert_eq!(before.ca_cert, after.ca_cert); + assert_eq!(before.ca_key, after.ca_key); + assert_ne!(before.leaf_cert, after.leaf_cert); + assert!(verify_server(&second, &first.trust_cert, "192.168.2.3")); + // Removing an adapter does not require another leaf or CA. + load_or_create(dir.path(), &names()).unwrap(); + assert_eq!(after.leaf_cert, stored(dir.path()).leaf_cert); + } + + #[test] + fn renews_expiring_leaf_without_rotating_ca() { + let dir = tempfile::tempdir().unwrap(); + let now = OffsetDateTime::now_utc(); + load_at(dir.path(), &names(), now).unwrap(); + let before = stored(dir.path()); + load_at(dir.path(), &names(), now + Duration::days(340)).unwrap(); + let after = stored(dir.path()); + assert_eq!(before.ca_cert, after.ca_cert); + assert_ne!(before.leaf_cert, after.leaf_cert); + assert!(parse_cert(&after.leaf_cert).unwrap().not_after > now + Duration::days(700)); + } + + #[test] + fn repairs_bad_leaf_but_never_replaces_damaged_or_mismatched_ca() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create(dir.path(), &names()).unwrap(); + let path = dir.path().join(IDENTITY_FILE); + let mut saved = stored(dir.path()); + saved.leaf_cert = vec![0]; + save_identity(&path, &saved, false).unwrap(); + let repaired = load_or_create(dir.path(), &names()).unwrap(); + assert!(verify_server(&repaired, &first.trust_cert, "localhost")); + saved = stored(dir.path()); + let mut damaged = stored(dir.path()); + *damaged.ca_cert.last_mut().unwrap() ^= 1; + save_identity(&path, &damaged, false).unwrap(); + assert!(load_or_create(dir.path(), &names()).is_err()); + assert_eq!(damaged.ca_cert, stored(dir.path()).ca_cert); + saved.ca_key = KeyPair::generate().unwrap().serialize_der(); + save_identity(&path, &saved, false).unwrap(); + let before = std::fs::read(&path).unwrap(); + assert!(load_or_create(dir.path(), &names()).is_err()); + assert_eq!(before, std::fs::read(&path).unwrap()); + std::fs::write(&path, b"interrupted write").unwrap(); + assert!(load_or_create(dir.path(), &names()).is_err()); + assert_eq!( + b"interrupted write", + std::fs::read(&path).unwrap().as_slice() + ); + } + + #[test] + fn refuses_expired_ca_instead_of_silently_revoking_trust() { + let dir = tempfile::tempdir().unwrap(); + let now = OffsetDateTime::now_utc(); + load_at(dir.path(), &names(), now).unwrap(); + let before = std::fs::read(dir.path().join(IDENTITY_FILE)).unwrap(); + assert!(load_at(dir.path(), &names(), now + Duration::days(3651)).is_err()); + assert_eq!( + before, + std::fs::read(dir.path().join(IDENTITY_FILE)).unwrap() + ); + } + + #[test] + fn migration_keeps_legacy_files_and_profiles_only_contain_public_ca() { + use base64::Engine; + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("remote-cert-v4.der"), b"legacy cert").unwrap(); + std::fs::write(dir.path().join("remote-key-v4.der"), b"legacy key").unwrap(); + let first = load_or_create(dir.path(), &names()).unwrap(); + let profile = mobileconfig(&first.trust_cert); + let saved = stored(dir.path()); + let encoded = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes); + assert!(profile.contains(&format!("{}", encoded(&first.trust_cert)))); + assert!(!profile.contains(&encoded(&saved.ca_key))); + assert!(!profile.contains(&encoded(&saved.leaf_key))); + assert!(!profile.contains(&encoded(&saved.leaf_cert))); + assert_eq!( + std::fs::read(dir.path().join("remote-cert-v4.der")).unwrap(), + b"legacy cert" + ); + assert_eq!( + std::fs::read(dir.path().join("remote-key-v4.der")).unwrap(), + b"legacy key" + ); + let other = tempfile::tempdir().unwrap(); + let second = load_or_create(other.path(), &names()).unwrap(); + assert_ne!(first.trust_cert, second.trust_cert); + assert!(!verify_server(&second, &first.trust_cert, "localhost")); + assert_ne!(profile, mobileconfig(&second.trust_cert)); + } + + #[test] + fn persistence_errors_never_fall_back_to_an_ephemeral_identity() { + let dir = tempfile::tempdir().unwrap(); + let blocked = dir.path().join("blocked"); + std::fs::write(&blocked, b"not a directory").unwrap(); + assert!(load_or_create(&blocked, &names()).is_err()); + std::fs::create_dir(dir.path().join(IDENTITY_FILE)).unwrap(); + assert!(load_or_create(dir.path(), &names()).is_err()); + assert!(load_or_create(dir.path(), &[]).is_err()); + } + + #[cfg(unix)] + #[test] + fn stored_private_keys_are_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + load_or_create(dir.path(), &names()).unwrap(); + assert_eq!( + std::fs::metadata(dir.path().join(IDENTITY_FILE)) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } +} diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 0b073f21..60fad289 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1435,9 +1435,9 @@ export const en: typeof zhCN = { portInUse: 'Port {{port}} is in use, please change it', startError: 'Failed to start the remote input service: {{reason}}', securityHint: 'Reachable only on the same LAN and requires the pairing code; turn it off when not in use.', - certHint: 'On first visit the browser warns the certificate is untrusted — choose "Proceed".', - certTrustWarning: - 'The certificate is only used by this PC’s remote input service (it cannot issue other certificates). Never trust certificates from unknown sources; remove it from your phone’s settings when no longer needed.', + certHint: "Open “First-time setup: trust this computer” on the phone to install and trust the certificate. Older versions require one-time setup; subsequent restarts and IP changes preserve trust.", + certTrustWarning: "The initial certificate download cannot verify the computer's identity; a malicious device on the LAN could replace the root certificate in a man-in-the-middle attack. Install it only on a trusted home or private network, never on a public or shared network. The root CA can issue certificates and its private key stays on this computer; remove it from your phone when no longer needed.", + certSetupLink: "Copy iPhone certificate link", waitingStart: 'The service is not running yet. Turn the switch off, then on again. Do not restart the app.', starting: 'Starting the remote input service…', urlsStale: 'These addresses come from the previous run and may be out of date.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 36a57b13..2467d088 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1395,9 +1395,9 @@ export const ja: typeof zhCN = { portInUse: 'ポート {{port}} は使用中です。変更してください', startError: 'リモート入力サービスの起動に失敗しました:{{reason}}', securityHint: '同一 LAN からのみアクセス可能で、ペアリングコードの入力が必要です。使わないときはオフにすることを推奨します。', - certHint: '初回アクセス時、ブラウザが証明書は信頼されていないと警告します。案内に従って「続行」を選択してください。', - certTrustWarning: - 'この証明書は本機のリモート入力サービス専用です(他の証明書を発行できません)。出所不明の証明書は信頼しないでください。不要になったらスマートフォンの設定から削除できます。', + certHint: "スマートフォンの「初回設定:このコンピュータを信頼」で証明書をインストールし信頼してください。旧バージョンからは一度設定が必要ですが、その後は再起動や IP 変更でも信頼が保持されます。", + certTrustWarning: "初回の証明書ダウンロードではコンピューターの身元を確認できず、LAN 上の悪意あるデバイスが中間者攻撃でルート証明書を置き換える可能性があります。信頼できる家庭内またはプライベートネットワークでのみインストールし、公共または共有ネットワークでは操作しないでください。ルート CA は証明書を発行でき、秘密鍵はこのコンピューターに保存されます。不要になったらスマートフォンから削除してください。", + certSetupLink: "iPhone 証明書リンクをコピー", waitingStart: 'サービスはまだ起動していません。スイッチを一度オフにしてからオンにしてください。アプリを再起動しないでください。', starting: 'リモート入力サービスを起動しています…', urlsStale: 'これらのアドレスは前回の起動時のもので、古くなっている可能性があります。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 09fd69a9..21423d9e 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1395,9 +1395,9 @@ export const ko: typeof zhCN = { portInUse: '포트 {{port}}이(가) 사용 중입니다. 변경하세요', startError: '원격 입력 서비스 시작에 실패했습니다: {{reason}}', securityHint: '같은 LAN에서만 접속 가능하며 페어링 코드 입력이 필요합니다. 사용하지 않을 때는 끄는 것을 권장합니다.', - certHint: '첫 접속 시 브라우저가 인증서를 신뢰할 수 없다고 경고합니다. 안내에 따라 "계속 진행"을 선택하세요.', - certTrustWarning: - '이 인증서는 이 PC의 원격 입력 서비스 전용입니다(다른 인증서를 발급할 수 없음). 출처를 알 수 없는 인증서는 신뢰하지 마세요. 더 이상 사용하지 않으면 휴대폰 설정에서 제거할 수 있습니다.', + certHint: "휴대폰에서 “최초 설정: 이 컴퓨터 신뢰”를 열고 인증서를 설치하고 신뢰하세요. 이전 버전에서는 한 번 설정해야 하며, 이후 재시작과 IP 변경 시 신뢰가 유지됩니다.", + certTrustWarning: "최초 인증서 다운로드에서는 컴퓨터의 신원을 확인할 수 없으며, LAN의 악성 기기가 중간자 공격으로 루트 인증서를 바꿀 수 있습니다. 신뢰할 수 있는 가정용 또는 사설 네트워크에서만 설치하고 공용 또는 공유 네트워크에서는 진행하지 마세요. 루트 CA는 인증서를 발급할 수 있고 개인 키는 이 컴퓨터에 저장됩니다. 더 이상 사용하지 않으면 휴대폰에서 제거하세요.", + certSetupLink: "iPhone 인증서 링크 복사", waitingStart: '서비스가 아직 시작되지 않았습니다. 스위치를 끈 다음 다시 켜세요. 앱을 다시 시작하지 마세요.', starting: '원격 입력 서비스를 시작하는 중입니다…', urlsStale: '이 주소는 이전 실행에서 가져온 것으로 최신이 아닐 수 있습니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index c8d176de..70e9bd6b 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1433,9 +1433,9 @@ export const zhCN = { portInUse: '端口 {{port}} 被占用,请更换', startError: '远程输入服务启动失败:{{reason}}', securityHint: '仅同一局域网可访问,需输入配对码;不用时建议关闭。', - certHint: '首次访问浏览器会提示证书不受信任,按提示选择"继续访问"。', - certTrustWarning: - '该证书仅用于本机远程输入服务(不能签发其他证书),请勿信任来源不明的证书;不再使用时可在手机系统设置中移除。', + certHint: "首次连接请在手机页面展开“首次设置:信任此电脑”,安装并信任证书。升级旧版需设置一次;以后重启和换 IP 会保留信任。", + certTrustWarning: "首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。", + certSetupLink: "复制 iPhone 证书链接", waitingStart: '服务尚未启动。请关闭开关再打开一次,不要重启软件。', starting: '正在启动远程输入服务…', urlsStale: '这些地址来自上次运行,可能已经过期。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 9ce75771..f34c8781 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1401,9 +1401,9 @@ export const zhTW: typeof zhCN = { portInUse: '連接埠 {{port}} 被佔用,請更換', startError: '遠端輸入服務啟動失敗:{{reason}}', securityHint: '僅同一區域網路可存取,需輸入配對碼;不用時建議關閉。', - certHint: '首次存取瀏覽器會提示憑證不受信任,按提示選擇「繼續存取」。', - certTrustWarning: - '該憑證僅用於本機遠端輸入服務(無法簽發其他憑證),請勿信任來源不明的憑證;不再使用時可在手機系統設定中移除。', + certHint: "首次連線請在手機頁面展開「首次設定:信任這台電腦」,安裝並信任憑證。升級舊版需設定一次;之後重新啟動和更換 IP 會保留信任。", + certTrustWarning: "首次憑證下載無法驗證電腦身分,惡意區域網路裝置可能透過中間人攻擊替換根憑證。僅在可信任的家庭或私人網路中安裝,請勿在公共或共享網路操作。根憑證能簽發憑證,私密金鑰保存在這台電腦;不再使用時請從手機移除。", + certSetupLink: "複製 iPhone 憑證連結", waitingStart: '服務尚未啟動。請關閉開關再打開一次,不要重啟軟體。', starting: '正在啟動遠端輸入服務…', urlsStale: '這些地址來自上次執行,可能已經過期。', diff --git a/openless-all/app/src/pages/settings/RemoteInputSection.tsx b/openless-all/app/src/pages/settings/RemoteInputSection.tsx index e4336915..d3c38cab 100644 --- a/openless-all/app/src/pages/settings/RemoteInputSection.tsx +++ b/openless-all/app/src/pages/settings/RemoteInputSection.tsx @@ -205,7 +205,7 @@ export function RemoteInputSection() { {status.urls.map((u) => (
{copied === u ? '✓' : '⧉'} +
))}