fix(linux): fcitx5 hotkey intercept + remove backdrop-filter + clipboard cleanup

- fcitx5 plugin: fix Alt+a hotkey leaking 'a' character by normalizing
  case comparison (Alt produces uppercase sym, Key::check expects lowercase)
- fcitx5 plugin: use PreInputMethod + filterAndAccept to block engine
  before commit happens (same strategy as clipboard addon)
- Linux: remove backdrop-filter (unreliable on WebKitGTK, #553),
  replace with opaque background
- Linux: skip clipboard writes after streaming insert (fcitx5 commits
  directly, clipboard fallback corrupts user data)
- SettingsModal: render via React Portal to document.body (#580, fixes
  scrollbars overlapping modal)
- Remove is_no_compositing_mode command and CSS fallback (no longer needed)
- Remove StartupShell loading screen (app renders FloatingShell immediately)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
aeoformandClaude Opus 4.7 committed 2026-06-05 21:59:12 +08:00
1 parent 5ca3a80cd6
commit 7e21123bad
9 files changed
+72 -158

No files matched your search

@@ -3564,12 +3564,6 @@ pub async fn github_device_flow_poll(
Ok(GithubDevicePollResult::Error { message: msg })
}
#[tauri::command]
pub fn is_no_compositing_mode() -> bool {
// Linux WebKitGTK: WEBKIT_DISABLE_COMPOSITING_MODE=1 时 backdrop-filter 不可用
std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").as_deref() == Ok("1")
}
#[cfg(test)]
mod tests {
use super::{
@@ -187,8 +187,9 @@ async fn run_streaming_polish(
Some(e) => (typed_text, Some(format!("typing partially failed: {e}"))),
None => (text, None),
};
// 把 final_text 写回剪贴板(默认 on,可关)。一次性路径天然走剪贴板,
// 开关默认对齐一次性行为,让 Cmd+V 重复粘贴可用。
// 把 final_text 写回剪贴板(默认 on,macOS/Windows 适用)。
// Linux:fcitx5 插件已直写文字到目标 app,跳过剪贴板避免破坏用户数据。
#[cfg(not(target_os = "linux"))]
if inner.prefs.get().streaming_insert_save_clipboard {
match arboard::Clipboard::new() {
Ok(mut cb) => match cb.set_text(final_text.clone()) {
+22 -18
View File
@@ -30,10 +30,30 @@ impl TextInserter {
Self
}
/// Windows/Linux 路径:写剪贴板 + 模拟 `paste_shortcut`。
/// Linux 路径:仅走 fcitx5 CommitText 直写。无剪贴板 fallback。
#[cfg(target_os = "linux")]
pub fn insert(
&self,
text: &str,
_restore_clipboard_after_paste: bool,
_paste_shortcut: PasteShortcut,
) -> InsertStatus {
if text.is_empty() {
return InsertStatus::CopiedFallback;
}
match crate::linux_fcitx::commit_text(text) {
Ok(()) => InsertStatus::Inserted,
Err(e) => {
log::warn!("[insertion] fcitx commit_text failed: {e}");
InsertStatus::Failed
}
}
}
/// Windows 路径:写剪贴板 + 模拟 `paste_shortcut`。
/// - `restore_clipboard_after_paste`:粘贴后是否恢复用户原剪贴板。
/// - `paste_shortcut`:模拟按下的粘贴快捷键(如终端可能要 Ctrl+Shift+V)。
#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "windows")]
pub fn insert(
&self,
text: &str,
@@ -43,22 +63,6 @@ impl TextInserter {
if text.is_empty() {
return InsertStatus::CopiedFallback;
}
// Linux: 始终优先使用 fcitx5 CommitText 直写(支持中文)。
// 如果插件未加载,降级到剪贴板拷贝(统一路径,不单独维护 enigo XTest)。
#[cfg(target_os = "linux")]
{
match crate::linux_fcitx::commit_text(text) {
Ok(()) => return InsertStatus::Inserted,
Err(e) => {
log::warn!("[insertion] fcitx commit_text failed: {e}, fallback to clipboard only");
if copy_to_clipboard(text) {
return InsertStatus::CopiedFallback;
}
return InsertStatus::Failed;
}
}
}
#[cfg(not(target_os = "linux"))]
insert_with_clipboard_restore(text, restore_clipboard_after_paste, paste_shortcut)
}
-1
View File
@@ -471,7 +471,6 @@ pub fn run() {
#[cfg(target_os = "windows")]
commands::sherpa_onnx_asr_reveal_model_dir,
commands::export_error_log,
commands::is_no_compositing_mode,
restart_app,
])
.build(tauri::generate_context!())
-13
View File
@@ -17,7 +17,6 @@ import {
windowMouseHotkeyCode,
} from './lib/windowHotkeyFallback';
import { QaPanel } from './pages/QaPanel';
import { invoke } from '@tauri-apps/api/core';
import { HotkeySettingsProvider } from './state/HotkeySettingsContext';
interface AppProps {
@@ -169,18 +168,6 @@ export function App({ isCapsule, isQa, forcedOs }: AppProps) {
};
}, [os]);
// Linux: 检测 WEBKIT_DISABLE_COMPOSITING_MODE → 触发磨砂 fallback(#570, #553)
useEffect(() => {
if (!isTauri) return;
invoke<boolean>('is_no_compositing_mode').then((val) => {
if (val) {
document.documentElement.dataset.olNoCompositing = 'true';
}
}).catch((err) => {
console.warn('[startup] is_no_compositing_mode failed', err);
});
}, []);
return (
<HotkeySettingsProvider>
{gate === 'onboarding' ? <Onboarding onComplete={() => setGate('ready')} /> : <FloatingShell os={os} />}
@@ -5,6 +5,7 @@
// Ported verbatim from design_handoff_openless/variants.jsx::FloatingShell.
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentType } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Icon } from './Icon';
import { WindowChrome, detectOS, type OS } from './WindowChrome';
@@ -324,13 +325,13 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
一起坐在 WindowChrome 的磨砂底板上,整体一块连续玻璃。 */}
<div style={{ flex: 1, minWidth: 0, padding: '4px 8px 6px 0', display: 'flex' }}>
<main
className="ol-console-main"
style={{
flex: 1, minWidth: 0,
overflow: 'hidden',
background: 'rgba(255, 255, 255, 0.62)',
backdropFilter: 'blur(18px) saturate(170%)',
WebkitBackdropFilter: 'blur(18px) saturate(170%)',
// Linux: 不透明白底替代 backdrop-filter 磨砂(WebKitGTK 下不可靠,#553)
background: os === 'linux' ? 'rgba(255, 255, 255, 0.95)' : 'rgba(255, 255, 255, 0.62)',
backdropFilter: os === 'linux' ? undefined : 'blur(18px) saturate(170%)',
WebkitBackdropFilter: os === 'linux' ? undefined : 'blur(18px) saturate(170%)',
borderRadius: 'var(--ol-window-console-radius)',
border: '0.5px solid rgba(0,0,0,0.06)',
boxShadow: '0 1px 0 rgba(255,255,255,0.8) inset, 0 8px 24px -12px rgba(15,17,22,0.10), 0 2px 6px -2px rgba(15,17,22,0.06)',
@@ -381,15 +382,18 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
</div>
</div>
{/* Settings modal — rendered inside this window */}
{settingsOpen &&
{/* Settings modal — Rendered to document.body via portal
to escape the main console's overflow:hidden stacking context,
preventing page scrollbars from rendering on top. */}
{settingsOpen && createPortal(
<SettingsModal
key={settingsInitialSection ?? 'default'}
os={os}
initialSettingsSection={settingsInitialSection}
onClose={() => setSettingsOpen(false)}
/>
}
/>,
document.body,
)}
{providerPromptOpen ? (
<ProviderSetupPrompt
@@ -37,21 +37,23 @@ export function WindowChrome({
const consoleRadius = os === 'mac' ? 20 : os === 'win' ? WIN_CONSOLE_RADIUS : 14;
const titlebarHeight = os === 'mac' ? MAC_TITLEBAR_HEIGHT : os === 'linux' ? LINUX_TITLEBAR_HEIGHT : 0;
// 三个平台共用半透明玻璃 background + backdropFilter。
// macOS: NSVisualEffectView 提供材质;Windows: Tauri apply_mica 提供 Mica;
// Linux: decorations:false 后 CSS 磨砂玻璃自成背景。
//
// 注意:三层渐变的参数与 global.css 中 [data-ol-no-compositing] .ol-winchrome
// 的回退 background 同步(只 opacity 从 0.92 提到 0.96)。改这里时请同步更新 CSS。
const background = `
// macOS/Windows: 半透明玻璃背景 + backdropFilter 磨砂。
// Linux WebKitGTK: backdrop-filter 在部分重绘时不可靠(hover/tab 切换失效),
// 直接使用不透明白底,免维护 CSS fallback / compositing 检测。
const bgLinux = `
radial-gradient(120% 80% at 0% 0%, rgba(255,255,255,0.98) 0%, rgba(255,255,255,0) 60%),
radial-gradient(100% 70% at 100% 100%, rgba(37,99,235,0.04) 0%, rgba(37,99,235,0) 55%),
linear-gradient(180deg, rgba(245,245,247,1) 0%, rgba(232,232,236,1) 100%)
`;
const bgMacWin = `
radial-gradient(120% 80% at 0% 0%, rgba(255,255,255,0.55) 0%, rgba(255,255,255,0) 60%),
radial-gradient(100% 70% at 100% 100%, rgba(37,99,235,0.07) 0%, rgba(37,99,235,0) 55%),
linear-gradient(180deg, rgba(245,245,247,0.92) 0%, rgba(232,232,236,0.92) 100%)
`;
const background = os === 'linux' ? bgLinux : bgMacWin;
return (
<div
className="ol-winchrome"
style={{
'--ol-window-shell-radius': `${shellRadius}px`,
'--ol-window-console-radius': `${consoleRadius}px`,
@@ -66,11 +68,11 @@ export function WindowChrome({
flexDirection: 'column',
border: os === 'win' ? 'none' : os === 'mac' ? 'none' : '0.5px solid rgba(0,0,0,.10)',
background,
backdropFilter: 'blur(var(--ol-glass-blur-strong)) saturate(190%)',
WebkitBackdropFilter: 'blur(var(--ol-glass-blur-strong)) saturate(190%)',
animation: os === 'win' ? undefined : 'ol-window-enter 0.42s var(--ol-motion-spring) both',
transition: 'box-shadow 0.28s var(--ol-motion-soft), border-color 0.28s var(--ol-motion-soft), backdrop-filter 0.28s var(--ol-motion-soft)',
willChange: 'opacity, transform, filter',
backdropFilter: os === 'linux' ? undefined : 'blur(var(--ol-glass-blur-strong)) saturate(190%)',
WebkitBackdropFilter: os === 'linux' ? undefined : 'blur(var(--ol-glass-blur-strong)) saturate(190%)',
animation: os === 'win' || os === 'linux' ? undefined : 'ol-window-enter 0.42s var(--ol-motion-spring) both',
transition: os === 'linux' ? undefined : 'box-shadow 0.28s var(--ol-motion-soft), border-color 0.28s var(--ol-motion-soft), backdrop-filter 0.28s var(--ol-motion-soft)',
willChange: os === 'linux' ? undefined : 'opacity, transform, filter',
} as CSSProperties}
>
{os === 'mac' && (
@@ -152,7 +154,6 @@ function LinuxTitlebar() {
return (
<div
data-tauri-drag-region
className="ol-linux-titlebar"
style={{
height: LINUX_TITLEBAR_HEIGHT,
flexShrink: 0,
@@ -160,9 +161,7 @@ function LinuxTitlebar() {
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 6px 0 14px',
background: 'rgba(245,245,247,0.85)',
backdropFilter: 'blur(12px) saturate(180%)',
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
background: 'rgba(245,245,247,0.98)',
borderBottom: '0.5px solid rgba(0,0,0,0.08)',
color: 'var(--ol-ink-3)',
fontSize: 13,
-50
View File
@@ -163,53 +163,3 @@ a { color: inherit; text-decoration: none; }
opacity: 0.32;
pointer-events: none;
}
/* ── Linux no-compositing fallback ──────────────────────────────────────
WEBKIT_DISABLE_COMPOSITING_MODE=1 时 backdrop-filter 不可用,用噪点纹理
替代磨砂效果。data-ol-no-compositing 由 App.tsx 在初始化时设置。
!important 是因为三处目标的 backdrop-filter / background 均为 inline style
(WindowChrome.tsx / FloatingShell.tsx 的 style={...}),CSS class 必须
用 !important 才能覆盖。 */
[data-ol-no-compositing] .ol-winchrome {
position: relative;
isolation: isolate;
/* !important 覆盖 WindowChrome.tsx inline backdropFilter */
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
/* 提高透明梯度不透明度,补偿缺少模糊的视觉深度
注意:此三层的渐变参数与 WindowChrome.tsx 的 inline background 同步,
改动那边时请同步更新这里。 */
background:
radial-gradient(120% 80% at 0% 0%, rgba(255,255,255,0.55) 0%, rgba(255,255,255,0) 60%),
radial-gradient(100% 70% at 100% 100%, rgba(37,99,235,0.07) 0%, rgba(37,99,235,0) 55%),
linear-gradient(180deg, rgba(245,245,247,0.96) 0%, rgba(232,232,236,0.96) 100%) !important;
}
[data-ol-no-compositing] .ol-winchrome::after {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
/* 灰阶噪点颗粒,低 opacity 模拟磨砂质感 */
background-image: var(--ol-frost-grain);
background-size: 100px 100px;
opacity: 0.20;
pointer-events: none;
}
[data-ol-no-compositing] .ol-console-main {
/* !important 覆盖 FloatingShell.tsx inline backdropFilter */
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
background: rgba(255, 255, 255, 0.80) !important;
}
[data-ol-no-compositing] .ol-linux-titlebar {
/* !important 覆盖 WindowChrome.tsx LinuxTitlebar inline backdropFilter */
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
background: rgba(245, 245, 247, 0.95) !important;
}
@@ -96,30 +96,29 @@ public:
<< "DBus module not available";
}
// 3. 注册快捷键事件监听
// 3. 快捷键事件监听。
// PreInputMethod 在引擎 InputMethod 阶段之前运行,
// filterAndAccept() 设 filtered+accepted → 引擎跳过 commit → 字符不泄漏。
eventHandlers_.push_back(
instance_->watchEvent(
EventType::InputContextKeyEvent,
EventWatcherPhase::PreInputMethod,
[this](Event &event) {
auto &keyEvent = static_cast<KeyEvent &>(event);
// 保存当前输入上下文:快捷键按下时用户在目标 app 中,
// 此后胶囊窗口可能抢走焦点,但 commitText 仍能用此 IC 提交文字。
if (!keyEvent.isRelease()) {
savedIc_ = keyEvent.inputContext();
}
auto sym = static_cast<uint32_t>(keyEvent.key().sym());
auto states = static_cast<uint32_t>(keyEvent.key().states());
bool isPress = !keyEvent.isRelease();
// 检查自定义组合键(优先级最高)
if (hasCustomDictationKey_ &&
keyEvent.key().check(customDictationKey_)) {
// 自定义组合键:Alt 状态下字母 sym 可能大写(A vs a),归一化比较
if (hasCustomDictationKey_ && states == static_cast<uint32_t>(customDictationKey_.states()) &&
(sym == static_cast<uint32_t>(customDictationKey_.sym()) ||
(sym >= 65 && sym <= 90 && sym + 32 == static_cast<uint32_t>(customDictationKey_.sym())) ||
(sym >= 97 && sym <= 122 && sym - 32 == static_cast<uint32_t>(customDictationKey_.sym())))) {
FCITX_LOGC(openless, Debug)
<< "Custom dictation combo: sym=" << sym
<< " states=" << states
<< " isPress=" << isPress;
<< "Custom dictation: sym=" << sym << " states=" << states;
dictationKeyEvent(
static_cast<uint32_t>(customDictationKey_.sym()),
static_cast<uint32_t>(customDictationKey_.states()),
@@ -127,8 +126,6 @@ public:
keyEvent.filterAndAccept();
return;
}
// 检查听写触发键(raw + keylist 双路径)
if ((triggerRawSym_ != 0 &&
keyEvent.key().check(Key(static_cast<KeySym>(triggerRawSym_),
static_cast<KeyStates>(triggerRawStates_)))) ||
@@ -140,55 +137,35 @@ public:
}
return false;
}())) {
auto dsym = triggerRawSym_ != 0
? triggerRawSym_
auto dsym = triggerRawSym_ != 0 ? triggerRawSym_
: static_cast<uint32_t>(triggerKeyList_[0].sym());
auto dstates = triggerRawStates_ != 0
? triggerRawStates_
auto dstates = triggerRawStates_ != 0 ? triggerRawStates_
: static_cast<uint32_t>(triggerKeyList_[0].states());
FCITX_LOGC(openless, Debug)
<< "Dictation hotkey: sym=" << dsym
<< " states=" << dstates
<< " isPress=" << isPress;
<< "Dictation hotkey sym=" << dsym;
dictationKeyEvent(dsym, dstates, isPress);
keyEvent.filterAndAccept();
return;
}
// 检查 QA 快捷键
if (qaRawSym_ != 0 &&
sym == qaRawSym_ &&
if (qaRawSym_ != 0 && sym == qaRawSym_ &&
states == qaRawStates_) {
FCITX_LOGC(openless, Debug)
<< "QA shortcut: sym=" << qaRawSym_
<< " states=" << qaRawStates_
<< " isPress=" << isPress;
<< "QA shortcut";
qaShortcutEvent(qaRawSym_, qaRawStates_, isPress);
keyEvent.filterAndAccept();
return;
}
// 检查翻译模式修饰键(自定义 + 内置 Shift)
bool translationMatched = false;
if (translationRawSym_ != 0 &&
sym == translationRawSym_ &&
states == translationRawStates_) {
if (translationRawSym_ != 0 && sym == translationRawSym_ &&
states == translationRawStates_)
translationMatched = true;
}
// 内置 Shift 修饰键(仅在配置了自定义翻译键时生效,
// 避免无翻译配置时每次按 Shift 都触发信号)。
if (translationRawSym_ != 0 &&
(sym == 0xffe1 || sym == 0xffe2)) {
(sym == 0xffe1 || sym == 0xffe2))
translationMatched = true;
}
if (translationMatched) {
FCITX_LOGC(openless, Debug)
<< "Translation modifier: sym=" << sym
<< " states=" << states
<< " isPress=" << isPress;
<< "Translation modifier: sym=" << sym;
translationModifierEvent(sym, states, isPress);
// 不 filterAndAccept:修改键只需通知 OpenLess 翻译状态变更,
// 不应阻塞输入法引擎处理 Shift 事件(如中英文切换)。
}
}));
@@ -222,8 +199,7 @@ public:
instance_->flushUI();
}));
// 6. PostInputMethod 阶段恢复 lastAuxText_:fcitx5 在处理按键后可能
// 清掉 auxDown(如 enter/backspace 触发内联模式),此钩子自动补回。
// 6. PostInputMethod:恢复 auxDown(fcitx5 内联模式/方向键后可能清掉)
eventHandlers_.push_back(
instance_->watchEvent(
EventType::InputContextKeyEvent,