diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 97430e9f..c80926c7 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -662,6 +662,16 @@ mod platform { binding: HotkeyBinding, tx: Sender, ) -> Result, HotkeyInstallError> { + if std::env::var("XDG_SESSION_TYPE") + .ok() + .as_deref() + == Some("wayland") + { + return Err(install_error( + "wayland_unsupported", + "Wayland 暂不支持全局热键,请切到 X11 session 后再试", + )); + } let listener = start_listener_thread( binding, tx, diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index 51074a8c..9b4963c6 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -19,7 +19,7 @@ use crate::types::InsertStatus; const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(750); #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] -const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(150); +const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(750); pub struct TextInserter; diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs index 305fd061..3980ec2e 100644 --- a/openless-all/app/src-tauri/src/selection.rs +++ b/openless-all/app/src-tauri/src/selection.rs @@ -75,11 +75,24 @@ pub fn capture_selection() -> Option { } } - // 3. Linux:暂不支持 + // 3. Linux:best-effort 读 PRIMARY selection(wl-paste / xclip / xsel)。 #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] - { - let _ = source_app; - log::info!("[selection] platform unsupported, returning None"); + if let Some(text) = linux_selection::read_selected_text() { + let trimmed = text.trim(); + if !trimmed.is_empty() { + log::info!( + "[selection] linux primary selection OK ({} chars){}", + trimmed.chars().count(), + source_app + .as_deref() + .map(|a| format!(" front_app={a}")) + .unwrap_or_default() + ); + return Some(SelectionContext { + text: truncate_selection(trimmed), + source_app, + }); + } } None @@ -177,6 +190,42 @@ fn post_copy_shortcut() -> bool { windows_paste::send_ctrl_c().is_ok() } +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] +mod linux_selection { + use std::process::Command; + + const PRIMARY_SELECTION_COMMANDS: &[(&str, &[&str])] = &[ + ("wl-paste", &["--primary", "--no-newline"]), + ("xclip", &["-o", "-selection", "primary"]), + ("xsel", &["--primary", "--output"]), + ]; + + pub fn read_selected_text() -> Option { + for (bin, args) in PRIMARY_SELECTION_COMMANDS { + if let Some(text) = run_capture(bin, args) { + return Some(text); + } + } + log::info!( + "[selection] linux primary selection unavailable (wl-paste/xclip/xsel all failed)" + ); + None + } + + fn run_capture(bin: &str, args: &[&str]) -> Option { + let output = Command::new(bin).args(args).output().ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8(output.stdout).ok()?; + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.to_string()) + } +} + // ─────────────────────────── macOS AX read ─────────────────────────── #[cfg(target_os = "macos")] diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index f4a4a9b6..8e034c9a 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -350,7 +350,7 @@ impl HotkeyCapability { supports_side_specific_modifiers: true, explicit_fallback_available: false, status_hint: Some( - "Linux 仅 best-effort:不同桌面环境 / Wayland 组合可能限制全局热键。".into(), + "Linux 仅 best-effort:X11 可尝试 rdev 监听;Wayland 会明确提示暂不支持全局热键。".into(), ), } } diff --git a/openless-all/app/src/components/WindowChrome.tsx b/openless-all/app/src/components/WindowChrome.tsx index c4a1e84a..a572c5dc 100644 --- a/openless-all/app/src/components/WindowChrome.tsx +++ b/openless-all/app/src/components/WindowChrome.tsx @@ -14,13 +14,17 @@ import { type CSSProperties, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -export type OS = 'mac' | 'win'; +export type OS = 'mac' | 'win' | 'linux'; export function detectOS(): OS { if (typeof navigator === 'undefined') return 'mac'; - const ua = navigator.userAgent || ''; - if (/Mac|iPhone|iPad|iPod/.test(ua)) return 'mac'; - if (/Windows/.test(ua)) return 'win'; + const uaDataPlatform = ( + navigator as Navigator & { userAgentData?: { platform?: string } } + ).userAgentData?.platform ?? ''; + const hints = `${navigator.userAgent || ''} ${navigator.platform || ''} ${uaDataPlatform}`; + if (/Mac|iPhone|iPad|iPod/.test(hints)) return 'mac'; + if (/Windows|Win32|Win64/.test(hints)) return 'win'; + if (/Linux|X11|Wayland/.test(hints)) return 'linux'; return 'mac'; } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 3a23103c..4b17addc 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -212,12 +212,12 @@ export const en: typeof zhCN = { selectionAsk: { kicker: 'SELECTION ASK', title: 'Selection Ask', - desc: 'Select text in any app, press Cmd+Shift+; to open the panel, then press Option to record a question. Multi-turn follow-ups stay in the same panel until you close it.', + desc: 'Select text in any app, press {{hotkey}} to open the panel, then press {{recordHotkey}} to record a question. Multi-turn follow-ups stay in the same panel until you close it.', statusEnabled: 'Enabled', statusDisabled: 'Disabled', hotkey: { title: 'Hotkey to open the panel', - desc: 'This only opens / closes the panel. Recording inside the panel reuses your main Option dictation key. Pick "Disabled" to turn the feature off.', + desc: 'This only opens / closes the panel. Recording inside the panel reuses your main {{recordHotkey}} dictation key. Pick "Disabled" to turn the feature off.', optionDisabled: 'Disabled', chordWarning: '', }, @@ -229,8 +229,8 @@ export const en: typeof zhCN = { title: 'How to use', step1: 'Press「{{hotkey}}」any time to open the panel (no need to select first).', step2: 'Select text in any app (browser, Mail, IDE, PDF reader…).', - step3: 'Press **Option** (rightOption — same key you use for dictation) to start recording. Press Option again to stop and submit; the answer shows in the panel.', - step4: 'Keep asking follow-ups in the same panel: press Option again to record, press again to submit. You can re-select different text for the next turn or skip selection entirely.', + step3: 'Press **{{recordHotkey}}** to start recording. Press {{recordHotkey}} again to stop and submit; the answer shows in the panel.', + step4: 'Keep asking follow-ups in the same panel: press {{recordHotkey}} again to record, press {{recordHotkey}} again to submit. You can re-select different text for the next turn or skip selection entirely.', step5: 'Press Esc or the ✕ in the top-right to close — closing wipes the multi-turn history. Pressing「{{hotkey}}」again starts a fresh conversation.', windowTitle: 'Position, drag, and pin', windowDesc: 'The panel first opens above the recording capsule. The toolbar is draggable; once moved, it stays at the dragged position for the rest of the app session. The 📌 pin keeps the window open across follow-up turns.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index c3d4a4ed..b08578c2 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -210,12 +210,12 @@ export const zhCN = { selectionAsk: { kicker: 'SELECTION ASK', title: '划词追问', - desc: '选中任意 app 里的一段文字,按 Cmd+Shift+; 弹出浮窗,再按 Option 录音提问。支持多轮追问,浮窗一直保留直到你手动关。', + desc: '选中任意 app 里的一段文字,按 {{hotkey}} 弹出浮窗,再按 {{recordHotkey}} 录音提问。支持多轮追问,浮窗一直保留直到你手动关。', statusEnabled: '已启用', statusDisabled: '未启用', hotkey: { title: '弹出浮窗的快捷键', - desc: '只决定「打开 / 关闭」浮窗。浮窗里录音 / 提问统一用 Option(与你的主听写键复用)。选「不启用」则关闭整个功能。', + desc: '只决定「打开 / 关闭」浮窗。浮窗里录音 / 提问统一用 {{recordHotkey}}(与你的主听写键复用)。选「不启用」则关闭整个功能。', optionDisabled: '不启用', chordWarning: '', }, @@ -227,8 +227,8 @@ export const zhCN = { title: '使用方法', step1: '按「{{hotkey}}」在任意时刻打开浮窗(不需要先选文字)。', step2: '在任意 app(浏览器、Mail、IDE、PDF reader…)里选中一段文字。', - step3: '按一下 **Option**(rightOption,跟你录音用的同一个键)——开始录音;再按一下 Option,停止并提交,AI 答案显示在浮窗里。', - step4: '同一个浮窗里可继续多轮追问:再按 Option 录音 → 再按 Option 提交。可以重新选文字让下一轮带新选区,也可以不选直接对话。', + step3: '按一下 **{{recordHotkey}}**——开始录音;再按一下 {{recordHotkey}},停止并提交,AI 答案显示在浮窗里。', + step4: '同一个浮窗里可继续多轮追问:再按 {{recordHotkey}} 录音 → 再按 {{recordHotkey}} 提交。可以重新选文字让下一轮带新选区,也可以不选直接对话。', step5: '按 Esc 或浮窗右上角 ✕ 关闭,关闭即清空所有多轮历史。再按「{{hotkey}}」就是一段新的对话。', windowTitle: '浮窗位置 + 拖动 + 钉住', windowDesc: '浮窗第一次打开在屏幕底部录音胶囊正上方;标题栏可拖动,移到任意位置后下一次打开会保留位置(同一次启动期间)。右上角 📌 钉住时即使重新提问也保留窗口;不钉住按 Esc 即关。', diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index 82af05ba..74eb77ca 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -34,6 +34,7 @@ function useModeLabel(): Record { export function History() { const { t } = useTranslation(); + const os = detectOS(); const FILTERS = useFilters(); const MODE_LABEL = useModeLabel(); const [filter, setFilter] = useState<'all' | PolishMode>('all'); @@ -203,7 +204,7 @@ export function History() { : item.insertStatus === 'pasteSent' ? t('history.pasteSent') : item.insertStatus === 'copiedFallback' - ? t('history.copiedFallback', { shortcut: detectOS() === 'win' ? 'Ctrl+V' : '⌘V' }) + ? t('history.copiedFallback', { shortcut: os === 'mac' ? '⌘V' : 'Ctrl+V' }) : t('history.insertFailed') } diff --git a/openless-all/app/src/pages/SelectionAsk.tsx b/openless-all/app/src/pages/SelectionAsk.tsx index c79818d8..17eadfed 100644 --- a/openless-all/app/src/pages/SelectionAsk.tsx +++ b/openless-all/app/src/pages/SelectionAsk.tsx @@ -10,7 +10,8 @@ import { Card, PageHeader } from './_atoms'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { setQaHotkey } from '../lib/ipc'; import type { QaHotkeyBinding } from '../lib/types'; -import { detectOS } from '../components/WindowChrome'; +import { detectOS, type OS } from '../components/WindowChrome'; +import { getHotkeyTriggerLabel } from '../lib/hotkey'; const QA_HOTKEY_DISABLED_ID = 'disabled' as const; @@ -39,24 +40,50 @@ const QA_HOTKEY_PRESETS_WIN: readonly QaHotkeyPreset[] = [ { id: 'ctrl+shift+,', label: 'Ctrl+Shift+,', binding: { primary: ',', modifiers: ['ctrl', 'shift'] } }, ] as const; -const QA_HOTKEY_PRESETS: readonly QaHotkeyPreset[] = - detectOS() === 'mac' ? QA_HOTKEY_PRESETS_MAC : QA_HOTKEY_PRESETS_WIN; +// Linux:UI 展示用 Super,后端 binding 仍用 SUPER 同义词 `cmd` 透传到 global-hotkey。 +const QA_HOTKEY_PRESETS_LINUX: readonly QaHotkeyPreset[] = [ + { id: 'super+shift+;', label: 'Super+Shift+;', binding: { primary: ';', modifiers: ['cmd', 'shift'] } }, + { id: 'super+shift+/', label: 'Super+Shift+/', binding: { primary: '/', modifiers: ['cmd', 'shift'] } }, + { id: 'super+shift+.', label: 'Super+Shift+.', binding: { primary: '.', modifiers: ['cmd', 'shift'] } }, + { id: 'super+shift+,', label: 'Super+Shift+,', binding: { primary: ',', modifiers: ['cmd', 'shift'] } }, +] as const; + +function getQaHotkeyPresets(os: OS): readonly QaHotkeyPreset[] { + if (os === 'mac') return QA_HOTKEY_PRESETS_MAC; + if (os === 'linux') return QA_HOTKEY_PRESETS_LINUX; + return QA_HOTKEY_PRESETS_WIN; +} + +function normalizeQaModifier(modifier: string): string { + const tag = modifier.toLowerCase(); + if (tag === 'command' || tag === 'super' || tag === 'meta' || tag === 'win') { + return 'cmd'; + } + return tag; +} -function bindingToPresetId(binding: QaHotkeyBinding | null): string { +function bindingToPresetId( + binding: QaHotkeyBinding | null, + presets: readonly QaHotkeyPreset[], +): string { if (!binding) return QA_HOTKEY_DISABLED_ID; - const sortedMods = [...binding.modifiers].map(m => m.toLowerCase()).sort(); - const match = QA_HOTKEY_PRESETS.find(p => { - const pMods = [...p.binding.modifiers].sort(); + const sortedMods = [...binding.modifiers].map(normalizeQaModifier).sort(); + const match = presets.find(p => { + const pMods = [...p.binding.modifiers].map(normalizeQaModifier).sort(); return p.binding.primary === binding.primary && pMods.length === sortedMods.length && pMods.every((m, i) => m === sortedMods[i]); }); - return match ? match.id : QA_HOTKEY_PRESETS[0].id; + return match ? match.id : presets[0].id; } export function SelectionAsk() { const { t } = useTranslation(); - const { prefs, updatePrefs: savePrefs } = useHotkeySettings(); + const { prefs, hotkey, updatePrefs: savePrefs } = useHotkeySettings(); + const os = detectOS(); + const qaHotkeyPresets = getQaHotkeyPresets(os); + const defaultHotkeyLabel = qaHotkeyPresets[0]?.label ?? '快捷键'; + const recordHotkeyLabel = getHotkeyTriggerLabel(hotkey?.trigger); if (!prefs) { return ( @@ -64,7 +91,10 @@ export function SelectionAsk() {
{t('common.loading')}
@@ -78,7 +108,7 @@ export function SelectionAsk() { await savePrefs({ ...prefs, qaHotkey: null }); return; } - const preset = QA_HOTKEY_PRESETS.find(p => p.id === id); + const preset = qaHotkeyPresets.find(p => p.id === id); if (!preset) return; // 先让后端真注册成功 → 再写盘 prefs。否则 prefs 跟实际生效的快捷键脱节, // 会让用户陷入"UI 改了但按了没反应"的迷雾(issue #118 v1 实测过)。 @@ -96,15 +126,18 @@ export function SelectionAsk() { savePrefs({ ...prefs, qaSaveHistory }); const enabled = prefs.qaHotkey !== null; - const currentId = bindingToPresetId(prefs.qaHotkey); - const currentLabel = QA_HOTKEY_PRESETS.find(p => p.id === currentId)?.label ?? ''; + const currentId = bindingToPresetId(prefs.qaHotkey, qaHotkeyPresets); + const currentLabel = qaHotkeyPresets.find(p => p.id === currentId)?.label ?? defaultHotkeyLabel; return ( <>
@@ -129,7 +162,7 @@ export function SelectionAsk() {
- {t('selectionAsk.hotkey.desc')} + {t('selectionAsk.hotkey.desc', { recordHotkey: recordHotkeyLabel })}
@@ -195,11 +228,11 @@ export function SelectionAsk() {
{t('selectionAsk.howto.title')}
    -
  1. {t('selectionAsk.howto.step1')}
  2. -
  3. {t('selectionAsk.howto.step2', { hotkey: enabled ? currentLabel : '快捷键' })}
  4. -
  5. {t('selectionAsk.howto.step3')}
  6. -
  7. {t('selectionAsk.howto.step4', { hotkey: enabled ? currentLabel : '快捷键' })}
  8. -
  9. {t('selectionAsk.howto.step5')}
  10. +
  11. {t('selectionAsk.howto.step1', { hotkey: enabled ? currentLabel : defaultHotkeyLabel })}
  12. +
  13. {t('selectionAsk.howto.step2')}
  14. +
  15. {t('selectionAsk.howto.step3', { recordHotkey: recordHotkeyLabel })}
  16. +
  17. {t('selectionAsk.howto.step4', { recordHotkey: recordHotkeyLabel })}
  18. +
  19. {t('selectionAsk.howto.step5', { hotkey: enabled ? currentLabel : defaultHotkeyLabel })}