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
13 changes: 13 additions & 0 deletions .github/workflows/release-tauri.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 用默认
Expand Down
5 changes: 4 additions & 1 deletion openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
5 changes: 3 additions & 2 deletions openless-all/app/src-tauri/src/coordinator/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
40 changes: 22 additions & 18 deletions openless-all/app/src-tauri/src/insertion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
}

Expand Down
81 changes: 81 additions & 0 deletions openless-all/app/src/components/AudioCue.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>(true);
const prevStateRef = useRef<CapsuleState>('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<UserPreferences>('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<CapsulePayload>('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;
}
59 changes: 2 additions & 57 deletions openless-all/app/src/components/Capsule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -311,10 +310,6 @@ export function Capsule() {
const [lastVisibleState, setLastVisibleState] = useState<CapsuleState>(INITIAL_VISIBLE_STATE);
// Windows 端 host 在翻译模式从 84 长到 118;macOS / Linux 上 capsuleLayout 已固定 42 忽略此参数。
const hostMetrics = getCapsuleHostMetrics(os, translation);
// 录音提示音:是否开启(默认 true,老配置缺字段也按开启)+ 上一帧 capsule 状态,
// 用于检测「进入 recording」这条边沿。用 ref 而非 state:提示音是副作用,不该触发重渲染。
const audioCueEnabledRef = useRef<boolean>(true);
const prevStateRef = useRef<CapsuleState>(INITIAL_VISIBLE_STATE);

useEffect(() => {
if (!isTauri) return;
Expand Down Expand Up @@ -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<UserPreferences>('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;
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/components/FloatingShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -400,6 +401,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
onOpenSettings={openHotkeyRecordingSettings}
/>
) : null}
<AudioCueListener />

{/* tab 切换 + provider prompt + footer popover 公用的入场关键帧 */}
<style>{`
Expand Down
Loading
Loading