Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions openless-all/app/src-tauri/src/hotkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,16 @@ mod platform {
binding: HotkeyBinding,
tx: Sender<HotkeyEvent>,
) -> Result<Box<dyn HotkeyAdapter>, 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,
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src-tauri/src/insertion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
57 changes: 53 additions & 4 deletions openless-all/app/src-tauri/src/selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,24 @@ pub fn capture_selection() -> Option<SelectionContext> {
}
}

// 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
Expand Down Expand Up @@ -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<String> {
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<String> {
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")]
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
),
}
}
Expand Down
12 changes: 8 additions & 4 deletions openless-all/app/src/components/WindowChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
8 changes: 4 additions & 4 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
},
Expand All @@ -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.',
Expand Down
8 changes: 4 additions & 4 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
},
Expand All @@ -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 即关。',
Expand Down
3 changes: 2 additions & 1 deletion openless-all/app/src/pages/History.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function useModeLabel(): Record<PolishMode, string> {

export function History() {
const { t } = useTranslation();
const os = detectOS();
const FILTERS = useFilters();
const MODE_LABEL = useModeLabel();
const [filter, setFilter] = useState<'all' | PolishMode>('all');
Expand Down Expand Up @@ -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')
}</span>
</div>
Expand Down
75 changes: 54 additions & 21 deletions openless-all/app/src/pages/SelectionAsk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -39,32 +40,61 @@ 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 (
<>
<PageHeader
kicker={t('selectionAsk.kicker')}
title={t('selectionAsk.title')}
desc={t('selectionAsk.desc')}
desc={t('selectionAsk.desc', {
hotkey: defaultHotkeyLabel,
recordHotkey: recordHotkeyLabel,
})}
/>
<Card>
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
Expand All @@ -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 实测过)。
Expand All @@ -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 (
<>
<PageHeader
kicker={t('selectionAsk.kicker')}
title={t('selectionAsk.title')}
desc={t('selectionAsk.desc')}
desc={t('selectionAsk.desc', {
hotkey: enabled ? currentLabel : defaultHotkeyLabel,
recordHotkey: recordHotkeyLabel,
})}
/>

<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
Expand All @@ -129,7 +162,7 @@ export function SelectionAsk() {
</span>
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 12, lineHeight: 1.55 }}>
{t('selectionAsk.hotkey.desc')}
{t('selectionAsk.hotkey.desc', { recordHotkey: recordHotkeyLabel })}
</div>
<select
value={currentId}
Expand All @@ -149,7 +182,7 @@ export function SelectionAsk() {
}}
>
<option value={QA_HOTKEY_DISABLED_ID}>{t('selectionAsk.hotkey.optionDisabled')}</option>
{QA_HOTKEY_PRESETS.map(p => (
{qaHotkeyPresets.map(p => (
<option key={p.id} value={p.id}>{p.label}</option>
))}
</select>
Expand Down Expand Up @@ -195,11 +228,11 @@ export function SelectionAsk() {
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>{t('selectionAsk.howto.title')}</div>
<ol style={{ margin: 0, paddingLeft: 18, fontSize: 12.5, color: 'var(--ol-ink-2)', lineHeight: 1.7 }}>
<li>{t('selectionAsk.howto.step1')}</li>
<li>{t('selectionAsk.howto.step2', { hotkey: enabled ? currentLabel : '快捷键' })}</li>
<li>{t('selectionAsk.howto.step3')}</li>
<li>{t('selectionAsk.howto.step4', { hotkey: enabled ? currentLabel : '快捷键' })}</li>
<li>{t('selectionAsk.howto.step5')}</li>
<li>{t('selectionAsk.howto.step1', { hotkey: enabled ? currentLabel : defaultHotkeyLabel })}</li>
<li>{t('selectionAsk.howto.step2')}</li>
<li>{t('selectionAsk.howto.step3', { recordHotkey: recordHotkeyLabel })}</li>
<li>{t('selectionAsk.howto.step4', { recordHotkey: recordHotkeyLabel })}</li>
<li>{t('selectionAsk.howto.step5', { hotkey: enabled ? currentLabel : defaultHotkeyLabel })}</li>
</ol>

<div
Expand Down
Loading