diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index bfe4797a..2dd0695c 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -550,6 +550,16 @@ jobs: openless-all/app/src-tauri/target/release/bundle/latest-windows-x86_64*.json if-no-files-found: error + - name: Upload fcitx5 plugin artifact (standalone) + if: matrix.platform == 'ubuntu-22.04' + uses: actions/upload-artifact@v4 + with: + name: openless-fcitx5-plugin-linux-x64 + path: | + ${{ github.workspace }}/openless-all/scripts/linux-fcitx5-plugin/build/libopenless.so + ${{ github.workspace }}/openless-all/scripts/linux-fcitx5-plugin/build/openless.conf + if-no-files-found: error + - name: Upload Linux artifacts if: matrix.platform == 'ubuntu-22.04' uses: actions/upload-artifact@v4 @@ -637,6 +647,9 @@ jobs: openless-all/app/src-tauri/target/release/bundle/appimage/*.AppImage openless-all/app/src-tauri/target/release/bundle/appimage/*.AppImage.sig openless-all/app/src-tauri/target/release/bundle/latest-*.json + # fcitx5 插件独立下载:供 AppImage 用户或手动安装 + openless-all/scripts/linux-fcitx5-plugin/build/libopenless.so + openless-all/scripts/linux-fcitx5-plugin/build/openless.conf # ── 正式版发布后,自动更新 Homebrew cask ── # 为什么放进这条流水线,而不是单独的 `release: published` 工作流:softprops 用默认 diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index f8c58e6c..e7f18b3d 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -5341,7 +5341,10 @@ fn emit_capsule( } }); - let _ = app.emit_to("capsule", "capsule:state", payload); + let _ = app.emit_to("capsule", "capsule:state", &payload); + // 主窗口也需要 capsule:state 事件:AudioCueListener 用它触发录音提示音。 + // Linux 上胶囊隐藏时提示音仍应工作,所以同时发给 main 窗口。 + let _ = app.emit_to("main", "capsule:state", &payload); } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index dfbbe3a5..4944a970 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -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()) { diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index 2d8c454b..d7876600 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -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) } diff --git a/openless-all/app/src/components/AudioCue.tsx b/openless-all/app/src/components/AudioCue.tsx new file mode 100644 index 00000000..5ad4f2b2 --- /dev/null +++ b/openless-all/app/src/components/AudioCue.tsx @@ -0,0 +1,81 @@ +// 录音提示音:监听 capsule:state 事件,在"开始录音"边沿播放合成提示音。 +// 独立组件,不依赖胶囊窗口显示——Linux 上胶囊隐藏也能正常工作。 +// 全平台通用,在 FloatingShellBody 中渲染。 + +import { useEffect, useRef } from 'react'; +import { isTauri } from '../lib/ipc'; +import { playRecordStartCue, primeAudioCue, stopAudioCue } from '../lib/audioCue'; +import type { CapsuleState, UserPreferences } from '../lib/types'; + +interface CapsulePayload { + state: CapsuleState; + level?: number; + message?: string | null; + insertedChars?: number | null; + translation?: boolean; +} + +export function AudioCueListener() { + const audioCueEnabledRef = useRef(true); + const prevStateRef = useRef('idle' as CapsuleState); + + // 读取设置(默认开启) + useEffect(() => { + if (!isTauri) return; + let cancelled = false; + (async () => { + try { + const { getSettings } = await import('../lib/ipc'); + const prefs = await getSettings(); + if (!cancelled) audioCueEnabledRef.current = prefs.audioCueOnRecord !== false; + } catch { + // 读取失败保持默认 true + } + })(); + return () => { cancelled = true; }; + }, []); + + // 监听设置变更 + useEffect(() => { + if (!isTauri) return; + let unlisten: (() => void) | undefined; + let cancelled = false; + (async () => { + const { listen } = await import('@tauri-apps/api/event'); + listen('prefs:changed', (event) => { + const next = event.payload; + if (next) audioCueEnabledRef.current = next.audioCueOnRecord !== false; + }).then(fn => { if (!cancelled) unlisten = fn; }).catch(() => {}); + })(); + return () => { cancelled = true; unlisten?.(); }; + }, []); + + // 预热 AudioContext + useEffect(() => { + if (!isTauri) return; + primeAudioCue(); + }, []); + + // 监听 capsule 状态边沿 + useEffect(() => { + if (!isTauri) return; + let unlisten: (() => void) | undefined; + let cancelled = false; + (async () => { + const { listen } = await import('@tauri-apps/api/event'); + listen('capsule:state', (event) => { + const state = event.payload.state; + const prev = prevStateRef.current; + prevStateRef.current = state; + if (state === 'recording' && prev !== 'recording') { + if (audioCueEnabledRef.current) playRecordStartCue(); + } else if (state !== 'recording' && prev === 'recording') { + stopAudioCue(); + } + }).then(fn => { if (!cancelled) unlisten = fn; }).catch(() => {}); + })(); + return () => { cancelled = true; unlisten?.(); }; + }, []); + + return null; +} diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index eb4afcb4..d480378e 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -6,9 +6,8 @@ import { getCapsuleMessageLayout, getCapsulePillMetrics, } from '../lib/capsuleLayout'; -import { getSettings, invokeOrMock, isTauri } from '../lib/ipc'; -import { playRecordStartCue, primeAudioCue, stopAudioCue } from '../lib/audioCue'; -import type { CapsulePayload, CapsuleState, UserPreferences } from '../lib/types'; +import { invokeOrMock, isTauri } from '../lib/ipc'; +import type { CapsulePayload, CapsuleState } from '../lib/types'; interface AudioBarsProps { level: number; @@ -311,10 +310,6 @@ export function Capsule() { const [lastVisibleState, setLastVisibleState] = useState(INITIAL_VISIBLE_STATE); // Windows 端 host 在翻译模式从 84 长到 118;macOS / Linux 上 capsuleLayout 已固定 42 忽略此参数。 const hostMetrics = getCapsuleHostMetrics(os, translation); - // 录音提示音:是否开启(默认 true,老配置缺字段也按开启)+ 上一帧 capsule 状态, - // 用于检测「进入 recording」这条边沿。用 ref 而非 state:提示音是副作用,不该触发重渲染。 - const audioCueEnabledRef = useRef(true); - const prevStateRef = useRef(INITIAL_VISIBLE_STATE); useEffect(() => { if (!isTauri) return; @@ -345,56 +340,6 @@ export function Capsule() { }; }, []); - // 读取「录音提示音」开关并跟随设置实时更新:capsule 窗口不在 HotkeySettingsProvider 下, - // 所以这里自己拉一次 getSettings(),再订阅 prefs:changed 保持同步。缺字段按默认开启。 - useEffect(() => { - if (!isTauri) return; - let cancelled = false; - let unlisten: (() => void) | undefined; - (async () => { - try { - const prefs = await getSettings(); - if (!cancelled) audioCueEnabledRef.current = prefs.audioCueOnRecord !== false; - } catch (err) { - console.warn('[capsule] read audioCueOnRecord failed; default on', err); - } - const { listen } = await import('@tauri-apps/api/event'); - const handle = await listen('prefs:changed', event => { - const next = event.payload; - if (next) audioCueEnabledRef.current = next.audioCueOnRecord !== false; - }); - if (cancelled) handle(); - else unlisten = handle; - })().catch(err => { - // import / listen 早期失败(Tauri IPC 尚未就绪)不能变成 unhandled rejection。 - console.warn('[capsule] audio-cue prefs listener init failed', err); - }); - return () => { - cancelled = true; - if (unlisten) unlisten(); - }; - }, []); - - // 预热 AudioContext:胶囊挂载时就 resume,让录音开始时提示音能同步播放, - // 避免 suspended→resume 的异步竞态在「快速录音」时整段丢音(提示音偶尔消失的根因)。 - useEffect(() => { - if (!isTauri) return; - primeAudioCue(); - }, []); - - // 提示音触发:检测 capsule 状态进入 recording 的边沿就播放(提醒「已开始录音」); - // 离开 recording 则停掉,避免连按热键时残留尾音。独立于 showCapsule —— 胶囊隐藏也会响。 - useEffect(() => { - const prev = prevStateRef.current; - prevStateRef.current = state; - if (!isTauri) return; - if (state === 'recording' && prev !== 'recording') { - if (audioCueEnabledRef.current) playRecordStartCue(); - } else if (state !== 'recording' && prev === 'recording') { - stopAudioCue(); - } - }, [state]); - // 退出动画调度:在 state 真正进入 idle 时,先用 capsule-out 播放 EXIT_ANIM_MS,再卸载。 // 设计要点: // 1. 进入非 idle:清掉 leaving,记录最新可见 state; diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index cde892fd..37d6df7c 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -8,6 +8,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentTy import { useTranslation } from 'react-i18next'; import { Icon } from './Icon'; import { WindowChrome, detectOS, type OS } from './WindowChrome'; +import { AudioCueListener } from "./AudioCue"; import { SettingsModal } from './SettingsModal'; import { Overview } from '../pages/Overview'; import { History } from '../pages/History'; @@ -400,6 +401,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia onOpenSettings={openHotkeyRecordingSettings} /> ) : null} + {/* tab 切换 + provider prompt + footer popover 公用的入场关键帧 */}