diff --git a/docs/windows-sherpa-onnx-asr-plan.md b/docs/windows-sherpa-onnx-asr-plan.md index bd85f8a74..da98e9118 100644 --- a/docs/windows-sherpa-onnx-asr-plan.md +++ b/docs/windows-sherpa-onnx-asr-plan.md @@ -1,6 +1,6 @@ # Windows sherpa-onnx 本地 ASR 实施规划 -> 状态:草案 / 待评审 +> 状态:历史规划 / 已实现(当前行为以代码与测试为准) > 日期:2026-05-12 > 范围:仅 Windows;不替换 macOS `local-qwen3`;不替换 Windows `foundry-local-whisper` @@ -58,7 +58,8 @@ asr/local/ `#[cfg(target_os = "windows")]` 分支 - `commands.rs`:增加准备 / 释放 / 状态 / 模型管理命令 - `types.rs`:增加 `UserPreferences` 字段 -- 前端 Settings 高级页:在 Windows 下新增第三个本地 ASR toggle +- 前端 Settings 服务页:本地模型板块负责模型管理;是否启用由「AI 提供商」中的 + `sherpa-onnx-local` 渠道开关统一控制,不再保留第二套本地 ASR 总开关 --- @@ -303,7 +304,7 @@ sherpa-onnx = "..." # 选最新稳定版,feature 关闭非必要后端 - `sherpa_provider.rs` / `sherpa_runtime.rs` / `sherpa_models.rs` 文件结构 - `ActiveAsr::SherpaOnnxLocal` - `commands.rs` 桩函数 -- 前端 toggle + i18n +- 前端渠道开关 + i18n - **不实际推理**,先打通主链路(mock transcribe 返回空串或固定字符串) ### M2 Batch 推理可用 (1.5 周) diff --git a/gnome-shell-extension/openless-capsule@openless/extension.js b/gnome-shell-extension/openless-capsule@openless/extension.js new file mode 100644 index 000000000..474ee10a3 --- /dev/null +++ b/gnome-shell-extension/openless-capsule@openless/extension.js @@ -0,0 +1,618 @@ +// OpenLess Capsule — GNOME Shell 扩展(GNOME 49/50, ESM) +// 语音输入悬浮胶囊:监听 OpenLess 状态桥(Unix socket JSON lines), +// 在屏幕底部居中渲染 Siri 流体光球。 +// - 录音/忙碌态:GLSL 着色器(metaball 流体 + 光谱色带 + 色差 + 辉光,音量驱动) +// - 终态(完成/取消/出错):Cairo 图标(符号锐利) +// - 着色器不可用时整体回退 Cairo +// 置顶、鼠标穿透、不抢焦点;OpenLess 不在时零存在感。 +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import Clutter from 'gi://Clutter'; +import Cairo from 'gi://cairo'; +import Shell from 'gi://Shell'; +import Meta from 'gi://Meta'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; + +// 画布只罩住球体+光晕(无底板);桥正常时 level 帧约 30Hz,超时视为失联 +const W = 220, H = 150, BOTTOM_MARGIN = 70, REPAINT_MS = 16, TERMINAL_MS = 2000, + WATCHDOG_SEC = 5; +const SOCKET_PATH = `${GLib.get_user_cache_dir()}/openless/capsule-state.sock`; +// 着色器热替换:此文件存在时以其函数体(void main...)覆盖内置着色器, +// 修改后 ~2 秒内生效(免注销调参通道)。删除文件即回到内置版本。 +const FRAG_OVERRIDE_PATH = `${GLib.get_user_cache_dir()}/openless/capsule-shader.frag`; +const DEMO = GLib.getenv('OPENLESS_CAPSULE_DEMO') === '1'; +const NO_SHADER = GLib.getenv('OPENLESS_CAPSULE_NO_SHADER') === '1'; + +const RGBA = (cr, r, g, b, a) => cr.setSourceRGBA(r, g, b, a); + +// 深色柔光衬底:浅色桌面下保证对比度(Cairo 回退路径用) +function softBacking(cr, cx, cy, radius, alpha) { + const g = new Cairo.RadialGradient(cx, cy, radius * 0.35, cx, cy, radius); + g.addColorStopRGBA(0, 0.06, 0.06, 0.09, alpha); + g.addColorStopRGBA(0.75, 0.06, 0.06, 0.09, alpha * 0.55); + g.addColorStopRGBA(1, 0.06, 0.06, 0.09, 0); + cr.setSource(g); + cr.arc(cx, cy, radius, 0, Math.PI * 2); + cr.fill(); +} + +// ———— GLSL:Siri 流体光球(metaball + smin 软融合 + 光谱色带 + 色差)———— +// 自包含、无纹理依赖;u_volume/u_peak 驱动半径与亮度,u_busy 区分录音/处理态 +const SHADER_DECLS = ` +uniform float u_time; +uniform float u_volume; +uniform float u_peak; +uniform float u_busy; // 0=recording 1=busy(转写/润色) +uniform float u_w; +uniform float u_h; +const float TAU = 6.28318530718; +float hash11(float n){ return fract(sin(n*127.1 + 311.7)*43758.5453); } +vec3 hue2rgb(float h){ + h = fract(h); + float r = clamp(abs(h*6.0 - 3.0) - 1.0, 0.0, 1.0); + float g = clamp(2.0 - abs(h*6.0 - 2.0), 0.0, 1.0); + float b = clamp(2.0 - abs(h*6.0 - 4.0), 0.0, 1.0); + return vec3(r, g, b); +} +float smin(float a, float b, float k){ + float h = max(k - abs(a - b), 0.0) / k; + return min(a, b) - h*h*k*0.25; +} +// 6 个 metaball 沿椭圆轨道运行;vol/pk 驱动半径与亮度,说话时形体涨大 +float orbField(vec2 p, float t, float vol, float pk){ + float d = 1e5; + for (int i = 0; i < 6; i++) { + float fi = float(i); + float h1 = hash11(fi*3.7 + 1.3); + float h2 = hash11(fi*7.1 + 4.9); + float ang = t*(0.55 + h1*0.85) + fi*1.047 + h2*6.28; + float rx = 0.34 + 0.10*sin(t*0.7 + fi*2.1); + float ry = 0.17 + 0.05*cos(t*0.9 + fi*1.3); + vec2 c = vec2(cos(ang)*rx, sin(ang)*ry); + float rr = (0.075 + 0.05*h1) * (0.45 + 0.95*vol + 0.45*pk*h2); + d = smin(d, length(p - c) - rr, 0.085); + } + return d; +} +// 光谱色带:核心亮蓝 → 中层青蓝 → 外圈紫/粉(避开绿色段) +vec3 orbColor(float x, float t){ + vec3 core = vec3(0.72, 0.87, 1.0); + vec3 mid = hue2rgb(fract(0.58 + 0.04*sin(t*0.5))); + vec3 rim = hue2rgb(fract(0.90 - 0.16*x + 0.05*sin(t*0.35))); + return mix(mix(core, mid, smoothstep(0.05, 0.45, x)), rim, smoothstep(0.4, 0.95, x)); +} +`; + +const SHADER_BODY = ` +// ===== Waveform Ring —— 移植自 VoiceOrbs waveform-ring (MIT, © Alexis Munoz) +// 极坐标波形环:合成谐波(官方回退式)+ 整流偏置;level 驱动振幅/线宽/中心辉光 +void main() { + vec2 uv = cogl_tex_coord_in[0].xy; + vec2 p = (uv - 0.5) * 2.0 * vec2(u_w / u_h, 1.0); + float t = u_time; + float vol = clamp(u_volume, 0.0, 1.0); + float pk = clamp(u_peak, 0.0, 1.0); + + float ang = atan(p.y, p.x); + float r = length(p); + + // 合成波形(VoiceOrbs listening 回退式)+ 说话时高频细节档 + float shape = 0.62 * sin(6.0 * ang + 7.2 * t) + + 0.38 * sin(11.0 * ang - 9.6 * t); + shape = mix(shape, abs(shape), 0.85); // 整流:只往外凸 + shape += (0.30 + 0.7 * vol) * 0.42 * sin(17.0 * ang + 4.4 * t); + + // 振幅主通道:level 驱动起伏,静音时收敛为纯净圆环 + float amp = 0.22 * (0.35 + 0.65 * vol) + 0.07 * pk; + float R0 = 0.32; + float rr = R0 * (1.0 + amp * shape + 0.05 * vol); + + // 双层描边:宽辉光 + 细主线(线宽随音量) + float d = abs(r - rr); + float lineWidth = 0.012 + 0.010 * vol; + float core = exp(-(d * d) / (lineWidth * lineWidth)); + float glow = exp(-d / (lineWidth * 3.2)) * 0.6; + + // 颜色:沿角度蓝→紫→青流转 + vec3 cA = vec3(0.506, 0.549, 0.973); + vec3 cB = vec3(0.655, 0.545, 0.980); + vec3 cC = vec3(0.133, 0.827, 0.933); + float cm = 0.5 + 0.5 * sin(ang * 2.0 + t * 0.7); + vec3 col = mix(cA, cB, cm); + col = mix(col, cC, 0.25 + 0.25 * vol); + + vec3 outc = col * (core * 1.1 + glow * 0.8); + outc += col * exp(-r * r * 24.0) * (0.10 + 0.25 * vol); // 中心辉光 + + // 范围保险 + 输出 + float alpha = clamp(max(max(outc.r, outc.g), outc.b), 0.0, 1.0); + alpha *= 1.0 - smoothstep(0.46, 0.52, r); + cogl_color_out = vec4(outc, alpha); +} +`; + +// ———— GLSL 效果封装(legacy set_shader_source 路线;paint_target 上传 uniform)———— +const SiriShaderEffect = GObject.registerClass( +class SiriShaderEffect extends Clutter.ShaderEffect { + _init(host) { + super._init({shader_type: 1}); // FRAGMENT=1;mutter 50 从 GI 移除了 ShaderType 枚举 + this._host = host; + } + + vfunc_paint_target(...args) { + if (this._host) { + this._upload('u_time', this._host.shaderTime); + this._upload('u_volume', this._host.envVol); + this._upload('u_peak', this._host.envPeak); + const busy = (this._host.mode === 'transcribing' || + this._host.mode === 'polishing') ? 1 : 0; + this._upload('u_busy', busy); + this._upload('u_w', W); + this._upload('u_h', H); + } + super.vfunc_paint_target(...args); + } + + _upload(name, value) { + const isInt = name === 'u_busy'; + const val = new GObject.Value(); + if (isInt) { + val.init(GObject.TYPE_INT); + val.set_int(Math.trunc(value ?? 0)); + } else { + val.init(GObject.TYPE_FLOAT); + val.set_float(parseFloat(value ?? 0)); + } + try { + this.set_uniform_value(name, val); + } catch (e) { /* 单个 uniform 失败不致命 */ } + } +}); + +const CapsuleArea = GObject.registerClass( +class CapsuleArea extends St.DrawingArea { + _init() { + super._init({reactive: false, style_class: 'openless-capsule'}); + this.set_width(W); + this.set_height(H); + this._state = 'idle'; + this._level = 0; + this._shownAt = 0; + this._t0 = GLib.get_monotonic_time() / 1e6; + this.shaderActive = false; // 录音/忙碌态由着色器接管 + this.connect('repaint', this._onDraw.bind(this)); + } + + setState(state, level = 0) { + const prev = this._state; + this._state = state; + this._level = level; + if (state !== 'idle' && (prev === 'idle' || prev === undefined)) + this._shownAt = GLib.get_monotonic_time() / 1e6; + // 空闲彻底隐藏(零存在感),收到新状态立即恢复 + if (state === 'idle') + this.hide(); + else + this.show(); + this.queue_repaint(); + } + + get visibleState() { + return this._state; + } + + _onDraw(area) { + const cr = area.get_context(); + const now = GLib.get_monotonic_time() / 1e6 - this._t0; + cr.setOperator(Cairo.Operator.CLEAR); + cr.paint(); + cr.setOperator(Cairo.Operator.OVER); + + // 录音/忙碌态由 GLSL 接管(effect 已挂在 actor 上),cairo 留空 + if (this.shaderActive && + (this._state === 'recording' || this._state === 'transcribing' || + this._state === 'polishing')) + return Clutter.EVENT_PROPAGATE; + + const cx = W / 2, cy = H / 2; + const t = now; + switch (this._state) { + case 'recording': { + // 呼吸光球:半径、抖动、光晕全部跟随音量——说话时动,静音完全静止 + const lvl = Math.min(1, Math.max(0, this._level)); + const r = 13 + lvl * 11 + lvl * Math.sin(t * 5) * 2.5; + softBacking(cr, cx, cy, r * 2.4, 0.28 + 0.17 * lvl); + const glowR = r * 2.2; + const grad = new Cairo.RadialGradient(cx, cy, r * 0.2, cx, cy, glowR); + grad.addColorStopRGBA(0, 0.45, 0.72, 1.0, 0.55 * (0.3 + 0.7 * lvl)); + grad.addColorStopRGBA(1, 0.30, 0.55, 1.0, 0.0); + cr.setSource(grad); + cr.arc(cx, cy, glowR, 0, Math.PI * 2); + cr.fill(); + RGBA(cr, 0.62, 0.83, 1.0, 0.95); + cr.arc(cx, cy, r, 0, Math.PI * 2); + cr.fill(); + break; + } + case 'transcribing': + case 'polishing': { + // 旋转圆环 + const polishing = this._state === 'polishing'; + const col = polishing ? [0.75, 0.55, 1.0] : [0.45, 0.75, 1.0]; + const r = 16 + Math.sin(t * 3) * 1.6; + softBacking(cr, cx, cy, r * 2.1, 0.38); + for (let i = 0; i < 3; i++) { + const a0 = t * 3.2 + (i * Math.PI * 2) / 3; + RGBA(cr, col[0], col[1], col[2], 0.9 - i * 0.28); + cr.setLineWidth(4 - i); + cr.arc(cx, cy, r - i * 4, a0, a0 + 1.1); + cr.stroke(); + } + break; + } + case 'done': { + softBacking(cr, cx, cy, 30, 0.38); + RGBA(cr, 0.35, 0.9, 0.55, 0.95); + cr.arc(cx, cy, 13, 0, Math.PI * 2); + cr.fill(); + RGBA(cr, 0.06, 0.2, 0.1, 1); + cr.setLineWidth(3); + cr.moveTo(cx - 5.5, cy); + cr.lineTo(cx - 1.5, cy + 5); + cr.lineTo(cx + 6.5, cy - 5); + cr.stroke(); + break; + } + case 'cancelled': { + softBacking(cr, cx, cy, 30, 0.38); + RGBA(cr, 0.55, 0.55, 0.6, 0.8); + cr.setLineWidth(3.5); + cr.moveTo(cx - 6.5, cy - 6.5); + cr.lineTo(cx + 6.5, cy + 6.5); + cr.moveTo(cx + 6.5, cy - 6.5); + cr.lineTo(cx - 6.5, cy + 6.5); + cr.stroke(); + break; + } + case 'error': { + softBacking(cr, cx, cy, 30, 0.38); + RGBA(cr, 0.95, 0.35, 0.35, 0.95); + cr.arc(cx, cy, 13 + Math.sin(t * 8) * 1.2, 0, Math.PI * 2); + cr.fill(); + break; + } + default: + break; + } + return Clutter.EVENT_PROPAGATE; + } +}); + +export default class OpenLessCapsuleExtension extends Extension { + enable() { + this._area = new CapsuleArea(); + this._area.reactive = false; + this._conn = null; + this._cancellable = null; + this._buffer = ''; + this._reconnectSource = null; + this._animSource = null; + this._demoSource = null; + this._demoStep = 0; + this._idleSource = null; + + // 包络 / 着色器驱动状态(字符串状态体系) + this.mode = 'idle'; + this.shaderTime = 0; + this.envVol = 0; + this.envPeak = 0; + this.lastLevel = 0; + this._lastFrameAt = 0; + this._lastFrameMono = 0; + + Main.layoutManager.addTopChrome(this._area); + this._area.hide(); // 初始零存在感,收到第一帧状态再显示 + this._place(); + this._monitorsChanged = Main.layoutManager.connect( + 'monitors-changed', () => this._place()); + + // 着色器优先(录音/忙碌态),失败回退 Cairo + this._shaderWanted = !NO_SHADER && typeof Clutter.ShaderEffect !== 'undefined'; + this._shaderAttached = false; + if (this._shaderWanted) { + try { + this._effect = new SiriShaderEffect(this); + console.log('[openless-capsule] GLSL Siri orb effect created'); + this._startFragWatch(); + } catch (e) { + console.warn('[openless-capsule] shader create failed, cairo fallback:', e); + this._effect = null; + this._shaderWanted = false; + } + } + + // 全局听写热键:Mutter 抓取,与焦点应用无关。 + // (修复:fcitx5 无 Wayland 原生 IM 协议,插件热键依赖焦点应用以 fcitx 模块 + // 启动;全局抓取让任意窗口下右 Alt 都触发,且被合成器消费不双触发。) + try { + const schemaSource = Gio.SettingsSchemaSource.new_from_directory( + GLib.build_filenamev([this.path, 'schemas']), + Gio.SettingsSchemaSource.get_default(), false); + const schema = schemaSource.lookup('org.gnome.shell.extensions.openless-capsule', false); + if (schema) { + this._settings = new Gio.Settings({settings_schema: schema}); + Main.wm.addKeybinding('openless-dictation', this._settings, + Meta.KeyBindingFlags.NONE, Shell.ActionMode.ALL, + () => this._toggleDictation()); + console.log('[openless-capsule] global dictation hotkey registered'); + } else { + console.warn('[openless-capsule] schema not found; fcitx plugin hotkey remains'); + } + } catch (e) { + console.warn('[openless-capsule] keybinding failed (fcitx plugin hotkey still works):', e); + } + + // 60fps 驱动:包络 + uniform 上传(仅在可见时渲染) + this._animSource = GLib.timeout_add(GLib.PRIORITY_DEFAULT, REPAINT_MS, () => { + if (!this._area) + return GLib.SOURCE_REMOVE; + const now = GLib.get_monotonic_time() / 1e6; + const st = this._area.visibleState; + if (st === 'idle') + return GLib.SOURCE_CONTINUE; + + const dt = this._lastFrameMono > 0 + ? Math.min(0.2, Math.max(0.001, now - this._lastFrameMono)) : 0.016; + this._lastFrameMono = now; + this.shaderTime += dt * (st === 'recording' ? 1.0 : 0.55); + // 包络:应用侧已做噪声门/归一化/包络,这里轻度平滑 + 峰值保持 + const k = this.lastLevel > this.envVol + ? 1 - Math.exp(-dt / 0.045) + : 1 - Math.exp(-dt / 0.22); + this.envVol += (this.lastLevel - this.envVol) * k; + this.envPeak = Math.max(this.lastLevel, this.envPeak * Math.exp(-dt / 0.15)); + + // 看门狗:桥失联/卡死时收回胶囊,避免永久悬浮挡内容 + if (this._lastFrameAt > 0 && + now - this._area._shownAt > WATCHDOG_SEC && + now - this._lastFrameAt > WATCHDOG_SEC) { + this._area.setState('idle'); + return GLib.SOURCE_CONTINUE; + } + + // effect 挂/摘跟随状态(终态图标走 Cairo,符号更锐利) + if (this._shaderWanted && this._effect) { + const wantShader = st === 'recording' || st === 'transcribing' || + st === 'polishing'; + if (wantShader && !this._shaderAttached) { + this._area.add_effect_with_name('siri-orb', this._effect); + this._shaderAttached = true; + this._area.shaderActive = true; + } else if (!wantShader && this._shaderAttached) { + this._area.remove_effect_by_name('siri-orb'); + this._shaderAttached = false; + this._area.shaderActive = false; + } + } + + this._area.queue_repaint(); + return GLib.SOURCE_CONTINUE; + }); + + if (DEMO) { + this._startDemo(); + } else { + this._connectSocket(); + } + } + + _place() { + const m = Main.layoutManager.primaryMonitor; + if (!m) + return; + this._area.set_position( + m.x + Math.floor((m.width - W) / 2), + m.y + m.height - H - BOTTOM_MARGIN); + } + + // —— 真数据:Unix socket JSON lines —— + _connectSocket() { + if (this._conn || !this._area) + return; + this._cancellable = new Gio.Cancellable(); + const client = new Gio.SocketClient(); + const addr = new Gio.UnixSocketAddress({ path: SOCKET_PATH }); + client.connect_async(addr, this._cancellable, (c, res) => { + try { + this._conn = c.connect_finish(res); + this._buffer = ''; + this._readLine(); + } catch (e) { + this._conn = null; + this._scheduleReconnect(); + } + }); + } + + _scheduleReconnect() { + if (this._reconnectSource || DEMO || !this._area) + return; + this._reconnectSource = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, 2, () => { + this._reconnectSource = null; + this._connectSocket(); + return GLib.SOURCE_REMOVE; + }); + } + + _readLine() { + if (!this._conn) + return; + this._conn.get_input_stream().read_bytes_async( + 4096, GLib.PRIORITY_DEFAULT, this._cancellable, (s, res) => { + try { + const bytes = s.read_bytes_finish(res); + if (bytes.get_size() === 0) + throw new Error('EOF'); + const text = new TextDecoder().decode(bytes.get_data()); + this._buffer += text; + let idx; + while ((idx = this._buffer.indexOf('\n')) >= 0) { + const line = this._buffer.slice(0, idx).trim(); + this._buffer = this._buffer.slice(idx + 1); + if (!line) + continue; + try { + if (!this._area) + return; + this._lastFrameAt = GLib.get_monotonic_time() / 1e6; + const msg = JSON.parse(line); + this.lastLevel = msg.level ?? 0; + this._area.setState(msg.state ?? 'idle', msg.level ?? 0); + if (this._isTerminal(msg.state)) + this._scheduleIdle(msg.state); + } catch (e) { /* 坏行忽略 */ } + } + this._readLine(); + } catch (e) { + this._teardownConn(); + if (this._area) + this._area.setState('idle'); + this._scheduleReconnect(); + } + }); + } + + _isTerminal(state) { + return state === 'done' || state === 'cancelled' || state === 'error'; + } + + // 终态停留 TERMINAL_MS 后收回 idle(扩展隐藏) + _scheduleIdle(_state) { + if (this._idleSource) + GLib.source_remove(this._idleSource); + this._idleSource = GLib.timeout_add(GLib.PRIORITY_DEFAULT, TERMINAL_MS, () => { + this._idleSource = null; + this._area.setState('idle'); + return GLib.SOURCE_REMOVE; + }); + } + + _teardownConn() { + if (this._cancellable) + this._cancellable.cancel(); + if (this._conn) { + try { this._conn.close(null); } catch (e) { /* 已断 */ } + this._conn = null; + } + } + + // —— 假数据:嵌套 shell / 无 OpenLess 环境的可视化验证 —— + _startDemo() { + const steps = [ + ['recording', 5], + ['transcribing', 2], + ['polishing', 1.5], + ['done', 2], + ['idle', 2], + ]; + const stepFn = () => { + const [state, secs] = steps[this._demoStep % steps.length]; + this._demoStep += 1; + this._area.setState(state, state === 'recording' ? 0.5 : 0); + if (state === 'recording') + this._demoLevel(); + this._demoSource = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, secs * 1000, stepFn); + return GLib.SOURCE_REMOVE; + }; + this._demoSource = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 800, stepFn); + } + + _demoLevel() { + const t0 = GLib.get_monotonic_time() / 1e6; + const push = () => { + if (this._area.visibleState !== 'recording') + return GLib.SOURCE_REMOVE; + const t = GLib.get_monotonic_time() / 1e6 - t0; + this._area.setState('recording', 0.35 + 0.35 * Math.abs(Math.sin(t * 2.4))); + return GLib.SOURCE_CONTINUE; + }; + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 90, push); + } + + // 着色器热替换轮询:frag 文件变化 → 重建 effect(移除+重挂强制重新编译) + _startFragWatch() { + if (DEMO) + return; + this._fragBody = null; + this._fragPoll = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 2, () => { + try { + const [ok, bytes] = GLib.file_get_contents(FRAG_OVERRIDE_PATH); + if (!ok) + return GLib.SOURCE_CONTINUE; + const body = new TextDecoder().decode(bytes); + if (body !== this._fragBody && body.includes('void main')) { + this._fragBody = body; + if (this._effect && this._shaderAttached) { + // 已挂载(正在显示):同对象换源 + 摘/挂强制重编译(已验证路径) + this._area.remove_effect_by_name('siri-orb'); + this._effect.set_shader_source(SHADER_DECLS + body); + this._area.add_effect_with_name('siri-orb', this._effect); + } else if (this._effect) { + // 未挂载(空闲):新建实例并单次设源,避免同对象双设源的边界行为 + try { this._area.remove_effect_by_name('siri-orb'); } catch (e) { /* 未挂载 */ } + this._effect = new SiriShaderEffect(this); + this._effect.set_shader_source(SHADER_DECLS + body); + } + console.log('[openless-capsule] shader hot-swapped from frag file'); + } + } catch (e) { /* 读失败忽略 */ } + return GLib.SOURCE_CONTINUE; + }); + } + + // 全局热键处理器:向 OpenLess 发听写键事件(应用 Toggle 语义:按一下切换) + _toggleDictation() { + try { + if (!this._dbusConn) + this._dbusConn = Gio.bus_get_sync(Gio.BusType.SESSION, null); + this._dbusConn.emit_signal(null, '/openless', + 'org.fcitx.Fcitx.OpenLess1', 'DictationKeyEvent', + new GLib.Variant('(uub)', [65514, 108, true])); + return Clutter.EVENT_STOP; + } catch (e) { + console.warn('[openless-capsule] toggle emit failed:', e); + return Clutter.EVENT_PROPAGATE; + } + } + + disable() { + if (this._settings) { + try { Main.wm.removeKeybinding('openless-dictation'); } catch (e) { /* 已移除 */ } + this._settings = null; + } + if (this._dbusConn) { + try { this._dbusConn.close(null); } catch (e) { /* 已断 */ } + this._dbusConn = null; + } + if (this._fragPoll) { GLib.source_remove(this._fragPoll); this._fragPoll = null; } + if (this._idleSource) { GLib.source_remove(this._idleSource); this._idleSource = null; } + if (this._reconnectSource) { GLib.source_remove(this._reconnectSource); this._reconnectSource = null; } + if (this._animSource) { GLib.source_remove(this._animSource); this._animSource = null; } + if (this._demoSource) { GLib.source_remove(this._demoSource); this._demoSource = null; } + this._teardownConn(); + if (this._monitorsChanged) { + Main.layoutManager.disconnect(this._monitorsChanged); + this._monitorsChanged = null; + } + if (this._effect && this._area) + this._area.remove_effect(this._effect); + Main.layoutManager.removeChrome(this._area); + this._area.destroy(); + this._area = null; + } +} diff --git a/gnome-shell-extension/openless-capsule@openless/metadata.json b/gnome-shell-extension/openless-capsule@openless/metadata.json new file mode 100644 index 000000000..3656dcc93 --- /dev/null +++ b/gnome-shell-extension/openless-capsule@openless/metadata.json @@ -0,0 +1,8 @@ +{ + "uuid": "openless-capsule@openless", + "name": "OpenLess Capsule", + "description": "语音输入悬浮胶囊:跟随 OpenLess 听写状态在屏幕底部渲染流光动效(GNOME Wayland 专用,点击穿透、不抢焦点)。", + "shell-version": ["49", "50"], + "url": "https://github.com/Open-Less/openless", + "session-modes": ["user", "unlock-dialog"] +} diff --git a/gnome-shell-extension/openless-capsule@openless/schemas/gschemas.compiled b/gnome-shell-extension/openless-capsule@openless/schemas/gschemas.compiled new file mode 100644 index 000000000..5bcd0066c Binary files /dev/null and b/gnome-shell-extension/openless-capsule@openless/schemas/gschemas.compiled differ diff --git a/gnome-shell-extension/openless-capsule@openless/schemas/org.gnome.shell.extensions.openless-capsule.gschema.xml b/gnome-shell-extension/openless-capsule@openless/schemas/org.gnome.shell.extensions.openless-capsule.gschema.xml new file mode 100644 index 000000000..2f1382322 --- /dev/null +++ b/gnome-shell-extension/openless-capsule@openless/schemas/org.gnome.shell.extensions.openless-capsule.gschema.xml @@ -0,0 +1,10 @@ + + + + + + 听写热键 + 按下触发听写开始/停止切换(由合成器全局抓取,与焦点应用无关)。 + + + diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 20499e011..2844d7ac1 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "openless-app", - "version": "1.3.18-Beta.6", + "version": "1.3.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "1.3.18-Beta.6", + "version": "1.3.18", "dependencies": { "@base-ui/react": "^1.6.0", "@formkit/auto-animate": "^0.9.0", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 5d8d1449f..d458f993d 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -1,7 +1,7 @@ { "name": "openless-app", "private": true, - "version": "1.3.18-Beta.6", + "version": "1.3.18", "type": "module", "scripts": { "pretest": "npm run build", diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 2a2d0a9a2..1490b5da3 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -174,12 +174,6 @@ dependencies = [ "x11rb", ] -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - [[package]] name = "async-broadcast" version = "0.7.2" @@ -390,13 +384,10 @@ dependencies = [ "matchit", "memchr", "mime", - "multer", "percent-encoding", "pin-project-lite", "rustversion", "serde", - "serde_json", - "serde_path_to_error", "serde_urlencoded", "sha1", "sync_wrapper", @@ -405,7 +396,6 @@ dependencies = [ "tower", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -426,7 +416,6 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -876,46 +865,6 @@ dependencies = [ "libloading 0.8.9", ] -[[package]] -name = "clap" -version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - [[package]] name = "clipboard-win" version = "5.4.1" @@ -1810,12 +1759,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "extended" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" - [[package]] name = "fastrand" version = "2.4.1" @@ -2524,12 +2467,6 @@ dependencies = [ "digest", ] -[[package]] -name = "hound" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" - [[package]] name = "html5ever" version = "0.38.0" @@ -3129,12 +3066,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -3329,15 +3260,6 @@ dependencies = [ "web_atoms", ] -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - [[package]] name = "matchit" version = "0.7.3" @@ -3480,23 +3402,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "multer" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http", - "httparse", - "memchr", - "mime", - "spin", - "version_check", -] - [[package]] name = "munge" version = "0.4.7" @@ -3654,15 +3559,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "num" version = "0.4.3" @@ -4195,7 +4091,7 @@ dependencies = [ [[package]] name = "openless" -version = "1.3.18-Beta.6" +version = "1.3.18" dependencies = [ "anyhow", "arboard", @@ -4235,7 +4131,6 @@ dependencies = [ "objc2-foundation 0.2.2", "once_cell", "parking_lot", - "qwen3-asr-rs", "raw-window-handle", "rcgen", "reqwest 0.12.28", @@ -4649,15 +4544,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "primal-check" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" -dependencies = [ - "num-integer", -] - [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -4826,29 +4712,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "qwen3-asr-rs" -version = "0.2.0" -dependencies = [ - "anyhow", - "axum", - "clap", - "cmake", - "hound", - "rubato", - "safetensors", - "serde", - "serde_json", - "symphonia", - "tempfile", - "thiserror 2.0.18", - "tokenizers", - "tokio", - "tower-http 0.5.2", - "tracing", - "tracing-subscriber", -] - [[package]] name = "r-efi" version = "5.3.0" @@ -5005,15 +4868,6 @@ dependencies = [ "yasna", ] -[[package]] -name = "realfft" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" -dependencies = [ - "rustfft", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -5141,7 +4995,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http 0.6.11", + "tower-http", "tower-service", "url", "wasm-bindgen", @@ -5181,7 +5035,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http 0.6.11", + "tower-http", "tower-service", "url", "wasm-bindgen", @@ -5258,18 +5112,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "rubato" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5258099699851cfd0082aeb645feb9c084d9a5e1f1b8d5372086b989fc5e56a1" -dependencies = [ - "num-complex", - "num-integer", - "num-traits", - "realfft", -] - [[package]] name = "rustc-hash" version = "2.1.2" @@ -5285,20 +5127,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustfft" -version = "6.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" -dependencies = [ - "num-complex", - "num-integer", - "num-traits", - "primal-check", - "strength_reduce", - "transpose", -] - [[package]] name = "rustix" version = "1.1.4" @@ -5399,16 +5227,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "safetensors" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc0cdb7198d738a111f6df8fef42cb175412c311d0c4ac9126ff4e550ad1a0e8" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "same-file" version = "1.0.6" @@ -5634,17 +5452,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_repr" version = "0.1.20" @@ -5771,15 +5578,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - [[package]] name = "shared_child" version = "1.1.1" @@ -5965,12 +5763,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "spin" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" - [[package]] name = "spm_precompiled" version = "0.1.4" @@ -5995,12 +5787,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "strength_reduce" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" - [[package]] name = "string_cache" version = "0.9.0" @@ -6048,164 +5834,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "symphonia" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" -dependencies = [ - "lazy_static", - "symphonia-bundle-flac", - "symphonia-bundle-mp3", - "symphonia-codec-aac", - "symphonia-codec-adpcm", - "symphonia-codec-pcm", - "symphonia-codec-vorbis", - "symphonia-core", - "symphonia-format-mkv", - "symphonia-format-ogg", - "symphonia-format-riff", - "symphonia-metadata", -] - -[[package]] -name = "symphonia-bundle-flac" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" -dependencies = [ - "log", - "symphonia-core", - "symphonia-metadata", - "symphonia-utils-xiph", -] - -[[package]] -name = "symphonia-bundle-mp3" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" -dependencies = [ - "lazy_static", - "log", - "symphonia-core", - "symphonia-metadata", -] - -[[package]] -name = "symphonia-codec-aac" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" -dependencies = [ - "lazy_static", - "log", - "symphonia-core", -] - -[[package]] -name = "symphonia-codec-adpcm" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" -dependencies = [ - "log", - "symphonia-core", -] - -[[package]] -name = "symphonia-codec-pcm" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" -dependencies = [ - "log", - "symphonia-core", -] - -[[package]] -name = "symphonia-codec-vorbis" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" -dependencies = [ - "log", - "symphonia-core", - "symphonia-utils-xiph", -] - -[[package]] -name = "symphonia-core" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" -dependencies = [ - "arrayvec", - "bitflags 1.3.2", - "bytemuck", - "lazy_static", - "log", -] - -[[package]] -name = "symphonia-format-mkv" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" -dependencies = [ - "lazy_static", - "log", - "symphonia-core", - "symphonia-metadata", - "symphonia-utils-xiph", -] - -[[package]] -name = "symphonia-format-ogg" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" -dependencies = [ - "log", - "symphonia-core", - "symphonia-metadata", - "symphonia-utils-xiph", -] - -[[package]] -name = "symphonia-format-riff" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" -dependencies = [ - "extended", - "log", - "symphonia-core", - "symphonia-metadata", -] - -[[package]] -name = "symphonia-metadata" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" -dependencies = [ - "encoding_rs", - "lazy_static", - "log", - "symphonia-core", -] - -[[package]] -name = "symphonia-utils-xiph" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" -dependencies = [ - "symphonia-core", - "symphonia-metadata", -] - [[package]] name = "syn" version = "1.0.109" @@ -6227,17 +5855,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -6476,7 +6093,6 @@ dependencies = [ [[package]] name = "tauri-nspanel" version = "2.0.1" -source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#18ffb9a201fbf6fedfaa382fd4b92315ea30ab1a" dependencies = [ "bitflags 2.13.0", "block", @@ -6802,15 +6418,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", -] - [[package]] name = "tiff" version = "0.11.3" @@ -7138,24 +6745,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" -dependencies = [ - "bitflags 2.13.0", - "bytes", - "http", - "http-body", - "http-body-util", - "pin-project-lite", - "tower-layer", - "tower-service", - "tracing", ] [[package]] @@ -7194,7 +6783,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -7218,46 +6806,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "transpose" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" -dependencies = [ - "num-integer", - "strength_reduce", ] [[package]] @@ -7546,12 +7094,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "vcpkg" version = "0.2.15" diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 920a84bfe..d65ad9156 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openless" -version = "1.3.18-Beta.6" +version = "1.3.18" description = "OpenLess — local voice input that types where your cursor is" authors = ["OpenLess"] edition = "2021" @@ -134,7 +134,6 @@ tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" # 无关),所以**所有平台**源码构建都必须先 `git submodule update --init --recursive`, # 否则 cargo check 会在解析阶段硬失败。CI 里非 macOS 任务依赖 # scripts/ci-disable-macos-qwen3.mjs 删掉本行来规避。 -qwen3-asr-rs = { path = "vendor/qwen3-asr-rs", default-features = false, features = ["mlx"] } [target.'cfg(target_os = "windows")'.dependencies] foundry-local-sdk = { version = "=1.2.1", features = ["winml"] } raw-window-handle = "0.6" diff --git a/openless-all/app/src-tauri/linux-fcitx5-plugin/openless.conf b/openless-all/app/src-tauri/linux-fcitx5-plugin/openless.conf new file mode 100644 index 000000000..76ac01a9a --- /dev/null +++ b/openless-all/app/src-tauri/linux-fcitx5-plugin/openless.conf @@ -0,0 +1,15 @@ +[Addon] +Name=OpenLess +Name[zh_CN]=OpenLess 听写辅助 +Comment=OpenLess dictation commit helper — exposes CommitText DBus method and dictation hotkey +Comment[zh_CN]=供 OpenLess 听写提交文字的 DBus 接口及快捷键监听 +Category=Module +Type=SharedLibrary +Library=libopenless +Version=1.0.0 +OnDemand=False +Configurable=False + +[Addon/Dependencies] +0=core:5.0.14 +1=dbus diff --git a/openless-all/app/src-tauri/src/asr/volcengine.rs b/openless-all/app/src-tauri/src/asr/volcengine.rs index 58a7958af..5bc880787 100644 --- a/openless-all/app/src-tauri/src/asr/volcengine.rs +++ b/openless-all/app/src-tauri/src/asr/volcengine.rs @@ -5,7 +5,7 @@ //! quirks are preserved verbatim — see comments tagged with `[asr]` for the //! original learnings (especially the "definite=true is NOT stream end" bug). -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -183,6 +183,10 @@ pub struct VolcengineStreamingASR { /// 而把后续 chunk 当成「stream 已结束」之后的多余数据丢弃 → 尾句丢失。 pending_sends: Arc, send_done: Arc, + /// 会话代数:open_session 每开一局 +1。audio worker 持有自己的代数快照, + /// 发送前比对——上一局遗留的 worker 在新连接建立后必须自行退出, + /// 否则旧序号的帧会泄漏进新连接(服务端报 autoAssignedSequence mismatch)。 + session_gen: Arc, } impl VolcengineStreamingASR { @@ -196,6 +200,7 @@ impl VolcengineStreamingASR { audio_tx: ParkingMutex::new(None), pending_sends: Arc::new(AtomicUsize::new(0)), send_done: Arc::new(Notify::new()), + session_gen: Arc::new(AtomicU64::new(0)), } } @@ -239,8 +244,16 @@ impl VolcengineStreamingASR { let writer_for_worker = Arc::clone(&self.writer); let pending_for_worker = Arc::clone(&self.pending_sends); let notify_for_worker = Arc::clone(&self.send_done); + // 本 worker 只服务这一局会话:新 open_session 会推进代数,旧 worker 见到 + // 代数变化即退出(writer 槽位已被新会话接管,旧帧绝不能发上去)。 + let gen_for_worker = self.session_gen.fetch_add(1, Ordering::SeqCst) + 1; + let gen_slot = Arc::clone(&self.session_gen); tokio::spawn(async move { while let Some((seq, chunk)) = audio_rx.recv().await { + if gen_for_worker != gen_slot.load(Ordering::SeqCst) { + log::info!("[asr] audio worker 会话代数已更替,退出(seq={seq} 未发送)"); + break; + } let frame = frame::build( MessageType::AudioOnlyRequest, Flags::PositiveSequence, @@ -249,7 +262,10 @@ impl VolcengineStreamingASR { Some(seq), ); if let Err(e) = send_binary(&writer_for_worker, frame).await { - log::error!("[asr] audio frame seq={} send 失败: {}", seq, e); + // 连接已死:本会话发送终止,防止对同一槽位(可能已被新会话接管) + // 继续推帧造成 45000000 序列号错位与日志刷屏。 + log::error!("[asr] audio frame seq={seq} send 失败,终止本会话发送: {e}"); + break; } if pending_for_worker.fetch_sub(1, Ordering::SeqCst) == 1 { notify_for_worker.notify_waiters(); diff --git a/openless-all/app/src-tauri/src/capsule_bridge.rs b/openless-all/app/src-tauri/src/capsule_bridge.rs new file mode 100644 index 000000000..75721fee2 --- /dev/null +++ b/openless-all/app/src-tauri/src/capsule_bridge.rs @@ -0,0 +1,105 @@ +//! 胶囊状态桥:把听写胶囊状态以 JSON 行形式广播给本机 GUI 客户端 +//! (GNOME Shell 扩展 openless-capsule@openless)。 +//! +//! 协议:Unix 流式 socket `~/.cache/openless/capsule-state.sock`, +//! 每行一个 JSON 对象 `{"state":"recording","level":0.42}`。 +//! 多客户端广播;无客户端时写入静默丢弃;对端断开自动剔除。 +//! 线程安全:emit_capsule 可能来自 cpal 音频回调线程,本模块只做 +//! 非阻塞 send 到内部通道,accept/广播都在独立线程。 + +use std::io::Write; +use std::os::unix::net::UnixListener; +use std::path::PathBuf; +use std::sync::mpsc::{self, Sender}; +use std::sync::OnceLock; + +static BRIDGE: OnceLock> = OnceLock::new(); +/// 当前连接的 GUI 客户端数(由 bridge 线程维护)。宿主据此决定是否 +/// 抑制 fcitx 辅助区文字——扩展已接管状态显示时避免双重指示。 +static CLIENT_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// 是否有 GUI 客户端订阅状态桥。 +pub fn has_clients() -> bool { + CLIENT_COUNT.load(std::sync::atomic::Ordering::Relaxed) > 0 +} + +fn socket_path() -> PathBuf { + let cache = std::env::var("XDG_CACHE_HOME") + .ok() + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var("HOME") + .ok() + .filter(|v| !v.is_empty()) + .map(|h| PathBuf::from(h).join(".cache")) + }) + .unwrap_or_else(|| PathBuf::from("/tmp")); + cache.join("openless").join("capsule-state.sock") +} + +/// 广播一帧胶囊状态。state 用小写短名(idle/recording/transcribing/polishing/ +/// done/cancelled/error),level 为 0.0–1.0 音量电平。任何失败都静默忽略—— +/// 状态桥是纯增值功能,绝不能影响听写主链路。 +pub fn capsule_bridge_send(state: &str, level: f32) { + let sender = BRIDGE.get_or_init(|| { + let (tx, rx) = mpsc::channel::(); + let spawned = std::thread::Builder::new() + .name("capsule-bridge".into()) + .spawn(move || bridge_thread(rx)); + if let Err(e) = spawned { + log::warn!("[capsule-bridge] spawn thread failed: {e}"); + } + tx + }); + // std mpsc 无界通道,send 不会阻塞音频线程;接收端死亡则静默丢弃 + let _ = sender.send(format!("{{\"state\":\"{state}\",\"level\":{level:.3}}}\n")); +} + +fn bridge_thread(rx: mpsc::Receiver) { + let path = socket_path(); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::remove_file(&path); + let listener = match UnixListener::bind(&path) { + Ok(l) => l, + Err(e) => { + log::warn!("[capsule-bridge] bind {:?} failed: {e}", path); + return; + } + }; + if let Err(e) = listener.set_nonblocking(true) { + log::warn!("[capsule-bridge] set_nonblocking failed: {e}"); + } + log::info!("[capsule-bridge] listening at {:?}", path); + + let mut clients: Vec = Vec::new(); + loop { + match listener.accept() { + Ok((stream, _)) => { + let _ = stream.set_nonblocking(true); + log::info!("[capsule-bridge] client connected"); + clients.push(stream); + CLIENT_COUNT.store(clients.len(), std::sync::atomic::Ordering::Relaxed); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(e) => log::warn!("[capsule-bridge] accept: {e}"), + } + + while let Ok(line) = rx.try_recv() { + let data = line.as_bytes(); + clients.retain_mut(|client| { + match client.write(data).and_then(|_| client.flush()) { + Ok(_) => true, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => true, + Err(_) => false, + } + }); + } + // 写失败被剔除的客户端在这里反映为计数下降 + CLIENT_COUNT.store(clients.len(), std::sync::atomic::Ordering::Relaxed); + + std::thread::sleep(std::time::Duration::from_millis(16)); + } +} diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 0857d1444..183af4f60 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -446,7 +446,7 @@ pub fn set_settings( app: AppHandle, tray_microphones: State<'_, TrayMicrophoneMenuState>, mut prefs: UserPreferences, -) -> Result<(), String> { +) -> Result { // 捕获旧值用于远程输入服务的 diff(persist 后端口/开关变化时启停/重启)。 let remote_prev = coord.prefs().get(); let packs = coord.style_packs().list().map_err(|e| e.to_string())?; @@ -502,7 +502,7 @@ pub fn set_settings( { coord.refresh_remote_server(); } - Ok(()) + Ok(prefs) } #[cfg(mobile)] @@ -511,7 +511,7 @@ pub fn set_settings( coord: CoordinatorState<'_>, app: AppHandle, mut prefs: UserPreferences, -) -> Result<(), String> { +) -> Result { let previous = coord.prefs().get(); let packs = coord.style_packs().list().map_err(|e| e.to_string())?; sync_style_pack_preferences(&mut prefs, &packs); @@ -528,7 +528,7 @@ pub fn set_settings( coord.apply_android_overlay_settings_change(&previous, &prefs); let _ = app.emit("prefs:changed", &prefs); let _ = app.emit_to("main", "prefs:changed", &prefs); - Ok(()) + Ok(prefs) } #[cfg(test)] diff --git a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs index 2c2ae71c6..a91ee6333 100644 --- a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs +++ b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs @@ -7,6 +7,11 @@ use super::*; +/// 辅助区最后写入插件的状态文字(set/clear 与桥抑制两个路径共享此记录): +/// 状态桥客户端连上后据此补发清除,消除启动竞态窗口发出的残影。 +#[cfg(target_os = "linux")] +static LAST_AUX: std::sync::Mutex> = std::sync::Mutex::new(None); + /// 与 capture_focus_target 类似,但前台窗口属于本进程(即用户停在 QA / capsule / main /// 等自家窗口)时返回 None,让 caller 区分"用户没切到别处" vs "用户切到了另一个真正的 /// 外部 app"。issue #466 多轮场景下用来刷新 qa_focus_target。 @@ -470,22 +475,13 @@ pub(super) fn apply_capsule_window_payload( Ordering::Relaxed, ); - // Linux 通过 fcitx 辅助区显示状态,不操作胶囊窗口。 + // Linux:Wayland 会话无「置顶且不抢焦点」能力,状态走 fcitx 辅助区(见下方 aux + // 分支);X11 会话具备该能力,走与 macOS/Windows 相同的胶囊窗口路径。 #[cfg(target_os = "linux")] - { - let _ = ( - app, - window, - payload, - fallback_card_active, - reassert_spaces, - show_capsule, - classic_style, - ); + if !crate::linux_capsule_x11() { return; } - #[cfg(not(target_os = "linux"))] { let action = capsule_window_action(fallback_card_active, show_capsule, payload.state); if action == CapsuleWindowAction::PreserveFallbackCard { @@ -612,6 +608,11 @@ fn emit_capsule_with_context_locked( }; defer_capsule_payload_if_fallback_active(inner, &payload); + // 状态桥:把这一帧广播给本机 GUI 客户端(GNOME Shell 扩展悬浮胶囊)。 + // 非阻塞、失败静默——纯增值通道,绝不影响听写主链路。 + #[cfg(target_os = "linux")] + crate::capsule_bridge::capsule_bridge_send(capsule_state_log_name(state), payload.level); + #[cfg(target_os = "android")] crate::android::notify_capsule_state(&payload); @@ -622,17 +623,18 @@ fn emit_capsule_with_context_locked( // 入场帧:胶囊从不可见第一次变可见。按平时的「同步 emit + 异步 show」,前端会在窗口 // 还隐藏时就起播 capsule-in,等窗口真 show 出来动画早已播完 → 用户看到胶囊「凭空出 // 现」而非「滑入」。修法:入场帧把发给 capsule 窗口的事件推迟到主线程闭包里、 - // window.show 之后再 emit,保证前端起播入场动画时窗口已可见、动画完整可见。Linux 不 - // 走胶囊窗口(文字经 fcitx5 直接 commit),保持原同步 emit 不变。 + // window.show 之后再 emit,保证前端起播入场动画时窗口已可见、动画完整可见。Linux + // Wayland 不走胶囊窗口(文字经 fcitx5 直接 commit),保持原同步 emit 不变;X11 + // 会话走胶囊窗口,与桌面平台一致采用「先 show 后 emit」的入场帧时序。 let was_visible = matches!(prev_state, Some(s) if !matches!(s, CapsuleState::Idle)); - let defer_capsule_emit = visible && !was_visible && cfg!(not(target_os = "linux")); + let defer_capsule_emit = + visible && !was_visible && (cfg!(not(target_os = "linux")) || crate::linux_capsule_x11()); - // Linux: 通过 fcitx5 插件在候选词列表下方显示听写状态,不干扰输入法预编辑。 + // Linux(Wayland): 通过 fcitx5 插件在候选词列表下方显示听写状态,不干扰输入法预编辑。 // 只在文本变化时调用 DBus,避免录音中 ~30Hz 的音频电平回调重复调用。 + // 若状态桥已有 GUI 客户端(Shell 扩展)接管显示,则跳过辅助区文字避免双重指示。 #[cfg(target_os = "linux")] - { - use std::sync::Mutex; - static LAST_AUX: Mutex> = Mutex::new(None); + if !crate::linux_capsule_x11() && !crate::capsule_bridge::has_clients() { let aux = match state { CapsuleState::Idle => None, @@ -716,6 +718,21 @@ fn emit_capsule_with_context_locked( } } + // 状态桥已有 GUI 客户端(Shell 扩展)接管显示:跳过辅助区文字避免双重指示。 + // 若客户端连接前(启动竞态窗口)已把文字发进插件,这里补发一次清除—— + // 否则插件端 lastAuxText_ 的焦点重放机制会把残影永久顶在屏幕上。 + #[cfg(target_os = "linux")] + if !crate::linux_capsule_x11() && crate::capsule_bridge::has_clients() { + if LAST_AUX.lock().unwrap().take().is_some() { + log::info!("[capsule] bridge client present, clearing residual aux text"); + std::thread::spawn(|| { + if let Err(e) = crate::linux_fcitx::clear_aux_down() { + log::warn!("[capsule] residual clear_aux_down failed: {e}"); + } + }); + } + } + // emit_capsule 会被 cpal process_callback(音频回调线程)调用 ~30 Hz —— 在该 // 线程上调用 NSWindow / HWND API 会撞 macOS dispatch_assert_queue_fail SIGTRAP // 或者 Win32 SendMessage 死锁。把 window.show/hide + 位置调整 marshal 到主线程; diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 9268b9ca6..cf577305f 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -2747,6 +2747,61 @@ pub(super) async fn start_recorder_for_starting( // 配合 CSS 短 transition 让每次 emit 完整可见。 let last_emit_at = Arc::new(Mutex::new(None::)); const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33; + + /// UI 电平动态处理:只影响胶囊 payload,静音检测已消费原始 level。 + /// 麦克风链路常带系统 AGC,原始 RMS×4 后底噪 0.3~0.5、说话顶格 1.0, + /// 动态被压扁。链路:噪声门(自适应基线)→ 基线归一化 → 扩张 → 快攻慢放包络。 + struct LevelDynamics { + noise_floor: f32, + envelope: f32, + last_update: Instant, + seeded: bool, + } + impl LevelDynamics { + fn new() -> Self { + Self { + noise_floor: 0.0, + envelope: 0.0, + last_update: Instant::now(), + seeded: false, + } + } + fn process(&mut self, raw: f32) -> f32 { + let now = Instant::now(); + let dt = now + .duration_since(self.last_update) + .as_secs_f32() + .clamp(0.0005, 0.2); + self.last_update = now; + if !self.seeded { + self.noise_floor = raw * 0.8; // 保守种子:基线略低于首帧 + self.seeded = true; + } + // 基线追踪:下方快速贴到底噪;上方极慢上爬(τ25s,长讲话也不显著抬基线) + if raw < self.noise_floor { + let k = 1.0 - (-dt / 0.35f32).exp(); + self.noise_floor += (raw - self.noise_floor) * k; + } else { + let k = 1.0 - (-dt / 25.0f32).exp(); + self.noise_floor += (raw - self.noise_floor) * k; + } + self.noise_floor = self.noise_floor.clamp(0.0, 0.6); + // 门限+归一化+扩张 + let above = (raw - self.noise_floor * 1.35 - 0.015).max(0.0); + let span = (0.95 - self.noise_floor * 1.35).clamp(0.15, 0.9); + let expanded = (above / span).clamp(0.0, 1.0).powf(1.35); + // 快攻慢放包络 + let k = if expanded > self.envelope { + 1.0 - (-dt / 0.045f32).exp() // 攻击 45ms:跟手 + } else { + 1.0 - (-dt / 0.25f32).exp() // 释放 250ms:余韵 + }; + self.envelope += (expanded - self.envelope) * k; + self.envelope.clamp(0.0, 1.0) + } + } + let ui_level_dyn = Arc::new(Mutex::new(LevelDynamics::new())); + let level_handler: Arc = Arc::new(move |level| { let phase = inner_for_level.state.lock().phase; if phase != SessionPhase::Listening && phase != SessionPhase::Starting { @@ -2762,6 +2817,8 @@ pub(super) async fn start_recorder_for_starting( let _ = tx.try_send(decision); } } + // UI 电平动态化(在节流之前处理,包络按 185Hz 原始节奏平滑) + let level = ui_level_dyn.lock().process(level); let now = Instant::now(); { let mut last = last_emit_at.lock(); @@ -4121,9 +4178,11 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { ) { log::error!("[coord] history append failed: {e}"); } + // 空转写不是用户的错(多为服务端抖动/真的没说话)——走中性灰 ✗ 而非红色 + // 错误球,避免把服务端故障窗口误呈现成「你出错了」。 emit_capsule( inner, - CapsuleState::Error, + CapsuleState::Cancelled, 0.0, elapsed, Some("没有识别到语音".to_string()), diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 4a8d0a968..8480ba0ac 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -17,6 +17,8 @@ mod android; #[cfg(test)] mod build_target; +#[cfg(target_os = "linux")] +mod capsule_bridge; mod asr; mod audio_mute; mod cli; @@ -103,6 +105,8 @@ use std::sync::mpsc; use std::sync::Arc; use std::time::Duration; +#[cfg(target_os = "linux")] +use gtk::prelude::GtkWindowExt; #[cfg(target_os = "linux")] use gtk::prelude::WidgetExt; @@ -600,6 +604,16 @@ fn run_desktop() { let cursor_passthrough_ready = match capsule.gtk_window() { Ok(gtk_window) => { gtk_window.realize(); + // X11 会话具备 keep-above + 不抢焦点的完整能力:胶囊走与 macOS + // nonactivating NSPanel 同效的悬浮路径。GTK 提示属性在窗口显示前 + // 设一次即可持久生效;Wayland 下这些调用无效果(胶囊也不会显示)。 + if linux_capsule_x11() { + gtk_window.set_keep_above(true); + gtk_window.set_accept_focus(false); + gtk_window.set_focus_on_map(false); + gtk_window.set_skip_taskbar_hint(true); + gtk_window.set_skip_pager_hint(true); + } true } Err(e) => { @@ -2945,6 +2959,13 @@ pub(crate) fn foreground_window_monitor() -> Option { } } +/// 胶囊悬浮窗只在 X11 会话启用:GNOME Wayland 不提供「第三方窗口置顶且不抢焦点」 +/// 的能力(无 layer-shell / always-on-top 协议),该场景下状态仍由 fcitx 辅助区 +/// 承载;X11 会话则复用与 macOS 相同的胶囊窗口与 webview 动效。 +pub(crate) fn linux_capsule_x11() -> bool { + matches!(std::env::var("XDG_SESSION_TYPE").as_deref(), Ok("x11")) +} + /// 把 capsule 窗口移到屏幕底部居中,与 Swift `CapsuleWindowController.repositionToBottomCenter` 同效。 /// 留 80pt 给 macOS Dock;Windows 任务栏一般在底部 48pt 以内,整体也合适。 pub(crate) fn position_capsule_bottom_center( diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index 40a3b61d9..1a88586b9 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -597,6 +597,14 @@ fn process_callback( return; } + // 首个回调块是设备启动瞬间的削波垃圾(实测 inRMS≈0.999、peak 1.0), + // 会被当作 deferred bytes 首包上行并归档进 WAV——直接丢弃(约 20ms,无感知)。 + let count = state.callback_count.fetch_add(1, Ordering::Relaxed) + 1; + if count == 1 { + *state.last_callback_time.lock() = Some(std::time::Instant::now()); + return; + } + let mono = downmix_to_mono(interleaved, channels); let input_rms = rms(&mono); @@ -618,7 +626,6 @@ fn process_callback( *state.last_callback_time.lock() = Some(std::time::Instant::now()); // 诊断:峰值 + 周期性日志。 - let count = state.callback_count.fetch_add(1, Ordering::Relaxed) + 1; update_peak(&state.peak_input_rms_milli, input_rms); update_peak(&state.peak_output_rms_milli, output_rms); if count == 1 || count % LOG_EVERY_N_CALLBACKS == 0 { diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 7a18bb1e4..fae213f6c 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -1,11 +1,11 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenLess", - "version": "1.3.18-Beta.6", + "version": "1.3.18", "identifier": "com.openless.app", "build": { "beforeDevCommand": "npm run dev", - "beforeBuildCommand": "npm run check:macos-metal-toolchain && npm run build", + "beforeBuildCommand": "npm run build", "devUrl": "http://localhost:1420", "frontendDist": "../dist" }, @@ -26,7 +26,10 @@ "shadow": true, "hiddenTitle": true, "titleBarStyle": "Overlay", - "trafficLightPosition": { "x": 14, "y": 20 }, + "trafficLightPosition": { + "x": 14, + "y": 20 + }, "visible": false, "acceptFirstMouse": true }, @@ -107,4 +110,4 @@ ] } } -} +} \ No newline at end of file diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 28351ea55..8163e9b05 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -23,6 +23,7 @@ export const en: typeof zhCN = { hide: 'Hide', saved: 'Saved', saving: 'Saving…', + experimental: 'Experimental', copied: 'Copied', operationFailed: 'Operation failed', add: 'Add', @@ -980,7 +981,7 @@ export const en: typeof zhCN = { asrXiaomiMimo: 'Xiaomi MiMo ASR', asrIflytek: 'iFlytek Realtime ASR', asrElevenLabs: 'ElevenLabs Scribe', - asrSherpaOnnxLocal: 'Local sherpa-onnx (experimental)', + asrSherpaOnnxLocal: 'Local sherpa-onnx (Experimental)', asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)', asrLocalWhisper: 'Local Whisper (batch)', asrLocalQwen3: 'Local Qwen3-ASR', @@ -1273,12 +1274,12 @@ export const en: typeof zhCN = { }, }, advanced: { - multimodalPipelineTitle: 'Multimodal recognition pipeline (experimental)', + multimodalPipelineTitle: 'Multimodal recognition pipeline', multimodalPipelineTitleHint: 'One-pass audio recognition with a single multimodal model; traditional ASR + LLM configuration is fully isolated from it.', multimodalPipelineLabel: 'Enable multimodal pipeline', multimodalPipelineHint: 'Adds a Traditional / Multimodal switch on the AI providers page. Traditional = ASR + LLM; Multimodal = one audio-capable model. The two configurations are stored separately and never share credentials.', streamingInsertTitle: 'Streaming insertion', - streamingInsertTitleLinux: 'Streaming insertion (experimental)', + streamingInsertTitleLinux: 'Streaming insertion (Experimental)', streamingInsertDesc: 'Streams text to cursor character by character, reducing perceived latency. Falls back to one-shot paste when conditions are not met.', streamingInsertLabel: 'Streaming insertion', @@ -1290,7 +1291,7 @@ export const en: typeof zhCN = { 'Uses fcitx5 plugin for text submission; streaming insertion uses enigo + XTest for keystroke synthesis.', streamingInsertSaveClipboardLabel: 'Copy to clipboard', streamingInsertSaveClipboardHint: 'After a successful insert, write the final text to the clipboard so Cmd+V can paste it again. Off = clipboard is never touched.', - localAsrTitle: 'Local ASR models (experimental)', + localAsrTitle: 'Local ASR models', localAsrDesc: 'Move transcription from cloud ASR to on-device inference. Offline / privacy-sensitive use only.', localAsrWarningShort: 'Local inference is slower; under-spec hardware may drop words.', qwen3Desc: 'Once enabled, the ASR provider will be taken over.', @@ -1571,7 +1572,7 @@ export const en: typeof zhCN = { foundryModelBaseDesc: 'Faster with lower resource use for lightweight daily dictation.', foundryModelTiny: 'Whisper Tiny (fastest / smoke test)', foundryModelTinyDesc: 'Fastest check option for confirming the Foundry path works.', - sherpaTitle: 'Windows sherpa-onnx Local (experimental)', + sherpaTitle: 'Windows sherpa-onnx Local (Experimental)', sherpaDesc: 'Windows uses sherpa-onnx for offline batch recognition on this device with no ASR API key.', sherpaRuntimeReady: 'Model loaded', sherpaRuntimeMissing: 'Model not loaded', diff --git a/openless-all/app/src/i18n/experimentalTitles.test.ts b/openless-all/app/src/i18n/experimentalTitles.test.ts new file mode 100644 index 000000000..c3e7ccdb8 --- /dev/null +++ b/openless-all/app/src/i18n/experimentalTitles.test.ts @@ -0,0 +1,37 @@ +import { zhCN } from './zh-CN'; +import { zhTW } from './zh-TW'; +import { en } from './en'; +import { ja } from './ja'; +import { ko } from './ko'; + +const locales = { zhCN, zhTW, en, ja, ko }; + +for (const [locale, messages] of Object.entries(locales)) { + const marker = messages.common.experimental; + if (!marker) throw new Error(`${locale} must define the shared experimental badge`); + + for (const [key, title] of Object.entries({ + multimodalPipelineTitle: messages.settings.advanced.multimodalPipelineTitle, + localAsrTitle: messages.settings.advanced.localAsrTitle, + })) { + if (title.includes(marker)) { + throw new Error(`${locale}.${key} must leave the experimental marker to the shared title component`); + } + } + + if (messages.localAsr.qwenExperimentalBadge !== marker) { + throw new Error(`${locale} Qwen badge must use the same experimental wording`); + } + + for (const [key, title] of Object.entries({ + streamingInsertTitleLinux: messages.settings.advanced.streamingInsertTitleLinux, + asrSherpaOnnxLocal: messages.settings.providers.presets.asrSherpaOnnxLocal, + sherpaTitle: messages.localAsr.sherpaTitle, + })) { + if (!title.includes(marker)) { + throw new Error(`${locale}.${key} must use the shared experimental wording`); + } + } +} + +console.log('experimental title translations are consistent'); diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 888497f71..441919117 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -25,6 +25,7 @@ export const ja: typeof zhCN = { hide: '非表示', saved: '保存しました', saving: '保存中', + experimental: '実験的', copied: 'コピーしました', operationFailed: '操作に失敗しました', add: '追加', @@ -1241,7 +1242,7 @@ export const ja: typeof zhCN = { }, }, advanced: { - multimodalPipelineTitle: 'マルチモーダル認識パイプライン(実験的)', + multimodalPipelineTitle: 'マルチモーダル認識パイプライン', multimodalPipelineTitleHint: '1つのマルチモーダルモデルで音声認識を一括実行。従来の ASR + LLM 設定から完全に分離されます。', multimodalPipelineLabel: 'マルチモーダルパイプラインを有効化', multimodalPipelineHint: '有効にすると「サービス → AI プロバイダー」ページに従来 / マルチモーダルの切り替えが表示されます。従来 = ASR + LLM、マルチモーダル = 音声対応モデル1つ。設定は別々に保存され、認証情報を共有しません。', @@ -1258,7 +1259,7 @@ export const ja: typeof zhCN = { 'fcitx5 プラグインで文字を送信。ストリーミング入力は enigo + XTest でキー合成。', streamingInsertSaveClipboardLabel: 'クリップボードに保存', streamingInsertSaveClipboardHint: '挿入成功後に最終テキストをクリップボードへ書き込み、Cmd+V で再貼付け可能にします。OFF ではクリップボードに触れません。', - localAsrTitle: 'ローカル ASR モデル(実験的)', + localAsrTitle: 'ローカル ASR モデル', localAsrDesc: '転写をクラウドから本機推論に切り替えます。オフライン/プライバシー重視向け。', localAsrWarningShort: 'ローカル推論は遅く、スペック不足では欠字の可能性があります。', qwen3Desc: '有効化すると ASR プロバイダーが引き継がれます。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 09dbc5f5e..31162212a 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -25,6 +25,7 @@ export const ko: typeof zhCN = { hide: '숨기기', saved: '저장됨', saving: '저장 중', + experimental: '실험적', copied: '복사됨', operationFailed: '작업 실패', add: '추가', @@ -1241,7 +1242,7 @@ export const ko: typeof zhCN = { }, }, advanced: { - multimodalPipelineTitle: '멀티모달 인식 파이프라인 (실험적)', + multimodalPipelineTitle: '멀티모달 인식 파이프라인', multimodalPipelineTitleHint: '단일 멀티모달 모델로 음성 인식을 한 번에 처리합니다. 기존 ASR + LLM 설정과 완전히 분리됩니다.', multimodalPipelineLabel: '멀티모달 파이프라인 활성화', multimodalPipelineHint: '활성화하면 「서비스 → AI 공급자」 페이지에 전통 / 멀티모달 전환이 나타납니다. 전통 = ASR + LLM, 멀티모달 = 오디오 지원 모델 1개. 두 설정은 별도로 저장되며 자격 증명을 공유하지 않습니다.', @@ -1258,7 +1259,7 @@ export const ko: typeof zhCN = { 'fcitx5 플러그인으로 텍스트 전송. 스트리밍 입력은 enigo + XTest 키 합성 사용.', streamingInsertSaveClipboardLabel: '클립보드에 저장', streamingInsertSaveClipboardHint: '삽입 성공 후 최종 텍스트를 클립보드에 기록하여 Cmd+V 로 다시 붙여넣을 수 있게 합니다. 끄면 클립보드를 건드리지 않습니다.', - localAsrTitle: '로컬 ASR 모델 (실험적)', + localAsrTitle: '로컬 ASR 모델', localAsrDesc: '전사를 클라우드에서 로컬 추론으로 전환합니다. 오프라인 / 프라이버시용에만 권장됩니다.', localAsrWarningShort: '로컬 추론은 느리며, 사양 부족 시 글자 누락이 발생할 수 있습니다.', qwen3Desc: '활성화하면 ASR 프로바이더가 인수됩니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 68fd8875e..e7907be36 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -21,6 +21,7 @@ export const zhCN = { hide: '隐藏', saved: '已保存', saving: '保存中', + experimental: '实验性', copied: '已复制', operationFailed: '操作失败', add: '添加', @@ -1271,12 +1272,12 @@ export const zhCN = { }, }, advanced: { - multimodalPipelineTitle: '多模态识别管线(实验性)', + multimodalPipelineTitle: '多模态识别管线', multimodalPipelineTitleHint: '用单个多模态模型一步完成语音识别;与传统 ASR + LLM 配置完全隔离。', multimodalPipelineLabel: '启用多模态识别管线', multimodalPipelineHint: '开启后,「服务 → AI 提供商」页出现「传统模式 / 多模态模式」切换。传统 = ASR + LLM;多模态 = 单个支持音频的模型。两套配置分开存储、绝不共享凭据。', streamingInsertTitle: '流式输入', - streamingInsertTitleLinux: '流式输入(实验)', + streamingInsertTitleLinux: '流式输入(实验性)', streamingInsertDesc: '逐字实时插入,降低感知延迟。不满足条件时回落到一次性粘贴。', streamingInsertLabel: '流式输入', @@ -1288,7 +1289,7 @@ export const zhCN = { '通过 fcitx5 插件提交文字;流式输入使用 enigo + XTest 合成按键。', streamingInsertSaveClipboardLabel: '同步到剪贴板', streamingInsertSaveClipboardHint: '插入成功后把最终文本写入剪贴板,方便 Cmd+V 再次粘贴;关闭后流式过程不动剪贴板。', - localAsrTitle: '本地 ASR 模型(实验性)', + localAsrTitle: '本地 ASR 模型', localAsrDesc: '把转写从云端切到本机推理。仅推荐离线 / 隐私敏感场景。', localAsrWarningShort: '本地推理较慢,配置不足时可能吞字。', qwen3Desc: '启动之后,ASR 提供商将被接管。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 876318675..dbceab3e5 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -23,6 +23,7 @@ export const zhTW: typeof zhCN = { hide: '隱藏', saved: '已保存', saving: '保存中', + experimental: '實驗性', copied: '已複製', operationFailed: '操作失敗', add: '添加', @@ -1239,12 +1240,12 @@ export const zhTW: typeof zhCN = { }, }, advanced: { - multimodalPipelineTitle: '多模態辨識管線(實驗性)', + multimodalPipelineTitle: '多模態辨識管線', multimodalPipelineTitleHint: '用單一多模態模型一步完成語音辨識;與傳統 ASR + LLM 設定完全隔離。', multimodalPipelineLabel: '啟用多模態辨識管線', multimodalPipelineHint: '開啟後,「服務 → AI 提供者」頁出現「傳統模式 / 多模態模式」切換。傳統 = ASR + LLM;多模態 = 單一支援音訊的模型。兩套設定分開儲存、絕不共用憑證。', streamingInsertTitle: '流式輸入', - streamingInsertTitleLinux: '流式輸入(實驗)', + streamingInsertTitleLinux: '流式輸入(實驗性)', streamingInsertDesc: '逐字即時插入,降低感知延遲。不滿足條件時回落到一次性貼上。', streamingInsertLabel: '流式輸入', @@ -1256,7 +1257,7 @@ export const zhTW: typeof zhCN = { '通過 fcitx5 插件提交文字;串流輸入使用 enigo + XTest 合成按鍵。', streamingInsertSaveClipboardLabel: '同步到剪貼簿', streamingInsertSaveClipboardHint: '插入成功後把最終文字寫入剪貼簿,方便 Cmd+V 再次貼上;關閉後流式過程不動剪貼簿。', - localAsrTitle: '本地 ASR 模型(實驗性)', + localAsrTitle: '本地 ASR 模型', localAsrDesc: '把轉寫從雲端切到本機推理。僅推薦離線 / 隱私敏感場景。', localAsrWarningShort: '本地推理較慢,配置不足時可能吞字。', qwen3Desc: '啓動之後,ASR 提供商將被接管。', diff --git a/openless-all/app/src/lib/ipc/settings.ts b/openless-all/app/src/lib/ipc/settings.ts index 0ad0dad23..f18c366cc 100644 --- a/openless-all/app/src/lib/ipc/settings.ts +++ b/openless-all/app/src/lib/ipc/settings.ts @@ -13,9 +13,9 @@ export function getDefaultStyleSystemPrompts(): Promise { })) } -export function setSettings(prefs: UserPreferences): Promise { +export function setSettings(prefs: UserPreferences): Promise { return invokeOrMock("set_settings", { prefs }, () => { mockSetSettings(prefs) - return undefined + return prefs }) } diff --git a/openless-all/app/src/main.tsx b/openless-all/app/src/main.tsx index 8cdd250d8..6be51d45b 100644 --- a/openless-all/app/src/main.tsx +++ b/openless-all/app/src/main.tsx @@ -19,6 +19,8 @@ const isLessComputerGlow = windowKind === "less-computer-glow"; const osQuery = params.get("os") as OS | null; const os = osQuery ?? detectOS(); document.documentElement.dataset.olPlatform = os; +// 标记窗口类型:Linux 的不透明回退背景需要排除胶囊窗(胶囊必须透明才能露出光效)。 +if (windowKind) document.documentElement.dataset.olWindow = windowKind; initThemeMode(); const root = ReactDOM.createRoot(document.getElementById("root")!); diff --git a/openless-all/app/src/pages/settings/ExperimentalSectionTitle.test.ts b/openless-all/app/src/pages/settings/ExperimentalSectionTitle.test.ts new file mode 100644 index 000000000..039ba35ef --- /dev/null +++ b/openless-all/app/src/pages/settings/ExperimentalSectionTitle.test.ts @@ -0,0 +1,18 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { ExperimentalSectionTitle } from './shared'; + +const markup = renderToStaticMarkup( + ExperimentalSectionTitle({ children: 'Local ASR models', badge: 'Experimental' }), +); + +if (!markup.includes('Local ASR models')) { + throw new Error('experimental section title must render its title'); +} +if (!markup.includes('Experimental')) { + throw new Error('experimental section title must render the shared badge'); +} +if ((markup.match(/Experimental/g) ?? []).length !== 1) { + throw new Error('experimental badge must be rendered exactly once'); +} + +console.log('experimental section title tests passed'); diff --git a/openless-all/app/src/pages/settings/LocalModelSection.tsx b/openless-all/app/src/pages/settings/LocalModelSection.tsx index 68c8efe7c..445b06e68 100644 --- a/openless-all/app/src/pages/settings/LocalModelSection.tsx +++ b/openless-all/app/src/pages/settings/LocalModelSection.tsx @@ -12,6 +12,7 @@ import { LocalAsr } from '../LocalAsr'; import { detectOS } from '../../components/WindowChrome'; import { getPlatformCapabilities } from '../../lib/platform'; import { Card } from '../_atoms'; +import { ExperimentalSectionTitle } from './shared'; export function LocalModelSection() { const { t } = useTranslation(); @@ -31,7 +32,9 @@ export function LocalModelSection() { Windows:标题区整体灰显 —— 本地 ASR 在 Win 上走 Foundry / sherpa 独立路径。 */}
-
{t('settings.advanced.localAsrTitle')}
+ + {t('settings.advanced.localAsrTitle')} +
{ - void updatePrefs({ ...prefs, multimodalPipelineEnabled }).catch(error => { + void updatePrefs(current => ({ ...current, multimodalPipelineEnabled })).catch(error => { console.error('[settings] failed to update multimodal pipeline flag', error); }); }; return ( - + {t('settings.advanced.multimodalPipelineTitle')} - + + + {children} + + {badge} + + + + ) +} + // 页面瘦身:设置页描述文案全部隐藏(保留组件签名 + 调用点,便于需要时恢复)。 export function SectionDesc(_props: { children: ReactNode diff --git a/openless-all/app/src/state/HotkeySettingsContext.tsx b/openless-all/app/src/state/HotkeySettingsContext.tsx index 6527fd3cc..299af2241 100644 --- a/openless-all/app/src/state/HotkeySettingsContext.tsx +++ b/openless-all/app/src/state/HotkeySettingsContext.tsx @@ -22,6 +22,7 @@ import type { import i18n, { outputPrefsForLocale, type SupportedLocale } from "../i18n" import { applyThemeFromPreference } from "../lib/themeMode" import { emitSaved } from "../lib/savedEvent" +import { PreferencesWriteGate } from "./preferencesWriteGate" interface HotkeySettingsContextValue { prefs: UserPreferences | null @@ -42,6 +43,24 @@ const HotkeySettingsContext = createContext( const errorMessage = (error: unknown) => String(error instanceof Error ? error.message : error) +type PreferenceKey = keyof UserPreferences + +const changedPreferenceEntries = ( + previous: UserPreferences, + next: UserPreferences, +) => { + const changes = new Map() + for (const key of Object.keys(next) as PreferenceKey[]) { + if (!Object.is(previous[key], next[key])) { + changes.set(key, next[key]) + } + } + return changes +} + +const samePreferences = (left: UserPreferences, right: UserPreferences) => + JSON.stringify(left) === JSON.stringify(right) + export function HotkeySettingsProvider({ children }: { children: ReactNode }) { const [prefs, setPrefs] = useState(null) const [capability, setCapability] = useState(null) @@ -50,21 +69,60 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { const persistQueueRef = useRef>(Promise.resolve()) const latestPrefsRef = useRef(null) const persistedPrefsRef = useRef(null) + const writeGateRef = useRef( + new PreferencesWriteGate(samePreferences), + ) + const prefsChangeVersionRef = useRef(0) + const pendingLocalChangesRef = useRef( + new Map(), + ) + + const applyIncomingPrefs = useCallback( + (nextPrefs: UserPreferences, persistedPrefs = nextPrefs) => { + prefsChangeVersionRef.current += 1 + latestPrefsRef.current = nextPrefs + persistedPrefsRef.current = persistedPrefs + setPrefs(nextPrefs) + applyThemeFromPreference(nextPrefs.themeMode ?? "system") + }, + [], + ) + + const mergePendingLocalChanges = useCallback( + (incoming: UserPreferences) => { + const merged = { ...incoming } + for (const [key, value] of pendingLocalChangesRef.current) { + Object.assign(merged, { [key]: value }) + } + return merged + }, + [], + ) + + const waitForPersistence = useCallback(async () => { + while (true) { + const observedQueue = persistQueueRef.current + await observedQueue.catch(() => undefined) + if (observedQueue === persistQueueRef.current) return + } + }, []) const refresh = useCallback(async () => { setLoading(true) setError(null) try { + await waitForPersistence() + const readVersion = prefsChangeVersionRef.current const [prefsResult, capabilityResult] = await Promise.allSettled([ getSettings(), getHotkeyCapability(), ]) let nextError: string | null = null if (prefsResult.status === "fulfilled") { - latestPrefsRef.current = prefsResult.value - persistedPrefsRef.current = prefsResult.value - setPrefs(prefsResult.value) - applyThemeFromPreference(prefsResult.value.themeMode ?? "system") + if (prefsChangeVersionRef.current === readVersion) { + const mergedPrefs = mergePendingLocalChanges(prefsResult.value) + applyIncomingPrefs(mergedPrefs, prefsResult.value) + } } else { console.error( "[hotkey-settings] failed to load preferences", @@ -91,19 +149,39 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { } finally { setLoading(false) } - }, []) + }, [applyIncomingPrefs, mergePendingLocalChanges, waitForPersistence]) const queueSetSettings = useCallback( - (resolved: UserPreferences) => { + ( + resolved: UserPreferences, + previous: UserPreferences | null = latestPrefsRef.current, + trackLocalChanges = true, + ) => { + if (trackLocalChanges && previous) { + for (const [key, value] of changedPreferenceEntries(previous, resolved)) { + pendingLocalChangesRef.current.set(key, value) + } + } + const finishWrite = writeGateRef.current.beginWrite(resolved) + let savedPrefs: UserPreferences | null = null const task = persistQueueRef.current .catch(() => undefined) .then(async () => { - await setSettings(resolved) + savedPrefs = await setSettings(resolved) + }) + .then(() => { + persistedPrefsRef.current = savedPrefs + }) + .finally(() => { + if (finishWrite(savedPrefs ?? undefined)) { + pendingLocalChangesRef.current.clear() + if (savedPrefs) applyIncomingPrefs(savedPrefs) + } }) persistQueueRef.current = task return task }, - [], + [applyIncomingPrefs], ) useEffect(() => { @@ -122,10 +200,21 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { (event) => { const nextPrefs = event.payload if (!nextPrefs) return - latestPrefsRef.current = nextPrefs - persistedPrefsRef.current = nextPrefs - setPrefs(nextPrefs) - applyThemeFromPreference(nextPrefs.themeMode ?? "system") + const incoming = writeGateRef.current.receiveIncoming(nextPrefs) + if (incoming.isOwnWrite) return + + const mergedPrefs = mergePendingLocalChanges(nextPrefs) + applyIncomingPrefs(mergedPrefs, nextPrefs) + if (incoming.wasPending) { + // The queued full snapshot may still overwrite fields changed by + // another window. Re-save the reconciled snapshot after it drains. + void queueSetSettings(mergedPrefs, null, false).catch((error) => { + console.warn( + "[settings] reconcile external preference change failed", + error, + ) + }) + } }, ) if (cancelled) { @@ -144,7 +233,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { cancelled = true unlisten?.() } - }, []) + }, [applyIncomingPrefs, mergePendingLocalChanges, queueSetSettings]) useEffect(() => { latestPrefsRef.current = prefs @@ -178,9 +267,10 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { return } const merged = { ...currentPrefs, ...nextLocalePrefs } + prefsChangeVersionRef.current += 1 latestPrefsRef.current = merged setPrefs(merged) - void queueSetSettings(merged).catch((error) => { + void queueSetSettings(merged, currentPrefs).catch((error) => { console.warn( "[settings] sync locale output preferences failed", error, @@ -199,10 +289,10 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { const resolved = typeof next === "function" ? next(current) : next if (resolved === current) return setPrefs(resolved) + prefsChangeVersionRef.current += 1 latestPrefsRef.current = resolved try { - await queueSetSettings(resolved) - persistedPrefsRef.current = resolved + await queueSetSettings(resolved, current) } catch (error) { // 兜底(#904):保存失败必须回滚乐观状态并可见, // 不能出现界面显示已切换、重启后回退的“假保存”。 diff --git a/openless-all/app/src/state/preferencesWriteGate.test.ts b/openless-all/app/src/state/preferencesWriteGate.test.ts new file mode 100644 index 000000000..c157d0d45 --- /dev/null +++ b/openless-all/app/src/state/preferencesWriteGate.test.ts @@ -0,0 +1,44 @@ +import { PreferencesWriteGate } from './preferencesWriteGate'; + +function assert(condition: boolean, message: string) { + if (!condition) throw new Error(message); +} + +const gate = new PreferencesWriteGate(); +assert(gate.shouldApplyIncoming(), 'remote preference events should apply while idle'); +const idleIncoming = gate.receiveIncoming('idle'); +assert(idleIncoming.value === 'idle', 'idle events should be returned immediately'); +assert(!idleIncoming.isOwnWrite, 'an unrelated idle event is not a local echo'); + +const finishFirst = gate.beginWrite('first'); +assert(!gate.shouldApplyIncoming(), 'an older event must not replace an optimistic local update'); +const externalIncoming = gate.receiveIncoming('latest'); +assert(externalIncoming.wasPending, 'events during a write must be marked as pending'); +assert(!externalIncoming.isOwnWrite, 'external events must not be discarded as local echoes'); +const ownIncoming = gate.receiveIncoming('first'); +assert(ownIncoming.isOwnWrite, 'the write payload must be recognized as its own echo'); + +const finishSecond = gate.beginWrite('second'); +assert(!gate.receiveIncoming('old').isOwnWrite, 'unrelated queued events must remain visible to the caller'); +assert(finishFirst('canonical-first') === false, 'only the final queued write may apply its canonical preference payload'); +assert(!gate.shouldApplyIncoming(), 'all queued local writes must finish before events resume'); + +assert(finishSecond('canonical-second'), 'the final queued write must apply its canonical preference payload'); +assert(gate.shouldApplyIncoming(), 'remote preference events should resume after local writes finish'); +assert(gate.receiveIncoming('canonical-second').isOwnWrite, 'delayed canonical echoes must be recognized'); + +const objectGate = new PreferencesWriteGate<{ value: string }>( + (left, right) => left.value === right.value, +); +const finishObjectWrite = objectGate.beginWrite({ value: 'saved' }); +assert( + objectGate.receiveIncoming({ value: 'saved' }).isOwnWrite, + 'structurally equal event payloads must be recognized as local echoes', +); +finishObjectWrite(); + +// Completion callbacks can be reached from defensive cleanup paths; they must be idempotent. +assert(finishSecond() === false, 'a completion callback must only release its event once'); +assert(gate.shouldApplyIncoming(), 'finishing one write twice must not corrupt the gate'); + +console.log('preference write gate tests passed'); diff --git a/openless-all/app/src/state/preferencesWriteGate.ts b/openless-all/app/src/state/preferencesWriteGate.ts new file mode 100644 index 000000000..c528c5857 --- /dev/null +++ b/openless-all/app/src/state/preferencesWriteGate.ts @@ -0,0 +1,50 @@ +export interface IncomingPreference { + value: T; + wasPending: boolean; + isOwnWrite: boolean; +} + +/** Correlates preference broadcasts with writes initiated by this webview. */ +export class PreferencesWriteGate { + private readonly pendingWrites = new Map(); + private readonly recentWrites: T[] = []; + private nextWriteId = 0; + + constructor( + private readonly equals: (left: T, right: T) => boolean = Object.is, + ) {} + + beginWrite(value: T): (canonical?: T) => boolean { + const writeId = this.nextWriteId++; + this.pendingWrites.set(writeId, value); + let finished = false; + return (canonical) => { + if (finished) return false; + finished = true; + this.pendingWrites.delete(writeId); + this.remember(value); + if (canonical !== undefined) this.remember(canonical); + return this.pendingWrites.size === 0; + }; + } + + shouldApplyIncoming(): boolean { + return this.pendingWrites.size === 0; + } + + receiveIncoming(value: T): IncomingPreference { + const wasPending = this.pendingWrites.size > 0; + const isOwnWrite = [ + ...this.pendingWrites.values(), + ...this.recentWrites, + ].some(expected => this.equals(expected, value)); + return { value, wasPending, isOwnWrite }; + } + + private remember(value: T) { + const previousIndex = this.recentWrites.findIndex(item => this.equals(item, value)); + if (previousIndex >= 0) this.recentWrites.splice(previousIndex, 1); + this.recentWrites.push(value); + if (this.recentWrites.length > 16) this.recentWrites.shift(); + } +} diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index 3b443344c..b524d8501 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -7,9 +7,11 @@ html, body, #root { overflow: hidden; } -html[data-ol-platform="linux"], -html[data-ol-platform="linux"] body, -html[data-ol-platform="linux"] #root { +/* Linux 主窗口无系统级毛玻璃,保留不透明底;胶囊窗排除在外 —— 它依赖 + 透明窗口渲染 Siri 光效,X11 会话下透明可用(Wayland 不显示胶囊窗)。 */ +html[data-ol-platform="linux"]:not([data-ol-window="capsule"]), +html[data-ol-platform="linux"]:not([data-ol-window="capsule"]) body, +html[data-ol-platform="linux"]:not([data-ol-window="capsule"]) #root { background: var(--ol-surface); } @@ -188,11 +190,11 @@ a { color: inherit; text-decoration: none; } pointer-events: none; } -html[data-ol-platform="linux"] .ol-frost { +html[data-ol-platform="linux"]:not([data-ol-window="capsule"]) .ol-frost { background: var(--ol-surface); } -html[data-ol-platform="linux"] .ol-frost::before { +html[data-ol-platform="linux"]:not([data-ol-window="capsule"]) .ol-frost::before { content: none; } diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp index 27a436f37..6ef8ad15e 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp +++ b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp @@ -262,25 +262,35 @@ class OpenLess final : public AddonInstance, // 返回 void 而非 std::tuple<>,以匹配 FCITX_OBJECT_VTABLE_METHOD 的 RET("") void commitText(const std::string &text) { - // 优先使用快捷键按下时保存的输入上下文(savedIc_), - // 此时用户在目标 app 中,此后胶囊窗口抢焦点不影响提交。 - // 若 savedIc_ 为空则兜底用 foreachFocused。 - auto *ic = savedIc_; - if (!ic) { - FCITX_LOGC(openless, Warn) - << "CommitText: savedIc_ is null, trying foreachFocused"; + // 优先用**当前焦点 IC**(实时),savedIc_ 仅作焦点缺失时的兜底。 + // + // 原实现优先 savedIc_(快捷键按下时快照),前提是快捷键事件经由 fcitx5 + // 流过、能顺带刷新快照。但 GNOME Wayland 下全局热键(Mutter 抓取)不会 + // 到达 fcitx5,savedIc_ 停留在「上一次某个 app 走过 fcitx 按键」的时刻 + // —— CommitText 会把文字提交到那个陈旧窗口,造成「说话在 A 窗口、 + // 文字落在 B 窗口」。Linux 无胶囊窗口抢焦点的问题,实时焦点即用户 + // 意图目标;焦点暂时拿不到时退回 savedIc_。 + auto *ic = static_cast(nullptr); + { auto &mgr = instance_->inputContextManager(); mgr.foreachFocused([&](InputContext *focusedIc) { ic = focusedIc; return false; }); } + if (!ic) { + FCITX_LOGC(openless, Warn) + << "CommitText: no focused IC, falling back to savedIc_"; + ic = savedIc_; + } if (!ic) { FCITX_LOGC(openless, Warn) << "CommitText: no input context available"; throw std::runtime_error("no focused input context"); } - FCITX_LOGC(openless, Debug) << "CommitText: " << text; + FCITX_LOGC(openless, Info) + << "CommitText: " << text.size() << " bytes, program=[" + << ic->program() << "] display=[" << ic->display() << "]"; ic->commitString(text); }