From 2fb1b3d75cd5939835075fd7cdfa0b9a02549cc3 Mon Sep 17 00:00:00 2001 From: Touyu <63269843+TheTouYu@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:11:06 +0800 Subject: [PATCH 01/31] =?UTF-8?q?fix(linux-egui):=20=E5=90=AC=E5=86=99?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=E6=80=A7=E4=B8=A4=E4=BF=AE=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20Menu=20=E7=83=AD=E9=94=AE=20/=20=E5=BD=95=E9=9F=B3?= =?UTF-8?q?=E6=8E=89=E7=BA=BF=E4=B8=8D=E4=B8=A2=E5=AD=97=EF=BC=88+=20?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E6=97=A5=E5=BF=97=EF=BC=89=20(#1255)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/crates/openless-core/src/api.rs | 32 ++++++++++------- .../openless-core/src/dictation_engine.rs | 17 ++++----- openless-all/app/linux-egui/src/audio.rs | 14 +++++--- openless-all/app/linux-egui/src/settings.rs | 35 ++++++++++++++++--- 4 files changed, 67 insertions(+), 31 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index ab3a2a084..480c5002a 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -9368,6 +9368,13 @@ impl OpenLessBackend { } fn mark_dictation_failed(&self, session_id: SessionId, error: &BackendError) { + // 诊断:失败只写 errorCode 时无法定位是录音停止、转写还是插入哪一步出的错。 + log::warn!( + "[dictation] session {} failed: code={:?} message={}", + session_id, + error.code, + error.message + ); let mut state = self.state.write().expect("backend state lock poisoned"); if state.dictation.session_id != Some(session_id) { return; @@ -18493,18 +18500,19 @@ mod tests { Some(stopping) => stopping.await, None => backend.stop_dictation().await, }; - if stop_fails { - assert_eq!(result.unwrap_err().code, BackendErrorCode::Platform); - assert_eq!( - backend.list_history().unwrap()[0].error_code.as_deref(), - Some("transcribeFailed") - ); - } else { - let result = result.unwrap(); - assert_eq!(result.session_id, session_id); - assert_eq!(result.polished_text, "captured prefix"); - assert_eq!(transcriber.pcm(), vec![1, 0, 2, 0]); - } + // 录音流的 `stop()` 失败(蓝牙麦掉线、PipeWire 节点被挂起)不再 + // 中止这次听写:已经流进 ASR 的音频要照常转写并插入。断言因此 + // 与成功路径一致 —— 差别只剩「有没有打过那条 warn」,行为对 + // 用户不该有区别。 + let result = result.unwrap(); + assert_eq!(result.session_id, session_id); + assert_eq!(result.polished_text, "captured prefix"); + assert_eq!(transcriber.pcm(), vec![1, 0, 2, 0]); + assert_eq!( + backend.list_history().unwrap()[0].error_code.as_deref(), + None, + "a dropped capture stream must not be recorded as a transcription failure", + ); assert_eq!(recorder.stop_count(), 1); assert!(archive.lock().unwrap().as_ref().unwrap().is_available()); assert_eq!( diff --git a/openless-all/app/crates/openless-core/src/dictation_engine.rs b/openless-all/app/crates/openless-core/src/dictation_engine.rs index f8d3d58ee..a121f3efe 100644 --- a/openless-all/app/crates/openless-core/src/dictation_engine.rs +++ b/openless-all/app/crates/openless-core/src/dictation_engine.rs @@ -719,17 +719,12 @@ impl DictationEngine for PipelineDictationEngine { let archive = recording.archive(); let mut has_audio_recording = archive.as_ref().map(|archive| archive.is_available()); if let Err(error) = recording.stop().await { - demote_failed_archive( - archive.as_ref(), - context.output_target, - &mut has_audio_recording, - ) - .await; - let _ = cancel_transcription_once(&session, transcription).await; - remove_session(&sessions, session_id, &session); - let mut failure = EngineFailure::new(error, EngineFailureStage::Transcribing); - failure.has_audio_recording = has_audio_recording; - return Err(failure); + // 录音设备中途掉线(蓝牙麦欠载/节点被挂起时报 "Device disconnected") + // 不应丢掉已经流进 ASR 的内容:这里降级为警告继续转写, + // 由后续「未收到有效音频」守卫决定是否真的没有可识别内容。 + log::warn!( + "[dictation] recorder stop failed; transcribing the captured audio anyway: {error}" + ); } if context.output_target == crate::dictation_context::DictationOutputTarget::QuickNote { if let Some(archive) = archive.as_ref() { diff --git a/openless-all/app/linux-egui/src/audio.rs b/openless-all/app/linux-egui/src/audio.rs index 19d10b1dc..5e5d931af 100644 --- a/openless-all/app/linux-egui/src/audio.rs +++ b/openless-all/app/linux-egui/src/audio.rs @@ -620,21 +620,27 @@ fn select_input_device( let devices = host .input_devices() .map_err(|error| classify_audio_error("enumerate input devices", error.to_string()))?; + let mut available: Vec = Vec::new(); for device in devices { - if device.to_string() == preferred { + let name = device.to_string(); + if name == preferred { return Ok(device); } + available.push(name); } + // 设备名是精确匹配:把候选项一并打出来,省得再猜。 log::warn!( - "preferred Linux microphone was not found; using the default device: {preferred}" + "preferred Linux microphone was not found; using the default device: {preferred}; available: {available:?}" ); } - host.default_input_device().ok_or_else(|| { + let device = host.default_input_device().ok_or_else(|| { BackendError::new( BackendErrorCode::Platform, "no Linux microphone input device is available", ) - }) + })?; + log::info!("[audio] using input device: {}", device); + Ok(device) } #[cfg(target_os = "linux")] diff --git a/openless-all/app/linux-egui/src/settings.rs b/openless-all/app/linux-egui/src/settings.rs index 38dfad58d..c5f934908 100644 --- a/openless-all/app/linux-egui/src/settings.rs +++ b/openless-all/app/linux-egui/src/settings.rs @@ -467,10 +467,15 @@ fn binding_to_fcitx_key(binding: &ShortcutBinding) -> String { fn normalize_fcitx_primary(primary: &str) -> String { let trimmed = primary.trim(); - if let Some(stripped) = trimmed.strip_prefix("Key") { - stripped.to_ascii_lowercase() - } else { - trimmed.to_ascii_lowercase() + let stripped = trimmed.strip_prefix("Key").unwrap_or(trimmed); + let lower = stripped.to_ascii_lowercase(); + // fcitx5 的 Key::parse 按 X11 keysym 名做**大小写敏感**查表:实测 + // `SetCustomDictationTrigger("Menu")` → sym=0xff67 有效,`("menu")` → + // invalid key(2026-10-22)。这里只把查表中确实首字母大写的名字还原, + // 其余继续交给小写(space / f5 / 单字符都已验证可用)。 + match lower.as_str() { + "menu" => "Menu".to_string(), + _ => lower, } } @@ -603,6 +608,28 @@ mod tests { .is_err()); } + #[test] + fn menu_primary_keeps_the_case_fcitx_expects() { + // fcitx5 的 Key::parse 用 X11 keysym 名做大小写敏感查表:'Menu' 有效、 + // 'menu' 被拒(2026-10-22 实测 SetCustomDictationTrigger 返回 + // "invalid key")。主键名统一小写的旧逻辑会让 Menu 键永远注不上。 + let binding = ShortcutBinding { + primary: "Menu".into(), + modifiers: Vec::new(), + }; + assert_eq!(binding_to_fcitx_key(&binding), "Menu"); + let key_cased = ShortcutBinding { + primary: "KeyMenu".into(), + modifiers: Vec::new(), + }; + assert_eq!(binding_to_fcitx_key(&key_cased), "Menu"); + let space = ShortcutBinding { + primary: "Space".into(), + modifiers: vec!["ctrl".into(), "alt".into()], + }; + assert_eq!(binding_to_fcitx_key(&space), "Control+Alt+space"); + } + #[test] fn mouse_shortcuts_are_rejected_by_the_linux_host() { for primary in ["Mouse4", "Mouse5"] { From 60f67a9b09f5c664bce8d82c4351a34bb570e5a8 Mon Sep 17 00:00:00 2001 From: TRIP <1933142963@qq.com> Date: Thu, 22 Oct 2026 21:18:08 -0700 Subject: [PATCH 02/31] =?UTF-8?q?build(ios):=20=E5=AF=B9=E9=BD=90=20Beta?= =?UTF-8?q?=20=E5=B9=B6=E9=9A=94=E7=A6=BB=E7=A7=BB=E5=8A=A8=E7=AB=AF?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E4=B8=8E=E6=B5=8B=E8=AF=95=20(#1254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ios): 补齐 Core 集成并统一应用与输入法版本 Restore the native input contracts required by the iOS host, reconcile startup with the current Core write gate, and deliver version 0.1.0 (16) from shared bundle settings. Add verified IPA packaging and iOS source CI, refresh dependency attribution, and replace obsolete reports with maintained project entry points. Co-Authored-By: Codex * test(core): 修复翻译字段回归测试的 Clippy 检查 Co-Authored-By: Codex * fix(core): 保留旧客户端翻译选择并同步录音目标列表 Reconcile a legacy scalar selection echoed with an initially empty target list without weakening explicit deletion of stored choices. Keep the recording picker selection and ordered targets in one durable update. Add persistence regressions and account for canonical language names in the Linux revision-conflict fixture. Co-Authored-By: Codex * fix(core): 在界面草稿重建前规范翻译偏好 Resolve translation scalar/list compatibility while producing a patched draft so rebasing and serialization cannot discard a pending selection. Cover the round trip and explicit deletion, and verify the Linux settings queue against the normalized result. Co-Authored-By: Codex * build(ios): 对齐 Beta 并隔离移动端构建与测试 Keep desktop sources identical to beta. Prepare the iOS input-contract additions in a generated Core copy owned by the mobile Rust workspace, and verify it through dedicated tests and an unsigned device build in CI. Co-Authored-By: Codex --------- Co-authored-by: TRIP <192432815+appergb@users.noreply.github.com> Co-authored-by: Codex --- .github/workflows/check-ios.yml | 90 + README.md | 2 + README.zh.md | 2 + docs/structure.md | 2 + openless-all/README.md | 4 + openless-all/ios/.gitattributes | 4 + openless-all/ios/.gitignore | 5 + openless-all/ios/App/AppDesign.swift | 2 +- .../ios/App/ExperimentalPiPKeepAlive.swift | 6 +- openless-all/ios/App/HostApplicationReturn.m | 10 +- .../ios/App/PiPCoexistenceAdapter.swift | 3 +- .../ios/AppTests/SpeakButtonVisualTests.swift | 9 +- .../TranslationArcGeometryTests.swift | 2 +- openless-all/ios/Config/App-Info.plist | 4 +- openless-all/ios/Config/Base.xcconfig | 3 +- openless-all/ios/Config/Keyboard-Info.plist | 4 +- .../ios/Config/RecordingWidget-Info.plist | 4 +- openless-all/ios/Config/Version.xcconfig | 3 + openless-all/ios/CoreHost/Cargo.lock | 1272 +++++++++---- openless-all/ios/CoreHost/Cargo.toml | 5 +- .../Patches/ios-input-contracts.patch | 1682 ++++++++++++++++ .../ios/CoreHost/src/account_sync_tests.rs | 6 +- openless-all/ios/CoreHost/src/lib.rs | 4 + .../ios/Keyboard/KeyboardHostApplication.m | 12 +- openless-all/ios/README.md | 165 +- .../Licenses/GlobalRefresh-PiP-NOTICE.txt | 4 +- .../Rust/adler2-2.0.1-LICENSE-0BSD.txt | 12 - ...ACHE.txt => aead-0.6.1-LICENSE-APACHE.txt} | 0 ...NSE-MIT.txt => aead-0.6.1-LICENSE-MIT.txt} | 2 +- ...PACHE.txt => aes-0.9.3-LICENSE-APACHE.txt} | 0 ...ENSE-MIT.txt => aes-0.9.3-LICENSE-MIT.txt} | 1 + .../Licenses/Rust/anymap3-1.1.0-COPYING.txt | 17 + ...HE.txt => argon2-0.6.0-LICENSE-APACHE.txt} | 0 ...E-MIT.txt => argon2-0.6.0-LICENSE-MIT.txt} | 2 +- .../Rust/array-init-2.1.0-LICENSE-APACHE.txt | 201 ++ .../Rust/array-init-2.1.0-LICENSE-MIT.txt | 25 + ....txt => base16ct-0.2.0-LICENSE-APACHE.txt} | 0 .../Rust/base16ct-0.2.0-LICENSE-MIT.txt | 26 + ...E.txt => base64-0.23.1-LICENSE-APACHE.txt} | 0 .../Rust/base64-0.23.1-LICENSE-MIT.txt | 21 + ...E.txt => blake2-0.11.0-LICENSE-APACHE.txt} | 0 ...-MIT.txt => blake2-0.11.0-LICENSE-MIT.txt} | 3 +- ...=> block-buffer-0.12.1-LICENSE-APACHE.txt} | 0 ...xt => block-buffer-0.12.1-LICENSE-MIT.txt} | 2 +- .../Licenses/Rust/byteorder-1.5.0-COPYING.txt | 3 - .../Rust/byteorder-1.5.0-UNLICENSE.txt | 24 - ...CHE.txt => bzip2-0.6.1-LICENSE-APACHE.txt} | 0 ...SE-MIT.txt => bzip2-0.6.1-LICENSE-MIT.txt} | 0 ...APACHE.txt => cc-1.9.0-LICENSE-APACHE.txt} | 0 ...CENSE-MIT.txt => cc-1.9.0-LICENSE-MIT.txt} | 0 ...HE.txt => cfg-if-1.0.5-LICENSE-APACHE.txt} | 0 ...E-MIT.txt => cfg-if-1.0.5-LICENSE-MIT.txt} | 0 ...hacha20poly1305-0.11.0-LICENSE-APACHE.txt} | 0 ...> chacha20poly1305-0.11.0-LICENSE-MIT.txt} | 2 +- ...HE.txt => cipher-0.5.2-LICENSE-APACHE.txt} | 0 ...E-MIT.txt => cipher-0.5.2-LICENSE-MIT.txt} | 2 +- ...ACHE.txt => cmov-0.5.4-LICENSE-APACHE.txt} | 0 .../Licenses/Rust/cmov-0.5.4-LICENSE-MIT.txt | 25 + ...xt => const-oid-0.10.2-LICENSE-APACHE.txt} | 0 .../Rust/const-oid-0.10.2-LICENSE-MIT.txt | 25 + ...txt => const-oid-0.9.6-LICENSE-APACHE.txt} | 0 .../Rust/const-oid-0.9.6-LICENSE-MIT.txt | 25 + ...constant_time_eq-0.6.1-LICENSE-APACHE.txt} | 0 ...=> constant_time_eq-0.6.1-LICENSE-CC0.txt} | 0 ...> constant_time_eq-0.6.1-LICENSE-MIT0.txt} | 0 ...E.txt => cpubits-0.1.1-LICENSE-APACHE.txt} | 0 .../Rust/cpubits-0.1.1-LICENSE-MIT.txt | 25 + ...t => cpufeatures-0.3.1-LICENSE-APACHE.txt} | 0 .../Rust/cpufeatures-0.3.1-LICENSE-MIT.txt | 25 + ...txt => crc32fast-1.5.2-LICENSE-APACHE.txt} | 0 ...IT.txt => crc32fast-1.5.2-LICENSE-MIT.txt} | 0 ...=> crypto-bigint-0.5.5-LICENSE-APACHE.txt} | 0 ...xt => crypto-bigint-0.5.5-LICENSE-MIT.txt} | 2 +- ...=> crypto-common-0.2.2-LICENSE-APACHE.txt} | 0 ...xt => crypto-common-0.2.2-LICENSE-MIT.txt} | 2 +- ...d.txt => ctutils-0.4.3-LICENSE-APACHE.txt} | 0 .../Rust/ctutils-0.4.3-LICENSE-MIT.txt | 25 + .../Rust/curve25519-dalek-4.1.3-LICENSE.txt | 65 + ...ACHE.txt => der-0.7.10-LICENSE-APACHE.txt} | 0 .../Licenses/Rust/der-0.7.10-LICENSE-MIT.txt | 25 + ....txt => deranged-0.5.9-LICENSE-Apache.txt} | 0 ...MIT.txt => deranged-0.5.9-LICENSE-MIT.txt} | 0 ...E.txt => digest-0.11.3-LICENSE-APACHE.txt} | 4 +- .../Rust/digest-0.11.3-LICENSE-MIT.txt | 26 + .../Rust/easyfft-0.4.2-LICENSE-APACHE.txt | 202 ++ .../Rust/easyfft-0.4.2-LICENSE-MIT.txt | 19 + .../Rust/ecdsa-0.16.9-LICENSE-APACHE.txt | 201 ++ .../Rust/ecdsa-0.16.9-LICENSE-MIT.txt | 25 + .../Rust/ed25519-2.2.3-LICENSE-APACHE.txt | 201 ++ .../Rust/ed25519-2.2.3-LICENSE-MIT.txt | 25 + .../Rust/ed25519-dalek-2.2.0-LICENSE.txt | 28 + .../elliptic-curve-0.13.8-LICENSE-APACHE.txt | 201 ++ .../elliptic-curve-0.13.8-LICENSE-MIT.txt | 25 + ...xt => equivalent-1.0.3-LICENSE-APACHE.txt} | 0 ...T.txt => equivalent-1.0.3-LICENSE-MIT.txt} | 0 .../ferrous-opencc-0.4.0-UPSTREAM-NOTICE.txt | 82 + ...-opencc-compiler-0.4.0-UPSTREAM-NOTICE.txt | 82 + ...PACHE.txt => ff-0.13.1-LICENSE-APACHE.txt} | 5 +- ...ENSE-MIT.txt => ff-0.13.1-LICENSE-MIT.txt} | 2 +- ...find-msvc-tools-0.1.15-LICENSE-APACHE.txt} | 0 ...=> find-msvc-tools-0.1.15-LICENSE-MIT.txt} | 0 ...generic_singleton-0.5.3-LICENSE-APACHE.txt | 202 ++ .../generic_singleton-0.5.3-LICENSE-MIT.txt | 19 + .../Rust/getrandom-0.3.4-LICENSE-MIT.txt | 26 - ...HE.txt => group-0.13.0-LICENSE-APACHE.txt} | 0 ...E-MIT.txt => group-0.13.0-LICENSE-MIT.txt} | 0 .../Rust/hex-0.4.3-LICENSE-APACHE.txt | 202 ++ ...E-MIT.md.txt => hex-0.4.3-LICENSE-MIT.txt} | 8 +- .../Rust/hkdf-0.12.4-LICENSE-APACHE.txt | 201 ++ .../Licenses/Rust/hkdf-0.12.4-LICENSE-MIT.txt | 26 + .../Rust/hmac-0.13.0-LICENSE-APACHE.txt | 201 ++ ...SE-MIT.txt => hmac-0.13.0-LICENSE-MIT.txt} | 2 +- .../hybrid-array-0.4.15-LICENSE-APACHE.txt | 201 ++ .../Rust/hybrid-array-0.4.15-LICENSE-MIT.txt | 25 + ...1-LICENSE.txt => hyper-1.12.0-LICENSE.txt} | 0 ...> hyper-rustls-0.27.10-LICENSE-APACHE.txt} | 0 ...t => hyper-rustls-0.27.10-LICENSE-ISC.txt} | 0 ...t => hyper-rustls-0.27.10-LICENSE-MIT.txt} | 0 ...ENSE.txt => hyper-util-0.1.21-LICENSE.txt} | 0 .../Rust/inout-0.2.5-LICENSE-APACHE.txt | 201 ++ ...SE-MIT.txt => inout-0.2.5-LICENSE-MIT.txt} | 2 +- ...SE.txt => jsonwebtoken-11.1.0-LICENSE.txt} | 4 +- ...t => lazy_static-1.5.1-LICENSE-APACHE.txt} | 0 ....txt => lazy_static-1.5.1-LICENSE-MIT.txt} | 0 .../Rust/libbz2-rs-sys-0.2.5-LICENSE.txt | 48 + ...HE.txt => libc-0.2.191-LICENSE-APACHE.txt} | 0 ...E-MIT.txt => libc-0.2.191-LICENSE-MIT.txt} | 0 .../Licenses/Rust/libm-0.2.16-LICENSE.txt.txt | 258 +++ ....txt => lru-slab-0.1.3-LICENSE-APACHE.txt} | 0 ...MIT.txt => lru-slab-0.1.3-LICENSE-MIT.txt} | 0 ...IB.txt => lru-slab-0.1.3-LICENSE-ZLIB.txt} | 0 ...E.md.txt => lzma-rust2-0.22.0-LICENSE.txt} | 26 +- .../ios/Resources/Licenses/Rust/manifest.json | 1688 ++++++++++++----- .../Rust/md-5-0.11.0-LICENSE-APACHE.txt | 201 ++ ...SE-MIT.txt => md-5-0.11.0-LICENSE-MIT.txt} | 5 +- ... => micyou-dsp-0.1.0-LICENSE-AEC7.txt.txt} | 2 +- ... micyou-dsp-0.1.0-LICENSE-PureVox.txt.txt} | 2 +- .../micyou-dsp-0.1.0-LICENSE-RNNoise.txt.txt | 32 + .../Rust/micyou-dsp-0.1.0-LICENSE.txt | 725 +++++++ .../Rust/micyou-infer-0.1.0-LICENSE.txt | 725 +++++++ .../miniz_oxide-0.9.1-LICENSE-ZLIB.md.txt | 14 - .../Rust/miniz_oxide-0.9.1-LICENSE.txt | 25 - ....2.3-LICENSE.txt => mio-1.2.4-LICENSE.txt} | 0 .../Rust/nnnoiseless-0.5.2-COPYING.txt | 32 + ...> num-bigint-dig-0.8.6-LICENSE-APACHE.txt} | 0 .../Rust/num-bigint-dig-0.8.6-LICENSE-MIT.txt | 25 + ...t => num-complex-0.4.6-LICENSE-APACHE.txt} | 0 .../Rust/num-complex-0.4.6-LICENSE-MIT.txt | 25 + ... => num-integer-0.1.47-LICENSE-APACHE.txt} | 0 .../Rust/num-integer-0.1.47-LICENSE-MIT.txt | 25 + ...txt => num-iter-0.1.46-LICENSE-APACHE.txt} | 0 .../Rust/num-iter-0.1.46-LICENSE-MIT.txt | 25 + .../Rust/p256-0.13.2-LICENSE-APACHE.txt | 201 ++ ...SE-MIT.txt => p256-0.13.2-LICENSE-MIT.txt} | 0 .../Rust/p384-0.13.1-LICENSE-APACHE.txt | 201 ++ .../Licenses/Rust/p384-0.13.1-LICENSE-MIT.txt | 25 + .../password-hash-0.6.1-LICENSE-APACHE.txt | 201 ++ .../Rust/password-hash-0.6.1-LICENSE-MIT.txt | 25 + .../Rust/pbkdf2-0.13.0-LICENSE-APACHE.txt | 201 ++ ...-MIT.txt => pbkdf2-0.13.0-LICENSE-MIT.txt} | 0 .../Rust/pem-rfc7468-0.7.0-LICENSE-APACHE.txt | 201 ++ .../Rust/pem-rfc7468-0.7.0-LICENSE-MIT.txt | 25 + .../Rust/phc-0.6.1-LICENSE-APACHE.txt | 201 ++ .../Licenses/Rust/phc-0.6.1-LICENSE-MIT.txt | 25 + .../Rust/pkcs1-0.7.5-LICENSE-APACHE.txt | 201 ++ .../Licenses/Rust/pkcs1-0.7.5-LICENSE-MIT.txt | 25 + .../Rust/pkcs8-0.10.2-LICENSE-APACHE.txt | 201 ++ .../Rust/pkcs8-0.10.2-LICENSE-MIT.txt | 25 + .../Rust/poly1305-0.8.0-LICENSE-MIT.txt | 25 - .../Rust/poly1305-0.9.1-LICENSE-APACHE.txt | 201 ++ .../Rust/poly1305-0.9.1-LICENSE-MIT.txt | 25 + ....txt => powerfmt-0.2.1-LICENSE-Apache.txt} | 0 ...MIT.txt => powerfmt-0.2.1-LICENSE-MIT.txt} | 0 .../Rust/ppmd-rust-1.5.1-LICENSE-CC0.md.txt | 119 ++ .../Rust/ppmd-rust-1.5.1-LICENSE-MIT-0.md.txt | 16 + ... => primal-check-0.3.4-LICENSE-APACHE.txt} | 0 ...txt => primal-check-0.3.4-LICENSE-MIT.txt} | 4 +- .../Rust/primeorder-0.13.6-LICENSE-APACHE.txt | 201 ++ .../Rust/primeorder-0.13.6-LICENSE-MIT.txt | 25 + ...E.txt => quinn-0.11.12-LICENSE-APACHE.txt} | 0 ...-MIT.txt => quinn-0.11.12-LICENSE-MIT.txt} | 0 ...=> quinn-proto-0.11.19-LICENSE-APACHE.txt} | 0 ...xt => quinn-proto-0.11.19-LICENSE-MIT.txt} | 0 ...xt => quinn-udp-0.5.16-LICENSE-APACHE.txt} | 0 ...T.txt => quinn-udp-0.5.16-LICENSE-MIT.txt} | 0 ...CHE.txt => rand-0.10.3-LICENSE-APACHE.txt} | 0 ...SE-MIT.txt => rand-0.10.3-LICENSE-MIT.txt} | 0 .../Rust/realfft-3.5.0-UPSTREAM-NOTICE.txt | 27 + .../Rust/rfc6979-0.4.0-LICENSE-APACHE.txt | 201 ++ .../Rust/rfc6979-0.4.0-LICENSE-MIT.txt | 25 + .../Rust/rsa-0.9.10-LICENSE-APACHE.txt | 201 ++ .../Licenses/Rust/rsa-0.9.10-LICENSE-MIT.txt | 23 + ...=> rustc_version-0.4.1-LICENSE-APACHE.txt} | 0 .../Rust/rustc_version-0.4.1-LICENSE-MIT.txt | 25 + .../Rust/rustfft-6.4.1-LICENSE-APACHE.txt | 202 ++ .../Rust/rustfft-6.4.1-LICENSE-MIT.txt | 19 + ...HE.txt => rustix-1.1.5-LICENSE-APACHE.txt} | 0 ...ICENSE-Apache-2.0_WITH_LLVM-exception.txt} | 0 ...E-MIT.txt => rustix-1.1.5-LICENSE-MIT.txt} | 0 ....txt => rustls-0.23.46-LICENSE-APACHE.txt} | 0 ...ISC.txt => rustls-0.23.46-LICENSE-ISC.txt} | 0 ...MIT.txt => rustls-0.23.46-LICENSE-MIT.txt} | 0 ... => rustversion-1.0.23-LICENSE-APACHE.txt} | 0 ...txt => rustversion-1.0.23-LICENSE-MIT.txt} | 0 .../Rust/sec1-0.7.3-LICENSE-APACHE.txt | 201 ++ .../Licenses/Rust/sec1-0.7.3-LICENSE-MIT.txt | 25 + ...E.txt => semver-1.0.28-LICENSE-APACHE.txt} | 0 ...-MIT.txt => semver-1.0.28-LICENSE-MIT.txt} | 0 ... => serde_json-1.0.154-LICENSE-APACHE.txt} | 0 ...txt => serde_json-1.0.154-LICENSE-MIT.txt} | 0 .../Rust/sha1-0.11.0-LICENSE-APACHE.txt | 201 ++ ...SE-MIT.txt => sha1-0.11.0-LICENSE-MIT.txt} | 5 +- .../Rust/sha2-0.11.1-LICENSE-APACHE.txt | 201 ++ .../Licenses/Rust/sha2-0.11.1-LICENSE-MIT.txt | 28 + .../Rust/signature-2.2.0-LICENSE-APACHE.txt | 201 ++ .../Rust/signature-2.2.0-LICENSE-MIT.txt | 25 + ...OPYING.txt => siphasher-1.0.4-COPYING.txt} | 0 ...txt => siphasher-1.0.4-LICENSE-APACHE.txt} | 4 +- .../Rust/siphasher-1.0.4-LICENSE-MIT.txt | 17 + ...txt => smallvec-1.16.3-LICENSE-APACHE.txt} | 2 +- ...IT.txt => smallvec-1.16.3-LICENSE-MIT.txt} | 0 .../Licenses/Rust/spin-0.9.9-LICENSE.txt | 21 + .../Rust/spki-0.7.3-LICENSE-APACHE.txt | 201 ++ .../Licenses/Rust/spki-0.7.3-LICENSE-MIT.txt | 25 + .../strength_reduce-0.2.4-LICENSE-APACHE.txt | 202 ++ .../strength_reduce-0.2.4-LICENSE-MIT.txt | 19 + ...PACHE.txt => syn-3.0.9-LICENSE-APACHE.txt} | 0 ...ENSE-MIT.txt => syn-3.0.9-LICENSE-MIT.txt} | 0 ...SE.txt => synstructure-0.14.0-LICENSE.txt} | 0 ...xt => thiserror-2.0.21-LICENSE-APACHE.txt} | 0 ...T.txt => thiserror-2.0.21-LICENSE-MIT.txt} | 0 ... thiserror-impl-2.0.21-LICENSE-APACHE.txt} | 0 ... => thiserror-impl-2.0.21-LICENSE-MIT.txt} | 0 .../Rust/tinyvec-1.13.3-LICENSE-APACHE.md.txt | 202 ++ ....txt => tinyvec-1.13.3-LICENSE-MIT.md.txt} | 0 ...txt => tinyvec-1.13.3-LICENSE-ZLIB.md.txt} | 0 ...1-LICENSE.txt => tokio-1.53.3-LICENSE.txt} | 0 ...=> tokio-rustls-0.26.6-LICENSE-APACHE.txt} | 0 ...xt => tokio-rustls-0.26.6-LICENSE-MIT.txt} | 0 ...t => tokio-tungstenite-0.30.0-LICENSE.txt} | 0 ...ENSE.txt => tokio-util-0.7.20-LICENSE.txt} | 0 .../Rust/transpose-0.2.3-LICENSE-APACHE.txt | 201 ++ .../Rust/transpose-0.2.3-LICENSE-MIT.txt | 25 + .../tungstenite-0.30.0-LICENSE-APACHE.txt | 201 ++ ...txt => tungstenite-0.30.0-LICENSE-MIT.txt} | 0 ...t => typed-path-0.12.3-LICENSE-APACHE.txt} | 0 ....txt => typed-path-0.12.3-LICENSE-MIT.txt} | 0 .../Rust/unicase-2.10.0-LICENSE-APACHE.txt | 201 ++ ...MIT.txt => unicase-2.10.0-LICENSE-MIT.txt} | 0 ...> unicode-ident-1.0.27-LICENSE-APACHE.txt} | 0 ...t => unicode-ident-1.0.27-LICENSE-MIT.txt} | 2 - ... unicode-ident-1.0.27-LICENSE-UNICODE.txt} | 0 ...de-normalization-0.1.26-LICENSE-APACHE.txt | 201 ++ ...code-normalization-0.1.26-LICENSE-MIT.txt} | 0 ...e-normalization-0.1.26-LICENSE-UNICODE.txt | 39 + .../Rust/universal-hash-0.5.1-LICENSE-MIT.txt | 25 - .../universal-hash-0.6.1-LICENSE-APACHE.txt | 201 ++ .../Rust/universal-hash-0.6.1-LICENSE-MIT.txt | 25 + .../Rust/uuid-1.28.0-LICENSE-APACHE.txt | 201 ++ ...SE-MIT.txt => uuid-1.28.0-LICENSE-MIT.txt} | 0 ...3.1-LICENSE.txt => want-0.3.2-LICENSE.txt} | 0 ...ENSE.txt => yoke-derive-0.8.4-LICENSE.txt} | 0 ...txt => zerocopy-0.8.63-LICENSE-APACHE.txt} | 0 ...SD.txt => zerocopy-0.8.63-LICENSE-BSD.txt} | 0 ...IT.txt => zerocopy-0.8.63-LICENSE-MIT.txt} | 0 ....txt => zerofrom-derive-0.1.8-LICENSE.txt} | 0 .../Rust/zeroize-1.9.1-LICENSE-APACHE.txt | 202 ++ ...-MIT.txt => zeroize-1.9.1-LICENSE-MIT.txt} | 0 ....4.2-LICENSE.txt => zip-9.0.5-LICENSE.txt} | 3 - ...-ZLIB.md.txt => zlib-rs-0.6.8-LICENSE.txt} | 9 +- ...3-COPYING.txt => zopfli-0.8.4-COPYING.txt} | 0 .../Licenses/Rust/zstd-0.13.3-LICENSE.txt | 8 - ....0-LICENSE.txt => zstd-0.14.3-LICENSE.txt} | 0 ...ICENSE.txt => zstd-safe-8.2.0-LICENSE.txt} | 0 ...2.1.2+zstd.1.5.7-LICENSE.BSD-3-Clause.txt} | 0 .../zstd-sys-2.1.2+zstd.1.5.7-LICENSE.txt | 28 + .../Resources/Licenses/SPDX-Apache-2.0.txt | 73 + .../ios/Resources/Licenses/SPDX-MIT.txt | 18 + .../ios/Resources/LocalizationSources.json | 36 +- openless-all/ios/docs/TODO-20261020.md | 59 - .../ios/docs/architecture-and-processing.md | 63 - openless-all/ios/docs/asr.md | 48 - .../ios/docs/continuation-checkpoint.md | 111 -- openless-all/ios/docs/execution-plan.md | 118 -- .../ios/docs/input-predictions-selfcheck.md | 39 - openless-all/ios/docs/input-predictions.md | 50 - openless-all/ios/docs/ios-a1-diagnosis.md | 49 - openless-all/ios/docs/ios-build10-goals.md | 43 - openless-all/ios/docs/ios-build10-release.md | 37 - openless-all/ios/docs/ios-build11-goals.md | 32 - openless-all/ios/docs/ios-build11-release.md | 40 - openless-all/ios/docs/ios-no-jump-plan.md | 25 - .../ios/docs/ios-nojump-root-cause.md | 68 - openless-all/ios/docs/ios-ui-spec.md | 155 -- openless-all/ios/docs/keepalive.md | 85 - .../ios/docs/keyboard-environment-check.md | 42 - openless-all/ios/docs/keyboard-raw-input.md | 27 - .../ios/docs/keyboard-video-reference.md | 41 - .../ios/docs/local-speech-provenance.md | 34 - .../ios/docs/pip-hidden-coexistence.md | 33 - openless-all/ios/docs/platform-support.md | 34 - openless-all/ios/docs/product-library.md | 58 - .../ios/docs/requirements-and-todo.md | 282 --- openless-all/ios/docs/verification.md | 169 -- openless-all/ios/docs/voice-handoff-fix.md | 31 - openless-all/ios/project.yml | 12 +- openless-all/ios/scripts/audio-smoke.swift | 11 +- openless-all/ios/scripts/bootstrap.sh | 5 +- openless-all/ios/scripts/build-core.sh | 18 +- .../ios/scripts/collect-rust-licenses.py | 26 +- openless-all/ios/scripts/package-ipa.py | 78 + openless-all/ios/scripts/prepare-core.py | 96 + openless-all/ios/scripts/test-audio.sh | 2 +- .../ios/scripts/test-interface-resources.py | 3 +- openless-all/ios/scripts/test.sh | 7 +- openless-all/ios/scripts/validate-product.py | 7 + openless-all/ios/scripts/validate-project.py | 10 +- 317 files changed, 15598 insertions(+), 3053 deletions(-) create mode 100644 .github/workflows/check-ios.yml create mode 100644 openless-all/ios/.gitattributes create mode 100644 openless-all/ios/Config/Version.xcconfig create mode 100644 openless-all/ios/CoreHost/Patches/ios-input-contracts.patch delete mode 100644 openless-all/ios/Resources/Licenses/Rust/adler2-2.0.1-LICENSE-0BSD.txt rename openless-all/ios/Resources/Licenses/Rust/{aead-0.5.2-LICENSE-APACHE.txt => aead-0.6.1-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{aead-0.5.2-LICENSE-MIT.txt => aead-0.6.1-LICENSE-MIT.txt} (94%) rename openless-all/ios/Resources/Licenses/Rust/{aes-0.8.4-LICENSE-APACHE.txt => aes-0.9.3-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{opaque-debug-0.3.1-LICENSE-MIT.txt => aes-0.9.3-LICENSE-MIT.txt} (97%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/anymap3-1.1.0-COPYING.txt rename openless-all/ios/Resources/Licenses/Rust/{argon2-0.5.3-LICENSE-APACHE.txt => argon2-0.6.0-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{bzip2-sys-0.1.13+1.0.8-LICENSE-MIT.txt => argon2-0.6.0-LICENSE-MIT.txt} (94%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/array-init-2.1.0-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/array-init-2.1.0-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{blake2-0.10.6-LICENSE-APACHE.txt => base16ct-0.2.0-LICENSE-APACHE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/base16ct-0.2.0-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{bzip2-0.4.4-LICENSE-APACHE.txt => base64-0.23.1-LICENSE-APACHE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/base64-0.23.1-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{chacha20-0.9.1-LICENSE-APACHE.txt => blake2-0.11.0-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{blake2-0.10.6-LICENSE-MIT.txt => blake2-0.11.0-LICENSE-MIT.txt} (95%) rename openless-all/ios/Resources/Licenses/Rust/{chacha20poly1305-0.10.1-LICENSE-APACHE.txt => block-buffer-0.12.1-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{argon2-0.5.3-LICENSE-MIT.txt => block-buffer-0.12.1-LICENSE-MIT.txt} (94%) delete mode 100644 openless-all/ios/Resources/Licenses/Rust/byteorder-1.5.0-COPYING.txt delete mode 100644 openless-all/ios/Resources/Licenses/Rust/byteorder-1.5.0-UNLICENSE.txt rename openless-all/ios/Resources/Licenses/Rust/{bzip2-0.5.2-LICENSE-APACHE.txt => bzip2-0.6.1-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{bzip2-0.5.2-LICENSE-MIT.txt => bzip2-0.6.1-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{bzip2-sys-0.1.13+1.0.8-LICENSE-APACHE.txt => cc-1.9.0-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{bzip2-0.4.4-LICENSE-MIT.txt => cc-1.9.0-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{cc-1.4.5-LICENSE-APACHE.txt => cfg-if-1.0.5-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{cc-1.4.5-LICENSE-MIT.txt => cfg-if-1.0.5-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{cipher-0.4.4-LICENSE-APACHE.txt => chacha20poly1305-0.11.0-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{chacha20-0.9.1-LICENSE-MIT.txt => chacha20poly1305-0.11.0-LICENSE-MIT.txt} (94%) rename openless-all/ios/Resources/Licenses/Rust/{inout-0.1.4-LICENSE-APACHE.txt => cipher-0.5.2-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{cipher-0.4.4-LICENSE-MIT.txt => cipher-0.5.2-LICENSE-MIT.txt} (95%) rename openless-all/ios/Resources/Licenses/Rust/{lru-slab-0.1.2-LICENSE-APACHE.txt => cmov-0.5.4-LICENSE-APACHE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/cmov-0.5.4-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{md-5-0.10.6-LICENSE-APACHE.txt => const-oid-0.10.2-LICENSE-APACHE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/const-oid-0.10.2-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{opaque-debug-0.3.1-LICENSE-APACHE.txt => const-oid-0.9.6-LICENSE-APACHE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/const-oid-0.9.6-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{constant_time_eq-0.3.1-LICENSE-APACHE.txt => constant_time_eq-0.6.1-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{constant_time_eq-0.3.1-LICENSE-CC0.txt => constant_time_eq-0.6.1-LICENSE-CC0.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{constant_time_eq-0.3.1-LICENSE-MIT0.txt => constant_time_eq-0.6.1-LICENSE-MIT0.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{password-hash-0.5.0-LICENSE-APACHE.txt => cpubits-0.1.1-LICENSE-APACHE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/cpubits-0.1.1-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{pbkdf2-0.12.2-LICENSE-APACHE.txt => cpufeatures-0.3.1-LICENSE-APACHE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/cpufeatures-0.3.1-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{crc32fast-1.5.1-LICENSE-APACHE.txt => crc32fast-1.5.2-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{crc32fast-1.5.1-LICENSE-MIT.txt => crc32fast-1.5.2-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{poly1305-0.8.0-LICENSE-APACHE.txt => crypto-bigint-0.5.5-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{chacha20poly1305-0.10.1-LICENSE-MIT.txt => crypto-bigint-0.5.5-LICENSE-MIT.txt} (95%) rename openless-all/ios/Resources/Licenses/Rust/{sha1-0.10.7-LICENSE-APACHE.txt => crypto-common-0.2.2-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{aes-0.8.4-LICENSE-MIT.txt => crypto-common-0.2.2-LICENSE-MIT.txt} (95%) rename openless-all/ios/Resources/Licenses/Rust/{tinyvec-1.13.2-LICENSE-APACHE.md.txt => ctutils-0.4.3-LICENSE-APACHE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/ctutils-0.4.3-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/curve25519-dalek-4.1.3-LICENSE.txt rename openless-all/ios/Resources/Licenses/Rust/{universal-hash-0.5.1-LICENSE-APACHE.txt => der-0.7.10-LICENSE-APACHE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/der-0.7.10-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{deranged-0.5.8-LICENSE-Apache.txt => deranged-0.5.9-LICENSE-Apache.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{deranged-0.5.8-LICENSE-MIT.txt => deranged-0.5.9-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{getrandom-0.3.4-LICENSE-APACHE.txt => digest-0.11.3-LICENSE-APACHE.txt} (99%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/digest-0.11.3-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/easyfft-0.4.2-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/easyfft-0.4.2-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/ecdsa-0.16.9-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/ecdsa-0.16.9-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/ed25519-2.2.3-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/ed25519-2.2.3-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/ed25519-dalek-2.2.0-LICENSE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/elliptic-curve-0.13.8-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/elliptic-curve-0.13.8-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{cfg-if-1.0.4-LICENSE-APACHE.txt => equivalent-1.0.3-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{equivalent-1.0.2-LICENSE-MIT.txt => equivalent-1.0.3-LICENSE-MIT.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/ferrous-opencc-0.4.0-UPSTREAM-NOTICE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/ferrous-opencc-compiler-0.4.0-UPSTREAM-NOTICE.txt rename openless-all/ios/Resources/Licenses/Rust/{adler2-2.0.1-LICENSE-APACHE.txt => ff-0.13.1-LICENSE-APACHE.txt} (98%) rename openless-all/ios/Resources/Licenses/Rust/{byteorder-1.5.0-LICENSE-MIT.txt => ff-0.13.1-LICENSE-MIT.txt} (96%) rename openless-all/ios/Resources/Licenses/Rust/{equivalent-1.0.2-LICENSE-APACHE.txt => find-msvc-tools-0.1.15-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{cfg-if-1.0.4-LICENSE-MIT.txt => find-msvc-tools-0.1.15-LICENSE-MIT.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/generic_singleton-0.5.3-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/generic_singleton-0.5.3-LICENSE-MIT.txt delete mode 100644 openless-all/ios/Resources/Licenses/Rust/getrandom-0.3.4-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{find-msvc-tools-0.1.12-LICENSE-APACHE.txt => group-0.13.0-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{adler2-2.0.1-LICENSE-MIT.txt => group-0.13.0-LICENSE-MIT.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/hex-0.4.3-LICENSE-APACHE.txt rename openless-all/ios/Resources/Licenses/Rust/{miniz_oxide-0.9.1-LICENSE-MIT.md.txt => hex-0.4.3-LICENSE-MIT.txt} (84%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/hkdf-0.12.4-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/hkdf-0.12.4-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/hmac-0.13.0-LICENSE-APACHE.txt rename openless-all/ios/Resources/Licenses/Rust/{lzma-sys-0.1.20-LICENSE-MIT.txt => hmac-0.13.0-LICENSE-MIT.txt} (96%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/hybrid-array-0.4.15-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/hybrid-array-0.4.15-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{hyper-1.11.1-LICENSE.txt => hyper-1.12.0-LICENSE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{hyper-rustls-0.27.9-LICENSE-APACHE.txt => hyper-rustls-0.27.10-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{hyper-rustls-0.27.9-LICENSE-ISC.txt => hyper-rustls-0.27.10-LICENSE-ISC.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{hyper-rustls-0.27.9-LICENSE-MIT.txt => hyper-rustls-0.27.10-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{hyper-util-0.1.20-LICENSE.txt => hyper-util-0.1.21-LICENSE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/inout-0.2.5-LICENSE-APACHE.txt rename openless-all/ios/Resources/Licenses/Rust/{inout-0.1.4-LICENSE-MIT.txt => inout-0.2.5-LICENSE-MIT.txt} (94%) rename openless-all/ios/Resources/Licenses/Rust/{lzma-rs-0.3.0-LICENSE.txt => jsonwebtoken-11.1.0-LICENSE.txt} (94%) rename openless-all/ios/Resources/Licenses/Rust/{lzma-sys-0.1.20-LICENSE-APACHE.txt => lazy_static-1.5.1-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{rustix-1.1.4-LICENSE-MIT.txt => lazy_static-1.5.1-LICENSE-MIT.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/libbz2-rs-sys-0.2.5-LICENSE.txt rename openless-all/ios/Resources/Licenses/Rust/{libc-0.2.189-LICENSE-APACHE.txt => libc-0.2.191-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{libc-0.2.189-LICENSE-MIT.txt => libc-0.2.191-LICENSE-MIT.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/libm-0.2.16-LICENSE.txt.txt rename openless-all/ios/Resources/Licenses/Rust/{zeroize-1.9.0-LICENSE-APACHE.txt => lru-slab-0.1.3-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{lru-slab-0.1.2-LICENSE-MIT.txt => lru-slab-0.1.3-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{lru-slab-0.1.2-LICENSE-ZLIB.txt => lru-slab-0.1.3-LICENSE-ZLIB.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{miniz_oxide-0.9.1-LICENSE-APACHE.md.txt => lzma-rust2-0.22.0-LICENSE.txt} (89%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/md-5-0.11.0-LICENSE-APACHE.txt rename openless-all/ios/Resources/Licenses/Rust/{md-5-0.10.6-LICENSE-MIT.txt => md-5-0.11.0-LICENSE-MIT.txt} (95%) rename openless-all/ios/Resources/Licenses/Rust/{tinyvec_macros-0.1.1-LICENSE-MIT.md.txt => micyou-dsp-0.1.0-LICENSE-AEC7.txt.txt} (97%) rename openless-all/ios/Resources/Licenses/Rust/{crc-3.4.0-LICENSE-MIT.txt => micyou-dsp-0.1.0-LICENSE-PureVox.txt.txt} (96%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/micyou-dsp-0.1.0-LICENSE-RNNoise.txt.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/micyou-dsp-0.1.0-LICENSE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/micyou-infer-0.1.0-LICENSE.txt delete mode 100644 openless-all/ios/Resources/Licenses/Rust/miniz_oxide-0.9.1-LICENSE-ZLIB.md.txt delete mode 100644 openless-all/ios/Resources/Licenses/Rust/miniz_oxide-0.9.1-LICENSE.txt rename openless-all/ios/Resources/Licenses/Rust/{mio-1.2.3-LICENSE.txt => mio-1.2.4-LICENSE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/nnnoiseless-0.5.2-COPYING.txt rename openless-all/ios/Resources/Licenses/Rust/{rustix-1.1.4-LICENSE-APACHE.txt => num-bigint-dig-0.8.6-LICENSE-APACHE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/num-bigint-dig-0.8.6-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{rustls-0.23.44-LICENSE-APACHE.txt => num-complex-0.4.6-LICENSE-APACHE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/num-complex-0.4.6-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{smallvec-1.16.0-LICENSE-APACHE.txt => num-integer-0.1.47-LICENSE-APACHE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/num-integer-0.1.47-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{tungstenite-0.24.0-LICENSE-APACHE.txt => num-iter-0.1.46-LICENSE-APACHE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/num-iter-0.1.46-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/p256-0.13.2-LICENSE-APACHE.txt rename openless-all/ios/Resources/Licenses/Rust/{password-hash-0.5.0-LICENSE-MIT.txt => p256-0.13.2-LICENSE-MIT.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/p384-0.13.1-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/p384-0.13.1-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/password-hash-0.6.1-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/password-hash-0.6.1-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/pbkdf2-0.13.0-LICENSE-APACHE.txt rename openless-all/ios/Resources/Licenses/Rust/{pbkdf2-0.12.2-LICENSE-MIT.txt => pbkdf2-0.13.0-LICENSE-MIT.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/pem-rfc7468-0.7.0-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/pem-rfc7468-0.7.0-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/phc-0.6.1-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/phc-0.6.1-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/pkcs1-0.7.5-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/pkcs1-0.7.5-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/pkcs8-0.10.2-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/pkcs8-0.10.2-LICENSE-MIT.txt delete mode 100644 openless-all/ios/Resources/Licenses/Rust/poly1305-0.8.0-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/poly1305-0.9.1-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/poly1305-0.9.1-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{powerfmt-0.2.0-LICENSE-Apache.txt => powerfmt-0.2.1-LICENSE-Apache.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{powerfmt-0.2.0-LICENSE-MIT.txt => powerfmt-0.2.1-LICENSE-MIT.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/ppmd-rust-1.5.1-LICENSE-CC0.md.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/ppmd-rust-1.5.1-LICENSE-MIT-0.md.txt rename openless-all/ios/Resources/Licenses/Rust/{unicase-2.9.0-LICENSE-APACHE.txt => primal-check-0.3.4-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{xz2-0.1.7-LICENSE-MIT.txt => primal-check-0.3.4-LICENSE-MIT.txt} (94%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/primeorder-0.13.6-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/primeorder-0.13.6-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{quinn-0.11.11-LICENSE-APACHE.txt => quinn-0.11.12-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{quinn-0.11.11-LICENSE-MIT.txt => quinn-0.11.12-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{quinn-proto-0.11.17-LICENSE-APACHE.txt => quinn-proto-0.11.19-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{quinn-proto-0.11.17-LICENSE-MIT.txt => quinn-proto-0.11.19-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{quinn-udp-0.5.15-LICENSE-APACHE.txt => quinn-udp-0.5.16-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{quinn-udp-0.5.15-LICENSE-MIT.txt => quinn-udp-0.5.16-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{rand-0.10.2-LICENSE-APACHE.txt => rand-0.10.3-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{rand-0.10.2-LICENSE-MIT.txt => rand-0.10.3-LICENSE-MIT.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/realfft-3.5.0-UPSTREAM-NOTICE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/rfc6979-0.4.0-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/rfc6979-0.4.0-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/rsa-0.9.10-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/rsa-0.9.10-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{unicode-normalization-0.1.25-LICENSE-APACHE.txt => rustc_version-0.4.1-LICENSE-APACHE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/rustc_version-0.4.1-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/rustfft-6.4.1-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/rustfft-6.4.1-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{uuid-1.26.1-LICENSE-APACHE.txt => rustix-1.1.5-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{rustix-1.1.4-LICENSE-Apache-2.0_WITH_LLVM-exception.txt => rustix-1.1.5-LICENSE-Apache-2.0_WITH_LLVM-exception.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{serde_json-1.0.151-LICENSE-MIT.txt => rustix-1.1.5-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{xz2-0.1.7-LICENSE-APACHE.txt => rustls-0.23.46-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{rustls-0.23.44-LICENSE-ISC.txt => rustls-0.23.46-LICENSE-ISC.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{rustls-0.23.44-LICENSE-MIT.txt => rustls-0.23.46-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{serde_json-1.0.151-LICENSE-APACHE.txt => rustversion-1.0.23-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{syn-3.0.5-LICENSE-MIT.txt => rustversion-1.0.23-LICENSE-MIT.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/sec1-0.7.3-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/sec1-0.7.3-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{syn-3.0.5-LICENSE-APACHE.txt => semver-1.0.28-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{thiserror-1.0.69-LICENSE-MIT.txt => semver-1.0.28-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{thiserror-1.0.69-LICENSE-APACHE.txt => serde_json-1.0.154-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{thiserror-2.0.20-LICENSE-MIT.txt => serde_json-1.0.154-LICENSE-MIT.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/sha1-0.11.0-LICENSE-APACHE.txt rename openless-all/ios/Resources/Licenses/Rust/{sha1-0.10.7-LICENSE-MIT.txt => sha1-0.11.0-LICENSE-MIT.txt} (95%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/sha2-0.11.1-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/sha2-0.11.1-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/signature-2.2.0-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/signature-2.2.0-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{siphasher-1.0.3-COPYING.txt => siphasher-1.0.4-COPYING.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{tinyvec_macros-0.1.1-LICENSE-APACHE.md.txt => siphasher-1.0.4-LICENSE-APACHE.txt} (99%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/siphasher-1.0.4-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{crc-3.4.0-LICENSE-APACHE.txt => smallvec-1.16.3-LICENSE-APACHE.txt} (99%) rename openless-all/ios/Resources/Licenses/Rust/{smallvec-1.16.0-LICENSE-MIT.txt => smallvec-1.16.3-LICENSE-MIT.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/spin-0.9.9-LICENSE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/spki-0.7.3-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/spki-0.7.3-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/strength_reduce-0.2.4-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/strength_reduce-0.2.4-LICENSE-MIT.txt rename openless-all/ios/Resources/Licenses/Rust/{thiserror-2.0.20-LICENSE-APACHE.txt => syn-3.0.9-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{thiserror-impl-1.0.69-LICENSE-MIT.txt => syn-3.0.9-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{synstructure-0.13.2-LICENSE.txt => synstructure-0.14.0-LICENSE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{thiserror-impl-1.0.69-LICENSE-APACHE.txt => thiserror-2.0.21-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{thiserror-impl-2.0.20-LICENSE-MIT.txt => thiserror-2.0.21-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{thiserror-impl-2.0.20-LICENSE-APACHE.txt => thiserror-impl-2.0.21-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{unicode-ident-1.0.24-LICENSE-MIT.txt => thiserror-impl-2.0.21-LICENSE-MIT.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/tinyvec-1.13.3-LICENSE-APACHE.md.txt rename openless-all/ios/Resources/Licenses/Rust/{tinyvec-1.13.2-LICENSE-MIT.md.txt => tinyvec-1.13.3-LICENSE-MIT.md.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{tinyvec-1.13.2-LICENSE-ZLIB.md.txt => tinyvec-1.13.3-LICENSE-ZLIB.md.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{tokio-1.53.1-LICENSE.txt => tokio-1.53.3-LICENSE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{tokio-rustls-0.26.5-LICENSE-APACHE.txt => tokio-rustls-0.26.6-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{tokio-rustls-0.26.5-LICENSE-MIT.txt => tokio-rustls-0.26.6-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{tokio-tungstenite-0.24.0-LICENSE.txt => tokio-tungstenite-0.30.0-LICENSE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{tokio-util-0.7.19-LICENSE.txt => tokio-util-0.7.20-LICENSE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/transpose-0.2.3-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/transpose-0.2.3-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/tungstenite-0.30.0-LICENSE-APACHE.txt rename openless-all/ios/Resources/Licenses/Rust/{tungstenite-0.24.0-LICENSE-MIT.txt => tungstenite-0.30.0-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{unicode-ident-1.0.24-LICENSE-APACHE.txt => typed-path-0.12.3-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{utf-8-0.7.6-LICENSE-MIT.txt => typed-path-0.12.3-LICENSE-MIT.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/unicase-2.10.0-LICENSE-APACHE.txt rename openless-all/ios/Resources/Licenses/Rust/{unicase-2.9.0-LICENSE-MIT.txt => unicase-2.10.0-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{utf-8-0.7.6-LICENSE-APACHE.txt => unicode-ident-1.0.27-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{find-msvc-tools-0.1.12-LICENSE-MIT.txt => unicode-ident-1.0.27-LICENSE-MIT.txt} (96%) rename openless-all/ios/Resources/Licenses/Rust/{unicode-ident-1.0.24-LICENSE-UNICODE.txt => unicode-ident-1.0.27-LICENSE-UNICODE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/unicode-normalization-0.1.26-LICENSE-APACHE.txt rename openless-all/ios/Resources/Licenses/Rust/{unicode-normalization-0.1.25-LICENSE-MIT.txt => unicode-normalization-0.1.26-LICENSE-MIT.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/unicode-normalization-0.1.26-LICENSE-UNICODE.txt delete mode 100644 openless-all/ios/Resources/Licenses/Rust/universal-hash-0.5.1-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/universal-hash-0.6.1-LICENSE-APACHE.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/universal-hash-0.6.1-LICENSE-MIT.txt create mode 100644 openless-all/ios/Resources/Licenses/Rust/uuid-1.28.0-LICENSE-APACHE.txt rename openless-all/ios/Resources/Licenses/Rust/{uuid-1.26.1-LICENSE-MIT.txt => uuid-1.28.0-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{want-0.3.1-LICENSE.txt => want-0.3.2-LICENSE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{yoke-derive-0.8.2-LICENSE.txt => yoke-derive-0.8.4-LICENSE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{zerocopy-0.8.57-LICENSE-APACHE.txt => zerocopy-0.8.63-LICENSE-APACHE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{zerocopy-0.8.57-LICENSE-BSD.txt => zerocopy-0.8.63-LICENSE-BSD.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{zerocopy-0.8.57-LICENSE-MIT.txt => zerocopy-0.8.63-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{zerofrom-derive-0.1.7-LICENSE.txt => zerofrom-derive-0.1.8-LICENSE.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/zeroize-1.9.1-LICENSE-APACHE.txt rename openless-all/ios/Resources/Licenses/Rust/{zeroize-1.9.0-LICENSE-MIT.txt => zeroize-1.9.1-LICENSE-MIT.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{zip-2.4.2-LICENSE.txt => zip-9.0.5-LICENSE.txt} (89%) rename openless-all/ios/Resources/Licenses/Rust/{tinyvec_macros-0.1.1-LICENSE-ZLIB.md.txt => zlib-rs-0.6.8-LICENSE.txt} (86%) rename openless-all/ios/Resources/Licenses/Rust/{zopfli-0.8.3-COPYING.txt => zopfli-0.8.4-COPYING.txt} (100%) delete mode 100644 openless-all/ios/Resources/Licenses/Rust/zstd-0.13.3-LICENSE.txt rename openless-all/ios/Resources/Licenses/Rust/{zstd-safe-7.3.0-LICENSE.txt => zstd-0.14.3-LICENSE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{zstd-sys-2.1.0+zstd.1.5.7-LICENSE.txt => zstd-safe-8.2.0-LICENSE.txt} (100%) rename openless-all/ios/Resources/Licenses/Rust/{zstd-sys-2.1.0+zstd.1.5.7-LICENSE.BSD-3-Clause.txt => zstd-sys-2.1.2+zstd.1.5.7-LICENSE.BSD-3-Clause.txt} (100%) create mode 100644 openless-all/ios/Resources/Licenses/Rust/zstd-sys-2.1.2+zstd.1.5.7-LICENSE.txt create mode 100644 openless-all/ios/Resources/Licenses/SPDX-Apache-2.0.txt create mode 100644 openless-all/ios/Resources/Licenses/SPDX-MIT.txt delete mode 100644 openless-all/ios/docs/TODO-20261020.md delete mode 100644 openless-all/ios/docs/architecture-and-processing.md delete mode 100644 openless-all/ios/docs/asr.md delete mode 100644 openless-all/ios/docs/continuation-checkpoint.md delete mode 100644 openless-all/ios/docs/execution-plan.md delete mode 100644 openless-all/ios/docs/input-predictions-selfcheck.md delete mode 100644 openless-all/ios/docs/input-predictions.md delete mode 100644 openless-all/ios/docs/ios-a1-diagnosis.md delete mode 100644 openless-all/ios/docs/ios-build10-goals.md delete mode 100644 openless-all/ios/docs/ios-build10-release.md delete mode 100644 openless-all/ios/docs/ios-build11-goals.md delete mode 100644 openless-all/ios/docs/ios-build11-release.md delete mode 100644 openless-all/ios/docs/ios-no-jump-plan.md delete mode 100644 openless-all/ios/docs/ios-nojump-root-cause.md delete mode 100644 openless-all/ios/docs/ios-ui-spec.md delete mode 100644 openless-all/ios/docs/keepalive.md delete mode 100644 openless-all/ios/docs/keyboard-environment-check.md delete mode 100644 openless-all/ios/docs/keyboard-raw-input.md delete mode 100644 openless-all/ios/docs/keyboard-video-reference.md delete mode 100644 openless-all/ios/docs/local-speech-provenance.md delete mode 100644 openless-all/ios/docs/pip-hidden-coexistence.md delete mode 100644 openless-all/ios/docs/platform-support.md delete mode 100644 openless-all/ios/docs/product-library.md delete mode 100644 openless-all/ios/docs/requirements-and-todo.md delete mode 100644 openless-all/ios/docs/verification.md delete mode 100644 openless-all/ios/docs/voice-handoff-fix.md create mode 100644 openless-all/ios/scripts/package-ipa.py create mode 100644 openless-all/ios/scripts/prepare-core.py diff --git a/.github/workflows/check-ios.yml b/.github/workflows/check-ios.yml new file mode 100644 index 000000000..b2a33c758 --- /dev/null +++ b/.github/workflows/check-ios.yml @@ -0,0 +1,90 @@ +name: iOS tests and device build + +on: + pull_request: + paths: + - 'openless-all/ios/**' + - 'openless-all/app/crates/openless-core/**' + - 'openless-all/app/crates/micyou-*/**' + - 'openless-all/app/contract/**' + - 'openless-all/app/assets/**' + - '.github/workflows/check-ios.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ios-checks-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_PROFILE_DEV_DEBUG: 0 + CARGO_PROFILE_TEST_DEBUG: 0 + +jobs: + test: + runs-on: xcode-27 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@1.99.0 + with: + components: clippy + - name: Record the unchanged shared source baseline + run: | + git diff --exit-code HEAD -- openless-all/app + git rev-parse HEAD:openless-all/app + - name: Prepare the isolated mobile Core + run: python3 openless-all/ios/scripts/prepare-core.py + - name: Test shared keyboard contracts + run: swift test --package-path openless-all/ios/Packages/OpenLessShared + - name: Test mobile Core and iOS Host + run: cargo test --manifest-path openless-all/ios/CoreHost/Cargo.toml --workspace --lib --locked + - name: Check mobile Core + run: cargo clippy --manifest-path openless-all/ios/CoreHost/Cargo.toml -p openless-core --all-targets --locked -- -D warnings + - name: Test interface resources, predictions and native audio + working-directory: openless-all/ios + run: | + python3 scripts/test-interface-resources.py + python3 scripts/build-predictions.py + bash scripts/test-predictions.sh + bash scripts/test-audio.sh + - name: Verify shared sources were not modified + run: git diff --exit-code -- openless-all/app + + device-build: + needs: test + runs-on: xcode-27 + timeout-minutes: 60 + defaults: + run: + working-directory: openless-all/ios + steps: + - uses: actions/checkout@v6 + - name: Prepare native build tools and dependencies + run: | + xcodebuild -version + command -v cmake >/dev/null || brew install cmake + bash scripts/bootstrap.sh + bash scripts/bootstrap-local-speech.sh + - name: Build Rime and test composition + run: | + bash scripts/build-rime.sh host + bash scripts/test-rime.sh + bash scripts/build-rime.sh device + - name: Build the mobile Core and refresh attribution + run: | + bash scripts/build-core.sh device + python3 scripts/collect-rust-licenses.py + - name: Build and validate the unsigned iPhone app + env: + OPENLESS_DERIVED_DATA: ${{ runner.temp }}/ios-device + run: | + python3 scripts/validate-project.py + bash scripts/build.sh device Release unsigned + python3 scripts/package-ipa.py \ + "$OPENLESS_DERIVED_DATA/Build/Products/Release-iphoneos/OpenLess.app" \ + "$RUNNER_TEMP/OpenLess-ci-unsigned.ipa" + - name: Verify shared sources were not modified + run: git diff --exit-code -- ../app diff --git a/README.md b/README.md index 5cbd52f3f..a80e238a1 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,8 @@ OpenLess is a cross-platform (macOS and Windows) voice-input application — a fully open-source alternative to commercial tools such as [Typeless](https://www.typeless.com/), [Wispr Flow](https://wisprflow.ai), [Lazy](https://heylazy.com), and Superwhisper. Official site: [openless.top](https://openless.top). +The native [iOS app and keyboard](openless-all/ios/README.md) are maintained in `openless-all/ios/`, with Chinese/English typing, voice-focused and hold-space input modes, and speech translation. See the iOS guide for source builds and personal signing. + Place your cursor in any text field — ChatGPT, Claude, Cursor, Notion, an email draft, a chat box — press a single global hotkey, and speak. OpenLess records the audio, transcribes it, polishes the text in the mode you selected, and inserts the result at the cursor. If insertion is blocked, the text is copied to the clipboard instead, so nothing you said is lost. Unlike dictation tools that simply emit a word-for-word transcript, OpenLess's headline capability is its **AI-prompt mode**: you speak freely, and it adds structure, captures your constraints, and produces a context-rich prompt that you can paste directly into ChatGPT, Claude, or Cursor. diff --git a/README.zh.md b/README.zh.md index 2bc8e2245..10215d759 100644 --- a/README.zh.md +++ b/README.zh.md @@ -111,6 +111,8 @@ OpenLess 是一款跨平台(macOS 与 Windows)语音输入应用,是 [Typeless](https://www.typeless.com/)、[Wispr Flow](https://wisprflow.ai)、[Lazy](https://heylazy.com)、Superwhisper 等商业工具的完全开源替代品。官网:[openless.top](https://openless.top)。 +原生 [iOS 应用与输入法](openless-all/ios/README.md) 位于 `openless-all/ios/`,支持中英文打字、语音优先与长按空格输入两种模式,以及语音翻译。源码构建和个人签名方式见 iOS 工程说明。 + 把光标放在任意文本框中——ChatGPT、Claude、Cursor、Notion、邮件草稿、聊天框——按下一个全局快捷键,然后开口说话。OpenLess 会录音、转写,按你选定的模式润色文本,并将结果插入到光标处。如果插入被阻止,文本会改为复制到剪贴板,你说过的话不会丢失。 与只产出逐字转写稿的听写工具不同,OpenLess 的核心能力是 **AI Prompt 模式**:你自由地说,它来补充结构、整理你的约束条件,生成一段包含上下文的提示词,可直接粘贴进 ChatGPT、Claude 或 Cursor。 diff --git a/docs/structure.md b/docs/structure.md index 653f820fd..9c7c7c504 100644 --- a/docs/structure.md +++ b/docs/structure.md @@ -15,6 +15,7 @@ openless-src/ Git 仓库、分支与发布边界 ├── scripts/ 仓库级辅助脚本(版本号同步等) └── openless-all/ ├── README.md 全平台总览 + ├── ios/ 原生 iPhone App、键盘扩展与录音组件 └── app/ npm 与 Core/Linux Cargo 工作目录 ├── src/ React / TypeScript 界面 ├── crates/openless-core/ 共享业务 Rust crate @@ -48,6 +49,7 @@ openless-src/ Git 仓库、分支与发布边界 | Tauri 组装与系统能力 | `src-tauri/src/coordinator.rs`、`core_adapters.rs`、`tauri_coordinator_host.rs` | 窗口、热键、权限、平台输入与生命周期 | | Linux 原生接入 | `linux-egui/src/main.rs`、`lib.rs`、`backend.rs` | `audio/credentials/fcitx5/hotkeys/settings` 等 Host 模块;见 [交接](linux-egui-handoff/README.md) | | Android 集成 | `android/`、`src-tauri/src/android/` | `@android` 别名与 `merge-android-*.mjs` 生成链 | +| iOS 应用与输入法 | `../ios/App/`、`../ios/Keyboard/`、`../ios/RecordingWidget/` | SwiftUI/UIKit 界面、独立 Rust Host、共享版本配置;见 [iOS 工程](../openless-all/ios/README.md) | | Windows 输入法 | `windows-ime/`、`src-tauri/src/windows_ime_*.rs` | 原生工程、IPC 协议、目标应用和安装检查 | Core 其余模块按领域列于 [架构模块地图](architecture.md)。平台缺口、事件签名与验收项由专项文档维护,本文件只提供定位。 diff --git a/openless-all/README.md b/openless-all/README.md index 893675bd0..5a52567b5 100644 --- a/openless-all/README.md +++ b/openless-all/README.md @@ -2,6 +2,10 @@ This directory contains the cross-platform OpenLess application. Start with the repository [documentation index](../docs/index.md), [architecture](../docs/architecture.md), and [source structure](../docs/structure.md). +## Native iOS app and keyboard + +[`ios/`](ios/README.md) contains the native iPhone app, Chinese/English keyboard extension, and recording widget. Settings offer a voice-focused layout or a single keyboard with hold-space dictation, translation, and send/cancel gestures. The app and keyboard target iOS 16; the recording widget requires iOS 18. The iOS guide covers the shared version configuration, input engines, source build, signing, and unsigned IPA packaging. + ## App Directory The runnable sources live in `app/`: diff --git a/openless-all/ios/.gitattributes b/openless-all/ios/.gitattributes new file mode 100644 index 000000000..9e6de3525 --- /dev/null +++ b/openless-all/ios/.gitattributes @@ -0,0 +1,4 @@ +# Preserve upstream license text, including its original whitespace. +Resources/Licenses/**/*.txt -whitespace +# Unified patch context includes significant whitespace prefixes. +CoreHost/Patches/*.patch -whitespace diff --git a/openless-all/ios/.gitignore b/openless-all/ios/.gitignore index ab46d4091..b89c23efc 100644 --- a/openless-all/ios/.gitignore +++ b/openless-all/ios/.gitignore @@ -3,6 +3,10 @@ .vendor/ DerivedData*/ Artifacts/ +*.ipa +*.xcarchive/ +*.xcresult/ +*.log __pycache__/ Config/Local.xcconfig *.xcuserstate @@ -17,6 +21,7 @@ Resources/SenseVoice/*.download Resources/Rime/build/ Resources/Rime/user.yaml Resources/Prediction/*.sqlite3 +Resources/Prediction/*.building.sqlite3 !OpenLess.xcodeproj/ !OpenLess.xcodeproj/project.pbxproj !OpenLess.xcodeproj/xcshareddata/ diff --git a/openless-all/ios/App/AppDesign.swift b/openless-all/ios/App/AppDesign.swift index 6aafd6e2d..b5d2c694e 100644 --- a/openless-all/ios/App/AppDesign.swift +++ b/openless-all/ios/App/AppDesign.swift @@ -7,7 +7,7 @@ enum AppTheme: String, CaseIterable { var scheme: ColorScheme? { switch self { case .system: nil; case .light: .light; case .dark: .dark } } } -/// One palette for the product screens; no gray-on-gray diagnostic panels. +/// Shared colors for product screens and controls. enum AppDesign { static let canvas = Color(uiColor: UIColor { traits in traits.userInterfaceStyle == .dark diff --git a/openless-all/ios/App/ExperimentalPiPKeepAlive.swift b/openless-all/ios/App/ExperimentalPiPKeepAlive.swift index 0cfbde4f6..2c7971232 100644 --- a/openless-all/ios/App/ExperimentalPiPKeepAlive.swift +++ b/openless-all/ios/App/ExperimentalPiPKeepAlive.swift @@ -272,8 +272,7 @@ final class ExperimentalPiPKeepAlive: NSObject { onChange?() } - /// A low-frequency content update retains the reference's content-only path - /// without its 60 Hz animation. Device testing must establish long-run retention. + /// Refresh content once per second while PiP is active and unsuspended. private func updateContentRefresh() { let eligible = enabled && isActive && controller?.isPictureInPictureSuspended == false && !protectedDataUnavailable @@ -283,8 +282,7 @@ final class ExperimentalPiPKeepAlive: NSObject { Task { @MainActor [weak self] in guard let self, self.isReady else { return } self.contentPhase.toggle() - // A nearly transparent layer mutation generates content work, without - // a visible video surface or an audio session. Never request 120 Hz. + // A transparent layer update avoids starting an audio or video engine. self.contentController?.view.layer.backgroundColor = UIColor(white: 0, alpha: self.contentPhase ? 0.001 : 0).cgColor self.contentController?.view.setNeedsDisplay() diff --git a/openless-all/ios/App/HostApplicationReturn.m b/openless-all/ios/App/HostApplicationReturn.m index d7cd9a176..7089d2990 100644 --- a/openless-all/ios/App/HostApplicationReturn.m +++ b/openless-all/ios/App/HostApplicationReturn.m @@ -3,14 +3,8 @@ #import #import -// iOS 27.0 (24A434), arm64 Simulator runtime probe, 2026-10-18: -// UIApplication._systemNavigationAction: @16@0:8 -// UISystemNavigationAction.destinations: @16@0:8 -// bundleIdForDestination: @24@0:8Q16; sendResponseForDestination: B24@0:8Q16 -// LSApplicationWorkspace.defaultWorkspace: @16@0:8 -// openApplicationWithBundleID: B24@0:8@16 -// Check the complete ABI again before every call. No installation inventory, -// URL-scheme guessing, process suspension, or private ivar offsets are used. +// Validate each runtime method's complete ABI before invoking it. A return +// destination belongs only to the current activation; never enumerate apps. static BOOL OLMethodMatches(id object, SEL selector, const char *result, const char *argument) { if (!object) return NO; diff --git a/openless-all/ios/App/PiPCoexistenceAdapter.swift b/openless-all/ios/App/PiPCoexistenceAdapter.swift index 37d7126e3..e18050c82 100644 --- a/openless-all/ios/App/PiPCoexistenceAdapter.swift +++ b/openless-all/ios/App/PiPCoexistenceAdapter.swift @@ -1,5 +1,4 @@ -// Adapted from Yoroin/GlobalRefresh-PiP, commit 1b62341c1d62308962fb3656192d6783631aa00c. -// Original PiP demo: CaiWanFeng/PiP. See Resources/Licenses/GlobalRefresh-PiP-NOTICE.txt. +// Third-party source attribution is bundled in Resources/Licenses. import Foundation import ObjectiveC #if os(iOS) diff --git a/openless-all/ios/AppTests/SpeakButtonVisualTests.swift b/openless-all/ios/AppTests/SpeakButtonVisualTests.swift index d7364b40a..fc2517433 100644 --- a/openless-all/ios/AppTests/SpeakButtonVisualTests.swift +++ b/openless-all/ios/AppTests/SpeakButtonVisualTests.swift @@ -29,21 +29,20 @@ nonisolated final class SpeakButtonVisualTests: XCTestCase { controller.view.layoutIfNeeded() button.configure(phase: .recording, level: 0, available: true) try await Task.sleep(for: .milliseconds(300)) - capture(controller.view, name: "build11-recording-silence") - // Synthetic RMS inputs exercise only the presentation. No microphone is - // activated; screenshots/video must not be described as capture evidence. + capture(controller.view, name: "recording-silence") + // Synthetic RMS values exercise presentation without opening the microphone. for _ in 0..<3 { for level: Float in [0, 0.004, 0.01, 0.025, 0.06, 0.1, 0.025, 0.01] { button.configure(phase: .recording, level: level, available: true) try await Task.sleep(for: .milliseconds(160)) } } - capture(controller.view, name: "build11-recording-speech") + capture(controller.view, name: "recording-speech") XCTAssertEqual(button.accessibilityLabel, "结束录音") button.configure(phase: .processing, level: 0, available: false) try await Task.sleep(for: .milliseconds(350)) XCTAssertEqual(button.accessibilityLabel, "thinking") - capture(controller.view, name: "build11-thinking") + capture(controller.view, name: "thinking") } @MainActor private func capture(_ view: UIView, name: String) { diff --git a/openless-all/ios/AppTests/TranslationArcGeometryTests.swift b/openless-all/ios/AppTests/TranslationArcGeometryTests.swift index 6fcb1ace6..7999ae5c6 100644 --- a/openless-all/ios/AppTests/TranslationArcGeometryTests.swift +++ b/openless-all/ios/AppTests/TranslationArcGeometryTests.swift @@ -2,7 +2,7 @@ import XCTest import UIKit import OpenLessShared -/// Call in an iOS test host on MainActor. Uses the actual patched UIView and UILabel sizes. +/// Measures the rendered UIView and UILabel geometry on the main actor. /// Geometry uses the production view; only the irrelevant canvas color is supplied here. @MainActor enum TranslationArcGeometryChecks { diff --git a/openless-all/ios/Config/App-Info.plist b/openless-all/ios/Config/App-Info.plist index adfe18a0c..350d59200 100644 --- a/openless-all/ios/Config/App-Info.plist +++ b/openless-all/ios/Config/App-Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.1.0 + $(MARKETING_VERSION) CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 15 + $(CURRENT_PROJECT_VERSION) NSMicrophoneUsageDescription 仅在你点按或按住录音时采集声音,结束后立即释放麦克风。 NSSpeechRecognitionUsageDescription diff --git a/openless-all/ios/Config/Base.xcconfig b/openless-all/ios/Config/Base.xcconfig index b170d4633..f3f43efcb 100644 --- a/openless-all/ios/Config/Base.xcconfig +++ b/openless-all/ios/Config/Base.xcconfig @@ -1,4 +1,5 @@ -// Local signing is deliberately not committed. +#include "Version.xcconfig" +// Signing overrides stay local to each developer. OL_BUNDLE_ID = app.openless.ios OL_APP_GROUP = group.app.openless.ios #include? "Local.xcconfig" diff --git a/openless-all/ios/Config/Keyboard-Info.plist b/openless-all/ios/Config/Keyboard-Info.plist index b3d8162fa..c6a3acbd8 100644 --- a/openless-all/ios/Config/Keyboard-Info.plist +++ b/openless-all/ios/Config/Keyboard-Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 0.1.0 + $(MARKETING_VERSION) CFBundleVersion - 15 + $(CURRENT_PROJECT_VERSION) NSExtension NSExtensionAttributes diff --git a/openless-all/ios/Config/RecordingWidget-Info.plist b/openless-all/ios/Config/RecordingWidget-Info.plist index f40c00f4e..a0ed44094 100644 --- a/openless-all/ios/Config/RecordingWidget-Info.plist +++ b/openless-all/ios/Config/RecordingWidget-Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 0.1.0 + $(MARKETING_VERSION) CFBundleVersion - 15 + $(CURRENT_PROJECT_VERSION) NSExtension NSExtensionPointIdentifier diff --git a/openless-all/ios/Config/Version.xcconfig b/openless-all/ios/Config/Version.xcconfig new file mode 100644 index 000000000..ad590883e --- /dev/null +++ b/openless-all/ios/Config/Version.xcconfig @@ -0,0 +1,3 @@ +// Shared by the app, keyboard and recording widget. +MARKETING_VERSION = 0.1.0 +CURRENT_PROJECT_VERSION = 16 diff --git a/openless-all/ios/CoreHost/Cargo.lock b/openless-all/ios/CoreHost/Cargo.lock index bfcaeb6ab..69ae4c87f 100644 --- a/openless-all/ios/CoreHost/Cargo.lock +++ b/openless-all/ios/CoreHost/Cargo.lock @@ -2,31 +2,25 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aead" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common", - "generic-array", + "crypto-common 0.2.2", + "inout", ] [[package]] name = "aes" -version = "0.8.4" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cfg-if", "cipher", - "cpufeatures 0.2.17", + "cpubits", + "cpufeatures 0.3.1", ] [[package]] @@ -54,27 +48,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] -name = "arbitrary" -version = "1.4.2" +name = "anymap3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] +checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9" [[package]] name = "argon2" -version = "0.5.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", + "cpufeatures 0.3.1", "password-hash", "zeroize", ] +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -87,12 +84,24 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -107,11 +116,11 @@ checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "blake2" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -123,6 +132,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -149,15 +168,9 @@ checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.12.1" @@ -166,38 +179,18 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bzip2" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" -dependencies = [ - "bzip2-sys", - "libc", -] - -[[package]] -name = "bzip2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" dependencies = [ - "cc", - "pkg-config", + "libbz2-rs-sys", ] [[package]] name = "cc" -version = "1.4.5" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "jobserver", @@ -207,9 +200,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -217,17 +210,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" -[[package]] -name = "chacha20" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - [[package]] name = "chacha20" version = "0.10.2" @@ -235,21 +217,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", + "cipher", "cpufeatures 0.3.1", "rand_core 0.10.1", ] [[package]] name = "chacha20poly1305" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" dependencies = [ "aead", - "chacha20 0.9.1", + "chacha20", "cipher", "poly1305", - "zeroize", ] [[package]] @@ -265,20 +247,41 @@ dependencies = [ [[package]] name = "cipher" -version = "0.4.4" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common", + "block-buffer 0.12.1", + "crypto-common 0.2.2", "inout", - "zeroize", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" -version = "0.3.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "9eb6feb175df65d0a3ccd3e73484e940d0ac664e8034f0519fd49a93db3071d9" +dependencies = [ + "rustversion", +] [[package]] name = "core-foundation" @@ -306,6 +309,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -325,44 +334,81 @@ dependencies = [ ] [[package]] -name = "crc" -version = "3.4.0" +name = "crc32fast" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ - "crc-catalog", + "cfg-if", ] [[package]] -name = "crc-catalog" -version = "2.5.0" +name = "crypto-bigint" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] [[package]] -name = "crc32fast" -version = "1.5.1" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "cfg-if", + "generic-array", + "typenum", ] [[package]] -name = "crossbeam-utils" -version = "0.8.21" +name = "crypto-common" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] [[package]] -name = "crypto-common" -version = "0.1.7" +name = "ctutils" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ - "generic-array", - "rand_core 0.6.4", - "typenum", + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -378,21 +424,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" [[package]] -name = "deranged" -version = "0.5.8" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] [[package]] -name = "derive_arbitrary" -version = "1.4.2" +name = "deranged" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" [[package]] name = "digest" @@ -400,11 +446,25 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", + "zeroize", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -413,14 +473,86 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", +] + +[[package]] +name = "easyfft" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "767e39eef2ad8a3b6f1d733be3ec70364d21d437d06d4f18ea76ce08df20b75f" +dependencies = [ + "array-init", + "generic_singleton", + "num-complex", + "realfft", + "rustfft", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", ] [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -453,7 +585,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.20", + "thiserror", ] [[package]] @@ -467,6 +599,22 @@ dependencies = [ "rkyv", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.29" @@ -479,9 +627,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.12" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flate2" @@ -489,8 +637,7 @@ version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ - "crc32fast", - "miniz_oxide", + "zlib-rs", ] [[package]] @@ -537,7 +684,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -576,32 +723,29 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] -name = "getrandom" -version = "0.2.17" +name = "generic_singleton" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "ab6e923c8e978e57cf63e2e200ca967d1d20f0ea2662b28f6d4e11c44aa6ab16" dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", + "anymap3", + "parking_lot", ] [[package]] name = "getrandom" -version = "0.3.4" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 5.3.0", - "wasip2", + "wasi", "wasm-bindgen", ] @@ -614,24 +758,59 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 6.0.0", + "r-efi", "rand_core 0.10.1", "wasm-bindgen", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -673,15 +852,23 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", - "futures-channel", "futures-core", "http", "http-body", @@ -695,9 +882,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -711,16 +898,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -874,11 +1062,11 @@ dependencies = [ [[package]] name = "inout" -version = "0.1.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -905,20 +1093,63 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac 0.12.1", + "js-sys", + "p256", + "p384", + "rand 0.8.8", + "rsa", + "serde", + "serde_json", + "sha2 0.10.9", + "signature", + "zeroize", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "linux-raw-sys" @@ -949,39 +1180,27 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "lzma-rs" -version = "0.3.0" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" -dependencies = [ - "byteorder", - "crc", -] +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] -name = "lzma-sys" -version = "0.1.20" +name = "lzma-rust2" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb" dependencies = [ - "cc", - "libc", - "pkg-config", + "sha2 0.11.1", ] [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] @@ -990,6 +1209,21 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "micyou-dsp" +version = "0.1.0" +dependencies = [ + "log", + "micyou-infer", + "nnnoiseless", + "rustfft", + "serde", +] + +[[package]] +name = "micyou-infer" +version = "0.1.0" + [[package]] name = "mime" version = "0.3.17" @@ -1007,44 +1241,82 @@ dependencies = [ ] [[package]] -name = "miniz_oxide" -version = "0.9.1" +name = "mio" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ - "adler2", - "simd-adler32", + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", ] [[package]] -name = "mio" -version = "1.2.3" +name = "nnnoiseless" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "805d5964d1e7a0006a7fdced7dae75084d66d18b35f1dfe81bd76929b1f8da0c" dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", + "easyfft", + "once_cell", ] [[package]] -name = "munge" -version = "0.4.7" +name = "num-bigint-dig" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ - "munge_macro", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", ] [[package]] -name = "munge_macro" -version = "0.4.7" +name = "num-complex" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "num-traits", + "serde", ] [[package]] @@ -1053,6 +1325,25 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1060,6 +1351,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1068,38 +1360,37 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "openless-core" version = "0.1.0" dependencies = [ "anyhow", "argon2", - "base64", - "bzip2 0.4.4", + "base64 0.23.1", + "bzip2", "chacha20poly1305", "chrono", "ferrous-opencc", "futures-util", - "getrandom 0.3.4", - "hmac", + "getrandom 0.4.3", + "hex", + "hmac 0.13.0", + "jsonwebtoken", "log", "md-5", + "micyou-dsp", "once_cell", "parking_lot", + "rand 0.8.8", "regex", "reqwest", + "rsa", "serde", "serde_json", "sha1", - "sha2", + "sha2 0.11.1", "tar", - "thiserror 1.0.69", + "thiserror", "tokio", "tokio-tungstenite", "unicode-normalization", @@ -1118,13 +1409,37 @@ dependencies = [ "security-framework", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "tokio", "uuid", "zeroize", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1150,23 +1465,31 @@ dependencies = [ [[package]] name = "password-hash" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", + "getrandom 0.4.3", + "phc", ] [[package]] name = "pbkdf2" -version = "0.12.2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" dependencies = [ - "digest", - "hmac", + "base64ct", ] [[package]] @@ -1175,6 +1498,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "phf" version = "0.13.1" @@ -1234,6 +1568,27 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -1242,12 +1597,11 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "poly1305" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", + "cpufeatures 0.3.1", "universal-hash", ] @@ -1262,9 +1616,15 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppmd-rust" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "494b4379484fc92217cc7d9e3a532375b1d01a61889ec8333025dc3b0377af56" [[package]] name = "ppv-lite86" @@ -1275,6 +1635,24 @@ dependencies = [ "zerocopy", ] +[[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 = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1301,14 +1679,14 @@ checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -1318,7 +1696,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.20", + "thiserror", "tokio", "tracing", "web-time", @@ -1326,21 +1704,21 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.17" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand 0.10.3", "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror", "tinyvec", "tracing", "web-time", @@ -1348,9 +1726,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", @@ -1369,12 +1747,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1403,11 +1775,11 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "chacha20 0.10.2", + "chacha20", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -1446,6 +1818,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[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" @@ -1499,7 +1880,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -1535,6 +1916,16 @@ dependencies = [ "webpki-roots 1.0.9", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1576,7 +1967,27 @@ checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", ] [[package]] @@ -1585,11 +1996,34 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +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" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags", "errno", @@ -1600,9 +2034,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.44" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "once_cell", "ring", @@ -1635,9 +2069,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -1651,6 +2085,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1674,6 +2122,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -1701,14 +2155,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -1731,13 +2185,13 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.7" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1748,7 +2202,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1757,6 +2222,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -1771,9 +2246,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -1783,9 +2258,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.0" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "socket2" @@ -1797,12 +2272,34 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "subtle" version = "2.6.1" @@ -1822,9 +2319,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.5" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -1842,13 +2339,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -1898,42 +2395,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl 2.0.20", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -1943,6 +2420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -1967,24 +2445,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", @@ -2003,14 +2472,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] name = "tokio-rustls" -version = "0.26.5" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -2018,9 +2487,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.24.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", "log", @@ -2034,9 +2503,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", @@ -2109,6 +2578,16 @@ dependencies = [ "once_cell", ] +[[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]] name = "try-lock" version = "0.2.5" @@ -2117,24 +2596,28 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.24.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" dependencies = [ - "byteorder", "bytes", "data-encoding", "http", "httparse", "log", - "rand 0.8.8", + "rand 0.10.3", "rustls", "rustls-pki-types", "sha1", - "thiserror 1.0.69", - "utf-8", + "thiserror", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.20.1" @@ -2143,33 +2626,33 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicase" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" dependencies = [ "tinyvec", ] [[package]] name = "universal-hash" -version = "0.5.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common", - "subtle", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -2191,12 +2674,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -2205,9 +2682,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.1" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -2223,9 +2700,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -2236,20 +2713,11 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -2260,19 +2728,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2280,22 +2749,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -2315,9 +2784,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -2503,12 +2972,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "writeable" version = "0.6.4" @@ -2525,15 +2988,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "xz2" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] - [[package]] name = "yoke" version = "0.8.3" @@ -2547,30 +3001,30 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -2588,21 +3042,21 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ "zeroize_derive", ] @@ -2648,39 +3102,43 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] name = "zip" -version = "2.4.2" +version = "9.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ "aes", - "arbitrary", - "bzip2 0.5.2", + "bzip2", "constant_time_eq", "crc32fast", - "crossbeam-utils", "deflate64", - "displaydoc", "flate2", - "getrandom 0.3.4", - "hmac", + "getrandom 0.4.3", + "hmac 0.13.0", "indexmap", - "lzma-rs", + "lzma-rust2", "memchr", + "nix", "pbkdf2", + "ppmd-rust", "sha1", - "thiserror 2.0.20", "time", - "xz2", + "typed-path", "zeroize", "zopfli", "zstd", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" @@ -2689,9 +3147,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" dependencies = [ "bumpalo", "crc32fast", @@ -2701,27 +3159,27 @@ dependencies = [ [[package]] name = "zstd" -version = "0.13.3" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.3.0" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.1.0+zstd.1.5.7" +version = "2.1.2+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" dependencies = [ "cc", "pkg-config", diff --git a/openless-all/ios/CoreHost/Cargo.toml b/openless-all/ios/CoreHost/Cargo.toml index ee8d4e6b9..a8301490d 100644 --- a/openless-all/ios/CoreHost/Cargo.toml +++ b/openless-all/ios/CoreHost/Cargo.toml @@ -9,9 +9,12 @@ publish = false crate-type = ["staticlib", "rlib"] [workspace] +members = [".build/core-source/crates/openless-core"] +exclude = [".build/core-source/crates/micyou-dsp", ".build/core-source/crates/micyou-infer"] +resolver = "2" [dependencies] -openless-core = { path = "../../app/crates/openless-core" } +openless-core = { path = ".build/core-source/crates/openless-core" } futures-util = "0.3" tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] } serde = { version = "1", features = ["derive"] } diff --git a/openless-all/ios/CoreHost/Patches/ios-input-contracts.patch b/openless-all/ios/CoreHost/Patches/ios-input-contracts.patch new file mode 100644 index 000000000..b11789cb0 --- /dev/null +++ b/openless-all/ios/CoreHost/Patches/ios-input-contracts.patch @@ -0,0 +1,1682 @@ +diff --git a/crates/openless-core/src/api.rs b/crates/openless-core/src/api.rs +index ab3a2a08..b6e5305a 100644 +--- a/crates/openless-core/src/api.rs ++++ b/crates/openless-core/src/api.rs +@@ -5753,7 +5753,13 @@ impl OpenLessBackend { + }; + // Keep finalization outside this critical section. A failed save changes neither + // the active target nor the remembered choice, and no other preference is overwritten. +- self.preferences.update(|preferences| preferences.translation_target_language = target)?; ++ self.preferences.update(|preferences| { ++ if !preferences.translation_target_languages.contains(&target) { ++ preferences.translation_target_languages.push(target.clone()); ++ } ++ preferences.translation_target_language = target; ++ preferences.translation_target_languages_present = true; ++ })?; + state.dictation_context = Some(Arc::new(updated)); + // The common stop boundary must also send the changed context to a streaming engine. + state.dictation_translation_requested = Some(true); +@@ -6589,6 +6595,146 @@ impl OpenLessBackend { + self.preferences.get() + } + ++ /// Replace the ordered target choices. Removing the selection chooses the first ++ /// remaining target; an empty list clears/disables the legacy selected target. ++ pub fn set_translation_targets( ++ &self, ++ targets: Vec, ++ ) -> Result { ++ self.update_translation_targets(|preferences| { ++ preferences.translation_target_languages = targets; ++ let targets = crate::settings::ordered_translation_targets( ++ preferences.translation_target_languages.clone(), ++ ); ++ if !targets.contains(&preferences.translation_target_language) { ++ preferences.translation_target_language = ++ targets.first().cloned().unwrap_or_default(); ++ } ++ Ok(()) ++ }) ++ } ++ ++ /// Add a catalog target once, at the end. The first addition becomes selected. ++ pub fn add_translation_target( ++ &self, ++ target: String, ++ ) -> Result { ++ self.update_translation_targets(|preferences| { ++ let target = crate::settings::canonical_translation_target(&target); ++ if target.is_empty() { ++ return Err(BackendError::new( ++ BackendErrorCode::InvalidArgument, ++ "empty translation target", ++ )); ++ } ++ if preferences.translation_target_languages.is_empty() { ++ preferences.translation_target_language = target.clone(); ++ } ++ if !preferences.translation_target_languages.contains(&target) { ++ preferences.translation_target_languages.push(target); ++ } ++ Ok(()) ++ }) ++ } ++ ++ /// Deletion is idempotent. Deleting the selection chooses the first remaining target. ++ pub fn delete_translation_target( ++ &self, ++ target: String, ++ ) -> Result { ++ self.update_translation_targets(|preferences| { ++ let target = crate::settings::canonical_translation_target(&target); ++ preferences ++ .translation_target_languages ++ .retain(|value| value != &target); ++ if !target.is_empty() && preferences.translation_target_language == target { ++ preferences.translation_target_language = preferences ++ .translation_target_languages ++ .first() ++ .cloned() ++ .unwrap_or_default(); ++ } ++ Ok(()) ++ }) ++ } ++ ++ /// Reorder only: omissions, additions and duplicates are rejected atomically. ++ pub fn reorder_translation_targets( ++ &self, ++ targets: Vec, ++ ) -> Result { ++ self.update_translation_targets(|preferences| { ++ let ordered = crate::settings::ordered_translation_targets(targets.clone()); ++ if ordered.len() != targets.len() ++ || ordered.len() != preferences.translation_target_languages.len() ++ || ordered ++ .iter() ++ .any(|target| !preferences.translation_target_languages.contains(target)) ++ { ++ return Err(BackendError::new( ++ BackendErrorCode::InvalidArgument, ++ "translation target reorder must be an exact permutation", ++ )); ++ } ++ preferences.translation_target_languages = ordered; ++ Ok(()) ++ }) ++ } ++ ++ /// Select a configured target, or disable legacy translation without erasing choices. ++ pub fn select_translation_target( ++ &self, ++ target: Option, ++ ) -> Result { ++ self.update_translation_targets(|preferences| { ++ preferences.translation_target_language = match target { ++ Some(target) => { ++ crate::settings::validate_session_translation_target(preferences, &target)? ++ } ++ None => String::new(), ++ }; ++ Ok(()) ++ }) ++ } ++ ++ fn update_translation_targets( ++ &self, ++ update: impl FnOnce(&mut UserPreferences) -> Result<(), BackendError>, ++ ) -> Result { ++ self.ensure_runtime_ready()?; ++ let _write_guard = self ++ .settings_write_gate ++ .lock() ++ .unwrap_or_else(std::sync::PoisonError::into_inner); ++ let previous = self.preferences.get(); ++ let mut preferences = previous.clone(); ++ let (targets, selected) = crate::settings::migrate_translation_targets( ++ if preferences.translation_target_languages_present { ++ Some(preferences.translation_target_languages.clone()) ++ } else { ++ None ++ }, ++ &preferences.translation_target_language, ++ ); ++ preferences.translation_target_languages = targets; ++ preferences.translation_target_language = selected; ++ preferences.translation_target_languages_present = true; ++ update(&mut preferences)?; ++ crate::settings::reconcile_translation_settings(&previous, &mut preferences)?; ++ // Own only these fields, not a potentially stale whole-document snapshot. ++ self.preferences.update(|current| { ++ current.translation_target_languages = preferences.translation_target_languages.clone(); ++ current.translation_target_language = preferences.translation_target_language.clone(); ++ current.translation_target_languages_present = true; ++ })?; ++ self.publish_preferences_changed(); ++ Ok(crate::SettingsUpdateOutcome { ++ preferences: self.preferences.get(), ++ reconciled_hotkey_count: 0, ++ effects: crate::SettingsEffectPlan::default(), ++ }) ++ } ++ + /// Consumes the "first launch of this major version" splash PV marker: + /// returns false when the config's `splash_seen_version` matches the + /// given current major (no replay); on mismatch it writes back the major +@@ -6716,6 +6862,7 @@ impl OpenLessBackend { + }; + crate::sync_dictation_hotkey_legacy_fields(&mut previous); + crate::sync_dictation_hotkey_legacy_fields(&mut preferences); ++ crate::settings::reconcile_translation_settings(&previous, &mut preferences)?; + // The splash marker may only advance via take_splash_playback: when a + // whole-document submit (old frontend, or a request that hasn't read + // the marker back) omits the field, serde's default would write the +@@ -7700,10 +7847,42 @@ impl OpenLessBackend { + self.start_reserved_dictation(reservation, options).await + } + ++ /// Capture using one explicitly registered native route and the unmodified ++ /// built-in Raw prompt. This never selects a cloud fallback or mutates styles. ++ pub async fn start_local_raw_dictation( ++ &self, ++ native_provider: &str, ++ insert_text: bool, ++ ) -> Result { ++ let provider = crate::credentials::ProviderType::new(native_provider.to_string())?; ++ let options = DictationStartOptions { ++ insert_text, ++ ..Default::default() ++ }; ++ let reservation = ++ self.reserve_dictation_session(insert_text, options.output_target, None)?; ++ self.start_reserved_dictation_with_raw_provider( ++ reservation, ++ options, ++ Some(provider.as_str()), ++ ) ++ .await ++ } ++ + async fn start_reserved_dictation( + &self, + reservation: DictationReservation, + options: DictationStartOptions, ++ ) -> Result { ++ self.start_reserved_dictation_with_raw_provider(reservation, options, None) ++ .await ++ } ++ ++ async fn start_reserved_dictation_with_raw_provider( ++ &self, ++ reservation: DictationReservation, ++ options: DictationStartOptions, ++ raw_provider: Option<&str>, + ) -> Result { + let DictationReservation { + session_id, +@@ -7720,14 +7899,25 @@ impl OpenLessBackend { + return Err(error); + } + } +- let context = match self +- .capture_dictation_context_with_host( +- &options, +- DictationContextPurpose::Dictation, +- host_context.as_ref(), +- ) +- .await +- { ++ let captured = match raw_provider { ++ Some(provider) => async { ++ let context = self.capture_local_raw_context(&options, provider)?; ++ // Resolve the explicit adapter before capture; this route has no fallback. ++ Arc::clone(&self.deps.dictation_engine) ++ .prepare_transcription(session_id, Arc::new(context.clone())) ++ .await?; ++ Ok(context) ++ }.await, ++ None => { ++ self.capture_dictation_context_with_host( ++ &options, ++ DictationContextPurpose::Dictation, ++ host_context.as_ref(), ++ ) ++ .await ++ } ++ }; ++ let context = match captured { + Ok(context) => Arc::new(context), + Err(error) => { + self.schedule_pending_audio_capture_discard(pending_capture.take()); +@@ -9177,6 +9367,36 @@ impl OpenLessBackend { + } + } + ++ fn capture_local_raw_context( ++ &self, ++ options: &DictationStartOptions, ++ provider: &str, ++ ) -> Result { ++ let preferences = self.get_preferences(); ++ let hotwords = self ++ .vocabulary ++ .list()? ++ .into_iter() ++ .filter(|entry| entry.enabled) ++ .map(|entry| entry.phrase) ++ .collect(); ++ let style = crate::style_packs::builtin_style_pack_for_mode(crate::types::PolishMode::Raw); ++ let mut context = DictationContext::capture( ++ &preferences, ++ &style, ++ DictationProviderInvocations::new( ++ crate::dictation_context::ProviderInvocation::for_provider(provider), ++ crate::dictation_context::ProviderInvocation::for_provider("raw-unused-llm"), ++ crate::dictation_context::ProviderInvocation::for_provider("raw-unused-omni"), ++ ), ++ hotwords, ++ Vec::new(), ++ options, ++ ); ++ context.pipeline_mode = crate::shared_types::PipelineMode::Traditional; ++ Ok(context) ++ } ++ + async fn capture_dictation_context( + &self, + options: &DictationStartOptions, +@@ -9198,6 +9418,11 @@ impl OpenLessBackend { + ) -> Result { + let preferences = self.get_preferences(); + let mut captured_options = options.clone(); ++ if let Some(target) = &options.translation_target_language { ++ captured_options.translation_target_language = Some( ++ crate::settings::validate_session_translation_target(&preferences, target)?, ++ ); ++ } + if !preferences.cursor_context_enabled { + // This switch controls document text, including text supplied by + // callers. Front-application metadata is still needed for history, +@@ -12189,6 +12414,285 @@ mod tests { + let _ = std::fs::remove_dir_all(data_dir); + } + ++ #[test] ++ fn translation_targets_business_apis_persist_order_selection_and_events() { ++ let (backend, _) = backend(); ++ let mut events = backend.subscribe(); ++ let original = backend.get_preferences(); ++ let outcome = backend ++ .set_translation_targets(vec![ ++ "ja".into(), ++ "en".into(), ++ "EN".into(), ++ "fr".into(), ++ "de".into(), ++ ]) ++ .unwrap(); ++ assert_eq!( ++ outcome.preferences.translation_target_languages, ++ vec!["日本語", "English", "Français", "Deutsch"] ++ ); ++ assert_eq!(outcome.preferences.translation_target_language, "日本語"); ++ assert!(outcome.effects.is_empty()); ++ assert_eq!(backend.snapshot().preferences_revision, 1); ++ assert!(matches!( ++ events.try_recv().unwrap().kind, ++ BackendEventKind::PreferencesChanged(PreferencesChange { revision: 1 }) ++ )); ++ assert_eq!(events.try_recv(), Err(crate::events::EventRecvError::Empty)); ++ backend.add_translation_target("en".into()).unwrap(); ++ backend ++ .reorder_translation_targets(vec!["de".into(), "en".into(), "fr".into(), "ja".into()]) ++ .unwrap(); ++ backend ++ .select_translation_target(Some("fr".into())) ++ .unwrap(); ++ backend.delete_translation_target("fr".into()).unwrap(); ++ let preferences = backend.get_preferences(); ++ assert_eq!( ++ preferences.translation_target_languages, ++ vec!["Deutsch", "English", "日本語"] ++ ); ++ assert_eq!(preferences.translation_target_language, "Deutsch"); ++ assert_eq!( ++ preferences.active_llm_provider, ++ original.active_llm_provider ++ ); ++ assert_eq!(preferences.working_languages, original.working_languages); ++ let reloaded = crate::preferences::PreferencesStore::open( ++ backend._data_dir.path().join("preferences.json"), ++ ) ++ .unwrap() ++ .get(); ++ assert_eq!( ++ reloaded.translation_target_languages, ++ preferences.translation_target_languages ++ ); ++ assert_eq!(reloaded.translation_target_language, "Deutsch"); ++ backend.select_translation_target(None).unwrap(); ++ backend.delete_translation_target("".into()).unwrap(); ++ assert!(backend ++ .get_preferences() ++ .translation_target_language ++ .is_empty()); ++ assert_eq!( ++ backend.get_preferences().translation_target_languages.len(), ++ 3 ++ ); ++ backend.set_translation_targets(vec![]).unwrap(); ++ assert!(backend ++ .get_preferences() ++ .translation_target_languages ++ .is_empty()); ++ assert!(backend ++ .get_preferences() ++ .translation_target_language ++ .is_empty()); ++ } ++ ++ #[test] ++ fn translation_targets_invalid_mutations_are_atomic() { ++ let (backend, _) = backend(); ++ backend ++ .set_translation_targets(vec!["en".into(), "ja".into()]) ++ .unwrap(); ++ let before = serde_json::to_value(backend.get_preferences()).unwrap(); ++ let revision = backend.snapshot().preferences_revision; ++ for result in [ ++ backend.add_translation_target("Unknown new language".into()), ++ backend.add_translation_target(" ".into()), ++ backend.set_translation_targets(vec!["fr".into(), "Unknown new language".into()]), ++ backend.reorder_translation_targets(vec!["en".into()]), ++ backend.reorder_translation_targets(vec!["en".into(), "EN".into()]), ++ backend.select_translation_target(Some("fr".into())), ++ ] { ++ assert_eq!(result.unwrap_err().code, BackendErrorCode::InvalidArgument); ++ assert_eq!( ++ serde_json::to_value(backend.get_preferences()).unwrap(), ++ before ++ ); ++ assert_eq!(backend.snapshot().preferences_revision, revision); ++ } ++ } ++ ++ #[test] ++ fn translation_targets_old_client_whole_settings_saves_preserve_choices() { ++ let (backend, _) = backend(); ++ backend ++ .set_translation_targets(vec!["en".into(), "ja".into()]) ++ .unwrap(); ++ for (selected, expected, targets) in [ ++ ("en", "English", vec!["English", "日本語"]), ++ ("ja", "日本語", vec!["English", "日本語"]), ++ ("fr", "Français", vec!["English", "日本語", "Français"]), ++ ("", "", vec!["English", "日本語", "Français"]), ++ ] { ++ let mut value = serde_json::to_value(backend.get_preferences()).unwrap(); ++ value ++ .as_object_mut() ++ .unwrap() ++ .remove("translationTargetLanguages"); ++ value["translationTargetLanguage"] = serde_json::json!(selected); ++ let legacy = serde_json::from_value(value).unwrap(); ++ let outcome = backend ++ .update_settings( ++ legacy, ++ crate::SettingsUpdateOptions::SETTINGS_DOCUMENT, ++ &crate::NoopSettingsRuntime, ++ ) ++ .unwrap(); ++ assert_eq!(outcome.preferences.translation_target_language, expected); ++ assert_eq!(outcome.preferences.translation_target_languages, targets); ++ } ++ let mut explicit = serde_json::to_value(backend.get_preferences()).unwrap(); ++ explicit["translationTargetLanguages"] = serde_json::json!([]); ++ explicit["translationTargetLanguage"] = serde_json::json!("en"); ++ let cleared = backend ++ .update_settings( ++ serde_json::from_value(explicit).unwrap(), ++ crate::SettingsUpdateOptions::SETTINGS_DOCUMENT, ++ &crate::NoopSettingsRuntime, ++ ) ++ .unwrap(); ++ assert!(cleared.preferences.translation_target_languages.is_empty()); ++ assert!(cleared.preferences.translation_target_language.is_empty()); ++ } ++ ++ #[test] ++ fn translation_targets_legacy_scalar_edits_survive_roundtripping_the_new_list() { ++ let (backend, _) = backend(); ++ backend ++ .set_translation_targets(vec!["en".into(), "ja".into()]) ++ .unwrap(); ++ // Older frontends may spread a preferences object including fields they do not edit. ++ let mut value = serde_json::to_value(backend.get_preferences()).unwrap(); ++ value["translationTargetLanguage"] = serde_json::json!("fr"); ++ let outcome = backend ++ .update_settings( ++ serde_json::from_value(value).unwrap(), ++ crate::SettingsUpdateOptions::SETTINGS_DOCUMENT, ++ &crate::NoopSettingsRuntime, ++ ) ++ .unwrap(); ++ assert_eq!(outcome.preferences.translation_target_language, "Français"); ++ assert_eq!( ++ outcome.preferences.translation_target_languages, ++ vec!["English", "日本語", "Français"] ++ ); ++ } ++ ++ #[test] ++ fn translation_targets_historical_custom_name_can_be_retained_selected_and_removed() { ++ let data_dir = TestDataDir::new("translation-target-historical"); ++ std::fs::create_dir_all(data_dir.path()).unwrap(); ++ // A historical file is migration input, not a new addition via the business API. ++ std::fs::write( ++ data_dir.path().join("preferences.json"), ++ serde_json::to_vec(&serde_json::json!({ ++ "translationTargetLanguage": "Historical custom language", ++ })) ++ .unwrap(), ++ ) ++ .unwrap(); ++ let migrated = backend_with_dictation_engine( ++ data_dir.path().to_path_buf(), ++ Arc::new(crate::testing::FixtureDictationEngine::successful( ++ "raw", "final", ++ )), ++ ); ++ migrated.add_translation_target("en".into()).unwrap(); ++ migrated ++ .reorder_translation_targets(vec!["en".into(), "Historical custom language".into()]) ++ .unwrap(); ++ migrated ++ .select_translation_target(Some("Historical custom language".into())) ++ .unwrap(); ++ assert_eq!( ++ migrated.get_preferences().translation_target_language, ++ "Historical custom language" ++ ); ++ migrated ++ .delete_translation_target("Historical custom language".into()) ++ .unwrap(); ++ assert_eq!( ++ migrated.get_preferences().translation_target_language, ++ "English" ++ ); ++ assert_eq!( ++ migrated ++ .add_translation_target("Historical custom language".into()) ++ .unwrap_err() ++ .code, ++ BackendErrorCode::InvalidArgument ++ ); ++ } ++ ++ #[tokio::test] ++ async fn translation_target_start_option_is_session_local_and_survives_settings_changes() { ++ let data_dir = TestDataDir::new("translation-target-session"); ++ let engine = crate::testing::FixtureDictationEngine::successful("raw", "final"); ++ let backend = ++ backend_with_dictation_engine(data_dir.path().to_path_buf(), Arc::new(engine.clone())); ++ backend.start().await.unwrap(); ++ backend ++ .set_translation_targets(vec!["en".into(), "ja".into()]) ++ .unwrap(); ++ let mut preferences = backend.get_preferences(); ++ preferences.working_languages = vec!["简体中文".into()]; ++ backend.set_preferences(preferences).unwrap(); ++ let revision = backend.snapshot().preferences_revision; ++ backend ++ .start_external_dictation_with_options(DictationStartOptions { ++ translation_requested: true, ++ translation_target_language: Some("ja".into()), ++ ..Default::default() ++ }) ++ .await ++ .unwrap(); ++ assert_eq!( ++ backend.get_preferences().translation_target_language, ++ "English" ++ ); ++ assert_eq!(backend.snapshot().preferences_revision, revision); ++ backend.set_translation_targets(vec!["fr".into()]).unwrap(); ++ backend.stop_dictation().await.unwrap(); ++ let contexts = engine.contexts(); ++ let context = contexts.last().unwrap(); ++ assert_eq!(context.polish.translation_target_language, "日本語"); ++ assert!(context.polish.translation_target_explicit); ++ assert!(context.polish.translation_active); ++ assert!(context.effective_polish_system_prompt().contains("日本語")); ++ assert_eq!( ++ backend.get_preferences().translation_target_language, ++ "Français" ++ ); ++ backend.shutdown().await.unwrap(); ++ } ++ ++ #[tokio::test] ++ async fn translation_target_start_option_rejects_unconfigured_or_empty_without_starting_engine() ++ { ++ let data_dir = TestDataDir::new("translation-target-invalid"); ++ let engine = crate::testing::FixtureDictationEngine::successful("raw", "final"); ++ let backend = ++ backend_with_dictation_engine(data_dir.path().to_path_buf(), Arc::new(engine.clone())); ++ backend.start().await.unwrap(); ++ backend.add_translation_target("en".into()).unwrap(); ++ for target in ["", " ", "ja", "Unknown target"] { ++ let error = backend ++ .start_external_dictation_with_options(DictationStartOptions { ++ translation_requested: true, ++ translation_target_language: Some(target.into()), ++ ..Default::default() ++ }) ++ .await ++ .unwrap_err(); ++ assert_eq!(error.code, BackendErrorCode::InvalidArgument); ++ assert!(engine.contexts().is_empty()); ++ } ++ backend.shutdown().await.unwrap(); ++ } ++ + #[test] + fn focused_microphone_selection_persists_and_publishes_once() { + let (backend, _) = backend(); +@@ -13870,6 +14374,10 @@ mod tests { + let selected = backend.select_dictation_translation_target(session, "ja".into()).unwrap(); + assert_eq!(selected.target_language, "日本語"); + assert_eq!(backend.get_preferences().translation_target_language, "日本語"); ++ assert_eq!( ++ backend.get_preferences().translation_target_languages, ++ vec!["English", "日本語"] ++ ); + assert_eq!(backend.get_preferences().microphone_device_name, "next microphone"); + backend.stop_dictation().await.unwrap(); + let contexts = engine.contexts(); +@@ -13880,6 +14388,35 @@ mod tests { + backend.shutdown().await.unwrap(); + } + ++ #[tokio::test] ++ async fn translation_picker_remembers_target_after_configured_choices_are_cleared() { ++ let data_dir = TestDataDir::new("translation-picker-cleared-choices"); ++ let engine = crate::testing::FixtureDictationEngine::successful("raw", "translated"); ++ let backend = backend_with_dictation_engine( ++ data_dir.path().to_path_buf(), ++ Arc::new(engine), ++ ); ++ backend.start().await.unwrap(); ++ backend.add_translation_target("en".into()).unwrap(); ++ let session = backend ++ .start_dictation_with_options(DictationStartOptions { ++ translation_requested: true, ++ ..Default::default() ++ }) ++ .await ++ .unwrap(); ++ backend.set_translation_targets(vec![]).unwrap(); ++ backend.select_dictation_translation_target(session, "ja".into()).unwrap(); ++ let saved = crate::preferences::PreferencesStore::open( ++ data_dir.path().join("preferences.json"), ++ ).unwrap().get(); ++ assert_eq!(saved.translation_target_language, "日本語"); ++ assert_eq!(saved.translation_target_languages, vec!["日本語"]); ++ assert_eq!(backend.get_preferences().translation_target_languages, vec!["日本語"]); ++ backend.cancel_dictation(Some(session)).await.unwrap(); ++ backend.shutdown().await.unwrap(); ++ } ++ + #[tokio::test] + async fn translation_picker_save_failure_keeps_active_and_saved_targets() { + let data_dir = TestDataDir::new("translation-picker-save-failure"); +diff --git a/crates/openless-core/src/auxiliary.rs b/crates/openless-core/src/auxiliary.rs +index 75c9c3c3..55a7de23 100644 +--- a/crates/openless-core/src/auxiliary.rs ++++ b/crates/openless-core/src/auxiliary.rs +@@ -64,7 +64,106 @@ impl RetranscriptionFailure { + } + } + ++/// A one-shot compose operation prepared before recording starts. The instruction ++/// is supplied only after real ASR completes; no editor selection is accepted. ++/// This auxiliary operation does not hold the global voice-session gate. Hosts ++/// must retain admission across their capture and this operation, including ++/// runtime restore/management probes, until completion or cancellation cleanup. ++pub struct PreparedVoiceCompose { ++ session_id: SessionId, ++ context: Arc, ++ polisher: Arc, ++ task_spawner: Arc, ++ abort: futures_util::future::AbortHandle, ++ registration: std::sync::Mutex>, ++} ++ ++#[derive(Debug, Clone, serde::Serialize)] ++#[serde(rename_all = "camelCase")] ++pub struct VoiceComposePreview { ++ pub text: String, ++ pub provider_type: String, ++ pub model: Option, ++ pub prompt_kind: &'static str, ++} ++ ++impl PreparedVoiceCompose { ++ pub fn cancel(&self) { ++ self.abort.abort(); ++ let polisher = self.polisher.clone(); ++ let session = self.session_id; ++ self.task_spawner.spawn(Box::pin(async move { ++ let _ = polisher.cancel(session).await; ++ })); ++ } ++ ++ pub async fn compose(&self, instruction: String) -> Result { ++ if self.abort.is_aborted() { ++ return Err(BackendError::new( ++ BackendErrorCode::Cancelled, ++ "compose cancelled", ++ )); ++ } ++ let registration = self.registration.lock().unwrap().take().ok_or_else(|| { ++ BackendError::new(BackendErrorCode::InvalidState, "compose is one-shot") ++ })?; ++ if instruction.trim().is_empty() || instruction.len() > 128 * 1024 { ++ return Err(BackendError::new( ++ BackendErrorCode::InvalidArgument, ++ "invalid spoken instruction", ++ )); ++ } ++ let input = crate::prompts::voice_compose_user_prompt(&instruction, None); ++ let operation = self.polisher.polish( ++ self.session_id, ++ self.context.clone(), ++ input, ++ Arc::new(DiscardTextStream), ++ ); ++ let output = futures_util::future::Abortable::new(operation, registration) ++ .await ++ .map_err(|_| BackendError::new(BackendErrorCode::Cancelled, "compose cancelled"))??; ++ if self.abort.is_aborted() { ++ return Err(BackendError::new( ++ BackendErrorCode::Cancelled, ++ "compose cancelled", ++ )); ++ } ++ let text = ++ crate::selection_voice_intent::clean_selection_voice_compose_output(&output.text); ++ if text.is_empty() || text.len() > 128 * 1024 { ++ return Err(BackendError::new( ++ BackendErrorCode::Provider, ++ "invalid compose output", ++ )); ++ } ++ Ok(VoiceComposePreview { ++ text, ++ provider_type: self.context.llm.provider_type.clone(), ++ model: self.context.llm.model.clone(), ++ prompt_kind: "voice_compose", ++ }) ++ } ++} ++ ++impl Drop for PreparedVoiceCompose { ++ fn drop(&mut self) { ++ self.cancel(); ++ } ++} ++ + pub trait AuxiliaryApi: Send + Sync { ++ fn prepare_voice_compose( ++ &self, ++ ) -> BoxFuture<'static, Result, BackendError>> { ++ Box::pin(async { ++ Err(BackendError::new( ++ BackendErrorCode::Unsupported, ++ "voice compose is not configured", ++ )) ++ }) ++ } ++ + fn repolish( + &self, + request: RepolishRequest, +@@ -212,6 +311,62 @@ impl AuxiliaryService { + } + + impl AuxiliaryApi for AuxiliaryService { ++ fn prepare_voice_compose( ++ &self, ++ ) -> BoxFuture<'static, Result, BackendError>> { ++ let service = self.clone_for_future(); ++ Box::pin(async move { ++ let preferences = service.preferences.get(); ++ let llm = crate::provider_resolution::resolve_session_provider( ++ &service.credential_store, ++ ProviderSlot::Llm, ++ &preferences.active_llm_provider, ++ ) ++ .await?; ++ let style = ++ crate::style_packs::builtin_style_pack_for_mode(crate::types::PolishMode::Light); ++ let mut context = DictationContext::capture( ++ &preferences, ++ &style, ++ DictationProviderInvocations::new( ++ ProviderInvocation::for_provider("compose-unused-asr"), ++ llm, ++ ProviderInvocation::for_provider("compose-unused-omni"), ++ ), ++ Vec::new(), ++ Vec::new(), ++ &DictationStartOptions { ++ insert_text: false, ++ ..Default::default() ++ }, ++ ); ++ context.pipeline_mode = crate::shared_types::PipelineMode::Traditional; ++ context.polish.style_system_prompt = crate::prompts::voice_compose_system_prompt(); ++ context.polish.translation_active = false; ++ context.polish.translation_target_language.clear(); ++ context.polish.hotwords.clear(); ++ context.polish.cursor_context = None; ++ context.polish.front_app = None; ++ context.polish.context_window_minutes = 0; ++ context.polish.prior_turns.clear(); ++ let context = Arc::new(context); ++ let polisher = service ++ .polisher ++ .prepare(context.clone()) ++ .await? ++ .unwrap_or(service.polisher); ++ let (abort, registration) = futures_util::future::AbortHandle::new_pair(); ++ Ok(Arc::new(PreparedVoiceCompose { ++ session_id: SessionId::new(), ++ context, ++ polisher, ++ task_spawner: service.task_spawner, ++ abort, ++ registration: std::sync::Mutex::new(Some(registration)), ++ })) ++ }) ++ } ++ + fn repolish( + &self, + request: RepolishRequest, +@@ -474,6 +629,50 @@ mod tests { + } + } + ++ #[tokio::test] ++ async fn voice_compose_is_lazy_one_shot_and_cancellation_prevents_provider_start() { ++ let polisher = Arc::new(FixtureTextPolisher::successful("draft")); ++ let (service, path) = service( ++ polisher.clone(), ++ Arc::new(FixtureTranscriptionEngine::successful("unused", 0)), ++ ); ++ let prepared = service.prepare_voice_compose().await.unwrap(); ++ assert!(polisher.inputs().is_empty()); ++ let preview = prepared ++ .compose("Write an invitation".into()) ++ .await ++ .unwrap(); ++ assert_eq!(preview.text, "draft"); ++ assert_eq!(preview.prompt_kind, "voice_compose"); ++ assert_eq!( ++ polisher.inputs(), ++ vec![crate::prompts::voice_compose_user_prompt( ++ "Write an invitation", ++ None ++ )] ++ ); ++ assert_eq!( ++ prepared ++ .compose("second instruction".into()) ++ .await ++ .unwrap_err() ++ .code, ++ BackendErrorCode::InvalidState ++ ); ++ let cancelled = service.prepare_voice_compose().await.unwrap(); ++ cancelled.cancel(); ++ assert_eq!( ++ cancelled ++ .compose("never sent".into()) ++ .await ++ .unwrap_err() ++ .code, ++ BackendErrorCode::Cancelled ++ ); ++ assert_eq!(polisher.inputs().len(), 1); ++ let _ = std::fs::remove_file(path); ++ } ++ + #[tokio::test] + async fn untouched_builtin_raw_repolish_is_a_true_passthrough() { + let (service, path) = service( +diff --git a/crates/openless-core/src/cloud_providers.rs b/crates/openless-core/src/cloud_providers.rs +index 4c38be0f..bce7e393 100644 +--- a/crates/openless-core/src/cloud_providers.rs ++++ b/crates/openless-core/src/cloud_providers.rs +@@ -1056,7 +1056,89 @@ enum CloudPolisherProvider { + Gemini(crate::llm_gemini::GeminiProvider), + } + ++// Read-only, selected-channel request credentials. SecretValue zeroizes on drop; ++// no vault reference, serialization, diagnostics or mutation survives preparation. ++struct FrozenPolishCredentials(Vec<(CredentialKey, crate::SecretValue)>); ++impl FrozenPolishCredentials { ++ fn denied() -> BoxFuture<'static, Result> { ++ Box::pin(async { ++ Err(BackendError::new( ++ BackendErrorCode::Unsupported, ++ "immutable polish credentials", ++ )) ++ }) ++ } ++} ++impl CredentialStore for FrozenPolishCredentials { ++ fn status( ++ &self, ++ _: crate::UserPreferences, ++ ) -> BoxFuture<'static, Result> { ++ Self::denied() ++ } ++ fn read( ++ &self, ++ key: CredentialKey, ++ ) -> BoxFuture<'static, Result, BackendError>> { ++ let value = self ++ .0 ++ .iter() ++ .find(|(stored, _)| stored == &key) ++ .map(|(_, value)| value.clone()); ++ Box::pin(async move { Ok(value) }) ++ } ++ fn write( ++ &self, ++ _: CredentialKey, ++ _: crate::SecretValue, ++ ) -> BoxFuture<'static, Result<(), BackendError>> { ++ Self::denied() ++ } ++ fn remove(&self, _: CredentialKey) -> BoxFuture<'static, Result<(), BackendError>> { ++ Self::denied() ++ } ++} ++ + impl TextPolisher for SharedCloudTextPolisher { ++ fn prepare( ++ &self, ++ context: Arc, ++ ) -> BoxFuture<'static, Result>, BackendError>> { ++ let credentials = self.credentials.clone(); ++ Box::pin(async move { ++ // OAuth resolves external runtime state and cannot promise an immutable ++ // delayed invocation through this credential-only seam. ++ if context.llm.provider_type == crate::polish::CODEX_OAUTH_PROVIDER_ID { ++ return Err(BackendError::new( ++ BackendErrorCode::Unsupported, ++ "OAuth delayed compose is unsupported", ++ )); ++ } ++ let mut values = Vec::new(); ++ for account in [ ++ LLM_API_KEY_ACCOUNT, ++ LLM_ENDPOINT_ACCOUNT, ++ LLM_EXTRA_HEADERS_ACCOUNT, ++ LLM_TEMPERATURE_ACCOUNT, ++ ] ++ .into_iter() ++ .chain(crate::llm_protocol::CONFIG_ACCOUNTS) ++ { ++ let key = CredentialKey::new( ++ CredentialNamespace::Llm, ++ Some(context.llm.provider_id.clone()), ++ account, ++ )?; ++ if let Some(value) = credentials.read(key.clone()).await? { ++ values.push((key, value)); ++ } ++ } ++ Ok(Some(Arc::new(SharedCloudTextPolisher::new(Arc::new( ++ FrozenPolishCredentials(values), ++ ))) as Arc)) ++ }) ++ } ++ + fn polish( + &self, + session_id: SessionId, +diff --git a/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +index 3b7e806c..66a16066 100644 +--- a/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs ++++ b/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +@@ -368,6 +368,13 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ + shape: PreferenceShape::TextList, + reason: "portable_setting", + }, ++ PreferenceField { ++ rust_name: "translation_target_languages", ++ key: "translationTargetLanguages", ++ class: PreferenceClass::Portable, ++ shape: PreferenceShape::TextList, ++ reason: "portable_setting", ++ }, + PreferenceField { + rust_name: "translation_target_language", + key: "translationTargetLanguage", +diff --git a/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs b/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs +index d3d9688c..eb2e6ccb 100644 +--- a/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs ++++ b/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs +@@ -108,6 +108,22 @@ fn all_current_preference_fields_have_an_explicit_registration() { + .map(|(name, _)| name) + }) + .collect(); ++ // Wire-presence metadata is not persisted or synced. Verify the explicit ++ // exception against actual serialization rather than exempting new fields. ++ let metadata = ["translation_target_languages_present", "translation_target_language_input"]; ++ let prefs = crate::shared_types::UserPreferences { ++ translation_target_languages_present: true, ++ translation_target_language_input: Some("en".into()), ++ ..Default::default() ++ }; ++ let serialized = serde_json::to_value(prefs).unwrap(); ++ assert!(serialized.get("translationTargetLanguagesPresent").is_none()); ++ assert!(serialized.get("translationTargetLanguageInput").is_none()); ++ assert!(metadata.iter().all(|name| serialized.get(*name).is_none())); ++ let names: std::collections::BTreeSet<_> = names ++ .into_iter() ++ .filter(|name| !metadata.contains(name)) ++ .collect(); + let registered: std::collections::BTreeSet<_> = registry::PREFERENCE_FIELDS + .iter() + .map(|field| field.rust_name) +diff --git a/crates/openless-core/src/dictation_context.rs b/crates/openless-core/src/dictation_context.rs +index b82b76a0..495952a7 100644 +--- a/crates/openless-core/src/dictation_context.rs ++++ b/crates/openless-core/src/dictation_context.rs +@@ -43,6 +43,9 @@ pub enum DictationOutputTarget { + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct DictationStartOptions { + pub translation_requested: bool, ++ /// Configured target for this session only; None uses the legacy selected target. ++ /// Selecting a target does not request translation or enable any provider. ++ pub translation_target_language: Option, + pub audio_source: DictationAudioSource, + pub insert_text: bool, + pub output_target: DictationOutputTarget, +@@ -55,6 +58,7 @@ impl Default for DictationStartOptions { + fn default() -> Self { + Self { + translation_requested: false, ++ translation_target_language: None, + audio_source: DictationAudioSource::Microphone, + insert_text: true, + output_target: DictationOutputTarget::ForegroundApp, +@@ -128,6 +132,8 @@ pub struct DictationPolishContext { + pub hotwords: Vec, + pub working_languages: Vec, + pub translation_target_language: String, ++ /// True when the start options explicitly selected the snapshotted target. ++ pub translation_target_explicit: bool, + pub translation_active: bool, + pub edit_plan_input: bool, + /// Selected text uses its own prompt envelope, separate from voice transcripts. +@@ -252,8 +258,12 @@ impl DictationContext { + omni, + } = providers; + let hotwords = normalize_hotwords(hotwords); +- let translation_target_language = +- preferences.translation_target_language.trim().to_string(); ++ let translation_target_language = crate::settings::canonical_translation_target( ++ options ++ .translation_target_language ++ .as_deref() ++ .unwrap_or(&preferences.translation_target_language), ++ ); + let translation_active = translation_effective( + options.translation_requested, + &translation_target_language, +@@ -357,6 +367,7 @@ impl DictationContext { + hotwords, + working_languages: preferences.working_languages.clone(), + translation_target_language, ++ translation_target_explicit: options.translation_target_language.is_some(), + translation_active, + edit_plan_input: false, + selection_input: false, +@@ -813,6 +824,56 @@ mod tests { + assert_eq!(local_asr_fallback(&no_model, &cloud_asr(), true), None); + } + ++ #[test] ++ fn explicit_translation_target_preserves_working_language_suppression_and_raw_mode() { ++ let preferences = UserPreferences { ++ default_mode: PolishMode::Raw, ++ working_languages: vec!["English".into()], ++ translation_target_language: "日本語".into(), ++ ..Default::default() ++ }; ++ let capture = |requested, target| { ++ DictationContext::capture( ++ &preferences, ++ &builtin_style_pack_for_mode(PolishMode::Raw), ++ DictationProviderInvocations::new( ++ ProviderInvocation::for_provider("local-qwen3"), ++ ProviderInvocation::for_provider("original-llm"), ++ ProviderInvocation::for_provider("original-omni"), ++ ), ++ vec![], ++ vec![], ++ &DictationStartOptions { ++ translation_requested: requested, ++ translation_target_language: target, ++ ..Default::default() ++ }, ++ ) ++ }; ++ let suppressed = capture(true, Some("en".into())); ++ assert_eq!(suppressed.polish.translation_target_language, "English"); ++ assert!(suppressed.polish.translation_target_explicit); ++ assert!(!suppressed.polish.translation_active); ++ assert!(!suppressed.uses_llm_polisher()); ++ assert!( ++ !suppressed ++ .with_translation_requested(true) ++ .polish ++ .translation_active ++ ); ++ let selected_only = capture(false, Some("ja".into())); ++ assert!(!selected_only.uses_llm_polisher()); ++ let translated = capture(true, Some("ja".into())); ++ assert!(translated.polish.translation_active); ++ assert!(translated.uses_llm_polisher()); ++ assert_eq!(translated.asr.provider_id, "local-qwen3"); ++ assert_eq!(translated.llm.provider_id, "original-llm"); ++ let legacy = capture(true, None); ++ assert!(!legacy.polish.translation_target_explicit); ++ assert_eq!(legacy.polish.translation_target_language, "日本語"); ++ assert!(legacy.polish.translation_active); ++ } ++ + #[test] + fn selection_polish_uses_the_selected_text_envelope() { + let mut context = DictationContext::default(); +diff --git a/crates/openless-core/src/dictation_engine.rs b/crates/openless-core/src/dictation_engine.rs +index f8d3d58e..d4f95155 100644 +--- a/crates/openless-core/src/dictation_engine.rs ++++ b/crates/openless-core/src/dictation_engine.rs +@@ -1011,6 +1011,13 @@ impl DictationEngine for PipelineDictationEngine { + let skips_polish_for_short_text = context.skips_polish_for_short_text(&transcript.text); + // Optional polish may be skipped after fallback; an explicit translation is still required. + let translation_active = context.polish.translation_active; ++ // Explicit per-session translation must not succeed with untransformed text. ++ let require_translation = translation_active && context.polish.translation_target_explicit; ++ let policy = if require_translation { ++ PolishFailurePolicy::Fail ++ } else { ++ policy ++ }; + let uses_polisher = context.uses_llm_polisher() + && !(context.voice_activation.is_some() && transcript.text.trim().is_empty()) + && !skips_polish_for_short_text +@@ -1063,6 +1070,16 @@ impl DictationEngine for PipelineDictationEngine { + ) + .await + }; ++ let output = output.and_then(|value| { ++ if require_translation && value.text.trim().is_empty() { ++ Err(BackendError::new( ++ BackendErrorCode::Provider, ++ "translation returned empty text", ++ )) ++ } else { ++ Ok(value) ++ } ++ }); + let result = match output { + Ok(text) => (text, false), + // Offline after local recognition: the raw transcript is the +@@ -3810,6 +3827,37 @@ mod tests { + assert_eq!(result.polished_text, "raw text"); + } + ++ #[tokio::test] ++ async fn requested_translation_failure_never_becomes_successful_source_text() { ++ for output in [ ++ Err(BackendError::new( ++ BackendErrorCode::Provider, ++ "fixture HTTP 500", ++ )), ++ Ok(crate::ports::PolishOutput::text(" ")), ++ ] { ++ let fixture = fixture_engine(false, output, None, None); ++ let id = SessionId::new(); ++ let mut context = DictationContext::default(); ++ context.polish.translation_active = true; ++ context.polish.translation_target_language = "English".into(); ++ context.polish.translation_target_explicit = true; ++ fixture ++ .engine ++ .start(id, Arc::new(context), fixture.progress.clone()) ++ .await ++ .unwrap(); ++ let failure = fixture ++ .engine ++ .finish(id, fixture.progress.clone()) ++ .await ++ .unwrap_err(); ++ assert_eq!(failure.error.code, BackendErrorCode::Provider); ++ assert_eq!(failure.raw_text.as_deref(), Some("raw text")); ++ assert!(fixture.engine.sessions.lock().unwrap().is_empty()); ++ } ++ } ++ + #[tokio::test] + async fn voice_activation_commands_are_cleaned_before_correction_and_polishing() { + use crate::testing::{ +diff --git a/crates/openless-core/src/language_catalog.rs b/crates/openless-core/src/language_catalog.rs +index 6bcfcf27..b8ac2f0c 100644 +--- a/crates/openless-core/src/language_catalog.rs ++++ b/crates/openless-core/src/language_catalog.rs +@@ -43,6 +43,14 @@ pub fn language_code(value: &str) -> Option { + language(value).map(|language| language.code.clone()) + } + ++/// Ordered catalog choices; provider support is checked separately. ++pub fn translation_language_names() -> Vec { ++ LANGUAGES ++ .iter() ++ .map(|language| language.native_name.clone()) ++ .collect() ++} ++ + pub fn asr_language_code(native_name: &str) -> Option { + language(native_name).map(|language| language.asr_code.clone()) + } +@@ -55,6 +63,22 @@ pub fn apple_speech_locale(native_name: &str) -> Option { + mod tests { + use super::*; + ++ #[test] ++ fn translation_names_and_aliases_share_the_existing_catalog() { ++ let names = translation_language_names(); ++ assert!(names.len() > 3); ++ for (index, name) in names.iter().enumerate() { ++ assert!(!names[..index].contains(name)); ++ assert_eq!(native_language_name(name).as_ref(), Some(name)); ++ } ++ assert_eq!(native_language_name(" EN ").as_deref(), Some("English")); ++ assert_eq!( ++ native_language_name("繁體中文").as_deref(), ++ Some("繁体中文") ++ ); ++ assert_eq!(native_language_name("unknown"), None); ++ } ++ + #[test] + fn extended_names_route_to_iso_and_apple_identifiers() { + for (name, code, locale) in [ +diff --git a/crates/openless-core/src/ports.rs b/crates/openless-core/src/ports.rs +index 66dfa69f..e5c8e58f 100644 +--- a/crates/openless-core/src/ports.rs ++++ b/crates/openless-core/src/ports.rs +@@ -780,6 +780,15 @@ impl PreparedTranscription for DeferredDictationPre + } + + pub trait TextPolisher: Send + Sync { ++ /// Freeze request credentials/options for a delayed operation without provider network I/O. ++ /// Adapters with no mutable request configuration may keep their runtime. ++ fn prepare( ++ &self, ++ _context: Arc, ++ ) -> BoxFuture<'static, Result>, BackendError>> { ++ Box::pin(async { Ok(None) }) ++ } ++ + fn polish( + &self, + session_id: SessionId, +diff --git a/crates/openless-core/src/preference_patch.rs b/crates/openless-core/src/preference_patch.rs +index 411a4b5f..07e176ec 100644 +--- a/crates/openless-core/src/preference_patch.rs ++++ b/crates/openless-core/src/preference_patch.rs +@@ -18,7 +18,9 @@ pub fn patch_preferences( + if let Some(mode) = edits.get("/capsuleDisplayMode") { + document["capsuleDisplayModeV2"] = mode.clone(); + } +- serde_json::from_value(document).map_err(invalid) ++ let mut patched = serde_json::from_value(document).map_err(invalid)?; ++ crate::settings::reconcile_translation_settings(current, &mut patched)?; ++ Ok(patched) + } + + fn invalid(error: impl std::fmt::Display) -> BackendError { +@@ -52,6 +54,24 @@ pub fn update_fields( + #[cfg(test)] + mod tests { + use super::*; ++ ++ #[test] ++ fn translation_drafts_survive_rebasing_and_serialization_before_save() { ++ let current = UserPreferences::default(); ++ let edits = BTreeMap::from([("/translationTargetLanguage".into(), Value::from("ko"))]); ++ let draft = patch_preferences(¤t, &edits).unwrap(); ++ assert_eq!(draft.translation_target_language, "한국어"); ++ assert_eq!(draft.translation_target_languages, vec!["한국어"]); ++ let loaded: UserPreferences = serde_json::from_value(serde_json::to_value(&draft).unwrap()).unwrap(); ++ assert_eq!(loaded.translation_target_language, draft.translation_target_language); ++ assert_eq!(loaded.translation_target_languages, draft.translation_target_languages); ++ let cleared = patch_preferences(&loaded, &BTreeMap::from([ ++ ("/translationTargetLanguages".into(), serde_json::json!([])), ++ ])).unwrap(); ++ assert!(cleared.translation_target_language.is_empty()); ++ assert!(cleared.translation_target_languages.is_empty()); ++ } ++ + #[test] + fn capsule_display_edits_establish_v2_without_opt_in_from_unrelated_edits() { + let mut current = UserPreferences::default(); +diff --git a/crates/openless-core/src/preferences.rs b/crates/openless-core/src/preferences.rs +index 817d7663..129f2a33 100644 +--- a/crates/openless-core/src/preferences.rs ++++ b/crates/openless-core/src/preferences.rs +@@ -266,6 +266,7 @@ impl PreferencesStore { + } + + pub fn set(&self, mut preferences: UserPreferences) -> Result<(), BackendError> { ++ crate::settings::reconcile_translation_settings(&self.get(), &mut preferences)?; + self.update(|current| { + // The sync transaction owns this field; stale whole-document saves cannot reroute its token. + preferences.sync_custom_server_origin = current.sync_custom_server_origin.clone(); +@@ -370,6 +371,7 @@ impl PreferencesStore { + &self, + mut preferences: UserPreferences, + ) -> Result<(), BackendError> { ++ crate::settings::reconcile_translation_settings(&self.get(), &mut preferences)?; + self.update(|current| { + preferences.preserve_style_preferences_from(current); + preferences.sync_custom_server_origin = current.sync_custom_server_origin.clone(); +diff --git a/crates/openless-core/src/provider_registry.rs b/crates/openless-core/src/provider_registry.rs +index 3f5c973b..1112e0fe 100644 +--- a/crates/openless-core/src/provider_registry.rs ++++ b/crates/openless-core/src/provider_registry.rs +@@ -136,6 +136,17 @@ impl TextPolisherRouter { + } + + impl TextPolisher for TextPolisherRouter { ++ fn prepare( ++ &self, ++ context: Arc, ++ ) -> BoxFuture<'static, Result>, BackendError>> { ++ let provider = match self.resolve(&context.llm.provider_type) { ++ Ok(provider) => provider, ++ Err(error) => return Box::pin(async move { Err(error) }), ++ }; ++ Box::pin(async move { Ok(Some(provider.prepare(context).await?.unwrap_or(provider))) }) ++ } ++ + fn polish( + &self, + session_id: SessionId, +diff --git a/crates/openless-core/src/settings.rs b/crates/openless-core/src/settings.rs +index 51600f8d..ad33194b 100644 +--- a/crates/openless-core/src/settings.rs ++++ b/crates/openless-core/src/settings.rs +@@ -19,6 +19,125 @@ pub(crate) fn ensure_testing_features_enabled( + } + } + ++/// Preserve historical custom names on load, while canonicalizing catalog aliases. ++pub(crate) fn canonical_translation_target(value: &str) -> String { ++ crate::language_catalog::native_language_name(value).unwrap_or_else(|| value.trim().to_string()) ++} ++ ++pub(crate) fn ordered_translation_targets(values: Vec) -> Vec { ++ let mut targets = Vec::new(); ++ for value in values { ++ let value = canonical_translation_target(&value); ++ if !value.is_empty() && !targets.contains(&value) { ++ targets.push(value); ++ } ++ } ++ targets ++} ++ ++pub(crate) fn migrate_translation_targets( ++ targets: Option>, ++ selected: &str, ++) -> (Vec, String) { ++ let selected = canonical_translation_target(selected); ++ let mut targets = ++ ordered_translation_targets(targets.unwrap_or_else(|| vec![selected.clone()])); ++ // A nonempty legacy selection remains authoritative when an older client ++ // round-trips a newly introduced list it does not know how to update. ++ if !targets.is_empty() && !selected.is_empty() && !targets.contains(&selected) { ++ targets.push(selected.clone()); ++ } ++ let selected = if targets.is_empty() { ++ String::new() ++ } else { ++ selected ++ }; ++ (targets, selected) ++} ++ ++/// A missing list is a legacy scalar-only update, not permission to erase targets. ++/// Unknown names already stored by an older client remain usable, but cannot be newly added. ++pub(crate) fn reconcile_translation_settings( ++ previous: &UserPreferences, ++ next: &mut UserPreferences, ++) -> Result<(), BackendError> { ++ let (previous_targets, previous_selected) = migrate_translation_targets( ++ if previous.translation_target_languages.is_empty() ++ && !previous.translation_target_languages_present ++ { ++ None ++ } else { ++ Some(previous.translation_target_languages.clone()) ++ }, ++ &previous.translation_target_language, ++ ); ++ // A legacy client can echo the initially empty list while selecting its first language. ++ // An empty list replacing stored choices remains an explicit deletion. ++ let input = if previous_targets.is_empty() && next.translation_target_languages.is_empty() { ++ next.translation_target_language_input.as_deref() ++ .unwrap_or(&next.translation_target_language) ++ } else { ++ &next.translation_target_language ++ }; ++ let selected = canonical_translation_target(input); ++ let mut targets = if next.translation_target_languages_present { ++ ordered_translation_targets(next.translation_target_languages.clone()) ++ } else { ++ previous_targets.clone() ++ }; ++ // Scalar-only clients (including existing Rust callers editing a snapshot) select ++ // a target without replacing the ordered collection. Explicit [] always clears. ++ if (!next.translation_target_languages_present ++ || (targets == previous_targets && selected != previous_selected)) ++ && !selected.is_empty() ++ && !targets.contains(&selected) ++ { ++ targets.push(selected.clone()); ++ } ++ for target in &targets { ++ if crate::language_catalog::native_language_name(target).is_none() ++ && !previous_targets.contains(target) ++ { ++ return Err(BackendError::new( ++ crate::errors::BackendErrorCode::InvalidArgument, ++ format!("unknown translation target: {target}"), ++ )); ++ } ++ } ++ let selected = if !selected.is_empty() && !targets.contains(&selected) { ++ targets.first().cloned().unwrap_or_default() ++ } else { ++ selected ++ }; ++ let (targets, selected) = migrate_translation_targets(Some(targets), &selected); ++ next.translation_target_languages = targets; ++ next.translation_target_language = selected; ++ next.translation_target_languages_present = true; ++ next.translation_target_language_input = None; ++ Ok(()) ++} ++ ++pub(crate) fn validate_session_translation_target( ++ preferences: &UserPreferences, ++ target: &str, ++) -> Result { ++ let target = canonical_translation_target(target); ++ let mut targets = ordered_translation_targets(preferences.translation_target_languages.clone()); ++ if !preferences.translation_target_languages_present { ++ let legacy = canonical_translation_target(&preferences.translation_target_language); ++ if !legacy.is_empty() && !targets.contains(&legacy) { ++ targets.push(legacy); ++ } ++ } ++ if target.is_empty() || !targets.contains(&target) { ++ return Err(BackendError::new( ++ crate::errors::BackendErrorCode::InvalidArgument, ++ "session translation target must be a configured nonempty target", ++ )); ++ } ++ Ok(target) ++} ++ + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "snake_case")] + pub enum SettingsCollisionPolicy { +@@ -292,3 +411,141 @@ mod tests { + assert!(!tsf_with_hidden_pref.windows_show_openless_in_keyboard_list); + } + } ++ ++#[cfg(test)] ++mod translation_targets_tests { ++ use super::*; ++ use serde_json::json; ++ ++ #[test] ++ fn empty_list_roundtrip_allows_the_first_legacy_selection_but_explicit_clear_still_wins() { ++ let previous = UserPreferences::default(); ++ let mut document = serde_json::to_value(&previous).unwrap(); ++ document["translationTargetLanguage"] = json!("ko"); ++ let mut next: UserPreferences = serde_json::from_value(document).unwrap(); ++ reconcile_translation_settings(&previous, &mut next).unwrap(); ++ assert_eq!(next.translation_target_language, "한국어"); ++ assert_eq!(next.translation_target_languages, vec!["한국어"]); ++ let previous = next; ++ let mut document = serde_json::to_value(&previous).unwrap(); ++ document["translationTargetLanguages"] = json!([]); ++ let mut cleared: UserPreferences = serde_json::from_value(document).unwrap(); ++ reconcile_translation_settings(&previous, &mut cleared).unwrap(); ++ assert!(cleared.translation_target_language.is_empty()); ++ assert!(cleared.translation_target_languages.is_empty()); ++ } ++ ++ #[test] ++ fn legacy_selected_target_seeds_list_and_preserves_custom_names() { ++ for (input, expected) in [ ++ (" en ", "English"), ++ (" My historical language ", "My historical language"), ++ ] { ++ let preferences: UserPreferences = serde_json::from_value(json!({ ++ "translationTargetLanguage": input, ++ })) ++ .unwrap(); ++ assert_eq!(preferences.translation_target_languages, vec![expected]); ++ assert_eq!(preferences.translation_target_language, expected); ++ assert!(!preferences.translation_target_languages_present); ++ } ++ let defaults: UserPreferences = serde_json::from_value(json!({})).unwrap(); ++ assert!(defaults.translation_target_languages.is_empty()); ++ assert!(defaults.translation_target_language.is_empty()); ++ } ++ ++ #[test] ++ fn explicit_targets_canonicalize_dedupe_keep_order_and_validate_selection() { ++ let preferences: UserPreferences = serde_json::from_value(json!({ ++ "translationTargetLanguages": ["ja", " en ", "English", "", "繁體中文", "zh-TW"], ++ "translationTargetLanguage": "en", ++ })) ++ .unwrap(); ++ assert_eq!( ++ preferences.translation_target_languages, ++ vec!["日本語", "English", "繁体中文"] ++ ); ++ assert_eq!(preferences.translation_target_language, "English"); ++ assert!(preferences.translation_target_languages_present); ++ let invalid_selection: UserPreferences = serde_json::from_value(json!({ ++ "translationTargetLanguages": ["ja", "en"], ++ "translationTargetLanguage": "fr", ++ })) ++ .unwrap(); ++ assert_eq!(invalid_selection.translation_target_language, "Français"); ++ assert_eq!( ++ invalid_selection.translation_target_languages, ++ vec!["日本語", "English", "Français"] ++ ); ++ } ++ ++ #[test] ++ fn explicit_empty_list_clears_selected_target_and_marker_is_not_wire_data() { ++ let preferences: UserPreferences = serde_json::from_value(json!({ ++ "translationTargetLanguages": [], ++ "translationTargetLanguage": "en", ++ "translationTargetLanguagesPresent": false, ++ })) ++ .unwrap(); ++ assert!(preferences.translation_target_languages_present); ++ assert!(preferences.translation_target_languages.is_empty()); ++ assert!(preferences.translation_target_language.is_empty()); ++ let value = serde_json::to_value(preferences).unwrap(); ++ assert!(value.get("translationTargetLanguagesPresent").is_none()); ++ let forged: UserPreferences = serde_json::from_value(json!({ ++ "translationTargetLanguage": "en", ++ "translationTargetLanguagesPresent": true, ++ })) ++ .unwrap(); ++ assert!(!forged.translation_target_languages_present); ++ } ++ ++ #[test] ++ fn unknown_new_additions_fail_but_historical_targets_survive() { ++ let previous: UserPreferences = serde_json::from_value(json!({ ++ "translationTargetLanguage": "Historical custom target", ++ })) ++ .unwrap(); ++ let mut next = previous.clone(); ++ reconcile_translation_settings(&previous, &mut next).unwrap(); ++ assert_eq!( ++ next.translation_target_languages, ++ vec!["Historical custom target"] ++ ); ++ assert_eq!( ++ validate_session_translation_target(&next, "Historical custom target").unwrap(), ++ "Historical custom target" ++ ); ++ next.translation_target_languages ++ .push("Unknown new target".into()); ++ assert_eq!( ++ reconcile_translation_settings(&previous, &mut next) ++ .unwrap_err() ++ .code, ++ crate::errors::BackendErrorCode::InvalidArgument ++ ); ++ } ++ ++ #[test] ++ fn old_client_scalar_updates_preserve_order_and_disabled_selection() { ++ let previous: UserPreferences = serde_json::from_value(json!({ ++ "translationTargetLanguages": ["en", "ja"], ++ "translationTargetLanguage": "en", ++ })) ++ .unwrap(); ++ for (scalar, selected, targets) in [ ++ ("en", "English", vec!["English", "日本語"]), ++ ("ja", "日本語", vec!["English", "日本語"]), ++ ("fr", "Français", vec!["English", "日本語", "Français"]), ++ ("", "", vec!["English", "日本語"]), ++ ] { ++ let mut next: UserPreferences = serde_json::from_value(json!({ ++ "translationTargetLanguage": scalar, ++ })) ++ .unwrap(); ++ reconcile_translation_settings(&previous, &mut next).unwrap(); ++ assert_eq!(next.translation_target_languages, targets); ++ assert_eq!(next.translation_target_language, selected); ++ } ++ } ++} +diff --git a/crates/openless-core/src/shared_types.rs b/crates/openless-core/src/shared_types.rs +index ddb570f9..f2ad5f27 100644 +--- a/crates/openless-core/src/shared_types.rs ++++ b/crates/openless-core/src/shared_types.rs +@@ -551,12 +551,21 @@ pub struct UserPreferences { + /// model knows which languages the user works in. See issue #4. + #[serde(default = "default_working_languages")] + pub working_languages: Vec, +- /// Translation output target language (single-select, native name). +- /// Empty = translation mode off (Shift combos are inert). The frontend +- /// picks from the built-in language list; the backend just splices the +- /// native name into the prompt. See issue #4. ++ /// Legacy selected translation target (canonical native name, or a preserved custom name). ++ /// Empty disables legacy translation gestures without erasing configured choices. + #[serde(default)] + pub translation_target_language: String, ++ /// Ordered, duplicate-free translation choices, separate from working/ASR/UI languages. ++ #[serde(default)] ++ pub translation_target_languages: Vec, ++ /// Wire presence distinguishes an old client's whole-document save from explicit deletion. ++ #[doc(hidden)] ++ #[serde(skip)] ++ pub translation_target_languages_present: bool, ++ /// Retains the submitted scalar until an empty list can be reconciled with stored choices. ++ #[doc(hidden)] ++ #[serde(skip)] ++ pub translation_target_language_input: Option, + /// Chinese output script preference (not exposed as a UI switch): + /// - Simplified: prefer simplified for Chinese output + /// - Traditional: prefer traditional for Chinese output +@@ -1093,6 +1102,8 @@ struct UserPreferencesWire { + windows_show_openless_in_keyboard_list: bool, + working_languages: Vec, + translation_target_language: String, ++ #[serde(default)] ++ translation_target_languages: Option>, + chinese_script_preference: ChineseScriptPreference, + #[serde(default)] + output_language_preference: OutputLanguagePreference, +@@ -1346,6 +1357,7 @@ impl Default for UserPreferencesWire { + windows_show_openless_in_keyboard_list: prefs.windows_show_openless_in_keyboard_list, + working_languages: prefs.working_languages, + translation_target_language: prefs.translation_target_language, ++ translation_target_languages: None, + chinese_script_preference: prefs.chinese_script_preference, + output_language_preference: prefs.output_language_preference, + qa_hotkey: prefs.qa_hotkey, +@@ -1470,6 +1482,15 @@ impl<'de> Deserialize<'de> for UserPreferences { + selection_polish_hotkey = None; + } + } ++ let translation_target_languages_present = wire.translation_target_languages.is_some(); ++ let translation_target_language_input = wire.translation_target_languages.as_ref() ++ .filter(|targets| targets.is_empty()) ++ .map(|_| wire.translation_target_language.clone()); ++ let (translation_target_languages, translation_target_language) = ++ crate::settings::migrate_translation_targets( ++ wire.translation_target_languages, ++ &wire.translation_target_language, ++ ); + let streaming_insert_default_migrated = wire.streaming_insert_default_migrated; + let streaming_insert = if streaming_insert_default_migrated { + wire.streaming_insert +@@ -1575,7 +1596,10 @@ impl<'de> Deserialize<'de> for UserPreferences { + ), + windows_show_openless_in_keyboard_list: wire.windows_show_openless_in_keyboard_list, + working_languages: wire.working_languages, +- translation_target_language: wire.translation_target_language, ++ translation_target_language, ++ translation_target_languages, ++ translation_target_languages_present, ++ translation_target_language_input, + chinese_script_preference: wire.chinese_script_preference, + output_language_preference: wire.output_language_preference, + qa_hotkey: if cfg!(target_os = "linux") { wire.qa_hotkey } else { None }, // retired desktop follow-up shortcut +@@ -1966,6 +1990,9 @@ impl Default for UserPreferences { + windows_show_openless_in_keyboard_list: true, + working_languages: default_working_languages(), + translation_target_language: String::new(), ++ translation_target_languages: Vec::new(), ++ translation_target_languages_present: false, ++ translation_target_language_input: None, + chinese_script_preference: ChineseScriptPreference::Auto, + output_language_preference: OutputLanguagePreference::Auto, + qa_hotkey: default_qa_hotkey(), diff --git a/openless-all/ios/CoreHost/src/account_sync_tests.rs b/openless-all/ios/CoreHost/src/account_sync_tests.rs index e6ac47646..552c46139 100644 --- a/openless-all/ios/CoreHost/src/account_sync_tests.rs +++ b/openless-all/ios/CoreHost/src/account_sync_tests.rs @@ -219,7 +219,9 @@ fn account_sync_real_core_device_flow_keychain_adapter_reopen_and_signout_preser assert!(credentials.read(provider).await.unwrap().is_some()); assert_eq!(before.0,serde_json::to_value(backend.list_vocabulary().unwrap()).unwrap()); assert_eq!(before.1,serde_json::to_value(backend.list_history().unwrap()).unwrap()); - assert!(!dir.path().join("encrypted-sync").exists()); + // Core's local migration journal shares this root; remote sync stays absent. + assert!(!dir.path().join("encrypted-sync/protected").exists()); + assert!(backend.cloud_sync_e2ee_status().is_err()); assert_eq!(server.count.load(Ordering::Acquire),3); server.assert_complete(); }); } @@ -266,7 +268,7 @@ fn account_sync_all_sync_controls_fail_closed_without_capture_credentials_or_net } assert_eq!(server.count.load(Ordering::Acquire), 0); assert!(backend.cloud_sync_e2ee_status().is_err()); - assert!(!dir.path().join("encrypted-sync").exists()); + assert!(!dir.path().join("encrypted-sync/protected").exists()); }); } diff --git a/openless-all/ios/CoreHost/src/lib.rs b/openless-all/ios/CoreHost/src/lib.rs index 1fb633849..3e6055de6 100644 --- a/openless-all/ios/CoreHost/src/lib.rs +++ b/openless-all/ios/CoreHost/src/lib.rs @@ -530,6 +530,10 @@ fn create_with_marketplace( "absolute data directory required", )); } + // Register the durable write gate before opening stores or applying startup + // preferences. Reopened roots must not be mistaken for an unowned restore. + let _write_gate = openless_core::cloud_sync_e2ee_store::gate::open_for_data_dir(&config.data_dir) + .map_err(|failure| error(BackendErrorCode::InvalidState, &failure.to_string()))?; let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() diff --git a/openless-all/ios/Keyboard/KeyboardHostApplication.m b/openless-all/ios/Keyboard/KeyboardHostApplication.m index c931d5d8a..b6bbaa993 100644 --- a/openless-all/ios/Keyboard/KeyboardHostApplication.m +++ b/openless-all/ios/Keyboard/KeyboardHostApplication.m @@ -6,11 +6,8 @@ NSString *OLKeyboardHostBundleIdentifier(UIInputViewController *controller) { if (!NSThread.isMainThread || !controller.isViewLoaded || !controller.view.window) return nil; @try { - // This legacy getter is absent on current UIKit's base controllers. Check - // the actual extension parent and its ABI instead of using KVC or a PID/XPC - // heuristic. Modern systems can return nil and use the main app's real - // source navigation action or the separate on-demand arbiter query below. - // Never retain a host ID from an earlier document/keyboard lifecycle. + // Probe the attached extension parent and validate the optional getter's + // ABI. A missing getter falls through to the request-scoped host query. SEL selector = NSSelectorFromString(@"_hostBundleID"); UIViewController *candidate = controller; for (NSUInteger depth = 0; candidate && depth < 4; depth++, candidate = candidate.parentViewController) { @@ -36,9 +33,8 @@ return nil; } -// Selector evidence: KeyboardHostBundleID, commit f1106dd9f1f09e01c9c28e10e2882990bb14acdc. -// This bridge is independently implemented. Runtime signatures are checked -// before hooking/calling; no layout assumptions or historical identity cache. +// Runtime hooks require matching signatures. Query generations prevent a +// delayed callback from resolving a replacement keyboard's destination. static os_unfair_lock OLHostLock = OS_UNFAIR_LOCK_INIT; static uint64_t OLHostGeneration; static uint64_t OLHostActiveQueryID; diff --git a/openless-all/ios/README.md b/openless-all/ios/README.md index 23a8f065f..9352b71c9 100644 --- a/openless-all/ios/README.md +++ b/openless-all/ios/README.md @@ -1,43 +1,39 @@ -# OpenLess iOS · 原生工程 +# OpenLess iOS +Native iPhone app, keyboard extension, and recording widget. The app uses SwiftUI and UIKit; the keyboard combines UIKit controls with KeyboardKit. The main app owns audio capture, speech recognition, and the shared Rust Core. -> 历史 Build 11:默认尺寸按新参考收小,可自定义高度;保存润色选择,恢复中文候选与两步英文确认,@ 常驻,改善录音波形、翻译提示和 thinking 文案。[目标与实现](docs/ios-build11-goals.md) · [交付与验证](docs/ios-build11-release.md) · [未签名 IPA](Artifacts/release-11/OpenLess-0.1.0-11-unsigned.ipa)。 +## Version and platform support -> 历史 Build 10:隐藏 VideoCall PiP 与共存适配、锁屏/前后台恢复、空闲音频释放、拼音原文提交和按键尺寸已落盘。[目标与实现](docs/ios-build10-goals.md) · [交付与验证](docs/ios-build10-release.md) · [未签名 IPA](Artifacts/release-10/OpenLess-0.1.0-10-unsigned.ipa)。真机隐藏、共存和扬声器效果仍待本包验收。 +`Config/Version.xcconfig` is the single version source for all three bundles. This integration is **0.1.0 (16)** and preserves the Build 15 keyboard behavior. Update `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` there, then regenerate the project with XcodeGen. Product validation rejects mismatched app/extension versions. -> 历史 Build 7:修复后台音频准入过期误判、中英文切换后未重新准备、旧输入目标交付阻塞新录音;加入离线英文下一词和中文续词,扩大发送按钮、更新按键音与原生触感,消除风格/词典切换的整页淡入闪烁。[下载未签名 IPA](Artifacts/interaction-build7/OpenLess-0.1.0-7-unsigned.ipa);[交付与验证记录](Artifacts/interaction-build7/README.md)。 +| Component | Minimum iOS | Responsibility | +| --- | --- | --- | +| `OpenLess` | 16 | Settings, speech, processing, history, vocabulary, and styles | +| `OpenLessKeyboard` | 16 | Typing, candidates, recording gestures, and insertion | +| `OpenLessRecordingWidget` | 18 | Recording controls and Live Activity integration | -> 最新产品范围以 [完整需求与 To-do](docs/requirements-and-todo.md) 为准;2026-10-17 的实现、安装产物及验证范围见 [验收记录](Artifacts/overnight-usability/acceptance-summary.json)。风格库/市场、翻译/帮写、词典桥接及账户页已接入;远程加密同步和完整真机运行验收仍有缺口;键盘跨应用录音的最新状态见 Build 10 目标与交付记录。 +The iOS version is independent of the desktop release version. Device builds currently target arm64 iPhone; simulator builds target Apple silicon. -原生 iPhone App + Keyboard Extension,最低部署目标 iOS 16;目前主要运行证据来自 iOS 27,旧系统还需实际设备验收。主 App 用 SwiftUI;UIKit 键盘扩展嵌入 KeyboardKit 9.9.0 的原生 SwiftUI 键盘,中文组合使用 Rime。没有 WebView、Hermes、React Native 或 Flutter;Rust Core 和模型只在主 App 中运行。 +## Input modes -> 当前录音实现包括系统控制中心、键盘首次自动唤醒/返回,以及有效会话内的后台重新开麦。具体测试范围见上方 Build 7 记录;旧系统和用户真机仍需验收。 -> Build 10 已移除空闲输出引擎,待机只保留 PiP;系统内部接口不兼容时降级为前台激活。设备验证名单仍为空,详见 [后台会话与 PiP 研究](docs/keepalive.md)。 +Choose **Voice focused** or **Single keyboard** in Settings. The selection, keyboard height, Chinese/English language, and selected processing style persist across keyboard sessions. -## 已写入的功能 +| Mode | Typing and voice behavior | +| --- | --- | +| Voice focused | Dedicated voice panel plus Chinese/English typing; long-press space can move the cursor or start recording | +| Single keyboard | Chinese/English typing with `123`, language switch, wide space, and the host's Return action; hold space to record | + +Both modes retain the voice toolbar, processing styles, translation, and system keyboard switching. Held recording offers cancel, translation, and send actions. Single-keyboard mode uses one configured translation output language; recording freezes that selection for the current request. Basic typing works without Full Access. Shared preferences and cross-process voice control require Full Access and matching App Group entitlements. + +Chinese composition uses librime with a bounded rime-ice dictionary. Candidates expand into a grid; users can select Chinese candidates or explicitly confirm the typed Latin letters. English uses an offline frequency/context dictionary, `UITextChecker`, and the supplementary `UILexicon`. These are the input engines used by this extension, rather than the system keyboard's complete prediction model. Supplementary entries stay in memory. Key sounds use `UIDevice.playInputClick()`; haptic and sound preferences remain configurable. -- 官方 OpenLess 图标与品牌;统一“语音/EN/拼”模式栏,语音页和错位排布的文字键盘共用输入和会话。 -- 26 键英文、简体全拼、真实 Rime 候选、数字符号、大小写、连续删除、光标操作、Return 类型与系统输入法切换。 -- 中文采用雾凇常用字与 20 万高频基础词,英文采用离线词频/上下文词对 + Apple 拼写纠错;空格或中文上屏后仍可选续词,候选左侧返回工具栏。见[离线候选与续词](docs/input-predictions.md)。 -- 大、小麦克风按钮共用点按/按住手势;空格长按可选移动光标或录音,进入光标模式时显示明确提示。 -- 模式滑块与页面过渡可打断;语音空白区可横滑切页,不抢按住麦克风、空格或候选的手势。Shift/Caps Lock 有可见状态,轻触反馈开关接入实际键位。 -- 普通模式固定 SenseVoice Small int8 本地 ASR,模型随 App 打包,不预设 LLM、不经过 LLM 后处理。Apple Speech 是开发者模式下可选的系统识别器,不作为静默回退。 -- 设置中连点版本号三次,经确认安全关闭任务并重载界面与 Core,进入开发者模式;不是终止/重启 iOS 进程。可配置自己的 ASR/LLM/全模态 API,传统管线和全模态管线复用桌面 Core。 -- 密钥使用主 App 的 Keychain;提供商/模型列表、验证复用 Core,只在用户主动请求时执行。iOS 选区改写 UI 已移除,共享 Core 接口仍保留。 -- 真实 PCM、音量与计时,停止先释放采集再处理结果;30 分钟上限、键盘断联自动停止。 -- Core 的会话、取消、输入结果和历史规则,通过独立 iOS Host 接线。 -- App Group SQLite + Darwin 通知。首次唤醒录音的结果自动写入返回后的当前输入框;重复通知、已尝试的写入与旧消息不会重放。 -- 独立原生试用编辑页,键盘出现时不再与 Tab 栏重叠;浅/深色界面、大字号纵向设置布局;授权被拒绝时提供实际的系统设置入口。 -- 历史详情与原文/结果复制、管理菜单和确认删除;重新润色为 Core 生成预览,不覆盖原记录。失败录音可在真实资产存在时播放/导出,完成转写无录音导出。 -- 真实概览和 7/30 天活动趋势、本地 Core 词典管理;应用主题、帮助与版本说明。已确认并启用的词条可通过 App Group 共享到 Rime;远程加密同步仍未开放。 -- 待确认文字、安装引导、隐私和许可;实验诊断保持二级入口。 -- 修复后台授权回调的 Swift actor 断言、键盘断连时空文档 UUID 强制桥接;新增真实系统回调及反复重开键盘回归。 +Normal speech recognition uses bundled SenseVoice Small int8. Developer settings can select Apple Speech or configure Core ASR/LLM/Omni providers. Recognition language is separate from typing language. Translation and polishing depend on the configured processing provider. Preparing voice access does not start recording. -首次授权由主 App 显示系统权限请求。键盘点击语音后先确认后台服务;仅在无法服务时前台唤醒,自动开始并返回原宿主。结果就绪后自动写入当前可用输入框;没有输入框时等待键盘重新连接。控制中心录音入口仍可使用。 +## Build from source -## 构建 +Requirements: an Apple silicon Mac, Xcode 27 with its command-line tools selected, Python 3, CMake, Git, and Rustup. Bootstrap installs Rust 1.99.0 and the two iOS targets, and pins native dependencies and XcodeGen. Downloaded speech artifacts and prediction sources are checksum-verified. -环境:Xcode 27、Swift 6、CMake、Rustup、Git。构建辅助工具和开源源码保存在忽略的 `.build-tools/`、`.vendor/`;不修改系统签名配置,不安装竞品。 +Desktop sources under `openless-all/app/` follow `beta` unchanged. `scripts/prepare-core.py` copies the checked-in Core into the ignored `CoreHost/.build/core-source/` directory and applies `CoreHost/Patches/ios-input-contracts.patch` there. The iOS Rust workspace builds and tests that copy. The patch adds the mobile input contracts without changing the Core used by desktop, Android, or Linux. Bootstrap, tests, native builds, and license collection prepare the copy automatically; incompatible upstream changes fail patch validation. ```sh cd openless-all/ios @@ -45,94 +41,57 @@ cd openless-all/ios ./scripts/bootstrap-local-speech.sh ./scripts/build-rime.sh ./scripts/build-core.sh -./scripts/build.sh simulator Debug -./scripts/build.sh device Personal +python3 scripts/collect-rust-licenses.py +python3 scripts/validate-project.py +./scripts/build.sh device Release unsigned +python3 scripts/package-ipa.py DerivedData/Build/Products/Release-iphoneos/OpenLess.app \ + "$HOME/Downloads/OpenLess-0.1.0-16-unsigned.ipa" ``` -- Xcode 工程:`OpenLess.xcodeproj`,由 `project.yml` 生成,两个均保留。 -- 普通 Scheme:`OpenLess`;个人诊断 Scheme:`OpenLessPersonal`。 -- 默认设备构建不签名,不能直接安装到 iPhone;默认模拟器构建使用 ad-hoc 签名并检查有效的模拟 App Group 声明。 -- XcodeGen 按 `OPENLESS_XCODEGEN_BIN`、仓库缓存、PATH 中的已安装版本顺序解析。可用 `OPENLESS_DERIVED_DATA` 指定构建缓存目录;不会因缓存被清理而重复下载工具。 -- 无签名设备包使用显式 `validate-product.py --unsigned-device` 检查结构和资源,输出明确不包含签名/安装就绪证明。签名包与模拟器的正常校验仍严格核对实际产品 entitlement。 -- 二进制依赖和编译后拼音资源不入库;新检出必须先运行构建脚本。 -- `swift scripts/sync-brand-assets.swift` 同步仓库 `app/src-tauri/icons/` 的官方素材;只为 iOS App Icon 去除 alpha,不重画 Logo。`Resources/Brand.xcassets` 同时供 App/键盘使用。 - -### 真机签名与安装 - -1. 复制 `Config/Local.xcconfig.example` 为 `Config/Local.xcconfig`。 -2. 填写自己的 Team ID、唯一 Bundle ID 和 App Group;主 App、键盘和录音控制扩展必须使用同一团队和 App Group。 -3. 在 Xcode 账户/开发者后台配置可用的 App Group 签名。免费个人团队若不能配置该能力,不能据此验证跨进程语音。 -4. 解锁并连接 iPhone,启用开发者模式,在 Xcode 选择真机和 `OpenLessPersonal` 运行。也可显式执行 `./scripts/build.sh device Personal signed`,该命令会请求 Xcode 的自动配置服务。 -5. 系统设置 → 通用 → 键盘 → 键盘 → 添加新键盘 → OpenLess;语音控制需要开启“允许完全访问”。基本打字不依赖完整访问。 -6. 在主 App 点击“启用语音”授权并准备本地模型;准备不会录音。普通模式不请求 Apple Speech 权限/下载。再从首页进入“试用键盘”独立编辑页。键盘中的打开 App 请求可能被系统拒绝,会提示手动打开,不调用未验证的打开应用技巧。 -7. 关闭完全访问时,中英文仍可在键盘的模式栏中切换;主 App 的默认面板、空格长按等偏好需要完全访问才能同步。 +The unsigned IPA is for personal signing. Packaging validates the actual app, keyboard, widget, deployment floors, resource checksums, archive contents, and executable permissions. An existing output is never overwritten. -尚未配置新的签名身份或安装到手机。已有模拟器录音与服务验证的准确范围见验收记录;这不是对所有系统、服务或真机状态的保证。 +For the simulator, use `./scripts/build.sh simulator Debug`. `OPENLESS_DERIVED_DATA` selects a build directory and `OPENLESS_XCODEGEN_BIN` selects an installed XcodeGen. `project.yml` generates the checked-in Xcode project; edit the specification before regenerating it. -## 启动本地预览 +For Xcode device signing, copy `Config/Local.xcconfig.example` to `Config/Local.xcconfig`, set your team, bundle ID, and App Group, then run `./scripts/build.sh device Personal signed`. All three bundles must use the same App Group. Local signing configuration stays outside Git. -```sh -IOS_SIMULATOR_ID=<设备标识> ./scripts/preview.sh -``` +## Enable the keyboard -这条命令只构建并安装产品,不运行 XCTest;检查的是构建包与实际安装包中的可执行文件、编译图标、Assets.car 和键盘资源。桌面占位图、旧安装记录不算成功。界面可通过 Orca 的 iOS 预览打开;没有独立 Simulator.app 时也不需要伪造预览页。 +1. Install the signed app and open OpenLess to enable voice access. +2. In iOS Settings, choose General → Keyboard → Keyboards → Add New Keyboard → OpenLess. +3. Enable Full Access for shared settings and voice control. +4. Choose the keyboard experience in OpenLess Settings and try it in the app's keyboard editor. -UI 测试会临时安装 `OpenLessUITests-Runner`。`test.sh` 在正常结束或失败退出时移除它;`preview.sh` 也会清理残留 Runner,只保留一个 OpenLess 入口,不删除产品数据。 +The main app activates voice when needed and returns to the requesting host when the runtime supports it. Active PiP can keep the session available between recordings. The isolated PiP/host-return compatibility adapters use runtime-checked private selectors and fall back when unavailable; this source build is not an App Store approval claim. Idle state does not keep the microphone or an audio render loop running. -## 测试 +## Verification ```sh ./scripts/test.sh unit -# 只跑 App 原生 + UI,或加上上述共享/Rust/Rime/音频检查: -IOS_SIMULATOR_ID=<设备标识> ./scripts/test.sh ui -IOS_SIMULATOR_ID=<设备标识> ./scripts/test.sh all -# 使用专门的 QA 模拟器;测试会切换页面、安装键盘和关闭完全访问。 -# 导出有来源索引的截图: -python3 scripts/export-ui-evidence.py Artifacts/<测试结果>.xcresult --tag light +python3 scripts/test-interface-resources.py +python3 scripts/validate-project.py +IOS_SIMULATOR_ID= ./scripts/test.sh ui ``` -无需麦克风的测试包括:共享协议/SQLite/手势/活跃键盘租约;真实 Core 管线配合明确标识的原生测试适配器;真实 Rime 拼音与候选;AVFoundation 重采样和尾帧排空。Core 测试只使用独立临时目录。 - -UI 测试会在指定模拟器的系统设置里添加 OpenLess 键盘并关闭完全访问,验证“nihao → 你好”、English、符号/换行/删除、横屏、模式切换、编辑页避让、授权拒绝、只读诊断以及文字裁切。空历史测试要求专用 QA 数据。 - -当前完整套件 `keyboardkit-full-current.xcresult`:**12 项 UI + 4 项 App 原生通过,1 项授权回调因未授权跳过,0 失败**。包括真实键盘、默认无 LLM 的开发者重载、实际本地模型公共音频 smoke 与不可用按钮安全。另有 27 项共享 Swift、14 项 Rust Host 通过;最新带模型的无签名设备构建通过。真实授权回调只复用既有决定,不偷偷授权。另有 3 项深色检查和 1 项最大辅助字号首页/设置裁切审计通过。旧的 11 UI/3 原生是历史版本,不混算。证据及准确边界见[验证记录](docs/verification.md)。模拟器使用 ad-hoc 签名,不配置真实开发团队。 - -结果写入忽略的 `Artifacts/`,不会因 Orca 重启丢失;`UI_RESULT_BUNDLE` 可指定结果路径。UI 测试与真机录音、隐藏 PiP、输入目标行为、资源及能耗验收分别记录,不能互相替代。 +Unit checks cover shared contracts, preferences, delivery, the mobile Core and Rust Host, real Rime composition, predictions, and audio conversion. UI tests exercise the installed extension and host app; use a dedicated simulator because they configure keyboard settings. `test.sh all` combines both suites. The GitHub iOS workflow verifies that shared sources remain unchanged, runs mobile Swift/Rust/resource tests, builds the native dependencies and unsigned iPhone app, and validates the IPA. Runtime microphone, routing, and PiP behavior still require device testing. -## 结构与数据边界 +## Source layout -| 目录 | 职责 | +| Directory | Contents | | --- | --- | -| `App/` | 主 App、原生 ASR 调度、音频、Core、开发者配置与显式选区预览 | -| `Keyboard/` | 键位、候选、录音控制、输入目标与最终上屏 | -| `PlatformShared/` | 两个目标共用的 UIKit 说话按钮、共享容器和通知 | -| `Packages/OpenLessShared/` | 无 UI 的 v2 协议、持久化、手势策略和测试 | -| `Packages/LocalSpeech/` | 仅主 App 链接的 sherpa-onnx/ONNX Runtime 与 SenseVoice actor | -| `CoreHost/` | 独立 Rust workspace,窄 C ABI,依赖现有 `openless-core` | -| `RimeBridge/` | 基于公开 Rime C API 的 Objective-C++ 桥 | -| `Resources/` | 官方 OpenLess 图标、许可、隐私声明、输入方案与空兼容白名单 | -| `AppTests/`/`UITests/` | 真实授权回调/说话按钮测试;App 与实际扩展的 UI 回归 | - -- Core 与 Rime 的静态库只分别链接主 App/键盘,不把 Core 放进扩展。 -- 主 App 的历史在自己的 Application Support;拼音学习数据在扩展私有容器。 -- App Group 不保存原始音频。提交日志保留至多一天供错误恢复;已提交内容随清空历史清理,待确认项可单独移除。 -- 只有用户点“复制”才写剪贴板。无账户、云同步、广告、埋点或竞品 API。 -- 识别预览跨进程限长,音频使用有界队列,录音之外不维持音频采集。 -- 空闲没有键盘心跳;只有活跃的键盘录音每秒更新租约,超过 4 秒失联则停止。 - -## 依赖与许可 - -全部输入引擎/词库来自合法上游,没有取用竞品包中的二进制、字体、Logo 或商业词典。 - -- KeyboardKit **9.9.0 MIT 源码版**:`a7e33ac2e6f47e16288ce09ec8d765cd329f918c`;不是当前闭源/二进制主版本。 -- SenseVoice Small int8、sherpa-onnx 1.13.8 和 ONNX Runtime 1.28.2:固定上游与 SHA-256,见[模型来源](docs/local-speech-provenance.md)。模型约 228 MiB,不进入键盘扩展。 -- librime 1.13.1:`1c23358157934bd6e6d6981f0c0164f05393b497`,BSD-3-Clause。 -- rime-ice:`da1fbe602e38f26db846fa10120ee64c2b0324c0`,GPL-3.0-only,使用常用字表及 20 万高频基础词。方案 ID 沿用 `pinyin_simp` 以保留用户学习。 -- SymSpell 频率数据:`c239062ae02961df18ab7da1671d01b4388204e0`,MIT,英语单词与上下文词对;其上游数据归属见应用内 `prediction-data` 说明。 -- Boost 1.85.0:源码压缩包校验值固定在 bootstrap 中,BSL-1.0。 -- LevelDB、yaml-cpp、marisa、OpenCC 使用上述 librime 固定的子模块提交;禁用外部插件、glog 和原生依赖测试工具。 -- OpenLess/Core 沿用项目 AGPL-3.0-only。许可证位于 `Resources/Licenses/`;Rust 依赖由独立 `CoreHost/Cargo.lock` 固定。 - -不包含 CarPlay、耳机硬件键、九宫格、其他键盘输入语言或账户/订阅/同步体系。自定义云端服务只在开发者模式下由用户配置;没有预置第三方账号或竞品服务。 - -普通模式原文输出;开发者模式可主动启用 LLM 或全模态模型。分段合并不是改写,提供商接线/假服务测试不等于真实云端兼容性。与豆包/Typeless 的进程分工、后台缺口和处理链路见[架构与后处理对照](docs/architecture-and-processing.md)。 +| `App/` | Native app, settings, speech, audio, and Core integration | +| `Keyboard/` | Typing layout, candidates, voice gestures, and host insertion | +| `RecordingWidget/` | Recording controls and Live Activity views | +| `PlatformShared/` | Shared native controls and container access | +| `Packages/OpenLessShared/` | Versioned mailbox, preferences, gestures, and delivery contracts | +| `Packages/LocalSpeech/` | SenseVoice runtime adapter used by the app | +| `CoreHost/` | iOS Rust workspace, C ABI, and isolated Core source patch | +| `RimeBridge/` | Objective-C++ composition and vocabulary bridge | +| `Resources/` | Brand assets, localization, input schemas, privacy manifest, and licenses | +| `AppTests/`, `UITests/`, `Tests/` | Native, extension, and runtime fixtures | +| `scripts/` | Dependency bootstrap, builds, packaging, and validation | + +Credentials stay in the main app's Keychain. The App Group carries bounded commands and results, not raw audio. Typing data and Rime learning stay in the keyboard container; supplementary system words are not persisted. Result insertion validates the current document and records delivery to prevent stale replay. + +Dependency sources are pinned in `bootstrap.sh`, `bootstrap-local-speech.sh`, and `build-predictions.py`. Preserve `Resources/Licenses/` when distributing the app. `collect-rust-licenses.py` refreshes attribution from the locked runtime dependency graph. + +Generated frameworks, models, compiled dictionaries, logs, screenshots, signing configuration, and IPA archives are excluded from Git. Source assets, fixtures, lockfiles, and dependency notices are part of the project. diff --git a/openless-all/ios/Resources/Licenses/GlobalRefresh-PiP-NOTICE.txt b/openless-all/ios/Resources/Licenses/GlobalRefresh-PiP-NOTICE.txt index 6e57b450c..6b911793a 100644 --- a/openless-all/ios/Resources/Licenses/GlobalRefresh-PiP-NOTICE.txt +++ b/openless-all/ios/Resources/Licenses/GlobalRefresh-PiP-NOTICE.txt @@ -12,8 +12,8 @@ When modifying, distributing, or publishing an app based on this project, please retain attribution to both CaiWanFeng and Yoroin, together with the corresponding project links. -OpenLess integration (2026-10-21): +OpenLess integration: The scoped control-style and content-type adapters in App/PiPCoexistenceAdapter.swift are adapted from commit 1b62341c1d62308962fb3656192d6783631aa00c of GlobalRefresh-PiP. The application-specific lifecycle, audio ownership and transparent content are -integrated for OpenLess. No claim is made that competitor application source was recovered. +integrated for OpenLess. diff --git a/openless-all/ios/Resources/Licenses/Rust/adler2-2.0.1-LICENSE-0BSD.txt b/openless-all/ios/Resources/Licenses/Rust/adler2-2.0.1-LICENSE-0BSD.txt deleted file mode 100644 index 89336aaca..000000000 --- a/openless-all/ios/Resources/Licenses/Rust/adler2-2.0.1-LICENSE-0BSD.txt +++ /dev/null @@ -1,12 +0,0 @@ -Copyright (C) Jonas Schievink - -Permission to use, copy, modify, and/or distribute this software for -any purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN -AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT -OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/aead-0.5.2-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/aead-0.6.1-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/aead-0.5.2-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/aead-0.6.1-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/aead-0.5.2-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/aead-0.6.1-LICENSE-MIT.txt similarity index 94% rename from openless-all/ios/Resources/Licenses/Rust/aead-0.5.2-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/aead-0.6.1-LICENSE-MIT.txt index 88e64ca7b..4a68199a6 100644 --- a/openless-all/ios/Resources/Licenses/Rust/aead-0.5.2-LICENSE-MIT.txt +++ b/openless-all/ios/Resources/Licenses/Rust/aead-0.6.1-LICENSE-MIT.txt @@ -1,4 +1,4 @@ -Copyright (c) 2019 The RustCrypto Project Developers +Copyright (c) 2019-2026 The RustCrypto Project Developers Copyright (c) 2019 MobileCoin, LLC Permission is hereby granted, free of charge, to any diff --git a/openless-all/ios/Resources/Licenses/Rust/aes-0.8.4-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/aes-0.9.3-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/aes-0.8.4-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/aes-0.9.3-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/opaque-debug-0.3.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/aes-0.9.3-LICENSE-MIT.txt similarity index 97% rename from openless-all/ios/Resources/Licenses/Rust/opaque-debug-0.3.1-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/aes-0.9.3-LICENSE-MIT.txt index 1aef8daab..a85fb15a3 100644 --- a/openless-all/ios/Resources/Licenses/Rust/opaque-debug-0.3.1-LICENSE-MIT.txt +++ b/openless-all/ios/Resources/Licenses/Rust/aes-0.9.3-LICENSE-MIT.txt @@ -1,4 +1,5 @@ Copyright (c) 2018-2024 The RustCrypto Project Developers +Copyright (c) 2018 Artyom Pavlov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated diff --git a/openless-all/ios/Resources/Licenses/Rust/anymap3-1.1.0-COPYING.txt b/openless-all/ios/Resources/Licenses/Rust/anymap3-1.1.0-COPYING.txt new file mode 100644 index 000000000..d8060a4e2 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/anymap3-1.1.0-COPYING.txt @@ -0,0 +1,17 @@ +Copyright © 2014–2022 Chris Morgan + +This project is distributed under the terms of three different licenses, +at your choice: + +- Blue Oak Model License 1.0.0: https://blueoakcouncil.org/license/1.0.0 +- MIT License: https://opensource.org/licenses/MIT +- Apache License, Version 2.0: https://www.apache.org/licenses/LICENSE-2.0 + +If you do not have particular cause to select the MIT or the Apache-2.0 +license, Chris Morgan recommends that you select BlueOak-1.0.0, which is +better and simpler than both MIT and Apache-2.0, which are only offered +due to their greater recognition and their conventional use in the Rust +ecosystem. (BlueOak-1.0.0 was only published in March 2019.) + +When using this code, ensure you comply with the terms of at least one of +these licenses. diff --git a/openless-all/ios/Resources/Licenses/Rust/argon2-0.5.3-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/argon2-0.6.0-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/argon2-0.5.3-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/argon2-0.6.0-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/bzip2-sys-0.1.13+1.0.8-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/argon2-0.6.0-LICENSE-MIT.txt similarity index 94% rename from openless-all/ios/Resources/Licenses/Rust/bzip2-sys-0.1.13+1.0.8-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/argon2-0.6.0-LICENSE-MIT.txt index d9c0ff4e7..11d191686 100644 --- a/openless-all/ios/Resources/Licenses/Rust/bzip2-sys-0.1.13+1.0.8-LICENSE-MIT.txt +++ b/openless-all/ios/Resources/Licenses/Rust/argon2-0.6.0-LICENSE-MIT.txt @@ -1,4 +1,4 @@ -Copyright (c) 2014-2025 Alex Crichton and Contributors +Copyright (c) 2021-2026 The RustCrypto Project Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated diff --git a/openless-all/ios/Resources/Licenses/Rust/array-init-2.1.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/array-init-2.1.0-LICENSE-APACHE.txt new file mode 100644 index 000000000..c1179aef4 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/array-init-2.1.0-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2017-2020 The array-init developers + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/array-init-2.1.0-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/array-init-2.1.0-LICENSE-MIT.txt new file mode 100644 index 000000000..6c7d88bab --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/array-init-2.1.0-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2017-2020 The array-init developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/blake2-0.10.6-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/base16ct-0.2.0-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/blake2-0.10.6-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/base16ct-0.2.0-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/base16ct-0.2.0-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/base16ct-0.2.0-LICENSE-MIT.txt new file mode 100644 index 000000000..f151c9982 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/base16ct-0.2.0-LICENSE-MIT.txt @@ -0,0 +1,26 @@ +Copyright (c) 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com) +Copyright (c) 2022 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/bzip2-0.4.4-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/base64-0.23.1-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/bzip2-0.4.4-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/base64-0.23.1-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/base64-0.23.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/base64-0.23.1-LICENSE-MIT.txt new file mode 100644 index 000000000..5864d3814 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/base64-0.23.1-LICENSE-MIT.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2025 Alice Maz, Marshall Pierce + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/chacha20-0.9.1-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/blake2-0.11.0-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/chacha20-0.9.1-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/blake2-0.11.0-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/blake2-0.10.6-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/blake2-0.11.0-LICENSE-MIT.txt similarity index 95% rename from openless-all/ios/Resources/Licenses/Rust/blake2-0.10.6-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/blake2-0.11.0-LICENSE-MIT.txt index 872e206f2..bf966c48c 100644 --- a/openless-all/ios/Resources/Licenses/Rust/blake2-0.10.6-LICENSE-MIT.txt +++ b/openless-all/ios/Resources/Licenses/Rust/blake2-0.11.0-LICENSE-MIT.txt @@ -1,5 +1,6 @@ -Copyright (c) 2015-2016 The blake2-rfc Developers, Cesar Barros +Copyright (c) 2017-2026 The RustCrypto Project Developers Copyright (c) 2017 Artyom Pavlov +Copyright (c) 2015-2016 The blake2-rfc Developers, Cesar Barros Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated diff --git a/openless-all/ios/Resources/Licenses/Rust/chacha20poly1305-0.10.1-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/block-buffer-0.12.1-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/chacha20poly1305-0.10.1-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/block-buffer-0.12.1-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/argon2-0.5.3-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/block-buffer-0.12.1-LICENSE-MIT.txt similarity index 94% rename from openless-all/ios/Resources/Licenses/Rust/argon2-0.5.3-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/block-buffer-0.12.1-LICENSE-MIT.txt index 6f139a79c..ecb2ee5c5 100644 --- a/openless-all/ios/Resources/Licenses/Rust/argon2-0.5.3-LICENSE-MIT.txt +++ b/openless-all/ios/Resources/Licenses/Rust/block-buffer-0.12.1-LICENSE-MIT.txt @@ -1,4 +1,4 @@ -Copyright (c) 2021-2024 The RustCrypto Project Developers +Copyright (c) 2018-2025 The RustCrypto Project Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated diff --git a/openless-all/ios/Resources/Licenses/Rust/byteorder-1.5.0-COPYING.txt b/openless-all/ios/Resources/Licenses/Rust/byteorder-1.5.0-COPYING.txt deleted file mode 100644 index bb9c20a09..000000000 --- a/openless-all/ios/Resources/Licenses/Rust/byteorder-1.5.0-COPYING.txt +++ /dev/null @@ -1,3 +0,0 @@ -This project is dual-licensed under the Unlicense and MIT licenses. - -You may use this code under the terms of either license. diff --git a/openless-all/ios/Resources/Licenses/Rust/byteorder-1.5.0-UNLICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/byteorder-1.5.0-UNLICENSE.txt deleted file mode 100644 index 68a49daad..000000000 --- a/openless-all/ios/Resources/Licenses/Rust/byteorder-1.5.0-UNLICENSE.txt +++ /dev/null @@ -1,24 +0,0 @@ -This is free and unencumbered software released into the public domain. - -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. - -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. - -For more information, please refer to diff --git a/openless-all/ios/Resources/Licenses/Rust/bzip2-0.5.2-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/bzip2-0.6.1-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/bzip2-0.5.2-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/bzip2-0.6.1-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/bzip2-0.5.2-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/bzip2-0.6.1-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/bzip2-0.5.2-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/bzip2-0.6.1-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/bzip2-sys-0.1.13+1.0.8-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/cc-1.9.0-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/bzip2-sys-0.1.13+1.0.8-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/cc-1.9.0-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/bzip2-0.4.4-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/cc-1.9.0-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/bzip2-0.4.4-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/cc-1.9.0-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/cc-1.4.5-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/cfg-if-1.0.5-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/cc-1.4.5-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/cfg-if-1.0.5-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/cc-1.4.5-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/cfg-if-1.0.5-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/cc-1.4.5-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/cfg-if-1.0.5-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/cipher-0.4.4-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/chacha20poly1305-0.11.0-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/cipher-0.4.4-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/chacha20poly1305-0.11.0-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/chacha20-0.9.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/chacha20poly1305-0.11.0-LICENSE-MIT.txt similarity index 94% rename from openless-all/ios/Resources/Licenses/Rust/chacha20-0.9.1-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/chacha20poly1305-0.11.0-LICENSE-MIT.txt index 2889a9a80..f9d910e2f 100644 --- a/openless-all/ios/Resources/Licenses/Rust/chacha20-0.9.1-LICENSE-MIT.txt +++ b/openless-all/ios/Resources/Licenses/Rust/chacha20poly1305-0.11.0-LICENSE-MIT.txt @@ -1,4 +1,4 @@ -Copyright (c) 2019-2023 The RustCrypto Project Developers +Copyright (c) 2019-2026 The RustCrypto Project Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated diff --git a/openless-all/ios/Resources/Licenses/Rust/inout-0.1.4-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/cipher-0.5.2-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/inout-0.1.4-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/cipher-0.5.2-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/cipher-0.4.4-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/cipher-0.5.2-LICENSE-MIT.txt similarity index 95% rename from openless-all/ios/Resources/Licenses/Rust/cipher-0.4.4-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/cipher-0.5.2-LICENSE-MIT.txt index f3a832c3b..82812ae61 100644 --- a/openless-all/ios/Resources/Licenses/Rust/cipher-0.4.4-LICENSE-MIT.txt +++ b/openless-all/ios/Resources/Licenses/Rust/cipher-0.5.2-LICENSE-MIT.txt @@ -1,4 +1,4 @@ -Copyright (c) 2016-2020 RustCrypto Developers +Copyright (c) 2016-2025 RustCrypto Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated diff --git a/openless-all/ios/Resources/Licenses/Rust/lru-slab-0.1.2-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/cmov-0.5.4-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/lru-slab-0.1.2-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/cmov-0.5.4-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/cmov-0.5.4-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/cmov-0.5.4-LICENSE-MIT.txt new file mode 100644 index 000000000..5b37ba5c5 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/cmov-0.5.4-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2022-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/md-5-0.10.6-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/const-oid-0.10.2-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/md-5-0.10.6-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/const-oid-0.10.2-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/const-oid-0.10.2-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/const-oid-0.10.2-LICENSE-MIT.txt new file mode 100644 index 000000000..b135e18de --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/const-oid-0.10.2-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2020-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/opaque-debug-0.3.1-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/const-oid-0.9.6-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/opaque-debug-0.3.1-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/const-oid-0.9.6-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/const-oid-0.9.6-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/const-oid-0.9.6-LICENSE-MIT.txt new file mode 100644 index 000000000..1b7880915 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/const-oid-0.9.6-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2020-2022 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/constant_time_eq-0.3.1-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/constant_time_eq-0.6.1-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/constant_time_eq-0.3.1-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/constant_time_eq-0.6.1-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/constant_time_eq-0.3.1-LICENSE-CC0.txt b/openless-all/ios/Resources/Licenses/Rust/constant_time_eq-0.6.1-LICENSE-CC0.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/constant_time_eq-0.3.1-LICENSE-CC0.txt rename to openless-all/ios/Resources/Licenses/Rust/constant_time_eq-0.6.1-LICENSE-CC0.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/constant_time_eq-0.3.1-LICENSE-MIT0.txt b/openless-all/ios/Resources/Licenses/Rust/constant_time_eq-0.6.1-LICENSE-MIT0.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/constant_time_eq-0.3.1-LICENSE-MIT0.txt rename to openless-all/ios/Resources/Licenses/Rust/constant_time_eq-0.6.1-LICENSE-MIT0.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/password-hash-0.5.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/cpubits-0.1.1-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/password-hash-0.5.0-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/cpubits-0.1.1-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/cpubits-0.1.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/cpubits-0.1.1-LICENSE-MIT.txt new file mode 100644 index 000000000..d49c41b52 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/cpubits-0.1.1-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2023-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/pbkdf2-0.12.2-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/cpufeatures-0.3.1-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/pbkdf2-0.12.2-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/cpufeatures-0.3.1-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/cpufeatures-0.3.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/cpufeatures-0.3.1-LICENSE-MIT.txt new file mode 100644 index 000000000..b135e18de --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/cpufeatures-0.3.1-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2020-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/crc32fast-1.5.1-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/crc32fast-1.5.2-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/crc32fast-1.5.1-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/crc32fast-1.5.2-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/crc32fast-1.5.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/crc32fast-1.5.2-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/crc32fast-1.5.1-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/crc32fast-1.5.2-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/poly1305-0.8.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/crypto-bigint-0.5.5-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/poly1305-0.8.0-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/crypto-bigint-0.5.5-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/chacha20poly1305-0.10.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/crypto-bigint-0.5.5-LICENSE-MIT.txt similarity index 95% rename from openless-all/ios/Resources/Licenses/Rust/chacha20poly1305-0.10.1-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/crypto-bigint-0.5.5-LICENSE-MIT.txt index b7f571161..c869ada57 100644 --- a/openless-all/ios/Resources/Licenses/Rust/chacha20poly1305-0.10.1-LICENSE-MIT.txt +++ b/openless-all/ios/Resources/Licenses/Rust/crypto-bigint-0.5.5-LICENSE-MIT.txt @@ -1,4 +1,4 @@ -Copyright (c) 2019 The RustCrypto Project Developers +Copyright (c) 2021 The RustCrypto Project Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated diff --git a/openless-all/ios/Resources/Licenses/Rust/sha1-0.10.7-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/crypto-common-0.2.2-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/sha1-0.10.7-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/crypto-common-0.2.2-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/aes-0.8.4-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/crypto-common-0.2.2-LICENSE-MIT.txt similarity index 95% rename from openless-all/ios/Resources/Licenses/Rust/aes-0.8.4-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/crypto-common-0.2.2-LICENSE-MIT.txt index f5b157a6c..4296756c6 100644 --- a/openless-all/ios/Resources/Licenses/Rust/aes-0.8.4-LICENSE-MIT.txt +++ b/openless-all/ios/Resources/Licenses/Rust/crypto-common-0.2.2-LICENSE-MIT.txt @@ -1,4 +1,4 @@ -Copyright (c) 2018 Artyom Pavlov +Copyright (c) 2021-2026 RustCrypto Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated diff --git a/openless-all/ios/Resources/Licenses/Rust/tinyvec-1.13.2-LICENSE-APACHE.md.txt b/openless-all/ios/Resources/Licenses/Rust/ctutils-0.4.3-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/tinyvec-1.13.2-LICENSE-APACHE.md.txt rename to openless-all/ios/Resources/Licenses/Rust/ctutils-0.4.3-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/ctutils-0.4.3-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/ctutils-0.4.3-LICENSE-MIT.txt new file mode 100644 index 000000000..232969482 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/ctutils-0.4.3-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2025-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/curve25519-dalek-4.1.3-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/curve25519-dalek-4.1.3-LICENSE.txt new file mode 100644 index 000000000..ff3475745 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/curve25519-dalek-4.1.3-LICENSE.txt @@ -0,0 +1,65 @@ +Copyright (c) 2016-2021 isis agora lovecruft. All rights reserved. +Copyright (c) 2016-2021 Henry de Valence. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +======================================================================== + +Portions of curve25519-dalek were originally derived from Adam Langley's +Go ed25519 implementation, found at , +under the following licence: + +======================================================================== + +Copyright (c) 2012 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER +OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/openless-all/ios/Resources/Licenses/Rust/universal-hash-0.5.1-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/der-0.7.10-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/universal-hash-0.5.1-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/der-0.7.10-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/der-0.7.10-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/der-0.7.10-LICENSE-MIT.txt new file mode 100644 index 000000000..e0d082780 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/der-0.7.10-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2020-2023 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/deranged-0.5.8-LICENSE-Apache.txt b/openless-all/ios/Resources/Licenses/Rust/deranged-0.5.9-LICENSE-Apache.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/deranged-0.5.8-LICENSE-Apache.txt rename to openless-all/ios/Resources/Licenses/Rust/deranged-0.5.9-LICENSE-Apache.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/deranged-0.5.8-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/deranged-0.5.9-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/deranged-0.5.8-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/deranged-0.5.9-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/getrandom-0.3.4-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/digest-0.11.3-LICENSE-APACHE.txt similarity index 99% rename from openless-all/ios/Resources/Licenses/Rust/getrandom-0.3.4-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/digest-0.11.3-LICENSE-APACHE.txt index 17d74680f..78173fa2e 100644 --- a/openless-all/ios/Resources/Licenses/Rust/getrandom-0.3.4-LICENSE-APACHE.txt +++ b/openless-all/ios/Resources/Licenses/Rust/digest-0.11.3-LICENSE-APACHE.txt @@ -1,6 +1,6 @@ Apache License Version 2.0, January 2004 - https://www.apache.org/licenses/ + http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION @@ -192,7 +192,7 @@ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - https://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, diff --git a/openless-all/ios/Resources/Licenses/Rust/digest-0.11.3-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/digest-0.11.3-LICENSE-MIT.txt new file mode 100644 index 000000000..37f14fe5e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/digest-0.11.3-LICENSE-MIT.txt @@ -0,0 +1,26 @@ +Copyright (c) 2017-2025 RustCrypto Developers +Copyright (c) 2017 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/easyfft-0.4.2-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/easyfft-0.4.2-LICENSE-APACHE.txt new file mode 100644 index 000000000..8f71f43fe --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/easyfft-0.4.2-LICENSE-APACHE.txt @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/openless-all/ios/Resources/Licenses/Rust/easyfft-0.4.2-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/easyfft-0.4.2-LICENSE-MIT.txt new file mode 100644 index 000000000..75dbd461c --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/easyfft-0.4.2-LICENSE-MIT.txt @@ -0,0 +1,19 @@ +Copyright (c) 2015 The RustFFT Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/ecdsa-0.16.9-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/ecdsa-0.16.9-LICENSE-APACHE.txt new file mode 100644 index 000000000..c394d8ad9 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/ecdsa-0.16.9-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2018-2022 RustCrypto Developers + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/ecdsa-0.16.9-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/ecdsa-0.16.9-LICENSE-MIT.txt new file mode 100644 index 000000000..81a3d57ac --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/ecdsa-0.16.9-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2018-2022 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/ed25519-2.2.3-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/ed25519-2.2.3-LICENSE-APACHE.txt new file mode 100644 index 000000000..c394d8ad9 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/ed25519-2.2.3-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2018-2022 RustCrypto Developers + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/ed25519-2.2.3-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/ed25519-2.2.3-LICENSE-MIT.txt new file mode 100644 index 000000000..d8d87fe29 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/ed25519-2.2.3-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2018-2023 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/ed25519-dalek-2.2.0-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/ed25519-dalek-2.2.0-LICENSE.txt new file mode 100644 index 000000000..acf8498e2 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/ed25519-dalek-2.2.0-LICENSE.txt @@ -0,0 +1,28 @@ +Copyright (c) 2017-2019 isis agora lovecruft. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/openless-all/ios/Resources/Licenses/Rust/elliptic-curve-0.13.8-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/elliptic-curve-0.13.8-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/elliptic-curve-0.13.8-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/elliptic-curve-0.13.8-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/elliptic-curve-0.13.8-LICENSE-MIT.txt new file mode 100644 index 000000000..d4ce06527 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/elliptic-curve-0.13.8-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2020-2022 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/cfg-if-1.0.4-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/equivalent-1.0.3-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/cfg-if-1.0.4-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/equivalent-1.0.3-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/equivalent-1.0.2-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/equivalent-1.0.3-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/equivalent-1.0.2-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/equivalent-1.0.3-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/ferrous-opencc-0.4.0-UPSTREAM-NOTICE.txt b/openless-all/ios/Resources/Licenses/Rust/ferrous-opencc-0.4.0-UPSTREAM-NOTICE.txt new file mode 100644 index 000000000..9695bfa65 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/ferrous-opencc-0.4.0-UPSTREAM-NOTICE.txt @@ -0,0 +1,82 @@ +Package: ferrous-opencc 0.4.0 +Declared license: Apache-2.0 +Authors: apoint123 +Repository: https://github.com/apoint123/ferrous-opencc +Source revision: bd557afbf2fd07f0bc55371159d7006629dfd935 + +The upstream crate declares this license without bundling a standalone license file. +Standard license text: SPDX license-list-data v3.27.0. + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/ferrous-opencc-compiler-0.4.0-UPSTREAM-NOTICE.txt b/openless-all/ios/Resources/Licenses/Rust/ferrous-opencc-compiler-0.4.0-UPSTREAM-NOTICE.txt new file mode 100644 index 000000000..faeb3c2ae --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/ferrous-opencc-compiler-0.4.0-UPSTREAM-NOTICE.txt @@ -0,0 +1,82 @@ +Package: ferrous-opencc-compiler 0.4.0 +Declared license: Apache-2.0 +Authors: +Repository: Not supplied by upstream +Source revision: bd557afbf2fd07f0bc55371159d7006629dfd935 + +The upstream crate declares this license without bundling a standalone license file. +Standard license text: SPDX license-list-data v3.27.0. + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/adler2-2.0.1-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/ff-0.13.1-LICENSE-APACHE.txt similarity index 98% rename from openless-all/ios/Resources/Licenses/Rust/adler2-2.0.1-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/ff-0.13.1-LICENSE-APACHE.txt index c98d27d4f..1e5006dc1 100644 --- a/openless-all/ios/Resources/Licenses/Rust/adler2-2.0.1-LICENSE-APACHE.txt +++ b/openless-all/ios/Resources/Licenses/Rust/ff-0.13.1-LICENSE-APACHE.txt @@ -1,6 +1,6 @@ Apache License Version 2.0, January 2004 - https://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION @@ -192,10 +192,11 @@ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - https://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + diff --git a/openless-all/ios/Resources/Licenses/Rust/byteorder-1.5.0-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/ff-0.13.1-LICENSE-MIT.txt similarity index 96% rename from openless-all/ios/Resources/Licenses/Rust/byteorder-1.5.0-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/ff-0.13.1-LICENSE-MIT.txt index 3b0a5dc09..ed3a13fdd 100644 --- a/openless-all/ios/Resources/Licenses/Rust/byteorder-1.5.0-LICENSE-MIT.txt +++ b/openless-all/ios/Resources/Licenses/Rust/ff-0.13.1-LICENSE-MIT.txt @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2015 Andrew Gallant +Copyright (c) 2017 Sean Bowe Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/openless-all/ios/Resources/Licenses/Rust/equivalent-1.0.2-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/find-msvc-tools-0.1.15-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/equivalent-1.0.2-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/find-msvc-tools-0.1.15-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/cfg-if-1.0.4-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/find-msvc-tools-0.1.15-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/cfg-if-1.0.4-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/find-msvc-tools-0.1.15-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/generic_singleton-0.5.3-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/generic_singleton-0.5.3-LICENSE-APACHE.txt new file mode 100644 index 000000000..8f71f43fe --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/generic_singleton-0.5.3-LICENSE-APACHE.txt @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/openless-all/ios/Resources/Licenses/Rust/generic_singleton-0.5.3-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/generic_singleton-0.5.3-LICENSE-MIT.txt new file mode 100644 index 000000000..75dbd461c --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/generic_singleton-0.5.3-LICENSE-MIT.txt @@ -0,0 +1,19 @@ +Copyright (c) 2015 The RustFFT Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/getrandom-0.3.4-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/getrandom-0.3.4-LICENSE-MIT.txt deleted file mode 100644 index e54440a9e..000000000 --- a/openless-all/ios/Resources/Licenses/Rust/getrandom-0.3.4-LICENSE-MIT.txt +++ /dev/null @@ -1,26 +0,0 @@ -Copyright (c) 2018-2025 The rust-random Project Developers -Copyright (c) 2014 The Rust Project Developers - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/find-msvc-tools-0.1.12-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/group-0.13.0-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/find-msvc-tools-0.1.12-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/group-0.13.0-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/adler2-2.0.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/group-0.13.0-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/adler2-2.0.1-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/group-0.13.0-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/hex-0.4.3-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/hex-0.4.3-LICENSE-APACHE.txt new file mode 100644 index 000000000..8f71f43fe --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/hex-0.4.3-LICENSE-APACHE.txt @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/openless-all/ios/Resources/Licenses/Rust/miniz_oxide-0.9.1-LICENSE-MIT.md.txt b/openless-all/ios/Resources/Licenses/Rust/hex-0.4.3-LICENSE-MIT.txt similarity index 84% rename from openless-all/ios/Resources/Licenses/Rust/miniz_oxide-0.9.1-LICENSE-MIT.md.txt rename to openless-all/ios/Resources/Licenses/Rust/hex-0.4.3-LICENSE-MIT.txt index ec71b31cc..a7b461f97 100644 --- a/openless-all/ios/Resources/Licenses/Rust/miniz_oxide-0.9.1-LICENSE-MIT.md.txt +++ b/openless-all/ios/Resources/Licenses/Rust/hex-0.4.3-LICENSE-MIT.txt @@ -1,9 +1,5 @@ -MIT License - -Copyright 2013-2014 RAD Game Tools and Valve Software -Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC -Copyright (c) 2017 Frommi -Copyright (c) 2017-2024 oyvindln +Copyright (c) 2013-2014 The Rust Project Developers. +Copyright (c) 2015-2020 The rust-hex Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/openless-all/ios/Resources/Licenses/Rust/hkdf-0.12.4-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/hkdf-0.12.4-LICENSE-APACHE.txt new file mode 100644 index 000000000..53b7ccd84 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/hkdf-0.12.4-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/openless-all/ios/Resources/Licenses/Rust/hkdf-0.12.4-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/hkdf-0.12.4-LICENSE-MIT.txt new file mode 100644 index 000000000..c0d07816b --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/hkdf-0.12.4-LICENSE-MIT.txt @@ -0,0 +1,26 @@ +Copyright (c) 2015-2018 Vlad Filippov +Copyright (c) 2018-2021 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/hmac-0.13.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/hmac-0.13.0-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/hmac-0.13.0-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/lzma-sys-0.1.20-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/hmac-0.13.0-LICENSE-MIT.txt similarity index 96% rename from openless-all/ios/Resources/Licenses/Rust/lzma-sys-0.1.20-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/hmac-0.13.0-LICENSE-MIT.txt index 28e630cf4..8dcb85b30 100644 --- a/openless-all/ios/Resources/Licenses/Rust/lzma-sys-0.1.20-LICENSE-MIT.txt +++ b/openless-all/ios/Resources/Licenses/Rust/hmac-0.13.0-LICENSE-MIT.txt @@ -1,4 +1,4 @@ -Copyright (c) 2016 Alex Crichton +Copyright (c) 2017 Artyom Pavlov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated diff --git a/openless-all/ios/Resources/Licenses/Rust/hybrid-array-0.4.15-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/hybrid-array-0.4.15-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/hybrid-array-0.4.15-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/hybrid-array-0.4.15-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/hybrid-array-0.4.15-LICENSE-MIT.txt new file mode 100644 index 000000000..5b37ba5c5 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/hybrid-array-0.4.15-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2022-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/hyper-1.11.1-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/hyper-1.12.0-LICENSE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/hyper-1.11.1-LICENSE.txt rename to openless-all/ios/Resources/Licenses/Rust/hyper-1.12.0-LICENSE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/hyper-rustls-0.27.9-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/hyper-rustls-0.27.10-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/hyper-rustls-0.27.9-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/hyper-rustls-0.27.10-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/hyper-rustls-0.27.9-LICENSE-ISC.txt b/openless-all/ios/Resources/Licenses/Rust/hyper-rustls-0.27.10-LICENSE-ISC.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/hyper-rustls-0.27.9-LICENSE-ISC.txt rename to openless-all/ios/Resources/Licenses/Rust/hyper-rustls-0.27.10-LICENSE-ISC.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/hyper-rustls-0.27.9-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/hyper-rustls-0.27.10-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/hyper-rustls-0.27.9-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/hyper-rustls-0.27.10-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/hyper-util-0.1.20-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/hyper-util-0.1.21-LICENSE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/hyper-util-0.1.20-LICENSE.txt rename to openless-all/ios/Resources/Licenses/Rust/hyper-util-0.1.21-LICENSE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/inout-0.2.5-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/inout-0.2.5-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/inout-0.2.5-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/inout-0.1.4-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/inout-0.2.5-LICENSE-MIT.txt similarity index 94% rename from openless-all/ios/Resources/Licenses/Rust/inout-0.1.4-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/inout-0.2.5-LICENSE-MIT.txt index 26af94064..126fdd7bf 100644 --- a/openless-all/ios/Resources/Licenses/Rust/inout-0.1.4-LICENSE-MIT.txt +++ b/openless-all/ios/Resources/Licenses/Rust/inout-0.2.5-LICENSE-MIT.txt @@ -1,4 +1,4 @@ -Copyright (c) 2022 The RustCrypto Project Developers +Copyright (c) 2022-2025 The RustCrypto Project Developers Copyright (c) 2022 Artyom Pavlov Permission is hereby granted, free of charge, to any diff --git a/openless-all/ios/Resources/Licenses/Rust/lzma-rs-0.3.0-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/jsonwebtoken-11.1.0-LICENSE.txt similarity index 94% rename from openless-all/ios/Resources/Licenses/Rust/lzma-rs-0.3.0-LICENSE.txt rename to openless-all/ios/Resources/Licenses/Rust/jsonwebtoken-11.1.0-LICENSE.txt index ccf054e27..36272c3f2 100644 --- a/openless-all/ios/Resources/Licenses/Rust/lzma-rs-0.3.0-LICENSE.txt +++ b/openless-all/ios/Resources/Licenses/Rust/jsonwebtoken-11.1.0-LICENSE.txt @@ -1,6 +1,6 @@ -MIT License +The MIT License (MIT) -Copyright (c) 2017 - 2018 Guillaume Endignoux +Copyright (c) 2015 Vincent Prouillet Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/openless-all/ios/Resources/Licenses/Rust/lzma-sys-0.1.20-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/lazy_static-1.5.1-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/lzma-sys-0.1.20-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/lazy_static-1.5.1-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/rustix-1.1.4-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/lazy_static-1.5.1-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/rustix-1.1.4-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/lazy_static-1.5.1-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/libbz2-rs-sys-0.2.5-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/libbz2-rs-sys-0.2.5-LICENSE.txt new file mode 100644 index 000000000..f5e2b1d39 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/libbz2-rs-sys-0.2.5-LICENSE.txt @@ -0,0 +1,48 @@ + +-------------------------------------------------------------------------- + +The original program, "bzip2", the associated library "libbzip2", and all +documentation, are + +Copyright (C) 1996-2021 Julian R Seward. +Copyright (C) 2019-2020 Federico Mena Quintero +Copyright (C) 2021 Micah Snyder + +This Rust translation, "libbzip2-rs" is a derived work based on "bzip2" and +"libbzip2", and is Copyright (C) 2024-2025 Trifecta Tech Foundation and contributors + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. The origin of this software must not be misrepresented; you must + not claim that you wrote the original software. If you use this + software in a product, an acknowledgment in the product + documentation would be appreciated but is not required. + +3. Altered source versions must be plainly marked as such, and must + not be misrepresented as being the original software. + +4. The name of the author may not be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Julian Seward, jseward@acm.org +bzip2/libbzip2 version 1.1.0 of 6 September 2010 + +-------------------------------------------------------------------------- diff --git a/openless-all/ios/Resources/Licenses/Rust/libc-0.2.189-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/libc-0.2.191-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/libc-0.2.189-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/libc-0.2.191-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/libc-0.2.189-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/libc-0.2.191-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/libc-0.2.189-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/libc-0.2.191-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/libm-0.2.16-LICENSE.txt.txt b/openless-all/ios/Resources/Licenses/Rust/libm-0.2.16-LICENSE.txt.txt new file mode 100644 index 000000000..2f8e41f14 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/libm-0.2.16-LICENSE.txt.txt @@ -0,0 +1,258 @@ +rust-lang/libm as a whole is available for use under the MIT license: + +------------------------------------------------------------------------------ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +------------------------------------------------------------------------------ + +As a contributor, you agree that your code can be used under either the MIT +license or the Apache-2.0 license: + +------------------------------------------------------------------------------ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +------------------------------------------------------------------------------ + +This Rust library contains the following copyrights: + + Copyright (c) 2018 Jorge Aparicio + +Portions of this software are derived from third-party works licensed under +terms compatible with the above MIT license: + +* musl libc https://www.musl-libc.org/. This library contains the following + copyright: + + Copyright © 2005-2020 Rich Felker, et al. + +* The CORE-MATH project https://core-math.gitlabpages.inria.fr/. CORE-MATH + routines are available under the MIT license on a per-file basis. + +The musl libc COPYRIGHT file also includes the following notice relevant to +math portions of the library: + +------------------------------------------------------------------------------ +Much of the math library code (src/math/* and src/complex/*) is +Copyright © 1993,2004 Sun Microsystems or +Copyright © 2003-2011 David Schultz or +Copyright © 2003-2009 Steven G. Kargl or +Copyright © 2003-2009 Bruce D. Evans or +Copyright © 2008 Stephen L. Moshier or +Copyright © 2017-2018 Arm Limited +and labelled as such in comments in the individual source files. All +have been licensed under extremely permissive terms. +------------------------------------------------------------------------------ + +Copyright notices are retained in src/* files where relevant. diff --git a/openless-all/ios/Resources/Licenses/Rust/zeroize-1.9.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/lru-slab-0.1.3-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/zeroize-1.9.0-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/lru-slab-0.1.3-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/lru-slab-0.1.2-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/lru-slab-0.1.3-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/lru-slab-0.1.2-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/lru-slab-0.1.3-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/lru-slab-0.1.2-LICENSE-ZLIB.txt b/openless-all/ios/Resources/Licenses/Rust/lru-slab-0.1.3-LICENSE-ZLIB.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/lru-slab-0.1.2-LICENSE-ZLIB.txt rename to openless-all/ios/Resources/Licenses/Rust/lru-slab-0.1.3-LICENSE-ZLIB.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/miniz_oxide-0.9.1-LICENSE-APACHE.md.txt b/openless-all/ios/Resources/Licenses/Rust/lzma-rust2-0.22.0-LICENSE.txt similarity index 89% rename from openless-all/ios/Resources/Licenses/Rust/miniz_oxide-0.9.1-LICENSE-APACHE.md.txt rename to openless-all/ios/Resources/Licenses/Rust/lzma-rust2-0.22.0-LICENSE.txt index f433b1a53..f49a4e16e 100644 --- a/openless-all/ios/Resources/Licenses/Rust/miniz_oxide-0.9.1-LICENSE-APACHE.md.txt +++ b/openless-all/ios/Resources/Licenses/Rust/lzma-rust2-0.22.0-LICENSE.txt @@ -1,4 +1,3 @@ - Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -175,3 +174,28 @@ of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/openless-all/ios/Resources/Licenses/Rust/manifest.json b/openless-all/ios/Resources/Licenses/Rust/manifest.json index 8fd088875..dd7be3ccf 100644 --- a/openless-all/ios/Resources/Licenses/Rust/manifest.json +++ b/openless-all/ios/Resources/Licenses/Rust/manifest.json @@ -1,53 +1,33 @@ [ - { - "name": "adler2", - "version": "2.0.1", - "license": "0BSD OR MIT OR Apache-2.0", - "repository": "https://github.com/oyvindln/adler2", - "files": [ - { - "file": "adler2-2.0.1-LICENSE-0BSD.txt", - "sha256": "861399f8c21c042b110517e76dc6b63a2b334276c8cf17412fc3c8908ca8dc17" - }, - { - "file": "adler2-2.0.1-LICENSE-APACHE.txt", - "sha256": "8ada45cd9f843acf64e4722ae262c622a2b3b3007c7310ef36ac1061a30f6adb" - }, - { - "file": "adler2-2.0.1-LICENSE-MIT.txt", - "sha256": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" - } - ] - }, { "name": "aead", - "version": "0.5.2", + "version": "0.6.1", "license": "MIT OR Apache-2.0", "repository": "https://github.com/RustCrypto/traits", "files": [ { - "file": "aead-0.5.2-LICENSE-APACHE.txt", + "file": "aead-0.6.1-LICENSE-APACHE.txt", "sha256": "b1cf9a3333ca78152b859012cd4a804156e5243e9ca20ad1df7327ba5ea7405c" }, { - "file": "aead-0.5.2-LICENSE-MIT.txt", - "sha256": "949ab7b3e7140fee216f06fe3a2bd67d68dd511f8ea73c1e01c98cb348e9c8ae" + "file": "aead-0.6.1-LICENSE-MIT.txt", + "sha256": "33b32a251d445c5c03a634e64c53314c55540fc367fabdd45d9b6c8f260c028c" } ] }, { "name": "aes", - "version": "0.8.4", + "version": "0.9.3", "license": "MIT OR Apache-2.0", "repository": "https://github.com/RustCrypto/block-ciphers", "files": [ { - "file": "aes-0.8.4-LICENSE-APACHE.txt", + "file": "aes-0.9.3-LICENSE-APACHE.txt", "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" }, { - "file": "aes-0.8.4-LICENSE-MIT.txt", - "sha256": "f7e8ab639afef15573680c97f796166835cbeb3865175882fea41c60d106b733" + "file": "aes-0.9.3-LICENSE-MIT.txt", + "sha256": "25f77efa393854f7c15eaf6a2589a2071db2d7337642742763adb12bdecf0445" } ] }, @@ -87,19 +67,47 @@ } ] }, + { + "name": "anymap3", + "version": "1.1.0", + "license": "BlueOak-1.0.0 OR MIT OR Apache-2.0", + "repository": "https://github.com/reivilibre/anymap3", + "files": [ + { + "file": "anymap3-1.1.0-COPYING.txt", + "sha256": "cce3eaac7d45535f0c5421e1a7330933e816265e766f4a94f20442de9da548df" + } + ] + }, { "name": "argon2", - "version": "0.5.3", + "version": "0.6.0", "license": "MIT OR Apache-2.0", - "repository": "https://github.com/RustCrypto/password-hashes/tree/master/argon2", + "repository": "https://github.com/RustCrypto/password-hashes", "files": [ { - "file": "argon2-0.5.3-LICENSE-APACHE.txt", + "file": "argon2-0.6.0-LICENSE-APACHE.txt", "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" }, { - "file": "argon2-0.5.3-LICENSE-MIT.txt", - "sha256": "33f702959c0ea91c08b21b65cf1f08b6c122ec9e6db0b5db784a7b367d942330" + "file": "argon2-0.6.0-LICENSE-MIT.txt", + "sha256": "57a4d75c1d095bb94754a478f3cd15e60ac66a5c5627d844be01a918eea5fe88" + } + ] + }, + { + "name": "array-init", + "version": "2.1.0", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/Manishearth/array-init/", + "files": [ + { + "file": "array-init-2.1.0-LICENSE-APACHE.txt", + "sha256": "c8d9a0d15dd76ca3bf277b6bf6da56799e266eac60bdc321a97ebc6d76d5153c" + }, + { + "file": "array-init-2.1.0-LICENSE-MIT.txt", + "sha256": "e27fb2953c088c71285a4f2f54a0ac53323460ee7c2b1b838d563bd2687a38af" } ] }, @@ -139,6 +147,22 @@ } ] }, + { + "name": "base16ct", + "version": "0.2.0", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/formats/tree/master/base16ct", + "files": [ + { + "file": "base16ct-0.2.0-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "base16ct-0.2.0-LICENSE-MIT.txt", + "sha256": "0aa8963e105e8b6e02f634484145d4e5b0f42d0a6dd05c16f8148f033383adef" + } + ] + }, { "name": "base64", "version": "0.22.1", @@ -155,6 +179,22 @@ } ] }, + { + "name": "base64", + "version": "0.23.1", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/marshallpierce/rust-base64", + "files": [ + { + "file": "base64-0.23.1-LICENSE-APACHE.txt", + "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" + }, + { + "file": "base64-0.23.1-LICENSE-MIT.txt", + "sha256": "ab499c75a0f0da8e0fe83bf9ba3a27e5c39705310f07107c6c66b69958d2401c" + } + ] + }, { "name": "base64ct", "version": "1.8.3", @@ -189,17 +229,17 @@ }, { "name": "blake2", - "version": "0.10.6", + "version": "0.11.0", "license": "MIT OR Apache-2.0", "repository": "https://github.com/RustCrypto/hashes", "files": [ { - "file": "blake2-0.10.6-LICENSE-APACHE.txt", + "file": "blake2-0.11.0-LICENSE-APACHE.txt", "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" }, { - "file": "blake2-0.10.6-LICENSE-MIT.txt", - "sha256": "9c768944eb4a0422ca2efc25ea9fb2fb9e7fbd3fdb04e86b87366339cb7466db" + "file": "blake2-0.11.0-LICENSE-MIT.txt", + "sha256": "3721e5764ca384059a3521d84c0fb40373894a099d8246fe76bf0d7418041846" } ] }, @@ -219,6 +259,22 @@ } ] }, + { + "name": "block-buffer", + "version": "0.12.1", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/RustCrypto/utils", + "files": [ + { + "file": "block-buffer-0.12.1-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "block-buffer-0.12.1-LICENSE-MIT.txt", + "sha256": "98181e7249d0c01737645ec982499ce99a0f07eb8f7d625b8840d799d10dbc01" + } + ] + }, { "name": "bumpalo", "version": "3.20.3", @@ -259,26 +315,6 @@ } ] }, - { - "name": "byteorder", - "version": "1.5.0", - "license": "Unlicense OR MIT", - "repository": "https://github.com/BurntSushi/byteorder", - "files": [ - { - "file": "byteorder-1.5.0-COPYING.txt", - "sha256": "01c266bced4a434da0051174d6bee16a4c82cf634e2679b6155d40d75012390f" - }, - { - "file": "byteorder-1.5.0-LICENSE-MIT.txt", - "sha256": "0f96a83840e146e43c0ec96a22ec1f392e0680e6c1226e6f3ba87e0740af850f" - }, - { - "file": "byteorder-1.5.0-UNLICENSE.txt", - "sha256": "7e12e5df4bae12cb21581ba157ced20e1986a0508dd10d0e8a4ab9a4cf94e85c" - } - ] - }, { "name": "bytes", "version": "1.12.1", @@ -293,80 +329,48 @@ }, { "name": "bzip2", - "version": "0.4.4", - "license": "MIT/Apache-2.0", - "repository": "https://github.com/alexcrichton/bzip2-rs", - "files": [ - { - "file": "bzip2-0.4.4-LICENSE-APACHE.txt", - "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" - }, - { - "file": "bzip2-0.4.4-LICENSE-MIT.txt", - "sha256": "378f5840b258e2779c39418f3f2d7b2ba96f1c7917dd6be0713f88305dbda397" - } - ] - }, - { - "name": "bzip2", - "version": "0.5.2", + "version": "0.6.1", "license": "MIT OR Apache-2.0", "repository": "https://github.com/trifectatechfoundation/bzip2-rs", "files": [ { - "file": "bzip2-0.5.2-LICENSE-APACHE.txt", + "file": "bzip2-0.6.1-LICENSE-APACHE.txt", "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" }, { - "file": "bzip2-0.5.2-LICENSE-MIT.txt", - "sha256": "c8c6324cf2f0f076e3ed5dea4a21f00bd4dd6fce44f72b110bc52e7fe06f6e1e" - } - ] - }, - { - "name": "bzip2-sys", - "version": "0.1.13+1.0.8", - "license": "MIT/Apache-2.0", - "repository": "https://github.com/alexcrichton/bzip2-rs", - "files": [ - { - "file": "bzip2-sys-0.1.13+1.0.8-LICENSE-APACHE.txt", - "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" - }, - { - "file": "bzip2-sys-0.1.13+1.0.8-LICENSE-MIT.txt", + "file": "bzip2-0.6.1-LICENSE-MIT.txt", "sha256": "c8c6324cf2f0f076e3ed5dea4a21f00bd4dd6fce44f72b110bc52e7fe06f6e1e" } ] }, { "name": "cc", - "version": "1.4.5", + "version": "1.9.0", "license": "MIT OR Apache-2.0", "repository": "https://github.com/rust-lang/cc-rs", "files": [ { - "file": "cc-1.4.5-LICENSE-APACHE.txt", + "file": "cc-1.9.0-LICENSE-APACHE.txt", "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" }, { - "file": "cc-1.4.5-LICENSE-MIT.txt", + "file": "cc-1.9.0-LICENSE-MIT.txt", "sha256": "378f5840b258e2779c39418f3f2d7b2ba96f1c7917dd6be0713f88305dbda397" } ] }, { "name": "cfg-if", - "version": "1.0.4", + "version": "1.0.5", "license": "MIT OR Apache-2.0", "repository": "https://github.com/rust-lang/cfg-if", "files": [ { - "file": "cfg-if-1.0.4-LICENSE-APACHE.txt", + "file": "cfg-if-1.0.5-LICENSE-APACHE.txt", "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" }, { - "file": "cfg-if-1.0.4-LICENSE-MIT.txt", + "file": "cfg-if-1.0.5-LICENSE-MIT.txt", "sha256": "378f5840b258e2779c39418f3f2d7b2ba96f1c7917dd6be0713f88305dbda397" } ] @@ -403,35 +407,19 @@ } ] }, - { - "name": "chacha20", - "version": "0.9.1", - "license": "Apache-2.0 OR MIT", - "repository": "https://github.com/RustCrypto/stream-ciphers", - "files": [ - { - "file": "chacha20-0.9.1-LICENSE-APACHE.txt", - "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" - }, - { - "file": "chacha20-0.9.1-LICENSE-MIT.txt", - "sha256": "b6f6f01b03ee7e93c0ae96d62ed39527e98a120fb951afc9de456a741ef3f204" - } - ] - }, { "name": "chacha20poly1305", - "version": "0.10.1", + "version": "0.11.0", "license": "Apache-2.0 OR MIT", - "repository": "https://github.com/RustCrypto/AEADs/tree/master/chacha20poly1305", + "repository": "https://github.com/RustCrypto/AEADs", "files": [ { - "file": "chacha20poly1305-0.10.1-LICENSE-APACHE.txt", + "file": "chacha20poly1305-0.11.0-LICENSE-APACHE.txt", "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" }, { - "file": "chacha20poly1305-0.10.1-LICENSE-MIT.txt", - "sha256": "3c0dfa33fd2e6976038555b52095699452653b1fcabe113074f14e0848a6b11e" + "file": "chacha20poly1305-0.11.0-LICENSE-MIT.txt", + "sha256": "b8c6939380a400f53e11923d50fcc4dd2fa1ba8339fd9d04cda38a0251b6c9b0" } ] }, @@ -449,36 +437,84 @@ }, { "name": "cipher", - "version": "0.4.4", + "version": "0.5.2", "license": "MIT OR Apache-2.0", "repository": "https://github.com/RustCrypto/traits", "files": [ { - "file": "cipher-0.4.4-LICENSE-APACHE.txt", + "file": "cipher-0.5.2-LICENSE-APACHE.txt", "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" }, { - "file": "cipher-0.4.4-LICENSE-MIT.txt", - "sha256": "5c7bd92d1f096f12203dc1b601e3cb17484fa1b023e30b6b8edcc80e416237ec" + "file": "cipher-0.5.2-LICENSE-MIT.txt", + "sha256": "950d712c518a02fcb7cff96950aee304a1cc5283361712c980ea21e8d6d669a5" + } + ] + }, + { + "name": "cmov", + "version": "0.5.4", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/utils", + "files": [ + { + "file": "cmov-0.5.4-LICENSE-APACHE.txt", + "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" + }, + { + "file": "cmov-0.5.4-LICENSE-MIT.txt", + "sha256": "70c9d40f1f9545c3f133b8a67206e89da850f6468eed072281bb3701514114a9" + } + ] + }, + { + "name": "const-oid", + "version": "0.10.2", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/formats", + "files": [ + { + "file": "const-oid-0.10.2-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "const-oid-0.10.2-LICENSE-MIT.txt", + "sha256": "73b9dc2e79c7308998dd30296e073aefaefb944a68fb89aa412c23c0edcabcaa" + } + ] + }, + { + "name": "const-oid", + "version": "0.9.6", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/formats/tree/master/const-oid", + "files": [ + { + "file": "const-oid-0.9.6-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "const-oid-0.9.6-LICENSE-MIT.txt", + "sha256": "bada9e7ed8dc00d63502053c455d7c8d7575dfb7e8277a2a832531844d900682" } ] }, { "name": "constant_time_eq", - "version": "0.3.1", + "version": "0.6.1", "license": "CC0-1.0 OR MIT-0 OR Apache-2.0", - "repository": "https://github.com/cesarb/constant_time_eq", + "repository": "https://codeberg.org/cesarb/constant_time_eq", "files": [ { - "file": "constant_time_eq-0.3.1-LICENSE-APACHE.txt", + "file": "constant_time_eq-0.6.1-LICENSE-APACHE.txt", "sha256": "62c7a1e35f56406896d7aa7ca52d0cc0d272ac022b5d2796e7d6905db8a3636a" }, { - "file": "constant_time_eq-0.3.1-LICENSE-CC0.txt", + "file": "constant_time_eq-0.6.1-LICENSE-CC0.txt", "sha256": "a2010f343487d3f7618affe54f789f5487602331c0a8d03f49e9a7c547cf0499" }, { - "file": "constant_time_eq-0.3.1-LICENSE-MIT0.txt", + "file": "constant_time_eq-0.6.1-LICENSE-MIT0.txt", "sha256": "9a3aeb3f63659c4c6302d315e1d98784d1e8559858a47ff9562e091bb922d28d" } ] @@ -515,6 +551,22 @@ } ] }, + { + "name": "cpubits", + "version": "0.1.1", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/RustCrypto/utils", + "files": [ + { + "file": "cpubits-0.1.1-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "cpubits-0.1.1-LICENSE-MIT.txt", + "sha256": "003736bc98408b53511b9cc8ff5d1af3bf0a8f17d53841cc39d8ab1b96a195b0" + } + ] + }, { "name": "cpufeatures", "version": "0.2.17", @@ -532,44 +584,53 @@ ] }, { - "name": "crc", - "version": "3.4.0", + "name": "cpufeatures", + "version": "0.3.1", "license": "MIT OR Apache-2.0", - "repository": "https://github.com/mrhooray/crc-rs.git", + "repository": "https://github.com/RustCrypto/utils", "files": [ { - "file": "crc-3.4.0-LICENSE-APACHE.txt", - "sha256": "470355a7eed93fcc4281ec2e0f82ca3b94e7af1e4d83629f91de8cfac34d750e" + "file": "cpufeatures-0.3.1-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" }, { - "file": "crc-3.4.0-LICENSE-MIT.txt", - "sha256": "3488679340a49ecc34d342c4009d2dabf76f4a21f12aec2ca99b15805d656544" + "file": "cpufeatures-0.3.1-LICENSE-MIT.txt", + "sha256": "73b9dc2e79c7308998dd30296e073aefaefb944a68fb89aa412c23c0edcabcaa" } ] }, - { - "name": "crc-catalog", - "version": "2.5.0", - "license": "MIT OR Apache-2.0", - "repository": "https://github.com/akhilles/crc-catalog.git", - "files": [] - }, { "name": "crc32fast", - "version": "1.5.1", + "version": "1.5.2", "license": "MIT OR Apache-2.0", "repository": "https://github.com/srijs/rust-crc32fast", "files": [ { - "file": "crc32fast-1.5.1-LICENSE-APACHE.txt", + "file": "crc32fast-1.5.2-LICENSE-APACHE.txt", "sha256": "c6596eb7be8581c18be736c846fb9173b69eccf6ef94c5135893ec56bd92ba08" }, { - "file": "crc32fast-1.5.1-LICENSE-MIT.txt", + "file": "crc32fast-1.5.2-LICENSE-MIT.txt", "sha256": "61d383b05b87d78f94d2937e2580cce47226d17823c0430fbcad09596537efcf" } ] }, + { + "name": "crypto-bigint", + "version": "0.5.5", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/crypto-bigint", + "files": [ + { + "file": "crypto-bigint-0.5.5-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "crypto-bigint-0.5.5-LICENSE-MIT.txt", + "sha256": "90c503b61dee04e1449c323ec34c229dfb68d7adcb96c7e140ee55f70fce2d8e" + } + ] + }, { "name": "crypto-common", "version": "0.1.7", @@ -586,6 +647,50 @@ } ] }, + { + "name": "crypto-common", + "version": "0.2.2", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/RustCrypto/traits", + "files": [ + { + "file": "crypto-common-0.2.2-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "crypto-common-0.2.2-LICENSE-MIT.txt", + "sha256": "d2e7ec5355c96eeade56b09187ceb48a6a30299da3ce7531a66d3d11405ab963" + } + ] + }, + { + "name": "ctutils", + "version": "0.4.3", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/utils", + "files": [ + { + "file": "ctutils-0.4.3-LICENSE-APACHE.txt", + "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" + }, + { + "file": "ctutils-0.4.3-LICENSE-MIT.txt", + "sha256": "91585c36e4fb9ab4ca0d3dfac5d66d3c0c62cc51f640a0e1196542daf2267eae" + } + ] + }, + { + "name": "curve25519-dalek", + "version": "4.1.3", + "license": "BSD-3-Clause", + "repository": "https://github.com/dalek-cryptography/curve25519-dalek/tree/main/curve25519-dalek", + "files": [ + { + "file": "curve25519-dalek-4.1.3-LICENSE.txt", + "sha256": "cca0bd3c4fcdba74145ef9d49c62337e2c9fbf9368288f11d0547f1b0273219f" + } + ] + }, { "name": "data-encoding", "version": "2.11.1", @@ -610,18 +715,34 @@ } ] }, + { + "name": "der", + "version": "0.7.10", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/formats/tree/master/der", + "files": [ + { + "file": "der-0.7.10-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "der-0.7.10-LICENSE-MIT.txt", + "sha256": "ad64fcb9589f162720f3cc5010ad76ca6ad3764e11861f9192c489df176bb71d" + } + ] + }, { "name": "deranged", - "version": "0.5.8", + "version": "0.5.9", "license": "MIT OR Apache-2.0", "repository": "https://github.com/jhpratt/deranged", "files": [ { - "file": "deranged-0.5.8-LICENSE-Apache.txt", + "file": "deranged-0.5.9-LICENSE-Apache.txt", "sha256": "edd65bdd88957a205c47d53fa499eed8865a70320f0f03f6391668cb304ea376" }, { - "file": "deranged-0.5.8-LICENSE-MIT.txt", + "file": "deranged-0.5.9-LICENSE-MIT.txt", "sha256": "231c837c45eb53f108fb48929e488965bc4fcc14e9ea21d35f50e6b99d98685b" } ] @@ -642,6 +763,22 @@ } ] }, + { + "name": "digest", + "version": "0.11.3", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/RustCrypto/traits", + "files": [ + { + "file": "digest-0.11.3-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "digest-0.11.3-LICENSE-MIT.txt", + "sha256": "af59cea35d7f5e2777a713b8d155d65efa2c339eb43f3c14e868c6ac8506edad" + } + ] + }, { "name": "displaydoc", "version": "0.2.7", @@ -658,18 +795,94 @@ } ] }, + { + "name": "easyfft", + "version": "0.4.2", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/WalterSmuts/easyfft", + "files": [ + { + "file": "easyfft-0.4.2-LICENSE-APACHE.txt", + "sha256": "c6596eb7be8581c18be736c846fb9173b69eccf6ef94c5135893ec56bd92ba08" + }, + { + "file": "easyfft-0.4.2-LICENSE-MIT.txt", + "sha256": "cb22dce3ea93e49e43b70059c193184e27612526591db29a4cd95a369a2a5645" + } + ] + }, + { + "name": "ecdsa", + "version": "0.16.9", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/signatures/tree/master/ecdsa", + "files": [ + { + "file": "ecdsa-0.16.9-LICENSE-APACHE.txt", + "sha256": "78779d420019e6b4630376af8e86b6b335ee8a2f89ede6e0411e0469a326aaa4" + }, + { + "file": "ecdsa-0.16.9-LICENSE-MIT.txt", + "sha256": "bdebaf9156a298f8fdab56dd26cb5144673de522d80f4c0d88e0039145f147f9" + } + ] + }, + { + "name": "ed25519", + "version": "2.2.3", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/signatures/tree/master/ed25519", + "files": [ + { + "file": "ed25519-2.2.3-LICENSE-APACHE.txt", + "sha256": "78779d420019e6b4630376af8e86b6b335ee8a2f89ede6e0411e0469a326aaa4" + }, + { + "file": "ed25519-2.2.3-LICENSE-MIT.txt", + "sha256": "b3470648aff02beb36d7a53240fc9260ed80ed93bd43bace6b67d7ef7336ee33" + } + ] + }, + { + "name": "ed25519-dalek", + "version": "2.2.0", + "license": "BSD-3-Clause", + "repository": "https://github.com/dalek-cryptography/curve25519-dalek/tree/main/ed25519-dalek", + "files": [ + { + "file": "ed25519-dalek-2.2.0-LICENSE.txt", + "sha256": "7a313964a6e050794d2ad57f4863c11f6bbe055c0ae6ce2cf3b9fc45150bada3" + } + ] + }, + { + "name": "elliptic-curve", + "version": "0.13.8", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/traits/tree/master/elliptic-curve", + "files": [ + { + "file": "elliptic-curve-0.13.8-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "elliptic-curve-0.13.8-LICENSE-MIT.txt", + "sha256": "d27687b51f2874822c1530976b7e837eac4f308d94bf3dd42047011b7d437b47" + } + ] + }, { "name": "equivalent", - "version": "1.0.2", + "version": "1.0.3", "license": "Apache-2.0 OR MIT", "repository": "https://github.com/indexmap-rs/equivalent", "files": [ { - "file": "equivalent-1.0.2-LICENSE-APACHE.txt", + "file": "equivalent-1.0.3-LICENSE-APACHE.txt", "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" }, { - "file": "equivalent-1.0.2-LICENSE-MIT.txt", + "file": "equivalent-1.0.3-LICENSE-MIT.txt", "sha256": "7365cc8878a1d7ce155a58c4ca09c3d7a6be413efa5334a80ea842912b669349" } ] @@ -711,14 +924,42 @@ "version": "0.4.0", "license": "Apache-2.0", "repository": "https://github.com/apoint123/ferrous-opencc", - "files": [] + "files": [ + { + "file": "ferrous-opencc-0.4.0-UPSTREAM-NOTICE.txt", + "sha256": "386bac628861c8e4a0d7817fd1bee5573c19035985fb63ae2b833a83191884eb", + "source": "upstream package metadata and SPDX standard text" + } + ] }, { "name": "ferrous-opencc-compiler", "version": "0.4.0", "license": "Apache-2.0", "repository": null, - "files": [] + "files": [ + { + "file": "ferrous-opencc-compiler-0.4.0-UPSTREAM-NOTICE.txt", + "sha256": "e3e98be94e329686a8c2ae05ae49d9eb21f3295d3f85948c73b9152aed72ccdb", + "source": "upstream package metadata and SPDX standard text" + } + ] + }, + { + "name": "ff", + "version": "0.13.1", + "license": "MIT/Apache-2.0", + "repository": "https://github.com/zkcrypto/ff", + "files": [ + { + "file": "ff-0.13.1-LICENSE-APACHE.txt", + "sha256": "3708458dee7f359ac6c9c5558023ed481be87e5372c43ebd7f2ca7ad23c12026" + }, + { + "file": "ff-0.13.1-LICENSE-MIT.txt", + "sha256": "8d0c1d1d4b2bebf5b9211f0e883d5a482b7808f3ed31320da14ddee95811f4ef" + } + ] }, { "name": "filetime", @@ -738,16 +979,16 @@ }, { "name": "find-msvc-tools", - "version": "0.1.12", + "version": "0.1.15", "license": "MIT OR Apache-2.0", "repository": "https://github.com/rust-lang/cc-rs", "files": [ { - "file": "find-msvc-tools-0.1.12-LICENSE-APACHE.txt", + "file": "find-msvc-tools-0.1.15-LICENSE-APACHE.txt", "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" }, { - "file": "find-msvc-tools-0.1.12-LICENSE-MIT.txt", + "file": "find-msvc-tools-0.1.15-LICENSE-MIT.txt", "sha256": "378f5840b258e2779c39418f3f2d7b2ba96f1c7917dd6be0713f88305dbda397" } ] @@ -929,34 +1170,34 @@ ] }, { - "name": "getrandom", - "version": "0.2.17", + "name": "generic_singleton", + "version": "0.5.3", "license": "MIT OR Apache-2.0", - "repository": "https://github.com/rust-random/getrandom", + "repository": "https://github.com/WalterSmuts/generic_singleton", "files": [ { - "file": "getrandom-0.2.17-LICENSE-APACHE.txt", - "sha256": "aaff376532ea30a0cd5330b9502ad4a4c8bf769c539c87ffe78819d188a18ebf" + "file": "generic_singleton-0.5.3-LICENSE-APACHE.txt", + "sha256": "c6596eb7be8581c18be736c846fb9173b69eccf6ef94c5135893ec56bd92ba08" }, { - "file": "getrandom-0.2.17-LICENSE-MIT.txt", - "sha256": "42fa16951ce7f24b5a467a40e5b449a1d41e662f97ca779864f053f39e097737" + "file": "generic_singleton-0.5.3-LICENSE-MIT.txt", + "sha256": "cb22dce3ea93e49e43b70059c193184e27612526591db29a4cd95a369a2a5645" } ] }, { "name": "getrandom", - "version": "0.3.4", + "version": "0.2.17", "license": "MIT OR Apache-2.0", "repository": "https://github.com/rust-random/getrandom", "files": [ { - "file": "getrandom-0.3.4-LICENSE-APACHE.txt", + "file": "getrandom-0.2.17-LICENSE-APACHE.txt", "sha256": "aaff376532ea30a0cd5330b9502ad4a4c8bf769c539c87ffe78819d188a18ebf" }, { - "file": "getrandom-0.3.4-LICENSE-MIT.txt", - "sha256": "29e9fe5074bd27e0e5d5d110394fbbcd841baee2651a3c4b4560a632702cede4" + "file": "getrandom-0.2.17-LICENSE-MIT.txt", + "sha256": "42fa16951ce7f24b5a467a40e5b449a1d41e662f97ca779864f053f39e097737" } ] }, @@ -976,6 +1217,22 @@ } ] }, + { + "name": "group", + "version": "0.13.0", + "license": "MIT/Apache-2.0", + "repository": "https://github.com/zkcrypto/group", + "files": [ + { + "file": "group-0.13.0-LICENSE-APACHE.txt", + "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" + }, + { + "file": "group-0.13.0-LICENSE-MIT.txt", + "sha256": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" + } + ] + }, { "name": "hashbrown", "version": "0.17.1", @@ -992,6 +1249,38 @@ } ] }, + { + "name": "hex", + "version": "0.4.3", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/KokaKiwi/rust-hex", + "files": [ + { + "file": "hex-0.4.3-LICENSE-APACHE.txt", + "sha256": "c6596eb7be8581c18be736c846fb9173b69eccf6ef94c5135893ec56bd92ba08" + }, + { + "file": "hex-0.4.3-LICENSE-MIT.txt", + "sha256": "f7bdb3426d045cd50efd4953026e3eb5a83d0199f458a075602611b9344da5b9" + } + ] + }, + { + "name": "hkdf", + "version": "0.12.4", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/RustCrypto/KDFs/", + "files": [ + { + "file": "hkdf-0.12.4-LICENSE-APACHE.txt", + "sha256": "59013a5c8d3a19c26a457579105915a5d51bb0c09d579f8cdedf12e4203c3018" + }, + { + "file": "hkdf-0.12.4-LICENSE-MIT.txt", + "sha256": "d288f9c9b4590446ec18c22ead8f8b5a12a3d4025b68f62dc9015063eb9cca69" + } + ] + }, { "name": "hmac", "version": "0.12.1", @@ -1008,6 +1297,22 @@ } ] }, + { + "name": "hmac", + "version": "0.13.0", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/RustCrypto/MACs", + "files": [ + { + "file": "hmac-0.13.0-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "hmac-0.13.0-LICENSE-MIT.txt", + "sha256": "9e0dfd2dd4173a530e238cb6adb37aa78c34c6bc7444e0e10c1ab5d8881f63ba" + } + ] + }, { "name": "http", "version": "1.5.0", @@ -1059,51 +1364,67 @@ "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" }, { - "file": "httparse-1.10.1-LICENSE-MIT.txt", - "sha256": "391a5396cec6230bfabd4ef4eb2350eb895bc5efce377a2218f5702ed020d3e3" + "file": "httparse-1.10.1-LICENSE-MIT.txt", + "sha256": "391a5396cec6230bfabd4ef4eb2350eb895bc5efce377a2218f5702ed020d3e3" + } + ] + }, + { + "name": "hybrid-array", + "version": "0.4.15", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/RustCrypto/hybrid-array", + "files": [ + { + "file": "hybrid-array-0.4.15-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "hybrid-array-0.4.15-LICENSE-MIT.txt", + "sha256": "70c9d40f1f9545c3f133b8a67206e89da850f6468eed072281bb3701514114a9" } ] }, { "name": "hyper", - "version": "1.11.1", + "version": "1.12.0", "license": "MIT", "repository": "https://github.com/hyperium/hyper", "files": [ { - "file": "hyper-1.11.1-LICENSE.txt", + "file": "hyper-1.12.0-LICENSE.txt", "sha256": "2d01890414494742ba4a509fcec8efa40f6d8be22cbd72be7cff08d6fda4ec89" } ] }, { "name": "hyper-rustls", - "version": "0.27.9", + "version": "0.27.10", "license": "Apache-2.0 OR ISC OR MIT", "repository": "https://github.com/rustls/hyper-rustls", "files": [ { - "file": "hyper-rustls-0.27.9-LICENSE-APACHE.txt", + "file": "hyper-rustls-0.27.10-LICENSE-APACHE.txt", "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" }, { - "file": "hyper-rustls-0.27.9-LICENSE-ISC.txt", + "file": "hyper-rustls-0.27.10-LICENSE-ISC.txt", "sha256": "7cfafc877eccc46c0e346ccbaa5c51bb6b894d2b818e617d970211e232785ad4" }, { - "file": "hyper-rustls-0.27.9-LICENSE-MIT.txt", + "file": "hyper-rustls-0.27.10-LICENSE-MIT.txt", "sha256": "709e3175b4212f7b13aa93971c9f62ff8c69ec45ad8c6532a7e0c41d7a7d6f8c" } ] }, { "name": "hyper-util", - "version": "0.1.20", + "version": "0.1.21", "license": "MIT", "repository": "https://github.com/hyperium/hyper-util", "files": [ { - "file": "hyper-util-0.1.20-LICENSE.txt", + "file": "hyper-util-0.1.21-LICENSE.txt", "sha256": "9e0a97848ea543aef745c98e84fde696a9a3e0735538f6daefdd3cb1942effc1" } ] @@ -1258,17 +1579,17 @@ }, { "name": "inout", - "version": "0.1.4", + "version": "0.2.5", "license": "MIT OR Apache-2.0", "repository": "https://github.com/RustCrypto/utils", "files": [ { - "file": "inout-0.1.4-LICENSE-APACHE.txt", + "file": "inout-0.2.5-LICENSE-APACHE.txt", "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" }, { - "file": "inout-0.1.4-LICENSE-MIT.txt", - "sha256": "304b898acad7f02e03d6d8832d682a3b43ee729ab5d3a069af5a36979083b6b4" + "file": "inout-0.2.5-LICENSE-MIT.txt", + "sha256": "a07fcacc3c60de4dc0fab10ac9d6aaba7379974e28451c99da7f7df09c25b28c" } ] }, @@ -1320,22 +1641,74 @@ } ] }, + { + "name": "jsonwebtoken", + "version": "11.1.0", + "license": "MIT", + "repository": "https://github.com/Keats/jsonwebtoken", + "files": [ + { + "file": "jsonwebtoken-11.1.0-LICENSE.txt", + "sha256": "a8012be84ea176195305bfb4e9109ed6f28860a8e697bc50302e75c2040813f3" + } + ] + }, + { + "name": "lazy_static", + "version": "1.5.1", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/rust-lang-nursery/lazy-static.rs", + "files": [ + { + "file": "lazy_static-1.5.1-LICENSE-APACHE.txt", + "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" + }, + { + "file": "lazy_static-1.5.1-LICENSE-MIT.txt", + "sha256": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" + } + ] + }, + { + "name": "libbz2-rs-sys", + "version": "0.2.5", + "license": "bzip2-1.0.6", + "repository": "https://github.com/trifectatechfoundation/libbzip2-rs", + "files": [ + { + "file": "libbz2-rs-sys-0.2.5-LICENSE.txt", + "sha256": "54e1fd7bd53273e601c9599eb162a004dacddee031ae297ee7109a7beb93b1c2" + } + ] + }, { "name": "libc", - "version": "0.2.189", + "version": "0.2.191", "license": "MIT OR Apache-2.0", "repository": "https://github.com/rust-lang/libc", "files": [ { - "file": "libc-0.2.189-LICENSE-APACHE.txt", + "file": "libc-0.2.191-LICENSE-APACHE.txt", "sha256": "62c7a1e35f56406896d7aa7ca52d0cc0d272ac022b5d2796e7d6905db8a3636a" }, { - "file": "libc-0.2.189-LICENSE-MIT.txt", + "file": "libc-0.2.191-LICENSE-MIT.txt", "sha256": "123a331b5dbf04c30097fa43b8f858bc85df671fe776de498d01f3d6b7c1f69e" } ] }, + { + "name": "libm", + "version": "0.2.16", + "license": "MIT", + "repository": "https://github.com/rust-lang/compiler-builtins", + "files": [ + { + "file": "libm-0.2.16-LICENSE.txt.txt", + "sha256": "3823dda7cf046602f4b4e77ec8e227863dc4736037cc85bb33d9f19febe16bb7" + } + ] + }, { "name": "litemap", "version": "0.8.3", @@ -1382,65 +1755,49 @@ }, { "name": "lru-slab", - "version": "0.1.2", + "version": "0.1.3", "license": "MIT OR Apache-2.0 OR Zlib", "repository": "https://github.com/Ralith/lru-slab", "files": [ { - "file": "lru-slab-0.1.2-LICENSE-APACHE.txt", + "file": "lru-slab-0.1.3-LICENSE-APACHE.txt", "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" }, { - "file": "lru-slab-0.1.2-LICENSE-MIT.txt", + "file": "lru-slab-0.1.3-LICENSE-MIT.txt", "sha256": "1466d9b4f3a59bb8997fc8d5fa977ce3f6a24bb2358793c09bfdf007039f6486" }, { - "file": "lru-slab-0.1.2-LICENSE-ZLIB.txt", + "file": "lru-slab-0.1.3-LICENSE-ZLIB.txt", "sha256": "09785877275ffada6c371b652a30e95c26b435126fc0fc62f1bc2d72171f80a4" } ] }, { - "name": "lzma-rs", - "version": "0.3.0", - "license": "MIT", - "repository": "https://github.com/gendx/lzma-rs", - "files": [ - { - "file": "lzma-rs-0.3.0-LICENSE.txt", - "sha256": "ad9f07faa5f6f671df93523293cf8c1ce24f14886fa101013c21536c7890c59b" - } - ] - }, - { - "name": "lzma-sys", - "version": "0.1.20", - "license": "MIT/Apache-2.0", - "repository": "https://github.com/alexcrichton/xz2-rs", + "name": "lzma-rust2", + "version": "0.22.0", + "license": "Apache-2.0", + "repository": "https://github.com/hasenbanck/lzma-rust2/", "files": [ { - "file": "lzma-sys-0.1.20-LICENSE-APACHE.txt", - "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" - }, - { - "file": "lzma-sys-0.1.20-LICENSE-MIT.txt", - "sha256": "69036b033e4bb951821964dbc3d9b1efe6913a6e36d9c1f206de4035a1a85cc4" + "file": "lzma-rust2-0.22.0-LICENSE.txt", + "sha256": "43070e2d4e532684de521b885f385d0841030efa2b1a20bafb76133a5e1379c1" } ] }, { "name": "md-5", - "version": "0.10.6", + "version": "0.11.0", "license": "MIT OR Apache-2.0", "repository": "https://github.com/RustCrypto/hashes", "files": [ { - "file": "md-5-0.10.6-LICENSE-APACHE.txt", + "file": "md-5-0.11.0-LICENSE-APACHE.txt", "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" }, { - "file": "md-5-0.10.6-LICENSE-MIT.txt", - "sha256": "b4eb00df6e2a4d22518fcaa6a2b4646f249b3a3c9814509b22bd2091f1392ff1" + "file": "md-5-0.11.0-LICENSE-MIT.txt", + "sha256": "831e0f43ad0bf014c1c4fec5767aae470434c1d66d6e671be2d823e729063e25" } ] }, @@ -1464,6 +1821,42 @@ } ] }, + { + "name": "micyou-dsp", + "version": "0.1.0", + "license": null, + "repository": null, + "files": [ + { + "file": "micyou-dsp-0.1.0-LICENSE.txt", + "sha256": "9638e134977de4ee0c9745cf94e3a2c64c2252f88057fd5092731b9d74e75816" + }, + { + "file": "micyou-dsp-0.1.0-LICENSE-AEC7.txt.txt", + "sha256": "c41ed2d1fe4ff8660f2b7250343f078316aad743a4871ce205482236ca5f8df6" + }, + { + "file": "micyou-dsp-0.1.0-LICENSE-PureVox.txt.txt", + "sha256": "06c669821b66a2d5cb87a0eb24794e21950534bc2b1fc87f0d4b81d72999a09d" + }, + { + "file": "micyou-dsp-0.1.0-LICENSE-RNNoise.txt.txt", + "sha256": "26e8aae6fb3622e281d8d99aa7ba0df8ef3a83465d8717f2e4cacbcbed8efc92" + } + ] + }, + { + "name": "micyou-infer", + "version": "0.1.0", + "license": null, + "repository": null, + "files": [ + { + "file": "micyou-infer-0.1.0-LICENSE.txt", + "sha256": "9638e134977de4ee0c9745cf94e3a2c64c2252f88057fd5092731b9d74e75816" + } + ] + }, { "name": "mime", "version": "0.3.17", @@ -1492,38 +1885,14 @@ } ] }, - { - "name": "miniz_oxide", - "version": "0.9.1", - "license": "MIT OR Zlib OR Apache-2.0", - "repository": "https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide", - "files": [ - { - "file": "miniz_oxide-0.9.1-LICENSE.txt", - "sha256": "4108245a1f2df9d4e94df8abed5b4ba0759bb2f9b40a6b939f1be141077ae50b" - }, - { - "file": "miniz_oxide-0.9.1-LICENSE-APACHE.md.txt", - "sha256": "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594" - }, - { - "file": "miniz_oxide-0.9.1-LICENSE-MIT.md.txt", - "sha256": "799e9ca9d179295ef372f25d3769cdda7d25bb2668add6a6a1e22d1e4c678b8d" - }, - { - "file": "miniz_oxide-0.9.1-LICENSE-ZLIB.md.txt", - "sha256": "0a54e647fe54104658b5e563c04c6f9edf251710e47bce692e0bd990a4ddaa39" - } - ] - }, { "name": "mio", - "version": "1.2.3", + "version": "1.2.4", "license": "MIT", "repository": "https://github.com/tokio-rs/mio", "files": [ { - "file": "mio-1.2.3-LICENSE.txt", + "file": "mio-1.2.4-LICENSE.txt", "sha256": "07919255c7e04793d8ea760d6c2ce32d19f9ff02bdbdde3ce90b1e1880929a9b" } ] @@ -1552,6 +1921,50 @@ } ] }, + { + "name": "nnnoiseless", + "version": "0.5.2", + "license": "BSD-3-Clause", + "repository": "https://github.com/jneem/nnnoiseless", + "files": [ + { + "file": "nnnoiseless-0.5.2-COPYING.txt", + "sha256": "26e8aae6fb3622e281d8d99aa7ba0df8ef3a83465d8717f2e4cacbcbed8efc92" + } + ] + }, + { + "name": "num-bigint-dig", + "version": "0.8.6", + "license": "MIT/Apache-2.0", + "repository": "https://github.com/dignifiedquire/num-bigint", + "files": [ + { + "file": "num-bigint-dig-0.8.6-LICENSE-APACHE.txt", + "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" + }, + { + "file": "num-bigint-dig-0.8.6-LICENSE-MIT.txt", + "sha256": "6485b8ed310d3f0340bf1ad1f47645069ce4069dcc6bb46c7d5c6faf41de1fdb" + } + ] + }, + { + "name": "num-complex", + "version": "0.4.6", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/rust-num/num-complex", + "files": [ + { + "file": "num-complex-0.4.6-LICENSE-APACHE.txt", + "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" + }, + { + "file": "num-complex-0.4.6-LICENSE-MIT.txt", + "sha256": "6485b8ed310d3f0340bf1ad1f47645069ce4069dcc6bb46c7d5c6faf41de1fdb" + } + ] + }, { "name": "num-conv", "version": "0.2.2", @@ -1568,6 +1981,38 @@ } ] }, + { + "name": "num-integer", + "version": "0.1.47", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/rust-num/num-integer", + "files": [ + { + "file": "num-integer-0.1.47-LICENSE-APACHE.txt", + "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" + }, + { + "file": "num-integer-0.1.47-LICENSE-MIT.txt", + "sha256": "6485b8ed310d3f0340bf1ad1f47645069ce4069dcc6bb46c7d5c6faf41de1fdb" + } + ] + }, + { + "name": "num-iter", + "version": "0.1.46", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/rust-num/num-iter", + "files": [ + { + "file": "num-iter-0.1.46-LICENSE-APACHE.txt", + "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" + }, + { + "file": "num-iter-0.1.46-LICENSE-MIT.txt", + "sha256": "6485b8ed310d3f0340bf1ad1f47645069ce4069dcc6bb46c7d5c6faf41de1fdb" + } + ] + }, { "name": "num-traits", "version": "0.2.19", @@ -1600,22 +2045,6 @@ } ] }, - { - "name": "opaque-debug", - "version": "0.3.1", - "license": "MIT OR Apache-2.0", - "repository": "https://github.com/RustCrypto/utils", - "files": [ - { - "file": "opaque-debug-0.3.1-LICENSE-APACHE.txt", - "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" - }, - { - "file": "opaque-debug-0.3.1-LICENSE-MIT.txt", - "sha256": "f59f5a78193597e04fd932ee9af86254d14d08be5c9d0e003ab888feccce2161" - } - ] - }, { "name": "openless-core", "version": "0.1.0", @@ -1640,6 +2069,38 @@ } ] }, + { + "name": "p256", + "version": "0.13.2", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/elliptic-curves/tree/master/p256", + "files": [ + { + "file": "p256-0.13.2-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "p256-0.13.2-LICENSE-MIT.txt", + "sha256": "233b95ccbf90dc67e32f3e8995c489f6312d9191ebd141a931c3b684f1e3be6d" + } + ] + }, + { + "name": "p384", + "version": "0.13.1", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/elliptic-curves/tree/master/p384", + "files": [ + { + "file": "p384-0.13.1-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "p384-0.13.1-LICENSE-MIT.txt", + "sha256": "02168781a5b59970e3da1784d2eb69f432598a3757f283dbffdafd9d483a9113" + } + ] + }, { "name": "parking_lot", "version": "0.12.5", @@ -1674,36 +2135,52 @@ }, { "name": "password-hash", - "version": "0.5.0", + "version": "0.6.1", "license": "MIT OR Apache-2.0", - "repository": "https://github.com/RustCrypto/traits/tree/master/password-hash", + "repository": "https://github.com/RustCrypto/traits", "files": [ { - "file": "password-hash-0.5.0-LICENSE-APACHE.txt", + "file": "password-hash-0.6.1-LICENSE-APACHE.txt", "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" }, { - "file": "password-hash-0.5.0-LICENSE-MIT.txt", - "sha256": "233b95ccbf90dc67e32f3e8995c489f6312d9191ebd141a931c3b684f1e3be6d" + "file": "password-hash-0.6.1-LICENSE-MIT.txt", + "sha256": "d8c4ab431f20da8452c9a217084163822e5fd9d134e1343903c929f2859fc8fd" } ] }, { "name": "pbkdf2", - "version": "0.12.2", + "version": "0.13.0", "license": "MIT OR Apache-2.0", - "repository": "https://github.com/RustCrypto/password-hashes/tree/master/pbkdf2", + "repository": "https://github.com/RustCrypto/password-hashes", "files": [ { - "file": "pbkdf2-0.12.2-LICENSE-APACHE.txt", + "file": "pbkdf2-0.13.0-LICENSE-APACHE.txt", "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" }, { - "file": "pbkdf2-0.12.2-LICENSE-MIT.txt", + "file": "pbkdf2-0.13.0-LICENSE-MIT.txt", "sha256": "8ea5cea8ddecc46a64a004406414d25529fca7eb71cfd8c0cfe49c343bff79c5" } ] }, + { + "name": "pem-rfc7468", + "version": "0.7.0", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/formats/tree/master/pem-rfc7468", + "files": [ + { + "file": "pem-rfc7468-0.7.0-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "pem-rfc7468-0.7.0-LICENSE-MIT.txt", + "sha256": "90c503b61dee04e1449c323ec34c229dfb68d7adcb96c7e140ee55f70fce2d8e" + } + ] + }, { "name": "percent-encoding", "version": "2.3.2", @@ -1720,6 +2197,22 @@ } ] }, + { + "name": "phc", + "version": "0.6.1", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/formats", + "files": [ + { + "file": "phc-0.6.1-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "phc-0.6.1-LICENSE-MIT.txt", + "sha256": "57a4d75c1d095bb94754a478f3cd15e60ac66a5c5627d844be01a918eea5fe88" + } + ] + }, { "name": "phf", "version": "0.13.1", @@ -1796,6 +2289,38 @@ } ] }, + { + "name": "pkcs1", + "version": "0.7.5", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/formats/tree/master/pkcs1", + "files": [ + { + "file": "pkcs1-0.7.5-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "pkcs1-0.7.5-LICENSE-MIT.txt", + "sha256": "c995204cc6bad2ed67dd41f7d89bb9f1a9d48e0edd745732b30640d7912089a4" + } + ] + }, + { + "name": "pkcs8", + "version": "0.10.2", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/formats/tree/master/pkcs8", + "files": [ + { + "file": "pkcs8-0.10.2-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "pkcs8-0.10.2-LICENSE-MIT.txt", + "sha256": "ad64fcb9589f162720f3cc5010ad76ca6ad3764e11861f9192c489df176bb71d" + } + ] + }, { "name": "pkg-config", "version": "0.3.34", @@ -1814,17 +2339,17 @@ }, { "name": "poly1305", - "version": "0.8.0", + "version": "0.9.1", "license": "Apache-2.0 OR MIT", "repository": "https://github.com/RustCrypto/universal-hashes", "files": [ { - "file": "poly1305-0.8.0-LICENSE-APACHE.txt", + "file": "poly1305-0.9.1-LICENSE-APACHE.txt", "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" }, { - "file": "poly1305-0.8.0-LICENSE-MIT.txt", - "sha256": "6d99fffea918d1f7dfe567029c7bd21d6fcaf310ad28c44e6511a0e1f68c79ea" + "file": "poly1305-0.9.1-LICENSE-MIT.txt", + "sha256": "b67acfaaf787b346e1d3bf7654b4fabfd20360fdeb4351cc5e9d624147824527" } ] }, @@ -1842,33 +2367,81 @@ }, { "name": "powerfmt", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT OR Apache-2.0", "repository": "https://github.com/jhpratt/powerfmt", "files": [ { - "file": "powerfmt-0.2.0-LICENSE-Apache.txt", + "file": "powerfmt-0.2.1-LICENSE-Apache.txt", "sha256": "155420c6403d4e0fca34105e3c03fdd6939b64c393c7ec6f95f5b72c5474eab0" }, { - "file": "powerfmt-0.2.0-LICENSE-MIT.txt", - "sha256": "070dbc7dda03a29296f2d58bdb9b7331af90f2abc9f31df22875d1eabaf29852" + "file": "powerfmt-0.2.1-LICENSE-MIT.txt", + "sha256": "070dbc7dda03a29296f2d58bdb9b7331af90f2abc9f31df22875d1eabaf29852" + } + ] + }, + { + "name": "ppmd-rust", + "version": "1.5.1", + "license": "CC0-1.0 OR MIT-0", + "repository": "https://github.com/hasenbanck/ppmd-rust", + "files": [ + { + "file": "ppmd-rust-1.5.1-LICENSE-CC0.md.txt", + "sha256": "c2c14e64a5798ecd6e5332ed6139d950522352fb82ddfd1d620524db890712ec" + }, + { + "file": "ppmd-rust-1.5.1-LICENSE-MIT-0.md.txt", + "sha256": "5574529c6fc6bb5f1dba17056a3ae5fb5cac51bc50ae4d7cd146e13b708fe0ff" + } + ] + }, + { + "name": "ppv-lite86", + "version": "0.2.21", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/cryptocorrosion/cryptocorrosion", + "files": [ + { + "file": "ppv-lite86-0.2.21-LICENSE-APACHE.txt", + "sha256": "0218327e7a480793ffdd4eb792379a9709e5c135c7ba267f709d6f6d4d70af0a" + }, + { + "file": "ppv-lite86-0.2.21-LICENSE-MIT.txt", + "sha256": "4cada0bd02ea3692eee6f16400d86c6508bbd3bafb2b65fed0419f36d4f83e8f" + } + ] + }, + { + "name": "primal-check", + "version": "0.3.4", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/huonw/primal", + "files": [ + { + "file": "primal-check-0.3.4-LICENSE-APACHE.txt", + "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" + }, + { + "file": "primal-check-0.3.4-LICENSE-MIT.txt", + "sha256": "6d3a9431e65e69c73a8923e6517b889d17549b23db406b9ec027710d16af701f" } ] }, { - "name": "ppv-lite86", - "version": "0.2.21", - "license": "MIT OR Apache-2.0", - "repository": "https://github.com/cryptocorrosion/cryptocorrosion", + "name": "primeorder", + "version": "0.13.6", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/elliptic-curves/tree/master/primeorder", "files": [ { - "file": "ppv-lite86-0.2.21-LICENSE-APACHE.txt", - "sha256": "0218327e7a480793ffdd4eb792379a9709e5c135c7ba267f709d6f6d4d70af0a" + "file": "primeorder-0.13.6-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" }, { - "file": "ppv-lite86-0.2.21-LICENSE-MIT.txt", - "sha256": "4cada0bd02ea3692eee6f16400d86c6508bbd3bafb2b65fed0419f36d4f83e8f" + "file": "primeorder-0.13.6-LICENSE-MIT.txt", + "sha256": "233b95ccbf90dc67e32f3e8995c489f6312d9191ebd141a931c3b684f1e3be6d" } ] }, @@ -1914,48 +2487,48 @@ }, { "name": "quinn", - "version": "0.11.11", + "version": "0.11.12", "license": "MIT OR Apache-2.0", "repository": "https://github.com/quinn-rs/quinn", "files": [ { - "file": "quinn-0.11.11-LICENSE-APACHE.txt", + "file": "quinn-0.11.12-LICENSE-APACHE.txt", "sha256": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4" }, { - "file": "quinn-0.11.11-LICENSE-MIT.txt", + "file": "quinn-0.11.12-LICENSE-MIT.txt", "sha256": "4b2d0aca6789fa39e03d6738e869ea0988cceba210ca34ebb59c15c463e93a04" } ] }, { "name": "quinn-proto", - "version": "0.11.17", + "version": "0.11.19", "license": "MIT OR Apache-2.0", "repository": "https://github.com/quinn-rs/quinn", "files": [ { - "file": "quinn-proto-0.11.17-LICENSE-APACHE.txt", + "file": "quinn-proto-0.11.19-LICENSE-APACHE.txt", "sha256": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4" }, { - "file": "quinn-proto-0.11.17-LICENSE-MIT.txt", + "file": "quinn-proto-0.11.19-LICENSE-MIT.txt", "sha256": "4b2d0aca6789fa39e03d6738e869ea0988cceba210ca34ebb59c15c463e93a04" } ] }, { "name": "quinn-udp", - "version": "0.5.15", + "version": "0.5.16", "license": "MIT OR Apache-2.0", "repository": "https://github.com/quinn-rs/quinn", "files": [ { - "file": "quinn-udp-0.5.15-LICENSE-APACHE.txt", + "file": "quinn-udp-0.5.16-LICENSE-APACHE.txt", "sha256": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4" }, { - "file": "quinn-udp-0.5.15-LICENSE-MIT.txt", + "file": "quinn-udp-0.5.16-LICENSE-MIT.txt", "sha256": "4b2d0aca6789fa39e03d6738e869ea0988cceba210ca34ebb59c15c463e93a04" } ] @@ -1990,16 +2563,16 @@ }, { "name": "rand", - "version": "0.10.2", + "version": "0.10.3", "license": "MIT OR Apache-2.0", "repository": "https://github.com/rust-random/rand", "files": [ { - "file": "rand-0.10.2-LICENSE-APACHE.txt", + "file": "rand-0.10.3-LICENSE-APACHE.txt", "sha256": "35242e7a83f69875e6edeff02291e688c97caafe2f8902e4e19b49d3e78b4cab" }, { - "file": "rand-0.10.2-LICENSE-MIT.txt", + "file": "rand-0.10.3-LICENSE-MIT.txt", "sha256": "209fbbe0ad52d9235e37badf9cadfe4dbdc87203179c0899e738b39ade42177b" } ] @@ -2084,6 +2657,19 @@ } ] }, + { + "name": "realfft", + "version": "3.5.0", + "license": "MIT", + "repository": "https://github.com/HEnquist/realfft", + "files": [ + { + "file": "realfft-3.5.0-UPSTREAM-NOTICE.txt", + "sha256": "263f2a0a7792caa1ec7ece419e7bf4e6ac1beaea5a4ab31e9a9fdbae2d00e42b", + "source": "upstream package metadata and SPDX standard text" + } + ] + }, { "name": "regex", "version": "1.13.1", @@ -2160,6 +2746,22 @@ } ] }, + { + "name": "rfc6979", + "version": "0.4.0", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/signatures/tree/master/rfc6979", + "files": [ + { + "file": "rfc6979-0.4.0-LICENSE-APACHE.txt", + "sha256": "78779d420019e6b4630376af8e86b6b335ee8a2f89ede6e0411e0469a326aaa4" + }, + { + "file": "rfc6979-0.4.0-LICENSE-MIT.txt", + "sha256": "bdebaf9156a298f8fdab56dd26cb5144673de522d80f4c0d88e0039145f147f9" + } + ] + }, { "name": "ring", "version": "0.17.14", @@ -2204,6 +2806,22 @@ } ] }, + { + "name": "rsa", + "version": "0.9.10", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/RustCrypto/RSA", + "files": [ + { + "file": "rsa-0.9.10-LICENSE-APACHE.txt", + "sha256": "769f80b5bcb42ed0af4e4d2fd74e1ac9bf843cb80c5a29219d1ef3544428a6bb" + }, + { + "file": "rsa-0.9.10-LICENSE-MIT.txt", + "sha256": "30fefc3a7d6a0041541858293bcbea2dde4caa4c0a5802f996a7f7e8c0085652" + } + ] + }, { "name": "rustc-hash", "version": "2.1.3", @@ -2220,42 +2838,74 @@ } ] }, + { + "name": "rustc_version", + "version": "0.4.1", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/djc/rustc-version-rs", + "files": [ + { + "file": "rustc_version-0.4.1-LICENSE-APACHE.txt", + "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" + }, + { + "file": "rustc_version-0.4.1-LICENSE-MIT.txt", + "sha256": "c9a75f18b9ab2927829a208fc6aa2cf4e63b8420887ba29cdb265d6619ae82d5" + } + ] + }, + { + "name": "rustfft", + "version": "6.4.1", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/ejmahler/RustFFT", + "files": [ + { + "file": "rustfft-6.4.1-LICENSE-APACHE.txt", + "sha256": "2e54cd84a645bea25943c75dd8ae67cb291e66a47a11578333c9b4b3b6b86c85" + }, + { + "file": "rustfft-6.4.1-LICENSE-MIT.txt", + "sha256": "8f5442dfa8e9169045697e386bc91d19f393c939635741fa2a665ec36ca6f0ad" + } + ] + }, { "name": "rustix", - "version": "1.1.4", + "version": "1.1.5", "license": "Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT", "repository": "https://github.com/bytecodealliance/rustix", "files": [ { - "file": "rustix-1.1.4-LICENSE-APACHE.txt", + "file": "rustix-1.1.5-LICENSE-APACHE.txt", "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" }, { - "file": "rustix-1.1.4-LICENSE-Apache-2.0_WITH_LLVM-exception.txt", + "file": "rustix-1.1.5-LICENSE-Apache-2.0_WITH_LLVM-exception.txt", "sha256": "268872b9816f90fd8e85db5a28d33f8150ebb8dd016653fb39ef1f94f2686bc5" }, { - "file": "rustix-1.1.4-LICENSE-MIT.txt", + "file": "rustix-1.1.5-LICENSE-MIT.txt", "sha256": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" } ] }, { "name": "rustls", - "version": "0.23.44", + "version": "0.23.46", "license": "Apache-2.0 OR ISC OR MIT", "repository": "https://github.com/rustls/rustls", "files": [ { - "file": "rustls-0.23.44-LICENSE-APACHE.txt", + "file": "rustls-0.23.46-LICENSE-APACHE.txt", "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" }, { - "file": "rustls-0.23.44-LICENSE-ISC.txt", + "file": "rustls-0.23.46-LICENSE-ISC.txt", "sha256": "7cfafc877eccc46c0e346ccbaa5c51bb6b894d2b818e617d970211e232785ad4" }, { - "file": "rustls-0.23.44-LICENSE-MIT.txt", + "file": "rustls-0.23.46-LICENSE-MIT.txt", "sha256": "709e3175b4212f7b13aa93971c9f62ff8c69ec45ad8c6532a7e0c41d7a7d6f8c" } ] @@ -2288,6 +2938,22 @@ } ] }, + { + "name": "rustversion", + "version": "1.0.23", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/dtolnay/rustversion", + "files": [ + { + "file": "rustversion-1.0.23-LICENSE-APACHE.txt", + "sha256": "62c7a1e35f56406896d7aa7ca52d0cc0d272ac022b5d2796e7d6905db8a3636a" + }, + { + "file": "rustversion-1.0.23-LICENSE-MIT.txt", + "sha256": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" + } + ] + }, { "name": "ryu", "version": "1.0.23", @@ -2320,6 +2986,22 @@ } ] }, + { + "name": "sec1", + "version": "0.7.3", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/formats/tree/master/sec1", + "files": [ + { + "file": "sec1-0.7.3-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "sec1-0.7.3-LICENSE-MIT.txt", + "sha256": "4a883ecc3bb1010faed542bf63d53e530fea5e5e12cf676aed588784298ba929" + } + ] + }, { "name": "security-framework", "version": "3.7.0", @@ -2352,6 +3034,22 @@ } ] }, + { + "name": "semver", + "version": "1.0.28", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/dtolnay/semver", + "files": [ + { + "file": "semver-1.0.28-LICENSE-APACHE.txt", + "sha256": "62c7a1e35f56406896d7aa7ca52d0cc0d272ac022b5d2796e7d6905db8a3636a" + }, + { + "file": "semver-1.0.28-LICENSE-MIT.txt", + "sha256": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" + } + ] + }, { "name": "serde", "version": "1.0.229", @@ -2402,16 +3100,16 @@ }, { "name": "serde_json", - "version": "1.0.151", + "version": "1.0.154", "license": "MIT OR Apache-2.0", "repository": "https://github.com/serde-rs/json", "files": [ { - "file": "serde_json-1.0.151-LICENSE-APACHE.txt", + "file": "serde_json-1.0.154-LICENSE-APACHE.txt", "sha256": "62c7a1e35f56406896d7aa7ca52d0cc0d272ac022b5d2796e7d6905db8a3636a" }, { - "file": "serde_json-1.0.151-LICENSE-MIT.txt", + "file": "serde_json-1.0.154-LICENSE-MIT.txt", "sha256": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" } ] @@ -2434,17 +3132,17 @@ }, { "name": "sha1", - "version": "0.10.7", + "version": "0.11.0", "license": "MIT OR Apache-2.0", "repository": "https://github.com/RustCrypto/hashes", "files": [ { - "file": "sha1-0.10.7-LICENSE-APACHE.txt", + "file": "sha1-0.11.0-LICENSE-APACHE.txt", "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" }, { - "file": "sha1-0.10.7-LICENSE-MIT.txt", - "sha256": "b4eb00df6e2a4d22518fcaa6a2b4646f249b3a3c9814509b22bd2091f1392ff1" + "file": "sha1-0.11.0-LICENSE-MIT.txt", + "sha256": "831e0f43ad0bf014c1c4fec5767aae470434c1d66d6e671be2d823e729063e25" } ] }, @@ -2464,6 +3162,22 @@ } ] }, + { + "name": "sha2", + "version": "0.11.1", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/RustCrypto/hashes", + "files": [ + { + "file": "sha2-0.11.1-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "sha2-0.11.1-LICENSE-MIT.txt", + "sha256": "831e0f43ad0bf014c1c4fec5767aae470434c1d66d6e671be2d823e729063e25" + } + ] + }, { "name": "shlex", "version": "2.0.1", @@ -2480,6 +3194,22 @@ } ] }, + { + "name": "signature", + "version": "2.2.0", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/traits/tree/master/signature", + "files": [ + { + "file": "signature-2.2.0-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "signature-2.2.0-LICENSE-MIT.txt", + "sha256": "b3470648aff02beb36d7a53240fc9260ed80ed93bd43bace6b67d7ef7336ee33" + } + ] + }, { "name": "simd-adler32", "version": "0.3.10", @@ -2510,13 +3240,21 @@ }, { "name": "siphasher", - "version": "1.0.3", - "license": "MIT/Apache-2.0", + "version": "1.0.4", + "license": "MIT OR Apache-2.0", "repository": "https://github.com/jedisct1/rust-siphash", "files": [ { - "file": "siphasher-1.0.3-COPYING.txt", + "file": "siphasher-1.0.4-COPYING.txt", "sha256": "c962ee4d1d05ddc138b202b2540219ebc57893fcf97b364852094a9a94ce1365" + }, + { + "file": "siphasher-1.0.4-LICENSE-APACHE.txt", + "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" + }, + { + "file": "siphasher-1.0.4-LICENSE-MIT.txt", + "sha256": "f9c4c77baa3828004ee54b8a4f2db2e88ed44a6237a493965bf551fac0fcb62d" } ] }, @@ -2534,16 +3272,16 @@ }, { "name": "smallvec", - "version": "1.16.0", + "version": "1.16.3", "license": "MIT OR Apache-2.0", "repository": "https://github.com/servo/rust-smallvec", "files": [ { - "file": "smallvec-1.16.0-LICENSE-APACHE.txt", + "file": "smallvec-1.16.3-LICENSE-APACHE.txt", "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" }, { - "file": "smallvec-1.16.0-LICENSE-MIT.txt", + "file": "smallvec-1.16.3-LICENSE-MIT.txt", "sha256": "0b28172679e0009b655da42797c03fd163a3379d5cfa67ba1f1655e974a2a1a9" } ] @@ -2564,6 +3302,34 @@ } ] }, + { + "name": "spin", + "version": "0.9.9", + "license": "MIT", + "repository": "https://github.com/mvdnes/spin-rs.git", + "files": [ + { + "file": "spin-0.9.9-LICENSE.txt", + "sha256": "6ac8711fb340c62ce0a4ecd463342d3fa0e8e70de697c863a2e1c0c53006003c" + } + ] + }, + { + "name": "spki", + "version": "0.7.3", + "license": "Apache-2.0 OR MIT", + "repository": "https://github.com/RustCrypto/formats/tree/master/spki", + "files": [ + { + "file": "spki-0.7.3-LICENSE-APACHE.txt", + "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" + }, + { + "file": "spki-0.7.3-LICENSE-MIT.txt", + "sha256": "c995204cc6bad2ed67dd41f7d89bb9f1a9d48e0edd745732b30640d7912089a4" + } + ] + }, { "name": "stable_deref_trait", "version": "1.2.1", @@ -2580,6 +3346,22 @@ } ] }, + { + "name": "strength_reduce", + "version": "0.2.4", + "license": "MIT OR Apache-2.0", + "repository": "http://github.com/ejmahler/strength_reduce", + "files": [ + { + "file": "strength_reduce-0.2.4-LICENSE-APACHE.txt", + "sha256": "2e54cd84a645bea25943c75dd8ae67cb291e66a47a11578333c9b4b3b6b86c85" + }, + { + "file": "strength_reduce-0.2.4-LICENSE-MIT.txt", + "sha256": "8f5442dfa8e9169045697e386bc91d19f393c939635741fa2a665ec36ca6f0ad" + } + ] + }, { "name": "subtle", "version": "2.6.1", @@ -2610,16 +3392,16 @@ }, { "name": "syn", - "version": "3.0.5", + "version": "3.0.9", "license": "MIT OR Apache-2.0", "repository": "https://github.com/dtolnay/syn", "files": [ { - "file": "syn-3.0.5-LICENSE-APACHE.txt", + "file": "syn-3.0.9-LICENSE-APACHE.txt", "sha256": "62c7a1e35f56406896d7aa7ca52d0cc0d272ac022b5d2796e7d6905db8a3636a" }, { - "file": "syn-3.0.5-LICENSE-MIT.txt", + "file": "syn-3.0.9-LICENSE-MIT.txt", "sha256": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" } ] @@ -2638,12 +3420,12 @@ }, { "name": "synstructure", - "version": "0.13.2", + "version": "0.14.0", "license": "MIT", "repository": "https://github.com/mystor/synstructure", "files": [ { - "file": "synstructure-0.13.2-LICENSE.txt", + "file": "synstructure-0.14.0-LICENSE.txt", "sha256": "219920e865eee70b7dcfc948a86b099e7f4fe2de01bcca2ca9a20c0a033f2b59" } ] @@ -2682,64 +3464,32 @@ }, { "name": "thiserror", - "version": "1.0.69", - "license": "MIT OR Apache-2.0", - "repository": "https://github.com/dtolnay/thiserror", - "files": [ - { - "file": "thiserror-1.0.69-LICENSE-APACHE.txt", - "sha256": "62c7a1e35f56406896d7aa7ca52d0cc0d272ac022b5d2796e7d6905db8a3636a" - }, - { - "file": "thiserror-1.0.69-LICENSE-MIT.txt", - "sha256": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" - } - ] - }, - { - "name": "thiserror", - "version": "2.0.20", - "license": "MIT OR Apache-2.0", - "repository": "https://github.com/dtolnay/thiserror", - "files": [ - { - "file": "thiserror-2.0.20-LICENSE-APACHE.txt", - "sha256": "62c7a1e35f56406896d7aa7ca52d0cc0d272ac022b5d2796e7d6905db8a3636a" - }, - { - "file": "thiserror-2.0.20-LICENSE-MIT.txt", - "sha256": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" - } - ] - }, - { - "name": "thiserror-impl", - "version": "1.0.69", + "version": "2.0.21", "license": "MIT OR Apache-2.0", "repository": "https://github.com/dtolnay/thiserror", "files": [ { - "file": "thiserror-impl-1.0.69-LICENSE-APACHE.txt", + "file": "thiserror-2.0.21-LICENSE-APACHE.txt", "sha256": "62c7a1e35f56406896d7aa7ca52d0cc0d272ac022b5d2796e7d6905db8a3636a" }, { - "file": "thiserror-impl-1.0.69-LICENSE-MIT.txt", + "file": "thiserror-2.0.21-LICENSE-MIT.txt", "sha256": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" } ] }, { "name": "thiserror-impl", - "version": "2.0.20", + "version": "2.0.21", "license": "MIT OR Apache-2.0", "repository": "https://github.com/dtolnay/thiserror", "files": [ { - "file": "thiserror-impl-2.0.20-LICENSE-APACHE.txt", + "file": "thiserror-impl-2.0.21-LICENSE-APACHE.txt", "sha256": "62c7a1e35f56406896d7aa7ca52d0cc0d272ac022b5d2796e7d6905db8a3636a" }, { - "file": "thiserror-impl-2.0.20-LICENSE-MIT.txt", + "file": "thiserror-impl-2.0.21-LICENSE-MIT.txt", "sha256": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" } ] @@ -2790,52 +3540,32 @@ }, { "name": "tinyvec", - "version": "1.13.2", + "version": "1.13.3", "license": "Zlib OR Apache-2.0 OR MIT", "repository": "https://github.com/Lokathor/tinyvec", "files": [ { - "file": "tinyvec-1.13.2-LICENSE-APACHE.md.txt", + "file": "tinyvec-1.13.3-LICENSE-APACHE.md.txt", "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" }, { - "file": "tinyvec-1.13.2-LICENSE-MIT.md.txt", + "file": "tinyvec-1.13.3-LICENSE-MIT.md.txt", "sha256": "fd80a26fbb3f644af1fa994134446702932968519797227e07a1368dea80f0bc" }, { - "file": "tinyvec-1.13.2-LICENSE-ZLIB.md.txt", + "file": "tinyvec-1.13.3-LICENSE-ZLIB.md.txt", "sha256": "84b34dd7608f7fb9b17bd588a6bf392bf7de504e2716f024a77d89f1b145a151" } ] }, - { - "name": "tinyvec_macros", - "version": "0.1.1", - "license": "MIT OR Apache-2.0 OR Zlib", - "repository": "https://github.com/Soveu/tinyvec_macros", - "files": [ - { - "file": "tinyvec_macros-0.1.1-LICENSE-APACHE.md.txt", - "sha256": "4f44572785f35152c1fd2eadf565b7e079c0f300b4324f0af653419f9d76b735" - }, - { - "file": "tinyvec_macros-0.1.1-LICENSE-MIT.md.txt", - "sha256": "1dd8eca0f83669e75fa119e34fb9e1be9d16e3e9b6368962b8019db6e8ae5f7b" - }, - { - "file": "tinyvec_macros-0.1.1-LICENSE-ZLIB.md.txt", - "sha256": "41ace205715d9f19a3214218cc1c01d57c533e02cd0fef7c8e51a49a7fce5ac5" - } - ] - }, { "name": "tokio", - "version": "1.53.1", + "version": "1.53.3", "license": "MIT", "repository": "https://github.com/tokio-rs/tokio", "files": [ { - "file": "tokio-1.53.1-LICENSE.txt", + "file": "tokio-1.53.3-LICENSE.txt", "sha256": "253cd04c6714889df2d32f3f64d669179a1c95c76ac43c40882c52eb06bc3552" } ] @@ -2854,40 +3584,40 @@ }, { "name": "tokio-rustls", - "version": "0.26.5", + "version": "0.26.6", "license": "MIT OR Apache-2.0", "repository": "https://github.com/rustls/tokio-rustls", "files": [ { - "file": "tokio-rustls-0.26.5-LICENSE-APACHE.txt", + "file": "tokio-rustls-0.26.6-LICENSE-APACHE.txt", "sha256": "cc117d90b498b32b11a886f279b359da16a73c3b01efbb2f5cc004b20262334e" }, { - "file": "tokio-rustls-0.26.5-LICENSE-MIT.txt", + "file": "tokio-rustls-0.26.6-LICENSE-MIT.txt", "sha256": "e20fa2b8e0a2565f24a792b94b4bf4b6c2b9d36f781d8a9516e218a036e6677a" } ] }, { "name": "tokio-tungstenite", - "version": "0.24.0", + "version": "0.30.0", "license": "MIT", "repository": "https://github.com/snapview/tokio-tungstenite", "files": [ { - "file": "tokio-tungstenite-0.24.0-LICENSE.txt", + "file": "tokio-tungstenite-0.30.0-LICENSE.txt", "sha256": "fdd55e2b2da854b0fbdc1e607df7c2ba1e1ebf91ecb77c515511ebeef972bc8f" } ] }, { "name": "tokio-util", - "version": "0.7.19", + "version": "0.7.20", "license": "MIT", "repository": "https://github.com/tokio-rs/tokio", "files": [ { - "file": "tokio-util-0.7.19-LICENSE.txt", + "file": "tokio-util-0.7.20-LICENSE.txt", "sha256": "253cd04c6714889df2d32f3f64d669179a1c95c76ac43c40882c52eb06bc3552" } ] @@ -2964,6 +3694,22 @@ } ] }, + { + "name": "transpose", + "version": "0.2.3", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/ejmahler/transpose", + "files": [ + { + "file": "transpose-0.2.3-LICENSE-APACHE.txt", + "sha256": "8797ef61538ec5ee9222ebef7ca4e0f3ec5761b145ca9943d358c450efb644dd" + }, + { + "file": "transpose-0.2.3-LICENSE-MIT.txt", + "sha256": "5080149357fd0be590bdc10cf92165412bb4d61ce496284d56f2d12874ae3121" + } + ] + }, { "name": "try-lock", "version": "0.2.5", @@ -2978,20 +3724,36 @@ }, { "name": "tungstenite", - "version": "0.24.0", + "version": "0.30.0", "license": "MIT OR Apache-2.0", "repository": "https://github.com/snapview/tungstenite-rs", "files": [ { - "file": "tungstenite-0.24.0-LICENSE-APACHE.txt", + "file": "tungstenite-0.30.0-LICENSE-APACHE.txt", "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" }, { - "file": "tungstenite-0.24.0-LICENSE-MIT.txt", + "file": "tungstenite-0.30.0-LICENSE-MIT.txt", "sha256": "7fea0ee51a4ca5d5cea7464135fd55e8b09caf3a61da3d451ac8a22af95c033f" } ] }, + { + "name": "typed-path", + "version": "0.12.3", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/chipsenkbeil/typed-path", + "files": [ + { + "file": "typed-path-0.12.3-LICENSE-APACHE.txt", + "sha256": "62c7a1e35f56406896d7aa7ca52d0cc0d272ac022b5d2796e7d6905db8a3636a" + }, + { + "file": "typed-path-0.12.3-LICENSE-MIT.txt", + "sha256": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" + } + ] + }, { "name": "typenum", "version": "1.20.1", @@ -3014,69 +3776,73 @@ }, { "name": "unicase", - "version": "2.9.0", + "version": "2.10.0", "license": "MIT OR Apache-2.0", "repository": "https://github.com/seanmonstar/unicase", "files": [ { - "file": "unicase-2.9.0-LICENSE-APACHE.txt", + "file": "unicase-2.10.0-LICENSE-APACHE.txt", "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" }, { - "file": "unicase-2.9.0-LICENSE-MIT.txt", + "file": "unicase-2.10.0-LICENSE-MIT.txt", "sha256": "4f6bd11a0f17fe5b085ace1daaedb7b002a08316850dd9577c246c6a68a68e8c" } ] }, { "name": "unicode-ident", - "version": "1.0.24", + "version": "1.0.27", "license": "(MIT OR Apache-2.0) AND Unicode-3.0", "repository": "https://github.com/dtolnay/unicode-ident", "files": [ { - "file": "unicode-ident-1.0.24-LICENSE-APACHE.txt", + "file": "unicode-ident-1.0.27-LICENSE-APACHE.txt", "sha256": "62c7a1e35f56406896d7aa7ca52d0cc0d272ac022b5d2796e7d6905db8a3636a" }, { - "file": "unicode-ident-1.0.24-LICENSE-MIT.txt", + "file": "unicode-ident-1.0.27-LICENSE-MIT.txt", "sha256": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" }, { - "file": "unicode-ident-1.0.24-LICENSE-UNICODE.txt", + "file": "unicode-ident-1.0.27-LICENSE-UNICODE.txt", "sha256": "f7db81051789b729fea528a63ec4c938fdcb93d9d61d97dc8cc2e9df6d47f2a1" } ] }, { "name": "unicode-normalization", - "version": "0.1.25", - "license": "MIT OR Apache-2.0", + "version": "0.1.26", + "license": "(MIT OR Apache-2.0) AND Unicode-3.0", "repository": "https://github.com/unicode-rs/unicode-normalization", "files": [ { - "file": "unicode-normalization-0.1.25-LICENSE-APACHE.txt", + "file": "unicode-normalization-0.1.26-LICENSE-APACHE.txt", "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" }, { - "file": "unicode-normalization-0.1.25-LICENSE-MIT.txt", + "file": "unicode-normalization-0.1.26-LICENSE-MIT.txt", "sha256": "7b63ecd5f1902af1b63729947373683c32745c16a10e8e6292e2e2dcd7e90ae0" + }, + { + "file": "unicode-normalization-0.1.26-LICENSE-UNICODE.txt", + "sha256": "f7db81051789b729fea528a63ec4c938fdcb93d9d61d97dc8cc2e9df6d47f2a1" } ] }, { "name": "universal-hash", - "version": "0.5.1", + "version": "0.6.1", "license": "MIT OR Apache-2.0", "repository": "https://github.com/RustCrypto/traits", "files": [ { - "file": "universal-hash-0.5.1-LICENSE-APACHE.txt", + "file": "universal-hash-0.6.1-LICENSE-APACHE.txt", "sha256": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5" }, { - "file": "universal-hash-0.5.1-LICENSE-MIT.txt", - "sha256": "6b50510a0400c72172355cc5d2ba0f2c5c2eb33149b8f96539dc345fdc484920" + "file": "universal-hash-0.6.1-LICENSE-MIT.txt", + "sha256": "efa52eb70a774b62c50cf50f5e57e2625c29d375d09b91132f4f020e47b9944e" } ] }, @@ -3108,22 +3874,6 @@ } ] }, - { - "name": "utf-8", - "version": "0.7.6", - "license": "MIT OR Apache-2.0", - "repository": "https://github.com/SimonSapin/rust-utf8", - "files": [ - { - "file": "utf-8-0.7.6-LICENSE-APACHE.txt", - "sha256": "62c7a1e35f56406896d7aa7ca52d0cc0d272ac022b5d2796e7d6905db8a3636a" - }, - { - "file": "utf-8-0.7.6-LICENSE-MIT.txt", - "sha256": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" - } - ] - }, { "name": "utf8_iter", "version": "1.0.4", @@ -3142,16 +3892,16 @@ }, { "name": "uuid", - "version": "1.26.1", + "version": "1.28.0", "license": "Apache-2.0 OR MIT", "repository": "https://github.com/uuid-rs/uuid", "files": [ { - "file": "uuid-1.26.1-LICENSE-APACHE.txt", + "file": "uuid-1.28.0-LICENSE-APACHE.txt", "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" }, { - "file": "uuid-1.26.1-LICENSE-MIT.txt", + "file": "uuid-1.28.0-LICENSE-MIT.txt", "sha256": "436bc5a105d8e57dcd8778730f3754f7bf39c14d2f530e4cde4bd2d17a83ec3d" } ] @@ -3174,12 +3924,12 @@ }, { "name": "want", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "repository": "https://github.com/seanmonstar/want", "files": [ { - "file": "want-0.3.1-LICENSE.txt", + "file": "want-0.3.2-LICENSE.txt", "sha256": "a65f5d0a945d267751344c95665945b90c030ea107faf5c85d518929886187da" } ] @@ -3236,22 +3986,6 @@ } ] }, - { - "name": "xz2", - "version": "0.1.7", - "license": "MIT/Apache-2.0", - "repository": "https://github.com/alexcrichton/xz2-rs", - "files": [ - { - "file": "xz2-0.1.7-LICENSE-APACHE.txt", - "sha256": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2" - }, - { - "file": "xz2-0.1.7-LICENSE-MIT.txt", - "sha256": "69036b033e4bb951821964dbc3d9b1efe6913a6e36d9c1f206de4035a1a85cc4" - } - ] - }, { "name": "yoke", "version": "0.8.3", @@ -3266,32 +4000,32 @@ }, { "name": "yoke-derive", - "version": "0.8.2", + "version": "0.8.4", "license": "Unicode-3.0", "repository": "https://github.com/unicode-org/icu4x", "files": [ { - "file": "yoke-derive-0.8.2-LICENSE.txt", + "file": "yoke-derive-0.8.4-LICENSE.txt", "sha256": "f367c1b8e1aa262435251e442901da4607b4650e0e63a026f5044473ecfb90f2" } ] }, { "name": "zerocopy", - "version": "0.8.57", + "version": "0.8.63", "license": "BSD-2-Clause OR Apache-2.0 OR MIT", "repository": "https://github.com/google/zerocopy", "files": [ { - "file": "zerocopy-0.8.57-LICENSE-APACHE.txt", + "file": "zerocopy-0.8.63-LICENSE-APACHE.txt", "sha256": "9d185ac6703c4b0453974c0d85e9eee43e6941009296bb1f5eb0b54e2329e9f3" }, { - "file": "zerocopy-0.8.57-LICENSE-BSD.txt", + "file": "zerocopy-0.8.63-LICENSE-BSD.txt", "sha256": "83c1763356e822adde0a2cae748d938a73fdc263849ccff6b27776dff213bd32" }, { - "file": "zerocopy-0.8.57-LICENSE-MIT.txt", + "file": "zerocopy-0.8.63-LICENSE-MIT.txt", "sha256": "1a2f5c12ddc934d58956aa5dbdd3255fe55fd957633ab7d0d39e4f0daa73f7df" } ] @@ -3310,28 +4044,28 @@ }, { "name": "zerofrom-derive", - "version": "0.1.7", + "version": "0.1.8", "license": "Unicode-3.0", "repository": "https://github.com/unicode-org/icu4x", "files": [ { - "file": "zerofrom-derive-0.1.7-LICENSE.txt", + "file": "zerofrom-derive-0.1.8-LICENSE.txt", "sha256": "f367c1b8e1aa262435251e442901da4607b4650e0e63a026f5044473ecfb90f2" } ] }, { "name": "zeroize", - "version": "1.9.0", + "version": "1.9.1", "license": "Apache-2.0 OR MIT", "repository": "https://github.com/RustCrypto/utils", "files": [ { - "file": "zeroize-1.9.0-LICENSE-APACHE.txt", + "file": "zeroize-1.9.1-LICENSE-APACHE.txt", "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" }, { - "file": "zeroize-1.9.0-LICENSE-MIT.txt", + "file": "zeroize-1.9.1-LICENSE-MIT.txt", "sha256": "8c7516d4b27b1e495be5e38b612298b63de48d05f49cdac94f70f3cd70f8864b" } ] @@ -3390,13 +4124,25 @@ }, { "name": "zip", - "version": "2.4.2", + "version": "9.0.5", "license": "MIT", - "repository": "https://github.com/zip-rs/zip2.git", + "repository": "https://github.com/zip-rs/zip2", + "files": [ + { + "file": "zip-9.0.5-LICENSE.txt", + "sha256": "58545fed1565e42d687aecec6897d35c6d37ccb71479a137c0deb2203e125c79" + } + ] + }, + { + "name": "zlib-rs", + "version": "0.6.8", + "license": "Zlib", + "repository": "https://github.com/trifectatechfoundation/zlib-rs", "files": [ { - "file": "zip-2.4.2-LICENSE.txt", - "sha256": "04dc52136d82400b11df5f474949863a70a5d05d2278dec45b99c479494450a6" + "file": "zlib-rs-0.6.8-LICENSE.txt", + "sha256": "e72111c52b7d96ebe25348dee19f0744f444d3c95ae6b1ecb6ccaecc5bce05ba" } ] }, @@ -3414,52 +4160,52 @@ }, { "name": "zopfli", - "version": "0.8.3", + "version": "0.8.4", "license": "Apache-2.0", "repository": "https://github.com/zopfli-rs/zopfli", "files": [ { - "file": "zopfli-0.8.3-COPYING.txt", + "file": "zopfli-0.8.4-COPYING.txt", "sha256": "018b1cb87efdf7a04c2fcc13d57ed63f62149113fb207b27ea13430d64f13513" } ] }, { "name": "zstd", - "version": "0.13.3", - "license": "MIT", + "version": "0.14.3", + "license": "BSD-3-Clause", "repository": "https://github.com/gyscos/zstd-rs", "files": [ { - "file": "zstd-0.13.3-LICENSE.txt", - "sha256": "129e8edef29e9abcd2ebabe252f4ef1b1289cdca356bf0040284a2fbccfb96c8" + "file": "zstd-0.14.3-LICENSE.txt", + "sha256": "ce4868c8e4ebe47a7d4b3c93dbf6b802033e5ae2fa7638098173f80836daac60" } ] }, { "name": "zstd-safe", - "version": "7.3.0", + "version": "8.2.0", "license": "BSD-3-Clause", "repository": "https://github.com/gyscos/zstd-rs", "files": [ { - "file": "zstd-safe-7.3.0-LICENSE.txt", + "file": "zstd-safe-8.2.0-LICENSE.txt", "sha256": "ce4868c8e4ebe47a7d4b3c93dbf6b802033e5ae2fa7638098173f80836daac60" } ] }, { "name": "zstd-sys", - "version": "2.1.0+zstd.1.5.7", + "version": "2.1.2+zstd.1.5.7", "license": "BSD-3-Clause", "repository": "https://github.com/gyscos/zstd-rs", "files": [ { - "file": "zstd-sys-2.1.0+zstd.1.5.7-LICENSE.txt", + "file": "zstd-sys-2.1.2+zstd.1.5.7-LICENSE.txt", "sha256": "ce4868c8e4ebe47a7d4b3c93dbf6b802033e5ae2fa7638098173f80836daac60" }, { - "file": "zstd-sys-2.1.0+zstd.1.5.7-LICENSE.BSD-3-Clause.txt", + "file": "zstd-sys-2.1.2+zstd.1.5.7-LICENSE.BSD-3-Clause.txt", "sha256": "bf7017a73a95ea63bf3716b13ec2bcd79c92942356e3408c6fb66332bad6d977" } ] diff --git a/openless-all/ios/Resources/Licenses/Rust/md-5-0.11.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/md-5-0.11.0-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/md-5-0.11.0-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/md-5-0.10.6-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/md-5-0.11.0-LICENSE-MIT.txt similarity index 95% rename from openless-all/ios/Resources/Licenses/Rust/md-5-0.10.6-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/md-5-0.11.0-LICENSE-MIT.txt index 66cf75563..9ce242cec 100644 --- a/openless-all/ios/Resources/Licenses/Rust/md-5-0.10.6-LICENSE-MIT.txt +++ b/openless-all/ios/Resources/Licenses/Rust/md-5-0.11.0-LICENSE-MIT.txt @@ -1,6 +1,7 @@ -Copyright (c) 2006-2009 Graydon Hoare -Copyright (c) 2009-2013 Mozilla Foundation +Copyright (c) 2016-2026 The RustCrypto Project Developers Copyright (c) 2016 Artyom Pavlov +Copyright (c) 2009-2013 Mozilla Foundation +Copyright (c) 2006-2009 Graydon Hoare Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated diff --git a/openless-all/ios/Resources/Licenses/Rust/tinyvec_macros-0.1.1-LICENSE-MIT.md.txt b/openless-all/ios/Resources/Licenses/Rust/micyou-dsp-0.1.0-LICENSE-AEC7.txt.txt similarity index 97% rename from openless-all/ios/Resources/Licenses/Rust/tinyvec_macros-0.1.1-LICENSE-MIT.md.txt rename to openless-all/ios/Resources/Licenses/Rust/micyou-dsp-0.1.0-LICENSE-AEC7.txt.txt index 4a8554df7..ab6dc217a 100644 --- a/openless-all/ios/Resources/Licenses/Rust/tinyvec_macros-0.1.1-LICENSE-MIT.md.txt +++ b/openless-all/ios/Resources/Licenses/Rust/micyou-dsp-0.1.0-LICENSE-AEC7.txt.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Soveu +Copyright (c) 2026 a2heng Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/openless-all/ios/Resources/Licenses/Rust/crc-3.4.0-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/micyou-dsp-0.1.0-LICENSE-PureVox.txt.txt similarity index 96% rename from openless-all/ios/Resources/Licenses/Rust/crc-3.4.0-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/micyou-dsp-0.1.0-LICENSE-PureVox.txt.txt index ea21c2256..bfa7788c0 100644 --- a/openless-all/ios/Resources/Licenses/Rust/crc-3.4.0-LICENSE-MIT.txt +++ b/openless-all/ios/Resources/Licenses/Rust/micyou-dsp-0.1.0-LICENSE-PureVox.txt.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 crc-rs Developers +Copyright (c) 2025 a2heng Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/openless-all/ios/Resources/Licenses/Rust/micyou-dsp-0.1.0-LICENSE-RNNoise.txt.txt b/openless-all/ios/Resources/Licenses/Rust/micyou-dsp-0.1.0-LICENSE-RNNoise.txt.txt new file mode 100644 index 000000000..2caa6a9cc --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/micyou-dsp-0.1.0-LICENSE-RNNoise.txt.txt @@ -0,0 +1,32 @@ +Copyright (c) 2020, Joe Neeman +Copyright (c) 2017, Mozilla +Copyright (c) 2007-2017, Jean-Marc Valin +Copyright (c) 2005-2017, Xiph.Org Foundation +Copyright (c) 2003-2004, Mark Borgerding + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +- Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +- Neither the name of the Xiph.Org Foundation nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION +OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/openless-all/ios/Resources/Licenses/Rust/micyou-dsp-0.1.0-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/micyou-dsp-0.1.0-LICENSE.txt new file mode 100644 index 000000000..01288814f --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/micyou-dsp-0.1.0-LICENSE.txt @@ -0,0 +1,725 @@ +MicYou is licensed under the GNU General Public License version 3.0 (GPLv3) +with the MicYou Plugin Exception (Version 1.0). + +Copyright (C) 2026 LanRhyme + +============================================================================== + MicYou Plugin Exception (Version 1.0) +============================================================================== + +As a special exception to Sections 5 and 6 of the GNU General Public License +version 3 ("GPLv3"), under Section 7 (Additional Permissions), the copyright +holders of MicYou grant permission to link, load, or combine independently +created software modules ("Plugins") with MicYou through its defined Plugin +Interfaces, and to convey the resulting combined work, provided that all of +the following conditions are met: + +1. Defined Plugin Interfaces: + This exception applies solely to Plugins that interact with MicYou through + its official, public Plugin Application Programming Interfaces (APIs), + including but not limited to the C ABI (mpl_host_api_t / micyou_plugin_abi.h), + WASM guest-host interfaces, and official Plugin SDKs. This exception does + not grant permission to extract, repackage, or link internal components of + MicYou outside the defined Plugin Interfaces into proprietary works. + +2. Non-Commercial Plugins: + Plugins that are developed, distributed, or used solely for non-commercial + purposes are NOT required to be licensed under the GNU GPLv3. Authors of + such non-commercial Plugins may choose any open-source or proprietary + license of their choice. + +3. Commercial Plugins: + Any commercial distribution, sale, paid subscription, or proprietary + commercial monetization of Plugins that link with or run inside MicYou + requires prior written authorization or a separate commercial license from + the copyright holders of MicYou. + +4. Core Program Protection: + Nothing in this exception modifies the license terms of MicYou itself or + works directly based on MicYou (other than the Plugin combination permitted + herein). Any modification to MicYou's core source code remains strictly + subject to the full terms and conditions of the GNU General Public License + version 3. + +5. Marketplace Distribution: + Distribution via the official MicYou Plugin Marketplace is subject to the + MicYou Plugin Marketplace Policy, which may require GPL-3.0-compatible open + source licensing or official review and authorization. + +============================================================================== + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 +============================================================================== + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. + diff --git a/openless-all/ios/Resources/Licenses/Rust/micyou-infer-0.1.0-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/micyou-infer-0.1.0-LICENSE.txt new file mode 100644 index 000000000..01288814f --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/micyou-infer-0.1.0-LICENSE.txt @@ -0,0 +1,725 @@ +MicYou is licensed under the GNU General Public License version 3.0 (GPLv3) +with the MicYou Plugin Exception (Version 1.0). + +Copyright (C) 2026 LanRhyme + +============================================================================== + MicYou Plugin Exception (Version 1.0) +============================================================================== + +As a special exception to Sections 5 and 6 of the GNU General Public License +version 3 ("GPLv3"), under Section 7 (Additional Permissions), the copyright +holders of MicYou grant permission to link, load, or combine independently +created software modules ("Plugins") with MicYou through its defined Plugin +Interfaces, and to convey the resulting combined work, provided that all of +the following conditions are met: + +1. Defined Plugin Interfaces: + This exception applies solely to Plugins that interact with MicYou through + its official, public Plugin Application Programming Interfaces (APIs), + including but not limited to the C ABI (mpl_host_api_t / micyou_plugin_abi.h), + WASM guest-host interfaces, and official Plugin SDKs. This exception does + not grant permission to extract, repackage, or link internal components of + MicYou outside the defined Plugin Interfaces into proprietary works. + +2. Non-Commercial Plugins: + Plugins that are developed, distributed, or used solely for non-commercial + purposes are NOT required to be licensed under the GNU GPLv3. Authors of + such non-commercial Plugins may choose any open-source or proprietary + license of their choice. + +3. Commercial Plugins: + Any commercial distribution, sale, paid subscription, or proprietary + commercial monetization of Plugins that link with or run inside MicYou + requires prior written authorization or a separate commercial license from + the copyright holders of MicYou. + +4. Core Program Protection: + Nothing in this exception modifies the license terms of MicYou itself or + works directly based on MicYou (other than the Plugin combination permitted + herein). Any modification to MicYou's core source code remains strictly + subject to the full terms and conditions of the GNU General Public License + version 3. + +5. Marketplace Distribution: + Distribution via the official MicYou Plugin Marketplace is subject to the + MicYou Plugin Marketplace Policy, which may require GPL-3.0-compatible open + source licensing or official review and authorization. + +============================================================================== + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 +============================================================================== + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. + diff --git a/openless-all/ios/Resources/Licenses/Rust/miniz_oxide-0.9.1-LICENSE-ZLIB.md.txt b/openless-all/ios/Resources/Licenses/Rust/miniz_oxide-0.9.1-LICENSE-ZLIB.md.txt deleted file mode 100644 index 92871cd24..000000000 --- a/openless-all/ios/Resources/Licenses/Rust/miniz_oxide-0.9.1-LICENSE-ZLIB.md.txt +++ /dev/null @@ -1,14 +0,0 @@ -Copyright 2013-2014 RAD Game Tools and Valve Software -Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC -Copyright (c) 2020 Frommi -Copyright (c) 2017-2024 oyvindln - -This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software. - -Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: - -1. The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required. - -2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. - -3. This notice may not be removed or altered from any source distribution. diff --git a/openless-all/ios/Resources/Licenses/Rust/miniz_oxide-0.9.1-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/miniz_oxide-0.9.1-LICENSE.txt deleted file mode 100644 index a1980025c..000000000 --- a/openless-all/ios/Resources/Licenses/Rust/miniz_oxide-0.9.1-LICENSE.txt +++ /dev/null @@ -1,25 +0,0 @@ -MIT License - -Copyright 2013-2014 RAD Game Tools and Valve Software -Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC -Copyright (c) 2017 Frommi -Copyright (c) 2017-2024 oyvindln - - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/mio-1.2.3-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/mio-1.2.4-LICENSE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/mio-1.2.3-LICENSE.txt rename to openless-all/ios/Resources/Licenses/Rust/mio-1.2.4-LICENSE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/nnnoiseless-0.5.2-COPYING.txt b/openless-all/ios/Resources/Licenses/Rust/nnnoiseless-0.5.2-COPYING.txt new file mode 100644 index 000000000..2caa6a9cc --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/nnnoiseless-0.5.2-COPYING.txt @@ -0,0 +1,32 @@ +Copyright (c) 2020, Joe Neeman +Copyright (c) 2017, Mozilla +Copyright (c) 2007-2017, Jean-Marc Valin +Copyright (c) 2005-2017, Xiph.Org Foundation +Copyright (c) 2003-2004, Mark Borgerding + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +- Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +- Neither the name of the Xiph.Org Foundation nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION +OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/openless-all/ios/Resources/Licenses/Rust/rustix-1.1.4-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/num-bigint-dig-0.8.6-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/rustix-1.1.4-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/num-bigint-dig-0.8.6-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/num-bigint-dig-0.8.6-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/num-bigint-dig-0.8.6-LICENSE-MIT.txt new file mode 100644 index 000000000..39d4bdb5a --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/num-bigint-dig-0.8.6-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/rustls-0.23.44-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/num-complex-0.4.6-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/rustls-0.23.44-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/num-complex-0.4.6-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/num-complex-0.4.6-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/num-complex-0.4.6-LICENSE-MIT.txt new file mode 100644 index 000000000..39d4bdb5a --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/num-complex-0.4.6-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/smallvec-1.16.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/num-integer-0.1.47-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/smallvec-1.16.0-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/num-integer-0.1.47-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/num-integer-0.1.47-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/num-integer-0.1.47-LICENSE-MIT.txt new file mode 100644 index 000000000..39d4bdb5a --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/num-integer-0.1.47-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/tungstenite-0.24.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/num-iter-0.1.46-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/tungstenite-0.24.0-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/num-iter-0.1.46-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/num-iter-0.1.46-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/num-iter-0.1.46-LICENSE-MIT.txt new file mode 100644 index 000000000..39d4bdb5a --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/num-iter-0.1.46-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/p256-0.13.2-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/p256-0.13.2-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/p256-0.13.2-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/password-hash-0.5.0-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/p256-0.13.2-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/password-hash-0.5.0-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/p256-0.13.2-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/p384-0.13.1-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/p384-0.13.1-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/p384-0.13.1-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/p384-0.13.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/p384-0.13.1-LICENSE-MIT.txt new file mode 100644 index 000000000..c4edc443d --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/p384-0.13.1-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2020-2021 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/password-hash-0.6.1-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/password-hash-0.6.1-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/password-hash-0.6.1-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/password-hash-0.6.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/password-hash-0.6.1-LICENSE-MIT.txt new file mode 100644 index 000000000..ede6f74f2 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/password-hash-0.6.1-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2020-2026 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/pbkdf2-0.13.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/pbkdf2-0.13.0-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/pbkdf2-0.13.0-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/pbkdf2-0.12.2-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/pbkdf2-0.13.0-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/pbkdf2-0.12.2-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/pbkdf2-0.13.0-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/pem-rfc7468-0.7.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/pem-rfc7468-0.7.0-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/pem-rfc7468-0.7.0-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/pem-rfc7468-0.7.0-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/pem-rfc7468-0.7.0-LICENSE-MIT.txt new file mode 100644 index 000000000..c869ada57 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/pem-rfc7468-0.7.0-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2021 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/phc-0.6.1-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/phc-0.6.1-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/phc-0.6.1-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/phc-0.6.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/phc-0.6.1-LICENSE-MIT.txt new file mode 100644 index 000000000..11d191686 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/phc-0.6.1-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2021-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/pkcs1-0.7.5-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/pkcs1-0.7.5-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/pkcs1-0.7.5-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/pkcs1-0.7.5-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/pkcs1-0.7.5-LICENSE-MIT.txt new file mode 100644 index 000000000..3294d7434 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/pkcs1-0.7.5-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2021-2023 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/pkcs8-0.10.2-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/pkcs8-0.10.2-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/pkcs8-0.10.2-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/pkcs8-0.10.2-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/pkcs8-0.10.2-LICENSE-MIT.txt new file mode 100644 index 000000000..e0d082780 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/pkcs8-0.10.2-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2020-2023 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/poly1305-0.8.0-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/poly1305-0.8.0-LICENSE-MIT.txt deleted file mode 100644 index 4596d9fad..000000000 --- a/openless-all/ios/Resources/Licenses/Rust/poly1305-0.8.0-LICENSE-MIT.txt +++ /dev/null @@ -1,25 +0,0 @@ -Copyright (c) 2015-2019 RustCrypto Developers - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/poly1305-0.9.1-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/poly1305-0.9.1-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/poly1305-0.9.1-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/poly1305-0.9.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/poly1305-0.9.1-LICENSE-MIT.txt new file mode 100644 index 000000000..a9eb4ab7f --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/poly1305-0.9.1-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2015-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/powerfmt-0.2.0-LICENSE-Apache.txt b/openless-all/ios/Resources/Licenses/Rust/powerfmt-0.2.1-LICENSE-Apache.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/powerfmt-0.2.0-LICENSE-Apache.txt rename to openless-all/ios/Resources/Licenses/Rust/powerfmt-0.2.1-LICENSE-Apache.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/powerfmt-0.2.0-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/powerfmt-0.2.1-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/powerfmt-0.2.0-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/powerfmt-0.2.1-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/ppmd-rust-1.5.1-LICENSE-CC0.md.txt b/openless-all/ios/Resources/Licenses/Rust/ppmd-rust-1.5.1-LICENSE-CC0.md.txt new file mode 100644 index 000000000..bf30e849b --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/ppmd-rust-1.5.1-LICENSE-CC0.md.txt @@ -0,0 +1,119 @@ +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be + protected by copyright and related or neighboring rights ("Copyright and + Related Rights"). Copyright and Related Rights include, but are not + limited to, the following: + +i. the right to reproduce, adapt, distribute, perform, display, +communicate, and translate a Work; +ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or +likeness depicted in a Work; +iv. rights protecting against unfair competition in regards to a Work, +subject to the limitations in paragraph 4(a), below; +v. rights protecting the extraction, dissemination, use and reuse of data +in a Work; +vi. database rights (such as those arising under Directive 96/9/EC of the +European Parliament and of the Council of 11 March 1996 on the legal +protection of databases, and under any national implementation +thereof, including any amended or successor version of such +directive); and +vii. other similar, equivalent or corresponding rights throughout the +world based on applicable law or treaty, and any national +implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention + of, applicable law, Affirmer hereby overtly, fully, permanently, + irrevocably and unconditionally waives, abandons, and surrenders all of + Affirmer's Copyright and Related Rights and associated claims and causes + of action, whether now known or unknown (including existing as well as + future claims and causes of action), in the Work (i) in all territories + worldwide, (ii) for the maximum duration provided by applicable law or + treaty (including future time extensions), (iii) in any current or future + medium and for any number of copies, and (iv) for any purpose whatsoever, + including without limitation commercial, advertising or promotional + purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each + member of the public at large and to the detriment of Affirmer's heirs and + successors, fully intending that such Waiver shall not be subject to + revocation, rescission, cancellation, termination, or any other legal or + equitable action to disrupt the quiet enjoyment of the Work by the public + as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason + be judged legally invalid or ineffective under applicable law, then the + Waiver shall be preserved to the maximum extent permitted taking into + account Affirmer's express Statement of Purpose. In addition, to the + extent the Waiver is so judged Affirmer hereby grants to each affected + person a royalty-free, non transferable, non sublicensable, non exclusive, + irrevocable and unconditional license to exercise Affirmer's Copyright and + Related Rights in the Work (i) in all territories worldwide, (ii) for the + maximum duration provided by applicable law or treaty (including future + time extensions), (iii) in any current or future medium and for any number + of copies, and (iv) for any purpose whatsoever, including without + limitation commercial, advertising or promotional purposes (the + "License"). The License shall be deemed effective as of the date CC0 was + applied by Affirmer to the Work. Should any part of the License for any + reason be judged legally invalid or ineffective under applicable law, such + partial invalidity or ineffectiveness shall not invalidate the remainder + of the License, and in such case Affirmer hereby affirms that he or she + will not (i) exercise any of his or her remaining Copyright and Related + Rights in the Work or (ii) assert any associated claims and causes of + action with respect to the Work, in either case contrary to Affirmer's + express Statement of Purpose. + +4. Limitations and Disclaimers. + +a. No trademark or patent rights held by Affirmer are waived, abandoned, +surrendered, licensed or otherwise affected by this document. +b. Affirmer offers the Work as-is and makes no representations or +warranties of any kind concerning the Work, express, implied, +statutory or otherwise, including without limitation warranties of +title, merchantability, fitness for a particular purpose, non +infringement, or the absence of latent or other defects, accuracy, or +the present or absence of errors, whether or not discoverable, all to +the greatest extent permissible under applicable law. +c. Affirmer disclaims responsibility for clearing rights of other persons +that may apply to the Work or any use thereof, including without +limitation any person's Copyright and Related Rights in the Work. +Further, Affirmer disclaims responsibility for obtaining any necessary +consents, permissions or other rights required for any use of the +Work. +d. Affirmer understands and acknowledges that Creative Commons is not a +party to this document and has no duty or obligation with respect to +this CC0 or use of the Work. diff --git a/openless-all/ios/Resources/Licenses/Rust/ppmd-rust-1.5.1-LICENSE-MIT-0.md.txt b/openless-all/ios/Resources/Licenses/Rust/ppmd-rust-1.5.1-LICENSE-MIT-0.md.txt new file mode 100644 index 000000000..8782a83bf --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/ppmd-rust-1.5.1-LICENSE-MIT-0.md.txt @@ -0,0 +1,16 @@ +MIT No Attribution + +Copyright 2026 The ppmd-rust authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this +software and associated documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/unicase-2.9.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/primal-check-0.3.4-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/unicase-2.9.0-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/primal-check-0.3.4-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/xz2-0.1.7-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/primal-check-0.3.4-LICENSE-MIT.txt similarity index 94% rename from openless-all/ios/Resources/Licenses/Rust/xz2-0.1.7-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/primal-check-0.3.4-LICENSE-MIT.txt index 28e630cf4..bf6c304f7 100644 --- a/openless-all/ios/Resources/Licenses/Rust/xz2-0.1.7-LICENSE-MIT.txt +++ b/openless-all/ios/Resources/Licenses/Rust/primal-check-0.3.4-LICENSE-MIT.txt @@ -1,4 +1,4 @@ -Copyright (c) 2016 Alex Crichton +Copyright (c) 2014 Huon Wilson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -22,4 +22,4 @@ SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/openless-all/ios/Resources/Licenses/Rust/primeorder-0.13.6-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/primeorder-0.13.6-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/primeorder-0.13.6-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/primeorder-0.13.6-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/primeorder-0.13.6-LICENSE-MIT.txt new file mode 100644 index 000000000..50b1254c1 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/primeorder-0.13.6-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2020-2023 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/quinn-0.11.11-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/quinn-0.11.12-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/quinn-0.11.11-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/quinn-0.11.12-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/quinn-0.11.11-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/quinn-0.11.12-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/quinn-0.11.11-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/quinn-0.11.12-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/quinn-proto-0.11.17-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/quinn-proto-0.11.19-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/quinn-proto-0.11.17-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/quinn-proto-0.11.19-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/quinn-proto-0.11.17-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/quinn-proto-0.11.19-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/quinn-proto-0.11.17-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/quinn-proto-0.11.19-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/quinn-udp-0.5.15-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/quinn-udp-0.5.16-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/quinn-udp-0.5.15-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/quinn-udp-0.5.16-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/quinn-udp-0.5.15-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/quinn-udp-0.5.16-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/quinn-udp-0.5.15-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/quinn-udp-0.5.16-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/rand-0.10.2-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/rand-0.10.3-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/rand-0.10.2-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/rand-0.10.3-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/rand-0.10.2-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/rand-0.10.3-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/rand-0.10.2-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/rand-0.10.3-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/realfft-3.5.0-UPSTREAM-NOTICE.txt b/openless-all/ios/Resources/Licenses/Rust/realfft-3.5.0-UPSTREAM-NOTICE.txt new file mode 100644 index 000000000..f81098b45 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/realfft-3.5.0-UPSTREAM-NOTICE.txt @@ -0,0 +1,27 @@ +Package: realfft 3.5.0 +Declared license: MIT +Authors: HEnquist +Repository: https://github.com/HEnquist/realfft +Source revision: d0d4eee0525fd27c96c8a046d6d107acd5ed84a6 + +The upstream crate declares this license without bundling a standalone license file. +Standard license text: SPDX license-list-data v3.27.0. + +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/rfc6979-0.4.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/rfc6979-0.4.0-LICENSE-APACHE.txt new file mode 100644 index 000000000..c394d8ad9 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/rfc6979-0.4.0-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2018-2022 RustCrypto Developers + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/rfc6979-0.4.0-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/rfc6979-0.4.0-LICENSE-MIT.txt new file mode 100644 index 000000000..81a3d57ac --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/rfc6979-0.4.0-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2018-2022 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/rsa-0.9.10-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/rsa-0.9.10-LICENSE-APACHE.txt new file mode 100644 index 000000000..f8e5e5ea0 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/rsa-0.9.10-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/openless-all/ios/Resources/Licenses/Rust/rsa-0.9.10-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/rsa-0.9.10-LICENSE-MIT.txt new file mode 100644 index 000000000..468cd79a8 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/rsa-0.9.10-LICENSE-MIT.txt @@ -0,0 +1,23 @@ +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/openless-all/ios/Resources/Licenses/Rust/unicode-normalization-0.1.25-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/rustc_version-0.4.1-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/unicode-normalization-0.1.25-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/rustc_version-0.4.1-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/rustc_version-0.4.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/rustc_version-0.4.1-LICENSE-MIT.txt new file mode 100644 index 000000000..40b8817a4 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/rustc_version-0.4.1-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2016 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/rustfft-6.4.1-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/rustfft-6.4.1-LICENSE-APACHE.txt new file mode 100644 index 000000000..113e84387 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/rustfft-6.4.1-LICENSE-APACHE.txt @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/openless-all/ios/Resources/Licenses/Rust/rustfft-6.4.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/rustfft-6.4.1-LICENSE-MIT.txt new file mode 100644 index 000000000..cf5d4ed3f --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/rustfft-6.4.1-LICENSE-MIT.txt @@ -0,0 +1,19 @@ +Copyright (c) 2015 The RustFFT Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/uuid-1.26.1-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/rustix-1.1.5-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/uuid-1.26.1-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/rustix-1.1.5-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/rustix-1.1.4-LICENSE-Apache-2.0_WITH_LLVM-exception.txt b/openless-all/ios/Resources/Licenses/Rust/rustix-1.1.5-LICENSE-Apache-2.0_WITH_LLVM-exception.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/rustix-1.1.4-LICENSE-Apache-2.0_WITH_LLVM-exception.txt rename to openless-all/ios/Resources/Licenses/Rust/rustix-1.1.5-LICENSE-Apache-2.0_WITH_LLVM-exception.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/serde_json-1.0.151-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/rustix-1.1.5-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/serde_json-1.0.151-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/rustix-1.1.5-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/xz2-0.1.7-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/rustls-0.23.46-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/xz2-0.1.7-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/rustls-0.23.46-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/rustls-0.23.44-LICENSE-ISC.txt b/openless-all/ios/Resources/Licenses/Rust/rustls-0.23.46-LICENSE-ISC.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/rustls-0.23.44-LICENSE-ISC.txt rename to openless-all/ios/Resources/Licenses/Rust/rustls-0.23.46-LICENSE-ISC.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/rustls-0.23.44-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/rustls-0.23.46-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/rustls-0.23.44-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/rustls-0.23.46-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/serde_json-1.0.151-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/rustversion-1.0.23-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/serde_json-1.0.151-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/rustversion-1.0.23-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/syn-3.0.5-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/rustversion-1.0.23-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/syn-3.0.5-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/rustversion-1.0.23-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/sec1-0.7.3-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/sec1-0.7.3-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/sec1-0.7.3-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/sec1-0.7.3-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/sec1-0.7.3-LICENSE-MIT.txt new file mode 100644 index 000000000..68ddaa3c9 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/sec1-0.7.3-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2021-2022 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/syn-3.0.5-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/semver-1.0.28-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/syn-3.0.5-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/semver-1.0.28-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/thiserror-1.0.69-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/semver-1.0.28-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/thiserror-1.0.69-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/semver-1.0.28-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/thiserror-1.0.69-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/serde_json-1.0.154-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/thiserror-1.0.69-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/serde_json-1.0.154-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/thiserror-2.0.20-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/serde_json-1.0.154-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/thiserror-2.0.20-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/serde_json-1.0.154-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/sha1-0.11.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/sha1-0.11.0-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/sha1-0.11.0-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/sha1-0.10.7-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/sha1-0.11.0-LICENSE-MIT.txt similarity index 95% rename from openless-all/ios/Resources/Licenses/Rust/sha1-0.10.7-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/sha1-0.11.0-LICENSE-MIT.txt index 66cf75563..9ce242cec 100644 --- a/openless-all/ios/Resources/Licenses/Rust/sha1-0.10.7-LICENSE-MIT.txt +++ b/openless-all/ios/Resources/Licenses/Rust/sha1-0.11.0-LICENSE-MIT.txt @@ -1,6 +1,7 @@ -Copyright (c) 2006-2009 Graydon Hoare -Copyright (c) 2009-2013 Mozilla Foundation +Copyright (c) 2016-2026 The RustCrypto Project Developers Copyright (c) 2016 Artyom Pavlov +Copyright (c) 2009-2013 Mozilla Foundation +Copyright (c) 2006-2009 Graydon Hoare Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated diff --git a/openless-all/ios/Resources/Licenses/Rust/sha2-0.11.1-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/sha2-0.11.1-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/sha2-0.11.1-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/sha2-0.11.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/sha2-0.11.1-LICENSE-MIT.txt new file mode 100644 index 000000000..9ce242cec --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/sha2-0.11.1-LICENSE-MIT.txt @@ -0,0 +1,28 @@ +Copyright (c) 2016-2026 The RustCrypto Project Developers +Copyright (c) 2016 Artyom Pavlov +Copyright (c) 2009-2013 Mozilla Foundation +Copyright (c) 2006-2009 Graydon Hoare + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/signature-2.2.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/signature-2.2.0-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/signature-2.2.0-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/signature-2.2.0-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/signature-2.2.0-LICENSE-MIT.txt new file mode 100644 index 000000000..d8d87fe29 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/signature-2.2.0-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2018-2023 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/siphasher-1.0.3-COPYING.txt b/openless-all/ios/Resources/Licenses/Rust/siphasher-1.0.4-COPYING.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/siphasher-1.0.3-COPYING.txt rename to openless-all/ios/Resources/Licenses/Rust/siphasher-1.0.4-COPYING.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/tinyvec_macros-0.1.1-LICENSE-APACHE.md.txt b/openless-all/ios/Resources/Licenses/Rust/siphasher-1.0.4-LICENSE-APACHE.txt similarity index 99% rename from openless-all/ios/Resources/Licenses/Rust/tinyvec_macros-0.1.1-LICENSE-APACHE.md.txt rename to openless-all/ios/Resources/Licenses/Rust/siphasher-1.0.4-LICENSE-APACHE.txt index 69a59b412..d64569567 100644 --- a/openless-all/ios/Resources/Licenses/Rust/tinyvec_macros-0.1.1-LICENSE-APACHE.md.txt +++ b/openless-all/ios/Resources/Licenses/Rust/siphasher-1.0.4-LICENSE-APACHE.txt @@ -1,3 +1,4 @@ + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -186,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2020 Tomasz "Soveu" Marx + Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -199,4 +200,3 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - diff --git a/openless-all/ios/Resources/Licenses/Rust/siphasher-1.0.4-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/siphasher-1.0.4-LICENSE-MIT.txt new file mode 100644 index 000000000..969d061e8 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/siphasher-1.0.4-LICENSE-MIT.txt @@ -0,0 +1,17 @@ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/crc-3.4.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/smallvec-1.16.3-LICENSE-APACHE.txt similarity index 99% rename from openless-all/ios/Resources/Licenses/Rust/crc-3.4.0-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/smallvec-1.16.3-LICENSE-APACHE.txt index d368c8557..16fe87b06 100644 --- a/openless-all/ios/Resources/Licenses/Rust/crc-3.4.0-LICENSE-APACHE.txt +++ b/openless-all/ios/Resources/Licenses/Rust/smallvec-1.16.3-LICENSE-APACHE.txt @@ -1,5 +1,5 @@ Apache License - Version 2.0 January 2004 + Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION diff --git a/openless-all/ios/Resources/Licenses/Rust/smallvec-1.16.0-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/smallvec-1.16.3-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/smallvec-1.16.0-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/smallvec-1.16.3-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/spin-0.9.9-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/spin-0.9.9-LICENSE.txt new file mode 100644 index 000000000..b2d7f7bbd --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/spin-0.9.9-LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Mathijs van de Nes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/openless-all/ios/Resources/Licenses/Rust/spki-0.7.3-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/spki-0.7.3-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/spki-0.7.3-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/spki-0.7.3-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/spki-0.7.3-LICENSE-MIT.txt new file mode 100644 index 000000000..3294d7434 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/spki-0.7.3-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2021-2023 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/strength_reduce-0.2.4-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/strength_reduce-0.2.4-LICENSE-APACHE.txt new file mode 100644 index 000000000..113e84387 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/strength_reduce-0.2.4-LICENSE-APACHE.txt @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/openless-all/ios/Resources/Licenses/Rust/strength_reduce-0.2.4-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/strength_reduce-0.2.4-LICENSE-MIT.txt new file mode 100644 index 000000000..cf5d4ed3f --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/strength_reduce-0.2.4-LICENSE-MIT.txt @@ -0,0 +1,19 @@ +Copyright (c) 2015 The RustFFT Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/thiserror-2.0.20-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/syn-3.0.9-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/thiserror-2.0.20-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/syn-3.0.9-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/thiserror-impl-1.0.69-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/syn-3.0.9-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/thiserror-impl-1.0.69-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/syn-3.0.9-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/synstructure-0.13.2-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/synstructure-0.14.0-LICENSE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/synstructure-0.13.2-LICENSE.txt rename to openless-all/ios/Resources/Licenses/Rust/synstructure-0.14.0-LICENSE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/thiserror-impl-1.0.69-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/thiserror-2.0.21-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/thiserror-impl-1.0.69-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/thiserror-2.0.21-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/thiserror-impl-2.0.20-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/thiserror-2.0.21-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/thiserror-impl-2.0.20-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/thiserror-2.0.21-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/thiserror-impl-2.0.20-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/thiserror-impl-2.0.21-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/thiserror-impl-2.0.20-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/thiserror-impl-2.0.21-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/unicode-ident-1.0.24-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/thiserror-impl-2.0.21-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/unicode-ident-1.0.24-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/thiserror-impl-2.0.21-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/tinyvec-1.13.3-LICENSE-APACHE.md.txt b/openless-all/ios/Resources/Licenses/Rust/tinyvec-1.13.3-LICENSE-APACHE.md.txt new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/tinyvec-1.13.3-LICENSE-APACHE.md.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/tinyvec-1.13.2-LICENSE-MIT.md.txt b/openless-all/ios/Resources/Licenses/Rust/tinyvec-1.13.3-LICENSE-MIT.md.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/tinyvec-1.13.2-LICENSE-MIT.md.txt rename to openless-all/ios/Resources/Licenses/Rust/tinyvec-1.13.3-LICENSE-MIT.md.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/tinyvec-1.13.2-LICENSE-ZLIB.md.txt b/openless-all/ios/Resources/Licenses/Rust/tinyvec-1.13.3-LICENSE-ZLIB.md.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/tinyvec-1.13.2-LICENSE-ZLIB.md.txt rename to openless-all/ios/Resources/Licenses/Rust/tinyvec-1.13.3-LICENSE-ZLIB.md.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/tokio-1.53.1-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/tokio-1.53.3-LICENSE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/tokio-1.53.1-LICENSE.txt rename to openless-all/ios/Resources/Licenses/Rust/tokio-1.53.3-LICENSE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/tokio-rustls-0.26.5-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/tokio-rustls-0.26.6-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/tokio-rustls-0.26.5-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/tokio-rustls-0.26.6-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/tokio-rustls-0.26.5-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/tokio-rustls-0.26.6-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/tokio-rustls-0.26.5-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/tokio-rustls-0.26.6-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/tokio-tungstenite-0.24.0-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/tokio-tungstenite-0.30.0-LICENSE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/tokio-tungstenite-0.24.0-LICENSE.txt rename to openless-all/ios/Resources/Licenses/Rust/tokio-tungstenite-0.30.0-LICENSE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/tokio-util-0.7.19-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/tokio-util-0.7.20-LICENSE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/tokio-util-0.7.19-LICENSE.txt rename to openless-all/ios/Resources/Licenses/Rust/tokio-util-0.7.20-LICENSE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/transpose-0.2.3-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/transpose-0.2.3-LICENSE-APACHE.txt new file mode 100644 index 000000000..1d06c058e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/transpose-0.2.3-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2022 The transpose developers + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/transpose-0.2.3-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/transpose-0.2.3-LICENSE-MIT.txt new file mode 100644 index 000000000..2f3fc16c1 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/transpose-0.2.3-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2022 The transpose Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/tungstenite-0.30.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/tungstenite-0.30.0-LICENSE-APACHE.txt new file mode 100644 index 000000000..16fe87b06 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/tungstenite-0.30.0-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/tungstenite-0.24.0-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/tungstenite-0.30.0-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/tungstenite-0.24.0-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/tungstenite-0.30.0-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/unicode-ident-1.0.24-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/typed-path-0.12.3-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/unicode-ident-1.0.24-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/typed-path-0.12.3-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/utf-8-0.7.6-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/typed-path-0.12.3-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/utf-8-0.7.6-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/typed-path-0.12.3-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/unicase-2.10.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/unicase-2.10.0-LICENSE-APACHE.txt new file mode 100644 index 000000000..16fe87b06 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/unicase-2.10.0-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/unicase-2.9.0-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/unicase-2.10.0-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/unicase-2.9.0-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/unicase-2.10.0-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/utf-8-0.7.6-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/unicode-ident-1.0.27-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/utf-8-0.7.6-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/unicode-ident-1.0.27-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/find-msvc-tools-0.1.12-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/unicode-ident-1.0.27-LICENSE-MIT.txt similarity index 96% rename from openless-all/ios/Resources/Licenses/Rust/find-msvc-tools-0.1.12-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/unicode-ident-1.0.27-LICENSE-MIT.txt index 39e0ed660..31aa79387 100644 --- a/openless-all/ios/Resources/Licenses/Rust/find-msvc-tools-0.1.12-LICENSE-MIT.txt +++ b/openless-all/ios/Resources/Licenses/Rust/unicode-ident-1.0.27-LICENSE-MIT.txt @@ -1,5 +1,3 @@ -Copyright (c) 2014 Alex Crichton - Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the diff --git a/openless-all/ios/Resources/Licenses/Rust/unicode-ident-1.0.24-LICENSE-UNICODE.txt b/openless-all/ios/Resources/Licenses/Rust/unicode-ident-1.0.27-LICENSE-UNICODE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/unicode-ident-1.0.24-LICENSE-UNICODE.txt rename to openless-all/ios/Resources/Licenses/Rust/unicode-ident-1.0.27-LICENSE-UNICODE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/unicode-normalization-0.1.26-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/unicode-normalization-0.1.26-LICENSE-APACHE.txt new file mode 100644 index 000000000..16fe87b06 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/unicode-normalization-0.1.26-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/unicode-normalization-0.1.25-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/unicode-normalization-0.1.26-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/unicode-normalization-0.1.25-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/unicode-normalization-0.1.26-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/unicode-normalization-0.1.26-LICENSE-UNICODE.txt b/openless-all/ios/Resources/Licenses/Rust/unicode-normalization-0.1.26-LICENSE-UNICODE.txt new file mode 100644 index 000000000..11f2842a3 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/unicode-normalization-0.1.26-LICENSE-UNICODE.txt @@ -0,0 +1,39 @@ +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2023 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. diff --git a/openless-all/ios/Resources/Licenses/Rust/universal-hash-0.5.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/universal-hash-0.5.1-LICENSE-MIT.txt deleted file mode 100644 index 468bccc2d..000000000 --- a/openless-all/ios/Resources/Licenses/Rust/universal-hash-0.5.1-LICENSE-MIT.txt +++ /dev/null @@ -1,25 +0,0 @@ -Copyright (c) 2019-2020 RustCrypto Developers - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/universal-hash-0.6.1-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/universal-hash-0.6.1-LICENSE-APACHE.txt new file mode 100644 index 000000000..78173fa2e --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/universal-hash-0.6.1-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/universal-hash-0.6.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/universal-hash-0.6.1-LICENSE-MIT.txt new file mode 100644 index 000000000..bb7ff7c2c --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/universal-hash-0.6.1-LICENSE-MIT.txt @@ -0,0 +1,25 @@ +Copyright (c) 2019-2025 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/uuid-1.28.0-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/uuid-1.28.0-LICENSE-APACHE.txt new file mode 100644 index 000000000..16fe87b06 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/uuid-1.28.0-LICENSE-APACHE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/uuid-1.26.1-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/uuid-1.28.0-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/uuid-1.26.1-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/uuid-1.28.0-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/want-0.3.1-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/want-0.3.2-LICENSE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/want-0.3.1-LICENSE.txt rename to openless-all/ios/Resources/Licenses/Rust/want-0.3.2-LICENSE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/yoke-derive-0.8.2-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/yoke-derive-0.8.4-LICENSE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/yoke-derive-0.8.2-LICENSE.txt rename to openless-all/ios/Resources/Licenses/Rust/yoke-derive-0.8.4-LICENSE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/zerocopy-0.8.57-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/zerocopy-0.8.63-LICENSE-APACHE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/zerocopy-0.8.57-LICENSE-APACHE.txt rename to openless-all/ios/Resources/Licenses/Rust/zerocopy-0.8.63-LICENSE-APACHE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/zerocopy-0.8.57-LICENSE-BSD.txt b/openless-all/ios/Resources/Licenses/Rust/zerocopy-0.8.63-LICENSE-BSD.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/zerocopy-0.8.57-LICENSE-BSD.txt rename to openless-all/ios/Resources/Licenses/Rust/zerocopy-0.8.63-LICENSE-BSD.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/zerocopy-0.8.57-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/zerocopy-0.8.63-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/zerocopy-0.8.57-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/zerocopy-0.8.63-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/zerofrom-derive-0.1.7-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/zerofrom-derive-0.1.8-LICENSE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/zerofrom-derive-0.1.7-LICENSE.txt rename to openless-all/ios/Resources/Licenses/Rust/zerofrom-derive-0.1.8-LICENSE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/zeroize-1.9.1-LICENSE-APACHE.txt b/openless-all/ios/Resources/Licenses/Rust/zeroize-1.9.1-LICENSE-APACHE.txt new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/zeroize-1.9.1-LICENSE-APACHE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/Rust/zeroize-1.9.0-LICENSE-MIT.txt b/openless-all/ios/Resources/Licenses/Rust/zeroize-1.9.1-LICENSE-MIT.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/zeroize-1.9.0-LICENSE-MIT.txt rename to openless-all/ios/Resources/Licenses/Rust/zeroize-1.9.1-LICENSE-MIT.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/zip-2.4.2-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/zip-9.0.5-LICENSE.txt similarity index 89% rename from openless-all/ios/Resources/Licenses/Rust/zip-2.4.2-LICENSE.txt rename to openless-all/ios/Resources/Licenses/Rust/zip-9.0.5-LICENSE.txt index 4a45976f8..84d5f4d7a 100644 --- a/openless-all/ios/Resources/Licenses/Rust/zip-2.4.2-LICENSE.txt +++ b/openless-all/ios/Resources/Licenses/Rust/zip-9.0.5-LICENSE.txt @@ -19,6 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -Some files in the "tests/data" subdirectory of this repository are under other -licences; see files named LICENSE.*.txt for details. \ No newline at end of file diff --git a/openless-all/ios/Resources/Licenses/Rust/tinyvec_macros-0.1.1-LICENSE-ZLIB.md.txt b/openless-all/ios/Resources/Licenses/Rust/zlib-rs-0.6.8-LICENSE.txt similarity index 86% rename from openless-all/ios/Resources/Licenses/Rust/tinyvec_macros-0.1.1-LICENSE-ZLIB.md.txt rename to openless-all/ios/Resources/Licenses/Rust/zlib-rs-0.6.8-LICENSE.txt index f43568b03..f91124fc0 100644 --- a/openless-all/ios/Resources/Licenses/Rust/tinyvec_macros-0.1.1-LICENSE-ZLIB.md.txt +++ b/openless-all/ios/Resources/Licenses/Rust/zlib-rs-0.6.8-LICENSE.txt @@ -1,9 +1,7 @@ -zlib License - -(C) 2020 Tomasz "Soveu" Marx +(C) 2024 Trifecta Tech Foundation This software is provided 'as-is', without any express or implied -warranty. In no event will the authors be held liable for any damages +warranty. In no event will the authors be held liable for any damages arising from the use of this software. Permission is granted to anyone to use this software for any purpose, @@ -14,7 +12,8 @@ freely, subject to the following restrictions: claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required. + 2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. -3. This notice may not be removed or altered from any source distribution. +3. This notice may not be removed or altered from any source distribution. diff --git a/openless-all/ios/Resources/Licenses/Rust/zopfli-0.8.3-COPYING.txt b/openless-all/ios/Resources/Licenses/Rust/zopfli-0.8.4-COPYING.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/zopfli-0.8.3-COPYING.txt rename to openless-all/ios/Resources/Licenses/Rust/zopfli-0.8.4-COPYING.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/zstd-0.13.3-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/zstd-0.13.3-LICENSE.txt deleted file mode 100644 index 1190b3c62..000000000 --- a/openless-all/ios/Resources/Licenses/Rust/zstd-0.13.3-LICENSE.txt +++ /dev/null @@ -1,8 +0,0 @@ -The MIT License (MIT) -Copyright (c) 2016 Alexandre Bury - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/Licenses/Rust/zstd-safe-7.3.0-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/zstd-0.14.3-LICENSE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/zstd-safe-7.3.0-LICENSE.txt rename to openless-all/ios/Resources/Licenses/Rust/zstd-0.14.3-LICENSE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/zstd-sys-2.1.0+zstd.1.5.7-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/zstd-safe-8.2.0-LICENSE.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/zstd-sys-2.1.0+zstd.1.5.7-LICENSE.txt rename to openless-all/ios/Resources/Licenses/Rust/zstd-safe-8.2.0-LICENSE.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/zstd-sys-2.1.0+zstd.1.5.7-LICENSE.BSD-3-Clause.txt b/openless-all/ios/Resources/Licenses/Rust/zstd-sys-2.1.2+zstd.1.5.7-LICENSE.BSD-3-Clause.txt similarity index 100% rename from openless-all/ios/Resources/Licenses/Rust/zstd-sys-2.1.0+zstd.1.5.7-LICENSE.BSD-3-Clause.txt rename to openless-all/ios/Resources/Licenses/Rust/zstd-sys-2.1.2+zstd.1.5.7-LICENSE.BSD-3-Clause.txt diff --git a/openless-all/ios/Resources/Licenses/Rust/zstd-sys-2.1.2+zstd.1.5.7-LICENSE.txt b/openless-all/ios/Resources/Licenses/Rust/zstd-sys-2.1.2+zstd.1.5.7-LICENSE.txt new file mode 100644 index 000000000..93223ef4a --- /dev/null +++ b/openless-all/ios/Resources/Licenses/Rust/zstd-sys-2.1.2+zstd.1.5.7-LICENSE.txt @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, Alexandre Bury + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/openless-all/ios/Resources/Licenses/SPDX-Apache-2.0.txt b/openless-all/ios/Resources/Licenses/SPDX-Apache-2.0.txt new file mode 100644 index 000000000..137069b82 --- /dev/null +++ b/openless-all/ios/Resources/Licenses/SPDX-Apache-2.0.txt @@ -0,0 +1,73 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/openless-all/ios/Resources/Licenses/SPDX-MIT.txt b/openless-all/ios/Resources/Licenses/SPDX-MIT.txt new file mode 100644 index 000000000..d817195da --- /dev/null +++ b/openless-all/ios/Resources/Licenses/SPDX-MIT.txt @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/openless-all/ios/Resources/LocalizationSources.json b/openless-all/ios/Resources/LocalizationSources.json index c4bcf2509..825542855 100644 --- a/openless-all/ios/Resources/LocalizationSources.json +++ b/openless-all/ios/Resources/LocalizationSources.json @@ -1842,9 +1842,7 @@ "translationSource": "Native iOS translation" }, "模拟器(不是真机验收)": { - "callsites": [ - "App/ExperimentalPiPKeepAlive.swift:39" - ], + "callsites": [], "translationSource": "Native iOS translation" }, "正在保存翻译目标…": { @@ -2049,9 +2047,7 @@ "translationSource": "Native iOS translation" }, "此构建不包含私有接口诊断。请使用 OpenLessPersonal Scheme。": { - "callsites": [ - "App/ExperimentalPiPKeepAlive.swift:72" - ], + "callsites": [], "translationSource": "Native iOS translation" }, "没有匹配的市场风格": { @@ -2315,9 +2311,7 @@ "translationSource": "Native iOS translation" }, "真机": { - "callsites": [ - "App/ExperimentalPiPKeepAlive.swift:43" - ], + "callsites": [], "translationSource": "Native iOS translation" }, "离开页面或进入后台会暂停后续检查,不会中断已发出的 HTTP 请求。取消不会撤销已完成的登录;需要时请明确退出。": { @@ -3217,9 +3211,7 @@ "translationSource": "Native iOS translation" }, "隐藏式免跳转尚未通过真机验证。不会用常驻麦克风、可见小窗或收边拉手替代。": { - "callsites": [ - "App/ExperimentalPiPKeepAlive.swift:16" - ], + "callsites": [], "translationSource": "Native iOS translation" }, "需要下载系统语音资源": { @@ -6430,36 +6422,28 @@ }, "免跳转后台保活已关闭。": { "callsites": [ - "App/ExperimentalPiPKeepAlive.swift:68" + "App/ExperimentalPiPKeepAlive.swift:61" ], "translationSource": "Native iOS translation", "sourceSnapshot": "2026-10-20 public PiP keep-alive" }, "免跳转后台保活已激活;待机时不会采集麦克风。": { - "callsites": [ - "App/ExperimentalPiPKeepAlive.swift:69" - ], + "callsites": [], "translationSource": "Native iOS translation", "sourceSnapshot": "2026-10-20 public PiP keep-alive" }, "首次离开主应用时建立 PiP 保活;待机时不会采集麦克风。": { - "callsites": [ - "App/ExperimentalPiPKeepAlive.swift:71" - ], + "callsites": [], "translationSource": "Native iOS translation", "sourceSnapshot": "2026-10-20 public PiP keep-alive" }, "\nPiP 使用公开 AVSampleBufferDisplayLayer/ContentSource;未调用私有接口。": { - "callsites": [ - "App/ExperimentalPiPKeepAlive.swift:133" - ], + "callsites": [], "translationSource": "Native iOS translation", "sourceSnapshot": "2026-10-20 public PiP keep-alive" }, "系统尚未允许 PiP": { - "callsites": [ - "App/ExperimentalPiPKeepAlive.swift:159" - ], + "callsites": [], "translationSource": "Native iOS translation", "sourceSnapshot": "2026-10-20 public PiP keep-alive" }, @@ -6546,7 +6530,7 @@ }, "画中画已结束;返回主应用可恢复免跳转": { "callsites": [ - "App/ExperimentalPiPKeepAlive.swift:336" + "App/ExperimentalPiPKeepAlive.swift:334" ], "translationSource": "Native iOS translation (Build 11 resource audit)" }, diff --git a/openless-all/ios/docs/TODO-20261020.md b/openless-all/ios/docs/TODO-20261020.md deleted file mode 100644 index 369a87d23..000000000 --- a/openless-all/ios/docs/TODO-20261020.md +++ /dev/null @@ -1,59 +0,0 @@ -# OpenLess iOS 总待办(2026-10-20) - -基线提交:`62112923`(Build 7 工作快照)。分支 `appergb/合并两个分支`,未 push。 -验收原则:以用户真机连续操作 + 录屏为准;动画/闪烁类问题必须用录屏逐帧(或 `simctl recordVideo` 抽帧)证明,不能只凭静态截图宣称修复。模拟器通过 ≠ 真机通过,报告里分开写。 - -## A. 本轮用户反馈(10-20 真机录屏/截图) - -| # | 问题 | 现象 / 线索 | 目标与验收 | 负责通道 | -|---|---|---|---|---| -| A1 | **语音跳转、自动返回、换输入框恢复(最高优先级)** | 录屏 `~/Downloads/ScreenRecording_10-20-2026 16-26-45_1.MP4`:0–5s 微信键盘长时间“准备/连接”;5–9s 跳入 OpenLess 已开始录音却停在主 App,经多任务界面才回微信;16–25s 微信内换一个聊天输入框(不换 App)就再次出现“准备→请求未完成→启用语音→跳入 OpenLess”。 | 一次点击完成录音启动;仅在确需激活时跳转,且自动返回原输入位置,不要求再次点击/重录。换输入框(同 App)不得触发重新激活。需同时排查:会话恢复(后台资格/租约是否随输入目标变化被作废)、输入目标变化处理(997e1faa 冻结目标逻辑是否把“换聊天”误判为需要前台激活)、返回路由(source 解析失败/超时)。 | 逻辑 · Codex | -| A2 | 风格 / 设置 / 词典之间切换仍闪烁 | 左侧词典小岛 ↔ 右侧风格大岛切换时页面明暗变化、背景闪动、选中背景晚出现。上次“闪烁已修复”不成立。 | 检查页面容器、导航栏、安全区、Dock 变化是否造成重复布局或页面重建(视图 identity 变化、条件分支换根视图、背景随 tab 重建等)。录屏逐帧无明暗跳变、选中背景与 dock 同步。 | UI · Claude Sonnet | -| A3 | 选择“原文 / 正式表达”等风格时整页闪动 | 选择风格进入全局忙碌态:其他卡片同时禁用变淡,列表顶部插入进度/提示,完成后一起恢复 → 整页亮度与布局跳变。 | 反馈只集中在被选卡片(局部进度/勾选),其他内容、背景、滚动位置不变;不插入会推动布局的顶部提示。 | UI · Claude Sonnet | -| A4 | 历史页顶部结构错位 | `~/Downloads/IMG_1624.PNG` 初始顶部大片空白、搜索框偏下;`IMG_1623.PNG` 下拉时刷新区、搜索框、大标题“历史”上下错位;`IMG_1625.PNG` 滚动后正常。自动大标题 + 顶部搜索区 + 下拉刷新三者叠加。 | 统一布局关系(如用 `.searchable` + `.refreshable` 原生组合,或去掉自定义顶部搜索区)。验收覆盖:初次打开、上滑、下拉刷新、搜索聚焦、退出搜索。 | UI · Claude Sonnet | -| A5 | 翻译目标交互语义不对 | 设置页有“默认翻译目标”等全局选择文案;主麦克风长按未接入“向上选语言”手势。 | 主 App 只负责“添加目标语言”列表;键盘长按右侧翻译按钮,或长按“点击说话”后上滑选语言,松手确认,**仅本次录音翻译**;普通说话保持听写。明确规则:滑出取消、未选中目标=普通听写、普通按住说话不翻译。核实普通听写请求确实不带翻译参数。 | 文案/设置页 · Claude Sonnet;手势与请求逻辑 · Codex | -| A6 | 音效过响、音色奇怪;普通按钮反馈不合适 | 早先嫌小;改自制音效(KeyboardClickSounds.swift、Resources/KeyboardSounds/)后过响、生硬。部分工具栏按钮和录音起止共用按键音。 | 自然、轻、短;打字音与普通界面操作音明确区分;录音起止用独立的轻提示或仅触感。触感用户认为基本 OK,**音效与触感分别验收**,不要一起大改触感。 | 审美 · Claude Sonnet | -| A7 | 拼音键盘长按连删启动太慢 | 语音面板有自己的删除定时器,拼音/英文沿用 KeyboardKit 默认 repeat 设置,两套计时。 | 统一:首次删除即时响应、进入连删延迟、连删速度、松手立即停止;覆盖拼音组合串(composition)与已上屏文字。 | 逻辑 · Codex | -| A8 | 按钮大小、比例、间距与操作方式 | 早先发送按钮过小;上一轮只放大字号和局部按钮,不算验收。 | 对照之前 Typeless 截图(Artifacts/handoff ZIP 内竞品资料 / keyboardkit-public-capabilities 证据),逐项核对发送、退格、空格、模式切换、语音工具按钮的实际点击区域、视觉尺寸、行高、间距、布局比例;语音/拼音/英文三模式一致。 | 审美 · Claude Sonnet | -| A9 | 中英文联想体验(保留待验收) | 之前联想弱、近乎没有。Build 7 换了中文词库并增加英文下一词、中文续词(KeyboardPredictionEngine.swift、Resources/Prediction/)。 | 用户尚未确认。检查候选相关性、常用词排序、连续输入、候选条操作。先做自查报告,最终由用户真机验收。 | 用户验收(Codex 可做自查) | - -## B. 此前遗留 - -| # | 事项 | 状态 | -|---|---|---| -| B1 | PiP 隐藏式免跳转 | Build 9 已实现公开 PiP 保活;不是隐藏 PiP,后台重新开麦仍待用户真机验收。 | -| B2 | 真机验证:长时间后台、来电/Siri/锁屏/蓝牙中断、取消时换输入框竞态、App 重启 epoch | 未验证(A1 修复后一并录屏)。 | -| B3 | IPA 版本号 | 本轮交付 0.1.0 (9),未签名,见 `ios-build9-release.md`;旧包保留。 | -| B4 | 12 个旧 `ios-*-61` worktree 收敛 | 已完成。续接复核 Orca 中本项目剩 4 个工作区,12 份源码备份保留在 `~/openless-backups/worktrees/`。 | -| B5 | 桌面端 4 个已删除但仍被引用的 Rust 模块(model_store.rs 等) | 未处理,桌面构建不过;不在本轮 iOS 范围。 | -| B6 | UI 测试 3 项失败(“设置”按钮重复匹配等) | 用户要求暂不跑测试;保留记录。 | - -## C. 通道与文件归属(避免并行冲突) - -- **逻辑通道 · Codex(Orca,当前 worktree)**:A1、A7、A5 手势/请求逻辑。文件:KeyboardViewController.swift(非视图逻辑)、KeyboardStartHandshake、KeyboardVoiceLifecycle、NoJumpAudioSession、AppController、HostApplicationReturn.m、KeyboardHostApplication.m、TypingKeyboard.swift 的删除/repeat 设置、SpeakButton.swift 的手势逻辑、OpenLessShared 中协议/可用性代码。 -- **主 App UI 通道 · Claude Sonnet(独立 worktree)**:A2、A3、A4、A5 设置页文案。文件:OpenLessApp.swift、ProductDock.swift、DockSafeAreaBridge.swift、HistoryView.swift、StyleLibraryView.swift、StyleLibrary.swift(忙碌态表现)、StyleTranslationSettingsView.swift、AppDesign.swift。 -- **键盘视觉与音效通道 · Claude Sonnet(独立 worktree)**:A6、A8。文件:KeyboardClickSounds.swift、Resources/KeyboardSounds/、scripts/generate-keyboard-sounds.py、KeyboardStyle.swift、TypingKeyboard.swift 的布局/尺寸、KeyboardViewController.swift 的视图布局部分、SpeakButton.swift 的视觉。与逻辑通道共享文件时只改视图/尺寸代码。 -- 合并:两个 Sonnet worktree 完成后由协调者合回 `appergb/合并两个分支`,再统一出新 IPA(递增 build 号)。 - -## D. 10-20 晚间新增 - -| # | 问题 | 负责 | -|---|---|---| -| D1 | Build 8 真机仍做不到免跳转(录屏 ~/Downloads/ScreenRecording_10-20-2026 22-36-26_1.MP4)。对照 Typeless:靠隐藏 PiP 保活让主 App 后台重新开麦;OpenLess 仅输出会话保活,疑似后台不能新开麦。实现 PiP 保活 + 真机验证。 | Codex(max 推理) | -| D2 | 语音识别不应让用户选“中文全拼/English”,默认中英混合自动识别;preferences.language 只保留为打字布局。 | Codex(D1 之后) | -| D3 | 用户已明确自行签名;只交付未签名 IPA,不再要求登录 Xcode 或提供证书。 | 已确定 | - -### Build 9 续接状态 - -- D1:PiP 已提交,后续补齐可见视频源、时间基准、关闭竞态与后台配置;Release 构建及包结构校验通过。真机免跳转与中断恢复仍待验收,不标记为已解决。 -- D2:已完成,单独提交 `6b5a8d78`;SenseVoice 使用 `auto`,首页移除语言选择,布局不再重置 ASR。 -- D3:按用户选择交付未签名 IPA。没有安装真机,没有运行完整测试或 UI 测试。 -- A2–A9 及其他旧条目的验收状态沿用此前记录,本次未重复实施或验收。 - -## 2026-10-21 Build 10 续接 - -用户确认 Build 9 基础 PiP 免跳转已可用;新增隐藏/共存、锁屏和回主应用生命周期、B 站扬声器、拼音原文提交和按钮尺寸目标。实现与验收矩阵统一见 [Build 10 目标](ios-build10-goals.md)。自动验证与交付见 [Build 10 报告](ios-build10-release.md)。旧条目不代表这些新目标已经真机验收。 - -## 2026-10-21 Build 11 键盘体验 - -后续反馈的九项范围见 [Build 11 目标](ios-build11-goals.md):默认/自定义尺寸、润色选择持久化、中文候选与原始字母两步确认、@ 保留、后端说明、录音波形、翻译提示、thinking 文案及工具布局。交付见 [Build 11 验证](ios-build11-release.md)。 diff --git a/openless-all/ios/docs/architecture-and-processing.md b/openless-all/ios/docs/architecture-and-processing.md deleted file mode 100644 index cfd9a5d33..000000000 --- a/openless-all/ios/docs/architecture-and-processing.md +++ /dev/null @@ -1,63 +0,0 @@ -# iOS 架构与实际处理管线 - -更新:2026-10-16。参考来自用户提供的豆包/Typeless 拆包报告,不是竞品真机实测,也没有恢复其服务端。当前 KeyboardKit 已通过中英文、连续五次重开、系统听写键隐藏、符号/换行、Caps Lock/空格光标/横滑及横屏回归;完整浅色套件为 12 UI、4 原生通过、1 授权回调跳过。模拟器回归与构建成功仍不代表真机语音或完整后台能力。 - -## 进程与依赖 - -主 App 使用 SwiftUI/UIKit、AVFoundation、原生识别器和 Rust Core。键盘是 `UIInputViewController`,嵌入 **KeyboardKit 9.9.0 MIT 源码版**的 `KeyboardView`;它负责普通键位布局与触摸、大小写、符号、删除和光标手势,OpenLess 适配 Rime 组合/候选、英文补全和语音控制。不是仿造开源键盘外观,也没有引入 KeyboardKit 当前的闭源二进制版。 - -保留 UIKit 控制器和 nullable Objective-C 文档标识边界,避免断连时把 nil 强桥接为 UUID。无有效文档就停止目标相关操作,不生成假 UUID。键盘收起走 UIKit 原生生命周期;移除试用页额外的跨进程清焦点通知,避免收起旧会话的通知干扰下一次编辑。没有靠额外点击手势或反复 resign/become 修补。 - -- Rime 与学习数据留在扩展私有容器;基础中文全拼、English QWERTY 不要求完全访问。 -- SenseVoice、sherpa-onnx、ONNX Runtime、Rust Core 只链接主 App。键盘不加载模型、不打开麦克风、不持有提供商密钥。 -- Full Access 只允许共享偏好与控制通信,不代表后台开麦能力已实现。 -- App Group SQLite WAL 是持久命令/结果通道;Darwin 只通知重读。v2 协议明确区分 `.prepare` 和 `.begin`,准备不会录音。 -- 按 Apple 的[公开文档](https://developer.apple.com/documentation/uikit/configuring-a-custom-keyboard-interface),在控制器初始化和重新连接时设置 `hasDictationKey = true`。当前英语/语音完整屏幕截图中系统右下角麦克风已消失,系统地球键保留;没有遮挡层/负 inset 或私有 Hook。iOS 仍可能在 AX 树保留 disabled 的隐藏听写元素,测试检查不可点击并保留截图,而不是把 AX 的元素个数当作视觉隐藏证据。 - -## 普通模式:本地 ASR,不预设 LLM - -```text -用户主动准备 → 麦克风权限与已打包模型就绪(不采集) -用户主动开始 → AVFoundation 采集 → 16 kHz mono PCM → Core 会话 -主动停止 → 先关闭采集、排空尾帧 → 本机 SenseVoice 解码 -→ Core builtin.raw → 应用内结果/带目标身份的插入请求 → 回执与历史 -``` - -普通模式由 Rust Host 强制使用 `ios-sensevoice`、传统管线和 `builtin.raw`。不是仅在 Swift 界面隐藏云端开关;也不会静默回退 Apple Speech 或某个云端服务。 - -`NativeSpeechService` 选择 SenseVoice/Apple 原生适配器;本地模型由 `Packages/LocalSpeech` actor 管理,两个 CPU 线程、20 秒解码分块、最多 30 分钟音频,取消标记可在模型加载/分块之间检查。模型不进入键盘。公开音频夹具的非空中文结果证明运行了打包模型,不是准确率、实时性、真机资源或人声采集验收。 - -Core 保留空文本检查、已配置纠正规则和简繁偏好等本地步骤。iOS 尚无完整的用户纠正规则/简繁设置界面;识别器产生的标点、分段累计和裁剪空白不冒充 LLM 润色。`polishedText`/`finalText` 字段名本身也不证明调用过模型后处理。 - -## 开发者模式:显式配置,共用桌面 Core - -设置中在 1.5 秒连续间隔内点击版本三次,确认后取消并关闭本机任务、关闭旧 Core,切换普通/开发者存储,重建 Core、宿主 epoch 与界面。**这是应用业务状态重载,不是 iOS 进程退出/重启。**首次开发者存储仍是传统/原文模式,没有预配 LLM;随后显式保存的配置可以保留。 - -```text -传统:主 App PCM → Native ASR 或共享 Core ASR provider - → ASR 文本 → 可选、主动启用的共享 Core LLM provider → 最终文本 -全模态:主 App PCM → 共享 Core Omni provider → 识别/处理结果 -选区:冻结选区与原文 → 显式指令 → 共享 Core LLM → 可编辑预览 → 显式替换/复制 -``` - -复用 `TranscriptionRouter`、`SharedCloudTranscriptionEngine`、`TextPolisherRouter`、`SharedCloudTextPolisher`、`DictationEngineRouter` 和 `SharedOmniDictationEngine`,没有另写一套 Swift HTTP 语音管线。Core 的 ASR/LLM 预览阶段分别标记 `asr`/`llm`,后处理预览不会反馈进识别累计器。 - -主 App 提供频道增删、激活、地址/模型/认证编辑、模型列表和显式验证。普通频道用 UUID;Omni 保持 Core 自己的 providerType 标识与命名空间,不伪造 LLM 频道映射。需要桌面 OAuth 会话的 `codex_oauth` 未开放。 - -密钥进入主 App 的 generic-password Keychain:服务 `app.openless.ios.corehost.credentials.v1`,稳定账号 `standard`/`developer`,不依赖每次安装会变化的沙盒路径。快照、App Group、普通日志不返回密钥;提供商错误体不会直通公开失败消息。空密钥编辑表示保留,不会在打开配置页时自动验证/请求服务。真实云端和真机 Keychain 仍需对应环境验证。 - -选区改写目前只接入应用内试用编辑器。预览后仅当全文仍等于冻结原文、UTF-16 范围有效时才能替换。没有声称已接入任意第三方 App 的选区,也没有失败后静默写剪贴板。 - -## 上屏与资源边界 - -停止先释放音频硬件,再做解码/后处理。采集有无帧超时、30 分钟上限、有界 PCM 队列和尾帧排空;音量来自真实 PCM。空闲无键盘心跳,活跃键盘录音每秒续租,4 秒失联停止。 - -上屏绑定 session、epoch、document、revision、deadline 和持久尝试/回执。目标变化或写入不确定时保留待确认文字,不写进新输入框、不重放、不静默复制。`submitted` 只代表发送,不能宣称宿主已验证接受。当前不支持流式上屏。 - -## 与参考输入法相近和不同的部分 - -资料显示豆包/Typeless 都分离主 App、键盘、原生音频和进程通信;Typeless 有 RN/Hermes 前端和识别/refine 协议,豆包含录音保持与 PiP/hide/hook 相关符号。符号不能证明其真实停麦、隐藏方法、能耗或服务端提示词,也不授权复制代码、词库、商业资产或凭据。 - -**历史 PiP 路线状态(当前输出会话方案见 [Build 4](voice-handoff-fix.md))**:`ExperimentalPiPKeepAlive.isImplemented == false`、`isReady == false`,白名单为空。不会用长期静默占麦、可见 PiP/横幅/边缘把手或反复切 App 代替。App 退后台在没有有效保持能力时停止会话;跨 App 后台开始请求拒绝,而不是假装成功。 - -原始证据归档为 `openless/7-research/ios-input-methods/analysis/`;本机只读副本在忽略的 `Artifacts/references/`。蓝图明确是参考规范,不是已实现组件。当前的本机模型、开发者 provider 接线与后台缺口应分别验收。 diff --git a/openless-all/ios/docs/asr.md b/openless-all/ios/docs/asr.md deleted file mode 100644 index ddb9e4648..000000000 --- a/openless-all/ios/docs/asr.md +++ /dev/null @@ -1,48 +0,0 @@ -# ASR 入口与文件导航 - -## 当前怎么用 - -普通模式使用已打包的 **SenseVoice Small int8**,在主 App 本机识别,不预配 LLM,不经过 LLM 后处理。准备只做权限与模型初始化;用户主动开始后才采集声音。 - -### 语言语义 - -`KeyboardPreferences.language` 只表示默认打字布局(中文全拼或 English),不再决定语音识别语言。旧偏好仍按原有 `Codable` 解码规则读取,缺失字段继续使用默认中文布局。 - -- 键盘能力快照的 `sourceLanguage` 固定为 `auto`,普通听写默认支持中英文混合。 -- SenseVoice 的语言参数固定传 `auto`,不会因为用户切换中文全拼/English 而重建识别器。 -- Core 管理的云端 ASR、LLM 和 Omni 管线不接收键盘布局字段;翻译语言仍只由一次录音的上滑选择和已配置目标控制。 -- 开发者模式的 Apple Speech 仍使用系统可用的中文 locale 作为模块选择参数,但它与打字布局解耦;系统 Speech framework 的具体混合语言能力由设备和 provider 决定。 - -开发者模式在设置中连点版本三次后进入,安全重载界面与 Core,而不是退出/重启 iOS 进程。可选择原生 Apple Speech、Core 已支持的云端 ASR、自定义服务、LLM 后处理或全模态管线。云端模型列表/验证只在主动操作时请求,密钥保存在主 App Keychain。 - -2026-10-16 用户反馈应用内语音已经能正常聆听、感知,正在手工测试。这与自动化公共音频 smoke 分开记录;不据此宣称跨 App 后台免跳转完成。 - -## 原生实现统一放在 `App/Speech/` - -| 文件 | 职责 | -| --- | --- | -| `NativeSpeechService.swift` | 原生识别入口;选择 SenseVoice 或 Apple,控制输入、完成、取消和释放 | -| `AudioCapture.swift` | AVAudioSession/AVAudioEngine;按系统版本选择 tap,先停硬件再排空处理 | -| `AudioPCM.swift` | 可发送的音频值快照、16 kHz mono S16LE 重采样、有界尾帧 | -| `MicrophoneAccess.swift` | iOS 16 与 17+ 的权限 API;不从键盘请求麦克风 | -| `AppleSpeechService.swift` | Apple provider 门面与授权桥接;按系统版本选择后端 | -| `ModernAppleSpeechService.swift` | iOS 26+ 的 SpeechAnalyzer/系统转写和资源准备 | -| `LegacyAppleSpeechService.swift` | iOS 16–18 的 SFSpeechRecognizer,本机模式、取消、结束超时和结果回调隔离 | - -其他相关入口: - -- `Packages/LocalSpeech/Sources/LocalSpeech/SenseVoiceRuntime.swift`:离线 C API actor、两个 CPU 线程、20 秒分块、取消与长度上限。模型不进入键盘。 -- `CoreHost/src/lib.rs`、`control.rs`、`credentials.rs`:普通/开发者模式约束、复用桌面 Core provider、控制请求和 Keychain。 -- `App/AppController.swift`:业务状态、音频会话生命周期与 Core 回调;不实现另一套 HTTP ASR。 -- `PlatformShared/SharedContainer.swift` 与 `Packages/OpenLessShared/`:v2 准备/开始命令、输入目标身份和持久上屏回执。 - -桌面的实验性本地 ASR 仍以仓库根目录的 [experimental-local-asr.md](../../../docs/experimental-local-asr.md) 为入口。之前研究的 TextStream/Qwen3-ASR Mac MLX 子进程不是 iOS 实现,不在手机上包装桌面 subprocess。 - -## 资源与验证 - -- [固定模型、运行时和哈希](local-speech-provenance.md) -- [iOS 16 起的版本路径与兼容边界](platform-support.md) -- [完整处理管线、显式选区与后台缺口](architecture-and-processing.md) -- [测试/安装包/截图证据](verification.md) - -不重新下载已校验资源,不删除已有桌面实验 ASR 工作,也不把 ASR 分段累计、识别器标点或 `finalText` 字段冒充 LLM 改写。 diff --git a/openless-all/ios/docs/continuation-checkpoint.md b/openless-all/ios/docs/continuation-checkpoint.md deleted file mode 100644 index 1d96e2cd9..000000000 --- a/openless-all/ios/docs/continuation-checkpoint.md +++ /dev/null @@ -1,111 +0,0 @@ -# 恢复检查点:最终集成/审查/实际产品验收阶段 - -更新:2026-10-16。先读本文件、[requirements-and-todo.md](requirements-and-todo.md)、[execution-plan.md](execution-plan.md);不要重做已完成worker/fixture或将部分功能冒充完整产品通过。 - -## 用户最新要求与资源 - -- 补齐剩余需求,所有子Agent **GPT-6.1-Sol high**,不许更高;实现后Codex独立review,主Agent再验收。 -- 验收使用 **Orca自带唯一模拟器真实操作与截图**,不能只有编译/XCTest。 -- 新词典参考已实现列表/来源筛选/浮动加号/紧凑弹窗。 -- 新56.45秒键盘视频实际路径 `/Users/trip/Library/Application Support/com.netease.uuremote/Download/ScreenRecording_10-16-2026 15-35-41_1.mp4`。已抽帧,SHA `fe35d4f505d27fc98b7f5acc51341b0cf215466ea043d32c309ba85059c411c1`;`docs/keyboard-video-reference.md` 与 `Artifacts/video-keyboard/`。 -- 用户还在 **Codex Test另一个会话**要求蓝色“启用语音”、去除opaque背景以衔接系统Liquid Glass、独立第五个“风格”Tab及我的风格/市场/翻译目标分区、真实raw风格翻译和失败不插原文。其发来的`[Codex coordination]`是协作消息,不是要重复创建worker。 -- 主Agent不要每一步宣称完成;完成可验证工作后统一汇报,外部缺口/未实现项不得藏起来。 - -## 工作区与保护 - -主目录 `/Users/trip/orca/workspaces/openless/合并两个分支`,分支 `appergb/合并两个分支`,HEAD `e4727697ddf559c3f4d21dd1cd4ac94d5ea935ef`。大量原有desktop/Android/ASR dirty改动保留。不得commit/push/reset/批量格式化;iOS仍untracked,git diff不覆盖全部新增源。 - -普通模式bundled SenseVoice/local/raw;不预配云凭据、不静默Apple/cloud回退。Core负责领域/网络/crypto/prompt;键盘不采集、不运行Core/ASR、不存keys。自动测试不授权麦克风、不录制人声、不动真实账户/云服务。用户数据、Full Access、输入法列表和唯一模拟器不清空。 - -唯一VM:`A4B5F07C-C6C9-498F-BEEC-AC7AC98B8A6A`,OpenLess iOS27 QA。当前 **模拟器租约交给Codex Test做误收起恢复/最终表面检查**,是否归还以 `Artifacts/keyboard-codex-coordination.md` 最新节和其 `SIMULATOR_RETURNED=YES` 消息为准。主Agent在归还前不要安装/launch/XCTest/改外观,generic build和Core测试可以。 - -Orca固定CLI `/Applications/Orca.app/Contents/Resources/bin/orca`;实际serve-sim runtime **1.4.227**(旧1.4.224不存在)。已读orca-cli和orca-emulator版本指南。所有normalized tap要先AX/截图并核实效果。扩展AX有时是键盘局部坐标,不直接当全屏坐标。 - -## 已完成并集成的组件(不重跑worker) - -### 第一波 A/B/D - -Workflow `af5b23fc-7fa2-43f5-aa9f-75f105c1e13d`全部完成。 -- A词典UI run `437b8215-7a49-4ae9-8f78-269ad70f0add`,五个Swift文件已基线SHA核验合入;主Host source filter all/learned/manual分页前过滤,保留Core真实分类。 -- B Core风格/marketplace/ZIP staging/capability run `915eb1bf-56d9-4a20-ae1d-c7ef652e4213`,已集成。 -- D真实device-flow账户/禁止错误全量同步 run `5a2ebe6c-f8ef-4a93-8b5c-cf972dc0433d`,已集成。 -报告在 `Artifacts/execution-wave1/*-handoff.md`。 - -### 第二波 B-UI/F/T/E - -原workflow `50c5fb79-bdc8-4479-b2d8-2564e50dd85e` 因extension reload显示stopped,**不能据此重启已完成children**。 -- B-UI `abdb83a7-cd1d-4e08-b53e-d5e1268efa1f` 已完成并合入7新Swift文件,style ZIP bounded copy、no auto-network、同consumer缓存。 -- T `e685c87f-f97e-4c13-bb63-d78380977334` 已完成并合入6Core文件:有序多目标/旧scalar迁移/presence marker/freeze会话目标。主Agent补了sync registry TextList分类和serde-skipped元数据测试,完整Core曾1191通过1忽略。 -- F `f91fdc18...`曾超时,续run **`1db1e696-d75d-481a-9b1c-26ed13101894`已完成**,13文件已合入:有限本地scope/overlay/confirmed-ID/numeric identity facade。**没有remote有限sync,不可用singleton full vault上传**。服务端缺namespace/CAS/receipts/authenticated scope,详根docs/encrypted-cloud-sync-limited-scope.md。 -- E录音维护 `a8f597f1...`等待决策超时,续run **`7d2d0cd4-95b7-42f9-b4cd-dfac286ca259`已完成**:4源文件+lib/control hunks已合入;捕获journal、恢复unfinished、隔离孤儿、retention清扫;clear保留Quick Notes WAV。 -报告在 `Artifacts/execution-wave2/*-handoff.md`。第二波独立Codex run `e4dc00f0-e320-480e-9cc7-ad4bca6d4d65`已完成,无证实P0/P1/P2,报告 `execution-wave2/codex-review.md`;明确不验收远程sync/后续键盘等。 - -### 第三波 C-UI/账户/Core voice/词典桥/本地化 - -全部实施worker已完成并合入,不再启动同任务: -- C-UI `5925582e-307b-4a65-a572-29dcee945cd0`(workflow `49ad043e-edb7-49ae-b67b-bf5b51bcb1aa`),10文件视频键盘/intent/capabilities/generatedPreview/confirm DTO。最终声明WRITER RELEASED,报告 `Artifacts/execution-wave3/keyboard-video-handoff.md`。 -- 账户App `cf177465-899f-4b97-b459-048ba06a483b` 同workflow,4Swift文件已合入,status/deviceflow/poll/cancel/signout真实Core调用;未联网验收,remote sync禁用不算实现。 -- Core voice `3696b18a-292f-4881-ab2c-34835ee5eac3`:voice_intent新模块+api/auxiliary/ports/router/cloud provider最小seam已合入。普通与帮写录音强制unmodified raw/native ios-sensevoice,不改用户style;prepare compose冻结endpoint/key/model/options,SecretValue内存;生成专用Core compose preview,**不会自动insert**。 -- 词典/Rime `06f4ba8f-8b91-4102-a8fa-903252a83d54`(workflow `f8bbe755-2d38-4888-85aa-09ce80f3be8a`),12文件已合入;主Agent接了control metadata invalidation/confirmation、App VocabularyBridge、Shared vocabularyDecision、键盘真实纠正建议menu和Rime快照import。纯BMP中文CFStringTransform pinyin生成独立script_translator,保留原personal userdb/活动composition后更新。**真实ASR自动新词抽取不存在;现有为Core手改纠正建议,不可冒充自动ASR候选**。 -- 本地化 `2dc50399-47b7-4df7-a5cc-eb01de890bbf` 同workflow,11文件已合入;续run **`b4edeee4-9070-4aa6-9584-8307446c24cf`已完成**,68新词+InfoPlist资源已合入,主Agent又加Undo/Space与styleUI31keys。资源现668keys每语言(按audit实际为准)。UI语言system/zh-Hans/en独立ASR/翻译;主题独立mailbox `interface.preferences.v1`,无FullAccess系统fallback。Root modifier保持runtimeID不以language重置录音。App动态文案迁移仍不完全;资源覆盖≠所有runtime String均已本地化。 - -## 主Agent已接的关键公共入口 - -- CoreBridge `onConfigurationMutation` + pending-controls状态,成功管理操作使键盘capabilityRevision失效再刷新;只读nested account status/vocab snapshot/proposals不触发循环。 -- `KeyboardCapabilityProjection.swift` 投影无秘密,**最新 canAttemptTranslation+Core effectiveTargetLanguages**,不再自行用canAttemptPolish推断;capability配置变化和源语言变化均轮换revision。 -- AppController start验证command.intent/capabilityRevision/source/language、Core无待处理controls;ActiveSession保存KeyboardVoiceStart。Core独立验证意图,普通模式不外发。 -- completed outputDisposition preview只接受matching core/client/intent/promptKind,发布有界generatedPreview(180s),保持App ActiveSession直到confirm/cancel/expiry。confirm只传ID/捕获元数据;Shared纯校验通过后一次性storeInsertion,清preview避免双份大文本;generatedInsertionID防重放。 -- 新 `.vocabularyDecision` 独立于start/mic;请求claim-once+epoch/deadline,只有真实proposal ID/generation可accept/reject/dismiss;不会替换当前输入。 -- Mailbox `revokeInsertion` 将pending→targetChanged、attempting→uncertain,submitted不变,不删attempt证据;取消/确认失败调用。 -- 键盘CUI-FIX02 enqueue false立即本地收尾+成功发送后5s专属client回执deadline(全局fresh不能代替确认)。CUI-FIX03 insertText后ack失败也清UI,durable uncertain不重插。 -- 确认storeInsertion失败:明确phase error、清preview/ActiveSession、保留主App结果,不锁死按钮到180s;不靠文案识别协议。 -- 安全撤销:`KeyboardUndoRecord`内存仅本键盘已写入且未替换selection、≤128字、document/revision/before/after完全一致;每delete步骤重核上下文,不符合就停。按钮仅有可撤销record时显示。 -- 新source根Scene语言/theme都已接;旧 `appThemePicker`测试入口换为 `openInterfacePreferences`。键盘UIInputView不替换,仅透明内容以跟随系统材质;确认动作/撤销/风格/翻译用公开API。 - -## Codex Test(外部会话)协作事实 - -同主cwd,但只在tmp产patch,主Agent唯一源集成者。以 `Artifacts/keyboard-codex-coordination.md`为协调入口,精确范围已授权。 - -已读并apply-check后合入: -1. `keyboard-codex-ui-fixes.patch` SHA `a236ba35655bcdc25316f9ef07e68d352c6a3f97c77c6535d9a3eebdc65fee55`:arc最终release(at:)/旋转命中/分页/取消区/辅助字号纵向滚动;真实helpwrite圆触区;processing-only sweep与reduceMotion/window/offpage生命周期。8UIKit fixture测试通过,**不重跑旧fixture**。证据完整保存在 `Artifacts/keyboard-codex-evidence/`(从tmp复制)。 -2. surface patch最终SHA `e3d85f404846c6975d03debae142963e2d18749038eceb6a84c512919e8fd2a9`(最初749c废弃):native背景clear、renderBackground:false、蓝色Enable、透明overlay隐藏背后控件且longpress视图只layer.opacity0保持gesture。 -3. caption patch SHA `031f4091dfce5cd937476b8f9ca57176a9c73a3a878545d43c60506bb84dfd33`:bounded键盘hint字体随DynamicType增至22,防Return重叠。已合入。 -4. key-legends patch SHA `025f56e58670c26d76ae60d85471084ed6c303febb6f18c9f8e83cd96b1fa533`:只在KeyboardKit standardButtonText为空时给Space/123/ABC/#+=fallback,不改action。已合入。 -5. STYLE UI patch SHA `0c995bf1350e886a8dd73b554d8b717a8ca194d1b01c443bb174f2b1c71f501d`:第五风格Tab、MyStyles/Market/Targets、模型设置直接复用DeveloperSettings;selectedTab上移App @State跨runtime reload保留、不改重启默认。Destination自己的SceneStorage待恢复model sheet(等!restarting),配置关闭后refresh原consumer不丢缓存。已合入并更新UITest;**不再使用主Agent曾预留的requestProviderConfiguration,未使用方法/flags已删除**。 -6. `/tmp/openless-translation-swift-tests/style-library-tests.patch` 已从openless-all apply,七新增Swift声明+14旧,共21声明(含参数化)。已存 `Artifacts/execution-wave3/translation-model-tests.patch/report.md`;最终iOS执行仍待跑。 - -外部Codex已正式产品Orca验过blueEnable/native背景/EN/拼,`Artifacts/keyboard-native-surface-qa/README.md`。最大字号当时失败→caption修复,后续新包复查仍待其回报。当前租约延续用于用户误收起keyboard恢复/最终surface复查;不要抢设备。 - -## 最新新增翻译业务修复(已写源码/Host测试通过,尚未重建静态库) - -用户要求raw风格可显式翻译,且翻译失败绝不能插原文假装成功: -- CoreHost capability使用developer+traditional+configured+enabled/supported channel独立计算`providerConfiguredAndEnabled`;`canAttemptTranslation`看任一Core effective target,`canAttemptPolish`仍反映postprocess/style开关。helpWrite保持既有显式开关。 -- voice_intent translation admission读canAttemptTranslation,不借canAttemptPolish;Core仍校验configured exact target。 -- PipelineDictationEngine仅context.translation_active时强制PolishFailurePolicy::Fail并拒绝空结果。普通polish UseRawText保持;raw transcript在failure中保留,绝不调用insert。 -- Host新测试raw+多个目标(默认等working language、备用有效)、合法tagged翻译成功、空/malformed输出失败不插原文,通过。 -- 初版新fixture给plain text而缺Core translation markers导致失败,是fixture错误已改为合法 `[[OPENLESS_POLISHED_SOURCE]]…[[OPENLESS_TRANSLATION]]…`;并保留malformed失败用例。 - -## 当前证据/下一验证 - -- `execution-wave3/translation-integrated-tests.log` **Host70通过**,包含metadata9/voice8+新增raw translation等。 -- `execution-wave3/core-full-final-bounded.log` **Core1193通过、1忽略**,未跳过新增测试;同目录最初 `core-full-final.log`进程SIGTERM,没有测试断言失败,原因未证实;以4线程有界重跑完整通过,保留失败事实。 -- Shared最新曾 **57 Testing通过**(额外XCTest本地化模块结果分开看日志),随后新增Undo测试,仍需最终再跑。首次InsertionRevocation测试`#require`缺内部try已修复,不是产品失败。 -- 当前所有App测试 `build-for-testing` **通过**,`execution-wave3/final-app-test-build.log`;修复两个worker XCTest class继承init actor冲突:改nonisolated class+每test @MainActor,不改变断言。 -- 旧第三波App native:词典6+历史4,style19,account18已实际iOS测试宿主通过;产品导航/词典取消2UI通过,记录在对应日志。**新增多目标21、projection、locale、VocabularyBridge及最终keyboard仍待最终实际执行**。 -- ENV02原键盘popup恢复:Orca softwareKeyboard helper toggle(type12)后globe切系统拼音再回OpenLess,原三轮refocus测试通过 `keyboard-popup-restored.xcresult`。系统输入框也可能选中OpenLess,不用“Settings也失败”排除扩展。恢复具体原因不可过度归因。 -- 已安装产品最近 `caption-legends-product-build.log`(pid72755;后续Codex可能重启了App),包含caption/keylegends/blue/native背景/locale/69Host版本,**尚不含最新Core raw translation修复和第五StyleTab**。源 `final-app-test-build.log`包含新UI,但没有安装。最终必须重建Core双平台后link/install。 -- 当前资源audit5测试通过(`localization-audit.log`);636+31keys按实际文件数为准。infoPlist英文/中文2项随资源包,品牌不变。 - -## 活动review/最后行动顺序 - -仅活动独立final Codex review **`e581dd6e-a1d7-4a0a-a682-8c8254d7ca37`**(profile `.pi/agents/openless-codex-review-61.md`,explicit gpt-6.1-sol high/read-only);收到完成读取 `review/final-ios-integration.md`。它启动后又有第五Tab/raw translation改动,必要时对这部分做有界follow-up review,不能把旧结果覆盖新改动。 - -1. 回答外部Codex协调/等其归还唯一模拟器;不重跑已过8fixture。 -2. 处理final review具体findings,保留未实现项真实状态;查source改动和模型限制。 -3. 更新静态Core双平台(显式Rust1.98、fresh `.build/core-ios16`,不可用旧CoreHost/target iOS27缓存),link最终App/extension。 -4. 实际iOS native tests(新增projection/vocab/locale/style21)、定向keyboard与页面UI、device unsigned build/every Mach-O floor16。 -5. 主Agent Orca最终实际操作/截图:第五Tab、三分区、目标添加/排序/删除可用专有测试目标后恢复;词典专有测试词后只删自己数据;语音/EN/拼/refocus/depth/大字号/dark/locale,防误录/误调用服务;导出真实fixture时标注fixture不能假装用户录音。 -6. 收尾当前README/verification/requirements/execution状态,清Runner/测试条目/无用临时资源(不删除复用模型或任何user数据),保留有价值证据,恢复light/large/用户原locale等。 -7. 不声称全需求已完成:remote limited sync需服务端namespace及Host vault/runtime hooks,hidden no-switch仍false需真机实现/签名,自动ASR新词抽取无真实接口,风格原位更新不支持,真实云/旧系统/真机无新证据。用户要求all不是伪造完成的授权;最终明确这些残余。 - -工具:XcodeGen绝对相对path `ios/.build-tools/XcodeGen/.build/release/xcodegen`;Rust显式RUSTC/RUSTDOC1.98;DerivedData-compatibility产品。codexhost不存在,未创建CodexHost thread;原生Pi和独立Codex CLI profile均已正常运行。所有active worker已交接,只有final review/外部Codex QA在继续。 diff --git a/openless-all/ios/docs/execution-plan.md b/openless-all/ios/docs/execution-plan.md deleted file mode 100644 index 42456e199..000000000 --- a/openless-all/ios/docs/execution-plan.md +++ /dev/null @@ -1,118 +0,0 @@ -# 剩余工作执行清单与分工 - -更新:2026-10-16。主需求见 [requirements-and-todo.md](requirements-and-todo.md)。本清单先于新一轮实现落盘;勾选只表示对应阶段已经实际完成,不代表整个功能验收通过。 - -## 执行合同 - -- 所有子 Agent:`openai-codex/gpt-6.1-sol`,thinking **high**。不使用其他模型,不使用 xhigh/max,不允许子 Agent 再委派。 -- 各组件在当前 `appergb/合并两个分支` 的隔离子工作区运行,复制当前未提交源码快照;不以旧 HEAD 代替 dirty iOS 源码。不 commit/push/reset,不覆盖其他平台工作。 -- 多组件任务:独占实现 → 窄接口交接 → 主 Agent 集成 → Codex 独立审查 → 原作者或主 Agent 修复 → 主 Agent 最终验收。 -- 模拟器由主 Agent 独占;worker 不启动、删除或重置设备,不改权限,不用用户数据做破坏性测试。Core 测试用临时目录/memory vault;语音验证优先公共音频 fixture。 -- 最终使用 Orca 自带模拟器实际操作与截图,AX/截图证明结果;命令返回 ok 不算操作成功。保留唯一设备 `A4B5F07C-C6C9-498F-BEEC-AC7AC98B8A6A`。 -- 发现环境/服务/签名缺口时,继续可独立完成的代码,但缺失验收不得勾选。隐藏免跳转不得用假按钮、空 allowlist、长期静默占麦或可见小窗冒充完成。 - -## 完整执行 To-do - -### 0. 需求、环境与调度 - -- [x] 汇总既有 63 条需求及最新词典截图、键盘退化追问。 -- [x] 新增 DICT-01~04、REF-05、EXEC-01~03 和本执行清单。 -- [x] 核对当前模型注册名:`openai-codex/gpt-6.1-sol`;显式 high。 -- [x] 建立隔离组件工作区、当前 dirty 源码快照与 183 个关键源文件 SHA-256 基线;`Artifacts/execution-wave1/*.json`。不提交工作区。 -- [x] 首波 worker 运行记录:workflow `af5b23fc-7fa2-43f5-aa9f-75f105c1e13d`;A `437b8215-7a49-4ae9-8f78-269ad70f0add`、B `915eb1bf-56d9-4a20-ae1d-c7ef652e4213`、D `5a2ebe6c-f8ef-4a93-8b5c-cf972dc0433d`,全部 `openai-codex/gpt-6.1-sol:high`。报告为 runtime-managed `wave1/{dictionary,style-core,account-sync-core}.md`,当前运行中。 - -### 1. 词典截图实现(A,DICT-01~04/LANG-04) - -- [x] 词典大标题、按需搜索、所有/自动/手动胶囊筛选代码由 A 交接并集成;Orca 当前空态截图已核对(非最终全部状态验收)。 -- [ ] 按真实 Core 来源分页前筛选并接入 UI。主 Host 已支持 source all/learned/manual,遵循 Core exact learned marker 规则,查询与分类在 total/100 条分页之前应用;210 条混合测试通过(`Artifacts/execution-wave1/vocabulary-source-tests.log`),UI 待 A 交接。 -- [ ] 简洁分隔列表、来源图标、必要停用状态;备注与使用次数按需查看。 -- [x] 右下浮动加号/紧凑添加弹窗已集成;Orca 实际打开/取消后回到空词典且不写入,截图 `Artifacts/execution-wave1/dictionary-new-modal.png`。保存/失败运行验收仍在总验收门。 -- [ ] 编辑/启停/删除放入可发现的行菜单;长词、中文、emoji、空态、加载、错误、深色与大字号。 -- [ ] 模型/筛选/输入校验测试;模拟器实际打开、搜索、筛选、添加取消与截图。 - -### 2. 风格、能力与翻译配置(B,STYLE-01~05/SET-04/LANG-01~05) - -- [ ] 复用 Core 市场服务、风格仓库/归档校验,确认本地归档实际格式,不发明 VIP 付费。 -- [ ] 窄 Host API:市场列表/详情/下载,已安装包读取/启停/选择/更新/移除/导入。 -- [ ] 能力快照区分普通原文、LLM 未启用、缺凭据、服务未验证、模型/权限/网络错误;不泄露密钥。 -- [ ] App 只读风格管理与文件导入 UI,无包编辑器。 -- [ ] 翻译语言目录和目标新增/删除/排序/持久化,对齐 Core 契约,不凭参考图硬写不兼容上限。 -- [ ] 统一提供商/模型设置入口,保留初始本地原文和显式启用原则。 -- [ ] 本地校验/假传输契约测试;真实市场/服务结果与网络缺口单独记录。 - -### 3. 键盘意图、词条确认与 Rime(C,KBD-01~08/SYNC-03/05/06) - -- [x] VID-01:找到用户上传的56.45秒视频并实际抽帧,详见 [keyboard-video-reference.md](keyboard-video-reference.md)。 -- [ ] VID-02~06:按视频实现语音/帮写/弧形翻译/处理态的UI和逻辑,保留EN/拼真实打字引擎;视频未展开的26键不臆造。 - -- [ ] 明确点按、按住、选择态、取消、越界、中断、释放的状态表。 -- [ ] 语音会话开始固定源语言/目标/风格/提供商能力,durable IPC 兼容旧状态。 -- [ ] 长按/滑动翻译目标,只在真实能力可用时开放,普通按住说话不改成松手一律取消。 -- [ ] 提示词驱动帮写:描述要写什么 → ASR/LLM → 预览 → 用户受控插入;不读取选区充当帮写。 -- [ ] 键盘内选择实际可用的润色模式/风格。 -- [ ] 词条建议去重、确认/拒绝/关闭;确认前不写词典/不替换原文。 -- [ ] Core 领域词条映射到 Rime 本机词典,不跨进程同步正在写的二进制学习文件。 -- [ ] 保留 KeyboardKit 文字输入、无 Full Access 基础打字、系统地球键/听写键处理、目标变更保护和减少动态效果。 -- [ ] 协议/状态机测试及主 Agent 的真实键盘操作验收。 - -### 4. 账户与加密同步(D,ACCOUNT-01/02/SYNC-01~08) - -- [ ] 核对实际账户认证、回调 URI/移动平台约束、Keychain 与登出契约。 -- [ ] 复用 Core E2EE 注册/初始化、密钥派生、恢复、同步、冲突和错误,不另写 Swift crypto/HTTP。 -- [ ] 同步仅确认词条、模式、风格引用;缺失风格可补下载/提示,不默默改服务商。 -- [ ] 登录/未登录/实际权限、恢复与错误界面;不编造 Free/Pro、升级、支付或订阅。 -- [ ] 本地数据隔离与退出/切账户,不因错误密钥/网络/恢复失败覆盖数据。 -- [ ] 离线/冲突/错密钥/重试/跨端契约测试;真实认证/服务验收单独记录。 - -### 5. P1 余项、兼容、键盘退化与免跳转(E) - -- [ ] HIST-04/05:详情复制/删除/重新润色 UI,真实服务可用时验证;原文不可被预览覆盖。 -- [ ] HIST-06~10:播放/停止/系统导出真实 WAV,完成态禁止导出;崩溃遗留恢复、历史过期资产清扫。 -- [ ] SET-02/03:App/键盘主题一致性,UI 语言独立于识别/翻译;复用已有本地化资源。 -- [ ] BASE-02:README、验证、ASR、许可/模型 notices 与实现一致。 -- [ ] ENV-02:已用 Orca 实际点击对照,系统 Settings 搜索框有光标、能接收 ASCII,但同样无软件键盘;不只发生在 OpenLess。证据见 [keyboard-environment-check.md](keyboard-environment-check.md)。仍需定位具体根因,不能以系统同现象当作修复通过。 -- [ ] VOICE-01:保持本地 SenseVoice、准备不录音、取消/超时/目标保护;不自动录制人声。 -- [ ] VOICE-02:可行的真实隐藏免跳转实现与物理设备验证;没有实现/签名不能勾选。 -- [ ] COMP-03:当前 device/simulator 构建、Mach-O 下限、原生测试;iOS 16/18 真实运行证据另列,不冒充已覆盖。 - -### 6. Codex 审查与主 Agent 验收(R/V) - -- [ ] 完成组件交接、基线校验与集成,逐条回写主需求状态。 -- [ ] Codex(GPT-6.1 high)独立审查集成代码,覆盖数据丢失、秘密泄露、IPC/会话意图、UI/iOS 16 和需求遗漏。 -- [ ] 将 findings 分类为有效/已失效/无效/范围外,修复有效阻断并复审。 -- [ ] 主 Agent 检查修改文件和测试结果,不以 worker 声称通过代替证据。 -- [ ] Orca 自带模拟器实际页面/键盘操作、截图和 AX;深浅色、大字号、取消/离线错误和导航。 -- [ ] 使用安全的专有测试条目验证 CRUD,清理仅本次测试条目;不清用户历史/词典/凭据/Full Access。 -- [ ] 恢复单产品入口、无 Runner/多余探针,无后台录音;唯一模拟器与模型资源保留。 -- [ ] 提交最终逐项结果:完成、未完成、证据、外部依赖,不把总体目标写成已完成。 - -## 拓扑与文件所有权 - -分类:**multi-seam**。词典呈现、Core 风格服务、Core 账户/同步、键盘状态机、App 集成、环境诊断可分别验证。采用分波执行;不是让一个 worker 包办整项产品。 - -| Lane | 决策/独占范围 | 隔离 | 依赖/下一门 | 输出 | -| --- | --- | --- | --- | --- | -| A | 词典呈现:`App/VocabularyView.swift`、词典 DTO/筛选模型、新词典测试;不改 Rust/根入口 | `/Users/trip/orca/workspaces/openless/ios-dict-ui-61` | Core 来源字段契约交给主 Agent;Swift 编译/窄测试 | 修改文件、界面值、校验、剩余缺口 | -| B | 风格服务:新 `CoreHost/src/style_library.rs`、对应 tests;本地可接线编译,但公共 lib/control 交主 Agent 按补丁集成 | `/Users/trip/orca/workspaces/openless/ios-style-core-61` | 先交 Host JSON 契约;后启动独占 App 风格 UI worker | Core 方法与 JSON 合同、测试 | -| D | 账户/E2EE:新 `CoreHost/src/account_sync.rs` 与测试;本地可接线编译,但公共入口交主 Agent 按补丁集成 | `/Users/trip/orca/workspaces/openless/ios-account-sync-61` | 先核对现有服务/密钥契约;后启动账户 UI worker | 可实现合同/实现、外部缺口与测试 | -| B-UI | 风格 App 消费者:新 StyleLibrary.swift/StyleLibraryView.swift,翻译设置消费者;不改 Root/Host | `/Users/trip/orca/workspaces/openless/ios-style-app-61`(创建后执行) | B Host JSON 已明确,31 项集成测试通过;仅实现真实支持能力,后续多目标合同由 T 提供 | 页面与 DTO/导入校验测试 | -| F | 有限范围 E2EE Core 策略:共享 Core cloud_sync_e2ee* 的 opt-in policy/document gate/迁移测试,默认桌面全量不变 | `/Users/trip/orca/workspaces/openless/ios-sync-policy-61`(创建后执行) | D 证实全量协议超范围;不得上传历史/凭据来冒充完成 | Core 范围合同、向后兼容及错密钥/冲突测试 | -| T | 多翻译目标/会话冻结:共享 Core config/settings/DictationContext/start-options,与既有单目标向后兼容 | `/Users/trip/orca/workspaces/openless/ios-translation-domain-61`(创建后执行) | B 证实只有单目标;先补领域合同,C 才能消费 | 目标迁移/排序/会话意图测试 | -| C-UI | 视频键盘呈现/手势:Keyboard/PlatformShared/Shared意图DTO与状态机,不改Host/AppController | `/Users/trip/orca/workspaces/openless/ios-keyboard-video-61` | VID参考、B能力和T会话合同;App/Host主Agent接线 | 状态表、协议、UI及定向测试 | -| D-UI | 账户App消费者:AccountSettings model/view/tests,不改Host/Root | `/Users/trip/orca/workspaces/openless/ios-account-app-61` | D已接通真实device-flow;F未就绪时清楚禁同步 | 账户页面/轮询取消/安全错误 | -| C-Core | 冻结语音意图/Core compose执行:CoreHost新voice_intent模块和最小Core compose facade | `/Users/trip/orca/workspaces/openless/ios-voice-intent-core-61` | T已集成;仅此worker写该契约,App接线主Agent | 取消/无凭据/冻结/preview不自动插入测试 | -| C-Rime | 词条桥接/确认建议独立模块:RimeBridge、新Shared词条DTO、App词条桥;不改C-UI控制器 | `/Users/trip/orca/workspaces/openless/ios-vocabulary-bridge-61` | C-UI挂接点交主Agent;只同步明确确认词条 | 词条映射/无FullAccess/确认测试 | -| E-audio | 历史音频恢复/过期清扫:新 recording_maintenance 模块,窄 recordings/library接线;主入口补丁交主Agent | `/Users/trip/orca/workspaces/openless/ios-history-maintenance-61` | A/B/D已集成;不得丢弃尚可恢复的孤立录音 | 临时目录崩溃/过期/幂等测试 | -| E-UI | UI语言独立设置及本地化资源/主题传递的新模块,不改Root/Settings/KeyboardController公共入口 | `/Users/trip/orca/workspaces/openless/ios-localization-61` | 复用中文/英文已有翻译;公共入口补丁交主Agent | 本地化覆盖/持久化/原生构建 | -| I | 主 Agent 唯一集成者:`lib.rs`/`control.rs`/AppController/Root/project/主需求文档 | 当前工作区 | 所有组件交接后集成;不得并发拷贝覆盖 | 集成记录、冲突处置 | -| R | Codex 独立只读审查 | 集成工作区只读 | 实现与定向测试完成后运行,high | 有证据的 findings/复审 | -| V | 主 Agent 独占 Orca 自带模拟器 | 当前工作区 | review 修复后 | 操作记录、截图、逐项验收 | - -## 调度事实 - -- 当前可用 Pi registry:`openai-codex/gpt-6.1-sol`;worker 显式 `:high`。 -- `codexhost delegate --help` 返回 `/bin/bash: codexhost: command not found`,未启动任何 CodexHost thread。已核对 Codex 本机模型缓存支持 `gpt-6.1-sol`/high;新增项目 profile `.pi/agents/openless-codex-review-61.md`,通过原生 external-cli runner 显式 `codex exec --model gpt-6.1-sol --config model_reasoning_effort="high" --sandbox read-only`。内置 adapter 不支持自定义 args 的预检已纠正;当前 profile 发现有效,尚未启动 review,不用模型默认 low。 -- 首波 A/B/D 已交接。A 五个Swift文件经基线哈希检查集成,B/D 入口手工逐段组合,没有覆盖词典筛选或其他lane修改。主Host全部 **39测试通过**(`execution-wave1/integrated-account-tests.log`);新词典6+历史模型4在实际iOS测试宿主通过(`dictionary-model-current.xcresult`)。尚未Codex最终review。 -- 第二波 `50c5fb79-bdc8-4479-b2d8-2564e50dd85e`:B-UI `abdb83a7-cd1d-4e08-b53e-d5e1268efa1f`,F `f91fdc18-5e25-4a9d-b888-f36cac6a005c`,T `e685c87f-f97e-4c13-bb63-d78380977334`,全部6.1/high,运行中。C/E和账户消费者按合同继续;最终review和验收未提前勾选。 -- D 已证实当前 Core E2EE 使用 `encrypted-full-snapshot-v1`,包括历史/活动/频道/凭据,没有有限文档策略注入。已批准先做真实 device-flow 账户与 fail-closed 同步边界;D 必须交接精确 Core scope-policy/transaction seam,随后另开共享 Core 范围适配任务。**禁用同步只是中间保护,不算 SYNC-01~08 已实现。** -- A 的來源合同已批准:遵循 Core 当前 exact learned marker 分类,不按短语猜来源;unknown/未来 DTO 值仍不强行标自动。A 初次包解析缺失本地 speech Frameworks,已批准仅补只读依赖 symlink 后重跑,不重下/修改二进制。 diff --git a/openless-all/ios/docs/input-predictions-selfcheck.md b/openless-all/ios/docs/input-predictions-selfcheck.md deleted file mode 100644 index fdee6c9d0..000000000 --- a/openless-all/ios/docs/input-predictions-selfcheck.md +++ /dev/null @@ -1,39 +0,0 @@ -# 输入联想自查(2026-10-20) - -结论:本地词库完整,候选链路能连续工作,但相关性仍有明显弱项,不能记为用户体验验收通过。没有大改词库或排序;只修复了中文 `limit <= 0` 仍返回一个候选的明确边界错误。 - -## 方法与资源 - -用 `xcrun swiftc Keyboard/KeyboardPredictionEngine.swift <临时 main.swift>` 直接编译当前 Swift 引擎,连接 `Resources/Prediction/lexicon.sqlite3`,运行 29 组中英文/边界样例;没有用 Python 复刻排序代替真实引擎。另用 SQLite 只读连接执行完整性与条目数检查。 - -- 数据库 `PRAGMA integrity_check = ok`;SHA-256 `bbec6f0934bfd18e25c981fe81246c7d2eb8195151e64b65ddda115afe692e03`,与 manifest 一致。 -- 英文单词 82,833;英文 bigram 115,314;中文续词 178,855,与 manifest 一致。 -- 9 个原生断言通过:零/负上限为空、limit=1、最长中文前缀、标点断句、英文 thank→you 与 want→to、句末清空、全大写保持。 -- 没有运行完整 App/UI 测试套件;UITextChecker 的系统纠错/补全、Rime 组合串排序和真实候选栏触控不在本次命令行样例的覆盖范围。 - -## 实际候选(按引擎顺序) - -| 输入上下文 | 候选摘录 | 判断 | -| --- | --- | --- | -| `th` | `th, That, This, They, Their, There` | 原输入保留首位,常用补全排序合理。 | -| `thank ` | `you, the, all, my, them` | 第一候选有直接相关性。 | -| `I ` → `I want ` → `I want to ` | `have/am/was` → `to/it/a` → `the/be/a/get/do` | 连续输入有候选,只有前一单词参与 bigram,长句语义没有保留。 | -| `good m` | `m, more, my, may, me, most, music, make` | 常见搭配 morning 未进入前八,下一词语境约束较弱。 | -| `HEL` | `HEL, HELP, HELD, HELPFUL, HELLO` | 大小写保持;按频率排序而非固定 hello 优先。 | -| `we're ` | 空 | 带撇号的上下文覆盖不足。 | -| 空输入、`Hello. `、换行 | 空 | 句子边界不乱猜下一词。 | -| `中国` | 人、共产党、企业、国际、人民、经济 | 有相关性,新闻/机构用语偏多。 | -| `中华人民` | 共和国、共和国主席、共和国证券法… | 最长匹配优先,机构法律类偏多。 | -| `今天` | 是、在、晚上、下午、上午、早上 | 常用续词较合理。 | -| `天气` | 预报、变化、情况、炎热、寒冷、状况 | 较合理。 | -| `谢谢` → `谢谢你` | 合作、你、大家、了、您 → 们、了、啊、的爱… | 可连续候选;商务短语的频率压过日常表达。 | -| `你好` | 吗、啊、的、像、了、评度、友、处 | 后部结果由“好”的短前缀回退产生,相关性差。 | -| `今天下午` | 茶、三点、好、五点、四时… | 回退只看“下午”,失去“今天”的语境;下午茶排首不理想。 | -| `早上` | 起来、好、起床、市公司、的、海、述、面 | 三个较合理候选后,单字“上”的回退导致碎片续词。 | -| `好,` | 空 | 中文标点切断续词,符合当前策略。 | - -## 候选条与风险 - -读码确认:中文只插入续词后缀,英文替换当前 prefix 并按后文补空格;候选点击前复核 document + before + after 锚点,换输入框/光标后拒绝旧候选。密码、URL、邮箱、验证码、选中文本与词中插入点限制仍在;Rime 有组合串时优先显示 Rime 候选,提交后再走续词。首位工具栏返回按钮和 44pt 点击高度仍保留,本次未改布局。 - -后续优化应先处理短中文后缀回退的相关性门槛、日常对话词频、英文常用搭配及缩写覆盖。当前缺少真实输入语料评测,不能为几条示例加入硬编码排序;需用户在聊天连续输入、选词、删除、切输入框后验收。 diff --git a/openless-all/ios/docs/input-predictions.md b/openless-all/ios/docs/input-predictions.md deleted file mode 100644 index 01bf46d08..000000000 --- a/openless-all/ios/docs/input-predictions.md +++ /dev/null @@ -1,50 +0,0 @@ -# iOS 离线候选与续词 - -2026-10-20 更新:中文仍使用 librime 1.13.1,输入词库替换为 -[雾凇拼音](https://github.com/iDvel/rime-ice/tree/da1fbe602e38f26db846fa10120ee64c2b0324c0) -的完整常用字表与 20 万条高频基础词,共 208,757 条字音/词音。旧版为 -rime-pinyin-simp 的 65,140 行基础词库。保留 Rime 组句、简拼、补全、 -用户词频学习与已确认个人词典桥接。 - -英文原本只调用 -[UITextChecker.completions](https://developer.apple.com/documentation/uikit/uitextchecker); -这提供单词补全,不等于系统键盘的下一词模型。旧实现遇到空格就清空候选, -中文选词后也不做续词。现在采用 -[SymSpell 的开放频率数据](https://github.com/wolfgarbe/SymSpell/tree/c239062ae02961df18ab7da1671d01b4388204e0) -提供 82,833 个英文词、115,314 个上下文词对,再由 UITextChecker 提供拼写纠错。 -输入 `Thank ` 后提供 `you`,部分单词按前词和词频排序;候选保留原输入, -只有点击候选才替换,不在按空格时静默纠错。 - -中文从同一雾凇词库建立 178,855 条词语后缀关联。上屏 `你好` 后可选 `吗`、 -`啊`,点击只追加未输入的部分。它是词语续词,不是神经网络自由生成, -也不是对完整句子语义的推理。优先匹配最近 4 个汉字,随后回退到更短上下文。 - -候选条在空格/上屏后继续显示,左侧 44 pt 返回按钮可恢复语言、风格与语音工具栏。 -URL、邮箱、密码、验证码、用户名和选中文本不显示统计候选; -英文单词中间及中文后接已有汉字时不补词。选择前会核对文档 ID 和光标两侧文本, -过期候选不会删除或写入其他位置。 - -## 数据与资源 - -- 数据源固定提交和 SHA-256;`scripts/build-predictions.py` 生成词库与只读 SQLite。 - `scripts/build-rime.sh host` 会调用该步骤并重新编译 Rime 数据。 -- SQLite 文件 15,151,104 bytes(约 14.45 MiB),按索引查询,512 KiB 页缓存, - 关闭 mmap,不把整库或大模型载入内存。 -- 无网络请求,不要求完全访问,不存储输入文本或新的英文个人词频。 - 原有 Rime 学习仍在键盘私有目录内。 -- `Resources/Prediction/manifest.json` 记录精确来源、校验值和计数; - 应用内开源许可可查看 rime-ice、SymSpell 和 prediction-data。 -- GPL-3.0-only 的雾凇数据与 MIT 的 SymSpell 数据保留许可和原始来源说明。 - SymSpell 上游说明频率来自 Google Books Ngram 与 SCOWL;数据归属和链接见 - `Resources/Licenses/prediction-data-LICENSE.txt`。 - -## 验证 - -`bash scripts/test-predictions.sh` 覆盖英文上下文续词、纠错候选、大小写、句末、 -中文后缀和缺失资源时的回退。450 次主机采样的英文与中文联合查询: -p50 0.22 ms、p95 0.95 ms;不代表 iPhone 真机延迟。 - -`scripts/test-rime.sh` 使用真实 librime 验证中文组合与提交,加入旧词库没有独立 -条目的 `明天见`、`等一下`、`微信`、`朋友圈`、`语音输入`。 -`testOfflineNextWordAndChineseContinuation` 在实际键盘中验证英文下一词、 -中文续词不重复前缀、返回工具栏,以及关闭完全访问时的可用性。 diff --git a/openless-all/ios/docs/ios-a1-diagnosis.md b/openless-all/ios/docs/ios-a1-diagnosis.md deleted file mode 100644 index 8eb20f6ee..000000000 --- a/openless-all/ios/docs/ios-a1-diagnosis.md +++ /dev/null @@ -1,49 +0,0 @@ -# A1 语音往返诊断(2026-10-20) - -基线 `5780e153`;先记录诊断,再修改代码。本文区分代码可证明的缺口和仍需真机日志确认的原因,不把模拟器构建当作真机验收。 - -## 现象、路径与判断 - -| 现象 | 代码路径 | 根因判断与证据 | -| --- | --- | --- | -| 录屏 0–5 秒准备/连接 | `startVoice → beginVoice → KeyboardStartHandshake.advance`;过期快照连续 3 次 `.probe`,每次 1.2 秒,随后 `.openHost → requestForegroundVoice`;宿主查询最多再等 2 秒 | **确定存在串行等待放大**,两段等待可达约 5.6 秒。快照过期只说明没有新状态;录屏不能区分进程挂起、退出、通知失联或音频会话失效。 | -| 5–9 秒已录音却停在 OpenLess | `receiveHostActivation → scheduleAutomaticHostActivation → startHostActivation → prepareSpeech → begin`;首次真实 `level` 事件才触发 `returnToKeyboardAfterCaptureStarted`;`HostApplicationReturn.m` 使用系统导航 action,失败再用精确 bundle | **录音已经启动,等待首次音频不是持续滞留的充分解释**。键盘私有 arbiter 要求两次新通知,真实系统若只发送一次会丢弃已读到的来源;跨团队 `sourceApplication` 又可能为空。没有本次日志,不能断言是来源为空还是返回 API 被拒绝。导航 action 的“accepted”也不等于真的离开前台。 | -| 微信内换聊天后准备/请求未完成/再激活 | `changedInput → stopForDestinationChange`;新请求创建新的 `KeyboardStartHandshake`,重新使用共享快照 | **未发现换文档直接撤销后台资格**。清空的是旧录音的目标、握手和 `activeHostEpoch`;`NoJumpAudioSession.epoch` 属于主 App,不在此路径清空。旧快照的 `.error` 当前会在 `activeClient == nil` 时进入新输入框的状态,造成无关错误沿用;这与是否有真实后台资格是两件事。 | -| 返回宿主后下次还要激活 | `NoJumpAudioSession.activate/checkHealth/releaseCapture` 与通知观察者 | `prepareSpeech` 已尝试激活输出会话,`foregroundChanged(false)` 也不会主动关闭它;不存在“接管漏调用 activate”的证据。**确定缺口**:中断 began/部分路由变化立即 deactivate;interruption ended 被忽略;音频引擎配置变化无恢复处理。发生一次中断后,本次输出资格永久丢失,必须再次前台激活。低音量循环输出能否在用户真机长期维持后台执行仍未验证。 | - -用 ffmpeg 每 3 秒抽帧核对了原录屏:约 6 秒主 App 两处都显示录音,约 9 秒多任务卡片已回微信,约 21 秒第二个聊天显示请求失败。录屏显示的是可见行为,不能替代 Console 的分支证据。 - -## 修复边界 - -保留真实音频渲染时间、权限、锁屏与 session generation 的准入校验;绝不把已停引擎标记为可录音。缩短失联握手的串行等待,仍必须先探测;处理可恢复的输出配置变化及系统明确允许的中断结束,只恢复输出,绝不自动恢复麦克风。输入框改变必须继续终止旧请求与旧目标交付,但不能污染下一次请求的状态。 - -自动返回仍须保留本次输入目标/来源,重试只使用当前请求的精确 bundle 或唯一系统导航目标;不猜微信 URL scheme、不用上一次宿主缓存。返回在真实 PCM 已到达后执行,以免切后台过早让采集无法启动。 - -## 真机与日志 - -`xcrun devicectl list devices` 发现 iPhone 18 Pro(`00008160-00065DA114800036`);`device info details` 确认有线连接、Developer Mode 开启、iOS 27.0.1(24A446)。另一台 iPhone 16 Pro 不可用。本记录尚未宣称新包安装或真机验收通过。 - -Mac 打开 Console.app(控制台),左栏选连接的 iPhone,开始流式记录,启用 Info/Debug 消息,搜索 `OPENLESS_`。保留 OpenLess 主进程与键盘扩展两者;不要只按进程过滤主 App。先清空显示,再按以下顺序复现并保存 `.logarchive` 或导出的日志: - -1. 前台打开 OpenLess 并启用键盘语音,返回微信原聊天;点击一次说话、停止,确认只上屏一次。 -2. 同一微信 App 内切到另一聊天,再点一次;分别等待 5 秒、30 秒、5 分钟后重复;记录是否跳转及原光标位置。 -3. 主 App 确实不响应时点一次,确认激活后自动开始和返回,无需第二次点击;记录 `source`、`target`、`accepted` 和应用前后台变化。 -4. 接入/断开蓝牙、Siri/来电中断结束、锁屏解锁后重复;中断时应停止当前录音,不自动重开麦克风。检查恢复输出是否真正渲染、空闲 `outputInputIO=0`。 -5. 启动准备中切输入框/取消,确认旧回调不会再跳转或向新输入框投递旧会话。 - -需要关联的日志:`OPENLESS_START_*`(请求/探针/原因)、`OPENLESS_AUDIO_*`(真实引擎/渲染/中断/恢复)、`OPENLESS_HOST_*`(宿主查询)、`OPENLESS_VOICE_HANDOFF`(来源/返回)、`OPENLESS_DESTINATION`(旧目标解除)。不记录文本、音频、凭据。 - -若真机日志证明音频循环被系统挂起,Darwin ping 无法唤醒挂起进程,缩短超时只能减少等待,不能解决免跳转。可落地后续方案是:在真机做独立 PiP/系统录音控制实验,验证后台重新开麦、停止后麦克风释放、能耗及用户可见界面;按系统/机型记录实测资格,再接入产品。当前 PiP 未实现,不能声称隐藏 PiP 或绝对免跳转已完成。 - -Apple 对引擎配置变化会停止引擎的说明见 [AVAudioEngineConfigurationChange](https://developer.apple.com/documentation/foundation/nsnotification/name-swift.struct/avaudioengineconfigurationchange),中断结束的恢复提示见 [Responding to interruptions](https://developer.apple.com/library/archive/documentation/Audio/Conceptual/AudioSessionProgrammingGuide/HandlingAudioInterruptions/HandlingAudioInterruptions.html)。这些说明支持恢复处理的必要性,不证明本机输出保活有效。 - -## 已实现的修复与验证边界 - -- 探测改为一次 1.2 秒有界 ping,再决定前台接管;没有把旧快照当作真实 ready。单次请求仍有原目标、20 秒总期限和结束闩锁。 -- `NoJumpAudioSession` 在输出引擎配置变化时有限重启输出图;系统中断结束且带 `shouldResume` 时,才尝试恢复之前已由用户激活的输出会话。iOS 27 的 didBecomeInactive 与 interruption-began 先后顺序均保留该恢复意图;锁屏、媒体服务丢失、用户关闭或不允许恢复仍撤销资格。通知在其原本的 main queue 中处理,避免额外异步 Task 让旧中断影响新一代会话。 -- 新增可选 `backgroundRecovering` 表示真实的有界修复过程,键盘等待它结束;`backgroundReady` 仍只来自真实渲染/采集。恢复不创建输入节点、不复活被中断的录音,失败就撤销输出资格。 -- 进一步确认来源查询的缺口:`stopConnection/checkConnection` 期间无 bundle 的中间通知原来会立即结束查询,已改为等到原超时或完整来源;仍要求两次新 generation 的一致来源及原 document/window 挂载,不采用单次来源回退或历史缓存。此为代码可证缺口,是否正是微信本次失败原因仍需新日志。 -- 主 App 进入 active 时也检查已到达的 captureReady,避免首次音频事件早于 scene 激活时错过自动返回;详细记录导航 action/精确 bundle 打开/返回是否实际观察到。仍在收到真实音频后返回。 -- 无当前请求时不把旧 `.error/.cancelled` 沿用到新输入框;换输入框继续只解除旧会话目标,保持主进程音频资格独立。 - -本轮模拟器构建与定向共享测试通过;它们不能证明真机后台输出会话可长期存活,也不能证明微信来源查询/自动返回实际成功。设备已确认连接,本通道未覆盖安装用户设备上的包,交协调者合并两个通道后统一签名安装并按上文抓日志。若最新日志仍显示无响应或返回失败,需依据具体 `OPENLESS_` 分支继续真机修复,不应把本轮标记为真机 A1 验收通过。 diff --git a/openless-all/ios/docs/ios-build10-goals.md b/openless-all/ios/docs/ios-build10-goals.md deleted file mode 100644 index c141165df..000000000 --- a/openless-all/ios/docs/ios-build10-goals.md +++ /dev/null @@ -1,43 +0,0 @@ -# Build 10:目标、实现与验收边界 - -日期:2026-10-21。接续 Build 9,用户已确认基础 PiP 免跳转可用;本轮解决其后续反馈,不重做该结论。交付未签名 IPA,由用户自行签名。桌面端既有改动不属于本轮。 - -## 目标与实现 - -| 用户目标 | 本轮实现 | 验证要求 | -|---|---|---| -| 免跳转没有可见黑块、控制条或侧边拉手 | 透明 VideoCall 内容、0.1pt 高度、构建时隐藏 controls 适配;停止普通 sample-buffer 视频路径 | 编译与内部接口 fixture;实际外观需真机 | -| B 站等其他视频可以使用 PiP | 独立兼容层:按签名检查内部接口,普通 VideoCall 启动后提交一次共存内容类型;不循环抢占系统 PiP | fixture 验证隔离/取消,双 PiP 需要真机 | -| 锁屏解锁后保留免跳转意图 | 锁屏不撤销激活代际、不销毁已活跃 PiP;解锁重新读取实时状态;不会自动重开麦克风 | 状态测试;锁屏期间系统是否保留 PiP 需真机 | -| 回到 OpenLess 再离开无需再次激活 | 激活意图与前后台、音频录制状态分离;前台同步 PiP 并有界恢复 | 状态测试及真机前后台序列 | -| B 站恢复正常扬声器,无需切两次 | 删除空闲输出引擎与静音循环;只在录音期间激活 playAndRecord;硬件停止后 deactivate 并切回非录音类别;失败做有界重试,空闲路由通知不重启会话 | driver 测试验证释放顺序;音量/路由需真机 | -| 拼音模式能提交 openless 原文 | Return/原文候选提交 rawInput,空格/中文候选保留中文提交语义 | 真实 Rime smoke + 聚焦 UI 用例 | -| 键盘按钮更易点击 | 模式切换、语音工具、发送等尺寸调整;原文候选至少 44×44pt | 语法/布局检查,操作体验待验收 | - -## 拆包证据如何转成代码 - -Typeless 的 `stopRecording → stopService → cleanupKeepActive` 在 PiP 模式跳过 PiP cleanup。键盘先 ping 主 App,PiP active 时允许 microphone IDLE 直接启动。依据是 `Artifacts/keyboard-reopen-fix/competitor-evidence.md` 的 Hermes 字节码复核,不是原生隐藏方法体。 - -因此 `NoJumpAudioSession` 不再维持输出音频租约。`activate()` 只建立用户激活代际;`AppController` 将实际 PiP readiness 作为后台录音能力;`acquireCapture(.keyboard(generation))` 同时校验代际、PiP、权限和锁定/中断状态。录音服务仍只有收到显式请求才创建 inputNode。`AudioCapture.stopHardware()` 先拆掉输入引擎,再释放会话;转写和插入无需持有音频。 - -豆包的 hidden/stash/hook 导出以及 Typeless 的 pipHideEnabled 分支证明存在专用路径,不能恢复其全部原生实现。本轮隐藏/共存兼容代码参考已归档的 GlobalRefresh-PiP 固定版本,来源、运行时签名检查与未知边界见 `pip-hidden-coexistence.md`。不将参考项目方案冒称为豆包或 Typeless 原始源码。 - -PiP 启动允许临时 playback + mixWithOthers 会话,在启动完成、失败、取消、超时后释放;若此时录音或历史播放已接管音频,不得提前 deactivate。音频路由不调用强制 speaker override,不周期性改路由,不模拟点击控制中心。录音类别的 defaultToSpeaker 仅覆盖无外设时的内置默认值;空闲时退出录音类别才是本轮的主要修复。 - -## 验证与证据边界 - -- 自动测试能证明:状态转换、旧代际取消、录音结束释放、空闲不重开音频、兼容层实例隔离和 ABI 不符时降级,以及键盘原文提交。 -- 构建能证明:目标设备架构、Swift/Objective-C 编译链接、包结构和资源完整性。 -- 上述均不能证明:iPhone 上隐藏视觉、其他视频 PiP 共存、后台重新开麦、锁屏后的长期保活、实际扬声器音量。 -- `KeepAliveCompatibility.json` 继续为空,不伪造系统/机型实测记录。若接口不兼容、PiP 停止或已被系统挂起,`isReady` 必须为 false,键盘使用既有前台激活路径。 - -## 真机验收序列 - -1. 自签安装 Build 10,首次激活后在微信连续录音两次,确认真实 PCM、识别结果和麦克风指示正常。 -2. 停录后确认麦克风指示消失,检查完整屏幕和侧边无残留浮窗;开启 B 站播放,不切输出设备,记录音量与控制中心输出列表。 -3. 打开 B 站视频 PiP,再返回微信录音;关闭视频 PiP 后再录音。 -4. 锁屏 30 秒及 5 分钟,解锁直接在原应用录音;随后进入 OpenLess,再返回原应用录音。 -5. 插拔耳机/切蓝牙,结束后播放视频;确认不会被后台会话持续改回内置输出。 -6. 拼音输入 openless:Return 和原文候选应提交英文;中文候选和空格仍提交中文。 - -收集 `OPENLESS_PIP_*`、`OPENLESS_AUDIO_ARM`、`OPENLESS_AUDIO_IDLE_RELEASED`、`OPENLESS_AUDIO_EVENT`、`OPENLESS_AUDIO_CAPTURE_START_FAILED` 与诊断中的 routeIn/routeOut/idleAudio/provider/localType。若失败,按实时 PiP、系统开麦结果、会话释放结果定位,不用布尔开关替代真机证据。 diff --git a/openless-all/ios/docs/ios-build10-release.md b/openless-all/ios/docs/ios-build10-release.md deleted file mode 100644 index 4ae885983..000000000 --- a/openless-all/ios/docs/ios-build10-release.md +++ /dev/null @@ -1,37 +0,0 @@ -# OpenLess iOS Build 10 交付 - -日期:2026-10-21(America/Los_Angeles)。版本 `0.1.0 (10)`,arm64,未签名,由用户自行签名安装;未 push。Build 9 基础 PiP 免跳转已获用户确认,本轮接续处理其后的隐藏、共存、生命周期、扬声器及键盘反馈。 - -## 产物与目标 - -[下载未签名 IPA](../Artifacts/release-10/OpenLess-0.1.0-10-unsigned.ipa)。文件大小 198,093,424 字节(188.9 MiB),SHA-256: - -```text -24554fe1f24e50a2112d11b8ff64c478f2cab381e45a26ebb2c14a8f3e42de95 -``` - -目标、每项实现位置和真机验收步骤见 [Build 10 目标](ios-build10-goals.md)。专门设计见 [PiP 隐藏/共存](pip-hidden-coexistence.md)、[拼音原文与按键](keyboard-raw-input.md)。当前 README、keepalive 和待办已同步,历史 Build 7/9 记录保留并标识时点。 - -本轮移除待机音频引擎与静音循环。录音结束先停止采集,再 deactivate 音频并退出录音类别;释放失败做三次有界重试,旧重试不能关闭新录音。后台准入使用激活代际、实时 PiP 状态和权限/中断状态。锁屏保留意图,只对锁屏打断的 PiP 授予一次解锁恢复,不在后台反复争抢其他视频。 - -PiP 改为透明 VideoCall 内容,并集成独立、按运行时签名检查的隐藏/共存适配。该部分使用系统内部接口;不兼容时不会假报就绪。不是对豆包或 Typeless 完整原生源码的恢复,也不能用本地 type 6 提交成功证明系统已接受多 PiP。 - -## 验证 - -- 设备 Release 构建通过;主 App、键盘和控件版本均为 10。项目静态校验通过。 -- IPA ZIP CRC、三个 executable 权限、解压产品校验通过;532 个文件逐一 SHA-256 与原始构建产物一致。打包不包含 AppleDouble/xattr 旁文件。 -- 12 项模拟器音频所有权/准入测试通过:闲置不激活音频、PiP 独立准入、录音结束释放、锁屏解锁、前后台、系统中断、音频服务重置、路由变化、历史播放、旧代际、释放失败重试与新录音隔离。 -- PiP runtime fixture 六组通过:构造范围、签名拒绝、代理身份、type4→6 延迟一次提交、回调/取消顺序、100 次释放,以及锁屏一次恢复状态机。用生产适配文件编译执行,不是手抄实现测试。 -- 真实 AVKit 构造检查在当前 iOS 27 模拟器明确 skipped:该运行时 `isPictureInPictureSupported()` 返回 false。因此没有将 fixture 结果冒充实际 iOS PiP 运行证明。 -- 真实 Rime smoke 通过,包括中文候选/空格、四种原文、删除与清空;Swift 语法与差异检查通过。 -- 聚焦键盘 UI 用例 `testSymbolsReturnAndCompositionSwitch` 最终通过:原文/中文提交、两次 Return、44pt 目标、符号、大写与语音页换行。初次安装等待、跨进程更新同步及符号切换预期已更正,旧失败日志保留;未宣称整套 UI 或横屏验收通过。最终结果见 `keyboard-confirmed-tests.log/.xcresult`。 - -日志和 xcresult 位于 `Artifacts/release-10/`,包括 `build-device-release.log`、`validate-project.log`、`pip-runtime-tests.log`、`final-focused-tests.log`、`keyboard-confirmed-tests.log`、`validate-extracted-product.log`、`package-verification.json`。未运行完整测试套件。 - -设备构建复用原有 `Frameworks/device` 静态库,没有构建桌面端既有未提交修改。签名、安装、目标 iPhone 的实际隐藏、多 PiP、后台重新开麦、B 站扬声器音量和长期驻留尚未在本包实测;`KeepAliveCompatibility.json` 保持空,不伪造通过记录。 - -## 验收入口 - -自签安装后按目标文档的六步序列检查。最关键的三项:停止录音后直接播放 B 站无需切输出;B 站开启自己的 PiP 后 OpenLess 仍能录音;锁屏解锁和回主应用再离开不重新激活。失败时保留对应录屏及 OPENLESS_AUDIO/OPENLESS_PIP 日志,区分系统结束 PiP、录音准入失败和音频释放失败。 - -本轮没有修改原 provider 会话或归档,也未操作用户 Chrome。用户提供的 macOS DMG 路径在本机不存在,未安装或分析该文件;本次工作和交付范围为 iOS。 diff --git a/openless-all/ios/docs/ios-build11-goals.md b/openless-all/ios/docs/ios-build11-goals.md deleted file mode 100644 index c12c1ca76..000000000 --- a/openless-all/ios/docs/ios-build11-goals.md +++ /dev/null @@ -1,32 +0,0 @@ -# Build 11:键盘体验目标与实现 - -参考:用户提供的 `IMG_1751.jpg`、`IMG_1752.jpg` 和 `ScreenRecording_10-21-2026 16-58-04_1.MP4`(169.16 秒)。录屏前段展示 Typeless 的中文候选 → 确认原始字母 → 再次发送;后段展示 OpenLess 的尺寸、录音波形、翻译白条与 processing 文案。沿用 Build 10 的 PiP/音频生命周期,本轮不改变其准入或路由。 - -| 反馈 | 实现 | -|---|---| -| 键盘太大、希望自定义 | 默认四行内容区从 232pt 改为 208pt,接近参考截图;设置→键盘→键盘高度提供 90%–120% 调节及恢复推荐大小。主 App 保存并通过现有共享偏好同步,横屏使用更低的独立基准。 | -| 润色模式重开变原文 | 使用键盘私有 UserDefaults 独立保存风格 ID,扩展重新创建时读取。普通设置/旧主 App 快照不覆盖该选择;不可用风格显示不可用,不静默切回原文。 | -| 英文输入不应直接出现在候选栏 | 移除人工添加的原文候选按钮,保持 Rime 候选。组合期间第一次 Return 提交准确 rawInput,不发送;组合清空后第二次 Return 执行宿主换行/发送动作。 | -| 语音页缺少 @ | @ 始终占独立空闲编辑位置,不再被撤销替换;有可安全撤销的听写结果时,长按 @ 可选择撤销。删除放在右侧上方,减少底部拥挤。 | -| 输入法后端是什么 | 拼音组合/候选为 Rime;布局/手势为 KeyboardKit 9.9.0;项目提供本地联想与词库;默认语音识别为 SenseVoice Small。本轮不替换引擎。 | -| 红色录音与波形不明显 | 键盘录音圆由 72pt 改为 96pt,白色波形加粗;真实 RMS 按语音常见 dB 范围映射,低音量也能看清波形。无音量时不伪造跳动;尊重减少动态效果。 | -| 翻译白条难看 | 去掉实色背景,翻译状态放在“再次点击以完成”下方;两行同字体、同 secondaryLabel 灰色。 | -| 处理中/分析统一文案 | 语音 processing 标签统一为小写 `thinking`,覆盖共享按钮、主应用状态及激活页;准备和等待插入仍表达各自状态。 | -| 键盘整体体验 | 顶部模式改为波形图标/EN/拼,简化选中背景;中文主操作键缺少库内文案时显示发送/回车图标;空闲麦克风使用清晰胶囊和上方提示,收敛工具尺寸与布局。 | - -## 状态与兼容 - -- `KeyboardPreferences.keyboardScale` 为新增可选解码字段,旧数据默认 100%;限制异常/非有限数值,不让键盘塌陷或异常增高。 -- 高度在主应用设置;与其他共享偏好一样,扩展同步需要“允许完全访问”。基础打字和本地润色选择记忆不新增网络依赖。 -- 原文从 Rime `get_input` 读取,保留 `v`、用户输入的分隔符等,不从格式化 preedit 反推。点击中文候选、空格选词与原始字母确认是不同操作。 -- 录音波形只改变显示映射,不更改麦克风增益、PCM 或识别输入。视觉测试使用明确标识的模拟 RMS,不能作为实际开麦证据。 - -## 验收 - -1. 设置高度为 90% 后重开应用,数值保留;恢复推荐为 100%,键盘整体低于 Build 10。 -2. 选择润色风格,收起/重开及扩展重启后仍保留;主动选原文后才清除。 -3. 拼音输入 openless,候选栏不额外插英文项;第一次确认输入 openless,第二次执行宿主动作;nihao 点击“你好”仍为中文。 -4. 语音页输入 @,听写结束后 @ 仍存在;需要撤销时长按 @。 -5. 录音时能明显辨认圆形与波形;翻译说明在完成提示下面,无白条;结束后显示 thinking。 - -自动检查及产物见 [Build 11 交付记录](ios-build11-release.md)。真机打字手感、动态音量感受及宿主 App 的发送语义以安装验收为准。 diff --git a/openless-all/ios/docs/ios-build11-release.md b/openless-all/ios/docs/ios-build11-release.md deleted file mode 100644 index 0971400b7..000000000 --- a/openless-all/ios/docs/ios-build11-release.md +++ /dev/null @@ -1,40 +0,0 @@ -# OpenLess iOS Build 11 交付 - -日期:2026-10-21。版本 `0.1.0 (11)`,arm64,未签名,由用户自行签名安装。实现范围见 [目标与实现](ios-build11-goals.md)。本轮沿用 Build 10 的音频/PiP 逻辑。 - -## 安装包 - -[下载 IPA](../Artifacts/release-11/OpenLess-0.1.0-11-unsigned.ipa) - -- 大小:198,107,772 字节,约 188.9 MiB。 -- SHA-256:`7cfe27ef52cd19c6f32b1af6da2f844d313408a9e3fbf702231a95a538ccf790`。 -- 主 App、键盘扩展及录音控件均为 Build 11。 -- ZIP CRC、解压产品结构及 532 个文件与原始构建产物的 SHA-256 比对通过。 - -## 已实现 - -默认键盘内容区缩为 208pt;设置增加 90%–120% 高度与恢复推荐大小。润色选择独立持久保存,键盘重新出现会读取最近选择。候选栏恢复仅由 Rime 提供候选,第一次确认原始字母、第二次执行宿主发送/换行。@ 不再被撤销按钮替换,撤销改为长按 @ 的菜单项,删除移至右上、翻译移至左上,底部主按钮居中。 - -录音圆扩大至 96pt,波形改用真实 RMS 的语音范围映射。翻译白条改为完成提示下方的灰色文字;语音 processing 状态统一为 `thinking`。顶部模式控制和空闲麦克风外观参考用户截图整理。 - -输入法后端:Rime 负责中文组合与候选,KeyboardKit 9.9.0 负责键位布局和手势;英文候选结合本地预测词库与系统 UITextChecker;语音默认是 SenseVoice Small 本地识别。本次没有替换这些引擎。 - -## 验证记录 - -- 设备 Release 构建、项目静态校验、IPA 解压产品校验通过。 -- `KeyboardPresentationTests` 4 项通过:旧偏好兼容、尺寸范围/异常值、风格持久保存/原文清除、真实音量显示映射。 -- 界面资源检查 5 项通过,并补齐上轮新增 PiP 静态提示的翻译资源。 -- `SpeakButtonTests` 两项通过:未就绪时不偷偷录音,以及录音/准备状态的取消与停止。 -- 录音视觉测试通过:模拟 RMS 驱动录音组件并转到 thinking,截图及组件录像保留。没有使用麦克风,不能把它当作实际采集测试。 -- 高度偏好在主 App 调整、重启、恢复推荐的 UI 用例已通过。 -- 拼音两步确认、中文候选和 @ 输入 UI 用例通过;最终主按钮居中、语音/拼音布局截图检查通过。中文主操作键在库内缺少文案时也有图标兜底。 - -证据目录:`Artifacts/release-11/`。设备日志 `build-device-release.log`;偏好测试 `preferences-tests.log`;资源检查 `interface-resources-tests.log`;最终模拟器测试 `final-focused-tests.log/.xcresult`;视觉组件录像 `recording-component.mov`;包校验 `package-verification.json`。首轮并行编译受同机资源压力影响,主动中断后改为串行完成,未将中断记录标为测试通过。 - -未运行完整测试套件。真机输入手感、微信等宿主的实际发送动作、低音量实际录音视觉以及自定义高度的舒适范围,仍需自签安装后验收。没有把模拟器测试扩大为新包的 iPhone 真机结论。 - -最终 UI 证据:`keyboard-confirmed-tests.log/.xcresult`(输入行为)、`layout-tests.log/.xcresult`(最终布局)。冷启动设置导航及跨进程文字等待的中间失败记录保留,重跑后上述用例通过;没有重跑完整测试套件。 - -- [最终语音键盘](../Artifacts/release-11/final-layout-evidence/59A456C6-30E2-4E66-8438-A73452CE43DD.png) -- [最终拼音键盘](../Artifacts/release-11/final-layout-evidence/01E96C2E-35D4-4E47-85F6-A1B415925750.png) -- [录音组件动态演示(模拟音量)](../Artifacts/release-11/recording-component.mov) diff --git a/openless-all/ios/docs/ios-no-jump-plan.md b/openless-all/ios/docs/ios-no-jump-plan.md deleted file mode 100644 index 1645d5aa9..000000000 --- a/openless-all/ios/docs/ios-no-jump-plan.md +++ /dev/null @@ -1,25 +0,0 @@ -# 键盘免跳转修复方案 · 2026-10-19 - -## 根因与边界 - -本次审计基于 de062611。已有 KeyboardStartHandshake 使用请求 ID、原始目标、20 秒 uptime 期限、快照 freshness、host epoch 和完成闩锁;Host 忙碌时等待,只有探针失联或空闲 Host 缺少后台资格才请求前台。自动上屏通过 Mailbox 持久化 claim、同步 insertText 和 uncertain 终态防止重复提交,扩展不初始化麦克风、Core 或模型。 - -发现一个目标冻结缺口:beginVoice 在 DispatchQueue.main.async 中才读取目标;用户点击后、回调前换输入框时,会把原手势绑定到新目标。修复为在提交已有输入法组合后立即捕获目标,并在异步回调及发送 start 前重新核对目标和可见性。失配终止该手势,不转移目标、不打开主应用。 - -## 状态机与改动点 - -- 手势提交后冻结 target → 异步检查可见性与 target → 创建握手。 -- stale snapshot → 最多三次 ping → 超时才 openHost;fresh busy → 等待并保留绝对期限;fresh idle 有资格 → start;epoch、目标、期限变化 → 终止。 -- sendStart 作为最后一道边界,再次检查原目标与键盘可见性。 -- deferred start 保持原命令 requestID、epoch、deadline、KeyboardLease、后台资格和配置能力复核;不延长请求有效期。 -- 视觉、动画和文案由设计 worker 负责,本 worker 不保留相关修改;canStart=false 的展示原因交其区分 busy、准备、失联和缺少后台资格。 - -## 测试与验收 - -补共享握手测试:等待期间丢失后台资格、过期快照不能覆盖 epoch、deadline 边界、配置变更终止,保证每个手势只有一次终态。运行共享 swift test;集成阶段执行 scripts/build.sh simulator Debug,重装独占 QA 模拟器后运行 App 单测、真实 Safari 键盘冷启动返回、收起重开及自动上屏去重回归,xcodebuild 使用 -collect-test-diagnostics never。日志和截图保存在 Artifacts/ui-redesign-20261019/,按方案、实现及验证结果分阶段提交。 - -真机仍需验证:同一轮 run loop 换输入框与收起键盘、App 重启 epoch、连续录音清理与配置切换、锁屏/中断/路由变化、后台输出租约真实性与空闲 input I/O 关闭,以及主 App 返回原宿主后一次上屏。PiP 仍未实现,不能宣称隐藏式免跳转已验收。 - -## 追加发现:取消前台准备与延迟解析 - -requestForegroundVoice 的宿主解析异步返回后,原代码只核对 foregroundHandoffID;voiceAction 的取消路径却未清除此 ID,因此取消准备后仍可能被延迟回调带到主应用。修复为取消/结束首先作废前台 handoff ID 与输入 anchor,解析回调另受 20 秒 uptime 绝对期限约束;收起键盘原有 stopForDestinationChange 会清除此 ID。所有提示复用既有文案,无新增资源字符串。 diff --git a/openless-all/ios/docs/ios-nojump-root-cause.md b/openless-all/ios/docs/ios-nojump-root-cause.md deleted file mode 100644 index f00cc9426..000000000 --- a/openless-all/ios/docs/ios-nojump-root-cause.md +++ /dev/null @@ -1,68 +0,0 @@ -# iOS 免跳转录音根因与证据 - -日期:2026-10-20 - -## 结论 - -Build 8 已确认的实现缺口是:OpenLess 只用 `NoJumpAudioSession` 保留了一个**输出**音频会话,真正录音时仍由 `AudioCapture` 新建 `AVAudioEngine` 并访问 `inputNode`。输出引擎存活不能证明下一次后台开麦会成功。若 `engine.start()` 失败,旧路径还会销毁输出 generation,于是键盘下一次只能重新激活主 App。后台开麦被系统拒绝是当前待验证的解释;缺少该次真机错误日志,不能把它写成已证实的系统根因。 - -这不是“换输入框必然清理会话”的证据。当前代码中,换输入框主要重建键盘侧 target/lease;没有找到它直接销毁 `NoJumpAudioSession` generation 的路径。录屏能证明发生了两次“准备 → 跳主 App → 返回”,但没有 `OPENLESS_` 真机日志,不能把某个具体的系统错误码伪装成已观测事实。 - -## 录屏逐帧 - -输入文件:`/Users/trip/Downloads/ScreenRecording_10-20-2026 22-36-26_1.MP4`,时长 39.623 秒,1206×2622,按 0.5 秒抽取 79 帧。帧号按第 1 帧约为 0 秒计;时间为近似值。 - -| 帧 / 时间 | 画面与状态文案 | 结论 | -| --- | --- | --- | -| 1–30 / 0–14.5s | 微信聊天中切换系统键盘、进入 OpenLess 键盘、输入英文和打开风格菜单;主 App 没有跳转。 | 普通键盘操作本身没有触发主 App 激活。 | -| 31–34 / 15–16.5s | OpenLess 键盘出现录音面板,随后白色按钮显示“准备中…”,下方显示“正在连接 OpenLess…”。 | 键盘先走主 App 握手,而不是直接开始后台采集。 | -| 35–40 / 17–19.5s | 画面切到 OpenLess 主 App,显示“正在准备语音”“准备中…”;之后回到微信。 | 第一次确实发生主 App 前台激活并自动返回。 | -| 41–50 / 20–24.5s | 返回后的键盘显示红色录音按钮和“再次点击我完成”;顶部出现正常的系统麦克风指示。 | 采集开始后系统隐私指示正常,没有把待机保活误当成持续录音。 | -| 51–55 / 25–27s | 识别结果插入为“试 open试 openless 输入”,随后切到 QQ/另一聊天。 | 第一轮结果已返回;切换聊天发生在下一轮之前。 | -| 56–60 / 27.5–29.5s | 新聊天中又显示录音面板;随后进入等待/准备状态。 | 第二轮没有沿用可直接后台录音的资格。 | -| 61–63 / 30–31s | 键盘显示“准备中…”或“正在等待上一段语音完成…”。 | 第二轮仍在等待主 App/上一段会话收敛。 | -| 64–66 / 31.5–32.5s | 再次跳到 OpenLess,主 App 先显示“可以开始了”,随后显示“正在准备语音”;系统键盘语音面板也短暂出现。 | 第二次同样需要前台激活,不是单纯的输入框切换动画。 | -| 67–70 / 33–34.5s | 返回 QQ,键盘进入录音态并显示红色按钮。 | 第二轮在主 App 前台取得采集资格后开始。 | -| 71–79 / 35–39s | 结果“C试 opened输入功”回到当前 QQ 输入框,随后录音面板收起。 | 第二轮也能自动返回并插入,但仍付出了跳转成本。 | - -用户指定的上一份录屏 `/Users/trip/Downloads/ScreenRecording_10-20-2026 16-26-45_1.MP4` 在本机不存在,因此本报告不推断该文件内容。 - -## 代码证据 - -`App/NoJumpAudioSession.swift` 的待机引擎只创建 `AVAudioPlayerNode` 和低音量循环 buffer,类别是 `.playAndRecord`,但没有创建 `inputNode` 或安装采集 tap。它的 `outputInputIO` 诊断用于确认待机 output unit 的 input I/O 没有打开。 - -`App/Speech/AudioCapture.swift` 只有在一次真实录音开始时才执行 `AVAudioEngine()`、读取 `engine.inputNode`、安装 tap 并调用 `engine.start()`。因此后台失败边界发生在 capture start,而不是键盘 UI 或识别结果插入。 - -`App/AppController.swift` 的旧准入条件是前台,或 `keyboardAudio.isReady`;它没有 PiP 条件。`nativeRequest(captureStart)` 失败后旧代码还调用 `keyboardAudio.deactivate(generation:)`,会把本来可能仍然有效的输出租约一并撤销。现已改为:后台新录音必须同时满足输出租约和 PiP 实际 active;capture start 失败只记录错误并释放本轮 capture,不主动摧毁仍有效的输出/PiP 保活。 - -## Typeless 对照 - -仓库中的 `Artifacts/keyboard-reopen-fix/competitor-evidence.md` 以及 `docs/keepalive.md` 对 Typeless 2.7.0 的静态证据显示:键盘重开前会 ping 主 App;`isSelfApp`、麦克风非 IDLE、或 `mainAppState.isPIPActive` 任一成立即可走直接启动;一轮录音停止会停服务但保留 PiP;键盘隐退不清理 PiP;关闭保活、登出或模式切换才 cleanup PiP。其待机麦克风仍是关闭的。 - -因此本轮采用公开 API 等价边界:`AVSampleBufferDisplayLayer` + `AVPictureInPictureController.ContentSource` + 中性视频帧;Info.plist 使用 `UIBackgroundModes: [audio]`;`picture-in-picture` 不是该键的合法独立值。OpenLess 不调用私有 selector、不伪造隐藏窗口,也不在待机创建 `inputNode`。系统 PiP 窗口的大小和是否被用户收起不能由公开 API 任意隐藏;这部分必须以真机系统行为验收,不能由代码静态宣称与 Typeless 的私有隐藏表面完全相同。 - -## 需要的真机日志 - -当前录屏没有配套 `OPENLESS_` 日志。用户有签名包后,在 Mac 打开“控制台”并连接 iPhone: - -1. 左侧选中 iPhone 18 Pro `00008160-00065DA114800036`。 -2. 搜索框输入 `OPENLESS_`,开始录制日志。 -3. 先前台打开 OpenLess、启用键盘语音,再回微信完成两轮录音;保留主 App 和键盘扩展的完整时间段。 -4. 重点保留 `OPENLESS_PIP_CONFIGURED`、`OPENLESS_PIP_START`、`OPENLESS_PIP_STARTED`、`OPENLESS_PIP_FAILED`、`OPENLESS_PIP_STOPPED`、`OPENLESS_START_REQUEST`、`OPENLESS_START_DEFERRED`、`OPENLESS_AUDIO_ACTIVATE`、`OPENLESS_AUDIO_RENDER_READY`、`OPENLESS_AUDIO_ACTIVATE_FAILED`、`OPENLESS_AUDIO_EVENT`、`OPENLESS_AUDIO_INVALIDATED`、`OPENLESS_AUDIO_CAPTURE_RELEASE`、`OPENLESS_AUDIO_CAPTURE_START_FAILED` 和 `OPENLESS_VOICE_HANDOFF`。 -5. 对每次失败,必须保留同一行里的 `error`、`localizedDescription`、`appState`、`generation`、`outputInputIO` 和 `captureStart` session ID;其中 `cannot start recording`、`required condition is false`、`AVAudioSession` interruption/route change 是判断系统拒绝后台新开麦的关键字段。 - -## 代码边界 - -PiP 保活只有在用户启用后台保活且主 App 曾经前台准备过语音后建立。PiP 活跃和输出租约都是真实状态,任一失效时键盘会退回“激活主 App,完成后自动返回”的路径。关闭“免跳转后台保活”会显式停止 PiP、释放输出会话;待机路径不会采集麦克风,只有真实录音请求才启动 `AudioCapture`。 - -本文件记录的是 Build 8 录屏的现象、代码缺口和 Build 9 的改动;在没有签名安装和 `OPENLESS_` 真机日志前,不把长期后台免跳转标记为真机通过。 - -## 重启后续接与构建修正 - -此前工作停在 PiP 提交 `26ae3691` 和自动识别语言的未提交改动,尚未交付 IPA。续接时确认两个设备构建已结束,后台 agent 不再运行,未恢复旧会话。 - -本次补全了 PiP 的 inline source:以实际 `UIView` 的 `AVSampleBufferDisplayLayer` 作为根界面右上角 48×27 的可见源,持续保留在页面切换之外。帧采用运行中的 host-clock timebase,按 layer 背压投递;待机只绘制中性波形,不打开输入节点。关闭保活同时取消自动启动和重试,迟到的 `didStart` 不会重新发布就绪;暂停的 PiP 不再算作后台就绪。已修正无效的后台模式值,项目静态校验和设备 Release 构建通过。 - -默认 SenseVoice 固定传入 `auto`;打字布局变化不再使语音准备失效。开发者模式 Apple Speech 使用固定中文 locale,与布局解耦,其混合识别效果仍由系统实现决定。 - -这不是隐藏 PiP:系统可以显示画中画浮窗,用户可按系统方式收起。没有本次真机运行证据,不能保证当前 iOS 版本允许 PiP 中重新开麦。最终验收与 IPA 见 `ios-build9-release.md`。 diff --git a/openless-all/ios/docs/ios-ui-spec.md b/openless-all/ios/docs/ios-ui-spec.md deleted file mode 100644 index bf3d8afbe..000000000 --- a/openless-all/ios/docs/ios-ui-spec.md +++ /dev/null @@ -1,155 +0,0 @@ -# OpenLess iOS UI 规格(Sonnet 5.5,2026-10-19) - -本规格基于 `Artifacts/ui-sonnet-20261019/before-*.png`(iPhone 17 / iOS 27 模拟器,浅色,简体中文)实测审查,不照搬 `ios-ui-redesign.md`(系统标签栏方案已被否决)。 - -## 0. 不可改动的约束 - -- 导航保持 `App/ProductDock.swift` 的 `NativeDockPair`:左侧 dock 放 概览/历史/词典/设置,右侧单独一列 dock 放“风格”,选中后展开为 我的风格/风格市场/翻译目标。严禁改成系统 TabView 标签栏或把风格移到页面顶部。本轮只在其内打磨。 -- 不用假状态:账户同步、隐藏 PiP 未实现就保持“暂不可用”。结果自动上屏,界面不再要求用户二次确认。 -- 尊重 `accessibilityReduceMotion`、动态字号、深浅色;键盘扩展里不加重型动画(不加模糊/粒子/持续动画)。 -- 不碰免跳转逻辑(KeyboardStartHandshake、KeyboardVoiceLifecycle、NoJumpAudioSession、AppController 会话/握手、HostApplicationReturn.m、KeyboardViewController 的启动/握手/返回/状态机)。 - -## 1. 审查结论(问题清单) - -| # | 位置 | 问题 | -|---|---|---| -| P1 | 全局 | 页面横向边距 16/20/24 混用;圆角 20/24/14/12 混用;各页标题三种做法(首页自绘、历史/词典自绘大标题、设置/风格/帮助为居中 inline 标题)。 | -| P2 | dock | 浮动玻璃 dock 下方内容(卡片文字、搜索栏、列表行)直接透出,文字与图标叠在一起(首页“平均段落”卡、设置“界面语言”行);出现/隐藏无过渡;切换页面内容瞬切,没有过渡。 | -| P3 | 首页 | 口号 34pt 大标题 + 副标题 + 状态点 + 180pt 浅蓝光晕圆 + 提示 + 语言菜单 + “概览”标题 + 4 张全 0 统计卡 + 折线图(空数据时 140pt 空白)+ 试用键盘卡,首屏几乎只有营销语;主操作(启用语音/录音)不够突出,语言入口像孤立文字。无数据时统计占屏过大。 | -| P4 | 设置 | 账户欢迎卡 ≈ 260pt 占满首屏,而键盘配置被挤到下面;“账户”能力实际是登录 GitHub,同步暂不可用,不应是首屏主角。各行图标有无不一,pinned 搜索栏下方列表内容透过玻璃,造成脏乱。 | -| P5 | 风格库 | 每张风格卡 ≈ 220pt(图标、2 行描述、勾选圆、整行宽的“当前使用/使用此风格”灰色按钮),一屏仅 2 张;“当前使用”灰色禁用按钮看起来像失效;“勾选圆”与按钮重复表达同一状态;“设置文字模型”蓝色文字孤立。 | -| P6 | 翻译目标 | 空态文案与“配置翻译模型/尚未设置”像三个独立块;“在键盘中翻译”卡末尾有一个空 `Text("")` 导致底部空白和多余分割线。 | -| P7 | 详情页 | 底部主操作条用 `.regularMaterial` 矩形,贴 dock 上缘,和玻璃 dock 风格冲突;“当前使用”用灰色禁用 borderedProminent。 | -| P8 | 历史/词典 | 历史无日期分组,卡片 5 行文本过高;词典背景为 `systemBackground` 与其它页的 `canvas` 不同;空态图标/字号三页各异。 | -| P9 | 文案 | 帮助/系统录音说明仍写“停止录音后,回到 OpenLess 键盘确认插入”“键盘中的录音入口会打开 OpenLess”,与“结果自动上屏、免跳转”相矛盾。 | -| P10 | 键盘 | 见第 6 节(需实机/模拟器键盘截图;本轮模拟器键盘自动选择失败,依据代码审查)。 | - -## 2. 设计 token(`App/AppDesign.swift` 新增/调整) - -```swift -enum AppDesign { // 颜色沿用现有 canvas/surface/accent - static let accentSoft = accent.opacity(0.10) // 图标底、选中底 - static let success = Color(uiColor: .systemGreen) - static let danger = Color(uiColor: .systemRed) - enum Space { static let page: CGFloat = 20, section: CGFloat = 24, card: CGFloat = 16, row: CGFloat = 12 } - enum Radius { static let card: CGFloat = 18, tile: CGFloat = 12, icon: CGFloat = 10 } - static let dockClearance: CGFloat = 78 // 62 dock + 8 top + 8 bottom -} -``` - -- 排版(全部 Dynamic Type 文本样式,不写死字号):页面标题 `.largeTitle.weight(.semibold)` + `tracking(-0.6)`;卡片标题 `.headline`;行标题 `.body`;说明 `.footnote` + `.secondary`;统计数字 `.title2.weight(.semibold).monospacedDigit()`。 -- 卡片:`productCard(padding: 16)`,圆角 18,描边 `Color.primary.opacity(0.06)` 0.5pt,浅色无阴影;深色加描边即可。 -- 图标底 `IconTile(symbol:, tint:)`:28×28(设置行)/ 36×36(风格卡),圆角 10,底色 `accentSoft`,图标 `.system(size: 15/18, weight: .medium)`。 -- 命中区域最小 44×44;行最小高度 52。 -- 动画 token(`ProductMotion`,放 `AppDesign.swift`;`PlatformShared/ProductMotion.swift` 已被移除,不要恢复): - - `quick = .easeOut(duration: 0.16)`:按压、勾选、徽标。 - - `standard = .spring(response: 0.34, dampingFraction: 0.88)`:卡片展开、列表增删。 - - `page = .easeOut(duration: 0.22)`:页面出现(opacity 0→1 + y 偏移 8→0)。 - - `dock = .easeInOut(duration: 0.4)`(ProductDock 内 UIKit 动画保持 0.4s 临界阻尼)。 - - Reduce Motion:所有位移/缩放去掉,只保留 `.easeOut(duration: 0.12)` 的透明度。 - - 封装:`func motion(_ a: Animation, reduce: Bool) -> Animation`,视图用 `@Environment(\.accessibilityReduceMotion)`。 - -## 3. 双 dock 打磨(`App/ProductDock.swift`、`App/OpenLessApp.swift` RootView) - -保持结构与 UIKit 实现(`DockPairHost` / `DockBarHost` / `DockMorphItem`)不动,只做: - -1. **底部渐隐遮罩**(解决 P2):在 `RootView` 的 dock overlay 之下加 `DockScrim`:`LinearGradient(colors:[canvas.opacity(0), canvas.opacity(0.94)], startPoint:.top, endPoint:.bottom)`,高度 `dockClearance + safeAreaInsets.bottom + 12`,`allowsHitTesting(false)`,`ignoresSafeArea(edges:.bottom)`。内容从 dock 后滚过时淡出,不再与图标重叠。 -2. **出现/隐藏过渡**:`dockVisible` 改变时 dock + scrim 使用 `.transition(.move(edge:.bottom).combined(with:.opacity))`,包在 `.animation(motion(.standard), value: dockVisible)`(RootView body 的 overlay 上);Reduce Motion 时只用 `.opacity`。键盘弹出时即不闪烁。 -3. **触感**:`onPrimary`/`onStyle` 回调里触发 `UISelectionFeedbackGenerator().selectionChanged()`(仅当选中项变化时,且遵循 `controller.preferences.haptics`——`ProductDock` 通过 `@Environment` 传入布尔值;此项为可选,若无法干净取得偏好则省略,不加新状态)。 -4. **页面切换过渡**:新增 `PageAppear` ViewModifier(`.onAppear` 将 `@State shown` 置 true,`opacity(shown ? 1 : 0)`,`offset(y: shown || reduceMotion ? 0 : 8)`,`animation(page)`),应用在各 tab 根视图内容上。TabView 切换时新页面淡入上移 8pt,0.22s。 -5. 无障碍:dock 不变;scrim `accessibilityHidden(true)`。 -6. 验收:dock 下方不再出现内容透字;风格展开/收起动画不回归(`testDockRoundTripsAndStyleCardsStayResponsive` 通过)。 - -## 4. 页面规格 - -### 4.1 首页 `App/HomeView.swift`、`OverviewMetricsView.swift`(P3) - -- 顶部:保留品牌行(`OpenLessMark(34)` + 字标 + 键盘安装按钮,按钮改 `accentSoft` 圆底 44×44)。删除“少打字,多表达。/把你想说的,变成文字。”两行大字:品牌行即是首屏标识,口号放入空态副文案即可。 -- 主卡 `VoiceHeroCard`(新 private struct,HomeView 内):一张 `productCard`,内部垂直排列:状态行(状态点 + `stateTitle`,右侧语言菜单 `Picker` 改成 `Menu` 胶囊,44pt 高)→ 麦克风控件(`NativeSpeakControl` 230×132;去掉 180pt 光晕圆,改为录音时才出现的 `Circle().fill(accentSoft)`,随 `level` 缩放 1.0…1.12,Reduce Motion 下不缩放)→ 提示 `hint` → 次级动作“启用键盘语音”。结构不改业务调用。 -- 概览:标题与刷新放在一行;4 张统计卡改为一张卡内 2×2 网格(`Grid`,分隔线 0.5pt),每格“标题 caption / 数值 title2 / 说明 caption2”。无数据(`library.history.isEmpty`)时不显示图表,改显示 1 行说明“开始录音后,这里会显示趋势”;有数据时图表高 112。 -- “试用键盘”卡保留(identifier `openKeyboardTrial` 不变),底部 info 文案删除“启用键盘语音后…”重复提示或合并进主卡。 -- 状态过渡:`stateTitle`/`hint` 使用 `.contentTransition(.opacity)` + `.animation(motion(.quick), value: phase)`;错误卡 `.transition(.opacity.combined(with: .move(edge:.top)))`;结果卡出现用 `.standard`。 -- 页边距 `Space.page`。 - -### 4.2 设置 `App/SettingsView.swift`、`AccountProfileHeader.swift`(P4) - -- 账户行改为紧凑行 `AccountSummaryRow`:头像 44pt + 名称 `.headline` + 副标题(未登录:“登录 GitHub;同步暂不可用”)+ chevron,整体一个 `Section` 行,高 ≈ 72pt,去掉渐变大卡。`AccountProfileHeader(compact:true)` 重做为该行;`compact:false`(账户详情页)保留居中布局但头像 88、`padding(.vertical,24)`。 -- 区段顺序:键盘 → 风格与外观 → 语音识别 → 系统录音 → 账户 → 帮助与关于 → 版本。账户移到“键盘”之后(输入是核心任务,账户是辅助)。 -- 每行加 28pt `IconTile` 前缀(Picker 用 `Label` + `.labelStyle(SettingsLabelStyle())`),行高 ≥ 52。 -- 搜索栏 `PinnedSearchBar`:背景改 `AppDesign.canvas`(不透明)+ 底部 0.5pt 分隔线,消除内容透字。该改动在 `NativeSearchField.swift` 的 `PinnedSearchBar` 内,所有页面共享。 -- 页面标题:设置、风格根页使用大标题风格——设置改 `.navigationBarTitleDisplayMode(.large)` 不可与 safeAreaInset 搜索栏共存时,保留 inline,但标题文本 `.headline`;本轮保持 inline(风险最低),仅统一 `PageAppear`。 - -### 4.3 风格库 `App/StyleLibraryView.swift`(P5、P7) - -- `installedStyles` 的卡片重做为 `StyleCard`(≈ 92pt):`HStack`:`IconTile(sparkles, 36)` | 名称 `.headline` 1 行 + 描述 `.footnote.secondary` 2 行 | 右侧 `StyleUseControl`。整卡其余区域是 `NavigationLink`(identifier `styleDetail-` 不变)。 -- `StyleUseControl`:`Button`,当前使用 → 填充 accent 胶囊 + `checkmark`,文字“使用中”;未使用 → 描边 accent 胶囊,文字“使用”;高 32、最小宽 64;`accessibilityLabel` 仍为“当前使用”/“使用此风格”,identifier `styleUse-` 不变;当前项不再灰色禁用外观(disabled 仍保留语义,视觉用 accent 填充而不是 gray)。移除左下角重复的勾选圆。 -- 当前使用的卡片:描边 `accent.opacity(0.35)` 1pt,其余 `outline`。选中动画 `.animation(motion(.quick), value: style.active)`。 -- 空/可用性提示:`availability` 内“设置文字模型”改成 `ProductRow` 风格的小卡(symbol `slider.horizontal.3`),置于列表顶部提示区,不与风格卡抢层级。 -- 详情页底部操作条 `StylePrimaryAction`:背景由 `.regularMaterial` 改为 `canvas` 渐隐(同 DockScrim),按钮 `.borderedProminent`;当前使用时显示 “当前使用 ✓”,使用 `.tint(AppDesign.success)`,`disabled` 保留;底部 padding 加 `dock` 之上的间距(detail 页 dock 仍可见,沿用 `additionalSafeAreaInsets`)。 -- 风格市场行:保留 `List`,`StyleSummary` 的 `.padding(.vertical, 4)` 保持。 - -### 4.4 翻译目标 `App/StyleTranslationSettingsView.swift`(P6) - -- 删除 `Section("在键盘中翻译")` 中的空 `Text("")`,改 footer 文案 `Text("按住键盘上的翻译按钮并向上滑动选择语言,也可点按按钮后选择。")`;该区段改为带 `hand.draw`(`character.bubble`)图标的单行说明。 -- “翻译模型”区段:状态用 `Label`;未配置时按钮“配置翻译模型”保持 identifier;说明文案并入 footer。 -- 目标语言行当前默认项使用 accent `checkmark` 并加粗。 - -### 4.5 历史 `App/HistoryView.swift`(P8) - -- 条目卡文字 `lineLimit(3)`;时间与时长同一行,状态放在右下 `caption`,去掉整行 chevron 重复(整卡可点已足够,保留 `accessibilityHint`)。 -- 空态统一为 `EmptyState(symbol:, title:, message:)`(新建于 `AppDesign.swift`:56pt 图标圆底 `accentSoft`,标题 `.headline`,说明 `.footnote.secondary`,垂直 padding 48),历史、词典共用。 - -### 4.6 词典 `App/VocabularyView.swift`(P8) - -- 背景 `systemBackground` → `AppDesign.canvas`;空态改用 `EmptyState`;页边距 `Space.page`。 - -### 4.7 授权/激活 `App/HostVoiceActivationView.swift` - -- 结构保留;各 phase 之间用 `.animation(motion(.standard), value: request.phase)` + `.transition(.opacity)`;ready 文案已是“结果会自动输入”,保持。 - -### 4.8 文案(P9) - -- `SystemRecordingInstructions`、`HelpView`、安装页:删除“回到 OpenLess 键盘确认插入”;改为“停止录音后,文字会自动输入到当前输入框。”;“键盘中的录音入口会打开 OpenLess”改为“首次启用或后台会话过期时,键盘会短暂打开 OpenLess 并自动返回;其余情况直接在键盘内录音。” -- 同步 `Resources` 里对应的 zh-Hans/en `.lproj` 字符串(如存在),并检查 `AppTests`/`UITests` 中引用这些文案的断言。 - -## 5. 过渡动画清单 - -| 场景 | 动画 | 实现 | -|---|---|---| -| 页面切换(dock 切 tab) | 新页 opacity 0→1 + y 8→0,0.22s easeOut | `PageAppear` | -| dock 展开/收起 | 保持现有 0.4s 临界阻尼 morph | `DockPairHost.update`(不改) | -| dock 随键盘/二级页隐藏 | 下移 + 淡出,0.34 spring | RootView overlay `.transition` | -| 风格卡选中 | 描边/胶囊颜色 0.16s easeOut | `StyleUseControl` | -| 首页状态切换 | 文案 opacity 交叉淡化 0.16s | `contentTransition(.opacity)` | -| 错误/结果卡出现 | opacity + 8pt 上移,0.34 spring | `.transition` | -| 麦克风电平光晕 | scale 1.0–1.12,随 level,0.1s | HomeView 内 | -| sheet | 系统默认;详情 `presentationDetents([.medium,.large])` | 保持 | -| 键盘模式切换(语音/EN/拼) | 选中块平移 0.2s(已有);面板内容 opacity 交叉 0.16s | 第 6 节 | -| 录音 准备→录音→处理→上屏/失败 | 见第 6.3 | SpeakButton / 键盘 | - -Reduce Motion:位移、缩放全部关闭,仅 0.12s 透明度。 - -## 6. 键盘视觉(只改布局/样式/文案,不改状态机) - -> 依据代码审查(`Keyboard/KeyboardStyle.swift`、`PlatformShared/SpeakButton.swift`、`KeyboardViewController.swift` 视图部分);若运行截图显示不同,以截图为准并更新本节。 - -1. 模式条 `KeyboardModeControl`:保持 3 段;选中块圆角与外框同心(外 18 / 内 15 已满足);选中文字 `.label` + `.semibold`,未选中 `.secondaryLabel`;触控高度 ≥ 36(键盘垂直空间有限,不再增高)。 -2. 语音页 `KeyboardStyle.voicePanelHeight = 200`:提示/状态行放在按钮上方固定高度 20pt 的槽位,避免文案出现时按钮跳动;按钮居中,大面积留白用于“风格/翻译”芯片行而不是空着。 -3. 状态与文案(遵循协调者规则,只改文案,不改状态机): - - `canStart == false` 且 snapshot 新鲜 + 主 App 忙/配置刷新中:显示“正在等待,可取消”,不提示打开 App。 - - snapshot 新鲜 + 空闲 + `backgroundReady == false`(首次启用或后台会话过期):唯一显示“需要打开 OpenLess,将自动返回”的情形。 - - snapshot 过期:显示“正在连接…”,不用错误色。 - - 结果不再出现“确认插入”。 -4. 录音状态视觉(`SpeakButton`):准备中 = 按钮灰蓝 + 小菊花;录音中 = 红色实心 + 7 根电平条(现有);处理中 = 渐变扫光(现有,`presentationIsVisible` 控制暂停);上屏 = 0.16s 勾选淡入后回 idle;失败 = 0.16s 文案淡入,红色文字,不加抖动。均受 Reduce Motion 控制。 -5. 不新增模糊/Metal/持续动画,保持扩展内存。 - -## 7. 验收截图清单(`Artifacts/ui-sonnet-20261019/`,before-*/after-* 成对) - -首页、首页滚动、历史、词典、设置、设置滚动、账户、界面与外观、帮助、风格库、风格详情、风格市场、翻译目标、翻译语言选择、添加键盘、试用键盘;深色再做首页/设置/风格库;键盘三模式与录音各状态在可用时补充。 - -截图由 `UITests/UIAuditTests.swift`(只导航+截图,不改数据)生成: -`AUDIT_DIR` / `AUDIT_PREFIX` 通过 `TEST_RUNNER_` 环境变量传入,`xcrun simctl ui appearance dark|light` 切换外观。 - -## 8. 测试保护 - -保留所有 accessibilityIdentifier:`primaryDock`、`styleDock`、`dockTab-*`、`dockStyle-*`、`styleDetail-*`、`styleUse-*`(label 仍为“当前使用/使用此风格”)、`styleActivate-*`、`openAccountSettings`、`openStyleLibrary`、`openKeyboardTrial`、`prepareSpeech`、`historySearch`、`settingsSearch` 等。 diff --git a/openless-all/ios/docs/keepalive.md b/openless-all/ios/docs/keepalive.md deleted file mode 100644 index eacbaf0f1..000000000 --- a/openless-all/ios/docs/keepalive.md +++ /dev/null @@ -1,85 +0,0 @@ -# 跨应用录音:Build 10 当前实现 - -2026-10-21:用户已确认 Build 9 基础 PiP 免跳转可用。本轮改为隐藏 VideoCall PiP 保活、空闲释放音频,目标与验收见 [Build 10 目标](ios-build10-goals.md),隐藏/共存方案见 [PiP 设计](pip-hidden-coexistence.md)。 - -当前准入依赖用户激活代际、实际 PiP active/non-suspended、麦克风权限、解锁和系统中断状态;不再依赖静音输出引擎或 render timestamp。停止一次录音只关闭输入与音频会话,保留 PiP 和激活意图;关闭保活才显式销毁。运行时接口不兼容时降级为前台激活,不报告隐藏或共存成功。 - -下文全部为 Build 9 及以前的历史记录,其中“当前实现”“下一步”等措辞仅对应历史时点,不代表 Build 10 的代码状态。Build 10 真机效果以新包验收记录为准。 - ---- - -# 跨应用录音:输出会话与 PiP 研究 - -## 2026-10-20:再次收起键盘后的交付与准入修复 - -本次重新检查了“停止录音 → 收起键盘 → 切到其他 App → 再录”的代码路径,发现 Build 6 仍有三处缺口: - -- 输出音频在独立渲染线程上继续运行,但 `isReady` 使用主线程健康计时器缓存的时间。主线程延迟超过三秒后,IPC 回调可能先于计时器执行,把仍在输出的会话误判为失效。现在每次准入直接读取 `AVAudioPlayerNode.lastRenderTime`;健康计时器继续负责清理真实停止的会话。 -- 切换中英文现在只改变键盘布局,不会清零 `speechPrepared`,也不会重新准备识别器;能力快照始终使用 `sourceLanguage=auto`。布局切换不会创建采集引擎或重新激活音频。 -- 录音结束时键盘可能已经消失,识别结果仍向旧 `keyboardID/documentID/revision` 发送插入请求。旧扩展不再回执,Core 等待三十秒,新录音握手却在二十秒超时。现在键盘重新接入时,不携带 session ID 的有效空闲 ping 会撤销旧交付并立即回复 Core;即使 UIKit 重用同一个输入目标也适用。仍持有录音/结果的键盘发送带 session ID 的心跳,不会误撤销自己的结果。文字仍留在 SQLite 的待确认记录;尚未完成的识别结果随后到达也遵守该撤销。迟到的旧回执不能消费新会话,旧 native `insert` 必须匹配当前 Core 会话 ID。 - -键盘文案也区分了等待录音处理、识别配置准备、需要激活和主进程失联;空闲键盘不再一直显示“正在连接”,失去后台资格也不再被统称为“OpenLess 正忙”。 - -本次新增共享交付消息序列测试,覆盖真实 Mailbox 的旧插入、目标更换、保留待确认、新会话和迟到回执。原生音频探针增加了阻塞主线程 4.2 秒后直接检查实时准入的场景。最终运行结果由本轮构建/测试记录提供;代码修复本身不构成用户 iPhone 上长期免跳转已通过的证据。 - -Build 7 产物、测试记录和截图见[本轮交付记录](../Artifacts/interaction-build7/README.md)。 - -### 竞品与平台依据 - -[Typeless 官方说明](https://www.typeless.com/help/release-notes/ios/when-does-app-switching-happen)(2026-07-28,检索于 2026-10-20)明确区分两种状态:可直接听写,以及需要短暂打开主 App 取得麦克风后返回;同时说明待机时麦克风关闭。它没有承诺任何系统状态下都无需激活。仓库已有的 [Typeless 字节码复核](../Artifacts/keyboard-reopen-fix/competitor-evidence.md) 则显示,键盘隐藏、停一轮录音和关闭 PiP 保活是独立入口;本次沿用这种生命周期分离,没有声称恢复了其隐藏 PiP 原生实现。 - -Apple 的[自定义键盘文档](https://developer.apple.com/documentation/uikit/creating-a-custom-keyboard)说明键盘在独立且受内存限制的进程中运行,收起键盘也不意味着扩展必然销毁。因此后台语音能力与结果交付都不能以键盘视图是否还存在作为唯一判断。Apple 的[音频引擎配置变更文档](https://developer.apple.com/documentation/avfaudio/avaudioengineconfigurationchangenotification)还说明硬件通道数/采样率变化会停止引擎;本项目继续在真实输出停止、中断或权限失效时撤销准入,不凭缓存布尔值宣称可以后台开麦。 - -## 当前实现:输出音频会话 + PiP - -键盘仍通过 `NoJumpAudioSession` 的独立输出引擎保持后台服务,录音期间才运行输入引擎;首次在主 App 前台准备语音后,`ExperimentalPiPKeepAlive` 使用公开 `AVSampleBufferDisplayLayer`/`ContentSource` 建立 PiP 保活。只有输出租约和 PiP 都真实有效时,后台新录音才直接准入;待机不创建 `inputNode`,也不采集麦克风。Build 9 的代码根因与准入边界见 [免跳转录音根因](ios-nojump-root-cause.md)。 - -Build 6 补充了键盘收起重开的真实录音验证与两个准入竞态修复;Build 9 新增 PiP 保活和后台录音准入。公开 API 不提供完全隐藏 PiP 窗口的开关,且本机当前没有签名身份,因此 Build 9 仍不能宣称通过 iPhone 真机长期免跳转验收。 - -下面保留的是此前的 PiP 研究与 2026-10-17 历史状态;它不覆盖 Build 9 的公开 API 实现。系统中断、锁屏、其他 PiP、来电/Siri、进程回收和能耗仍需真机验证。 - -## 2026-10-17 历史验证路径 - -- 系统控制中心录音已实现:iOS 27 模拟器中,主 App 不在进程列表时点击控制可后台启动主进程,收到真实音频帧,持续 79.7 秒后主动停止。实时活动只覆盖录音期间,结束时释放采集并结束活动。 -- 键盘前台往返已实现:键盘按钮打开主 App 的对应请求,用户点按开始,返回 Safari 后继续录音,由键盘停止、预览并确认插入。输入位置变化时再次确认,交付不自动重放。 -- 键盘直接后台启动仍未实现。此前真实键盘的 `Button(intent:)` 路由证明只执行在扩展进程;控制中心的成功不能替代这项证据。 -- 最新界面与 Release 产物见 `../Artifacts/consumer-redesign/README.md`;语音运行证据见 `../Artifacts/overnight-usability/acceptance-summary.json`。模拟器证据不能代替真机运行。 - -继续检查发现一台已连接且开发者模式已开启的 iPhone 18 Pro(iOS 27.0.1 / 24A446)。本机当前可用代码签名身份为 0,项目未配置 `Config/Local.xcconfig`,该设备也未安装本项目默认 bundle。真机安装仍需有效开发证书、Team 和 App Group 配置。 - -## 硬要求 - -在明确激活过的会话内,同时做到: - -1. 键盘请求录音时不切主 App 前台。 -2. 两次录音之间没有持续音频采集。 -3. 没有长期可见的小窗、横幅、边缘拉手。 -4. 仍显示系统真实录音隐私指示;不越狱、不绕过麦克风权限。 - -## 已有证据与缺口 - -- 用户提供的 `ios-no-switch-report.md`:竞品将键盘、主进程采集和保活分离,存在 PiP/hidden/stash 模块线索。 -- Typeless 2.7.0 字节码:停录时关闭音频服务但保留 PiP 的分支可读;原生隐藏实现和设备行为未恢复。 -- 本机 iOS 27 SDK:公开 `AVPictureInPictureController` API 没有完全隐藏窗口的开关。 -- 历史 [AVKit](https://github.com/nst/iOS-Runtime-Headers/blob/master/Frameworks/AVKit.framework/AVPictureInPictureController.h)/[Pegasus](https://github.com/nst/iOS-Runtime-Headers/blob/master/PrivateFrameworks/Pegasus.framework/PGPictureInPictureController.h) 头文件只作研究导航,不能证明 iOS 27 的接口、ABI 或行为。 -- 已安装 iOS 27.0 模拟器运行时,App、实际键盘扩展与只读诊断测试已运行。真机连接与签名状态见上方更新;模拟器 UI、macOS PiP 行为均不能替代 iPhone 后台重新开麦和隐藏窗口证据。 - -## 代码实际状态 - -`ExperimentalPiPKeepAlive.isImplemented` 只表示当前系统具备公开 PiP API;`isReady` 只有在支持、已启用且系统报告 PiP active 时才为 true。它不写死成功状态,也不会在待机时打开麦克风。关闭设置中的“免跳转后台保活”会停止 PiP 并释放输出会话。 - -`Resources/KeepAliveCompatibility.json` 为空;即便人为修改 JSON,也不会把不存在的隐藏实现变成就绪。没有“只差打开开关”的承诺。 - -当前系统控制中心后台录音与键盘前台往返可独立使用;Build 9 的键盘直接免跳转仍需要签名包和 iPhone 18 Pro 运行证据。公开 PiP 实现不等于 Typeless 的私有隐藏表面。 - -## 下一步必须在真机完成 - -- 配置签名及 App Group,登记机型和完整系统 build。 -- 收集目标系统的实际接口,检查方法签名及对象所有权;明确恢复隐藏方法后才在隔离实验模块实现。不能猜 selector、复用历史 ABI 或盲目调用 `perform`。 -- 先验证隐藏动作是否维持真正后台执行及重新开麦权限,而不只是看不到视频内容。 -- 激活后连续 20 次听写,以及空闲 1/5/30 分钟后恢复。 -- 核对真实采集停止、系统麦克风指示、外部录像中的所有悬浮 UI。 -- 来电、Siri、其他媒体/PiP、蓝牙、锁屏、键盘进程回收、强退主 App:退出就绪且不得自动重开麦。 -- 记录 CPU、内存、发热和能耗,再登记可用 build。只出现一个成功截图不算通过。 - -没有通过就保留“不支持/未验证”,不得切换成持续占麦、透明内容的小窗、自动收边残留或循环跳转。 diff --git a/openless-all/ios/docs/keyboard-environment-check.md b/openless-all/ios/docs/keyboard-environment-check.md deleted file mode 100644 index 04da4c956..000000000 --- a/openless-all/ios/docs/keyboard-environment-check.md +++ /dev/null @@ -1,42 +0,0 @@ -# 软件键盘退化:当前环境对照 - -2026-10-16。ENV-02 尚未关闭,本记录只说明本次观察,不宣称已经确定根因或修复。 - -## 操作与观察 - -主 Agent 通过 Orca 自带模拟器连接唯一设备 `A4B5F07C-C6C9-498F-BEEC-AC7AC98B8A6A`,先读取共享 voice snapshot:inactive、level 0;没有调用准备语音/开始录音。 - -1. 打开当前已安装 OpenLess,Orca AX 返回真实页面;通过 normalized tap 点击词典 Tab 后复查 AX,词典 radio value 从 0 变为 1。 -2. 点击旧版词典添加,再点词条输入框。截图中没有软件键盘。未保存词条,点击取消离开。 -3. 打开系统 Settings,通过 Orca tap 从键盘设置退回根页,再点击系统搜索输入框。 -4. 系统搜索框出现光标,但截图没有软件键盘;Orca type `openless` 后,AX TextField 的 value 真实变为 `openless`,屏幕键盘仍无。没有修改任何系统设置。 - -证据目录 `Artifacts/execution-wave1/`: - -- `orca-after-tab-ax.json`:Tab 实际切换,不以 tap 返回 ok 当成功。 -- `orca-focused-word-input.png`:当前旧词典 Form,无软件键盘;不是新词典设计验收。 -- `orca-system-search-focused.png`/`orca-system-search-focused-ax.json`:系统输入框获得光标也没有屏幕键盘。 -- `orca-system-search-typed.png`/`orca-system-search-typed-ax.json`:系统输入框接收 ASCII,仍无软件键盘。 - -## 可下的结论 - -- 当前问题**不只发生在 OpenLess 编辑器**;系统输入框同样可复现。 -- `ConnectHardwareKeyboard=false` 的全局/设备偏好不是屏幕键盘真实显示的充分证据。 -- 输入服务/扩展进程存在不证明 UI 可见;AX 有焦点不证明屏幕键盘可见。 -- 模拟器 keyboard preferences 当时存在 `HardwareKeyboardLastSeen=1`,只是线索,不能单凭这个历史字段定根因,更不直接改用户偏好清库。 -- Orca 当前 serve-sim runtime 是 `1.4.227`,旧检查点的 `1.4.224` 已不存在。当前 normalized tap/type 有 AX 变化证据;不能继续引用旧版本 tap 无变化的结论。 - -## 后续恢复证据(同日) - -在当前 Orca serve-sim 1.4.227 中,通过其已实现的 `softwareKeyboard` HID toggle(helper WebSocket type 12,对应工具条软件键盘开关)切换一次后,出现底部系统地球键/听写栏,但键盘内容暂时仍未出现。随后实际点击地球键切到系统简体拼音,**系统26键显示**;再切回 OpenLess,**OpenLess 语音键盘也完整显示**。 - -- `Artifacts/execution-wave3/keyboard-toggle.png`:先出现底部系统栏,无键盘内容。 -- `system-keyboard-switched.png`:系统拼音键盘真正显示,输入框/弹窗随键盘避让。 -- `openless-keyboard-after-switch.png`:切回 OpenLess 后键盘真正显示(还是视频改版前版本)。 -- `keyboard-popup-restored.xcresult/.log`:原 `testTrialEditorShowsSoftwareKeyboardAfterRefocus` **通过,exit 0**;实际键盘存在、编辑器缩小、收起/再次点击三轮原断言未改。 - -没有重置数据、改 Full Access、重启系统服务或加产品强制聚焦补丁。恢复步骤是软件键盘显示切换+输入法切换;不能单凭此确定最初由哪个版本/服务导致隐藏状态,也不能保证所有重启后永不复发。 - -重要修正:系统应用输入框也可能选中 OpenLess 扩展;所以“系统输入框也不弹”单独并不能排除第三方扩展问题。上述地球键切到系统拼音再切回才是实际输入法对照。 - -当前恢复可继续验收新键盘。最终视频改版后仍需重跑相关输入回归;旧失败日志保留,不改写为历史通过。 diff --git a/openless-all/ios/docs/keyboard-raw-input.md b/openless-all/ios/docs/keyboard-raw-input.md deleted file mode 100644 index bbdf68a24..000000000 --- a/openless-all/ios/docs/keyboard-raw-input.md +++ /dev/null @@ -1,27 +0,0 @@ -# Build 11 更新 - -用户通过新录屏澄清:候选栏保留中文,不添加独立英文原文项。Build 11 已移除 Build 10 的原文候选按钮,保留第一次 Return 确认 rawInput、第二次执行宿主动作的两步行为。默认尺寸与自定义高度见 [Build 11 目标](ios-build11-goals.md)。以下为 Build 10 历史记录。 - ---- - -# 拼音原文提交与按键尺寸 - -目标:拼音模式输入 `openless` 时,蓝色确认键应提交实际键入的英文;用户也能直接点击候选栏中的原文。中文候选、空格选词及符号布局切换保持各自明确的行为。 - -## 输入行为 - -- 有组合输入时,第一次按 Return 提交原始字母,不换行、搜索或发送;第二次 Return 才交给宿主执行原本的动作。 -- 候选栏第一项显示实际输入,如 `openless`、`nihao`、`nvse` 或 `xi'an`。点击后提交原文,保留 `v` 和用户自己输入的音节分隔符。 -- 点击中文候选或按空格仍提交中文;切换符号布局时沿用 Rime 的组合提交规则,不隐式选择第一个中文候选。提交后原文候选消失,恢复后续词预测或工具栏。 - -`OLRimeSnapshot.rawInput` 直接读取 Rime `get_input`。`commitRawInput` 先复制 Rime 的输入缓冲,再清除组合并返回提交文本;不从会自动插入空格、显示 `ü` 的 `preedit` 反推原文。键盘沿用原有 marked-text 更新路径,替换当前组合,避免重复插入。 - -## 尺寸 - -语音、英文和拼音共用 232pt 内容高度。竖屏的四行文字键盘每行 58pt,保留 KeyboardKit 的错位、宽度与手势;模式切换文字为 18pt、44pt 高度,语音页 Return 为 48pt 高度。原文候选至少为 44 × 44pt。横屏继续采用 KeyboardKit 布局并适配共同视口。 - -## 验证范围 - -2026-10-21:`bash ios/scripts/test-rime.sh` 已通过,覆盖真实中文候选、空格中文提交、四种原文输入、删除、重复原文提交、清空及提交后原文状态清理;相关 Swift 文件通过语法解析,改动通过 `git diff --check`。 - -`OpenLessUITests.testSymbolsReturnAndCompositionSwitch` 在 iOS 27 专用模拟器最终通过,覆盖原文候选、两次 Return、中文选词、单字候选 44pt 点击尺寸、符号切换、大写及语音页换行。日志和结果为 `Artifacts/release-10/keyboard-confirmed-tests.log/.xcresult`。测试通过逐键确认真实原文、等待宿主实际文字更新来处理跨进程异步交付,不补打丢失的按键;符号切换沿用 Rime 当前提交规则,不假定隐式选中文。初次设置安装流程失败和中间断言失败记录保留。横屏用例初次停在安装辅助步骤,本轮未重新跑横屏视觉验收。 diff --git a/openless-all/ios/docs/keyboard-video-reference.md b/openless-all/ios/docs/keyboard-video-reference.md deleted file mode 100644 index 06f02e68a..000000000 --- a/openless-all/ios/docs/keyboard-video-reference.md +++ /dev/null @@ -1,41 +0,0 @@ -# 最新键盘视频:观察、实现要求与验收 - -2026-10-16,用户明确要求按此视频复刻 iOS 输入键盘的 UI 与逻辑,纳入剩余清单统一实施/审查/验收。 - -## 来源与边界 - -文件实际路径(Application Support 后无额外空格): -`/Users/trip/Library/Application Support/com.netease.uuremote/Download/ScreenRecording_10-16-2026 15-35-41_1.mp4` - -- 56.451667 秒,1206×2622,HEVC+AAC,72,970,649 字节。 -- SHA-256 `fe35d4f505d27fc98b7f5acc51341b0cf215466ea043d32c309ba85059c411c1`。 -- 已实际抽帧查看全屏4秒采样及键盘1秒序列,证据 `Artifacts/video-keyboard/{contact,keyboard-sequence-01,keyboard-sequence-02}.png`。必要时针对切换段加密采样。 -- 只参考键盘UI/可观察交互,不复制 Typeless 品牌或视频中的聊天内容/后台/服务凭据。仍使用 OpenLess 官方标志。 -- 视频主要展示**语音键盘**:普通录音、帮写、翻译选择和处理中;能看到 EN/拼切换入口,但没有实际展开英文26键/中文拼音键位。因此保留 KeyboardKit/Rime 成熟打字布局,统一顶栏/颜色;不能宣称视频证明未出现的文字布局或光标手势。 - -## 时间段与观察 - -| 时间(近似) | 实际可见 | 实现要求 | -| --- | --- | --- | -| 0~7s | 浅灰偏紫整片键盘、左上品牌、右上灰色圆形取消;中央黑色圆形真实波形,淡灰环;下方“再次点击以完成”,左下地球键 | 录音视图收起无关编辑键,波形绑定真实音量;点按完成、取消独立;无真实采集时不伪造波形 | -| 8~10s | 中央收缩为横向深色 Thinking 胶囊,内部浅色扫动;保留取消和地球键 | processing 状态、取消、减少动态效果;不以定时器假装返回结果 | -| 11~12s、22~26s | 空闲页品牌+右上语音/EN/拼白色胶囊;中央“点击说话”与黑色横向麦克风;左右有撤销/帮写、删除/@;下方白色换行胶囊 | 简洁一致的空闲控件;Return/delete/@作用于真实输入目标,撤销只在可安全撤销时出现/可用;不借参考增加不受控编辑 | -| 13~18s | “帮我写”页;左下较小黑色音量圆,右侧“描述要写的内容/再次点击以完成”;右上取消 | 提示词驱动帮写,不是选区润色;请求真正可用后才执行;生成预览/受控插入保留 | -| 19~21s | 帮写处理胶囊 | 真实任务处理状态,失败不伪造文本或插入 | -| 27s | 顶部弧形目标选择,提示“向上滑动以选择”,下方“松开以取消”;三个示例目标 | 长按进入选择态,上滑命中已配置目标;不是所有按住说话松开都取消;使用真实目标列表,不把视频示例语言写死 | -| 28s | 右侧英语目标黑底选中,提示“释放以翻译”,取消区域 | 有效目标释放提交意图;无目标释放取消;越界/中断规则可测 | -| 29~43s | 回到录音圆;顶部黑色“正在翻译为 英语(美国)”标签,音量随输入变化 | 会话开始固定翻译目标与服务能力;标签必须对应实际Core请求,修改全局设置不串会话 | -| 44~56s | 录音圆向处理中胶囊过渡,显示Thinking | 转场短而可打断;仅真实processing显示,完成/取消释放动画与音频 | - -## To-do(VID-01~06) - -- [x] VID-01:找到视频、核对元信息与哈希、抽帧观察,记录出现与未出现的内容。 -- [ ] VID-02:原生语音键盘空闲/录音/处理/取消布局,品牌、圆形/胶囊和实音量波形;深浅色/横屏/字号。 -- [ ] VID-03:帮写页与真实处理意图,配置不可用时清楚禁用/解释;不恢复已删除选区润色。 -- [ ] VID-04:弧形翻译目标选择与明确状态表、命中/释放/取消、冻结会话目标;多目标依赖Core T接口。 -- [ ] VID-05:EN/拼切换保留 KeyboardKit/Rime,Return/删除/@与安全撤销;无Full Access基本打字不退化。 -- [ ] VID-06:独立Codex review;主Agent在Orca内置模拟器逐态操作/截图,对照视频验收,不能只验静态布局或测试注入的假录音。 - -## 不得混淆 - -复刻键盘外观不等于隐藏式免跳转完成;当前可用性必须如实展示。视频中的圆环/橙色麦克风提示是该产品运行状态,不构成 OpenLess 已具备后台采集的证据。不能为展示效果自动启动人声采集或云服务。主Agent实际录音验收遵循已有用户授权边界,合成fixture与真实设备证据分开。 diff --git a/openless-all/ios/docs/local-speech-provenance.md b/openless-all/ios/docs/local-speech-provenance.md deleted file mode 100644 index 221762b4d..000000000 --- a/openless-all/ios/docs/local-speech-provenance.md +++ /dev/null @@ -1,34 +0,0 @@ -# 本地语音资源来源 - -资源由 `scripts/bootstrap-local-speech.sh` 下载、校验并复制。二进制在忽略目录中,不将下载一半的文件作为产品资源。主 App 打包模型;键盘不链接 LocalSpeech。 - -## 固定资源 - -模型仓库:`csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17`。 - -固定 revision:`2365baeacb507f821a0c8120fcee3d484dba7a07`。 - -| 文件 | SHA-256 | -| --- | --- | -| `model.int8.onnx`,239,233,841 bytes | `c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51` | -| `tokens.txt` | `f449eb28dc567533d7fa59be34e2abca8784f771850c78a47fb731a31429a1dc` | -| 公共测试夹具 `test_wavs/zh.wav` | `b77f1794fe374a0ba1ee1dc458bfaf9349496cbbfc32780c50ba3c5a7ad8e373` | - -模型与 tokens 放入 `Resources/SenseVoice/`;公开夹具放入 `AppTests/Fixtures/`,不包含用户录音。HF 固定 revision 链接: - -`https://huggingface.co/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/tree/2365baeacb507f821a0c8120fcee3d484dba7a07` - -| 运行时 | 上游地址 | 压缩包 SHA-256 | -| --- | --- | --- | -| sherpa-onnx 1.13.8,Apache-2.0 | `https://github.com/k2-fsa/sherpa-onnx/releases/download/xcframework/sherpa-onnx-v1.13.8-ios-static.xcframework.zip` | `6b8e769cb153343270fdccbe92e3b3db0d1c421d67fa0989ab01fdf5b2fcf2de` | -| ONNX Runtime 1.28.2,MIT | `https://github.com/csukuangfj/onnxruntime-libs/releases/download/v1.28.2/onnxruntime-ios-static-xcframework-1.28.2.xcframework.zip` | `2c2299acbb461d26d4bac4bc85985d40e7c7177ed6072703ae0846d88b0b4599` | - -两个 xcframework 包含 device/simulator 切片。许可在 `Resources/Licenses/`。模型/夹具的完整授权和 notices 仍应在公开分发前按固定模型仓库核对,不把“公开下载”当作任意再分发授权。 - -## 验证口径 - -`SenseVoiceTests` 运行打包 int8 模型、读取公共 16 kHz mono PCM,检查非空中文输出,并验证预先取消不会启动模型。两项测试通过,没有打开麦克风。 - -没有为夹具建立人工 gold transcript。最初的测试误用了与音频无关的预期词,导致真实解码后断言失败;现改为明确命名的集成 smoke,不作为准确率测试。已观测输出为“开饭时间早上9点至下午5点。”,不能据此宣称字错率或真实人声体验。 - -桌面新分支的 TextStream 是经 Mac MLX 子进程运行的 Qwen3-ASR,不是可直接移入 iOS 的适配器。此次使用真正的 iOS C API 运行时,没有包装桌面 subprocess,也没有引入竞品服务或提供商密钥。 diff --git a/openless-all/ios/docs/pip-hidden-coexistence.md b/openless-all/ios/docs/pip-hidden-coexistence.md deleted file mode 100644 index c3fc26aa1..000000000 --- a/openless-all/ios/docs/pip-hidden-coexistence.md +++ /dev/null @@ -1,33 +0,0 @@ -# 隐藏 PiP 与生命周期集成(2026-10-21) - -## 目标与当前实现 - -待机只保留 PiP 和激活意图,不打开麦克风、不持续占用音频输出;后台录音准入必须同时检查真正活跃且未暂停的 PiP、解锁状态及音频捕获条件。用户关闭保活立即撤销状态;系统停止 PiP 后及时降级,不能沿用缓存的“已就绪”。 - -`ExperimentalPiPKeepAlive` 使用透明 `AVPictureInPictureVideoCallViewController`,尺寸为 300 × 0.1pt,替代普通 sample-buffer 视频方块。附着在主窗口的透明 `PiPSourceView` 提供系统启动几何位置。没有 AVPlayer、sample-buffer 视频、静音播放或音频输出引擎。活跃且未暂停、未锁屏时,每秒一次极低透明度内容更新;停用、暂停或锁屏后停止更新。这保留参考的内容更新路径,但 1Hz 是本次节能集成选择,不是已证实的长期保活条件。 - -`PiPCoexistenceAdapter` 将私有接口隔离在一处:构造期间仅对本次捕获的 adapter 返回 `_proxyControlsStyle = 5`;构造结束立即恢复该实例的原生 getter 行为。获取 lazy proxy 前装入限定实例的 content-type provider,启动使用 type 4,真实 `didStart` 后等待一秒,只提交一次 type 6。其他 adapter、其他 proxy 保留自身行为;不改对象的 isa,不循环强制覆盖系统状态。卸载时清除关联并恢复本地 type 4,不发送额外远端状态。 - -调用前校验 selector、参数数量、返回类型、参数类型、proxy 类和 `proxy.delegate === adapter`;拒绝覆盖系统原本已有的未知 provider。检查失败会明确返回免跳转不可用,走现有返回主应用录音路径。类上已安装的 wrapper 会保留到进程结束,但未关联实例继续执行原有行为。 - -## 调用契约 - -- `arm()`:开启保活意图并准备控制器,重复调用幂等;不启动音频输入。 -- `startIfPossible()`:有界启动。最多等待五秒 source/系统就绪;真正发起启动后再给系统最多五秒回调时间。 -- `onPrepareStart`:由音频所有者准备启动所需的短时 playback 会话;PiP 本身不改音频 category、route 或 active 状态。回调抛错时,音频所有者负责清理其部分准备。 -- `onSettled`:每次成功的 prepare 对应一次结束通知,涵盖 didStart、失败、超时、锁屏取消和 stop;音频所有者据此释放空闲会话,不得停掉正在进行的用户录音。 -- `foregroundChanged(Bool)`:恢复已有保活意图。前台通知与 didStop 顺序交错时,每次前台访问最多补一次恢复;后台被另一个 PiP 挤走时不持续争抢。 -- `onChange`:重新计算音频准入、发布真实 readiness。`isRecovering` 仅在等待或启动期间为 true。 -- `stop()`:取消任务、控制器、KVO、内容更新、私有适配和音频启动租约。异步回调检查控制器 identity 或 generation,旧回调不能重新激活新状态。 - -锁屏通知立即撤销 readiness、取消未完成启动、停止内容更新。已经活跃的 PiP 保留给系统管理;解锁后若仍活跃则重新检查暂停状态并恢复内容更新。如果锁屏打断了启动、或锁屏期间该控制器停止,解锁时消耗一次恢复标记,在前台或后台均做一次有界恢复尝试;同时覆盖 AVKit active 先变、didStop 晚于解锁通知的顺序。普通视频 PiP 挤出不产生标记,用户关闭立即清除标记,恢复失败不循环重试。其余已结束 PiP 只有前台访问触发恢复。KVO 监听 active/suspended,实际状态变化会通知音频所有者。 - -## 证据边界与验证 - -参考源是工作区已保存的 GlobalRefresh-PiP commit `1b62341c1d62308962fb3656192d6783631aa00c`(`Artifacts/release-10/research/globalrefresh`),不是声称恢复了豆包或 Typeless 的完整源码。来源署名随包保留在 `Resources/Licenses/GlobalRefresh-PiP-NOTICE.txt`。集成去掉了参考的 60Hz 动画、刷新率策略、诊断全局 hook 和不相关 UI。 - -可复现检查:`bash ios/scripts/test-pip-runtime.sh`(从 openless-all 目录)。它用生产 `PiPCoexistenceAdapter.swift`、Swift 6 与 MainActor 默认隔离,在本机 ObjC runtime 验证构造 getter 的实例范围、签名拒绝、代理身份、type4→6 一秒延迟、一次提交、暂停后恢复、旧任务/延迟 block 失效、恢复原有行为及 100 次释放循环;另直接验证生产 `PiPLockRecovery` 的一次解锁恢复、两种回调顺序、启动被锁打断、视频 PiP 隔离及用户关闭清理。脚本删除全部临时编译文件。 - -这些测试不模拟 iOS 画中画服务。系统是否接受远端 type 6、0.1pt 实际视觉隐藏、B 站/其他视频 PiP 同时运行、音频释放后的长时驻留、自动熄屏及锁屏恢复,仍必须在目标 iPhone/系统上验证。诊断里的 `transitionSubmitted=true` 只表示本地 mutation 已执行,不表示远端已接受多 PiP。readiness 仍以 AVKit active/suspended 和锁屏状态为准;系统不兼容时会降级,不假报成功。 - -真机验收顺序:全退后重新激活 → 键盘连续录音两次 → 结束后 B 站外放音量/路由 → B 站开启自己的 PiP → 回主 App 再离开 → 锁屏/解锁 → 用户关闭保活。每一步确认 readiness、麦克风占用、声音和浮窗;特别检查 didStart 后释放音频不会导致 PiP 立即结束。 diff --git a/openless-all/ios/docs/platform-support.md b/openless-all/ios/docs/platform-support.md deleted file mode 100644 index 5eab3d547..000000000 --- a/openless-all/ios/docs/platform-support.md +++ /dev/null @@ -1,34 +0,0 @@ -# iOS 16 起的系统兼容 - -最低部署版本统一为 **iOS 16.0**,主 App、键盘扩展、OpenLessShared 和 LocalSpeech 使用同一个下限。使用 Xcode 27 SDK 构建,不意味着最低系统必须是 iOS 27。 - -## 系统路径 - -| 系统 | 普通模式识别 | 音频采集/权限 | 可选 Apple Speech | -| --- | --- | --- | --- | -| iOS 16 | 打包 SenseVoice,本机 CPU,不预设 LLM | AVAudioEngine 传统 tap;回调先复制值数据,工作 actor 重采样;AVAudioSession 权限 API | SFSpeechRecognizer,仅在支持本机识别时启用 | -| iOS 17/18 | 同上 | 传统 tap;AVAudioApplication 权限 API | SFSpeechRecognizer,本机识别 | -| iOS 26 | 同上 | 传统 tap;AVAudioApplication 权限 API | SpeechAnalyzer/SpeechTranscriber 或 DictationTranscriber | -| iOS 27 | 同上 | immutable installAudioTap;在工作 actor 内复制/转换,保留现有新系统路径 | SpeechAnalyzer/系统转写模块 | - -较新的类型隔离在标记了 `@available` 的实现中,调用处使用 `#available`。不支持所选 Apple 本机识别的旧设备会明确报错,**不会偷偷转云端,也不会改变普通模式的 SenseVoice**。旧的 Apple 系统识别可能受系统语言资产、设备能力和会话时长限制;它不是 iOS 16 基础语音的唯一入口。 - -界面使用 iOS 16 可用的 NavigationStack、ObservableObject/Published、UIKit 文本视图。移除对 iOS 17 Observation 运行时和新版本专属 Button/动画重载的硬依赖。LocalSpeech 的取消标记使用有明确锁保护的 NSLock,不依赖 iOS 18 才提供的 Mutex。 - -## 不只是修改版本号 - -- Core 和 Rime 的 device/simulator 静态库都重建为 iOS 16 下限。 -- Core 使用 `.build/core-ios16` 独立 Cargo 目录,避免复用之前按 iOS 27 编译的依赖对象。 -- `scripts/validate-deployment.py` 检查库中每个 Mach-O 对象,以及最终 App/键盘/debug dylib 的最低版本。旧缓存里混入 iOS 27 对象时会拒绝通过。 -- 已下载的 sherpa-onnx/ONNX Runtime 原生二进制最低为 iOS 13,低于本工程的 iOS 16 下限,无需重新下载模型或替换识别引擎。 -- iOS 27 的 readonly tap 路径保留;iOS 16–26 的旧 tap 会在回调结束前独立复制,不能跨线程共享系统随后会复用的可变音频缓冲。 - -## 验证边界 - -16 组无麦克风合成音频检查覆盖新旧缓冲路径、4 种采样率、单/双声道、尾帧和幅度;旧路径在转换前覆盖原始缓冲,验证复制不依赖原始内存。 - -SDK 可用性编译、Mach-O 最低版本检查、iOS 27 运行回归是不同证据。**目前没有 iOS 16/18 真机或对应旧运行时的实测,不能宣称每个系统都已运行验收。**旧系统实际安装、权限、音频路由和资源占用仍应在对应设备验证。具体本轮结果见 [verification.md](verification.md)。 - -## 免跳转不在此兼容承诺内 - -隐藏式后台免跳转仍未实现;`ExperimentalPiPKeepAlive.isImplemented`/`isReady` 保持 false。降低部署版本不代表任何系统的后台重新开麦、隐藏小窗或免跳转已经完成。没有通过长期占麦或可见 PiP 代替。 diff --git a/openless-all/ios/docs/product-library.md b/openless-all/ios/docs/product-library.md deleted file mode 100644 index 230eae029..000000000 --- a/openless-all/ios/docs/product-library.md +++ /dev/null @@ -1,58 +0,0 @@ -# 历史、概览、词典与设置:本批实现与证据 - -更新:2026-10-16。对应主需求 P1 和 STYLE-06。**这是部分实现报告,不是 63 条需求全部完成。** 软件键盘弹出按用户最新指示归为模拟器环境问题,本批未再排查、重置虚拟机或修改其失败断言。 - -## 实现 - -- `CoreHost/src/library.rs` 复用 Core 的历史仓库、独立活动统计、风格与 auxiliary repolish。列表只传 160 字符预览及完整字符数;打开详情后才请求完整文本。预览不写回记录、不插入输入框。非原文风格须有 LLM 凭据,当前仍受开发者模式限制。 -- `CoreHost/src/control.rs` 增加 library、详情、单条删除、清空、音频资产、重新润色及词典 CRUD/启停/搜索/分页。普通模式可管理本地产品数据,但不泄露开发者频道或密钥。录音/处理期间限制写操作。 -- `CoreBridge.swift` 控制请求串行排队,上限 32,单请求超时 75 秒;关闭时失败返回。接收事件上限 2 MiB,不将音频或凭据放入键盘邮箱。 -- `ProductLibrary.swift` 为主 App 管理共享数据。模式重载和搜索使用请求标识,旧请求不得污染新 Core/新查询。操作错误真实显示。 -- `HistoryView.swift` 有详情、原文/结果独立复制、单条删除、刷新和清空菜单。不可逆动作使用有明确取消按钮的 alert;取消不改历史。没有音频时仍保留文字操作。 -- `HistoryAudioPlayer.swift` 使用 AVAudioPlayer;录音开始或页面离开时停止播放。系统 ShareLink 只接受 Core 返回的真实音频 URL,并受未完成 ASR 状态限制。 -- `OverviewMetricsView.swift`/`HistoryModels.swift` 使用真实数据。今日字数按 Unicode scalar,与 Rust `chars()`/桌面 `Array.from` 对齐;今日时间按本地日边界;“平均段落”是今日每段平均时长;累计记录表示当前保留的历史数量。 -- 7/30 天趋势使用 Core 独立活动数据,缺失日期补零,未来日期不纳入当前周期。清空历史不清空趋势。空、加载、错误状态不放示例数字。 -- `VocabularyView.swift` 接通 Core 本地新增/编辑词条、启停、删除、大小写不敏感搜索、分页及重复处理。**尚未桥接 Rime 学习词典、词条自动建议或加密同步。** -- `AppTheme` 只控制主 App 的浅/深/系统外观并持久化;帮助、版本说明和隐私内容有真实入口。UI 多语言、键盘独立主题与统一提供商中心尚未完成。 -- STYLE-06:删除 `SelectionRewriteView.swift`、试用编辑器改写入口/草稿状态和无调用的 Swift rewrite 方法。保留 Core/桌面 rewrite API、历史重新润色与后续帮写。 - -## 音频保留契约 - -`CoreHost/src/recordings.rs` 实现 Core 的 `RecordingArchive`,不在 Swift 新造识别业务。 - -| 状态 | 处理 | -| --- | --- | -| 主动采集 | 可选归档标准 16 kHz/mono/S16LE WAV,最大 30 分钟;独占创建、Unix 0600,每次完整帧更新 WAV 头 | -| 归档失败 | 不允许覆盖已有文件;无效帧停用/丢弃该资产。归档是可选项,不能因此禁用普通 ASR | -| 停止 | 先按原流程停止硬件和排空 PCM,再完成文件头与同步,由 Core 判断保留/清理 | -| ASR 失败且有有效音频 | 保存实际文件,并关联 Core 记录;可播放与系统分享 | -| 成功得到识别文本 | 沿用 Core 的临时音频清理策略;完成转写无导出入口,即使以后有可播放资产也不因此开放导出 | -| 主动取消 | 沿用 Core 丢弃策略,不把取消假装成已完成识别 | -| 单条删除/清空历史 | 清理相应记录关联的音频,不清理词典、独立活动、凭据或另一模式的数据 | - -目录是主 App 私有业务目录下的 `recordings/`,不是 App Group。资产只允许 UUID 文件名、普通文件及有界大小;不能用任意路径查询。录音没有加入本轮词典/模式/风格的云同步范围。 - -**尚缺:**崩溃遗留孤立录音的恢复、历史自动过期后的资产清扫,以及真机上的实际播放/导出、长录音磁盘与内存验证。不能据本批短 WAV 测试声称这些已经完成。 - -## 验证 - -证据位于 `Artifacts/product-library/`: - -| 证据 | 实际结果 | -| --- | --- | -| `core-tests.log/.exit` | Rust Host **21 通过**;包括 3 项 WAV 安全/生命周期测试、4 项产品库领域集成。使用临时目录与 memory vault,不读写用户 Mac Keychain | -| `library_tests.rs` | 验证预览有界/详情完整、正常模式产品权限、词典重复/编辑/启停/跨 Host 重开、raw repolish 不覆盖原文、原生 ASR 失败保留真实 WAV 且删除时清理、历史清空不清活动 | -| `core-build.log/.exit` | device/simulator 原生库重建成功,各 984 个 Mach-O 对象最高 iOS 16 | -| `native-and-ui-current.log` | 新历史模型 **4 项通过**;同包当时 UI 失败,不能把整包计为成功。模型覆盖导出状态、本地跨日/完整字数/平均时长、独立 7/30 天/溢出保护、原文与结果区别 | -| `related-regression-current.xcresult/.log/.exit` | **4 原生 + 4 UI,整包通过**:公共音频本地 SenseVoice、取消/按钮保护;清空确认后取消、首页设置裁切、滚动不录音、概览/词典/设置/帮助导航 | -| `large-type-current.xcresult/.log/.exit` | 最大辅助字号首页/设置裁切审计 **1 通过**;检查后恢复原字号 large | -| `device-current-build.log/.exit`、`device-current-validation.log` | 最新无签名设备构建和实际模型/产品/iOS 16 下限检查通过;不是签名安装/旧系统运行证明 | -| `Review/*-product-library-current.png`、`*-product-library-large-type.png` | 本批通过用例导出的真实截图;来源见 `Review/index.json` | - -本批发现并修复:父容器 accessibilityIdentifier 覆盖统计 picker 的标识;iOS 27 的 confirmationDialog popover 不展示 cancel,故不可逆操作改为显式 alert。原失败结果不算通过;包含用户历史的临时失败附件不作为交付材料。 - -本批未授予麦克风权限、未录制人声、未调用真实云服务、未清空用户历史/词典,也未改变 Full Access。公共中文 fixture 是集成 smoke,不是准确率测评。 - -## 仍然未完成 - -P1 的真实 LLM/播放/分享操作验收、历史详情全场景视觉与音频恢复清扫;P2 的市场/包管理/翻译目标/手势/帮写;P3 的账户与 Core E2EE 同步;Rime 用户词典、确认词条建议、UI 本地化和键盘主题一致性。隐藏式免跳转仍未实现;iOS 16/18 真实运行、签名与真机性能也没有新证据。 diff --git a/openless-all/ios/docs/requirements-and-todo.md b/openless-all/ios/docs/requirements-and-todo.md deleted file mode 100644 index 673c79606..000000000 --- a/openless-all/ios/docs/requirements-and-todo.md +++ /dev/null @@ -1,282 +0,0 @@ -# OpenLess iOS:完整需求汇总与 To-do - -更新:2026-10-16。范围从“重启模拟器、向下兼容”,延续到历史、键盘交互、风格市场、加密云同步、词典、翻译、概览和设置的最新要求。 - -**本文件是这一轮产品需求与待办的主入口,不是完成报告。** 后续 compact/会话恢复先读本文件,再读 [continuation-checkpoint.md](continuation-checkpoint.md)。实现事实与测试证据另见 [verification.md](verification.md)。 - -## 1. 执行原则与范围变更 - -1. 保留已经可用的语音输入。用户已反馈应用内语音可以正常聆听、感知,仍在手工测试;这不等于后台免跳转、所有旧系统或所有云服务已经验收。 -2. 新要求优先:账户、风格市场、词典和加密云同步现在属于 iOS 产品范围。旧的“个人预览暂不做账户/同步”不再作为排除理由,但也不能把尚未实现的功能写成已经完成。 -3. 普通模式仍默认打包 SenseVoice 本地 ASR,不预配 LLM/云端密钥。后处理、翻译、帮写需用户实际配置并启用相应能力。 -4. 尽量复用 OpenLess 桌面现有 Core、领域模型、词典、历史、风格包和同步协议;不在 Swift 重写一套网络、提示词或加密算法。 -5. App/键盘保持原生。键盘使用 MIT 源码版 KeyboardKit 9.9.0 与 Rime,不运行 Rust Core/ASR 模型,不打开麦克风、不保存 API 密钥。 -6. 不重置、提交、推送或覆盖工作区中原有桌面、Android、ASR 改动。用户历史、词典、配置和凭据不得为了测试被清空。 -7. 只保留一个 iOS 模拟器。不删除物理设备、当前保留设备的数据或仍需复用的模型资源。 -8. iOS 的选区润色入口按最新要求删除;历史重新润色、语音后处理和帮写是不同功能,仍然保留。 -9. Typeless 用来比较交互与信息结构,不复制其品牌、私有词库、商业实现、后端凭据或订阅权益。 -10. 最新执行方式:先补齐本文及执行清单,再按独立组件交给其他 Agent;全部使用 GPT-6.1、high,不得使用 xhigh/max 或其他模型。完成实现后由 Codex 独立 review,主 Agent 处理发现并做最终检查。 -11. 最终验收必须使用 Orca 自带模拟器实际操作与截图,不以 CLI 返回 ok、源码检查、单元测试或构建成功替代界面验收。只操作保留设备,操作前确认没有录音。 -12. 用户随后追问“之前能弹、为什么现在不能”:根因仍未查明,不能再断言一定是虚拟机问题。将系统输入框与 OpenLess 输入框对照、旧版本与当前版本回归列入执行清单;不清空数据、不用反复强制聚焦掩盖故障。 - -## 2. 当前真实状态 - -| 项目 | 状态 | 证据/边界 | -| --- | --- | --- | -| 模拟器清理 | 已完成 | 删除其余 11 台模拟器,只保留 `OpenLess iOS 27 QA`,UUID `A4B5F07C-C6C9-498F-BEEC-AC7AC98B8A6A`。本次汇总时它为 Shutdown,不擅自声称正在运行 | -| 模拟器重启 | 已执行 | 曾重启保留设备、关闭硬件键盘连接,重新连接辅助程序;`repair-input` 报告未被 Device Hub shadow,无额外修复 | -| 输入框/软件键盘弹出 | **当前已恢复,最初根因未证实** | Orca软件键盘显示开关后切系统拼音再切回OpenLess,两者实际显示;原popup/三轮refocus回归通过(`execution-wave3/keyboard-popup-restored.xcresult`)。没有改弱断言/清数据/加产品焦点补丁;视频新键盘仍需再验。见 [环境对照](keyboard-environment-check.md) | -| iOS 16 起的兼容代码 | 已实现,待完整验收 | 新旧音频路径、权限 API、Apple Speech 后端、Combine 界面状态、公开 API 可用性判断已补;App/扩展及依赖二进制下限检查通过 | -| 旧系统实际运行 | 未完成 | 尚无 iOS 16/18 真机或对应运行时证据,不宣称“16 到 27 每个系统都已实测” | -| 普通模式 SenseVoice | 已实现,有用户手工反馈 | 本地模型实际解码公共音频;用户反馈能正常聆听、感知。正常语音流程要继续保留 | -| ASR/LLM/全模态提供商 | 基础接线已完成 | 共用 Core,开发者配置界面和安全凭据入口已存在;新产品的统一权限/能力展示和真实云端验收未完成 | -| 历史与录音 | 新代码已接入,部分验收完成 | 真实详情、原文/结果复制、管理菜单、逐条删除、Core 重新润色预览、失败 WAV 归档与条件播放/导出;真实 LLM、播放/分享及崩溃恢复仍待验收/补齐,见 [实现与证据](product-library.md) | -| 旧键盘基础回归 | 有历史通过证据 | 兼容改造前曾 12 UI + 4 原生通过、1 授权测试跳过。**不能代替当前兼容分支的键盘弹出验收** | -| 最新兼容分支测试 | 部分通过 | 16 组合成音频、5 项 App 原生测试通过;首页/设置裁切和只读诊断通过;新软件键盘弹出回归仍失败 | -| 跨 App 免跳转 | **输出会话已实现;真机仍需验收** | Build 4 分离输出保持与输入采集,首次自动唤醒/返回;详见 [实现及验证](voice-handoff-fix.md)。隐藏 PiP 路线仍未实现 | -| 概览、基础词典与设置 | 已接入 | 真实今日及 7/30 天统计、Core 词典 CRUD/启停/搜索、App 深浅色、帮助与版本说明;尚无 Rime 用户词典桥、云同步、UI 多语言和独立键盘主题 | -| 风格市场/加密同步/账户页 | 未实现 | 仍须复用桌面领域代码,不能把本地词典、帮助页或未登录占位冒充打通 | -| iOS 选区润色 | 已移除:STYLE-06 | 删除专用页面、试用编辑器入口/草稿状态和无调用的 Swift rewrite 方法;保留共享 Core/桌面 API、历史重新润色及后续帮写 | - -## 3. 全量功能要求与验收条件 - -### A. 基础整理、模拟器与系统兼容 - -| ID | 功能/要求 | 完成标准 | -| --- | --- | --- | -| BASE-01 | 整理 iOS App 结构 | 入口、概览、历史、设置、键盘试用、语音服务分开;项目生成和脚本引用同步;不做无关大重构 | -| BASE-02 | 整理 ASR 入口和文档 | 清晰区分 SenseVoice、本地/Apple/云端/全模态路径、桌面实验模型;资源来源和构建入口可追溯,不重复下载已验证模型 | -| ENV-01 | 只保留一台模拟器 | 清点结果恰好一台;保留指定 UUID 的 App 数据与设置;清理其他设备和无用测试 Runner | -| ENV-02 | 重启并恢复键盘输入 | 普通点击输入框会弹出屏幕键盘;反复收起/重新点按仍可输入;Orca 显示/输入链路和产品焦点问题分开验证 | -| COMP-01 | 最低兼容 iOS 16 | App、键盘、Swift 包、Rust/C++/推理运行时一致支持最低 16,不只改 Info.plist | -| COMP-02 | iOS 16/17/18/26/27 路径 | 旧 tap 独立复制缓冲;17+ 权限 API;26+ SpeechAnalyzer;27 immutable tap;较新符号弱链接且有 availability guard | -| COMP-03 | 兼容验收 | 编译、全部 Mach-O 最低版本检查、对应系统运行分开记录;实际验证权限、中文/英文、识别、取消、路由变化、字体和深浅色 | -| VOICE-01 | 保留正常 ASR/聆听体验 | 准备不录音;主动开始才采集;停止先关硬件;取消、无帧超时、队列上限、目标改变保护继续有效 | -| VOICE-02 | 免跳转 | 单独实现真实跨 App 后台语音控制及失败恢复;禁止以长期静默占麦、可见 PiP/横幅/边缘把手或反复切 App 冒充;需真机证据 | - -### B. 历史 - -| ID | 功能/要求 | 完成标准 | -| --- | --- | --- | -| HIST-01 | 字体与排版 | 对齐 OpenLess 原有界面;正文/原文/时间/状态层级清楚,长段落可读、可选中,支持大字号和深浅色 | -| HIST-02 | 历史管理菜单 | 在历史页面提供刷新、清空等操作;清空需确认,执行真实 Core 动作,不能清空别人的云端/其他账户数据 | -| HIST-03 | 单条详情 | 可查看原文、最终处理结果、时间/时长/错误状态;不存在的字段不伪造 | -| HIST-04 | 复制与删除 | 支持复制原文、复制最终文本、删除单条;区别二者,不把润色结果当原文 | -| HIST-05 | 重新润色 | 对已有记录主动选择当前可用模式/风格/LLM 重跑;保留原文,显示处理中/错误/新结果,不假装成功 | -| HIST-06 | 播放录音 | 只对真正存在且可读取的音频资产显示播放器;播放状态、时长、停止与错误真实可用 | -| HIST-07 | 未完成 ASR 的录音导出 | 只有录音存在、ASR 尚未完成/失败等符合状态的记录才显示导出;使用真实文件与系统分享/文件导出 | -| HIST-08 | 已转写内容不导出录音 | 已转换完成的记录不显示“导出录音”,不能仅因有历史条目就展示导出按钮 | -| HIST-09 | 流式/无音频的降级 | 若没有可恢复的原始录音,不造播放器或导出;仍支持重新润色、删除、看原文和复制原文 | -| HIST-10 | 音频生命周期 | 明确音频在哪些状态保留、如何关联记录、何时删除;音频存主 App 私有目录,不进入键盘共享容器、不默认加入云同步 | - -说明:用户规定了“完成转写后无录音导出”,并未明确要求“完成后一定立即删除一切音频”。播放能力和保存策略要结合现有 Core 音频资产契约设计,不擅自把两者混为一个开关。 - -### C. 键盘语音、翻译、帮写与润色模式 - -| ID | 功能/要求 | 完成标准 | -| --- | --- | --- | -| KBD-01 | 长按选择态 | 按用户图实现长按反馈,出现“松开以取消”等提示;先明确正常说话、选择态、取消态、释放提交的状态表 | -| KBD-02 | 滑动选翻译 | 按住/滑动出现“滑动去选择翻译”,可滑向已配置目标语言;选择、取消、越界和中断清楚,不误开始或提交 | -| KBD-03 | 有能力才开放翻译 | LLM 后处理配置/启用后提供对应翻译操作;无可用能力时解释或禁用,不空跑、不静默改服务商 | -| KBD-04 | 左侧“帮写” | 入口语义是“描述要写的内容”,作为 AI 生成提示词;不是读取选区来润色;取消/完成/预览/插入边界清楚 | -| KBD-05 | 键盘内选择润色模式 | 选择实际可用的模式/风格;与 App 同一份 Core 配置;普通原文模式不被默认改成 LLM | -| KBD-06 | 语音会话固定意图 | 开始时固定源语言、目标、润色模式/风格和提供商能力;中途改设置不会串会话或写错输入框 | -| KBD-07 | 保留成熟文字键盘 | 保持 KeyboardKit/Rime、大小写、符号、删除、Return、空格光标、地球键、系统听写键隐藏和无 Full Access 基础打字 | -| KBD-08 | 无障碍与操作反馈 | 提示不只靠颜色;按下、录音、取消、翻译目标、帮写状态可读;减少动态效果、横屏和大字号不丢功能 | - -“松开以取消”属于截图中的长按选择态要求,不能在没有明确状态表的情况下把所有正常按住说话都改成“松手一律取消”。 - -### D. 风格市场与提供商能力 - -| ID | 功能/要求 | 完成标准 | -| --- | --- | --- | -| STYLE-01 | 打通云端风格市场 | 复用 OpenLess 现有市场服务、列表、包详情和领域校验,真实下载并落库 | -| STYLE-02 | 下载/导入/选择 | iOS 可从云端下载并导入兼容风格包,查看/应用/更新/移除;状态与 Core 一致 | -| STYLE-03 | 不做 iOS 风格包编辑器 | 不移植桌面编辑 UI;保留包的只读信息与必要管理 | -| STYLE-04 | 本地包导入 | 原话“VIP 导入”需核对是否指 ZIP;先沿用现有 style_pack_archive 的实际格式,不擅自加入 VIP 付费逻辑 | -| STYLE-05 | 提供商/模型权限打通 | 显示风格依赖的模型和可用能力;区分未登录、缺凭据、模型不可用、缺权限、网络失败,不能做虚假的开通/验证 | -| STYLE-06 | 移除 iOS 选区润色 | 删除试用编辑器改写入口和 iOS 专用选区页面/状态;共享 Core 和桌面仍需要的 rewrite API 不盲删 | - -### E. 云同步、加密与词典 - -| ID | 功能/要求 | 完成标准 | -| --- | --- | --- | -| SYNC-01 | 移植现有加密云同步 | 复用 Core 端到端加密协议、版本、校验、传输、错误和恢复,不另写一套 Swift crypto | -| SYNC-02 | 用户词典同步 | 同步用户确认的词条及其必要元数据;删除、重名、冲突和多设备更新有确定规则 | -| SYNC-03 | iOS 词典接入 | 研究并桥接 Core 词典与 Rime 本机词典;不直接多进程同步正在写入的 Rime 二进制学习文件 | -| SYNC-04 | 模式与市场选择同步 | 同步润色模式、风格包选择及必要引用;对另一端没有的包明确补下载/提示,不产生悬空选择 | -| SYNC-05 | 自动词条候选 | 用户说完后,键盘提示是否添加候选词;展示候选、确认/拒绝/关闭,不未经确认自动写词典 | -| SYNC-06 | 候选词纳入同步 | 用户确认后进入同一份词典并可加密同步;重复候选去重,原文不被悄悄替换 | -| SYNC-07 | 安全状态与恢复 | 账户、密钥、恢复/重置、同步中/错误/冲突状态可见;错误密钥、离线、退出账户和重试不覆盖本地数据 | -| SYNC-08 | 同步范围边界 | 本轮要求是词典、润色模式与市场选择;不擅自默认同步历史、原始录音、API 密钥或其他敏感内容 | - -### F. 翻译目标与词典页 - -| ID | 功能/要求 | 完成标准 | -| --- | --- | --- | -| LANG-01 | 多语言目录 | 复用 OpenLess/Core 已有语言目录与 provider 能力;UI 语言、识别语言、键盘布局和翻译目标分开,不凭空增加输入引擎 | -| LANG-02 | 目标管理页面 | 在页面内新增/删除/排序目标语言,编辑后持久化并反映到键盘滑动选择 | -| LANG-03 | 滑动选择目标 | 键盘选择的语言就是实际请求目标;可取消、有清楚选中状态,无效配置不能发送 | -| LANG-04 | 词典优先 | 像参考产品一样提供实用词典管理与相关历史/使用信息,但历史来源必须真实 | -| LANG-05 | 数量与兼容策略 | 参考图展示最多 3 个目标;这是参考行为,最终上限要与现有 Core 契约统一,不能无依据另设不兼容格式 | - -### G. 首页概览 - -| ID | 功能/要求 | 完成标准 | -| --- | --- | --- | -| HOME-01 | 首页成为概览 | 对齐 OpenLess 原有前端信息结构,保留清楚的语音入口,不只是大标语页 | -| HOME-02 | 今日字数/总时长 | 从真实历史数据计算;时间、字数口径和桌面一致,正确处理中文和跨日 | -| HOME-03 | 平均段落/累计记录 | 核对桌面字段含义并复用,不能自行把“平均段落”猜成另一个统计值 | -| HOME-04 | 近 7 天/近 30 天 | 可切换的真实趋势/统计;空数据、错误和加载有实际状态,不放示例数字 | - -### H. 设置、账户与视觉 - -| ID | 功能/要求 | 完成标准 | -| --- | --- | --- | -| SET-01 | 键盘与触觉反馈 | 整理默认面板、输入、空格手势、触觉与安装/Full Access 说明,开关控制真实行为 | -| SET-02 | 外观 | 深色、浅色、跟随系统与已有外观变体;App/键盘的一致性和大字号单独检查 | -| SET-03 | 界面语言 | 独立于识别/翻译语言;复用已有语言与翻译资源,不能把识别语言切换当作 UI 本地化 | -| SET-04 | 服务商与模型设置中心 | 使用真实 Core 提供商/模型配置、凭据状态、能力和验证结果;梳理普通/开发者入口,仍不预配云端密钥 | -| SET-05 | 帮助与版本说明 | 设置中心、帮助中心、关于、版本说明可进入真实内容;实验诊断保持次级入口 | -| ACCOUNT-01 | 账户显示 | 对齐 OpenLess 的真实账户/登录状态与同步身份;未登录/已登录/退出有完整流程,不展示参考图中的私人邮箱或伪造 Free/Pro 权益 | -| ACCOUNT-02 | 账户能力 | 若现有后端返回权限/方案则展示实际值;不因参考截图有升级卡片就擅自新增订阅/支付系统 | -| UI-01 | 整体对齐 OpenLess 前端 | 统一字体、层级、留白、卡片、空状态、动作菜单和深浅色,参考 Typeless 的交互,不照搬品牌 | -| UI-02 | 稳定而非装饰性重写 | 不破坏已可用的语音;键盘高频输入即时反馈,动画有边界、可打断并尊重减少动态效果 | - -### I. Typeless 对比与验收材料 - -| ID | 功能/要求 | 完成标准 | -| --- | --- | --- | -| REF-01 | 安装包与版本核对 | 找到用户提供的实际包后检查版本、架构、签名和 Mach-O 平台;普通 iPhone IPA 不等于 Simulator 构建 | -| REF-02 | 可安装时并排比较 | 在唯一保留的虚拟机使用合法、兼容的包;不为对比留下多余虚拟机 | -| REF-03 | 无兼容包时的替代证据 | 使用现有报告/四张截图和用户授权设备,不假装已经在模拟器安装成功,不绕过 DRM 或拿其服务凭据给 OpenLess 用 | -| REF-04 | 比较产出 | 记录长按/滑动、帮写、目标管理、账户和字体的实际差异;OpenLess 自己的实现/测试证据独立保存 | - -### J. 新增截图与执行/验收要求 - -| ID | 功能/要求 | 完成标准 | -| --- | --- | --- | -| DICT-01 | 词典采用最新截图的信息结构 | 大标题、右上搜索、浅灰胶囊来源筛选、简洁分隔列表、右下浮动加号;保留 OpenLess 品牌、原生 Tab 导航,支持深色/大字号 | -| DICT-02 | 所有/自动添加/手动添加 | 按 Core 实际来源在分页前筛选,搜索与筛选组合正确;无来源记录不凭空标为自动;自动候选仍须确认后入库 | -| DICT-03 | 紧凑添加弹窗 | 标题“添加到词典”、单词输入、取消/添加;空白、换行、超长和重复有明确处理,保存失败保留输入;不再使用整屏多字段 Form | -| DICT-04 | 精简行与管理 | 行内只显示来源、词条和必要停用状态;编辑/启停/删除收进可发现的行菜单,备注/使用量按需查看;不删除原有能力 | -| REF-05 | 新截图补充 | 最新四张图包括词典列表/添加弹窗、历史和首页统计;词典为明确实施要求,其他两页作为现有要求的视觉补充,不复制付费、零保留或云同步等未核实声明 | -| EXEC-01 | 分块实施与模型限制 | 各块有需求 ID、独占文件/契约、依赖、验收与报告;子 Agent 全部 GPT-6.1 high;隔离保留现有 dirty 快照,不先提交来换隔离 | -| EXEC-02 | Codex 独立审查 | 集成后审查实际代码,列证据化问题/等级/文件位置;修复后复审,不以 worker 自评代替 | -| EXEC-03 | 主 Agent 最终验收 | 核对剩余清单、实际产品与测试证据;Orca 内置模拟器操作、截图、深浅色/大字号/取消/错误;未满足条目不勾选 | - -### K. 最新键盘视频(本次新增) - -实际文件、时间段观察和逐态验收见 [keyboard-video-reference.md](keyboard-video-reference.md)。视频明确展示语音键盘,不虚构未展开的26键/拼音布局。 - -| ID | 功能/要求 | 完成标准 | -| --- | --- | --- | -| VID-01 | 视频核对与逐段观察 | 已找到56.45秒视频并抽帧;元信息、哈希、可见/未知行为有记录 | -| VID-02 | 语音键盘视觉与状态 | 复刻空闲/录音圆/处理中胶囊/取消结构,OpenLess品牌,真实音量、无障碍、深浅色和横屏 | -| VID-03 | 视频帮写逻辑 | 描述内容驱动,正确开始/完成/取消/错误,复用Core能力,非选区润色 | -| VID-04 | 弧形翻译选择 | 长按选择、滑动命中目标、释放翻译/取消,真实已配置目标,固定会话意图 | -| VID-05 | 打字模式与编辑操作 | 保留KeyboardKit/Rime,统一EN/拼/语音顶栏,Return/删除/@/安全撤销有真实行为,无Full Access打字保留 | -| VID-06 | 视频对照验收 | Codex review后主Agent用Orca内置模拟器实际操作/截图;未实现后台能力不伪装 | - -## 4. To-do list:执行顺序 - -最新分块、依赖、负责人及逐项执行/审查/验收清单见 [execution-plan.md](execution-plan.md)。必须先完成清单再开始新实现。 - -### P0:先修基础与兼容回归 - -- [x] 保留指定模拟器,删除其他 11 台:ENV-01。 -- [x] 执行重启与硬件键盘连接设置:ENV-02 的环境动作,**不等于问题已修复**。 -- [x] 归整 App 入口与页面、`App/Speech/`:BASE-01/02 的代码整理部分。 -- [x] 实现 iOS 16 起的新旧 API 路径,替换 Observation/Mutex 硬依赖:COMP-01/02 的代码部分。 -- [x] 重建并检查 Core/Rime/推理运行时,排除 iOS 27 旧缓存;App/扩展/debug dylib 最低版本 16。 -- [x] 16 组合成音频和 5 项原生测试通过,不打开麦克风。 -- [x] 曾按用户指示停止软件键盘排查;这只是当时执行决定,不是环境根因已经证明。 -- [x] ENV-02 当前恢复:Orca软件键盘开关+系统拼音/OpenLess切换后两者显示,原三轮refocus回归通过;原因未过度归因,不等于新视频键盘全部验收。 -- [ ] 当前代码重新完成键盘、App 页面与原生回归:COMP-03/KBD-07。 -- [ ] 在可用 iOS 16/18 设备或运行时验证;如果环境没有,明确记录缺口,不虚报覆盖:COMP-03。 -- [ ] 更新 README/ASR/验证文档,明确旧版本绿色记录不等于当前分支通过:BASE-02。 -- [ ] 记录正常语音的用户手工结果,保持已有链路:VOICE-01。 -- [x] 移除 iOS 选区润色 UI 与多余状态:STYLE-06;已重生成工程并编译。 -- [ ] 对免跳转做独立原生实现与真机验证,不以 UI 假按钮或占麦替代:VOICE-02。 - -### P1:历史、概览与设置基础 - -- [x] 核对桌面历史/音频/统计 API,扩充 Host/DTO:`CoreHost/src/{library,recordings}.rs`,不重写领域模型。 -- [x] 实现历史字体、完整详情、刷新/清空菜单及确认取消:HIST-01/02/03;长文本/深色详情仍纳入最终视觉验收。 -- [ ] HIST-04/05:单条删除、原文/结果复制和 Core 预览代码已实现;已测原文不被预览覆盖,还需真实 LLM 与详细 UI 操作验收。 -- [ ] HIST-06~10:失败录音真实归档、资产定位、播放/系统分享代码与完成态禁导出规则已实现并有 Core/原生状态测试;还需播放/分享实操、崩溃遗留恢复与历史过期资产清理。 -- [ ] DICT-01~04:按最新词典截图重做列表、来源筛选、搜索与紧凑添加弹窗,保留真实 CRUD;验证深浅色、大字号、空态和取消不写入。 -- [ ] SYNC-02/03、LANG-04:本地 Core 词典 CRUD、启停、搜索、分页及重复处理已实现/领域测试通过;Rime 用户词典映射和加密同步未接。 -- [x] 首页概览和今日/7 天/30 天真实统计:HOME-01~04;平均段落按桌面“今日平均时长”,趋势独立于历史删除。 -- [ ] SET-01~05、UI-01:应用主题、帮助/版本说明已实现,默认/最大字号首页设置裁切审计通过;UI 本地化、键盘主题一致性与统一服务中心未完成。 - -### P2:风格、翻译和键盘意图 - -- [ ] 按最新56秒视频补键盘UI和交互:VID-02~06,详细时间表见 [keyboard-video-reference.md](keyboard-video-reference.md)。 - -- [ ] 把现有提供商/模型/风格能力做成 App 与键盘可消费的统一快照:STYLE-05、SET-04。 -- [ ] 云端风格列表、下载、包校验、只读详情、选择/更新/移除:STYLE-01~03。 -- [ ] 核对“VIP 导入”的实际格式后,接现有风格包导入:STYLE-04。 -- [ ] 多语言目录与目标管理、目标排序/持久化:LANG-01/02/05。 -- [ ] 长按/取消/滑动翻译状态表与真实请求意图:KBD-01~03/06、LANG-03。 -- [ ] 左侧帮写:提示词描述、ASR/LLM 生成、预览、取消和受控插入:KBD-04。 -- [ ] 键盘内润色模式/风格选择:KBD-05。 -- [ ] 词条建议确认提示,确认才加入词典:SYNC-05/06。 - -### P3:账户与加密同步 - -- [ ] 核对现有 OpenLess 账户/同步认证与移动端回调、Keychain 保存:ACCOUNT-01/02。 -- [ ] 复用 Core E2EE:初始化、密钥/恢复、加密传输与错误状态:SYNC-01/07。 -- [ ] 同步确认词条、iOS 词典映射、润色模式与风格选择:SYNC-02~06。 -- [ ] 验证跨设备冲突、离线、错误密钥、退出/切账户和恢复;不自动同步音频/密钥/历史:SYNC-07/08。 - -### 对比与最终验收(贯穿各阶段) - -- [ ] 核对 Typeless 可用包;仅在平台兼容、合法可安装时装入保留设备:REF-01~03。 -- [ ] 根据截图/实际版本比较,记录而不盲抄:REF-04。 -- [ ] EXEC-01:完成分块、模型 high 核验与逐块实现/测试报告。 -- [ ] EXEC-02:集成后 Codex 独立 review,处理发现并复审。 -- [ ] EXEC-03:主 Agent 用 Orca 自带模拟器实际操作和截图,逐模块验收。 -- [ ] 逐模块保存当前版本截图、来源索引、真实结果与失败日志。 -- [ ] 最终检查 iOS 16 起的能力差异、27 运行、权限/Full Access、离线和网络错误、深浅色、字号、取消和输入目标变化。 -- [ ] 复核只剩一台模拟器、没有 Runner、多余探针或无用临时资源;不删除可复用模型和用户数据。 - -## 5. 需要核对但不阻塞先做代码审查的语义 - -| 项目 | 暂按什么处理 | 核对点 | -| --- | --- | --- | -| “选区认色” | 依上下文理解为现有 iOS 选区润色,删除其 UI | 若原意不同,再按用户反馈调整;不误删历史重新润色 | -| “VIP 导入风格包” | 不建立付费/会员逻辑;先检查现有归档格式 | 是否实际指 ZIP/本地文件导入 | -| “ts” | 按本次 Typeless 截图的词典/翻译体验作参考 | 不虚构一个新服务商 | -| 最多 3 个翻译目标 | 参考图展示的行为 | 以 OpenLess 现有契约和用户最终选择确定上限 | -| “平均段落” | 先看桌面真实字段与算法 | 不自己替换成平均字数/语速等其他指标 | -| 长按“松开以取消” | 定义为显式选择态提示 | 正常录音、按住完成、上滑翻译和取消的提交规则需成表 | -| 完成后的音频 | 不提供导出入口 | 是否仍保留用于播放,按现有资产能力及明确保留策略处理 | - -## 6. 四张参考图的实际用途 - -1. 深色键盘的弧形目标语言/“向上滑动以选择”/“松开以取消”:用于长按选择态与翻译目标的交互设计。 -2. 深色“帮我写/描述要写的内容/再次点击以完成”:用于提示词驱动的帮写,不是选区润色。 -3. 浅色账户页、账户身份、设置/关于/帮助与底部导航:用于信息层级;不复制私人邮箱、不伪造 Pro 或支付权益。 -4. 翻译目标编辑页:用于新增、排序、编辑与键盘选择一致性;截图中的 3 个目标上限与 OpenLess 契约分开核对。 - -## 7. 优先复用的代码与协议 - -以 `openless-all/` 为基准: - -- 原生 App:`ios/App/`,语音实现统一在 `ios/App/Speech/`。 -- 原生 Host:`ios/CoreHost/src/{lib,control,credentials}.rs`,`ios/App/CoreBridge.swift`。 -- 键盘与持久 IPC:`ios/Keyboard/`、`ios/PlatformShared/`、`ios/Packages/OpenLessShared/`。 -- 历史:`app/crates/openless-core/src/history.rs`、`app/src-tauri/src/commands/history.rs`、`app/src/pages/History.tsx`。 -- 风格与市场:Core 的 `marketplace.rs`、`style_packs.rs`、`style_pack_store.rs`、`style_pack_archive.rs`;桌面 `commands/{marketplace,style_packs}.rs`。 -- 词典:Core 的 `vocabulary.rs` 与桌面 `Vocab.tsx`。先确认领域模型,再桥接 iOS/Rime。 -- 加密同步:`app/crates/openless-core/src/cloud_sync_e2ee*`、`cloud_sync_e2ee_protocol/`、`cloud_sync_e2ee_documents/`、`cloud_sync_e2ee_store/`。 -- 同步契约:仓库根 `docs/{encrypted-cloud-sync,cloud-sync,cloud-sync-server-handoff}.md`;桌面 `commands/{cloud_sync,cloud_sync_e2ee}.rs` 和 `src/lib/encryptedSyncUiBridge.ts`。 -- 概览/翻译/设置:桌面 `src/pages/{Overview,Translation,Style,Marketplace}.tsx` 与 `src/pages/settings/`,先核对现有语义,不另造数据。 - -## 8. compact 与恢复执行约定 - -- 每个任务用本文件的 ID 更新状态,并补具体源码/命令/结果。不能把“有接线”“编译成功”“假服务测试”“用户手工反馈”“真实云端/真机通过”混作一种完成状态。 -- compact 前写好恢复检查点;compact 后重新读取本文件与检查点,不依赖模糊记忆,也不重做已经验证的下载和构建。 -- 当前接口没有发现可直接调用的会话 compact 工具。只有真实触发宿主的 `/compact` 或上下文压缩接口,才能称“已执行 compact”;写这份摘要本身不冒充会话压缩。 -- 最新执行顺序以 [execution-plan.md](execution-plan.md) 为准:先完成需求/To-do,分块实现剩余工作与新词典参考,再 Codex review、主 Agent 修复和 Orca 自带模拟器验收。ENV-02 原因待查,不能擅自归因模拟器;COMP-03 旧系统/真机证据仍须分开记录。 diff --git a/openless-all/ios/docs/verification.md b/openless-all/ios/docs/verification.md deleted file mode 100644 index 406096f48..000000000 --- a/openless-all/ios/docs/verification.md +++ /dev/null @@ -1,169 +0,0 @@ -# iOS 预览与验证记录 - -更新:2026-10-16。证据必须对应实现版本;与参考输入法的实现差异见 [architecture-and-processing.md](architecture-and-processing.md)。 - -## 最新状态:历史、概览、词典与设置基础 - -本批证据见 [product-library.md](product-library.md):Rust Host 21 项通过;新增历史模型 4 项通过;另外 SenseVoice/SpeakButton 4 项和不依赖软件键盘的 UI 4 项通过;最大辅助字号首页/设置裁切审计 1 项通过。模拟器与无签名设备构建、App/扩展/debug dylib iOS 16 下限检查通过。没有清空用户历史/词典、调整 Full Access、录制人声或调用真实云服务。 - -STYLE-06 的 iOS 选区润色 UI 已移除。风格市场、翻译/帮写、Rime 用户词典、账户/加密同步、隐藏式免跳转仍未完成;新播放器/导出及 LLM 预览还需要相应真实操作验收,不能以构建代替。 - -软件键盘弹出按用户最新指示是模拟器环境问题,不再排查或作为产品阻塞。原 `Artifacts/compatibility/runtime-regression.xcresult`、`keyboard-stable-navigation.xcresult` 仍保留失败事实;没有改断言或伪造绿色。下文完整 KeyboardKit 绿状态只属于兼容改造前基线。 - -当前完整要求见 [requirements-and-todo.md](requirements-and-todo.md),继续执行先读 [continuation-checkpoint.md](continuation-checkpoint.md)。 - -## 兼容改造前基线:KeyboardKit/SenseVoice/开发者模式 - -当前文字键盘使用 KeyboardKit 9.9.0 MIT 源码和 Rime。**完整浅色回归已通过:12 项 UI、4 项 App 原生通过,1 项授权回调因没有既有授权而跳过,0 失败。**没有用旧自制键盘的次数代替此次验收。 - -### 已完成的验证 - -| 层级 | 最新证据 | -| --- | --- | -| 完整 App/键盘回归 | `keyboardkit-full-current.xcresult`/`.log`,脚本 exit 0;xcresult 总计 17,passed 16、skipped 1、failed 0 | -| 键盘交互 | 完全访问关闭下真实“nihao → 你好”和 English;五轮收起/重开;符号/换行/删除/组合切换;Shift/Caps Lock;空格长按实际移动光标且不误插空格;语音空白区横滑;横屏和反复模式切换 | -| 系统底部区域 | 控制器初始化及重新连接时设置公开 `hasDictationKey = true`。实际完整屏幕图中右下角系统听写键消失、地球键保留;没有遮挡层、负 inset 或私有 Hook | -| 开发者真实 UI/Core | 完整套件包含三击版本、确认重载、真实快照读取、默认 LLM 关闭、保存传统管线、退出并重载普通模式;不发云端请求 | -| 深色/最大辅助字号 | `keyboardkit-current-dark.xcresult` 3 项通过;`keyboardkit-current-large-type.xcresult` 1 项首页/设置裁切审计通过。独立结果,不计成另一轮完整 UI | -| 原生 ASR/按钮 | 两项 SenseVoice 通过:打包模型解码公共 PCM、预先取消。两项 SpeakButton 通过:不可用只解释、不意外开始,准备/录音阶段仍可取消或停止 | -| 授权回调 | `NativeAuthorizationTests` 显式跳过未决定的系统授权,不自动授予权限。完整 UI 的拒绝权限分支通过;旧版本已有系统回调证据不能当作本次实际执行 | -| 共享 v2 协议 | `shared-v2-tests.log`:27 项/6 套通过;prepare 与 begin 分离,prepare 专用行为仍可补充分支覆盖 | -| Rust Host | `provider-host-verified.log`:14 项通过;首次开发者传统/原文且无 LLM、频道/凭据/激活持久化、选择改写和提供商错误脱敏;测试显式使用 memory vault,不读取用户 Mac Keychain | -| 无签名设备构建 | `keyboardkit-device-final.log`/`.exit`:build 和实际模型哈希验证通过,exit 0;不能直接安装到 iPhone,不代表真机性能 | -| 产品完整性 | `validate-product.py` 检查构建/安装包内可执行文件、编译图标、Assets.car、键盘、Rime、实际 SenseVoice 模型与 tokens 的固定 SHA-256;模型不进入键盘 | - -本轮没有授予麦克风权限或开始人声录音。模型下载是之前明确授权的资源准备,不是 UI 测试暗中触发。公开音频是集成 smoke,没有人工 gold transcript,不能宣称准确率。 - -浅色完整截图:`Artifacts/Review/*-keyboardkit-current-light.png`;深色和最大字号分别为 `*-keyboardkit-current-dark.png`、`*-keyboardkit-current-large-type.png`。来源索引在 `Review/index.json`;原始包对应上述三份当前结果,不能把旧图当成新键盘。 - -预览已恢复到 `OpenLess iOS 27 QA`(A4B5F07C-C6C9-498F-BEEC-AC7AC98B8A6A),实际安装的主 App/键盘二进制与通过完整回归的构建产物哈希一致;安装包内模型与 tokens 也通过固定哈希验证。系统注册只保留 `app.openless.ios` 一个 OpenLess 启动入口,Runner 已移除。Orca 已附加实际设备画面,没有启动麦克风。当前 CLI tap 虽返回 ok,但复核画面未观察到导航,不将它算作交互成功;上述交互验收来自真实 XCTest,而非这个返回值。 - -### 定位与兼容处理 - -- 收起键盘原有的跨进程“清焦点”通知会干扰再次编辑,现移除该通知,走 UIKit 原生生命周期。额外重开手势、强制反复 resign/become 及笼统的 keyboardDidHide 补丁均已撤掉。旧的硬件键盘日志不能直接当作全部根因。 -- 听写键需在控制器初始化、重新连接时声明能力,而非只在 viewDidLoad 设置。Apple [完整文档](https://developer.apple.com/documentation/uikit/configuring-a-custom-keyboard-interface)说明会抑制系统按钮;不能只凭头文件的 disabled 字样推断不支持隐藏。iOS AX 可保留 disabled 的隐藏元素,因此检查不可点击并核对屏幕,不错误要求 AX 个数为零。 -- iOS 27 下 9.9 的 Shift 适配收到的双击只有一次 release;仅给 Shift 增加显式双击识别,仍调用 KeyboardKit 自己的 caps-lock action,不重写布局、键位命中或大小写状态机。Caps Lock/解除/实际输入已整例通过。 -- 空格长按沿用库的 0.5 秒门槛;测试改为 0.65 秒,不沿用旧自制键盘的 0.35 秒。数字/字母/Return 用稳定标识;等待 SwiftUI 状态渲染完成,而非用旧文案和同步读取断言。 - -`keyboardkit-typing-*`、`keyboardkit-current-clean`、`keyboardkit-completed-hide`、`keyboardkit-public-capabilities`、Shift 诊断等失败保留,不计入通过。探针代码和临时日志已从产品代码移除。早期 `Review/*-keyboardkit-current-incomplete.png` 不是最终版本。 - -### 仍没有完成或未验证的部分 - -- 人声麦克风 → Core → 本地识别 → 结果/上屏,以及真机内存、耗时、热量与能耗。 -- 用户真实云端 API、额外认证、模型列表/验证;当前是接线、Core 测试和无网络配置 UI 证据,不是所有实际服务兼容证明。 -- 这份旧基线曾接入 Swift 选区改写;最新需求已移除 iOS UI,不再补第三方选区集成。共享 Core/桌面接口保留。 -- Team、App Group provisioning 和真机 Keychain。本机检查为 0 个有效 codesigning identity;开发者模式不代替签名。 -- 隐藏式后台免跳转没有实现,不是只缺测试。新模型/运行时/夹具的分发 notices 和少数 Rust 许可正文仍须核对。 - -## 历史版本:实际交付与旧自制键盘 - -以下为 KeyboardKit 替换前的验收,保留故障来源,不作为当前版本的 pass。 - -### 当时先修的是实际交付,不是只改源图 - -用户看到默认占位图、两个入口、点开退出。本机检查发现: - -- LaunchServices 中仍有 `app.openless.ios` 安装记录,但记录对应的 `.app` 目录、可执行文件、Info.plist 和 Assets.car 已不存在。启动日志为 `FBSOpenApplicationErrorDomain / NotFound`,没有启动出产品进程。文件被移除的来源没有证据,不能归因给某个清理工具。 -- 第二个入口是 `app.openless.ios.uitests.xctrunner`,即 UI 测试宿主,不是键盘扩展或第二个产品。 -- 重建原生依赖并重新安装后,SpringBoard 实际显示官方图标;安装列表只剩一个 OpenLess。完成三次独立冷启动和从桌面点图标启动,进程持续运行、首页控件可用。截图:`Artifacts/restored-springboard.png`、`restored-open-from-icon.png`。 - -新增 `scripts/validate-product.py` 检查**构建后的产品和实际安装后的产品**:主可执行文件、编译图标声明及小尺寸图标、Assets.car、嵌入键盘与 Rime 数据。源图存在或残留桌面图标不再算交付成功。 - -`scripts/preview.sh` 只构建/安装产品,不运行 XCTest。`scripts/test.sh` 在 UI 测试结束或失败退出时移除 Runner;再次打开预览也会清理残留 Runner,不删除产品数据。 - -### 旧版本测试 - -环境:Xcode 27.0(27A266a)、Swift 6、Rust 1.98.0。主 QA 设备为 **OpenLess iOS 27 QA**,iPhone 16 Pro,iOS 27.0(24A434),UUID `A4B5F07C-C6C9-498F-BEEC-AC7AC98B8A6A`。 - -| 层级 | 本轮结果/来源 | -| --- | --- | -| 共享 Swift | 27 项/6 套通过。`Artifacts/current-polish-light.log` | -| iOS Rust Host | 3 项通过。真实 Core 管线配明确标识的原生测试适配器;同一日志 | -| Rime | 桥接引擎的拼音、选词、提交、删除/取消检查通过;实际扩展中文/英文输入也通过 | -| 合成音频 | 8k/16k/44.1k/48kHz × 单/双声道共 8 组;一秒各输出 16,000 样本,幅度及尾帧通过;不打开麦克风 | -| App 原生 | 3 项/2 套通过。真实 Apple 授权回调返回 MainActor;不可用说话入口只解释;准备与录音阶段仍可取消/停止 | -| 最终完整 UI | **11 项 UI + 3 项 App 原生测试通过,0 失败**;安装产品检查通过。`Artifacts/current-polish-final.xcresult`/`.log`,`current-polish-final.exit` 为 0 | -| 定点复查 | 首页/历史/滚动不启动准备/权限或资源分支,4 项通过;`current-polish-targeted.xcresult` | -| 最大辅助字号 | 首页、设置 `.textClipped` 审计通过;`current-polish-large-type.xcresult`。保留系统字号和滚动,不强行缩字 | -| 新装权限拒绝 | 在临时全新模拟器实际点击“不允许”,主 App 存活、无录音,出现“前往系统设置”;`current-permission-recovered.xcresult`。没有修改主 QA 原有的授权;临时设备已删除 | -| 静态交付 | App Group/隐私/键盘无采集代码、空免跳转白名单、Rime 数据、官方品牌逐字节一致、1024px 无 alpha 图标检查通过 | - -本轮无人值守测试没有授予麦克风、没有启动录音,也没有下载语音模型。主 QA 原有系统决定已为授权,不能把它当“新装拒绝权限”证据,因此后者使用单独的全新实例。真正的 Speech 授权回调原生测试复用已有决定;无决定时会跳过。 - -### UI 覆盖 - -- 首页、设置、空历史、原生试用编辑页、键盘避让与无重叠 Tab 栏。 -- 完全访问关闭状态下,实际 OpenLess 扩展输入“nihao → 你好”和 English。 -- 符号、删除、Return、语言切换提交组合文本、横屏和连续切换模式。 -- Shift 单次大写、Caps Lock 锁定/解除、空格长按实际移动插入点且不误插空格。 -- 在语音页空白区横滑进入文字页;不以点击麦克风模拟横滑。 -- 滚动首页不触发准备/录音;授权回调、资源提示和只读实验诊断。 - -### 保留的失败,不算通过 - -`current-polish-light.xcresult` 初次运行中 7 项 UI 通过、3 项失败。首页/历史受准备资源提示干扰;原测试还用固定的 115pt 底部禁区反复滚动已经可见的内容,并错误假设已有授权的 QA 一定走拒绝分支。修正为实际 Tab 栏可见边界、区分既有授权与新弹窗,并增加滚动回归;4 项定点复查通过。 - -`current-polish-dark.xcresult` 与 `current-permission-fresh.xcresult` 的第一次并行执行因 120 秒命令上限中断,不算整轮通过:前者日志只确认深色裁切审计通过;后者新设备首次迁移及编译耗时,不能当 App 崩溃。随后复用已编译产品执行新装权限测试成功,临时设备/重复编译目录已清理。 - -## UI 与交互改动 - -| Before | After | Why | -| --- | --- | --- | -| 只核对源图,实际安装包缺失 | 构建包和安装包都检查图标、可执行文件与键盘数据 | 默认占位图和安装残影不能算成功 | -| UI 测试宿主留在桌面 | 测试结束清理 Runner,产品预览不运行测试 | 产品只保留一个入口 | -| 首页多个大盒子,视觉重点分散 | 独立 HomeView、统一冷浅色/深色底、官方品牌、集中语音入口与试用路径 | 减少诊断面板感,让主要操作更明确 | -| 模式选中背景直接跳变 | 一个滑块在“语音/EN/拼”间移动 | 让位置和状态连续,可在过渡中改选 | -| 只能从窄顶栏横滑 | 顶栏和语音空白区都可滑;排除 UIControl、候选和键位 | 手势可发现,不抢按住说话/移出取消/空格光标 | -| Shift/Caps Lock 无视觉区别 | 空心/实心 Shift 与 Caps Lock 图标、对应辅助功能值 | 明确下一字和持续大写状态 | -| 长按空格进入光标模式但没有反馈 | 提示“移动光标”,弱化字母,松手恢复 | 解释当前手势,不误插空格 | -| 轻触反馈偏好没覆盖实际普通键位 | 字母、Shift、删除、空格、Return 接入轻触反馈 | 偏好与实际行为一致,不加逐键弹跳延迟 | -| 拒绝授权只显示错误 | 提供可执行的“前往系统设置”入口 | 不让用户反复点一个无法重新弹权限的按钮 | - -### 动效参数与边界 - -- 主 App 的偶发按钮:按下缩放 0.98/透明度 0.8,140ms ease-out;减少动态效果时只变透明度。 -- 模式滑块:200ms、dampingRatio 1,可打断;切页:250ms、dampingRatio 1,拖动按真实触点,释放使用速度投影。 -- 说话控件沿用参考的 147×60/109×109/129×60 状态几何、200ms 形态变化;七根真实音量条 80ms 跟随,不播放伪波形。 -- 字母输入立即响应,不逐键等待弹簧或页面动画;减少动态效果不会关闭必要的状态反馈。 -- 本轮未主动录音,因此录音中形态和音量驱动有代码/原生单元及合成音频证据,**没有新的人声录音观感验收**。 -- 浅/深色的实际模式按钮、拼音候选及官方品牌在当前安装包中再次目视核对,Orca 预览保持开启。 - -## 复跑与截图 - -```sh -cd openless-all/ios -./scripts/test.sh unit -IOS_SIMULATOR_ID=<专用设备标识> ./scripts/test.sh ui -IOS_SIMULATOR_ID=<专用设备标识> ./scripts/test.sh all -IOS_SIMULATOR_ID=<设备标识> ./scripts/preview.sh -python3 scripts/export-ui-evidence.py Artifacts/current-polish-final.xcresult --tag current -``` - -`unit` 跑共享/Rust/Rime/合成音频;`ui` 跑 App 原生与实际 UI;`all` 跑两者。`UI_RESULT_BUNDLE` 可指定新结果路径。UI 测试会操作指定模拟器的设置、键盘和页面,应使用无个人历史的 QA 实例。 - -`Artifacts/Review/index.json` 关联截图、结果包、测试与原附件。完整 11 项 UI 回归后,视觉复核又发现系统 UIButton 选中样式叠出黑块、键盘品牌被缩成 24 个物理像素;现改用自定义文字按钮和显示倍率渲染,并补足辅助键按下色。它们经过增量构建及实际点击核对,未因此重复整套逻辑测试。 - -**当前安装外观**看 `current-pinyin-candidates-installed.png`、`current-english-dark-installed.png`、`current-voice-dark-installed.png` 和 `keyboard-interactions.mov`。录像是实际模式过渡与 Rime 按键,不是录音演示;没有点击麦克风。`*-current.png` 的自动附件保留为测试时的版本,不覆盖成改样式后的图。 - -横屏完整屏幕图可能保留设备原生方向,旋转查看副本必须标明,不能裁图掩盖布局问题。未封口的中断结果包不支持导出。 - -`Artifacts/` 是本机忽略目录,不是永久发布材料。之前的 9 UI/3 原生和桌面 1,163 Core 等是历史结果;旧结果包当前已不在磁盘,不冒充本次复跑证据。 - -## 已定位的原生崩溃 - -- 主 App 旧报告 `OpenLess-2026-10-14-191901.ips`:系统 Speech 授权回调不在 MainActor,隐式隔离闭包触发 `_dispatch_assert_queue_fail`。显式 `@Sendable` continuation 桥接和真实系统回调回归通过。 -- 键盘旧报告 `OpenLessKeyboard-2026-10-14-191900.ips`:远程代理断连时非空声明的 `documentIdentifier` 实际可能为 nil,UUID 强桥接崩溃。现经 nullable Objective-C 边界读取;无目标就停止,不生成假 UUID。 -- 重排键位时的 `OpenLessKeyboard-2026-10-14-211048.ips`:跨行等宽约束在没有共同父视图时激活。现先建层级再激活,实际扩展重建/横屏回归通过。 - -这些历史代码崩溃与本轮“安装记录在、包目录不在”的启动失败不是同一原因。 - -## 当前能力边界 - -以本文开头的当前版本表为准。开发者 ASR/LLM/Omni 和主应用选区预览已有接线;真实云端调用尚未验证。隐藏式后台免跳转和完整用户纠正规则/简繁设置仍未实现。 - -真机签名与 App Group、实际麦克风/识别、不同宿主上屏、系统中断、后台重新开麦、热量与能耗仍没有真机证据。开发者模式并不代替签名。公开分发前仍需补齐缺少的许可正文与新资源 notices。 - - -## 2026-10-18:Build 6 键盘重开 - -88 项共享协议测试(新增 9 项握手状态测试)、5 项资源检查、工程检查、Release 设备结构与 IPA 校验通过。实际键盘用例 `testKeyboardVoiceSurvivesDismissalAndReopening` 通过:输出会话准备后,收起 5 秒/30 秒重开录音,录音中收起后停止采集并再次重开录音,全程 Safari 前台、主 App 后台。详见 [本轮验收](../Artifacts/keyboard-reopen-fix/acceptance-summary.json)。这不是隐藏 PiP 或用户 iPhone 运行通过证明;本轮独立冷启动返回前置未取得来源 bundle,没有计作通过。 diff --git a/openless-all/ios/docs/voice-handoff-fix.md b/openless-all/ios/docs/voice-handoff-fix.md deleted file mode 100644 index b9b49760b..000000000 --- a/openless-all/ios/docs/voice-handoff-fix.md +++ /dev/null @@ -1,31 +0,0 @@ -# 键盘语音首次唤醒与返回 - -## 当前接线 - -键盘点击录音先通过 App Group 和 Darwin 通知确认主进程可服务。共享快照过期不会直接触发跳转;后台会话可用时沿用原进程启动采集。无法连接或后台会话失效时,保存包含输入目标与语音意图的请求,再打开主应用。 - -主应用只自动消费与本次 URL 精确匹配、仍有效且由新版键盘明确标记的请求。冷启动等待 Core 和前台状态就绪;系统麦克风授权照常显示。无需再次点击开始。恢复旧请求、重复打开 URL、取消及过期请求不会自动重开录音。主进程重建时将遗留录音请求标为失败;键盘在 IPC 无响应且存活快照过期后可以替换旧请求。替换与快照检查在同一 SQLite 事务内,新的有效心跳会阻止替换。 - -返回发生在收到实际音频帧之后。SwiftUI 通过 UIApplicationDelegateAdaptor/UISceneDelegate 接收 URL context;公开 sourceApplication 只对同一开发团队提供来源,跨团队为空。键盘因此在本次语音点击后读取当前宿主通知,不以旧 bundle 缓存推断去向。iOS 27 的键盘通知实际走 _didHandleKeyboardClientChange:shouldConsiderSnapshottingKeyboard:isLocalEvent:,返回 BOOL,代码先校验 ABI 再接收。重新建立 arbiter 连接、要求两个新 generation 的同一宿主结果,且 window 和 documentIdentifier 必须仍匹配。返回桥优先使用匹配来源的系统导航 action,必要时使用本次查得的精确来源 bundle 打开应用。返回 token 每次只消费一次;切换期间允许有界重试,只有离开前台才停止返回流程。每个 token 只消费一次,不枚举已安装应用,不猜 URL scheme。iOS 27 模拟器的独立双应用验证表明,旧导航 action 可为空,系统 URL context 的来源与精确 bundle 返回仍可工作。 - -这里使用了非公开的应用返回接口;桥在每次调用前检查实际方法签名。接口或来源不可用时保留录音界面并提示返回,不把调用失败伪装为成功。模拟器兼容性不代表全部真机系统版本已经验证。 - -## 录音与待机分开 - -NoJumpAudioSession 使用独立的输出音频引擎维持已经前台激活的音频会话。AudioCapture 只在录音期间创建输入引擎,停止时移除输入 tap、停止引擎并结束 PCM 流。待机路径没有输入 node、识别请求或音频上传。原有空闲十分钟主动销毁会话已删除。 - -播放历史录音可共用输出会话,不再销毁键盘语音的 generation。播放期间拒绝同时开始录音,播放结束后恢复原 generation。真实音频中断、失去渲染、权限变化或进程被回收仍会使会话失效;下一次显式录音动作才恢复,不自行重新采集。 - -本地 SenseVoice 的 PCM 在独立、加锁且有容量上限的缓冲区中收集,不再逐帧经过界面线程。切换应用时的界面停顿不会填满原来的 16 帧主线程队列。finish 在采集 drain 之后原子取出完整数据;cancel 和迟到数据按 sessionID 隔离,旧会话的错误也不能取消新会话。开发者选择 Apple Speech 时保留其现有主线程接线。 - -这个实现是输出音频会话方案。之前的 Typeless 报告证明了“PiP 保活与停止采集分离”的分支,并未恢复隐藏 PiP 的原生实现;不能把那份静态证据当成本项目实现同一套 PiP 的证明。 - -## 输入位置与自动写入(Build 5) - -返回结果就绪后,键盘等待可见且已连接的 textDocumentProxy,直接将结果绑定当前输入框。跳转前后的 keyboardID、documentID 或 revision 不同,不再触发预览和二次确认。若用户在返回后选择了新的可用输入框,则按当前输入框写入。 - -领取结果和写入仍串行执行:先提交当前拼音组合,再读取当前目标;在 SQLite 事务中创建唯一 insertionID,在 UIKit 写入前标记 attempting,完成后标记 submitted。重复通知、重新挂接或进程重建不会重放已经尝试的写入。没有输入框时保留结果等待连接,不静默复制到剪贴板。 - -## 证据位置 - -Build 5 的编译、共享协议测试和实际键盘验证见 [交付记录](../Artifacts/auto-insert-fix/README.md)。Build 4 的真实音频生命周期证据保留在 [上版交付记录](../Artifacts/voice-handoff-fix/README.md)。单独双应用探针已验证返回后原 UITextField 仍为 first responder;实际键盘路径需以相应 UI 测试结果为准。 diff --git a/openless-all/ios/project.yml b/openless-all/ios/project.yml index 363ef1617..e8d5fede6 100644 --- a/openless-all/ios/project.yml +++ b/openless-all/ios/project.yml @@ -67,8 +67,8 @@ targets: path: Config/App-Info.plist properties: CFBundleDisplayName: OpenLess - CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "15" + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" OLAppGroupIdentifier: "$(OL_APP_GROUP)" NSMicrophoneUsageDescription: "仅在你点按或按住录音时采集声音,结束后立即释放麦克风。" NSSpeechRecognitionUsageDescription: "使用 Apple 语音识别将你主动录制的声音转换成文字。" @@ -114,8 +114,8 @@ targets: path: Config/RecordingWidget-Info.plist properties: CFBundleDisplayName: OpenLess - CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "15" + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" OLAppGroupIdentifier: "$(OL_APP_GROUP)" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension @@ -156,8 +156,8 @@ targets: path: Config/Keyboard-Info.plist properties: CFBundleDisplayName: OpenLess - CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "15" + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" OLAppGroupIdentifier: "$(OL_APP_GROUP)" NSExtension: NSExtensionPointIdentifier: com.apple.keyboard-service diff --git a/openless-all/ios/scripts/audio-smoke.swift b/openless-all/ios/scripts/audio-smoke.swift index efef6f82f..418814d8c 100644 --- a/openless-all/ios/scripts/audio-smoke.swift +++ b/openless-all/ios/scripts/audio-smoke.swift @@ -3,7 +3,11 @@ import Foundation @main struct AudioSmoke { static func main() async throws { + var combinations = 0 for legacy in [false, true] { + if !legacy { + if #unavailable(macOS 27) { continue } + } for rate in [8_000.0, 16_000, 44_100, 48_000] { for channels: AVAudioChannelCount in [1, 2] { let worker = PCMWorker() @@ -26,8 +30,10 @@ import Foundation for channel in 0.. 0.005 && power < 0.1, "invalid PCM amplitude") print("PCM \(legacy ? "legacy copy" : "immutable") \(Int(rate))Hz × \(channels) → 16kHz mono: \(samples.count) samples") + combinations += 1 } } } - print("Audio conversion smoke passed: 16 combinations; no microphone was opened.") + print("Audio conversion smoke passed: \(combinations) combinations; no microphone was opened.") } } diff --git a/openless-all/ios/scripts/bootstrap.sh b/openless-all/ios/scripts/bootstrap.sh index 2c74e8d83..ee27bfd81 100755 --- a/openless-all/ios/scripts/bootstrap.sh +++ b/openless-all/ios/scripts/bootstrap.sh @@ -20,6 +20,7 @@ BOOST="$ROOT/.vendor/boost_1_85_0.tar.bz2" echo "7009fe1faa1697476bdc7027703a2badb84e849b7b0baad5086b087b971f8617 $BOOST" | shasum -a 256 -c - [ -d "$ROOT/.vendor/boost_1_85_0/boost" ] || tar -xjf "$BOOST" -C "$ROOT/.vendor" boost_1_85_0/boost boost_1_85_0/LICENSE_1_0.txt swift build --package-path "$ROOT/.build-tools/XcodeGen" -c release -rustup toolchain install 1.98.0 --profile minimal -rustup target add --toolchain 1.98.0 aarch64-apple-ios aarch64-apple-ios-sim +rustup toolchain install 1.99.0 --profile minimal +rustup target add --toolchain 1.99.0 aarch64-apple-ios aarch64-apple-ios-sim +python3 "$ROOT/scripts/prepare-core.py" echo "Dependencies ready. Run scripts/build-rime.sh, then scripts/build-core.sh." diff --git a/openless-all/ios/scripts/build-core.sh b/openless-all/ios/scripts/build-core.sh index bc20ce7ac..3b793c0b5 100755 --- a/openless-all/ios/scripts/build-core.sh +++ b/openless-all/ios/scripts/build-core.sh @@ -1,17 +1,23 @@ #!/bin/bash set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PLATFORM="${1:-all}" +case "$PLATFORM" in + all|device|simulator) ;; + *) echo 'Usage: scripts/build-core.sh [all|device|simulator]' >&2; exit 2;; +esac +python3 "$ROOT/scripts/prepare-core.py" # Homebrew rustc can precede rustup shims even when cargo is launched by rustup. -export RUSTC="$(rustup which --toolchain 1.98.0 rustc)" -export RUSTDOC="$(rustup which --toolchain 1.98.0 rustdoc)" -# rustc reads the deployment environment, but Cargo may otherwise reuse old -# dependency objects. Keep this ABI/deployment build out of the old iOS 27 cache. -export CARGO_TARGET_DIR="$ROOT/.build/core-ios16" +export RUSTC="$(rustup which --toolchain 1.99.0 rustc)" +export RUSTDOC="$(rustup which --toolchain 1.99.0 rustdoc)" +# Isolate iOS 16 artifacts from caches built with another deployment floor. +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/.build/core-ios16}" for spec in "device:aarch64-apple-ios:iphoneos" "simulator:aarch64-apple-ios-sim:iphonesimulator"; do IFS=: read -r platform target sdk <<< "$spec" + [ "$PLATFORM" = all ] || [ "$PLATFORM" = "$platform" ] || continue mkdir -p "$ROOT/Frameworks/$platform" IPHONEOS_DEPLOYMENT_TARGET=16.0 SDKROOT="$(xcrun --sdk "$sdk" --show-sdk-path)" \ - rustup run 1.98.0 cargo build --manifest-path "$ROOT/CoreHost/Cargo.toml" --locked --release --target "$target" + rustup run 1.99.0 cargo build --manifest-path "$ROOT/CoreHost/Cargo.toml" --locked --release --target "$target" cp "$CARGO_TARGET_DIR/$target/release/libopenless_ios_host.a" "$ROOT/Frameworks/$platform/libOpenLessCore.a" python3 "$ROOT/scripts/validate-deployment.py" "$ROOT/Frameworks/$platform/libOpenLessCore.a" done diff --git a/openless-all/ios/scripts/collect-rust-licenses.py b/openless-all/ios/scripts/collect-rust-licenses.py index 2880aa8a7..bdf3b1e2b 100644 --- a/openless-all/ios/scripts/collect-rust-licenses.py +++ b/openless-all/ios/scripts/collect-rust-licenses.py @@ -6,8 +6,9 @@ from pathlib import Path root = Path(__file__).resolve().parents[1] +subprocess.run(["python3", str(root / "scripts/prepare-core.py")], check=True) metadata = json.loads(subprocess.check_output([ - "rustup", "run", "1.98.0", "cargo", "metadata", "--format-version", "1", + "rustup", "run", "1.99.0", "cargo", "metadata", "--format-version", "1", "--manifest-path", str(root / "CoreHost/Cargo.toml"), "--locked", "--filter-platform", "aarch64-apple-ios" ])) @@ -40,10 +41,33 @@ def visit(identifier): files.append({"file": name, "sha256": hashlib.sha256(data).hexdigest()}) if package["name"] in ("openless-core", "openless-ios-host"): files = [{"file": "../OpenLess-LICENSE.txt", "sha256": hashlib.sha256((output.parent / "OpenLess-LICENSE.txt").read_bytes()).hexdigest()}] + if not files: + standard = output.parent / ("SPDX-" + str(package["license"]) + ".txt") + if standard.is_file(): + # Some upstream crates declare a license but omit its standalone file. + # Preserve their metadata alongside the corresponding standard text. + vcs = directory / ".cargo_vcs_info.json" + revision = json.loads(vcs.read_text())["git"]["sha1"] if vcs.is_file() else None + name = f"{package['name']}-{package['version']}-UPSTREAM-NOTICE.txt" + notice = (f"Package: {package['name']} {package['version']}\n" + f"Declared license: {package['license']}\n" + f"Authors: {', '.join(package['authors'])}\n" + f"Repository: {package['repository'] or 'Not supplied by upstream'}\n" + f"Source revision: {revision or 'Not supplied by upstream'}\n\n" + "The upstream crate declares this license without bundling a standalone license file.\n" + "Standard license text: SPDX license-list-data v3.27.0.\n\n" + + standard.read_text()).encode() + (output / name).write_bytes(notice) + files = [{"file": name, "sha256": hashlib.sha256(notice).hexdigest(), + "source": "upstream package metadata and SPDX standard text"}] manifest.append({"name": package["name"], "version": package["version"], "license": package["license"], "repository": package["repository"], "files": files}) (output / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") +retained = {entry["file"] for package in manifest for entry in package["files"]} +for path in output.glob("*.txt"): + if path.name not in retained: + path.unlink() print("Bundled attribution for", len(manifest), "resolved non-dev Rust packages.") missing = [item["name"] for item in manifest if not item["files"]] if missing: diff --git a/openless-all/ios/scripts/package-ipa.py b/openless-all/ios/scripts/package-ipa.py new file mode 100644 index 000000000..65f463ceb --- /dev/null +++ b/openless-all/ios/scripts/package-ipa.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Validate an unsigned device product and package it without overwriting an IPA.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import plistlib +import stat +import subprocess +import sys +import tempfile +import zipfile + + +def digest(stream): + result = hashlib.sha256() + for block in iter(lambda: stream.read(1024 * 1024), b""): + result.update(block) + return result.hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("app", type=Path, help="Release-iphoneos/OpenLess.app") + parser.add_argument("output", type=Path, help="Destination .ipa; must not exist") + args = parser.parse_args() + app, output = args.app.resolve(), args.output.absolute() + if app.suffix != ".app" or not app.is_dir(): + parser.error("Expected an existing .app directory") + if output.suffix != ".ipa" or output.exists() or output.is_symlink(): + parser.error("Destination must be a new .ipa file") + if app in output.resolve().parents: + parser.error("Destination must be outside the app bundle") + subprocess.run([sys.executable, str(Path(__file__).with_name("validate-product.py")), + "--unsigned-device", str(app)], check=True) + with (app / "Info.plist").open("rb") as stream: + metadata = plistlib.load(stream) + output.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=".openless-ipa-", dir=output.parent) + os.close(descriptor) + temporary = Path(temporary) + try: + expected = {} + with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED, + compresslevel=6) as archive: + for path in sorted(app.rglob("*")): + if path.is_symlink(): + raise ValueError(f"Unexpected symlink in device product: {path}") + if not path.is_file() or path.name == ".DS_Store" or path.name.startswith("._"): + continue + name = "Payload/" + str(path.relative_to(app.parent)) + with path.open("rb") as stream: + expected[name] = (digest(stream), stat.S_IMODE(path.stat().st_mode)) + archive.write(path, name) + with zipfile.ZipFile(temporary) as archive: + if set(archive.namelist()) != expected.keys() or archive.testzip() is not None: + raise ValueError("Archive members or CRC differ from the device product") + for name, (checksum, mode) in expected.items(): + with archive.open(name) as stream: + if digest(stream) != checksum: + raise ValueError(f"Archive content mismatch: {name}") + if stat.S_IMODE(archive.getinfo(name).external_attr >> 16) != mode: + raise ValueError(f"Archive permissions mismatch: {name}") + # A hard link publishes atomically and fails if another process created output. + temporary.chmod(0o644) + os.link(temporary, output) + finally: + temporary.unlink(missing_ok=True) + with output.open("rb") as stream: + checksum = digest(stream) + print(json.dumps({"ipa": str(output), "version": metadata["CFBundleShortVersionString"], + "build": metadata["CFBundleVersion"], "bytes": output.stat().st_size, + "sha256": checksum, "files": len(expected)}, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/openless-all/ios/scripts/prepare-core.py b/openless-all/ios/scripts/prepare-core.py new file mode 100644 index 000000000..ac5862d55 --- /dev/null +++ b/openless-all/ios/scripts/prepare-core.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Prepare an isolated Core source tree for the iOS host without editing shared sources.""" +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + + +IOS = Path(__file__).resolve().parents[1] +APP = IOS.parent / "app" +CORE = APP / "crates/openless-core" +PATCH = IOS / "CoreHost/Patches/ios-input-contracts.patch" +BUILD = IOS / "CoreHost/.build" +OUTPUT = BUILD / "core-source" + + +def checksum(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def generated_files(root): + return {str(path.relative_to(root)): checksum(path) + for path in sorted(root.rglob("*")) if path.is_file()} + + +def main(): + repository = Path(subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], cwd=IOS, text=True).strip()) + names = subprocess.check_output( + ["git", "ls-files", "-z", "--", str(CORE.relative_to(repository))], + cwd=repository).decode().split("\0") + sources = [repository / name for name in names if name] + if not sources or not (CORE / "Cargo.toml").is_file(): + raise RuntimeError("The checked-in shared Core source is required") + before = {str(path.relative_to(APP)): checksum(path) for path in sources} + patch = PATCH.read_text() + paths = re.findall(r"^(?:---|\+\+\+) [ab]/(.+)$", patch, re.MULTILINE) + paths += [path for pair in re.findall(r"^diff --git a/(\S+) b/(\S+)$", patch, re.MULTILINE) for path in pair] + if not paths: + raise RuntimeError("The iOS Core patch contains no source paths") + for path in paths: + if not path.startswith("crates/openless-core/") or ".." in Path(path).parts: + raise RuntimeError(f"Patch escapes the iOS Core copy: {path}") + fingerprint = hashlib.sha256(json.dumps({ + "sources": before, "patch": checksum(PATCH), "script": checksum(Path(__file__)), + }, sort_keys=True).encode()).hexdigest() + BUILD.mkdir(exist_ok=True) + with (BUILD / "core-source.lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + stamp = OUTPUT / "source.json" + if stamp.is_file(): + saved = json.loads(stamp.read_text()) + if saved.get("fingerprint") == fingerprint and saved.get("generated") == generated_files(OUTPUT / "crates/openless-core"): + print(f"iOS Core source is current: {OUTPUT}") + return + staging = Path(tempfile.mkdtemp(prefix="core-source-", dir=BUILD)) + try: + for source in sources: + destination = staging / source.relative_to(APP) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + # Unmodified sibling crates and include_str resources remain shared and read-only. + for sibling in (APP / "crates").iterdir(): + if sibling.name != "openless-core" and sibling.is_dir(): + (staging / "crates" / sibling.name).symlink_to(sibling, target_is_directory=True) + for name in ["assets", "contract", "src"]: + (staging / name).symlink_to(APP / name, target_is_directory=True) + environment = os.environ.copy() + for name in ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]: + environment.pop(name, None) + environment["GIT_CEILING_DIRECTORIES"] = str(BUILD) + subprocess.run(["git", "apply", "--check", str(PATCH)], cwd=staging, env=environment, check=True) + subprocess.run(["git", "apply", str(PATCH)], cwd=staging, env=environment, check=True) + after = {str(path.relative_to(APP)): checksum(path) for path in sources} + if after != before: + raise RuntimeError("Shared Core changed while preparing the iOS copy") + (staging / "source.json").write_text(json.dumps({ + "fingerprint": fingerprint, + "generated": generated_files(staging / "crates/openless-core"), + }, indent=2) + "\n") + if OUTPUT.exists(): + shutil.rmtree(OUTPUT) + staging.rename(OUTPUT) + finally: + if staging.exists(): + shutil.rmtree(staging) + print(f"Prepared isolated iOS Core source: {OUTPUT}") + + +if __name__ == "__main__": + main() diff --git a/openless-all/ios/scripts/test-audio.sh b/openless-all/ios/scripts/test-audio.sh index 59ace9dd5..7b31fa0de 100755 --- a/openless-all/ios/scripts/test-audio.sh +++ b/openless-all/ios/scripts/test-audio.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" mkdir -p "$ROOT/.build/tests" -xcrun swiftc -swift-version 6 -target arm64-apple-macos27.0 -parse-as-library \ +xcrun swiftc -swift-version 6 -target "$(uname -m)-apple-macos14.0" -parse-as-library \ "$ROOT/App/Speech/AudioPCM.swift" "$ROOT/scripts/audio-smoke.swift" \ -o "$ROOT/.build/tests/audio-smoke" "$ROOT/.build/tests/audio-smoke" diff --git a/openless-all/ios/scripts/test-interface-resources.py b/openless-all/ios/scripts/test-interface-resources.py index 12d68637e..63c96bcd8 100644 --- a/openless-all/ios/scripts/test-interface-resources.py +++ b/openless-all/ios/scripts/test-interface-resources.py @@ -54,8 +54,7 @@ def test_format_placeholders_match_and_have_helpers(self): self.assertIn(f'"{key}"', helper) def test_current_chinese_static_literals_have_resources(self): - # Deliberately ignores interpolated strings; their explicit migrations are - # listed in the handoff. Only static literal presence is asserted here. + # Interpolated strings use explicit format keys; this checks static literals. paths = [*IOS.glob('App/**/*.swift'), *IOS.glob('Keyboard/**/*.swift'), *IOS.glob('PlatformShared/**/*.swift'), IOS / 'Packages/OpenLessShared/Sources/OpenLessShared/Contract.swift'] diff --git a/openless-all/ios/scripts/test.sh b/openless-all/ios/scripts/test.sh index 237bfce78..095734ce4 100755 --- a/openless-all/ios/scripts/test.sh +++ b/openless-all/ios/scripts/test.sh @@ -7,10 +7,11 @@ case "$SUITE" in *) echo 'Usage: scripts/test.sh unit|ui|all' >&2; exit 2;; esac if [ "$SUITE" = unit ] || [ "$SUITE" = all ]; then - export RUSTC="$(rustup which --toolchain 1.98.0 rustc)" - export RUSTDOC="$(rustup which --toolchain 1.98.0 rustdoc)" + python3 "$ROOT/scripts/prepare-core.py" + export RUSTC="$(rustup which --toolchain 1.99.0 rustc)" + export RUSTDOC="$(rustup which --toolchain 1.99.0 rustdoc)" swift test --package-path "$ROOT/Packages/OpenLessShared" - rustup run 1.98.0 cargo test --manifest-path "$ROOT/CoreHost/Cargo.toml" --locked + rustup run 1.99.0 cargo test --manifest-path "$ROOT/CoreHost/Cargo.toml" --workspace --lib --locked "$ROOT/scripts/test-rime.sh" bash "$ROOT/scripts/test-predictions.sh" "$ROOT/scripts/test-audio.sh" diff --git a/openless-all/ios/scripts/validate-product.py b/openless-all/ios/scripts/validate-product.py index 61ad6b082..3de5374bc 100644 --- a/openless-all/ios/scripts/validate-product.py +++ b/openless-all/ios/scripts/validate-product.py @@ -87,6 +87,10 @@ def app_groups(bundle, metadata): info = bundle_info(app) +for key, pattern in [("CFBundleShortVersionString", r"\d+\.\d+\.\d+"), + ("CFBundleVersion", r"[1-9]\d*")]: + if not re.fullmatch(pattern, str(info.get(key, ""))): + parser.error(f"Missing or unresolved app version: {key}") primary = info.get("CFBundleIcons", {}).get("CFBundlePrimaryIcon", {}) if primary.get("CFBundleIconName") != "AppIcon": parser.error("The built Info.plist does not declare the AppIcon primary icon") @@ -107,6 +111,9 @@ def app_groups(bundle, metadata): } for extension in extensions: metadata = bundle_info(extension, assets=False) + for key in ["CFBundleShortVersionString", "CFBundleVersion"]: + if metadata.get(key) != info[key]: + parser.error(f"Extension version differs from the app: {extension}: {key}") point = metadata.get("NSExtension", {}).get("NSExtensionPointIdentifier") if point not in expected_extensions or point in identified: parser.error(f"Unexpected or duplicate extension point in {extension}: {point}") diff --git a/openless-all/ios/scripts/validate-project.py b/openless-all/ios/scripts/validate-project.py index 254c770aa..fdfce80f1 100755 --- a/openless-all/ios/scripts/validate-project.py +++ b/openless-all/ios/scripts/validate-project.py @@ -44,6 +44,13 @@ def plist(name): app = plist("App-Info.plist") keyboard = plist("Keyboard-Info.plist") widget = plist("RecordingWidget-Info.plist") +version = (root / "Config/Version.xcconfig").read_text() +assert re.search(r"^MARKETING_VERSION = \d+\.\d+\.\d+$", version, re.MULTILINE) +assert re.search(r"^CURRENT_PROJECT_VERSION = [1-9]\d*$", version, re.MULTILINE) +assert '#include "Version.xcconfig"' in (root / "Config/Base.xcconfig").read_text() +for metadata in [app, keyboard, widget]: + assert metadata["CFBundleShortVersionString"] == "$(MARKETING_VERSION)" + assert metadata["CFBundleVersion"] == "$(CURRENT_PROJECT_VERSION)" assert app["NSMicrophoneUsageDescription"] assert app["UIBackgroundModes"] == ["audio"] extension = keyboard["NSExtension"] @@ -58,8 +65,7 @@ def plist(name): for path in (root / "Keyboard").glob("*.swift"): text = path.read_text() assert "AVAudioEngine" not in text and "SFSpeechRecognizer" not in text and "URLSession" not in text -# The existing interface-resource suite covers App/Keyboard/PlatformShared. -# Include the new widget's static localization keys here without narrowing it. +# Widget strings must share the app and keyboard localization inventory. inventory = json.loads((root / "Resources/LocalizationSources.json").read_text())["entries"] token = re.compile(r'"(?:\\.|[^"\\])*"') han = re.compile(r'[\u3400-\u9fff]') From af14756d3d9d1474fa7db782c332b61deef58688 Mon Sep 17 00:00:00 2001 From: TRIP <192432815+appergb@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:03:54 -0700 Subject: [PATCH 03/31] chore(release): prepare 2.0.2 Beta.2 build 20261023 --- openless-all/app/package-lock.json | 4 ++-- openless-all/app/package.json | 2 +- openless-all/app/scripts/desktop-version-contract.test.mjs | 2 +- openless-all/app/src-tauri/Cargo.lock | 2 +- openless-all/app/src-tauri/Cargo.toml | 2 +- openless-all/app/src-tauri/tauri.conf.json | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 5c8a41b76..7612b1df2 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "openless-app", - "version": "2.0.2-Beta.1+build.20261022-dictation-71a18a77", + "version": "2.0.2-Beta.2+build.20261023-dictation-71a18a77", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "2.0.2-Beta.1+build.20261022-dictation-71a18a77", + "version": "2.0.2-Beta.2+build.20261023-dictation-71a18a77", "dependencies": { "@base-ui/react": "^1.6.0", "@shadcn/react": "^0.2.1", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index c251ab0a6..0ac3c608c 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -1,7 +1,7 @@ { "name": "openless-app", "private": true, - "version": "2.0.2-Beta.1+build.20261022-dictation-71a18a77", + "version": "2.0.2-Beta.2+build.20261023-dictation-71a18a77", "type": "module", "scripts": { "pretest": "npm run build", diff --git a/openless-all/app/scripts/desktop-version-contract.test.mjs b/openless-all/app/scripts/desktop-version-contract.test.mjs index 5882ab089..d03e5d1bb 100644 --- a/openless-all/app/scripts/desktop-version-contract.test.mjs +++ b/openless-all/app/scripts/desktop-version-contract.test.mjs @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; const app = new URL('../', import.meta.url); const read = async (path) => (await readFile(new URL(path, app), 'utf8')).replace(/\r\n/g, '\n'); -const version = '2.0.2-Beta.1+build.20261022-dictation-71a18a77'; +const version = '2.0.2-Beta.2+build.20261023-dictation-71a18a77'; const pkg = JSON.parse(await read('package.json')); const lock = JSON.parse(await read('package-lock.json')); const tauri = JSON.parse(await read('src-tauri/tauri.conf.json')); diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 1cd00ee98..bd2f0326a 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -4374,7 +4374,7 @@ dependencies = [ [[package]] name = "openless" -version = "2.0.2-Beta.1+build.20261022-dictation-71a18a77" +version = "2.0.2-Beta.2+build.20261023-dictation-71a18a77" dependencies = [ "anyhow", "arboard", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index f6cc5dbf4..116d575c5 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 = "2.0.2-Beta.1+build.20261022-dictation-71a18a77" +version = "2.0.2-Beta.2+build.20261023-dictation-71a18a77" license = "AGPL-3.0-only" description = "OpenLess — local voice input that types where your cursor is" authors = ["OpenLess"] diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 57a96fc2a..a0086f01d 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenLess", - "version": "2.0.2-Beta.1+build.20261022-dictation-71a18a77", + "version": "2.0.2-Beta.2+build.20261023-dictation-71a18a77", "identifier": "com.openless.app", "build": { "beforeDevCommand": "npm run dev", From 1db451a17b351c7bb4e870f798780f8c30edd791 Mon Sep 17 00:00:00 2001 From: TRIP <192432815+appergb@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:03:00 -0700 Subject: [PATCH 04/31] ci: add one-off iOS release asset uploader --- .../workflows/one-off-ios-asset-upload.yml | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/one-off-ios-asset-upload.yml diff --git a/.github/workflows/one-off-ios-asset-upload.yml b/.github/workflows/one-off-ios-asset-upload.yml new file mode 100644 index 000000000..dd853f5db --- /dev/null +++ b/.github/workflows/one-off-ios-asset-upload.yml @@ -0,0 +1,45 @@ +name: One-off iOS asset upload + +on: + workflow_dispatch: + inputs: + release_tag: + description: Existing draft release tag + required: true + type: string + source_url: + description: Temporary URL serving the IPA + required: true + type: string + asset_name: + description: Release asset filename + required: true + type: string + +permissions: + contents: write + +jobs: + upload: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Download IPA + env: + SOURCE_URL: ${{ inputs.source_url }} + run: | + set -euo pipefail + curl --fail --location --retry 5 --retry-all-errors --connect-timeout 30 --max-time 1200 \ + "$SOURCE_URL" -o "$RUNNER_TEMP/openless-ios.ipa" + test -s "$RUNNER_TEMP/openless-ios.ipa" + ls -lh "$RUNNER_TEMP/openless-ios.ipa" + + - name: Attach IPA to the draft release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + ASSET_NAME: ${{ inputs.asset_name }} + run: | + set -euo pipefail + gh release upload "$RELEASE_TAG" "$RUNNER_TEMP/openless-ios.ipa#$ASSET_NAME" \ + --repo "$GITHUB_REPOSITORY" --clobber From e04d692564e8302ffdcffabe5a4a754609c7144b Mon Sep 17 00:00:00 2001 From: TRIP <192432815+appergb@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:11:14 -0700 Subject: [PATCH 05/31] Revert "ci: add one-off iOS release asset uploader" This reverts commit 1db451a17b351c7bb4e870f798780f8c30edd791. --- .../workflows/one-off-ios-asset-upload.yml | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 .github/workflows/one-off-ios-asset-upload.yml diff --git a/.github/workflows/one-off-ios-asset-upload.yml b/.github/workflows/one-off-ios-asset-upload.yml deleted file mode 100644 index dd853f5db..000000000 --- a/.github/workflows/one-off-ios-asset-upload.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: One-off iOS asset upload - -on: - workflow_dispatch: - inputs: - release_tag: - description: Existing draft release tag - required: true - type: string - source_url: - description: Temporary URL serving the IPA - required: true - type: string - asset_name: - description: Release asset filename - required: true - type: string - -permissions: - contents: write - -jobs: - upload: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Download IPA - env: - SOURCE_URL: ${{ inputs.source_url }} - run: | - set -euo pipefail - curl --fail --location --retry 5 --retry-all-errors --connect-timeout 30 --max-time 1200 \ - "$SOURCE_URL" -o "$RUNNER_TEMP/openless-ios.ipa" - test -s "$RUNNER_TEMP/openless-ios.ipa" - ls -lh "$RUNNER_TEMP/openless-ios.ipa" - - - name: Attach IPA to the draft release - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ inputs.release_tag }} - ASSET_NAME: ${{ inputs.asset_name }} - run: | - set -euo pipefail - gh release upload "$RELEASE_TAG" "$RUNNER_TEMP/openless-ios.ipa#$ASSET_NAME" \ - --repo "$GITHUB_REPOSITORY" --clobber From 38fbe1c082e782e566e13769e4675a183ccc812a Mon Sep 17 00:00:00 2001 From: aeoform <128159516+aeoform@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:42:03 +0800 Subject: [PATCH 06/31] =?UTF-8?q?feat(linux-egui):=20=E9=87=8D=E5=81=9A?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=90=8E=E7=AB=AF=E2=80=94=E2=80=94=E7=BA=A7?= =?UTF-8?q?=E5=88=AB=E5=8F=AF=E8=AE=BE=E3=80=81=E5=99=AA=E5=A3=B0=E6=B8=85?= =?UTF-8?q?=E9=9B=B6=E3=80=81=E7=9B=B2=E5=8C=BA=E8=A1=A5=E9=BD=90=20(#1259?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(linux-egui): 让 Menu 键可以注册为听写热键 fcitx5 的 Key::parse 按 X11 keysym 名做大小写敏感查表:SetCustomDictationTrigger("Menu") → sym=0xff67 成功,("menu") → invalid key 被拒。而 normalize_fcitx_primary 会把主键名 统一转小写,于是任何非修饰键的命名 keysym(Menu 等)都注册不上。 只对查表中确实首字母大写的名字还原大小写,其余(space / f5 / 单字符)维持原行为。 新增单测 menu_primary_keeps_the_case_fcitx_expects。 * fix(core): 录音流掉线时继续转写已捕获音频 蓝牙/虚拟输入设备在录音过程中可能报 "Device disconnected"(PipeWire 节点被挂起或 蓝牙 SCO 掉线)。此前 recording.stop() 返回错误会取消转写并丢弃已经流进 ASR 的音频, 用户看到的就是「识别到一半整段消失、既不插入也不进剪贴板」(history 记 transcribeFailed)。 改为:掉线只记警告,继续用已捕获音频完成转写;是否真的没有内容交给下游 「未收到有效音频」守卫判断。 * feat(diagnostics): 记录听写失败原因与输入设备选择 - core: mark_dictation_failed 记录 code + message。此前只把 errorCode 写进 history, 日志里看不到失败发生在哪一步(录音停止 / 转写 / 润色)。 - linux-egui: 输入设备精确匹配失败时打印可选设备列表,并记录最终选用的设备名。 之前只有一句「using the default device: <填的名字>」,无法判断实际用的是哪只麦。 * test(core): 录音流 stop 失败不再算转写失败 `stop_during_provider_preparation_releases_capture_before_finalization` 的 `stop_fails` 分支断言的是旧契约:录音流 `stop()` 报错时整次听写以 `BackendErrorCode::Platform` 失败、history 记 `transcribeFailed`。 而本 PR 要修的正是这一点 —— 蓝牙麦掉线 / PipeWire 节点被挂起时,已经流进 ASR 的音频不该丢,`stop()` 的失败降级为 warn 后继续转写。于是 CI 上 `openless-core --lib` 稳定挂在这一条(本地 3/3 复现,beta 上通过)。 行为变了,断言要跟着变:`stop_fails` 现在与成功路径结果一致,只多一条 warn。 断言改为「返回 Ok + 文本正确 + history 不记 transcribeFailed」, 并保留原有的 stop_count / archive / has_audio_recording 检查。 * feat(linux-egui): 重做日志后端——可调级别、心跳降噪、轮转与契约 日志是 Linux 用户唯一能交回来的证据,但现在的实现在三处上不去: 一、**级别写死**。`LevelFilter::Info` 是常量,`RUST_LOG` 完全没读。用户报 「听写没反应」时,能做的只有重新编译一个 debug 版本给他。现在 `OPENLESS_LOG`(兼容 `RUST_LOG`)决定过滤,语法 `[,<模块>=]`, 最长前缀优先;无法识别的片段退回 `info` 而不是静默关掉。启动首行写明生效 级别与是否轮转过。 二、**心跳吃满日志**。实测一份 5 天的日志 39,111 行里 36,485 行(93%)是 `[pump] heartbeat` 与 `[ui-client] ipc round-trip`,2 MiB × 2 份的配额一天 就滚完,真正的错误全被挤出去。现在: - 泵心跳仍每 10 秒采样,但**状态变化立刻记 `info`**(窗口进程/托盘/录音 这三项的翻转才是当初「关窗后热键失效」的判据),状态没变时每 5 分钟一条 存活证明,其余走 `debug`; - IPC 往返探针(每 20 次一条)降为 `debug`。 三、**`error` 当 tracer 用**。`cloud_sync_e2ee/adapter.rs` 把 `stage=gate_open` / `stage=store_ok` / `build start` 这类进度全写 `log::error!`,于是每次正常启动都刷七行 ERROR —— 79 行 ERROR 里 66 行是它, 真错误反而看不见。这些降为 `debug`,失败分支保持 `error`。 顺带: - 轮转从「1 份 5 MiB」改成「3 份 2 MiB」,且**只有宿主轮转**(UI 窗口进程 若也改名,宿主手里的句柄会指向被改名的 inode,之后的宿主管日志就写进 `.1` 里);带单测。 - 时间戳落到本地时区(之前是 UTC,用户对着自己的操作时间找不到对应行)。 - 行格式补上模块目标,线程名与源码位置只在 debug 及以上出现。 - `scripts/egui-logging-contract.test.mjs` 守住四条:必须读 `OPENLESS_LOG`、 心跳不许无条件 `info`、IPC 探针必须 `debug`、`error` 里不许出现 `stage=`,另外拒绝形如 `api_key=`/`token=`/`password=` 的日志参数。 - `docs/linux-egui-logging.md` 写清级别语义、开关、轮转、前缀表与不记录的东西。 * feat(linux-egui): 日志级别可设、导出带历史、补上六处盲区 第二轮,把上一版日志「能看」提升到「能定位」。 一、**级别可在设置里改**(设置 → 高级 → 调试 → 日志级别) - 写 `/logs/level`,重启生效,界面提示;`info`/`debug`/`trace` 三档。 - 宿主本地设置,不进 Core 偏好:不上云、不影响 Tauri 与移动端,也不动 `contract/backend-2.0.json`。 - 优先级:`OPENLESS_LOG`/`RUST_LOG` → 设置文件 → 默认 `info`。环境变量仍然 最高,临时排查不该被上一次的界面选择挡住。 二、**导出不再只有当前文件** `export_error_logs` 按 `.3` → `.2` → `.1` → 当前的时间正序拼接,带分隔头, 并在文件头写明版本、系统架构、生效级别与导出时间 —— 用户报的问题几乎总是 发生在轮转之后,只导 `openless.log` 恰好丢掉现场。默认文件名改成 `openless-diagnostics.log`,避免误以为和那个单文件是一回事。 三、**周期噪声再降一档** | 日志 | 以前 | 现在 | |---|---|---| | `[transcript] partial emitted` | 每个流式分片一条 info | debug | | `[hotkey] local edge` | 每次按键一条 info | debug | | `[ui-client] actions from the renderer` | 每批动作一条 info | debug | 四、**补上六处盲区** | 位置 | 新增 | 以前查不到什么 | |---|---|---| | `audio.rs` | `[audio] capture started backend=… device=… format=… rate=… channels=…` | 「录到的全是静音」:成功路径原本一条日志都没有,不知道落到哪个后端/哪只麦 | | `audio.rs` | `[audio] no input backend could open a stream (tried […])` | 三个后端全失败时的总括 | | core `api.rs` | `[dictation] start session_id=… target=… asr=… insert=… translate=…` | 「按了热键没反应」与「录上了没识别」在日志里长得一样 | | core `dictation_engine.rs` | `[asr] session_id=… provider=… elapsed_ms=… chars=…` | 「听写很慢」:分不清慢在 ASR 还是润色 | | `main.rs` | `[startup] single-instance lock acquired` / `another instance owns the lock; forwarded…` | 「双击图标没反应」原本完全无痕 | | `settings_save.rs` | `[settings] saved request=…` / `failed: …` | 设置改了没保存,卡在哪一段无从判断 | 五、测试 - `logging::tests` 6 项:级别解析(含最长前缀优先)、垃圾输入退回默认、 轮转链(`.4` 被丢弃)、导出含全部历史且时间正序、级别持久化。 - `scripts/egui-logging-contract.test.mjs` 扩到 9 组断言:级别必须可持久化、 导出必须含历史、capture 成功必须记后端与格式、听写起点与单实例结果必须 留痕、streaming partial 与按键 edge 不许在 info。 - `docs/linux-egui-logging.md` 更新:设置入口、导出行为、新增的「一次听写在 日志里长什么样」串读示例。 * test(linux-egui): 单测断言消息改英文,别触发本地化契约 localization_contract 会把 linux-egui 源码里的裸简体中文当成用户可见文案拦下来。 我上一版在 logging 的轮转单测里写了中文断言消息("当前文件应被移走"),CI 报 two new raw Simplified-Chinese literals。断言消息改成英文即可。 * fix(linux-egui): preserve effective logging diagnostics --------- Co-authored-by: TheTouyu <1634389899@qq.com> Co-authored-by: Chris233 --- docs/index.md | 1 + docs/linux-egui-logging.md | 137 ++++ .../app/crates/openless-core/src/api.rs | 11 + .../src/cloud_sync_e2ee/adapter.rs | 12 +- .../openless-core/src/dictation_engine.rs | 16 +- openless-all/app/linux-egui/src/audio.rs | 14 + openless-all/app/linux-egui/src/i18n.rs | 15 + openless-all/app/linux-egui/src/lib.rs | 5 +- .../linux-egui/src/linux_app/settings_save.rs | 46 +- .../app/linux-egui/src/linux_app/window.rs | 7 +- openless-all/app/linux-egui/src/logging.rs | 697 +++++++++++++++++- openless-all/app/linux-egui/src/main.rs | 192 ++++- .../linux-egui/src/ui/frontend/settings.rs | 13 + .../linux-egui/src/ui/frontend/view_model.rs | 6 + .../scripts/egui-logging-contract.test.mjs | 191 +++++ 15 files changed, 1317 insertions(+), 46 deletions(-) create mode 100644 docs/linux-egui-logging.md create mode 100644 openless-all/app/scripts/egui-logging-contract.test.mjs diff --git a/docs/index.md b/docs/index.md index 35dc3a68d..81d1b6317 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,6 +15,7 @@ - [交接总览](linux-egui-handoff/README.md):阅读顺序、责任与复用起点。 - [01 Core 合同](linux-egui-handoff/01-core-contract.md)、[02 缺口登记](linux-egui-handoff/02-gap-register.md)(L01–L12)、[03 热键与窗口](linux-egui-handoff/03-hotkeys-and-windows.md)、[04 页面与领域](linux-egui-handoff/04-ui-domains.md)、[05 原生宿主与数据](linux-egui-handoff/05-native-host-and-data.md)、[06 事件与会话](linux-egui-handoff/06-events-and-sessions.md)、[07 验收](linux-egui-handoff/07-acceptance.md)。 +- [Linux egui 日志约定](linux-egui-logging.md):级别语义、`OPENLESS_LOG` 开关、轮转策略与不记录的内容。 - [Linux 已知缺口](linux-egui-known-gaps.md):当前仍未闭环的行为与复现路径(如 G01 纠错卡)。 ## 接口契约 diff --git a/docs/linux-egui-logging.md b/docs/linux-egui-logging.md new file mode 100644 index 000000000..ec38ec237 --- /dev/null +++ b/docs/linux-egui-logging.md @@ -0,0 +1,137 @@ +# Linux egui 日志约定 + +状态:canonical;更新:2026-10-22。适用于 `openless-all/app/linux-egui`,以及被两个前端共用的 `crates/openless-core`。 + +日志是 Linux 用户唯一能交回来的证据:界面出错时他们既没有控制台,也不会跑 gdb。所以这份文件规定的不是文风,而是**「出问题时能不能从日志里读出结论」**。 + +## 1. 落在哪里 + +| 项 | 值 | +|---|---| +| 文件 | `$XDG_DATA_HOME/openless/logs/openless.log`(默认 `~/.local/share/OpenLess/logs/`) | +| 单文件上限 | 2 MiB | +| 历史份数 | `openless.log.1` … `openless.log.3`(合计 ≤ 8 MiB) | +| 轮转时机 | 进程启动时;**只有宿主轮转**,UI 窗口进程不轮转(否则宿主手里的句柄会指向被改名的 inode) | +| 终端 | 同时输出到 stderr,级别与文件一致 | +| 导出 | 设置 → 高级 → 调试 → 「导出错误日志」(`export_error_logs`,原子写) | +| 导出内容 | 当前 `openless.log` **加全部轮转历史**(`.3`→`.2`→`.1`→当前,时间正序),文件头写明版本、系统架构、生效级别与导出时间 | + +两个进程(宿主 `openless`、界面 `openless --ui-client`)写同一个文件,排查「窗口进程怎么没了」时两端记录在同一处。 + +## 2. 级别 + +| 级别 | 用来记什么 | 判据 | +|---|---|---| +| `error` | **失败**。用户可见的能力没做到,或数据可能受损 | 「这条出现时,功能是坏的」 | +| `warn` | 降级但仍然工作:回退到默认设备、重试成功、可选能力缺失 | 「这条出现时,行为与预期不同」 | +| `info` | **状态变化**与低频存活证明(热键注册结果、模型加载、云同步相位、会话开始/结束) | 用户报「它不工作了」时,需要看到「上一次好的是哪一步」 | +| `debug` | 周期仪表:心跳、IPC 往返、逐帧/逐事件细节 | 打开后能让 `info` 的结论落到具体数值 | +| `trace` | 逐条协议/逐样本,只为复现一次具体 bug | 默认关闭,通常只用一次 | + +两条硬规则: + +1. **周期性的东西不许写 `info`。** 每 10 秒一条 = 8640 行/天,轮转后整份日志九成都是它,真正的错误被挤出去。这类日志写 `debug`,`info` 只留状态变化与低频存活证明。 + - 参考实现:`log_pump_heartbeat`(`linux-egui/src/main.rs`)——状态变化立刻记 `info`,否则每 5 分钟一条,其余 `debug`。 +2. **`error` 只写失败,不写进度。** `log::error!("[x] stage=device_id_ok")` 这类 tracer 会让每次启动看起来都像崩了,必须降到 `debug`。 + +## 3. 设置里也能改 + +设置 → 高级 → 调试 → **日志级别**(`info` / `debug` / `trace`): + +- 选完写进 `/logs/level`,**重启 OpenLess 生效**(界面会提示)。 +- 这是宿主本地设置,不进 Core 偏好,因此不上云、不影响 Tauri 与移动端。 +- 环境变量优先于这里的设置:临时排查不该被上一次的界面选择挡住。 + +## 4. 不用重新编译也能改级别 + +```bash +# 全开 +OPENLESS_LOG=debug openless + +# 只把 Core 的依赖图噪声打开 +OPENLESS_LOG=info,openless_core=debug openless + +# 环境变量在 systemd 启动的情况下要写进服务定义 +systemctl --user edit openless +# [Service] +# Environment=OPENLESS_LOG=debug +``` + +`OPENLESS_LOG` 优先,`RUST_LOG` 作为兼容名也认。语法: + +``` +[,=]... +``` + +- 模块前缀按 `::` 边界匹配,**最长匹配优先**(`openless_core::asr=warn` 会盖住 `openless_core=debug`)。 +- 任何无法识别的片段会让整条规则退回默认 `info`:宁可多记,不可静默少记。 +- 默认 `info`。发布版不需要用户改任何东西就能看到状态变化。 + +启动时的第一行会写明生效的过滤与是否轮转过: + +``` +2026-10-22T23:40:12.423+08:00 [INFO] openless_linux_egui::logging: Linux egui logger ready: level=debug rotated=false path=/home/x/.local/share/OpenLess/logs/openless.log +``` + +## 5. 行格式 + +``` +<本地时区 RFC3339> [] [] [] <文件:行> <消息> +``` + +- `target` 一直在(模块路径),定位「哪一层打的」不用猜。 +- 线程名与源码位置只在 `debug` 及以上出现,避免 `info` 行变长。 +- 时间戳是**用户本地时区**。历史版本写 UTC(结尾 `Z`),用户对着自己的操作时间找不到对应行。 + +消息里的固定前缀是约定,用方括号小写,便于 `grep '\[fcitx\]'`: + +| 前缀 | 范围 | +|---|---| +| `[pump]` | 宿主循环存活 | +| `[hotkey]` | 热键注册与分发 | +| `[fcitx]` | fcitx5 addon 通信 | +| `[ui-host]` / `[ui-client]` | 两个进程各自的 IPC 侧 | +| `[dictation]` | 一次听写的生命周期(Core) | +| `[insert-diagnostics]` | 文本注入 | +| `[audio]` / `[audio-cue]` / `[audio-mute]` | 采集与提示音 | +| `[style]` | 风格包 | +| `[remote-input]` | 手机/远程输入 | +| `[capture]` | 截图与选区 | + +## 6. 一次听写在日志里长什么样 + +``` +[dictation] start session_id=… target=Dictation asr=dashscope insert=true translate=false silence_after_ms=None +[audio] capture started backend=pipewire device=… format=F32 rate=48000Hz channels=1 +[transcript] partial emitted session=… ← 仅 debug +[asr] session_id=… provider=dashscope label=… elapsed_ms=1840 chars=42 +[asr-diagnostics] session_id=… asr_chars=42 asr_hash=… corrected_chars=42 … +[polish-diagnostics] session_id=… stage=decision uses_polisher=true … +[insert-diagnostics] session_id=… attempt=1 operation=… chars=42 hash=… +[dictation] session … failed: code=… message=… ← 失败时(warn) +``` + +## 7. 定位一次听写 + +同一次听写里的多行用 `session_id` 串起来(`[dictation]`、`[insert-diagnostics]` 都带)。排查顺序: + +```bash +grep 'session_id=' ~/.local/share/OpenLess/logs/openless.log* +``` + +失败原因现在会同时给出 `code` 与 `message`(见 `mark_dictation_failed`)。历史版本只有 `code`,因此「录音停止 / 转写 / 润色」这一步光看日志分不出来。 + +## 8. 不要记录的东西 + +- 任何凭据:API key、token、password、私钥、keystore 内容。日志会被用户原样贴到 issue 里。 +- 用户听写的正文。需要时可记长度或哈希(`[insert-diagnostics]` 就是这么做的:`chars=` / `hash=`)。 +- 完整 `data_dir` 之外的路径通常没必要;`data_dir` 本身要记,因为「日志写在哪」是第一个问题。 + +`scripts/egui-logging-contract.test.mjs` 会拒绝形如 `api_key=` / `token=` / `password=` 的日志格式化参数,以及 `stage=` 级别的 `error`。 + +## 9. 改动这份约定时 + +- 新增周期日志 → 必须是 `debug`,或带节流。 +- 新增 `info` → 必须能回答「状态变了什么」。 +- 新增 `error` → 必须对应一次真实的失败分支。 +- 跑 `node scripts/egui-logging-contract.test.mjs`。 diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 480c5002a..3402ab525 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -7809,6 +7809,17 @@ impl OpenLessBackend { context }; + // 一次听写的起点。日志从「按下热键」到第一条识别结果之间原本是空白, + // 于是「按了没反应」与「录上了但没识别」在日志里长得一样。 + log::info!( + "[dictation] start session_id={} target={:?} asr={} insert={} translate={} silence_after_ms={:?}", + session_id, + context.output_target, + context.asr.provider_type, + context.insertion.enabled, + context.polish.translation_active, + context.recording.silence_after_ms, + ); if let Err(error) = self .deps .host_actions diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs index f5ce14cf0..ec0167d72 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs @@ -43,7 +43,7 @@ pub(crate) fn build( } let origin = origin.origin().ascii_serialization(); let root = data_dir.join("encrypted-sync"); - log::error!( + log::debug!( "[e2ee-adapter] build start data_dir={} root={}", data_dir.display(), root.display() @@ -55,15 +55,15 @@ pub(crate) fn build( ); super::error("local_storage_unavailable") })?; - log::error!("[e2ee-adapter] stage=gate_open"); + log::debug!("[e2ee-adapter] stage=gate_open"); let gate = crate::cloud_sync_e2ee_store::gate::open_for_data_dir(data_dir).map_err(|error| { log::error!("[e2ee-adapter] reopen sync write gate failed: {error:#}"); document_error(error) })?; - log::error!("[e2ee-adapter] stage=device_id"); + log::debug!("[e2ee-adapter] stage=device_id"); let device_id = load_device_id(&root)?; - log::error!( + log::debug!( "[e2ee-adapter] stage=device_id_ok id_len={}", device_id.len() ); @@ -79,7 +79,7 @@ pub(crate) fn build( device_id, credentials.clone(), ); - log::error!("[e2ee-adapter] stage=store_new"); + log::debug!("[e2ee-adapter] stage=store_new"); let credential_store_for_service = credentials.clone(); let store = Arc::new( CoreSyncStore::new( @@ -96,7 +96,7 @@ pub(crate) fn build( document_error(error) })?, ); - log::error!("[e2ee-adapter] stage=store_ok"); + log::debug!("[e2ee-adapter] stage=store_ok"); let service = super::EncryptedSyncService::new( super::SyncServiceConfig { origin, diff --git a/openless-all/app/crates/openless-core/src/dictation_engine.rs b/openless-all/app/crates/openless-core/src/dictation_engine.rs index a121f3efe..3a95c61cb 100644 --- a/openless-all/app/crates/openless-core/src/dictation_engine.rs +++ b/openless-all/app/crates/openless-core/src/dictation_engine.rs @@ -949,7 +949,17 @@ impl DictationEngine for PipelineDictationEngine { } } }; - let asr_ms = Some(asr_started.elapsed().as_millis() as u64); + let asr_elapsed_ms = asr_started.elapsed().as_millis() as u64; + let asr_ms = Some(asr_elapsed_ms); + // 「听写很慢」时要能一句话看出慢在 ASR 还是慢在润色。 + log::info!( + "[asr] session_id={} provider={} label={:?} elapsed_ms={} chars={}", + session_id, + context.asr.provider_type, + asr_call_label, + asr_elapsed_ms, + transcript.text.chars().count(), + ); if session.cancelled.load(Ordering::Acquire) { if !context.recording.archive_successful_recording { discard_ephemeral_archive(archive.as_ref(), &mut has_audio_recording).await; @@ -2195,7 +2205,9 @@ struct TranscriptProgressForwarder { impl TextStreamSink for TranscriptProgressForwarder { fn publish(&self, chunk: TextStreamChunk) -> Result<(), BackendError> { - log::info!( + // 流式转写每来一块就一条;它属于逐事件仪表,走 debug, + // 否则一次长听写就能把 info 层刷满。 + log::debug!( "[transcript] partial emitted session={} offset={} chars={}", self.session_id, chunk.offset, diff --git a/openless-all/app/linux-egui/src/audio.rs b/openless-all/app/linux-egui/src/audio.rs index 5e5d931af..adb75bc7a 100644 --- a/openless-all/app/linux-egui/src/audio.rs +++ b/openless-all/app/linux-egui/src/audio.rs @@ -485,6 +485,11 @@ fn run_audio_thread(args: LinuxAudioThreadArgs) { stream } Err(error) => { + log::error!( + "[audio] no input backend could open a stream (tried {:?}): {}", + audio_backend_order(), + error.message + ); drop(frame_tx); let _ = processor.finish(); let _ = startup.send(Err(error)); @@ -540,6 +545,15 @@ fn try_start_audio_stream( .play() .map_err(|error| classify_audio_error("start input stream", error.to_string()))?; accepting.store(true, std::sync::atomic::Ordering::Release); + // 「录到的全是静音」这类问题,第一个要回答的就是:落到哪个后端、哪只 + // 设备、什么采样格式。以前只有失败时才有日志,成功时什么都看不到。 + log::info!( + "[audio] capture started backend={backend} device={} format={:?} rate={}Hz channels={}", + device, + supported.sample_format(), + supported.sample_rate(), + usize::from(supported.channels()), + ); Ok(stream) }; let initial_error = match device.default_input_config() { diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index 29bc7c752..13ca6fe9e 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -1468,6 +1468,7 @@ pub const CATALOG: &[Msg] = &[ Msg { key: "status.channel_saved", text: row("渠道配置已保存", "管道設定已儲存", "Channel configuration saved", "チャネル設定を保存しました", "채널 설정 저장됨") }, Msg { key: "status.secret_cleared", text: row("渠道 Secret 已清除", "管道 Secret 已清除", "Channel Secret cleared", "チャネルの Secret を消去しました", "채널 Secret 지워짐") }, Msg { key: "status.provider_validated", text: row("Provider 验证通过({} ms)", "Provider 驗證通過({} ms)", "Provider validated ({} ms)", "Provider 検証成功({} ms)", "Provider 검증 통과({}ms)") }, + Msg { key: "status.log_level_saved", text: row("日志级别已保存,重启 OpenLess 后生效", "日誌等級已儲存,重新啟動 OpenLess 後生效", "Log level saved; restart OpenLess to apply", "ログレベルを保存しました。OpenLess の再起動で反映されます", "로그 레벨을 저장했습니다. OpenLess를 다시 시작하면 적용됩니다") }, Msg { key: "status.export_log_done", text: row("错误日志已导出", "錯誤日誌已匯出", "Error log exported", "エラーログをエクスポートしました", "오류 로그 내보냄") }, Msg { key: "status.hotkey_handled", text: row("已处理快捷键", "已處理快速鍵", "Hotkey handled", "ホットキーを処理しました", "단축키 처리됨") }, Msg { key: "status.launch_handled", text: row("已处理启动请求", "已處理啟動請求", "Launch request handled", "起動要求を処理しました", "시작 요청 처리됨") }, @@ -3794,6 +3795,20 @@ pub const CATALOG: &[Msg] = &[ "Shift+Insert (xterm / urxvt)", ), }, + Msg { + key: "settings.debug.log_level_label", + text: row("日志级别", "日誌等級", "Log level", "ログレベル", "로그 레벨"), + }, + Msg { + key: "settings.debug.log_level_desc", + text: row( + "debug 记录心跳与 IPC 细节,排查问题时更有用;选择后需重启 OpenLess 生效。", + "debug 記錄心跳與 IPC 細節,排查問題時更有用;選擇後需重新啟動 OpenLess 生效。", + "debug records heartbeats and IPC details, which is what support needs; restart OpenLess to apply.", + "debug はハートビートと IPC の詳細を記録します(調査向け)。OpenLess の再起動で反映されます。", + "debug는 하트비트와 IPC 세부 정보를 기록합니다(문제 해결용). OpenLess를 다시 시작해야 적용됩니다.", + ), + }, Msg { key: "settings.recording.record_audio_for_debug_label", text: row( diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index d0bdb47e3..765508486 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -95,7 +95,10 @@ pub use local_hotkeys::{ next_local_press_id, plugin_event_hotkey, style_pack_raw, translation_hotkey_event, HotkeyDeduplicator, LocalHotkey, LocalHotkeyEdge, LocalHotkeyEdgeKind, HOTKEY_DEDUPE_WINDOW, }; -pub use logging::{export_error_log, init_file_logger, log_path}; +pub use logging::{ + export_error_log, export_error_logs, init_file_logger, level_name, log_path, read_log_level, + write_log_level, +}; pub use popup::{ force_x11_for, popup_command, read_jsonl, run_popup, write_jsonl, ApplyOutcome as PopupApplyOutcome, CapsulePopupState, CapsuleSuggestion, HostToPopup, diff --git a/openless-all/app/linux-egui/src/linux_app/settings_save.rs b/openless-all/app/linux-egui/src/linux_app/settings_save.rs index d7e83d1b4..6bb556f03 100644 --- a/openless-all/app/linux-egui/src/linux_app/settings_save.rs +++ b/openless-all/app/linux-egui/src/linux_app/settings_save.rs @@ -137,6 +137,12 @@ impl OpenLessEguiApp { self.snapshot = Some(backend.snapshot()); } self.hydrate_text_fields = true; + // 设置写入是「用户改了东西但看起来没保存」类问题的分界线: + // 成功一条 debug,失败一条 warn(带字段数),不问就不知道卡在哪一段。 + match &result { + Ok(()) => log::debug!("[settings] saved request={request}"), + Err(error) => log::warn!("[settings] save request={request} failed: {error}"), + } match result { Ok(()) => { self.status = tr_l10n(self.lang, "status.settings_saved").to_string(); @@ -247,10 +253,40 @@ impl OpenLessEguiApp { field: frontend::view_model::SettingsComboField, index: usize, ) { + // 日志级别是宿主本地设置(`/logs/level`),不经过 Core 偏好, + // 因此要放在下面 `self.preferences` 的守卫之前 —— 否则偏好还没加载时 + // 连日志级别都改不了。 + if matches!(field, frontend::view_model::SettingsComboField::LogLevel) { + let Some(backend) = self.backend() else { + return; + }; + let level = ["info", "debug", "trace"] + .get(index) + .copied() + .unwrap_or("info"); + let data_dir = backend.config().data_dir.clone(); + match openless_linux_egui::write_log_level(&data_dir, level) { + Ok(parsed) => { + self.frontend_vm.settings.log_level = index; + log::info!( + "[logging] level set to {} (takes effect on the next start)", + openless_linux_egui::level_name(parsed) + ); + self.status = tr_l10n(self.lang, "status.log_level_saved").to_string(); + } + Err(error) => { + log::warn!("[logging] failed to persist the level: {error}"); + self.status = error.to_string(); + } + } + return; + } let Some(preferences) = self.preferences.as_mut() else { return; }; match field { + // 已在函数开头处理(不走 Core 偏好)。 + frontend::view_model::SettingsComboField::LogLevel => {} frontend::view_model::SettingsComboField::Theme => { preferences.theme_mode = match index { 0 => openless_core::shared_types::ThemeMode::System, @@ -673,13 +709,15 @@ impl OpenLessEguiApp { } frontend::view_model::SettingsActionField::ExportDiagnostics => { if let Some(backend) = self.backend() { - let source = openless_linux_egui::log_path(&backend.config().data_dir); + let data_dir = backend.config().data_dir.clone(); let lang = self.lang; self.spawn(async move { let destination = tokio::task::spawn_blocking(|| { rfd::FileDialog::new() - .add_filter("Log", &["log"]) - .set_file_name("openless.log") + // 导出的是「当前 + 全部轮转历史」,默认名字别再叫 openless.log, + // 否则用户会以为是那个单文件。 + .add_filter("Log", &["log", "txt"]) + .set_file_name("openless-diagnostics.log") .save_file() }) .await @@ -696,7 +734,7 @@ impl OpenLessEguiApp { ) })?; tokio::task::spawn_blocking(move || { - openless_linux_egui::export_error_log(&source, &destination) + openless_linux_egui::export_error_logs(&data_dir, &destination) }) .await .map_err(|error| { diff --git a/openless-all/app/linux-egui/src/linux_app/window.rs b/openless-all/app/linux-egui/src/linux_app/window.rs index 34dd03e56..fe2ba3712 100644 --- a/openless-all/app/linux-egui/src/linux_app/window.rs +++ b/openless-all/app/linux-egui/src/linux_app/window.rs @@ -208,7 +208,7 @@ impl OpenLessEguiApp { self.pending_ui_actions.push(*action); } WindowToHost::Hotkey { sequence, edge } => { - log::info!("[hotkey] local edge from the UI window #{sequence}: {edge:?}"); + log::debug!("[hotkey] local edge from the UI window #{sequence}: {edge:?}"); self.pending_local_hotkeys .push((std::time::Instant::now(), edge)); } @@ -584,7 +584,8 @@ impl UiClientApp { let count = self.latency_samples.len(); let max = self.latency_samples.iter().copied().max().unwrap_or(0); let sum: u128 = self.latency_samples.iter().sum(); - log::info!( + // 每 20 次探针一条;它是仪表,不是事件,走 debug。 + log::debug!( "[ui-client] ipc round-trip avg={}ms max={max}ms over {count} probes (last #{sequence})", sum / count as u128 ); @@ -802,7 +803,7 @@ impl eframe::App for UiClientApp { } } if ui_debug_enabled() && !actions.is_empty() { - log::info!("[ui-client] actions from the renderer: {actions:?}"); + log::debug!("[ui-client] actions from the renderer: {actions:?}"); } self.dispatch(actions, &ctx); self.ping_if_due(); diff --git a/openless-all/app/linux-egui/src/logging.rs b/openless-all/app/linux-egui/src/logging.rs index ed7418812..e24752798 100644 --- a/openless-all/app/linux-egui/src/logging.rs +++ b/openless-all/app/linux-egui/src/logging.rs @@ -1,55 +1,385 @@ +//! Linux egui 的日志后端。 +//! +//! 两个进程(宿主 `openless`、UI 窗口 `openless --ui-client`)都写同一个 +//! `$XDG_DATA/openless/logs/openless.log`,这样「窗口进程怎么没了」这类问题 +//! 两端的记录在同一处。 +//! +//! 三条约定,缺一条日志就会退化成没人看的噪声: +//! +//! 1. **级别可调,不用重新编译**。`OPENLESS_LOG`(或 `RUST_LOG`)决定过滤, +//! 例如 `OPENLESS_LOG=debug`、`OPENLESS_LOG=openless_core=debug,info`。 +//! 默认 `info` —— 发布版看不出问题的时候,用户可以只加一个环境变量重启复现。 +//! 2. **周期心跳不许占满文件**。日志是 append + 轮转,任何每 10 秒一条的 +//! 「我还在」都会在一天内吃掉整个配额,把真正的错误挤出去。心跳走 `debug`, +//! `info` 只留状态变化与低频存活证明(见 `log_pump_heartbeat`)。 +//! 3. **`error` 只写失败**,不写进度。轮转后仍有 3 份历史,够回溯一次事故。 + use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +// 导出只报告已安装的规则,不把待重启的设置误当成生效配置。 +static ACTIVE_FILTER: OnceLock = OnceLock::new(); -const ROTATE_LIMIT_BYTES: u64 = 5 * 1024 * 1024; +/// 单文件上限。整个日志目录最多 `ROTATE_KEEP + 1` 份,即 8 MiB。 +const ROTATE_LIMIT_BYTES: u64 = 2 * 1024 * 1024; +/// 保留的历史份数:`openless.log.1` … `.3`。 +const ROTATE_KEEP: usize = 3; +/// 无法解析 `OPENLESS_LOG` 时的默认级别。 +const DEFAULT_LEVEL: log::LevelFilter = log::LevelFilter::Info; +/// 设置页写入的级别文件名,位于 `/logs/level`。 +/// 环境变量仍然优先:临时排查不该被上一次的界面设置挡住。 +const LEVEL_FILE_NAME: &str = "level"; pub fn log_path(data_dir: &Path) -> PathBuf { data_dir.join("logs").join("openless.log") } +/// 环境变量名:优先 `OPENLESS_LOG`,兼容 Rust 生态惯用的 `RUST_LOG`。 +const ENV_KEYS: [&str; 2] = ["OPENLESS_LOG", "RUST_LOG"]; + pub fn init_file_logger(data_dir: &Path) -> Result { - use simplelog::{ - ColorChoice, CombinedLogger, ConfigBuilder, LevelFilter, TermLogger, TerminalMode, - WriteLogger, - }; + use simplelog::{ColorChoice, SharedLogger, TermLogger, TerminalMode, WriteLogger}; let path = log_path(data_dir); let parent = path.parent().ok_or("log path has no parent")?; std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; - rotate_if_needed(&path).map_err(|error| error.to_string())?; + // 只有宿主轮转:UI 窗口进程若是也有资格改名,宿主手里的文件句柄会指向被 + // 改名的 inode,之后的宿主管日志就写进 `.1` 里了。 + let rotated = if is_ui_client_process() { + false + } else { + rotate_if_needed(&path).map_err(|error| error.to_string())? + }; let file = std::fs::OpenOptions::new() .create(true) .append(true) .open(&path) .map_err(|error| error.to_string())?; - let config = ConfigBuilder::new().set_time_format_rfc3339().build(); - CombinedLogger::init(vec![ + + let filter = FilterSpec::resolve(data_dir); + let config = base_config(); + let sinks: Vec> = vec![ + // 终端上也用同一份过滤结果,级别再放宽到 Trace 交给 FilterSpec 决定。 TermLogger::new( - LevelFilter::Info, + log::LevelFilter::Trace, config.clone(), TerminalMode::Mixed, ColorChoice::Auto, ), - WriteLogger::new(LevelFilter::Info, config, file), - ]) - .map_err(|error| error.to_string())?; - log::info!("Linux egui file logger ready: {}", path.display()); + WriteLogger::new(log::LevelFilter::Trace, config, file), + ]; + + let logger = FilteredLogger { + sinks, + spec: filter.clone(), + }; + log::set_boxed_logger(Box::new(logger)).map_err(|error| error.to_string())?; + log::set_max_level(filter.max_level()); + let description = filter.describe(); + let _ = ACTIVE_FILTER.set(filter); + + log::info!( + "Linux egui logger ready: level={description} rotated={rotated} path={}", + path.display() + ); Ok(path) } -fn rotate_if_needed(path: &Path) -> std::io::Result<()> { +fn base_config() -> simplelog::Config { + use simplelog::{ConfigBuilder, LevelFilter, ThreadLogMode}; + // simplelog 的 builder 方法是 `&mut self` 风格,先持有再链式调用。 + let mut builder = ConfigBuilder::new(); + builder.set_time_format_rfc3339(); + // 模块名一直在,定位「哪一层的日志」不用猜。 + // simplelog 的判据是 `config.target <= record.level()`,级别越小越宽松, + // 所以「始终打印」要设在 Error(最严格端),设成 Trace 反而不打印。 + builder.set_target_level(LevelFilter::Error); + // 线程与源码位置只在 debug 及以上出现,避免 info 行变长。 + builder.set_thread_level(LevelFilter::Debug); + builder.set_thread_mode(ThreadLogMode::Names); + builder.set_location_level(LevelFilter::Debug); + // 用户在 +08:00,日志却是 UTC 的 `Z`:让时间戳落到本地时区。 + // 判定不出来(Err)就保持 UTC,不影响其余配置。 + let _ = builder.set_time_offset_to_local(); + builder.build() +} + +/// 日志过滤规则:一个默认级别 + 若干 `模块前缀=级别` 覆盖。 +#[derive(Clone)] +struct FilterSpec { + default: log::LevelFilter, + overrides: Vec<(String, log::LevelFilter)>, +} + +impl FilterSpec { + /// 优先级:`OPENLESS_LOG`/`RUST_LOG` → 设置里存的级别 → `info`。 + fn resolve(data_dir: &Path) -> Self { + for key in ENV_KEYS { + if let Ok(value) = std::env::var(key) { + return Self::parse(&value).unwrap_or(Self { + default: DEFAULT_LEVEL, + overrides: Vec::new(), + }); + } + } + if let Some(level) = read_log_level(data_dir) { + return Self { + default: level, + overrides: Vec::new(), + }; + } + Self { + default: DEFAULT_LEVEL, + overrides: Vec::new(), + } + } + + /// 解析 `debug` / `info,openless_core=debug` / `OPENLESS_LOG=*`。 + /// 任何一条无法理解的片段都退回默认级别:宁可多记,不可静默少记。 + fn parse(value: &str) -> Option { + let mut default = None; + let mut overrides = Vec::new(); + for piece in value.split(',') { + let piece = piece.trim(); + if piece.is_empty() { + continue; + } + let (target, level) = match piece.split_once('=') { + Some((target, level)) => (Some(target.trim()), level.trim()), + None => (None, piece), + }; + let level = parse_level(level)?; + match target { + Some(target) if !target.is_empty() && target != "*" => { + overrides.push((target.to_string(), level)); + } + _ => default = Some(level), + } + } + Some(Self { + default: default.unwrap_or(DEFAULT_LEVEL), + overrides, + }) + } + + fn level_for(&self, target: &str) -> log::LevelFilter { + // 最长匹配优先:`openless_core::asr` 比 `openless_core` 更具体。 + let mut best: Option<(usize, log::LevelFilter)> = None; + for (prefix, level) in &self.overrides { + if target == prefix || target.starts_with(&format!("{prefix}::")) { + let len = prefix.len(); + if best.is_none_or(|(best_len, _)| len >= best_len) { + best = Some((len, *level)); + } + } + } + best.map(|(_, level)| level).unwrap_or(self.default) + } + + fn max_level(&self) -> log::LevelFilter { + self.overrides + .iter() + .map(|(_, level)| *level) + .chain(std::iter::once(self.default)) + .max() + .unwrap_or(DEFAULT_LEVEL) + } + + fn describe(&self) -> String { + let overrides = self + .overrides + .iter() + .map(|(target, level)| format!("{target}={}", level_label(*level))) + .collect::>(); + if overrides.is_empty() { + level_label(self.default).to_string() + } else { + format!("{} ({})", level_label(self.default), overrides.join(",")) + } + } +} + +fn parse_level(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "off" => Some(log::LevelFilter::Off), + "error" => Some(log::LevelFilter::Error), + "warn" | "warning" => Some(log::LevelFilter::Warn), + "info" => Some(log::LevelFilter::Info), + "debug" => Some(log::LevelFilter::Debug), + "trace" => Some(log::LevelFilter::Trace), + _ => None, + } +} + +/// 设置页保存的日志级别;文件不存在或内容无法识别时返回 `None`。 +pub fn read_log_level(data_dir: &Path) -> Option { + let path = data_dir.join("logs").join(LEVEL_FILE_NAME); + let raw = std::fs::read_to_string(path).ok()?; + parse_level(raw.trim()) +} + +/// 把级别写进 `/logs/level`,下次启动生效。 +pub fn write_log_level(data_dir: &Path, level: &str) -> std::io::Result { + let Some(parsed) = parse_level(level) else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("unknown log level: {level}"), + )); + }; + let dir = data_dir.join("logs"); + std::fs::create_dir_all(&dir)?; + crate::atomic_save(&dir.join(LEVEL_FILE_NAME), level_name(parsed).as_bytes()) + .map_err(|error| std::io::Error::other(error.to_string()))?; + Ok(parsed) +} + +/// 级别的稳定名称:文件名、设置项与日志首行都用它,避免各自拼写。 +pub fn level_name(level: log::LevelFilter) -> &'static str { + level_label(level) +} + +fn level_label(level: log::LevelFilter) -> &'static str { + match level { + log::LevelFilter::Off => "off", + log::LevelFilter::Error => "error", + log::LevelFilter::Warn => "warn", + log::LevelFilter::Info => "info", + log::LevelFilter::Debug => "debug", + log::LevelFilter::Trace => "trace", + } +} + +/// 把 `FilterSpec` 套在所有 sink 前面。simplelog 自身只支持单一级别, +/// 按模块区分级别得在它外面做一层。 +struct FilteredLogger { + sinks: Vec>, + spec: FilterSpec, +} + +impl log::Log for FilteredLogger { + fn enabled(&self, metadata: &log::Metadata) -> bool { + metadata.level() <= self.spec.level_for(metadata.target()) + } + + fn log(&self, record: &log::Record) { + if !self.enabled(record.metadata()) { + return; + } + for sink in &self.sinks { + sink.log(record); + } + } + + fn flush(&self) { + for sink in &self.sinks { + sink.flush(); + } + } +} + +/// UI 窗口进程由宿主拉起,参数里带 `--ui-client`。 +fn is_ui_client_process() -> bool { + std::env::args().any(|arg| arg == "--ui-client") +} + +/// 按 `.log` → `.1` → … → `.ROTATE_KEEP` 依次后移,最老的一份被丢弃。 +/// 返回是否真的轮转过。 +fn rotate_if_needed(path: &Path) -> std::io::Result { let Ok(metadata) = std::fs::metadata(path) else { - return Ok(()); + return Ok(false); }; if metadata.len() <= ROTATE_LIMIT_BYTES { - return Ok(()); + return Ok(false); } - let archive = path.with_file_name("openless.log.1"); - match std::fs::remove_file(&archive) { + let archive = |index: usize| path.with_file_name(format!("openless.log.{index}")); + // 先把最老的删掉,再逐级改名,最后把当前文件挪成 `.1`。 + match std::fs::remove_file(archive(ROTATE_KEEP)) { Ok(()) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(error), } - std::fs::rename(path, archive) + for index in (1..ROTATE_KEEP).rev() { + match std::fs::rename(archive(index), archive(index + 1)) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + std::fs::rename(path, archive(1))?; + Ok(true) +} + +/// 导出「当前日志 + 全部轮转历史」到单个文件。 +/// +/// 只导出 `openless.log` 时,用户报的问题几乎总是发生在轮转之后 —— 现场刚好被 +/// 切进 `.1` 而导出里没有。这里按 `.3` → `.2` → `.1` → 当前的顺序拼接(时间 +/// 正序),每段加分隔头,并在最前面写明版本、系统、生效级别与导出时间, +/// 省掉「你用的哪个版本」这一轮往返。 +pub fn export_error_logs(data_dir: &Path, destination: &Path) -> Result<(), crate::DesktopError> { + let current = log_path(data_dir); + let mut out = String::new(); + out.push_str("# OpenLess Linux diagnostics\n"); + out.push_str(&format!("version: {}\n", env!("OPENLESS_APP_VERSION"))); + out.push_str(&format!( + "os: {} {}\n", + std::env::consts::OS, + std::env::consts::ARCH + )); + out.push_str(&format!( + "level: {}\n", + ACTIVE_FILTER + .get() + .map(FilterSpec::describe) + .unwrap_or_else(|| "unknown (logger not initialized)".to_string()) + )); + out.push_str(&format!( + "exported_at: {}\n\n", + time::OffsetDateTime::now_local() + .unwrap_or_else(|_| time::OffsetDateTime::now_utc()) + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_default() + )); + + let mut exported = 0usize; + for index in (1..=ROTATE_KEEP).rev() { + let path = current.with_file_name(format!("openless.log.{index}")); + if append_log_section(&mut out, &path, &format!("openless.log.{index}"))? { + exported += 1; + } + } + if append_log_section(&mut out, ¤t, "openless.log")? { + exported += 1; + } + if exported == 0 { + return Err(crate::DesktopError::Io { + operation: "read error log", + source: std::io::Error::new(std::io::ErrorKind::NotFound, "no log file yet"), + }); + } + crate::atomic_save(destination, out.as_bytes()).map(|_| ()) +} + +fn append_log_section( + out: &mut String, + path: &Path, + label: &str, +) -> Result { + match std::fs::read(path) { + Ok(bytes) => { + out.push_str(&format!("===== {label} =====\n")); + out.push_str(&String::from_utf8_lossy(&bytes)); + if !out.ends_with('\n') { + out.push('\n'); + } + out.push('\n'); + Ok(true) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(crate::DesktopError::Io { + operation: "read error log", + source, + }), + } } pub fn export_error_log(source: &Path, destination: &Path) -> Result<(), crate::DesktopError> { @@ -64,6 +394,221 @@ pub fn export_error_log(source: &Path, destination: &Path) -> Result<(), crate:: mod tests { use super::*; + fn run_in_child(name: &str) -> bool { + const CHILD_ENV: &str = "OPENLESS_LOG_TEST_CHILD"; + if std::env::var(CHILD_ENV).as_deref() == Ok(name) { + return false; + } + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + &format!("logging::tests::{name}"), + "--nocapture", + "--test-threads=1", + ]) + .env(CHILD_ENV, name) + .env_remove("OPENLESS_LOG_RESTART_ROOT") + .env_remove("OPENLESS_LOG") + .env_remove("RUST_LOG") + .output() + .expect("launch isolated logging regression"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "isolated logging regression failed: {}\n{stdout}\n{stderr}", + output.status + ); + assert!( + stdout.contains("test result: ok. 1 passed; 0 failed"), + "{stdout}" + ); + true + } + + fn temp_dir() -> PathBuf { + let root = std::env::temp_dir().join(format!("openless-log-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(root.join("logs")).unwrap(); + root + } + + #[test] + fn resolves_configuration_in_priority_order() { + if run_in_child("resolves_configuration_in_priority_order") { + return; + } + let cases = [ + (None, None, None, "info"), + (None, None, Some("debug"), "debug"), + (None, None, Some("loud"), "info"), + (Some("debug"), Some("off"), Some("trace"), "debug"), + ( + Some("debug,openless_core=loud"), + Some("off"), + Some("off"), + "info", + ), + (Some("loud"), None, Some("off"), "info"), + (None, Some("loud"), Some("off"), "info"), + (None, Some("debug"), Some("off"), "debug"), + (Some("off"), Some("debug"), Some("trace"), "off"), + (None, Some("off"), Some("debug"), "off"), + (Some(""), Some("off"), Some("debug"), "info"), + ]; + for (openless, rust, saved, expected) in cases { + let root = temp_dir(); + for (key, value) in [("OPENLESS_LOG", openless), ("RUST_LOG", rust)] { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + if let Some(saved) = saved { + std::fs::write(root.join("logs/level"), saved).unwrap(); + } + assert_eq!( + FilterSpec::resolve(&root).describe(), + expected, + "OPENLESS_LOG={openless:?}, RUST_LOG={rust:?}, saved={saved:?}" + ); + std::fs::remove_dir_all(root).unwrap(); + } + } + + fn exported_text(root: &Path) -> String { + log::logger().flush(); + let destination = root.join("diagnostics.log"); + export_error_logs(root, &destination).unwrap(); + std::fs::read_to_string(destination).unwrap() + } + + #[test] + fn exports_the_active_rust_log_filter() { + if run_in_child("exports_the_active_rust_log_filter") { + return; + } + let root = temp_dir(); + write_log_level(&root, "off").unwrap(); + std::env::set_var("RUST_LOG", "debug"); + init_file_logger(&root).unwrap(); + log::debug!("rust-log-debug-probe"); + let text = exported_text(&root); + assert!(text.lines().any(|line| line == "level: debug"), "{text}"); + assert!(text.contains("rust-log-debug-probe")); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn exports_module_overrides_and_filters_their_boundaries() { + if run_in_child("exports_module_overrides_and_filters_their_boundaries") { + return; + } + let root = temp_dir(); + std::env::set_var( + "OPENLESS_LOG", + "info,openless_core=debug,openless_core::asr=warn", + ); + init_file_logger(&root).unwrap(); + log::debug!(target: "openless_core::api", "core-debug-probe"); + log::debug!(target: "openless_core::asr", "asr-debug-probe"); + log::debug!(target: "openless_core_extra", "lookalike-debug-probe"); + log::warn!(target: "openless_core::asr", "asr-warn-probe"); + let text = exported_text(&root); + assert!( + text.lines() + .any(|line| line == "level: info (openless_core=debug,openless_core::asr=warn)"), + "{text}" + ); + assert!(text.contains("core-debug-probe")); + assert!(text.contains("asr-warn-probe")); + assert!(!text.contains("asr-debug-probe")); + assert!(!text.contains("lookalike-debug-probe")); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn exports_the_active_level_until_restart() { + const NAME: &str = "exports_the_active_level_until_restart"; + const ROOT_ENV: &str = "OPENLESS_LOG_RESTART_ROOT"; + if run_in_child(NAME) { + return; + } + if let Some(root) = std::env::var_os(ROOT_ENV) { + let root = PathBuf::from(root); + init_file_logger(&root).unwrap(); + log::debug!("restarted-debug-probe"); + let text = exported_text(&root); + assert!(text.lines().any(|line| line == "level: debug"), "{text}"); + assert!(text.contains("restarted-debug-probe")); + return; + } + let root = temp_dir(); + write_log_level(&root, "info").unwrap(); + init_file_logger(&root).unwrap(); + write_log_level(&root, "debug").unwrap(); + log::debug!("pending-debug-probe"); + let text = exported_text(&root); + assert!(text.lines().any(|line| line == "level: info"), "{text}"); + assert!(!text.contains("pending-debug-probe")); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "logging::tests::exports_the_active_level_until_restart", + "--nocapture", + "--test-threads=1", + ]) + .env("OPENLESS_LOG_TEST_CHILD", NAME) + .env(ROOT_ENV, &root) + .env_remove("OPENLESS_LOG") + .env_remove("RUST_LOG") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{stdout}\n{stderr}"); + assert!( + stdout.contains("test result: ok. 1 passed; 0 failed"), + "{stdout}" + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn exports_existing_logs_without_an_initialized_logger() { + if run_in_child("exports_existing_logs_without_an_initialized_logger") { + return; + } + let root = temp_dir(); + std::fs::write(log_path(&root), "existing-log-probe\n").unwrap(); + write_log_level(&root, "debug").unwrap(); + let text = exported_text(&root); + assert!( + text.lines() + .any(|line| line == "level: unknown (logger not initialized)"), + "{text}" + ); + assert!(text.contains("existing-log-probe")); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn failed_initialization_preserves_the_active_filter() { + if run_in_child("failed_initialization_preserves_the_active_filter") { + return; + } + let root = temp_dir(); + std::env::set_var("OPENLESS_LOG", "debug"); + init_file_logger(&root).unwrap(); + std::env::set_var("OPENLESS_LOG", "info"); + assert!(init_file_logger(&root).is_err()); + assert_eq!(log::max_level(), log::LevelFilter::Debug); + log::debug!("still-debug-after-failed-init"); + let text = exported_text(&root); + assert!(text.lines().any(|line| line == "level: debug"), "{text}"); + assert!(text.contains("still-debug-after-failed-init")); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn exports_the_current_log_with_atomic_save() { let root = std::env::temp_dir().join(format!( @@ -80,4 +625,118 @@ mod tests { assert_eq!(std::fs::read(destination).unwrap(), b"diagnostic\n"); std::fs::remove_dir_all(root).unwrap(); } + + #[test] + fn exports_every_rotated_file_with_a_header() { + let root = std::env::temp_dir().join(format!( + "openless-linux-log-export-all-{}", + uuid::Uuid::new_v4().simple() + )); + let logs = root.join("logs"); + std::fs::create_dir_all(&logs).unwrap(); + std::fs::write(logs.join("openless.log"), b"newest\n").unwrap(); + std::fs::write(logs.join("openless.log.1"), b"older\n").unwrap(); + std::fs::write(logs.join("openless.log.2"), b"oldest\n").unwrap(); + let destination = root.join("diagnostics.log"); + + export_error_logs(&root, &destination).unwrap(); + let text = std::fs::read_to_string(&destination).unwrap(); + + assert!(text.starts_with("# OpenLess Linux diagnostics\n")); + assert!(text.contains("version: ")); + // 时间正序:`.2` 在 `.1` 之前,当前文件在最后。 + let oldest = text.find("oldest").unwrap(); + let older = text.find("older").unwrap(); + let newest = text.find("newest").unwrap(); + assert!( + oldest < older && older < newest, + "sections must be chronological" + ); + assert!(text.contains("===== openless.log.2 =====")); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn persists_the_level_for_the_next_start() { + let root = std::env::temp_dir().join(format!( + "openless-linux-log-level-{}", + uuid::Uuid::new_v4().simple() + )); + assert_eq!(read_log_level(&root), None); + assert_eq!( + write_log_level(&root, "debug").unwrap(), + log::LevelFilter::Debug + ); + assert_eq!(read_log_level(&root), Some(log::LevelFilter::Debug)); + assert!(write_log_level(&root, "loud").is_err()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn parses_plain_and_per_module_levels() { + let spec = FilterSpec::parse("debug").unwrap(); + assert_eq!(spec.level_for("openless_core"), log::LevelFilter::Debug); + + let spec = FilterSpec::parse("info,openless_core=debug").unwrap(); + assert_eq!( + spec.level_for("openless_core::asr"), + log::LevelFilter::Debug + ); + assert_eq!( + spec.level_for("openless_linux_egui"), + log::LevelFilter::Info + ); + + // 更具体的模块前缀优先。 + let spec = FilterSpec::parse("openless_core=debug,openless_core::asr=warn").unwrap(); + assert_eq!( + spec.level_for("openless_core::asr::volcengine"), + log::LevelFilter::Warn + ); + assert_eq!( + spec.level_for("openless_core::api"), + log::LevelFilter::Debug + ); + } + + #[test] + fn rejects_garbage_instead_of_silencing_everything() { + assert!(FilterSpec::parse("loud").is_none()); + } + + #[test] + fn rotates_into_numbered_archives_and_drops_the_oldest() { + let root = std::env::temp_dir().join(format!( + "openless-linux-log-rotate-{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("openless.log"); + std::fs::write(&path, vec![b'x'; (ROTATE_LIMIT_BYTES + 1) as usize]).unwrap(); + std::fs::write(root.join("openless.log.1"), b"one").unwrap(); + std::fs::write(root.join("openless.log.2"), b"two").unwrap(); + std::fs::write(root.join("openless.log.3"), b"three").unwrap(); + + assert!(rotate_if_needed(&path).unwrap()); + + assert!(!path.exists(), "the current log must have been moved aside"); + assert_eq!( + std::fs::read(root.join("openless.log.1")).unwrap().len(), + (ROTATE_LIMIT_BYTES + 1) as usize + ); + assert_eq!(std::fs::read(root.join("openless.log.2")).unwrap(), b"one"); + assert_eq!(std::fs::read(root.join("openless.log.3")).unwrap(), b"two"); + assert!( + !root.join("openless.log.4").exists(), + "archives beyond ROTATE_KEEP must be dropped" + ); + + // 小于上限时不轮转。 + std::fs::write(&path, b"small").unwrap(); + assert!(!rotate_if_needed(&path).unwrap()); + assert!(path.exists()); + + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 6daf7ac10..63fe56c4c 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -7,13 +7,17 @@ mod style_icon; #[cfg(target_os = "linux")] mod ui; -#[cfg(target_os = "linux")] mod linux_app { mod history; use history::HistoryCache; mod settings_save; mod window; use settings_save::SettingsSave; + /// 泵心跳的采样间隔:热键消费停没停,十秒内一定看得出来。 + const PUMP_HEARTBEAT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); + /// 状态没有变化时,`info` 级心跳的最低频率(存活证明,不为排查细节服务)。 + const PUMP_HEARTBEAT_INFO_INTERVAL: std::time::Duration = std::time::Duration::from_secs(300); + use std::future::Future; use std::sync::mpsc; use std::sync::Arc; @@ -667,8 +671,13 @@ mod linux_app { vocab_suggestions_checked: std::time::Instant, tray: Option, exit_requested: bool, - /// 上次打「泵心跳」日志的时间。 + /// 上次采样「泵心跳」的时间(每 10 秒一次,与日志级别无关)。 last_pump_heartbeat: std::time::Instant, + /// 上次把心跳写进 `info` 的时间。心跳是每 10 秒一次的存在性证明, + /// 全写 `info` 会在一天内吃满日志配额,把真正的错误挤出去。 + last_pump_heartbeat_info: std::time::Instant, + /// 上次写 `info` 时的状态快照;状态一变就立刻记一条。 + last_pump_heartbeat_snapshot: [bool; 4], window: WindowState, /// 本帧从 UI 收到、待宿主执行的动作(按到达顺序)。 pending_ui_actions: Vec, @@ -903,6 +912,8 @@ mod linux_app { tray, exit_requested: false, last_pump_heartbeat: std::time::Instant::now(), + last_pump_heartbeat_info: std::time::Instant::now(), + last_pump_heartbeat_snapshot: [false; 4], window: WindowState::new(window_should_be_open), pending_ui_actions: Vec::new(), pending_local_hotkeys: Vec::new(), @@ -1022,6 +1033,8 @@ mod linux_app { tray, exit_requested: false, last_pump_heartbeat: std::time::Instant::now(), + last_pump_heartbeat_info: std::time::Instant::now(), + last_pump_heartbeat_snapshot: [false; 4], window: WindowState::new(window_should_be_open), pending_ui_actions: Vec::new(), pending_local_hotkeys: Vec::new(), @@ -2120,7 +2133,7 @@ mod linux_app { } }, PopupSupervisorEvent::Message(PopupToHost::Hotkey { edge, .. }) => { - log::info!("[hotkey] local edge from the {kind:?} panel: {edge:?}"); + log::debug!("[hotkey] local edge from the {kind:?} panel: {edge:?}"); self.pending_local_hotkeys .push((std::time::Instant::now(), edge)); } @@ -4190,25 +4203,42 @@ mod linux_app { /// current-thread 运行时,`spawn` 的任务只在别处 `block_on` 时才被推进, /// 当作后台泵用就是「写完看着对、最小化后照样死」(实测心跳会在窗口 /// 收走的那一刻停)。线程只做一件事:`request_repaint()` 把事件循环戳醒。 - /// 泵心跳:宿主循环每 10s 一条。窗口关掉之后这条心跳**不能**停 —— + /// 泵心跳:宿主循环每 10s 采样一次。窗口关掉之后这条心跳**不能**停 —— /// 停了就说明热键消费与弹窗拉起也没在跑(这正是当初「关窗后热键失效」 - /// 的判据),用户/支持可以直接看日志确认。 + /// 的判据)。 + /// + /// 采样归采样,写日志要节流:每 10 秒一条 `info` 是 8640 行/天,轮转后 + /// 整份日志九成以上都是它,真正的错误反而被挤出去。所以**状态变化立刻 + /// 记一条 `info`**(这才是判据),状态没变时只每 + /// [`PUMP_HEARTBEAT_INFO_INTERVAL`] 留一条存活证明,其余走 `debug`。 fn log_pump_heartbeat(&mut self, ctx: &egui::Context) { self.tick_vocab_suggestions(); - if self.last_pump_heartbeat.elapsed() < std::time::Duration::from_secs(10) { + if self.last_pump_heartbeat.elapsed() < PUMP_HEARTBEAT_INTERVAL { return; } self.last_pump_heartbeat = std::time::Instant::now(); // 宿主没有窗口,所以心跳只看「窗口进程还在不在」—— // 这正是关窗后必须继续为 true 的那一项能力。 let _ = ctx; - log::info!( - "[pump] heartbeat window_process={} window_wanted={} tray={} recording={}", + let snapshot = [ self.window.child.is_some(), self.window.should_be_open, self.tray.is_some(), self.recording_phase_active, + ]; + let changed = snapshot != self.last_pump_heartbeat_snapshot; + let due = self.last_pump_heartbeat_info.elapsed() >= PUMP_HEARTBEAT_INFO_INTERVAL; + let message = format!( + "[pump] heartbeat window_process={} window_wanted={} tray={} recording={}", + snapshot[0], snapshot[1], snapshot[2], snapshot[3], ); + if changed || due { + self.last_pump_heartbeat_info = std::time::Instant::now(); + self.last_pump_heartbeat_snapshot = snapshot; + log::info!("{message}"); + } else { + log::debug!("{message}"); + } } /// 当前生效的热键配置(本地匹配与去重都用它)。 @@ -4596,6 +4626,7 @@ mod linux_app { let mut actions = Vec::new(); native.host_actions().drain(|action| actions.push(action)); for intent in launch_intents { + log::info!("[ui-host] launch intent from the user: {intent:?}"); // CLI 的 ToggleQa 与「按一次 QA 热键」等价(Tauri // `dispatch_cli_intent` 把 ToggleQa 直接转给 `handle_qa_hotkey_pressed`), // 所以它既不拉起主窗口,也不走 Core 的 ToggleQa(那条是“切换录音”)。 @@ -4607,7 +4638,6 @@ mod linux_app { self.toggle_qa_panel(); continue; } - log::info!("[ui-host] launch intent from the user: {intent:?}"); launch_intent_window_requested = true; let host = Arc::clone(&host); self.spawn(async move { @@ -5660,6 +5690,15 @@ mod linux_app { // 文本型设置行只在偏好载入 / 外部变更时回灌一次,避免把输入中的 // 内容每帧弹回旧值。 let hydrate_text = std::mem::replace(&mut self.hydrate_text_fields, false); + // 日志级别是宿主本地文件(不在 Core 偏好里),要在借用 vm 之前读。 + let log_level_index = self + .backend() + .and_then(|backend| openless_linux_egui::read_log_level(&backend.config().data_dir)) + .map_or(0, |level| match level { + log::LevelFilter::Debug => 1, + log::LevelFilter::Trace => 2, + _ => 0, + }); let vm = &mut self.frontend_vm; @@ -5840,6 +5879,8 @@ mod linux_app { s.launch_at_login = prefs.launch_at_login; s.streaming_save_clipboard = prefs.streaming_insert_save_clipboard; s.record_audio_for_debug = prefs.record_audio_for_debug; + // 日志级别是宿主本地文件,不在 Core 偏好里;读不到就按 info 显示。 + s.log_level = log_level_index; if hydrate_text { s.history_max_entries = prefs .history_max_entries @@ -10316,8 +10357,9 @@ Internal flags (set by OpenLess itself, not for regular use): let tray_available = tray.is_some(); let native = (|| { let mut config = backend_config()?; - if let Err(error) = openless_linux_egui::init_file_logger(&config.data_dir) { - eprintln!("OpenLess file logger unavailable: {error}"); + match openless_linux_egui::init_file_logger(&config.data_dir) { + Ok(_) => log::info!("[startup] single-instance lock acquired; running as primary"), + Err(error) => eprintln!("OpenLess file logger unavailable: {error}"), } if let Some(error) = tray_error { // 没有托盘 ⇒ 关窗就退出:这是用户行为差异,必须写进启动日志。 @@ -10466,6 +10508,134 @@ Internal flags (set by OpenLess itself, not for regular use): } } + #[test] + fn startup_logging_records_forwarded_intents_without_rotating_again() { + use std::io::Write; + + const CHILD_ENV: &str = "OPENLESS_STARTUP_LOG_TEST_CHILD"; + const TEST: &str = "linux_app::tests::startup_logging_records_forwarded_intents_without_rotating_again"; + if std::env::var_os(CHILD_ENV).as_deref() != Some(std::ffi::OsStr::new("1")) { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST, "--nocapture", "--test-threads=1"]) + .env(CHILD_ENV, "1") + .env_remove("OPENLESS_LOG") + .env_remove("RUST_LOG") + .output() + .expect("launch isolated startup logging regression"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{stdout}\n{stderr}"); + assert!( + stdout.contains("test result: ok. 1 passed; 0 failed"), + "{stdout}" + ); + return; + } + let root = tempfile::tempdir().unwrap(); + for key in [ + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_RUNTIME_DIR", + ] { + std::env::set_var(key, root.path()); + } + let runtime_dir = root.path().join("runtime"); + let data_dir = root.path().join("data"); + std::fs::create_dir_all(&runtime_dir).unwrap(); + std::fs::create_dir_all(data_dir.join("logs")).unwrap(); + let path = openless_linux_egui::log_path(&data_dir); + let old_log = vec![b'o'; 2 * 1024 * 1024 + 1]; + std::fs::write(&path, &old_log).unwrap(); + let args = vec!["openless".to_string()]; + let primary = match acquire_broker(&runtime_dir, &args).unwrap() { + BrokerAcquisition::Primary(broker) => Arc::new(broker), + BrokerAcquisition::Forwarded => panic!("the first launch must be primary"), + }; + openless_linux_egui::init_file_logger(&data_dir).unwrap(); + assert!(std::fs::read_to_string(&path) + .unwrap() + .contains("Linux egui logger ready:")); + + let mut app = fixture_app(false); + let host_actions = Arc::new(openless_linux_egui::LinuxHostActions::default()); + let mut dependencies = openless_core::BackendDependencies::unsupported(); + dependencies.host_actions = host_actions.clone(); + dependencies.credential_store = + Arc::new(openless_core::InMemoryCredentialStore::default()); + dependencies.services.remote_input = Arc::new( + openless_core::RemoteInputService::new( + Arc::new(openless_core::testing::RecordingRemoteInputRuntime::default()), + 8443, + "en", + ) + .unwrap(), + ); + let backend = Arc::new( + openless_core::OpenLessBackend::new( + BackendConfig { + data_dir: data_dir.clone(), + ..Default::default() + }, + dependencies, + ) + .unwrap(), + ); + let runtime = openless_linux_egui::LinuxBackendRuntime { + backend: backend.clone(), + host_actions, + settings_runtime: Arc::new(openless_core::settings::NoopSettingsRuntime), + }; + app.native = Some( + app.tokio + .block_on(LinuxNativeRuntime::start( + runtime, + Some(primary.clone()), + None, + )) + .unwrap(), + ); + app.snapshot = Some(backend.snapshot()); + app.preferences = Some(backend.get_preferences()); + // 只测试 QA 的收起分支,不创建真实弹窗或启动录音。 + app.qa_visible = true; + + let mut active = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + active.write_all(&vec![b'x'; 2 * 1024 * 1024 + 1]).unwrap(); + active.write_all(b"\nactive-file-marker\n").unwrap(); + drop(active); + for args in [ + args, + vec!["openless".to_string(), "--toggle-qa".to_string()], + ] { + assert!(matches!( + acquire_broker(&runtime_dir, &args).unwrap(), + BrokerAcquisition::Forwarded + )); + } + app.poll(&egui::Context::default()); + log::logger().flush(); + let text = std::fs::read_to_string(&path).unwrap(); + for intent in ["ShowMain", "Cli(ToggleQa)"] { + let message = format!("[ui-host] launch intent from the user: {intent}"); + assert_eq!(text.matches(&message).count(), 1, "{message}"); + } + assert!(text.contains("active-file-marker")); + assert!(app.window.should_be_open); + assert!(!app.qa_visible); + assert_eq!( + std::fs::read(data_dir.join("logs/openless.log.1")).unwrap(), + old_log + ); + assert!(!data_dir.join("logs/openless.log.2").exists()); + assert!(!data_dir.join("logs/openless.log.3").exists()); + drop(app); + drop(primary); + } + #[test] fn an_idle_popup_submission_reaches_core_and_displays_failure() { let data_dir = tempfile::tempdir().unwrap(); diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index 5dcd97f23..493a62327 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -3210,6 +3210,19 @@ fn advanced(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec/logs/level`, + /// 重启后生效;不是 Core 偏好,所以不上云、不影响 Tauri。 + LogLevel, } #[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] @@ -1201,6 +1204,8 @@ pub struct SettingsFields { #[serde(default)] pub llm_thinking_enabled: bool, pub record_audio_for_debug: bool, + /// 日志级别:0 = info,1 = debug,2 = trace。宿主本地设置,不进 Core 偏好。 + pub log_level: usize, pub history_max_entries: String, /// 风格直达快捷键(可录制/停用/移除)。 pub style_pack_hotkeys: Vec, @@ -1268,6 +1273,7 @@ impl Default for SettingsFields { capsule_transcript_font_size: 14, llm_thinking_enabled: false, record_audio_for_debug: false, + log_level: 0, history_max_entries: String::new(), style_pack_hotkeys: Vec::new(), polish_context_window: String::new(), diff --git a/openless-all/app/scripts/egui-logging-contract.test.mjs b/openless-all/app/scripts/egui-logging-contract.test.mjs new file mode 100644 index 000000000..1ff1eb13e --- /dev/null +++ b/openless-all/app/scripts/egui-logging-contract.test.mjs @@ -0,0 +1,191 @@ +// Contract test for the Linux egui logging backend. +// +// Logs are the only thing a user can hand us when the Linux host misbehaves, so +// the backend has to satisfy three properties that are easy to lose in a +// refactor: +// +// 1. The level filter is reachable without a rebuild (`OPENLESS_LOG`). +// 2. Periodic liveness chatter never sits at `info`: a 10 s heartbeat is 8,640 +// lines a day and pushed every real message out of the rotated files. +// 3. `error` means failure. A `stage=` progress tracer logged at `error` makes +// every startup look broken. +// +// The Rust sources are read as text: this test runs in the Linux contract job +// and must not need a compiled tree. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const read = (relative) => readFileSync(join(appRoot, relative), 'utf8'); + +const logging = read('linux-egui/src/logging.rs'); +const main = read('linux-egui/src/main.rs'); +const window = read('linux-egui/src/linux_app/window.rs'); +const adapter = read('crates/openless-core/src/cloud_sync_e2ee/adapter.rs'); + +// 1. Level filter from the environment, not a constant baked in at build time. +assert.match( + logging, + /"OPENLESS_LOG"/, + 'logging.rs must read OPENLESS_LOG so a user can turn on debug without a rebuild', +); +assert.match( + logging, + /"RUST_LOG"/, + 'logging.rs must keep honouring RUST_LOG for people who expect the Rust convention', +); +assert.match( + logging, + /log::set_boxed_logger/, + 'logging.rs must install a logger that applies the parsed filter', +); +assert.ok( + !/LevelFilter::Info,\s*\n\s*config\.clone\(\),\s*\n\s*TerminalMode/.test(logging), + 'the terminal sink must not be hard-wired to Info any more', +); + +// 2. Rotation keeps more than a single archive, otherwise one busy day erases +// the incident we are trying to diagnose. +assert.match( + logging, + /const ROTATE_KEEP: usize = [2-9]/, + 'logging.rs must keep at least two log archives', +); + +// 3. The pump heartbeat is throttled: state changes at `info`, the rest at +// `debug`, plus a low-frequency liveness line. +assert.match( + main, + /const PUMP_HEARTBEAT_INFO_INTERVAL[\s\S]{0,80}from_secs\(\d{2,}\)/, + 'the pump heartbeat needs an info-level interval of at least tens of seconds', +); +assert.ok( + !/log::info!\(\s*\n?\s*"\[pump\] heartbeat/.test(main), + 'the 10 s pump heartbeat must not be logged at info unconditionally', +); +assert.match( + main, + /log::debug!\("\{message\}"\)/, + 'the throttled heartbeat must fall back to debug', +); + +// 4. The IPC probe is a gauge, not an event. +assert.ok( + !/log::info!\(\s*\n?\s*"\[ui-client\] ipc round-trip/.test(window), + 'the IPC round-trip probe must stay at debug', +); + +// 5. No progress tracer disguised as a failure. +assert.ok( + !/log::error!\(\s*"\[e2ee-adapter\] stage=/.test(adapter), + 'the e2ee adapter must not log its stage tracers at error level', +); +assert.ok( + !/log::error!\(\s*\n\s*"\[e2ee-adapter\] build start/.test(adapter), + 'the e2ee adapter must not log its build start marker at error level', +); + +// 6. Nothing that looks like a secret may be formatted into a log line. +const logCalls = [...`${main}\n${window}\n${adapter}`.matchAll(/log::(?:error|warn|info|debug|trace)!\(([\s\S]{0,400}?)\);/g)]; +for (const [, body] of logCalls) { + assert.ok( + !/\b(?:api_key|apikey|access_token|refresh_token|client_secret|password|private_key)\s*=/i.test( + body, + ), + `a log line formats what looks like a secret: ${body.trim().slice(0, 120)}`, + ); +} + + +// 7. The level chosen in the UI has to outlive the process, and the export has to +// carry the rotated history - otherwise the file the user sends us is exactly +// the one that no longer contains the incident. +const settingsSave = read('linux-egui/src/linux_app/settings_save.rs'); +assert.match( + logging, + /pub fn write_log_level/, + 'logging.rs must persist the level chosen in the settings UI', +); +assert.match( + logging, + /pub fn read_log_level/, + 'logging.rs must read the persisted level at startup', +); +assert.match( + settingsSave, + /write_log_level\(/, + 'the settings handler must persist the log level', +); +assert.match( + settingsSave, + /export_error_logs\(/, + 'the export action must include the rotated logs, not only the current file', +); +assert.match( + logging, + /pub fn export_error_logs/, + 'logging.rs must provide the multi-file export', +); + +// 8. The gaps that used to make a report unusable: no capture line on success, no +// dictation start, no single-instance outcome. +assert.match( + read('linux-egui/src/audio.rs'), + /"\[audio\] capture started backend=/, + 'capture start must name the backend, device and format that actually won', +); +assert.match( + read('crates/openless-core/src/api.rs'), + /"\[dictation\] start session_id=/, + 'the dictation start boundary must be logged (hotkey pressed but nothing happened)', +); +const brokerStart = main.indexOf(' fn acquire_broker('); +const brokerEnd = main.indexOf(' impl Drop for OpenLessEguiApp', brokerStart); +assert.ok(brokerStart >= 0 && brokerEnd > brokerStart, 'find the single-instance acquisition function'); +assert.doesNotMatch( + main.slice(brokerStart, brokerEnd), + /log::(?:info|warn|error)!/, + 'single-instance acquisition runs before logger initialization; it must not emit lost startup logs', +); +const runStart = main.indexOf(' pub fn run() -> Result<(), String>'); +const runEnd = main.indexOf(' #[cfg(test)]', runStart); +assert.ok(runStart >= 0 && runEnd > runStart, 'find the production startup function'); +const startup = main.slice(runStart, runEnd); +const acquire = startup.indexOf('acquire_broker('); +const forwardedExit = startup.indexOf('BrokerAcquisition::Forwarded => return Ok(())'); +const loggerInit = startup.indexOf('init_file_logger('); +const primaryLog = startup.indexOf('[startup] single-instance lock acquired'); +assert.ok( + acquire >= 0 && forwardedExit > acquire && loggerInit > forwardedExit && primaryLog > loggerInit, + 'only the primary may initialize/rotate logs, and its role must be logged after initialization', +); +assert.match( + startup, + /match openless_linux_egui::init_file_logger\([^)]*\)\s*\{\s*Ok\([^)]*\)\s*=>\s*(?:\{\s*)?log::info!/, + 'the primary startup line must only be emitted after successful logger initialization', +); +const intentLoopStart = main.indexOf(' for intent in launch_intents {'); +const intentLoopEnd = main.indexOf(' // 本地边沿先于插件信号处理', intentLoopStart); +assert.ok(intentLoopStart >= 0 && intentLoopEnd > intentLoopStart, 'find the primary intent-consumption loop'); +const intentLoop = main.slice(intentLoopStart, intentLoopEnd); +const receivedLog = intentLoop.indexOf('[ui-host] launch intent from the user:'); +assert.ok( + receivedLog >= 0 && receivedLog < intentLoop.indexOf('if matches!('), + 'the primary must log forwarded intents before any dispatch branch, including ToggleQa', +); + +// 9. Per-event chatter must not sit at info. +assert.ok( + !/log::info!\("\[transcript\] partial emitted/.test( + read('crates/openless-core/src/dictation_engine.rs'), + ), + 'each streaming partial must not be logged at info', +); +assert.ok( + !/log::info!\("\[hotkey\] local edge/.test(main), + 'per-keystroke hotkey edges must stay at debug', +); + +console.log('egui-logging contract test passed'); From f88bf246e7b6823ecd0422475cca3802ed4a50bb Mon Sep 17 00:00:00 2001 From: TRIP <192432815+appergb@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:37:37 -0700 Subject: [PATCH 07/31] fix(windows): keep WASAPI resources on their owning thread --- .github/workflows/ci.yml | 6 + .../app/scripts/test-recorder-module.py | 15 +- openless-all/app/src-tauri/Cargo.lock | 2 - openless-all/app/src-tauri/Cargo.toml | 3 +- .../app/src-tauri/backend-tests/Cargo.lock | 2 - .../app/src-tauri/backend-tests/Cargo.toml | 2 +- .../app/src-tauri/src/core_adapters.rs | 24 +- .../vendor/cpal/.cargo_vcs_info.json | 6 + .../vendor/cpal/.github/workflows/cpal.yml | 265 +++ .../app/src-tauri/vendor/cpal/.gitignore | 5 + .../app/src-tauri/vendor/cpal/CHANGELOG.md | 196 +++ .../app/src-tauri/vendor/cpal/Cargo.lock | 1452 +++++++++++++++++ .../app/src-tauri/vendor/cpal/Cargo.toml | 184 +++ .../app/src-tauri/vendor/cpal/Cargo.toml.orig | 95 ++ .../app/src-tauri/vendor/cpal/Cross.toml | 7 + .../app/src-tauri/vendor/cpal/Dockerfile | 9 + .../app/src-tauri/vendor/cpal/LICENSE | 201 +++ .../src-tauri/vendor/cpal/OPENLESS_PATCH.md | 119 ++ .../vendor/cpal/OPENLESS_UPSTREAM.json | 8 + .../app/src-tauri/vendor/cpal/README.md | 142 ++ .../app/src-tauri/vendor/cpal/build.rs | 14 + .../src-tauri/vendor/cpal/examples/android.rs | 82 + .../src-tauri/vendor/cpal/examples/beep.rs | 138 ++ .../vendor/cpal/examples/enumerate.rs | 74 + .../vendor/cpal/examples/feedback.rs | 174 ++ .../vendor/cpal/examples/record_wav.rs | 177 ++ .../vendor/cpal/examples/synth_tones.rs | 191 +++ .../app/src-tauri/vendor/cpal/src/error.rs | 292 ++++ .../vendor/cpal/src/host/alsa/enumerate.rs | 70 + .../vendor/cpal/src/host/alsa/mod.rs | 1163 +++++++++++++ .../vendor/cpal/src/host/asio/device.rs | 232 +++ .../vendor/cpal/src/host/asio/mod.rs | 138 ++ .../vendor/cpal/src/host/asio/stream.rs | 718 ++++++++ .../cpal/src/host/coreaudio/ios/enumerate.rs | 43 + .../vendor/cpal/src/host/coreaudio/ios/mod.rs | 434 +++++ .../src/host/coreaudio/macos/enumerate.rs | 152 ++ .../cpal/src/host/coreaudio/macos/mod.rs | 941 +++++++++++ .../host/coreaudio/macos/property_listener.rs | 85 + .../vendor/cpal/src/host/coreaudio/mod.rs | 121 ++ .../vendor/cpal/src/host/emscripten/mod.rs | 422 +++++ .../vendor/cpal/src/host/jack/device.rs | 268 +++ .../vendor/cpal/src/host/jack/mod.rs | 180 ++ .../vendor/cpal/src/host/jack/stream.rs | 463 ++++++ .../app/src-tauri/vendor/cpal/src/host/mod.rs | 30 + .../vendor/cpal/src/host/null/mod.rs | 160 ++ .../cpal/src/host/oboe/android_media.rs | 62 + .../vendor/cpal/src/host/oboe/convert.rs | 82 + .../cpal/src/host/oboe/input_callback.rs | 90 + .../vendor/cpal/src/host/oboe/mod.rs | 499 ++++++ .../cpal/src/host/oboe/output_callback.rs | 90 + .../vendor/cpal/src/host/wasapi/com.rs | 58 + .../vendor/cpal/src/host/wasapi/device.rs | 1016 ++++++++++++ .../vendor/cpal/src/host/wasapi/mod.rs | 104 ++ .../vendor/cpal/src/host/wasapi/stream.rs | 749 +++++++++ .../vendor/cpal/src/host/webaudio/mod.rs | 528 ++++++ .../app/src-tauri/vendor/cpal/src/lib.rs | 871 ++++++++++ .../src-tauri/vendor/cpal/src/platform/mod.rs | 742 +++++++++ .../vendor/cpal/src/samples_formats.rs | 167 ++ .../app/src-tauri/vendor/cpal/src/traits.rs | 221 +++ .../vendor/cpal/tests/windows_lifecycle.rs | 214 +++ 60 files changed, 14980 insertions(+), 18 deletions(-) create mode 100644 openless-all/app/src-tauri/vendor/cpal/.cargo_vcs_info.json create mode 100644 openless-all/app/src-tauri/vendor/cpal/.github/workflows/cpal.yml create mode 100644 openless-all/app/src-tauri/vendor/cpal/.gitignore create mode 100644 openless-all/app/src-tauri/vendor/cpal/CHANGELOG.md create mode 100644 openless-all/app/src-tauri/vendor/cpal/Cargo.lock create mode 100644 openless-all/app/src-tauri/vendor/cpal/Cargo.toml create mode 100644 openless-all/app/src-tauri/vendor/cpal/Cargo.toml.orig create mode 100644 openless-all/app/src-tauri/vendor/cpal/Cross.toml create mode 100644 openless-all/app/src-tauri/vendor/cpal/Dockerfile create mode 100644 openless-all/app/src-tauri/vendor/cpal/LICENSE create mode 100644 openless-all/app/src-tauri/vendor/cpal/OPENLESS_PATCH.md create mode 100644 openless-all/app/src-tauri/vendor/cpal/OPENLESS_UPSTREAM.json create mode 100644 openless-all/app/src-tauri/vendor/cpal/README.md create mode 100644 openless-all/app/src-tauri/vendor/cpal/build.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/examples/android.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/examples/beep.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/examples/enumerate.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/examples/feedback.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/examples/record_wav.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/examples/synth_tones.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/error.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/alsa/enumerate.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/alsa/mod.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/asio/device.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/asio/mod.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/asio/stream.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/ios/enumerate.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/ios/mod.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/macos/enumerate.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/macos/mod.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/macos/property_listener.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/mod.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/emscripten/mod.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/jack/device.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/jack/mod.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/jack/stream.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/mod.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/null/mod.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/oboe/android_media.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/oboe/convert.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/oboe/input_callback.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/oboe/mod.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/oboe/output_callback.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/wasapi/com.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/wasapi/device.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/wasapi/mod.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/wasapi/stream.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/host/webaudio/mod.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/lib.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/platform/mod.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/samples_formats.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/src/traits.rs create mode 100644 openless-all/app/src-tauri/vendor/cpal/tests/windows_lifecycle.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d3f9075c..f330acb4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -749,6 +749,12 @@ jobs: if: runner.os == 'Windows' run: python scripts/test-recorder-module.py + - name: Run Windows audio COM lifecycle tests + # These tests use independent processes for COM/enumerator lifetime checks and do not + # require a microphone. Device-dependent capture/playback tests remain explicitly ignored. + if: runner.os == 'Windows' + run: cargo test --locked --manifest-path src-tauri/vendor/cpal/Cargo.toml --lib --test windows_lifecycle + - name: Check Tauri backend with Rust 1.99 MSRV if: runner.os == 'Windows' && needs.changes.outputs.msrv_needed == 'true' run: cargo +1.99.0 check --locked --manifest-path src-tauri/Cargo.toml diff --git a/openless-all/app/scripts/test-recorder-module.py b/openless-all/app/scripts/test-recorder-module.py index c7e25910f..0e061e778 100644 --- a/openless-all/app/scripts/test-recorder-module.py +++ b/openless-all/app/scripts/test-recorder-module.py @@ -3,6 +3,7 @@ The bridge trait below matches the only core interface used by recorder.rs. This checks the production recorder module; it does not replace a desktop cargo check. +Pass --check to compile the same tests for a cross target without linking or running them. """ import json from pathlib import Path @@ -11,6 +12,10 @@ import tempfile recorder = Path(__file__).resolve().parents[1] / "src-tauri/src/recorder.rs" +cargo_args = sys.argv[1:] +check_only = "--check" in cargo_args +if check_only: + cargo_args.remove("--check") with tempfile.TemporaryDirectory(prefix="openless-recorder-tests-") as directory: root = Path(directory) (root / "src").mkdir() @@ -19,13 +24,13 @@ version = "0.1.0" edition = "2021" [dependencies] -cpal = "=0.15.3" parking_lot = "0.12" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "1" log = "0.4" -""" + "micyou-dsp = { path = " + json.dumps((recorder.parents[2] / "crates/micyou-dsp").as_posix()) + " }\n") +""" + "cpal = { version = \"=0.15.3\", path = " + json.dumps((recorder.parents[1] / "vendor/cpal").as_posix()) + " }\n" + + "micyou-dsp = { path = " + json.dumps((recorder.parents[2] / "crates/micyou-dsp").as_posix()) + " }\n") (root / "src/lib.rs").write_text( "extern crate self as openless_core;\n" "#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BackendErrorCode { InvalidArgument }\n" @@ -38,4 +43,8 @@ "pub trait AudioConsumer: Send + Sync { fn consume_pcm_chunk(&self, pcm: &[u8]); }\n" + "#[path = " + json.dumps(str(recorder), ensure_ascii=False) + "]\npub mod recorder;\n" ) - subprocess.run(["cargo", "test", "--manifest-path", str(root / "Cargo.toml"), *sys.argv[1:]], check=True) + subprocess.run( + ["cargo", "check" if check_only else "test", "--manifest-path", str(root / "Cargo.toml"), + *(["--tests"] if check_only else []), *cargo_args], + check=True, + ) diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index bd2f0326a..13b8308d0 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -1154,8 +1154,6 @@ dependencies = [ [[package]] name = "cpal" version = "0.15.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" dependencies = [ "alsa", "core-foundation-sys", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 116d575c5..cd39d9243 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -77,7 +77,8 @@ bytes = "1" url = "2" raw-window-handle = "0.6" # Audio capture — shared across desktop and mobile. -cpal = "0.15" +# Keep Windows COM objects on their owning audio thread; see vendor/cpal/OPENLESS_PATCH.md. +cpal = { version = "=0.15.3", path = "vendor/cpal" } [target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] tauri-plugin-fs = "2.7.1" diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.lock b/openless-all/app/src-tauri/backend-tests/Cargo.lock index 13773b023..d4cf3382c 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.lock +++ b/openless-all/app/src-tauri/backend-tests/Cargo.lock @@ -452,8 +452,6 @@ dependencies = [ [[package]] name = "cpal" version = "0.15.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" dependencies = [ "alsa", "core-foundation-sys", diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.toml b/openless-all/app/src-tauri/backend-tests/Cargo.toml index 1b3248046..5544befb7 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.toml +++ b/openless-all/app/src-tauri/backend-tests/Cargo.toml @@ -39,5 +39,5 @@ windows = { version = "0.58", features = ["Win32_Foundation", "Win32_System_Thre [target.'cfg(target_os = "linux")'.dev-dependencies] # Exercise the production recorder worker without the retired Linux Tauri host. -cpal = "0.15.3" +cpal = { version = "=0.15.3", path = "../vendor/cpal" } parking_lot = "0.12" diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index b395a9efd..d3b383201 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -1605,13 +1605,6 @@ fn map_permission_state( } } -fn current_permission_snapshot() -> openless_core::PermissionSnapshot { - openless_core::PermissionSnapshot { - microphone: map_permission_state(crate::permissions::check_microphone()), - accessibility: map_permission_state(crate::permissions::check_accessibility()), - } -} - impl openless_core::PlatformApi for TauriPlatformApi { fn capabilities( &self, @@ -1649,13 +1642,26 @@ impl openless_core::PlatformApi for TauriPlatformApi { fn microphone_permission( &self, ) -> BoxFuture<'static, Result> { - Box::pin(async { Ok(current_permission_snapshot()) }) + Box::pin(async { + Ok(openless_core::PermissionSnapshot { + microphone: map_permission_state(crate::permissions::check_microphone()), + accessibility: map_permission_state(crate::permissions::check_accessibility()), + }) + }) } fn accessibility_permission( &self, ) -> BoxFuture<'static, Result> { - Box::pin(async { Ok(current_permission_snapshot()) }) + // Windows microphone checks open a temporary native input stream. The UI queries + // both permissions concurrently, so querying the microphone here would open a + // second stream solely to discard its result in check_accessibility_permission. + Box::pin(async { + Ok(openless_core::PermissionSnapshot { + microphone: openless_core::PermissionState::Unknown, + accessibility: map_permission_state(crate::permissions::check_accessibility()), + }) + }) } fn request_microphone_permission(&self) -> BoxFuture<'static, Result<(), BackendError>> { diff --git a/openless-all/app/src-tauri/vendor/cpal/.cargo_vcs_info.json b/openless-all/app/src-tauri/vendor/cpal/.cargo_vcs_info.json new file mode 100644 index 000000000..18d730c11 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "ac6cbb2ba55e61665a35ab88ae136a83380d1354" + }, + "path_in_vcs": "" +} \ No newline at end of file diff --git a/openless-all/app/src-tauri/vendor/cpal/.github/workflows/cpal.yml b/openless-all/app/src-tauri/vendor/cpal/.github/workflows/cpal.yml new file mode 100644 index 000000000..c2061ebac --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/.github/workflows/cpal.yml @@ -0,0 +1,265 @@ +name: cpal + +on: [push, pull_request] + +jobs: + + clippy-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Update apt + run: sudo apt update + - name: Install alsa + run: sudo apt-get install libasound2-dev + - name: Install libjack + run: sudo apt-get install libjack-jackd2-dev libjack-jackd2-0 + - name: Install stable + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + target: armv7-linux-androideabi + - name: Run clippy + run: cargo clippy --all --all-features + - name: Run clippy for Android target + run: cargo clippy --all --features asio --features oboe/fetch-prebuilt --target armv7-linux-androideabi + + rustfmt-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install stable + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: Run rustfmt + run: cargo fmt --all -- --check + + cargo-publish: + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install rust + uses: dtolnay/rust-toolchain@stable + - name: Update apt + run: sudo apt update + - name: Install alsa + run: sudo apt-get install libasound2-dev + - name: Verify publish crate + uses: katyo/publish-crates@v2 + with: + dry-run: true + ignore-unpublished-changes: true + - name: Publish crate + uses: katyo/publish-crates@v2 + with: + ignore-unpublished-changes: true + registry-token: ${{ secrets.CRATESIO_TOKEN }} + + ubuntu-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Update apt + run: sudo apt update + - name: Install alsa + run: sudo apt-get install libasound2-dev + - name: Install libjack + run: sudo apt-get install libjack-jackd2-dev libjack-jackd2-0 + - name: Install stable + uses: dtolnay/rust-toolchain@stable + - name: Run without features + run: cargo test --all --no-default-features --verbose + - name: Run all features + run: cargo test --all --all-features --verbose + + linux-check-and-test-armv7: + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Install stable toolchain + uses: dtolnay/rust-toolchain@stable + with: + target: armv7-unknown-linux-gnueabihf + + - name: Build image + run: docker build -t cross/cpal_armv7:v1 ./ + + - name: Install cross + run: cargo install cross + + - name: Check without features for armv7 + run: cross check --target armv7-unknown-linux-gnueabihf --workspace --no-default-features --verbose + + - name: Test without features for armv7 + run: cross test --target armv7-unknown-linux-gnueabihf --workspace --no-default-features --verbose + + - name: Check all features for armv7 + run: cross check --target armv7-unknown-linux-gnueabihf --workspace --all-features --verbose + + - name: Test all features for armv7 + run: cross test --target armv7-unknown-linux-gnueabihf --workspace --all-features --verbose + + asmjs-wasm32-test: + strategy: + matrix: + target: [wasm32-unknown-emscripten] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Emscripten toolchain + uses: mymindstorm/setup-emsdk@v14 + - name: Install stable + uses: dtolnay/rust-toolchain@stable + with: + target: ${{ matrix.target }} + - name: Build beep example + run: cargo build --example beep --release --target ${{ matrix.target }} + + wasm32-bindgen-test: + + strategy: + matrix: + target: [wasm32-unknown-unknown] + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Install stable + uses: dtolnay/rust-toolchain@stable + with: + target: ${{ matrix.target }} + - name: Build beep example + run: cargo build --example beep --target ${{ matrix.target }} --features=wasm-bindgen + + wasm32-wasi-test: + + strategy: + matrix: + target: [wasm32-wasi] + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Install stable + uses: dtolnay/rust-toolchain@stable + with: + target: ${{ matrix.target }} + - name: Build beep example + run: cargo build --example beep --target ${{ matrix.target }} + + windows-test: + strategy: + matrix: + version: [x86_64, i686] + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Install ASIO SDK + env: + LINK: https://www.steinberg.net/asiosdk + run: | + curl -L -o asio.zip $env:LINK + 7z x -oasio asio.zip + move asio\*\* asio\ + - name: Install ASIO4ALL + run: choco install asio4all + - name: Install llvm and clang + run: choco install llvm + - name: Install stable + uses: dtolnay/rust-toolchain@stable + with: + target: ${{ matrix.version }}-pc-windows-msvc + - name: Run without features + run: cargo test --all --no-default-features --verbose + - name: Run all features + run: | + $Env:CPAL_ASIO_DIR = "$Env:GITHUB_WORKSPACE\asio" + cargo test --all --all-features --verbose + + macos-test: + runs-on: macOS-latest + steps: + - uses: actions/checkout@v4 + - name: Install llvm and clang + run: brew install llvm + - name: Install stable + uses: dtolnay/rust-toolchain@stable + - name: Build beep example + run: cargo build --example beep + - name: Run without features + run: cargo test --all --no-default-features --verbose + - name: Run all features + run: cargo test --all --all-features --verbose + + android-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install stable (Android target) + uses: dtolnay/rust-toolchain@stable + with: + target: armv7-linux-androideabi + - name: Check android + run: cargo check --example android --target armv7-linux-androideabi --features oboe/fetch-prebuilt --verbose + - name: Check beep + run: cargo check --example beep --target armv7-linux-androideabi --features oboe/fetch-prebuilt --verbose + - name: Check enumerate + run: cargo check --example enumerate --target armv7-linux-androideabi --features oboe/fetch-prebuilt --verbose + - name: Check feedback + run: cargo check --example feedback --target armv7-linux-androideabi --features oboe/fetch-prebuilt --verbose + - name: Check record_wav + run: cargo check --example record_wav --target armv7-linux-androideabi --features oboe/fetch-prebuilt --verbose + + android-apk-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install stable (Android targets) + uses: dtolnay/rust-toolchain@stable + with: + targets: armv7-linux-androideabi,aarch64-linux-android,i686-linux-android,x86_64-linux-android + - name: Set Up Android tools + run: | + ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_SDK_ROOT --install "platforms;android-30" + - name: Install Cargo APK + run: cargo install cargo-apk + - name: Build APK + run: cargo apk build --example android + + ios-build: + runs-on: macOS-latest + steps: + - uses: actions/checkout@v4 + - name: Install llvm and clang + run: brew install llvm + - name: Install stable (iOS targets) + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios,x86_64-apple-ios + - name: Install cargo lipo + run: cargo install cargo-lipo + - name: Build iphonesimulator feedback example + run: cd examples/ios-feedback && xcodebuild -scheme cpal-ios-example -configuration Debug -derivedDataPath build -sdk iphonesimulator + + wasm-beep-build: + # this only confirms that the Rust source builds + # and checks to prevent regressions like #721. + # + # It does not test the javascript/web integration + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install stable (wasm32 target) + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - name: Cargo Build + working-directory: ./examples/wasm-beep + run: cargo build --target wasm32-unknown-unknown + diff --git a/openless-all/app/src-tauri/vendor/cpal/.gitignore b/openless-all/app/src-tauri/vendor/cpal/.gitignore new file mode 100644 index 000000000..9b81e989e --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/.gitignore @@ -0,0 +1,5 @@ +/target +.cargo/ +.DS_Store +recorded.wav +rls*.log diff --git a/openless-all/app/src-tauri/vendor/cpal/CHANGELOG.md b/openless-all/app/src-tauri/vendor/cpal/CHANGELOG.md new file mode 100644 index 000000000..d82ce0460 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/CHANGELOG.md @@ -0,0 +1,196 @@ +# Unreleased + +# Version 0.15.3 (2024-03-04) + +- Add `try_with_sample_rate`, a non-panicking variant of `with_sample_rate`. +- struct `platform::Stream` is now #[must_use]. +- enum `SupportedBufferSize` and struct `SupportedStreamConfigRange` are now `Copy`. +- `platform::Device` is now `Clone`. +- Remove `parking_lot` dependency in favor of the std library. +- Fix crash on web/wasm when `atomics` flag is enabled. +- Improve Examples: Migrate wasm example to `trunk`, Improve syth-thones example. +- Improve CI: Update actions, Use Android 30 API level in CI, Remove `asmjs-unknown-emscripten` target. +- Update `windows` dependency to v0.54 +- Update `jni` dependency to 0.21 +- Update `alsa` dependency to 0.9 +- Update `oboe` dependency to 0.6 +- Update `ndk` dependency to 0.8 and disable `default-features`. +- Update `wasm-bindgen` to 0.2.89 + +# Version 0.15.2 (2023-03-30) + +- webaudio: support multichannel output streams +- Update `windows` dependency +- wasapi: fix some thread panics + +# Version 0.15.1 (2023-03-14) + +- Add feature `oboe-shared-stdcxx` to enable `shared-stdcxx` on `oboe` for Android support +- Remove `thiserror` dependency +- Swith `mach` dependency to `mach2` + +# Version 0.15.0 (2023-01-29) + +- Update `windows-rs`, `jack`, `coreaudio-sys`, `oboe`, `alsa` dependencies +- Switch to the `dasp_sample` crate for the sample trait +- Switch to `web-sys` on the emscripten target +- Adopt edition 2021 +- Add disconnection detection on Mac OS + +# Version 0.14.1 (2022-10-23) + +- Support the 0.6.1 release of `alsa-rs` +- Fix `asio` feature broken in 0.14.0 +- `NetBSD` support +- CI improvements + +# Version 0.14.0 (2022-08-22) + +- Switch to `windows-rs` crate +- Turn `ndk-glue` into a dev-dependency and use `ndk-context` instead +- Update dependencies (ndk, ndk-glue, parking_lot, once_cell, jack) + +# Version 0.13.5 (2022-01-28) + +- Faster sample format conversion +- Update dependencies (ndk, oboe, ndk-glue, jack, alsa, nix) + +# Version 0.13.4 (2021-08-08) + +- wasapi: Allow both threading models and switch the default to STA +- Update dependencies (core-foundation-sys, jni, rust-jack) +- Alsa: improve stream setup parameters + +# Version 0.13.3 (2021-03-29) + +- Give each thread a unique name +- Fix distortion regression on some alsa configs + +# Version 0.13.2 (2021-03-16) + +- Update dependencies (ndk, nix, oboe, jni, etc) + +# Version 0.13.1 (2020-11-08) + +- Don't panic when device is plugged out on Windows +- Update `parking_lot` dependency + +# Version 0.13.0 (2020-10-28) + +- Add Android support via `oboe-rs`. +- Add Android APK build an CI job. + +# Version 0.12.1 (2020-07-23) + +- Bugfix release to get the asio feature working again. + +# Version 0.12.0 (2020-07-09) + +- Large refactor removing the blocking EventLoop API. +- Rename many `Format` types to `StreamConfig`: + - `Format` type's `data_type` field renamed to `sample_format`. + - `Shape` -> `StreamConfig` - The configuration input required to build a stream. + - `Format` -> `SupportedStreamConfig` - Describes a single supported stream configuration. + - `SupportedFormat` -> `SupportedStreamConfigRange` - Describes a range of supported configurations. + - `Device::default_input/output_format` -> `Device::default_input/output_config`. + - `Device::supported_input/output_formats` -> `Device::supported_input/output_configs`. + - `Device::SupportedInput/OutputFormats` -> `Device::SupportedInput/OutputConfigs`. + - `SupportedFormatsError` -> `SupportedStreamConfigsError` + - `DefaultFormatError` -> `DefaultStreamConfigError` + - `BuildStreamError::FormatNotSupported` -> `BuildStreamError::StreamConfigNotSupported` +- Address deprecated use of `mem::uninitialized` in WASAPI. +- Removed `UnknownTypeBuffer` in favour of specifying sample type. +- Added `build_input/output_stream_raw` methods allowing for dynamically + handling sample format type. +- Added support for DragonFly platform. +- Add `InputCallbackInfo` and `OutputCallbackInfo` types and update expected + user data callback function signature to provide these. + +# Version 0.11.0 (2019-12-11) + +- Fix some underruns that could occur in ALSA. +- Add name to `HostId`. +- Use `snd_pcm_hw_params_set_buffer_time_near` rather than `set_buffer_time_max` + in ALSA backend. +- Remove many uses of `std::mem::uninitialized`. +- Fix WASAPI capture logic. +- Panic on stream ID overflow rather than returning an error. +- Use `ringbuffer` crate in feedback example. +- Move errors into a separate module. +- Switch from `failure` to `thiserror` for error handling. +- Add `winbase` winapi feature to solve windows compile error issues. +- Lots of CI improvements. + +# Version 0.10.0 (2019-07-05) + +- core-foundation-sys and coreaudio-rs version bumps. +- Add an ASIO host, available under Windows. +- Introduce a new Host API, adding support for alternative audio APIs. +- Remove sleep loop on macOS in favour of using a `Condvar`. +- Allow users to handle stream callback errors with a new `StreamEvent` type. +- Overhaul error handling throughout the crate. +- Remove unnecessary Mutex from ALSA and WASAPI backends in favour of channels. +- Remove `panic!` from OutputBuffer Deref impl as it is no longer necessary. + +# Version 0.9.0 (2019-06-06) + +- Better buffer handling +- Fix logic error in frame/sample size +- Added error handling for unknown ALSA device errors +- Fix resuming a paused stream on Windows (wasapi). +- Implement `default_output_format` for emscripten backend. + +# Version 0.8.1 (2018-03-18) + +- Fix the handling of non-default sample rates for coreaudio input streams. + +# Version 0.8.0 (2018-02-15) + +- Add `record_wav.rs` example. Records 3 seconds to + `$CARGO_MANIFEST_DIR/recorded.wav` using default input device. +- Update `enumerate.rs` example to display default input/output devices and + formats. +- Add input stream support to coreaudio, alsa and windows backends. +- Introduce `StreamData` type for handling either input or output streams in + `EventLoop::run` callback. +- Add `Device::supported_{input/output}_formats` methods. +- Add `Device::default_{input/output}_format` methods. +- Add `default_{input/output}_device` functions. +- Replace usage of `Voice` with `Stream` throughout the crate. +- Remove `Endpoint` in favour of `Device` for supporting both input and output + streams. + +# Version 0.7.0 (2018-02-04) + +- Rename `ChannelsCount` to `ChannelCount`. +- Rename `SamplesRate` to `SampleRate`. +- Rename the `min_samples_rate` field of `SupportedFormat` to `min_sample_rate` +- Rename the `with_max_samples_rate()` method of`SupportedFormat` to `with_max_sample_rate()` +- Rename the `samples_rate` field of `Format` to `sample_rate` +- Changed the type of the `channels` field of the `SupportedFormat` struct from `Vec` to `ChannelCount` (an alias to `u16`) +- Remove unused ChannelPosition API. +- Implement `Endpoint` and `Format` Enumeration for macOS. +- Implement format handling for macos `build_voice` method. + +# Version 0.6.0 (2017-12-11) + +- Changed the emscripten backend to consume less CPU. +- Added improvements to the crate documentation. +- Implement `pause` and `play` for ALSA backend. +- Reduced the number of allocations in the CoreAudio backend. +- Fixes for macOS build (#186, #189). + +# Version 0.5.1 (2017-10-21) + +- Added `Sample::to_i16()`, `Sample::to_u16()` and `Sample::from`. + +# Version 0.5.0 (2017-10-21) + +- Removed the dependency on the `futures` library. +- Removed the `Voice` and `SamplesStream` types. +- Added `EventLoop::build_voice`, `EventLoop::destroy_voice`, `EventLoop::play`, + and `EventLoop::pause` that can be used to create, destroy, play and pause voices. +- Added a `VoiceId` struct that is now used to identify a voice owned by an `EventLoop`. +- Changed `EventLoop::run()` to take a callback that is called whenever a voice requires sound data. +- Changed `supported_formats()` to produce a list of `SupportedFormat` instead of `Format`. A + `SupportedFormat` must then be turned into a `Format` in order to build a voice. diff --git a/openless-all/app/src-tauri/vendor/cpal/Cargo.lock b/openless-all/app/src-tauri/vendor/cpal/Cargo.lock new file mode 100644 index 000000000..b2fd19151 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/Cargo.lock @@ -0,0 +1,1452 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" +dependencies = [ + "memchr", +] + +[[package]] +name = "alsa" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37fe60779335388a88c01ac6c3be40304d1e349de3ada3b15f7808bb90fa9dce" +dependencies = [ + "alsa-sys", + "bitflags 2.4.2", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + +[[package]] +name = "anyhow" +version = "1.0.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" + +[[package]] +name = "asio-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0cd2f17aa86bcd7eb6b6a423943352843695eb6adee9bd24a09a9e7568b9a" +dependencies = [ + "bindgen 0.56.0", + "cc", + "num-derive 0.3.3", + "num-traits", + "once_cell", + "walkdir", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bindgen" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da379dbebc0b76ef63ca68d8fc6e71c0f13e59432e0987e508c1820e6ab5239" +dependencies = [ + "bitflags 1.3.2", + "cexpr 0.4.0", + "clang-sys", + "clap 2.34.0", + "env_logger", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 0.1.1", + "which", +] + +[[package]] +name = "bindgen" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a022e58a142a46fea340d68012b9201c094e93ec3d033a944a24f8fd4a4f09a" +dependencies = [ + "bitflags 1.3.2", + "cexpr 0.6.0", + "clang-sys", + "lazy_static", + "lazycell", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.1.0", + "syn 1.0.107", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" + +[[package]] +name = "bumpalo" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" + +[[package]] +name = "bytes" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfb24e866b15a1af2a1b663f10c6b6b8f397a84aadb828f12e5b289ec23a3a3c" + +[[package]] +name = "cc" +version = "1.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ba8f7aaa012f30d5b2861462f6708eccd49c3c39863fe083a308035f63d723" +dependencies = [ + "jobserver", + "libc", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4aedb84272dbe89af497cf81375129abda4fc0a9e7c5d317498c15cc30c0d27" +dependencies = [ + "nom 5.1.3", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clang-sys" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa2e27ae6ab525c3d369ded447057bca5438d86dc3a68f6faafb8269ba82ebf3" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim 0.8.0", + "textwrap", + "unicode-width", + "vec_map", +] + +[[package]] +name = "clap" +version = "4.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f13b9c79b5d1dd500d20ef541215a6423c75829ef43117e1b4d17fd8af0b5d76" +dependencies = [ + "bitflags 1.3.2", + "clap_derive", + "clap_lex", + "is-terminal", + "once_cell", + "strsim 0.10.0", + "termcolor", +] + +[[package]] +name = "clap_derive" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "684a277d672e91966334af371f1a7b5833f9aa00b07c84e92fbce95e00208ce8" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.107", +] + +[[package]] +name = "clap_lex" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "783fe232adfca04f90f56201b26d79682d4cd2625e0bc7290b95123afe558ade" +dependencies = [ + "os_str_bytes", +] + +[[package]] +name = "combine" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation-sys" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ca8a5221364ef15ce201e8ed2f609fc312682a8f4e0e3d4aa5879764e0fa3b" + +[[package]] +name = "core-foundation-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" + +[[package]] +name = "coreaudio-rs" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb17e2d1795b1996419648915df94bc7103c28f7b48062d7acf4652fc371b2ff" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys 0.6.2", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a9444b94b8024feecc29e01a9706c69c1e26bfee480221c90764200cfd778fb" +dependencies = [ + "bindgen 0.61.0", +] + +[[package]] +name = "cpal" +version = "0.15.3" +dependencies = [ + "alsa", + "anyhow", + "asio-sys", + "clap 4.1.4", + "core-foundation-sys 0.8.3", + "coreaudio-rs", + "dasp_sample", + "hound", + "jack", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "ndk-glue", + "num-traits", + "oboe", + "ringbuf", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb766fa798726286dbbb842f174001dab8abc7b627a1dd86e0b7222a95d929f" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cty" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35" + +[[package]] +name = "darling" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.107", +] + +[[package]] +name = "darling_macro" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.107", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "env_logger" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "errno" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" +dependencies = [ + "errno-dragonfly", + "libc", + "winapi", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" +dependencies = [ + "libc", +] + +[[package]] +name = "hound" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d13cdbd5dbb29f9c88095bbdc2590c9cba0d0a1269b983fef6b2cdd7e9f4db1" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399" +dependencies = [ + "autocfg", + "hashbrown", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7d6c6f8c91b4b9ed43484ad1a938e393caf35960fce7f82a040497207bd8e9e" +dependencies = [ + "libc", + "windows-sys 0.42.0", +] + +[[package]] +name = "is-terminal" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dfb6c8100ccc63462345b67d1bbc3679177c75ee4bf59bf29c8b1d110b8189" +dependencies = [ + "hermit-abi 0.2.6", + "io-lifetimes", + "rustix", + "windows-sys 0.42.0", +] + +[[package]] +name = "jack" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa45e2d29c5ec66b9bd760af188a0d31fdea002254d419c63104b9125072140e" +dependencies = [ + "bitflags 1.3.2", + "jack-sys", + "lazy_static", + "libc", + "log", +] + +[[package]] +name = "jack-sys" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6013b7619b95a22b576dfb43296faa4ecbe40abbdb97dfd22ead520775fc86ab" +dependencies = [ + "bitflags 1.3.2", + "lazy_static", + "libc", + "libloading", + "log", + "pkg-config", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "068b1ee6743e4d11fb9c6a1e6064b3693a1b600e7f5f5988047d98b3dc9fb90b" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49409df3e3bf0856b916e2ceaca09ee28e6871cf7d9ce97a692cacfdb2a25a47" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "linux-raw-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f051f77a7c8e6957c0696eac88f26b0117e54f52d3fc682ab19397a8812846a4" + +[[package]] +name = "lock_api" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "mach2" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "ndk" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451422b7e4718271c8b5b3aadf5adedba43dc76312454b387e98fae0fc951aa0" +dependencies = [ + "bitflags 1.3.2", + "jni-sys", + "ndk-sys 0.4.1+23.1.7779620", + "num_enum 0.5.9", + "raw-window-handle", + "thiserror", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.4.2", + "jni-sys", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum 0.7.2", + "thiserror", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-glue" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0434fabdd2c15e0aab768ca31d5b7b333717f03cf02037d5a0a3ff3c278ed67f" +dependencies = [ + "libc", + "log", + "ndk 0.7.0", + "ndk-context", + "ndk-macro", + "ndk-sys 0.4.1+23.1.7779620", + "once_cell", + "parking_lot", +] + +[[package]] +name = "ndk-macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0df7ac00c4672f9d5aece54ee3347520b7e20f158656c7db2e6de01902eb7a6c" +dependencies = [ + "darling", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 1.0.107", +] + +[[package]] +name = "ndk-sys" +version = "0.4.1+23.1.7779620" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cf2aae958bd232cac5069850591667ad422d263686d75b52a065f9badeee5a3" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "nom" +version = "5.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08959a387a676302eebf4ddbcbc611da04285579f76f88ee0506c63b1a61dd4b" +dependencies = [ + "memchr", + "version_check", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom8" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae01545c9c7fc4486ab7debaf2aad7003ac19431791868fb2e8066df97fad2f8" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.107", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d829733185c1ca374f17e52b762f24f535ec625d2cc1f070e34c8a9068f341b" +dependencies = [ + "num_enum_derive 0.5.9", +] + +[[package]] +name = "num_enum" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02339744ee7253741199f897151b38e72257d13802d4ee837285cc2990a90845" +dependencies = [ + "num_enum_derive 0.7.2", +] + +[[package]] +name = "num_enum_derive" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2be1598bf1c313dcdd12092e3f1920f463462525a21b7b4e11b4168353d0123e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 1.0.107", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "681030a937600a36906c185595136d26abfebb4aa9c65701cefcaf8578bb982b" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk 0.8.0", + "ndk-context", + "num-derive 0.4.2", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66" + +[[package]] +name = "os_str_bytes" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7820b9daea5457c9f21c69448905d723fbd21136ccf521748f23fd49e723ee" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ef8814b5c993410bb3adfad7a5ed269563e4a2f90c41f5d85be7fb47133bf" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-sys 0.42.0", +] + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "pkg-config" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" + +[[package]] +name = "proc-macro-crate" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66618389e4ec1c7afe67d51a9bf34ff9236480f8d51e7489b7d5ab0303c13f34" +dependencies = [ + "once_cell", + "toml_edit", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.107", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "raw-window-handle" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7e3d950b66e19e0c372f3fa3fbbcf85b1746b571f74e0c2af6042a5c93420a" +dependencies = [ + "cty", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "regex" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" + +[[package]] +name = "ringbuf" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93ca10b9c9e53ac855a2d6953bce34cef6edbac32c4b13047a4d59d67299420a" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fdebc4b395b7fbb9ab11e462e20ed9051e7b16e42d24042c776eca0ac81b03" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys", + "windows-sys 0.42.0", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "shlex" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fdf1b9db47230893d76faad238fd6097fd6d6a9245cd7a4d90dbd639536bbd2" + +[[package]] +name = "shlex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3" + +[[package]] +name = "smallvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" + +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "syn" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f4064b5b16e03ae50984a5a8ed5d4f8803e6bc1fd170a3cda91a1be4b18e3f5" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.107", +] + +[[package]] +name = "toml_datetime" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4553f467ac8e3d374bc9a177a26801e5d0f9b211aa1673fb137a403afd1c9cf5" + +[[package]] +name = "toml_edit" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c59d8dd7d0dcbc6428bf7aa2f0e823e26e43b3c9aca15bbc9475d23e5fa12b" +dependencies = [ + "indexmap", + "nom8", + "toml_datetime", +] + +[[package]] +name = "unicode-ident" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc" + +[[package]] +name = "unicode-width" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "walkdir" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +dependencies = [ + "same-file", + "winapi", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.52", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23639446165ca5a5de86ae1d8896b737ae80319560fbaa4c2887b7da6e7ebd7d" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" + +[[package]] +name = "web-sys" +version = "0.3.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcda906d8be16e728fd5adc5b729afad4e444e106ab28cd1c7256e54fa61510f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "which" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d011071ae14a2f6671d0b74080ae0cd8ebf3a6f8c9589a2cd45f23126fe29724" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core", + "windows-targets 0.52.4", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result", + "windows-targets 0.52.4", +] + +[[package]] +name = "windows-result" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd19df78e5168dfb0aedc343d1d1b8d422ab2db6756d2dc3fef75035402a3f64" +dependencies = [ + "windows-targets 0.52.4", +] + +[[package]] +name = "windows-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +dependencies = [ + "windows_aarch64_gnullvm 0.42.1", + "windows_aarch64_msvc 0.42.1", + "windows_i686_gnu 0.42.1", + "windows_i686_msvc 0.42.1", + "windows_x86_64_gnu 0.42.1", + "windows_x86_64_gnullvm 0.42.1", + "windows_x86_64_msvc 0.42.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2522491fbfcd58cc84d47aeb2958948c4b8982e9a2d8a2a35bbaed431390e7" +dependencies = [ + "windows_aarch64_gnullvm 0.42.1", + "windows_aarch64_msvc 0.42.1", + "windows_i686_gnu 0.42.1", + "windows_i686_msvc 0.42.1", + "windows_x86_64_gnu 0.42.1", + "windows_x86_64_gnullvm 0.42.1", + "windows_x86_64_msvc 0.42.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +dependencies = [ + "windows_aarch64_gnullvm 0.52.4", + "windows_aarch64_msvc 0.52.4", + "windows_i686_gnu 0.52.4", + "windows_i686_msvc 0.52.4", + "windows_x86_64_gnu 0.52.4", + "windows_x86_64_gnullvm 0.52.4", + "windows_x86_64_msvc 0.52.4", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9864e83243fdec7fc9c5444389dcbbfd258f745e7853198f365e3c4968a608" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c8b1b673ffc16c47a9ff48570a9d85e25d265735c503681332589af6253c6c7" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3887528ad530ba7bdbb1faa8275ec7a1155a45ffa57c37993960277145d640" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4d1122317eddd6ff351aa852118a2418ad4214e6613a50e0191f7004372605" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1040f221285e17ebccbc2591ffdc2d44ee1f9186324dd3e84e99ac68d699c45" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628bfdf232daa22b0d64fdb62b09fcc36bb01f05a3939e20ab73aaf9470d0463" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447660ad36a13288b1db4d4248e857b510e8c3a225c822ba4fb748c0aafecffd" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" diff --git a/openless-all/app/src-tauri/vendor/cpal/Cargo.toml b/openless-all/app/src-tauri/vendor/cpal/Cargo.toml new file mode 100644 index 000000000..8df046c11 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/Cargo.toml @@ -0,0 +1,184 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.70" +name = "cpal" +version = "0.15.3" +description = "Low-level cross-platform audio I/O library in pure Rust." +documentation = "https://docs.rs/cpal" +readme = "README.md" +keywords = [ + "audio", + "sound", +] +license = "Apache-2.0" +repository = "https://github.com/rustaudio/cpal" + +[[example]] +name = "android" +crate-type = ["cdylib"] +path = "examples/android.rs" + +[[example]] +name = "beep" + +[[example]] +name = "enumerate" + +[[example]] +name = "feedback" + +[[example]] +name = "record_wav" + +[[example]] +name = "synth_tones" + +[dependencies.dasp_sample] +version = "0.11" + +[dev-dependencies.anyhow] +version = "1.0" + +[dev-dependencies.clap] +version = "4.0" +features = ["derive"] + +[dev-dependencies.hound] +version = "3.5" + +[dev-dependencies.ringbuf] +version = "0.3" + +[features] +asio = [ + "asio-sys", + "num-traits", +] +oboe-shared-stdcxx = ["oboe/shared-stdcxx"] + +[target."cfg(all(target_arch = \"wasm32\", target_os = \"unknown\"))".dependencies.js-sys] +version = "0.3.35" + +[target."cfg(all(target_arch = \"wasm32\", target_os = \"unknown\"))".dependencies.wasm-bindgen] +version = "0.2.58" +optional = true + +[target."cfg(all(target_arch = \"wasm32\", target_os = \"unknown\"))".dependencies.web-sys] +version = "0.3.35" +features = [ + "AudioContext", + "AudioContextOptions", + "AudioBuffer", + "AudioBufferSourceNode", + "AudioNode", + "AudioDestinationNode", + "Window", + "AudioContextState", +] + +[target."cfg(any(target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"netbsd\"))".dependencies.alsa] +version = "0.9" + +[target."cfg(any(target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"netbsd\"))".dependencies.jack] +version = "0.11" +optional = true + +[target."cfg(any(target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"netbsd\"))".dependencies.libc] +version = "0.2" + +[target."cfg(any(target_os = \"macos\", target_os = \"ios\"))".dependencies.core-foundation-sys] +version = "0.8.2" + +[target."cfg(any(target_os = \"macos\", target_os = \"ios\"))".dependencies.mach2] +version = "0.4" + +[target."cfg(target_os = \"android\")".dependencies.jni] +version = "0.21" + +[target."cfg(target_os = \"android\")".dependencies.ndk] +version = "0.8" +default-features = false + +[target."cfg(target_os = \"android\")".dependencies.ndk-context] +version = "0.1" + +[target."cfg(target_os = \"android\")".dependencies.oboe] +version = "0.6" +features = ["java-interface"] + +[target."cfg(target_os = \"android\")".dev-dependencies.ndk-glue] +version = "0.7" + +[target."cfg(target_os = \"emscripten\")".dependencies.js-sys] +version = "0.3.35" + +[target."cfg(target_os = \"emscripten\")".dependencies.wasm-bindgen] +version = "0.2.89" + +[target."cfg(target_os = \"emscripten\")".dependencies.wasm-bindgen-futures] +version = "0.4.33" + +[target."cfg(target_os = \"emscripten\")".dependencies.web-sys] +version = "0.3.35" +features = [ + "AudioContext", + "AudioContextOptions", + "AudioBuffer", + "AudioBufferSourceNode", + "AudioNode", + "AudioDestinationNode", + "Window", + "AudioContextState", +] + +[target."cfg(target_os = \"ios\")".dependencies.coreaudio-rs] +version = "0.11" +features = [ + "audio_unit", + "core_audio", + "audio_toolbox", +] +default-features = false + +[target."cfg(target_os = \"macos\")".dependencies.coreaudio-rs] +version = "0.11" +features = [ + "audio_unit", + "core_audio", +] +default-features = false + +[target."cfg(target_os = \"windows\")".dependencies.asio-sys] +version = "0.2" +optional = true + +[target."cfg(target_os = \"windows\")".dependencies.num-traits] +version = "0.2.6" +optional = true + +[target."cfg(target_os = \"windows\")".dependencies.windows] +version = "0.54.0" +features = [ + "Win32_Media_Audio", + "Win32_Foundation", + "Win32_Devices_Properties", + "Win32_Media_KernelStreaming", + "Win32_System_Com_StructuredStorage", + "Win32_System_Threading", + "Win32_Security", + "Win32_System_SystemServices", + "Win32_System_Variant", + "Win32_Media_Multimedia", + "Win32_UI_Shell_PropertiesSystem", +] diff --git a/openless-all/app/src-tauri/vendor/cpal/Cargo.toml.orig b/openless-all/app/src-tauri/vendor/cpal/Cargo.toml.orig new file mode 100644 index 000000000..7b00ce902 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/Cargo.toml.orig @@ -0,0 +1,95 @@ +[package] +name = "cpal" +version = "0.15.3" +description = "Low-level cross-platform audio I/O library in pure Rust." +repository = "https://github.com/rustaudio/cpal" +documentation = "https://docs.rs/cpal" +license = "Apache-2.0" +keywords = ["audio", "sound"] +edition = "2021" +rust-version = "1.70" + +[features] +asio = ["asio-sys", "num-traits"] # Only available on Windows. See README for setup instructions. +oboe-shared-stdcxx = ["oboe/shared-stdcxx"] # Only available on Android. See README for what it does. + +[dependencies] +dasp_sample = "0.11" + +[dev-dependencies] +anyhow = "1.0" +hound = "3.5" +ringbuf = "0.3" +clap = { version = "4.0", features = ["derive"] } + +[target.'cfg(target_os = "android")'.dev-dependencies] +ndk-glue = "0.7" + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.54.0", features = [ + "Win32_Media_Audio", + "Win32_Foundation", + "Win32_Devices_Properties", + "Win32_Media_KernelStreaming", + "Win32_System_Com_StructuredStorage", + "Win32_System_Threading", + "Win32_Security", + "Win32_System_SystemServices", + "Win32_System_Variant", + "Win32_Media_Multimedia", + "Win32_UI_Shell_PropertiesSystem" +]} +asio-sys = { version = "0.2", path = "asio-sys", optional = true } +num-traits = { version = "0.2.6", optional = true } + +[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd"))'.dependencies] +alsa = "0.9" +libc = "0.2" +jack = { version = "0.11", optional = true } + +[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] +core-foundation-sys = "0.8.2" # For linking to CoreFoundation.framework and handling device name `CFString`s. +mach2 = "0.4" # For access to mach_timebase type. + +[target.'cfg(target_os = "macos")'.dependencies] +coreaudio-rs = { version = "0.11", default-features = false, features = ["audio_unit", "core_audio"] } + +[target.'cfg(target_os = "ios")'.dependencies] +coreaudio-rs = { version = "0.11", default-features = false, features = ["audio_unit", "core_audio", "audio_toolbox"] } + +[target.'cfg(target_os = "emscripten")'.dependencies] +wasm-bindgen = { version = "0.2.89" } +wasm-bindgen-futures = "0.4.33" +js-sys = { version = "0.3.35" } +web-sys = { version = "0.3.35", features = [ "AudioContext", "AudioContextOptions", "AudioBuffer", "AudioBufferSourceNode", "AudioNode", "AudioDestinationNode", "Window", "AudioContextState"] } + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +wasm-bindgen = { version = "0.2.58", optional = true } +js-sys = { version = "0.3.35" } +web-sys = { version = "0.3.35", features = [ "AudioContext", "AudioContextOptions", "AudioBuffer", "AudioBufferSourceNode", "AudioNode", "AudioDestinationNode", "Window", "AudioContextState"] } + +[target.'cfg(target_os = "android")'.dependencies] +oboe = { version = "0.6", features = [ "java-interface" ] } +ndk = { version = "0.8", default-features = false } +ndk-context = "0.1" +jni = "0.21" + +[[example]] +name = "android" +path = "examples/android.rs" +crate-type = ["cdylib"] + +[[example]] +name = "beep" + +[[example]] +name = "enumerate" + +[[example]] +name = "feedback" + +[[example]] +name = "record_wav" + +[[example]] +name = "synth_tones" diff --git a/openless-all/app/src-tauri/vendor/cpal/Cross.toml b/openless-all/app/src-tauri/vendor/cpal/Cross.toml new file mode 100644 index 000000000..39abb8173 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/Cross.toml @@ -0,0 +1,7 @@ +[target.armv7-unknown-linux-gnueabihf] +image = "cross/cpal_armv7:v1" + +[target.armv7-unknown-linux-gnueabihf.env] +passthrough = [ + "RUSTFLAGS", +] \ No newline at end of file diff --git a/openless-all/app/src-tauri/vendor/cpal/Dockerfile b/openless-all/app/src-tauri/vendor/cpal/Dockerfile new file mode 100644 index 000000000..05045c34f --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/Dockerfile @@ -0,0 +1,9 @@ +FROM rustembedded/cross:armv7-unknown-linux-gnueabihf + +ENV PKG_CONFIG_ALLOW_CROSS 1 +ENV PKG_CONFIG_PATH /usr/lib/arm-linux-gnueabihf/pkgconfig/ + +RUN dpkg --add-architecture armhf && \ + apt-get update && \ + apt-get install libasound2-dev:armhf -y && \ + apt-get install libjack-jackd2-dev:armhf libjack-jackd2-0:armhf -y \ \ No newline at end of file diff --git a/openless-all/app/src-tauri/vendor/cpal/LICENSE b/openless-all/app/src-tauri/vendor/cpal/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/openless-all/app/src-tauri/vendor/cpal/OPENLESS_PATCH.md b/openless-all/app/src-tauri/vendor/cpal/OPENLESS_PATCH.md new file mode 100644 index 000000000..046ce47de --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/OPENLESS_PATCH.md @@ -0,0 +1,119 @@ +# OpenLess Windows WASAPI lifetime patch + +This directory vendors the complete official `cpal 0.15.3` crates.io package, +including its Apache-2.0 license and original dependency lockfile. Package version +and non-Windows backends are unchanged. `OPENLESS_UPSTREAM.json` records the source +archive, upstream commit and verified SHA-256: + +`873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779` + +The archive hash was checked against the crates.io version metadata before +extracting. Only `src/host/wasapi/com.rs`, `device.rs`, `stream.rs`, and `.gitignore` +from the archive are modified. The `.gitignore` change retains the existing +`Cargo.lock` in this repository so the standalone Windows checks can use +`--locked`. The added files are this note, upstream identity metadata and +`tests/windows_lifecycle.rs`. + +## Scope and evidence + +This fixes independently identified COM ownership/lifetime defects in the pinned +WASAPI implementation. It does **not** establish that those defects caused +OpenLess issue #1227, and does not claim its `0xc0000374` crash is resolved. The +same dependency code existed in OpenLess 1.3.18. Original-machine crash evidence +and controlled before/after runs remain necessary. + +The original backend cached one `IMMDeviceEnumerator` process-wide although its +COM initialization was thread-local, and allowed `Device`/`Devices` to cross +threads with `unsafe impl Send/Sync`. It also acquired capture/render/clock +services on a stream builder's thread and released them on an audio thread that +did not itself initialize COM. + +Microsoft's interface contracts require a COM-initialized audio thread and +require each service's `Release` on the thread that called `GetService`: + +- [IAudioClient](https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nn-audioclient-iaudioclient) +- [IAudioCaptureClient](https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nn-audioclient-iaudiocaptureclient) +- [IAudioRenderClient](https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nn-audioclient-iaudiorenderclient) +- [IAudioClock](https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nn-audioclient-iaudioclock) +- [Single-threaded apartments](https://learn.microsoft.com/en-us/windows/win32/com/single-threaded-apartments) + +Upstream [cpal#1302](https://github.com/RustAudio/cpal/issues/1302) discusses related +COM affinity problems. Its reported access violation is distinct from the +OpenLess heap-corruption report; the shared dependency does not prove causality. + +## Ownership and failure behavior + +- Enumeration initializes COM in the calling thread and creates a local + enumerator. MMDevice/collection objects never enter a process-wide cache. +- WASAPI `Device` and `Devices` no longer implement `Send` or `Sync`. Their dynamic + platform wrappers inherit this restriction. The application must transfer + owned device descriptions, not MMDevice pointers. +- Building a stream transfers only an owned UTF-16 endpoint ID, configuration, + and user callbacks to `cpal_wasapi_in` or `cpal_wasapi_out`. +- The worker initializes COM, resolves its own Device, activates and initializes + its own AudioClient, obtains capture/render/clock services, and runs callbacks. + No COM interface crosses the thread boundary. The existing STA initialization + convention is retained; an already initialized caller may reuse its apartment. +- A startup handshake returns success only after native initialization succeeds. + Native startup failures return `BuildStreamError` synchronously. A startup + panic closes the handshake and becomes a builder error after the thread is + joined. There is no detached native cleanup on those paths. +- Every acquired audio packet has a release guard. Normal release errors do not + cause a second release. Timestamp errors and callback unwinding return the + input packet; interrupted output packets are released as silent. +- A zero-frame `AUDCLNT_S_BUFFER_EMPTY` result is handled before reading the data + pointer or timestamps. It is a successful HRESULT with no acquired packet; + the callback and `ReleaseBuffer` are skipped for that result. +- Worker shutdown first releases any acquired packet, then stops a playing + client. `StreamInner` field order releases capture/render and clock interfaces, + then AudioClient, then its audio event. Partial-construction local ordering + provides the same client-before-event lifetime on initialization failures. +- The COM guard is thread-local and is destroyed after worker stack values, + including all interfaces, during both normal exit and panic unwinding. +- Controller destruction sends termination and joins even if the command + receiver has already disconnected after a callback failure. It does not + propagate a worker panic from its destructor. The shared kernel control event + is closed only after the worker can no longer wait on it. Kernel events are + the only new explicit `Send/Sync` wrapper; they contain no COM interfaces. + +## Verification + +From the repository root, the Windows CI tests are: + +```sh +cargo test --manifest-path openless-all/app/src-tauri/vendor/cpal/Cargo.toml \ + --lib --test windows_lifecycle --locked +``` + +The integration target launches fresh child processes for the first-thread-exit +and concurrent-enumeration scenarios. Empty endpoint lists still exercise COM +enumerator/collection creation and teardown. It also enforces that the public +Device/Devices wrappers cannot cross threads at compile time. Unit tests cover +startup errors, startup panic, COM availability during stack cleanup, and packet +release on success, release failure and callback unwinding. + +Real audio-device tests are explicitly ignored by default: + +```sh +cargo test --manifest-path openless-all/app/src-tauri/vendor/cpal/Cargo.toml \ + --test windows_lifecycle --locked -- --ignored --nocapture +``` + +They require input/output hardware and microphone permission. Missing devices, +missing callbacks or unsupported fixture formats fail an explicitly requested +hardware test; they are not reported as successful coverage. Input tests cover +normal pause/drop, direct cancellation and callback panic. Output tests repeatedly +start and stop silent playback. Each child has a bounded timeout to expose hangs. + +On the implementation host (macOS, 2026-10-25), the latest patched library and all +Windows test targets passed cross compilation with Rust 1.98.0: + +```sh +cargo check --manifest-path openless-all/app/src-tauri/vendor/cpal/Cargo.toml \ + --target x86_64-pc-windows-gnu --lib --tests --locked --offline +``` + +This was a compile check, not execution of Windows tests. No Windows runtime, +real microphone or issue #1227 crash reproduction was available on this host. +The application-level macOS tests and Windows CI are tracked by the enclosing +OpenLess change, not claimed here as local WASAPI runtime validation. diff --git a/openless-all/app/src-tauri/vendor/cpal/OPENLESS_UPSTREAM.json b/openless-all/app/src-tauri/vendor/cpal/OPENLESS_UPSTREAM.json new file mode 100644 index 000000000..47932aaa9 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/OPENLESS_UPSTREAM.json @@ -0,0 +1,8 @@ +{ + "crate": "cpal", + "version": "0.15.3", + "source": "https://static.crates.io/crates/cpal/cpal-0.15.3.crate", + "sha256": "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779", + "verified_against": "https://crates.io/api/v1/crates/cpal/0.15.3", + "upstream_commit": "ac6cbb2ba55e61665a35ab88ae136a83380d1354" +} diff --git a/openless-all/app/src-tauri/vendor/cpal/README.md b/openless-all/app/src-tauri/vendor/cpal/README.md new file mode 100644 index 000000000..b5ac67097 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/README.md @@ -0,0 +1,142 @@ +# CPAL - Cross-Platform Audio Library + +[![Actions Status](https://github.com/RustAudio/cpal/workflows/cpal/badge.svg)](https://github.com/RustAudio/cpal/actions) +[![Crates.io](https://img.shields.io/crates/v/cpal.svg)](https://crates.io/crates/cpal) [![docs.rs](https://docs.rs/cpal/badge.svg)](https://docs.rs/cpal/) + +Low-level library for audio input and output in pure Rust. + +This library currently supports the following: + +- Enumerate supported audio hosts. +- Enumerate all available audio devices. +- Get the current default input and output devices. +- Enumerate known supported input and output stream formats for a device. +- Get the current default input and output stream formats for a device. +- Build and run input and output PCM streams on a chosen device with a given stream format. + +Currently, supported hosts include: + +- Linux (via ALSA or JACK) +- Windows (via WASAPI by default, see ASIO instructions below) +- macOS (via CoreAudio) +- iOS (via CoreAudio) +- Android (via Oboe) +- Emscripten + +Note that on Linux, the ALSA development files are required. These are provided +as part of the `libasound2-dev` package on Debian and Ubuntu distributions and +`alsa-lib-devel` on Fedora. + +## Compiling for Web Assembly + +If you are interested in using CPAL with WASM, please see [this guide](https://github.com/RustAudio/cpal/wiki/Setting-up-a-new-CPAL-WASM-project) in our Wiki which walks through setting up a new project from scratch. + +## Feature flags for audio backends + +Some audio backends are optional and will only be compiled with a [feature flag](https://doc.rust-lang.org/cargo/reference/features.html). + +- JACK (on Linux): `jack` +- ASIO (on Windows): `asio` + +Oboe can either use a shared or static runtime. The static runtime is used by default, but activating the +`oboe-shared-stdcxx` feature makes it use the shared runtime, which requires `libc++_shared.so` from the Android NDK to +be present during execution. + +## ASIO on Windows + +[ASIO](https://en.wikipedia.org/wiki/Audio_Stream_Input/Output) is an audio +driver protocol by Steinberg. While it is available on multiple operating +systems, it is most commonly used on Windows to work around limitations of +WASAPI including access to large numbers of channels and lower-latency audio +processing. + +CPAL allows for using the ASIO SDK as the audio host on Windows instead of +WASAPI. + +### Locating the ASIO SDK + +The location of ASIO SDK is exposed to CPAL by setting the `CPAL_ASIO_DIR` environment variable. + +The build script will try to find the ASIO SDK by following these steps in order: + +1. Check if `CPAL_ASIO_DIR` is set and if so use the path to point to the SDK. +2. Check if the ASIO SDK is already installed in the temporary directory, if so use that and set the path of `CPAL_ASIO_DIR` to the output of `std::env::temp_dir().join("asio_sdk")`. +3. If the ASIO SDK is not already installed, download it from and install it in the temporary directory. The path of `CPAL_ASIO_DIR` will be set to the output of `std::env::temp_dir().join("asio_sdk")`. + +In an ideal situation you don't need to worry about this step. + +### Preparing the build environment + +1. `bindgen`, the library used to generate bindings to the C++ SDK, requires + clang. **Download and install LLVM** from + [here](http://releases.llvm.org/download.html) under the "Pre-Built Binaries" + section. The version as of writing this is 17.0.1. +2. Add the LLVM `bin` directory to a `LIBCLANG_PATH` environment variable. If + you installed LLVM to the default directory, this should work in the command + prompt: + ``` + setx LIBCLANG_PATH "C:\Program Files\LLVM\bin" + ``` +3. If you don't have any ASIO devices or drivers available, you can [**download + and install ASIO4ALL**](http://www.asio4all.org/). Be sure to enable the + "offline" feature during installation despite what the installer says about + it being useless. +4. Our build script assumes that Microsoft Visual Studio is installed if the host OS for compilation is Windows. The script will try to find `vcvarsall.bat` + and execute it with the right host and target machine architecture regardless of the Microsoft Visual Studio version. + If there are any errors encountered in this process which is unlikely, + you may find the `vcvarsall.bat` manually and execute it with your machine architecture as an argument. + The script will detect this and skip the step. + + A manually executed command example for 64 bit machines: + + ``` + "C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build\vcvarsall.bat" amd64 + ``` + + For more information please refer to the documentation of [`vcvarsall.bat``](https://docs.microsoft.com/en-us/cpp/build/building-on-the-command-line?view=msvc-160#vcvarsall-syntax). + +5. Select the ASIO host at the start of our program with the following code: + + ```rust + let host; + #[cfg(target_os = "windows")] + { + host = cpal::host_from_id(cpal::HostId::Asio).expect("failed to initialise ASIO host"); + } + ``` + + If you run into compilations errors produced by `asio-sys` or `bindgen`, make + sure that `CPAL_ASIO_DIR` is set correctly and try `cargo clean`. + +6. Make sure to enable the `asio` feature when building CPAL: + + ``` + cargo build --features "asio" + ``` + + or if you are using CPAL as a dependency in a downstream project, enable the + feature like this: + + ```toml + cpal = { version = "*", features = ["asio"] } + ``` + +_Updated as of ASIO version 2.3.3._ + +### Cross compilation + +When Windows is the host and the target OS, the build script of `asio-sys` supports all cross compilation targets +which are supported by the MSVC compiler. An exhaustive list of combinations could be found [here](https://docs.microsoft.com/en-us/cpp/build/building-on-the-command-line?view=msvc-160#vcvarsall-syntax) with the addition of undocumented `arm64`, `arm64_x86`, `arm64_amd64` and `arm64_arm` targets. (5.11.2023) + +It is also possible to compile Windows applications with ASIO support on Linux and macOS. + +For both platforms the common way to do this is to use the [MinGW-w64](https://www.mingw-w64.org/) toolchain. + +Make sure that you have included the `MinGW-w64` include directory in your `CPLUS_INCLUDE_PATH` environment variable. +Make sure that LLVM is installed and include directory is also included in your `CPLUS_INCLUDE_PATH` environment variable. + +Example for macOS for the target of `x86_64-pc-windows-gnu` where `mingw-w64` is installed via brew: + +``` +export CPLUS_INCLUDE_PATH="$CPLUS_INCLUDE_PATH:/opt/homebrew/Cellar/mingw-w64/11.0.1/toolchain-x86_64/x86_64-w64-mingw32/include" +``` diff --git a/openless-all/app/src-tauri/vendor/cpal/build.rs b/openless-all/app/src-tauri/vendor/cpal/build.rs new file mode 100644 index 000000000..ae97f68f7 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/build.rs @@ -0,0 +1,14 @@ +use std::env; + +const CPAL_ASIO_DIR: &str = "CPAL_ASIO_DIR"; + +fn main() { + println!("cargo:rerun-if-env-changed={}", CPAL_ASIO_DIR); + + // If ASIO directory isn't set silently return early + // otherwise set the asio config flag + match env::var(CPAL_ASIO_DIR) { + Err(_) => {} + Ok(_) => println!("cargo:rustc-cfg=asio"), + }; +} diff --git a/openless-all/app/src-tauri/vendor/cpal/examples/android.rs b/openless-all/app/src-tauri/vendor/cpal/examples/android.rs new file mode 100644 index 000000000..b58731dbf --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/examples/android.rs @@ -0,0 +1,82 @@ +#![allow(dead_code)] + +extern crate anyhow; +extern crate cpal; + +use cpal::{ + traits::{DeviceTrait, HostTrait, StreamTrait}, + SizedSample, +}; +use cpal::{FromSample, Sample}; + +#[cfg_attr(target_os = "android", ndk_glue::main(backtrace = "full"))] +fn main() { + let host = cpal::default_host(); + + let device = host + .default_output_device() + .expect("failed to find output device"); + + let config = device.default_output_config().unwrap(); + + match config.sample_format() { + cpal::SampleFormat::I8 => run::(&device, &config.into()).unwrap(), + cpal::SampleFormat::I16 => run::(&device, &config.into()).unwrap(), + // cpal::SampleFormat::I24 => run::(&device, &config.into()).unwrap(), + cpal::SampleFormat::I32 => run::(&device, &config.into()).unwrap(), + // cpal::SampleFormat::I48 => run::(&device, &config.into()).unwrap(), + cpal::SampleFormat::I64 => run::(&device, &config.into()).unwrap(), + cpal::SampleFormat::U8 => run::(&device, &config.into()).unwrap(), + cpal::SampleFormat::U16 => run::(&device, &config.into()).unwrap(), + // cpal::SampleFormat::U24 => run::(&device, &config.into()).unwrap(), + cpal::SampleFormat::U32 => run::(&device, &config.into()).unwrap(), + // cpal::SampleFormat::U48 => run::(&device, &config.into()).unwrap(), + cpal::SampleFormat::U64 => run::(&device, &config.into()).unwrap(), + cpal::SampleFormat::F32 => run::(&device, &config.into()).unwrap(), + cpal::SampleFormat::F64 => run::(&device, &config.into()).unwrap(), + sample_format => panic!("Unsupported sample format '{sample_format}'"), + } +} + +fn run(device: &cpal::Device, config: &cpal::StreamConfig) -> Result<(), anyhow::Error> +where + T: SizedSample + FromSample, +{ + let sample_rate = config.sample_rate.0 as f32; + let channels = config.channels as usize; + + // Produce a sinusoid of maximum amplitude. + let mut sample_clock = 0f32; + let mut next_value = move || { + sample_clock = (sample_clock + 1.0) % sample_rate; + (sample_clock * 440.0 * 2.0 * std::f32::consts::PI / sample_rate).sin() + }; + + let err_fn = |err| eprintln!("an error occurred on stream: {}", err); + + let stream = device.build_output_stream( + config, + move |data: &mut [T], _: &cpal::OutputCallbackInfo| { + write_data(data, channels, &mut next_value) + }, + err_fn, + None, + )?; + stream.play()?; + + std::thread::sleep(std::time::Duration::from_millis(1000)); + + Ok(()) +} + +fn write_data(output: &mut [T], channels: usize, next_sample: &mut dyn FnMut() -> f32) +where + T: Sample + FromSample, +{ + for frame in output.chunks_mut(channels) { + let value: T = T::from_sample(next_sample()); + for sample in frame.iter_mut() { + *sample = value; + } + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/examples/beep.rs b/openless-all/app/src-tauri/vendor/cpal/examples/beep.rs new file mode 100644 index 000000000..7d3b23d88 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/examples/beep.rs @@ -0,0 +1,138 @@ +use clap::Parser; +use cpal::{ + traits::{DeviceTrait, HostTrait, StreamTrait}, + FromSample, Sample, SizedSample, +}; + +#[derive(Parser, Debug)] +#[command(version, about = "CPAL beep example", long_about = None)] +struct Opt { + /// The audio device to use + #[arg(short, long, default_value_t = String::from("default"))] + device: String, + + /// Use the JACK host + #[cfg(all( + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + ), + feature = "jack" + ))] + #[arg(short, long)] + #[allow(dead_code)] + jack: bool, +} + +fn main() -> anyhow::Result<()> { + let opt = Opt::parse(); + + // Conditionally compile with jack if the feature is specified. + #[cfg(all( + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + ), + feature = "jack" + ))] + // Manually check for flags. Can be passed through cargo with -- e.g. + // cargo run --release --example beep --features jack -- --jack + let host = if opt.jack { + cpal::host_from_id(cpal::available_hosts() + .into_iter() + .find(|id| *id == cpal::HostId::Jack) + .expect( + "make sure --features jack is specified. only works on OSes where jack is available", + )).expect("jack host unavailable") + } else { + cpal::default_host() + }; + + #[cfg(any( + not(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + )), + not(feature = "jack") + ))] + let host = cpal::default_host(); + + let device = if opt.device == "default" { + host.default_output_device() + } else { + host.output_devices()? + .find(|x| x.name().map(|y| y == opt.device).unwrap_or(false)) + } + .expect("failed to find output device"); + println!("Output device: {}", device.name()?); + + let config = device.default_output_config().unwrap(); + println!("Default output config: {:?}", config); + + match config.sample_format() { + cpal::SampleFormat::I8 => run::(&device, &config.into()), + cpal::SampleFormat::I16 => run::(&device, &config.into()), + // cpal::SampleFormat::I24 => run::(&device, &config.into()), + cpal::SampleFormat::I32 => run::(&device, &config.into()), + // cpal::SampleFormat::I48 => run::(&device, &config.into()), + cpal::SampleFormat::I64 => run::(&device, &config.into()), + cpal::SampleFormat::U8 => run::(&device, &config.into()), + cpal::SampleFormat::U16 => run::(&device, &config.into()), + // cpal::SampleFormat::U24 => run::(&device, &config.into()), + cpal::SampleFormat::U32 => run::(&device, &config.into()), + // cpal::SampleFormat::U48 => run::(&device, &config.into()), + cpal::SampleFormat::U64 => run::(&device, &config.into()), + cpal::SampleFormat::F32 => run::(&device, &config.into()), + cpal::SampleFormat::F64 => run::(&device, &config.into()), + sample_format => panic!("Unsupported sample format '{sample_format}'"), + } +} + +pub fn run(device: &cpal::Device, config: &cpal::StreamConfig) -> Result<(), anyhow::Error> +where + T: SizedSample + FromSample, +{ + let sample_rate = config.sample_rate.0 as f32; + let channels = config.channels as usize; + + // Produce a sinusoid of maximum amplitude. + let mut sample_clock = 0f32; + let mut next_value = move || { + sample_clock = (sample_clock + 1.0) % sample_rate; + (sample_clock * 440.0 * 2.0 * std::f32::consts::PI / sample_rate).sin() + }; + + let err_fn = |err| eprintln!("an error occurred on stream: {}", err); + + let stream = device.build_output_stream( + config, + move |data: &mut [T], _: &cpal::OutputCallbackInfo| { + write_data(data, channels, &mut next_value) + }, + err_fn, + None, + )?; + stream.play()?; + + std::thread::sleep(std::time::Duration::from_millis(1000)); + + Ok(()) +} + +fn write_data(output: &mut [T], channels: usize, next_sample: &mut dyn FnMut() -> f32) +where + T: Sample + FromSample, +{ + for frame in output.chunks_mut(channels) { + let value: T = T::from_sample(next_sample()); + for sample in frame.iter_mut() { + *sample = value; + } + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/examples/enumerate.rs b/openless-all/app/src-tauri/vendor/cpal/examples/enumerate.rs new file mode 100644 index 000000000..ccba0eb68 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/examples/enumerate.rs @@ -0,0 +1,74 @@ +extern crate anyhow; +extern crate cpal; + +use cpal::traits::{DeviceTrait, HostTrait}; + +fn main() -> Result<(), anyhow::Error> { + println!("Supported hosts:\n {:?}", cpal::ALL_HOSTS); + let available_hosts = cpal::available_hosts(); + println!("Available hosts:\n {:?}", available_hosts); + + for host_id in available_hosts { + println!("{}", host_id.name()); + let host = cpal::host_from_id(host_id)?; + + let default_in = host.default_input_device().map(|e| e.name().unwrap()); + let default_out = host.default_output_device().map(|e| e.name().unwrap()); + println!(" Default Input Device:\n {:?}", default_in); + println!(" Default Output Device:\n {:?}", default_out); + + let devices = host.devices()?; + println!(" Devices: "); + for (device_index, device) in devices.enumerate() { + println!(" {}. \"{}\"", device_index + 1, device.name()?); + + // Input configs + if let Ok(conf) = device.default_input_config() { + println!(" Default input stream config:\n {:?}", conf); + } + let input_configs = match device.supported_input_configs() { + Ok(f) => f.collect(), + Err(e) => { + println!(" Error getting supported input configs: {:?}", e); + Vec::new() + } + }; + if !input_configs.is_empty() { + println!(" All supported input stream configs:"); + for (config_index, config) in input_configs.into_iter().enumerate() { + println!( + " {}.{}. {:?}", + device_index + 1, + config_index + 1, + config + ); + } + } + + // Output configs + if let Ok(conf) = device.default_output_config() { + println!(" Default output stream config:\n {:?}", conf); + } + let output_configs = match device.supported_output_configs() { + Ok(f) => f.collect(), + Err(e) => { + println!(" Error getting supported output configs: {:?}", e); + Vec::new() + } + }; + if !output_configs.is_empty() { + println!(" All supported output stream configs:"); + for (config_index, config) in output_configs.into_iter().enumerate() { + println!( + " {}.{}. {:?}", + device_index + 1, + config_index + 1, + config + ); + } + } + } + } + + Ok(()) +} diff --git a/openless-all/app/src-tauri/vendor/cpal/examples/feedback.rs b/openless-all/app/src-tauri/vendor/cpal/examples/feedback.rs new file mode 100644 index 000000000..eae9fa2e7 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/examples/feedback.rs @@ -0,0 +1,174 @@ +//! Feeds back the input stream directly into the output stream. +//! +//! Assumes that the input and output devices can use the same stream configuration and that they +//! support the f32 sample format. +//! +//! Uses a delay of `LATENCY_MS` milliseconds in case the default input and output streams are not +//! precisely synchronised. + +use clap::Parser; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use ringbuf::HeapRb; + +#[derive(Parser, Debug)] +#[command(version, about = "CPAL feedback example", long_about = None)] +struct Opt { + /// The input audio device to use + #[arg(short, long, value_name = "IN", default_value_t = String::from("default"))] + input_device: String, + + /// The output audio device to use + #[arg(short, long, value_name = "OUT", default_value_t = String::from("default"))] + output_device: String, + + /// Specify the delay between input and output + #[arg(short, long, value_name = "DELAY_MS", default_value_t = 150.0)] + latency: f32, + + /// Use the JACK host + #[cfg(all( + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + ), + feature = "jack" + ))] + #[arg(short, long)] + #[allow(dead_code)] + jack: bool, +} + +fn main() -> anyhow::Result<()> { + let opt = Opt::parse(); + + // Conditionally compile with jack if the feature is specified. + #[cfg(all( + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + ), + feature = "jack" + ))] + // Manually check for flags. Can be passed through cargo with -- e.g. + // cargo run --release --example beep --features jack -- --jack + let host = if opt.jack { + cpal::host_from_id(cpal::available_hosts() + .into_iter() + .find(|id| *id == cpal::HostId::Jack) + .expect( + "make sure --features jack is specified. only works on OSes where jack is available", + )).expect("jack host unavailable") + } else { + cpal::default_host() + }; + + #[cfg(any( + not(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + )), + not(feature = "jack") + ))] + let host = cpal::default_host(); + + // Find devices. + let input_device = if opt.input_device == "default" { + host.default_input_device() + } else { + host.input_devices()? + .find(|x| x.name().map(|y| y == opt.input_device).unwrap_or(false)) + } + .expect("failed to find input device"); + + let output_device = if opt.output_device == "default" { + host.default_output_device() + } else { + host.output_devices()? + .find(|x| x.name().map(|y| y == opt.output_device).unwrap_or(false)) + } + .expect("failed to find output device"); + + println!("Using input device: \"{}\"", input_device.name()?); + println!("Using output device: \"{}\"", output_device.name()?); + + // We'll try and use the same configuration between streams to keep it simple. + let config: cpal::StreamConfig = input_device.default_input_config()?.into(); + + // Create a delay in case the input and output devices aren't synced. + let latency_frames = (opt.latency / 1_000.0) * config.sample_rate.0 as f32; + let latency_samples = latency_frames as usize * config.channels as usize; + + // The buffer to share samples + let ring = HeapRb::::new(latency_samples * 2); + let (mut producer, mut consumer) = ring.split(); + + // Fill the samples with 0.0 equal to the length of the delay. + for _ in 0..latency_samples { + // The ring buffer has twice as much space as necessary to add latency here, + // so this should never fail + producer.push(0.0).unwrap(); + } + + let input_data_fn = move |data: &[f32], _: &cpal::InputCallbackInfo| { + let mut output_fell_behind = false; + for &sample in data { + if producer.push(sample).is_err() { + output_fell_behind = true; + } + } + if output_fell_behind { + eprintln!("output stream fell behind: try increasing latency"); + } + }; + + let output_data_fn = move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + let mut input_fell_behind = false; + for sample in data { + *sample = match consumer.pop() { + Some(s) => s, + None => { + input_fell_behind = true; + 0.0 + } + }; + } + if input_fell_behind { + eprintln!("input stream fell behind: try increasing latency"); + } + }; + + // Build streams. + println!( + "Attempting to build both streams with f32 samples and `{:?}`.", + config + ); + let input_stream = input_device.build_input_stream(&config, input_data_fn, err_fn, None)?; + let output_stream = output_device.build_output_stream(&config, output_data_fn, err_fn, None)?; + println!("Successfully built streams."); + + // Play the streams. + println!( + "Starting the input and output streams with `{}` milliseconds of latency.", + opt.latency + ); + input_stream.play()?; + output_stream.play()?; + + // Run for 3 seconds before closing. + println!("Playing for 3 seconds... "); + std::thread::sleep(std::time::Duration::from_secs(3)); + drop(input_stream); + drop(output_stream); + println!("Done!"); + Ok(()) +} + +fn err_fn(err: cpal::StreamError) { + eprintln!("an error occurred on stream: {}", err); +} diff --git a/openless-all/app/src-tauri/vendor/cpal/examples/record_wav.rs b/openless-all/app/src-tauri/vendor/cpal/examples/record_wav.rs new file mode 100644 index 000000000..4841bfa06 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/examples/record_wav.rs @@ -0,0 +1,177 @@ +//! Records a WAV file (roughly 3 seconds long) using the default input device and config. +//! +//! The input data is recorded to "$CARGO_MANIFEST_DIR/recorded.wav". + +use clap::Parser; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{FromSample, Sample}; +use std::fs::File; +use std::io::BufWriter; +use std::sync::{Arc, Mutex}; + +#[derive(Parser, Debug)] +#[command(version, about = "CPAL record_wav example", long_about = None)] +struct Opt { + /// The audio device to use + #[arg(short, long, default_value_t = String::from("default"))] + device: String, + + /// Use the JACK host + #[cfg(all( + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + ), + feature = "jack" + ))] + #[arg(short, long)] + #[allow(dead_code)] + jack: bool, +} + +fn main() -> Result<(), anyhow::Error> { + let opt = Opt::parse(); + + // Conditionally compile with jack if the feature is specified. + #[cfg(all( + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + ), + feature = "jack" + ))] + // Manually check for flags. Can be passed through cargo with -- e.g. + // cargo run --release --example beep --features jack -- --jack + let host = if opt.jack { + cpal::host_from_id(cpal::available_hosts() + .into_iter() + .find(|id| *id == cpal::HostId::Jack) + .expect( + "make sure --features jack is specified. only works on OSes where jack is available", + )).expect("jack host unavailable") + } else { + cpal::default_host() + }; + + #[cfg(any( + not(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + )), + not(feature = "jack") + ))] + let host = cpal::default_host(); + + // Set up the input device and stream with the default input config. + let device = if opt.device == "default" { + host.default_input_device() + } else { + host.input_devices()? + .find(|x| x.name().map(|y| y == opt.device).unwrap_or(false)) + } + .expect("failed to find input device"); + + println!("Input device: {}", device.name()?); + + let config = device + .default_input_config() + .expect("Failed to get default input config"); + println!("Default input config: {:?}", config); + + // The WAV file we're recording to. + const PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/recorded.wav"); + let spec = wav_spec_from_config(&config); + let writer = hound::WavWriter::create(PATH, spec)?; + let writer = Arc::new(Mutex::new(Some(writer))); + + // A flag to indicate that recording is in progress. + println!("Begin recording..."); + + // Run the input stream on a separate thread. + let writer_2 = writer.clone(); + + let err_fn = move |err| { + eprintln!("an error occurred on stream: {}", err); + }; + + let stream = match config.sample_format() { + cpal::SampleFormat::I8 => device.build_input_stream( + &config.into(), + move |data, _: &_| write_input_data::(data, &writer_2), + err_fn, + None, + )?, + cpal::SampleFormat::I16 => device.build_input_stream( + &config.into(), + move |data, _: &_| write_input_data::(data, &writer_2), + err_fn, + None, + )?, + cpal::SampleFormat::I32 => device.build_input_stream( + &config.into(), + move |data, _: &_| write_input_data::(data, &writer_2), + err_fn, + None, + )?, + cpal::SampleFormat::F32 => device.build_input_stream( + &config.into(), + move |data, _: &_| write_input_data::(data, &writer_2), + err_fn, + None, + )?, + sample_format => { + return Err(anyhow::Error::msg(format!( + "Unsupported sample format '{sample_format}'" + ))) + } + }; + + stream.play()?; + + // Let recording go for roughly three seconds. + std::thread::sleep(std::time::Duration::from_secs(3)); + drop(stream); + writer.lock().unwrap().take().unwrap().finalize()?; + println!("Recording {} complete!", PATH); + Ok(()) +} + +fn sample_format(format: cpal::SampleFormat) -> hound::SampleFormat { + if format.is_float() { + hound::SampleFormat::Float + } else { + hound::SampleFormat::Int + } +} + +fn wav_spec_from_config(config: &cpal::SupportedStreamConfig) -> hound::WavSpec { + hound::WavSpec { + channels: config.channels() as _, + sample_rate: config.sample_rate().0 as _, + bits_per_sample: (config.sample_format().sample_size() * 8) as _, + sample_format: sample_format(config.sample_format()), + } +} + +type WavWriterHandle = Arc>>>>; + +fn write_input_data(input: &[T], writer: &WavWriterHandle) +where + T: Sample, + U: Sample + hound::Sample + FromSample, +{ + if let Ok(mut guard) = writer.try_lock() { + if let Some(writer) = guard.as_mut() { + for &sample in input.iter() { + let sample: U = U::from_sample(sample); + writer.write_sample(sample).ok(); + } + } + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/examples/synth_tones.rs b/openless-all/app/src-tauri/vendor/cpal/examples/synth_tones.rs new file mode 100644 index 000000000..c83f816d9 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/examples/synth_tones.rs @@ -0,0 +1,191 @@ +/* This example expose parameter to pass generator of sample. +Good starting point for integration of cpal into your application. +*/ + +extern crate anyhow; +extern crate clap; +extern crate cpal; + +use cpal::{ + traits::{DeviceTrait, HostTrait, StreamTrait}, + SizedSample, +}; +use cpal::{FromSample, Sample}; + +fn main() -> anyhow::Result<()> { + let stream = stream_setup_for()?; + stream.play()?; + std::thread::sleep(std::time::Duration::from_millis(4000)); + Ok(()) +} + +pub enum Waveform { + Sine, + Square, + Saw, + Triangle, +} + +pub struct Oscillator { + pub sample_rate: f32, + pub waveform: Waveform, + pub current_sample_index: f32, + pub frequency_hz: f32, +} + +impl Oscillator { + fn advance_sample(&mut self) { + self.current_sample_index = (self.current_sample_index + 1.0) % self.sample_rate; + } + + fn set_waveform(&mut self, waveform: Waveform) { + self.waveform = waveform; + } + + fn calculate_sine_output_from_freq(&self, freq: f32) -> f32 { + let two_pi = 2.0 * std::f32::consts::PI; + (self.current_sample_index * freq * two_pi / self.sample_rate).sin() + } + + fn is_multiple_of_freq_above_nyquist(&self, multiple: f32) -> bool { + self.frequency_hz * multiple > self.sample_rate / 2.0 + } + + fn sine_wave(&mut self) -> f32 { + self.advance_sample(); + self.calculate_sine_output_from_freq(self.frequency_hz) + } + + fn generative_waveform(&mut self, harmonic_index_increment: i32, gain_exponent: f32) -> f32 { + self.advance_sample(); + let mut output = 0.0; + let mut i = 1; + while !self.is_multiple_of_freq_above_nyquist(i as f32) { + let gain = 1.0 / (i as f32).powf(gain_exponent); + output += gain * self.calculate_sine_output_from_freq(self.frequency_hz * i as f32); + i += harmonic_index_increment; + } + output + } + + fn square_wave(&mut self) -> f32 { + self.generative_waveform(2, 1.0) + } + + fn saw_wave(&mut self) -> f32 { + self.generative_waveform(1, 1.0) + } + + fn triangle_wave(&mut self) -> f32 { + self.generative_waveform(2, 2.0) + } + + fn tick(&mut self) -> f32 { + match self.waveform { + Waveform::Sine => self.sine_wave(), + Waveform::Square => self.square_wave(), + Waveform::Saw => self.saw_wave(), + Waveform::Triangle => self.triangle_wave(), + } + } +} + +pub fn stream_setup_for() -> Result +where +{ + let (_host, device, config) = host_device_setup()?; + + match config.sample_format() { + cpal::SampleFormat::I8 => make_stream::(&device, &config.into()), + cpal::SampleFormat::I16 => make_stream::(&device, &config.into()), + cpal::SampleFormat::I32 => make_stream::(&device, &config.into()), + cpal::SampleFormat::I64 => make_stream::(&device, &config.into()), + cpal::SampleFormat::U8 => make_stream::(&device, &config.into()), + cpal::SampleFormat::U16 => make_stream::(&device, &config.into()), + cpal::SampleFormat::U32 => make_stream::(&device, &config.into()), + cpal::SampleFormat::U64 => make_stream::(&device, &config.into()), + cpal::SampleFormat::F32 => make_stream::(&device, &config.into()), + cpal::SampleFormat::F64 => make_stream::(&device, &config.into()), + sample_format => Err(anyhow::Error::msg(format!( + "Unsupported sample format '{sample_format}'" + ))), + } +} + +pub fn host_device_setup( +) -> Result<(cpal::Host, cpal::Device, cpal::SupportedStreamConfig), anyhow::Error> { + let host = cpal::default_host(); + + let device = host + .default_output_device() + .ok_or_else(|| anyhow::Error::msg("Default output device is not available"))?; + println!("Output device : {}", device.name()?); + + let config = device.default_output_config()?; + println!("Default output config : {:?}", config); + + Ok((host, device, config)) +} + +pub fn make_stream( + device: &cpal::Device, + config: &cpal::StreamConfig, +) -> Result +where + T: SizedSample + FromSample, +{ + let num_channels = config.channels as usize; + let mut oscillator = Oscillator { + waveform: Waveform::Sine, + sample_rate: config.sample_rate.0 as f32, + current_sample_index: 0.0, + frequency_hz: 440.0, + }; + let err_fn = |err| eprintln!("Error building output sound stream: {}", err); + + let time_at_start = std::time::Instant::now(); + println!("Time at start: {:?}", time_at_start); + + let stream = device.build_output_stream( + config, + move |output: &mut [T], _: &cpal::OutputCallbackInfo| { + // for 0-1s play sine, 1-2s play square, 2-3s play saw, 3-4s play triangle_wave + let time_since_start = std::time::Instant::now() + .duration_since(time_at_start) + .as_secs_f32(); + if time_since_start < 1.0 { + oscillator.set_waveform(Waveform::Sine); + } else if time_since_start < 2.0 { + oscillator.set_waveform(Waveform::Triangle); + } else if time_since_start < 3.0 { + oscillator.set_waveform(Waveform::Square); + } else if time_since_start < 4.0 { + oscillator.set_waveform(Waveform::Saw); + } else { + oscillator.set_waveform(Waveform::Sine); + } + process_frame(output, &mut oscillator, num_channels) + }, + err_fn, + None, + )?; + + Ok(stream) +} + +fn process_frame( + output: &mut [SampleType], + oscillator: &mut Oscillator, + num_channels: usize, +) where + SampleType: Sample + FromSample, +{ + for frame in output.chunks_mut(num_channels) { + let value: SampleType = SampleType::from_sample(oscillator.tick()); + + // copy the same value to all channels + for sample in frame.iter_mut() { + *sample = value; + } + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/error.rs b/openless-all/app/src-tauri/vendor/cpal/src/error.rs new file mode 100644 index 000000000..e92ff4a56 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/error.rs @@ -0,0 +1,292 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +/// The requested host, although supported on this platform, is unavailable. +#[derive(Copy, Clone, Debug)] +pub struct HostUnavailable; + +impl Display for HostUnavailable { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str("the requested host is unavailable") + } +} + +impl Error for HostUnavailable {} + +/// Some error has occurred that is specific to the backend from which it was produced. +/// +/// This error is often used as a catch-all in cases where: +/// +/// - It is unclear exactly what error might be produced by the backend API. +/// - It does not make sense to add a variant to the enclosing error type. +/// - No error was expected to occur at all, but we return an error to avoid the possibility of a +/// `panic!` caused by some unforeseen or unknown reason. +/// +/// **Note:** If you notice a `BackendSpecificError` that you believe could be better handled in a +/// cross-platform manner, please create an issue or submit a pull request with a patch that adds +/// the necessary error variant to the appropriate error enum. +#[derive(Clone, Debug)] +pub struct BackendSpecificError { + pub description: String, +} + +impl Display for BackendSpecificError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "A backend-specific error has occurred: {}", + self.description + ) + } +} + +impl Error for BackendSpecificError {} + +/// An error that might occur while attempting to enumerate the available devices on a system. +#[derive(Clone, Debug)] +pub enum DevicesError { + /// See the [`BackendSpecificError`] docs for more information about this error variant. + BackendSpecific { err: BackendSpecificError }, +} + +impl Display for DevicesError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::BackendSpecific { err } => err.fmt(f), + } + } +} + +impl Error for DevicesError {} + +impl From for DevicesError { + fn from(err: BackendSpecificError) -> Self { + Self::BackendSpecific { err } + } +} + +/// An error that may occur while attempting to retrieve a device name. +#[derive(Clone, Debug)] +pub enum DeviceNameError { + /// See the [`BackendSpecificError`] docs for more information about this error variant. + BackendSpecific { err: BackendSpecificError }, +} + +impl Display for DeviceNameError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::BackendSpecific { err } => err.fmt(f), + } + } +} + +impl Error for DeviceNameError {} + +impl From for DeviceNameError { + fn from(err: BackendSpecificError) -> Self { + Self::BackendSpecific { err } + } +} + +/// Error that can happen when enumerating the list of supported formats. +#[derive(Debug)] +pub enum SupportedStreamConfigsError { + /// The device no longer exists. This can happen if the device is disconnected while the + /// program is running. + DeviceNotAvailable, + /// We called something the C-Layer did not understand + InvalidArgument, + /// See the [`BackendSpecificError`] docs for more information about this error variant. + BackendSpecific { err: BackendSpecificError }, +} + +impl Display for SupportedStreamConfigsError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::BackendSpecific { err } => err.fmt(f), + Self::DeviceNotAvailable => f.write_str("The requested device is no longer available. For example, it has been unplugged."), + Self::InvalidArgument => f.write_str("Invalid argument passed to the backend. For example, this happens when trying to read capture capabilities when the device does not support it.") + } + } +} + +impl Error for SupportedStreamConfigsError {} + +impl From for SupportedStreamConfigsError { + fn from(err: BackendSpecificError) -> Self { + Self::BackendSpecific { err } + } +} + +/// May occur when attempting to request the default input or output stream format from a [`Device`](crate::Device). +#[derive(Debug)] +pub enum DefaultStreamConfigError { + /// The device no longer exists. This can happen if the device is disconnected while the + /// program is running. + DeviceNotAvailable, + /// Returned if e.g. the default input format was requested on an output-only audio device. + StreamTypeNotSupported, + /// See the [`BackendSpecificError`] docs for more information about this error variant. + BackendSpecific { err: BackendSpecificError }, +} + +impl Display for DefaultStreamConfigError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::BackendSpecific { err } => err.fmt(f), + DefaultStreamConfigError::DeviceNotAvailable => f.write_str( + "The requested device is no longer available. For example, it has been unplugged.", + ), + DefaultStreamConfigError::StreamTypeNotSupported => { + f.write_str("The requested stream type is not supported by the device.") + } + } + } +} + +impl Error for DefaultStreamConfigError {} + +impl From for DefaultStreamConfigError { + fn from(err: BackendSpecificError) -> Self { + Self::BackendSpecific { err } + } +} +/// Error that can happen when creating a [`Stream`](crate::Stream). +#[derive(Debug)] +pub enum BuildStreamError { + /// The device no longer exists. This can happen if the device is disconnected while the + /// program is running. + DeviceNotAvailable, + /// The specified stream configuration is not supported. + StreamConfigNotSupported, + /// We called something the C-Layer did not understand + /// + /// On ALSA device functions called with a feature they do not support will yield this. E.g. + /// Trying to use capture capabilities on an output only format yields this. + InvalidArgument, + /// Occurs if adding a new Stream ID would cause an integer overflow. + StreamIdOverflow, + /// See the [`BackendSpecificError`] docs for more information about this error variant. + BackendSpecific { err: BackendSpecificError }, +} + +impl Display for BuildStreamError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::BackendSpecific { err } => err.fmt(f), + BuildStreamError::DeviceNotAvailable => f.write_str( + "The requested device is no longer available. For example, it has been unplugged.", + ), + BuildStreamError::StreamConfigNotSupported => { + f.write_str("The requested stream configuration is not supported by the device.") + } + BuildStreamError::InvalidArgument => f.write_str( + "The requested device does not support this capability (invalid argument)", + ), + BuildStreamError::StreamIdOverflow => { + f.write_str("Adding a new stream ID would cause an overflow") + } + } + } +} + +impl Error for BuildStreamError {} + +impl From for BuildStreamError { + fn from(err: BackendSpecificError) -> Self { + Self::BackendSpecific { err } + } +} + +/// Errors that might occur when calling [`Stream::play()`](crate::traits::StreamTrait::play). +/// +/// As of writing this, only macOS may immediately return an error while calling this method. This +/// is because both the alsa and wasapi backends only enqueue these commands and do not process +/// them immediately. +#[derive(Debug)] +pub enum PlayStreamError { + /// The device associated with the stream is no longer available. + DeviceNotAvailable, + /// See the [`BackendSpecificError`] docs for more information about this error variant. + BackendSpecific { err: BackendSpecificError }, +} + +impl Display for PlayStreamError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::BackendSpecific { err } => err.fmt(f), + PlayStreamError::DeviceNotAvailable => { + f.write_str("the device associated with the stream is no longer available") + } + } + } +} + +impl Error for PlayStreamError {} + +impl From for PlayStreamError { + fn from(err: BackendSpecificError) -> Self { + Self::BackendSpecific { err } + } +} + +/// Errors that might occur when calling [`Stream::pause()`](crate::traits::StreamTrait::pause). +/// +/// As of writing this, only macOS may immediately return an error while calling this method. This +/// is because both the alsa and wasapi backends only enqueue these commands and do not process +/// them immediately. +#[derive(Debug)] +pub enum PauseStreamError { + /// The device associated with the stream is no longer available. + DeviceNotAvailable, + /// See the [`BackendSpecificError`] docs for more information about this error variant. + BackendSpecific { err: BackendSpecificError }, +} + +impl Display for PauseStreamError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::BackendSpecific { err } => err.fmt(f), + PauseStreamError::DeviceNotAvailable => { + f.write_str("the device associated with the stream is no longer available") + } + } + } +} + +impl Error for PauseStreamError {} + +impl From for PauseStreamError { + fn from(err: BackendSpecificError) -> Self { + Self::BackendSpecific { err } + } +} + +/// Errors that might occur while a stream is running. +#[derive(Debug)] +pub enum StreamError { + /// The device no longer exists. This can happen if the device is disconnected while the + /// program is running. + DeviceNotAvailable, + /// See the [`BackendSpecificError`] docs for more information about this error variant. + BackendSpecific { err: BackendSpecificError }, +} + +impl Display for StreamError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::BackendSpecific { err } => err.fmt(f), + StreamError::DeviceNotAvailable => f.write_str( + "The requested device is no longer available. For example, it has been unplugged.", + ), + } + } +} + +impl Error for StreamError {} + +impl From for StreamError { + fn from(err: BackendSpecificError) -> Self { + Self::BackendSpecific { err } + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/alsa/enumerate.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/alsa/enumerate.rs new file mode 100644 index 000000000..3032861c9 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/alsa/enumerate.rs @@ -0,0 +1,70 @@ +use super::alsa; +use super::{Device, DeviceHandles}; +use crate::{BackendSpecificError, DevicesError}; +use std::sync::{Arc, Mutex}; + +/// ALSA's implementation for `Devices`. +pub struct Devices { + hint_iter: alsa::device_name::HintIter, +} + +impl Devices { + pub fn new() -> Result { + Ok(Devices { + hint_iter: alsa::device_name::HintIter::new_str(None, "pcm")?, + }) + } +} + +unsafe impl Send for Devices {} +unsafe impl Sync for Devices {} + +impl Iterator for Devices { + type Item = Device; + + fn next(&mut self) -> Option { + loop { + match self.hint_iter.next() { + None => return None, + Some(hint) => { + let name = match hint.name { + None => continue, + // Ignoring the `null` device. + Some(name) if name == "null" => continue, + Some(name) => name, + }; + + if let Ok(handles) = DeviceHandles::open(&name) { + return Some(Device { + name, + handles: Arc::new(Mutex::new(handles)), + }); + } + } + } + } + } +} + +#[inline] +pub fn default_input_device() -> Option { + Some(Device { + name: "default".to_owned(), + handles: Arc::new(Mutex::new(Default::default())), + }) +} + +#[inline] +pub fn default_output_device() -> Option { + Some(Device { + name: "default".to_owned(), + handles: Arc::new(Mutex::new(Default::default())), + }) +} + +impl From for DevicesError { + fn from(err: alsa::Error) -> Self { + let err: BackendSpecificError = err.into(); + err.into() + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/alsa/mod.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/alsa/mod.rs new file mode 100644 index 000000000..009ddef95 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/alsa/mod.rs @@ -0,0 +1,1163 @@ +extern crate alsa; +extern crate libc; + +use self::alsa::poll::Descriptors; +use crate::traits::{DeviceTrait, HostTrait, StreamTrait}; +use crate::{ + BackendSpecificError, BufferSize, BuildStreamError, ChannelCount, Data, + DefaultStreamConfigError, DeviceNameError, DevicesError, InputCallbackInfo, OutputCallbackInfo, + PauseStreamError, PlayStreamError, SampleFormat, SampleRate, StreamConfig, StreamError, + SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange, + SupportedStreamConfigsError, +}; +use std::cmp; +use std::convert::TryInto; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; +use std::vec::IntoIter as VecIntoIter; + +pub use self::enumerate::{default_input_device, default_output_device, Devices}; + +pub type SupportedInputConfigs = VecIntoIter; +pub type SupportedOutputConfigs = VecIntoIter; + +mod enumerate; + +/// The default linux, dragonfly, freebsd and netbsd host type. +#[derive(Debug)] +pub struct Host; + +impl Host { + pub fn new() -> Result { + Ok(Host) + } +} + +impl HostTrait for Host { + type Devices = Devices; + type Device = Device; + + fn is_available() -> bool { + // Assume ALSA is always available on linux/dragonfly/freebsd/netbsd. + true + } + + fn devices(&self) -> Result { + Devices::new() + } + + fn default_input_device(&self) -> Option { + default_input_device() + } + + fn default_output_device(&self) -> Option { + default_output_device() + } +} + +impl DeviceTrait for Device { + type SupportedInputConfigs = SupportedInputConfigs; + type SupportedOutputConfigs = SupportedOutputConfigs; + type Stream = Stream; + + fn name(&self) -> Result { + Device::name(self) + } + + fn supported_input_configs( + &self, + ) -> Result { + Device::supported_input_configs(self) + } + + fn supported_output_configs( + &self, + ) -> Result { + Device::supported_output_configs(self) + } + + fn default_input_config(&self) -> Result { + Device::default_input_config(self) + } + + fn default_output_config(&self) -> Result { + Device::default_output_config(self) + } + + fn build_input_stream_raw( + &self, + conf: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + let stream_inner = + self.build_stream_inner(conf, sample_format, alsa::Direction::Capture)?; + let stream = Stream::new_input( + Arc::new(stream_inner), + data_callback, + error_callback, + timeout, + ); + Ok(stream) + } + + fn build_output_stream_raw( + &self, + conf: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + let stream_inner = + self.build_stream_inner(conf, sample_format, alsa::Direction::Playback)?; + let stream = Stream::new_output( + Arc::new(stream_inner), + data_callback, + error_callback, + timeout, + ); + Ok(stream) + } +} + +struct TriggerSender(libc::c_int); + +struct TriggerReceiver(libc::c_int); + +impl TriggerSender { + fn wakeup(&self) { + let buf = 1u64; + let ret = unsafe { libc::write(self.0, &buf as *const u64 as *const _, 8) }; + assert_eq!(ret, 8); + } +} + +impl TriggerReceiver { + fn clear_pipe(&self) { + let mut out = 0u64; + let ret = unsafe { libc::read(self.0, &mut out as *mut u64 as *mut _, 8) }; + assert_eq!(ret, 8); + } +} + +fn trigger() -> (TriggerSender, TriggerReceiver) { + let mut fds = [0, 0]; + match unsafe { libc::pipe(fds.as_mut_ptr()) } { + 0 => (TriggerSender(fds[1]), TriggerReceiver(fds[0])), + _ => panic!("Could not create pipe"), + } +} + +impl Drop for TriggerSender { + fn drop(&mut self) { + unsafe { + libc::close(self.0); + } + } +} + +impl Drop for TriggerReceiver { + fn drop(&mut self) { + unsafe { + libc::close(self.0); + } + } +} + +#[derive(Default)] +struct DeviceHandles { + playback: Option, + capture: Option, +} + +impl DeviceHandles { + /// Create `DeviceHandles` for `name` and try to open a handle for both + /// directions. Returns `Ok` if either direction is opened successfully. + fn open(name: &str) -> Result { + let mut handles = Self::default(); + let playback_err = handles.try_open(name, alsa::Direction::Playback).err(); + let capture_err = handles.try_open(name, alsa::Direction::Capture).err(); + if let Some(err) = capture_err.and(playback_err) { + Err(err) + } else { + Ok(handles) + } + } + + /// Get a mutable reference to the `Option` for a specific `stream_type`. + /// If the `Option` is `None`, the `alsa::PCM` will be opened and placed in + /// the `Option` before returning. If `handle_mut()` returns `Ok` the contained + /// `Option` is guaranteed to be `Some(..)`. + fn try_open( + &mut self, + name: &str, + stream_type: alsa::Direction, + ) -> Result<&mut Option, alsa::Error> { + let handle = match stream_type { + alsa::Direction::Playback => &mut self.playback, + alsa::Direction::Capture => &mut self.capture, + }; + + if handle.is_none() { + *handle = Some(alsa::pcm::PCM::new(name, stream_type, true)?); + } + + Ok(handle) + } + + /// Get a mutable reference to the `alsa::PCM` handle for a specific `stream_type`. + /// If the handle is not yet opened, it will be opened and stored in `self`. + fn get_mut( + &mut self, + name: &str, + stream_type: alsa::Direction, + ) -> Result<&mut alsa::PCM, alsa::Error> { + Ok(self.try_open(name, stream_type)?.as_mut().unwrap()) + } + + /// Take ownership of the `alsa::PCM` handle for a specific `stream_type`. + /// If the handle is not yet opened, it will be opened and returned. + fn take(&mut self, name: &str, stream_type: alsa::Direction) -> Result { + Ok(self.try_open(name, stream_type)?.take().unwrap()) + } +} + +#[derive(Clone)] +pub struct Device { + name: String, + handles: Arc>, +} + +impl Device { + fn build_stream_inner( + &self, + conf: &StreamConfig, + sample_format: SampleFormat, + stream_type: alsa::Direction, + ) -> Result { + let handle_result = self + .handles + .lock() + .unwrap() + .take(&self.name, stream_type) + .map_err(|e| (e, e.errno())); + + let handle = match handle_result { + Err((_, libc::EBUSY)) => return Err(BuildStreamError::DeviceNotAvailable), + Err((_, libc::EINVAL)) => return Err(BuildStreamError::InvalidArgument), + Err((e, _)) => return Err(e.into()), + Ok(handle) => handle, + }; + let can_pause = set_hw_params_from_format(&handle, conf, sample_format)?; + let period_len = set_sw_params_from_format(&handle, conf, stream_type)?; + + handle.prepare()?; + + let num_descriptors = handle.count(); + if num_descriptors == 0 { + let description = "poll descriptor count for stream was 0".to_string(); + let err = BackendSpecificError { description }; + return Err(err.into()); + } + + // Check to see if we can retrieve valid timestamps from the device. + // Related: https://bugs.freedesktop.org/show_bug.cgi?id=88503 + let ts = handle.status()?.get_htstamp(); + let creation_instant = match (ts.tv_sec, ts.tv_nsec) { + (0, 0) => Some(std::time::Instant::now()), + _ => None, + }; + + if let alsa::Direction::Capture = stream_type { + handle.start()?; + } + + let stream_inner = StreamInner { + channel: handle, + sample_format, + num_descriptors, + conf: conf.clone(), + period_len, + can_pause, + creation_instant, + }; + + Ok(stream_inner) + } + + #[inline] + fn name(&self) -> Result { + Ok(self.name.clone()) + } + + fn supported_configs( + &self, + stream_t: alsa::Direction, + ) -> Result, SupportedStreamConfigsError> { + let mut guard = self.handles.lock().unwrap(); + let handle_result = guard + .get_mut(&self.name, stream_t) + .map_err(|e| (e, e.errno())); + + let handle = match handle_result { + Err((_, libc::ENOENT)) | Err((_, libc::EBUSY)) => { + return Err(SupportedStreamConfigsError::DeviceNotAvailable) + } + Err((_, libc::EINVAL)) => return Err(SupportedStreamConfigsError::InvalidArgument), + Err((e, _)) => return Err(e.into()), + Ok(handle) => handle, + }; + + let hw_params = alsa::pcm::HwParams::any(handle)?; + + // TODO: check endianness + const FORMATS: [(SampleFormat, alsa::pcm::Format); 8] = [ + (SampleFormat::I8, alsa::pcm::Format::S8), + (SampleFormat::U8, alsa::pcm::Format::U8), + (SampleFormat::I16, alsa::pcm::Format::S16LE), + //SND_PCM_FORMAT_S16_BE, + (SampleFormat::U16, alsa::pcm::Format::U16LE), + //SND_PCM_FORMAT_U16_BE, + //SND_PCM_FORMAT_S24_LE, + //SND_PCM_FORMAT_S24_BE, + //SND_PCM_FORMAT_U24_LE, + //SND_PCM_FORMAT_U24_BE, + (SampleFormat::I32, alsa::pcm::Format::S32LE), + //SND_PCM_FORMAT_S32_BE, + (SampleFormat::U32, alsa::pcm::Format::U32LE), + //SND_PCM_FORMAT_U32_BE, + (SampleFormat::F32, alsa::pcm::Format::FloatLE), + //SND_PCM_FORMAT_FLOAT_BE, + (SampleFormat::F64, alsa::pcm::Format::Float64LE), + //SND_PCM_FORMAT_FLOAT64_BE, + //SND_PCM_FORMAT_IEC958_SUBFRAME_LE, + //SND_PCM_FORMAT_IEC958_SUBFRAME_BE, + //SND_PCM_FORMAT_MU_LAW, + //SND_PCM_FORMAT_A_LAW, + //SND_PCM_FORMAT_IMA_ADPCM, + //SND_PCM_FORMAT_MPEG, + //SND_PCM_FORMAT_GSM, + //SND_PCM_FORMAT_SPECIAL, + //SND_PCM_FORMAT_S24_3LE, + //SND_PCM_FORMAT_S24_3BE, + //SND_PCM_FORMAT_U24_3LE, + //SND_PCM_FORMAT_U24_3BE, + //SND_PCM_FORMAT_S20_3LE, + //SND_PCM_FORMAT_S20_3BE, + //SND_PCM_FORMAT_U20_3LE, + //SND_PCM_FORMAT_U20_3BE, + //SND_PCM_FORMAT_S18_3LE, + //SND_PCM_FORMAT_S18_3BE, + //SND_PCM_FORMAT_U18_3LE, + //SND_PCM_FORMAT_U18_3BE, + ]; + + let mut supported_formats = Vec::new(); + for &(sample_format, alsa_format) in FORMATS.iter() { + if hw_params.test_format(alsa_format).is_ok() { + supported_formats.push(sample_format); + } + } + + let min_rate = hw_params.get_rate_min()?; + let max_rate = hw_params.get_rate_max()?; + + let sample_rates = if min_rate == max_rate || hw_params.test_rate(min_rate + 1).is_ok() { + vec![(min_rate, max_rate)] + } else { + const RATES: [libc::c_uint; 13] = [ + 5512, 8000, 11025, 16000, 22050, 32000, 44100, 48000, 64000, 88200, 96000, 176400, + 192000, + ]; + + let mut rates = Vec::new(); + for &rate in RATES.iter() { + if hw_params.test_rate(rate).is_ok() { + rates.push((rate, rate)); + } + } + + if rates.is_empty() { + vec![(min_rate, max_rate)] + } else { + rates + } + }; + + let min_channels = hw_params.get_channels_min()?; + let max_channels = hw_params.get_channels_max()?; + + let max_channels = cmp::min(max_channels, 32); // TODO: limiting to 32 channels or too much stuff is returned + let supported_channels = (min_channels..max_channels + 1) + .filter_map(|num| { + if hw_params.test_channels(num).is_ok() { + Some(num as ChannelCount) + } else { + None + } + }) + .collect::>(); + + let min_buffer_size = hw_params.get_buffer_size_min()?; + let max_buffer_size = hw_params.get_buffer_size_max()?; + + let buffer_size_range = SupportedBufferSize::Range { + min: min_buffer_size as u32, + max: max_buffer_size as u32, + }; + + let mut output = Vec::with_capacity( + supported_formats.len() * supported_channels.len() * sample_rates.len(), + ); + for &sample_format in supported_formats.iter() { + for &channels in supported_channels.iter() { + for &(min_rate, max_rate) in sample_rates.iter() { + output.push(SupportedStreamConfigRange { + channels, + min_sample_rate: SampleRate(min_rate as u32), + max_sample_rate: SampleRate(max_rate as u32), + buffer_size: buffer_size_range.clone(), + sample_format, + }); + } + } + } + + Ok(output.into_iter()) + } + + fn supported_input_configs( + &self, + ) -> Result { + self.supported_configs(alsa::Direction::Capture) + } + + fn supported_output_configs( + &self, + ) -> Result { + self.supported_configs(alsa::Direction::Playback) + } + + // ALSA does not offer default stream formats, so instead we compare all supported formats by + // the `SupportedStreamConfigRange::cmp_default_heuristics` order and select the greatest. + fn default_config( + &self, + stream_t: alsa::Direction, + ) -> Result { + let mut formats: Vec<_> = { + match self.supported_configs(stream_t) { + Err(SupportedStreamConfigsError::DeviceNotAvailable) => { + return Err(DefaultStreamConfigError::DeviceNotAvailable); + } + Err(SupportedStreamConfigsError::InvalidArgument) => { + // this happens sometimes when querying for input and output capabilities, but + // the device supports only one + return Err(DefaultStreamConfigError::StreamTypeNotSupported); + } + Err(SupportedStreamConfigsError::BackendSpecific { err }) => { + return Err(err.into()); + } + Ok(fmts) => fmts.collect(), + } + }; + + formats.sort_by(|a, b| a.cmp_default_heuristics(b)); + + match formats.into_iter().last() { + Some(f) => { + let min_r = f.min_sample_rate; + let max_r = f.max_sample_rate; + let mut format = f.with_max_sample_rate(); + const HZ_44100: SampleRate = SampleRate(44_100); + if min_r <= HZ_44100 && HZ_44100 <= max_r { + format.sample_rate = HZ_44100; + } + Ok(format) + } + None => Err(DefaultStreamConfigError::StreamTypeNotSupported), + } + } + + fn default_input_config(&self) -> Result { + self.default_config(alsa::Direction::Capture) + } + + fn default_output_config(&self) -> Result { + self.default_config(alsa::Direction::Playback) + } +} + +struct StreamInner { + // The ALSA channel. + channel: alsa::pcm::PCM, + + // When converting between file descriptors and `snd_pcm_t`, this is the number of + // file descriptors that this `snd_pcm_t` uses. + num_descriptors: usize, + + // Format of the samples. + sample_format: SampleFormat, + + // The configuration used to open this stream. + conf: StreamConfig, + + // Minimum number of samples to put in the buffer. + period_len: usize, + + #[allow(dead_code)] + // Whether or not the hardware supports pausing the stream. + // TODO: We need an API to expose this. See #197, #284. + can_pause: bool, + + // In the case that the device does not return valid timestamps via `get_htstamp`, this field + // will be `Some` and will contain an `Instant` representing the moment the stream was created. + // + // If this field is `Some`, then the stream will use the duration since this instant as a + // source for timestamps. + // + // If this field is `None` then the elapsed duration between `get_trigger_htstamp` and + // `get_htstamp` is used. + creation_instant: Option, +} + +// Assume that the ALSA library is built with thread safe option. +unsafe impl Sync for StreamInner {} + +#[derive(Debug, Eq, PartialEq)] +enum StreamType { + Input, + Output, +} + +pub struct Stream { + /// The high-priority audio processing thread calling callbacks. + /// Option used for moving out in destructor. + thread: Option>, + + /// Handle to the underlying stream for playback controls. + inner: Arc, + + /// Used to signal to stop processing. + trigger: TriggerSender, +} + +struct StreamWorkerContext { + descriptors: Vec, + buffer: Vec, + poll_timeout: i32, +} + +impl StreamWorkerContext { + fn new(poll_timeout: &Option) -> Self { + let poll_timeout: i32 = if let Some(d) = poll_timeout { + d.as_millis().try_into().unwrap() + } else { + -1 + }; + + Self { + descriptors: Vec::new(), + buffer: Vec::new(), + poll_timeout, + } + } +} + +fn input_stream_worker( + rx: TriggerReceiver, + stream: &StreamInner, + data_callback: &mut (dyn FnMut(&Data, &InputCallbackInfo) + Send + 'static), + error_callback: &mut (dyn FnMut(StreamError) + Send + 'static), + timeout: Option, +) { + let mut ctxt = StreamWorkerContext::new(&timeout); + loop { + let flow = + poll_descriptors_and_prepare_buffer(&rx, stream, &mut ctxt).unwrap_or_else(|err| { + error_callback(err.into()); + PollDescriptorsFlow::Continue + }); + + match flow { + PollDescriptorsFlow::Continue => { + continue; + } + PollDescriptorsFlow::XRun => { + if let Err(err) = stream.channel.prepare() { + error_callback(err.into()); + } + continue; + } + PollDescriptorsFlow::Return => return, + PollDescriptorsFlow::Ready { + status, + avail_frames: _, + delay_frames, + stream_type, + } => { + assert_eq!( + stream_type, + StreamType::Input, + "expected input stream, but polling descriptors indicated output", + ); + if let Err(err) = process_input( + stream, + &mut ctxt.buffer, + status, + delay_frames, + data_callback, + ) { + error_callback(err.into()); + } + } + } + } +} + +fn output_stream_worker( + rx: TriggerReceiver, + stream: &StreamInner, + data_callback: &mut (dyn FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static), + error_callback: &mut (dyn FnMut(StreamError) + Send + 'static), + timeout: Option, +) { + let mut ctxt = StreamWorkerContext::new(&timeout); + loop { + let flow = + poll_descriptors_and_prepare_buffer(&rx, stream, &mut ctxt).unwrap_or_else(|err| { + error_callback(err.into()); + PollDescriptorsFlow::Continue + }); + + match flow { + PollDescriptorsFlow::Continue => continue, + PollDescriptorsFlow::XRun => { + if let Err(err) = stream.channel.prepare() { + error_callback(err.into()); + } + continue; + } + PollDescriptorsFlow::Return => return, + PollDescriptorsFlow::Ready { + status, + avail_frames, + delay_frames, + stream_type, + } => { + assert_eq!( + stream_type, + StreamType::Output, + "expected output stream, but polling descriptors indicated input", + ); + if let Err(err) = process_output( + stream, + &mut ctxt.buffer, + status, + avail_frames, + delay_frames, + data_callback, + error_callback, + ) { + error_callback(err.into()); + } + } + } + } +} + +enum PollDescriptorsFlow { + Continue, + Return, + Ready { + stream_type: StreamType, + status: alsa::pcm::Status, + avail_frames: usize, + delay_frames: usize, + }, + XRun, +} + +// This block is shared between both input and output stream worker functions. +fn poll_descriptors_and_prepare_buffer( + rx: &TriggerReceiver, + stream: &StreamInner, + ctxt: &mut StreamWorkerContext, +) -> Result { + let StreamWorkerContext { + ref mut descriptors, + ref mut buffer, + ref poll_timeout, + } = *ctxt; + + descriptors.clear(); + + // Add the self-pipe for signaling termination. + descriptors.push(libc::pollfd { + fd: rx.0, + events: libc::POLLIN, + revents: 0, + }); + + // Add ALSA polling fds. + let len = descriptors.len(); + descriptors.resize( + stream.num_descriptors + len, + libc::pollfd { + fd: 0, + events: 0, + revents: 0, + }, + ); + let filled = stream.channel.fill(&mut descriptors[len..])?; + debug_assert_eq!(filled, stream.num_descriptors); + + // Don't timeout, wait forever. + let res = alsa::poll::poll(descriptors, *poll_timeout)?; + if res == 0 { + let description = String::from("`alsa::poll()` spuriously returned"); + return Err(BackendSpecificError { description }); + } + + if descriptors[0].revents != 0 { + // The stream has been requested to be destroyed. + rx.clear_pipe(); + return Ok(PollDescriptorsFlow::Return); + } + + let revents = stream.channel.revents(&descriptors[1..])?; + if revents.contains(alsa::poll::Flags::ERR) { + let description = String::from("`alsa::poll()` returned POLLERR"); + return Err(BackendSpecificError { description }); + } + let stream_type = match revents { + alsa::poll::Flags::OUT => StreamType::Output, + alsa::poll::Flags::IN => StreamType::Input, + _ => { + // Nothing to process, poll again + return Ok(PollDescriptorsFlow::Continue); + } + }; + + let status = stream.channel.status()?; + let avail_frames = match stream.channel.avail() { + Err(err) if err.errno() == libc::EPIPE => return Ok(PollDescriptorsFlow::XRun), + res => res, + }? as usize; + let delay_frames = match status.get_delay() { + // Buffer underrun. TODO: Notify the user. + d if d < 0 => 0, + d => d as usize, + }; + let available_samples = avail_frames * stream.conf.channels as usize; + + // Only go on if there is at least `stream.period_len` samples. + if available_samples < stream.period_len { + return Ok(PollDescriptorsFlow::Continue); + } + + // Prepare the data buffer. + let buffer_size = stream.sample_format.sample_size() * available_samples; + buffer.resize(buffer_size, 0u8); + + Ok(PollDescriptorsFlow::Ready { + stream_type, + status, + avail_frames, + delay_frames, + }) +} + +// Read input data from ALSA and deliver it to the user. +fn process_input( + stream: &StreamInner, + buffer: &mut [u8], + status: alsa::pcm::Status, + delay_frames: usize, + data_callback: &mut (dyn FnMut(&Data, &InputCallbackInfo) + Send + 'static), +) -> Result<(), BackendSpecificError> { + stream.channel.io_bytes().readi(buffer)?; + let sample_format = stream.sample_format; + let data = buffer.as_mut_ptr() as *mut (); + let len = buffer.len() / sample_format.sample_size(); + let data = unsafe { Data::from_parts(data, len, sample_format) }; + let callback = stream_timestamp(&status, stream.creation_instant)?; + let delay_duration = frames_to_duration(delay_frames, stream.conf.sample_rate); + let capture = callback + .sub(delay_duration) + .expect("`capture` is earlier than representation supported by `StreamInstant`"); + let timestamp = crate::InputStreamTimestamp { callback, capture }; + let info = crate::InputCallbackInfo { timestamp }; + data_callback(&data, &info); + + Ok(()) +} + +// Request data from the user's function and write it via ALSA. +// +// Returns `true` +fn process_output( + stream: &StreamInner, + buffer: &mut [u8], + status: alsa::pcm::Status, + available_frames: usize, + delay_frames: usize, + data_callback: &mut (dyn FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static), + error_callback: &mut dyn FnMut(StreamError), +) -> Result<(), BackendSpecificError> { + { + // We're now sure that we're ready to write data. + let sample_format = stream.sample_format; + let data = buffer.as_mut_ptr() as *mut (); + let len = buffer.len() / sample_format.sample_size(); + let mut data = unsafe { Data::from_parts(data, len, sample_format) }; + let callback = stream_timestamp(&status, stream.creation_instant)?; + let delay_duration = frames_to_duration(delay_frames, stream.conf.sample_rate); + let playback = callback + .add(delay_duration) + .expect("`playback` occurs beyond representation supported by `StreamInstant`"); + let timestamp = crate::OutputStreamTimestamp { callback, playback }; + let info = crate::OutputCallbackInfo { timestamp }; + data_callback(&mut data, &info); + } + loop { + match stream.channel.io_bytes().writei(buffer) { + Err(err) if err.errno() == libc::EPIPE => { + // buffer underrun + // TODO: Notify the user of this. + let _ = stream.channel.try_recover(err, false); + } + Err(err) => { + error_callback(err.into()); + continue; + } + Ok(result) if result != available_frames => { + let description = format!( + "unexpected number of frames written: expected {}, \ + result {} (this should never happen)", + available_frames, result, + ); + error_callback(BackendSpecificError { description }.into()); + continue; + } + _ => { + break; + } + } + } + Ok(()) +} + +// Use the elapsed duration since the start of the stream. +// +// This ensures positive values that are compatible with our `StreamInstant` representation. +fn stream_timestamp( + status: &alsa::pcm::Status, + creation_instant: Option, +) -> Result { + match creation_instant { + None => { + let trigger_ts = status.get_trigger_htstamp(); + let ts = status.get_htstamp(); + let nanos = timespec_diff_nanos(ts, trigger_ts); + if nanos < 0 { + panic!( + "get_htstamp `{}.{}` was earlier than get_trigger_htstamp `{}.{}`", + ts.tv_sec, ts.tv_nsec, trigger_ts.tv_sec, trigger_ts.tv_nsec + ); + } + Ok(crate::StreamInstant::from_nanos(nanos)) + } + Some(creation) => { + let now = std::time::Instant::now(); + let duration = now.duration_since(creation); + let instant = crate::StreamInstant::from_nanos_i128(duration.as_nanos() as i128) + .expect("stream duration has exceeded `StreamInstant` representation"); + Ok(instant) + } + } +} + +// Adapted from `timestamp2ns` here: +// https://fossies.org/linux/alsa-lib/test/audio_time.c +fn timespec_to_nanos(ts: libc::timespec) -> i64 { + ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64 +} + +// Adapted from `timediff` here: +// https://fossies.org/linux/alsa-lib/test/audio_time.c +fn timespec_diff_nanos(a: libc::timespec, b: libc::timespec) -> i64 { + timespec_to_nanos(a) - timespec_to_nanos(b) +} + +// Convert the given duration in frames at the given sample rate to a `std::time::Duration`. +fn frames_to_duration(frames: usize, rate: crate::SampleRate) -> std::time::Duration { + let secsf = frames as f64 / rate.0 as f64; + let secs = secsf as u64; + let nanos = ((secsf - secs as f64) * 1_000_000_000.0) as u32; + std::time::Duration::new(secs, nanos) +} + +impl Stream { + fn new_input( + inner: Arc, + mut data_callback: D, + mut error_callback: E, + timeout: Option, + ) -> Stream + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + let (tx, rx) = trigger(); + // Clone the handle for passing into worker thread. + let stream = inner.clone(); + let thread = thread::Builder::new() + .name("cpal_alsa_in".to_owned()) + .spawn(move || { + input_stream_worker( + rx, + &stream, + &mut data_callback, + &mut error_callback, + timeout, + ); + }) + .unwrap(); + Stream { + thread: Some(thread), + inner, + trigger: tx, + } + } + + fn new_output( + inner: Arc, + mut data_callback: D, + mut error_callback: E, + timeout: Option, + ) -> Stream + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + let (tx, rx) = trigger(); + // Clone the handle for passing into worker thread. + let stream = inner.clone(); + let thread = thread::Builder::new() + .name("cpal_alsa_out".to_owned()) + .spawn(move || { + output_stream_worker( + rx, + &stream, + &mut data_callback, + &mut error_callback, + timeout, + ); + }) + .unwrap(); + Stream { + thread: Some(thread), + inner, + trigger: tx, + } + } +} + +impl Drop for Stream { + fn drop(&mut self) { + self.trigger.wakeup(); + self.thread.take().unwrap().join().unwrap(); + } +} + +impl StreamTrait for Stream { + fn play(&self) -> Result<(), PlayStreamError> { + self.inner.channel.pause(false).ok(); + Ok(()) + } + fn pause(&self) -> Result<(), PauseStreamError> { + self.inner.channel.pause(true).ok(); + Ok(()) + } +} + +fn set_hw_params_from_format( + pcm_handle: &alsa::pcm::PCM, + config: &StreamConfig, + sample_format: SampleFormat, +) -> Result { + let hw_params = alsa::pcm::HwParams::any(pcm_handle)?; + hw_params.set_access(alsa::pcm::Access::RWInterleaved)?; + + let sample_format = if cfg!(target_endian = "big") { + match sample_format { + SampleFormat::I8 => alsa::pcm::Format::S8, + SampleFormat::I16 => alsa::pcm::Format::S16BE, + // SampleFormat::I24 => alsa::pcm::Format::S24BE, + SampleFormat::I32 => alsa::pcm::Format::S32BE, + // SampleFormat::I48 => alsa::pcm::Format::S48BE, + // SampleFormat::I64 => alsa::pcm::Format::S64BE, + SampleFormat::U8 => alsa::pcm::Format::U8, + SampleFormat::U16 => alsa::pcm::Format::U16BE, + // SampleFormat::U24 => alsa::pcm::Format::U24BE, + SampleFormat::U32 => alsa::pcm::Format::U32BE, + // SampleFormat::U48 => alsa::pcm::Format::U48BE, + // SampleFormat::U64 => alsa::pcm::Format::U64BE, + SampleFormat::F32 => alsa::pcm::Format::FloatBE, + SampleFormat::F64 => alsa::pcm::Format::Float64BE, + sample_format => { + return Err(BackendSpecificError { + description: format!( + "Sample format '{}' is not supported by this backend", + sample_format + ), + }) + } + } + } else { + match sample_format { + SampleFormat::I8 => alsa::pcm::Format::S8, + SampleFormat::I16 => alsa::pcm::Format::S16LE, + // SampleFormat::I24 => alsa::pcm::Format::S24LE, + SampleFormat::I32 => alsa::pcm::Format::S32LE, + // SampleFormat::I48 => alsa::pcm::Format::S48LE, + // SampleFormat::I64 => alsa::pcm::Format::S64LE, + SampleFormat::U8 => alsa::pcm::Format::U8, + SampleFormat::U16 => alsa::pcm::Format::U16LE, + // SampleFormat::U24 => alsa::pcm::Format::U24LE, + SampleFormat::U32 => alsa::pcm::Format::U32LE, + // SampleFormat::U48 => alsa::pcm::Format::U48LE, + // SampleFormat::U64 => alsa::pcm::Format::U64LE, + SampleFormat::F32 => alsa::pcm::Format::FloatLE, + SampleFormat::F64 => alsa::pcm::Format::Float64LE, + sample_format => { + return Err(BackendSpecificError { + description: format!( + "Sample format '{}' is not supported by this backend", + sample_format + ), + }) + } + } + }; + + hw_params.set_format(sample_format)?; + hw_params.set_rate(config.sample_rate.0, alsa::ValueOr::Nearest)?; + hw_params.set_channels(config.channels as u32)?; + + match config.buffer_size { + BufferSize::Fixed(v) => { + hw_params.set_period_size_near((v / 4) as alsa::pcm::Frames, alsa::ValueOr::Nearest)?; + hw_params.set_buffer_size(v as alsa::pcm::Frames)?; + } + BufferSize::Default => { + // These values together represent a moderate latency and wakeup interval. + // Without them, we are at the mercy of the device + hw_params.set_period_time_near(25_000, alsa::ValueOr::Nearest)?; + hw_params.set_buffer_time_near(100_000, alsa::ValueOr::Nearest)?; + } + } + + pcm_handle.hw_params(&hw_params)?; + + Ok(hw_params.can_pause()) +} + +fn set_sw_params_from_format( + pcm_handle: &alsa::pcm::PCM, + config: &StreamConfig, + stream_type: alsa::Direction, +) -> Result { + let sw_params = pcm_handle.sw_params_current()?; + + let period_len = { + let (buffer, period) = pcm_handle.get_params()?; + if buffer == 0 { + return Err(BackendSpecificError { + description: "initialization resulted in a null buffer".to_string(), + }); + } + sw_params.set_avail_min(period as alsa::pcm::Frames)?; + + let start_threshold = match stream_type { + alsa::Direction::Playback => buffer - period, + + // For capture streams, the start threshold is irrelevant and ignored, + // because build_stream_inner() starts the stream before process_input() + // reads from it. Set it anyway I guess, since it's better than leaving + // it at an unspecified default value. + alsa::Direction::Capture => 1, + }; + sw_params.set_start_threshold(start_threshold.try_into().unwrap())?; + + period as usize * config.channels as usize + }; + + sw_params.set_tstamp_mode(true)?; + sw_params.set_tstamp_type(alsa::pcm::TstampType::MonotonicRaw)?; + + // tstamp_type param cannot be changed after the device is opened. + // The default tstamp_type value on most Linux systems is "monotonic", + // let's try to use it if setting the tstamp_type fails. + if pcm_handle.sw_params(&sw_params).is_err() { + sw_params.set_tstamp_type(alsa::pcm::TstampType::Monotonic)?; + pcm_handle.sw_params(&sw_params)?; + } + + Ok(period_len) +} + +impl From for BackendSpecificError { + fn from(err: alsa::Error) -> Self { + BackendSpecificError { + description: err.to_string(), + } + } +} + +impl From for BuildStreamError { + fn from(err: alsa::Error) -> Self { + let err: BackendSpecificError = err.into(); + err.into() + } +} + +impl From for SupportedStreamConfigsError { + fn from(err: alsa::Error) -> Self { + let err: BackendSpecificError = err.into(); + err.into() + } +} + +impl From for PlayStreamError { + fn from(err: alsa::Error) -> Self { + let err: BackendSpecificError = err.into(); + err.into() + } +} + +impl From for PauseStreamError { + fn from(err: alsa::Error) -> Self { + let err: BackendSpecificError = err.into(); + err.into() + } +} + +impl From for StreamError { + fn from(err: alsa::Error) -> Self { + let err: BackendSpecificError = err.into(); + err.into() + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/asio/device.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/asio/device.rs new file mode 100644 index 000000000..f503158e8 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/asio/device.rs @@ -0,0 +1,232 @@ +pub type SupportedInputConfigs = std::vec::IntoIter; +pub type SupportedOutputConfigs = std::vec::IntoIter; + +use super::sys; +use crate::BackendSpecificError; +use crate::DefaultStreamConfigError; +use crate::DeviceNameError; +use crate::DevicesError; +use crate::SampleFormat; +use crate::SampleRate; +use crate::SupportedBufferSize; +use crate::SupportedStreamConfig; +use crate::SupportedStreamConfigRange; +use crate::SupportedStreamConfigsError; +use std::hash::{Hash, Hasher}; +use std::sync::atomic::AtomicI32; +use std::sync::{Arc, Mutex}; + +/// A ASIO Device +#[derive(Clone)] +pub struct Device { + /// The driver represented by this device. + pub driver: Arc, + + // Input and/or Output stream. + // A driver can only have one of each. + // They need to be created at the same time. + pub asio_streams: Arc>, + pub current_buffer_index: Arc, +} + +/// All available devices. +pub struct Devices { + asio: Arc, + drivers: std::vec::IntoIter, +} + +impl PartialEq for Device { + fn eq(&self, other: &Self) -> bool { + self.driver.name() == other.driver.name() + } +} + +impl Eq for Device {} + +impl Hash for Device { + fn hash(&self, state: &mut H) { + self.driver.name().hash(state); + } +} + +impl Device { + pub fn name(&self) -> Result { + Ok(self.driver.name().to_string()) + } + + /// Gets the supported input configs. + /// TODO currently only supports the default. + /// Need to find all possible configs. + pub fn supported_input_configs( + &self, + ) -> Result { + // Retrieve the default config for the total supported channels and supported sample + // format. + let f = match self.default_input_config() { + Err(_) => return Err(SupportedStreamConfigsError::DeviceNotAvailable), + Ok(f) => f, + }; + + // Collect a config for every combination of supported sample rate and number of channels. + let mut supported_configs = vec![]; + for &rate in crate::COMMON_SAMPLE_RATES { + if !self + .driver + .can_sample_rate(rate.0.into()) + .ok() + .unwrap_or(false) + { + continue; + } + for channels in 1..f.channels + 1 { + supported_configs.push(SupportedStreamConfigRange { + channels, + min_sample_rate: rate, + max_sample_rate: rate, + buffer_size: f.buffer_size, + sample_format: f.sample_format, + }) + } + } + Ok(supported_configs.into_iter()) + } + + /// Gets the supported output configs. + /// TODO currently only supports the default. + /// Need to find all possible configs. + pub fn supported_output_configs( + &self, + ) -> Result { + // Retrieve the default config for the total supported channels and supported sample + // format. + let f = match self.default_output_config() { + Err(_) => return Err(SupportedStreamConfigsError::DeviceNotAvailable), + Ok(f) => f, + }; + + // Collect a config for every combination of supported sample rate and number of channels. + let mut supported_configs = vec![]; + for &rate in crate::COMMON_SAMPLE_RATES { + if !self + .driver + .can_sample_rate(rate.0.into()) + .ok() + .unwrap_or(false) + { + continue; + } + for channels in 1..f.channels + 1 { + supported_configs.push(SupportedStreamConfigRange { + channels, + min_sample_rate: rate, + max_sample_rate: rate, + buffer_size: f.buffer_size, + sample_format: f.sample_format, + }) + } + } + Ok(supported_configs.into_iter()) + } + + /// Returns the default input config + pub fn default_input_config(&self) -> Result { + let channels = self.driver.channels().map_err(default_config_err)?.ins as u16; + let sample_rate = SampleRate(self.driver.sample_rate().map_err(default_config_err)? as _); + let (min, max) = self.driver.buffersize_range().map_err(default_config_err)?; + let buffer_size = SupportedBufferSize::Range { + min: min as u32, + max: max as u32, + }; + // Map th ASIO sample type to a CPAL sample type + let data_type = self.driver.input_data_type().map_err(default_config_err)?; + let sample_format = convert_data_type(&data_type) + .ok_or(DefaultStreamConfigError::StreamTypeNotSupported)?; + Ok(SupportedStreamConfig { + channels, + sample_rate, + buffer_size, + sample_format, + }) + } + + /// Returns the default output config + pub fn default_output_config(&self) -> Result { + let channels = self.driver.channels().map_err(default_config_err)?.outs as u16; + let sample_rate = SampleRate(self.driver.sample_rate().map_err(default_config_err)? as _); + let (min, max) = self.driver.buffersize_range().map_err(default_config_err)?; + let buffer_size = SupportedBufferSize::Range { + min: min as u32, + max: max as u32, + }; + let data_type = self.driver.output_data_type().map_err(default_config_err)?; + let sample_format = convert_data_type(&data_type) + .ok_or(DefaultStreamConfigError::StreamTypeNotSupported)?; + Ok(SupportedStreamConfig { + channels, + sample_rate, + buffer_size, + sample_format, + }) + } +} + +impl Devices { + pub fn new(asio: Arc) -> Result { + let drivers = asio.driver_names().into_iter(); + Ok(Devices { asio, drivers }) + } +} + +impl Iterator for Devices { + type Item = Device; + + /// Load drivers and return device + fn next(&mut self) -> Option { + loop { + match self.drivers.next() { + Some(name) => match self.asio.load_driver(&name) { + Ok(driver) => { + let driver = Arc::new(driver); + let asio_streams = Arc::new(Mutex::new(sys::AsioStreams { + input: None, + output: None, + })); + return Some(Device { + driver, + asio_streams, + current_buffer_index: Arc::new(AtomicI32::new(-1)), + }); + } + Err(_) => continue, + }, + None => return None, + } + } + } +} + +pub(crate) fn convert_data_type(ty: &sys::AsioSampleType) -> Option { + let fmt = match *ty { + sys::AsioSampleType::ASIOSTInt16MSB => SampleFormat::I16, + sys::AsioSampleType::ASIOSTInt16LSB => SampleFormat::I16, + sys::AsioSampleType::ASIOSTFloat32MSB => SampleFormat::F32, + sys::AsioSampleType::ASIOSTFloat32LSB => SampleFormat::F32, + sys::AsioSampleType::ASIOSTInt32MSB => SampleFormat::I32, + sys::AsioSampleType::ASIOSTInt32LSB => SampleFormat::I32, + _ => return None, + }; + Some(fmt) +} + +fn default_config_err(e: sys::AsioError) -> DefaultStreamConfigError { + match e { + sys::AsioError::NoDrivers | sys::AsioError::HardwareMalfunction => { + DefaultStreamConfigError::DeviceNotAvailable + } + sys::AsioError::NoRate => DefaultStreamConfigError::StreamTypeNotSupported, + err => { + let description = format!("{}", err); + BackendSpecificError { description }.into() + } + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/asio/mod.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/asio/mod.rs new file mode 100644 index 000000000..73d936d99 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/asio/mod.rs @@ -0,0 +1,138 @@ +extern crate asio_sys as sys; + +use crate::traits::{DeviceTrait, HostTrait, StreamTrait}; +use crate::{ + BuildStreamError, Data, DefaultStreamConfigError, DeviceNameError, DevicesError, + InputCallbackInfo, OutputCallbackInfo, PauseStreamError, PlayStreamError, SampleFormat, + StreamConfig, StreamError, SupportedStreamConfig, SupportedStreamConfigsError, +}; + +pub use self::device::{Device, Devices, SupportedInputConfigs, SupportedOutputConfigs}; +pub use self::stream::Stream; +use std::sync::Arc; +use std::time::Duration; + +mod device; +mod stream; + +/// The host for ASIO. +#[derive(Debug)] +pub struct Host { + asio: Arc, +} + +impl Host { + pub fn new() -> Result { + let asio = Arc::new(sys::Asio::new()); + let host = Host { asio }; + Ok(host) + } +} + +impl HostTrait for Host { + type Devices = Devices; + type Device = Device; + + fn is_available() -> bool { + true + //unimplemented!("check how to do this using asio-sys") + } + + fn devices(&self) -> Result { + Devices::new(self.asio.clone()) + } + + fn default_input_device(&self) -> Option { + // ASIO has no concept of a default device, so just use the first. + self.input_devices().ok().and_then(|mut ds| ds.next()) + } + + fn default_output_device(&self) -> Option { + // ASIO has no concept of a default device, so just use the first. + self.output_devices().ok().and_then(|mut ds| ds.next()) + } +} + +impl DeviceTrait for Device { + type SupportedInputConfigs = SupportedInputConfigs; + type SupportedOutputConfigs = SupportedOutputConfigs; + type Stream = Stream; + + fn name(&self) -> Result { + Device::name(self) + } + + fn supported_input_configs( + &self, + ) -> Result { + Device::supported_input_configs(self) + } + + fn supported_output_configs( + &self, + ) -> Result { + Device::supported_output_configs(self) + } + + fn default_input_config(&self) -> Result { + Device::default_input_config(self) + } + + fn default_output_config(&self) -> Result { + Device::default_output_config(self) + } + + fn build_input_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Device::build_input_stream_raw( + self, + config, + sample_format, + data_callback, + error_callback, + timeout, + ) + } + + fn build_output_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Device::build_output_stream_raw( + self, + config, + sample_format, + data_callback, + error_callback, + timeout, + ) + } +} + +impl StreamTrait for Stream { + fn play(&self) -> Result<(), PlayStreamError> { + Stream::play(self) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + Stream::pause(self) + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/asio/stream.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/asio/stream.rs new file mode 100644 index 000000000..c9070005a --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/asio/stream.rs @@ -0,0 +1,718 @@ +extern crate asio_sys as sys; +extern crate num_traits; + +use self::num_traits::PrimInt; +use super::Device; +use crate::{ + BackendSpecificError, BufferSize, BuildStreamError, Data, InputCallbackInfo, + OutputCallbackInfo, PauseStreamError, PlayStreamError, SampleFormat, StreamConfig, StreamError, +}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +pub struct Stream { + playing: Arc, + // Ensure the `Driver` does not terminate until the last stream is dropped. + driver: Arc, + #[allow(dead_code)] + asio_streams: Arc>, + callback_id: sys::CallbackId, +} + +impl Stream { + pub fn play(&self) -> Result<(), PlayStreamError> { + self.playing.store(true, Ordering::SeqCst); + Ok(()) + } + + pub fn pause(&self) -> Result<(), PauseStreamError> { + self.playing.store(false, Ordering::SeqCst); + Ok(()) + } +} + +impl Device { + pub fn build_input_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + mut data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + let stream_type = self.driver.input_data_type().map_err(build_stream_err)?; + + // Ensure that the desired sample type is supported. + let expected_sample_format = super::device::convert_data_type(&stream_type) + .ok_or(BuildStreamError::StreamConfigNotSupported)?; + if sample_format != expected_sample_format { + return Err(BuildStreamError::StreamConfigNotSupported); + } + + let num_channels = config.channels; + let buffer_size = self.get_or_create_input_stream(config, sample_format)?; + let cpal_num_samples = buffer_size * num_channels as usize; + + // Create the buffer depending on the size of the data type. + let len_bytes = cpal_num_samples * sample_format.sample_size(); + let mut interleaved = vec![0u8; len_bytes]; + + let stream_playing = Arc::new(AtomicBool::new(false)); + let playing = Arc::clone(&stream_playing); + let asio_streams = self.asio_streams.clone(); + + // Set the input callback. + // This is most performance critical part of the ASIO bindings. + let config = config.clone(); + let callback_id = self.driver.add_callback(move |callback_info| unsafe { + // If not playing return early. + if !playing.load(Ordering::SeqCst) { + return; + } + + // There is 0% chance of lock contention the host only locks when recreating streams. + let stream_lock = asio_streams.lock().unwrap(); + let asio_stream = match stream_lock.input { + Some(ref asio_stream) => asio_stream, + None => return, + }; + + /// 1. Write from the ASIO buffer to the interleaved CPAL buffer. + /// 2. Deliver the CPAL buffer to the user callback. + unsafe fn process_input_callback( + data_callback: &mut D, + interleaved: &mut [u8], + asio_stream: &sys::AsioStream, + asio_info: &sys::CallbackInfo, + sample_rate: crate::SampleRate, + format: SampleFormat, + from_endianness: F, + ) where + A: Copy, + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + F: Fn(A) -> A, + { + // 1. Write the ASIO channels to the CPAL buffer. + let interleaved: &mut [A] = cast_slice_mut(interleaved); + let n_frames = asio_stream.buffer_size as usize; + let n_channels = interleaved.len() / n_frames; + let buffer_index = asio_info.buffer_index as usize; + for ch_ix in 0..n_channels { + let asio_channel = asio_channel_slice::(asio_stream, buffer_index, ch_ix); + for (frame, s_asio) in interleaved.chunks_mut(n_channels).zip(asio_channel) { + frame[ch_ix] = from_endianness(*s_asio); + } + } + + // 2. Deliver the interleaved buffer to the callback. + let data = interleaved.as_mut_ptr() as *mut (); + let len = interleaved.len(); + let data = Data::from_parts(data, len, format); + let callback = system_time_to_stream_instant(asio_info.system_time); + let delay = frames_to_duration(n_frames, sample_rate); + let capture = callback + .sub(delay) + .expect("`capture` occurs before origin of alsa `StreamInstant`"); + let timestamp = crate::InputStreamTimestamp { callback, capture }; + let info = InputCallbackInfo { timestamp }; + data_callback(&data, &info); + } + + match (&stream_type, sample_format) { + (&sys::AsioSampleType::ASIOSTInt16LSB, SampleFormat::I16) => { + process_input_callback::( + &mut data_callback, + &mut interleaved, + asio_stream, + callback_info, + config.sample_rate, + SampleFormat::I16, + from_le, + ); + } + (&sys::AsioSampleType::ASIOSTInt16MSB, SampleFormat::I16) => { + process_input_callback::( + &mut data_callback, + &mut interleaved, + asio_stream, + callback_info, + config.sample_rate, + SampleFormat::I16, + from_be, + ); + } + + (&sys::AsioSampleType::ASIOSTFloat32LSB, SampleFormat::F32) => { + process_input_callback::( + &mut data_callback, + &mut interleaved, + asio_stream, + callback_info, + config.sample_rate, + SampleFormat::F32, + from_le, + ); + } + (&sys::AsioSampleType::ASIOSTFloat32MSB, SampleFormat::F32) => { + process_input_callback::( + &mut data_callback, + &mut interleaved, + asio_stream, + callback_info, + config.sample_rate, + SampleFormat::F32, + from_be, + ); + } + + (&sys::AsioSampleType::ASIOSTInt32LSB, SampleFormat::I32) => { + process_input_callback::( + &mut data_callback, + &mut interleaved, + asio_stream, + callback_info, + config.sample_rate, + SampleFormat::I32, + from_le, + ); + } + (&sys::AsioSampleType::ASIOSTInt32MSB, SampleFormat::I32) => { + process_input_callback::( + &mut data_callback, + &mut interleaved, + asio_stream, + callback_info, + config.sample_rate, + SampleFormat::I32, + from_be, + ); + } + + (&sys::AsioSampleType::ASIOSTFloat64LSB, SampleFormat::F64) => { + process_input_callback::( + &mut data_callback, + &mut interleaved, + asio_stream, + callback_info, + config.sample_rate, + SampleFormat::F64, + from_le, + ); + } + (&sys::AsioSampleType::ASIOSTFloat64MSB, SampleFormat::F64) => { + process_input_callback::( + &mut data_callback, + &mut interleaved, + asio_stream, + callback_info, + config.sample_rate, + SampleFormat::F64, + from_be, + ); + } + + unsupported_format_pair => unreachable!( + "`build_input_stream_raw` should have returned with unsupported \ + format {:?}", + unsupported_format_pair + ), + } + }); + + let driver = self.driver.clone(); + let asio_streams = self.asio_streams.clone(); + + // Immediately start the device? + self.driver.start().map_err(build_stream_err)?; + + Ok(Stream { + playing: stream_playing, + driver, + asio_streams, + callback_id, + }) + } + + pub fn build_output_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + mut data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + let stream_type = self.driver.output_data_type().map_err(build_stream_err)?; + + // Ensure that the desired sample type is supported. + let expected_sample_format = super::device::convert_data_type(&stream_type) + .ok_or(BuildStreamError::StreamConfigNotSupported)?; + if sample_format != expected_sample_format { + return Err(BuildStreamError::StreamConfigNotSupported); + } + + let num_channels = config.channels; + let buffer_size = self.get_or_create_output_stream(config, sample_format)?; + let cpal_num_samples = buffer_size * num_channels as usize; + + // Create buffers depending on data type. + let len_bytes = cpal_num_samples * sample_format.sample_size(); + let mut interleaved = vec![0u8; len_bytes]; + let current_buffer_index = self.current_buffer_index.clone(); + + let stream_playing = Arc::new(AtomicBool::new(false)); + let playing = Arc::clone(&stream_playing); + let asio_streams = self.asio_streams.clone(); + + let config = config.clone(); + let callback_id = self.driver.add_callback(move |callback_info| unsafe { + // If not playing, return early. + if !playing.load(Ordering::SeqCst) { + return; + } + + // There is 0% chance of lock contention the host only locks when recreating streams. + let mut stream_lock = asio_streams.lock().unwrap(); + let asio_stream = match stream_lock.output { + Some(ref mut asio_stream) => asio_stream, + None => return, + }; + + // Silence the ASIO buffer that is about to be used. + // + // This checks if any other callbacks have already silenced the buffer associated with + // the current `buffer_index`. + let silence = + current_buffer_index.load(Ordering::Acquire) != callback_info.buffer_index; + + if silence { + current_buffer_index.store(callback_info.buffer_index, Ordering::Release); + } + + /// 1. Render the given callback to the given buffer of interleaved samples. + /// 2. If required, silence the ASIO buffer. + /// 3. Finally, write the interleaved data to the non-interleaved ASIO buffer, + /// performing endianness conversions as necessary. + unsafe fn process_output_callback( + data_callback: &mut D, + interleaved: &mut [u8], + silence_asio_buffer: bool, + asio_stream: &mut sys::AsioStream, + asio_info: &sys::CallbackInfo, + sample_rate: crate::SampleRate, + format: SampleFormat, + mix_samples: F, + ) where + A: Copy, + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + F: Fn(A, A) -> A, + { + // 1. Render interleaved buffer from callback. + let interleaved: &mut [A] = cast_slice_mut(interleaved); + let data = interleaved.as_mut_ptr() as *mut (); + let len = interleaved.len(); + let mut data = Data::from_parts(data, len, format); + let callback = system_time_to_stream_instant(asio_info.system_time); + let n_frames = asio_stream.buffer_size as usize; + let delay = frames_to_duration(n_frames, sample_rate); + let playback = callback + .add(delay) + .expect("`playback` occurs beyond representation supported by `StreamInstant`"); + let timestamp = crate::OutputStreamTimestamp { callback, playback }; + let info = OutputCallbackInfo { timestamp }; + data_callback(&mut data, &info); + + // 2. Silence ASIO channels if necessary. + let n_channels = interleaved.len() / n_frames; + let buffer_index = asio_info.buffer_index as usize; + if silence_asio_buffer { + for ch_ix in 0..n_channels { + let asio_channel = + asio_channel_slice_mut::(asio_stream, buffer_index, ch_ix); + asio_channel.align_to_mut::().1.fill(0); + } + } + + // 3. Write interleaved samples to ASIO channels, one channel at a time. + for ch_ix in 0..n_channels { + let asio_channel = + asio_channel_slice_mut::(asio_stream, buffer_index, ch_ix); + for (frame, s_asio) in interleaved.chunks(n_channels).zip(asio_channel) { + *s_asio = mix_samples(*s_asio, frame[ch_ix]); + } + } + } + + match (sample_format, &stream_type) { + (SampleFormat::I16, &sys::AsioSampleType::ASIOSTInt16LSB) => { + process_output_callback::( + &mut data_callback, + &mut interleaved, + silence, + asio_stream, + callback_info, + config.sample_rate, + SampleFormat::I16, + |old_sample, new_sample| { + from_le(old_sample).saturating_add(new_sample).to_le() + }, + ); + } + (SampleFormat::I16, &sys::AsioSampleType::ASIOSTInt16MSB) => { + process_output_callback::( + &mut data_callback, + &mut interleaved, + silence, + asio_stream, + callback_info, + config.sample_rate, + SampleFormat::I16, + |old_sample, new_sample| { + from_be(old_sample).saturating_add(new_sample).to_be() + }, + ); + } + (SampleFormat::F32, &sys::AsioSampleType::ASIOSTFloat32LSB) => { + process_output_callback::( + &mut data_callback, + &mut interleaved, + silence, + asio_stream, + callback_info, + config.sample_rate, + SampleFormat::F32, + |old_sample, new_sample| { + (f32::from_bits(from_le(old_sample)) + f32::from_bits(new_sample)) + .to_bits() + .to_le() + }, + ); + } + + (SampleFormat::F32, &sys::AsioSampleType::ASIOSTFloat32MSB) => { + process_output_callback::( + &mut data_callback, + &mut interleaved, + silence, + asio_stream, + callback_info, + config.sample_rate, + SampleFormat::F32, + |old_sample, new_sample| { + (f32::from_bits(from_be(old_sample)) + f32::from_bits(new_sample)) + .to_bits() + .to_be() + }, + ); + } + + (SampleFormat::I32, &sys::AsioSampleType::ASIOSTInt32LSB) => { + process_output_callback::( + &mut data_callback, + &mut interleaved, + silence, + asio_stream, + callback_info, + config.sample_rate, + SampleFormat::I32, + |old_sample, new_sample| { + from_le(old_sample).saturating_add(new_sample).to_le() + }, + ); + } + (SampleFormat::I32, &sys::AsioSampleType::ASIOSTInt32MSB) => { + process_output_callback::( + &mut data_callback, + &mut interleaved, + silence, + asio_stream, + callback_info, + config.sample_rate, + SampleFormat::I32, + |old_sample, new_sample| { + from_be(old_sample).saturating_add(new_sample).to_be() + }, + ); + } + + (SampleFormat::F64, &sys::AsioSampleType::ASIOSTFloat64LSB) => { + process_output_callback::( + &mut data_callback, + &mut interleaved, + silence, + asio_stream, + callback_info, + config.sample_rate, + SampleFormat::F64, + |old_sample, new_sample| { + (f64::from_bits(from_le(old_sample)) + f64::from_bits(new_sample)) + .to_bits() + .to_le() + }, + ); + } + + (SampleFormat::F64, &sys::AsioSampleType::ASIOSTFloat64MSB) => { + process_output_callback::( + &mut data_callback, + &mut interleaved, + silence, + asio_stream, + callback_info, + config.sample_rate, + SampleFormat::F64, + |old_sample, new_sample| { + (f64::from_bits(from_be(old_sample)) + f64::from_bits(new_sample)) + .to_bits() + .to_be() + }, + ); + } + + unsupported_format_pair => unreachable!( + "`build_output_stream_raw` should have returned with unsupported \ + format {:?}", + unsupported_format_pair + ), + } + }); + + let driver = self.driver.clone(); + let asio_streams = self.asio_streams.clone(); + + // Immediately start the device? + self.driver.start().map_err(build_stream_err)?; + + Ok(Stream { + playing: stream_playing, + driver, + asio_streams, + callback_id, + }) + } + + /// Create a new CPAL Input Stream. + /// + /// If there is no existing ASIO Input Stream it will be created. + /// + /// On success, the buffer size of the stream is returned. + fn get_or_create_input_stream( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + ) -> Result { + match self.default_input_config() { + Ok(f) => { + let num_asio_channels = f.channels; + check_config(&self.driver, config, sample_format, num_asio_channels) + } + Err(_) => Err(BuildStreamError::StreamConfigNotSupported), + }?; + let num_channels = config.channels as usize; + let mut streams = self.asio_streams.lock().unwrap(); + + let buffer_size = match config.buffer_size { + BufferSize::Fixed(v) => Some(v as i32), + BufferSize::Default => None, + }; + + // Either create a stream if thers none or had back the + // size of the current one. + match streams.input { + Some(ref input) => Ok(input.buffer_size as usize), + None => { + let output = streams.output.take(); + self.driver + .prepare_input_stream(output, num_channels, buffer_size) + .map(|new_streams| { + let bs = match new_streams.input { + Some(ref inp) => inp.buffer_size as usize, + None => unreachable!(), + }; + *streams = new_streams; + bs + }) + .map_err(|ref e| { + println!("Error preparing stream: {}", e); + BuildStreamError::DeviceNotAvailable + }) + } + } + } + + /// Create a new CPAL Output Stream. + /// + /// If there is no existing ASIO Output Stream it will be created. + fn get_or_create_output_stream( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + ) -> Result { + match self.default_output_config() { + Ok(f) => { + let num_asio_channels = f.channels; + check_config(&self.driver, config, sample_format, num_asio_channels) + } + Err(_) => Err(BuildStreamError::StreamConfigNotSupported), + }?; + let num_channels = config.channels as usize; + let mut streams = self.asio_streams.lock().unwrap(); + + let buffer_size = match config.buffer_size { + BufferSize::Fixed(v) => Some(v as i32), + BufferSize::Default => None, + }; + + // Either create a stream if thers none or had back the + // size of the current one. + match streams.output { + Some(ref output) => Ok(output.buffer_size as usize), + None => { + let input = streams.input.take(); + self.driver + .prepare_output_stream(input, num_channels, buffer_size) + .map(|new_streams| { + let bs = match new_streams.output { + Some(ref out) => out.buffer_size as usize, + None => unreachable!(), + }; + *streams = new_streams; + bs + }) + .map_err(|ref e| { + println!("Error preparing stream: {}", e); + BuildStreamError::DeviceNotAvailable + }) + } + } + } +} + +impl Drop for Stream { + fn drop(&mut self) { + self.driver.remove_callback(self.callback_id); + } +} + +fn asio_ns_to_double(val: sys::bindings::asio_import::ASIOTimeStamp) -> f64 { + let two_raised_to_32 = 4294967296.0; + val.lo as f64 + val.hi as f64 * two_raised_to_32 +} + +/// Asio retrieves system time via `timeGetTime` which returns the time in milliseconds. +fn system_time_to_stream_instant( + system_time: sys::bindings::asio_import::ASIOTimeStamp, +) -> crate::StreamInstant { + let systime_ns = asio_ns_to_double(system_time); + let secs = systime_ns as i64 / 1_000_000_000; + let nanos = (systime_ns as i64 - secs * 1_000_000_000) as u32; + crate::StreamInstant::new(secs, nanos) +} + +/// Convert the given duration in frames at the given sample rate to a `std::time::Duration`. +fn frames_to_duration(frames: usize, rate: crate::SampleRate) -> std::time::Duration { + let secsf = frames as f64 / rate.0 as f64; + let secs = secsf as u64; + let nanos = ((secsf - secs as f64) * 1_000_000_000.0) as u32; + std::time::Duration::new(secs, nanos) +} + +/// Check whether or not the desired config is supported by the stream. +/// +/// Checks sample rate, data type and then finally the number of channels. +fn check_config( + driver: &sys::Driver, + config: &StreamConfig, + sample_format: SampleFormat, + num_asio_channels: u16, +) -> Result<(), BuildStreamError> { + let StreamConfig { + channels, + sample_rate, + buffer_size: _, + } = config; + // Try and set the sample rate to what the user selected. + let sample_rate = sample_rate.0.into(); + if sample_rate != driver.sample_rate().map_err(build_stream_err)? { + if driver + .can_sample_rate(sample_rate) + .map_err(build_stream_err)? + { + driver + .set_sample_rate(sample_rate) + .map_err(build_stream_err)?; + } else { + return Err(BuildStreamError::StreamConfigNotSupported); + } + } + // unsigned formats are not supported by asio + match sample_format { + SampleFormat::I16 | SampleFormat::I32 | SampleFormat::F32 => (), + _ => return Err(BuildStreamError::StreamConfigNotSupported), + } + if *channels > num_asio_channels { + return Err(BuildStreamError::StreamConfigNotSupported); + } + Ok(()) +} + +/// Cast a byte slice into a mutable slice of desired type. +/// +/// Safety: it's up to the caller to ensure that the input slice has valid bit representations. +unsafe fn cast_slice_mut(v: &mut [u8]) -> &mut [T] { + debug_assert!(v.len() % std::mem::size_of::() == 0); + std::slice::from_raw_parts_mut(v.as_mut_ptr() as *mut T, v.len() / std::mem::size_of::()) +} + +/// Helper function to convert from little endianness. +fn from_le(t: T) -> T { + T::from_le(t) +} + +/// Helper function to convert from little endianness. +fn from_be(t: T) -> T { + T::from_be(t) +} + +/// Shorthand for retrieving the asio buffer slice associated with a channel. +unsafe fn asio_channel_slice( + asio_stream: &sys::AsioStream, + buffer_index: usize, + channel_index: usize, +) -> &[T] { + let buff_ptr: *const T = + asio_stream.buffer_infos[channel_index].buffers[buffer_index as usize] as *const _; + std::slice::from_raw_parts(buff_ptr, asio_stream.buffer_size as usize) +} + +/// Shorthand for retrieving the asio buffer slice associated with a channel. +unsafe fn asio_channel_slice_mut( + asio_stream: &mut sys::AsioStream, + buffer_index: usize, + channel_index: usize, +) -> &mut [T] { + let buff_ptr: *mut T = + asio_stream.buffer_infos[channel_index].buffers[buffer_index as usize] as *mut _; + std::slice::from_raw_parts_mut(buff_ptr, asio_stream.buffer_size as usize) +} + +fn build_stream_err(e: sys::AsioError) -> BuildStreamError { + match e { + sys::AsioError::NoDrivers | sys::AsioError::HardwareMalfunction => { + BuildStreamError::DeviceNotAvailable + } + sys::AsioError::InvalidInput | sys::AsioError::BadMode => BuildStreamError::InvalidArgument, + err => { + let description = format!("{}", err); + BackendSpecificError { description }.into() + } + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/ios/enumerate.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/ios/enumerate.rs new file mode 100644 index 000000000..44479297a --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/ios/enumerate.rs @@ -0,0 +1,43 @@ +use std::vec::IntoIter as VecIntoIter; + +use crate::DevicesError; +use crate::SupportedStreamConfigRange; + +use super::Device; + +pub type SupportedInputConfigs = ::std::vec::IntoIter; +pub type SupportedOutputConfigs = ::std::vec::IntoIter; + +// TODO: Support enumerating earpiece vs headset vs speaker etc? +pub struct Devices(VecIntoIter); + +impl Devices { + pub fn new() -> Result { + Ok(Self::default()) + } +} + +impl Default for Devices { + fn default() -> Devices { + Devices(vec![Device].into_iter()) + } +} + +impl Iterator for Devices { + type Item = Device; + + #[inline] + fn next(&mut self) -> Option { + self.0.next() + } +} + +#[inline] +pub fn default_input_device() -> Option { + Some(Device) +} + +#[inline] +pub fn default_output_device() -> Option { + Some(Device) +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/ios/mod.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/ios/mod.rs new file mode 100644 index 000000000..8d7176c61 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/ios/mod.rs @@ -0,0 +1,434 @@ +//! +//! coreaudio on iOS looks a bit different from macOS. A lot of configuration needs to use +//! the AVAudioSession objc API which doesn't exist on macOS. +//! +//! TODO: +//! - Use AVAudioSession to enumerate buffer size / sample rate / number of channels and set +//! buffer size. +//! + +extern crate core_foundation_sys; +extern crate coreaudio; + +use std::cell::RefCell; + +use self::coreaudio::audio_unit::render_callback::data; +use self::coreaudio::audio_unit::{render_callback, AudioUnit, Element, Scope}; +use self::coreaudio::sys::{ + kAudioOutputUnitProperty_EnableIO, kAudioUnitProperty_StreamFormat, AudioBuffer, + AudioStreamBasicDescription, +}; + +use super::{asbd_from_config, frames_to_duration, host_time_to_stream_instant}; +use crate::traits::{DeviceTrait, HostTrait, StreamTrait}; + +use crate::{ + BackendSpecificError, BufferSize, BuildStreamError, Data, DefaultStreamConfigError, + DeviceNameError, DevicesError, InputCallbackInfo, OutputCallbackInfo, PauseStreamError, + PlayStreamError, SampleFormat, SampleRate, StreamConfig, StreamError, SupportedBufferSize, + SupportedStreamConfig, SupportedStreamConfigRange, SupportedStreamConfigsError, +}; + +use self::enumerate::{ + default_input_device, default_output_device, Devices, SupportedInputConfigs, + SupportedOutputConfigs, +}; +use std::slice; +use std::time::Duration; + +pub mod enumerate; + +// These days the default of iOS is now F32 and no longer I16 +const SUPPORTED_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Device; + +pub struct Host; + +impl Host { + pub fn new() -> Result { + Ok(Host) + } +} + +impl HostTrait for Host { + type Devices = Devices; + type Device = Device; + + fn is_available() -> bool { + true + } + + fn devices(&self) -> Result { + Devices::new() + } + + fn default_input_device(&self) -> Option { + default_input_device() + } + + fn default_output_device(&self) -> Option { + default_output_device() + } +} + +impl Device { + #[inline] + fn name(&self) -> Result { + Ok("Default Device".to_owned()) + } + + #[inline] + fn supported_input_configs( + &self, + ) -> Result { + // TODO: query AVAudioSession for parameters, some values like sample rate and buffer size + // probably need to actually be set to see if it works, but channels can be enumerated. + + let asbd: AudioStreamBasicDescription = default_input_asbd()?; + let stream_config = stream_config_from_asbd(asbd); + Ok(vec![SupportedStreamConfigRange { + channels: stream_config.channels, + min_sample_rate: stream_config.sample_rate, + max_sample_rate: stream_config.sample_rate, + buffer_size: stream_config.buffer_size.clone(), + sample_format: SUPPORTED_SAMPLE_FORMAT, + }] + .into_iter()) + } + + #[inline] + fn supported_output_configs( + &self, + ) -> Result { + // TODO: query AVAudioSession for parameters, some values like sample rate and buffer size + // probably need to actually be set to see if it works, but channels can be enumerated. + + let asbd: AudioStreamBasicDescription = default_output_asbd()?; + let stream_config = stream_config_from_asbd(asbd); + + let configs: Vec<_> = (1..=asbd.mChannelsPerFrame as u16) + .map(|channels| SupportedStreamConfigRange { + channels, + min_sample_rate: stream_config.sample_rate, + max_sample_rate: stream_config.sample_rate, + buffer_size: stream_config.buffer_size.clone(), + sample_format: SUPPORTED_SAMPLE_FORMAT, + }) + .collect(); + Ok(configs.into_iter()) + } + + #[inline] + fn default_input_config(&self) -> Result { + let asbd: AudioStreamBasicDescription = default_input_asbd()?; + let stream_config = stream_config_from_asbd(asbd); + Ok(stream_config) + } + + #[inline] + fn default_output_config(&self) -> Result { + let asbd: AudioStreamBasicDescription = default_output_asbd()?; + let stream_config = stream_config_from_asbd(asbd); + Ok(stream_config) + } +} + +impl DeviceTrait for Device { + type SupportedInputConfigs = SupportedInputConfigs; + type SupportedOutputConfigs = SupportedOutputConfigs; + type Stream = Stream; + + #[inline] + fn name(&self) -> Result { + Device::name(self) + } + + #[inline] + fn supported_input_configs( + &self, + ) -> Result { + Device::supported_input_configs(self) + } + + #[inline] + fn supported_output_configs( + &self, + ) -> Result { + Device::supported_output_configs(self) + } + + #[inline] + fn default_input_config(&self) -> Result { + Device::default_input_config(self) + } + + #[inline] + fn default_output_config(&self) -> Result { + Device::default_output_config(self) + } + + fn build_input_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + mut data_callback: D, + mut error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + // The scope and element for working with a device's input stream. + let scope = Scope::Output; + let element = Element::Input; + + let mut audio_unit = create_audio_unit()?; + audio_unit.uninitialize()?; + configure_for_recording(&mut audio_unit)?; + audio_unit.initialize()?; + + // Set the stream in interleaved mode. + let asbd = asbd_from_config(config, sample_format); + audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?; + + // Set the buffersize + match config.buffer_size { + BufferSize::Fixed(_) => { + return Err(BuildStreamError::StreamConfigNotSupported); + } + BufferSize::Default => (), + } + + // Register the callback that is being called by coreaudio whenever it needs data to be + // fed to the audio buffer. + let bytes_per_channel = sample_format.sample_size(); + let sample_rate = config.sample_rate; + type Args = render_callback::Args; + audio_unit.set_input_callback(move |args: Args| unsafe { + let ptr = (*args.data.data).mBuffers.as_ptr() as *const AudioBuffer; + let len = (*args.data.data).mNumberBuffers as usize; + let buffers: &[AudioBuffer] = slice::from_raw_parts(ptr, len); + + // There is only 1 buffer when using interleaved channels + let AudioBuffer { + mNumberChannels: channels, + mDataByteSize: data_byte_size, + mData: data, + } = buffers[0]; + + let data = data as *mut (); + let len = (data_byte_size as usize / bytes_per_channel) as usize; + let data = Data::from_parts(data, len, sample_format); + + // TODO: Need a better way to get delay, for now we assume a double-buffer offset. + let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) { + Err(err) => { + error_callback(err.into()); + return Err(()); + } + Ok(cb) => cb, + }; + let buffer_frames = len / channels as usize; + let delay = frames_to_duration(buffer_frames, sample_rate); + let capture = callback + .sub(delay) + .expect("`capture` occurs before origin of alsa `StreamInstant`"); + let timestamp = crate::InputStreamTimestamp { callback, capture }; + + let info = InputCallbackInfo { timestamp }; + data_callback(&data, &info); + Ok(()) + })?; + + audio_unit.start()?; + + Ok(Stream::new(StreamInner { + playing: true, + audio_unit, + })) + } + + /// Create an output stream. + fn build_output_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + mut data_callback: D, + mut error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + match config.buffer_size { + BufferSize::Fixed(_) => { + return Err(BuildStreamError::StreamConfigNotSupported); + } + BufferSize::Default => (), + }; + + let mut audio_unit = create_audio_unit()?; + + // The scope and element for working with a device's output stream. + let scope = Scope::Input; + let element = Element::Output; + + // Set the stream in interleaved mode. + let asbd = asbd_from_config(config, sample_format); + audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?; + + // Register the callback that is being called by coreaudio whenever it needs data to be + // fed to the audio buffer. + let bytes_per_channel = sample_format.sample_size(); + let sample_rate = config.sample_rate; + type Args = render_callback::Args; + audio_unit.set_render_callback(move |args: Args| unsafe { + // If `run()` is currently running, then a callback will be available from this list. + // Otherwise, we just fill the buffer with zeroes and return. + + let AudioBuffer { + mNumberChannels: channels, + mDataByteSize: data_byte_size, + mData: data, + } = (*args.data.data).mBuffers[0]; + + let data = data as *mut (); + let len = (data_byte_size as usize / bytes_per_channel) as usize; + let mut data = Data::from_parts(data, len, sample_format); + + let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) { + Err(err) => { + error_callback(err.into()); + return Err(()); + } + Ok(cb) => cb, + }; + // TODO: Need a better way to get delay, for now we assume a double-buffer offset. + let buffer_frames = len / channels as usize; + let delay = frames_to_duration(buffer_frames, sample_rate); + let playback = callback + .add(delay) + .expect("`playback` occurs beyond representation supported by `StreamInstant`"); + let timestamp = crate::OutputStreamTimestamp { callback, playback }; + + let info = OutputCallbackInfo { timestamp }; + data_callback(&mut data, &info); + Ok(()) + })?; + + audio_unit.start()?; + + Ok(Stream::new(StreamInner { + playing: true, + audio_unit, + })) + } +} + +pub struct Stream { + inner: RefCell, +} + +impl Stream { + fn new(inner: StreamInner) -> Self { + Self { + inner: RefCell::new(inner), + } + } +} + +impl StreamTrait for Stream { + fn play(&self) -> Result<(), PlayStreamError> { + let mut stream = self.inner.borrow_mut(); + + if !stream.playing { + if let Err(e) = stream.audio_unit.start() { + let description = format!("{}", e); + let err = BackendSpecificError { description }; + return Err(err.into()); + } + stream.playing = true; + } + Ok(()) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + let mut stream = self.inner.borrow_mut(); + + if stream.playing { + if let Err(e) = stream.audio_unit.stop() { + let description = format!("{}", e); + let err = BackendSpecificError { description }; + return Err(err.into()); + } + + stream.playing = false; + } + Ok(()) + } +} + +struct StreamInner { + playing: bool, + audio_unit: AudioUnit, +} + +fn create_audio_unit() -> Result { + AudioUnit::new(coreaudio::audio_unit::IOType::RemoteIO) +} + +fn configure_for_recording(audio_unit: &mut AudioUnit) -> Result<(), coreaudio::Error> { + // Enable mic recording + let enable_input = 1u32; + audio_unit.set_property( + kAudioOutputUnitProperty_EnableIO, + Scope::Input, + Element::Input, + Some(&enable_input), + )?; + + // Disable output + let disable_output = 0u32; + audio_unit.set_property( + kAudioOutputUnitProperty_EnableIO, + Scope::Output, + Element::Output, + Some(&disable_output), + )?; + + Ok(()) +} + +fn default_output_asbd() -> Result { + let audio_unit = create_audio_unit()?; + let id = kAudioUnitProperty_StreamFormat; + let asbd: AudioStreamBasicDescription = + audio_unit.get_property(id, Scope::Output, Element::Output)?; + Ok(asbd) +} + +fn default_input_asbd() -> Result { + let mut audio_unit = create_audio_unit()?; + audio_unit.uninitialize()?; + configure_for_recording(&mut audio_unit)?; + audio_unit.initialize()?; + + let id = kAudioUnitProperty_StreamFormat; + let asbd: AudioStreamBasicDescription = + audio_unit.get_property(id, Scope::Input, Element::Input)?; + Ok(asbd) +} + +fn stream_config_from_asbd(asbd: AudioStreamBasicDescription) -> SupportedStreamConfig { + let buffer_size = SupportedBufferSize::Range { min: 0, max: 0 }; + SupportedStreamConfig { + channels: asbd.mChannelsPerFrame as u16, + sample_rate: SampleRate(asbd.mSampleRate as u32), + buffer_size: buffer_size.clone(), + sample_format: SUPPORTED_SAMPLE_FORMAT, + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/macos/enumerate.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/macos/enumerate.rs new file mode 100644 index 000000000..8d15f18e1 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/macos/enumerate.rs @@ -0,0 +1,152 @@ +extern crate coreaudio; + +use self::coreaudio::sys::{ + kAudioHardwareNoError, kAudioHardwarePropertyDefaultInputDevice, + kAudioHardwarePropertyDefaultOutputDevice, kAudioHardwarePropertyDevices, + kAudioObjectPropertyElementMaster, kAudioObjectPropertyScopeGlobal, kAudioObjectSystemObject, + AudioDeviceID, AudioObjectGetPropertyData, AudioObjectGetPropertyDataSize, + AudioObjectPropertyAddress, OSStatus, +}; +use super::Device; +use crate::{BackendSpecificError, DevicesError, SupportedStreamConfigRange}; +use std::mem; +use std::ptr::null; +use std::vec::IntoIter as VecIntoIter; + +unsafe fn audio_devices() -> Result, OSStatus> { + let property_address = AudioObjectPropertyAddress { + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster, + }; + + macro_rules! try_status_or_return { + ($status:expr) => { + if $status != kAudioHardwareNoError as i32 { + return Err($status); + } + }; + } + + let data_size = 0u32; + let status = AudioObjectGetPropertyDataSize( + kAudioObjectSystemObject, + &property_address as *const _, + 0, + null(), + &data_size as *const _ as *mut _, + ); + try_status_or_return!(status); + + let device_count = data_size / mem::size_of::() as u32; + let mut audio_devices = vec![]; + audio_devices.reserve_exact(device_count as usize); + + let status = AudioObjectGetPropertyData( + kAudioObjectSystemObject, + &property_address as *const _, + 0, + null(), + &data_size as *const _ as *mut _, + audio_devices.as_mut_ptr() as *mut _, + ); + try_status_or_return!(status); + + audio_devices.set_len(device_count as usize); + + Ok(audio_devices) +} + +pub struct Devices(VecIntoIter); + +impl Devices { + pub fn new() -> Result { + let devices = unsafe { + match audio_devices() { + Ok(devices) => devices, + Err(os_status) => { + let description = format!("{}", os_status); + let err = BackendSpecificError { description }; + return Err(err.into()); + } + } + }; + Ok(Devices(devices.into_iter())) + } +} + +unsafe impl Send for Devices {} +unsafe impl Sync for Devices {} + +impl Iterator for Devices { + type Item = Device; + fn next(&mut self) -> Option { + self.0.next().map(|id| Device { + audio_device_id: id, + is_default: false, + }) + } +} + +pub fn default_input_device() -> Option { + let property_address = AudioObjectPropertyAddress { + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster, + }; + + let audio_device_id: AudioDeviceID = 0; + let data_size = mem::size_of::(); + let status = unsafe { + AudioObjectGetPropertyData( + kAudioObjectSystemObject, + &property_address as *const _, + 0, + null(), + &data_size as *const _ as *mut _, + &audio_device_id as *const _ as *mut _, + ) + }; + if status != kAudioHardwareNoError as i32 { + return None; + } + + let device = Device { + audio_device_id, + is_default: true, + }; + Some(device) +} + +pub fn default_output_device() -> Option { + let property_address = AudioObjectPropertyAddress { + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster, + }; + + let audio_device_id: AudioDeviceID = 0; + let data_size = mem::size_of::(); + let status = unsafe { + AudioObjectGetPropertyData( + kAudioObjectSystemObject, + &property_address as *const _, + 0, + null(), + &data_size as *const _ as *mut _, + &audio_device_id as *const _ as *mut _, + ) + }; + if status != kAudioHardwareNoError as i32 { + return None; + } + + let device = Device { + audio_device_id, + is_default: true, + }; + Some(device) +} + +pub type SupportedInputConfigs = VecIntoIter; +pub type SupportedOutputConfigs = VecIntoIter; diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/macos/mod.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/macos/mod.rs new file mode 100644 index 000000000..55cd988ca --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/macos/mod.rs @@ -0,0 +1,941 @@ +extern crate core_foundation_sys; +extern crate coreaudio; + +use super::{asbd_from_config, check_os_status, frames_to_duration, host_time_to_stream_instant}; + +use self::core_foundation_sys::string::{CFStringGetCString, CFStringGetCStringPtr, CFStringRef}; +use self::coreaudio::audio_unit::render_callback::{self, data}; +use self::coreaudio::audio_unit::{AudioUnit, Element, Scope}; +use self::coreaudio::sys::{ + kAudioDevicePropertyAvailableNominalSampleRates, kAudioDevicePropertyBufferFrameSize, + kAudioDevicePropertyBufferFrameSizeRange, kAudioDevicePropertyDeviceIsAlive, + kAudioDevicePropertyDeviceNameCFString, kAudioDevicePropertyNominalSampleRate, + kAudioDevicePropertyScopeOutput, kAudioDevicePropertyStreamConfiguration, + kAudioDevicePropertyStreamFormat, kAudioObjectPropertyElementMaster, + kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyScopeInput, + kAudioObjectPropertyScopeOutput, kAudioOutputUnitProperty_CurrentDevice, + kAudioOutputUnitProperty_EnableIO, kAudioUnitProperty_StreamFormat, kCFStringEncodingUTF8, + AudioBuffer, AudioBufferList, AudioDeviceID, AudioObjectGetPropertyData, + AudioObjectGetPropertyDataSize, AudioObjectID, AudioObjectPropertyAddress, + AudioObjectPropertyScope, AudioObjectSetPropertyData, AudioStreamBasicDescription, + AudioValueRange, OSStatus, +}; +use crate::traits::{DeviceTrait, HostTrait, StreamTrait}; +use crate::{ + BackendSpecificError, BufferSize, BuildStreamError, ChannelCount, Data, + DefaultStreamConfigError, DeviceNameError, DevicesError, InputCallbackInfo, OutputCallbackInfo, + PauseStreamError, PlayStreamError, SampleFormat, SampleRate, StreamConfig, StreamError, + SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange, + SupportedStreamConfigsError, +}; +use std::ffi::CStr; +use std::fmt; +use std::mem; +use std::os::raw::c_char; +use std::ptr::null; +use std::rc::Rc; +use std::slice; +use std::sync::mpsc::{channel, RecvTimeoutError}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +pub use self::enumerate::{ + default_input_device, default_output_device, Devices, SupportedInputConfigs, + SupportedOutputConfigs, +}; + +use property_listener::AudioObjectPropertyListener; + +pub mod enumerate; +mod property_listener; + +/// Coreaudio host, the default host on macOS. +#[derive(Debug)] +pub struct Host; + +impl Host { + pub fn new() -> Result { + Ok(Host) + } +} + +impl HostTrait for Host { + type Devices = Devices; + type Device = Device; + + fn is_available() -> bool { + // Assume coreaudio is always available + true + } + + fn devices(&self) -> Result { + Devices::new() + } + + fn default_input_device(&self) -> Option { + default_input_device() + } + + fn default_output_device(&self) -> Option { + default_output_device() + } +} + +impl DeviceTrait for Device { + type SupportedInputConfigs = SupportedInputConfigs; + type SupportedOutputConfigs = SupportedOutputConfigs; + type Stream = Stream; + + fn name(&self) -> Result { + Device::name(self) + } + + fn supported_input_configs( + &self, + ) -> Result { + Device::supported_input_configs(self) + } + + fn supported_output_configs( + &self, + ) -> Result { + Device::supported_output_configs(self) + } + + fn default_input_config(&self) -> Result { + Device::default_input_config(self) + } + + fn default_output_config(&self) -> Result { + Device::default_output_config(self) + } + + fn build_input_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Device::build_input_stream_raw( + self, + config, + sample_format, + data_callback, + error_callback, + timeout, + ) + } + + fn build_output_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Device::build_output_stream_raw( + self, + config, + sample_format, + data_callback, + error_callback, + timeout, + ) + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct Device { + pub(crate) audio_device_id: AudioDeviceID, + is_default: bool, +} + +impl Device { + fn name(&self) -> Result { + let property_address = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyDeviceNameCFString, + mScope: kAudioDevicePropertyScopeOutput, + mElement: kAudioObjectPropertyElementMaster, + }; + let device_name: CFStringRef = null(); + let data_size = mem::size_of::(); + let c_str = unsafe { + let status = AudioObjectGetPropertyData( + self.audio_device_id, + &property_address as *const _, + 0, + null(), + &data_size as *const _ as *mut _, + &device_name as *const _ as *mut _, + ); + check_os_status(status)?; + + let c_string: *const c_char = CFStringGetCStringPtr(device_name, kCFStringEncodingUTF8); + if c_string.is_null() { + let status = AudioObjectGetPropertyData( + self.audio_device_id, + &property_address as *const _, + 0, + null(), + &data_size as *const _ as *mut _, + &device_name as *const _ as *mut _, + ); + check_os_status(status)?; + let mut buf: [i8; 255] = [0; 255]; + let result = CFStringGetCString( + device_name, + buf.as_mut_ptr(), + buf.len() as _, + kCFStringEncodingUTF8, + ); + if result == 0 { + let description = + "core foundation failed to return device name string".to_string(); + let err = BackendSpecificError { description }; + return Err(err.into()); + } + let name: &CStr = CStr::from_ptr(buf.as_ptr()); + return Ok(name.to_str().unwrap().to_owned()); + } + CStr::from_ptr(c_string as *mut _) + }; + Ok(c_str.to_string_lossy().into_owned()) + } + + // Logic re-used between `supported_input_configs` and `supported_output_configs`. + #[allow(clippy::cast_ptr_alignment)] + fn supported_configs( + &self, + scope: AudioObjectPropertyScope, + ) -> Result { + let mut property_address = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyStreamConfiguration, + mScope: scope, + mElement: kAudioObjectPropertyElementMaster, + }; + + unsafe { + // Retrieve the devices audio buffer list. + let data_size = 0u32; + let status = AudioObjectGetPropertyDataSize( + self.audio_device_id, + &property_address as *const _, + 0, + null(), + &data_size as *const _ as *mut _, + ); + check_os_status(status)?; + + let mut audio_buffer_list: Vec = vec![]; + audio_buffer_list.reserve_exact(data_size as usize); + let status = AudioObjectGetPropertyData( + self.audio_device_id, + &property_address as *const _, + 0, + null(), + &data_size as *const _ as *mut _, + audio_buffer_list.as_mut_ptr() as *mut _, + ); + check_os_status(status)?; + + let audio_buffer_list = audio_buffer_list.as_mut_ptr() as *mut AudioBufferList; + + // If there's no buffers, skip. + if (*audio_buffer_list).mNumberBuffers == 0 { + return Ok(vec![].into_iter()); + } + + // Count the number of channels as the sum of all channels in all output buffers. + let n_buffers = (*audio_buffer_list).mNumberBuffers as usize; + let first: *const AudioBuffer = (*audio_buffer_list).mBuffers.as_ptr(); + let buffers: &'static [AudioBuffer] = slice::from_raw_parts(first, n_buffers); + let mut n_channels = 0; + for buffer in buffers { + n_channels += buffer.mNumberChannels as usize; + } + + // TODO: macOS should support U8, I16, I32, F32 and F64. This should allow for using + // I16 but just use F32 for now as it's the default anyway. + let sample_format = SampleFormat::F32; + + // Get available sample rate ranges. + property_address.mSelector = kAudioDevicePropertyAvailableNominalSampleRates; + let data_size = 0u32; + let status = AudioObjectGetPropertyDataSize( + self.audio_device_id, + &property_address as *const _, + 0, + null(), + &data_size as *const _ as *mut _, + ); + check_os_status(status)?; + + let n_ranges = data_size as usize / mem::size_of::(); + let mut ranges: Vec = vec![]; + ranges.reserve_exact(data_size as usize); + let status = AudioObjectGetPropertyData( + self.audio_device_id, + &property_address as *const _, + 0, + null(), + &data_size as *const _ as *mut _, + ranges.as_mut_ptr() as *mut _, + ); + check_os_status(status)?; + + let ranges: *mut AudioValueRange = ranges.as_mut_ptr() as *mut _; + let ranges: &'static [AudioValueRange] = slice::from_raw_parts(ranges, n_ranges); + + let audio_unit = audio_unit_from_device(self, true)?; + let buffer_size = get_io_buffer_frame_size_range(&audio_unit)?; + + // Collect the supported formats for the device. + let mut fmts = vec![]; + for range in ranges { + let fmt = SupportedStreamConfigRange { + channels: n_channels as ChannelCount, + min_sample_rate: SampleRate(range.mMinimum as _), + max_sample_rate: SampleRate(range.mMaximum as _), + buffer_size, + sample_format, + }; + fmts.push(fmt); + } + + Ok(fmts.into_iter()) + } + } + + fn supported_input_configs( + &self, + ) -> Result { + self.supported_configs(kAudioObjectPropertyScopeInput) + } + + fn supported_output_configs( + &self, + ) -> Result { + self.supported_configs(kAudioObjectPropertyScopeOutput) + } + + fn default_config( + &self, + scope: AudioObjectPropertyScope, + ) -> Result { + fn default_config_error_from_os_status( + status: OSStatus, + ) -> Result<(), DefaultStreamConfigError> { + let err = match coreaudio::Error::from_os_status(status) { + Err(err) => err, + Ok(_) => return Ok(()), + }; + match err { + coreaudio::Error::AudioUnit( + coreaudio::error::AudioUnitError::FormatNotSupported, + ) + | coreaudio::Error::AudioCodec(_) + | coreaudio::Error::AudioFormat(_) => { + Err(DefaultStreamConfigError::StreamTypeNotSupported) + } + coreaudio::Error::AudioUnit(coreaudio::error::AudioUnitError::NoConnection) => { + Err(DefaultStreamConfigError::DeviceNotAvailable) + } + err => { + let description = format!("{}", err); + let err = BackendSpecificError { description }; + Err(err.into()) + } + } + } + + let property_address = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyStreamFormat, + mScope: scope, + mElement: kAudioObjectPropertyElementMaster, + }; + + unsafe { + let asbd: AudioStreamBasicDescription = mem::zeroed(); + let data_size = mem::size_of::() as u32; + let status = AudioObjectGetPropertyData( + self.audio_device_id, + &property_address as *const _, + 0, + null(), + &data_size as *const _ as *mut _, + &asbd as *const _ as *mut _, + ); + default_config_error_from_os_status(status)?; + + let sample_format = { + let audio_format = coreaudio::audio_unit::AudioFormat::from_format_and_flag( + asbd.mFormatID, + Some(asbd.mFormatFlags), + ); + let flags = match audio_format { + Some(coreaudio::audio_unit::AudioFormat::LinearPCM(flags)) => flags, + _ => return Err(DefaultStreamConfigError::StreamTypeNotSupported), + }; + let maybe_sample_format = + coreaudio::audio_unit::SampleFormat::from_flags_and_bits_per_sample( + flags, + asbd.mBitsPerChannel, + ); + match maybe_sample_format { + Some(coreaudio::audio_unit::SampleFormat::F32) => SampleFormat::F32, + Some(coreaudio::audio_unit::SampleFormat::I16) => SampleFormat::I16, + _ => return Err(DefaultStreamConfigError::StreamTypeNotSupported), + } + }; + + let audio_unit = audio_unit_from_device(self, true)?; + let buffer_size = get_io_buffer_frame_size_range(&audio_unit)?; + + let config = SupportedStreamConfig { + sample_rate: SampleRate(asbd.mSampleRate as _), + channels: asbd.mChannelsPerFrame as _, + buffer_size, + sample_format, + }; + Ok(config) + } + } + + fn default_input_config(&self) -> Result { + self.default_config(kAudioObjectPropertyScopeInput) + } + + fn default_output_config(&self) -> Result { + self.default_config(kAudioObjectPropertyScopeOutput) + } +} + +impl fmt::Debug for Device { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_struct("Device") + .field("audio_device_id", &self.audio_device_id) + .field("name", &self.name()) + .finish() + } +} + +struct StreamInner { + playing: bool, + audio_unit: AudioUnit, + /// Manage the lifetime of the closure that handles device disconnection. + _disconnect_listener: Option, + // Track the device with which the audio unit was spawned. + // + // We must do this so that we can avoid changing the device sample rate if there is already + // a stream associated with the device. + #[allow(dead_code)] + device_id: AudioDeviceID, +} + +/// Register the on-disconnect callback. +/// This will both stop the stream and call the error callback with DeviceNotAvailable. +/// This function should only be called once per stream. +fn add_disconnect_listener( + stream: &Stream, + error_callback: Arc>, +) -> Result<(), BuildStreamError> +where + E: FnMut(StreamError) + Send + 'static, +{ + let stream_copy = stream.clone(); + let mut stream_inner = stream.inner.lock().unwrap(); + stream_inner._disconnect_listener = Some(AudioObjectPropertyListener::new( + stream_inner.device_id, + AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyDeviceIsAlive, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster, + }, + move || { + let _ = stream_copy.pause(); + (error_callback.lock().unwrap())(StreamError::DeviceNotAvailable); + }, + )?); + Ok(()) +} + +fn audio_unit_from_device(device: &Device, input: bool) -> Result { + let output_type = if device.is_default && !input { + coreaudio::audio_unit::IOType::DefaultOutput + } else { + coreaudio::audio_unit::IOType::HalOutput + }; + let mut audio_unit = AudioUnit::new(output_type)?; + + if input { + // Enable input processing. + let enable_input = 1u32; + audio_unit.set_property( + kAudioOutputUnitProperty_EnableIO, + Scope::Input, + Element::Input, + Some(&enable_input), + )?; + + // Disable output processing. + let disable_output = 0u32; + audio_unit.set_property( + kAudioOutputUnitProperty_EnableIO, + Scope::Output, + Element::Output, + Some(&disable_output), + )?; + } + + audio_unit.set_property( + kAudioOutputUnitProperty_CurrentDevice, + Scope::Global, + Element::Output, + Some(&device.audio_device_id), + )?; + + Ok(audio_unit) +} + +impl Device { + #[allow(clippy::cast_ptr_alignment)] + #[allow(clippy::while_immutable_condition)] + #[allow(clippy::float_cmp)] + fn build_input_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + mut data_callback: D, + error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + // The scope and element for working with a device's input stream. + let scope = Scope::Output; + let element = Element::Input; + + // Potentially change the device sample rate to match the config. + set_sample_rate(self.audio_device_id, config.sample_rate)?; + + let mut audio_unit = audio_unit_from_device(self, true)?; + + // Set the stream in interleaved mode. + let asbd = asbd_from_config(config, sample_format); + audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?; + + // Set the buffersize + match config.buffer_size { + BufferSize::Fixed(v) => { + let buffer_size_range = get_io_buffer_frame_size_range(&audio_unit)?; + match buffer_size_range { + SupportedBufferSize::Range { min, max } => { + if v >= min && v <= max { + audio_unit.set_property( + kAudioDevicePropertyBufferFrameSize, + scope, + element, + Some(&v), + )? + } else { + return Err(BuildStreamError::StreamConfigNotSupported); + } + } + SupportedBufferSize::Unknown => (), + } + } + BufferSize::Default => (), + } + + let error_callback = Arc::new(Mutex::new(error_callback)); + let error_callback_disconnect = error_callback.clone(); + + // Register the callback that is being called by coreaudio whenever it needs data to be + // fed to the audio buffer. + let bytes_per_channel = sample_format.sample_size(); + let sample_rate = config.sample_rate; + type Args = render_callback::Args; + audio_unit.set_input_callback(move |args: Args| unsafe { + let ptr = (*args.data.data).mBuffers.as_ptr(); + let len = (*args.data.data).mNumberBuffers as usize; + let buffers: &[AudioBuffer] = slice::from_raw_parts(ptr, len); + + // TODO: Perhaps loop over all buffers instead? + let AudioBuffer { + mNumberChannels: channels, + mDataByteSize: data_byte_size, + mData: data, + } = buffers[0]; + + let data = data as *mut (); + let len = data_byte_size as usize / bytes_per_channel; + let data = Data::from_parts(data, len, sample_format); + + // TODO: Need a better way to get delay, for now we assume a double-buffer offset. + let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) { + Err(err) => { + (error_callback.lock().unwrap())(err.into()); + return Err(()); + } + Ok(cb) => cb, + }; + let buffer_frames = len / channels as usize; + let delay = frames_to_duration(buffer_frames, sample_rate); + let capture = callback + .sub(delay) + .expect("`capture` occurs before origin of alsa `StreamInstant`"); + let timestamp = crate::InputStreamTimestamp { callback, capture }; + + let info = InputCallbackInfo { timestamp }; + data_callback(&data, &info); + Ok(()) + })?; + + let stream = Stream::new(StreamInner { + playing: true, + _disconnect_listener: None, + audio_unit, + device_id: self.audio_device_id, + }); + + // If we didn't request the default device, stop the stream if the + // device disconnects. + if !self.is_default { + add_disconnect_listener(&stream, error_callback_disconnect)?; + } + + stream.inner.lock().unwrap().audio_unit.start()?; + + Ok(stream) + } + + fn build_output_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + mut data_callback: D, + error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + let mut audio_unit = audio_unit_from_device(self, false)?; + + // The scope and element for working with a device's output stream. + let scope = Scope::Input; + let element = Element::Output; + + // Set the stream in interleaved mode. + let asbd = asbd_from_config(config, sample_format); + audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?; + + // Set the buffersize + match config.buffer_size { + BufferSize::Fixed(v) => { + let buffer_size_range = get_io_buffer_frame_size_range(&audio_unit)?; + match buffer_size_range { + SupportedBufferSize::Range { min, max } => { + if v >= min && v <= max { + audio_unit.set_property( + kAudioDevicePropertyBufferFrameSize, + scope, + element, + Some(&v), + )? + } else { + return Err(BuildStreamError::StreamConfigNotSupported); + } + } + SupportedBufferSize::Unknown => (), + } + } + BufferSize::Default => (), + } + + let error_callback = Arc::new(Mutex::new(error_callback)); + let error_callback_disconnect = error_callback.clone(); + + // Register the callback that is being called by coreaudio whenever it needs data to be + // fed to the audio buffer. + let bytes_per_channel = sample_format.sample_size(); + let sample_rate = config.sample_rate; + type Args = render_callback::Args; + audio_unit.set_render_callback(move |args: Args| unsafe { + // If `run()` is currently running, then a callback will be available from this list. + // Otherwise, we just fill the buffer with zeroes and return. + + let AudioBuffer { + mNumberChannels: channels, + mDataByteSize: data_byte_size, + mData: data, + } = (*args.data.data).mBuffers[0]; + + let data = data as *mut (); + let len = data_byte_size as usize / bytes_per_channel; + let mut data = Data::from_parts(data, len, sample_format); + + let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) { + Err(err) => { + (error_callback.lock().unwrap())(err.into()); + return Err(()); + } + Ok(cb) => cb, + }; + // TODO: Need a better way to get delay, for now we assume a double-buffer offset. + let buffer_frames = len / channels as usize; + let delay = frames_to_duration(buffer_frames, sample_rate); + let playback = callback + .add(delay) + .expect("`playback` occurs beyond representation supported by `StreamInstant`"); + let timestamp = crate::OutputStreamTimestamp { callback, playback }; + + let info = OutputCallbackInfo { timestamp }; + data_callback(&mut data, &info); + Ok(()) + })?; + + let stream = Stream::new(StreamInner { + playing: true, + _disconnect_listener: None, + audio_unit, + device_id: self.audio_device_id, + }); + + // If we didn't request the default device, stop the stream if the + // device disconnects. + if !self.is_default { + add_disconnect_listener(&stream, error_callback_disconnect)?; + } + + stream.inner.lock().unwrap().audio_unit.start()?; + + Ok(stream) + } +} + +/// Attempt to set the device sample rate to the provided rate. +/// Return an error if the requested sample rate is not supported by the device. +fn set_sample_rate( + audio_device_id: AudioObjectID, + target_sample_rate: SampleRate, +) -> Result<(), BuildStreamError> { + // Get the current sample rate. + let mut property_address = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyNominalSampleRate, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster, + }; + let sample_rate: f64 = 0.0; + let data_size = mem::size_of::() as u32; + let status = unsafe { + AudioObjectGetPropertyData( + audio_device_id, + &property_address as *const _, + 0, + null(), + &data_size as *const _ as *mut _, + &sample_rate as *const _ as *mut _, + ) + }; + coreaudio::Error::from_os_status(status)?; + + // If the requested sample rate is different to the device sample rate, update the device. + if sample_rate as u32 != target_sample_rate.0 { + // Get available sample rate ranges. + property_address.mSelector = kAudioDevicePropertyAvailableNominalSampleRates; + let data_size = 0u32; + let status = unsafe { + AudioObjectGetPropertyDataSize( + audio_device_id, + &property_address as *const _, + 0, + null(), + &data_size as *const _ as *mut _, + ) + }; + coreaudio::Error::from_os_status(status)?; + let n_ranges = data_size as usize / mem::size_of::(); + let mut ranges: Vec = vec![]; + ranges.reserve_exact(data_size as usize); + let status = unsafe { + AudioObjectGetPropertyData( + audio_device_id, + &property_address as *const _, + 0, + null(), + &data_size as *const _ as *mut _, + ranges.as_mut_ptr() as *mut _, + ) + }; + coreaudio::Error::from_os_status(status)?; + let ranges: *mut AudioValueRange = ranges.as_mut_ptr() as *mut _; + let ranges: &'static [AudioValueRange] = unsafe { slice::from_raw_parts(ranges, n_ranges) }; + + // Now that we have the available ranges, pick the one matching the desired rate. + let sample_rate = target_sample_rate.0; + let maybe_index = ranges + .iter() + .position(|r| r.mMinimum as u32 == sample_rate && r.mMaximum as u32 == sample_rate); + let range_index = match maybe_index { + None => return Err(BuildStreamError::StreamConfigNotSupported), + Some(i) => i, + }; + + let (send, recv) = channel::>(); + let sample_rate_address = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyNominalSampleRate, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster, + }; + // Send sample rate updates back on a channel. + let sample_rate_handler = move || { + let mut rate: f64 = 0.0; + let data_size = mem::size_of::(); + + let result = unsafe { + AudioObjectGetPropertyData( + audio_device_id, + &sample_rate_address as *const _, + 0, + null(), + &data_size as *const _ as *mut _, + &mut rate as *const _ as *mut _, + ) + }; + send.send(coreaudio::Error::from_os_status(result).map(|_| rate)) + .ok(); + }; + + let listener = AudioObjectPropertyListener::new( + audio_device_id, + sample_rate_address, + sample_rate_handler, + )?; + + // Finally, set the sample rate. + property_address.mSelector = kAudioDevicePropertyNominalSampleRate; + let status = unsafe { + AudioObjectSetPropertyData( + audio_device_id, + &property_address as *const _, + 0, + null(), + data_size, + &ranges[range_index] as *const _ as *const _, + ) + }; + coreaudio::Error::from_os_status(status)?; + + // Wait for the reported_rate to change. + // + // This should not take longer than a few ms, but we timeout after 1 sec just in case. + // We loop over potentially several events from the channel to ensure + // that we catch the expected change in sample rate. + let mut timeout = Duration::from_secs(1); + let start = Instant::now(); + + loop { + match recv.recv_timeout(timeout) { + Err(err) => { + let description = match err { + RecvTimeoutError::Disconnected => { + "sample rate listener channel disconnected unexpectedly" + } + RecvTimeoutError::Timeout => { + "timeout waiting for sample rate update for device" + } + } + .to_string(); + return Err(BackendSpecificError { description }.into()); + } + Ok(Ok(reported_sample_rate)) => { + if reported_sample_rate == target_sample_rate.0 as f64 { + break; + } + } + Ok(Err(_)) => { + // TODO: should we consider collecting this error? + } + }; + timeout = timeout + .checked_sub(start.elapsed()) + .unwrap_or(Duration::ZERO); + } + listener.remove()?; + } + Ok(()) +} + +#[derive(Clone)] +pub struct Stream { + inner: Arc>, +} + +impl Stream { + fn new(inner: StreamInner) -> Self { + Self { + inner: Arc::new(Mutex::new(inner)), + } + } +} + +impl StreamTrait for Stream { + fn play(&self) -> Result<(), PlayStreamError> { + let mut stream = self.inner.lock().unwrap(); + + if !stream.playing { + if let Err(e) = stream.audio_unit.start() { + let description = format!("{}", e); + let err = BackendSpecificError { description }; + return Err(err.into()); + } + stream.playing = true; + } + Ok(()) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + let mut stream = self.inner.lock().unwrap(); + + if stream.playing { + if let Err(e) = stream.audio_unit.stop() { + let description = format!("{}", e); + let err = BackendSpecificError { description }; + return Err(err.into()); + } + + stream.playing = false; + } + Ok(()) + } +} + +fn get_io_buffer_frame_size_range( + audio_unit: &AudioUnit, +) -> Result { + let buffer_size_range: AudioValueRange = audio_unit.get_property( + kAudioDevicePropertyBufferFrameSizeRange, + Scope::Global, + Element::Output, + )?; + + Ok(SupportedBufferSize::Range { + min: buffer_size_range.mMinimum as u32, + max: buffer_size_range.mMaximum as u32, + }) +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/macos/property_listener.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/macos/property_listener.rs new file mode 100644 index 000000000..cbe8ec2c2 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/macos/property_listener.rs @@ -0,0 +1,85 @@ +//! Helper code for registering audio object property listeners. +use super::coreaudio::sys::{ + AudioObjectAddPropertyListener, AudioObjectID, AudioObjectPropertyAddress, + AudioObjectRemovePropertyListener, OSStatus, +}; + +use crate::BuildStreamError; + +/// A double-indirection to be able to pass a closure (a fat pointer) +/// via a single c_void. +struct PropertyListenerCallbackWrapper(Box); + +/// Maintain an audio object property listener. +/// The listener will be removed when this type is dropped. +pub struct AudioObjectPropertyListener { + callback: Box, + property_address: AudioObjectPropertyAddress, + audio_object_id: AudioObjectID, + removed: bool, +} + +impl AudioObjectPropertyListener { + /// Attach the provided callback as a audio object property listener. + pub fn new( + audio_object_id: AudioObjectID, + property_address: AudioObjectPropertyAddress, + callback: F, + ) -> Result { + let callback = Box::new(PropertyListenerCallbackWrapper(Box::new(callback))); + unsafe { + coreaudio::Error::from_os_status(AudioObjectAddPropertyListener( + audio_object_id, + &property_address as *const _, + Some(property_listener_handler_shim), + &*callback as *const _ as *mut _, + ))?; + }; + Ok(Self { + callback, + audio_object_id, + property_address, + removed: false, + }) + } + + /// Explicitly remove the property listener. + /// Use this method if you need to explicitly handle failure to remove + /// the property listener. + pub fn remove(mut self) -> Result<(), BuildStreamError> { + self.remove_inner() + } + + fn remove_inner(&mut self) -> Result<(), BuildStreamError> { + unsafe { + coreaudio::Error::from_os_status(AudioObjectRemovePropertyListener( + self.audio_object_id, + &self.property_address as *const _, + Some(property_listener_handler_shim), + &*self.callback as *const _ as *mut _, + ))?; + } + self.removed = true; + Ok(()) + } +} + +impl Drop for AudioObjectPropertyListener { + fn drop(&mut self) { + if !self.removed { + let _ = self.remove_inner(); + } + } +} + +/// Callback used to call user-provided closure as a property listener. +unsafe extern "C" fn property_listener_handler_shim( + _: AudioObjectID, + _: u32, + _: *const AudioObjectPropertyAddress, + callback: *mut ::std::os::raw::c_void, +) -> OSStatus { + let wrapper = callback as *mut PropertyListenerCallbackWrapper; + (*wrapper).0(); + 0 +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/mod.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/mod.rs new file mode 100644 index 000000000..f82f5da5c --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/coreaudio/mod.rs @@ -0,0 +1,121 @@ +extern crate coreaudio; + +use self::coreaudio::sys::{ + kAudioFormatFlagIsFloat, kAudioFormatFlagIsPacked, kAudioFormatLinearPCM, + AudioStreamBasicDescription, OSStatus, +}; + +use crate::DefaultStreamConfigError; +use crate::{BuildStreamError, SupportedStreamConfigsError}; + +use crate::{BackendSpecificError, SampleFormat, StreamConfig}; + +#[cfg(target_os = "ios")] +mod ios; +#[cfg(target_os = "macos")] +mod macos; + +#[cfg(target_os = "ios")] +pub use self::ios::{ + enumerate::{Devices, SupportedInputConfigs, SupportedOutputConfigs}, + Device, Host, Stream, +}; + +#[cfg(target_os = "macos")] +pub use self::macos::{ + enumerate::{Devices, SupportedInputConfigs, SupportedOutputConfigs}, + Device, Host, Stream, +}; + +/// Common helper methods used by both macOS and iOS + +fn check_os_status(os_status: OSStatus) -> Result<(), BackendSpecificError> { + match coreaudio::Error::from_os_status(os_status) { + Ok(()) => Ok(()), + Err(err) => { + let description = err.to_string(); + Err(BackendSpecificError { description }) + } + } +} + +// Create a coreaudio AudioStreamBasicDescription from a CPAL Format. +fn asbd_from_config( + config: &StreamConfig, + sample_format: SampleFormat, +) -> AudioStreamBasicDescription { + let n_channels = config.channels as usize; + let sample_rate = config.sample_rate.0; + let bytes_per_channel = sample_format.sample_size(); + let bits_per_channel = bytes_per_channel * 8; + let bytes_per_frame = n_channels * bytes_per_channel; + let frames_per_packet = 1; + let bytes_per_packet = frames_per_packet * bytes_per_frame; + let format_flags = match sample_format { + SampleFormat::F32 => kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked, + _ => kAudioFormatFlagIsPacked, + }; + AudioStreamBasicDescription { + mBitsPerChannel: bits_per_channel as _, + mBytesPerFrame: bytes_per_frame as _, + mChannelsPerFrame: n_channels as _, + mBytesPerPacket: bytes_per_packet as _, + mFramesPerPacket: frames_per_packet as _, + mFormatFlags: format_flags, + mFormatID: kAudioFormatLinearPCM, + mSampleRate: sample_rate as _, + ..Default::default() + } +} + +fn host_time_to_stream_instant( + m_host_time: u64, +) -> Result { + let mut info: mach2::mach_time::mach_timebase_info = Default::default(); + let res = unsafe { mach2::mach_time::mach_timebase_info(&mut info) }; + check_os_status(res)?; + let nanos = m_host_time * info.numer as u64 / info.denom as u64; + let secs = nanos / 1_000_000_000; + let subsec_nanos = nanos - secs * 1_000_000_000; + Ok(crate::StreamInstant::new(secs as i64, subsec_nanos as u32)) +} + +// Convert the given duration in frames at the given sample rate to a `std::time::Duration`. +fn frames_to_duration(frames: usize, rate: crate::SampleRate) -> std::time::Duration { + let secsf = frames as f64 / rate.0 as f64; + let secs = secsf as u64; + let nanos = ((secsf - secs as f64) * 1_000_000_000.0) as u32; + std::time::Duration::new(secs, nanos) +} + +// TODO need stronger error identification +impl From for BuildStreamError { + fn from(err: coreaudio::Error) -> BuildStreamError { + match err { + coreaudio::Error::RenderCallbackBufferFormatDoesNotMatchAudioUnitStreamFormat + | coreaudio::Error::NoKnownSubtype + | coreaudio::Error::AudioUnit(coreaudio::error::AudioUnitError::FormatNotSupported) + | coreaudio::Error::AudioCodec(_) + | coreaudio::Error::AudioFormat(_) => BuildStreamError::StreamConfigNotSupported, + _ => BuildStreamError::DeviceNotAvailable, + } + } +} + +impl From for SupportedStreamConfigsError { + fn from(err: coreaudio::Error) -> SupportedStreamConfigsError { + let description = format!("{}", err); + let err = BackendSpecificError { description }; + // Check for possible DeviceNotAvailable variant + SupportedStreamConfigsError::BackendSpecific { err } + } +} + +impl From for DefaultStreamConfigError { + fn from(err: coreaudio::Error) -> DefaultStreamConfigError { + let description = format!("{}", err); + let err = BackendSpecificError { description }; + // Check for possible DeviceNotAvailable variant + DefaultStreamConfigError::BackendSpecific { err } + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/emscripten/mod.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/emscripten/mod.rs new file mode 100644 index 000000000..4d6b3daf8 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/emscripten/mod.rs @@ -0,0 +1,422 @@ +use js_sys::Float32Array; +use std::time::Duration; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::{spawn_local, JsFuture}; +use web_sys::AudioContext; + +use crate::traits::{DeviceTrait, HostTrait, StreamTrait}; +use crate::{ + BufferSize, BuildStreamError, Data, DefaultStreamConfigError, DeviceNameError, DevicesError, + InputCallbackInfo, OutputCallbackInfo, PauseStreamError, PlayStreamError, SampleFormat, + SampleRate, StreamConfig, StreamError, SupportedBufferSize, SupportedStreamConfig, + SupportedStreamConfigRange, SupportedStreamConfigsError, +}; + +// The emscripten backend currently works by instantiating an `AudioContext` object per `Stream`. +// Creating a stream creates a new `AudioContext`. Destroying a stream destroys it. Creation of a +// `Host` instance initializes the `stdweb` context. + +/// The default emscripten host type. +#[derive(Debug)] +pub struct Host; + +/// Content is false if the iterator is empty. +pub struct Devices(bool); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Device; + +#[wasm_bindgen] +#[derive(Clone)] +pub struct Stream { + // A reference to an `AudioContext` object. + audio_ctxt: AudioContext, +} + +// Index within the `streams` array of the events loop. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct StreamId(usize); + +pub type SupportedInputConfigs = ::std::vec::IntoIter; +pub type SupportedOutputConfigs = ::std::vec::IntoIter; + +const MIN_CHANNELS: u16 = 1; +const MAX_CHANNELS: u16 = 32; +const MIN_SAMPLE_RATE: SampleRate = SampleRate(8_000); +const MAX_SAMPLE_RATE: SampleRate = SampleRate(96_000); +const DEFAULT_SAMPLE_RATE: SampleRate = SampleRate(44_100); +const MIN_BUFFER_SIZE: u32 = 1; +const MAX_BUFFER_SIZE: u32 = u32::MAX; +const DEFAULT_BUFFER_SIZE: usize = 2048; +const SUPPORTED_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32; + +impl Host { + pub fn new() -> Result { + Ok(Host) + } +} + +impl Devices { + fn new() -> Result { + Ok(Self::default()) + } +} + +impl Device { + #[inline] + fn name(&self) -> Result { + Ok("Default Device".to_owned()) + } + + #[inline] + fn supported_input_configs( + &self, + ) -> Result { + unimplemented!(); + } + + #[inline] + fn supported_output_configs( + &self, + ) -> Result { + let buffer_size = SupportedBufferSize::Range { + min: MIN_BUFFER_SIZE, + max: MAX_BUFFER_SIZE, + }; + let configs: Vec<_> = (MIN_CHANNELS..=MAX_CHANNELS) + .map(|channels| SupportedStreamConfigRange { + channels, + min_sample_rate: MIN_SAMPLE_RATE, + max_sample_rate: MAX_SAMPLE_RATE, + buffer_size: buffer_size.clone(), + sample_format: SUPPORTED_SAMPLE_FORMAT, + }) + .collect(); + Ok(configs.into_iter()) + } + + fn default_input_config(&self) -> Result { + unimplemented!(); + } + + fn default_output_config(&self) -> Result { + const EXPECT: &str = "expected at least one valid webaudio stream config"; + let config = self + .supported_output_configs() + .expect(EXPECT) + .max_by(|a, b| a.cmp_default_heuristics(b)) + .unwrap() + .with_sample_rate(DEFAULT_SAMPLE_RATE); + + Ok(config) + } +} + +impl HostTrait for Host { + type Devices = Devices; + type Device = Device; + + fn is_available() -> bool { + // Assume this host is always available on emscripten. + true + } + + fn devices(&self) -> Result { + Devices::new() + } + + fn default_input_device(&self) -> Option { + default_input_device() + } + + fn default_output_device(&self) -> Option { + default_output_device() + } +} + +impl DeviceTrait for Device { + type SupportedInputConfigs = SupportedInputConfigs; + type SupportedOutputConfigs = SupportedOutputConfigs; + type Stream = Stream; + + fn name(&self) -> Result { + Device::name(self) + } + + fn supported_input_configs( + &self, + ) -> Result { + Device::supported_input_configs(self) + } + + fn supported_output_configs( + &self, + ) -> Result { + Device::supported_output_configs(self) + } + + fn default_input_config(&self) -> Result { + Device::default_input_config(self) + } + + fn default_output_config(&self) -> Result { + Device::default_output_config(self) + } + + fn build_input_stream_raw( + &self, + _config: &StreamConfig, + _sample_format: SampleFormat, + _data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + unimplemented!() + } + + fn build_output_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + if !valid_config(config, sample_format) { + return Err(BuildStreamError::StreamConfigNotSupported); + } + + let buffer_size_frames = match config.buffer_size { + BufferSize::Fixed(v) => { + if v == 0 { + return Err(BuildStreamError::StreamConfigNotSupported); + } else { + v as usize + } + } + BufferSize::Default => DEFAULT_BUFFER_SIZE, + }; + + // Create the stream. + let audio_ctxt = AudioContext::new().expect("webaudio is not present on this system"); + let stream = Stream { audio_ctxt }; + + // Use `set_timeout` to invoke a Rust callback repeatedly. + // + // The job of this callback is to fill the content of the audio buffers. + // + // See also: The call to `set_timeout` at the end of the `audio_callback_fn` which creates + // the loop. + set_timeout( + 10, + stream.clone(), + data_callback, + config, + sample_format, + buffer_size_frames as u32, + ); + + Ok(stream) + } +} + +impl StreamTrait for Stream { + fn play(&self) -> Result<(), PlayStreamError> { + let future = JsFuture::from( + self.audio_ctxt + .resume() + .expect("Could not resume the stream"), + ); + spawn_local(async { + match future.await { + Ok(value) => assert!(value.is_undefined()), + Err(value) => panic!("AudioContext.resume() promise was rejected: {:?}", value), + } + }); + Ok(()) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + let future = JsFuture::from( + self.audio_ctxt + .suspend() + .expect("Could not suspend the stream"), + ); + spawn_local(async { + match future.await { + Ok(value) => assert!(value.is_undefined()), + Err(value) => panic!("AudioContext.suspend() promise was rejected: {:?}", value), + } + }); + Ok(()) + } +} + +fn audio_callback_fn( + mut data_callback: D, +) -> impl FnOnce(Stream, StreamConfig, SampleFormat, u32) +where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, +{ + |stream, config, sample_format, buffer_size_frames| { + let sample_rate = config.sample_rate.0; + let buffer_size_samples = buffer_size_frames * config.channels as u32; + let audio_ctxt = &stream.audio_ctxt; + + // TODO: We should be re-using a buffer. + let mut temporary_buffer = vec![0f32; buffer_size_samples as usize]; + + { + let len = temporary_buffer.len(); + let data = temporary_buffer.as_mut_ptr() as *mut (); + let mut data = unsafe { Data::from_parts(data, len, sample_format) }; + let now_secs: f64 = audio_ctxt.current_time(); + let callback = crate::StreamInstant::from_secs_f64(now_secs); + // TODO: Use proper latency instead. Currently, unsupported on most browsers though, so + // we estimate based on buffer size instead. Probably should use this, but it's only + // supported by firefox (2020-04-28). + // let latency_secs: f64 = audio_ctxt.outputLatency.try_into().unwrap(); + let buffer_duration = frames_to_duration(len, sample_rate as usize); + let playback = callback + .add(buffer_duration) + .expect("`playback` occurs beyond representation supported by `StreamInstant`"); + let timestamp = crate::OutputStreamTimestamp { callback, playback }; + let info = OutputCallbackInfo { timestamp }; + data_callback(&mut data, &info); + } + + let typed_array: Float32Array = temporary_buffer.as_slice().into(); + + debug_assert_eq!(temporary_buffer.len() % config.channels as usize, 0); + + let src_buffer = Float32Array::new(typed_array.buffer().as_ref()); + let context = audio_ctxt; + let buffer = context + .create_buffer( + config.channels as u32, + buffer_size_frames as u32, + sample_rate as f32, + ) + .expect("Buffer could not be created"); + for channel in 0..config.channels { + let mut buffer_content = buffer + .get_channel_data(channel as u32) + .expect("Should be impossible"); + for (i, buffer_content_item) in buffer_content.iter_mut().enumerate() { + *buffer_content_item = + src_buffer.get_index(i as u32 * config.channels as u32 + channel as u32); + } + } + + let node = context + .create_buffer_source() + .expect("The buffer source node could not be created"); + node.set_buffer(Some(&buffer)); + context + .destination() + .connect_with_audio_node(&node) + .expect("Could not connect the audio node to the destination"); + node.start().expect("Could not start the audio node"); + + // TODO: handle latency better ; right now we just use setInterval with the amount of sound + // data that is in each buffer ; this is obviously bad, and also the schedule is too tight + // and there may be underflows + set_timeout( + 1000 * buffer_size_frames as i32 / sample_rate as i32, + stream.clone().clone(), + data_callback, + &config, + sample_format, + buffer_size_frames as u32, + ); + } +} + +fn set_timeout( + time: i32, + stream: Stream, + data_callback: D, + config: &StreamConfig, + sample_format: SampleFormat, + buffer_size_frames: u32, +) where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, +{ + let window = web_sys::window().expect("Not in a window somehow?"); + window + .set_timeout_with_callback_and_timeout_and_arguments_4( + &Closure::once_into_js(audio_callback_fn(data_callback)) + .dyn_ref::() + .expect("The function was somehow not a function"), + time, + &stream.into(), + &((*config).clone()).into(), + &Closure::once_into_js(move || sample_format), + &buffer_size_frames.into(), + ) + .expect("The timeout could not be set"); +} + +impl Default for Devices { + fn default() -> Devices { + // We produce an empty iterator if the WebAudio API isn't available. + Devices(is_webaudio_available()) + } +} +impl Iterator for Devices { + type Item = Device; + #[inline] + fn next(&mut self) -> Option { + if self.0 { + self.0 = false; + Some(Device) + } else { + None + } + } +} + +#[inline] +fn default_input_device() -> Option { + unimplemented!(); +} + +#[inline] +fn default_output_device() -> Option { + if is_webaudio_available() { + Some(Device) + } else { + None + } +} + +// Detects whether the `AudioContext` global variable is available. +fn is_webaudio_available() -> bool { + AudioContext::new().is_ok() +} + +// Whether or not the given stream configuration is valid for building a stream. +fn valid_config(conf: &StreamConfig, sample_format: SampleFormat) -> bool { + conf.channels <= MAX_CHANNELS + && conf.channels >= MIN_CHANNELS + && conf.sample_rate <= MAX_SAMPLE_RATE + && conf.sample_rate >= MIN_SAMPLE_RATE + && sample_format == SUPPORTED_SAMPLE_FORMAT +} + +// Convert the given duration in frames at the given sample rate to a `std::time::Duration`. +fn frames_to_duration(frames: usize, rate: usize) -> std::time::Duration { + let secsf = frames as f64 / rate as f64; + let secs = secsf as u64; + let nanos = ((secsf - secs as f64) * 1_000_000_000.0) as u32; + std::time::Duration::new(secs, nanos) +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/jack/device.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/jack/device.rs new file mode 100644 index 000000000..7a911dce4 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/jack/device.rs @@ -0,0 +1,268 @@ +use crate::traits::DeviceTrait; +use crate::{ + BackendSpecificError, BuildStreamError, Data, DefaultStreamConfigError, DeviceNameError, + InputCallbackInfo, OutputCallbackInfo, SampleFormat, SampleRate, StreamConfig, StreamError, + SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange, + SupportedStreamConfigsError, +}; +use std::hash::{Hash, Hasher}; +use std::time::Duration; + +use super::stream::Stream; +use super::JACK_SAMPLE_FORMAT; + +pub type SupportedInputConfigs = std::vec::IntoIter; +pub type SupportedOutputConfigs = std::vec::IntoIter; + +const DEFAULT_NUM_CHANNELS: u16 = 2; +const DEFAULT_SUPPORTED_CHANNELS: [u16; 10] = [1, 2, 4, 6, 8, 16, 24, 32, 48, 64]; + +/// If a device is for input or output. +/// Until we have duplex stream support JACK clients and CPAL devices for JACK will be either input or output. +#[derive(Clone, Debug)] +pub enum DeviceType { + InputDevice, + OutputDevice, +} +#[derive(Clone, Debug)] +pub struct Device { + name: String, + sample_rate: SampleRate, + buffer_size: SupportedBufferSize, + device_type: DeviceType, + start_server_automatically: bool, + connect_ports_automatically: bool, +} + +impl Device { + fn new_device( + name: String, + connect_ports_automatically: bool, + start_server_automatically: bool, + device_type: DeviceType, + ) -> Result { + // ClientOptions are bit flags that you can set with the constants provided + let client_options = super::get_client_options(start_server_automatically); + + // Create a dummy client to find out the sample rate of the server to be able to provide it as a possible config. + // This client will be dropped, and a new one will be created when making the stream. + // This is a hack due to the fact that the Client must be moved to create the AsyncClient. + match super::get_client(&name, client_options) { + Ok(client) => Ok(Device { + // The name given to the client by JACK, could potentially be different from the name supplied e.g.if there is a name collision + name: client.name().to_string(), + sample_rate: SampleRate(client.sample_rate() as u32), + buffer_size: SupportedBufferSize::Range { + min: client.buffer_size(), + max: client.buffer_size(), + }, + device_type, + start_server_automatically, + connect_ports_automatically, + }), + Err(e) => Err(e), + } + } + + pub fn default_output_device( + name: &str, + connect_ports_automatically: bool, + start_server_automatically: bool, + ) -> Result { + let output_client_name = format!("{}_out", name); + Device::new_device( + output_client_name, + connect_ports_automatically, + start_server_automatically, + DeviceType::OutputDevice, + ) + } + + pub fn default_input_device( + name: &str, + connect_ports_automatically: bool, + start_server_automatically: bool, + ) -> Result { + let input_client_name = format!("{}_in", name); + Device::new_device( + input_client_name, + connect_ports_automatically, + start_server_automatically, + DeviceType::InputDevice, + ) + } + + pub fn default_config(&self) -> Result { + let channels = DEFAULT_NUM_CHANNELS; + let sample_rate = self.sample_rate; + let buffer_size = self.buffer_size.clone(); + // The sample format for JACK audio ports is always "32-bit float mono audio" in the current implementation. + // Custom formats are allowed within JACK, but this is of niche interest. + // The format can be found programmatically by calling jack::PortSpec::port_type() on a created port. + let sample_format = JACK_SAMPLE_FORMAT; + Ok(SupportedStreamConfig { + channels, + sample_rate, + buffer_size, + sample_format, + }) + } + + pub fn supported_configs(&self) -> Vec { + let f = match self.default_config() { + Err(_) => return vec![], + Ok(f) => f, + }; + + let mut supported_configs = vec![]; + + for &channels in DEFAULT_SUPPORTED_CHANNELS.iter() { + supported_configs.push(SupportedStreamConfigRange { + channels, + min_sample_rate: f.sample_rate, + max_sample_rate: f.sample_rate, + buffer_size: f.buffer_size.clone(), + sample_format: f.sample_format, + }); + } + supported_configs + } + + pub fn is_input(&self) -> bool { + matches!(self.device_type, DeviceType::InputDevice) + } + + pub fn is_output(&self) -> bool { + matches!(self.device_type, DeviceType::OutputDevice) + } +} + +impl DeviceTrait for Device { + type SupportedInputConfigs = SupportedInputConfigs; + type SupportedOutputConfigs = SupportedOutputConfigs; + type Stream = Stream; + + fn name(&self) -> Result { + Ok(self.name.clone()) + } + + fn supported_input_configs( + &self, + ) -> Result { + Ok(self.supported_configs().into_iter()) + } + + fn supported_output_configs( + &self, + ) -> Result { + Ok(self.supported_configs().into_iter()) + } + + /// Returns the default input config + /// The sample format for JACK audio ports is always "32-bit float mono audio" unless using a custom type. + /// The sample rate is set by the JACK server. + fn default_input_config(&self) -> Result { + self.default_config() + } + + /// Returns the default output config + /// The sample format for JACK audio ports is always "32-bit float mono audio" unless using a custom type. + /// The sample rate is set by the JACK server. + fn default_output_config(&self) -> Result { + self.default_config() + } + + fn build_input_stream_raw( + &self, + conf: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + if let DeviceType::OutputDevice = &self.device_type { + // Trying to create an input stream from an output device + return Err(BuildStreamError::StreamConfigNotSupported); + } + if conf.sample_rate != self.sample_rate || sample_format != JACK_SAMPLE_FORMAT { + return Err(BuildStreamError::StreamConfigNotSupported); + } + // The settings should be fine, create a Client + let client_options = super::get_client_options(self.start_server_automatically); + let client; + match super::get_client(&self.name, client_options) { + Ok(c) => client = c, + Err(e) => { + return Err(BuildStreamError::BackendSpecific { + err: BackendSpecificError { description: e }, + }) + } + }; + let mut stream = Stream::new_input(client, conf.channels, data_callback, error_callback); + + if self.connect_ports_automatically { + stream.connect_to_system_inputs(); + } + + Ok(stream) + } + + fn build_output_stream_raw( + &self, + conf: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + if let DeviceType::InputDevice = &self.device_type { + // Trying to create an output stream from an input device + return Err(BuildStreamError::StreamConfigNotSupported); + } + if conf.sample_rate != self.sample_rate || sample_format != JACK_SAMPLE_FORMAT { + return Err(BuildStreamError::StreamConfigNotSupported); + } + + // The settings should be fine, create a Client + let client_options = super::get_client_options(self.start_server_automatically); + let client; + match super::get_client(&self.name, client_options) { + Ok(c) => client = c, + Err(e) => { + return Err(BuildStreamError::BackendSpecific { + err: BackendSpecificError { description: e }, + }) + } + }; + let mut stream = Stream::new_output(client, conf.channels, data_callback, error_callback); + + if self.connect_ports_automatically { + stream.connect_to_system_outputs(); + } + + Ok(stream) + } +} + +impl PartialEq for Device { + fn eq(&self, other: &Self) -> bool { + // Device::name() can never fail in this implementation + self.name().unwrap() == other.name().unwrap() + } +} + +impl Eq for Device {} + +impl Hash for Device { + fn hash(&self, state: &mut H) { + self.name().unwrap().hash(state); + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/jack/mod.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/jack/mod.rs new file mode 100644 index 000000000..e1e244e96 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/jack/mod.rs @@ -0,0 +1,180 @@ +extern crate jack; + +use crate::traits::HostTrait; +use crate::{DevicesError, SampleFormat, SupportedStreamConfigRange}; + +mod device; +pub use self::device::Device; +pub use self::stream::Stream; +mod stream; + +const JACK_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32; + +pub type SupportedInputConfigs = std::vec::IntoIter; +pub type SupportedOutputConfigs = std::vec::IntoIter; +pub type Devices = std::vec::IntoIter; + +/// The JACK Host type +#[derive(Debug)] +pub struct Host { + /// The name that the client will have in JACK. + /// Until we have duplex streams two clients will be created adding "out" or "in" to the name + /// since names have to be unique. + name: String, + /// If ports are to be connected to the system (soundcard) ports automatically (default is true). + connect_ports_automatically: bool, + /// If the JACK server should be started automatically if it isn't already when creating a Client (default is false). + start_server_automatically: bool, + /// A list of the devices that have been created from this Host. + devices_created: Vec, +} + +impl Host { + pub fn new() -> Result { + let mut host = Host { + name: "cpal_client".to_owned(), + connect_ports_automatically: true, + start_server_automatically: false, + devices_created: vec![], + }; + // Devices don't exist for JACK, they have to be created + host.initialize_default_devices(); + Ok(host) + } + /// Set whether the ports should automatically be connected to system + /// (default is true) + pub fn set_connect_automatically(&mut self, do_connect: bool) { + self.connect_ports_automatically = do_connect; + } + /// Set whether a JACK server should be automatically started if it isn't already. + /// (default is false) + pub fn set_start_server_automatically(&mut self, do_start_server: bool) { + self.start_server_automatically = do_start_server; + } + + pub fn input_device_with_name(&mut self, name: &str) -> Option { + self.name = name.to_owned(); + self.default_input_device() + } + + pub fn output_device_with_name(&mut self, name: &str) -> Option { + self.name = name.to_owned(); + self.default_output_device() + } + + fn initialize_default_devices(&mut self) { + let in_device_res = Device::default_input_device( + &self.name, + self.connect_ports_automatically, + self.start_server_automatically, + ); + + match in_device_res { + Ok(device) => self.devices_created.push(device), + Err(err) => { + println!("{}", err); + } + } + + let out_device_res = Device::default_output_device( + &self.name, + self.connect_ports_automatically, + self.start_server_automatically, + ); + match out_device_res { + Ok(device) => self.devices_created.push(device), + Err(err) => { + println!("{}", err); + } + } + } +} + +impl HostTrait for Host { + type Devices = Devices; + type Device = Device; + + /// JACK is available if + /// - the jack feature flag is set + /// - libjack is installed (wouldn't compile without it) + /// - the JACK server can be started + /// + /// If the code compiles the necessary jack libraries are installed. + /// There is no way to know if the user has set up a correct JACK configuration e.g. with qjackctl. + /// Users can choose to automatically start the server if it isn't already started when creating a client + /// so checking if the server is running could give a false negative in some use cases. + /// For these reasons this function should always return true. + fn is_available() -> bool { + true + } + + fn devices(&self) -> Result { + Ok(self.devices_created.clone().into_iter()) + } + + fn default_input_device(&self) -> Option { + for device in &self.devices_created { + if device.is_input() { + return Some(device.clone()); + } + } + None + } + + fn default_output_device(&self) -> Option { + for device in &self.devices_created { + if device.is_output() { + return Some(device.clone()); + } + } + None + } +} + +fn get_client_options(start_server_automatically: bool) -> jack::ClientOptions { + let mut client_options = jack::ClientOptions::empty(); + client_options.set( + jack::ClientOptions::NO_START_SERVER, + !start_server_automatically, + ); + client_options +} + +fn get_client(name: &str, client_options: jack::ClientOptions) -> Result { + let c_res = jack::Client::new(name, client_options); + match c_res { + Ok((client, status)) => { + // The ClientStatus can tell us many things + if status.intersects(jack::ClientStatus::SERVER_ERROR) { + return Err(String::from( + "There was an error communicating with the JACK server!", + )); + } else if status.intersects(jack::ClientStatus::SERVER_FAILED) { + return Err(String::from("Could not connect to the JACK server!")); + } else if status.intersects(jack::ClientStatus::VERSION_ERROR) { + return Err(String::from( + "Error connecting to JACK server: Client's protocol version does not match!", + )); + } else if status.intersects(jack::ClientStatus::INIT_FAILURE) { + return Err(String::from( + "Error connecting to JACK server: Unable to initialize client!", + )); + } else if status.intersects(jack::ClientStatus::SHM_FAILURE) { + return Err(String::from( + "Error connecting to JACK server: Unable to access shared memory!", + )); + } else if status.intersects(jack::ClientStatus::NO_SUCH_CLIENT) { + return Err(String::from( + "Error connecting to JACK server: Requested client does not exist!", + )); + } else if status.intersects(jack::ClientStatus::INVALID_OPTION) { + return Err(String::from("Error connecting to JACK server: The operation contained an invalid or unsupported option!")); + } + + return Ok(client); + } + Err(e) => { + return Err(format!("Failed to open client because of error: {:?}", e)); + } + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/jack/stream.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/jack/stream.rs new file mode 100644 index 000000000..95a3e3b40 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/jack/stream.rs @@ -0,0 +1,463 @@ +use crate::traits::StreamTrait; +use crate::ChannelCount; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use crate::{ + BackendSpecificError, Data, InputCallbackInfo, OutputCallbackInfo, PauseStreamError, + PlayStreamError, SampleRate, StreamError, +}; + +use super::JACK_SAMPLE_FORMAT; + +type ErrorCallbackPtr = Arc>; + +pub struct Stream { + // TODO: It might be faster to send a message when playing/pausing than to check this every iteration + playing: Arc, + async_client: jack::AsyncClient, + // Port names are stored in order to connect them to other ports in jack automatically + input_port_names: Vec, + output_port_names: Vec, +} + +impl Stream { + // TODO: Return error messages + pub fn new_input( + client: jack::Client, + channels: ChannelCount, + data_callback: D, + mut error_callback: E, + ) -> Stream + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + let mut ports = vec![]; + let mut port_names: Vec = vec![]; + // Create ports + for i in 0..channels { + let port_try = client.register_port(&format!("in_{}", i), jack::AudioIn::default()); + match port_try { + Ok(port) => { + // Get the port name in order to later connect it automatically + if let Ok(port_name) = port.name() { + port_names.push(port_name); + } + // Store the port into a Vec to move to the ProcessHandler + ports.push(port); + } + Err(e) => { + // If port creation failed, send the error back via the error_callback + error_callback( + BackendSpecificError { + description: e.to_string(), + } + .into(), + ); + } + } + } + + let playing = Arc::new(AtomicBool::new(true)); + + let error_callback_ptr = Arc::new(Mutex::new(error_callback)) as ErrorCallbackPtr; + + let input_process_handler = LocalProcessHandler::new( + vec![], + ports, + SampleRate(client.sample_rate() as u32), + client.buffer_size() as usize, + Some(Box::new(data_callback)), + None, + playing.clone(), + Arc::clone(&error_callback_ptr), + ); + + let notification_handler = JackNotificationHandler::new(error_callback_ptr); + + let async_client = client + .activate_async(notification_handler, input_process_handler) + .unwrap(); + + Stream { + playing, + async_client, + input_port_names: port_names, + output_port_names: vec![], + } + } + + pub fn new_output( + client: jack::Client, + channels: ChannelCount, + data_callback: D, + mut error_callback: E, + ) -> Stream + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + let mut ports = vec![]; + let mut port_names: Vec = vec![]; + // Create ports + for i in 0..channels { + let port_try = client.register_port(&format!("out_{}", i), jack::AudioOut::default()); + match port_try { + Ok(port) => { + // Get the port name in order to later connect it automatically + if let Ok(port_name) = port.name() { + port_names.push(port_name); + } + // Store the port into a Vec to move to the ProcessHandler + ports.push(port); + } + Err(e) => { + // If port creation failed, send the error back via the error_callback + error_callback( + BackendSpecificError { + description: e.to_string(), + } + .into(), + ); + } + } + } + + let playing = Arc::new(AtomicBool::new(true)); + + let error_callback_ptr = Arc::new(Mutex::new(error_callback)) as ErrorCallbackPtr; + + let output_process_handler = LocalProcessHandler::new( + ports, + vec![], + SampleRate(client.sample_rate() as u32), + client.buffer_size() as usize, + None, + Some(Box::new(data_callback)), + playing.clone(), + Arc::clone(&error_callback_ptr), + ); + + let notification_handler = JackNotificationHandler::new(error_callback_ptr); + + let async_client = client + .activate_async(notification_handler, output_process_handler) + .unwrap(); + + Stream { + playing, + async_client, + input_port_names: vec![], + output_port_names: port_names, + } + } + + /// Connect to the standard system outputs in jack, system:playback_1 and system:playback_2 + /// This has to be done after the client is activated, doing it just after creating the ports doesn't work. + pub fn connect_to_system_outputs(&mut self) { + // Get the system ports + let system_ports = self.async_client.as_client().ports( + Some("system:playback_.*"), + None, + jack::PortFlags::empty(), + ); + + // Connect outputs from this client to the system playback inputs + for i in 0..self.output_port_names.len() { + if i >= system_ports.len() { + break; + } + match self + .async_client + .as_client() + .connect_ports_by_name(&self.output_port_names[i], &system_ports[i]) + { + Ok(_) => (), + Err(e) => println!("Unable to connect to port with error {}", e), + } + } + } + + /// Connect to the standard system outputs in jack, system:capture_1 and system:capture_2 + /// This has to be done after the client is activated, doing it just after creating the ports doesn't work. + pub fn connect_to_system_inputs(&mut self) { + // Get the system ports + let system_ports = self.async_client.as_client().ports( + Some("system:capture_.*"), + None, + jack::PortFlags::empty(), + ); + + // Connect outputs from this client to the system playback inputs + for i in 0..self.input_port_names.len() { + if i >= system_ports.len() { + break; + } + match self + .async_client + .as_client() + .connect_ports_by_name(&system_ports[i], &self.input_port_names[i]) + { + Ok(_) => (), + Err(e) => println!("Unable to connect to port with error {}", e), + } + } + } +} + +impl StreamTrait for Stream { + fn play(&self) -> Result<(), PlayStreamError> { + self.playing.store(true, Ordering::SeqCst); + Ok(()) + } + + fn pause(&self) -> Result<(), PauseStreamError> { + self.playing.store(false, Ordering::SeqCst); + Ok(()) + } +} + +struct LocalProcessHandler { + /// No new ports are allowed to be created after the creation of the LocalProcessHandler as that would invalidate the buffer sizes + out_ports: Vec>, + in_ports: Vec>, + + sample_rate: SampleRate, + buffer_size: usize, + input_data_callback: Option>, + output_data_callback: Option>, + + // JACK audio samples are 32-bit float (unless you do some custom dark magic) + temp_input_buffer: Vec, + temp_output_buffer: Vec, + playing: Arc, + creation_timestamp: std::time::Instant, + /// This should not be called on `process`, only on `buffer_size` because it can block. + error_callback_ptr: ErrorCallbackPtr, +} + +impl LocalProcessHandler { + fn new( + out_ports: Vec>, + in_ports: Vec>, + sample_rate: SampleRate, + buffer_size: usize, + input_data_callback: Option>, + output_data_callback: Option< + Box, + >, + playing: Arc, + error_callback_ptr: ErrorCallbackPtr, + ) -> Self { + // These may be reallocated in the `buffer_size` callback. + let temp_input_buffer = vec![0.0; in_ports.len() * buffer_size]; + let temp_output_buffer = vec![0.0; out_ports.len() * buffer_size]; + + LocalProcessHandler { + out_ports, + in_ports, + sample_rate, + buffer_size, + input_data_callback, + output_data_callback, + temp_input_buffer, + temp_output_buffer, + playing, + creation_timestamp: std::time::Instant::now(), + error_callback_ptr, + } + } +} + +fn temp_buffer_to_data(temp_input_buffer: &mut Vec, total_buffer_size: usize) -> Data { + let slice = &temp_input_buffer[0..total_buffer_size]; + let data = slice.as_ptr() as *mut (); + let len = total_buffer_size; + let data = unsafe { Data::from_parts(data, len, JACK_SAMPLE_FORMAT) }; + data +} + +impl jack::ProcessHandler for LocalProcessHandler { + fn process(&mut self, _: &jack::Client, process_scope: &jack::ProcessScope) -> jack::Control { + if !self.playing.load(Ordering::SeqCst) { + return jack::Control::Continue; + } + + // This should be equal to self.buffer_size, but the implementation will + // work even if it is less. Will panic in `temp_buffer_to_data` if greater. + let current_frame_count = process_scope.n_frames() as usize; + + // Get timestamp data + let cycle_times = process_scope.cycle_times(); + let current_start_usecs = match cycle_times { + Ok(times) => times.current_usecs, + Err(_) => { + // jack was unable to get the current time information + // Fall back to using Instants + let now = std::time::Instant::now(); + let duration = now.duration_since(self.creation_timestamp); + duration.as_micros() as u64 + } + }; + let start_cycle_instant = micros_to_stream_instant(current_start_usecs); + let start_callback_instant = start_cycle_instant + .add(frames_to_duration( + process_scope.frames_since_cycle_start() as usize, + self.sample_rate, + )) + .expect("`playback` occurs beyond representation supported by `StreamInstant`"); + + if let Some(input_callback) = &mut self.input_data_callback { + // Let's get the data from the input ports and run the callback + + let num_in_channels = self.in_ports.len(); + + // Read the data from the input ports into the temporary buffer + // Go through every channel and store its data in the temporary input buffer + for ch_ix in 0..num_in_channels { + let input_channel = &self.in_ports[ch_ix].as_slice(process_scope); + for i in 0..current_frame_count { + self.temp_input_buffer[ch_ix + i * num_in_channels] = input_channel[i]; + } + } + // Create a slice of exactly current_frame_count frames + let data = temp_buffer_to_data( + &mut self.temp_input_buffer, + current_frame_count * num_in_channels, + ); + // Create timestamp + let frames_since_cycle_start = process_scope.frames_since_cycle_start() as usize; + let duration_since_cycle_start = + frames_to_duration(frames_since_cycle_start, self.sample_rate); + let callback = start_callback_instant + .add(duration_since_cycle_start) + .expect("`playback` occurs beyond representation supported by `StreamInstant`"); + let capture = start_callback_instant; + let timestamp = crate::InputStreamTimestamp { callback, capture }; + let info = crate::InputCallbackInfo { timestamp }; + input_callback(&data, &info); + } + + if let Some(output_callback) = &mut self.output_data_callback { + let num_out_channels = self.out_ports.len(); + + // Create a slice of exactly current_frame_count frames + let mut data = temp_buffer_to_data( + &mut self.temp_output_buffer, + current_frame_count * num_out_channels, + ); + // Create timestamp + let frames_since_cycle_start = process_scope.frames_since_cycle_start() as usize; + let duration_since_cycle_start = + frames_to_duration(frames_since_cycle_start, self.sample_rate); + let callback = start_callback_instant + .add(duration_since_cycle_start) + .expect("`playback` occurs beyond representation supported by `StreamInstant`"); + let buffer_duration = frames_to_duration(current_frame_count, self.sample_rate); + let playback = start_cycle_instant + .add(buffer_duration) + .expect("`playback` occurs beyond representation supported by `StreamInstant`"); + let timestamp = crate::OutputStreamTimestamp { callback, playback }; + let info = crate::OutputCallbackInfo { timestamp }; + output_callback(&mut data, &info); + + // Deinterlace + for ch_ix in 0..num_out_channels { + let output_channel = &mut self.out_ports[ch_ix].as_mut_slice(process_scope); + for i in 0..current_frame_count { + output_channel[i] = self.temp_output_buffer[ch_ix + i * num_out_channels]; + } + } + } + + // Continue as normal + jack::Control::Continue + } + + fn buffer_size(&mut self, _: &jack::Client, size: jack::Frames) -> jack::Control { + // The `buffer_size` callback is actually called on the process thread, but + // it does not need to be suitable for real-time use. Thus we can simply allocate + // new buffers here. It is also fine to call the error callback. + // Details: https://github.com/RustAudio/rust-jack/issues/137 + let new_size = size as usize; + if new_size != self.buffer_size { + self.buffer_size = new_size; + self.temp_input_buffer = vec![0.0; self.in_ports.len() * new_size]; + self.temp_output_buffer = vec![0.0; self.out_ports.len() * new_size]; + let description = format!("buffer size changed to: {}", new_size); + if let Ok(mut mutex_guard) = self.error_callback_ptr.lock() { + let err = &mut *mutex_guard; + err(BackendSpecificError { description }.into()); + } + } + + jack::Control::Continue + } +} + +fn micros_to_stream_instant(micros: u64) -> crate::StreamInstant { + let nanos = micros * 1000; + let secs = micros / 1_000_000; + let subsec_nanos = nanos - secs * 1_000_000_000; + crate::StreamInstant::new(secs as i64, subsec_nanos as u32) +} + +// Convert the given duration in frames at the given sample rate to a `std::time::Duration`. +fn frames_to_duration(frames: usize, rate: crate::SampleRate) -> std::time::Duration { + let secsf = frames as f64 / rate.0 as f64; + let secs = secsf as u64; + let nanos = ((secsf - secs as f64) * 1_000_000_000.0) as u32; + std::time::Duration::new(secs, nanos) +} + +/// Receives notifications from the JACK server. It is unclear if this may be run concurrent with itself under JACK2 specs +/// so it needs to be Sync. +struct JackNotificationHandler { + error_callback_ptr: ErrorCallbackPtr, + init_sample_rate_flag: Arc, +} + +impl JackNotificationHandler { + pub fn new(error_callback_ptr: ErrorCallbackPtr) -> Self { + JackNotificationHandler { + error_callback_ptr, + init_sample_rate_flag: Arc::new(AtomicBool::new(false)), + } + } + + fn send_error(&mut self, description: String) { + // This thread isn't the audio thread, it's fine to block + if let Ok(mut mutex_guard) = self.error_callback_ptr.lock() { + let err = &mut *mutex_guard; + err(BackendSpecificError { description }.into()); + } + } +} + +impl jack::NotificationHandler for JackNotificationHandler { + fn shutdown(&mut self, _status: jack::ClientStatus, reason: &str) { + self.send_error(format!("JACK was shut down for reason: {}", reason)); + } + + fn sample_rate(&mut self, _: &jack::Client, srate: jack::Frames) -> jack::Control { + match self.init_sample_rate_flag.load(Ordering::SeqCst) { + false => { + // One of these notifications is sent every time a client is started. + self.init_sample_rate_flag.store(true, Ordering::SeqCst); + jack::Control::Continue + } + true => { + self.send_error(format!("sample rate changed to: {}", srate)); + // Since CPAL currently has no way of signaling a sample rate change in order to make + // all necessary changes that would bring we choose to quit. + jack::Control::Quit + } + } + } + + fn xrun(&mut self, _: &jack::Client) -> jack::Control { + self.send_error(String::from("xrun (buffer over or under run)")); + jack::Control::Continue + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/mod.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/mod.rs new file mode 100644 index 000000000..8de06cbe0 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/mod.rs @@ -0,0 +1,30 @@ +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" +))] +pub(crate) mod alsa; +#[cfg(all(windows, feature = "asio"))] +pub(crate) mod asio; +#[cfg(any(target_os = "macos", target_os = "ios"))] +pub(crate) mod coreaudio; +#[cfg(target_os = "emscripten")] +pub(crate) mod emscripten; +#[cfg(all( + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + ), + feature = "jack" +))] +pub(crate) mod jack; +pub(crate) mod null; +#[cfg(target_os = "android")] +pub(crate) mod oboe; +#[cfg(windows)] +pub(crate) mod wasapi; +#[cfg(all(target_arch = "wasm32", feature = "wasm-bindgen"))] +pub(crate) mod webaudio; diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/null/mod.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/null/mod.rs new file mode 100644 index 000000000..26497f87f --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/null/mod.rs @@ -0,0 +1,160 @@ +use std::time::Duration; + +use crate::traits::{DeviceTrait, HostTrait, StreamTrait}; +use crate::{ + BuildStreamError, Data, DefaultStreamConfigError, DeviceNameError, DevicesError, + InputCallbackInfo, OutputCallbackInfo, PauseStreamError, PlayStreamError, SampleFormat, + StreamConfig, StreamError, SupportedStreamConfig, SupportedStreamConfigRange, + SupportedStreamConfigsError, +}; + +#[derive(Default)] +pub struct Devices; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Device; + +pub struct Host; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Stream; + +pub struct SupportedInputConfigs; +pub struct SupportedOutputConfigs; + +impl Host { + #[allow(dead_code)] + pub fn new() -> Result { + Ok(Host) + } +} + +impl Devices { + pub fn new() -> Result { + Ok(Devices) + } +} + +impl DeviceTrait for Device { + type SupportedInputConfigs = SupportedInputConfigs; + type SupportedOutputConfigs = SupportedOutputConfigs; + type Stream = Stream; + + #[inline] + fn name(&self) -> Result { + Ok("null".to_owned()) + } + + #[inline] + fn supported_input_configs( + &self, + ) -> Result { + unimplemented!() + } + + #[inline] + fn supported_output_configs( + &self, + ) -> Result { + unimplemented!() + } + + #[inline] + fn default_input_config(&self) -> Result { + unimplemented!() + } + + #[inline] + fn default_output_config(&self) -> Result { + unimplemented!() + } + + fn build_input_stream_raw( + &self, + _config: &StreamConfig, + _sample_format: SampleFormat, + _data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + unimplemented!() + } + + /// Create an output stream. + fn build_output_stream_raw( + &self, + _config: &StreamConfig, + _sample_format: SampleFormat, + _data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + unimplemented!() + } +} + +impl HostTrait for Host { + type Devices = Devices; + type Device = Device; + + fn is_available() -> bool { + false + } + + fn devices(&self) -> Result { + Devices::new() + } + + fn default_input_device(&self) -> Option { + None + } + + fn default_output_device(&self) -> Option { + None + } +} + +impl StreamTrait for Stream { + fn play(&self) -> Result<(), PlayStreamError> { + unimplemented!() + } + + fn pause(&self) -> Result<(), PauseStreamError> { + unimplemented!() + } +} + +impl Iterator for Devices { + type Item = Device; + + #[inline] + fn next(&mut self) -> Option { + None + } +} + +impl Iterator for SupportedInputConfigs { + type Item = SupportedStreamConfigRange; + + #[inline] + fn next(&mut self) -> Option { + None + } +} + +impl Iterator for SupportedOutputConfigs { + type Item = SupportedStreamConfigRange; + + #[inline] + fn next(&mut self) -> Option { + None + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/oboe/android_media.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/oboe/android_media.rs new file mode 100644 index 000000000..f2a958031 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/oboe/android_media.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +extern crate jni; + +use self::jni::Executor; +use self::jni::{errors::Result as JResult, JNIEnv, JavaVM}; + +// constants from android.media.AudioFormat +pub const ENCODING_PCM_16BIT: i32 = 2; +pub const ENCODING_PCM_FLOAT: i32 = 4; +pub const CHANNEL_OUT_MONO: i32 = 4; +pub const CHANNEL_OUT_STEREO: i32 = 12; + +fn with_attached(closure: F) -> JResult +where + F: FnOnce(&mut JNIEnv) -> JResult, +{ + let android_context = ndk_context::android_context(); + let vm = Arc::new(unsafe { JavaVM::from_raw(android_context.vm().cast())? }); + Executor::new(vm).with_attached(|env| closure(env)) +} + +fn get_min_buffer_size( + class: &'static str, + sample_rate: i32, + channel_mask: i32, + format: i32, +) -> i32 { + // Unwrapping everything because these operations are not expected to fail + // or throw exceptions. Android returns negative values for invalid parameters, + // which is what we expect. + with_attached(|env| { + let class = env.find_class(class).unwrap(); + env.call_static_method( + class, + "getMinBufferSize", + "(III)I", + &[sample_rate.into(), channel_mask.into(), format.into()], + ) + .unwrap() + .i() + }) + .unwrap() +} + +pub fn get_audio_track_min_buffer_size(sample_rate: i32, channel_mask: i32, format: i32) -> i32 { + get_min_buffer_size( + "android/media/AudioTrack", + sample_rate, + channel_mask, + format, + ) +} + +pub fn get_audio_record_min_buffer_size(sample_rate: i32, channel_mask: i32, format: i32) -> i32 { + get_min_buffer_size( + "android/media/AudioRecord", + sample_rate, + channel_mask, + format, + ) +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/oboe/convert.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/oboe/convert.rs new file mode 100644 index 000000000..711d2f24c --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/oboe/convert.rs @@ -0,0 +1,82 @@ +use std::convert::TryInto; +use std::time::Duration; + +extern crate oboe; + +use crate::{ + BackendSpecificError, BuildStreamError, PauseStreamError, PlayStreamError, StreamError, + StreamInstant, +}; + +pub fn to_stream_instant(duration: Duration) -> StreamInstant { + StreamInstant::new( + duration.as_secs().try_into().unwrap(), + duration.subsec_nanos(), + ) +} + +pub fn stream_instant(stream: &mut T) -> StreamInstant { + const CLOCK_MONOTONIC: i32 = 1; + let ts = stream + .get_timestamp(CLOCK_MONOTONIC) + .unwrap_or(oboe::FrameTimestamp { + position: 0, + timestamp: 0, + }); + to_stream_instant(Duration::from_nanos(ts.timestamp as u64)) +} + +impl From for StreamError { + fn from(error: oboe::Error) -> Self { + use self::oboe::Error::*; + match error { + Disconnected | Unavailable | Closed => Self::DeviceNotAvailable, + e => (BackendSpecificError { + description: e.to_string(), + }) + .into(), + } + } +} + +impl From for PlayStreamError { + fn from(error: oboe::Error) -> Self { + use self::oboe::Error::*; + match error { + Disconnected | Unavailable | Closed => Self::DeviceNotAvailable, + e => (BackendSpecificError { + description: e.to_string(), + }) + .into(), + } + } +} + +impl From for PauseStreamError { + fn from(error: oboe::Error) -> Self { + use self::oboe::Error::*; + match error { + Disconnected | Unavailable | Closed => Self::DeviceNotAvailable, + e => (BackendSpecificError { + description: e.to_string(), + }) + .into(), + } + } +} + +impl From for BuildStreamError { + fn from(error: oboe::Error) -> Self { + use self::oboe::Error::*; + match error { + Disconnected | Unavailable | Closed => Self::DeviceNotAvailable, + NoFreeHandles => Self::StreamIdOverflow, + InvalidFormat | InvalidRate => Self::StreamConfigNotSupported, + IllegalArgument => Self::InvalidArgument, + e => (BackendSpecificError { + description: e.to_string(), + }) + .into(), + } + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/oboe/input_callback.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/oboe/input_callback.rs new file mode 100644 index 000000000..0dfb9c881 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/oboe/input_callback.rs @@ -0,0 +1,90 @@ +use std::marker::PhantomData; +use std::time::Instant; + +extern crate oboe; + +use super::convert::{stream_instant, to_stream_instant}; +use crate::{Data, InputCallbackInfo, InputStreamTimestamp, SizedSample, StreamError}; + +pub struct CpalInputCallback { + data_cb: Box, + error_cb: Box, + created: Instant, + phantom_channel: PhantomData, + phantom_input: PhantomData, +} + +impl CpalInputCallback { + pub fn new(data_cb: D, error_cb: E) -> Self + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Self { + data_cb: Box::new(data_cb), + error_cb: Box::new(error_cb), + created: Instant::now(), + phantom_channel: PhantomData, + phantom_input: PhantomData, + } + } + + fn make_callback_info( + &self, + audio_stream: &mut dyn oboe::AudioInputStreamSafe, + ) -> InputCallbackInfo { + InputCallbackInfo { + timestamp: InputStreamTimestamp { + callback: to_stream_instant(self.created.elapsed()), + capture: stream_instant(audio_stream), + }, + } + } +} + +impl oboe::AudioInputCallback for CpalInputCallback +where + (T, C): oboe::IsFrameType, +{ + type FrameType = (T, C); + + fn on_error_before_close( + &mut self, + _audio_stream: &mut dyn oboe::AudioInputStreamSafe, + error: oboe::Error, + ) { + (self.error_cb)(StreamError::from(error)) + } + + fn on_error_after_close( + &mut self, + _audio_stream: &mut dyn oboe::AudioInputStreamSafe, + error: oboe::Error, + ) { + (self.error_cb)(StreamError::from(error)) + } + + fn on_audio_ready( + &mut self, + audio_stream: &mut dyn oboe::AudioInputStreamSafe, + audio_data: &[<::FrameType as oboe::IsFrameType>::Type], + ) -> oboe::DataCallbackResult { + let cb_info = self.make_callback_info(audio_stream); + let channel_count = if C::CHANNEL_COUNT == oboe::ChannelCount::Mono { + 1 + } else { + 2 + }; + (self.data_cb)( + &unsafe { + Data::from_parts( + audio_data.as_ptr() as *mut _, + audio_data.len() * channel_count, + T::FORMAT, + ) + }, + &cb_info, + ); + oboe::DataCallbackResult::Continue + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/oboe/mod.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/oboe/mod.rs new file mode 100644 index 000000000..e2acb5208 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/oboe/mod.rs @@ -0,0 +1,499 @@ +use std::cell::RefCell; +use std::cmp; +use std::convert::TryInto; +use std::time::Duration; +use std::vec::IntoIter as VecIntoIter; + +extern crate oboe; + +use crate::traits::{DeviceTrait, HostTrait, StreamTrait}; +use crate::{ + BackendSpecificError, BufferSize, BuildStreamError, Data, DefaultStreamConfigError, + DeviceNameError, DevicesError, InputCallbackInfo, OutputCallbackInfo, PauseStreamError, + PlayStreamError, SampleFormat, SampleRate, SizedSample, StreamConfig, StreamError, + SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange, + SupportedStreamConfigsError, +}; + +mod android_media; +mod convert; +mod input_callback; +mod output_callback; + +use self::android_media::{get_audio_record_min_buffer_size, get_audio_track_min_buffer_size}; +use self::input_callback::CpalInputCallback; +use self::oboe::{AudioInputStream, AudioOutputStream}; +use self::output_callback::CpalOutputCallback; + +// Android Java API supports up to 8 channels, but oboe API +// only exposes mono and stereo. +const CHANNEL_MASKS: [i32; 2] = [ + android_media::CHANNEL_OUT_MONO, + android_media::CHANNEL_OUT_STEREO, +]; + +const SAMPLE_RATES: [i32; 13] = [ + 5512, 8000, 11025, 16000, 22050, 32000, 44100, 48000, 64000, 88200, 96000, 176_400, 192_000, +]; + +pub struct Host; +#[derive(Clone)] +pub struct Device(Option); +pub enum Stream { + Input(Box>), + Output(Box>), +} +pub type SupportedInputConfigs = VecIntoIter; +pub type SupportedOutputConfigs = VecIntoIter; +pub type Devices = VecIntoIter; + +impl Host { + pub fn new() -> Result { + Ok(Host) + } +} + +impl HostTrait for Host { + type Devices = Devices; + type Device = Device; + + fn is_available() -> bool { + true + } + + fn devices(&self) -> Result { + if let Ok(devices) = oboe::AudioDeviceInfo::request(oboe::AudioDeviceDirection::InputOutput) + { + Ok(devices + .into_iter() + .map(|d| Device(Some(d))) + .collect::>() + .into_iter()) + } else { + Ok(vec![Device(None)].into_iter()) + } + } + + fn default_input_device(&self) -> Option { + Some(Device(None)) + } + + fn default_output_device(&self) -> Option { + Some(Device(None)) + } +} + +fn buffer_size_range_for_params( + is_output: bool, + sample_rate: i32, + channel_mask: i32, + android_format: i32, +) -> SupportedBufferSize { + let min_buffer_size = if is_output { + get_audio_track_min_buffer_size(sample_rate, channel_mask, android_format) + } else { + get_audio_record_min_buffer_size(sample_rate, channel_mask, android_format) + }; + if min_buffer_size > 0 { + SupportedBufferSize::Range { + min: min_buffer_size as u32, + max: i32::MAX as u32, + } + } else { + SupportedBufferSize::Unknown + } +} + +fn default_supported_configs(is_output: bool) -> VecIntoIter { + // Have to "brute force" the parameter combinations with getMinBufferSize + const FORMATS: [SampleFormat; 2] = [SampleFormat::I16, SampleFormat::F32]; + + let mut output = Vec::with_capacity(SAMPLE_RATES.len() * CHANNEL_MASKS.len() * FORMATS.len()); + for sample_format in &FORMATS { + let android_format = if *sample_format == SampleFormat::I16 { + android_media::ENCODING_PCM_16BIT + } else { + android_media::ENCODING_PCM_FLOAT + }; + for (mask_idx, channel_mask) in CHANNEL_MASKS.iter().enumerate() { + let channel_count = mask_idx + 1; + for sample_rate in &SAMPLE_RATES { + if let SupportedBufferSize::Range { min, max } = buffer_size_range_for_params( + is_output, + *sample_rate, + *channel_mask, + android_format, + ) { + output.push(SupportedStreamConfigRange { + channels: channel_count as u16, + min_sample_rate: SampleRate(*sample_rate as u32), + max_sample_rate: SampleRate(*sample_rate as u32), + buffer_size: SupportedBufferSize::Range { min, max }, + sample_format: *sample_format, + }); + } + } + } + } + + output.into_iter() +} + +fn device_supported_configs( + device: &oboe::AudioDeviceInfo, + is_output: bool, +) -> VecIntoIter { + let sample_rates = if !device.sample_rates.is_empty() { + device.sample_rates.as_slice() + } else { + &SAMPLE_RATES + }; + + const ALL_CHANNELS: [i32; 2] = [1, 2]; + let channel_counts = if !device.channel_counts.is_empty() { + device.channel_counts.as_slice() + } else { + &ALL_CHANNELS + }; + + const ALL_FORMATS: [oboe::AudioFormat; 2] = [oboe::AudioFormat::I16, oboe::AudioFormat::F32]; + let formats = if !device.formats.is_empty() { + device.formats.as_slice() + } else { + &ALL_FORMATS + }; + + let mut output = Vec::with_capacity(sample_rates.len() * channel_counts.len() * formats.len()); + for sample_rate in sample_rates { + for channel_count in channel_counts { + assert!(*channel_count > 0); + if *channel_count > 2 { + // could be supported by the device, but oboe does not support more than 2 channels + continue; + } + let channel_mask = CHANNEL_MASKS[*channel_count as usize - 1]; + for format in formats { + let (android_format, sample_format) = match format { + oboe::AudioFormat::I16 => { + (android_media::ENCODING_PCM_16BIT, SampleFormat::I16) + } + oboe::AudioFormat::F32 => { + (android_media::ENCODING_PCM_FLOAT, SampleFormat::F32) + } + _ => panic!("Unexpected format"), + }; + let buffer_size = buffer_size_range_for_params( + is_output, + *sample_rate, + channel_mask, + android_format, + ); + output.push(SupportedStreamConfigRange { + channels: cmp::min(*channel_count as u16, 2u16), + min_sample_rate: SampleRate(*sample_rate as u32), + max_sample_rate: SampleRate(*sample_rate as u32), + buffer_size, + sample_format, + }); + } + } + } + + output.into_iter() +} + +fn configure_for_device( + builder: oboe::AudioStreamBuilder, + device: &Device, + config: &StreamConfig, +) -> oboe::AudioStreamBuilder { + let mut builder = if let Some(info) = &device.0 { + builder.set_device_id(info.id) + } else { + builder + }; + builder = builder.set_sample_rate(config.sample_rate.0.try_into().unwrap()); + match &config.buffer_size { + BufferSize::Default => builder, + BufferSize::Fixed(size) => builder.set_buffer_capacity_in_frames(*size as i32), + } +} + +fn build_input_stream( + device: &Device, + config: &StreamConfig, + data_callback: D, + error_callback: E, + builder: oboe::AudioStreamBuilder, +) -> Result +where + T: SizedSample + oboe::IsFormat + Send + 'static, + C: oboe::IsChannelCount + Send + 'static, + (T, C): oboe::IsFrameType, + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, +{ + let builder = configure_for_device(builder, device, config); + let stream = builder + .set_callback(CpalInputCallback::::new( + data_callback, + error_callback, + )) + .open_stream()?; + Ok(Stream::Input(Box::new(RefCell::new(stream)))) +} + +fn build_output_stream( + device: &Device, + config: &StreamConfig, + data_callback: D, + error_callback: E, + builder: oboe::AudioStreamBuilder, +) -> Result +where + T: SizedSample + oboe::IsFormat + Send + 'static, + C: oboe::IsChannelCount + Send + 'static, + (T, C): oboe::IsFrameType, + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, +{ + let builder = configure_for_device(builder, device, config); + let stream = builder + .set_callback(CpalOutputCallback::::new( + data_callback, + error_callback, + )) + .open_stream()?; + Ok(Stream::Output(Box::new(RefCell::new(stream)))) +} + +impl DeviceTrait for Device { + type SupportedInputConfigs = SupportedInputConfigs; + type SupportedOutputConfigs = SupportedOutputConfigs; + type Stream = Stream; + + fn name(&self) -> Result { + match &self.0 { + None => Ok("default".to_owned()), + Some(info) => Ok(info.product_name.clone()), + } + } + + fn supported_input_configs( + &self, + ) -> Result { + if let Some(info) = &self.0 { + Ok(device_supported_configs(info, false)) + } else { + Ok(default_supported_configs(false)) + } + } + + fn supported_output_configs( + &self, + ) -> Result { + if let Some(info) = &self.0 { + Ok(device_supported_configs(info, true)) + } else { + Ok(default_supported_configs(true)) + } + } + + fn default_input_config(&self) -> Result { + let mut configs: Vec<_> = self.supported_input_configs().unwrap().collect(); + configs.sort_by(|a, b| b.cmp_default_heuristics(a)); + let config = configs + .into_iter() + .next() + .ok_or(DefaultStreamConfigError::StreamTypeNotSupported)? + .with_max_sample_rate(); + Ok(config) + } + + fn default_output_config(&self) -> Result { + let mut configs: Vec<_> = self.supported_output_configs().unwrap().collect(); + configs.sort_by(|a, b| b.cmp_default_heuristics(a)); + let config = configs + .into_iter() + .next() + .ok_or(DefaultStreamConfigError::StreamTypeNotSupported)? + .with_max_sample_rate(); + Ok(config) + } + + fn build_input_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + match sample_format { + SampleFormat::I16 => { + let builder = oboe::AudioStreamBuilder::default() + .set_input() + .set_format::(); + if config.channels == 1 { + build_input_stream( + self, + config, + data_callback, + error_callback, + builder.set_mono(), + ) + } else if config.channels == 2 { + build_input_stream( + self, + config, + data_callback, + error_callback, + builder.set_stereo(), + ) + } else { + Err(BackendSpecificError { + description: "More than 2 channels are not supported by Oboe.".to_owned(), + } + .into()) + } + } + SampleFormat::F32 => { + let builder = oboe::AudioStreamBuilder::default() + .set_input() + .set_format::(); + if config.channels == 1 { + build_input_stream( + self, + config, + data_callback, + error_callback, + builder.set_mono(), + ) + } else if config.channels == 2 { + build_input_stream( + self, + config, + data_callback, + error_callback, + builder.set_stereo(), + ) + } else { + Err(BackendSpecificError { + description: "More than 2 channels are not supported by Oboe.".to_owned(), + } + .into()) + } + } + sample_format => Err(BackendSpecificError { + description: format!("{} format is not supported on Android.", sample_format), + } + .into()), + } + } + + fn build_output_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + match sample_format { + SampleFormat::I16 => { + let builder = oboe::AudioStreamBuilder::default() + .set_output() + .set_format::(); + if config.channels == 1 { + build_output_stream( + self, + config, + data_callback, + error_callback, + builder.set_mono(), + ) + } else if config.channels == 2 { + build_output_stream( + self, + config, + data_callback, + error_callback, + builder.set_stereo(), + ) + } else { + Err(BackendSpecificError { + description: "More than 2 channels are not supported by Oboe.".to_owned(), + } + .into()) + } + } + SampleFormat::F32 => { + let builder = oboe::AudioStreamBuilder::default() + .set_output() + .set_format::(); + if config.channels == 1 { + build_output_stream( + self, + config, + data_callback, + error_callback, + builder.set_mono(), + ) + } else if config.channels == 2 { + build_output_stream( + self, + config, + data_callback, + error_callback, + builder.set_stereo(), + ) + } else { + Err(BackendSpecificError { + description: "More than 2 channels are not supported by Oboe.".to_owned(), + } + .into()) + } + } + sample_format => Err(BackendSpecificError { + description: format!("{} format is not supported on Android.", sample_format), + } + .into()), + } + } +} + +impl StreamTrait for Stream { + fn play(&self) -> Result<(), PlayStreamError> { + match self { + Self::Input(stream) => stream + .borrow_mut() + .request_start() + .map_err(PlayStreamError::from), + Self::Output(stream) => stream + .borrow_mut() + .request_start() + .map_err(PlayStreamError::from), + } + } + + fn pause(&self) -> Result<(), PauseStreamError> { + match self { + Self::Input(_) => Err(BackendSpecificError { + description: "Pause called on the input stream.".to_owned(), + } + .into()), + Self::Output(stream) => stream + .borrow_mut() + .request_pause() + .map_err(PauseStreamError::from), + } + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/oboe/output_callback.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/oboe/output_callback.rs new file mode 100644 index 000000000..6ab696048 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/oboe/output_callback.rs @@ -0,0 +1,90 @@ +use std::marker::PhantomData; +use std::time::Instant; + +extern crate oboe; + +use super::convert::{stream_instant, to_stream_instant}; +use crate::{Data, OutputCallbackInfo, OutputStreamTimestamp, SizedSample, StreamError}; + +pub struct CpalOutputCallback { + data_cb: Box, + error_cb: Box, + created: Instant, + phantom_channel: PhantomData, + phantom_input: PhantomData, +} + +impl CpalOutputCallback { + pub fn new(data_cb: D, error_cb: E) -> Self + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Self { + data_cb: Box::new(data_cb), + error_cb: Box::new(error_cb), + created: Instant::now(), + phantom_channel: PhantomData, + phantom_input: PhantomData, + } + } + + fn make_callback_info( + &self, + audio_stream: &mut dyn oboe::AudioOutputStreamSafe, + ) -> OutputCallbackInfo { + OutputCallbackInfo { + timestamp: OutputStreamTimestamp { + callback: to_stream_instant(self.created.elapsed()), + playback: stream_instant(audio_stream), + }, + } + } +} + +impl oboe::AudioOutputCallback for CpalOutputCallback +where + (T, C): oboe::IsFrameType, +{ + type FrameType = (T, C); + + fn on_error_before_close( + &mut self, + _audio_stream: &mut dyn oboe::AudioOutputStreamSafe, + error: oboe::Error, + ) { + (self.error_cb)(StreamError::from(error)) + } + + fn on_error_after_close( + &mut self, + _audio_stream: &mut dyn oboe::AudioOutputStreamSafe, + error: oboe::Error, + ) { + (self.error_cb)(StreamError::from(error)) + } + + fn on_audio_ready( + &mut self, + audio_stream: &mut dyn oboe::AudioOutputStreamSafe, + audio_data: &mut [<::FrameType as oboe::IsFrameType>::Type], + ) -> oboe::DataCallbackResult { + let cb_info = self.make_callback_info(audio_stream); + let channel_count = if C::CHANNEL_COUNT == oboe::ChannelCount::Mono { + 1 + } else { + 2 + }; + (self.data_cb)( + &mut unsafe { + Data::from_parts( + audio_data.as_mut_ptr() as *mut _, + audio_data.len() * channel_count, + T::FORMAT, + ) + }, + &cb_info, + ); + oboe::DataCallbackResult::Continue + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/wasapi/com.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/wasapi/com.rs new file mode 100644 index 000000000..0c4796ff6 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/wasapi/com.rs @@ -0,0 +1,58 @@ +//! Handles COM initialization and cleanup. + +use std::marker::PhantomData; + +use windows::Win32::Foundation::RPC_E_CHANGED_MODE; +use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_APARTMENTTHREADED}; + +thread_local!(static COM_INITIALIZED: Result = { + unsafe { + // Try to initialize COM with STA by default to avoid compatibility issues with the ASIO + // backend (where CoInitialize() is called by the ASIO SDK) or winit (where drag and drop + // requires STA). + // This call can fail with RPC_E_CHANGED_MODE if another library initialized COM with MTA. + // Reuse that apartment. This does not marshal any interface: callers must + // keep every MMDevice interface in the thread that obtained it. + let result = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + if result.is_ok() || result == RPC_E_CHANGED_MODE { + Ok(ComInitialized { + result, + _ptr: PhantomData, + }) + } else { + Err(windows::core::Error::from_hresult(result)) + } + } +}); + +/// RAII object that guards the fact that COM is initialized. +/// +// We store a raw pointer because it's the only way at the moment to remove `Send`/`Sync` from the +// object. +struct ComInitialized { + result: windows::core::HRESULT, + _ptr: PhantomData<*mut ()>, +} + +impl Drop for ComInitialized { + #[inline] + fn drop(&mut self) { + // Need to avoid calling CoUninitialize() if CoInitializeEx failed since it may have + // returned RPC_E_MODE_CHANGED - which is OK, see above. + if self.result.is_ok() { + unsafe { CoUninitialize() }; + } + } +} + +/// Ensures that COM is initialized in this thread. +#[inline] +pub fn com_initialized() { + try_com_initialized().expect("cpal: COM initialization failed"); +} + +/// Fallible entry point for enumeration and the stream startup handshake. +/// TLS cleanup runs after the worker's stack (including every COM interface) is gone. +pub(super) fn try_com_initialized() -> Result<(), windows::core::Error> { + COM_INITIALIZED.with(|result| result.as_ref().map(|_| ()).map_err(Clone::clone)) +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/wasapi/device.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/wasapi/device.rs new file mode 100644 index 000000000..982a0a916 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/wasapi/device.rs @@ -0,0 +1,1016 @@ +use crate::FrameCount; +use crate::{ + BackendSpecificError, BufferSize, Data, DefaultStreamConfigError, DeviceNameError, + DevicesError, InputCallbackInfo, OutputCallbackInfo, SampleFormat, SampleRate, StreamConfig, + SupportedBufferSize, SupportedStreamConfig, SupportedStreamConfigRange, + SupportedStreamConfigsError, COMMON_SAMPLE_RATES, +}; +use std::ffi::OsString; +use std::fmt; +use std::mem; +use std::os::windows::ffi::OsStringExt; +use std::slice; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use super::com; +use super::{windows_err_to_cpal_err, windows_err_to_cpal_err_message}; +use windows::core::Interface; +use windows::core::GUID; +use windows::Win32::Devices::Properties; +use windows::Win32::Foundation; +use windows::Win32::Media::Audio::IAudioRenderClient; +use windows::Win32::Media::{Audio, KernelStreaming, Multimedia}; +use windows::Win32::System::Com; +use windows::Win32::System::Com::{StructuredStorage, STGM_READ}; +use windows::Win32::System::Variant::VT_LPWSTR; + +use super::stream::{AudioClientFlow, OwnedEvent, Stream, StreamInner}; +use crate::{traits::DeviceTrait, BuildStreamError, StreamError}; + +pub type SupportedInputConfigs = std::vec::IntoIter; +pub type SupportedOutputConfigs = std::vec::IntoIter; + +/// Cached audio client belonging to this device's calling thread. +#[derive(Clone)] +struct IAudioClientWrapper(Audio::IAudioClient); + +/// An opaque type that identifies an end point. +#[derive(Clone)] +pub struct Device { + device: Audio::IMMDevice, + /// We cache an uninitialized `IAudioClient` so that we can call functions from it without + /// having to create/destroy audio clients all the time. + future_audio_client: Arc>>, // TODO: add NonZero around the ptr +} + +impl DeviceTrait for Device { + type SupportedInputConfigs = SupportedInputConfigs; + type SupportedOutputConfigs = SupportedOutputConfigs; + type Stream = Stream; + + fn name(&self) -> Result { + Device::name(self) + } + + fn supported_input_configs( + &self, + ) -> Result { + Device::supported_input_configs(self) + } + + fn supported_output_configs( + &self, + ) -> Result { + Device::supported_output_configs(self) + } + + fn default_input_config(&self) -> Result { + Device::default_input_config(self) + } + + fn default_output_config(&self) -> Result { + Device::default_output_config(self) + } + + fn build_input_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Stream::new_input( + self.owned_id() + .map_err(windows_err_to_cpal_err::)?, + config.clone(), + sample_format, + data_callback, + error_callback, + ) + } + + fn build_output_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Stream::new_output( + self.owned_id() + .map_err(windows_err_to_cpal_err::)?, + config.clone(), + sample_format, + data_callback, + error_callback, + ) + } +} + +struct Endpoint { + endpoint: Audio::IMMEndpoint, +} + +// Use RAII to make sure CoTaskMemFree is called when we are responsible for freeing. +struct WaveFormatExPtr(*mut Audio::WAVEFORMATEX); + +impl Drop for WaveFormatExPtr { + fn drop(&mut self) { + unsafe { + Com::CoTaskMemFree(Some(self.0 as *mut _)); + } + } +} + +unsafe fn immendpoint_from_immdevice(device: Audio::IMMDevice) -> Audio::IMMEndpoint { + device + .cast::() + .expect("could not query IMMDevice interface for IMMEndpoint") +} + +unsafe fn data_flow_from_immendpoint(endpoint: &Audio::IMMEndpoint) -> Audio::EDataFlow { + endpoint + .GetDataFlow() + .expect("could not get endpoint data_flow") +} + +// Given the audio client and format, returns whether or not the format is supported. +pub unsafe fn is_format_supported( + client: &Audio::IAudioClient, + waveformatex_ptr: *const Audio::WAVEFORMATEX, +) -> Result { + // Check if the given format is supported. + let is_supported = |waveformatex_ptr, closest_waveformatex_ptr| { + let result = client.IsFormatSupported( + Audio::AUDCLNT_SHAREMODE_SHARED, + waveformatex_ptr, + Some(closest_waveformatex_ptr), + ); + // `IsFormatSupported` can return `S_FALSE` (which means that a compatible format + // has been found, but not an exact match) so we also treat this as unsupported. + match result { + Audio::AUDCLNT_E_DEVICE_INVALIDATED => { + Err(SupportedStreamConfigsError::DeviceNotAvailable) + } + r if r.is_err() => Ok(false), + Foundation::S_FALSE => Ok(false), + _ => Ok(true), + } + }; + + // First we want to retrieve a pointer to the `WAVEFORMATEX`. + // Although `GetMixFormat` writes the format to a given `WAVEFORMATEX` pointer, + // the pointer itself may actually point to a `WAVEFORMATEXTENSIBLE` structure. + // We check the wFormatTag to determine this and get a pointer to the correct type. + match (*waveformatex_ptr).wFormatTag as u32 { + Audio::WAVE_FORMAT_PCM | Multimedia::WAVE_FORMAT_IEEE_FLOAT => { + let mut closest_waveformatex = *waveformatex_ptr; + let mut closest_waveformatex_ptr = &mut closest_waveformatex as *mut _; + is_supported(waveformatex_ptr, &mut closest_waveformatex_ptr as *mut _) + } + KernelStreaming::WAVE_FORMAT_EXTENSIBLE => { + let waveformatextensible_ptr = waveformatex_ptr as *const Audio::WAVEFORMATEXTENSIBLE; + let mut closest_waveformatextensible = *waveformatextensible_ptr; + let closest_waveformatextensible_ptr = &mut closest_waveformatextensible as *mut _; + let mut closest_waveformatex_ptr = + closest_waveformatextensible_ptr as *mut Audio::WAVEFORMATEX; + is_supported(waveformatex_ptr, &mut closest_waveformatex_ptr as *mut _) + } + _ => Ok(false), + } +} + +// Get a cpal Format from a WAVEFORMATEX. +unsafe fn format_from_waveformatex_ptr( + waveformatex_ptr: *const Audio::WAVEFORMATEX, + audio_client: &Audio::IAudioClient, +) -> Option { + fn cmp_guid(a: &GUID, b: &GUID) -> bool { + (a.data1, a.data2, a.data3, a.data4) == (b.data1, b.data2, b.data3, b.data4) + } + let sample_format = match ( + (*waveformatex_ptr).wBitsPerSample, + (*waveformatex_ptr).wFormatTag as u32, + ) { + (8, Audio::WAVE_FORMAT_PCM) => SampleFormat::U8, + (16, Audio::WAVE_FORMAT_PCM) => SampleFormat::I16, + (32, Multimedia::WAVE_FORMAT_IEEE_FLOAT) => SampleFormat::F32, + (n_bits, KernelStreaming::WAVE_FORMAT_EXTENSIBLE) => { + let waveformatextensible_ptr = waveformatex_ptr as *const Audio::WAVEFORMATEXTENSIBLE; + let sub = (*waveformatextensible_ptr).SubFormat; + + if cmp_guid(&sub, &KernelStreaming::KSDATAFORMAT_SUBTYPE_PCM) { + match n_bits { + 8 => SampleFormat::U8, + 16 => SampleFormat::I16, + 32 => SampleFormat::I32, + 64 => SampleFormat::I64, + _ => return None, + } + } else if n_bits == 32 && cmp_guid(&sub, &Multimedia::KSDATAFORMAT_SUBTYPE_IEEE_FLOAT) { + SampleFormat::F32 + } else { + return None; + } + } + // Unknown data format returned by GetMixFormat. + _ => return None, + }; + + let sample_rate = SampleRate((*waveformatex_ptr).nSamplesPerSec); + + // GetBufferSizeLimits is only used for Hardware-Offloaded Audio + // Processing, which was added in Windows 8, which places hardware + // limits on the size of the audio buffer. If the sound system + // *isn't* using offloaded audio, we're using a software audio + // processing stack and have pretty much free rein to set buffer + // size. + // + // In software audio stacks GetBufferSizeLimits returns + // AUDCLNT_E_OFFLOAD_MODE_ONLY. + // + // https://docs.microsoft.com/en-us/windows-hardware/drivers/audio/hardware-offloaded-audio-processing + let (mut min_buffer_duration, mut max_buffer_duration) = (0, 0); + let buffer_size_is_limited = audio_client + .cast::() + .and_then(|audio_client| { + audio_client.GetBufferSizeLimits( + waveformatex_ptr, + true, + &mut min_buffer_duration, + &mut max_buffer_duration, + ) + }) + .is_ok(); + let buffer_size = if buffer_size_is_limited { + SupportedBufferSize::Range { + min: buffer_duration_to_frames(min_buffer_duration, sample_rate.0), + max: buffer_duration_to_frames(max_buffer_duration, sample_rate.0), + } + } else { + SupportedBufferSize::Range { + min: 0, + max: u32::max_value(), + } + }; + + let format = SupportedStreamConfig { + channels: (*waveformatex_ptr).nChannels as _, + sample_rate, + buffer_size, + sample_format, + }; + Some(format) +} + +// IMMDevice is apartment-bound. Do not send it (or its cached client) to another thread. + +impl Device { + /// Copy the opaque endpoint ID; only this owned value crosses into a stream worker. + fn owned_id(&self) -> Result, windows::core::Error> { + unsafe { + let id = self.device.GetId()?; + struct Id(windows::core::PWSTR); + impl Drop for Id { + fn drop(&mut self) { + unsafe { Com::CoTaskMemFree(Some(self.0 .0.cast())) }; + } + } + let id = Id(id); + let mut owned = id.0.as_wide().to_vec(); + owned.push(0); + Ok(owned) + } + } + + /// Resolve an owned endpoint ID in the current thread's COM apartment. + pub(super) fn from_id(id: &[u16]) -> Result { + debug_assert_eq!(id.last(), Some(&0)); + let enumerator = get_enumerator()?; + let device = unsafe { enumerator.GetDevice(windows::core::PCWSTR(id.as_ptr()))? }; + Ok(Self::from_immdevice(device)) + } + + pub fn name(&self) -> Result { + unsafe { + // Open the device's property store. + let property_store = self + .device + .OpenPropertyStore(STGM_READ) + .expect("could not open property store"); + + // Get the endpoint's friendly-name property. + let mut property_value = property_store + .GetValue(&Properties::DEVPKEY_Device_FriendlyName as *const _ as *const _) + .map_err(|err| { + let description = + format!("failed to retrieve name from property store: {}", err); + let err = BackendSpecificError { description }; + DeviceNameError::from(err) + })?; + + let prop_variant = &property_value.as_raw().Anonymous.Anonymous; + + // Read the friendly-name from the union data field, expecting a *const u16. + if prop_variant.vt != VT_LPWSTR.0 { + let description = format!( + "property store produced invalid data: {:?}", + prop_variant.vt + ); + let err = BackendSpecificError { description }; + return Err(err.into()); + } + let ptr_utf16 = *(&prop_variant.Anonymous as *const _ as *const *const u16); + + // Find the length of the friendly name. + let mut len = 0; + while *ptr_utf16.offset(len) != 0 { + len += 1; + } + + // Create the utf16 slice and convert it into a string. + let name_slice = slice::from_raw_parts(ptr_utf16, len as usize); + let name_os_string: OsString = OsStringExt::from_wide(name_slice); + let name_string = match name_os_string.into_string() { + Ok(string) => string, + Err(os_string) => os_string.to_string_lossy().into(), + }; + + // Clean up the property. + StructuredStorage::PropVariantClear(&mut property_value).ok(); + + Ok(name_string) + } + } + + #[inline] + fn from_immdevice(device: Audio::IMMDevice) -> Self { + Device { + device, + future_audio_client: Arc::new(Mutex::new(None)), + } + } + + /// Ensures that `future_audio_client` contains a `Some` and returns a locked mutex to it. + fn ensure_future_audio_client( + &self, + ) -> Result>, windows::core::Error> { + let mut lock = self.future_audio_client.lock().unwrap(); + if lock.is_some() { + return Ok(lock); + } + + let audio_client: Audio::IAudioClient = unsafe { + // can fail if the device has been disconnected since we enumerated it, or if + // the device doesn't support playback for some reason + self.device.Activate(Com::CLSCTX_ALL, None)? + }; + + *lock = Some(IAudioClientWrapper(audio_client)); + Ok(lock) + } + + /// Returns an uninitialized `IAudioClient`. + #[inline] + pub(crate) fn build_audioclient(&self) -> Result { + let mut lock = self.ensure_future_audio_client()?; + Ok(lock.take().unwrap().0) + } + + // There is no way to query the list of all formats that are supported by the + // audio processor, so instead we just trial some commonly supported formats. + // + // Common formats are trialed by first getting the default format (returned via + // `GetMixFormat`) and then mutating that format with common sample rates and + // querying them via `IsFormatSupported`. + // + // When calling `IsFormatSupported` with the shared-mode audio engine, only the default + // number of channels seems to be supported. Any, more or less returns an invalid + // parameter error. Thus, we just assume that the default number of channels is the only + // number supported. + fn supported_formats(&self) -> Result { + // initializing COM because we call `CoTaskMemFree` to release the format. + com::com_initialized(); + + // Retrieve the `IAudioClient`. + let lock = match self.ensure_future_audio_client() { + Ok(lock) => lock, + Err(ref e) if e.code() == Audio::AUDCLNT_E_DEVICE_INVALIDATED => { + return Err(SupportedStreamConfigsError::DeviceNotAvailable) + } + Err(e) => { + let description = format!("{}", e); + let err = BackendSpecificError { description }; + return Err(err.into()); + } + }; + let client = &lock.as_ref().unwrap().0; + + unsafe { + // Retrieve the pointer to the default WAVEFORMATEX. + let default_waveformatex_ptr = client + .GetMixFormat() + .map(WaveFormatExPtr) + .map_err(windows_err_to_cpal_err::)?; + + // If the default format can't succeed we have no hope of finding other formats. + if !is_format_supported(client, default_waveformatex_ptr.0)? { + let description = + "Could not determine support for default `WAVEFORMATEX`".to_string(); + let err = BackendSpecificError { description }; + return Err(err.into()); + } + + let format = match format_from_waveformatex_ptr(default_waveformatex_ptr.0, client) { + Some(fmt) => fmt, + None => { + let description = + "could not create a `cpal::SupportedStreamConfig` from a `WAVEFORMATEX`" + .to_string(); + let err = BackendSpecificError { description }; + return Err(err.into()); + } + }; + + let mut sample_rates: Vec = COMMON_SAMPLE_RATES.to_vec(); + + if !sample_rates.contains(&format.sample_rate) { + sample_rates.push(format.sample_rate) + } + + let mut supported_formats = Vec::new(); + + for sample_rate in sample_rates { + for sample_format in [ + SampleFormat::U8, + SampleFormat::I16, + SampleFormat::I32, + SampleFormat::I64, + SampleFormat::F32, + ] { + if let Some(waveformat) = config_to_waveformatextensible( + &StreamConfig { + channels: format.channels, + sample_rate, + buffer_size: BufferSize::Default, + }, + sample_format, + ) { + if is_format_supported( + client, + &waveformat.Format as *const Audio::WAVEFORMATEX, + )? { + supported_formats.push(SupportedStreamConfigRange { + channels: format.channels, + min_sample_rate: sample_rate, + max_sample_rate: sample_rate, + buffer_size: format.buffer_size.clone(), + sample_format, + }) + } + } + } + } + Ok(supported_formats.into_iter()) + } + } + + pub fn supported_input_configs( + &self, + ) -> Result { + if self.data_flow() == Audio::eCapture { + self.supported_formats() + // If it's an output device, assume no input formats. + } else { + Ok(vec![].into_iter()) + } + } + + pub fn supported_output_configs( + &self, + ) -> Result { + if self.data_flow() == Audio::eRender { + self.supported_formats() + // If it's an input device, assume no output formats. + } else { + Ok(vec![].into_iter()) + } + } + + // We always create voices in shared mode, therefore all samples go through an audio + // processor to mix them together. + // + // One format is guaranteed to be supported, the one returned by `GetMixFormat`. + fn default_format(&self) -> Result { + // initializing COM because we call `CoTaskMemFree` + com::com_initialized(); + + let lock = match self.ensure_future_audio_client() { + Ok(lock) => lock, + Err(ref e) if e.code() == Audio::AUDCLNT_E_DEVICE_INVALIDATED => { + return Err(DefaultStreamConfigError::DeviceNotAvailable) + } + Err(e) => { + let description = format!("{}", e); + let err = BackendSpecificError { description }; + return Err(err.into()); + } + }; + let client = &lock.as_ref().unwrap().0; + + unsafe { + let format_ptr = client + .GetMixFormat() + .map(WaveFormatExPtr) + .map_err(windows_err_to_cpal_err::)?; + + format_from_waveformatex_ptr(format_ptr.0, client) + .ok_or(DefaultStreamConfigError::StreamTypeNotSupported) + } + } + + pub(crate) fn data_flow(&self) -> Audio::EDataFlow { + let endpoint = Endpoint::from(self.device.clone()); + endpoint.data_flow() + } + + pub fn default_input_config(&self) -> Result { + if self.data_flow() == Audio::eCapture { + self.default_format() + } else { + Err(DefaultStreamConfigError::StreamTypeNotSupported) + } + } + + pub fn default_output_config(&self) -> Result { + let data_flow = self.data_flow(); + if data_flow == Audio::eRender { + self.default_format() + } else { + Err(DefaultStreamConfigError::StreamTypeNotSupported) + } + } + + pub(crate) fn build_input_stream_raw_inner( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + ) -> Result { + unsafe { + // Making sure that COM is initialized. + // It's not actually sure that this is required, but when in doubt do it. + com::com_initialized(); + + // Declare the event before the client: failures after SetEventHandle + // must release the client before closing its registered handle. + let event = OwnedEvent::new().map_err(windows_err_to_cpal_err::)?; + + // Obtaining a `IAudioClient`. + let audio_client = match self.build_audioclient() { + Ok(client) => client, + Err(ref e) if e.code() == Audio::AUDCLNT_E_DEVICE_INVALIDATED => { + return Err(BuildStreamError::DeviceNotAvailable) + } + Err(e) => { + let description = format!("{}", e); + let err = BackendSpecificError { description }; + return Err(err.into()); + } + }; + + let buffer_duration = + buffer_size_to_duration(&config.buffer_size, config.sample_rate.0); + + let mut stream_flags = Audio::AUDCLNT_STREAMFLAGS_EVENTCALLBACK; + + if self.data_flow() == Audio::eRender { + stream_flags |= Audio::AUDCLNT_STREAMFLAGS_LOOPBACK; + } + + // Computing the format and initializing the device. + let waveformatex = { + let format_attempt = config_to_waveformatextensible(config, sample_format) + .ok_or(BuildStreamError::StreamConfigNotSupported)?; + let share_mode = Audio::AUDCLNT_SHAREMODE_SHARED; + + // Ensure the format is supported. + match super::device::is_format_supported(&audio_client, &format_attempt.Format) { + Ok(false) => return Err(BuildStreamError::StreamConfigNotSupported), + Err(_) => return Err(BuildStreamError::DeviceNotAvailable), + _ => (), + } + + // Finally, initializing the audio client + let hresult = audio_client.Initialize( + share_mode, + stream_flags, + buffer_duration, + 0, + &format_attempt.Format, + None, + ); + match hresult { + Err(ref e) if e.code() == Audio::AUDCLNT_E_DEVICE_INVALIDATED => { + return Err(BuildStreamError::DeviceNotAvailable); + } + Err(e) => { + let description = format!("{}", e); + let err = BackendSpecificError { description }; + return Err(err.into()); + } + Ok(()) => (), + }; + + format_attempt.Format + }; + + // obtaining the size of the samples buffer in number of frames + let max_frames_in_buffer = audio_client + .GetBufferSize() + .map_err(windows_err_to_cpal_err::)?; + + // Creating the event that will be signalled whenever we need to submit some samples. + audio_client + .SetEventHandle(event.handle()) + .map_err(|error| { + windows_err_to_cpal_err_message::( + error, + "failed to call SetEventHandle: ", + ) + })?; + + // Building a `IAudioCaptureClient` that will be used to read captured samples. + let capture_client = audio_client + .GetService::() + .map_err(|e| { + windows_err_to_cpal_err_message::( + e, + "failed to build capture client: ", + ) + })?; + + // Once we built the `StreamInner`, we add a command that will be picked up by the + // `run()` method and added to the `RunContext`. + let client_flow = AudioClientFlow::Capture { capture_client }; + + let audio_clock = get_audio_clock(&audio_client)?; + + Ok(StreamInner { + audio_client, + audio_clock, + client_flow, + event, + playing: false, + max_frames_in_buffer, + bytes_per_frame: waveformatex.nBlockAlign, + config: config.clone(), + sample_format, + #[cfg(debug_assertions)] + owner_thread: std::thread::current().id(), + }) + } + } + + pub(crate) fn build_output_stream_raw_inner( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + ) -> Result { + unsafe { + // Making sure that COM is initialized. + // It's not actually sure that this is required, but when in doubt do it. + com::com_initialized(); + + // Declare the event before the client: failures after SetEventHandle + // must release the client before closing its registered handle. + let event = OwnedEvent::new().map_err(windows_err_to_cpal_err::)?; + + // Obtaining a `IAudioClient`. + let audio_client = self + .build_audioclient() + .map_err(windows_err_to_cpal_err::)?; + + let buffer_duration = + buffer_size_to_duration(&config.buffer_size, config.sample_rate.0); + + // Computing the format and initializing the device. + let waveformatex = { + let format_attempt = config_to_waveformatextensible(config, sample_format) + .ok_or(BuildStreamError::StreamConfigNotSupported)?; + let share_mode = Audio::AUDCLNT_SHAREMODE_SHARED; + + // Ensure the format is supported. + match super::device::is_format_supported(&audio_client, &format_attempt.Format) { + Ok(false) => return Err(BuildStreamError::StreamConfigNotSupported), + Err(_) => return Err(BuildStreamError::DeviceNotAvailable), + _ => (), + } + + // Finally, initializing the audio client + audio_client + .Initialize( + share_mode, + Audio::AUDCLNT_STREAMFLAGS_EVENTCALLBACK, + buffer_duration, + 0, + &format_attempt.Format, + None, + ) + .map_err(windows_err_to_cpal_err::)?; + + format_attempt.Format + }; + + // Creating the event that will be signalled whenever we need to submit some samples. + audio_client + .SetEventHandle(event.handle()) + .map_err(|error| { + windows_err_to_cpal_err_message::( + error, + "failed to call SetEventHandle: ", + ) + })?; + + // obtaining the size of the samples buffer in number of frames + let max_frames_in_buffer = audio_client.GetBufferSize().map_err(|e| { + windows_err_to_cpal_err_message::( + e, + "failed to obtain buffer size: ", + ) + })?; + + // Building a `IAudioRenderClient` that will be used to fill the samples buffer. + let render_client = audio_client + .GetService::() + .map_err(|e| { + windows_err_to_cpal_err_message::( + e, + "failed to build render client: ", + ) + })?; + + // Once we built the `StreamInner`, we add a command that will be picked up by the + // `run()` method and added to the `RunContext`. + let client_flow = AudioClientFlow::Render { render_client }; + + let audio_clock = get_audio_clock(&audio_client)?; + + Ok(StreamInner { + audio_client, + audio_clock, + client_flow, + event, + playing: false, + max_frames_in_buffer, + bytes_per_frame: waveformatex.nBlockAlign, + config: config.clone(), + sample_format, + #[cfg(debug_assertions)] + owner_thread: std::thread::current().id(), + }) + } + } +} + +impl PartialEq for Device { + #[inline] + fn eq(&self, other: &Device) -> bool { + // Use case: In order to check whether the default device has changed + // the client code might need to compare the previous default device with the current one. + // The pointer comparison (`self.device == other.device`) don't work there, + // because the pointers are different even when the default device stays the same. + // + // In this code section we're trying to use the GetId method for the device comparison, cf. + // https://docs.microsoft.com/en-us/windows/desktop/api/mmdeviceapi/nf-mmdeviceapi-immdevice-getid + unsafe { + struct IdRAII(windows::core::PWSTR); + /// RAII for device IDs. + impl Drop for IdRAII { + fn drop(&mut self) { + unsafe { Com::CoTaskMemFree(Some(self.0 .0 as *mut _)) } + } + } + // GetId only fails with E_OUTOFMEMORY and if it does, we're probably dead already. + // Plus it won't do to change the device comparison logic unexpectedly. + let id1 = self.device.GetId().expect("cpal: GetId failure"); + let id1 = IdRAII(id1); + let id2 = other.device.GetId().expect("cpal: GetId failure"); + let id2 = IdRAII(id2); + // 16-bit null-terminated comparison. + let mut offset = 0; + loop { + let w1: u16 = *(id1.0).0.offset(offset); + let w2: u16 = *(id2.0).0.offset(offset); + if w1 == 0 && w2 == 0 { + return true; + } + if w1 != w2 { + return false; + } + offset += 1; + } + } + } +} + +impl Eq for Device {} + +impl fmt::Debug for Device { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_struct("Device") + .field("device", &self.device) + .field("name", &self.name()) + .finish() + } +} + +impl From for Endpoint { + fn from(device: Audio::IMMDevice) -> Self { + unsafe { + let endpoint = immendpoint_from_immdevice(device); + Endpoint { endpoint } + } + } +} + +impl Endpoint { + fn data_flow(&self) -> Audio::EDataFlow { + unsafe { data_flow_from_immendpoint(&self.endpoint) } + } +} + +// MMDevice interfaces must never be cached across COM apartments. Create the +// enumerator in the calling thread, and keep all returned devices/iterators there. +fn get_enumerator() -> Result { + com::try_com_initialized()?; + unsafe { Com::CoCreateInstance(&Audio::MMDeviceEnumerator, None, Com::CLSCTX_ALL) } +} + +/// WASAPI implementation for `Devices`. +pub struct Devices { + collection: Audio::IMMDeviceCollection, + total_count: u32, + next_item: u32, +} + +impl Devices { + pub fn new() -> Result { + unsafe { + // can fail because of wrong parameters (should never happen) or out of memory + let collection = get_enumerator() + .map_err(BackendSpecificError::from)? + .EnumAudioEndpoints(Audio::eAll, Audio::DEVICE_STATE_ACTIVE) + .map_err(BackendSpecificError::from)?; + + let count = collection.GetCount().map_err(BackendSpecificError::from)?; + + Ok(Devices { + collection, + total_count: count, + next_item: 0, + }) + } + } +} + +// The IMMDeviceCollection iterator stays in the apartment that created it. + +impl Iterator for Devices { + type Item = Device; + + fn next(&mut self) -> Option { + if self.next_item >= self.total_count { + return None; + } + + unsafe { + let device = self.collection.Item(self.next_item).unwrap(); + self.next_item += 1; + Some(Device::from_immdevice(device)) + } + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + let num = self.total_count - self.next_item; + let num = num as usize; + (num, Some(num)) + } +} + +fn default_device(data_flow: Audio::EDataFlow) -> Option { + unsafe { + let device = get_enumerator() + .ok()? + .GetDefaultAudioEndpoint(data_flow, Audio::eConsole) + .ok()?; + // TODO: check specifically for `E_NOTFOUND`, and panic otherwise + Some(Device::from_immdevice(device)) + } +} + +pub fn default_input_device() -> Option { + default_device(Audio::eCapture) +} + +pub fn default_output_device() -> Option { + default_device(Audio::eRender) +} + +/// Get the audio clock used to produce `StreamInstant`s. +unsafe fn get_audio_clock( + audio_client: &Audio::IAudioClient, +) -> Result { + audio_client + .GetService::() + .map_err(|e| { + windows_err_to_cpal_err_message::(e, "failed to build audio clock: ") + }) +} + +// Turns a `Format` into a `WAVEFORMATEXTENSIBLE`. +// +// Returns `None` if the WAVEFORMATEXTENSIBLE does not support the given format. +fn config_to_waveformatextensible( + config: &StreamConfig, + sample_format: SampleFormat, +) -> Option { + let format_tag = match sample_format { + SampleFormat::U8 | SampleFormat::I16 => Audio::WAVE_FORMAT_PCM, + + SampleFormat::I32 | SampleFormat::I64 | SampleFormat::F32 => { + KernelStreaming::WAVE_FORMAT_EXTENSIBLE + } + + _ => return None, + }; + let channels = config.channels; + let sample_rate = config.sample_rate.0; + let sample_bytes = sample_format.sample_size() as u16; + let avg_bytes_per_sec = u32::from(channels) * sample_rate * u32::from(sample_bytes); + let block_align = channels * sample_bytes; + let bits_per_sample = 8 * sample_bytes; + + let cb_size = if format_tag == Audio::WAVE_FORMAT_PCM { + 0 + } else { + let extensible_size = mem::size_of::(); + let ex_size = mem::size_of::(); + (extensible_size - ex_size) as u16 + }; + + let waveformatex = Audio::WAVEFORMATEX { + wFormatTag: format_tag as u16, + nChannels: channels, + nSamplesPerSec: sample_rate, + nAvgBytesPerSec: avg_bytes_per_sec, + nBlockAlign: block_align, + wBitsPerSample: bits_per_sample, + cbSize: cb_size, + }; + + // CPAL does not care about speaker positions, so pass audio right through. + let channel_mask = KernelStreaming::KSAUDIO_SPEAKER_DIRECTOUT; + + let sub_format = match sample_format { + SampleFormat::U8 | SampleFormat::I16 | SampleFormat::I32 | SampleFormat::I64 => { + KernelStreaming::KSDATAFORMAT_SUBTYPE_PCM + } + + SampleFormat::F32 => Multimedia::KSDATAFORMAT_SUBTYPE_IEEE_FLOAT, + _ => return None, + }; + + let waveformatextensible = Audio::WAVEFORMATEXTENSIBLE { + Format: waveformatex, + Samples: Audio::WAVEFORMATEXTENSIBLE_0 { + wSamplesPerBlock: bits_per_sample, + }, + dwChannelMask: channel_mask, + SubFormat: sub_format, + }; + + Some(waveformatextensible) +} + +fn buffer_size_to_duration(buffer_size: &BufferSize, sample_rate: u32) -> i64 { + match buffer_size { + BufferSize::Fixed(frames) => *frames as i64 * (1_000_000_000 / 100) / sample_rate as i64, + BufferSize::Default => 0, + } +} + +fn buffer_duration_to_frames(buffer_duration: i64, sample_rate: u32) -> FrameCount { + (buffer_duration * sample_rate as i64 * 100 / 1_000_000_000) as FrameCount +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/wasapi/mod.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/wasapi/mod.rs new file mode 100644 index 000000000..e80760267 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/wasapi/mod.rs @@ -0,0 +1,104 @@ +pub use self::device::{ + default_input_device, default_output_device, Device, Devices, SupportedInputConfigs, + SupportedOutputConfigs, +}; +pub use self::stream::Stream; +use crate::traits::HostTrait; +use crate::BackendSpecificError; +use crate::DevicesError; +use std::io::Error as IoError; +use windows::Win32::Media::Audio; + +mod com; +mod device; +mod stream; + +/// The WASAPI host, the default windows host type. +/// +/// Note: If you use a WASAPI output device as an input device it will +/// transparently enable loopback mode (see +/// https://docs.microsoft.com/en-us/windows/win32/coreaudio/loopback-recording). +#[derive(Debug)] +pub struct Host; + +impl Host { + pub fn new() -> Result { + Ok(Host) + } +} + +impl HostTrait for Host { + type Devices = Devices; + type Device = Device; + + fn is_available() -> bool { + // Assume WASAPI is always available on Windows. + true + } + + fn devices(&self) -> Result { + Devices::new() + } + + fn default_input_device(&self) -> Option { + default_input_device() + } + + fn default_output_device(&self) -> Option { + default_output_device() + } +} + +impl From for BackendSpecificError { + fn from(error: windows::core::Error) -> Self { + BackendSpecificError { + description: format!("{}", IoError::from(error)), + } + } +} + +trait ErrDeviceNotAvailable: From { + fn device_not_available() -> Self; +} + +impl ErrDeviceNotAvailable for crate::BuildStreamError { + fn device_not_available() -> Self { + Self::DeviceNotAvailable + } +} + +impl ErrDeviceNotAvailable for crate::SupportedStreamConfigsError { + fn device_not_available() -> Self { + Self::DeviceNotAvailable + } +} + +impl ErrDeviceNotAvailable for crate::DefaultStreamConfigError { + fn device_not_available() -> Self { + Self::DeviceNotAvailable + } +} + +impl ErrDeviceNotAvailable for crate::StreamError { + fn device_not_available() -> Self { + Self::DeviceNotAvailable + } +} + +fn windows_err_to_cpal_err(e: windows::core::Error) -> E { + windows_err_to_cpal_err_message::(e, "") +} + +fn windows_err_to_cpal_err_message( + e: windows::core::Error, + message: &str, +) -> E { + match e.code() { + Audio::AUDCLNT_E_DEVICE_INVALIDATED => E::device_not_available(), + _ => { + let description = format!("{}{}", message, e); + let err = BackendSpecificError { description }; + err.into() + } + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/wasapi/stream.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/wasapi/stream.rs new file mode 100644 index 000000000..700cce10a --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/wasapi/stream.rs @@ -0,0 +1,749 @@ +use super::{com, device::Device, windows_err_to_cpal_err}; +use crate::traits::StreamTrait; +use crate::{ + BackendSpecificError, BuildStreamError, Data, InputCallbackInfo, OutputCallbackInfo, + PauseStreamError, PlayStreamError, SampleFormat, StreamConfig, StreamError, +}; +use std::mem; +use std::ptr; +use std::sync::mpsc::{channel, sync_channel, Receiver, Sender}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use windows::Win32::Foundation; +use windows::Win32::Foundation::HANDLE; +use windows::Win32::Foundation::WAIT_OBJECT_0; +use windows::Win32::Media::Audio; +use windows::Win32::System::SystemServices; +use windows::Win32::System::Threading; + +/// Owns a kernel event, not a COM interface. The last owner closes the handle. +pub(super) struct OwnedEvent(Foundation::HANDLE); + +// Windows event handles are process-wide synchronization objects, usable from +// any thread. No apartment-bound COM object is hidden in this wrapper. +unsafe impl Send for OwnedEvent {} +unsafe impl Sync for OwnedEvent {} + +impl OwnedEvent { + pub(super) fn new() -> Result { + unsafe { + Threading::CreateEventA(None, false, false, windows::core::PCSTR(ptr::null())).map(Self) + } + } + + pub(super) fn handle(&self) -> Foundation::HANDLE { + self.0 + } +} + +impl Drop for OwnedEvent { + fn drop(&mut self) { + unsafe { + let _ = Foundation::CloseHandle(self.0); + } + } +} + +pub struct Stream { + thread: Option>, + commands: Sender, + // Both the controller and worker retain this event until the worker exits. + pending_scheduled_event: Arc, + // Preserve the original controller's !Send/!Sync contract even though the + // shared kernel event itself is thread-safe. + _not_send_sync: std::marker::PhantomData<*mut ()>, +} + +// Constructed, used and destroyed exclusively within the audio worker. +// Intentionally !Send: none of its COM interfaces may cross into another thread. +struct RunContext { + stream: StreamInner, + handles: Vec, + commands: Receiver, +} + +pub enum Command { + PlayStream, + PauseStream, + Terminate, +} + +pub enum AudioClientFlow { + Render { + render_client: Audio::IAudioRenderClient, + }, + Capture { + capture_client: Audio::IAudioCaptureClient, + }, +} + +pub struct StreamInner { + // Field declaration order is destruction order: release all services before + // the audio client, then close its event. All GetService/Release calls occur + // in this same worker, including failed startup and callback unwinding. + pub client_flow: AudioClientFlow, + pub audio_clock: Audio::IAudioClock, + pub audio_client: Audio::IAudioClient, + pub playing: bool, + pub max_frames_in_buffer: u32, + pub bytes_per_frame: u16, + pub config: crate::StreamConfig, + pub sample_format: SampleFormat, + #[cfg(debug_assertions)] + pub owner_thread: thread::ThreadId, + pub(super) event: OwnedEvent, +} + +impl Stream { + pub(crate) fn new_input( + device_id: Vec, + config: StreamConfig, + sample_format: SampleFormat, + mut data_callback: D, + mut error_callback: E, + ) -> Result + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Self::spawn( + "cpal_wasapi_in", + move || { + let device = Device::from_id(&device_id) + .map_err(windows_err_to_cpal_err::)?; + device.build_input_stream_raw_inner(&config, sample_format) + }, + move |context| run_input(context, &mut data_callback, &mut error_callback), + ) + } + + pub(crate) fn new_output( + device_id: Vec, + config: StreamConfig, + sample_format: SampleFormat, + mut data_callback: D, + mut error_callback: E, + ) -> Result + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + Self::spawn( + "cpal_wasapi_out", + move || { + let device = Device::from_id(&device_id) + .map_err(windows_err_to_cpal_err::)?; + device.build_output_stream_raw_inner(&config, sample_format) + }, + move |context| run_output(context, &mut data_callback, &mut error_callback), + ) + } + + fn spawn( + name: &str, + build: impl FnOnce() -> Result + Send + 'static, + run: impl FnOnce(RunContext) + Send + 'static, + ) -> Result { + let pending_scheduled_event = + Arc::new(OwnedEvent::new().map_err(windows_err_to_cpal_err::)?); + let worker_event = Arc::clone(&pending_scheduled_event); + let (commands, receiver) = channel(); + let (ready, startup) = sync_channel(1); + let thread = thread::Builder::new() + .name(name.to_owned()) + .spawn(move || { + // No COM interface has crossed the thread boundary. The TLS guard + // outlives every worker stack value, also during panic unwinding. + let initialized = com::try_com_initialized() + .map_err(windows_err_to_cpal_err::) + .and_then(|()| build()); + let stream = match initialized { + Ok(stream) => stream, + Err(error) => { + let _ = ready.send(Err(error)); + return; + } + }; + let context = RunContext { + handles: vec![worker_event.handle(), stream.event.handle()], + stream, + commands: receiver, + }; + // Report success only once Activate/Initialize/GetService succeeded. + // If the builder went away, dropping context releases everything here. + if ready.send(Ok(())).is_ok() { + run(context); + } + }) + .map_err(|error| BackendSpecificError { + description: format!("failed to spawn WASAPI stream worker: {error}"), + })?; + + match startup.recv() { + Ok(Ok(())) => Ok(Stream { + thread: Some(thread), + commands, + pending_scheduled_event, + _not_send_sync: std::marker::PhantomData, + }), + Ok(Err(error)) => { + let _ = thread.join(); + Err(error) + } + Err(_) => { + let _ = thread.join(); + Err(BackendSpecificError { + description: "WASAPI stream worker exited before reporting startup".into(), + } + .into()) + } + } + } + + fn push_command(&self, command: Command) -> Result<(), ()> { + self.commands.send(command).map_err(|_| ())?; + unsafe { Threading::SetEvent(self.pending_scheduled_event.handle()).map_err(|_| ()) } + } +} + +impl Drop for Stream { + fn drop(&mut self) { + let _ = self.push_command(Command::Terminate); + // A failed callback can already have disconnected commands. Still join; + // never skip cleanup or panic again while another panic is unwinding. + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + // pending_scheduled_event closes after the worker can no longer wait on it. + } +} + +impl StreamTrait for Stream { + fn play(&self) -> Result<(), PlayStreamError> { + self.push_command(Command::PlayStream) + .map_err(|_| PlayStreamError::DeviceNotAvailable) + } + fn pause(&self) -> Result<(), PauseStreamError> { + self.push_command(Command::PauseStream) + .map_err(|_| PauseStreamError::DeviceNotAvailable) + } +} + +impl Drop for StreamInner { + fn drop(&mut self) { + #[cfg(debug_assertions)] + debug_assert_eq!(self.owner_thread, thread::current().id()); + if self.playing { + unsafe { + let _ = self.audio_client.Stop(); + } + self.playing = false; + } + // Fields release services/client/event here, before worker TLS COM cleanup. + } +} + +// Process any pending commands that are queued within the `RunContext`. +// Returns `true` if the loop should continue running, `false` if it should terminate. +fn process_commands(run_context: &mut RunContext) -> Result { + // Process the pending commands. + for command in run_context.commands.try_iter() { + match command { + Command::PlayStream => unsafe { + if !run_context.stream.playing { + run_context + .stream + .audio_client + .Start() + .map_err(windows_err_to_cpal_err::)?; + run_context.stream.playing = true; + } + }, + Command::PauseStream => unsafe { + if run_context.stream.playing { + run_context + .stream + .audio_client + .Stop() + .map_err(windows_err_to_cpal_err::)?; + run_context.stream.playing = false; + } + }, + Command::Terminate => { + return Ok(false); + } + } + } + + Ok(true) +} +// Wait for any of the given handles to be signalled. +// +// Returns the index of the `handle` that was signalled, or an `Err` if +// `WaitForMultipleObjectsEx` fails. +// +// This is called when the `run` thread is ready to wait for the next event. The +// next event might be some command submitted by the user (the first handle) or +// might indicate that one of the streams is ready to deliver or receive audio. +fn wait_for_handle_signal(handles: &[Foundation::HANDLE]) -> Result { + debug_assert!(handles.len() <= SystemServices::MAXIMUM_WAIT_OBJECTS as usize); + let result = unsafe { + Threading::WaitForMultipleObjectsEx( + handles, + false, // Don't wait for all, just wait for the first + Threading::INFINITE, // TODO: allow setting a timeout + false, // irrelevant parameter here + ) + }; + if result == Foundation::WAIT_FAILED { + let err = unsafe { Foundation::GetLastError() }; + let description = format!("`WaitForMultipleObjectsEx failed: {:?}", err); + let err = BackendSpecificError { description }; + return Err(err); + } + // Notifying the corresponding task handler. + let handle_idx = (result.0 - WAIT_OBJECT_0.0) as usize; + Ok(handle_idx) +} + +// Get the number of available frames that are available for writing/reading. +fn get_available_frames(stream: &StreamInner) -> Result { + unsafe { + let padding = stream + .audio_client + .GetCurrentPadding() + .map_err(windows_err_to_cpal_err::)?; + Ok(stream.max_frames_in_buffer - padding) + } +} + +fn run_input( + mut run_ctxt: RunContext, + data_callback: &mut dyn FnMut(&Data, &InputCallbackInfo), + error_callback: &mut dyn FnMut(StreamError), +) { + boost_current_thread_priority(); + + loop { + match process_commands_and_await_signal(&mut run_ctxt, error_callback) { + Some(ControlFlow::Break) => break, + Some(ControlFlow::Continue) => continue, + None => (), + } + let capture_client = match run_ctxt.stream.client_flow { + AudioClientFlow::Capture { ref capture_client } => capture_client.clone(), + _ => unreachable!(), + }; + match process_input( + &run_ctxt.stream, + capture_client, + data_callback, + error_callback, + ) { + ControlFlow::Break => break, + ControlFlow::Continue => continue, + } + } +} + +fn run_output( + mut run_ctxt: RunContext, + data_callback: &mut dyn FnMut(&mut Data, &OutputCallbackInfo), + error_callback: &mut dyn FnMut(StreamError), +) { + boost_current_thread_priority(); + + loop { + match process_commands_and_await_signal(&mut run_ctxt, error_callback) { + Some(ControlFlow::Break) => break, + Some(ControlFlow::Continue) => continue, + None => (), + } + let render_client = match run_ctxt.stream.client_flow { + AudioClientFlow::Render { ref render_client } => render_client.clone(), + _ => unreachable!(), + }; + match process_output( + &run_ctxt.stream, + render_client, + data_callback, + error_callback, + ) { + ControlFlow::Break => break, + ControlFlow::Continue => continue, + } + } +} + +fn boost_current_thread_priority() { + unsafe { + let thread_id = Threading::GetCurrentThreadId(); + + let _ = Threading::SetThreadPriority( + HANDLE(thread_id as isize), + Threading::THREAD_PRIORITY_TIME_CRITICAL, + ); + } +} + +enum ControlFlow { + Break, + Continue, +} + +fn process_commands_and_await_signal( + run_context: &mut RunContext, + error_callback: &mut dyn FnMut(StreamError), +) -> Option { + // Process queued commands. + match process_commands(run_context) { + Ok(true) => (), + Ok(false) => return Some(ControlFlow::Break), + Err(err) => { + error_callback(err); + return Some(ControlFlow::Break); + } + }; + + // Wait for any of the handles to be signalled. + let handle_idx = match wait_for_handle_signal(&run_context.handles) { + Ok(idx) => idx, + Err(err) => { + error_callback(err.into()); + return Some(ControlFlow::Break); + } + }; + + // If `handle_idx` is 0, then it's `pending_scheduled_event` that was signalled in + // order for us to pick up the pending commands. Otherwise, a stream needs data. + if handle_idx == 0 { + return Some(ControlFlow::Continue); + } + + None +} + +// Each successfully acquired packet is released exactly once, including a +// timestamp error or a panic in user code. Aborted render packets are silent. +struct BufferRelease Result<(), windows::core::Error>> { + release: Option, +} + +impl Result<(), windows::core::Error>> BufferRelease { + fn new(release: F) -> Self { + Self { + release: Some(release), + } + } + + fn finish(mut self) -> Result<(), windows::core::Error> { + // Disarm before the COM call: an HRESULT failure must not cause a second + // ReleaseBuffer call during destruction. + self.release.take().unwrap()(false) + } +} + +impl Result<(), windows::core::Error>> Drop for BufferRelease { + fn drop(&mut self) { + if let Some(release) = self.release.take() { + let _ = release(true); + } + } +} + +// The loop for processing pending input data. +fn process_input( + stream: &StreamInner, + capture_client: Audio::IAudioCaptureClient, + data_callback: &mut dyn FnMut(&Data, &InputCallbackInfo), + error_callback: &mut dyn FnMut(StreamError), +) -> ControlFlow { + unsafe { + // Get the available data in the shared buffer. + let mut buffer: *mut u8 = ptr::null_mut(); + let mut flags = mem::MaybeUninit::uninit(); + loop { + let mut frames_available = match capture_client.GetNextPacketSize() { + Ok(0) => return ControlFlow::Continue, + Ok(f) => f, + Err(err) => { + error_callback(windows_err_to_cpal_err(err)); + return ControlFlow::Break; + } + }; + let mut qpc_position: u64 = 0; + let result = capture_client.GetBuffer( + &mut buffer, + &mut frames_available, + flags.as_mut_ptr(), + None, + Some(&mut qpc_position), + ); + + match result { + Err(e) => { + error_callback(windows_err_to_cpal_err(e)); + return ControlFlow::Break; + } + Ok(_) => (), + } + + // AUDCLNT_S_BUFFER_EMPTY is a success HRESULT, so windows-rs returns + // Ok(()) and the frame count is zero. In this case GetBuffer leaves + // the data pointer and timestamps untouched: do not turn that pointer + // into a slice or invoke the callback. No packet needs to be released. + if frames_available == 0 { + return ControlFlow::Continue; + } + + let packet = BufferRelease::new(|_| capture_client.ReleaseBuffer(frames_available)); + debug_assert!(!buffer.is_null()); + + let data = buffer as *mut (); + let len = frames_available as usize * stream.bytes_per_frame as usize + / stream.sample_format.sample_size(); + let data = Data::from_parts(data, len, stream.sample_format); + + // The `qpc_position` is in 100 nanosecond units. Convert it to nanoseconds. + let timestamp = match input_timestamp(stream, qpc_position) { + Ok(ts) => ts, + Err(err) => { + error_callback(err); + return ControlFlow::Break; + } + }; + let info = InputCallbackInfo { timestamp }; + data_callback(&data, &info); + + // Release the buffer. + let result = packet.finish().map_err(windows_err_to_cpal_err); + if let Err(err) = result { + error_callback(err); + return ControlFlow::Break; + } + } + } +} + +// The loop for writing output data. +fn process_output( + stream: &StreamInner, + render_client: Audio::IAudioRenderClient, + data_callback: &mut dyn FnMut(&mut Data, &OutputCallbackInfo), + error_callback: &mut dyn FnMut(StreamError), +) -> ControlFlow { + // The number of frames available for writing. + let frames_available = match get_available_frames(stream) { + Ok(0) => return ControlFlow::Continue, // TODO: Can this happen? + Ok(n) => n, + Err(err) => { + error_callback(err); + return ControlFlow::Break; + } + }; + + unsafe { + let buffer = match render_client.GetBuffer(frames_available) { + Ok(b) => b, + Err(e) => { + error_callback(windows_err_to_cpal_err(e)); + return ControlFlow::Break; + } + }; + + let packet = BufferRelease::new(|aborted| { + let flags = if aborted { + Audio::AUDCLNT_BUFFERFLAGS_SILENT.0 as u32 + } else { + 0 + }; + render_client.ReleaseBuffer(frames_available, flags) + }); + debug_assert!(!buffer.is_null()); + + let data = buffer as *mut (); + let len = frames_available as usize * stream.bytes_per_frame as usize + / stream.sample_format.sample_size(); + let mut data = Data::from_parts(data, len, stream.sample_format); + let sample_rate = stream.config.sample_rate; + let timestamp = match output_timestamp(stream, frames_available, sample_rate) { + Ok(ts) => ts, + Err(err) => { + error_callback(err); + return ControlFlow::Break; + } + }; + let info = OutputCallbackInfo { timestamp }; + data_callback(&mut data, &info); + + if let Err(err) = packet.finish() { + error_callback(windows_err_to_cpal_err(err)); + return ControlFlow::Break; + } + } + + ControlFlow::Continue +} + +/// Convert the given duration in frames at the given sample rate to a `std::time::Duration`. +fn frames_to_duration(frames: u32, rate: crate::SampleRate) -> std::time::Duration { + let secsf = frames as f64 / rate.0 as f64; + let secs = secsf as u64; + let nanos = ((secsf - secs as f64) * 1_000_000_000.0) as u32; + std::time::Duration::new(secs, nanos) +} + +/// Use the stream's `IAudioClock` to produce the current stream instant. +/// +/// Uses the QPC position produced via the `GetPosition` method. +fn stream_instant(stream: &StreamInner) -> Result { + let mut position: u64 = 0; + let mut qpc_position: u64 = 0; + unsafe { + stream + .audio_clock + .GetPosition(&mut position, Some(&mut qpc_position)) + .map_err(windows_err_to_cpal_err::)?; + }; + // The `qpc_position` is in 100 nanosecond units. Convert it to nanoseconds. + let qpc_nanos = qpc_position as i128 * 100; + let instant = crate::StreamInstant::from_nanos_i128(qpc_nanos) + .expect("performance counter out of range of `StreamInstant` representation"); + Ok(instant) +} + +/// Produce the input stream timestamp. +/// +/// `buffer_qpc_position` is the `qpc_position` returned via the `GetBuffer` call on the capture +/// client. It represents the instant at which the first sample of the retrieved buffer was +/// captured. +fn input_timestamp( + stream: &StreamInner, + buffer_qpc_position: u64, +) -> Result { + // The `qpc_position` is in 100 nanosecond units. Convert it to nanoseconds. + let qpc_nanos = buffer_qpc_position as i128 * 100; + let capture = crate::StreamInstant::from_nanos_i128(qpc_nanos) + .expect("performance counter out of range of `StreamInstant` representation"); + let callback = stream_instant(stream)?; + Ok(crate::InputStreamTimestamp { capture, callback }) +} + +/// Produce the output stream timestamp. +/// +/// `frames_available` is the number of frames available for writing as reported by subtracting the +/// result of `GetCurrentPadding` from the maximum buffer size. +/// +/// `sample_rate` is the rate at which audio frames are processed by the device. +/// +/// TODO: The returned `playback` is an estimate that assumes audio is delivered immediately after +/// `frames_available` are consumed. The reality is that there is likely a tiny amount of latency +/// after this, but not sure how to determine this. +fn output_timestamp( + stream: &StreamInner, + frames_available: u32, + sample_rate: crate::SampleRate, +) -> Result { + let callback = stream_instant(stream)?; + let buffer_duration = frames_to_duration(frames_available, sample_rate); + let playback = callback + .add(buffer_duration) + .expect("`playback` occurs beyond representation supported by `StreamInstant`"); + Ok(crate::OutputStreamTimestamp { callback, playback }) +} + +#[cfg(test)] +mod lifecycle_tests { + use super::*; + use windows::Win32::System::Com::{CoGetApartmentType, APTTYPE, APTTYPEQUALIFIER}; + + #[test] + fn startup_failure_is_returned_after_worker_cleanup() { + let caller = thread::current().id(); + let (tx, rx) = channel(); + struct BeforeComCleanup(Sender); + impl Drop for BeforeComCleanup { + fn drop(&mut self) { + let mut apartment = APTTYPE::default(); + let mut qualifier = APTTYPEQUALIFIER::default(); + unsafe { CoGetApartmentType(&mut apartment, &mut qualifier) } + .expect("worker values must drop before COM is uninitialized"); + self.0.send(thread::current().id()).unwrap(); + } + } + let result = Stream::spawn( + "cpal_startup_failure_test", + move || { + let _cleanup = BeforeComCleanup(tx); + Err(BuildStreamError::StreamConfigNotSupported) + }, + |_| panic!("a failed stream must not enter its callback loop"), + ); + assert!(matches!( + result, + Err(BuildStreamError::StreamConfigNotSupported) + )); + assert_ne!(rx.recv().unwrap(), caller); + } + + #[test] + fn acquired_packet_releases_once_on_success_error_and_unwind() { + use std::cell::RefCell; + use std::panic::{catch_unwind, AssertUnwindSafe}; + let releases = RefCell::new(Vec::new()); + BufferRelease::new(|aborted| { + releases.borrow_mut().push(aborted); + Ok(()) + }) + .finish() + .unwrap(); + let failed = BufferRelease::new(|aborted| { + releases.borrow_mut().push(aborted); + Err(windows::core::Error::from_hresult(Foundation::E_FAIL)) + }) + .finish(); + assert!(failed.is_err()); + let panic = catch_unwind(AssertUnwindSafe(|| { + let _packet = BufferRelease::new(|aborted| { + releases.borrow_mut().push(aborted); + Ok(()) + }); + panic!("callback failure while a packet is acquired"); + })); + assert!(panic.is_err()); + assert_eq!(*releases.borrow(), [false, false, true]); + } + + #[test] + fn missing_endpoint_is_reported_synchronously() { + let id: Vec = "openless-invalid-endpoint-id" + .encode_utf16() + .chain(Some(0)) + .collect(); + let result = Stream::new_input( + id, + StreamConfig { + channels: 1, + sample_rate: crate::SampleRate(16_000), + buffer_size: crate::BufferSize::Default, + }, + SampleFormat::F32, + |_, _| panic!("an invalid endpoint must never invoke a data callback"), + |_| panic!("startup errors must be returned to the builder"), + ); + assert!(result.is_err()); + } + + #[test] + fn startup_panic_returns_an_error_instead_of_panicking_in_drop() { + let result = Stream::spawn( + "cpal_startup_panic_test", + || panic!("intentional startup panic"), + |_| panic!("startup never completed"), + ); + assert!(matches!( + result, + Err(BuildStreamError::BackendSpecific { .. }) + )); + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/host/webaudio/mod.rs b/openless-all/app/src-tauri/vendor/cpal/src/host/webaudio/mod.rs new file mode 100644 index 000000000..dc9c0d9bf --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/host/webaudio/mod.rs @@ -0,0 +1,528 @@ +extern crate js_sys; +extern crate wasm_bindgen; +extern crate web_sys; + +use self::js_sys::eval; +use self::wasm_bindgen::prelude::*; +use self::wasm_bindgen::JsCast; +use self::web_sys::{AudioContext, AudioContextOptions}; +use crate::traits::{DeviceTrait, HostTrait, StreamTrait}; +use crate::{ + BackendSpecificError, BufferSize, BuildStreamError, Data, DefaultStreamConfigError, + DeviceNameError, DevicesError, InputCallbackInfo, OutputCallbackInfo, PauseStreamError, + PlayStreamError, SampleFormat, SampleRate, StreamConfig, StreamError, SupportedBufferSize, + SupportedStreamConfig, SupportedStreamConfigRange, SupportedStreamConfigsError, +}; +use std::ops::DerefMut; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Duration; + +/// Content is false if the iterator is empty. +pub struct Devices(bool); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Device; + +pub struct Host; + +pub struct Stream { + ctx: Arc, + on_ended_closures: Vec>>>>, + config: StreamConfig, + buffer_size_frames: usize, +} + +pub type SupportedInputConfigs = ::std::vec::IntoIter; +pub type SupportedOutputConfigs = ::std::vec::IntoIter; + +const MIN_CHANNELS: u16 = 1; +const MAX_CHANNELS: u16 = 32; +const MIN_SAMPLE_RATE: SampleRate = SampleRate(8_000); +const MAX_SAMPLE_RATE: SampleRate = SampleRate(96_000); +const DEFAULT_SAMPLE_RATE: SampleRate = SampleRate(44_100); +const MIN_BUFFER_SIZE: u32 = 1; +const MAX_BUFFER_SIZE: u32 = u32::MAX; +const DEFAULT_BUFFER_SIZE: usize = 2048; +const SUPPORTED_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32; + +impl Host { + pub fn new() -> Result { + Ok(Host) + } +} + +impl HostTrait for Host { + type Devices = Devices; + type Device = Device; + + fn is_available() -> bool { + // Assume this host is always available on webaudio. + true + } + + fn devices(&self) -> Result { + Devices::new() + } + + fn default_input_device(&self) -> Option { + default_input_device() + } + + fn default_output_device(&self) -> Option { + default_output_device() + } +} + +impl Devices { + fn new() -> Result { + Ok(Self::default()) + } +} + +impl Device { + #[inline] + fn name(&self) -> Result { + Ok("Default Device".to_owned()) + } + + #[inline] + fn supported_input_configs( + &self, + ) -> Result { + // TODO + Ok(Vec::new().into_iter()) + } + + #[inline] + fn supported_output_configs( + &self, + ) -> Result { + let buffer_size = SupportedBufferSize::Range { + min: MIN_BUFFER_SIZE, + max: MAX_BUFFER_SIZE, + }; + let configs: Vec<_> = (MIN_CHANNELS..=MAX_CHANNELS) + .map(|channels| SupportedStreamConfigRange { + channels, + min_sample_rate: MIN_SAMPLE_RATE, + max_sample_rate: MAX_SAMPLE_RATE, + buffer_size: buffer_size.clone(), + sample_format: SUPPORTED_SAMPLE_FORMAT, + }) + .collect(); + Ok(configs.into_iter()) + } + + #[inline] + fn default_input_config(&self) -> Result { + // TODO + Err(DefaultStreamConfigError::StreamTypeNotSupported) + } + + #[inline] + fn default_output_config(&self) -> Result { + const EXPECT: &str = "expected at least one valid webaudio stream config"; + let config = self + .supported_output_configs() + .expect(EXPECT) + .max_by(|a, b| a.cmp_default_heuristics(b)) + .unwrap() + .with_sample_rate(DEFAULT_SAMPLE_RATE); + + Ok(config) + } +} + +impl DeviceTrait for Device { + type SupportedInputConfigs = SupportedInputConfigs; + type SupportedOutputConfigs = SupportedOutputConfigs; + type Stream = Stream; + + #[inline] + fn name(&self) -> Result { + Device::name(self) + } + + #[inline] + fn supported_input_configs( + &self, + ) -> Result { + Device::supported_input_configs(self) + } + + #[inline] + fn supported_output_configs( + &self, + ) -> Result { + Device::supported_output_configs(self) + } + + #[inline] + fn default_input_config(&self) -> Result { + Device::default_input_config(self) + } + + #[inline] + fn default_output_config(&self) -> Result { + Device::default_output_config(self) + } + + fn build_input_stream_raw( + &self, + _config: &StreamConfig, + _sample_format: SampleFormat, + _data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + // TODO + Err(BuildStreamError::StreamConfigNotSupported) + } + + /// Create an output stream. + fn build_output_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + _error_callback: E, + _timeout: Option, + ) -> Result + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + if !valid_config(config, sample_format) { + return Err(BuildStreamError::StreamConfigNotSupported); + } + + let n_channels = config.channels as usize; + + let buffer_size_frames = match config.buffer_size { + BufferSize::Fixed(v) => { + if v == 0 { + return Err(BuildStreamError::StreamConfigNotSupported); + } else { + v as usize + } + } + BufferSize::Default => DEFAULT_BUFFER_SIZE, + }; + let buffer_size_samples = buffer_size_frames * n_channels; + let buffer_time_step_secs = buffer_time_step_secs(buffer_size_frames, config.sample_rate); + + let data_callback = Arc::new(Mutex::new(Box::new(data_callback))); + + // Create the WebAudio stream. + let mut stream_opts = AudioContextOptions::new(); + stream_opts.sample_rate(config.sample_rate.0 as f32); + let ctx = AudioContext::new_with_context_options(&stream_opts).map_err( + |err| -> BuildStreamError { + let description = format!("{:?}", err); + let err = BackendSpecificError { description }; + err.into() + }, + )?; + + let destination = ctx.destination(); + + // If possible, set the destination's channel_count to the given config.channel. + // If not, fallback on the default destination channel_count to keep previous behavior + // and do not return an error. + if config.channels as u32 <= destination.max_channel_count() { + destination.set_channel_count(config.channels as u32); + } + + let ctx = Arc::new(ctx); + + // A container for managing the lifecycle of the audio callbacks. + let mut on_ended_closures: Vec>>>> = Vec::new(); + + // A cursor keeping track of the current time at which new frames should be scheduled. + let time = Arc::new(RwLock::new(0f64)); + + // Create a set of closures / callbacks which will continuously fetch and schedule sample + // playback. Starting with two workers, e.g. a front and back buffer so that audio frames + // can be fetched in the background. + for _i in 0..2 { + let data_callback_handle = data_callback.clone(); + let ctx_handle = ctx.clone(); + let time_handle = time.clone(); + + // A set of temporary buffers to be used for intermediate sample transformation steps. + let mut temporary_buffer = vec![0f32; buffer_size_samples]; + let mut temporary_channel_buffer = vec![0f32; buffer_size_frames]; + + #[cfg(target_feature = "atomics")] + let temporary_channel_array_view: js_sys::Float32Array; + #[cfg(target_feature = "atomics")] + { + let temporary_channel_array = js_sys::ArrayBuffer::new( + (std::mem::size_of::() * buffer_size_frames) as u32, + ); + temporary_channel_array_view = js_sys::Float32Array::new(&temporary_channel_array); + } + + // Create a webaudio buffer which will be reused to avoid allocations. + let ctx_buffer = ctx + .create_buffer( + config.channels as u32, + buffer_size_frames as u32, + config.sample_rate.0 as f32, + ) + .map_err(|err| -> BuildStreamError { + let description = format!("{:?}", err); + let err = BackendSpecificError { description }; + err.into() + })?; + + // A self reference to this closure for passing to future audio event calls. + let on_ended_closure: Arc>>> = + Arc::new(RwLock::new(None)); + let on_ended_closure_handle = on_ended_closure.clone(); + + on_ended_closure + .write() + .unwrap() + .replace(Closure::wrap(Box::new(move || { + let now = ctx_handle.current_time(); + let time_at_start_of_buffer = { + let time_at_start_of_buffer = time_handle + .read() + .expect("Unable to get a read lock on the time cursor"); + // Synchronise first buffer as necessary (eg. keep the time value + // referenced to the context clock). + if *time_at_start_of_buffer > 0.001 { + *time_at_start_of_buffer + } else { + // 25ms of time to fetch the first sample data, increase to avoid + // initial underruns. + now + 0.025 + } + }; + + // Populate the sample data into an interleaved temporary buffer. + { + let len = temporary_buffer.len(); + let data = temporary_buffer.as_mut_ptr() as *mut (); + let mut data = unsafe { Data::from_parts(data, len, sample_format) }; + let mut data_callback = data_callback_handle.lock().unwrap(); + let callback = crate::StreamInstant::from_secs_f64(now); + let playback = crate::StreamInstant::from_secs_f64(time_at_start_of_buffer); + let timestamp = crate::OutputStreamTimestamp { callback, playback }; + let info = OutputCallbackInfo { timestamp }; + (data_callback.deref_mut())(&mut data, &info); + } + + // Deinterleave the sample data and copy into the audio context buffer. + // We do not reference the audio context buffer directly e.g. getChannelData. + // As wasm-bindgen only gives us a copy, not a direct reference. + for channel in 0..n_channels { + for i in 0..buffer_size_frames { + temporary_channel_buffer[i] = + temporary_buffer[n_channels * i + channel]; + } + + #[cfg(not(target_feature = "atomics"))] + { + ctx_buffer + .copy_to_channel(&mut temporary_channel_buffer, channel as i32) + .expect( + "Unable to write sample data into the audio context buffer", + ); + } + + // copyToChannel cannot be directly copied into from a SharedArrayBuffer, + // which WASM memory is backed by if the 'atomics' flag is enabled. + // This workaround copies the data into an intermediary buffer first. + // There's a chance browsers may eventually relax that requirement. + // See this issue: https://github.com/WebAudio/web-audio-api/issues/2565 + #[cfg(target_feature = "atomics")] + { + temporary_channel_array_view.copy_from(&mut temporary_channel_buffer); + ctx_buffer + .unchecked_ref::() + .copy_to_channel(&temporary_channel_array_view, channel as i32) + .expect( + "Unable to write sample data into the audio context buffer", + ); + } + } + + // Create an AudioBufferSourceNode, schedule it to playback the reused buffer + // in the future. + let source = ctx_handle + .create_buffer_source() + .expect("Unable to create a webaudio buffer source"); + source.set_buffer(Some(&ctx_buffer)); + source + .connect_with_audio_node(&ctx_handle.destination()) + .expect( + "Unable to connect the web audio buffer source to the context destination", + ); + source.set_onended(Some( + on_ended_closure_handle + .read() + .unwrap() + .as_ref() + .unwrap() + .as_ref() + .unchecked_ref(), + )); + + source + .start_with_when(time_at_start_of_buffer) + .expect("Unable to start the webaudio buffer source"); + + // Keep track of when the next buffer worth of samples should be played. + *time_handle.write().unwrap() = time_at_start_of_buffer + buffer_time_step_secs; + }) as Box)); + + on_ended_closures.push(on_ended_closure); + } + + Ok(Stream { + ctx, + on_ended_closures, + config: config.clone(), + buffer_size_frames, + }) + } +} + +impl Stream { + /// Return the [`AudioContext`](https://developer.mozilla.org/docs/Web/API/AudioContext) used + /// by this stream. + pub fn audio_context(&self) -> &AudioContext { + &*self.ctx + } +} + +impl StreamTrait for Stream { + fn play(&self) -> Result<(), PlayStreamError> { + let window = web_sys::window().unwrap(); + match self.ctx.resume() { + Ok(_) => { + // Begin webaudio playback, initially scheduling the closures to fire on a timeout + // event. + let mut offset_ms = 10; + let time_step_secs = + buffer_time_step_secs(self.buffer_size_frames, self.config.sample_rate); + let time_step_ms = (time_step_secs * 1_000.0) as i32; + for on_ended_closure in self.on_ended_closures.iter() { + window + .set_timeout_with_callback_and_timeout_and_arguments_0( + on_ended_closure + .read() + .unwrap() + .as_ref() + .unwrap() + .as_ref() + .unchecked_ref(), + offset_ms, + ) + .unwrap(); + offset_ms += time_step_ms; + } + Ok(()) + } + Err(err) => { + let description = format!("{:?}", err); + let err = BackendSpecificError { description }; + Err(err.into()) + } + } + } + + fn pause(&self) -> Result<(), PauseStreamError> { + match self.ctx.suspend() { + Ok(_) => Ok(()), + Err(err) => { + let description = format!("{:?}", err); + let err = BackendSpecificError { description }; + Err(err.into()) + } + } + } +} + +impl Drop for Stream { + fn drop(&mut self) { + let _ = self.ctx.close(); + } +} + +impl Default for Devices { + fn default() -> Devices { + // We produce an empty iterator if the WebAudio API isn't available. + Devices(is_webaudio_available()) + } +} + +impl Iterator for Devices { + type Item = Device; + #[inline] + fn next(&mut self) -> Option { + if self.0 { + self.0 = false; + Some(Device) + } else { + None + } + } +} + +#[inline] +fn default_input_device() -> Option { + // TODO + None +} + +#[inline] +fn default_output_device() -> Option { + if is_webaudio_available() { + Some(Device) + } else { + None + } +} + +// Detects whether the `AudioContext` global variable is available. +fn is_webaudio_available() -> bool { + if let Ok(audio_context_is_defined) = eval("typeof AudioContext !== 'undefined'") { + audio_context_is_defined.as_bool().unwrap() + } else { + false + } +} + +// Whether or not the given stream configuration is valid for building a stream. +fn valid_config(conf: &StreamConfig, sample_format: SampleFormat) -> bool { + conf.channels <= MAX_CHANNELS + && conf.channels >= MIN_CHANNELS + && conf.sample_rate <= MAX_SAMPLE_RATE + && conf.sample_rate >= MIN_SAMPLE_RATE + && sample_format == SUPPORTED_SAMPLE_FORMAT +} + +fn buffer_time_step_secs(buffer_size_frames: usize, sample_rate: SampleRate) -> f64 { + buffer_size_frames as f64 / sample_rate.0 as f64 +} + +#[cfg(target_feature = "atomics")] +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_name = AudioBuffer)] + type ExternalArrayAudioBuffer; + + # [wasm_bindgen(catch, method, structural, js_class = "AudioBuffer", js_name = copyToChannel)] + pub fn copy_to_channel( + this: &ExternalArrayAudioBuffer, + source: &js_sys::Float32Array, + channel_number: i32, + ) -> Result<(), JsValue>; +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/lib.rs b/openless-all/app/src-tauri/vendor/cpal/src/lib.rs new file mode 100644 index 000000000..74405a4a2 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/lib.rs @@ -0,0 +1,871 @@ +//! # How to use cpal +//! +//! Here are some concepts cpal exposes: +//! +//! - A [`Host`] provides access to the available audio devices on the system. +//! Some platforms have more than one host available, but every platform supported by CPAL has at +//! least one [default_host] that is guaranteed to be available. +//! - A [`Device`] is an audio device that may have any number of input and +//! output streams. +//! - A [`Stream`] is an open flow of audio data. Input streams allow you to +//! receive audio data, output streams allow you to play audio data. You must choose which +//! [Device] will run your stream before you can create one. Often, a default device can be +//! retrieved via the [Host]. +//! +//! The first step is to initialise the [`Host`]: +//! +//! ``` +//! use cpal::traits::HostTrait; +//! let host = cpal::default_host(); +//! ``` +//! +//! Then choose an available [`Device`]. The easiest way is to use the default input or output +//! `Device` via the [`default_input_device()`] or [`default_output_device()`] methods on `host`. +//! +//! Alternatively, you can enumerate all the available devices with the [`devices()`] method. +//! Beware that the `default_*_device()` functions return an `Option` in case no device +//! is available for that stream type on the system. +//! +//! ```no_run +//! # use cpal::traits::HostTrait; +//! # let host = cpal::default_host(); +//! let device = host.default_output_device().expect("no output device available"); +//! ``` +//! +//! Before we can create a stream, we must decide what the configuration of the audio stream is +//! going to be. +//! You can query all the supported configurations with the +//! [`supported_input_configs()`] and [`supported_output_configs()`] methods. +//! These produce a list of [`SupportedStreamConfigRange`] structs which can later be turned into +//! actual [`SupportedStreamConfig`] structs. +//! +//! If you don't want to query the list of configs, +//! you can also build your own [`StreamConfig`] manually, but doing so could lead to an error when +//! building the stream if the config is not supported by the device. +//! +//! > **Note**: the `supported_input/output_configs()` methods +//! > could return an error for example if the device has been disconnected. +//! +//! ```no_run +//! use cpal::traits::{DeviceTrait, HostTrait}; +//! # let host = cpal::default_host(); +//! # let device = host.default_output_device().unwrap(); +//! let mut supported_configs_range = device.supported_output_configs() +//! .expect("error while querying configs"); +//! let supported_config = supported_configs_range.next() +//! .expect("no supported config?!") +//! .with_max_sample_rate(); +//! ``` +//! +//! Now that we have everything for the stream, we are ready to create it from our selected device: +//! +//! ```no_run +//! use cpal::Data; +//! use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +//! # let host = cpal::default_host(); +//! # let device = host.default_output_device().unwrap(); +//! # let config = device.default_output_config().unwrap().into(); +//! let stream = device.build_output_stream( +//! &config, +//! move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { +//! // react to stream events and read or write stream data here. +//! }, +//! move |err| { +//! // react to errors here. +//! }, +//! None // None=blocking, Some(Duration)=timeout +//! ); +//! ``` +//! +//! While the stream is running, the selected audio device will periodically call the data callback +//! that was passed to the function. The callback is passed an instance of either [`&Data` or +//! `&mut Data`](Data) depending on whether the stream is an input stream or output stream respectively. +//! +//! > **Note**: Creating and running a stream will *not* block the thread. On modern platforms, the +//! > given callback is called by a dedicated, high-priority thread responsible for delivering +//! > audio data to the system's audio device in a timely manner. On older platforms that only +//! > provide a blocking API (e.g. ALSA), CPAL will create a thread in order to consistently +//! > provide non-blocking behaviour (currently this is a thread per stream, but this may change to +//! > use a single thread for all streams). *If this is an issue for your platform or design, +//! > please share your issue and use-case with the CPAL team on the GitHub issue tracker for +//! > consideration.* +//! +//! In this example, we simply fill the given output buffer with silence. +//! +//! ```no_run +//! use cpal::{Data, Sample, SampleFormat, FromSample}; +//! use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +//! # let host = cpal::default_host(); +//! # let device = host.default_output_device().unwrap(); +//! # let supported_config = device.default_output_config().unwrap(); +//! let err_fn = |err| eprintln!("an error occurred on the output audio stream: {}", err); +//! let sample_format = supported_config.sample_format(); +//! let config = supported_config.into(); +//! let stream = match sample_format { +//! SampleFormat::F32 => device.build_output_stream(&config, write_silence::, err_fn, None), +//! SampleFormat::I16 => device.build_output_stream(&config, write_silence::, err_fn, None), +//! SampleFormat::U16 => device.build_output_stream(&config, write_silence::, err_fn, None), +//! sample_format => panic!("Unsupported sample format '{sample_format}'") +//! }.unwrap(); +//! +//! fn write_silence(data: &mut [T], _: &cpal::OutputCallbackInfo) { +//! for sample in data.iter_mut() { +//! *sample = Sample::EQUILIBRIUM; +//! } +//! } +//! ``` +//! +//! Not all platforms automatically run the stream upon creation. To ensure the stream has started, +//! we can use [`Stream::play`](traits::StreamTrait::play). +//! +//! ```no_run +//! # use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +//! # let host = cpal::default_host(); +//! # let device = host.default_output_device().unwrap(); +//! # let supported_config = device.default_output_config().unwrap(); +//! # let sample_format = supported_config.sample_format(); +//! # let config = supported_config.into(); +//! # let data_fn = move |_data: &mut cpal::Data, _: &cpal::OutputCallbackInfo| {}; +//! # let err_fn = move |_err| {}; +//! # let stream = device.build_output_stream_raw(&config, sample_format, data_fn, err_fn, None).unwrap(); +//! stream.play().unwrap(); +//! ``` +//! +//! Some devices support pausing the audio stream. This can be useful for saving energy in moments +//! of silence. +//! +//! ```no_run +//! # use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +//! # let host = cpal::default_host(); +//! # let device = host.default_output_device().unwrap(); +//! # let supported_config = device.default_output_config().unwrap(); +//! # let sample_format = supported_config.sample_format(); +//! # let config = supported_config.into(); +//! # let data_fn = move |_data: &mut cpal::Data, _: &cpal::OutputCallbackInfo| {}; +//! # let err_fn = move |_err| {}; +//! # let stream = device.build_output_stream_raw(&config, sample_format, data_fn, err_fn, None).unwrap(); +//! stream.pause().unwrap(); +//! ``` +//! +//! [`default_input_device()`]: traits::HostTrait::default_input_device +//! [`default_output_device()`]: traits::HostTrait::default_output_device +//! [`devices()`]: traits::HostTrait::devices +//! [`supported_input_configs()`]: traits::DeviceTrait::supported_input_configs +//! [`supported_output_configs()`]: traits::DeviceTrait::supported_output_configs + +#![recursion_limit = "2048"] + +// Extern crate declarations with `#[macro_use]` must unfortunately be at crate root. +#[cfg(target_os = "emscripten")] +#[macro_use] +extern crate wasm_bindgen; +#[cfg(target_os = "emscripten")] +extern crate js_sys; +#[cfg(target_os = "emscripten")] +extern crate web_sys; + +pub use error::*; +pub use platform::{ + available_hosts, default_host, host_from_id, Device, Devices, Host, HostId, Stream, + SupportedInputConfigs, SupportedOutputConfigs, ALL_HOSTS, +}; +pub use samples_formats::{FromSample, Sample, SampleFormat, SizedSample, I24, I48, U24, U48}; +use std::convert::TryInto; +use std::ops::{Div, Mul}; +use std::time::Duration; +#[cfg(target_os = "emscripten")] +use wasm_bindgen::prelude::*; + +mod error; +mod host; +pub mod platform; +mod samples_formats; +pub mod traits; + +/// A host's device iterator yielding only *input* devices. +pub type InputDevices = std::iter::Filter::Item) -> bool>; + +/// A host's device iterator yielding only *output* devices. +pub type OutputDevices = std::iter::Filter::Item) -> bool>; + +/// Number of channels. +pub type ChannelCount = u16; + +/// The number of samples processed per second for a single channel of audio. +#[cfg_attr(target_os = "emscripten", wasm_bindgen)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct SampleRate(pub u32); + +impl Mul for SampleRate +where + u32: Mul, +{ + type Output = Self; + fn mul(self, rhs: T) -> Self { + SampleRate(self.0 * rhs) + } +} + +impl Div for SampleRate +where + u32: Div, +{ + type Output = Self; + fn div(self, rhs: T) -> Self { + SampleRate(self.0 / rhs) + } +} + +/// The desired number of frames for the hardware buffer. +pub type FrameCount = u32; + +/// The buffer size used by the device. +/// +/// [`Default`] is used when no specific buffer size is set and uses the default +/// behavior of the given host. Note, the default buffer size may be surprisingly +/// large, leading to latency issues. If low latency is desired, [`Fixed(FrameCount)`] +/// should be used in accordance with the [`SupportedBufferSize`] range produced by +/// the [`SupportedStreamConfig`] API. +/// +/// [`Default`]: BufferSize::Default +/// [`Fixed(FrameCount)`]: BufferSize::Fixed +/// [`SupportedStreamConfig`]: SupportedStreamConfig::buffer_size +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BufferSize { + Default, + Fixed(FrameCount), +} + +#[cfg(target_os = "emscripten")] +impl wasm_bindgen::describe::WasmDescribe for BufferSize { + fn describe() {} +} + +#[cfg(target_os = "emscripten")] +impl wasm_bindgen::convert::IntoWasmAbi for BufferSize { + type Abi = Option; + fn into_abi(self) -> Self::Abi { + match self { + Self::Default => None, + Self::Fixed(fc) => Some(fc), + } + .into_abi() + } +} + +/// The set of parameters used to describe how to open a stream. +/// +/// The sample format is omitted in favour of using a sample type. +#[cfg_attr(target_os = "emscripten", wasm_bindgen)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StreamConfig { + pub channels: ChannelCount, + pub sample_rate: SampleRate, + pub buffer_size: BufferSize, +} + +/// Describes the minimum and maximum supported buffer size for the device +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SupportedBufferSize { + Range { + min: FrameCount, + max: FrameCount, + }, + /// In the case that the platform provides no way of getting the default + /// buffersize before starting a stream. + Unknown, +} + +/// Describes a range of supported stream configurations, retrieved via the +/// [`Device::supported_input/output_configs`](traits::DeviceTrait#required-methods) method. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SupportedStreamConfigRange { + pub(crate) channels: ChannelCount, + /// Minimum value for the samples rate of the supported formats. + pub(crate) min_sample_rate: SampleRate, + /// Maximum value for the samples rate of the supported formats. + pub(crate) max_sample_rate: SampleRate, + /// Buffersize ranges supported by the device + pub(crate) buffer_size: SupportedBufferSize, + /// Type of data expected by the device. + pub(crate) sample_format: SampleFormat, +} + +/// Describes a single supported stream configuration, retrieved via either a +/// [`SupportedStreamConfigRange`] instance or one of the +/// [`Device::default_input/output_config`](traits::DeviceTrait#required-methods) methods. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SupportedStreamConfig { + channels: ChannelCount, + sample_rate: SampleRate, + buffer_size: SupportedBufferSize, + sample_format: SampleFormat, +} + +/// A buffer of dynamically typed audio data, passed to raw stream callbacks. +/// +/// Raw input stream callbacks receive `&Data`, while raw output stream callbacks expect `&mut +/// Data`. +#[cfg_attr(target_os = "emscripten", wasm_bindgen)] +#[derive(Debug)] +pub struct Data { + data: *mut (), + len: usize, + sample_format: SampleFormat, +} + +/// A monotonic time instance associated with a stream, retrieved from either: +/// +/// 1. A timestamp provided to the stream's underlying audio data callback or +/// 2. The same time source used to generate timestamps for a stream's underlying audio data +/// callback. +/// +/// `StreamInstant` represents a duration since some unspecified origin occurring either before +/// or equal to the moment the stream from which it was created begins. +/// +/// ## Host `StreamInstant` Sources +/// +/// | Host | Source | +/// | ---- | ------ | +/// | alsa | `snd_pcm_status_get_htstamp` | +/// | coreaudio | `mach_absolute_time` | +/// | wasapi | `QueryPerformanceCounter` | +/// | asio | `timeGetTime` | +/// | emscripten | `AudioContext.getOutputTimestamp` | +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] +pub struct StreamInstant { + secs: i64, + nanos: u32, +} + +/// A timestamp associated with a call to an input stream's data callback. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub struct InputStreamTimestamp { + /// The instant the stream's data callback was invoked. + pub callback: StreamInstant, + /// The instant that data was captured from the device. + /// + /// E.g. The instant data was read from an ADC. + pub capture: StreamInstant, +} + +/// A timestamp associated with a call to an output stream's data callback. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub struct OutputStreamTimestamp { + /// The instant the stream's data callback was invoked. + pub callback: StreamInstant, + /// The predicted instant that data written will be delivered to the device for playback. + /// + /// E.g. The instant data will be played by a DAC. + pub playback: StreamInstant, +} + +/// Information relevant to a single call to the user's input stream data callback. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InputCallbackInfo { + timestamp: InputStreamTimestamp, +} + +/// Information relevant to a single call to the user's output stream data callback. +#[cfg_attr(target_os = "emscripten", wasm_bindgen)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutputCallbackInfo { + timestamp: OutputStreamTimestamp, +} + +impl SupportedStreamConfig { + pub fn new( + channels: ChannelCount, + sample_rate: SampleRate, + buffer_size: SupportedBufferSize, + sample_format: SampleFormat, + ) -> Self { + Self { + channels, + sample_rate, + buffer_size, + sample_format, + } + } + + pub fn channels(&self) -> ChannelCount { + self.channels + } + + pub fn sample_rate(&self) -> SampleRate { + self.sample_rate + } + + pub fn buffer_size(&self) -> &SupportedBufferSize { + &self.buffer_size + } + + pub fn sample_format(&self) -> SampleFormat { + self.sample_format + } + + pub fn config(&self) -> StreamConfig { + StreamConfig { + channels: self.channels, + sample_rate: self.sample_rate, + buffer_size: BufferSize::Default, + } + } +} + +impl StreamInstant { + /// The amount of time elapsed from another instant to this one. + /// + /// Returns `None` if `earlier` is later than self. + pub fn duration_since(&self, earlier: &Self) -> Option { + if self < earlier { + None + } else { + (self.as_nanos() - earlier.as_nanos()) + .try_into() + .ok() + .map(Duration::from_nanos) + } + } + + /// Returns the instant in time after the given duration has passed. + /// + /// Returns `None` if the resulting instant would exceed the bounds of the underlying data + /// structure. + pub fn add(&self, duration: Duration) -> Option { + self.as_nanos() + .checked_add(duration.as_nanos() as i128) + .and_then(Self::from_nanos_i128) + } + + /// Returns the instant in time one `duration` ago. + /// + /// Returns `None` if the resulting instant would underflow. As a result, it is important to + /// consider that on some platforms the [`StreamInstant`] may begin at `0` from the moment the + /// source stream is created. + pub fn sub(&self, duration: Duration) -> Option { + self.as_nanos() + .checked_sub(duration.as_nanos() as i128) + .and_then(Self::from_nanos_i128) + } + + fn as_nanos(&self) -> i128 { + (self.secs as i128 * 1_000_000_000) + self.nanos as i128 + } + + #[allow(dead_code)] + fn from_nanos(nanos: i64) -> Self { + let secs = nanos / 1_000_000_000; + let subsec_nanos = nanos - secs * 1_000_000_000; + Self::new(secs, subsec_nanos as u32) + } + + #[allow(dead_code)] + fn from_nanos_i128(nanos: i128) -> Option { + let secs = nanos / 1_000_000_000; + if secs > i64::MAX as i128 || secs < i64::MIN as i128 { + None + } else { + let subsec_nanos = nanos - secs * 1_000_000_000; + debug_assert!(subsec_nanos < u32::MAX as i128); + Some(Self::new(secs as i64, subsec_nanos as u32)) + } + } + + #[allow(dead_code)] + fn from_secs_f64(secs: f64) -> crate::StreamInstant { + let s = secs.floor() as i64; + let ns = ((secs - s as f64) * 1_000_000_000.0) as u32; + Self::new(s, ns) + } + + fn new(secs: i64, nanos: u32) -> Self { + StreamInstant { secs, nanos } + } +} + +impl InputCallbackInfo { + /// The timestamp associated with the call to an input stream's data callback. + pub fn timestamp(&self) -> InputStreamTimestamp { + self.timestamp + } +} + +impl OutputCallbackInfo { + /// The timestamp associated with the call to an output stream's data callback. + pub fn timestamp(&self) -> OutputStreamTimestamp { + self.timestamp + } +} + +#[allow(clippy::len_without_is_empty)] +impl Data { + // Internal constructor for host implementations to use. + // + // The following requirements must be met in order for the safety of `Data`'s public API. + // + // - The `data` pointer must point to the first sample in the slice containing all samples. + // - The `len` must describe the length of the buffer as a number of samples in the expected + // format specified via the `sample_format` argument. + // - The `sample_format` must correctly represent the underlying sample data delivered/expected + // by the stream. + pub(crate) unsafe fn from_parts( + data: *mut (), + len: usize, + sample_format: SampleFormat, + ) -> Self { + Data { + data, + len, + sample_format, + } + } + + /// The sample format of the internal audio data. + pub fn sample_format(&self) -> SampleFormat { + self.sample_format + } + + /// The full length of the buffer in samples. + /// + /// The returned length is the same length as the slice of type `T` that would be returned via + /// [`as_slice`](Self::as_slice) given a sample type that matches the inner sample format. + pub fn len(&self) -> usize { + self.len + } + + /// The raw slice of memory representing the underlying audio data as a slice of bytes. + /// + /// It is up to the user to interpret the slice of memory based on [`Data::sample_format`]. + pub fn bytes(&self) -> &[u8] { + let len = self.len * self.sample_format.sample_size(); + // The safety of this block relies on correct construction of the `Data` instance. + // See the unsafe `from_parts` constructor for these requirements. + unsafe { std::slice::from_raw_parts(self.data as *const u8, len) } + } + + /// The raw slice of memory representing the underlying audio data as a slice of bytes. + /// + /// It is up to the user to interpret the slice of memory based on [`Data::sample_format`]. + pub fn bytes_mut(&mut self) -> &mut [u8] { + let len = self.len * self.sample_format.sample_size(); + // The safety of this block relies on correct construction of the `Data` instance. See + // the unsafe `from_parts` constructor for these requirements. + unsafe { std::slice::from_raw_parts_mut(self.data as *mut u8, len) } + } + + /// Access the data as a slice of sample type `T`. + /// + /// Returns `None` if the sample type does not match the expected sample format. + pub fn as_slice(&self) -> Option<&[T]> + where + T: SizedSample, + { + if T::FORMAT == self.sample_format { + // The safety of this block relies on correct construction of the `Data` instance. See + // the unsafe `from_parts` constructor for these requirements. + unsafe { Some(std::slice::from_raw_parts(self.data as *const T, self.len)) } + } else { + None + } + } + + /// Access the data as a slice of sample type `T`. + /// + /// Returns `None` if the sample type does not match the expected sample format. + pub fn as_slice_mut(&mut self) -> Option<&mut [T]> + where + T: SizedSample, + { + if T::FORMAT == self.sample_format { + // The safety of this block relies on correct construction of the `Data` instance. See + // the unsafe `from_parts` constructor for these requirements. + unsafe { + Some(std::slice::from_raw_parts_mut( + self.data as *mut T, + self.len, + )) + } + } else { + None + } + } +} + +impl SupportedStreamConfigRange { + pub fn new( + channels: ChannelCount, + min_sample_rate: SampleRate, + max_sample_rate: SampleRate, + buffer_size: SupportedBufferSize, + sample_format: SampleFormat, + ) -> Self { + Self { + channels, + min_sample_rate, + max_sample_rate, + buffer_size, + sample_format, + } + } + + pub fn channels(&self) -> ChannelCount { + self.channels + } + + pub fn min_sample_rate(&self) -> SampleRate { + self.min_sample_rate + } + + pub fn max_sample_rate(&self) -> SampleRate { + self.max_sample_rate + } + + pub fn buffer_size(&self) -> &SupportedBufferSize { + &self.buffer_size + } + + pub fn sample_format(&self) -> SampleFormat { + self.sample_format + } + + /// Retrieve a [`SupportedStreamConfig`] with the given sample rate and buffer size. + /// + /// # Panics + /// + /// Panics if the given `sample_rate` is outside the range specified within + /// this [`SupportedStreamConfigRange`] instance. For a non-panicking + /// variant, use [`try_with_sample_rate`](#method.try_with_sample_rate). + pub fn with_sample_rate(self, sample_rate: SampleRate) -> SupportedStreamConfig { + self.try_with_sample_rate(sample_rate) + .expect("sample rate out of range") + } + + /// Retrieve a [`SupportedStreamConfig`] with the given sample rate and buffer size. + /// + /// Returns `None` if the given sample rate is outside the range specified + /// within this [`SupportedStreamConfigRange`] instance. + pub fn try_with_sample_rate(self, sample_rate: SampleRate) -> Option { + if self.min_sample_rate <= sample_rate && sample_rate <= self.max_sample_rate { + Some(SupportedStreamConfig { + channels: self.channels, + sample_rate, + sample_format: self.sample_format, + buffer_size: self.buffer_size, + }) + } else { + None + } + } + + /// Turns this [`SupportedStreamConfigRange`] into a [`SupportedStreamConfig`] corresponding to the maximum samples rate. + #[inline] + pub fn with_max_sample_rate(self) -> SupportedStreamConfig { + SupportedStreamConfig { + channels: self.channels, + sample_rate: self.max_sample_rate, + sample_format: self.sample_format, + buffer_size: self.buffer_size, + } + } + + /// A comparison function which compares two [`SupportedStreamConfigRange`]s in terms of their priority of + /// use as a default stream format. + /// + /// Some backends do not provide a default stream format for their audio devices. In these + /// cases, CPAL attempts to decide on a reasonable default format for the user. To do this we + /// use the "greatest" of all supported stream formats when compared with this method. + /// + /// SupportedStreamConfigs are prioritised by the following heuristics: + /// + /// **Channels**: + /// + /// - Stereo + /// - Mono + /// - Max available channels + /// + /// **Sample format**: + /// - f32 + /// - i16 + /// - u16 + /// + /// **Sample rate**: + /// + /// - 44100 (cd quality) + /// - Max sample rate + pub fn cmp_default_heuristics(&self, other: &Self) -> std::cmp::Ordering { + use std::cmp::Ordering::Equal; + use SampleFormat::{F32, I16, U16}; + + let cmp_stereo = (self.channels == 2).cmp(&(other.channels == 2)); + if cmp_stereo != Equal { + return cmp_stereo; + } + + let cmp_mono = (self.channels == 1).cmp(&(other.channels == 1)); + if cmp_mono != Equal { + return cmp_mono; + } + + let cmp_channels = self.channels.cmp(&other.channels); + if cmp_channels != Equal { + return cmp_channels; + } + + let cmp_f32 = (self.sample_format == F32).cmp(&(other.sample_format == F32)); + if cmp_f32 != Equal { + return cmp_f32; + } + + let cmp_i16 = (self.sample_format == I16).cmp(&(other.sample_format == I16)); + if cmp_i16 != Equal { + return cmp_i16; + } + + let cmp_u16 = (self.sample_format == U16).cmp(&(other.sample_format == U16)); + if cmp_u16 != Equal { + return cmp_u16; + } + + const HZ_44100: SampleRate = SampleRate(44_100); + let r44100_in_self = self.min_sample_rate <= HZ_44100 && HZ_44100 <= self.max_sample_rate; + let r44100_in_other = + other.min_sample_rate <= HZ_44100 && HZ_44100 <= other.max_sample_rate; + let cmp_r44100 = r44100_in_self.cmp(&r44100_in_other); + if cmp_r44100 != Equal { + return cmp_r44100; + } + + self.max_sample_rate.cmp(&other.max_sample_rate) + } +} + +#[test] +fn test_cmp_default_heuristics() { + let mut formats = [ + SupportedStreamConfigRange { + buffer_size: SupportedBufferSize::Range { min: 256, max: 512 }, + channels: 2, + min_sample_rate: SampleRate(1), + max_sample_rate: SampleRate(96000), + sample_format: SampleFormat::F32, + }, + SupportedStreamConfigRange { + buffer_size: SupportedBufferSize::Range { min: 256, max: 512 }, + channels: 1, + min_sample_rate: SampleRate(1), + max_sample_rate: SampleRate(96000), + sample_format: SampleFormat::F32, + }, + SupportedStreamConfigRange { + buffer_size: SupportedBufferSize::Range { min: 256, max: 512 }, + channels: 2, + min_sample_rate: SampleRate(1), + max_sample_rate: SampleRate(96000), + sample_format: SampleFormat::I16, + }, + SupportedStreamConfigRange { + buffer_size: SupportedBufferSize::Range { min: 256, max: 512 }, + channels: 2, + min_sample_rate: SampleRate(1), + max_sample_rate: SampleRate(96000), + sample_format: SampleFormat::U16, + }, + SupportedStreamConfigRange { + buffer_size: SupportedBufferSize::Range { min: 256, max: 512 }, + channels: 2, + min_sample_rate: SampleRate(1), + max_sample_rate: SampleRate(22050), + sample_format: SampleFormat::F32, + }, + ]; + + formats.sort_by(|a, b| a.cmp_default_heuristics(b)); + + // lowest-priority first: + assert_eq!(formats[0].sample_format(), SampleFormat::F32); + assert_eq!(formats[0].min_sample_rate(), SampleRate(1)); + assert_eq!(formats[0].max_sample_rate(), SampleRate(96000)); + assert_eq!(formats[0].channels(), 1); + + assert_eq!(formats[1].sample_format(), SampleFormat::U16); + assert_eq!(formats[1].min_sample_rate(), SampleRate(1)); + assert_eq!(formats[1].max_sample_rate(), SampleRate(96000)); + assert_eq!(formats[1].channels(), 2); + + assert_eq!(formats[2].sample_format(), SampleFormat::I16); + assert_eq!(formats[2].min_sample_rate(), SampleRate(1)); + assert_eq!(formats[2].max_sample_rate(), SampleRate(96000)); + assert_eq!(formats[2].channels(), 2); + + assert_eq!(formats[3].sample_format(), SampleFormat::F32); + assert_eq!(formats[3].min_sample_rate(), SampleRate(1)); + assert_eq!(formats[3].max_sample_rate(), SampleRate(22050)); + assert_eq!(formats[3].channels(), 2); + + assert_eq!(formats[4].sample_format(), SampleFormat::F32); + assert_eq!(formats[4].min_sample_rate(), SampleRate(1)); + assert_eq!(formats[4].max_sample_rate(), SampleRate(96000)); + assert_eq!(formats[4].channels(), 2); +} + +impl From for StreamConfig { + fn from(conf: SupportedStreamConfig) -> Self { + conf.config() + } +} + +// If a backend does not provide an API for retrieving supported formats, we query it with a bunch +// of commonly used rates. This is always the case for wasapi and is sometimes the case for alsa. +// +// If a rate you desire is missing from this list, feel free to add it! +#[cfg(target_os = "windows")] +const COMMON_SAMPLE_RATES: &[SampleRate] = &[ + SampleRate(5512), + SampleRate(8000), + SampleRate(11025), + SampleRate(16000), + SampleRate(22050), + SampleRate(32000), + SampleRate(44100), + SampleRate(48000), + SampleRate(64000), + SampleRate(88200), + SampleRate(96000), + SampleRate(176400), + SampleRate(192000), +]; + +#[test] +fn test_stream_instant() { + let a = StreamInstant::new(2, 0); + let b = StreamInstant::new(-2, 0); + let min = StreamInstant::new(i64::MIN, 0); + let max = StreamInstant::new(i64::MAX, 0); + assert_eq!( + a.sub(Duration::from_secs(1)), + Some(StreamInstant::new(1, 0)) + ); + assert_eq!( + a.sub(Duration::from_secs(2)), + Some(StreamInstant::new(0, 0)) + ); + assert_eq!( + a.sub(Duration::from_secs(3)), + Some(StreamInstant::new(-1, 0)) + ); + assert_eq!(min.sub(Duration::from_secs(1)), None); + assert_eq!( + b.add(Duration::from_secs(1)), + Some(StreamInstant::new(-1, 0)) + ); + assert_eq!( + b.add(Duration::from_secs(2)), + Some(StreamInstant::new(0, 0)) + ); + assert_eq!( + b.add(Duration::from_secs(3)), + Some(StreamInstant::new(1, 0)) + ); + assert_eq!(max.add(Duration::from_secs(1)), None); +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/platform/mod.rs b/openless-all/app/src-tauri/vendor/cpal/src/platform/mod.rs new file mode 100644 index 000000000..4cb4d5a29 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/platform/mod.rs @@ -0,0 +1,742 @@ +//! Platform-specific items. +//! +//! This module also contains the implementation of the platform's dynamically dispatched [`Host`] +//! type and its associated [`Device`], [`Stream`] and other associated types. These +//! types are useful in the case that users require switching between audio host APIs at runtime. + +#[doc(inline)] +pub use self::platform_impl::*; + +/// A macro to assist with implementing a platform's dynamically dispatched [`Host`] type. +/// +/// These dynamically dispatched types are necessary to allow for users to switch between hosts at +/// runtime. +/// +/// For example the invocation `impl_platform_host(Wasapi wasapi "WASAPI", Asio asio "ASIO")`, +/// this macro should expand to: +/// +// This sample code block is marked as text because it's not a valid test, +// it's just illustrative. (see rust issue #96573) +/// ```text +/// pub enum HostId { +/// Wasapi, +/// Asio, +/// } +/// +/// pub enum Host { +/// Wasapi(crate::host::wasapi::Host), +/// Asio(crate::host::asio::Host), +/// } +/// ``` +/// +/// And so on for Device, Devices, Host, Stream, SupportedInputConfigs, +/// SupportedOutputConfigs and all their necessary trait implementations. +/// +macro_rules! impl_platform_host { + ($($(#[cfg($feat: meta)])? $HostVariant:ident $host_mod:ident $host_name:literal),*) => { + /// All hosts supported by CPAL on this platform. + pub const ALL_HOSTS: &'static [HostId] = &[ + $( + $(#[cfg($feat)])? + HostId::$HostVariant, + )* + ]; + + /// The platform's dynamically dispatched `Host` type. + /// + /// An instance of this `Host` type may represent one of the `Host`s available + /// on the platform. + /// + /// Use this type if you require switching between available hosts at runtime. + /// + /// This type may be constructed via the [`host_from_id`] function. [`HostId`]s may + /// be acquired via the [`ALL_HOSTS`] const, and the [`available_hosts`] function. + pub struct Host(HostInner); + + /// The `Device` implementation associated with the platform's dynamically dispatched + /// [`Host`] type. + #[derive(Clone)] + pub struct Device(DeviceInner); + + /// The `Devices` iterator associated with the platform's dynamically dispatched [`Host`] + /// type. + pub struct Devices(DevicesInner); + + /// The `Stream` implementation associated with the platform's dynamically dispatched + /// [`Host`] type. + // Streams cannot be `Send` or `Sync` if we plan to support Android's AAudio API. This is + // because the stream API is not thread-safe, and the API prohibits calling certain + // functions within the callback. + // + // TODO: Confirm this and add more specific detail and references. + #[must_use = "If the stream is not stored it will not play."] + pub struct Stream(StreamInner, crate::platform::NotSendSyncAcrossAllPlatforms); + + /// The `SupportedInputConfigs` iterator associated with the platform's dynamically + /// dispatched [`Host`] type. + pub struct SupportedInputConfigs(SupportedInputConfigsInner); + + /// The `SupportedOutputConfigs` iterator associated with the platform's dynamically + /// dispatched [`Host`] type. + pub struct SupportedOutputConfigs(SupportedOutputConfigsInner); + + /// Unique identifier for available hosts on the platform. + #[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] + pub enum HostId { + $( + $(#[cfg($feat)])? + $HostVariant, + )* + } + + /// Contains a platform specific [`Device`] implementation. + #[derive(Clone)] + pub enum DeviceInner { + $( + $(#[cfg($feat)])? + $HostVariant(crate::host::$host_mod::Device), + )* + } + + /// Contains a platform specific [`Devices`] implementation. + pub enum DevicesInner { + $( + $(#[cfg($feat)])? + $HostVariant(crate::host::$host_mod::Devices), + )* + } + + /// Contains a platform specific [`Host`] implementation. + pub enum HostInner { + $( + $(#[cfg($feat)])? + $HostVariant(crate::host::$host_mod::Host), + )* + } + + /// Contains a platform specific [`Stream`] implementation. + pub enum StreamInner { + $( + $(#[cfg($feat)])? + $HostVariant(crate::host::$host_mod::Stream), + )* + } + + enum SupportedInputConfigsInner { + $( + $(#[cfg($feat)])? + $HostVariant(crate::host::$host_mod::SupportedInputConfigs), + )* + } + + enum SupportedOutputConfigsInner { + $( + $(#[cfg($feat)])? + $HostVariant(crate::host::$host_mod::SupportedOutputConfigs), + )* + } + + impl HostId { + pub fn name(&self) -> &'static str { + match self { + $( + $(#[cfg($feat)])? + HostId::$HostVariant => $host_name, + )* + } + } + } + + impl Devices { + /// Returns a reference to the underlying platform specific implementation of this + /// `Devices`. + pub fn as_inner(&self) -> &DevicesInner { + &self.0 + } + + /// Returns a mutable reference to the underlying platform specific implementation of + /// this `Devices`. + pub fn as_inner_mut(&mut self) -> &mut DevicesInner { + &mut self.0 + } + + /// Returns the underlying platform specific implementation of this `Devices`. + pub fn into_inner(self) -> DevicesInner { + self.0 + } + } + + impl Device { + /// Returns a reference to the underlying platform specific implementation of this + /// `Device`. + pub fn as_inner(&self) -> &DeviceInner { + &self.0 + } + + /// Returns a mutable reference to the underlying platform specific implementation of + /// this `Device`. + pub fn as_inner_mut(&mut self) -> &mut DeviceInner { + &mut self.0 + } + + /// Returns the underlying platform specific implementation of this `Device`. + pub fn into_inner(self) -> DeviceInner { + self.0 + } + } + + impl Host { + /// The unique identifier associated with this `Host`. + pub fn id(&self) -> HostId { + match self.0 { + $( + $(#[cfg($feat)])? + HostInner::$HostVariant(_) => HostId::$HostVariant, + )* + } + } + + /// Returns a reference to the underlying platform specific implementation of this + /// `Host`. + pub fn as_inner(&self) -> &HostInner { + &self.0 + } + + /// Returns a mutable reference to the underlying platform specific implementation of + /// this `Host`. + pub fn as_inner_mut(&mut self) -> &mut HostInner { + &mut self.0 + } + + /// Returns the underlying platform specific implementation of this `Host`. + pub fn into_inner(self) -> HostInner { + self.0 + } + } + + impl Stream { + /// Returns a reference to the underlying platform specific implementation of this + /// `Stream`. + pub fn as_inner(&self) -> &StreamInner { + &self.0 + } + + /// Returns a mutable reference to the underlying platform specific implementation of + /// this `Stream`. + pub fn as_inner_mut(&mut self) -> &mut StreamInner { + &mut self.0 + } + + /// Returns the underlying platform specific implementation of this `Stream`. + pub fn into_inner(self) -> StreamInner { + self.0 + } + } + + impl Iterator for Devices { + type Item = Device; + + fn next(&mut self) -> Option { + match self.0 { + $( + $(#[cfg($feat)])? + DevicesInner::$HostVariant(ref mut d) => { + d.next().map(DeviceInner::$HostVariant).map(Device::from) + } + )* + } + } + + fn size_hint(&self) -> (usize, Option) { + match self.0 { + $( + $(#[cfg($feat)])? + DevicesInner::$HostVariant(ref d) => d.size_hint(), + )* + } + } + } + + impl Iterator for SupportedInputConfigs { + type Item = crate::SupportedStreamConfigRange; + + fn next(&mut self) -> Option { + match self.0 { + $( + $(#[cfg($feat)])? + SupportedInputConfigsInner::$HostVariant(ref mut s) => s.next(), + )* + } + } + + fn size_hint(&self) -> (usize, Option) { + match self.0 { + $( + $(#[cfg($feat)])? + SupportedInputConfigsInner::$HostVariant(ref d) => d.size_hint(), + )* + } + } + } + + impl Iterator for SupportedOutputConfigs { + type Item = crate::SupportedStreamConfigRange; + + fn next(&mut self) -> Option { + match self.0 { + $( + $(#[cfg($feat)])? + SupportedOutputConfigsInner::$HostVariant(ref mut s) => s.next(), + )* + } + } + + fn size_hint(&self) -> (usize, Option) { + match self.0 { + $( + $(#[cfg($feat)])? + SupportedOutputConfigsInner::$HostVariant(ref d) => d.size_hint(), + )* + } + } + } + + impl crate::traits::DeviceTrait for Device { + type SupportedInputConfigs = SupportedInputConfigs; + type SupportedOutputConfigs = SupportedOutputConfigs; + type Stream = Stream; + + fn name(&self) -> Result { + match self.0 { + $( + $(#[cfg($feat)])? + DeviceInner::$HostVariant(ref d) => d.name(), + )* + } + } + + fn supported_input_configs(&self) -> Result { + match self.0 { + $( + $(#[cfg($feat)])? + DeviceInner::$HostVariant(ref d) => { + d.supported_input_configs() + .map(SupportedInputConfigsInner::$HostVariant) + .map(SupportedInputConfigs) + } + )* + } + } + + fn supported_output_configs(&self) -> Result { + match self.0 { + $( + $(#[cfg($feat)])? + DeviceInner::$HostVariant(ref d) => { + d.supported_output_configs() + .map(SupportedOutputConfigsInner::$HostVariant) + .map(SupportedOutputConfigs) + } + )* + } + } + + fn default_input_config(&self) -> Result { + match self.0 { + $( + $(#[cfg($feat)])? + DeviceInner::$HostVariant(ref d) => d.default_input_config(), + )* + } + } + + fn default_output_config(&self) -> Result { + match self.0 { + $( + $(#[cfg($feat)])? + DeviceInner::$HostVariant(ref d) => d.default_output_config(), + )* + } + } + + fn build_input_stream_raw( + &self, + config: &crate::StreamConfig, + sample_format: crate::SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&crate::Data, &crate::InputCallbackInfo) + Send + 'static, + E: FnMut(crate::StreamError) + Send + 'static, + { + match self.0 { + $( + $(#[cfg($feat)])? + DeviceInner::$HostVariant(ref d) => d + .build_input_stream_raw( + config, + sample_format, + data_callback, + error_callback, + timeout, + ) + .map(StreamInner::$HostVariant) + .map(Stream::from), + )* + } + } + + fn build_output_stream_raw( + &self, + config: &crate::StreamConfig, + sample_format: crate::SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&mut crate::Data, &crate::OutputCallbackInfo) + Send + 'static, + E: FnMut(crate::StreamError) + Send + 'static, + { + match self.0 { + $( + $(#[cfg($feat)])? + DeviceInner::$HostVariant(ref d) => d + .build_output_stream_raw( + config, + sample_format, + data_callback, + error_callback, + timeout, + ) + .map(StreamInner::$HostVariant) + .map(Stream::from), + )* + } + } + } + + impl crate::traits::HostTrait for Host { + type Devices = Devices; + type Device = Device; + + fn is_available() -> bool { + $( + $(#[cfg($feat)])? + if crate::host::$host_mod::Host::is_available() { return true; } + )* + false + } + + fn devices(&self) -> Result { + match self.0 { + $( + $(#[cfg($feat)])? + HostInner::$HostVariant(ref h) => { + h.devices().map(DevicesInner::$HostVariant).map(Devices::from) + } + )* + } + } + + fn default_input_device(&self) -> Option { + match self.0 { + $( + $(#[cfg($feat)])? + HostInner::$HostVariant(ref h) => { + h.default_input_device().map(DeviceInner::$HostVariant).map(Device::from) + } + )* + } + } + + fn default_output_device(&self) -> Option { + match self.0 { + $( + $(#[cfg($feat)])? + HostInner::$HostVariant(ref h) => { + h.default_output_device().map(DeviceInner::$HostVariant).map(Device::from) + } + )* + } + } + } + + impl crate::traits::StreamTrait for Stream { + fn play(&self) -> Result<(), crate::PlayStreamError> { + match self.0 { + $( + $(#[cfg($feat)])? + StreamInner::$HostVariant(ref s) => { + s.play() + } + )* + } + } + + fn pause(&self) -> Result<(), crate::PauseStreamError> { + match self.0 { + $( + $(#[cfg($feat)])? + StreamInner::$HostVariant(ref s) => { + s.pause() + } + )* + } + } + } + + impl From for Device { + fn from(d: DeviceInner) -> Self { + Device(d) + } + } + + impl From for Devices { + fn from(d: DevicesInner) -> Self { + Devices(d) + } + } + + impl From for Host { + fn from(h: HostInner) -> Self { + Host(h) + } + } + + impl From for Stream { + fn from(s: StreamInner) -> Self { + Stream(s, Default::default()) + } + } + + $( + $(#[cfg($feat)])? + impl From for Device { + fn from(h: crate::host::$host_mod::Device) -> Self { + DeviceInner::$HostVariant(h).into() + } + } + + $(#[cfg($feat)])? + impl From for Devices { + fn from(h: crate::host::$host_mod::Devices) -> Self { + DevicesInner::$HostVariant(h).into() + } + } + + $(#[cfg($feat)])? + impl From for Host { + fn from(h: crate::host::$host_mod::Host) -> Self { + HostInner::$HostVariant(h).into() + } + } + + $(#[cfg($feat)])? + impl From for Stream { + fn from(h: crate::host::$host_mod::Stream) -> Self { + StreamInner::$HostVariant(h).into() + } + } + )* + + /// Produces a list of hosts that are currently available on the system. + pub fn available_hosts() -> Vec { + let mut host_ids = vec![]; + $( + $(#[cfg($feat)])? + if ::is_available() { + host_ids.push(HostId::$HostVariant); + } + )* + host_ids + } + + /// Given a unique host identifier, initialise and produce the host if it is available. + pub fn host_from_id(id: HostId) -> Result { + match id { + $( + $(#[cfg($feat)])? + HostId::$HostVariant => { + crate::host::$host_mod::Host::new() + .map(HostInner::$HostVariant) + .map(Host::from) + } + )* + } + } + }; +} + +// TODO: Add pulseaudio and jack here eventually. +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" +))] +mod platform_impl { + pub use crate::host::alsa::{ + Device as AlsaDevice, Devices as AlsaDevices, Host as AlsaHost, Stream as AlsaStream, + SupportedInputConfigs as AlsaSupportedInputConfigs, + SupportedOutputConfigs as AlsaSupportedOutputConfigs, + }; + #[cfg(feature = "jack")] + pub use crate::host::jack::{ + Device as JackDevice, Devices as JackDevices, Host as JackHost, Stream as JackStream, + SupportedInputConfigs as JackSupportedInputConfigs, + SupportedOutputConfigs as JackSupportedOutputConfigs, + }; + + impl_platform_host!(#[cfg(feature = "jack")] Jack jack "JACK", Alsa alsa "ALSA"); + + /// The default host for the current compilation target platform. + pub fn default_host() -> Host { + AlsaHost::new() + .expect("the default host should always be available") + .into() + } +} + +#[cfg(any(target_os = "macos", target_os = "ios"))] +mod platform_impl { + pub use crate::host::coreaudio::{ + Device as CoreAudioDevice, Devices as CoreAudioDevices, Host as CoreAudioHost, + Stream as CoreAudioStream, SupportedInputConfigs as CoreAudioSupportedInputConfigs, + SupportedOutputConfigs as CoreAudioSupportedOutputConfigs, + }; + + impl_platform_host!(CoreAudio coreaudio "CoreAudio"); + + /// The default host for the current compilation target platform. + pub fn default_host() -> Host { + CoreAudioHost::new() + .expect("the default host should always be available") + .into() + } +} + +#[cfg(target_os = "emscripten")] +mod platform_impl { + pub use crate::host::emscripten::{ + Device as EmscriptenDevice, Devices as EmscriptenDevices, Host as EmscriptenHost, + Stream as EmscriptenStream, SupportedInputConfigs as EmscriptenSupportedInputConfigs, + SupportedOutputConfigs as EmscriptenSupportedOutputConfigs, + }; + + impl_platform_host!(Emscripten emscripten "Emscripten"); + + /// The default host for the current compilation target platform. + pub fn default_host() -> Host { + EmscriptenHost::new() + .expect("the default host should always be available") + .into() + } +} + +#[cfg(all(target_arch = "wasm32", feature = "wasm-bindgen"))] +mod platform_impl { + pub use crate::host::webaudio::{ + Device as WebAudioDevice, Devices as WebAudioDevices, Host as WebAudioHost, + Stream as WebAudioStream, SupportedInputConfigs as WebAudioSupportedInputConfigs, + SupportedOutputConfigs as WebAudioSupportedOutputConfigs, + }; + + impl_platform_host!(WebAudio webaudio "WebAudio"); + + /// The default host for the current compilation target platform. + pub fn default_host() -> Host { + WebAudioHost::new() + .expect("the default host should always be available") + .into() + } +} + +#[cfg(windows)] +mod platform_impl { + #[cfg(feature = "asio")] + pub use crate::host::asio::{ + Device as AsioDevice, Devices as AsioDevices, Host as AsioHost, Stream as AsioStream, + SupportedInputConfigs as AsioSupportedInputConfigs, + SupportedOutputConfigs as AsioSupportedOutputConfigs, + }; + pub use crate::host::wasapi::{ + Device as WasapiDevice, Devices as WasapiDevices, Host as WasapiHost, + Stream as WasapiStream, SupportedInputConfigs as WasapiSupportedInputConfigs, + SupportedOutputConfigs as WasapiSupportedOutputConfigs, + }; + + impl_platform_host!(#[cfg(feature = "asio")] Asio asio "ASIO", Wasapi wasapi "WASAPI"); + + /// The default host for the current compilation target platform. + pub fn default_host() -> Host { + WasapiHost::new() + .expect("the default host should always be available") + .into() + } +} + +#[cfg(target_os = "android")] +mod platform_impl { + pub use crate::host::oboe::{ + Device as OboeDevice, Devices as OboeDevices, Host as OboeHost, Stream as OboeStream, + SupportedInputConfigs as OboeSupportedInputConfigs, + SupportedOutputConfigs as OboeSupportedOutputConfigs, + }; + + impl_platform_host!(Oboe oboe "Oboe"); + + /// The default host for the current compilation target platform. + pub fn default_host() -> Host { + OboeHost::new() + .expect("the default host should always be available") + .into() + } +} + +#[cfg(not(any( + windows, + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "macos", + target_os = "ios", + target_os = "emscripten", + target_os = "android", + all(target_arch = "wasm32", feature = "wasm-bindgen"), +)))] +mod platform_impl { + pub use crate::host::null::{ + Device as NullDevice, Devices as NullDevices, Host as NullHost, + SupportedInputConfigs as NullSupportedInputConfigs, + SupportedOutputConfigs as NullSupportedOutputConfigs, + }; + + impl_platform_host!(Null null "Null"); + + /// The default host for the current compilation target platform. + pub fn default_host() -> Host { + NullHost::new() + .expect("the default host should always be available") + .into() + } +} + +// The following zero-sized types are for applying Send/Sync restrictions to ensure +// consistent behaviour across different platforms. These verbosely named types are used +// (rather than using the markers directly) in the hope of making the compile errors +// slightly more helpful. +// +// TODO: Remove these in favour of using negative trait bounds if they stabilise. + +// A marker used to remove the `Send` and `Sync` traits. +struct NotSendSyncAcrossAllPlatforms(std::marker::PhantomData<*mut ()>); + +impl Default for NotSendSyncAcrossAllPlatforms { + fn default() -> Self { + NotSendSyncAcrossAllPlatforms(std::marker::PhantomData) + } +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/samples_formats.rs b/openless-all/app/src-tauri/vendor/cpal/src/samples_formats.rs new file mode 100644 index 000000000..7d3bf96cc --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/samples_formats.rs @@ -0,0 +1,167 @@ +use std::{fmt::Display, mem}; +#[cfg(target_os = "emscripten")] +use wasm_bindgen::prelude::*; + +pub use dasp_sample::{FromSample, Sample, I24, I48, U24, U48}; + +/// Format that each sample has. +#[cfg_attr(target_os = "emscripten", wasm_bindgen)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum SampleFormat { + /// `i8` with a valid range of 'u8::MIN..=u8::MAX' with `0` being the origin + I8, + + /// `i16` with a valid range of 'u16::MIN..=u16::MAX' with `0` being the origin + I16, + + // /// `I24` with a valid range of '-(1 << 23)..(1 << 23)' with `0` being the origin + // I24, + /// `i32` with a valid range of 'u32::MIN..=u32::MAX' with `0` being the origin + I32, + + // /// `I24` with a valid range of '-(1 << 47)..(1 << 47)' with `0` being the origin + // I48, + /// `i64` with a valid range of 'u64::MIN..=u64::MAX' with `0` being the origin + I64, + + /// `u8` with a valid range of 'u8::MIN..=u8::MAX' with `1 << 7 == 128` being the origin + U8, + + /// `u16` with a valid range of 'u16::MIN..=u16::MAX' with `1 << 15 == 32768` being the origin + U16, + + // /// `U24` with a valid range of '0..16777216' with `1 << 23 == 8388608` being the origin + // U24, + /// `u32` with a valid range of 'u32::MIN..=u32::MAX' with `1 << 31` being the origin + U32, + + // /// `U48` with a valid range of '0..(1 << 48)' with `1 << 47` being the origin + // U48, + /// `u64` with a valid range of 'u64::MIN..=u64::MAX' with `1 << 63` being the origin + U64, + + /// `f32` with a valid range of `-1.0..1.0` with `0.0` being the origin + F32, + + /// `f64` with a valid range of -1.0..1.0 with 0.0 being the origin + F64, +} + +impl SampleFormat { + /// Returns the size in bytes of a sample of this format. + #[inline] + #[must_use] + pub fn sample_size(&self) -> usize { + match *self { + SampleFormat::I8 | SampleFormat::U8 => mem::size_of::(), + SampleFormat::I16 | SampleFormat::U16 => mem::size_of::(), + // SampleFormat::I24 | SampleFormat::U24 => 3, + SampleFormat::I32 | SampleFormat::U32 => mem::size_of::(), + // SampleFormat::I48 | SampleFormat::U48 => 6, + SampleFormat::I64 | SampleFormat::U64 => mem::size_of::(), + SampleFormat::F32 => mem::size_of::(), + SampleFormat::F64 => mem::size_of::(), + } + } + + #[inline] + #[must_use] + pub fn is_int(&self) -> bool { + //matches!(*self, SampleFormat::I8 | SampleFormat::I16 | SampleFormat::I24 | SampleFormat::I32 | SampleFormat::I48 | SampleFormat::I64) + matches!( + *self, + SampleFormat::I8 | SampleFormat::I16 | SampleFormat::I32 | SampleFormat::I64 + ) + } + + #[inline] + #[must_use] + pub fn is_uint(&self) -> bool { + //matches!(*self, SampleFormat::U8 | SampleFormat::U16 | SampleFormat::U24 | SampleFormat::U32 | SampleFormat::U48 | SampleFormat::U64) + matches!( + *self, + SampleFormat::U8 | SampleFormat::U16 | SampleFormat::U32 | SampleFormat::U64 + ) + } + + #[inline] + #[must_use] + pub fn is_float(&self) -> bool { + matches!(*self, SampleFormat::F32 | SampleFormat::F64) + } +} + +impl Display for SampleFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match *self { + SampleFormat::I8 => "i8", + SampleFormat::I16 => "i16", + // SampleFormat::I24 => "i24", + SampleFormat::I32 => "i32", + // SampleFormat::I48 => "i48", + SampleFormat::I64 => "i64", + SampleFormat::U8 => "u8", + SampleFormat::U16 => "u16", + // SampleFormat::U24 => "u24", + SampleFormat::U32 => "u32", + // SampleFormat::U48 => "u48", + SampleFormat::U64 => "u64", + SampleFormat::F32 => "f32", + SampleFormat::F64 => "f64", + } + .fmt(f) + } +} + +pub trait SizedSample: Sample { + const FORMAT: SampleFormat; +} + +impl SizedSample for i8 { + const FORMAT: SampleFormat = SampleFormat::I8; +} + +impl SizedSample for i16 { + const FORMAT: SampleFormat = SampleFormat::I16; +} + +// impl SizedSample for I24 { const FORMAT: SampleFormat = SampleFormat::I24; } + +impl SizedSample for i32 { + const FORMAT: SampleFormat = SampleFormat::I32; +} + +// impl SizedSample for I48 { const FORMAT: SampleFormat = SampleFormat::I48; } + +impl SizedSample for i64 { + const FORMAT: SampleFormat = SampleFormat::I64; +} + +impl SizedSample for u8 { + const FORMAT: SampleFormat = SampleFormat::U8; +} + +impl SizedSample for u16 { + const FORMAT: SampleFormat = SampleFormat::U16; +} + +// impl SizedSample for U24 { const FORMAT: SampleFormat = SampleFormat::U24; } + +impl SizedSample for u32 { + const FORMAT: SampleFormat = SampleFormat::U32; +} + +// impl SizedSample for U48 { const FORMAT: SampleFormat = SampleFormat::U48; } + +impl SizedSample for u64 { + const FORMAT: SampleFormat = SampleFormat::U64; +} + +impl SizedSample for f32 { + const FORMAT: SampleFormat = SampleFormat::F32; +} + +impl SizedSample for f64 { + const FORMAT: SampleFormat = SampleFormat::F64; +} diff --git a/openless-all/app/src-tauri/vendor/cpal/src/traits.rs b/openless-all/app/src-tauri/vendor/cpal/src/traits.rs new file mode 100644 index 000000000..6f5ff45aa --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/src/traits.rs @@ -0,0 +1,221 @@ +//! The suite of traits allowing CPAL to abstract over hosts, devices, event loops and stream IDs. + +use std::time::Duration; + +use crate::{ + BuildStreamError, Data, DefaultStreamConfigError, DeviceNameError, DevicesError, + InputCallbackInfo, InputDevices, OutputCallbackInfo, OutputDevices, PauseStreamError, + PlayStreamError, SampleFormat, SizedSample, StreamConfig, StreamError, SupportedStreamConfig, + SupportedStreamConfigRange, SupportedStreamConfigsError, +}; + +/// A [`Host`] provides access to the available audio devices on the system. +/// +/// Each platform may have a number of available hosts depending on the system, each with their own +/// pros and cons. +/// +/// For example, WASAPI is the standard audio host API that ships with the Windows operating +/// system. However, due to historical limitations with respect to performance and flexibility, +/// Steinberg created the ASIO API providing better audio device support for pro audio and +/// low-latency applications. As a result, it is common for some devices and device capabilities to +/// only be available via ASIO, while others are only available via WASAPI. +/// +/// Another great example is the Linux platform. While the ALSA host API is the lowest-level API +/// available to almost all distributions of Linux, its flexibility is limited as it requires that +/// each process have exclusive access to the devices with which they establish streams. PulseAudio +/// is another popular host API that aims to solve this issue by providing user-space mixing, +/// however it has its own limitations w.r.t. low-latency and high-performance audio applications. +/// JACK is yet another host API that is more suitable to pro-audio applications, however it is +/// less readily available by default in many Linux distributions and is known to be tricky to +/// set up. +/// +/// [`Host`]: crate::Host +pub trait HostTrait { + /// The type used for enumerating available devices by the host. + type Devices: Iterator; + /// The `Device` type yielded by the host. + type Device: DeviceTrait; + + /// Whether or not the host is available on the system. + fn is_available() -> bool; + + /// An iterator yielding all [`Device`](DeviceTrait)s currently available to the host on the system. + /// + /// Can be empty if the system does not support audio in general. + fn devices(&self) -> Result; + + /// The default input audio device on the system. + /// + /// Returns `None` if no input device is available. + fn default_input_device(&self) -> Option; + + /// The default output audio device on the system. + /// + /// Returns `None` if no output device is available. + fn default_output_device(&self) -> Option; + + /// An iterator yielding all `Device`s currently available to the system that support one or more + /// input stream formats. + /// + /// Can be empty if the system does not support audio input. + fn input_devices(&self) -> Result, DevicesError> { + fn supports_input(device: &D) -> bool { + device + .supported_input_configs() + .map(|mut iter| iter.next().is_some()) + .unwrap_or(false) + } + Ok(self.devices()?.filter(supports_input::)) + } + + /// An iterator yielding all `Device`s currently available to the system that support one or more + /// output stream formats. + /// + /// Can be empty if the system does not support audio output. + fn output_devices(&self) -> Result, DevicesError> { + fn supports_output(device: &D) -> bool { + device + .supported_output_configs() + .map(|mut iter| iter.next().is_some()) + .unwrap_or(false) + } + Ok(self.devices()?.filter(supports_output::)) + } +} + +/// A device that is capable of audio input and/or output. +/// +/// Please note that `Device`s may become invalid if they get disconnected. Therefore, all the +/// methods that involve a device return a `Result` allowing the user to handle this case. +pub trait DeviceTrait { + /// The iterator type yielding supported input stream formats. + type SupportedInputConfigs: Iterator; + /// The iterator type yielding supported output stream formats. + type SupportedOutputConfigs: Iterator; + /// The stream type created by [`build_input_stream_raw`] and [`build_output_stream_raw`]. + /// + /// [`build_input_stream_raw`]: Self::build_input_stream_raw + /// [`build_output_stream_raw`]: Self::build_output_stream_raw + type Stream: StreamTrait; + + /// The human-readable name of the device. + fn name(&self) -> Result; + + /// An iterator yielding formats that are supported by the backend. + /// + /// Can return an error if the device is no longer valid (e.g. it has been disconnected). + fn supported_input_configs( + &self, + ) -> Result; + + /// An iterator yielding output stream formats that are supported by the device. + /// + /// Can return an error if the device is no longer valid (e.g. it has been disconnected). + fn supported_output_configs( + &self, + ) -> Result; + + /// The default input stream format for the device. + fn default_input_config(&self) -> Result; + + /// The default output stream format for the device. + fn default_output_config(&self) -> Result; + + /// Create an input stream. + fn build_input_stream( + &self, + config: &StreamConfig, + mut data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + T: SizedSample, + D: FnMut(&[T], &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + self.build_input_stream_raw( + config, + T::FORMAT, + move |data, info| { + data_callback( + data.as_slice() + .expect("host supplied incorrect sample type"), + info, + ) + }, + error_callback, + timeout, + ) + } + + /// Create an output stream. + fn build_output_stream( + &self, + config: &StreamConfig, + mut data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + T: SizedSample, + D: FnMut(&mut [T], &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static, + { + self.build_output_stream_raw( + config, + T::FORMAT, + move |data, info| { + data_callback( + data.as_slice_mut() + .expect("host supplied incorrect sample type"), + info, + ) + }, + error_callback, + timeout, + ) + } + + /// Create a dynamically typed input stream. + fn build_input_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static; + + /// Create a dynamically typed output stream. + fn build_output_stream_raw( + &self, + config: &StreamConfig, + sample_format: SampleFormat, + data_callback: D, + error_callback: E, + timeout: Option, + ) -> Result + where + D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, + E: FnMut(StreamError) + Send + 'static; +} + +/// A stream created from [`Device`](DeviceTrait), with methods to control playback. +pub trait StreamTrait { + /// Run the stream. + /// + /// Note: Not all platforms automatically run the stream upon creation, so it is important to + /// call `play` after creation if it is expected that the stream should run immediately. + fn play(&self) -> Result<(), PlayStreamError>; + + /// Some devices support pausing the audio stream. This can be useful for saving energy in + /// moments of silence. + /// + /// Note: Not all devices support suspending the stream at the hardware level. This method may + /// fail in these cases. + fn pause(&self) -> Result<(), PauseStreamError>; +} diff --git a/openless-all/app/src-tauri/vendor/cpal/tests/windows_lifecycle.rs b/openless-all/app/src-tauri/vendor/cpal/tests/windows_lifecycle.rs new file mode 100644 index 000000000..e132243b6 --- /dev/null +++ b/openless-all/app/src-tauri/vendor/cpal/tests/windows_lifecycle.rs @@ -0,0 +1,214 @@ +//! Each enumeration scenario runs in a fresh process so another test cannot +//! initialize COM/CPAL first and conceal a stale process-global enumerator. +#![cfg(target_os = "windows")] + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; +use windows::Win32::System::Com::{CoGetApartmentType, APTTYPE, APTTYPEQUALIFIER}; + +fn isolated(name: &str, scenario: impl FnOnce()) { + const CHILD: &str = "CPAL_OPENLESS_LIFECYCLE_CHILD"; + if std::env::var(CHILD).as_deref() == Ok(name) { + scenario(); + return; + } + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", name, "--nocapture", "--include-ignored"]) + .env(CHILD, name) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(45); + loop { + if let Some(status) = child.try_wait().unwrap() { + assert!(status.success(), "{name}: child exited with {status}"); + return; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("{name}: worker cleanup or COM call hung"); + } + thread::sleep(Duration::from_millis(20)); + } +} + +fn enumerate() { + let host = cpal::default_host(); + // Empty device lists are valid: obtaining the enumerator and collection is + // enough to exercise their COM apartment and destruction even in CI. + for device in host + .devices() + .expect("WASAPI enumeration must return normally") + { + let _ = device.name(); + } + if let Some(device) = host.default_input_device() { + let _ = device.name(); + } + if let Some(device) = host.default_output_device() { + let _ = device.name(); + } +} + +#[test] +fn enumeration_survives_first_thread_exit() { + isolated("enumeration_survives_first_thread_exit", || { + for _ in 0..32 { + thread::spawn(enumerate).join().unwrap(); + } + enumerate(); + }); +} + +#[test] +fn enumeration_is_independent_across_threads() { + isolated("enumeration_is_independent_across_threads", || { + // Initialize and destroy the first apartment before concurrent callers. + thread::spawn(enumerate).join().unwrap(); + let workers: Vec<_> = (0..8) + .map(|_| { + thread::spawn(|| { + for _ in 0..16 { + enumerate(); + } + }) + }) + .collect(); + for worker in workers { + worker.join().unwrap(); + } + }); +} + +#[test] +fn mmdevice_handles_cannot_cross_threads() { + // If a type implements Send/Sync both impls apply and inference is ambiguous, + // making this test fail to compile. Check the public dynamic wrappers too. + trait AmbiguousIfSend { + fn check() {} + } + impl AmbiguousIfSend<()> for T {} + impl AmbiguousIfSend for T {} + trait AmbiguousIfSync { + fn check() {} + } + impl AmbiguousIfSync<()> for T {} + impl AmbiguousIfSync for T {} + let _ = >::check; + let _ = >::check; + let _ = >::check; + let _ = >::check; +} + +fn input_stream( + device: &cpal::Device, + config: &cpal::StreamConfig, + panic_in_callback: bool, +) -> (cpal::Stream, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(); + let creator = thread::current().id(); + let stream = device + .build_input_stream::( + config, + move |_, _| { + let current = thread::current().id(); + assert_ne!(current, creator, "callback should run on its owning worker"); + let mut apartment = APTTYPE::default(); + let mut qualifier = APTTYPEQUALIFIER::default(); + unsafe { CoGetApartmentType(&mut apartment, &mut qualifier) } + .expect("the WASAPI callback worker must have COM initialized"); + let _ = tx.send(current); + if panic_in_callback { + panic!("intentional callback panic to verify stream cleanup"); + } + }, + |error| panic!("hardware stream error: {error}"), + None, + ) + .expect("hardware capture must initialize successfully"); + (stream, rx) +} + +fn capture_once(panic_in_callback: bool, explicit_pause: bool) { + let device = cpal::default_host() + .default_input_device() + .expect("this explicitly requested hardware test requires an input device"); + let supported = device.default_input_config().unwrap(); + let config = supported.config(); + let (stream, callbacks) = match supported.sample_format() { + cpal::SampleFormat::F32 => input_stream::(&device, &config, panic_in_callback), + cpal::SampleFormat::I16 => input_stream::(&device, &config, panic_in_callback), + cpal::SampleFormat::U16 => input_stream::(&device, &config, panic_in_callback), + other => panic!("hardware test needs a supported fixture format; got {other:?}"), + }; + stream.play().unwrap(); + callbacks + .recv_timeout(Duration::from_secs(5)) + .expect("capture callback did not arrive"); + if explicit_pause && !panic_in_callback { + stream.pause().unwrap(); + } + // Includes direct cancellation and a worker that may already have unwound. + // Destruction must join it, stop the client and release its services/events. + drop(stream); +} + +#[test] +#[ignore = "requires a real Windows input device and microphone permission; no-device is a failure"] +fn hardware_capture_stop_cancel_and_callback_panic() { + isolated("hardware_capture_stop_cancel_and_callback_panic", || { + for _ in 0..4 { + capture_once(false, true); + capture_once(false, false); + capture_once(true, false); + } + }); +} + +fn silent_output(device: &cpal::Device, config: &cpal::StreamConfig) { + let (tx, rx) = mpsc::channel(); + let stream = device + .build_output_stream::( + config, + move |samples, _| { + let mut apartment = APTTYPE::default(); + let mut qualifier = APTTYPEQUALIFIER::default(); + unsafe { CoGetApartmentType(&mut apartment, &mut qualifier) } + .expect("the WASAPI output worker must have COM initialized"); + samples.fill(T::EQUILIBRIUM); + let _ = tx.send(()); + }, + |error| panic!("hardware output error: {error}"), + None, + ) + .expect("hardware output must initialize successfully"); + stream.play().unwrap(); + rx.recv_timeout(Duration::from_secs(5)) + .expect("output callback did not arrive"); + drop(stream); +} + +#[test] +#[ignore = "requires a real Windows output device; no-device is a failure"] +fn hardware_output_repeated_start_stop() { + isolated("hardware_output_repeated_start_stop", || { + for _ in 0..8 { + let device = cpal::default_host() + .default_output_device() + .expect("this explicitly requested hardware test requires an output device"); + let supported = device.default_output_config().unwrap(); + let config = supported.config(); + match supported.sample_format() { + cpal::SampleFormat::F32 => silent_output::(&device, &config), + cpal::SampleFormat::I16 => silent_output::(&device, &config), + cpal::SampleFormat::U16 => silent_output::(&device, &config), + other => panic!("hardware test needs a supported fixture format; got {other:?}"), + } + } + }); +} From 99b05579fc0632908613f41b90624c9a508cb53f Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Sun, 25 Oct 2026 21:35:06 +0800 Subject: [PATCH 08/31] fix(sync): scope events to service origin --- docs/dependency-audit.md | 133 ------------ docs/index.md | 3 +- docs/linux-egui-handoff/02-gap-register.md | 2 +- docs/research/issue-1003.md | 197 ------------------ .../openless-core/src/cloud_sync_e2ee/dto.rs | 3 + .../src/cloud_sync_e2ee/service.rs | 6 + .../scripts/encrypted-sync-ui-bridge.test.mjs | 57 ++++- .../app/src/lib/encryptedSyncUiBridge.ts | 17 +- .../app/src/lib/ipc/cloud-sync-e2ee.test.ts | 2 + .../app/src/lib/ipc/cloud-sync-e2ee.ts | 2 + .../pages/settings/CloudSyncSection.test.ts | 4 + 11 files changed, 84 insertions(+), 342 deletions(-) delete mode 100644 docs/dependency-audit.md delete mode 100644 docs/research/issue-1003.md diff --git a/docs/dependency-audit.md b/docs/dependency-audit.md deleted file mode 100644 index d2dfc695b..000000000 --- a/docs/dependency-audit.md +++ /dev/null @@ -1,133 +0,0 @@ -# 依赖审计(全部工程) - -审计时间:2026-10-19 · HEAD `feat/linux-egui-followups` - -工程里有**四个独立的依赖图**,必须分别看(Rust 侧其实是**三个独立锁文件**: -根 workspace、`src-tauri`、`src-tauri/backend-tests`——三者都要单独 `cargo update`, -CI 一律用 `--locked`): - -| 依赖图 | 位置 | 谁在用 | -|---|---|---| -| A. 根 workspace | `openless-all/app/Cargo.toml`(成员:`crates/openless-core`、`linux-egui`、`harmony/rust`) | Linux 宿主、Core、鸿蒙 | -| B. Tauri workspace | `openless-all/app/src-tauri/`(独立 `Cargo.lock`,根 workspace 的 `exclude` 里有它) | Windows / macOS / Android 客户端 | -| B2. backend-tests workspace | `openless-all/app/src-tauri/backend-tests/`(又一个独立 `Cargo.lock`) | 远程输入 TLS 身份的集成测试 | -| C. npm | `openless-all/app/package.json` | React 前端 + 打包脚本 | -| D. Gradle/Kotlin | **不在仓库内**:由 `tauri android init` 生成,`scripts/copy-android-scaffolding.mjs` 只拷 Kotlin 源码并打补丁 | Android | - ---- - -## A. 根 workspace —— 已在最新 - -- `cargo update` 把 42 个包推到最新兼容版本;直接依赖全部等于 crates.io 最新稳定版 - (校验方式:逐个 `cargo info ` 对比)。 -- egui/eframe 已升到 **0.37.0**(此前钉死 0.36.2),Core 的 `sha2` 升到 0.11.1。 - -仍落后但**本仓无法再升**: - -| 包 | 当前 | 最新 | 卡在哪 | -|---|---|---|---| -| `base64` | 0.22.1 | 0.23.1 | axum 0.8.9 与 harmony 宿主依赖 0.22(本仓直接依赖已是 0.23.1) | -| `generic-array` | 0.14.7 | 0.14.9 | 被 crypto 依赖链的多条 `^0.14` 约束共同钉住 | -| `matchit` | 0.8.4 | 0.8.6 | axum 0.8.9 写死 `matchit = "=0.8.4"` | - -未取的预发布版(不建议):`libc 1.0.0-alpha.5`、`rustls 0.24.0-dev.2`。 - -## B. Tauri workspace —— 补丁级已升,主版本被 tauri 2.11 挡着 - -本轮已升(同一 train 的最新补丁):`tauri 2.11.6`、`tauri-build 2.6.3`、 -`tauri-runtime 2.11.3`、`tauri-runtime-wry 2.11.4`、`tauri-macros 2.6.3`、 -`tauri-utils 2.9.3`;并 `cargo update` 刷新该独立锁文件。 - -**被 tauri 版本钉住、升不动**(尝试过,cargo 解析直接失败): - -| 目标 | 需要 | 冲突 | -|---|---|---| -| tauri 2.12.4 / tauri-build 2.7.3 / tauri-macros 2.7.3 / tauri-codegen 2.7.3 / tauri-utils 2.10.2 / tauri-runtime(-wry) 2.12.2 | `tauri-utils ^2.10` | `Cargo.toml` 里整条 train 用 `=` 钉死,注释记录了 2.12.0 时代的编译失败(Monitor / UnexpectedMenuKind) | -| tauri-plugin-fs 2.7.1 / dialog 2.8.3 / shell 2.5.0 / updater 2.14.0 / single-instance 2.5.4 / autostart 2.7.0 | `tauri-utils ^2.10` | 同上:`failed to select a version for tauri-utils ... required by tauri-plugin-fs v2.7.1` | - -要越过这道墙,必须整体迁到 tauri 2.12 训练线并重测 Windows/macOS/Android 三端编译 -(本地无法验证:`src-tauri` 已不支持 Linux 目标,`cargo check` 在 Linux 上因 -`keyring` 等 target 依赖直接失败)。 - -**与 tauri 无关、可独立升级的主版本**(需要改代码 + 三端 CI 验证,本轮未动): - -axum 0.7→0.8、base64 0.22→0.23、bzip2 0.4→0.6、cpal 0.15→0.18、enigo 0.3→0.6、 -getrandom 0.3→0.4、global-hotkey 0.6→0.9、hmac 0.12→0.13、jni 0.21→0.22、 -keyring 3.6.3→4.2.0、md-5 0.10→0.11、minisign-verify 0.2→0.3、objc2 0.5→0.6 -(连带 `objc2-foundation`/`objc2-app-kit` 0.2→0.3、`block2` 0.5→0.6、 -`core-graphics` 0.24→0.25)、rcgen 0.13→0.14、reqwest 0.12→0.13、sha1 0.10→0.11、 -sha2 0.10→0.11、thiserror 1→2、tokio-tungstenite 0.24→0.30、windows 0.58→0.62 -(连带 `windows-core` 0.58→0.100)、winreg 0.52→0.56、window-vibrancy 0.7→0.8、 -zip 2→9、x509-parser 0.16→0.18、toml 0.8.2→0.8.23(传递依赖,锁文件层面)。 - -**刻意钉死、不建议动**: - -- `sherpa-onnx = 1.13.2`:Android arm64 用官方预编译库,版本必须与 - `scripts/sherpa-onnx-android.mjs` 校验的产物一致。 -- `security-framework = 3.7.0`、`tao 0.35`(由 tauri-runtime-wry 约束)、 - `foundry-local-sdk`(winml 特性与 Windows 运行时绑定)。 -- `tauri-nspanel`:来自 git 分支 `v2`,没有版本化发布。 - -## B2. 用 `=` 钉住的 15 处(钉住 ≠ 落后) - -`Cargo.lock` 本来就把整个图锁定;这里的「钉」指 `Cargo.toml` 里的 `=x.y.z` 写法, -它会让 `cargo update` 连补丁版本都不再前进。分三类看: - -**① 钉住且正好是最新(钉与不钉今天没区别,钉着防未来破坏性小版本)** - -| 包 | 钉住 | 最新 | -|---|---|---| -| egui / eframe | 0.37.0 | 0.37.0 | -| resvg | 0.48.1 | 0.48.1 | -| tauri | 2.11.6 | 2.11.6(2.x 稳定线) | -| tauri-build / tauri-macros | 2.6.3 | 2.6.3 | -| tauri-runtime | 2.11.3 | 2.11.3 | -| tauri-runtime-wry | 2.11.4 | 2.11.4 | -| tauri-utils | 2.9.3 | 2.9.3 | -| security-framework | 3.7.0 | 3.7.0 | - -**② 钉住且确实落后(有升级空间,但都受外部条件约束)** - -| 包 | 钉住 | 最新 | 约束 | -|---|---|---|---| -| tauri-plugin-updater | 2.10.1 | 2.14.0 | 需要 `tauri-utils ^2.10`,即 tauri 2.12 训练线 | -| sherpa-onnx | 1.13.8 | 1.13.8 | 已升级;Android 归档的 size + SHA-256 在 `scripts/sherpa-onnx-android.mjs` 里同步(46_093_321 / `2ff63469…4f44`) | -| foundry-local-sdk | 1.2.1 | 2.1.0 | Windows winml 绑定,主版本跨越大 | - -**③ 被上游依赖钉死,本仓改不了** —— 见上面 A / B 两节的表 -(`base64 0.22.1`、`generic-array 0.14.7`、`matchit 0.8.4`、tauri 2.12 全线)。 - -## C. npm —— 次版本已升,剩 11 个主版本 - -`npm update` 升了 13 个(`@base-ui/react`、`dompurify`、`i18next`、`lucide-react`、 -`react-i18next`、`shadcn`、`tailwind-merge`、`tailwindcss`/`@tailwindcss/vite`、 -`@tauri-apps/api`、`@tauri-apps/cli`、`@tauri-apps/plugin-*` 等)。 - -另外 4 个原本写成精确版本的开发工具也一起升了: -`@playwright/test 1.63.0 → 1.64.1`、`prettier 3.9.6 → 3.9.10`、 -`prettier-plugin-sh 0.19.0 → 0.20.2`、`tsx 4.23.1 → 4.23.15`。 - -`npm run build`(含 `tsc`)与 `node scripts/frontend-test-runner.mjs` 均通过。 - -仍为主版本的: - -| 包 | 当前 | 最新 | -|---|---|---| -| react / react-dom | 18.3.1 | 19.3.1 | -| @types/react / @types/react-dom | 18.x | 19.3.0 | -| vite | 6.4.4 | 8.3.4 | -| @vitejs/plugin-react | 4.7.0 | 6.1.2 | -| typescript | 5.9.3 | 7.0.2 | -| framer-motion | 12.43.0 | 14.4.0 | -| marked | 11.2.0 | 18.1.1 | -| js-yaml | 4.3.0 | 5.5.0 | -| @shadcn/react | 0.2.1 | 0.3.1 | - -React 19 / Vite 8 / TS 7 是一次前端大迁移,和 Linux egui 宿主无关,建议单独一轮做。 - -## D. Gradle/Kotlin - -仓库里没有 `build.gradle.kts`。Android 工程由 `tauri android init` 生成, -`scripts/copy-android-scaffolding.mjs` 只负责拷 Kotlin 源码、改生成出来的 -`build.gradle.kts`。因此 Gradle 依赖版本跟着 Tauri 的 Android 模板走 —— -只有升级 tauri 2.12(见 B)才会变化,本仓不能单独升。 diff --git a/docs/index.md b/docs/index.md index 81d1b6317..e4138add9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -45,9 +45,8 @@ - [macOS 实验性本地语音模型](experimental-local-asr.md):三模型、旧版下架、升级清理和隔离验收。 - [本地 ASR 生命周期](local-asr-lifecycle.md):下载、选择和推理状态的边界。 -## 依赖与发布记录 +## 发布记录 -- [依赖审计(全部工程)](dependency-audit.md):四个 workspace、npm、Gradle 的版本面与升级约束。 - [2.0.2-Beta.1 发布说明](releases/2.0.2-Beta.1-release-notes.md) - [2.0.1 发布说明](releases/2.0.1-release-notes.md) diff --git a/docs/linux-egui-handoff/02-gap-register.md b/docs/linux-egui-handoff/02-gap-register.md index 8c4e433a1..474dc38a3 100644 --- a/docs/linux-egui-handoff/02-gap-register.md +++ b/docs/linux-egui-handoff/02-gap-register.md @@ -33,6 +33,6 @@ - 目前不建议拆 Core:这些条件编译只覆盖少量输入格式/供应商差异,服务、策略和契约仍平台中立;Linux 的 DBus、录音、fcitx5、桌面窗口和远程输入适配器已经位于 `linux-egui`。 - 无边框窗口拖动的触摸屏限制来自 Wayland 拖动协议所需的指针按键 serial,winit 丢弃触摸 serial;升级 egui 不能改变该协议条件。桌面侧可用 KWin 的 **Alt+F7** 移动窗口(键盘移动后用触摸板/方向键定位);若当前会话是 X11,可由窗口管理器移动。此项目不在应用内伪造拖动实现。 - 词汇纠正卡片在 Linux 上**不产生数据源**(宿主未接 `EditObservationAdapter`),原因与补法见 [`linux-egui-known-gaps.md`](../linux-egui-known-gaps.md) 的 G01。 -- 依赖升级与 Vulkan 后端切换已落入源码;版本面与升级约束见 [`dependency-audit.md`](../dependency-audit.md)。设备图形路径仍需在真实 GPU/Wayland/X11 主机验收。 +- 依赖升级与 Vulkan 后端切换已落入源码;各独立 workspace 继续使用自己的 `Cargo.lock`,CI 统一以 `--locked` 验证。设备图形路径仍需在真实 GPU/Wayland/X11 主机验收。 记录格式:`ID / commit / 实现效果 / 自动证据 / 设备证据 / 剩余限制`。 diff --git a/docs/research/issue-1003.md b/docs/research/issue-1003.md deleted file mode 100644 index 2925ccbb2..000000000 --- a/docs/research/issue-1003.md +++ /dev/null @@ -1,197 +0,0 @@ -# Issue #1003 语音激活输入法研究报告 - -> 调研日期:2026-10-18 -> -> 研究范围:GitHub Issue #1003、PR #1219、PR 关联提交,以及 `beta` 与 `feat/issue-1003-voice-activation` 的源码。本文只记录可追溯的主源证据;没有把 PR 的声明当作本次本地运行结果。 - -## 1. 结论摘要 - -| 问题 | 结论 | 证据 | -| --- | --- | --- | -| 功能是否已经进入当前 `beta` | **没有。** Issue 仍 OPEN;PR #1219 仍 OPEN、Draft、`REVIEW_REQUIRED`、`BLOCKED`,没有 merge commit。当前 `beta` 是 `de072a88`,而 PR head 是 fork `HKLHaoBin/openless` 的 `9270b43c`。 | [Issue #1003](https://github.com/Open-Less/openless/issues/1003)、[PR #1219](https://github.com/Open-Less/openless/pull/1219) | -| 分支落后情况 | 以当前 `beta` `de072a88373a222280a8c22948092a260880196c` 和功能分支 `9270b43c7e79ad9d462ad8bec78250de5f499093` 比较:功能分支 **领先 8、落后 3**,两边分叉,合并基点为 `96278f40d48b8ed02e3ce22ddc96b694c863a6bc`。落后的 3 个提交是当前 beta 新增的 `af8adf7e`、`5ab6ed43`、`de072a88`。 | [PR base/head 元数据](https://github.com/Open-Less/openless/pull/1219)、[功能分支提交](https://github.com/HKLHaoBin/openless/commits/feat/issue-1003-voice-activation);本地 `git rev-list beta..feat/issue-1003-voice-activation` 与反向计数为 8/3 | -| 重启后持久化 `voiceActivationEnabled` 是否自启动 | **条件性地是。** `voice_activation_enabled` 以偏好字段持久化;应用第一次调用 `get_startup_snapshot` 时,Core `start()` 读取当前偏好并调用 `configure_voice_activation()`。模型已安装、权限可用且麦克风未占用时会进入等待唤醒;模型缺失、权限拒绝或麦克风忙时只会报告失败状态。 | [`config.rs:395-465`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/config.rs#L395-L465)、[`App.tsx:84-96`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src/App.tsx#L84-L96)、[`shared.ts:45-57`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src/lib/ipc/shared.ts#L45-L57)、[`api.rs:4530-4609`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/api.rs#L4530-L4609) | -| 连续监听是否有 VAD/CPU gate | **没有找到。** 空闲 warm capture 的每个 PCM chunk 都会直接交给 activation consumer;Sherpa online worker 对每个收到的 PCM 调用 `accept_pcm_chunk`。现有环形缓冲和录音队列是容量/线程安全机制,不是静音跳过或 CPU 门控。 | [`recorder.rs:594-645`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/recorder.rs#L594-L645)、[`sherpa_provider.rs:244-255`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/asr/local/sherpa_provider.rs#L244-L255)、[`sherpa_provider.rs:292-343`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/asr/local/sherpa_provider.rs#L292-L343) | -| 结束词带标点是否清理 | **已在功能分支实现。** NFKC、空白和终端/中英文标点会参与归一化;匹配仅接受文本后缀;从原始 transcript 尾部逆向剥离结束词时跳过标点和空白,保留正文本身的标点。测试覆盖 `完成,语音录入。`、全角句号、正文标点和中间出现结束词的反例。 | [`voice_activation.rs:49-115`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/voice_activation.rs#L49-L115)、[`voice_activation.rs:390-445`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/voice_activation.rs#L390-L445)、[`api.rs:2264-2321`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/api.rs#L2264-L2321) | - -**一句话判断:** PR 已完成“本地 Windows 语音唤醒/结束词”的主体链路以及结束词标点修复,但尚未合并到当前 beta;持续监听仍把静音 PCM 全部送入 Zipformer,CPU/VAD 问题是合并前必须明确验收或拆分处理的风险。 - -## 2. 主源与需求演进 - -### 2.1 原始需求 - -Issue 标题为“功能建议:用语音替代按键来激活输入法”。原始描述提出:说“开始语音录入”,录入正文,再说“完成语音录入”(或等待超时);使用较书面的词以降低日常口语误激活,并希望命令可自定义、识别尽可能本地化。[Issue #1003](https://github.com/Open-Less/openless/issues/1003) - -### 2.2 讨论中新增的约束 - -1. **按键替代场景。** 评论说明右 Ctrl/快捷键会干扰肌肉记忆,也有人用 ScrLock 或鼠标侧键折衷。[评论 5436896181](https://github.com/Open-Less/openless/issues/1003#issuecomment-5436896181)、[评论 5441322107](https://github.com/Open-Less/openless/issues/1003#issuecomment-5441322107) -2. **常驻监听的系统成本。** 评论明确提出全天开麦可能耗电、持续占用 CPU,并可能占用 Windows 麦克风导致其他应用难以使用。[评论 5596617615](https://github.com/Open-Less/openless/issues/1003#issuecomment-5596617615) -3. **本地模型引导。** 维护者在 2026-10-14 发布模型缺失引导及 Windows 测试步骤,要求模型未安装时给出跳转按钮,安装后重新配置/回到设置确认进入等待唤醒。[评论 6170844277](https://github.com/Open-Less/openless/issues/1003#issuecomment-6170844277) -4. **结束词的 ASR 断句。** 用户报告“完成,语音录入。”会被混入正文;随后 PR 提交 `37e4665d` 宣称修复该问题并增加回归测试。[评论 6216314465](https://github.com/Open-Less/openless/issues/1003#issuecomment-6216314465)、[提交 37e4665d](https://github.com/HKLHaoBin/openless/commit/37e4665d78a39011431401393264002a4879c197) -5. **VAD/CPU 争议。** 用户建议无人在说话时不要把音频交给 Zipformer;维护者先认为额外人声模型可能增加 CPU,之后在讨论中接受这是应解决的优化,并表示可另行拆分以保持 PR 可维护。[评论 6218184776](https://github.com/Open-Less/openless/issues/1003#issuecomment-6218184776)、[评论 6219721968](https://github.com/Open-Less/openless/issues/1003#issuecomment-6219721968)、[评论 6221936954](https://github.com/Open-Less/openless/issues/1003#issuecomment-6221936954)、[评论 6231986920](https://github.com/Open-Less/openless/issues/1003#issuecomment-6231986920)、[评论 6236232646](https://github.com/Open-Less/openless/issues/1003#issuecomment-6236232646) - -### 2.3 PR 及修复提交序列 - -- `b0196a9e`:加入可配置 voice activation 的主体实现。[提交 b0196a9e](https://github.com/HKLHaoBin/openless/commit/b0196a9ed9bb1358c4d4bb1fb9b7cbf8ca4c66b3) -- `65afa352`:补充模型缺失引导。[提交 65afa352](https://github.com/HKLHaoBin/openless/commit/65afa3521be38cf1c143f3ddff1635fc18fa99d3) -- `37e4665d`:容忍结束词中间插入标点,并清理 transcript。[提交 37e4665d](https://github.com/HKLHaoBin/openless/commit/37e4665d78a39011431401393264002a4879c197) -- `d66c07c4`、`3e56d3a3`:功能分支合入当时的 beta;提交信息显示合并时曾处理多处冲突。[提交 d66c07c4](https://github.com/HKLHaoBin/openless/commit/d66c07c4f4a60d6fb5af4acf14aef3c94b2676cd)、[提交 3e56d3a3](https://github.com/HKLHaoBin/openless/commit/3e56d3a3c214a474cff10df34810c18660c6bf46) -- `9270b43c`:更新 warm capture 测试以适配 preroll 标志,当前 PR head。[提交 9270b43c](https://github.com/HKLHaoBin/openless/commit/9270b43c7e79ad9d462ad8bec78250de5f499093) - -PR #1219 的 body 写明 `Closes #1003`,并列出 Core 测试、格式检查、Bugbot 复审和三个 Actions 运行,但 PR 状态本身仍是 Draft/待 Review。[PR #1219](https://github.com/Open-Less/openless/pull/1219) - -## 3. 当前 beta 与 PR 状态 - -### 3.1 合并状态 - -截至调研日期,GitHub PR 元数据为: - -- base:`Open-Less/openless:beta`,PR 记录的 base SHA 为 `96278f40`; -- head:`HKLHaoBin/openless:feat/issue-1003-voice-activation`,head SHA 为 `9270b43c`; -- `state: OPEN`、`isDraft: true`、`reviewDecision: REVIEW_REQUIRED`、`mergeStateStatus: BLOCKED`; -- `mergeCommit: null`、`mergedAt: null`。 - -这些字段来自 [PR #1219](https://github.com/Open-Less/openless/pull/1219)。因此,功能分支曾把 beta 合入自身,**不能反推 beta 已经合入功能分支**;当前 beta 仍未包含该 PR。当前 beta 的本地 ref 为 `de072a88`,且功能新增文件 `openless-all/app/crates/openless-core/src/voice_activation.rs` 在当前 beta 中不存在。 - -### 3.2 落后/领先关系 - -以本地已存在的两个 ref 做纯 Git 图比较: - -```text -beta = de072a88373a222280a8c22948092a260880196c -feature = 9270b43c7e79ad9d462ad8bec78250de5f499093 -feature ahead of beta = 8 -feature behind beta = 3 -merge-base = 96278f40d48b8ed02e3ce22ddc96b694c863a6bc -``` - -功能分支落后的三个提交分别是: - -- `af8adf7e fix(dictation): keep local startup stoppable and bound inference resources`; -- `5ab6ed43 feat(capsule): add persisted multi-display modes and prepare 2.0.2 Beta.1`; -- `de072a88 docs(release): verified 2.0.2 Beta.1 publication evidence [skip ci]`。 - -PR 的 base/head 和当前分支提交可在 [PR #1219](https://github.com/Open-Less/openless/pull/1219) 与 [功能分支提交列表](https://github.com/HKLHaoBin/openless/commits/feat/issue-1003-voice-activation) 复核。合并前应以当前 beta 重新合并/变基并重新解决冲突,不能直接使用 PR 创建时的 base 作为最新基线。 - -### 3.3 检查状态 - -PR 当前列出的检查均为通过,包括 Android cargo check、Linux Core/egui、Windows checks、macOS checks、macOS Rust 1.88 MSRV、UI motion 和 Changed areas。[PR checks](https://github.com/Open-Less/openless/pull/1219/checks) - -这些是 CI/PR 的已有证据,不是本次调研重新运行的结果;本次没有修改业务代码、没有运行构建,也没有宣称已经在 Windows 实机复现。 - -## 4. 代码影响范围 - -PR 相对其 base 的 GitHub 统计为 **41 个文件、+3227/-201**。[PR #1219 changed files](https://github.com/Open-Less/openless/pull/1219/files) - -| 层次 | 主要影响 | 关键源码证据 | -| --- | --- | --- | -| Core policy/state | 新增 phrase matcher、生命周期/代际、超时、状态信号、端口;`OpenLessBackend` 增加配置、唤醒 handoff、结束和恢复。 | [`voice_activation.rs`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/voice_activation.rs)、[`api.rs:3093-3285`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/api.rs#L3093-L3285)、[`ports.rs`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/ports.rs) | -| 偏好/持久化 | 增加启用、唤醒/结束开关、两条 phrase、超时和麦克风字段,并保留旧配置默认值。 | [`config.rs:395-465`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/config.rs#L395-L465)、[`config.rs:978-1028`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/config.rs#L978-L1028) | -| Windows host/音频 | Tauri 运行时加载 Zipformer、创建在线 Sherpa session、接入 warm capture、处理 mic/permission/model 错误和 generation。 | [`core_adapters.rs:1492-1851`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/core_adapters.rs#L1492-L1851)、[`recorder.rs:332-672`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/recorder.rs#L332-L672) | -| 本地 ASR | Sherpa provider/runtime 支持在线 chunk、token callback 和结束 flush。 | [`sherpa_provider.rs:78-255`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/asr/local/sherpa_provider.rs#L78-L255)、[`sherpa_provider.rs:274-343`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/asr/local/sherpa_provider.rs#L274-L343) | -| UI/IPC/本地化 | Recording Input 增加 Windows-only voice activation 设置、状态和模型缺失跳转;同步 mock/types/i18n。 | [`RecordingInputSection.tsx:146-200`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src/pages/settings/RecordingInputSection.tsx#L146-L200)、[`RecordingInputSection.tsx:590-660`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src/pages/settings/RecordingInputSection.tsx#L590-L660)、[`voiceActivation.ts:1-30`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src/lib/voiceActivation.ts#L1-L30) | -| 测试/构建面 | Core phrase/lifecycle 测试、UI model-missing 测试、host contract、workflow 依赖变更。 | [`voice_activation.rs:390-503`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/voice_activation.rs#L390-L503)、[`voiceActivation.test.ts`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src/lib/voiceActivation.test.ts) | - -## 5. 已实现能力与行为链路 - -### 5.1 持久化与启动 - -`UserPreferences` 将 `voice_activation_enabled` 及相关字段纳入 camelCase wire contract,并给新增字段提供默认值。[`config.rs:395-465`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/config.rs#L395-L465) - -启动链路是: - -```text -App.tsx useEffect - -> getStartupSnapshot() - -> invoke('get_startup_snapshot') - -> commands::get_startup_snapshot() - -> core.start() - -> get_preferences() - -> configure_voice_activation() - -> model check / Sherpa online listener / ListeningForWake -``` - -前端启动握手见 [`App.tsx:84-96`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src/App.tsx#L84-L96) 与 [`shared.ts:45-57`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src/lib/ipc/shared.ts#L45-L57);命令和 Core 的调用见 [`commands/dictation.rs:3-8`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/commands/dictation.rs#L3-L8)、[`api.rs:4530-4609`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/api.rs#L4530-L4609)。因此,重启后的“自启动”不是偏好字段自身执行,而是应用首个 startup handshake 触发;若模型/权限/麦克风条件不满足,启动会发出失败状态而不会进入监听。 - -设置保存时,启用状态、phrase、超时和麦克风字段的变化会再次调用 `configure_voice_activation()`。[`commands/settings.rs:237-265`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/commands/settings.rs#L237-L265)、[`commands/settings.rs:284-317`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/commands/settings.rs#L284-L317) - -### 5.2 唤醒、交接、超时和恢复 - -Windows runtime 检查固定的 `zipformer-bilingual-zh-en-streaming`,模型存在后创建在线 Sherpa ASR,并把 token callback 接到 `VoiceActivationEmitter`。[`core_adapters.rs:1701-1765`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/core_adapters.rs#L1701-L1765) - -唤醒匹配后,Core 通过 generation 校验、warm-capture handoff 建立普通 dictation session,再保存 `VoiceActivationSession`;结束词或超时调用 `finish_voice_activation`,结束后按启用偏好重新配置监听。[`api.rs:3292-3528`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/api.rs#L3292-L3528)、[`api.rs:3565-3619`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/api.rs#L3565-L3619)。配置注释和停止旧 handle 的单麦克风约束见 [`api.rs:3093-3125`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/api.rs#L3093-L3125)。 - -### 5.3 结束词标点清理 - -实现分两层: - -1. `normalize_phrase` 用 NFKC、去空白和标点处理 ASR phrase;`VoicePhraseMatcher` 只认文本后缀,避免正文中间出现 phrase 就误停。[`voice_activation.rs:49-60`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/voice_activation.rs#L49-L60)、[`voice_activation.rs:201-229`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/voice_activation.rs#L201-L229) -2. `strip_phrase_suffix` 从原始 transcript 尾部反向扫描,跳过空白/中英文终端标点,仅切掉结束词;Core 在每个 `TranscriptDelta` 累积 raw transcript 后,将清理后的文字写回 state 并发布实时事件。[`voice_activation.rs:62-115`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/voice_activation.rs#L62-L115)、[`api.rs:2264-2321`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/api.rs#L2264-L2321) - -结束录音后,完成路径还会再次对 `engine_result.raw_text`、`engine_result.polished_text` 和可选 `engine_result.asr_transcript` 调用同一 suffix cleaner,再构造 `DictationResult`;对应实现见 [`api.rs:7291-7317`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/api.rs#L7291-L7317)。这些清理后的字段随后用于完成事件和历史持久化,历史写入字段见 [`api.rs:7470-7479`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/api.rs#L7470-L7479)、[`api.rs:7665-7752`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/api.rs#L7665-L7752)。该结论仍应通过 Windows 实机验证,因为不同 ASR 的 delta/revision 时序不等于单元测试输入。 - -### 5.4 模型缺失引导 - -runtime 在配置时检查模型是否安装,缺失返回 `ModelMissing`;UI 监听 `voice-activation:state-changed`,只对精确的模型缺失错误显示跳转模型页,避免把权限拒绝或麦克风占用误导成下载问题。[`core_adapters.rs:1718-1734`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/core_adapters.rs#L1718-L1734)、[`RecordingInputSection.tsx:146-200`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src/pages/settings/RecordingInputSection.tsx#L146-L200)、[`voiceActivation.ts:21-30`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src/lib/voiceActivation.ts#L21-L30) - -## 6. 可复现缺口与风险 - -### 6.1 明确缺口:连续监听没有 VAD/CPU gate - -代码路径是无条件转发: - -- `WarmCaptureSink::consume_pcm_chunk` 先写 ring,然后在满足 activation 条件时直接 `activation.consume_pcm_chunk(pcm)`,没有能量阈值、VAD 状态或静音短路。[`recorder.rs:594-645`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/recorder.rs#L594-L645) -- `SherpaOnnxAsr::consume_pcm_chunk` 对 online worker 直接 `send_pcm(pcm)`;worker 收到每个 `Pcm` 后立即调用 `session.accept_pcm_chunk(&pcm, &emit)`。[`sherpa_provider.rs:244-255`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/asr/local/sherpa_provider.rs#L244-L255)、[`sherpa_provider.rs:292-310`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/asr/local/sherpa_provider.rs#L292-L310) -- `send_pcm` 使用标准无界 `mpsc::channel`,只检查空 chunk/取消,不检查音量或丢弃静音。[`sherpa_provider.rs:274-343`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/asr/local/sherpa_provider.rs#L274-L343) - -**复现方法:** Windows 安装并准备 Zipformer,开启语音激活,在安静房间保持 60 秒不说话;记录进程 CPU、Sherpa worker 消费量和 microphone 独占状态,再以相同条件说唤醒词。预期若有 gate,静音段应显著减少 inference;当前静态路径预期仍逐块送入模型。该实验尚未由本次调研运行。 - -### 6.2 模型下载后自动恢复未形成闭环的风险 - -当前明确调用 `configure_voice_activation()` 的位置是 startup、偏好保存、warm fallback 释放和运行时失败恢复;本地模型下载命令本身只调用 local-ASR service 的 `start_download`。[`api.rs:4530-4609`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/api.rs#L4530-L4609)、[`commands/settings.rs:284-317`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/commands/settings.rs#L284-L317)、[`commands/local_asr.rs:329-344`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/commands/local_asr.rs#L329-L344)。因此“下载完成后当前 listener 自动恢复”没有直接源码证据;Issue 的测试说明也要求返回录音设置或重新配置。[评论 6170844277](https://github.com/Open-Less/openless/issues/1003#issuecomment-6170844277) - -这不一定是已确认 bug:下载流程可能由前端刷新或用户切换设置间接触发,需实机确认。若产品要求无刷新自动恢复,应添加下载完成事件到 voice activation reconfigure 的显式连接及回归测试。 - -### 6.3 麦克风占用、权限和模型缺失是启动条件,不是静默降级 - -配置 runtime 会检查模型;arm warm capture 失败时映射为权限拒绝、无输入设备、mic busy 或 runtime failure。[`core_adapters.rs:1498-1509`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/core_adapters.rs#L1498-L1509)、[`core_adapters.rs:1723-1795`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/core_adapters.rs#L1723-L1795)。应验证失败后热键录音仍可正常工作,且其他应用占用麦克风时 UI 不显示错误的“下载模型”建议;现有 UI 的错误分类测试只覆盖状态判别,未覆盖 Windows 设备竞争。[`voiceActivation.test.ts:12-42`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src/lib/voiceActivation.test.ts#L12-L42) - -### 6.4 分支基线漂移与合并风险 - -功能分支已通过两个 merge commit 合入旧 beta,但当前 beta 又前进 3 个提交;且 PR 仍 blocked/review required。合并前必须用当前 beta 重新整合并复跑全部桌面、Android、Core/UI 检查,尤其关注 PR 曾经修改的 `api.rs`、`core_adapters.rs`、`recorder.rs`、settings UI 和 workflow 文件。[PR #1219](https://github.com/Open-Less/openless/pull/1219)、[PR changed files](https://github.com/Open-Less/openless/pull/1219/files) - -## 7. 下一步实现建议 - -1. **先同步当前 beta,再决定 PR 边界。** 以 `de072a88` 为基线整合功能,解决冲突后重新跑 PR 全套检查;不要把“功能分支合入旧 beta”当作“beta 已合并”。 -2. **为静音成本设定可测验收。** 在不改变唤醒召回率的前提下加入轻量、可解释的能量/频域 gate 或 sherpa 自带 VAD(若评估后成本可接受);记录静音/有人声两类 CPU、延迟、漏唤醒和误唤醒指标。Issue 已有 VAD 建议和后续接受该优化方向的讨论。[评论 6218184776](https://github.com/Open-Less/openless/issues/1003#issuecomment-6218184776)、[评论 6236232646](https://github.com/Open-Less/openless/issues/1003#issuecomment-6236232646) -3. **避免无界积压。** 目前在线 PCM 使用无界 `mpsc::channel`;若推理速度低于采集速度,建议增加有界队列、明确丢帧/降采样策略和故障遥测,再用长时间静音及低性能 Windows 机器验证。[`sherpa_provider.rs:274-343`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/src-tauri/src/asr/local/sherpa_provider.rs#L274-L343) -4. **闭合模型生命周期。** 模型下载成功后应触发一次明确的 voice activation reconfigure,或在 UI 中明确要求重新配置;覆盖“启用已保存 + 模型缺失 → 下载 → 无需重启即监听”的测试。 -5. **补充真实设备验收。** 覆盖重启自动启动、权限拒绝、麦克风被其他应用占用、模型缺失/下载、热键与语音入口互斥、结束词断句、超时后自动恢复监听。 -6. **把结束词清理保持在 raw/source 层验证。** 继续保留正文自身标点,测试普通逗号、全角标点、空格/换行、ASR revision、结束词出现在正文中间等组合,并核对实时 transcript、最终插入、history raw/asr/final 三套字段。[`voice_activation.rs:394-445`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/voice_activation.rs#L394-L445)、[`api.rs:2264-2321`](https://github.com/HKLHaoBin/openless/blob/9270b43c7e79ad9d462ad8bec78250de5f499093/openless-all/app/crates/openless-core/src/api.rs#L2264-L2321) - -## 8. 验证清单 - -### 8.1 静态/CI - -- [x] `gh issue view 1003`:Issue 状态、原始需求和评论已核对。[Issue #1003](https://github.com/Open-Less/openless/issues/1003) -- [x] `gh pr view 1219`:base/head、Draft、Review、merge 字段已核对。[PR #1219](https://github.com/Open-Less/openless/pull/1219) -- [x] Git 图:当前 beta `de072a88` 与 head `9270b43c` 的 ahead/behind 为 8/3。 -- [x] PR 已有检查记录均为 pass。[PR checks](https://github.com/Open-Less/openless/pull/1219/checks) -- [x] 静态阅读确认启动、持久化、标点清理和每块 PCM 转发路径。 -- [ ] 以当前 beta 重新整合功能分支并重跑 CI(本次调研未执行)。 -- [ ] 运行 `cargo test -p openless-core`、voice activation 定向测试、`cargo fmt --all -- --check`(本次调研未执行;PR body 声称曾通过)。 - -### 8.2 Windows 实机 - -- [ ] 在设置 → 录音输入启用语音激活,确认安装模型后状态为等待唤醒。 -- [ ] 关闭并重启应用,确认已持久化的 `voiceActivationEnabled` 在 startup handshake 后重新进入等待唤醒;分别测试模型存在、模型缺失、权限拒绝、麦克风忙。 -- [ ] 说“开始语音录入”→正文→“完成语音录入”,确认唤醒词/结束词不进入正文。 -- [ ] 重复测试“完成,语音录入。”、“完成语音录入。”、句末全角标点、词间空格/换行;确认正文原有标点保留。 -- [ ] 语音激活开启后保持 60 秒静音,记录 CPU、内存、Sherpa inference 调用/队列量;与有人声的同长度样本比较,确认是否存在 VAD/CPU gate。 -- [ ] 下载模型后不重启,仅按产品预期操作(返回设置或等待事件),确认 listener 是否自动恢复;若不能,记录为模型生命周期缺口。 -- [ ] 连续完成两轮唤醒/正文/结束,验证结束后 listener 自动恢复;超时也应恢复。 -- [ ] 其他应用占用麦克风时,错误应归类为 mic busy/permission,不应显示错误的模型下载按钮;关闭语音激活后原热键入口仍可用。 - -## 9. 证据边界 - -本报告的“已实现”结论来自功能分支 `9270b43c` 的源码和测试;“当前 beta 未合并”来自 GitHub PR 状态、base/head 元数据和本地 Git 图;“VAD/CPU gate 缺失”是对上述 PCM 调用链的静态审计结论,不是 CPU profiler 结果。Issue 评论中的 Windows 测试包和 Actions 链接属于第一方项目记录,但本次没有下载或运行该产物。 diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/dto.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/dto.rs index 30ea132ca..37bb5224c 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/dto.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/dto.rs @@ -187,6 +187,7 @@ pub struct RestorePreview { #[serde(rename_all = "camelCase")] pub struct EncryptedSyncEvent { pub sequence: String, + pub service_origin: String, pub account_id: Option, pub vault_id: Option, pub task_id: Option, @@ -197,6 +198,7 @@ pub struct EncryptedSyncEvent { #[serde(rename_all = "camelCase")] pub struct EncryptedSyncConflictEvent { pub sequence: String, + pub service_origin: String, pub account_id: String, pub vault_id: String, pub task_id: Option, @@ -207,6 +209,7 @@ pub struct EncryptedSyncConflictEvent { #[serde(rename_all = "camelCase")] pub struct EncryptedSyncRestoreEvent { pub sequence: String, + pub service_origin: String, pub account_id: String, pub vault_id: String, pub task_id: Option, diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs index 3b03b4a69..8945fb080 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs @@ -245,9 +245,11 @@ impl EncryptedSyncService { } fn update(&self, update: impl FnOnce(&mut EncryptedSyncStatus)) { + let service_origin = self.remote_origin(); let status = { let mut status = self.0.status.lock().unwrap_or_else(|e| e.into_inner()); update(&mut status); + status.service_origin = service_origin; status.sequence = self .0 .sequence @@ -260,6 +262,7 @@ impl EncryptedSyncService { None, BackendEventKind::CloudSyncStateChanged(EncryptedSyncEvent { sequence: status.sequence.clone(), + service_origin: status.service_origin.clone(), account_id: status.account.as_ref().map(|v| v.github_id.clone()), vault_id: status.vault_id.clone(), task_id: status.task_id.clone(), @@ -1739,6 +1742,7 @@ impl EncryptedSyncService { .fetch_add(1, Ordering::AcqRel) .saturating_add(1) .to_string(), + service_origin: scope.service_origin.clone(), account_id: scope.owner_github_id, vault_id: scope.vault_id, task_id: self.status().task_id, @@ -1879,6 +1883,7 @@ impl EncryptedSyncService { .fetch_add(1, Ordering::AcqRel) .saturating_add(1) .to_string(), + service_origin: scope.service_origin.clone(), account_id: scope.owner_github_id, vault_id: scope.vault_id, task_id: self.status().task_id, @@ -2590,6 +2595,7 @@ impl EncryptedSyncService { .fetch_add(1, Ordering::AcqRel) .saturating_add(1) .to_string(), + service_origin: scope.service_origin.clone(), account_id: scope.owner_github_id.clone(), vault_id: scope.vault_id.clone(), task_id: self.status().task_id, diff --git a/openless-all/app/scripts/encrypted-sync-ui-bridge.test.mjs b/openless-all/app/scripts/encrypted-sync-ui-bridge.test.mjs index 94ddd8ac5..9d9a7a126 100644 --- a/openless-all/app/scripts/encrypted-sync-ui-bridge.test.mjs +++ b/openless-all/app/scripts/encrypted-sync-ui-bridge.test.mjs @@ -35,6 +35,7 @@ function context({ fresh = false, tauriInitially = true } = {}) { let preferences = fresh ? null : { locale, fontScale }; let status = { sequence: '0', + serviceOrigin: 'https://sync.example', account: fresh ? null : { githubId: '1' }, vaultId: fresh ? null : 'v1', consentVersion: fresh ? null : 'yes', @@ -129,6 +130,7 @@ function context({ fresh = false, tauriInitially = true } = {}) { handlers.get('cloud-sync-e2ee:restored')({ payload: { sequence, + serviceOrigin: status.serviceOrigin, accountId: status.account.githubId, vaultId: status.vaultId, taskId: 'restore', @@ -177,7 +179,13 @@ await test('old mirror queued before restore cannot overwrite its native value', c.externalWrite({ locale: 'fr' }); c.restore(); await tick(); - wait.resolve({ sequence: '2', account: { githubId: '1' }, vaultId: 'v1', consentVersion: 'yes' }); + wait.resolve({ + sequence: '2', + serviceOrigin: 'https://sync.example', + account: { githubId: '1' }, + vaultId: 'v1', + consentVersion: 'yes', + }); await drain(); assert.equal(c.snapshot().preferences.locale, 'fr'); assert.equal(c.snapshot().locale, 'fr'); @@ -191,7 +199,13 @@ await test('new choice after restore notification wins only its chosen field', a c.restore(); await tick(); await c.userLocale('de'); - wait.resolve({ sequence: '2', account: { githubId: '1' }, vaultId: 'v1', consentVersion: 'yes' }); + wait.resolve({ + sequence: '2', + serviceOrigin: 'https://sync.example', + account: { githubId: '1' }, + vaultId: 'v1', + consentVersion: 'yes', + }); await drain(); assert.deepEqual(c.snapshot().preferences, { locale: 'de', fontScale: 'large' }); assert.equal(c.snapshot().locale, 'de'); @@ -259,7 +273,13 @@ await test('an equal-value restore invalidates an older unsent user choice', asy await tick(); c.externalWrite({ locale: 'en' }); c.restore(); - wait.resolve({ sequence: '2', account: { githubId: '1' }, vaultId: 'v1', consentVersion: 'yes' }); + wait.resolve({ + sequence: '2', + serviceOrigin: 'https://sync.example', + account: { githubId: '1' }, + vaultId: 'v1', + consentVersion: 'yes', + }); await drain(); assert.equal(c.snapshot().preferences.locale, 'en'); await c.userLocale('fr'); @@ -286,7 +306,12 @@ await test('a late old-vault event cannot discard a new-account user choice', as await c.userLocale('de'); await tick(); c.restore('99', { accountId: '1', vaultId: 'v1' }); - wait.resolve({ account: { githubId: '2' }, vaultId: 'v2', consentVersion: 'yes' }); + wait.resolve({ + serviceOrigin: 'https://sync.example', + account: { githubId: '2' }, + vaultId: 'v2', + consentVersion: 'yes', + }); await drain(); assert.equal(c.snapshot().preferences.locale, 'de'); c.externalWrite({ locale: 'fr' }); @@ -294,6 +319,16 @@ await test('a late old-vault event cannot discard a new-account user choice', as await drain(); assert.equal(c.snapshot().locale, 'fr'); }); +await test('a late event from an old sync origin cannot mutate the current mirror', async () => { + const c = context(); + await c.install(); + c.setStatus({ serviceOrigin: 'https://new.example' }); + c.externalWrite({ locale: 'fr' }); + c.restore('99', { serviceOrigin: 'https://old.example' }); + await drain(); + assert.equal(c.snapshot().locale, 'en'); + assert.equal(c.snapshot().preferences.locale, 'fr'); +}); await test('a failed old-scope notification read retains the newer local choice', async () => { const c = context(); await c.install(); @@ -304,7 +339,12 @@ await test('a failed old-scope notification read retains the newer local choice' await c.userLocale('de'); await tick(); c.restore('99', { accountId: '1', vaultId: 'v1' }); - userStatus.resolve({ account: { githubId: '2' }, vaultId: 'v2', consentVersion: 'yes' }); + userStatus.resolve({ + serviceOrigin: 'https://sync.example', + account: { githubId: '2' }, + vaultId: 'v2', + consentVersion: 'yes', + }); await tick(); restoreStatus.reject(new Error('transient native status failure')); await drain(); @@ -322,7 +362,12 @@ await test('overlapping valid and old-scope notifications preserve a later field await tick(); await c.userLocale('de'); c.restore('99', { accountId: '1', vaultId: 'v1' }); - wait.resolve({ account: { githubId: '2' }, vaultId: 'v2', consentVersion: 'yes' }); + wait.resolve({ + serviceOrigin: 'https://sync.example', + account: { githubId: '2' }, + vaultId: 'v2', + consentVersion: 'yes', + }); await drain(); assert.deepEqual( c.snapshot().preferences, diff --git a/openless-all/app/src/lib/encryptedSyncUiBridge.ts b/openless-all/app/src/lib/encryptedSyncUiBridge.ts index e00ebf168..9b22f2a3f 100644 --- a/openless-all/app/src/lib/encryptedSyncUiBridge.ts +++ b/openless-all/app/src/lib/encryptedSyncUiBridge.ts @@ -13,12 +13,19 @@ type UiPreferences = { locale?: string; fontScale?: string }; type UiSnapshot = { preferences: UiPreferences | null; revision: string | null }; type UiKey = 'locale' | 'fontScale'; type Status = { + serviceOrigin: string; sequence: string; account: { githubId: string } | null; vaultId: string | null; consentVersion: string | null; }; -type Restored = { sequence: string; accountId: string; vaultId: string; taskId: string | null }; +type Restored = { + sequence: string; + serviceOrigin: string; + accountId: string; + vaultId: string; + taskId: string | null; +}; let installation: Promise | null = null; let flushBridge: (() => Promise) | null = null; @@ -50,9 +57,13 @@ async function install(): Promise { const readUi = () => invokeOrMock('cloud_sync_e2ee_get_ui_preferences_snapshot', undefined, unavailable); const sameScope = (a: Status, b: Status) => - a.account?.githubId === b.account?.githubId && a.vaultId === b.vaultId; + a.serviceOrigin === b.serviceOrigin && + a.account?.githubId === b.account?.githubId && + a.vaultId === b.vaultId; const belongs = (state: Status, event: Restored) => - state.account?.githubId === event.accountId && state.vaultId === event.vaultId; + state.serviceOrigin === event.serviceOrigin && + state.account?.githubId === event.accountId && + state.vaultId === event.vaultId; const failed = (error: unknown) => window.dispatchEvent(new CustomEvent('openless:sync-ui-persistence-failed', { detail: error })); const enqueue = (work: () => Promise): Promise => { diff --git a/openless-all/app/src/lib/ipc/cloud-sync-e2ee.test.ts b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.test.ts index 1e661457f..94413da7d 100644 --- a/openless-all/app/src/lib/ipc/cloud-sync-e2ee.test.ts +++ b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.test.ts @@ -109,6 +109,7 @@ try { } as EncryptedSyncStatus; const event = { sequence: '9007199254740994', + serviceOrigin: 'https://sync.example', accountId: 'owner', vaultId: 'vault', taskId: 'task', @@ -119,6 +120,7 @@ try { { ...event, sequence: '01' }, { ...event, sequence: '18446744073709551616' }, { ...event, accountId: 'other' }, + { ...event, serviceOrigin: 'https://other.example' }, { ...event, vaultId: 'other' }, { ...event, taskId: 'old-task' }, ]) diff --git a/openless-all/app/src/lib/ipc/cloud-sync-e2ee.ts b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.ts index 44a34e737..0f346d0cc 100644 --- a/openless-all/app/src/lib/ipc/cloud-sync-e2ee.ts +++ b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.ts @@ -58,6 +58,7 @@ export interface RestorePreview { export interface EncryptedSyncEventScope { sequence: string; + serviceOrigin: string; accountId: string | null; vaultId: string | null; taskId: string | null; @@ -171,6 +172,7 @@ export function matchesEncryptedSyncEvent( incoming !== null && previous !== null && incoming > previous && + event.serviceOrigin === status.serviceOrigin && event.accountId === (status.account?.githubId ?? null) && event.vaultId === status.vaultId && (!requireTask || event.taskId === status.taskId) diff --git a/openless-all/app/src/pages/settings/CloudSyncSection.test.ts b/openless-all/app/src/pages/settings/CloudSyncSection.test.ts index 5547506e7..17d4f7ec0 100644 --- a/openless-all/app/src/pages/settings/CloudSyncSection.test.ts +++ b/openless-all/app/src/pages/settings/CloudSyncSection.test.ts @@ -113,6 +113,7 @@ const initial: EncryptedSyncStatus = { syncState: 'disabled', account: { githubId: 'owner', login: 'fixture' }, vaultId: 'vault', + serviceOrigin: 'https://sync.example', keyId: 'key', localGeneration: '1', lastSyncedLocalGeneration: null, @@ -438,6 +439,7 @@ try { payload: { sequence: newer.sequence, accountId: 'owner', + serviceOrigin: 'https://sync.example', vaultId: 'vault', taskId: null, status: newer, @@ -452,6 +454,7 @@ try { payload: { sequence: (BigInt(newer.sequence) + 1n).toString(), accountId: 'owner', + serviceOrigin: 'https://sync.example', vaultId: 'vault', taskId: 'old-task', preview, @@ -469,6 +472,7 @@ try { payload: { sequence: pending.sequence, accountId: 'owner', + serviceOrigin: 'https://sync.example', vaultId: 'vault', taskId: null, status: pending, From f71ffd5482ee1ba2c85315418eb41be68284576e Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Sun, 25 Oct 2026 22:04:05 +0800 Subject: [PATCH 09/31] feat(sync): sync portable preferences and enable cursor context --- docs/android-ime.md | 2 +- docs/cloud-sync-server-handoff.md | 2 + docs/cloud-sync.md | 2 + docs/encrypted-cloud-sync.md | 2 +- .../kotlin/OpenLessAndroidPreferences.kt | 2 +- .../crates/openless-core/src/cloud_sync.rs | 68 ++++++++++++++++++- .../src/cloud_sync_e2ee_documents/registry.rs | 4 +- .../src/cloud_sync_e2ee_documents/tests.rs | 2 +- .../openless-core/src/cloud_sync_types.rs | 66 +++++++++++++++++- .../crates/openless-core/src/shared_types.rs | 21 ++++-- .../tests/cloud_sync_contract.rs | 18 +++++ openless-all/app/src/lib/ipc/mock-data.ts | 2 +- 12 files changed, 177 insertions(+), 14 deletions(-) diff --git a/docs/android-ime.md b/docs/android-ime.md index 48204623a..75b48a7f3 100644 --- a/docs/android-ime.md +++ b/docs/android-ime.md @@ -20,7 +20,7 @@ - 笔画输入:离线笔画字典、单字候选、确认后的词语联想、分词、简繁偏好、数字/符号面板、上滑数字和个人词频。 - 剪贴板:历史记录、收藏/删除/分类、选择/复制/粘贴、纠正词写入全局词典。 - 手改学词:独立授权默认关闭,无障碍服务在有界观察期内报告当前编辑器的文本变化,主进程 Core 判断建议与过期时间;确认卡或编辑结果的显式勾选才加入词典。跨进程共享截止时间,服务重连不延长观察。 -- 光标上下文(默认关闭):开始录音时通过 `InputConnection` 读取光标前后各一小段文字(`ImePrivacyPolicy.kt` 负责门禁:密码框、`TYPE_NULL`、`IME_FLAG_NO_PERSONALIZED_LEARNING` 和敏感应用一律不读),随 `start` 命令结构化传给 Rust,由 Core 裁剪到 600 字并组装 Prompt。听写、速记、云笔记润色时都参考,但不写入笔记、历史、Webhook 或日志;悬浮球路径暂未接入。 +- 光标上下文(默认开启):开始录音时通过 `InputConnection` 读取光标前后各一小段文字(`ImePrivacyPolicy.kt` 负责门禁:密码框、`TYPE_NULL`、`IME_FLAG_NO_PERSONALIZED_LEARNING` 和敏感应用一律不读),随 `start` 命令结构化传给 Rust,由 Core 裁剪到 600 字并组装 Prompt。听写、速记、云笔记润色时都参考,但不写入笔记、历史、Webhook 或日志;悬浮球路径暂未接入。 - 英文键盘:字母/数字/符号三层布局、英文候选词、个人词频、自定义词长按删除、按键预览和上滑输入数字/符号。 - 跨应用插入:按可用性使用无障碍、Shizuku 或剪贴板回退;权限和输入法启用状态在 Android 侧单独管理。 diff --git a/docs/cloud-sync-server-handoff.md b/docs/cloud-sync-server-handoff.md index 74db24415..afbba607b 100644 --- a/docs/cloud-sync-server-handoff.md +++ b/docs/cloud-sync-server-handoff.md @@ -60,6 +60,8 @@ - `baseRevision=0` 且云端确无记录时创建首份快照(版本 1)。 - `schemaVersion` 不是 1 返回 `400`(`unsupported_schema_version`)。 +`payload.preferences` 中的新增字段必须按可选字段处理,以兼容旧客户端上传的数据;当前客户端会上传可迁移的界面、听写、选择语音、布局、历史和更新偏好(包括 `cursorContextEnabled`、`polishContextWindowMinutes`、`polishSkipMaxChars`、`streamingInsert`、`windowsCompositionInput`、`selectionVoice*` 等)。旧响应缺少这些字段时,客户端保留本机已有值;服务端不能把未知字段静默丢失后再返回一个看似成功的完整快照,应完成 DTO、持久化和读回合同的同步更新。 + ### DELETE /me/sync — 清空云端 请求 `{"baseRevision":2}`;成功返回递增版本、`updatedAt` 更新、`payload:null` 的快照。对不存在(`revision=0`)的快照执行删除且 `baseRevision=0` 时,按合同创建版本 1 的空记录。本机数据由客户端自行保留,服务端只管云端。 diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index 9d400cf0c..7a0250a67 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -13,6 +13,8 @@ | 词典、纠正规则、风格包文本与 PNG 图标 | API 密钥、OAuth token、渠道配置 | | 主题、胶囊、语言、字号、选中风格、其他列明的个人偏好 | 设备权限、输入/模型路径、Agent 可执行文件/工作目录/权限模式、快捷键与远程输入 PIN | +兼容版快照还同步可迁移的听写与界面偏好,包括光标上下文开关、最近历史窗口、跳过短文本阈值、流式插入、Windows 组合输入、启动最小化、更新渠道、选择语音编辑偏好和布局偏好。光标上下文同步的是开关,不是光标附近的文本;文本仍只在本地隐私门禁通过后按当前设备读取。 + 具体允许字段由 [`cloud_sync_types.rs`](../openless-all/app/crates/openless-core/src/cloud_sync_types.rs) 定义;不自动映射整个 `UserPreferences`。图标只读取 Core 拥有的资源目录,云端传输的是最多 64 KiB 的 PNG base64,恢复时重新分配本机文件路径。旧风格包的 JPEG/WebP 图标不进入这一版 PNG 同步字段。 ## IPC 与冲突处理 diff --git a/docs/encrypted-cloud-sync.md b/docs/encrypted-cloud-sync.md index 05aa90a91..88627a18c 100644 --- a/docs/encrypted-cloud-sync.md +++ b/docs/encrypted-cloud-sync.md @@ -17,7 +17,7 @@ - `src-tauri/src/commands/cloud_sync_e2ee.rs`:仅主窗口的本地 IPC;`src/lib/ipc/cloud-sync-e2ee.ts` 为对应 typed wrapper。浏览器预览返回 unavailable。 - `src/lib/encryptedSyncUiBridge.ts`:主窗口中实际语言/字号偏好的受控镜像,以及恢复成功后的界面更新。 -上传只发送密文。密码和派生密钥不进入服务器请求;系统凭据库只导出登记过的服务账号,不复制 keyring/Keystore 文件。普通服务 API Key、接口设置、文本历史和风格内容纳入加密范围;OAuth 登录态、同步令牌、同步密码/派生密钥、设备私钥、远程输入 PIN、系统权限、录音、模型权重、缓存和程序不进入快照。 +上传只发送密文。密码和派生密钥不进入服务器请求;系统凭据库只导出登记过的服务账号,不复制 keyring/Keystore 文件。普通服务 API Key、接口设置、文本历史和风格内容纳入加密范围;OAuth 登录态、同步令牌、同步密码/派生密钥、设备私钥、远程输入 PIN、系统权限、录音、模型权重、缓存和程序不进入快照。光标上下文开关本身属于可同步偏好;实际光标文本仍只在开启后按平台隐私门禁读取,不会写入同步文档、历史或日志。 旧版 Omni 初始化可能将默认 `custom` 槽错误保存为空标识。原生读取时仅在目标不存在或规范后全部字段相同的情况下,将这条旧槽完整映射回 `custom`,保留其他已选供应商;不同配置发生碰撞时继续拒绝同步并保留两份数据。读取只规范内存副本,持久化仍走既有写入门,不通过删除凭据或放宽同步文档校验绕过错误。 diff --git a/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt index 18ac36dd9..4b15c0314 100644 --- a/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt +++ b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt @@ -107,7 +107,7 @@ object OpenLessAndroidPreferences { readPreferenceBoolean(context, "strokeUsageEnabled") ?: true fun cursorContextEnabled(context: Context): Boolean = - readPreferenceBoolean(context, "cursorContextEnabled") ?: false + readPreferenceBoolean(context, "cursorContextEnabled") ?: true private fun readPreferenceString(context: Context, key: String): String? { for (file in preferenceFiles(context).distinctBy { it.absolutePath }) { diff --git a/openless-all/app/crates/openless-core/src/cloud_sync.rs b/openless-all/app/crates/openless-core/src/cloud_sync.rs index 383407ed5..db9554ee5 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync.rs @@ -544,14 +544,19 @@ fn capture_preferences( ui: CloudSyncUiPreferences, ) -> SyncPreferences { SyncPreferences { + style_system_prompts: Some(preferences.style_system_prompts.clone()), + custom_style_prompts: Some(preferences.custom_style_prompts.clone()), theme_mode: Some(preferences.theme_mode), capsule_style: Some(preferences.capsule_style), + capsule_transcript_enabled: Some(preferences.capsule_transcript_enabled), + capsule_transcript_font_size: Some(preferences.capsule_transcript_font_size), capsule_display_mode: None, capsule_display_mode_v2: None, default_mode: Some(preferences.default_mode), enabled_modes: Some(preferences.enabled_modes.clone()), active_style_pack_id: Some(preferences.active_style_pack_id.clone()), selection_polish_style_pack_id: Some(preferences.selection_polish_style_pack_id.clone()), + selection_polish_output_mode: Some(preferences.selection_polish_output_mode), working_languages: Some(preferences.working_languages.clone()), translation_target_language: Some(preferences.translation_target_language.clone()), chinese_script_preference: Some(preferences.chinese_script_preference), @@ -565,6 +570,33 @@ fn capture_preferences( silence_auto_stop_seconds: Some(preferences.silence_auto_stop_seconds), show_overview_activity_heatmap: Some(preferences.show_overview_activity_heatmap), llm_thinking_enabled: Some(preferences.llm_thinking_enabled), + pipeline_mode: Some(preferences.pipeline_mode), + multimodal_pipeline_enabled: Some(preferences.multimodal_pipeline_enabled), + selection_voice_enabled: Some(preferences.selection_voice_enabled), + voice_edit_enabled: Some(preferences.voice_edit_enabled), + selection_voice_intent_mode: Some(preferences.selection_voice_intent_mode), + selection_voice_manual_intent: Some(preferences.selection_voice_manual_intent), + selection_voice_edit_keywords: Some(preferences.selection_voice_edit_keywords.clone()), + selection_voice_edit_plan_format: Some(preferences.selection_voice_edit_plan_format), + selection_voice_edit_system_prompt: Some( + preferences.selection_voice_edit_system_prompt.clone(), + ), + update_channel: Some(preferences.update_channel), + update_channel_explicit: Some(preferences.update_channel_explicit), + history_retention_days: Some(preferences.history_retention_days), + polish_context_window_minutes: Some(preferences.polish_context_window_minutes), + polish_skip_max_chars: Some(preferences.polish_skip_max_chars), + start_minimized: Some(preferences.start_minimized), + streaming_insert: Some(preferences.streaming_insert), + streaming_insert_save_clipboard: Some(preferences.streaming_insert_save_clipboard), + windows_composition_input: Some(preferences.windows_composition_input), + cursor_context_enabled: Some(preferences.cursor_context_enabled), + stacked_row_layout: Some(preferences.stacked_row_layout), + conservative_layout: Some(preferences.conservative_layout), + auto_update_check: Some(preferences.auto_update_check), + history_max_entries: Some(preferences.history_max_entries), + record_audio_for_debug: Some(preferences.record_audio_for_debug), + audio_recording_max_entries: Some(preferences.audio_recording_max_entries), locale: ui.locale, font_scale: ui.font_scale, } @@ -585,12 +617,17 @@ fn apply_preferences(preferences: &mut UserPreferences, incoming: &SyncPreferenc } macro_rules! apply { ($($field:ident),+ $(,)?) => { $(if let Some(value) = &incoming.$field { preferences.$field = value.clone(); })+ }; } apply!( + style_system_prompts, + custom_style_prompts, theme_mode, capsule_style, + capsule_transcript_enabled, + capsule_transcript_font_size, default_mode, enabled_modes, active_style_pack_id, selection_polish_style_pack_id, + selection_polish_output_mode, working_languages, translation_target_language, chinese_script_preference, @@ -603,8 +640,37 @@ fn apply_preferences(preferences: &mut UserPreferences, incoming: &SyncPreferenc silence_auto_stop_enabled, silence_auto_stop_seconds, show_overview_activity_heatmap, - llm_thinking_enabled + llm_thinking_enabled, + pipeline_mode, + multimodal_pipeline_enabled, + selection_voice_enabled, + voice_edit_enabled, + selection_voice_intent_mode, + selection_voice_manual_intent, + selection_voice_edit_keywords, + selection_voice_edit_plan_format, + selection_voice_edit_system_prompt, + update_channel, + update_channel_explicit, + history_retention_days, + polish_context_window_minutes, + polish_skip_max_chars, + start_minimized, + streaming_insert, + streaming_insert_save_clipboard, + windows_composition_input, + cursor_context_enabled, + stacked_row_layout, + conservative_layout, + auto_update_check, + record_audio_for_debug ); + if let Some(value) = incoming.history_max_entries { + preferences.history_max_entries = value; + } + if let Some(value) = incoming.audio_recording_max_entries { + preferences.audio_recording_max_entries = value; + } } fn owned_icon_target(assets: &Path, id: &str) -> Result { diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs index 3b7e806c1..44e900442 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -791,9 +791,9 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ PreferenceField { rust_name: "cursor_context_enabled", key: "cursorContextEnabled", - class: PreferenceClass::Excluded, + class: PreferenceClass::Portable, shape: PreferenceShape::Boolean, - reason: "local_consent_or_secret", + reason: "portable_setting", }, PreferenceField { rust_name: "vocabulary_learning_enabled", diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs index d3d9688c0..369ccf8ea 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs @@ -170,7 +170,7 @@ fn controlled_export_keeps_real_service_keys_but_excludes_local_secrets_and_gran ); assert!(!raw.contains("fixture-pin-never-export")); assert!(!raw.contains("codingAgentEnabled")); - assert!(!raw.contains("cursorContextEnabled")); + assert!(raw.contains("cursorContextEnabled")); assert!(!raw.contains("vocabularyLearningEnabled")); assert!(!raw.contains("fixture-login")); assert!(!raw.contains("activeLlmProvider")); diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_types.rs b/openless-all/app/crates/openless-core/src/cloud_sync_types.rs index 59f04cc03..9ded7c81b 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_types.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_types.rs @@ -3,11 +3,15 @@ //! These types deliberately do not embed the app's complete preferences or style-pack structs: //! credentials, provider connections, device permissions and local paths stay on the device. +use crate::edit_plan::EditPlanFormat; use crate::shared_types::{ CapsuleDisplayMode as SyncCapsuleDisplayMode, ChineseScriptPreference as SyncChineseScriptPreference, - OutputLanguagePreference as SyncOutputLanguagePreference, ThemeMode as SyncThemeMode, + OutputLanguagePreference as SyncOutputLanguagePreference, PipelineMode, + SelectionPolishOutputMode, ThemeMode as SyncThemeMode, UpdateChannel, }; +use crate::style_packs::{CustomStylePrompts, StyleSystemPrompts}; +use crate::types::{SelectionVoiceIntentMode, SelectionVoiceManualIntent}; use crate::PolishMode; use crate::{ CapsuleStyle as SyncCapsuleStyle, RuleSource as SyncRuleSource, @@ -115,10 +119,18 @@ pub struct SyncStylePackExample { #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SyncPreferences { + #[serde(skip_serializing_if = "Option::is_none")] + pub style_system_prompts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_style_prompts: Option, #[serde(skip_serializing_if = "Option::is_none")] pub theme_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] pub capsule_style: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub capsule_transcript_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub capsule_transcript_font_size: Option, // Old plaintext readers reject unknown keys. Display targeting syncs through E2EE only. #[serde(skip_serializing)] pub capsule_display_mode: Option, @@ -133,6 +145,8 @@ pub struct SyncPreferences { #[serde(skip_serializing_if = "Option::is_none")] pub selection_polish_style_pack_id: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub selection_polish_output_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub working_languages: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub translation_target_language: Option, @@ -159,6 +173,56 @@ pub struct SyncPreferences { #[serde(skip_serializing_if = "Option::is_none")] pub llm_thinking_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub pipeline_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub multimodal_pipeline_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selection_voice_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub voice_edit_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selection_voice_intent_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selection_voice_manual_intent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selection_voice_edit_keywords: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub selection_voice_edit_plan_format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selection_voice_edit_system_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub update_channel: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub update_channel_explicit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub history_retention_days: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub polish_context_window_minutes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub polish_skip_max_chars: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub start_minimized: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub streaming_insert: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub streaming_insert_save_clipboard: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub windows_composition_input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor_context_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stacked_row_layout: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conservative_layout: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_update_check: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub history_max_entries: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub record_audio_for_debug: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub audio_recording_max_entries: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub locale: Option, #[serde(skip_serializing_if = "Option::is_none")] pub font_scale: Option, diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index ddb570f96..ea06f2ee9 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -825,16 +825,16 @@ pub struct UserPreferences { /// Whether to send original text near the cursor from the document the /// user is writing as LLM polish context. /// - /// **Default false, and must stay false.** When on, every dictation + /// **Default true.** When on, every dictation /// reads the front app's body and sends part of it to the LLM vendor — /// data the user never handed over voluntarily, so only they may opt in. /// When off, `host_document` issues zero AX calls and prompts are /// byte-identical to before this feature existed. /// - /// macOS-only today; Windows / Linux degrade gracefully to no context. + /// macOS, Windows and Android support it today; Linux degrades gracefully to no context. /// Password boxes / Secure Input / password managers / terminals are /// hard-blocked regardless of this switch. - #[serde(default)] + #[serde(default = "default_true")] pub cursor_context_enabled: bool, /// Observe corrections locally after insertion; independent of LLM context. #[serde(default)] @@ -1207,7 +1207,7 @@ struct UserPreferencesWire { streaming_insert_save_clipboard: bool, #[serde(default)] windows_composition_input: bool, - #[serde(default)] + #[serde(default = "default_true")] cursor_context_enabled: bool, #[serde(default)] vocabulary_learning_enabled: bool, @@ -2025,7 +2025,7 @@ impl Default for UserPreferences { streaming_insert_default_migrated: true, streaming_insert_save_clipboard: true, windows_composition_input: false, - cursor_context_enabled: false, + cursor_context_enabled: true, vocabulary_learning_enabled: false, vocabulary_learning_settings: VocabularyLearningSettings::default(), show_overview_activity_heatmap: true, @@ -3702,6 +3702,17 @@ mod tests { assert_eq!(from_empty.paste_shortcut, PasteShortcut::CtrlV); } + #[test] + fn cursor_context_defaults_to_enabled_and_round_trips() { + assert!(UserPreferences::default().cursor_context_enabled); + let from_empty: UserPreferences = serde_json::from_str("{}").unwrap(); + assert!(from_empty.cursor_context_enabled); + + let disabled: UserPreferences = + serde_json::from_str(r#"{"cursorContextEnabled":false}"#).unwrap(); + assert!(!disabled.cursor_context_enabled); + } + #[test] fn polish_skip_max_chars_defaults_off_and_round_trips() { // Issue #1032: the short-input polish skip is opt-in, so existing users keep polishing. diff --git a/openless-all/app/crates/openless-core/tests/cloud_sync_contract.rs b/openless-all/app/crates/openless-core/tests/cloud_sync_contract.rs index a354ed1cf..e03270077 100644 --- a/openless-all/app/crates/openless-core/tests/cloud_sync_contract.rs +++ b/openless-all/app/crates/openless-core/tests/cloud_sync_contract.rs @@ -248,6 +248,12 @@ async fn portable_data_round_trips_without_uploading_credentials_or_device_setti preferences.theme_mode = openless_core::shared_types::ThemeMode::Dark; preferences.capsule_display_mode = CapsuleDisplayMode::ForegroundWindowScreen; preferences.stable_transcription_enabled = true; + preferences.cursor_context_enabled = true; + preferences.polish_context_window_minutes = 17; + preferences.polish_skip_max_chars = 23; + preferences.streaming_insert = false; + preferences.windows_composition_input = true; + preferences.history_max_entries = Some(42); source .backend .repositories() @@ -273,6 +279,12 @@ async fn portable_data_round_trips_without_uploading_credentials_or_device_setti let uploaded = server.state.lock().unwrap().payload.clone().unwrap(); let serialized = uploaded.to_string(); assert_eq!(uploaded["preferences"]["stableTranscriptionEnabled"], true); + assert_eq!(uploaded["preferences"]["cursorContextEnabled"], true); + assert_eq!(uploaded["preferences"]["polishContextWindowMinutes"], 17); + assert_eq!(uploaded["preferences"]["polishSkipMaxChars"], 23); + assert_eq!(uploaded["preferences"]["streamingInsert"], false); + assert_eq!(uploaded["preferences"]["windowsCompositionInput"], true); + assert_eq!(uploaded["preferences"]["historyMaxEntries"], 42); assert!(uploaded["preferences"].get("capsuleDisplayMode").is_none()); assert!(uploaded["preferences"] .get("capsuleDisplayModeV2") @@ -344,6 +356,12 @@ async fn portable_data_round_trips_without_uploading_credentials_or_device_setti ); assert_eq!(preferences.remote_input_pin, "target-pin"); assert!(preferences.stable_transcription_enabled); + assert!(preferences.cursor_context_enabled); + assert_eq!(preferences.polish_context_window_minutes, 17); + assert_eq!(preferences.polish_skip_max_chars, 23); + assert!(!preferences.streaming_insert); + assert!(preferences.windows_composition_input); + assert_eq!(preferences.history_max_entries, Some(42)); let secret = target .credentials .read( diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index a9dea163f..29dbcc579 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -136,7 +136,7 @@ export let mockSettings: UserPreferences = { streamingInsertDefaultMigrated: true, streamingInsertSaveClipboard: true, windowsCompositionInput: false, - cursorContextEnabled: false, + cursorContextEnabled: true, vocabularyLearningEnabled: false, vocabularyLearningSettings: { observationSeconds: 60, suggestionSeconds: 10, maxPhraseChars: 12 }, showOverviewActivityHeatmap: true, From c9f49c6088fa8b17493f03b2d6988e93cc0d0f7c Mon Sep 17 00:00:00 2001 From: TRIP <1933142963@qq.com> Date: Mon, 26 Oct 2026 01:14:09 +0800 Subject: [PATCH 10/31] fix(app): align cursor context contracts and CI fixtures Reviewed and validated end to end. Cursor context privacy-path tests now opt out explicitly after the default became enabled; all canonical cloud-sync event fixtures include serviceOrigin; capture-first startup documentation matches the implemented flow. Full local openless-core tests and every configured cross-platform CI check passed. --- docs/audio-startup-latency.md | 10 ++++++---- openless-all/app/contract/backend-2.0.json | 3 +++ openless-all/app/crates/openless-core/src/api.rs | 4 ++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/audio-startup-latency.md b/docs/audio-startup-latency.md index 50a03b41b..31079f9cf 100644 --- a/docs/audio-startup-latency.md +++ b/docs/audio-startup-latency.md @@ -11,8 +11,8 @@ PCM,而不是等待识别器后再开麦。缓冲按需增长、保留原有 capture,以及默认关闭的预热/预录选项;#1150 已在 2026-10-12 关闭。因此不能将 讨论里的早期流程直接当作当前实现。 -仍有可移走的前置步骤:`PipelineDictationEngine::start` 在提交 provisional capture -或调用普通 recorder 之前等待 `transcription.prepare()`。改为: +这项前置等待已经移走:`PipelineDictationEngine::start` 会先提交 provisional +capture 或启动普通 recorder,再并行准备 `transcription.prepare()`。当前流程为: ```text 快捷键 / 正式启动 @@ -53,7 +53,9 @@ Tauri 录音日志增加 `[audio-startup]` 阶段,只有时间和录音/按键 比较 `first_raw_pcm` 与 `first_output_pcm` 可以区分硬件等待与处理/分帧等待。 Windows 快捷键日志使用 `press` 和 `capture` 关联;预热路线标记为 `warm_hub`。 -新增回归测试会故意阻塞识别器准备,验证麦克风已开始、开头 PCM 仍能顺序交付, -并验证准备期间的取消无需等待准备完成即可释放麦克风。另验证准备失败清理。 +回归测试 `capture_starts_before_slow_preparation_and_cancellation_releases_it_promptly` +会故意阻塞识别器准备,验证麦克风已开始、开头 PCM 仍能顺序交付,并验证准备期间 +的取消无需等待准备完成即可释放麦克风;另有准备失败清理和 Windows WASAPI +资源生命周期测试。完整 `openless-core` 测试与 Windows CI 覆盖这些路径。 本轮未在原生 Windows 麦克风上测量优化前后的 P50/P95,不报告未经测量的毫秒收益。 diff --git a/openless-all/app/contract/backend-2.0.json b/openless-all/app/contract/backend-2.0.json index 02a281f4e..c48032b7f 100644 --- a/openless-all/app/contract/backend-2.0.json +++ b/openless-all/app/contract/backend-2.0.json @@ -210,6 +210,7 @@ "accountId": null, "vaultId": null, "taskId": null, + "serviceOrigin": "https://apic.openless.top:9443", "status": { "sequence": "1", "enabled": false, @@ -245,6 +246,7 @@ "accountId": "118526", "vaultId": "11111111-1111-4111-8111-111111111111", "taskId": null, + "serviceOrigin": "https://apic.openless.top:9443", "preview": { "previewId": "22222222-2222-4222-8222-222222222222", "unconfirmedOperationId": null, @@ -267,6 +269,7 @@ "accountId": "118526", "vaultId": "11111111-1111-4111-8111-111111111111", "taskId": null, + "serviceOrigin": "https://apic.openless.top:9443", "localGeneration": "1", "uiPreferences": { "locale": "zh-CN", "fontScale": "medium" } } diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 3402ab525..85d00be1d 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -13378,6 +13378,9 @@ mod tests { ) .unwrap(); + let mut preferences = backend.get_preferences(); + preferences.cursor_context_enabled = false; + backend.set_preferences(preferences).unwrap(); let context = backend .capture_host_dictation_context(DictationStartOptions::default()) .await @@ -13574,6 +13577,7 @@ mod tests { .unwrap(); let mut preferences = backend.get_preferences(); preferences.vocabulary_learning_enabled = true; + preferences.cursor_context_enabled = false; assert!( !preferences.cursor_context_enabled, "learning must not require uploading cursor context" From 39e83af975301884c59ca435d37127f69d7daded Mon Sep 17 00:00:00 2001 From: TRIP <1933142963@qq.com> Date: Mon, 26 Oct 2026 13:38:24 +0800 Subject: [PATCH 11/31] chore(release): prepare 2.0.2 Beta.3 Reviewed and validated: five Tauri version locations are synchronized, local version contract and 152 frontend tests pass, and all configured cross-platform CI checks are green. This merge prepares v2.0.2-Beta.3-tauri from the reviewed beta baseline. --- docs/releases/2.0.2-Beta.3-release-notes.md | 53 +++++++++++++++++++ openless-all/app/package-lock.json | 4 +- openless-all/app/package.json | 2 +- .../scripts/desktop-version-contract.test.mjs | 2 +- openless-all/app/src-tauri/Cargo.lock | 2 +- openless-all/app/src-tauri/Cargo.toml | 2 +- openless-all/app/src-tauri/tauri.conf.json | 2 +- 7 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 docs/releases/2.0.2-Beta.3-release-notes.md diff --git a/docs/releases/2.0.2-Beta.3-release-notes.md b/docs/releases/2.0.2-Beta.3-release-notes.md new file mode 100644 index 000000000..2f43dbc9c --- /dev/null +++ b/docs/releases/2.0.2-Beta.3-release-notes.md @@ -0,0 +1,53 @@ +# OpenLess 2.0.2-Beta.3+build.20261026-c9f49c60 + +本次为 **Beta 测试版**,比较范围为上一公开 Beta **`v2.0.2-Beta.2-tauri` → `v2.0.2-Beta.3-tauri`**。正式版用户不会自动收到本版本。 + +## 功能更新 + +- **光标上下文默认开启**:macOS、Windows 和 Android 用户首次使用时默认启用光标附近上下文;用户仍可在隐私设置中关闭。密码输入、Secure Input、Windows UIA 密码控件、已知密码管理器和终端继续被排除。相关实现见提交 [`f71ffd54`](https://github.com/Open-Less/openless/commit/f71ffd5482ee1ba2c85315418eb41be68284576e) 和合并 PR [#1268](https://github.com/Open-Less/openless/pull/1268)。 +- **云同步覆盖更多设置**:历史记录、流式插入、Windows composition、选区语音、润色上下文、更新渠道和布局等可同步设置现在会进入兼容的 legacy sync 与加密同步路径。相关提交 [`f71ffd54`](https://github.com/Open-Less/openless/commit/f71ffd5482ee1ba2c85315418eb41be68284576e)。 + +## Bug 修复 + +- **Windows 麦克风启动和崩溃风险**:WASAPI/CPAL 的 COM 资源和设备对象保持在所属线程释放,增加了跨线程枚举与生命周期回归测试,覆盖 Issue #1227 报告的堆损坏风险。提交 [`f88bf246`](https://github.com/Open-Less/openless/commit/f88bf246e7b6823ecd0422475cca3802ed4a50bb)。 +- **同步事件串线**:冲突、恢复和状态事件现在携带服务 origin,旧服务器或旧账户事件不能覆盖当前同步服务的本地状态。提交 [`99b05579`](https://github.com/Open-Less/openless/commit/99b05579fc0632908613f41b90624c9a508cb53f)。 +- **Beta CI 合同修复**:光标上下文默认值变化同步到隐私路径测试,云同步事件 fixture 补齐 `serviceOrigin` 字段,避免 App 端 Linux Core CI 因陈旧合同失败。PR [#1268](https://github.com/Open-Less/openless/pull/1268)。 + +## 架构改动 + +- **录音优先启动路径**:`PipelineDictationEngine` 在识别器准备之前先建立麦克风捕获,使用有界 PCM 缓冲保留开头音频;取消会立即释放麦克风,准备完成后按原顺序交付暂存 PCM。已有 `capture_starts_before_slow_preparation_and_cancellation_releases_it_promptly` 回归测试和 Windows 音频生命周期测试覆盖该路径。 +- **Linux egui 日志后端**:Linux 宿主日志等级和噪声处理重新整理,补齐边界诊断。PR [#1259](https://github.com/Open-Less/openless/pull/1259)。 +- **iOS Beta 基线**:iOS Host、键盘和录音扩展对齐 Beta,并隔离设备构建与测试流程。PR [#1254](https://github.com/Open-Less/openless/pull/1254)。 + +## 构建与分发改动 + +- 应用版本统一为 `2.0.2-Beta.3+build.20261026-c9f49c60`。 +- 新 Beta 标签为 `v2.0.2-Beta.3-tauri`,不会移动 `v2.0.2-Beta.2-tauri` 或稳定版标签。 +- Tauri release workflow 构建 macOS ARM64/Intel、Windows x64 和 Android 四 ABI 产物;Linux egui 与 HarmonyOS 仍由各自手动验收流程附加。 + +## PR 与参与者 + +| PR / 提交 | 内容 | 提交者 | +| --- | --- | --- | +| [#1268](https://github.com/Open-Less/openless/pull/1268) | App 合同 fixture、默认光标上下文测试和 Beta CI 收尾 | [@appergb](https://github.com/appergb) | +| [#1259](https://github.com/Open-Less/openless/pull/1259) | Linux egui 日志后端 | [@aeoform](https://github.com/aeoform) | +| [#1254](https://github.com/Open-Less/openless/pull/1254) | iOS Beta 对齐与设备构建隔离 | [@appergb](https://github.com/appergb) | +| [`f88bf246`](https://github.com/Open-Less/openless/commit/f88bf246e7b6823ecd0422475cca3802ed4a50bb) | Windows WASAPI 生命周期修复 | TRIP / [@appergb](https://github.com/appergb) | +| [`99b05579`](https://github.com/Open-Less/openless/commit/99b05579fc0632908613f41b90624c9a508cb53f) | 同步服务 origin 隔离 | lvbaiqing | +| [`f71ffd54`](https://github.com/Open-Less/openless/commit/f71ffd5482ee1ba2c85315418eb41be68284576e) | 设置同步与默认光标上下文 | lvbaiqing | + +## 验证说明 + +- Beta 基线 CI:App PR [#1268](https://github.com/Open-Less/openless/pull/1268) 的配置检查、Linux Core、Windows、macOS、Android、Harmony、iOS device-build、native capsule 和 UI motion 全部通过。 +- 合并后 Core 合同测试:`cargo test --locked -p openless-core --test contract_2`,6/6 通过。 +- 合并后前端测试:`npm test`,152/152 通过,TypeScript 与 Vite production build 成功。 +- 合并前完整 `openless-core`:1391 项通过,3 项 ignored;Windows CI 包含 WASAPI COM 生命周期测试。 +- Vite 构建仍输出已有的循环 re-export 和大 chunk 警告,但未导致构建或测试失败。 +- 本机无法替代真实 Windows 麦克风硬件测量 P50/P95;发布包应以 Windows CI 和实际 Beta 用户设备反馈继续验证冷启动延迟。 + +## 下载与升级 + +- Beta 用户应在设置中加入 Beta 渠道,或从 GitHub Release 下载 `v2.0.2-Beta.3-tauri` 对应平台安装包。 +- macOS 14 或更新版本:Apple Silicon 使用 `aarch64.dmg`,Intel 使用 `x64.dmg`。若构建未配置 Apple Developer ID 签名与公证,仍需按现有发行说明处理 Gatekeeper。 +- Windows 提供 x64 NSIS 安装包;Android 提供四种 ABI 的 Beta APK。 +- Stable 用户不会从 Stable updater feed 自动收到此 Beta。 diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 7612b1df2..dc382c304 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "openless-app", - "version": "2.0.2-Beta.2+build.20261023-dictation-71a18a77", + "version": "2.0.2-Beta.3+build.20261026-c9f49c60", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "2.0.2-Beta.2+build.20261023-dictation-71a18a77", + "version": "2.0.2-Beta.3+build.20261026-c9f49c60", "dependencies": { "@base-ui/react": "^1.6.0", "@shadcn/react": "^0.2.1", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 0ac3c608c..4af1a03e1 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -1,7 +1,7 @@ { "name": "openless-app", "private": true, - "version": "2.0.2-Beta.2+build.20261023-dictation-71a18a77", + "version": "2.0.2-Beta.3+build.20261026-c9f49c60", "type": "module", "scripts": { "pretest": "npm run build", diff --git a/openless-all/app/scripts/desktop-version-contract.test.mjs b/openless-all/app/scripts/desktop-version-contract.test.mjs index d03e5d1bb..e2492589e 100644 --- a/openless-all/app/scripts/desktop-version-contract.test.mjs +++ b/openless-all/app/scripts/desktop-version-contract.test.mjs @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; const app = new URL('../', import.meta.url); const read = async (path) => (await readFile(new URL(path, app), 'utf8')).replace(/\r\n/g, '\n'); -const version = '2.0.2-Beta.2+build.20261023-dictation-71a18a77'; +const version = '2.0.2-Beta.3+build.20261026-c9f49c60'; const pkg = JSON.parse(await read('package.json')); const lock = JSON.parse(await read('package-lock.json')); const tauri = JSON.parse(await read('src-tauri/tauri.conf.json')); diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 13b8308d0..fdc676e19 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -4372,7 +4372,7 @@ dependencies = [ [[package]] name = "openless" -version = "2.0.2-Beta.2+build.20261023-dictation-71a18a77" +version = "2.0.2-Beta.3+build.20261026-c9f49c60" dependencies = [ "anyhow", "arboard", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index cd39d9243..4571f80ae 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 = "2.0.2-Beta.2+build.20261023-dictation-71a18a77" +version = "2.0.2-Beta.3+build.20261026-c9f49c60" license = "AGPL-3.0-only" description = "OpenLess — local voice input that types where your cursor is" authors = ["OpenLess"] diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index a0086f01d..697be3b27 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenLess", - "version": "2.0.2-Beta.2+build.20261023-dictation-71a18a77", + "version": "2.0.2-Beta.3+build.20261026-c9f49c60", "identifier": "com.openless.app", "build": { "beforeDevCommand": "npm run dev", From ee0e3cd4b3e852b53c84e8b057e4f7ee4c0cc063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=A9=E5=BD=AC=28=5E-=5E=29?= <146098469+HKLHaoBin@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:30:34 +0800 Subject: [PATCH 12/31] feat(audio): add optional playback echo cancellation (#1228) * feat(audio): add optional playback echo cancellation (#1223) Add bounded render-reference processing for desktop dictation, preserve playback without muting, expose a local-only best-effort setting, and document platform limitations. Co-Authored-By: Claude Opus 5 * fix(ci): keep recorder harness on cpal 0.18 Update the standalone recorder module harness to match the production cpal API and expose the shared render-reference buffer used by the recorder. Co-Authored-By: Claude Opus 5 * chore(audio): instrument issue 1223 stream failures Add per-capture structured probes for reference availability, native CPAL errors, first and post-error callbacks, queue backpressure and watchdog faults. Keep microphone error behavior unchanged until the diagnostic build is reproduced on affected hardware. Cover diagnostic counters and error-kind redaction with recorder tests. Co-Authored-By: Claude Opus 5 * fix(audio): keep capture running after recoverable xruns Separate CPAL Xrun notifications from fatal microphone errors after instrumented Windows logs showed data callbacks continuing immediately after each xrun. Keep capture liveness unchanged so the existing watchdog still detects stalled streams, preserve device and invalidated-stream failures, and retain diagnostic probes for hardware verification. Add regression coverage for xrun handling, repeated notifications, real faults and stopped capture. Co-Authored-By: Claude Opus 5 * fix(ci): allow manual Intel macOS runner fallback Keep push and pull request defaults unchanged while allowing manual CI dispatches to select macos-15-intel when hosted ARM runners cannot acquire jobs. Apply the same explicit choice to macOS stable and MSRV checks, preserve all gates, and cover the routing contract in the frontend test suite. Co-Authored-By: Claude Opus 5 * fix(audio): adapt native cue to cpal 0.18 Use the current sample-rate alias and pass StreamConfig by value. Include the native cue module in the recorder harness so its CPAL calls are compiled by the standalone remote checks. Co-Authored-By: Claude Opus 5 * fix(linux): preserve backend diagnostics and adapt reference timestamps --------- Co-authored-by: Claude Opus 5 Co-authored-by: Chris233 --- .github/workflows/ci.yml | 10 +- docs/issue-1223-aec-feasibility.md | 159 ++++++ .../openless-core/examples/enhance_audio.rs | 1 + .../app/crates/openless-core/src/api.rs | 1 + .../app/crates/openless-core/src/audio.rs | 24 + .../openless-core/src/audio/dsp_tests.rs | 2 + .../crates/openless-core/src/audio/echo.rs | 537 ++++++++++++++++++ .../src/cloud_sync_e2ee_documents/registry.rs | 7 + .../openless-core/src/dictation_context.rs | 1 + .../src/experimental_asr/tests.rs | 7 +- .../app/crates/openless-core/src/lib.rs | 2 +- .../crates/openless-core/src/preferences.rs | 7 + .../crates/openless-core/src/shared_types.rs | 8 + .../app/e2e/settings-regressions.spec.ts | 28 +- openless-all/app/linux-egui/src/audio.rs | 306 +++++++++- openless-all/app/linux-egui/src/i18n.rs | 22 +- .../linux-egui/src/linux_app/settings_save.rs | 3 + openless-all/app/linux-egui/src/main.rs | 1 + .../linux-egui/src/ui/frontend/settings.rs | 11 + .../linux-egui/src/ui/frontend/view_model.rs | 4 + .../scripts/ci-macos-runner-contract.test.mjs | 39 ++ ...-runtime-minimum-version-contract.test.mjs | 13 + .../app/scripts/test-recorder-module.py | 14 +- openless-all/app/src-tauri/Info.plist | 2 + openless-all/app/src-tauri/build.rs | 8 + .../app/src-tauri/src/commands/settings.rs | 1 + .../app/src-tauri/src/core_adapters.rs | 3 + .../app/src-tauri/src/playback_reference.rs | 128 +++++ .../src-tauri/src/playback_reference_macos.m | 99 ++++ .../src-tauri/src/playback_reference_macos.rs | 120 ++++ openless-all/app/src-tauri/src/recorder.rs | 250 +++++++- openless-all/app/src/i18n/de.ts | 3 + openless-all/app/src/i18n/en.ts | 3 + openless-all/app/src/i18n/es.ts | 3 + openless-all/app/src/i18n/fr.ts | 3 + openless-all/app/src/i18n/ja.ts | 3 + openless-all/app/src/i18n/ko.ts | 3 + openless-all/app/src/i18n/zh-CN.ts | 3 + openless-all/app/src/i18n/zh-TW.ts | 3 + openless-all/app/src/lib/ipc/mock-data.ts | 1 + openless-all/app/src/lib/types.ts | 2 + .../pages/settings/RecordingInputSection.tsx | 13 + 42 files changed, 1813 insertions(+), 45 deletions(-) create mode 100644 docs/issue-1223-aec-feasibility.md create mode 100644 openless-all/app/crates/openless-core/src/audio/echo.rs create mode 100644 openless-all/app/scripts/ci-macos-runner-contract.test.mjs create mode 100644 openless-all/app/src-tauri/src/playback_reference.rs create mode 100644 openless-all/app/src-tauri/src/playback_reference_macos.m create mode 100644 openless-all/app/src-tauri/src/playback_reference_macos.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f330acb4d..1a5b847e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,11 @@ on: type: choice options: [all, macos] default: all + macos_runner: + description: macOS runner (Intel fallback when hosted ARM capacity is unavailable) + type: choice + options: [macos-latest, macos-15-intel] + default: macos-latest # Cancel old runs when the same PR is pushed repeatedly; workflow_dispatch is isolated by run_id. concurrency: @@ -599,7 +604,8 @@ jobs: fail-fast: false matrix: include: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos' && '[{"os":"macos-latest","label":"macOS","preflight":false}]' || '[{"os":"macos-latest","label":"macOS","preflight":false},{"os":"windows-latest","label":"Windows","preflight":true}]') }} - runs-on: ${{ matrix.os }} + # Dispatch-only fallback; push/PR runs keep the default matrix and all test gates. + runs-on: ${{ matrix.label == 'macOS' && github.event_name == 'workflow_dispatch' && inputs.macos_runner || matrix.os }} timeout-minutes: 60 env: # The shared Core and full Tauri test binary compile together; limit concurrency @@ -811,7 +817,7 @@ jobs: # Run the declared minimum toolchain even when stable has the same version. if: needs.changes.outputs.msrv != 'false' name: macOS Rust 1.99 MSRV - runs-on: macos-latest + runs-on: ${{ github.event_name == 'workflow_dispatch' && inputs.macos_runner || 'macos-latest' }} timeout-minutes: 60 env: CARGO_BUILD_JOBS: 2 diff --git a/docs/issue-1223-aec-feasibility.md b/docs/issue-1223-aec-feasibility.md new file mode 100644 index 000000000..7d6b178a4 --- /dev/null +++ b/docs/issue-1223-aec-feasibility.md @@ -0,0 +1,159 @@ +# Issue #1223: playback-preserving acoustic echo cancellation + +**Status:** implementation and feasibility note; the baseline research below is supplemented by the feature-branch implementation. +**Baseline revision examined:** `de072a88373a222280a8c22948092a260880196c` (`upstream/beta`, 2.0.2 Beta.1). +**Issue:** [Open-Less/openless#1223](https://github.com/Open-Less/openless/issues/1223) + +## Executive conclusion + +Issue #1223 is technically feasible, but not as a platform-neutral switch around the existing microphone normalizer. Acoustic echo cancellation (AEC) needs two time-related signals: the microphone/capture signal and a render/playback reference, plus a usable estimate of the delay between them. The official WebRTC API exposes this explicitly through `ProcessReverseStream()` for render audio, `ProcessStream()` for capture audio, and `set_stream_delay_ms()` for their timing relationship ([WebRTC Audio Processing API](https://webrtc.googlesource.com/src/+/refs/heads/main/api/audio/audio_processing.h)). A microphone-only RMS gate or gain stage can reduce quiet noise, but it cannot reliably identify and subtract speech from a video. + +The practical recommendation is an **optional, capability-reported, best-effort feature** that leaves playback and volume untouched. Keep “Mute while recording” as the explicit fallback. Implement and validate one platform at a time rather than promising universal AEC: + +1. Add a capability probe, diagnostics, fallback behavior, and an offline/fixture test harness. +2. Prototype Windows with WASAPI loopback plus a proven AEC implementation such as WebRTC APM/AEC3. +3. Add a PipeWire-specific Linux path where the echo-cancel virtual sink/source can own the playback reference. +4. Investigate macOS VoiceProcessingIO or a supported Core Audio render tap; do not claim that VoiceProcessingIO cancels arbitrary applications whose playback is outside that unit. +5. Add Android’s native `AudioRecord`/`AcousticEchoCanceler` path only where the device reports the effect as available. + +## 1. Issue and GitHub history + +### User request + +The issue asks for an optional playback-suppression/AEC setting so a video can keep playing while OpenLess recognizes the user’s speech. It explicitly accepts device- and environment-dependent results and asks that supported platforms and limitations be documented ([issue body](https://github.com/Open-Less/openless/issues/1223)). + +The two issue comments are: + +- On 2026-10-17, `HKLHaoBin` explained that AEC was omitted from the earlier audio-enhancement work because of CPU cost, additional dependencies, and implementation/maintenance complexity, and offered to try an implementation ([comment 6237559767](https://github.com/Open-Less/openless/issues/1223#issuecomment-6237559767)). +- On 2026-10-17, the reporter said a closed-source competitor appears to preserve video playback while preventing the video voice from entering recognition ([comment 6237637338](https://github.com/Open-Less/openless/issues/1223#issuecomment-6237637338)). This is a user observation, not an independently verified technical claim. + +`gh api --paginate repos/Open-Less/openless/issues/1223/events` returned an empty list. The issue timeline contained only the two `commented` entries above; it contained no cross-reference, linked-commit, or pull-request event. `gh search prs 1223 --repo Open-Less/openless` and `gh search commits 1223 --repo Open-Less/openless` returned no results at investigation time. The issue body does explicitly reference [#269](https://github.com/Open-Less/openless/issues/269), so that issue and its implementation PR are relevant context, but they are not direct links from the #1223 timeline. + +### Related repository history + +- [#269](https://github.com/Open-Less/openless/issues/269) requested pausing and restoring background media. It is closed. +- [PR #274](https://github.com/Open-Less/openless/pull/274) implemented the current “Mute while recording” behavior. Its description says the implementation mutes the system output instead of pausing each media application. Its automated review recorded the important limitation: media continues advancing while muted, and macOS/Linux needed real-device verification. The resulting commits were `9511eb6`, `bb0fefc`, and `78a3846`. +- [PR #1194](https://github.com/Open-Less/openless/pull/1194), merged for #1026, added opt-in RMS/downmix microphone enhancement and explicitly documented that it does **not** include echo cancellation. Its merge commit is `44972015349cac3a52c4a28190675554a8986fcb`. + +## 2. Current audio architecture + +The following bullets describe the committed code at the baseline revision above; they explain why the feature branch adds both a shared DSP seam and host-specific reference capture: + +- `openless-all/app/crates/openless-core/src/audio.rs` owns the canonical 16 kHz, mono, signed-16-bit PCM normalization contract. The baseline `AudioProcessingConfig`/`AudioProcessor` path implemented optional microphone enhancement/noise gating but had no render-reference input or AEC state ([file at baseline revision](https://github.com/Open-Less/openless/blob/de072a88373a222280a8c22948092a260880196c/openless-all/app/crates/openless-core/src/audio.rs)). +- `openless-all/app/src-tauri/src/recorder.rs` opens a `cpal` input stream, converts the selected microphone format to `f32`, then sends microphone callback data through the processor/normalizer. The baseline had no corresponding render/loopback stream in this adapter ([file](https://github.com/Open-Less/openless/blob/de072a88373a222280a8c22948092a260880196c/openless-all/app/src-tauri/src/recorder.rs)). +- `openless-all/app/linux-egui/src/audio.rs` likewise owned a `cpal` microphone input stream and forwarded normalized chunks. The baseline had no playback-monitor/reference capture path ([file](https://github.com/Open-Less/openless/blob/de072a88373a222280a8c22948092a260880196c/openless-all/app/linux-egui/src/audio.rs)). +- `openless-all/app/src-tauri/src/audio_mute.rs` is an output-mute guard: Windows uses `IAudioEndpointVolume`, macOS invokes `osascript` to set output mute, and Linux tries `wpctl` then `pactl`. It records and restores mute state, but it does not observe or process the render signal ([file](https://github.com/Open-Less/openless/blob/de072a88373a222280a8c22948092a260880196c/openless-all/app/src-tauri/src/audio_mute.rs)). +- `openless-all/app/crates/openless-core/src/ports.rs` states that each host owns device selection, permissions, resampling, and native stream implementation. This is the seam used by the feature branch for host-specific render-reference capture while keeping cancellation inside the existing PCM pipeline ([file](https://github.com/Open-Less/openless/blob/de072a88373a222280a8c22948092a260880196c/openless-all/app/crates/openless-core/src/ports.rs)). + +### Feature-branch implementation boundary + +The feature branch adds a session-frozen, opt-in `echo_cancellation_enabled` preference, excluded from cloud sync because it is host/device dependent. `ReferenceAudioBuffer` keeps at most two seconds of downmixed render samples and bypasses cancellation on startup starvation or underrun. `AudioProcessor::process_with_reference` resamples microphone and reference data to the canonical rate, applies a bounded adaptive canceller before the existing microphone enhancement, and preserves the old microphone-only path when the switch is off or a reference is unavailable. + +The Tauri adapter uses cpal 0.18’s render-endpoint input path on desktop targets where the backend supplies loopback (WASAPI and cpal’s macOS CoreAudio loopback support). The Linux egui adapter attempts PipeWire sink capture and PulseAudio monitor sources; ALSA remains microphone-only. All reference samples remain transient and are not archived or transmitted. Any unavailable, invalidated, or runtime-failed reference leaves ordinary recording running without changing system volume or media playback state. The independent `mute_during_recording` option remains unchanged and is still the explicit output-mute fallback. + +## 3. What AEC requires + +### Render reference and timing are mandatory inputs + +The official WebRTC Audio Processing API describes approximately 10 ms linear PCM frames, sends the reverse/render stream to `ProcessReverseStream()`, sends microphone/capture audio to `ProcessStream()`, and exposes a stream-delay setting for the timing relationship. Its AEC configuration is therefore fundamentally different from a capture-only gain or noise gate ([WebRTC API header](https://webrtc.googlesource.com/src/+/refs/heads/main/api/audio/audio_processing.h)). WebRTC’s AEC3 interface similarly describes 10 ms band-split frames, a render-analysis path that stores the render signal, and a capture-processing path that removes estimated echo ([AEC3 header](https://webrtc.googlesource.com/src/+/refs/heads/main/modules/audio_processing/aec3/echo_canceller3.h)). + +A FIFO of samples is not automatically a valid reference. The render and capture clocks can differ, the acoustic path has an unknown delay and impulse response, and output routing can change. The implementation must either use a platform voice-processing unit or maintain timestamps/delay and bounded drift handling in a software AEC path. + +### Why the existing enhancement is insufficient + +PR #1194’s enhancement is a stateful RMS/noise-floor gate and gain/soft-clip stage. It has no render input, delay estimate, adaptive echo filter, or double-talk reference. It should remain separately configurable; enabling it must not be presented as enabling AEC ([PR #1194](https://github.com/Open-Less/openless/pull/1194)). + +## 4. Platform feasibility and constraints + +| Platform | Official capability | Feasibility for OpenLess | Main limitation / required adapter | +| --- | --- | --- | --- | +| Windows | WASAPI loopback captures the stream played by a render endpoint; Microsoft says loopback exists primarily to support AEC. It is shared-mode only, and endpoint/hardware/DRM behavior matters ([Loopback Recording](https://learn.microsoft.com/en-us/windows/win32/coreaudio/loopback-recording)). Windows also exposes Communications/Speech processing modes and effect discovery, but modes are driver-defined and optional ([Audio Signal Processing Modes](https://learn.microsoft.com/en-us/windows-hardware/drivers/audio/audio-signal-processing-modes); [APO architecture](https://learn.microsoft.com/en-us/windows-hardware/drivers/audio/audio-processing-object-architecture)). | **Best first desktop prototype.** Passive WASAPI loopback can preserve audible playback while supplying a reference to WebRTC APM/AEC3. | The current `cpal` input path does not expose this render-reference lifecycle. Use a Windows-specific WASAPI adapter, select/track the actual render endpoint, handle shared-mode and protected-content gaps, and avoid assuming a driver APO is present. | +| macOS | Apple’s VoiceProcessingIO unit provides voice processing such as echo cancellation and AGC ([VoiceProcessingIO](https://developer.apple.com/documentation/audiotoolbox/kaudiounitsubtype_voiceprocessingio)). | **Feasible for a controlled voice-I/O route; uncertain for arbitrary system playback.** | VoiceProcessingIO expects application playback through its output path. OpenLess currently mutes the system endpoint and does not own every application’s playback graph. A separate supported Core Audio render tap/monitor path and permissions would need investigation; otherwise report unsupported and retain fallback. Voice processing can change speech-oriented audio characteristics, so compare near-end fidelity. | +| Linux (PipeWire) | PipeWire’s official echo-cancel module creates virtual echo-cancel capture/playback nodes and streams. Playback sent to the echo-cancel sink supplies the reference; `monitor.mode` can capture a default sink monitor. The documented backend is `aec/libspa-aec-webrtc` ([PipeWire Echo Cancel](https://docs.pipewire.org/page_module_echo_cancel.html)). | **Feasible when PipeWire can own or monitor the playback route.** | The current `linux-egui` recorder is a generic `cpal` input path. Add a PipeWire-specific node/monitor adapter or use the virtual source/sink; do not assume an ALSA/Pulse/default-device capture has a synchronized monitor. Device/route changes and non-PipeWire systems require capability fallback. | +| Android | `AcousticEchoCanceler` was added in API 16, is device-dependent, must be checked with `isAvailable()`, and attaches to the `AudioRecord` audio session; `create()` may return `null` ([Android API reference](https://developer.android.com/reference/android/media/audiofx/AcousticEchoCanceler)). | **Feasible only through a native Android capture path on supported devices.** | The current shared recorder uses `cpal`; the repository’s Kotlin code handles microphone permission but does not expose an `AudioRecord` session/effect adapter. Add native session ownership, route-specific tests, and a clear unavailable state. | +| Portable software fallback | WebRTC APM/AEC3 is a cross-platform algorithmic option, but its official API still requires render and capture frames plus timing ([APM header](https://webrtc.googlesource.com/src/+/refs/heads/main/api/audio/audio_processing.h)). | **Feasible only where each host can provide a reliable render/monitor reference.** | A portable library does not create a system loopback source. Adding it without a platform reference would produce an apparent setting with weak or misleading results. It also adds binary size, CPU, latency, and real-time integration work; these are consistent with the complexity concerns recorded in #1223’s first comment. | + +The W3C `echoCancellation` constraint is a useful model for product wording, not a guarantee that every source supports AEC. The specification says `true` must attempt at least remote-only cancellation, while `all` must attempt all system-played sound; it also requires capabilities to report only `false` when cancellation is unavailable ([Media Capture and Streams, `echoCancellation`](https://w3c.github.io/mediacapture-main/#def-constraint-echoCancellation)). OpenLess should expose an equivalent capability/unsupported result instead of claiming universal support. + +## 5. Risks and mitigations + +1. **Wrong or missing reference.** A default render monitor may not correspond to the selected output, a Bluetooth route may add variable delay, and applications may use another sink. Mitigate with endpoint identity, timestamps, route-change invalidation, reference starvation bypass, and a visible capability/diagnostic state. +2. **Double-talk damage.** The user and video speaker can talk simultaneously. AEC must protect near-end speech; use a proven double-talk strategy and measure near-end word recall, not only echo reduction. +3. **Clock drift and buffering.** Separate render/capture clocks eventually move out of alignment. Bound queues, monitor underflow/overflow, and resample or adapt delay rather than draining an un-timestamped FIFO indefinitely. +4. **Real-time callback safety.** Audio callbacks must not block on a long DSP operation, allocate without bounds, or wait on a worker. Keep callback work bounded and use a lock-free or carefully bounded handoff where the platform requires it. +5. **Native processing conflicts.** Windows driver APOs and Android/macOS voice processing may already apply AEC/AGC/NS. Discover/record active effects where the platform permits and avoid unintentionally stacking processors ([Windows effect discovery guidance](https://learn.microsoft.com/en-us/windows-hardware/drivers/audio/audio-signal-processing-modes)). +6. **Protected or inaccessible playback.** Microsoft documents that loopback behavior depends on endpoint capabilities and that protected content may not be capturable ([WASAPI loopback](https://learn.microsoft.com/en-us/windows/win32/coreaudio/loopback-recording)). Treat reference loss as unsupported/bypass, never as silence that could train the canceller incorrectly. +7. **Privacy.** A render reference can contain all system audio, not only the video selected by the user. Keep it in-process, do not archive or transmit it, and document that it is used only transiently to process microphone PCM. +8. **Failure cleanup.** Existing mute behavior is best-effort and uses a guard restored on drop ([`audio_mute.rs`](https://github.com/Open-Less/openless/blob/de072a88373a222280a8c22948092a260880196c/openless-all/app/src-tauri/src/audio_mute.rs)). AEC startup, route changes, cancellation, recorder errors, and shutdown must preserve the same cleanup guarantees without muting playback when AEC is selected. + +## 6. Phased implementation recommendation + +### Phase 0: contract and observability + +- Add a session-frozen AEC mode separate from `mute_during_recording` and microphone enhancement: `off`, `native`, or `software` is preferable to a boolean if the UI needs to explain fallback. +- Define a host capability response containing platform, reference source, endpoint identity, availability, and reason for unavailability. +- Preserve current mute as an explicit fallback, never silently mute when the user selected playback-preserving AEC. +- Add deterministic tests for PCM framing, reference underflow/overflow, reset, cancellation, and “no reference means bypass.” +- Add an offline fixture containing far-end audio, a known delayed/filtered echo, near-end speech, and double-talk. Test both echo reduction and preservation of near-end samples. + +### Phase 1: Windows proof of value + +- Implement a Windows WASAPI shared-mode loopback capture on the render endpoint used for playback. +- Feed 10 ms render and microphone frames into WebRTC APM/AEC3, preserving stream delay and route identity. +- Measure speaker, wired, Bluetooth, default-device change, device unplug, Remote Desktop, and protected-content cases before exposing the setting broadly. +- Keep AEC off by default until real-device results meet the acceptance gates below. + +### Phase 2: Linux PipeWire + +- Detect PipeWire and create/use the documented echo-cancel source/sink or monitor path. Keep the generic `cpal` path as fallback. +- Test default sink monitor, per-application routing, route changes, PulseAudio compatibility mode, and systems without PipeWire. +- Do not advertise “Linux support” as a single guarantee; report the backend and route capability. + +### Phase 3: macOS and Android + +- macOS: prototype VoiceProcessingIO only for a controlled voice-processing route; separately investigate a supported system render tap and permission requirements. If arbitrary app playback cannot be referenced reliably, mark the feature unavailable rather than muting or claiming cancellation. +- Android: add a Kotlin/native `AudioRecord` adapter, attach `AcousticEchoCanceler` to the correct session, check both availability and `create()` result, and test speaker/headset/Bluetooth/device-vendor combinations. + +### Phase 4: product hardening + +- Add per-platform support text, diagnostic logging without raw audio, route-change handling, power/CPU telemetry, and a one-click AEC sample test. +- Keep the existing mute option and clarify that it is the deterministic fallback; keep microphone enhancement separately described as noise suppression, not AEC. + +## 7. Proposed acceptance and test matrix + +The following are proposed product gates, not guarantees stated by platform vendors. Record the exact device, OS, audio route, sample rates, output endpoint, and AEC mode for every run. + +| Test area | Required cases | Evidence / gate | +| --- | --- | --- | +| Baseline and toggle semantics | AEC off; enhancement off/on; mute off/on; AEC setting changed between sessions | Existing behavior is unchanged when AEC is off. AEC does not call endpoint mute or change system volume. Mute restores the user’s original state on normal stop, cancel, startup failure, runtime fault, and shutdown. | +| Far-end-only | Video speech, music, tones, silence at microphone; several levels and delays | Compare processed and unprocessed microphone recordings. False transcription rate decreases materially; report ERLE/SNR or another reproducible echo metric. | +| Near-end-only | User speech with no playback; quiet/normal/loud speech; short words and sustained speech | Near-end word recall/WER remains within an agreed regression budget; no audible clipping or gate-induced loss in the archived processed PCM. | +| Double talk | Video speech overlaps user speech at beginning, middle, and end of utterances | Near-end speech remains intelligible and transcribes; cancellation does not treat the user as echo. | +| Route/device matrix | Built-in speaker + mic, USB mic, wired headset, Bluetooth headset/speaker, output and input changes during capture | Each combination is either validated or reports unsupported/bypassed. No stale reference after endpoint change. | +| Platform matrix | Windows WASAPI loopback; PipeWire source/sink and monitor mode; macOS VoiceProcessingIO/tap candidate; Android supported and unsupported devices | Capability result identifies backend and reason. Unsupported platforms keep working without a crash and without unexpected mute. | +| Faults and permissions | Missing microphone permission, missing render permission/device, protected content, route unplug, stream underflow/overflow | Recording remains usable through documented bypass/fallback; no raw render audio is persisted or transmitted; cleanup is verified. | +| Timing and performance | Start/stop latency, reference starvation, clock drift, 10-minute capture, repeated sessions | Bounded queue memory, no callback watchdog failures, agreed p95 CPU/latency/power budget, and no unbounded growth. | +| Regression | History playback/retranscription, streaming ASR, QA/selection voice, archive retention, concurrent lifecycle races | Existing recorder/core contract tests pass; processed archive format remains canonical 16 kHz mono PCM WAV where enabled. | + +## Current implementation decision + +The feature branch follows the best-effort design above: AEC is opt-in, session-frozen, capability-by-fallback, and independent from output muting. The shared software canceller is covered by deterministic signal tests, while host-specific loopback and monitor paths remain subject to platform/device validation. The setting must not be presented as a guarantee of perfect cancellation. Windows WASAPI and macOS CoreAudio loopback are the primary desktop paths; PipeWire/Pulse monitor capture is attempted on Linux; unsupported routes, ALSA-only capture, mobile builds, protected content, and unavailable monitors continue with ordinary microphone processing. A route changed during an active session is not dynamically rebound by this phase; users must stop and start a new dictation session for the new output route to be selected, and the route-change acceptance cases remain hardware-validation work. + +The remaining acceptance work is real-device validation of endpoint identity, route changes, Bluetooth latency, double-talk speech preservation, and long-running clock drift. Those hardware-only results should be recorded per platform rather than inferred from the offline tests. + +## Primary sources + +- [Issue #1223 and comments](https://github.com/Open-Less/openless/issues/1223) +- [PR #274: mute system output](https://github.com/Open-Less/openless/pull/274) +- [PR #1194: lightweight microphone enhancement](https://github.com/Open-Less/openless/pull/1194) +- [WebRTC Audio Processing API](https://webrtc.googlesource.com/src/+/refs/heads/main/api/audio/audio_processing.h) +- [WebRTC EchoCanceller3](https://webrtc.googlesource.com/src/+/refs/heads/main/modules/audio_processing/aec3/echo_canceller3.h) +- [Microsoft WASAPI Loopback Recording](https://learn.microsoft.com/en-us/windows/win32/coreaudio/loopback-recording) +- [Microsoft Audio Signal Processing Modes](https://learn.microsoft.com/en-us/windows-hardware/drivers/audio/audio-signal-processing-modes) +- [Microsoft Audio Processing Object Architecture](https://learn.microsoft.com/en-us/windows-hardware/drivers/audio/audio-processing-object-architecture) +- [Apple VoiceProcessingIO](https://developer.apple.com/documentation/audiotoolbox/kaudiounitsubtype_voiceprocessingio) +- [PipeWire Echo Cancel module](https://docs.pipewire.org/page_module_echo_cancel.html) +- [Android AcousticEchoCanceler](https://developer.android.com/reference/android/media/audiofx/AcousticEchoCanceler) +- [W3C `echoCancellation` constraint](https://w3c.github.io/mediacapture-main/#def-constraint-echoCancellation) + +*The baseline history and primary-source citations were collected during the read-only investigation; the feature-branch implementation notes above describe the changes now under test.* diff --git a/openless-all/app/crates/openless-core/examples/enhance_audio.rs b/openless-all/app/crates/openless-core/examples/enhance_audio.rs index 7acf34d98..7da52a73a 100644 --- a/openless-all/app/crates/openless-core/examples/enhance_audio.rs +++ b/openless-all/app/crates/openless-core/examples/enhance_audio.rs @@ -29,6 +29,7 @@ fn main() -> Result<(), Box> { .collect(); let mut processor = AudioProcessor::new(AudioProcessingConfig { enhancement_enabled: true, + echo_cancellation_enabled: false, dsp: AudioDspConfig { noise_reduction: algorithm, ..Default::default() diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 85d00be1d..14d05219a 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -5321,6 +5321,7 @@ impl OpenLessBackend { pre_roll_ms: 500, audio_processing: crate::audio::AudioProcessingConfig { enhancement_enabled: preferences.audio_enhancement_enabled, + echo_cancellation_enabled: preferences.echo_cancellation_enabled, dsp: preferences.audio_dsp, }, }; diff --git a/openless-all/app/crates/openless-core/src/audio.rs b/openless-all/app/crates/openless-core/src/audio.rs index 46233a7ab..3b680888d 100644 --- a/openless-all/app/crates/openless-core/src/audio.rs +++ b/openless-all/app/crates/openless-core/src/audio.rs @@ -1,5 +1,9 @@ //! Framework-independent audio normalization used by native recorder adapters. +#[path = "audio/echo.rs"] +mod echo; +pub use echo::ReferenceAudioBuffer; + pub const DICTATION_SAMPLE_RATE: u32 = 16_000; const LEVEL_RMS_GAIN: f32 = 4.0; @@ -13,6 +17,7 @@ pub struct NormalizedPcmChunk { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct AudioProcessingConfig { pub enhancement_enabled: bool, + pub echo_cancellation_enabled: bool, pub dsp: crate::audio_dsp::AudioDspConfig, } @@ -24,6 +29,7 @@ pub struct AudioProcessor { config: AudioProcessingConfig, normalizer: PcmNormalizer, enhanced: Option, + echo: Option, finished: bool, } @@ -41,6 +47,9 @@ impl AudioProcessor { config, normalizer: Default::default(), enhanced: None, + echo: config + .echo_cancellation_enabled + .then(echo::EchoProcessor::default), finished: false, } } @@ -97,6 +106,21 @@ impl AudioProcessor { pcm_chunk(&processed) } + /// A playback gap bypasses only cancellation; enhancement keeps its format and tail. + pub fn process_with_reference( + &mut self, + input: &[f32], + channels: usize, + rate: u32, + reference: Option<&[f32]>, + ) -> Option { + let Some(echo) = self.echo.as_mut() else { + return self.process(input, channels, rate); + }; + let samples = echo.process(input, channels, rate, reference); + self.process(&samples, 1, DICTATION_SAMPLE_RATE) + } + /// Idempotent end-of-stream barrier. Flushes fractional resampling state, /// a partial 10 ms frame and model lookahead without adding padding to PCM. pub fn finish(&mut self) -> Option { diff --git a/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs b/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs index bd04ee7be..59e82629d 100644 --- a/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs +++ b/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs @@ -5,6 +5,7 @@ use crate::{AudioDspConfig, NoiseReduction}; fn config(algorithm: NoiseReduction) -> AudioProcessingConfig { AudioProcessingConfig { enhancement_enabled: true, + echo_cancellation_enabled: false, dsp: AudioDspConfig { noise_reduction: algorithm, agc_enabled: false, @@ -174,6 +175,7 @@ fn default_agc_does_not_amplify_denoised_room_noise_back_to_original_level() { for algorithm in [NoiseReduction::Rnnoise, NoiseReduction::Purevox] { let config = AudioProcessingConfig { enhancement_enabled: true, + echo_cancellation_enabled: false, dsp: AudioDspConfig { noise_reduction: algorithm, ..Default::default() diff --git a/openless-all/app/crates/openless-core/src/audio/echo.rs b/openless-all/app/crates/openless-core/src/audio/echo.rs new file mode 100644 index 000000000..c2a03c4ab --- /dev/null +++ b/openless-all/app/crates/openless-core/src/audio/echo.rs @@ -0,0 +1,537 @@ +//! Timestamped playback reference and optional software cancellation. +use super::{PcmNormalizer, DICTATION_SAMPLE_RATE}; +use std::collections::VecDeque; +use std::sync::{Condvar, Mutex}; +use std::time::{Duration, Instant}; + +struct ReferenceChunk { + captured_at: Instant, + samples: Vec, +} +struct ReferenceState { + chunks: VecDeque, + wait_for_callback: bool, +} + +/// Two seconds of transient playback. Hosts map hardware capture timestamps to +/// `Instant`; callback arrival order must never determine audio alignment. +pub struct ReferenceAudioBuffer { + sample_rate: u32, + state: Mutex, + ready: Condvar, +} +impl ReferenceAudioBuffer { + pub fn new(sample_rate: u32) -> Self { + Self { + sample_rate, + state: Mutex::new(ReferenceState { + chunks: VecDeque::new(), + wait_for_callback: true, + }), + ready: Condvar::new(), + } + } + pub fn push_interleaved_at(&self, samples: &[f32], channels: usize, captured_at: Instant) { + if channels == 0 || self.sample_rate == 0 || samples.len() < channels { + return; + } + let mut mono = samples + .chunks_exact(channels) + .map(|frame| frame.iter().map(|&s| finite_sample(s)).sum::() / channels as f32) + .collect::>(); + let discard = mono.len().saturating_sub(self.sample_rate as usize * 2); + mono.drain(..discard); + let captured_at = captured_at + sample_duration(discard, self.sample_rate); + let end = captured_at + sample_duration(mono.len(), self.sample_rate); + let Ok(mut state) = self.state.try_lock() else { + return; + }; + if state + .chunks + .back() + .is_some_and(|last| captured_at < last.captured_at) + { + state.chunks.clear(); + } + state.chunks.push_back(ReferenceChunk { + captured_at, + samples: mono, + }); + while state.chunks.len() > 1 + && state.chunks.front().is_some_and(|first| { + end.saturating_duration_since(first.captured_at) > Duration::from_secs(2) + }) + { + state.chunks.pop_front(); + } + state.wait_for_callback = true; + self.ready.notify_one(); + } + /// A discontinuity invalidates the learned path; fresh callbacks can resume it. + pub fn invalidate(&self) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.chunks.clear(); + state.wait_for_callback = false; + self.ready.notify_one(); + } + /// Sample playback at microphone frame times, retaining fractional positions + /// rather than rounding up and consuming an extra source sample per callback. + pub fn take_for_input_at( + &self, + frames: usize, + rate: u32, + captured_at: Instant, + ) -> Option> { + if frames == 0 || rate == 0 || self.sample_rate == 0 { + return None; + } + let last_time = captured_at + sample_duration(frames - 1, rate); + let deadline = Instant::now() + Duration::from_millis(100); + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + loop { + if state + .chunks + .front() + .is_some_and(|first| captured_at < first.captured_at) + { + return None; + } + if state.chunks.back().is_some_and(|last| { + last_time < last.captured_at + sample_duration(last.samples.len(), self.sample_rate) + }) { + break; + } + if !state.wait_for_callback { + return None; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + state.wait_for_callback = false; + return None; + } + let (next, timeout) = self + .ready + .wait_timeout(state, remaining) + .unwrap_or_else(std::sync::PoisonError::into_inner); + state = next; + if timeout.timed_out() { + state.wait_for_callback = false; + return None; + } + } + let mut chunk_index = 0; + let mut output = Vec::with_capacity(frames); + for frame in 0..frames { + let time = captured_at + sample_duration(frame, rate); + loop { + let chunk = state.chunks.get(chunk_index)?; + let offset = time + .checked_duration_since(chunk.captured_at)? + .as_secs_f64() + * self.sample_rate as f64; + let index = offset.floor() as usize; + if index >= chunk.samples.len() { + chunk_index += 1; + continue; + } + let left = chunk.samples[index]; + let right = chunk.samples.get(index + 1).copied().unwrap_or(left); + output.push(left + (right - left) * offset.fract() as f32); + break; + } + } + Some(output) + } +} +fn sample_duration(frames: usize, rate: u32) -> Duration { + Duration::from_nanos(frames as u64 * 1_000_000_000 / u64::from(rate)) +} +fn finite_sample(sample: f32) -> f32 { + if sample.is_finite() { + sample.clamp(-1.0, 1.0) + } else { + 0.0 + } +} + +#[derive(Default)] +pub(super) struct EchoProcessor { + microphone: PcmNormalizer, + reference: PcmNormalizer, + canceller: EchoCanceller, + rate: u32, +} +impl EchoProcessor { + pub fn process( + &mut self, + input: &[f32], + channels: usize, + rate: u32, + reference: Option<&[f32]>, + ) -> Vec { + if channels == 0 || rate == 0 || input.len() < channels { + return Vec::new(); + } + if rate != self.rate { + *self = Self::default(); + self.rate = rate; + } + let microphone = input + .chunks_exact(channels) + .map(|frame| frame.iter().map(|&s| finite_sample(s)).sum::() / channels as f32) + .collect::>(); + let available = reference.filter(|samples| samples.len() == microphone.len()); + let render = match available { + Some(samples) => samples + .iter() + .map(|&s| finite_sample(s)) + .collect::>(), + None => vec![0.0; microphone.len()], + }; + let microphone = self + .microphone + .resample(µphone, rate, DICTATION_SAMPLE_RATE); + // Advance both resamplers through a gap, but never train on synthetic silence. + let render = self + .reference + .resample(&render, rate, DICTATION_SAMPLE_RATE); + if available.is_none() { + self.canceller.reset(); + return microphone; + } + self.canceller.process(µphone, &render) + } +} +const ECHO_FILTER_TAPS: usize = 1_024; +const ECHO_ADAPTATION_RATE: f32 = 0.12; +const ECHO_DOUBLE_TALK_RATIO: f32 = 2.5; +const ECHO_MIN_REFERENCE_POWER: f32 = 0.000_001; + +/// Conservative normalized-LMS echo canceller for a mono render reference. +/// +/// The filter is intentionally bounded and stateful. It learns the acoustic path while +/// the render signal is present, but pauses adaptation when near-end energy is clearly +/// stronger than the estimated echo (double talk). It is not a replacement for a +/// platform voice-processing stack; hosts therefore treat it as an optional best-effort +/// processor and bypass it when the render reference is unavailable. +#[derive(Debug, Clone)] +struct EchoCanceller { + coefficients: Vec, + history: Vec, + history_cursor: usize, +} + +impl Default for EchoCanceller { + fn default() -> Self { + Self { + coefficients: vec![0.0; ECHO_FILTER_TAPS], + history: vec![0.0; ECHO_FILTER_TAPS], + history_cursor: 0, + } + } +} + +impl EchoCanceller { + fn reset(&mut self) { + self.coefficients.fill(0.0); + self.history.fill(0.0); + self.history_cursor = 0; + } + + fn process(&mut self, microphone: &[f32], reference: &[f32]) -> Vec { + if microphone.is_empty() || reference.is_empty() { + return microphone.to_vec(); + } + let mut output = Vec::with_capacity(microphone.len()); + for (index, &sample) in microphone.iter().enumerate() { + let Some(&reference_sample) = reference.get(index) else { + // A resampler may produce one fewer sample at a chunk boundary. Do not + // repeat the last render value across the missing tail; preserve the + // microphone sample and let the next reference chunk re-establish alignment. + output.push(sample); + continue; + }; + self.history[self.history_cursor] = reference_sample; + let mut estimate = 0.0; + let mut reference_power = 0.0; + for tap in 0..ECHO_FILTER_TAPS { + let history_index = + (self.history_cursor + ECHO_FILTER_TAPS - tap) % ECHO_FILTER_TAPS; + let value = self.history[history_index]; + estimate += self.coefficients[tap] * value; + reference_power += value * value; + } + if !estimate.is_finite() { + self.reset(); + output.push(sample); + continue; + } + let error = sample - estimate; + let near_power = sample * sample; + let should_adapt = reference_power > ECHO_MIN_REFERENCE_POWER + && (estimate.abs() < 0.01 + || estimate * estimate * ECHO_DOUBLE_TALK_RATIO >= near_power + || near_power < ECHO_MIN_REFERENCE_POWER); + if should_adapt { + let normalization = reference_power + 0.000_01; + for tap in 0..ECHO_FILTER_TAPS { + let history_index = + (self.history_cursor + ECHO_FILTER_TAPS - tap) % ECHO_FILTER_TAPS; + self.coefficients[tap] += + ECHO_ADAPTATION_RATE * error * self.history[history_index] / normalization; + self.coefficients[tap] = self.coefficients[tap].clamp(-2.0, 2.0); + } + } + output.push(if error.is_finite() { error } else { 0.0 }); + self.history_cursor = (self.history_cursor + 1) % ECHO_FILTER_TAPS; + } + output + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audio::{AudioProcessingConfig, AudioProcessor}; + #[test] + fn non_finite_microphone_samples_do_not_poison_future_audio() { + let mut p = AudioProcessor::new(AudioProcessingConfig { + echo_cancellation_enabled: true, + ..Default::default() + }); + let far = vec![0.1; 160]; + let mut mic = vec![0.2; 160]; + mic[0] = f32::NAN; + mic[1] = f32::INFINITY; + p.process_with_reference(&mic, 1, 16_000, Some(&far)) + .unwrap(); + let out = p + .process_with_reference(&vec![0.2; 160], 1, 16_000, Some(&far)) + .unwrap(); + assert!(out + .pcm_i16_le + .as_chunks::<2>() + .0 + .iter() + .any(|b| i16::from_le_bytes([b[0], b[1]]) != 0)); + assert!(out.level.is_finite()); + } + #[test] + fn reference_conversion_has_no_rounding_drift_or_startup_backlog() { + let epoch = Instant::now(); + let b = ReferenceAudioBuffer::new(48_000); + b.push_interleaved_at(&vec![-0.75; 4800], 1, epoch); + let start = epoch + Duration::from_millis(100); + let mut published = 0_u64; + for callback in 0..4000_u64 { + let total = ((callback + 1) * 512 * 48_000).div_ceil(44_100); + b.push_interleaved_at( + &vec![0.25; (total - published) as usize], + 1, + start + sample_duration(published as usize, 48_000), + ); + published = total; + let chunk = b + .take_for_input_at( + 512, + 44_100, + start + sample_duration(callback as usize * 512, 44_100), + ) + .expect("equal-duration streams must stay aligned"); + assert_eq!(chunk.len(), 512); + assert!(chunk.iter().all(|&s| (s - 0.25).abs() < 1e-5)); + } + assert!( + b.state + .lock() + .unwrap() + .chunks + .iter() + .map(|c| c.samples.len()) + .sum::() + <= 96_558 + ); + } + #[test] + fn reference_gap_does_not_reuse_stale_samples() { + let epoch = Instant::now(); + let b = ReferenceAudioBuffer::new(16_000); + b.push_interleaved_at(&[0.25; 80], 1, epoch); + b.push_interleaved_at(&[0.75; 160], 1, epoch + Duration::from_millis(10)); + assert!(b.take_for_input_at(160, 16_000, epoch).is_none()); + assert_eq!( + b.take_for_input_at(160, 16_000, epoch + Duration::from_millis(10)) + .unwrap(), + vec![0.75; 160] + ); + b.invalidate(); + assert!(b + .take_for_input_at(160, 16_000, epoch + Duration::from_millis(10)) + .is_none()); + } + #[test] + fn reference_gaps_preserve_dsp_format_and_tail_duration() { + for rate in [16_000, 44_100, 48_000] { + let mut p = AudioProcessor::new(AudioProcessingConfig { + enhancement_enabled: true, + echo_cancellation_enabled: true, + ..Default::default() + }); + let mut samples = 0; + for available in [true, false, true, false] { + let far = vec![0.0; 317]; + if let Some(out) = p.process_with_reference( + &vec![0.25; 317], + 1, + rate, + available.then_some(far.as_slice()), + ) { + samples += out.pcm_i16_le.len() / 2; + } + } + if let Some(tail) = p.finish() { + samples += tail.pcm_i16_le.len() / 2; + } + assert_eq!( + samples, + (4_u64 * 317 * 16_000).div_ceil(u64::from(rate)) as usize + ); + assert!(p.finish().is_none()); + } + } + #[test] + fn startup_offset_is_aligned_before_the_causal_filter() { + let epoch = Instant::now(); + let b = ReferenceAudioBuffer::new(16_000); + let mut seed = 0x91234567_u32; + let far = (0..40000) + .map(|_| { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + ((seed >> 8) as f32 / 16777216.0 - 0.5) * 0.4 + }) + .collect::>(); + let mut c = EchoCanceller::default(); + let mut input_power = 0.0; + let mut output_power = 0.0; + for callback in 0..200 { + let time = 2400 + callback * 160; + let start = if callback == 0 { 0 } else { time }; + b.push_interleaved_at( + &far[start..time + 160], + 1, + epoch + sample_duration(start, 16_000), + ); + let r = b + .take_for_input_at(160, 16_000, epoch + sample_duration(time, 16_000)) + .unwrap(); + let mic = far[time - 160..time] + .iter() + .map(|s| s * 0.7) + .collect::>(); + let out = c.process(&mic, &r); + if callback >= 100 { + input_power += mic.iter().map(|s| s * s).sum::(); + output_power += out.iter().map(|s| s * s).sum::(); + } + } + assert!( + output_power < input_power * 0.5, + "{output_power} >= {input_power}" + ); + } + #[test] + fn echo_canceller_reduces_delayed_render_signal() { + let mut canceller = EchoCanceller::default(); + let mut reference = vec![0.0_f32; 16_000]; + for (index, sample) in reference.iter_mut().enumerate() { + *sample = (index as f32 * 0.071).sin() * 0.25; + } + let delay = 96; + let microphone = reference + .iter() + .enumerate() + .map(|(index, _)| { + reference + .get(index.saturating_sub(delay)) + .copied() + .unwrap_or_default() + * 0.7 + }) + .collect::>(); + let mut cancelled = Vec::new(); + for (mic, far) in microphone.chunks(160).zip(reference.chunks(160)) { + cancelled.extend(canceller.process(mic, far)); + } + let input_rms = microphone[2_000..] + .iter() + .map(|sample| sample * sample) + .sum::(); + let output_rms = cancelled[2_000..] + .iter() + .map(|sample| sample * sample) + .sum::(); + assert!(output_rms < input_rms * 0.35, "{output_rms} >= {input_rms}"); + } + + #[test] + fn echo_canceller_preserves_near_end_during_double_talk() { + let mut canceller = EchoCanceller::default(); + let mut reference = vec![0.0_f32; 12_000]; + for (index, sample) in reference.iter_mut().enumerate() { + // Deterministic broadband-ish reference gives the adaptive filter enough + // information to learn the delayed acoustic path. + *sample = (((index as u32) + .wrapping_mul(1_103_515_245) + .wrapping_add(12_345) + >> 16) as f32 + / 32_768.0 + - 1.0) + * 0.2; + } + let delay = 96; + let echo = |index: usize| { + reference + .get(index.saturating_sub(delay)) + .copied() + .unwrap_or_default() + * 0.7 + }; + for (index, far) in reference.chunks(160).enumerate() { + let start = index * 160; + let microphone = (start..start + far.len()).map(echo).collect::>(); + let _ = canceller.process(µphone, far); + } + + let mut output = Vec::new(); + let mut near_end = Vec::new(); + for (index, far) in reference[6_000..].chunks(160).enumerate() { + let start = 6_000 + index * 160; + let near = (start..start + far.len()) + .map(|sample| (sample as f32 * 0.017).sin() * 0.22) + .collect::>(); + let microphone = far + .iter() + .enumerate() + .map(|(offset, _sample)| echo(start + offset) + near[offset]) + .collect::>(); + output.extend(canceller.process(µphone, far)); + near_end.extend(near); + } + let dot = output + .iter() + .zip(&near_end) + .map(|(processed, near)| processed * near) + .sum::(); + let near_power = near_end.iter().map(|sample| sample * sample).sum::(); + assert!( + dot > near_power * 0.35, + "near-end correlation was suppressed" + ); + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs index 44e900442..420b9e4c7 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -186,6 +186,13 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ shape: PreferenceShape::Boolean, reason: "local_only_microphone_capture", }, + PreferenceField { + rust_name: "echo_cancellation_enabled", + key: "echoCancellationEnabled", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_only_microphone_capture", + }, PreferenceField { rust_name: "audio_enhancement_enabled", key: "audioEnhancementEnabled", diff --git a/openless-all/app/crates/openless-core/src/dictation_context.rs b/openless-all/app/crates/openless-core/src/dictation_context.rs index b82b76a0e..52992f475 100644 --- a/openless-all/app/crates/openless-core/src/dictation_context.rs +++ b/openless-all/app/crates/openless-core/src/dictation_context.rs @@ -335,6 +335,7 @@ impl DictationContext { .then(|| (preferences.silence_auto_stop_seconds * 1_000.0).round() as u64), audio_processing: AudioProcessingConfig { enhancement_enabled: preferences.audio_enhancement_enabled, + echo_cancellation_enabled: preferences.echo_cancellation_enabled, dsp: preferences.audio_dsp, }, }, diff --git a/openless-all/app/crates/openless-core/src/experimental_asr/tests.rs b/openless-all/app/crates/openless-core/src/experimental_asr/tests.rs index 7d8fef64d..a4725742a 100644 --- a/openless-all/app/crates/openless-core/src/experimental_asr/tests.rs +++ b/openless-all/app/crates/openless-core/src/experimental_asr/tests.rs @@ -593,7 +593,12 @@ async fn optional_runtime_files_are_not_download_progress() { let directory = f.store.model_dir(&Model::TextStream.target()).unwrap(); std::fs::create_dir_all(directory.join(".runtime/cache")).unwrap(); let unrelated = std::fs::File::create(directory.join(".runtime/cache/wheels")).unwrap(); - unrelated.set_len(5 * 1024 * 1024 * 1024).unwrap(); + // A modest non-model file is enough to catch accidental runtime-directory + // accounting without relying on sparse-file support from the host filesystem. + unrelated.set_len(5 * 1024 * 1024).unwrap(); + // Release the handle before the download commits a replacement model directory; + // Windows otherwise reports access denied while removing the fixture tree. + drop(unrelated); let status = f.service.status().unwrap(); assert_eq!(status[Model::TextStream.index()].downloaded_bytes, 0); f.service.set_enabled(Model::TextStream, true).unwrap(); diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 1f363893f..6ee3c0331 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -228,7 +228,7 @@ pub use api::{ }; pub use audio::{ encode_dictation_wav, AudioProcessingConfig, AudioProcessor, NormalizedPcmChunk, - PcmNormalizer, DICTATION_SAMPLE_RATE, + PcmNormalizer, ReferenceAudioBuffer, DICTATION_SAMPLE_RATE, }; pub use auxiliary::{ AsrCallLabel, AuxiliaryApi, RepolishRequest, RetranscriptionFailure, RetranscriptionResult, diff --git a/openless-all/app/crates/openless-core/src/preferences.rs b/openless-all/app/crates/openless-core/src/preferences.rs index 817d7663e..49352da57 100644 --- a/openless-all/app/crates/openless-core/src/preferences.rs +++ b/openless-all/app/crates/openless-core/src/preferences.rs @@ -124,6 +124,7 @@ fn preferences_json_preserving_unknown( fn strip_local_only_preferences(value: &mut serde_json::Value) { if let serde_json::Value::Object(object) = value { object.remove("lowLatencyDictationEnabled"); + object.remove("echoCancellationEnabled"); object.remove("audioDsp"); object.remove("lowMemoryBackgroundEnabled"); } @@ -312,6 +313,10 @@ impl PreferencesStore { "lowLatencyDictationEnabled".to_string(), serde_json::Value::Bool(state.low_latency_dictation_enabled), ); + object.insert( + "echoCancellationEnabled".to_string(), + serde_json::Value::Bool(state.echo_cancellation_enabled), + ); } let parsed: UserPreferences = serde_json::from_value(value.clone()) .map_err(|_| persistence_error("validate restored preferences"))?; @@ -480,11 +485,13 @@ mod tests { fn cloud_snapshot_omits_local_microphone_warm_capture_preference() { let mut value = serde_json::json!({ "lowLatencyDictationEnabled": true, + "echoCancellationEnabled": true, "audioDsp": {"noiseReduction": "purevox"}, "microphoneDeviceName": "Built-in Mic", }); strip_local_only_preferences(&mut value); assert!(value.get("lowLatencyDictationEnabled").is_none()); + assert!(value.get("echoCancellationEnabled").is_none()); assert!(value.get("audioDsp").is_none()); assert_eq!(value["microphoneDeviceName"], "Built-in Mic"); } diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index ea06f2ee9..a7f5a9e34 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -427,6 +427,9 @@ pub struct UserPreferences { /// Disabled by default until an audio-quality baseline supports changing it. #[serde(default)] pub audio_enhancement_enabled: bool, + /// Optional local playback-reference cancellation; independent of enhancement. + #[serde(default)] + pub echo_cancellation_enabled: bool, #[serde(default)] pub audio_dsp: crate::audio_dsp::AudioDspConfig, /// Reconnect the current ASR after recording ends and submit the whole PCM. Default off. @@ -1028,6 +1031,8 @@ struct UserPreferencesWire { #[serde(default)] audio_enhancement_enabled: bool, #[serde(default)] + echo_cancellation_enabled: bool, + #[serde(default)] audio_dsp: crate::audio_dsp::AudioDspConfig, #[serde(default)] stable_transcription_enabled: bool, @@ -1314,6 +1319,7 @@ impl Default for UserPreferencesWire { capsule_transcript_font_size: prefs.capsule_transcript_font_size, mute_during_recording: prefs.mute_during_recording, audio_enhancement_enabled: prefs.audio_enhancement_enabled, + echo_cancellation_enabled: prefs.echo_cancellation_enabled, audio_dsp: prefs.audio_dsp.normalized(), stable_transcription_enabled: prefs.stable_transcription_enabled, low_latency_dictation_enabled: prefs.low_latency_dictation_enabled, @@ -1535,6 +1541,7 @@ impl<'de> Deserialize<'de> for UserPreferences { capsule_transcript_font_size: wire.capsule_transcript_font_size.clamp(12, 20), mute_during_recording: wire.mute_during_recording, audio_enhancement_enabled: wire.audio_enhancement_enabled, + echo_cancellation_enabled: wire.echo_cancellation_enabled, audio_dsp: wire.audio_dsp.normalized(), stable_transcription_enabled: wire.stable_transcription_enabled, low_latency_dictation_enabled: wire.low_latency_dictation_enabled, @@ -1934,6 +1941,7 @@ impl Default for UserPreferences { capsule_transcript_font_size: default_capsule_transcript_font_size(), mute_during_recording: false, audio_enhancement_enabled: false, + echo_cancellation_enabled: false, audio_dsp: Default::default(), stable_transcription_enabled: false, low_latency_dictation_enabled: false, diff --git a/openless-all/app/e2e/settings-regressions.spec.ts b/openless-all/app/e2e/settings-regressions.spec.ts index d0bf5de97..3e9c0d46c 100644 --- a/openless-all/app/e2e/settings-regressions.spec.ts +++ b/openless-all/app/e2e/settings-regressions.spec.ts @@ -236,7 +236,9 @@ test('voice activation opens model setup without leaving the multimodal pipeline }, testInfo) => { await page.addInitScript(() => { Object.defineProperty(navigator, 'platform', { get: () => 'Win32' }); - Object.defineProperty(navigator, 'userAgent', { get: () => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }); + Object.defineProperty(navigator, 'userAgent', { + get: () => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + }); }); await page.goto('http://127.0.0.1:5326/?os=win'); await page.getByRole('button', { name: '设置', exact: true }).click(); @@ -244,9 +246,7 @@ test('voice activation opens model setup without leaving the multimodal pipeline const categories = dialog.getByRole('navigation', { name: '设置分类' }); await categories.getByRole('button', { name: 'AI 服务与模型', exact: true }).click(); await dialog.getByRole('button', { name: '多模态模式', exact: true }).click(); - const models = dialog - .locator('.ol-service-views') - .getByRole('button', { name: /^本地模型/ }); + const models = dialog.locator('.ol-service-views').getByRole('button', { name: /^本地模型/ }); await expect(models).toBeDisabled(); await categories.getByRole('button', { name: '录音与输入', exact: true }).click(); await dialog.getByRole('switch', { name: '语音唤醒听写', exact: true }).click(); @@ -288,3 +288,23 @@ test('voice activation opens model setup without leaving the multimodal pipeline await categories.getByRole('button', { name: 'AI 服务与模型', exact: true }).click(); await expect(models).toBeDisabled(); }); + +test('playback echo cancellation is opt-in and does not change output muting', async ({ + page, +}, testInfo) => { + const dialog = await openSettings(page); + const cancellation = dialog.getByRole('switch', { name: '播放回声抑制', exact: true }); + const mute = dialog.getByRole('switch', { name: '录音时静音', exact: true }); + await expect(cancellation).not.toBeChecked(); + const muted = await mute.isChecked(); + await cancellation.click(); + await expect(cancellation).toBeChecked(); + await expect(mute).toHaveAttribute('aria-checked', String(muted)); + await dialog.getByRole('button', { name: '外观与语言', exact: true }).click(); + await dialog.getByRole('button', { name: '录音与输入', exact: true }).click(); + await expect(cancellation).toBeChecked(); + await cancellation.scrollIntoViewIfNeeded(); + await page.screenshot({ path: testInfo.outputPath('playback-echo-cancellation.png') }); + await cancellation.click(); + await expect(cancellation).not.toBeChecked(); +}); diff --git a/openless-all/app/linux-egui/src/audio.rs b/openless-all/app/linux-egui/src/audio.rs index adb75bc7a..86722120b 100644 --- a/openless-all/app/linux-egui/src/audio.rs +++ b/openless-all/app/linux-egui/src/audio.rs @@ -38,6 +38,7 @@ const AUDIO_PROCESSING_QUEUE_CAPACITY: usize = 64; #[cfg(target_os = "linux")] struct RawAudioFrame { + captured_at: std::time::Instant, samples: Vec, channels: usize, input_sample_rate: u32, @@ -76,6 +77,7 @@ impl AudioProcessingWorker { consumer: Arc, progress: Arc, writer: Option>>, + reference_slot: Arc>>>, ) -> Result<(Self, SyncSender), BackendError> { let (sender, receiver) = sync_channel::(AUDIO_PROCESSING_QUEUE_CAPACITY); let started = std::time::Instant::now(); @@ -84,9 +86,23 @@ impl AudioProcessingWorker { .spawn(move || { let mut processor = openless_core::AudioProcessor::new(processing); while let Ok(frame) = receiver.recv() { - if let Some(chunk) = - processor.process(&frame.samples, frame.channels, frame.input_sample_rate) - { + let reference = reference_slot + .lock() + .expect("Linux echo reference lock poisoned") + .clone(); + let reference_samples = reference.as_ref().and_then(|buffer| { + buffer.take_for_input_at( + frame.samples.len() / frame.channels.max(1), + frame.input_sample_rate, + frame.captured_at, + ) + }); + if let Some(chunk) = processor.process_with_reference( + &frame.samples, + frame.channels, + frame.input_sample_rate, + reference_samples.as_deref(), + ) { consumer.consume_pcm_chunk(&chunk.pcm_i16_le); if let Some(writer) = &writer { if let Err(error) = writer @@ -426,14 +442,20 @@ fn run_audio_thread(args: LinuxAudioThreadArgs) { runtime_error, startup, } = args; - let (processor, frame_tx) = - match AudioProcessingWorker::start(audio_processing, consumer, progress, writer) { - Ok(worker) => worker, - Err(error) => { - let _ = startup.send(Err(error)); - return; - } - }; + let reference_slot = Arc::new(std::sync::Mutex::new(None)); + let (processor, frame_tx) = match AudioProcessingWorker::start( + audio_processing, + consumer, + progress, + writer, + Arc::clone(&reference_slot), + ) { + Ok(worker) => worker, + Err(error) => { + let _ = startup.send(Err(error)); + return; + } + }; let mut result = Err(BackendError::new( BackendErrorCode::Platform, "no Linux audio backend is available", @@ -463,6 +485,8 @@ fn run_audio_thread(args: LinuxAudioThreadArgs) { &frame_tx, &stop, &runtime_error, + &reference_slot, + audio_processing.echo_cancellation_enabled, ) { Ok(stream) => { result = Ok(stream); @@ -479,10 +503,10 @@ fn run_audio_thread(args: LinuxAudioThreadArgs) { } } - let stream = match result { - Ok(stream) => { + let streams = match result { + Ok(streams) => { let _ = startup.send(Ok(())); - stream + streams } Err(error) => { log::error!( @@ -499,7 +523,12 @@ fn run_audio_thread(args: LinuxAudioThreadArgs) { while !stop.load(std::sync::atomic::Ordering::Acquire) { std::thread::park_timeout(std::time::Duration::from_millis(25)); } - drop(stream); + drop(streams.microphone); + reference_slot + .lock() + .expect("Linux echo reference lock poisoned") + .take(); + drop(streams.reference); drop(frame_tx); let _ = processor.finish(); } @@ -511,6 +540,12 @@ fn audio_backend_order() -> [&'static str; 3] { ["pipewire", "pulseaudio", "alsa"] } +#[cfg(target_os = "linux")] +struct LinuxAudioStreams { + microphone: cpal::Stream, + reference: Option, +} + #[cfg(target_os = "linux")] #[allow(clippy::too_many_arguments)] fn try_start_audio_stream( @@ -520,18 +555,155 @@ fn try_start_audio_stream( frame_tx: &SyncSender, stop: &Arc, runtime_error: &Arc>>, + reference_slot: &Arc>>>, + echo_cancellation_enabled: bool, +) -> Result { + let device = select_input_device(host, preferred_device_name).map_err(|error| { + BackendError::new(error.code, format!("{backend} backend: {}", error.message)) + })?; + let stream = try_start_microphone_stream(&device, backend, frame_tx, stop, runtime_error)?; + let reference_stream = if echo_cancellation_enabled && backend != "alsa" { + match start_reference_stream(host, backend, reference_slot, stop) { + Ok(stream) => Some(stream), + Err(error) => { + log::warn!( + "{backend} playback monitor unavailable; recording without AEC: {error}" + ); + if backend == "pipewire" { + start_pulse_reference_fallback(reference_slot, stop) + } else { + None + } + } + } + } else { + None + }; + Ok(LinuxAudioStreams { + microphone: stream, + reference: reference_stream, + }) +} + +#[cfg(target_os = "linux")] +fn select_playback_monitor_device( + host: &cpal::Host, + backend: &str, +) -> Result { + use cpal::traits::{DeviceTrait, HostTrait}; + + let default_output_id = host + .default_output_device() + .and_then(|device| device.id().ok()) + .map(|id| id.id().to_owned()); + let devices = host + .devices() + .map_err(|error| classify_audio_error("enumerate playback monitors", error.to_string()))?; + for device in devices { + if !device.supports_input() { + continue; + } + let Ok(id) = device.id() else { + continue; + }; + if is_default_playback_reference(backend, id.id(), default_output_id.as_deref()) { + return Ok(device); + } + } + Err(BackendError::new( + BackendErrorCode::Unsupported, + format!("{backend} monitor of the default playback output was not found"), + )) +} + +#[cfg(any(target_os = "linux", test))] +fn is_default_playback_reference(backend: &str, id: &str, default_output: Option<&str>) -> bool { + match backend { + "pulseaudio" => { + default_output.is_some_and(|sink| id.strip_suffix(".monitor") == Some(sink)) + } + "pipewire" => id == "sink_default" || default_output == Some(id), + _ => false, + } +} + +#[cfg(target_os = "linux")] +fn start_pulse_reference_fallback( + reference_slot: &Arc>>>, + stop: &Arc, +) -> Option { + let host_id = cpal::available_hosts() + .into_iter() + .find(|id| id.name().eq_ignore_ascii_case("pulseaudio"))?; + let host = match cpal::host_from_id(host_id) { + Ok(host) => host, + Err(error) => { + log::debug!("PulseAudio reference fallback host unavailable: {error}"); + return None; + } + }; + match start_reference_stream(&host, "pulseaudio", reference_slot, stop) { + Ok(stream) => { + log::info!("using PulseAudio playback monitor as PipeWire AEC fallback"); + Some(stream) + } + Err(error) => { + log::debug!("PulseAudio playback monitor fallback unavailable: {error}"); + None + } + } +} + +#[cfg(target_os = "linux")] +fn start_reference_stream( + host: &cpal::Host, + backend: &str, + reference_slot: &Arc>>>, + stop: &Arc, ) -> Result { use cpal::traits::{DeviceTrait, StreamTrait}; - let device = select_input_device(host, preferred_device_name).map_err(|error| { - BackendError::new(error.code, format!("{backend} backend: {}", error.message)) + let device = select_playback_monitor_device(host, backend)?; + let supported = device.default_input_config().map_err(|error| { + classify_audio_error("default playback monitor config", error.to_string()) })?; + let config: cpal::StreamConfig = supported.config(); + let sample_rate = config.sample_rate; + let channels = usize::from(config.channels); + let reference = Arc::new(openless_core::ReferenceAudioBuffer::new(sample_rate)); + let stream = build_linux_reference_stream( + &device, + &config, + supported.sample_format(), + Arc::clone(&reference), + channels, + Arc::clone(stop), + )?; + stream + .play() + .map_err(|error| classify_audio_error("start playback monitor", error.to_string()))?; + *reference_slot + .lock() + .expect("Linux echo reference lock poisoned") = Some(reference); + Ok(stream) +} + +#[cfg(target_os = "linux")] +fn try_start_microphone_stream( + device: &cpal::Device, + backend: &str, + frame_tx: &SyncSender, + stop: &Arc, + runtime_error: &Arc>>, +) -> Result { + use cpal::traits::{DeviceTrait, StreamTrait}; + let mut native = None; let mut start = |supported: cpal::SupportedStreamConfig| -> Result { let accepting = Arc::new(std::sync::atomic::AtomicBool::new(false)); let config = supported.config(); let stream = build_input_stream( - &device, + device, &config, supported.sample_format(), supported.sample_rate(), @@ -623,6 +795,60 @@ fn native_input_alternatives( candidates } +#[cfg(target_os = "linux")] +fn build_linux_reference_stream( + device: &cpal::Device, + config: &cpal::StreamConfig, + sample_format: cpal::SampleFormat, + reference: Arc, + channels: usize, + stop: Arc, +) -> Result { + use cpal::traits::DeviceTrait; + macro_rules! make_stream { + ($t:ty, $to_f32:expr) => {{ + let reference = Arc::clone(&reference); + let error_reference = Arc::clone(&reference); + let stop_for_callback = Arc::clone(&stop); + device + .build_input_stream( + *config, + move |data: &[$t], info| { + if stop_for_callback.load(std::sync::atomic::Ordering::Acquire) { + return; + } + let captured_at = capture_time(info); + let samples = data.iter().copied().map($to_f32).collect::>(); + reference.push_interleaved_at(&samples, channels, captured_at); + }, + move |error| { + error_reference.invalidate(); + log::warn!("[linux-audio] playback monitor stream error: {error}"); + }, + None, + ) + .map_err(|error| classify_audio_error("build playback monitor", error.to_string())) + }}; + } + match sample_format { + cpal::SampleFormat::F32 => make_stream!(f32, |s: f32| s), + cpal::SampleFormat::F64 => make_stream!(f64, |s: f64| s as f32), + cpal::SampleFormat::I64 => make_stream!(i64, |s: i64| (s as f64 / i64::MAX as f64) as f32), + cpal::SampleFormat::U64 => make_stream!(u64, |s: u64| ((s as f64 / u64::MAX as f64) * 2.0 + - 1.0) as f32), + cpal::SampleFormat::U32 => make_stream!(u32, |s: u32| (s as f64 / u32::MAX as f64 * 2.0 + - 1.0) as f32), + cpal::SampleFormat::I16 => make_stream!(i16, |s: i16| s as f32 / i16::MAX as f32), + cpal::SampleFormat::U16 => make_stream!(u16, |s: u16| (s as f32 - 32768.0) / 32768.0), + cpal::SampleFormat::I32 => make_stream!(i32, |s: i32| s as f32 / i32::MAX as f32), + cpal::SampleFormat::I8 => make_stream!(i8, |s: i8| s as f32 / i8::MAX as f32), + cpal::SampleFormat::U8 => make_stream!(u8, |s: u8| (s as f32 - 128.0) / 128.0), + other => Err(BackendError::new( + BackendErrorCode::Unsupported, + format!("unsupported playback monitor sample format: {other:?}"), + )), + } +} #[cfg(target_os = "linux")] fn select_input_device( host: &cpal::Host, @@ -684,14 +910,16 @@ fn build_input_stream( device .build_input_stream::<$sample, _, _>( *config, - move |data: &[$sample], _| { + move |data: &[$sample], info| { if !accepting_audio.load(std::sync::atomic::Ordering::Acquire) || stop_for_callback.load(std::sync::atomic::Ordering::Acquire) { return; } + let captured_at = capture_time(info); let samples = data.iter().copied().map($to_f32).collect::>(); match frame_tx.try_send(RawAudioFrame { + captured_at, samples, channels, input_sample_rate, @@ -878,6 +1106,14 @@ fn classify_audio_error(context: &str, message: String) -> BackendError { BackendError::new(code, format!("Linux audio {context} failed: {message}")) } +#[cfg(target_os = "linux")] +fn capture_time(info: &cpal::InputCallbackInfo) -> std::time::Instant { + let now = std::time::Instant::now(); + let timestamp = info.timestamp(); + let delay = timestamp.callback.duration_since(timestamp.capture); + now.checked_sub(delay).unwrap_or(now) +} + #[cfg(test)] mod tests { use super::*; @@ -956,4 +1192,36 @@ mod tests { fn audio_backends_are_ordered_from_desktop_server_to_universal_fallback() { assert_eq!(audio_backend_order(), ["pipewire", "pulseaudio", "alsa"]); } + + #[test] + fn monitor_selection_uses_route_ids_not_translated_display_names() { + let sink = "alsa_output.usb-interface.analog-stereo"; + assert!(is_default_playback_reference( + "pulseaudio", + &format!("{sink}.monitor"), + Some(sink) + )); + assert!(!is_default_playback_reference( + "pulseaudio", + "another_output.monitor", + Some(sink) + )); + assert!(!is_default_playback_reference( + "pulseaudio", + sink, + Some(sink) + )); + assert!(!is_default_playback_reference( + "pulseaudio", + "any.monitor", + None + )); + assert!(is_default_playback_reference( + "pipewire", + "sink_default", + None + )); + assert!(is_default_playback_reference("pipewire", sink, Some(sink))); + assert!(!is_default_playback_reference("alsa", sink, Some(sink))); + } } diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index 13ca6fe9e..14558880c 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -2606,6 +2606,16 @@ pub const CATALOG: &[Msg] = &[ "음성 향상", ), }, + Msg { + key: "settings.recording.echo_cancellation_label", + text: row( + "播放回声抑制", + "播放回聲抑制", + "Playback echo cancellation", + "再生エコーキャンセル", + "재생 에코 제거", + ), + }, Msg { key: "settings.recording.mute_during_recording_label", text: row( @@ -2616,7 +2626,7 @@ pub const CATALOG: &[Msg] = &[ "녹음 중 음소거", ), }, - Msg { + Msg { key: "settings.recording.paste_shortcut_label", text: row( "模拟粘贴快捷键", @@ -4732,6 +4742,16 @@ pub const CATALOG: &[Msg] = &[ ), }, Msg { + key: "settings.recording.echo_cancellation_desc", + text: row( + "保持系统播放声音,同时使用桌面音频输出作为参考抑制回声。部分设备、蓝牙路径或 Linux ALSA 环境可能不可用。", + "保持系統播放聲音,同時使用桌面音訊輸出作為參考抑制回聲。部分裝置、藍牙路徑或 Linux ALSA 環境可能不可用。", + "Use desktop output as a reference while keeping playback audible; some devices, Bluetooth routes, or Linux ALSA setups may be unavailable.", + "再生音を聞きながらデスクトップ出力を参照してエコーを抑制します。デバイス、Bluetooth、Linux ALSA では利用できない場合があります。", + "재생을 계속 들으면서 데스크톱 출력을 참조해 에코를 줄입니다. 일부 장치, Bluetooth 경로 또는 Linux ALSA에서는 사용할 수 없을 수 있습니다.", + ), + }, + Msg { key: "settings.recording.paste_shortcut_desc", text: row( "插入时模拟按下的粘贴键,部分终端类应用需要 Ctrl+Shift+V(仅 Windows / Linux)。", diff --git a/openless-all/app/linux-egui/src/linux_app/settings_save.rs b/openless-all/app/linux-egui/src/linux_app/settings_save.rs index 6bb556f03..ce0a3cf24 100644 --- a/openless-all/app/linux-egui/src/linux_app/settings_save.rs +++ b/openless-all/app/linux-egui/src/linux_app/settings_save.rs @@ -233,6 +233,9 @@ impl OpenLessEguiApp { frontend::view_model::SettingsField::AudioEnhancement => { preferences.audio_enhancement_enabled = !preferences.audio_enhancement_enabled; } + frontend::view_model::SettingsField::EchoCancellation => { + preferences.echo_cancellation_enabled = !preferences.echo_cancellation_enabled; + } frontend::view_model::SettingsField::RecordAudioForDebug => { preferences.record_audio_for_debug = !preferences.record_audio_for_debug; } diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 63fe56c4c..bfa00c512 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -5874,6 +5874,7 @@ mod linux_app { }; s.mute_while_recording = prefs.mute_during_recording; s.audio_enhancement = prefs.audio_enhancement_enabled; + s.echo_cancellation = prefs.echo_cancellation_enabled; s.audio_dsp = prefs.audio_dsp; s.audio_cue = prefs.audio_cue_on_record; s.launch_at_login = prefs.launch_at_login; diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index 493a62327..a07b21be3 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -745,6 +745,17 @@ fn general(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec, pub mute_while_recording: bool, pub audio_enhancement: bool, + pub echo_cancellation: bool, pub audio_dsp: openless_core::AudioDspConfig, pub audio_cue: bool, /// 录音胶囊开关与样式(Tauri `capsuleLabel` / `capsuleStyleLabel`)。 @@ -1265,6 +1268,7 @@ impl Default for SettingsFields { microphone_error: None, mute_while_recording: false, audio_enhancement: false, + echo_cancellation: false, audio_dsp: Default::default(), audio_cue: false, show_capsule: true, diff --git a/openless-all/app/scripts/ci-macos-runner-contract.test.mjs b/openless-all/app/scripts/ci-macos-runner-contract.test.mjs new file mode 100644 index 000000000..809edb81c --- /dev/null +++ b/openless-all/app/scripts/ci-macos-runner-contract.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import yaml from 'js-yaml'; + +const workflow = yaml.load( + await readFile(new URL('../../../.github/workflows/ci.yml', import.meta.url), 'utf8'), +); + +const runnerInput = workflow.on.workflow_dispatch.inputs.macos_runner; +assert.equal(runnerInput.type, 'choice'); +assert.equal(runnerInput.default, 'macos-latest'); +assert.deepEqual(runnerInput.options, ['macos-latest', 'macos-15-intel']); + +const crossPlatform = workflow.jobs['cross-platform']; +assert.equal( + crossPlatform['runs-on'], + "${{ matrix.label == 'macOS' && github.event_name == 'workflow_dispatch' && inputs.macos_runner || matrix.os }}", + 'Only manual macOS checks may override the default matrix runner', +); +assert.equal( + workflow.jobs['macos-msrv']['runs-on'], + "${{ github.event_name == 'workflow_dispatch' && inputs.macos_runner || 'macos-latest' }}", + 'MSRV must share the manual fallback and keep its default for push/PR events', +); +assert.ok(crossPlatform.strategy.matrix.include.includes('"os":"windows-latest"')); +assert.equal(crossPlatform.strategy['fail-fast'], false); +for (const gate of [ + 'Build frontend and run frontend/contract tests', + 'Run Codex writable-roots sandbox contract', + 'Run Rust backend unit tests', + 'Run native recorder module tests (Windows)', +]) { + assert.ok( + crossPlatform.steps.some((step) => step.name === gate), + `${gate} must remain enabled`, + ); +} + +console.log('ci-macos-runner-contract.test.mjs passed'); diff --git a/openless-all/app/scripts/macos-runtime-minimum-version-contract.test.mjs b/openless-all/app/scripts/macos-runtime-minimum-version-contract.test.mjs index 0775995e7..fa61da31d 100644 --- a/openless-all/app/scripts/macos-runtime-minimum-version-contract.test.mjs +++ b/openless-all/app/scripts/macos-runtime-minimum-version-contract.test.mjs @@ -1,4 +1,5 @@ import { readFile } from 'node:fs/promises'; +import assert from 'node:assert/strict'; const raw = await readFile(new URL('../src-tauri/tauri.conf.json', import.meta.url), 'utf-8'); const config = JSON.parse(raw); @@ -16,3 +17,15 @@ if (major < 14 || (major === 14 && minor < 0)) { } console.log(`macOS runtime minimum-system-version contract passed: ${minimumSystemVersion}`); + +const plist = await readFile(new URL('../src-tauri/Info.plist', import.meta.url), 'utf8'); +const tap = await readFile( + new URL('../src-tauri/src/playback_reference_macos.m', import.meta.url), + 'utf8', +); +const manifest = await readFile(new URL('../src-tauri/Cargo.toml', import.meta.url), 'utf8'); +assert.match(plist, /NSAudioCaptureUsageDescription<\/key>\s*[^<]+<\/string>/); +assert.match(tap, /@available\(macOS 14\.6, \*\)/); +assert.match(tap, /initWithProcesses:/); +assert.match(tap, /setMuteBehavior:CATapUnmuted/); +assert.match(manifest, /cpal = \{ version = "=0\.15\.3", path = "vendor\/cpal" \}/); diff --git a/openless-all/app/scripts/test-recorder-module.py b/openless-all/app/scripts/test-recorder-module.py index 0e061e778..44db24747 100644 --- a/openless-all/app/scripts/test-recorder-module.py +++ b/openless-all/app/scripts/test-recorder-module.py @@ -30,7 +30,16 @@ thiserror = "1" log = "0.4" """ + "cpal = { version = \"=0.15.3\", path = " + json.dumps((recorder.parents[1] / "vendor/cpal").as_posix()) + " }\n" - + "micyou-dsp = { path = " + json.dumps((recorder.parents[2] / "crates/micyou-dsp").as_posix()) + " }\n") + + "micyou-dsp = { path = " + json.dumps((recorder.parents[2] / "crates/micyou-dsp").as_posix()) + " }\n" + + "[build-dependencies]\ncc = \"1\"\n[target.'cfg(target_os = \"macos\")'.dependencies]\ncoreaudio-sys = \"0.2\"\n") + (root / "build.rs").write_text( + 'fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {\n' + + 'cc::Build::new().file(' + json.dumps(str(recorder.with_name("playback_reference_macos.m"))) + + ').flag("-fobjc-arc").flag("-mmacosx-version-min=14.0").compile("openless_playback_reference");\n' + + 'let compiler=cc::Build::new().get_compiler(); let resource=std::process::Command::new(compiler.path()).arg("-print-resource-dir").output().expect("find Apple compiler runtime"); assert!(resource.status.success());\n' + + 'let runtime=std::path::PathBuf::from(String::from_utf8(resource.stdout).unwrap().trim()).join("lib/darwin"); println!("cargo:rustc-link-search=native={}",runtime.display()); println!("cargo:rustc-link-lib=static=clang_rt.osx");\n' + + 'println!("cargo:rustc-link-lib=framework=Foundation"); println!("cargo:rustc-link-lib=framework=CoreAudio"); } }\n' + ) (root / "src/lib.rs").write_text( "extern crate self as openless_core;\n" "#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BackendErrorCode { InvalidArgument }\n" @@ -39,9 +48,10 @@ + "#[path = " + json.dumps(str(recorder.parents[2] / "crates/openless-core/src/audio_dsp.rs"), ensure_ascii=False) + "]\npub mod audio_dsp;\n" "pub use audio_dsp::{AudioDspConfig, AudioDspStage, NoiseReduction};\n" + "#[path = " + json.dumps(str(recorder.parents[2] / "crates/openless-core/src/audio.rs"), ensure_ascii=False) + "]\npub mod audio;\n" - "pub use audio::{AudioProcessingConfig, AudioProcessor};\n" + "pub use audio::{AudioProcessingConfig, AudioProcessor, ReferenceAudioBuffer};\n" "pub trait AudioConsumer: Send + Sync { fn consume_pcm_chunk(&self, pcm: &[u8]); }\n" + "#[path = " + json.dumps(str(recorder), ensure_ascii=False) + "]\npub mod recorder;\n" + + "#[path = " + json.dumps(str(recorder.with_name("native_audio_cue.rs")), ensure_ascii=False) + "]\npub mod native_audio_cue;\n" ) subprocess.run( ["cargo", "check" if check_only else "test", "--manifest-path", str(root / "Cargo.toml"), diff --git a/openless-all/app/src-tauri/Info.plist b/openless-all/app/src-tauri/Info.plist index e0da7d68f..ac22511e2 100644 --- a/openless-all/app/src-tauri/Info.plist +++ b/openless-all/app/src-tauri/Info.plist @@ -2,6 +2,8 @@ + NSAudioCaptureUsageDescription + OpenLess 使用系统播放声音作为临时参考来抑制听写回声,不会保存或上传播放参考。 NSMicrophoneUsageDescription OpenLess 需要麦克风权限来听写你的语音。 NSSpeechRecognitionUsageDescription diff --git a/openless-all/app/src-tauri/build.rs b/openless-all/app/src-tauri/build.rs index 914dac69e..5948fe13c 100644 --- a/openless-all/app/src-tauri/build.rs +++ b/openless-all/app/src-tauri/build.rs @@ -19,6 +19,14 @@ fn main() { // Apple system recognition is independent of downloadable model runtimes. println!("cargo:rustc-link-lib=framework=Speech"); link_macos_compiler_runtime(); + println!("cargo:rerun-if-changed=src/playback_reference_macos.m"); + cc::Build::new() + .file("src/playback_reference_macos.m") + .flag("-fobjc-arc") + .flag("-mmacosx-version-min=14.0") + .compile("openless_playback_reference"); + println!("cargo:rustc-link-lib=framework=Foundation"); + println!("cargo:rustc-link-lib=framework=CoreAudio"); let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); if target_arch == "aarch64" { build_experimental_gemma(); diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 2b869abce..b3288cfb5 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -231,6 +231,7 @@ pub(crate) fn sync_low_latency_capture_preferences(prefs: &UserPreferences) { .then(|| prefs.microphone_device_name.clone()), openless_core::AudioProcessingConfig { enhancement_enabled: prefs.audio_enhancement_enabled, + echo_cancellation_enabled: prefs.echo_cancellation_enabled, dsp: prefs.audio_dsp, }, ); diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index d3b383201..e59e41bb0 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -150,6 +150,7 @@ pub(crate) fn backend_dependencies( .then(|| prefs.microphone_device_name.clone()), openless_core::AudioProcessingConfig { enhancement_enabled: prefs.audio_enhancement_enabled, + echo_cancellation_enabled: prefs.echo_cancellation_enabled, dsp: prefs.audio_dsp, }, ); @@ -2007,6 +2008,7 @@ impl openless_core::VoiceActivationRuntime for TauriVoiceActivationRuntime { ); let audio_processing = openless_core::AudioProcessingConfig { enhancement_enabled: preferences_snapshot.audio_enhancement_enabled, + echo_cancellation_enabled: preferences_snapshot.echo_cancellation_enabled, dsp: preferences_snapshot.audio_dsp, }; if let Some(wake_asr) = wake_asr.as_ref() { @@ -2272,6 +2274,7 @@ impl openless_core::VoiceActivationHandle for TauriVoiceActivationHandle { .then(|| prefs.microphone_device_name.clone()), openless_core::AudioProcessingConfig { enhancement_enabled: prefs.audio_enhancement_enabled, + echo_cancellation_enabled: prefs.echo_cancellation_enabled, dsp: prefs.audio_dsp, }, ); diff --git a/openless-all/app/src-tauri/src/playback_reference.rs b/openless-all/app/src-tauri/src/playback_reference.rs new file mode 100644 index 000000000..3ed20a61f --- /dev/null +++ b/openless-all/app/src-tauri/src/playback_reference.rs @@ -0,0 +1,128 @@ +//! Playback-only capture, separate from the primary microphone backend. +use super::{CaptureDiagnostics, RecorderError}; +use openless_core::ReferenceAudioBuffer; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::time::Instant; + +pub(super) fn capture_time(info: &cpal::InputCallbackInfo) -> Instant { + let now = Instant::now(); + let timestamp = info.timestamp(); + match timestamp.callback.duration_since(×tamp.capture) { + Some(delay) => now.checked_sub(delay).unwrap_or(now), + None => timestamp + .capture + .duration_since(×tamp.callback) + .and_then(|ahead| now.checked_add(ahead)) + .unwrap_or(now), + } +} + +#[cfg(target_os = "windows")] +pub(super) struct PlaybackReference(cpal::Stream); +#[cfg(target_os = "windows")] +impl Drop for PlaybackReference { + fn drop(&mut self) { + use cpal::traits::StreamTrait; + if let Err(error) = self.0.pause() { + log::warn!("[recorder] echo reference pause failed: {error}"); + } + } +} + +#[cfg(target_os = "windows")] +pub(super) fn start( + stop: &Arc, + diagnostics: &Arc, +) -> Result<(PlaybackReference, Arc), RecorderError> { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + let device = cpal::default_host() + .default_output_device() + .ok_or_else(|| RecorderError::EngineFailed("no playback output device".into()))?; + let supported = device + .default_output_config() + .map_err(|e| RecorderError::EngineFailed(format!("echo reference output format: {e}")))?; + let config = supported.config(); + let channels = config.channels as usize; + let reference = Arc::new(ReferenceAudioBuffer::new(config.sample_rate.0)); + macro_rules! stream { + ($t:ty,$convert:expr) => {{ + let reference = Arc::clone(&reference); + let error_reference = Arc::clone(&reference); + let stop = Arc::clone(stop); + let callback_diagnostics = Arc::clone(diagnostics); + let error_diagnostics = Arc::clone(diagnostics); + device.build_input_stream( + &config, + move |data: &[$t], info| { + let captured_at = capture_time(info); + if stop.load(Ordering::Acquire) { + return; + } + callback_diagnostics.data_callback(true, true, data.len() / channels.max(1)); + let samples = data.iter().copied().map($convert).collect::>(); + reference.push_interleaved_at(&samples, channels, captured_at); + }, + move |error| { + error_reference.invalidate(); + let count = error_diagnostics.reference.record_error(); + error_diagnostics.event( + "P3", + "[\"H1\",\"H2\"]", + "reference_stream_error", + "reference_fault", + &format!("{{\"error_count\":{count}}}"), + ); + log::warn!("[recorder] echo reference stream error: {error}"); + }, + None, + ) + }}; + } + // WASAPI render endpoints are distinct from input endpoints; CPAL opens + // these with AUDCLNT_STREAMFLAGS_LOOPBACK, never as a microphone. + let stream = match supported.sample_format() { + cpal::SampleFormat::F32 => stream!(f32, |s: f32| s), + cpal::SampleFormat::F64 => stream!(f64, |s: f64| s as f32), + cpal::SampleFormat::I16 => stream!(i16, |s: i16| s as f32 / i16::MAX as f32), + cpal::SampleFormat::I32 => stream!(i32, |s: i32| s as f32 / i32::MAX as f32), + cpal::SampleFormat::I64 => stream!(i64, |s: i64| (s as f64 / i64::MAX as f64) as f32), + cpal::SampleFormat::U8 => stream!(u8, |s: u8| (s as f32 - 128.0) / 128.0), + cpal::SampleFormat::U16 => stream!(u16, |s: u16| (s as f32 - 32768.0) / 32768.0), + cpal::SampleFormat::U32 => stream!(u32, |s: u32| (s as f64 / u32::MAX as f64 * 2.0 - 1.0) + as f32), + cpal::SampleFormat::U64 => stream!(u64, |s: u64| (s as f64 / u64::MAX as f64 * 2.0 - 1.0) + as f32), + cpal::SampleFormat::I8 => stream!(i8, |s: i8| s as f32 / i8::MAX as f32), + _ => { + return Err(RecorderError::EngineFailed( + "unsupported playback reference sample format".into(), + )) + } + } + .map_err(super::classify_build_stream_err)?; + stream + .play() + .map_err(|e| RecorderError::EngineFailed(format!("start playback reference: {e}")))?; + Ok((PlaybackReference(stream), reference)) +} + +#[cfg(target_os = "macos")] +#[path = "playback_reference_macos.rs"] +mod macos; +#[cfg(target_os = "macos")] +pub(super) use macos::{start, PlaybackReference}; + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +pub(super) struct PlaybackReference; +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +pub(super) fn start( + _stop: &Arc, + _diagnostics: &Arc, +) -> Result<(PlaybackReference, Arc), RecorderError> { + Err(RecorderError::EngineFailed( + "playback reference is unavailable in this host; Linux uses the egui recorder".into(), + )) +} diff --git a/openless-all/app/src-tauri/src/playback_reference_macos.m b/openless-all/app/src-tauri/src/playback_reference_macos.m new file mode 100644 index 000000000..8ffc32fe9 --- /dev/null +++ b/openless-all/app/src-tauri/src/playback_reference_macos.m @@ -0,0 +1,99 @@ +// Keep new tap APIs behind Clang availability checks; ordinary recording still runs on 14.0. +#import +#import +#import +#import +#include +#include + +typedef void (*PlaybackCallback)(void *, const AudioBufferList *, uint64_t); +typedef struct { + AudioObjectID tap; + AudioObjectID aggregate; + AudioDeviceIOProcID proc; + PlaybackCallback callback; + void *context; + mach_timebase_info_data_t timebase; + bool running; +} PlaybackReference; + +static OSStatus playback_io(AudioObjectID device, const AudioTimeStamp *now, + const AudioBufferList *input, const AudioTimeStamp *input_time, + AudioBufferList *output, const AudioTimeStamp *output_time, void *context) { + PlaybackReference *reference=context; + uint64_t host=mach_absolute_time(); + uint64_t captured=(input_time && (input_time->mFlags & kAudioTimeStampHostTimeValid)) ? input_time->mHostTime : host; + uint64_t age=host>captured ? (uint64_t)((long double)(host-captured)*reference->timebase.numer/reference->timebase.denom) : 0; + reference->callback(reference->context,input,age); + return noErr; +} + +void openless_playback_destroy(void *handle) { + if (!handle) return; + PlaybackReference *reference=handle; + if (reference->running) AudioDeviceStop(reference->aggregate,reference->proc); + if (reference->proc) AudioDeviceDestroyIOProcID(reference->aggregate,reference->proc); + if (@available(macOS 14.6, *)) { + if (reference->aggregate) AudioHardwareDestroyAggregateDevice(reference->aggregate); + if (reference->tap) AudioHardwareDestroyProcessTap(reference->tap); + } + free(reference); +} + +void *openless_playback_create(uint32_t *rate, int32_t *status) { + *status=-1; + if (@available(macOS 14.6, *)) { + @autoreleasepool { + PlaybackReference *reference=calloc(1,sizeof(*reference)); + if (!reference) return NULL; + mach_timebase_info(&reference->timebase); + AudioObjectID output=0;UInt32 size=sizeof(output); + AudioObjectPropertyAddress property={kAudioHardwarePropertyDefaultOutputDevice,kAudioObjectPropertyScopeGlobal,kAudioObjectPropertyElementMain}; + *status=AudioObjectGetPropertyData(kAudioObjectSystemObject,&property,0,NULL,&size,&output); + if (*status!=noErr || output==kAudioObjectUnknown) {openless_playback_destroy(reference);return NULL;} + CFStringRef uid=NULL;size=sizeof(uid); + property.mSelector=kAudioDevicePropertyDeviceUID; + *status=AudioObjectGetPropertyData(output,&property,0,NULL,&size,&uid); + if (*status!=noErr || !uid) {openless_playback_destroy(reference);return NULL;} + NSString *device_uid=CFBridgingRelease(uid); + // Unlike CPAL's generic input path, this always taps output, even if + // the selected audio interface also has microphone input channels. + CATapDescription *tap=[[CATapDescription alloc] initWithProcesses:@[] andDeviceUID:device_uid withStream:0]; + [tap setPrivate:YES];[tap setExclusive:YES];[tap setMuteBehavior:CATapUnmuted]; + *status=AudioHardwareCreateProcessTap(tap,&reference->tap); + if (*status!=noErr) {openless_playback_destroy(reference);return NULL;} + AudioStreamBasicDescription format={0};size=sizeof(format); + property.mSelector=kAudioTapPropertyFormat; + *status=AudioObjectGetPropertyData(reference->tap,&property,0,NULL,&size,&format); + if (*status!=noErr || format.mFormatID!=kAudioFormatLinearPCM || !(format.mFormatFlags & kAudioFormatFlagIsFloat) || format.mBitsPerChannel!=32 || format.mSampleRate<=0 || format.mChannelsPerFrame==0) { + if (*status==noErr) *status=kAudioHardwareUnsupportedOperationError; + openless_playback_destroy(reference);return NULL; + } + NSDictionary *properties=@{ + @kAudioAggregateDeviceNameKey: @"OpenLess playback reference", + @kAudioAggregateDeviceUIDKey: [[NSUUID UUID] UUIDString], + @kAudioAggregateDeviceIsPrivateKey: @YES, + @kAudioAggregateDeviceTapAutoStartKey: @YES, + @kAudioAggregateDeviceTapListKey: @[@{ + @kAudioSubTapUIDKey: [[tap UUID] UUIDString], + @kAudioSubTapDriftCompensationKey: @YES + }] + }; + *status=AudioHardwareCreateAggregateDevice((__bridge CFDictionaryRef)properties,&reference->aggregate); + if (*status!=noErr) {openless_playback_destroy(reference);return NULL;} + *rate=(uint32_t)format.mSampleRate; + return reference; + } + } + return NULL; +} + +int32_t openless_playback_start(void *handle, PlaybackCallback callback, void *context) { + PlaybackReference *reference=handle; + reference->callback=callback;reference->context=context; + OSStatus status=AudioDeviceCreateIOProcID(reference->aggregate,playback_io,reference,&reference->proc); + if (status!=noErr) return status; + status=AudioDeviceStart(reference->aggregate,reference->proc); + reference->running=status==noErr; + return status; +} diff --git a/openless-all/app/src-tauri/src/playback_reference_macos.rs b/openless-all/app/src-tauri/src/playback_reference_macos.rs new file mode 100644 index 000000000..1063c7465 --- /dev/null +++ b/openless-all/app/src-tauri/src/playback_reference_macos.rs @@ -0,0 +1,120 @@ +//! An explicit Core Audio output tap, including on duplex audio interfaces. +use super::*; +use coreaudio_sys::AudioBufferList; +use std::ffi::c_void; +use std::time::Duration; + +unsafe extern "C" { + fn openless_playback_create(rate: *mut u32, status: *mut i32) -> *mut c_void; + fn openless_playback_start( + handle: *mut c_void, + callback: extern "C" fn(*mut c_void, *const AudioBufferList, u64), + context: *mut c_void, + ) -> i32; + fn openless_playback_destroy(handle: *mut c_void); +} + +struct CallbackContext { + reference: Arc, + stop: Arc, + diagnostics: Arc, +} + +pub(in super::super) struct PlaybackReference { + handle: *mut c_void, + _context: Box, +} +impl Drop for PlaybackReference { + fn drop(&mut self) { + // Stop and destroy the IOProc before freeing its Rust callback context. + unsafe { + openless_playback_destroy(self.handle); + } + } +} + +extern "C" fn callback(context: *mut c_void, input: *const AudioBufferList, age_ns: u64) { + let context = unsafe { &*(context as *const CallbackContext) }; + if context.stop.load(Ordering::Acquire) || input.is_null() { + return; + } + let captured_at = Instant::now() + .checked_sub(Duration::from_nanos(age_ns)) + .unwrap_or_else(Instant::now); + let input = unsafe { &*input }; + let buffers = unsafe { + std::slice::from_raw_parts(input.mBuffers.as_ptr(), input.mNumberBuffers as usize) + }; + let Some(first) = buffers.first().filter(|buffer| buffer.mNumberChannels > 0) else { + return; + }; + let frames = first.mDataByteSize as usize / 4 / first.mNumberChannels as usize; + let channels = buffers + .iter() + .map(|buffer| buffer.mNumberChannels as usize) + .sum::(); + if channels == 0 { + return; + } + let mut mono = vec![0.0; frames]; + for buffer in buffers { + let count = buffer.mNumberChannels as usize; + if buffer.mData.is_null() + || count == 0 + || buffer.mDataByteSize as usize != frames * count * 4 + { + context.reference.invalidate(); + return; + } + let samples = + unsafe { std::slice::from_raw_parts(buffer.mData.cast::(), frames * count) }; + for (output, frame) in mono.iter_mut().zip(samples.chunks_exact(count)) { + *output += frame + .iter() + .map(|sample| { + if sample.is_finite() { + *sample / channels as f32 + } else { + 0.0 + } + }) + .sum::(); + } + } + context.diagnostics.data_callback(true, true, frames); + context.reference.push_interleaved_at(&mono, 1, captured_at); +} + +pub(in super::super) fn start( + stop: &Arc, + diagnostics: &Arc, +) -> Result<(PlaybackReference, Arc), RecorderError> { + let mut rate = 0; + let mut status = 0; + let handle = unsafe { openless_playback_create(&mut rate, &mut status) }; + if handle.is_null() { + return Err(RecorderError::EngineFailed(format!("system audio tap needs macOS 14.6+ and System Audio Recording permission (status {status})"))); + } + let reference = Arc::new(ReferenceAudioBuffer::new(rate)); + let mut stream = PlaybackReference { + handle, + _context: Box::new(CallbackContext { + reference: Arc::clone(&reference), + stop: Arc::clone(stop), + diagnostics: Arc::clone(diagnostics), + }), + }; + let status = unsafe { + openless_playback_start( + handle, + callback, + (&mut *stream._context as *mut CallbackContext).cast(), + ) + }; + if status != 0 { + return Err(RecorderError::EngineFailed(format!( + "start system audio tap: {status}" + ))); + } + Ok((stream, reference)) +} diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index 40cf087d1..9b437481a 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -125,6 +125,127 @@ fn voice_capture_key(generation: u64) -> u64 { generation | VOICE_CAPTURE_KEY_PREFIX } +/// [DEBUG-ISSUE1223] Per-capture state shared by the two native stream roles. +/// Only the first callback and callbacks immediately following errors are logged. +struct CaptureDiagnostics { + run_id: u64, + microphone: StreamDiagnostics, + reference: StreamDiagnostics, +} + +#[derive(Default)] +struct StreamDiagnostics { + callbacks: AtomicUsize, + errors: AtomicUsize, + error_pending: AtomicBool, +} + +impl StreamDiagnostics { + fn record_error(&self) -> usize { + let count = self.errors.fetch_add(1, Ordering::Relaxed) + 1; + self.error_pending.store(true, Ordering::Release); + count + } + + fn record_callback(&self) -> (usize, bool) { + let count = self.callbacks.fetch_add(1, Ordering::Relaxed) + 1; + let after_error = self.error_pending.swap(false, Ordering::AcqRel); + (count, after_error) + } +} + +impl CaptureDiagnostics { + fn new() -> Self { + static NEXT_RUN: AtomicU64 = AtomicU64::new(1); + Self { + run_id: NEXT_RUN.fetch_add(1, Ordering::Relaxed), + microphone: StreamDiagnostics::default(), + reference: StreamDiagnostics::default(), + } + } + + /// Only code-controlled enums, booleans and numeric facts may enter the payload. + /// Use the existing logger; do not open files on native callback threads. + fn event( + &self, + probe_id: &str, + hypothesis_ids: &str, + event: &str, + outcome: &str, + safe_json: &str, + ) { + let timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + log::info!( + "[DEBUG-ISSUE1223] {{\"schema\":\"diagnosing-bugs.v1\",\"session_id\":\"s1223-20261019\",\"run_id\":\"pid-{}-capture-{}\",\"probe_id\":\"{}\",\"hypothesis_ids\":{},\"event\":\"{}\",\"outcome\":\"{}\",\"safe\":{},\"ts\":{}}}", + std::process::id(), + self.run_id, + probe_id, + hypothesis_ids, + event, + outcome, + safe_json, + timestamp_ms, + ); + } + + fn data_callback(&self, reference: bool, accepting_audio: bool, frames: usize) { + let stream = if reference { + &self.reference + } else { + &self.microphone + }; + let (count, after_error) = stream.record_callback(); + if count == 1 || after_error { + self.event( + "P5", + "[\"H1\",\"H2\",\"H3\"]", + "data_callback", + if after_error { "after_error" } else { "first_callback" }, + &format!( + "{{\"stream_role\":\"{}\",\"accepting_audio\":{},\"callback_count\":{},\"error_count\":{},\"frames\":{}}}", + if reference { "reference" } else { "microphone" }, + accepting_audio, + count, + stream.errors.load(Ordering::Relaxed), + frames, + ), + ); + } + } +} + +fn handle_microphone_stream_error( + state: &StreamState, + error: cpal::StreamError, + runtime_error_tx: &Sender, +) { + if !state.accepting_audio.load(Ordering::Acquire) { + return; + } + let error_count = state.diagnostics.microphone.record_error(); + // Vendored 0.15 does not turn CoreAudio overload notices into stream errors. + // Preserve its device-loss and backend-error contract. + let kind = match &error { + cpal::StreamError::DeviceNotAvailable => "device_not_available", + cpal::StreamError::BackendSpecific { .. } => "backend_error", + }; + state.diagnostics.event( + "P2", + "[\"H1\",\"H2\",\"H3\"]", + "microphone_stream_error", + "fatal_candidate", + &format!("{{\"error_kind\":\"{kind}\",\"error_count\":{error_count}}}"), + ); + log::error!("[recorder] stream error: {error}"); + let _ = runtime_error_tx.send(RecorderError::EngineFailed(format!("stream: {error}"))); +} + +#[path = "playback_reference.rs"] +mod playback_reference; + /// Downstream that receives resampled Int16 PCM bytes (little-endian). pub trait AudioConsumer: Send + Sync { /// Each delivery is a little-endian byte sequence of Int16 samples; @@ -531,7 +652,8 @@ impl WarmCaptureSink { } fn set_activation_consumer(&self, consumer: Option>) { - self.activation_consumer_during_capture.store(false, Ordering::Release); + self.activation_consumer_during_capture + .store(false, Ordering::Release); *self.activation_consumer.lock() = consumer; self.activation_vad.lock().reset(); } @@ -592,7 +714,10 @@ impl WarmCaptureSink { ) { let even_len = pcm.len() & !1; let incoming = &pcm[..even_len]; - let excess = buffer.len().saturating_add(incoming.len()).saturating_sub(max_bytes); + let excess = buffer + .len() + .saturating_add(incoming.len()) + .saturating_sub(max_bytes); let evictable_preroll = excess.min(*pre_roll_bytes); for _ in 0..(evictable_preroll / 2) { let _ = buffer.pop_front(); @@ -827,8 +952,8 @@ impl AudioConsumer for WarmCaptureSink { && self .activation_consumer_during_capture .load(Ordering::Acquire))) - .then(|| self.activation_consumer.lock().clone()) - .flatten(); + .then(|| self.activation_consumer.lock().clone()) + .flatten(); for target in targets { CaptureSink::deliver(&target, pcm); } @@ -1068,7 +1193,10 @@ impl WarmCaptureHub { pub(crate) fn clear_activation_fault_handler_if(&self, generation: u64) { let mut handler = self.sink.activation_fault_handler.lock(); - if handler.as_ref().is_some_and(|(current, _)| *current == generation) { + if handler + .as_ref() + .is_some_and(|(current, _)| *current == generation) + { *handler = None; } } @@ -1827,6 +1955,35 @@ fn run_audio_thread( startup_tx: Sender>, runtime_error_tx: Sender, ) { + let (reference_stream, reference) = if processing.echo_cancellation_enabled { + match playback_reference::start(&stop_flag, &state.diagnostics) { + Ok((stream, buffer)) => (Some(stream), Some(buffer)), + Err(error) => { + log::warn!( + "[recorder] playback reference unavailable; recording without AEC: {error}" + ); + (None, None) + } + } + } else { + (None, None) + }; + state.diagnostics.event( + "P1", + "[\"H1\",\"H2\",\"H3\"]", + "reference_start", + if reference.is_some() { + "available" + } else { + "fallback" + }, + &format!( + "{{\"aec_requested\":{},\"reference_available\":{}}}", + processing.echo_cancellation_enabled, + reference.is_some() + ), + ); + *state.reference.lock() = reference; let (processor, frame_tx) = match AudioProcessingWorker::start( processing, consumer, @@ -1934,6 +2091,7 @@ fn run_audio_thread( "[recorder] watchdog: 录音回调已停止 {} 秒,触发错误恢复", elapsed.as_secs() ); + state_for_watchdog.diagnostics.event("P4", "[\"H4\"]", "watchdog_fault", "callback_silent", "{}"); let _ = runtime_error_tx_for_watchdog.send(RecorderError::EngineFailed( format!("录音回调静默停止 {} 秒", elapsed.as_secs()), @@ -1949,6 +2107,7 @@ fn run_audio_thread( "[recorder] watchdog: {} 秒内未收到首次回调,触发错误恢复", elapsed.as_secs() ); + state_for_watchdog.diagnostics.event("P4", "[\"H4\"]", "watchdog_fault", "first_callback_timeout", "{}"); let _ = runtime_error_tx_for_watchdog.send(RecorderError::EngineFailed( format!("录音启动后 {} 秒内未收到回调", elapsed.as_secs()), @@ -1981,6 +2140,7 @@ fn run_audio_thread( log::warn!("[recorder] cpal Stream pause before drop failed: {err}"); } drop(stream); + drop(reference_stream); log::info!("[recorder] cpal Stream dropped (mic released)"); *state.processor_sender.lock() = None; drop(frame_tx); @@ -2246,29 +2406,27 @@ fn build_stream_for_format( let runtime_error_for_error = runtime_error_tx.clone(); let error_state = Arc::clone(&state); let err_cb = move |err| { - if !error_state.accepting_audio.load(Ordering::Acquire) { - return; - } - log::error!("[recorder] stream error: {err}"); - let _ = runtime_error_for_error - .send(RecorderError::EngineFailed(format!("stream: {err}"))); + handle_microphone_stream_error(&error_state, err, &runtime_error_for_error); }; device .build_input_stream::<$t, _, _>( config, - move |data: &[$t], _info| { + move |data: &[$t], info| { if !state.accepting_audio.load(Ordering::Acquire) { return; } if !data.is_empty() { state.note_first_pcm(false); } + let captured_at = playback_reference::capture_time(info); + state.diagnostics.data_callback(false, true, data.len() / channels.max(1)); let mut floats = Vec::with_capacity(data.len()); for s in data { floats.push($to_f32(*s)); } *state.last_callback_time.lock() = Some(std::time::Instant::now()); match frame_tx.try_send(AudioProcessingMessage::Frame(RawAudioFrame { + captured_at, samples: floats, channels, input_sample_rate: input_sr, @@ -2283,6 +2441,7 @@ fn build_stream_for_format( stop_flag.store(true, Ordering::Release); } Err(TrySendError::Full(_)) => { + state.diagnostics.event("P4", "[\"H4\"]", "processing_backpressure", "queue_full", "{}"); if state.accepting_audio.swap(false, Ordering::AcqRel) { let _ = runtime_error_for_callback.send( RecorderError::EngineFailed( @@ -2330,6 +2489,7 @@ fn build_stream_for_format( } struct RawAudioFrame { + captured_at: std::time::Instant, samples: Vec, channels: usize, input_sample_rate: u32, @@ -2489,10 +2649,13 @@ impl AudioProcessingWorker { } enhancement_enabled = false; } - if let Some(chunk) = processor.process( + let reference = state.reference.lock().clone(); + let render = reference.and_then(|buffer| buffer.take_for_input_at(frame.samples.len()/frame.channels.max(1), frame.input_sample_rate, frame.captured_at)); + if let Some(chunk) = processor.process_with_reference( &frame.samples, frame.channels, frame.input_sample_rate, + render.as_deref(), ) { deliver(chunk); } @@ -2543,6 +2706,8 @@ impl AudioProcessingWorker { /// State carried across callbacks: resample leftovers, diagnostic counters and peak. struct StreamState { + diagnostics: Arc, + reference: Mutex>>, capture_id: u64, created_at: std::time::Instant, first_raw_pcm_us: AtomicU64, @@ -2566,6 +2731,8 @@ impl StreamState { fn new() -> Self { Self { capture_id: NEXT_CAPTURE_ID.fetch_add(1, Ordering::Relaxed), + diagnostics: Arc::new(CaptureDiagnostics::new()), + reference: Mutex::new(None), created_at: std::time::Instant::now(), first_raw_pcm_us: AtomicU64::new(0), first_output_pcm_us: AtomicU64::new(0), @@ -2923,6 +3090,7 @@ mod tests { }; let frame = || { AudioProcessingMessage::Frame(RawAudioFrame { + captured_at: std::time::Instant::now(), samples: vec![0.25; samples], channels: 1, input_sample_rate: 48_000, @@ -2958,6 +3126,7 @@ mod tests { backlog: Arc::new(AudioProcessingBacklog::default()), }; let frame = AudioProcessingMessage::Frame(RawAudioFrame { + captured_at: std::time::Instant::now(), samples: vec![0.0; 48_000 * 64], channels: 64, input_sample_rate: 48_000, @@ -2999,6 +3168,7 @@ mod tests { block_next_level.store(true, Ordering::Release); sender .send(AudioProcessingMessage::Frame(RawAudioFrame { + captured_at: std::time::Instant::now(), samples: vec![0.25; 997], channels: 1, input_sample_rate: TARGET_SAMPLE_RATE, @@ -3011,6 +3181,7 @@ mod tests { for _ in 0..100 { assert!(sender .try_send(AudioProcessingMessage::Frame(RawAudioFrame { + captured_at: std::time::Instant::now(), samples: vec![0.5; 160], channels: 1, input_sample_rate: TARGET_SAMPLE_RATE, @@ -3045,6 +3216,45 @@ mod tests { assert_eq!(sender.backlog.bytes.load(Ordering::Acquire), 0); } + #[test] + fn playback_reference_uses_capture_times_after_an_early_start() { + let epoch=std::time::Instant::now(); + let state=Arc::new(StreamState::new()); + let reference=Arc::new(openless_core::ReferenceAudioBuffer::new(16_000)); + reference.push_interleaved_at(&[0.9;160],1,epoch); + reference.push_interleaved_at(&[0.0;1280],1,epoch+std::time::Duration::from_millis(10)); + *state.reference.lock()=Some(reference); + let consumer=Arc::new(RecordingConsumer::default()); + let (worker,sender)=AudioProcessingWorker::start( + openless_core::AudioProcessingConfig {echo_cancellation_enabled:true,..Default::default()}, + Arc::clone(&consumer) as Arc,Arc::new(|_|{}),None,state, + ).unwrap(); + for index in 1..=8 { + sender.send(AudioProcessingMessage::Frame(RawAudioFrame { + captured_at:epoch+std::time::Duration::from_millis(index*10),samples:vec![0.25;160],channels:1,input_sample_rate:16_000, + })).unwrap(); + } + drop(sender);worker.finish(); + let chunks=consumer.chunks.lock().unwrap();assert_eq!(chunks.len(),8); + assert!(chunks.iter().all(|chunk|decode_i16_le(chunk)==vec![8191;160])); + } + + #[test] + fn diagnostics_and_microphone_errors_stay_capture_scoped() { + let first=StreamState::new();let second=StreamState::new(); + assert_ne!(first.diagnostics.run_id,second.diagnostics.run_id); + let (sender,receiver)=channel(); + first.accepting_audio.store(false,Ordering::Release); + handle_microphone_stream_error(&first,cpal::StreamError::DeviceNotAvailable,&sender); + assert!(receiver.try_recv().is_err());assert_eq!(first.diagnostics.microphone.errors.load(Ordering::Relaxed),0); + first.accepting_audio.store(true,Ordering::Release); + handle_microphone_stream_error(&first,cpal::StreamError::DeviceNotAvailable,&sender); + assert!(matches!(receiver.try_recv(),Ok(RecorderError::EngineFailed(_)))); + assert_eq!(first.diagnostics.microphone.record_callback(),(1,true)); + assert_eq!(first.diagnostics.reference.record_callback(),(1,false)); + assert_eq!(second.diagnostics.microphone.record_callback(),(1,false)); + } + #[test] fn audio_processing_worker_drains_accepted_frames_before_join() { let consumer = Arc::new(RecordingConsumer::default()); @@ -3062,6 +3272,7 @@ mod tests { .unwrap(); sender .send(AudioProcessingMessage::Frame(RawAudioFrame { + captured_at: std::time::Instant::now(), samples: vec![0.25, -0.25], channels: 1, input_sample_rate: TARGET_SAMPLE_RATE, @@ -3100,6 +3311,7 @@ mod tests { .unwrap(); sender .send(AudioProcessingMessage::Frame(RawAudioFrame { + captured_at: std::time::Instant::now(), samples: vec![0.15; 317], channels: 1, input_sample_rate: TARGET_SAMPLE_RATE, @@ -3122,6 +3334,7 @@ mod tests { ); sender .send(AudioProcessingMessage::Frame(RawAudioFrame { + captured_at: std::time::Instant::now(), samples: vec![-0.15; 211], channels: 1, input_sample_rate: TARGET_SAMPLE_RATE, @@ -3160,6 +3373,7 @@ mod tests { for _ in 0..8 { sender .send(AudioProcessingMessage::Frame(RawAudioFrame { + captured_at: std::time::Instant::now(), samples: vec![0.25, -0.25], channels: 1, input_sample_rate: TARGET_SAMPLE_RATE, @@ -3628,8 +3842,14 @@ mod tests { sink.set_activation_consumer(None); sink.consume_pcm_chunk(&[5, 6]); - assert_eq!(consumer.chunks.lock().unwrap().as_slice(), &[vec![1, 2, 3, 4]]); - assert!(matches!(&*sink.state.lock(), WarmCaptureSinkState::Ring { .. })); + assert_eq!( + consumer.chunks.lock().unwrap().as_slice(), + &[vec![1, 2, 3, 4]] + ); + assert!(matches!( + &*sink.state.lock(), + WarmCaptureSinkState::Ring { .. } + )); } #[test] diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 83ca7fdbd..ad0cd7975 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1602,6 +1602,9 @@ export const de: typeof zhCN = { down: 'Move down', reset: 'Reset enhancement settings', }, + echoCancellationLabel: 'Wiedergabe-Echounterdrückung', + echoCancellationDesc: + 'Verwendet eine lokale Wiedergabereferenz, ohne die Wiedergabe stummzuschalten. macOS benötigt 14.6+ und die Berechtigung zur Systemaudioaufnahme. Nicht unterstützte Geräte, Bluetooth-Routen und Linux ALSA verwenden nur das Mikrofon. Gilt ab der nächsten Aufnahme.', audioEnhancementLabel: 'Sprachverbesserung', audioEnhancementDesc: 'RNNoise- oder PureVox-Rauschunterdrückung, automatische Verstärkung und Begrenzung. Standardmäßig aus; Änderungen gelten ab der nächsten Aufnahme.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index cabdfbd89..ffb814568 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1572,6 +1572,9 @@ export const en: typeof zhCN = { down: 'Move down', reset: 'Reset enhancement settings', }, + echoCancellationLabel: 'Playback echo cancellation', + echoCancellationDesc: + 'Uses a local playback reference while keeping playback audible. On macOS, requires 14.6+ and System Audio Recording permission. Unsupported devices, Bluetooth routes, or Linux ALSA use microphone-only recording. Applies to the next capture.', audioEnhancementLabel: 'Speech enhancement', audioEnhancementDesc: 'Apply RNNoise or PureVox noise reduction, automatic gain and limiting. Off by default; changes apply to the next recording.', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 0378847ea..ad2b8b5f8 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1593,6 +1593,9 @@ export const es: typeof zhCN = { down: 'Move down', reset: 'Reset enhancement settings', }, + echoCancellationLabel: 'Cancelación del eco de reproducción', + echoCancellationDesc: + 'Usa una referencia local sin silenciar la reproducción. En macOS requiere 14.6+ y permiso para grabar audio del sistema. Los dispositivos, rutas Bluetooth o entornos Linux ALSA no compatibles usan solo el micrófono. Se aplica en la próxima captura.', audioEnhancementLabel: 'Mejora de voz', audioEnhancementDesc: 'Reducción de ruido RNNoise o PureVox, ganancia automática y limitación. Desactivada por defecto; los cambios se aplican a la próxima grabación.', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index b9487f656..89873a641 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1613,6 +1613,9 @@ export const fr: typeof zhCN = { down: 'Move down', reset: 'Reset enhancement settings', }, + echoCancellationLabel: 'Annulation de l’écho de lecture', + echoCancellationDesc: + 'Utilise une référence de lecture locale sans couper le son. Sur macOS, nécessite 14.6+ et une autorisation de capture audio système. Les appareils, routes Bluetooth ou configurations Linux ALSA non pris en charge utilisent uniquement le microphone. Prend effet à la prochaine capture.', audioEnhancementLabel: 'Amélioration de la voix', audioEnhancementDesc: 'Réduction du bruit RNNoise ou PureVox, gain automatique et limitation. Désactivée par défaut ; les changements prennent effet au prochain enregistrement.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index c1d09e207..ade100dea 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1560,6 +1560,9 @@ export const ja: typeof zhCN = { down: 'Move down', reset: 'Reset enhancement settings', }, + echoCancellationLabel: '再生エコーキャンセル', + echoCancellationDesc: + '再生音を止めずにローカルの再生音を参照してエコーを抑制します。macOS は 14.6 以降とシステム音声の収録許可が必要です。非対応デバイス、Bluetooth 経路、Linux ALSA では通常のマイク録音に戻ります。次の収録開始時に適用されます。', audioEnhancementLabel: '音声強調', audioEnhancementDesc: 'RNNoise または PureVox のノイズ抑制、自動ゲイン、リミッターを適用します。既定ではオフ。変更は次の録音から有効です。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index dff252b94..4c970c55b 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1547,6 +1547,9 @@ export const ko: typeof zhCN = { down: 'Move down', reset: 'Reset enhancement settings', }, + echoCancellationLabel: '재생 에코 제거', + echoCancellationDesc: + '재생을 유지하면서 로컬 재생음을 참조합니다. macOS는 14.6 이상 및 시스템 오디오 녹음 권한이 필요합니다. 지원하지 않는 장치, Bluetooth 경로 또는 Linux ALSA에서는 일반 마이크 녹음으로 전환합니다. 다음 캡처부터 적용됩니다.', audioEnhancementLabel: '음성 향상', audioEnhancementDesc: 'RNNoise 또는 PureVox 소음 억제, 자동 게인 및 리미터를 적용합니다. 기본값은 꺼짐이며 다음 녹음부터 적용됩니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 6b92f58ea..4666e8162 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1494,6 +1494,9 @@ export const zhCN = { down: '下移', reset: '恢复增强默认设置', }, + echoCancellationLabel: '播放回声抑制', + echoCancellationDesc: + '保持系统声音播放,使用本机播放参考抑制回声。macOS 需 14.6 或更新版本及系统音频录制权限;不支持的设备、蓝牙路径或 Linux ALSA 环境回退为普通麦克风录音。下次开始采集时生效。', audioEnhancementLabel: '语音增强', audioEnhancementDesc: '应用 RNNoise 或 PureVox 降噪、自动增益和限幅。默认关闭,设置在下一次录音生效。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 533136f34..ed0393c74 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1496,6 +1496,9 @@ export const zhTW: typeof zhCN = { down: '下移', reset: '恢復增強預設設定', }, + echoCancellationLabel: '播放回聲抑制', + echoCancellationDesc: + '保持系統聲音播放,使用本機播放參考抑制回聲。macOS 需 14.6 或更新版本及系統音訊錄製權限;不支援的裝置、藍牙路徑或 Linux ALSA 環境改用一般麥克風錄音。下次開始擷取時生效。', audioEnhancementLabel: '語音增強', audioEnhancementDesc: '套用 RNNoise 或 PureVox 降噪、自動增益和限幅。預設關閉,設定於下一次錄音生效。', diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 29dbcc579..48a7178bb 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -51,6 +51,7 @@ export let mockSettings: UserPreferences = { lowLatencyDictationEnabled: false, lowMemoryBackgroundEnabled: false, audioEnhancementEnabled: false, + echoCancellationEnabled: false, voiceActivationEnabled: false, voiceActivationWakeEnabled: true, voiceActivationFinishEnabled: true, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index e3f0f0e66..34042ce47 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -392,6 +392,8 @@ export interface UserPreferences { lowLatencyDictationEnabled: boolean; /** Enable the session-frozen speech enhancement chain. Disabled by default. */ audioEnhancementEnabled: boolean; + /** Use a local playback reference for optional echo cancellation. Off by default. */ + echoCancellationEnabled: boolean; audioDsp?: AudioDspConfig; /** Desktop resource policy, applied after restarting the application. */ lowMemoryBackgroundEnabled: boolean; diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index 23373d44f..21631bfd5 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -275,6 +275,8 @@ export function RecordingInputSection({ savePrefs({ ...prefs, lowLatencyDictationEnabled }); const onAudioEnhancementChange = (audioEnhancementEnabled: boolean) => savePrefs({ ...prefs, audioEnhancementEnabled }); + const onEchoCancellationChange = (echoCancellationEnabled: boolean) => + savePrefs({ ...prefs, echoCancellationEnabled }); const onMicrophoneDeviceChange = (microphoneDeviceName: string) => savePrefs({ ...prefs, microphoneDeviceName }); const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) => @@ -484,6 +486,17 @@ export function RecordingInputSection({ )} + {platformCaps?.platform === 'desktop' && ( + + + + )} <> Date: Tue, 27 Oct 2026 14:50:31 +0800 Subject: [PATCH 13/31] =?UTF-8?q?feat(linux-egui):=20=E5=9C=A8=20Linux=20?= =?UTF-8?q?=E7=AB=AF=E6=95=B4=E4=BD=93=E5=88=A0=E9=99=A4=20Less=20Computer?= =?UTF-8?q?=20(#1274)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(linux-egui): 在 Linux 端整体删除 Less Computer 只动 Linux,Core 与 Tauri / 移动端不受影响:`openless_core::less_computer`、 `coding_agent` 领域、`src/`(React)、`src-tauri/`、iOS/Harmony 均未改动。 删除的东西(Linux 侧,共 25 个文件、−2905/+234): - 设置页:Beta「正在测试中的功能」总开关与 Agent 详情页(后端 / 权限 / 模型 / 工作目录 / 可执行文件)、快捷键页的 Agent 语音行。删掉总开关是因为它原本只 门禁 Less Computer,留着就成了「点了没反应」的假开关。 - 快照/事件:`less_computer_input|output|turn_start|session|entries|working| popup` 状态、`BackendEventKind::LessComputerEvent` 分支、`HostAction:: ShowLessComputer`(保留空分支并注明原因:Core 仍会发这个动作)。 - 弹窗协议:`PopupKind::LessComputer`、`HostToPopup::LessComputer`、 `PopupToHost::{Submit,Approve,Cancel,Dismiss}LessComputer`、 `LessComputerEntry/Approval/PopupState`、面板窗口尺寸,以及 `ui/frontend/popups.rs` 里 10 KB 的面板渲染(连带 4 个只服务于它的行内组件)。 - 热键:`LocalHotkey::LessComputer`、`LinuxHotkeyEvent::LessComputer{ Pressed, Released,Combined }`、fcitx5 的 `SetLessComputerHotkeyRaw` 方法与 `LessComputerKeyEvent` / `LessComputerKeyCombined` 信号(Rust 封装 + C++ 插件 + 配置键 + 近失诊断表),宿主端的采集租约控制(`LinuxLessComputerRecording Control`)与对应的 lib.rs 单测。 - CLI 进程适配器:整个 `linux-egui/src/coding_agent.rs`(369 行)与 `configure_coding_agent_process` 接线。 - i18n:清理 43 条只被上述界面引用的文案;`sync-egui-i18n.mjs` 的 KEEP_EGUI 换成代理说明(Linux 端不再提 Less Computer)。 顺带吸收上游 4 条文案漂移(nav.selection_ask / selection_ask.title / qa.title / settings.recording.capsule_style_classic,Tauri 侧已把 「划词追问」改成「问任何问题」),否则 `sync-egui-i18n --check` 会红。 验证:cargo check/clippy `-D warnings` 干净;`cargo test -p openless-linux-egui --all-targets` 261 + 217 + 其它集成测试全绿(含 localization_contract); fcitx5 插件 `build.sh` + `ctest` 通过;前端 144 个契约测试全绿; `cargo build --release --locked` 通过。 * fix(linux-gate): Less Computer 移除后 public-surface 门禁不再要求 reducer 消费它 check-linux-public-surface.ps1 里硬编码了 Linux 事件 reducer 必须出现 `LessComputerEventKind::*` 分支。功能在 Linux 端整体删除后这条断言已经过期, 改为只校验 replay 相关 token,并注明 Core 仍会发该事件、Linux 刻意不消费。 * fix(linux): retire removed Agent shortcut bindings * test(linux): isolate headless host settings effects * fix(linux): skip native effects for retired-only binding migration --------- Co-authored-by: Chris233 --- docs/architecture.md | 2 +- docs/linux-egui-backend-contract.md | 8 +- docs/linux-egui-handoff/04-ui-domains.md | 2 +- .../app/linux-egui/examples/headless_host.rs | 29 +- openless-all/app/linux-egui/src/backend.rs | 56 +- .../app/linux-egui/src/coding_agent.rs | 369 --------- openless-all/app/linux-egui/src/fcitx5.rs | 15 - openless-all/app/linux-egui/src/hotkeys.rs | 75 -- openless-all/app/linux-egui/src/i18n.rs | 764 +++++------------- openless-all/app/linux-egui/src/lib.rs | 248 +----- .../linux-egui/src/linux_app/settings_save.rs | 60 -- .../app/linux-egui/src/local_hotkeys.rs | 24 +- openless-all/app/linux-egui/src/main.rs | 556 +------------ openless-all/app/linux-egui/src/popup.rs | 193 +---- .../app/linux-egui/src/popup_window.rs | 11 - openless-all/app/linux-egui/src/settings.rs | 36 +- .../app/linux-egui/src/ui/frontend/icons.rs | 4 + .../app/linux-egui/src/ui/frontend/mod.rs | 69 +- .../app/linux-egui/src/ui/frontend/popups.rs | 318 +------- .../linux-egui/src/ui/frontend/settings.rs | 213 +---- .../linux-egui/src/ui/frontend/view_model.rs | 28 - .../tests/fcitx5_config_contract.rs | 17 + .../app/linux-egui/tests/fcitx5_contract.rs | 20 +- .../app/linux-egui/tests/host_contract.rs | 156 +++- .../scripts/check-linux-public-surface.ps1 | 9 +- .../scripts/linux-fcitx5-plugin/openless.cpp | 52 -- openless-all/app/scripts/sync-egui-i18n.mjs | 4 +- 27 files changed, 418 insertions(+), 2920 deletions(-) delete mode 100644 openless-all/app/linux-egui/src/coding_agent.rs diff --git a/docs/architecture.md b/docs/architecture.md index e98cbeb7a..288c71a74 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -88,7 +88,7 @@ Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺 思考动画覆盖转写、润色和原生文字写入,输入完成后才收尾。macOS 流式键盘输入由会话内串行 worker 维护原控件和累计 UTF-16 终点;每批发送后不再等待 AX 长确认,finish/cancel 在已接收写入之后等待最终屏障,再恢复输入源。AX 仅读选区范围,不读正文;采用 250 ms 无进展预算和 10 秒总预算。不可读、提交型 Return 或预算耗尽时明确降级到按键已发送语义,不重新粘贴已发送文字。目标应用的实际输入表现仍需设备验收。 -Less Computer 与 Claude 控制台收拢到「实验与扩展 → 正在测试中的功能(Beta)」。本设备的 `testingFeaturesEnabled` 默认关闭,旧版已开启 Less Computer 的配置也不会自动打开总开关。开启后按真实平台能力显示入口;React 的 Less Computer 仅 Windows/macOS、Claude 控制台仅 macOS,Linux egui 保留自己的 Less Computer 接入入口,Android 当前显示无可用测试功能。关闭总开关会隐藏入口、快捷键和独立窗口,Core 停止当前捕获与 Agent/控制台任务并拒绝新的运行请求;已有 Agent、模型、权限与路径配置保留。总开关不进入云同步,恢复保留接收设备的本机选择。 +Less Computer 与 Claude 控制台收拢到「实验与扩展 → 正在测试中的功能(Beta)」。本设备的 `testingFeaturesEnabled` 默认关闭,旧版已开启 Less Computer 的配置也不会自动打开总开关。开启后按真实平台能力显示入口;React 的 Less Computer 仅 Windows/macOS、Claude 控制台仅 macOS,Android 当前显示无可用测试功能。Linux egui 已整体删除 Less Computer(含 Beta 总开关入口),Core 与其余平台不受影响。关闭总开关会隐藏入口、快捷键和独立窗口,Core 停止当前捕获与 Agent/控制台任务并拒绝新的运行请求;已有 Agent、模型、权限与路径配置保留。总开关不进入云同步,恢复保留接收设备的本机选择。 Less Computer 面板将听写与直接语音提交分开:麦克风把转写填入草稿供编辑,语音模式和快捷键可直接提交给 Agent。Core 的 `voice_state` 事件携带会话 ID、模式、实时转写及收尾结果;波形使用实际音量采样。停止和取消均绑定指定会话,延迟请求不能结束下一段录音;开麦与文字发送互斥。工具过程默认折叠,运行状态只在真实活动步骤显示动效,右侧工作台汇总当前轮次;历史和多会话仍标为暂不可用。登录弹窗打开时,听写结果只更新草稿,不抢走弹窗或授权浏览器的焦点。 diff --git a/docs/linux-egui-backend-contract.md b/docs/linux-egui-backend-contract.md index 56832a77e..2c60fef50 100644 --- a/docs/linux-egui-backend-contract.md +++ b/docs/linux-egui-backend-contract.md @@ -10,15 +10,15 @@ | --- | --- | | `startupSnapshot` | 启动快照结构与版本校验规则;UI 必须先消费快照再渲染 | | `backendEvent` | 语义事件清单、顺序与重放规则 | -| `testingFeatures` | 本设备 Beta 总开关、默认关闭、运行门禁与取消合同 | -| `lessComputerVoice` | Less Computer 语音事件面 | +| `testingFeatures` | 本设备 Beta 总开关、默认关闭、运行门禁与取消合同(Linux egui 已不再消费:Less Computer 在 Linux 端删除) | +| `lessComputerVoice` | Less Computer 语音事件面(仅 Tauri/移动端消费,Linux egui 不接入) | | `androidJni` | Android JNI 合同(src-tauri android 桥接共用) | | `linuxFacade` | Linux 专用 facade 面(`LinuxHost` 公开方法对应) | | `enums` | 共享枚举(provider 类型、状态、错误等) | ## 2. Linux 侧公开签名(源码为准) -- `linux-egui/src/lib.rs`:`pub struct LinuxHost`;`LinuxHost::new(Arc)`、`with_settings_runtime`、`backend()`、`subscribe() -> EventSubscription`、`snapshot() -> BackendSnapshot`、`save_settings(...)`、`update_settings_strict(...)`、`update_preference_fields(...)`(`preference_patch.rs`) 、`drain_events(...)`、`feed_less_computer_pcm(&[u8])`。 +- `linux-egui/src/lib.rs`:`pub struct LinuxHost`;`LinuxHost::new(Arc)`、`with_settings_runtime`、`backend()`、`subscribe() -> EventSubscription`、`snapshot() -> BackendSnapshot`、`save_settings(...)`、`update_settings_strict(...)`、`update_preference_fields(...)`(`preference_patch.rs`) 、`drain_events(...)`。 - `linux-egui/src/backend.rs`:`LinuxBackendRuntime`;`LinuxBackendBuilder::from_shared_providers(BackendConfig)` + `with_task_spawner / with_recorder / with_auxiliary_polisher / with_text_inserter / with_credential_store / with_services / with_settings_runtime / with_polish_failure_policy` → `build() -> LinuxBackendRuntime`。 - 事件消费:`drain_events`(`lib.rs`)批量取走 Core 语义事件;订阅经 `EventSubscription`。 @@ -34,7 +34,7 @@ ## 5. 行为约定(合同级) -- `UserPreferences.testingFeaturesEnabled` 默认 `false`,设备独立选择,不进入云同步。实验与扩展中的 Less Computer 移到 Beta 测试功能子页,开启总开关后才显示,运行还须开启原有 `codingAgentEnabled`。Core 在会话预约、录音与文字提交时验证有效开关,关闭返回 `PermissionDenied`;关闭总开关取消当前任务、注销热键并关闭独立弹窗,保留功能配置。 +- **Linux egui 不再提供 Less Computer / Claude 控制台**:设置页的 Beta 总开关、Agent 配置页、快捷键行、独立面板、fcitx5 的 `SetLessComputerHotkeyRaw` 方法与 `LessComputerKeyEvent`/`LessComputerKeyCombined` 信号、以及 CLI 进程适配器(`linux-egui/src/coding_agent.rs`)均已删除。`UserPreferences.testingFeaturesEnabled` 与 `codingAgent*` 字段仍在 Core(其他平台使用),Linux 只是不再读写它们;删除后 Linux 上不再存在"开关关闭仍可触发"或"开关无效"的表面。 - fcitx5 为生产启动硬依赖:持有单实例锁后检查插件和会话 D-Bus,按需刷新并在 15 秒就绪预算内等待,再启动监听器、注册必需热键、启动 Core 和 Remote Input。失败清理已启动资源并显示错误窗口;关闭后以非零状态退出,外层锁覆盖完整生命周期。 - 设置保存状态与动作处理位于 `linux_app/settings_save.rs`:按序列化字段生成补丁、串行提交、只重试明确 revision 冲突(最多三次),失败保留草稿并显式重试;Core 合同与持久化格式不变。 diff --git a/docs/linux-egui-handoff/04-ui-domains.md b/docs/linux-egui-handoff/04-ui-domains.md index d071b6195..546bb94b1 100644 --- a/docs/linux-egui-handoff/04-ui-domains.md +++ b/docs/linux-egui-handoff/04-ui-domains.md @@ -10,7 +10,7 @@ | Dictation | 普通听写(复用 Core `dictation_engine`)、ticket 化落字 | — | | Qa | QA 文本/语音(`qa.rs`)、全局待办、审批、取消 | 编辑模式/应用/撤回 UI(L04) | | Selection | 划词润色预览/撤回(`selection.rs`) | Selection Voice 生产触发/捕获/意图路由(L04) | -| Agent | Less Computer 工具调用、审批固定在页面外、后台事件保留 | Agent 检测/模型/路径/权限配置(L09) | +| Agent | —(Linux 端已整体删除 Less Computer:设置页、快捷键、独立面板、fcitx5 转发与 CLI 进程适配器全部移除) | — | | Services | AI 服务配置、渠道管理(`settings.rs` + Core provider 面) | Omni 有效模式配置入口完整性(L09) | | 本地 ASR | 明确不支持;Linux 不包含 Qwen 推理运行时 | 保持能力声明与 UI 一致,不作为待设备验证功能 | | Remote | 手机输入独立页、连接状态、陈旧地址处理 | — | diff --git a/openless-all/app/linux-egui/examples/headless_host.rs b/openless-all/app/linux-egui/examples/headless_host.rs index 809bc08b6..9917a0c51 100644 --- a/openless-all/app/linux-egui/examples/headless_host.rs +++ b/openless-all/app/linux-egui/examples/headless_host.rs @@ -12,7 +12,7 @@ use openless_core::testing::{ use openless_core::{ BackendErrorCode, LocalAsrRuntime, PolishMode, RepolishRequest, SelectionCapture, SelectionPolishOutputMode, SelectionPolishRequest, SelectionVoiceApplyOutcome, - SelectionVoicePhase, SelectionVoicePreviewUpdate, SessionId, + SelectionVoicePhase, SelectionVoicePreviewUpdate, }; use openless_linux_egui::{ drain_events, BackendConfig, BackendDependencies, BackendError, BackendServices, @@ -150,33 +150,6 @@ async fn main() -> Result<(), BackendError> { BackendErrorCode::Unsupported ); - // Less Computer voice hosts reserve a Core capture lease before touching - // recorder/native ASR resources and release it explicitly on cancellation - // or startup failure. The headless example exercises that seam without - // requiring a coding-agent process. - let mut less_computer_preferences = backend.get_preferences(); - less_computer_preferences.coding_agent_enabled = true; - less_computer_preferences.testing_features_enabled = true; - linux_host.update_settings_strict( - less_computer_preferences, - linux_host.snapshot().preferences_revision, - )?; - let less_computer_session = SessionId::new(); - backend.begin_less_computer_capture(less_computer_session)?; - assert_eq!( - backend.less_computer_active_session(), - Some(less_computer_session) - ); - backend - .cancel_less_computer(Some(less_computer_session)) - .await?; - // Core 2.0 cancellation is terminal and releases the capture lease. A host - // that observes the cancellation after the await must not expect the old - // lease's flag to remain queryable. - assert_eq!(backend.less_computer_active_session(), None); - backend.abort_less_computer_capture(less_computer_session)?; - assert_eq!(backend.less_computer_active_session(), None); - // The deterministic Linux selection fixture supports direct replacement, // but retained preview targets and platform revert are intentionally // unsupported. A view model can therefore exercise the exact capability diff --git a/openless-all/app/linux-egui/src/backend.rs b/openless-all/app/linux-egui/src/backend.rs index aa9a28e06..b4270dbc5 100644 --- a/openless-all/app/linux-egui/src/backend.rs +++ b/openless-all/app/linux-egui/src/backend.rs @@ -127,9 +127,6 @@ impl LinuxBackendBuilder { Arc::clone(&credential_store), Arc::clone(&task_spawner), )); - services.configure_coding_agent_process(Arc::new( - crate::coding_agent::LinuxCodingAgentProcessAdapter, - )); Ok(Self::new(config, transcription, polisher) .with_task_spawner(task_spawner) @@ -326,7 +323,7 @@ mod tests { FixtureAudioRecorder, FixtureTextInserter, FixtureTextPolisher, FixtureTranscriptionEngine, }; use openless_core::{ - BackendErrorCode, CodingAgentProvider, CodingAgentTestRequest, InMemoryCredentialStore, + BackendErrorCode, InMemoryCredentialStore, InsertOutcome, ProviderKind, ProviderRequest, }; @@ -652,55 +649,4 @@ mod tests { assert_eq!(error.code, BackendErrorCode::Unsupported); let _ = std::fs::remove_dir_all(data_dir); } - - #[tokio::test] - async fn coding_agent_runtime_reports_unavailable_cli_without_fake_success() { - let data_dir = std::env::temp_dir().join(format!( - "openless-linux-coding-agent-{}-{}", - std::process::id(), - uuid::Uuid::new_v4().simple() - )); - let runtime = LinuxBackendBuilder::from_shared_providers(BackendConfig { - data_dir: data_dir.clone(), - ..BackendConfig::default() - }) - .unwrap() - .build() - .unwrap(); - let request = CodingAgentTestRequest { - provider: CodingAgentProvider::ClaudeCodeCli, - executable: Some("openless-command-that-does-not-exist".into()), - prompt: "test".into(), - permission_mode: openless_core::CodingAgentPermissionMode::Plan, - workdir: None, - model: None, - max_budget_usd: Some(0.5), - timeout_secs: 5, - }; - let denied = runtime - .backend - .services() - .coding_agent - .run_test(request.clone()) - .await - .unwrap_err(); - assert_eq!(denied.code, BackendErrorCode::PermissionDenied); - let mut preferences = runtime.backend.get_preferences(); - preferences.testing_features_enabled = true; - runtime - .backend - .repositories() - .preferences - .set(preferences) - .unwrap(); - let error = runtime - .backend - .services() - .coding_agent - .run_test(request) - .await - .expect_err("missing coding agent executable must be explicit"); - assert_eq!(error.code, BackendErrorCode::Unsupported); - let _ = std::fs::remove_dir_all(data_dir); - } } diff --git a/openless-all/app/linux-egui/src/coding_agent.rs b/openless-all/app/linux-egui/src/coding_agent.rs deleted file mode 100644 index 6f9370be2..000000000 --- a/openless-all/app/linux-egui/src/coding_agent.rs +++ /dev/null @@ -1,369 +0,0 @@ -//! Linux Coding Agent Adapter: owns only temp files and subprocess I/O. - -use std::path::PathBuf; -use std::process::Stdio; -use std::sync::Arc; - -use futures_util::future::BoxFuture; -use openless_core::{ - AgentCommand, AgentMaterializationPlan, CancellationToken, CodingAgentProcessAdapter, - ProcessExit, ProcessOutputLine, ProcessOutputSink, ProcessStream, PromptPayload, -}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - -#[derive(Default)] -pub(crate) struct LinuxCodingAgentProcessAdapter; - -struct TemporaryWorkspace(PathBuf); - -impl Drop for TemporaryWorkspace { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -fn materialize( - command: &mut AgentCommand, -) -> Result, openless_core::BackendError> { - if command.temporary_files.is_empty() { - return Ok(None); - } - let directory = - std::env::temp_dir().join(format!("openless-agent-{}", uuid::Uuid::new_v4().simple())); - let plan = AgentMaterializationPlan::new(command, &directory)?; - std::fs::create_dir(&directory).map_err(platform_error)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)) - .map_err(platform_error)?; - } - let workspace = TemporaryWorkspace(directory.clone()); - for file in plan.files { - std::fs::write(file.path, file.contents).map_err(platform_error)?; - } - command.argv = plan.argv; - Ok(Some(workspace)) -} - -static LOGIN_SHELL_PATH: tokio::sync::OnceCell> = tokio::sync::OnceCell::const_new(); - -async fn login_shell_path() -> Option<&'static str> { - LOGIN_SHELL_PATH - .get_or_init(|| async { - let plan = openless_core::AgentLoginShellPathPlan::new(std::env::var("SHELL").ok())?; - let deadline = tokio::time::Instant::now() + plan.timeout; - for arguments in &plan.attempts { - let mut command = tokio::process::Command::new(&plan.shell); - command - .args(arguments) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .kill_on_drop(true); - let Ok(Ok(output)) = tokio::time::timeout_at(deadline, command.output()).await - else { - continue; - }; - if output.status.success() { - if let Some(path) = openless_core::parse_agent_login_shell_path( - &String::from_utf8_lossy(&output.stdout), - ) { - return Some(path); - } - } - } - None - }) - .await - .as_deref() -} - -async fn augment_path(command: &mut tokio::process::Command, cancel: &CancellationToken) -> bool { - if cancel.is_cancelled() { - return false; - } - let current = std::env::var_os("PATH").unwrap_or_default(); - let home = std::env::var_os("HOME").map(PathBuf::from); - if let Some(home) = &home { - command.env("HOME", home); - } - // Finder/desktop-launched shells may need up to five seconds to discover - // login PATH. Cancellation must still win during that lookup; otherwise a - // request cancelled before spawn appears to hang and no child exists for - // the normal process-group kill path to terminate. - let login_path = tokio::select! { - path = login_shell_path() => path, - _ = cancel.cancelled() => return false, - }; - command.env( - "PATH", - openless_core::merge_agent_path(¤t, home.as_deref(), login_path), - ); - true -} - -pub(crate) fn isolate_process_group(command: &mut tokio::process::Command) { - #[cfg(target_os = "linux")] - command.process_group(0); - #[cfg(not(target_os = "linux"))] - let _ = command; -} - -fn kill_process_group_with_id( - child: &mut tokio::process::Child, - _process_id: Option, -) -> Result<(), openless_core::BackendError> { - #[cfg(target_os = "linux")] - if _process_id.is_some_and(|pid| { - // SAFETY: `isolate_process_group` starts the child as process-group leader. - unsafe { libc::kill(-(pid as i32), libc::SIGKILL) == 0 } - }) { - return Ok(()); - } - child.start_kill().map_err(platform_error) -} - -impl CodingAgentProcessAdapter for LinuxCodingAgentProcessAdapter { - fn execute( - &self, - mut request: AgentCommand, - output: Arc, - cancel: CancellationToken, - ) -> BoxFuture<'static, Result> { - Box::pin(async move { - if cancel.is_cancelled() { - return Ok(ProcessExit { - code: None, - success: false, - }); - } - let _workspace = materialize(&mut request)?; - let mut command = tokio::process::Command::new(&request.executable); - if !augment_path(&mut command, &cancel).await { - return Ok(ProcessExit { - code: None, - success: false, - }); - } - command - .args(&request.argv) - .envs(&request.env) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - isolate_process_group(&mut command); - if let Some(cwd) = &request.cwd { - command.current_dir(cwd); - } - if let PromptPayload::Argv(prompt) = &request.prompt { - command.arg(prompt); - } - // PATH discovery/materialization may have completed at the same - // time as Esc. Check again at the last boundary before spawn. - if cancel.is_cancelled() { - return Ok(ProcessExit { - code: None, - success: false, - }); - } - let mut child = command.spawn().map_err(|error| { - openless_core::BackendError::new( - if error.kind() == std::io::ErrorKind::NotFound { - openless_core::BackendErrorCode::Unsupported - } else { - openless_core::BackendErrorCode::Platform - }, - error.to_string(), - ) - })?; - // Remember the group before wait() reaps the leader: a descendant - // can keep stdout/stderr open even after the leader has exited. - let process_id = child.id(); - let stdin = child.stdin.take(); - let stdout = child - .stdout - .take() - .ok_or_else(|| invalid("missing process stdout"))?; - let stderr = child - .stderr - .take() - .ok_or_else(|| invalid("missing process stderr"))?; - let stdout_sink = Arc::clone(&output); - let read_stdout = async move { - let mut lines = BufReader::new(stdout).lines(); - while let Ok(Some(line)) = lines.next_line().await { - stdout_sink.write(ProcessOutputLine { - stream: ProcessStream::Stdout, - line, - }); - } - }; - let read_stderr = async move { - let mut lines = BufReader::new(stderr).lines(); - while let Ok(Some(line)) = lines.next_line().await { - output.write(ProcessOutputLine { - stream: ProcessStream::Stderr, - line, - }); - } - }; - // Pipe backpressure is bidirectional: a CLI can fill stdout before - // consuming a large prompt. Write, drain, and wait concurrently, - // under the same cancellation lifetime. Scoped futures (not spawned - // tasks) close every pipe before process-group cleanup begins. - let result = { - let write_stdin = async move { - if let (Some(mut stdin), PromptPayload::Stdin(prompt)) = (stdin, request.prompt) - { - stdin.write_all(prompt.as_bytes()).await?; - stdin.flush().await?; - stdin.shutdown().await?; - } - Ok::<_, std::io::Error>(()) - }; - let drain = async move { - tokio::join!(read_stdout, read_stderr); - Ok::<_, std::io::Error>(()) - }; - let operation = async { - tokio::try_join!(write_stdin, drain, child.wait()).map(|(_, _, status)| status) - }; - tokio::pin!(operation); - tokio::select! { - biased; - _ = cancel.cancelled() => None, - result = &mut operation => Some(result), - } - }; - let status = match result { - Some(Ok(status)) => status, - result => { - kill_process_group_with_id(&mut child, process_id)?; - let status = child.wait().await.map_err(platform_error)?; - if let Some(Err(error)) = result { - return Err(platform_error(error)); - } - status - } - }; - Ok(ProcessExit { - code: status.code(), - success: status.success() && !cancel.is_cancelled(), - }) - }) - } -} - -fn invalid(message: impl Into) -> openless_core::BackendError { - openless_core::BackendError::new(openless_core::BackendErrorCode::InvalidArgument, message) -} - -fn platform_error(error: impl std::fmt::Display) -> openless_core::BackendError { - openless_core::BackendError::new(openless_core::BackendErrorCode::Platform, error.to_string()) -} - -#[cfg(all(test, target_os = "linux"))] -mod tests { - use std::collections::BTreeMap; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::Arc; - - use openless_core::{ - AgentCommand, CancellationToken, CodingAgentProcessAdapter, ProcessOutputLine, - ProcessOutputSink, PromptPayload, - }; - - use super::LinuxCodingAgentProcessAdapter; - - struct IgnoreOutput; - - impl ProcessOutputSink for IgnoreOutput { - fn write(&self, _line: ProcessOutputLine) {} - } - - #[tokio::test] - async fn cancellation_kills_a_running_child_with_blocked_stdin() { - super::login_shell_path().await; - let ready = - std::env::temp_dir().join(format!("openless-agent-ready-{}", uuid::Uuid::new_v4())); - let cancelled = Arc::new(AtomicBool::new(false)); - let flag = Arc::clone(&cancelled); - let ready_for_cancel = ready.clone(); - let cancel_task = tokio::spawn(async move { - for _ in 0..400 { - if ready_for_cancel.exists() { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - flag.store(true, Ordering::Release); - }); - let result = tokio::time::timeout( - std::time::Duration::from_secs(3), - LinuxCodingAgentProcessAdapter.execute( - AgentCommand { - executable: "sh".into(), - argv: vec![ - "-c".into(), - "sleep 30 & printf '%s %s' $$ $! > \"$OPENLESS_AGENT_TEST_READY\"; wait" - .into(), - ], - env: BTreeMap::from([( - "OPENLESS_AGENT_TEST_READY".into(), - ready.to_string_lossy().into_owned(), - )]), - cwd: None, - // Exceeds the pipe capacity; `sleep` never reads stdin. - prompt: PromptPayload::Stdin("x".repeat(1024 * 1024)), - temporary_files: Vec::new(), - }, - Arc::new(IgnoreOutput), - CancellationToken::from_flag(cancelled), - ), - ) - .await; - cancel_task.await.unwrap(); - let pids = std::fs::read_to_string(&ready).expect("child must actually start"); - let _ = std::fs::remove_file(&ready); - // SIGKILL is delivered immediately but the kernel retires the process - // (and its orphaned children) asynchronously, so give the group a - // bounded moment to disappear before calling the cancellation broken. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - let mut running = alive_pids(&pids); - while !running.is_empty() && std::time::Instant::now() < deadline { - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - running = alive_pids(&pids); - } - // Only fixture PIDs read from our private ready file are killed. - for pid in &running { - unsafe { - libc::kill(pid.parse().unwrap(), libc::SIGKILL); - } - } - let result = result.expect("cancelled child must exit promptly").unwrap(); - assert!(!result.success); - assert!( - running.is_empty(), - "cancelled process group is still running: {running:?}" - ); - } - - /// Fixture PIDs that are still alive (a zombie is already dead). - fn alive_pids(pids: &str) -> Vec { - pids.split_whitespace() - .filter(|pid| { - std::fs::read_to_string(format!("/proc/{pid}/stat")) - .ok() - .and_then(|stat| { - stat.rsplit_once(") ") - .map(|(_, rest)| !rest.starts_with('Z')) - }) - .unwrap_or(false) - }) - .map(str::to_owned) - .collect() - } -} diff --git a/openless-all/app/linux-egui/src/fcitx5.rs b/openless-all/app/linux-egui/src/fcitx5.rs index 748e10cf3..5a7031fe7 100644 --- a/openless-all/app/linux-egui/src/fcitx5.rs +++ b/openless-all/app/linux-egui/src/fcitx5.rs @@ -973,21 +973,6 @@ pub fn set_hotkeys(keys: Vec) -> Result<(), BackendError> { send_message("SetHotkey", |message| message.append1(keys)) } -#[cfg(target_os = "linux")] -pub fn set_less_computer_hotkey_raw(symbol: u32, states: u32) -> Result<(), BackendError> { - send_message("SetLessComputerHotkeyRaw", |message| { - message.append2(symbol, states) - }) -} - -#[cfg(not(target_os = "linux"))] -pub fn set_less_computer_hotkey_raw(_: u32, _: u32) -> Result<(), BackendError> { - Err(BackendError::new( - BackendErrorCode::Unsupported, - "fcitx5 is only available on Linux", - )) -} - #[cfg(not(target_os = "linux"))] pub fn set_hotkeys(_: Vec) -> Result<(), BackendError> { Err(BackendError::new( diff --git a/openless-all/app/linux-egui/src/hotkeys.rs b/openless-all/app/linux-egui/src/hotkeys.rs index 239d0653c..2ed9b8611 100644 --- a/openless-all/app/linux-egui/src/hotkeys.rs +++ b/openless-all/app/linux-egui/src/hotkeys.rs @@ -14,7 +14,6 @@ static NEXT_PRESS_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU6 #[derive(Default)] struct HotkeyPressIds { dictation: std::sync::atomic::AtomicU64, - less_computer: std::sync::atomic::AtomicU64, } #[cfg(any(target_os = "linux", test))] @@ -43,24 +42,6 @@ pub enum LinuxHotkeyEvent { press_id: u64, at: std::time::Instant, }, - LessComputerPressed { - symbol: u32, - states: u32, - press_id: u64, - at: std::time::Instant, - }, - LessComputerReleased { - symbol: u32, - states: u32, - press_id: u64, - at: std::time::Instant, - }, - LessComputerCombined { - symbol: u32, - states: u32, - press_id: u64, - at: std::time::Instant, - }, QaPressed, QuickNotePressed, SelectionPolishPressed, @@ -313,43 +294,6 @@ fn event_from_signal( .load(std::sync::atomic::Ordering::Acquire), at, }), - ("LessComputerKeyEvent", true) => { - let press_id = match press_ids - .less_computer - .load(std::sync::atomic::Ordering::Acquire) - { - 0 => { - let press_id = next_press_id(); - press_ids - .less_computer - .store(press_id, std::sync::atomic::Ordering::Release); - press_id - } - press_id => press_id, - }; - Some(LinuxHotkeyEvent::LessComputerPressed { - symbol, - states, - press_id, - at, - }) - } - ("LessComputerKeyEvent", false) => Some(LinuxHotkeyEvent::LessComputerReleased { - symbol, - states, - press_id: press_ids - .less_computer - .swap(0, std::sync::atomic::Ordering::AcqRel), - at, - }), - ("LessComputerKeyCombined", true) => Some(LinuxHotkeyEvent::LessComputerCombined { - symbol, - states, - press_id: press_ids - .less_computer - .load(std::sync::atomic::Ordering::Acquire), - at, - }), // Like the Tauri Linux bridge, action shortcuts are press notifications, // not held-key generations. Focus changes and modifier-first release can // omit key-up; latching here would disable the shortcut indefinitely. @@ -429,25 +373,6 @@ mod tests { states: 16, }) ); - let less_pressed = event_from_signal("LessComputerKeyEvent", 3, 4, true, at, &press_ids) - .expect("Less Computer press"); - let LinuxHotkeyEvent::LessComputerPressed { - press_id: less_press_id, - .. - } = less_pressed - else { - panic!("expected Less Computer press") - }; - assert!(matches!( - event_from_signal("LessComputerKeyCombined", 3, 4, true, at, &press_ids), - Some(LinuxHotkeyEvent::LessComputerCombined { press_id, .. }) - if press_id == less_press_id - )); - assert!(matches!( - event_from_signal("LessComputerKeyEvent", 3, 4, false, at, &press_ids), - Some(LinuxHotkeyEvent::LessComputerReleased { press_id, .. }) - if press_id == less_press_id - )); assert_eq!( event_from_signal("Unknown", 0, 0, true, at, &press_ids), None diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index 14558880c..f0caf8257 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -226,7 +226,7 @@ pub const CATALOG: &[Msg] = &[ "선택 영역에 잘린 대체 텍스트가 포함되어 있습니다. 생략된 원문을 보호하기 위해 교체하지 않았습니다.", ), }, - Msg { + Msg { key: "settings.recording.capsuleTranscriptLabel", text: row( "显示流式原文", @@ -236,7 +236,7 @@ pub const CATALOG: &[Msg] = &[ "실시간 원문 표시", ), }, - Msg { + Msg { key: "settings.recording.capsuleTranscriptDesc", text: row( "在胶囊中显示语音识别原文,替换音频条;关闭后恢复原有显示。非流式识别将在结果返回后显示。", @@ -246,7 +246,7 @@ pub const CATALOG: &[Msg] = &[ "오디오 막대 대신 인식된 원문을 표시합니다. 끄면 기존 표시로 돌아갑니다. 비스트리밍 인식은 결과 수신 후 표시됩니다.", ), }, - Msg { + Msg { key: "settings.recording.capsuleTranscriptFontSize", text: row( "原文字号", @@ -256,7 +256,7 @@ pub const CATALOG: &[Msg] = &[ "원문 글자 크기", ), }, - Msg { + Msg { key: "settings.providers.requestFormatLabel", text: row( "请求格式", @@ -266,7 +266,7 @@ pub const CATALOG: &[Msg] = &[ "요청 형식", ), }, - Msg { + Msg { key: "settings.providers.responsesThinkingHint", text: row( "部分模型只能降低思考,不能完全关闭。推理请求不发送温度参数。", @@ -276,7 +276,7 @@ pub const CATALOG: &[Msg] = &[ "일부 모델은 사고를 줄일 수만 있으며 완전히 끌 수 없습니다. 추론 요청에는 온도를 보내지 않습니다.", ), }, - Msg { + Msg { key: "settings.providers.serviceTierLabel", text: row( "方舟低延迟推理(fast)", @@ -286,7 +286,7 @@ pub const CATALOG: &[Msg] = &[ "Ark 저지연 추론(fast)", ), }, - Msg { + Msg { key: "settings.providers.serviceTierHint", text: row( "仅适用于支持低延迟的方舟模型;不支持的模型可能返回 400。", @@ -296,7 +296,7 @@ pub const CATALOG: &[Msg] = &[ "지원되는 Ark 모델에서만 사용할 수 있으며, 지원되지 않는 모델은 400을 반환할 수 있습니다.", ), }, - Msg { + Msg { key: "settings.providers.messagesThinkingLabel", text: row( "思考方式", @@ -306,15 +306,15 @@ pub const CATALOG: &[Msg] = &[ "사고 방식", ), }, - Msg { + Msg { key: "settings.providers.thinkingAdaptive", text: row("自适应", "自適應", "Adaptive", "適応型", "적응형"), }, - Msg { + Msg { key: "settings.providers.thinkingBudget", text: row("固定预算", "固定預算", "Fixed budget", "固定予算", "고정 예산"), }, - Msg { + Msg { key: "settings.providers.maxTokensLabel", text: row( "最大输出 tokens", @@ -324,7 +324,7 @@ pub const CATALOG: &[Msg] = &[ "최대 출력 토큰", ), }, - Msg { + Msg { key: "settings.providers.thinkingBudgetLabel", text: row( "思考预算 tokens", @@ -334,7 +334,7 @@ pub const CATALOG: &[Msg] = &[ "사고 토큰 예산", ), }, - Msg { + Msg { key: "settings.providers.messagesThinkingHint", text: row( "旧模型或兼容网关可能需要固定预算;思考预算必须小于最大输出。开启思考时不发送温度参数。", @@ -344,7 +344,7 @@ pub const CATALOG: &[Msg] = &[ "이전 모델이나 호환 게이트웨이는 고정 예산이 필요할 수 있습니다. 예산은 출력 한도보다 작아야 합니다. 사고 요청에는 온도를 보내지 않습니다.", ), }, - Msg { + Msg { key: "settings.providers.llmServiceTierInvalid", text: row( "低延迟推理选项无效,请重新选择。", @@ -354,7 +354,7 @@ pub const CATALOG: &[Msg] = &[ "저지연 추론 옵션이 잘못되었습니다. 다시 선택하세요.", ), }, - Msg { + Msg { key: "settings.providers.llmRequestFormatInvalid", text: row( "请求格式无效,请重新选择。", @@ -364,7 +364,7 @@ pub const CATALOG: &[Msg] = &[ "잘못된 요청 형식입니다. 다시 선택하세요.", ), }, - Msg { + Msg { key: "settings.providers.llmThinkingModeInvalid", text: row( "思考方式无效,请重新选择。", @@ -374,7 +374,7 @@ pub const CATALOG: &[Msg] = &[ "잘못된 사고 방식입니다. 다시 선택하세요.", ), }, - Msg { + Msg { key: "settings.providers.llmTokenLimitInvalid", text: row( "Token 上限必须为正整数。", @@ -384,7 +384,7 @@ pub const CATALOG: &[Msg] = &[ "토큰 한도는 양의 정수여야 합니다.", ), }, - Msg { + Msg { key: "settings.providers.llmThinkingBudgetInvalid", text: row( "思考预算至少为 1024,且固定预算必须小于最大输出。", @@ -431,7 +431,7 @@ pub const CATALOG: &[Msg] = &[ Msg { key: "quickNote.recording", text: row("录音中…", "錄音中…", "Recording…", "録音中…", "녹음 중…") }, Msg { key: "quickNote.shortcutTitle", text: row("速记快捷键", "速記快捷鍵", "Quick note shortcut", "速記ショートカット", "속기 단축키") }, Msg { key: "quickNote.shortcutDesc", text: row("按一次开始永久录音,再按一次结束并保存。", "按一下開始永久錄音,再按一下結束並保存。", "Press once to start a permanent capture, then press again to finish.", "一度押して録音を開始し、もう一度押して保存します。", "한 번 눌러 녹음하고 다시 눌러 저장합니다.") }, - Msg { + Msg { key: "nav.vocab", text: row("词典", "詞彙表", "Dictionary", "辞書", "사전"), }, @@ -473,9 +473,9 @@ pub const CATALOG: &[Msg] = &[ key: "nav.translation", text: row("翻译", "翻譯", "Translation", "翻訳", "번역"), }, - Msg { + Msg { key: "nav.selection_ask", - text: row("划词追问", "劃詞追問", "Ask", "選択追問", "선택 질문"), + text: row("问任何问题", "問任何問題", "Ask", "何でも質問", "무엇이든 질문"), }, Msg { key: "nav.corrections", @@ -496,26 +496,6 @@ pub const CATALOG: &[Msg] = &[ // ---- Dictation / empty states ------------------------------------------ - Msg { - key: "less_computer.done", - text: row( - "Less Computer 已完成", - "Less Computer 已完成", - "Less Computer finished", - "Less Computer が完了しました", - "Less Computer 완료", - ), - }, - Msg { - key: "less_computer.cancelled", - text: row( - "Less Computer 已取消", - "Less Computer 已取消", - "Less Computer cancelled", - "Less Computer をキャンセルしました", - "Less Computer 취소됨", - ), - }, Msg { key: "qa.submitted", @@ -629,7 +609,7 @@ pub const CATALOG: &[Msg] = &[ }, // ---- Overview page (2.0 dashboard) ------------------------------------ - Msg { + Msg { key: "overview.title", text: row( "今日概览", @@ -673,7 +653,7 @@ pub const CATALOG: &[Msg] = &[ "격식체", ), }, - Msg { + Msg { key: "overview.refresh", text: row( "刷新状态", @@ -683,7 +663,7 @@ pub const CATALOG: &[Msg] = &[ "상태 새로고침", ), }, - Msg { + Msg { key: "overview.stats_title", text: row( "使用记录", @@ -693,7 +673,7 @@ pub const CATALOG: &[Msg] = &[ "사용 기록", ), }, - Msg { + Msg { key: "overview.metric_chars", text: row( "今日字数", @@ -703,11 +683,11 @@ pub const CATALOG: &[Msg] = &[ "오늘 글자 수", ), }, - Msg { + Msg { key: "overview.metric_segments", text: row("{} 段", "{} 段", "{} segments", "{} セグメント", "{} 세그먼트"), }, - Msg { + Msg { key: "overview.metric_duration", text: row( "今日总时长", @@ -717,7 +697,7 @@ pub const CATALOG: &[Msg] = &[ "오늘 총 시간", ), }, - Msg { + Msg { key: "overview.metric_avg", text: row( "平均段落", @@ -727,7 +707,7 @@ pub const CATALOG: &[Msg] = &[ "평균 세그먼트", ), }, - Msg { + Msg { key: "overview.metric_avg_trend", text: row( "今日均值", @@ -792,7 +772,7 @@ pub const CATALOG: &[Msg] = &[ key: "overview.metric_duration_name", text: row("时长", "時長", "Duration", "時間", "시간"), }, - Msg { + Msg { key: "overview.recent_title", text: row( "最近识别", @@ -802,11 +782,11 @@ pub const CATALOG: &[Msg] = &[ "최근 인식", ), }, - Msg { + Msg { key: "overview.recent_all", text: row("全部记录 →", "全部記錄 →", "View all →", "すべて表示 →", "전체 보기 →"), }, - Msg { + Msg { key: "overview.recent_empty_hint", text: row( "还没有听写记录。跟着上方的引导试一次,结果会显示在这里。", @@ -821,7 +801,7 @@ pub const CATALOG: &[Msg] = &[ key: "overview.retry", text: row("重试", "重試", "Retry", "再試行", "다시 시도"), }, - Msg { + Msg { key: "overview.activity_title", text: row( "年度活动", @@ -831,7 +811,7 @@ pub const CATALOG: &[Msg] = &[ "연간 활동", ), }, - Msg { + Msg { key: "overview.activity_count", text: row( "{} 次听写", @@ -842,7 +822,7 @@ pub const CATALOG: &[Msg] = &[ ), }, - Msg { + Msg { key: "overview.services_title", text: row( "当前语音服务", @@ -852,7 +832,7 @@ pub const CATALOG: &[Msg] = &[ "현재 음성 서비스", ), }, - Msg { + Msg { key: "overview.asr_kind", text: row( "语音识别", @@ -862,7 +842,7 @@ pub const CATALOG: &[Msg] = &[ "음성 인식", ), }, - Msg { + Msg { key: "overview.llm_kind", text: row( "文字处理", @@ -872,7 +852,7 @@ pub const CATALOG: &[Msg] = &[ "텍스트 처리", ), }, - Msg { + Msg { key: "overview.provider_help_asr", text: row( "将语音转成文字。", @@ -882,7 +862,7 @@ pub const CATALOG: &[Msg] = &[ "음성을 텍스트로 변환합니다.", ), }, - Msg { + Msg { key: "overview.provider_help_llm", text: row( "按你的风格整理和润色文字。", @@ -896,7 +876,7 @@ pub const CATALOG: &[Msg] = &[ key: "overview.configure_provider", text: row("去配置", "前往設定", "Configure", "設定する", "설정하기"), }, - Msg { + Msg { key: "overview.manage_provider", text: row( "管理服务", @@ -964,7 +944,7 @@ pub const CATALOG: &[Msg] = &[ key: "common.refresh", text: row("刷新", "重新整理", "Refresh", "更新", "새로고침"), }, - Msg { + Msg { key: "common.clear", text: row("清空", "清空", "Clear", "クリア", "지우기"), }, @@ -980,7 +960,7 @@ pub const CATALOG: &[Msg] = &[ key: "common.copy", text: row("复制", "複製", "Copy", "コピー", "복사"), }, - Msg { + Msg { key: "common.copied", text: row("已复制", "已複製", "Copied", "コピーしました", "복사됨"), }, @@ -1069,7 +1049,7 @@ pub const CATALOG: &[Msg] = &[ key: "common.confirm", text: row("确认", "確認", "Confirm", "確認", "확인"), }, - Msg { + Msg { key: "common.duration_minutes", text: row("{} 分钟", "{} 分鐘", "{}m", "{} 分", "{}분"), }, @@ -1092,7 +1072,7 @@ pub const CATALOG: &[Msg] = &[ "로컬에 저장된 인식 기록.", ), }, - Msg { + Msg { key: "history.search_placeholder", text: row( "搜索转写内容…({})", @@ -1112,7 +1092,7 @@ pub const CATALOG: &[Msg] = &[ "아직 기록이 없습니다.", ), }, - Msg { + Msg { key: "history.search_no_match", text: row( "没有匹配「{}」的记录。", @@ -1186,7 +1166,7 @@ pub const CATALOG: &[Msg] = &[ "다시 받아쓰기", ), }, - Msg { + Msg { key: "history.raw_label", text: row("原文", "原文", "Raw", "原文", "원문"), }, @@ -1198,7 +1178,7 @@ pub const CATALOG: &[Msg] = &[ key: "history.step_asr", text: row("识别", "辨識", "Transcribe", "認識", "인식"), }, - Msg { + Msg { key: "history.step_polish", text: row("润色", "潤飾", "Polish", "推敲", "다듬기"), }, @@ -1210,7 +1190,7 @@ pub const CATALOG: &[Msg] = &[ key: "history.chars", text: row("{} 字", "{} 字", "{} chars", "{} 文字", "{}자"), }, - Msg { + Msg { key: "history.vocab_hits", text: row( "{} 个热词", @@ -1220,7 +1200,7 @@ pub const CATALOG: &[Msg] = &[ "핫워드 {}개", ), }, - Msg { + Msg { key: "history.inserted", text: row("已插入", "已插入", "Inserted", "入力済み", "입력됨"), }, @@ -1228,7 +1208,7 @@ pub const CATALOG: &[Msg] = &[ key: "history.paste_sent", text: row("已尝试粘贴", "已嘗試貼上", "Paste sent", "貼り付けを試行", "붙여넣기 시도됨"), }, - Msg { + Msg { key: "history.copied_fallback", text: row( "已复制(需 {})", @@ -1238,7 +1218,7 @@ pub const CATALOG: &[Msg] = &[ "복사됨({} 필요)", ), }, - Msg { + Msg { key: "history.insert_failed", text: row( "插入失败", @@ -1268,7 +1248,7 @@ pub const CATALOG: &[Msg] = &[ "이 기록을 삭제할까요? 되돌릴 수 없습니다.", ), }, - Msg { + Msg { key: "history.clear_failed", text: row( "清空失败:{}", @@ -1278,7 +1258,7 @@ pub const CATALOG: &[Msg] = &[ "기록 비우기 실패: {}", ), }, - Msg { + Msg { key: "history.delete_failed", text: row( "删除失败:{}", @@ -1288,7 +1268,7 @@ pub const CATALOG: &[Msg] = &[ "항목 삭제 실패: {}", ), }, - Msg { + Msg { key: "history.copy_failed", text: row( "复制失败:{}", @@ -1298,7 +1278,7 @@ pub const CATALOG: &[Msg] = &[ "복사 실패: {}", ), }, - Msg { + Msg { key: "history.export_failed", text: row( "导出失败:{}", @@ -1308,7 +1288,7 @@ pub const CATALOG: &[Msg] = &[ "내보내기 실패: {}", ), }, - Msg { + Msg { key: "history.retranscribe_failed", text: row( "重新转录失败:{}", @@ -1330,7 +1310,7 @@ pub const CATALOG: &[Msg] = &[ // ---- Language selector -------------------------------------------------- - Msg { + Msg { key: "settings.language_follow_system", text: row( "跟随系统", @@ -1398,7 +1378,7 @@ pub const CATALOG: &[Msg] = &[ "{} / {}개 불러옴", ), }, - Msg { + Msg { key: "common.saving", text: row("保存中", "儲存中", "Saving…", "保存中", "저장 중"), }, @@ -1448,10 +1428,6 @@ pub const CATALOG: &[Msg] = &[ // ---- Status / toast messages Msg { key: "status.auto_stopped", text: row("录音已自动结束", "錄音已自動結束", "Recording auto-stopped", "録音を自動終了", "녹음 자동 종료") }, - Msg { key: "status.less_compacted", text: row("Less Computer 已压缩上下文", "Less Computer 已壓縮上下文", "Less Computer compacted context", "Less Computer がコンテキストを圧縮", "Less Computer 컨텍스트 압축됨") }, - Msg { key: "status.less_waiting", text: row("Less Computer 等待审批", "Less Computer 等待審批", "Less Computer awaiting approval", "Less Computer が承認待ち", "Less Computer 승인 대기 중") }, - Msg { key: "status.less_tool", text: row("Less Computer 正在使用工具:{}", "Less Computer 正在使用工具:{}", "Less Computer is using a tool: {}", "Less Computer がツールを使用中: {}", "Less Computer 도구 사용 중: {}") }, - Msg { key: "status.less_running", text: row("Less Computer 正在运行", "Less Computer 正在執行", "Less Computer is running", "Less Computer 実行中", "Less Computer 실행 중") }, Msg { key: "status.marketplace_loaded", text: row("Marketplace 已加载 {} 个风格包", "Marketplace 已載入 {} 個風格包", "Marketplace loaded {} style packs", "Marketplace が {} 個のスタイルパックを読込", "마켓플레이스 스타일 팩 {}개 로드됨") }, @@ -1615,7 +1591,7 @@ pub const CATALOG: &[Msg] = &[ "먼저 단축키 설정에서 선택 질문 단축키를 지정하세요.", ), }, - Msg { + Msg { key: "selection_ask.history_desc", text: row( "开启后在本地保存问答记录,默认关闭。", @@ -1625,7 +1601,7 @@ pub const CATALOG: &[Msg] = &[ "활성화 시 Q&A 기록을 로컬에 저장. 기본 OFF.", ), }, - Msg { + Msg { key: "selection_ask.history_title", text: row("保存历史", "儲存歷史", "Save history", "履歴を保存", "기록 저장"), }, @@ -1645,13 +1621,7 @@ pub const CATALOG: &[Msg] = &[ }, Msg { key: "selection_ask.title", - text: row( - "划词追问", - "劃詞追問", - "Selection Ask", - "選択追問", - "선택 질문", - ), + text: row("问任何问题", "問任何問題", "Ask anything", "何でも質問", "무엇이든 질문"), }, Msg { key: "translation.desc", @@ -1663,7 +1633,7 @@ pub const CATALOG: &[Msg] = &[ "녹음 후 대상 언어로 자동 번역하여 삽입.", ), }, - Msg { + Msg { key: "translation.howto_step1", text: row( "在任意输入框聚焦光标。", @@ -1703,7 +1673,7 @@ pub const CATALOG: &[Msg] = &[ "다시 {} 를 눌러 정지.", ), }, - Msg { + Msg { key: "translation.howto_step5", text: row( "翻译结果自动插入到光标位置。", @@ -1717,19 +1687,19 @@ pub const CATALOG: &[Msg] = &[ key: "translation.howto_title", text: row("使用方法", "使用方法", "How to use", "使い方", "사용 방법"), }, - Msg { + Msg { key: "translation.kicker", text: row("翻译", "TRANSLATION", "TRANSLATION", "翻訳", "번역"), }, - Msg { + Msg { key: "translation.status_disabled", text: row("未启用", "未啟用", "Disabled", "無効", "비활성화됨"), }, - Msg { + Msg { key: "translation.status_enabled", text: row("已启用", "已啟用", "Enabled", "有効", "활성화됨"), }, - Msg { + Msg { key: "translation.style_desc", text: row( "自动继承「风格」页当前激活的风格包。", @@ -1759,7 +1729,7 @@ pub const CATALOG: &[Msg] = &[ "녹음 중 Shift 로 번역 실행. \"비활성화\" 시 Shift 무효.", ), }, - Msg { + Msg { key: "translation.target_disabled", text: row( "不启用(Shift 按下不触发翻译)", @@ -1867,7 +1837,7 @@ pub const CATALOG: &[Msg] = &[ "교정 규칙", ), }, - Msg { + Msg { key: "vocab.desc", text: row( "添加生词或专业术语,提高识别准确率。", @@ -1877,11 +1847,11 @@ pub const CATALOG: &[Msg] = &[ "새 단어나 전문 용어를 추가하여 인식 정확도 향상.", ), }, - Msg { + Msg { key: "vocab.kicker", text: row("词典", "VOCABULARY", "DICTIONARY", "辞書", "사전"), }, - Msg { + Msg { key: "vocabCard.title", text: row( "要记住这个词吗?", @@ -1891,15 +1861,15 @@ pub const CATALOG: &[Msg] = &[ "이 단어를 기억할까요?", ), }, - Msg { + Msg { key: "vocabCard.accept", text: row("记住", "記住", "Remember", "覚える", "기억하기"), }, - Msg { + Msg { key: "vocabCard.reject", text: row("不用", "不用", "Skip", "不要", "건너뛰기"), }, - Msg { + Msg { key: "vocab.learned_section", text: row( "确认收集({})", @@ -1909,7 +1879,7 @@ pub const CATALOG: &[Msg] = &[ "확인 후 수집 ({})", ), }, - Msg { + Msg { key: "vocab.placeholder", text: row( "输入词语,按 Enter 或点添加…", @@ -1919,7 +1889,7 @@ pub const CATALOG: &[Msg] = &[ "단어를 입력하고 Enter 또는 추가 클릭…", ), }, - Msg { + Msg { key: "vocab.presets_apply", text: row( "启用所选", @@ -1945,11 +1915,11 @@ pub const CATALOG: &[Msg] = &[ key: "vocab.presets_new_preset", text: row("新预设", "新預設", "New preset", "新しいプリセット", "새 프리셋"), }, - Msg { + Msg { key: "vocab.presets_save", text: row("保存预设", "儲存預設", "Save preset", "プリセットを保存", "프리셋 저장"), }, - Msg { + Msg { key: "vocab.presets_tip", text: row( "可多选批量启用,支持编辑和新建。", @@ -1987,7 +1957,7 @@ pub const CATALOG: &[Msg] = &[ key: "vocab.section_title", text: row("词条", "詞條", "Entries", "項目", "항목"), }, - Msg { + Msg { key: "vocab.tip", text: row( "支持中英混合 · 数字开头按字面识别 · 命中次数自动计数", @@ -1997,11 +1967,11 @@ pub const CATALOG: &[Msg] = &[ "한영 혼용 지원 · 숫자로 시작하면 그대로 인식 · 적중 횟수 자동 카운트", ), }, - Msg { + Msg { key: "vocab.title", text: row("词典", "詞彙表", "Dictionary", "辞書", "사전"), }, - Msg { + Msg { key: "style.custom_prompt_save", text: row("保存提示词", "儲存提示詞", "Save prompt", "プロンプトを保存", "프롬프트 저장"), }, @@ -2015,7 +1985,7 @@ pub const CATALOG: &[Msg] = &[ "녹음의 기본 출력 스타일 선택.", ), }, - Msg { + Msg { key: "style.kicker", text: row("风格", "STYLE", "STYLE", "スタイル", "스타일"), }, @@ -2071,7 +2041,7 @@ pub const CATALOG: &[Msg] = &[ key: "style.title", text: row("输出风格", "輸出風格", "Output style", "出力スタイル", "출력 스타일"), }, - Msg { + Msg { key: "marketplace.desc", text: row( "浏览、安装和分享社区风格包。", @@ -2109,7 +2079,7 @@ pub const CATALOG: &[Msg] = &[ key: "marketplace.install_btn", text: row("安装到本地", "安裝到本機", "Install", "インストール", "설치"), }, - Msg { + Msg { key: "marketplace.kicker", text: row("风格市场", "MARKETPLACE", "MARKETPLACE", "マーケット", "마켓"), }, @@ -2117,7 +2087,7 @@ pub const CATALOG: &[Msg] = &[ key: "marketplace.upload_btn", text: row("上传", "上傳", "Upload", "アップロード", "업로드"), }, - Msg { + Msg { key: "marketplace.upload.title", text: row( "选择要上传的风格包", @@ -2127,7 +2097,7 @@ pub const CATALOG: &[Msg] = &[ "업로드할 팩 선택", ), }, - Msg { + Msg { key: "marketplace.upload.updateTitle", text: row("更新「{}」", "更新「{}」", "Update \"{}\"", "「{}」を更新", "\"{}\" 업데이트"), }, @@ -2135,7 +2105,7 @@ pub const CATALOG: &[Msg] = &[ key: "marketplace.upload.hint", text: row("选择一个本地风格包发布到市场。", "選擇一個本機風格包發布到市場。", "Choose a local style pack to publish.", "公開するローカルスタイルパックを選択してください。", "게시할 로컬 스타일 팩을 선택하세요."), }, - Msg { + Msg { key: "marketplace.upload.updateHint", text: row( "选中要上传的本地新版本风格包,下方点「确定上传」。同名包默认预选。", @@ -2145,7 +2115,7 @@ pub const CATALOG: &[Msg] = &[ "업로드할 로컬 최신본을 선택하고 \"업로드 확정\"을 누르세요. 동명 팩이 기본 선택됩니다.", ), }, - Msg { + Msg { key: "marketplace.upload.noLocal", text: row( "本地没有可上传的风格包", @@ -2223,7 +2193,7 @@ pub const CATALOG: &[Msg] = &[ key: "modal.sections.help_center", text: row("帮助中心", "幫助中心", "Help center", "ヘルプセンター", "도움말 센터"), }, - Msg { + Msg { key: "modal.sections.release_notes", text: row( "发布日志", @@ -2237,7 +2207,7 @@ pub const CATALOG: &[Msg] = &[ key: "overview.actions.shortcuts", text: row("快捷键", "快捷鍵", "Shortcuts", "ショートカット", "단축키"), }, - Msg { + Msg { key: "overview.llm_name", text: row( "OpenAI 兼容", @@ -2247,46 +2217,6 @@ pub const CATALOG: &[Msg] = &[ "OpenAI 호환", ), }, - Msg { - key: "settings.coding_agent.enable", - text: row( - "启用 Less Computer", - "啟用 Less Computer", - "Enable Less Computer", - "Less Computer を有効化", - "Less Computer 켜기", - ), - }, - Msg { - key: "settings.coding_console.permission_mode", - text: row( - "权限模式", - "權限模式", - "Permission mode", - "権限モード", - "권한 모드", - ), - }, - Msg { - key: "settings.coding_console.title", - text: row( - "Claude 控制台", - "Claude 主控臺", - "Claude Console", - "Claude コンソール", - "Claude 콘솔", - ), - }, - Msg { - key: "settings.coding_console.workdir", - text: row( - "工作目录", - "工作目錄", - "Working directory", - "作業ディレクトリ", - "작업 디렉터리", - ), - }, Msg { key: "settings.data_storage.title", text: row("数据存储", "資料儲存", "Data storage", "データ保存", "데이터 저장"), @@ -2295,7 +2225,7 @@ pub const CATALOG: &[Msg] = &[ key: "settings.debug.title", text: row("调试工具", "除錯工具", "Debug tools", "デバッグツール", "디버그 도구"), }, - Msg { + Msg { key: "settings.language.title", text: row( "界面语言", @@ -2309,7 +2239,7 @@ pub const CATALOG: &[Msg] = &[ key: "settings.language.zh", text: row("简体中文", "簡體中文", "简体中文", "简体中文", "简体中文"), }, - Msg { + Msg { key: "settings.layout.title", text: row("布局", "佈局", "Layout", "レイアウト", "레이아웃"), }, @@ -2558,7 +2488,7 @@ pub const CATALOG: &[Msg] = &[ "자동 업데이트 확인", ), }, - Msg { + Msg { key: "settings.recording.desc", text: row( "全局录音的快捷键与触发方式。", @@ -2568,7 +2498,7 @@ pub const CATALOG: &[Msg] = &[ "전역 녹음의 단축키와 트리거 방식을 정의합니다.", ), }, - Msg { + Msg { key: "settings.recording.insert_group_title", text: row( "插入与剪贴板", @@ -2578,7 +2508,7 @@ pub const CATALOG: &[Msg] = &[ "삽입 및 클립보드", ), }, - Msg { + Msg { key: "settings.recording.microphone_system_default", text: row( "系统默认", @@ -2650,7 +2580,7 @@ pub const CATALOG: &[Msg] = &[ "원격 입력 활성화", ), }, - Msg { + Msg { key: "settings.remote_input.port_label", text: row("监听端口", "監聽連線埠", "Port", "待ち受けポート", "수신 포트"), }, @@ -2690,7 +2620,7 @@ pub const CATALOG: &[Msg] = &[ "선택 영역 도우미", ), }, - Msg { + Msg { key: "modal.sections.about", text: row( "关于与更新", @@ -2700,7 +2630,7 @@ pub const CATALOG: &[Msg] = &[ "정보 및 업데이트", ), }, - Msg { + Msg { key: "modal.sections.advanced", text: row( "实验与扩展", @@ -2720,7 +2650,7 @@ pub const CATALOG: &[Msg] = &[ "모양 및 언어", ), }, - Msg { + Msg { key: "modal.sections.general", text: row( "录音与输入", @@ -2730,7 +2660,7 @@ pub const CATALOG: &[Msg] = &[ "녹음 및 입력", ), }, - Msg { + Msg { key: "modal.sections.privacy", text: row( "权限与数据", @@ -2740,7 +2670,7 @@ pub const CATALOG: &[Msg] = &[ "권한 및 데이터", ), }, - Msg { + Msg { key: "modal.sections.services", text: row( "AI 服务与模型", @@ -2750,11 +2680,11 @@ pub const CATALOG: &[Msg] = &[ "AI 서비스 및 모델", ), }, - Msg { + Msg { key: "modal.sections.shortcuts", text: row("快捷键", "快捷鍵", "Shortcuts", "ショートカットと選択", "단축키 및 선택"), }, - Msg { + Msg { key: "settings.selection_workspace.hint", text: row( "有选区时按同一快捷键:关闭语音编辑则润色;开启后口述指令,可提问或编辑选区。无选区但光标在文本框时:可「帮我写」成稿或提问。", @@ -2850,7 +2780,7 @@ pub const CATALOG: &[Msg] = &[ key: "vocab.filter_all", text: row("所有", "所有", "All", "すべて", "전체"), }, - Msg { + Msg { key: "vocab.filter_auto", text: row("确认收集", "確認收集", "Confirmed", "確認して追加", "확인 후 수집"), }, @@ -3020,7 +2950,7 @@ pub const CATALOG: &[Msg] = &[ "플로팅 창 단축키", ), }, - Msg { + Msg { key: "settings.about.docs", text: row("文档", "檔案", "Docs", "ドキュメント", "문서"), }, @@ -3028,7 +2958,7 @@ pub const CATALOG: &[Msg] = &[ key: "settings.about.feedback", text: row("反馈", "反饋", "Feedback", "フィードバック", "피드백"), }, - Msg { + Msg { key: "settings.about.links_title", text: row( "文档链接", @@ -3052,7 +2982,7 @@ pub const CATALOG: &[Msg] = &[ "녹음은 전사를 위해 설정한 클라우드 공급자에게 전송될 수 있습니다.", ), }, - Msg { + Msg { key: "settings.about.qq", text: row( "社区 QQ 群", @@ -3062,7 +2992,7 @@ pub const CATALOG: &[Msg] = &[ "커뮤니티 QQ 그룹", ), }, - Msg { + Msg { key: "settings.about.source", text: row("源码", "原始碼", "Source", "ソース", "소스"), }, @@ -3096,7 +3026,7 @@ pub const CATALOG: &[Msg] = &[ "단일 멀티모달 모델로 음성 인식을 한 번에 처리합니다. 기존 ASR + LLM 설정과 완전히 분리됩니다.", ), }, - Msg { + Msg { key: "settings.advanced.platform_not_supported", text: row( "该平台暂未支持本地 ASR 模型集成。", @@ -3183,7 +3113,7 @@ pub const CATALOG: &[Msg] = &[ key: "settings.channels.elapsed", text: row("耗时 {} ms", "耗時 {} ms", "Took {} ms", "所要時間 {} ms", "소요 시간 {} ms"), }, - Msg { + Msg { key: "settings.channels.empty", text: row( "还没有渠道。点击「添加渠道」,连接你的第一个服务。", @@ -3301,7 +3231,7 @@ pub const CATALOG: &[Msg] = &[ "아직 확인하지 않음", ), }, - Msg { + Msg { key: "settings.channels.order_hint", text: row( "列表中第一个启用的渠道用于请求。拖动调整顺序;停用的渠道移到末尾。", @@ -3319,84 +3249,6 @@ pub const CATALOG: &[Msg] = &[ key: "settings.channels.verify", text: row("验证", "驗證", "Verify", "検証", "검증"), }, - Msg { - key: "settings.coding_agent.desc", - text: row( - "按住一个键说话,由所选 Agent 帮你操作电脑。仅 macOS。", - "按住一個鍵說話,由所選 Agent 幫你操作電腦。僅 macOS。", - "Hold a key, speak, and your selected agent operates your computer. macOS only.", - "キーを押して話すと、選択した Agent が PC を操作します。macOS のみ。", - "키를 누르고 말하면 선택한 Agent가 PC를 조작합니다. macOS 전용.", - ), - }, - Msg { - key: "settings.testing_features.title", - text: row( - "正在测试中的功能", - "正在測試中的功能", - "Features under testing", - "テスト中の機能", - "테스트 중인 기능", - ), - }, - Msg { - key: "settings.testing_features.enable", - text: row( - "启用正在测试中的功能", - "啟用正在測試中的功能", - "Enable features under testing", - "テスト中の機能を有効にする", - "테스트 중인 기능 활성화", - ), - }, - Msg { - key: "settings.testing_features.description", - text: row( - "开启后显示 Less Computer 和 Claude 控制台,可在支持的平台配置和使用。关闭会停止运行并隐藏入口,保留已有配置。仅在当前设备生效。", - "開啟後顯示 Less Computer 和 Claude 控制台,可在支援的平台設定和使用。關閉會停止執行並隱藏入口,保留既有設定。僅在目前裝置生效。", - "Show Less Computer and the Claude console for setup and use on supported platforms. Turning this off stops active tasks and hides their entries while keeping your settings. Applies only to this device.", - "対応するプラットフォームで Less Computer と Claude コンソールを表示し、設定・使用できます。オフにすると実行中のタスクを停止して項目を非表示にしますが、設定は保持されます。このデバイスのみに適用されます。", - "지원되는 플랫폼에서 Less Computer와 Claude 콘솔을 표시하고 설정 및 사용할 수 있습니다. 끄면 실행 중인 작업을 중지하고 항목을 숨기지만 설정은 유지됩니다. 이 기기에만 적용됩니다.", - ), - }, - Msg { - key: "modal.advanced_pages.testing_features", - text: row( - "按需开启尚在测试中的功能。", - "依需要開啟尚在測試中的功能。", - "Opt in to features that are still being tested.", - "テスト中の機能を必要に応じて有効にします。", - "필요할 때 테스트 중인 기능을 활성화합니다.", - ), - }, - Msg { - key: "settings.coding_agent.title", - text: row( - "Less Computer", - "Less Computer", - "Less Computer", - "Less Computer", - "Less Computer", - ), - }, - Msg { - key: "settings.coding_console.desc", - text: row( - "检测本机 Claude Code 与 MCP(computer use)状态,并护栏化地无头跑一次 Claude、流式查看输出与用量。", - "偵測本機 Claude Code 與 MCP(computer use)狀態,並以護欄方式無頭執行一次 Claude、串流檢視輸出與用量。", - "Detect your local Claude Code and MCP (computer use) status, then run Claude headlessly behind guardrails and watch the streamed output and cost.", - "ローカルの Claude Code と MCP(computer use)の状態を検出し、ガードレール付きで Claude をヘッドレス実行して、出力とコストをストリーミング表示します。", - "로컬 Claude Code 와 MCP(computer use) 상태를 감지하고, 가드레일 아래에서 Claude 를 헤드리스로 실행하여 출력과 비용을 스트리밍으로 확인합니다.", - ), - }, - Msg { - key: "settings.coding_console.detect", - text: row("检测", "偵測", "Detect", "検出", "감지"), - }, - Msg { - key: "settings.coding_console.status", - text: row("状态", "狀態", "Status", "状態", "상태"), - }, Msg { key: "settings.data_storage.desc", text: row( @@ -3417,7 +3269,7 @@ pub const CATALOG: &[Msg] = &[ "인식 문제를 진단할 때 사용합니다. 평소에는 꺼두어도 됩니다.", ), }, - Msg { + Msg { key: "settings.language.desc", text: row( "切换 UI 显示语言。当前会话即时生效,下次启动自动沿用。", @@ -3449,7 +3301,7 @@ pub const CATALOG: &[Msg] = &[ key: "settings.language.ko", text: row("한국어 (Beta)", "한국어 (Beta)", "한국어 (Beta)", "한국어 (Beta)", "한국어 (Beta)"), }, - Msg { + Msg { key: "settings.language.restart_hint", text: row( "部分原生菜单(系统托盘等)可能需要重启 App 才会切换。", @@ -3493,7 +3345,7 @@ pub const CATALOG: &[Msg] = &[ "접근성", ), }, - Msg { + Msg { key: "settings.permissions.desc_no_acc", text: row( "麦克风必需;全局快捷键状态用来检测 native hook 是否运行。", @@ -3507,7 +3359,7 @@ pub const CATALOG: &[Msg] = &[ key: "settings.permissions.granted", text: row("已授权", "已授權", "Granted", "許可済み", "허용됨"), }, - Msg { + Msg { key: "settings.permissions.hotkey_label", text: row( "全局快捷键", @@ -3525,7 +3377,7 @@ pub const CATALOG: &[Msg] = &[ key: "settings.permissions.mic_label", text: row("麦克风", "麥克風", "Microphone", "マイク", "마이크"), }, - Msg { + Msg { key: "settings.permissions.network_label", text: row("网络", "網路", "Network", "ネットワーク", "네트워크"), }, @@ -3533,7 +3385,7 @@ pub const CATALOG: &[Msg] = &[ key: "settings.permissions.network_ok", text: row("可用", "可用", "Available", "利用可能", "사용 가능"), }, - Msg { + Msg { key: "settings.permissions.open_system", text: row( "打开系统设置", @@ -3543,7 +3395,7 @@ pub const CATALOG: &[Msg] = &[ "시스템 설정 열기", ), }, - Msg { + Msg { key: "settings.providers.credential_storage_notice", text: row( "凭据保存在系统凭据库中。", @@ -3785,7 +3637,7 @@ pub const CATALOG: &[Msg] = &[ "Ctrl+Shift+V (kitty / alacritty / wezterm / 대부분 터미널)", ), }, - Msg { + Msg { key: "settings.recording.paste_shortcut_ctrl_v", text: row( "Ctrl+V(默认 / 多数应用)", @@ -3829,7 +3681,7 @@ pub const CATALOG: &[Msg] = &[ "원본 녹음 보관(디버그)", ), }, - Msg { + Msg { key: "settings.recording.restore_clipboard_label", text: row( "插入后恢复剪贴板", @@ -3863,7 +3715,7 @@ pub const CATALOG: &[Msg] = &[ key: "settings.recording.silence_auto_stop_seconds_value", text: row("{} 秒", "{} 秒", "{}s", "{} 秒", "{}초"), }, - Msg { + Msg { key: "settings.recording.start_minimized_label", text: row( "启动时静默运行", @@ -3873,7 +3725,7 @@ pub const CATALOG: &[Msg] = &[ "시작 시 메인 창 숨기기", ), }, - Msg { + Msg { key: "settings.recording.startup_at_boot", text: row( "开机自启", @@ -3893,7 +3745,7 @@ pub const CATALOG: &[Msg] = &[ "기본 녹음 방식", ), }, - Msg { + Msg { key: "settings.remote_input.enable_desc", text: row( "手机/平板浏览器连到电脑录音,语音实时落到电脑光标处(需 HTTPS,首次访问要信任证书)", @@ -3907,7 +3759,7 @@ pub const CATALOG: &[Msg] = &[ key: "settings.remote_input.mode_hold", text: row("按住说话", "按住說話", "Hold to talk", "押し続けて話す", "눌러서 말하기"), }, - Msg { + Msg { key: "settings.remote_input.mode_toggle", text: row( "点击切换", @@ -3917,16 +3769,6 @@ pub const CATALOG: &[Msg] = &[ "탭하여 전환", ), }, - Msg { - key: "settings.shortcuts.agent_voice", - text: row( - "Less Computer", - "Less Computer", - "Less Computer", - "Less Computer", - "Less Computer", - ), - }, Msg { key: "settings.shortcuts.cancel", text: row( @@ -3937,7 +3779,7 @@ pub const CATALOG: &[Msg] = &[ "이번 녹음 취소", ), }, - Msg { + Msg { key: "settings.shortcuts.desc_no_acc", text: row( "所有快捷键全局生效。若无响应,请在权限页查看全局快捷键监听状态。", @@ -3947,7 +3789,7 @@ pub const CATALOG: &[Msg] = &[ "모든 단축키는 전역에서 작동. 응답이 없으면 권한 페이지에서 전역 단축키 감지 상태를 확인해 주세요.", ), }, - Msg { + Msg { key: "settings.shortcuts.open_app", text: row( "打开 OpenLess", @@ -4027,7 +3869,7 @@ pub const CATALOG: &[Msg] = &[ "보수적 레이아웃", ), }, - Msg { + Msg { key: "settings.theme.stacked_row_layout_label", text: row( "易读布局(防溢出换行)", @@ -4092,7 +3934,7 @@ pub const CATALOG: &[Msg] = &[ ), }, Msg { key: "settings.providers.presets.deepseek", text: row("DeepSeek", "DeepSeek", "DeepSeek", "DeepSeek", "DeepSeek") }, - Msg { + Msg { key: "settings.providers.presets.siliconflow", text: row("硅基流动", "矽基流動", "SiliconFlow", "SiliconFlow", "SiliconFlow"), }, @@ -4227,7 +4069,7 @@ pub const CATALOG: &[Msg] = &[ "Bailian Fun-ASR-Flash (녹음 파일)", ), }, - Msg { + Msg { key: "settings.providers.presets.asrSiliconflow", text: row( "硅基流动 SenseVoice", @@ -4267,7 +4109,7 @@ pub const CATALOG: &[Msg] = &[ "Groq Whisper-large-v3", ), }, - Msg { + Msg { key: "settings.providers.presets.asrWhisper", text: row( "OpenAI Whisper(兼容)", @@ -4398,7 +4240,7 @@ pub const CATALOG: &[Msg] = &[ "로컬 Qwen3-ASR(C / CPU)", ), }, - Msg { + Msg { key: "settings.providers.presets.asrAppleSpeech", text: row( "苹果识别(macOS)", @@ -4457,7 +4299,7 @@ pub const CATALOG: &[Msg] = &[ key: "style.pack.add_pack_tile_title", text: row("新建风格包", "新建風格包", "New Pack", "新規パック", "새 팩"), }, - Msg { + Msg { key: "style.pack.dictation_prompt_hint", text: row( "用于录音转写后的 ASR 文本;这里可以写口语整理、ASR 错字纠正和专有名词还原规则。", @@ -4487,7 +4329,7 @@ pub const CATALOG: &[Msg] = &[ key: "style.pack.list_count", text: row("{} 个风格包", "{} 個風格包", "{} packs", "{} 個", "{}개"), }, - Msg { + Msg { key: "style.pack.list_desc", text: row( "浏览和切换风格包。", @@ -4531,7 +4373,7 @@ pub const CATALOG: &[Msg] = &[ "현재 버전, 업데이트 채널 및 자동 업데이트 설정을 확인합니다.", ), }, - Msg { + Msg { key: "modal.descriptions.advanced", text: row( "按需配置测试功能与调试工具。", @@ -4581,7 +4423,7 @@ pub const CATALOG: &[Msg] = &[ "음성 인식과 텍스트 처리 서비스, 채널, 로컬 모델 및 연결을 관리합니다.", ), }, - Msg { + Msg { key: "modal.descriptions.shortcuts", text: row( "搜索、分类与自定义各功能的快捷键。", @@ -4601,16 +4443,6 @@ pub const CATALOG: &[Msg] = &[ "디버그 녹음을 보관하고 커서 문맥을 확인하며 로그를 내보냅니다.", ), }, - Msg { - key: "modal.advanced_pages.less_computer", - text: row( - "选择 Agent,配置模型、权限与工作目录。", - "選擇 Agent,設定模型、權限與工作目錄。", - "Choose an agent and configure its model, permissions, and working directory.", - "Agent を選び、モデル・権限・作業ディレクトリを設定します。", - "Agent를 선택하고 모델, 권한, 작업 디렉터리를 설정합니다.", - ), - }, Msg { key: "modal.advanced_pages.multimodal", text: row( @@ -4635,7 +4467,7 @@ pub const CATALOG: &[Msg] = &[ "활성화하면 「서비스 → AI 공급자」 페이지에 전통 / 멀티모달 전환이 나타납니다. 전통 = ASR + LLM, 멀티모달 = 오디오 지원 모델 1개. 두 설정은 별도로 저장되며 자격 증명을 공유하지 않습니다.", ), }, - Msg { + Msg { key: "settings.language.label_desc", text: row( "选择「跟随系统」时按操作系统当前语言显示。", @@ -4648,11 +4480,11 @@ pub const CATALOG: &[Msg] = &[ Msg { key: "settings.network.use_system_proxy_desc", text: row( - "开启时请求跟随系统代理;关闭后所有网络请求直连(国内服务延迟通常更低),GitHub 登录、更新等境外服务可能连不上。实时语音流与 Less Computer 不受此开关影响。", - "開啟時請求跟隨系統代理;關閉後所有網路請求直連(國內服務延遲通常更低),GitHub 登入、更新等境外服務可能連不上。即時語音串流與 Less Computer 不受此開關影響。", - "When on, requests follow the system proxy. When off, all requests connect directly (usually lower latency for domestic services), but overseas services such as GitHub sign-in and updates may fail. Realtime voice streams and Less Computer are unaffected.", - "オンにするとリクエストはシステムプロキシを経由します。オフにするとすべて直接接続します(国内サービスの遅延が低くなる傾向)。GitHub ログインやアップデートなど海外サービスには接続できない場合があります。リアルタイム音声ストリームと Less Computer は影響を受けません。", - "켜면 요청이 시스템 프록시를 따릅니다. 끄면 모든 요청이 직결됩니다(국내 서비스는 보통 더 빠름). GitHub 로그인·업데이트 등 해외 서비스는 연결되지 않을 수 있습니다. 실시간 음성 스트림과 Less Computer는 영향을 받지 않습니다.", + "开启时请求跟随系统代理;关闭后所有网络请求直连(国内服务延迟通常更低),GitHub 登录、更新等境外服务可能连不上。实时语音流不受此开关影响。", + "開啟時請求跟隨系統代理;關閉後所有網路請求直連(國內服務延遲通常更低),GitHub 登入、更新等境外服務可能連不上。即時語音串流不受此開關影響。", + "When on, requests follow the system proxy. When off, all requests connect directly (usually lower latency for domestic services), but overseas services such as GitHub sign-in and updates may fail. Realtime voice streams are unaffected.", + "オンにするとリクエストはシステムプロキシを経由します。オフにするとすべて直接接続します(国内サービスの遅延が低くなる傾向)。GitHub ログインやアップデートなど海外サービスには接続できない場合があります。リアルタイム音声ストリームは影響を受けません。", + "켜면 요청이 시스템 프록시를 따릅니다. 끄면 모든 요청이 직결됩니다(국내 서비스는 보통 더 빠름). GitHub 로그인·업데이트 등 해외 서비스는 연결되지 않을 수 있습니다. 실시간 음성 스트림은 영향을 받지 않습니다.", ), }, Msg { @@ -4683,7 +4515,7 @@ pub const CATALOG: &[Msg] = &[ "로컬 보관 wav 파일 상한. 빈칸 = 200.", ), }, - Msg { + Msg { key: "settings.recording.combo_disable_hint", text: row( "核心快捷键不可停用,录音必须绑定一个热键", @@ -4693,7 +4525,7 @@ pub const CATALOG: &[Msg] = &[ "핵심 단축키는 비활성화할 수 없습니다 (녹음에는 단축키가 필수입니다)", ), }, - Msg { + Msg { key: "settings.recording.microphone_desc", text: row( "选择优先输入设备。设备断开时自动切到系统默认。", @@ -4721,7 +4553,7 @@ pub const CATALOG: &[Msg] = &[ key: "settings.recording.mode_hold", text: row("按住说话", "按住說話", "Push-to-talk", "押し続けて話す", "눌러서 말하기"), }, - Msg { + Msg { key: "settings.recording.mute_during_recording_desc", text: row( "录音期间临时静音系统输出,避免扬声器回音。", @@ -4741,14 +4573,14 @@ pub const CATALOG: &[Msg] = &[ "RNNoise 또는 PureVox 소음 억제, 자동 게인 및 리미터를 적용합니다. 기본값은 꺼짐이며 다음 녹음부터 적용됩니다.", ), }, - Msg { + Msg { key: "settings.recording.echo_cancellation_desc", text: row( - "保持系统播放声音,同时使用桌面音频输出作为参考抑制回声。部分设备、蓝牙路径或 Linux ALSA 环境可能不可用。", - "保持系統播放聲音,同時使用桌面音訊輸出作為參考抑制回聲。部分裝置、藍牙路徑或 Linux ALSA 環境可能不可用。", - "Use desktop output as a reference while keeping playback audible; some devices, Bluetooth routes, or Linux ALSA setups may be unavailable.", - "再生音を聞きながらデスクトップ出力を参照してエコーを抑制します。デバイス、Bluetooth、Linux ALSA では利用できない場合があります。", - "재생을 계속 들으면서 데스크톱 출력을 참조해 에코를 줄입니다. 일부 장치, Bluetooth 경로 또는 Linux ALSA에서는 사용할 수 없을 수 있습니다.", + "保持系统声音播放,使用本机播放参考抑制回声。macOS 需 14.6 或更新版本及系统音频录制权限;不支持的设备、蓝牙路径或 Linux ALSA 环境回退为普通麦克风录音。下次开始采集时生效。", + "保持系統聲音播放,使用本機播放參考抑制回聲。macOS 需 14.6 或更新版本及系統音訊錄製權限;不支援的裝置、藍牙路徑或 Linux ALSA 環境改用一般麥克風錄音。下次開始擷取時生效。", + "Uses a local playback reference while keeping playback audible. On macOS, requires 14.6+ and System Audio Recording permission. Unsupported devices, Bluetooth routes, or Linux ALSA use microphone-only recording. Applies to the next capture.", + "再生音を止めずにローカルの再生音を参照してエコーを抑制します。macOS は 14.6 以降とシステム音声の収録許可が必要です。非対応デバイス、Bluetooth 経路、Linux ALSA では通常のマイク録音に戻ります。次の収録開始時に適用されます。", + "재생을 유지하면서 로컬 재생음을 참조합니다. macOS는 14.6 이상 및 시스템 오디오 녹음 권한이 필요합니다. 지원하지 않는 장치, Bluetooth 경로 또는 Linux ALSA에서는 일반 마이크 녹음으로 전환합니다. 다음 캡처부터 적용됩니다.", ), }, Msg { @@ -4771,7 +4603,7 @@ pub const CATALOG: &[Msg] = &[ "최근 N 분간 정리된 전사를 멀티턴 컨텍스트로 전달합니다. 0 = 비활성화.", ), }, - Msg { + Msg { key: "settings.recording.polish_context_window_label", text: row( "对话上下文窗口(分钟)", @@ -4781,7 +4613,7 @@ pub const CATALOG: &[Msg] = &[ "대화 컨텍스트 윈도(분)", ), }, - Msg { + Msg { key: "settings.recording.polish_skip_max_chars_desc", text: row( "转写不超过 N 个字(不计空白)时直接插入原文,不调用润色模型。0 = 关闭。翻译模式始终润色。", @@ -4801,7 +4633,7 @@ pub const CATALOG: &[Msg] = &[ "짧은 입력은 다듬기 건너뛰기(최대 글자 수)", ), }, - Msg { + Msg { key: "settings.recording.restore_clipboard_desc", text: row( "粘贴成功后恢复你原来的剪贴板内容(仅 Windows / Linux)。", @@ -4959,130 +4791,6 @@ pub const CATALOG: &[Msg] = &[ "GitHub Issues ↗", ), }, - Msg { - key: "settings.coding_agent.coming_soon_note", - text: row( - "配置即时保存;热键触发与执行链路随后续版本生效。", - "設定即時儲存;熱鍵觸發與執行鏈路隨後續版本生效。", - "Config is saved now; hotkey triggering and the execution flow land in a later version.", - "設定はすぐ保存されます。ホットキー起動と実行フローは今後のバージョンで対応。", - "설정은 즉시 저장됩니다. 단축키 트리거와 실행 흐름은 이후 버전에서 제공됩니다.", - ), - }, - Msg { - key: "settings.coding_agent.exe", - text: row( - "可执行文件路径", - "可執行檔路徑", - "Executable path", - "実行ファイルのパス", - "실행 파일 경로", - ), - }, - Msg { - key: "settings.coding_agent.hotkey_hint", - text: row( - "开启后,按住快捷键说话,松开后由所选 Agent 处理并把结果显示在胶囊里。", - "開啟後,按住快捷鍵說話,放開後由所選 Agent 處理並把結果顯示在膠囊裡。", - "When enabled, hold the shortcut to talk; release it and the selected agent shows the result in the capsule.", - "有効にすると、ショートカットを押しながら話し、離すと選択した Agent の結果がカプセルに表示されます。", - "켜면 단축키를 누른 채 말하고, 놓으면 선택한 Agent 결과가 캡슐에 표시됩니다.", - ), - }, - Msg { - key: "settings.coding_agent.model", - text: row("模型", "模型", "Model", "モデル", "모델"), - }, - Msg { - key: "settings.coding_agent.model_hint", - text: row( - "Haiku 最快 · Sonnet 均衡 · Opus 最强", - "Haiku 最快 · Sonnet 均衡 · Opus 最強", - "Haiku = fastest · Sonnet = balanced · Opus = strongest", - "Haiku = 最速 · Sonnet = バランス · Opus = 最強", - "Haiku = 가장 빠름 · Sonnet = 균형 · Opus = 최강", - ), - }, - Msg { - key: "settings.coding_agent.model_placeholder", - text: row( - "默认 sonnet", - "預設 sonnet", - "Default: sonnet", - "デフォルト: sonnet", - "기본: sonnet", - ), - }, - Msg { - key: "settings.coding_agent.provider", - text: row( - "Agent 后端", - "Agent 後端", - "Agent backend", - "Agent バックエンド", - "Agent 백엔드", - ), - }, - Msg { - key: "settings.coding_console.mode.accept_edits", - text: row( - "放行(可恢复操作)", - "放行(可復原操作)", - "Allow (reversible)", - "許可(復元可能)", - "허용(복구 가능)", - ), - }, - Msg { - key: "settings.coding_console.mode.bypass_permissions", - text: row( - "完全放行(高风险)", - "完全放行(高風險)", - "Full bypass (risky)", - "完全許可(高リスク)", - "완전 허용(위험)", - ), - }, - Msg { - key: "settings.coding_console.mode.default", - text: row( - "默认(逐项确认)", - "預設(逐項確認)", - "Default (ask each)", - "デフォルト(都度確認)", - "기본(매번 확인)", - ), - }, - Msg { - key: "settings.coding_console.mode.plan", - text: row( - "只读 / 计划", - "唯讀 / 計畫", - "Read-only / plan", - "読み取り専用 / 計画", - "읽기 전용 / 계획", - ), - }, - Msg { - key: "settings.coding_console.workdir_desc", - text: row( - "可选。Claude 在此目录内运行;填写 git 仓库可启用运行前快照回滚。", - "選填。Claude 在此目錄內執行;填入 git 儲存庫可啟用執行前快照回滾。", - "Optional. Claude runs inside this dir; a git repo enables a pre-run snapshot for rollback.", - "任意。Claude はこのディレクトリ内で実行。git リポジトリなら実行前スナップショットで巻き戻し可能。", - "선택 사항. Claude 가 이 디렉터리에서 실행됩니다. git 저장소이면 실행 전 스냅샷으로 되돌릴 수 있습니다.", - ), - }, - Msg { - key: "settings.coding_console.workdir_placeholder", - text: row( - "留空则在临时目录运行", - "留空則於暫存目錄執行", - "Empty = run in a temp dir", - "空欄なら一時ディレクトリで実行", - "비우면 임시 디렉터리에서 실행", - ), - }, Msg { key: "common.experimental", text: row("实验性", "實驗性", "Experimental", "実験的", "실험적"), @@ -5117,16 +4825,6 @@ pub const CATALOG: &[Msg] = &[ "(시작 / 정지)", ), }, - Msg { - key: "settings.coding_agent.voice_hotkey_desc", - text: row( - "按住说话、松开执行。支持 Ctrl/Option/Fn 等单键。功能说明参见「高级」设置页。", - "按住說話、放開執行。支援 Ctrl/Option/Fn 等單鍵。功能說明參見「進階」設定頁。", - "Hold to talk, release to run. Supports Ctrl/Option/Fn single keys. See the Advanced settings page for what it does.", - "押して話す、離して実行。Ctrl/Option/Fn などの単キー対応。機能の説明は「詳細」設定ページを参照。", - "누르고 말하고 놓으면 실행. Ctrl/Option/Fn 단일 키 지원. 기능 설명은 「고급」 설정 페이지 참조.", - ), - }, Msg { key: "settings.recording.combo_conflict", text: row( @@ -5195,7 +4893,7 @@ pub const CATALOG: &[Msg] = &[ key: "qa.close_tooltip", text: row("关闭", "關閉", "Close", "閉じる", "닫기"), }, - Msg { + Msg { key: "qa.composer_placeholder", text: row( "输入问题,Enter 发送", @@ -5205,7 +4903,7 @@ pub const CATALOG: &[Msg] = &[ "질문을 입력하세요. Enter로 보내기", ), }, - Msg { + Msg { key: "qa.empty_desc", text: row( "选中任意文字后开始追问,或直接在下方输入问题。回答会显示在这里,可以连续多轮。", @@ -5239,7 +4937,7 @@ pub const CATALOG: &[Msg] = &[ key: "qa.header_hint", text: row("随时提问", "隨時提問", "Ask anytime", "いつでも質問", "언제든 질문하세요"), }, - Msg { + Msg { key: "qa.selection_preview", text: row( "基于选中文本:", @@ -5255,7 +4953,7 @@ pub const CATALOG: &[Msg] = &[ }, Msg { key: "qa.title", - text: row("划词追问", "劃詞追問", "Ask", "質問", "질문"), + text: row("问任何问题", "問任何問題", "Ask", "質問", "질문"), }, Msg { key: "selection.polish_preview.cancel", @@ -5344,90 +5042,6 @@ pub const CATALOG: &[Msg] = &[ text: row("取消固定", "取消固定", "Unpin", "ピン留めを解除", "고정 해제"), }, Msg { - key: "less_computer.approval_rerun_warning", - text: row( - "注意:批准后将在已被修改的工作区上重新运行,可能对不可重入操作产生副作用", - "注意:批准後將在已被修改的工作區上重新執行,可能對不可重入操作產生副作用", - "Note: approving re-runs on an already-modified workspace and may have side effects on non-idempotent operations.", - "注意:承認すると、すでに変更されたワークスペース上で再実行され、冪等でない操作に副作用が生じる可能性があります。", - "주의: 승인하면 이미 수정된 작업 공간에서 다시 실행되어 멱등하지 않은 작업에 부작용이 생길 수 있습니다.", - ), - }, - Msg { - key: "less_computer.approval_title", - text: row( - "执行被拦截的命令?", - "執行被攔截的指令?", - "Run blocked command?", - "ブロックされたコマンドを実行?", - "차단된 명령을 실행할까요?", - ), - }, - Msg { - key: "less_computer.approve", - text: row("允许", "允許", "Approve", "許可", "허용"), - }, - Msg { - key: "less_computer.compaction", - text: row( - "上下文已压缩", - "上下文已壓縮", - "Context compacted", - "コンテキストを圧縮しました", - "컨텍스트가 압축되었습니다", - ), - }, - Msg { - key: "less_computer.cost", - text: row("${}", "${}", "${}", "${}", "${}"), - }, - Msg { - key: "less_computer.deny", - text: row("拒绝", "拒絕", "Deny", "拒否", "거부"), - }, - Msg { - key: "less_computer.input_placeholder", - text: row( - "输入指令,Enter 发送", - "輸入指令,Enter 傳送", - "Type a command, Enter to send", - "指示を入力、Enter で送信", - "명령을 입력하고 Enter로 전송", - ), - }, - Msg { - key: "less_computer.send", - text: row("发送", "傳送", "Send", "送信", "전송"), - }, - Msg { - key: "less_computer.subtitle", - text: row( - "想让电脑做什么?", - "想讓電腦做什麼?", - "What should your computer do?", - "コンピュータに何をさせますか?", - "컴퓨터로 무엇을 할까요?", - ), - }, - Msg { - key: "less_computer.title", - text: row( - "Less Computer", - "Less Computer", - "Less Computer", - "Less Computer", - "Less Computer", - ), - }, - Msg { - key: "less_computer.tool", - text: row("调用了 {}", "呼叫了 {}", "Used {}", "{} を使用", "{} 사용"), - }, - Msg { - key: "less_computer.working", - text: row("正在操控电脑…", "正在操控電腦…", "Operating…", "操作中…", "조작 중…"), - }, - Msg { key: "onboarding.mic_no_device_hint", text: row( "未检测到麦克风,请连接并启用麦克风后重试。", @@ -5441,7 +5055,7 @@ pub const CATALOG: &[Msg] = &[ key: "settings.recording.audio_cue_preview", text: row("试听", "試聽", "Preview", "試聴", "미리듣기"), }, - Msg { + Msg { key: "settings.recording.capsule_desc", text: row( "录音 / 转写时显示屏幕底部胶囊。", @@ -5464,11 +5078,11 @@ pub const CATALOG: &[Msg] = &[ Msg { key: "settings.recording.capsule_style_classic", text: row( - "Openless 默认风格", - "Openless 預設風格", + "OpenLess 默认风格", + "OpenLess 預設風格", "OpenLess default style", - "Openless デフォルトスタイル", - "Openless 기본 스타일", + "OpenLess デフォルトスタイル", + "OpenLess 기본 스타일", ), }, Msg { @@ -5599,7 +5213,7 @@ pub const CATALOG: &[Msg] = &[ key: "settings.providers.modelLabel", text: row("模型", "模型", "Model", "モデル", "모델"), }, - Msg { + Msg { key: "settings.providers.modelSaved", text: row( "已保存模型 {}。", @@ -5669,7 +5283,7 @@ pub const CATALOG: &[Msg] = &[ "모델 검색…", ), }, - Msg { + Msg { key: "settings.providers.selectModel", text: row( "选择一个模型写入上方字段", @@ -5781,7 +5395,7 @@ pub const CATALOG: &[Msg] = &[ "(모델이 빈 결과를 반환했습니다)", ), }, - Msg { + Msg { key: "history.repolish.hint", text: row( "基于上面的原文再跑一次润色。结果只在本次查看时显示,不写回这条记录。原风格包已删除或旧记录时,重试将使用当前风格。", @@ -5823,7 +5437,7 @@ pub const CATALOG: &[Msg] = &[ "이름·태그 검색", ), }, - Msg { + Msg { key: "marketplace.myPacks.emptyTitle", text: row( "你还没有发布过风格包", @@ -5833,7 +5447,7 @@ pub const CATALOG: &[Msg] = &[ "아직 게시한 팩이 없습니다", ), }, - Msg { + Msg { key: "marketplace.myPacks.emptyHint", text: row( "在「风格」页面编辑后点「发布到风格市场」,或点击右上角上传本地风格包。", @@ -5843,7 +5457,7 @@ pub const CATALOG: &[Msg] = &[ "\"스타일\" 페이지에서 편집 후 \"마켓에 게시\"를 누르거나, 오른쪽 위에서 로컬 팩을 업로드하세요.", ), }, - Msg { + Msg { key: "marketplace.myPacks.noMatch", text: row( "没有匹配的风格包", @@ -5853,11 +5467,11 @@ pub const CATALOG: &[Msg] = &[ "일치하는 팩이 없습니다", ), }, - Msg { + Msg { key: "marketplace.myPacks.loadingTitle", text: row("正在拉取,请稍后…", "正在拉取,請稍後…", "Loading…", "読み込み中…", "불러오는 중…"), }, - Msg { + Msg { key: "marketplace.myPacks.loadingHint", text: row( "从风格市场获取你最新发布的风格包。", @@ -5867,7 +5481,7 @@ pub const CATALOG: &[Msg] = &[ "마켓에서 최신 게시물을 가져오는 중입니다.", ), }, - Msg { + Msg { key: "marketplace.myPacks.loadErrorTitle", text: row("加载失败", "載入失敗", "Load failed", "読み込み失敗", "불러오기 실패"), }, @@ -5965,7 +5579,7 @@ pub const CATALOG: &[Msg] = &[ key: "marketplace.modal.loggedInLabel", text: row("已登录", "已登入", "Signed in", "ログイン済み", "로그인됨"), }, - Msg { + Msg { key: "marketplace.oauth.loginBtn", text: row("登录", "登入", "Sign in", "サインイン", "로그인"), }, @@ -6121,7 +5735,7 @@ pub const CATALOG: &[Msg] = &[ key: "style.pack.unsaved", text: row("未保存", "未儲存", "Unsaved", "未保存", "저장 안 됨"), }, - Msg { + Msg { key: "style.pack.dictationPromptEditorDesc", text: row( "当前编辑录音 / ASR 风格 Prompt;输入对象是语音识别后的转写文本。", @@ -6131,7 +5745,7 @@ pub const CATALOG: &[Msg] = &[ "녹음 / ASR 스타일 프롬프트를 편집 중입니다. 입력은 음성 인식 후 받아쓰기 텍스트입니다.", ), }, - Msg { + Msg { key: "style.pack.selectionPromptEditorDesc", text: row( "当前编辑选区润色 Prompt;输入对象是用户主动选中的书面文字,不经过 ASR。", @@ -6195,7 +5809,7 @@ pub const CATALOG: &[Msg] = &[ "실시간 멀티턴 polish 전용", ), }, - Msg { + Msg { key: "style.pack.metaTitle", text: row( "安装信息", @@ -6205,19 +5819,19 @@ pub const CATALOG: &[Msg] = &[ "설치 정보", ), }, - Msg { + Msg { key: "style.pack.metaSource", text: row("来源", "來源", "Source", "ソース", "소스"), }, - Msg { + Msg { key: "style.pack.metaBaseMode", text: row("基础模式", "基礎模式", "Base Mode", "ベースモード", "베이스 모드"), }, - Msg { + Msg { key: "style.pack.metaUpdatedAt", text: row("更新时间", "更新時間", "Updated", "更新日時", "업데이트"), }, - Msg { + Msg { key: "style.pack.examplesTitle", text: row( "效果示例", @@ -6227,7 +5841,7 @@ pub const CATALOG: &[Msg] = &[ "효과 예시", ), }, - Msg { + Msg { key: "style.pack.examplesDesc", text: row( "会随风格包一起导出。", @@ -6237,11 +5851,11 @@ pub const CATALOG: &[Msg] = &[ "팩과 함께 내보내집니다.", ), }, - Msg { + Msg { key: "style.pack.addExample", text: row("新增示例", "新增範例", "Add Example", "例を追加", "예시 추가"), }, - Msg { + Msg { key: "style.pack.examplesEmpty", text: row( "还没有示例。", @@ -6251,7 +5865,7 @@ pub const CATALOG: &[Msg] = &[ "아직 예시가 없습니다.", ), }, - Msg { + Msg { key: "style.pack.exampleTitlePlaceholder", text: row( "示例 {} 标题", @@ -6261,11 +5875,11 @@ pub const CATALOG: &[Msg] = &[ "예시 {} 제목", ), }, - Msg { + Msg { key: "style.pack.exampleInput", text: row("输入", "輸入", "Input", "入力", "입력"), }, - Msg { + Msg { key: "style.pack.exampleOutput", text: row("输出", "輸出", "Output", "出力", "출력"), }, @@ -6323,7 +5937,7 @@ pub const CATALOG: &[Msg] = &[ key: "style.pack.runtimeInactive", text: row("当前未生效", "目前未生效", "Inactive", "無効", "비활성"), }, - Msg { + Msg { key: "style.pack.runtimePreviewOmittedFrontApp", text: row( "预览已省略前台 app 标签。", @@ -7653,7 +7267,7 @@ pub const CATALOG: &[Msg] = &[ "암호화 백업이 서비스 크기 제한을 초과합니다.", ), }, - Msg { + Msg { key: "cloudSyncE2ee.errors.unavailable", text: row( "加密同步暂不可用,请使用原生客户端并稍后检查服务。", @@ -8315,12 +7929,12 @@ mod tests { use super::*; const TEST_CATALOG: &[Msg] = &[ - Msg { + Msg { key: "k.full", text: row("完整", "完整", "Full", "完全", "전체"), }, // `k.missing_en` deliberately leaves `en` empty to exercise fallback. - Msg { + Msg { key: "k.missing_en", text: row("来源", "來源", "", "ソース", "출처"), }, diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index 765508486..6c0dcf211 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -12,7 +12,6 @@ mod backend; mod capabilities; pub mod cloud_sync_ui; -mod coding_agent; mod credentials; mod desktop; mod dictation_feedback; @@ -72,9 +71,8 @@ pub use fcitx5::{ copy_to_clipboard as fcitx5_copy_to_clipboard, ensure_plugin_installed as ensure_fcitx5_plugin_installed, reload_fcitx5_if_plugin_updated, reload_running_fcitx5, selection_text as fcitx5_selection_text, - set_hotkeys as set_fcitx5_hotkeys, - set_less_computer_hotkey_raw as set_fcitx5_less_computer_hotkey_raw, Fcitx5TextInserter, - FcitxPluginInstallPlan, FcitxPluginStatus, + set_hotkeys as set_fcitx5_hotkeys, Fcitx5TextInserter, FcitxPluginInstallPlan, + FcitxPluginStatus, }; pub use host_actions::LinuxHostActions; @@ -102,10 +100,10 @@ pub use logging::{ pub use popup::{ force_x11_for, popup_command, read_jsonl, run_popup, write_jsonl, ApplyOutcome as PopupApplyOutcome, CapsulePopupState, CapsuleSuggestion, HostToPopup, - LessComputerApproval, LessComputerEntry, LessComputerPopupState, PopupActionGuard, - PopupChatMessage, PopupKind, PopupSendError, PopupState, PopupSupervisor, PopupSupervisorEvent, - PopupToHost, ProtocolError as PopupProtocolError, ProtocolErrorKind as PopupProtocolErrorKind, - QaPolishState, QaPopupState, MAX_JSONL_LINE_BYTES, POPUP_PROTOCOL_VERSION, + PopupActionGuard, PopupChatMessage, PopupKind, PopupSendError, PopupState, PopupSupervisor, + PopupSupervisorEvent, PopupToHost, ProtocolError as PopupProtocolError, + ProtocolErrorKind as PopupProtocolErrorKind, QaPolishState, QaPopupState, MAX_JSONL_LINE_BYTES, + POPUP_PROTOCOL_VERSION, }; pub use popup_layer::{ capsule_geometry, capsule_path_override, choose_capsule_path, detect_capsule_path, @@ -119,7 +117,7 @@ pub use popup_window::{ bottom_center, clamp_to_area, monitor_containing, place_overlay, place_panel, popup_position, popup_size, select_overlay_window, x11_available, OverlayEnvironment, OverlayPlacement, OverlayX11, WindowCandidate, WindowMatch, X11Rect, CAPSULE_BOTTOM_GAP, CAPSULE_WINDOW_SIZE, - LESS_COMPUTER_WINDOW_SIZE, QA_WINDOW_SIZE, + QA_WINDOW_SIZE, }; pub use openless_core::contract::*; @@ -152,125 +150,6 @@ pub struct LinuxHost { backend: std::sync::Arc, settings_runtime: std::sync::Arc, translation_pending: std::sync::atomic::AtomicBool, - less_computer_voice: std::sync::Arc>, -} - -#[derive(Default)] -struct LinuxLessComputerCaptureState { - /// Set before entering Core so a recorder callback that fires during - /// construction can queue its Host effect against the right generation. - expected_session_id: Option, - session: Option, - pending: Option, -} - -struct LinuxLessComputerRecordingControl { - state: std::sync::Arc>, - runtime: tokio::runtime::Handle, -} - -impl LinuxLessComputerRecordingControl { - fn begin(&self, session_id: SessionId) -> Result<(), BackendError> { - let mut state = self - .state - .lock() - .expect("Linux Less Computer voice lock poisoned"); - if state.expected_session_id.is_some() || state.session.is_some() { - return Err(BackendError::new( - BackendErrorCode::Busy, - "Linux Less Computer capture is already active", - )); - } - state.expected_session_id = Some(session_id); - state.pending = None; - Ok(()) - } - - fn abort_start(&self, session_id: SessionId) { - let mut state = self - .state - .lock() - .expect("Linux Less Computer voice lock poisoned"); - if state.expected_session_id == Some(session_id) && state.session.is_none() { - state.expected_session_id = None; - state.pending = None; - } - } - - fn install(&self, session: LessComputerVoiceSession) -> Result<(), BackendError> { - let session_id = session.session_id(); - let pending = { - let mut state = self - .state - .lock() - .expect("Linux Less Computer voice lock poisoned"); - if state.expected_session_id != Some(session_id) || state.session.is_some() { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "Linux Less Computer capture generation changed during startup", - )); - } - state.session = Some(session); - state.pending.take() - }; - if let Some(action) = pending { - self.execute(session_id, action)?; - } - Ok(()) - } - - fn execute( - &self, - session_id: SessionId, - action: RecordingControlAction, - ) -> Result<(), BackendError> { - let session = { - let mut state = self - .state - .lock() - .expect("Linux Less Computer voice lock poisoned"); - if state.expected_session_id != Some(session_id) { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "Linux Less Computer recording control is stale", - )); - } - let Some(session) = state.session.take() else { - // Recorder level/fault callbacks can run before Core returns - // the capture object. Queue exactly one effect; Cancel wins - // because it is the stronger terminal request. - state.pending = Some(match (state.pending, action) { - (Some(RecordingControlAction::Cancel), _) - | (_, RecordingControlAction::Cancel) => RecordingControlAction::Cancel, - _ => RecordingControlAction::Stop, - }); - return Ok(()); - }; - state.expected_session_id = None; - state.pending = None; - session - }; - self.runtime.spawn(async move { - let result = match action { - RecordingControlAction::Stop => session.finish().await.map(|_| ()), - RecordingControlAction::Cancel => session.cancel().await, - }; - if let Err(error) = result { - log::warn!("Linux Less Computer recording control failed: {error}"); - } - }); - Ok(()) - } -} - -impl RecordingControlSink for LinuxLessComputerRecordingControl { - fn request( - &self, - session_id: SessionId, - action: RecordingControlAction, - ) -> Result<(), BackendError> { - self.execute(session_id, action) - } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -320,9 +199,6 @@ impl LinuxHost { backend, settings_runtime, translation_pending: std::sync::atomic::AtomicBool::new(false), - less_computer_voice: std::sync::Arc::new(std::sync::Mutex::new( - LinuxLessComputerCaptureState::default(), - )), } } @@ -349,9 +225,11 @@ impl LinuxHost { /// and the Linux platform-effect transaction. pub fn save_settings( &self, - preferences: UserPreferences, + mut preferences: UserPreferences, expected_preferences_revision: u64, ) -> Result { + // The retired Linux binding must not reserve keys in Core's shared validation. + preferences.coding_agent_voice_hotkey = None; self.backend.update_settings( preferences, SettingsUpdateOptions::SETTINGS_DOCUMENT.at_revision(expected_preferences_revision), @@ -362,9 +240,11 @@ impl LinuxHost { /// Apply a focused settings mutation with strict shortcut-collision rules. pub fn update_settings_strict( &self, - preferences: UserPreferences, + mut preferences: UserPreferences, expected_preferences_revision: u64, ) -> Result { + // The retired Linux binding must not reserve keys in Core's shared validation. + preferences.coding_agent_voice_hotkey = None; self.backend.update_settings( preferences, SettingsUpdateOptions::STRICT.at_revision(expected_preferences_revision), @@ -398,18 +278,6 @@ impl LinuxHost { event: LinuxHotkeyEvent, ) -> Result, BackendError> { match event { - LinuxHotkeyEvent::LessComputerPressed { press_id, at, .. } => { - self.dispatch_less_computer_edge(DictationHotkeyEdge::Pressed { press_id, at }) - .await - } - LinuxHotkeyEvent::LessComputerReleased { press_id, at, .. } => { - self.dispatch_less_computer_edge(DictationHotkeyEdge::Released { press_id, at }) - .await - } - LinuxHotkeyEvent::LessComputerCombined { press_id, at, .. } => { - self.dispatch_less_computer_edge(DictationHotkeyEdge::Combined { press_id, at }) - .await - } LinuxHotkeyEvent::DictationPressed { press_id, at, .. } => { let translation_requested = self .translation_pending @@ -500,94 +368,4 @@ impl LinuxHost { } } } - - async fn dispatch_less_computer_edge( - &self, - edge: DictationHotkeyEdge, - ) -> Result, BackendError> { - match self.backend.dispatch_less_computer_hotkey_edge(edge) { - LessComputerHotkeyAction::Start => { - let session_id = SessionId::new(); - let control = std::sync::Arc::new(LinuxLessComputerRecordingControl { - state: std::sync::Arc::clone(&self.less_computer_voice), - runtime: tokio::runtime::Handle::current(), - }); - control.begin(session_id)?; - let session = self - .backend - .start_less_computer_voice(session_id, control.clone()) - .await; - match session { - Ok(session) => control.install(session)?, - Err(error) => { - control.abort_start(session_id); - return Err(error); - } - } - } - LessComputerHotkeyAction::Finish => { - let session_id = self - .less_computer_voice - .lock() - .expect("Linux Less Computer voice lock poisoned") - .expected_session_id; - if let Some(session_id) = session_id { - LinuxLessComputerRecordingControl { - state: std::sync::Arc::clone(&self.less_computer_voice), - runtime: tokio::runtime::Handle::current(), - } - .execute(session_id, RecordingControlAction::Stop)?; - } - } - LessComputerHotkeyAction::Cancel => { - self.backend.cancel_active_voice_session(None).await?; - let mut state = self - .less_computer_voice - .lock() - .expect("Linux Less Computer voice lock poisoned"); - state.expected_session_id = None; - state.session = None; - state.pending = None; - } - LessComputerHotkeyAction::Noop => {} - } - Ok(None) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn less_computer_recording_control_queues_early_cancel_for_the_current_generation() { - let state = std::sync::Arc::new(std::sync::Mutex::new( - LinuxLessComputerCaptureState::default(), - )); - let control = LinuxLessComputerRecordingControl { - state: std::sync::Arc::clone(&state), - runtime: tokio::runtime::Handle::current(), - }; - let session_id = SessionId::new(); - control.begin(session_id).unwrap(); - - control - .request(session_id, RecordingControlAction::Stop) - .unwrap(); - control - .request(session_id, RecordingControlAction::Cancel) - .unwrap(); - - let queued = state.lock().unwrap(); - assert_eq!(queued.expected_session_id, Some(session_id)); - assert_eq!(queued.pending, Some(RecordingControlAction::Cancel)); - drop(queued); - assert_eq!( - control - .request(SessionId::new(), RecordingControlAction::Stop) - .unwrap_err() - .code, - BackendErrorCode::Cancelled - ); - } } diff --git a/openless-all/app/linux-egui/src/linux_app/settings_save.rs b/openless-all/app/linux-egui/src/linux_app/settings_save.rs index ce0a3cf24..f19dd2a81 100644 --- a/openless-all/app/linux-egui/src/linux_app/settings_save.rs +++ b/openless-all/app/linux-egui/src/linux_app/settings_save.rs @@ -187,9 +187,6 @@ impl OpenLessEguiApp { frontend::view_model::SettingsField::Multimodal => { preferences.multimodal_pipeline_enabled = !preferences.multimodal_pipeline_enabled; } - frontend::view_model::SettingsField::LessComputer => { - preferences.coding_agent_enabled = !preferences.coding_agent_enabled; - } frontend::view_model::SettingsField::SelectionVoiceEnabled => { preferences.selection_voice_enabled = !preferences.selection_voice_enabled; } @@ -204,9 +201,6 @@ impl OpenLessEguiApp { openless_core::shared_types::SelectionVoiceIntentMode::Auto }; } - frontend::view_model::SettingsField::TestingFeatures => { - preferences.testing_features_enabled = !preferences.testing_features_enabled; - } frontend::view_model::SettingsField::SilenceAutoStop => { preferences.silence_auto_stop_enabled = !preferences.silence_auto_stop_enabled; } @@ -321,24 +315,6 @@ impl OpenLessEguiApp { _ => openless_core::shared_types::HotkeyMode::Toggle, }; } - frontend::view_model::SettingsComboField::CodingAgentProvider => { - preferences.coding_agent_provider = match index { - 1 => "opencode-cli", - 2 => "codex-cli", - 3 => "dsh-cli", - _ => "claude-code-cli", - } - .to_string(); - } - frontend::view_model::SettingsComboField::CodingAgentPermission => { - preferences.coding_agent_permission_mode = match index { - 1 => "plan", - 2 => "default", - 3 => "bypassPermissions", - _ => "acceptEdits", - } - .to_string(); - } frontend::view_model::SettingsComboField::SelectionPolishDelivery => { preferences.selection_polish_output_mode = match index { 1 => openless_core::shared_types::SelectionPolishOutputMode::PreviewConfirm, @@ -435,11 +411,6 @@ impl OpenLessEguiApp { ShortcutField::QuickNote => preferences.quick_note_hotkey = Some(binding), ShortcutField::SwitchStyle => preferences.switch_style_hotkey = Some(binding), ShortcutField::OpenApp => preferences.open_app_hotkey = Some(binding), - ShortcutField::CodingAgentVoice => { - preferences.coding_agent_voice_hotkey = Some(binding); - // 「按住说话」有了触发键,Agent 也就该启用(Tauri 同样顺带打开)。 - preferences.coding_agent_enabled = true; - } ShortcutField::SelectionPolish => preferences.selection_polish_hotkey = Some(binding), ShortcutField::StylePack(index) => { if let Some(row) = preferences.style_pack_hotkeys.get_mut(index) { @@ -482,7 +453,6 @@ impl OpenLessEguiApp { ShortcutField::QuickNote => preferences.quick_note_hotkey = None, ShortcutField::SwitchStyle => preferences.switch_style_hotkey = None, ShortcutField::OpenApp => preferences.open_app_hotkey = None, - ShortcutField::CodingAgentVoice => preferences.coding_agent_voice_hotkey = None, ShortcutField::SelectionPolish => preferences.selection_polish_hotkey = None, ShortcutField::StylePack(index) => { if let Some(row) = preferences.style_pack_hotkeys.get(index) { @@ -520,9 +490,6 @@ impl OpenLessEguiApp { preferences.switch_style_hotkey = defaults.switch_style_hotkey } ShortcutField::OpenApp => preferences.open_app_hotkey = defaults.open_app_hotkey, - ShortcutField::CodingAgentVoice => { - preferences.coding_agent_voice_hotkey = defaults.coding_agent_voice_hotkey - } ShortcutField::SelectionPolish => { preferences.selection_polish_hotkey = defaults.selection_polish_hotkey } @@ -638,33 +605,6 @@ impl OpenLessEguiApp { self.frontend_vm.settings.audio_recording_max_entries = text; } - frontend::view_model::SettingsTextField::CodingAgentModel => { - preferences.coding_agent_model = if text.trim().is_empty() { - None - } else { - Some(text.trim().to_string()) - }; - - self.frontend_vm.settings.coding_agent_model = text; - } - frontend::view_model::SettingsTextField::CodingAgentWorkdir => { - preferences.coding_agent_workdir = if text.trim().is_empty() { - None - } else { - Some(text.trim().to_string()) - }; - - self.frontend_vm.settings.coding_agent_workdir = text; - } - frontend::view_model::SettingsTextField::CodingAgentExe => { - preferences.coding_agent_exe = if text.trim().is_empty() { - None - } else { - Some(text.trim().to_string()) - }; - - self.frontend_vm.settings.coding_agent_exe = text; - } frontend::view_model::SettingsTextField::SelectionVoiceKeywords => { // Tauri:一行一条,空行丢弃。 preferences.selection_voice_edit_keywords = text diff --git a/openless-all/app/linux-egui/src/local_hotkeys.rs b/openless-all/app/linux-egui/src/local_hotkeys.rs index bec59adf8..3bea89ccf 100644 --- a/openless-all/app/linux-egui/src/local_hotkeys.rs +++ b/openless-all/app/linux-egui/src/local_hotkeys.rs @@ -33,7 +33,6 @@ pub enum LocalHotkey { SwitchStyle, SelectionPolish, OpenApp, - LessComputer, /// 风格包热键带 **pack id**(不是下标):窗口进程与宿主各自持有的 /// `HotkeyRuntimeTarget` 可能来自不同时刻,用下标会指错包。 StylePack(String), @@ -98,7 +97,7 @@ pub fn match_hotkey( // 不支持的主键(egui 词表里有、X11 keysym 表里没有):交给插件那条路。 return None; } - let candidates: [(Option<&ShortcutBinding>, LocalHotkey); 8] = [ + let candidates: [(Option<&ShortcutBinding>, LocalHotkey); 7] = [ (Some(&target.dictation), LocalHotkey::Dictation), (target.qa.as_ref(), LocalHotkey::Qa), (target.quick_note.as_ref(), LocalHotkey::QuickNote), @@ -109,13 +108,6 @@ pub fn match_hotkey( (Some(&target.translation), LocalHotkey::Translation), (target.switch_style.as_ref(), LocalHotkey::SwitchStyle), (target.open_app.as_ref(), LocalHotkey::OpenApp), - ( - target - .coding_agent_voice - .as_ref() - .filter(|_| target.coding_agent_enabled), - LocalHotkey::LessComputer, - ), ]; for (binding, hotkey) in candidates { let Some(binding) = binding else { continue }; @@ -201,9 +193,6 @@ pub fn plugin_event_hotkey( } LinuxHotkeyEvent::SwitchStylePressed => LocalHotkey::SwitchStyle, LinuxHotkeyEvent::OpenAppPressed => LocalHotkey::OpenApp, - LinuxHotkeyEvent::LessComputerPressed { .. } - | LinuxHotkeyEvent::LessComputerReleased { .. } - | LinuxHotkeyEvent::LessComputerCombined { .. } => LocalHotkey::LessComputer, LinuxHotkeyEvent::StylePackPressed { symbol, states } => { let raw = (*symbol, *states); target @@ -364,10 +353,6 @@ mod tests { match_hotkey(&target, &binding(";", &["ctrl", "shift"])), Some(LocalHotkey::Qa) ); - assert_eq!( - match_hotkey(&target, &binding("V", &["alt"])), - Some(LocalHotkey::LessComputer) - ); // 没绑的组合不命中。 assert_eq!(match_hotkey(&target, &binding("B", &["alt"])), None); // 修饰键不同也不命中(Alt+Z 是翻译,不是听写)。 @@ -421,13 +406,6 @@ mod tests { ); } - #[test] - fn a_disabled_less_computer_hotkey_is_not_matched() { - let mut target = target(); - target.coding_agent_enabled = false; - assert_eq!(match_hotkey(&target, &binding("V", &["alt"])), None); - } - #[test] fn plugin_events_map_to_the_same_hotkey_identity() { let target = target(); diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index bfa00c512..4dbcb0f8d 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -32,7 +32,7 @@ mod linux_app { use eframe::egui; use openless_core::{ BackendConfig, BackendError, BackendEvent, BackendEventKind, BackendSnapshot, - DictationPhase, HostAction, LessComputerEventKind, QaStateEvent, QaStateKind, + DictationPhase, HostAction, QaStateEvent, QaStateKind, SelectionPhase, SelectionSnapshot, TranscriptAccumulator, UserPreferences, }; use openless_linux_egui::{ @@ -525,13 +525,12 @@ mod linux_app { const POPUP_RESTART_LIMIT: usize = 2; const POPUP_RESTART_WINDOW: Duration = Duration::from_secs(60); /// 面板种类数(`PopupKind` 没有 index 方法,这里只用于数组下标)。 - const POPUP_KIND_COUNT: usize = 3; + const POPUP_KIND_COUNT: usize = 2; fn popup_kind_index(kind: PopupKind) -> usize { match kind { PopupKind::Qa => 0, PopupKind::Capsule => 1, - PopupKind::LessComputer => 2, } } @@ -596,17 +595,6 @@ mod linux_app { transcript_session: Option, recording_phase_active: bool, last_event_sequence: u64, - less_computer_input: String, - less_computer_output: String, - less_computer_turn_start: usize, - less_computer_session: Option, - /// Less Computer 面板要呈现的事件序列。宿主是唯一所有者,弹窗进程只负责画; - /// 每次重连都收到完整序列,窗口进程重启不丢历史。 - less_computer_entries: Vec, - /// 本轮尚未终结(面板显示「执行中…」)。 - less_computer_working: bool, - /// 已展示过的面板是否还在(托起面板时只推状态,不重复拉起进程)。 - less_computer_popup: Option, pending_approval: Option<(String, String)>, qa_visible: bool, /// 划词追问的图钉:固定后 `HostAction::HideQa` 不再收起窗口。 @@ -859,13 +847,6 @@ mod linux_app { transcript_session: None, recording_phase_active: false, last_event_sequence: 0, - less_computer_input: String::new(), - less_computer_output: String::new(), - less_computer_turn_start: 0, - less_computer_session: None, - less_computer_entries: Vec::new(), - less_computer_working: false, - less_computer_popup: None, pending_approval: None, qa_visible: false, qa_pinned: false, @@ -980,13 +961,6 @@ mod linux_app { transcript_session: None, recording_phase_active: false, last_event_sequence: 0, - less_computer_input: String::new(), - less_computer_output: String::new(), - less_computer_turn_start: 0, - less_computer_session: None, - less_computer_entries: Vec::new(), - less_computer_working: false, - less_computer_popup: None, pending_approval: None, qa_visible: false, qa_pinned: false, @@ -1217,7 +1191,6 @@ mod linux_app { match kind { PopupKind::Qa => &mut self.qa_popup, PopupKind::Capsule => &mut self.capsule_popup, - PopupKind::LessComputer => &mut self.less_computer_popup, } } @@ -1345,59 +1318,6 @@ mod linux_app { ); } - /// Less Computer 面板的当前快照:宿主是事件序列的唯一所有者,弹窗进程 - /// 每次重连都收到完整序列(重开窗口不丢历史)。 - fn less_computer_snapshot(&self, lang: Lang) -> HostToPopup { - let approval = self.pending_approval.as_ref().map(|(token, command)| { - openless_linux_egui::LessComputerApproval { - token: token.clone(), - command: command.clone(), - reason: tr_l10n(lang, "less_computer.approval_rerun_warning").to_string(), - } - }); - HostToPopup::LessComputer { - version: POPUP_PROTOCOL_VERSION, - session_id: self - .less_computer_session - .map(|session| session.to_string()) - .unwrap_or_else(|| "less-computer".to_string()), - sequence: self.last_event_sequence.saturating_mul(2), - entries: self.less_computer_entries.clone(), - working: self.less_computer_working, - approval, - error: None, - } - } - - fn show_less_computer_popup(&mut self) { - if self - .backend() - .is_none_or(|backend| backend.ensure_testing_features_enabled().is_err()) - { - return; - } - self.ensure_popup(PopupKind::LessComputer); - if self - .backend() - .is_none_or(|backend| backend.ensure_testing_features_enabled().is_err()) - { - self.less_computer_popup = None; - return; - } - let message = self.less_computer_snapshot(self.lang); - self.send_popup(PopupKind::LessComputer, message); - } - - /// ✕ 只收起面板:不动已完成的对话,也不结束进程。 - fn hide_less_computer_popup(&mut self) { - let session_id = self - .less_computer_session - .map(|session| session.to_string()) - .unwrap_or_else(|| "less-computer".to_string()); - let sequence = self.last_event_sequence.saturating_mul(2).saturating_add(1); - self.hide_popup(PopupKind::LessComputer, session_id, sequence); - } - fn expected_popup_session(&self, kind: PopupKind) -> Option { match kind { // 面板已经收起:任何排队消息都不再有合法目标。 @@ -1418,9 +1338,6 @@ mod linux_app { .and_then(|snapshot| snapshot.dictation.session_id) .map(|session_id| session_id.to_string()) }), - PopupKind::LessComputer => self - .less_computer_session - .map(|session_id| session_id.to_string()), } } @@ -1907,7 +1824,6 @@ mod linux_app { let stale = match kind { PopupKind::Qa => generation != self.qa_popup_presentation.generation, PopupKind::Capsule => generation != self.capsule_popup_generation, - PopupKind::LessComputer => false, }; if stale { return; @@ -2127,10 +2043,6 @@ mod linux_app { } } PopupKind::Capsule => self.show_capsule_popup(), - PopupKind::LessComputer => { - self.send_popup_hotkeys(kind); - self.show_less_computer_popup(); - } }, PopupSupervisorEvent::Message(PopupToHost::Hotkey { edge, .. }) => { log::debug!("[hotkey] local edge from the {kind:?} panel: {edge:?}"); @@ -2192,56 +2104,6 @@ mod linux_app { }); } } - PopupSupervisorEvent::Message(PopupToHost::SubmitLessComputer { - session_id, - text, - .. - }) if self - .less_computer_session - .map(|session| session.to_string()) - .as_deref() - == Some(session_id.as_str()) => - { - if let Some(backend) = self.backend() { - // Core 自己解析 provider / 模型 / 权限 / workdir; - // 宿主只负责把用户文本交给它(Tauri `lessComputerSubmitText`)。 - self.spawn(async move { - backend.submit_less_computer(text).await?; - Ok(String::new()) - }); - } - } - PopupSupervisorEvent::Message(PopupToHost::ApproveLessComputer { - token, - approved, - .. - }) => { - let backend = self.backend(); - self.spawn(async move { - if let Some(backend) = backend { - backend - .services() - .less_computer - .approve(token, approved) - .await?; - } - Ok(String::new()) - }); - } - PopupSupervisorEvent::Message(PopupToHost::CancelLessComputer { .. }) => { - let session = self.less_computer_session; - let backend = self.backend(); - self.spawn(async move { - if let Some(backend) = backend { - backend.cancel_less_computer(session).await?; - } - Ok(String::new()) - }); - } - PopupSupervisorEvent::Message(PopupToHost::DismissLessComputer { .. }) => { - // 只收起面板:已完成的一轮保留在宿主状态里,下次打开仍在。 - self.hide_less_computer_popup(); - } PopupSupervisorEvent::Message( PopupToHost::SubmitQa { .. } | PopupToHost::ToggleQaRecording { .. } @@ -2249,8 +2111,7 @@ mod linux_app { | PopupToHost::SetPinned { .. } | PopupToHost::SetEditInstructionMode { .. } | PopupToHost::ApplyEdit { .. } - | PopupToHost::RevertEdit { .. } - | PopupToHost::SubmitLessComputer { .. }, + | PopupToHost::RevertEdit { .. }, ) => { self.status = tr_l10n(lang, "popup.ignore_late_qa").to_string(); } @@ -3884,129 +3745,6 @@ mod linux_app { }); } } - BackendEventKind::LessComputerEvent(event) => { - if self - .backend() - .is_some_and(|backend| !backend.get_preferences().testing_features_enabled) - { - return; - } - // Less Computer events may complete after a newer turn has - // already started. Session ownership, not arrival time, - // decides whether a delta/terminal may mutate this view. - if let LessComputerEventKind::User { text, fresh } = &event.kind { - // Every User starts a new turn UUID, including a - // continuation. `fresh` describes conversation history, - // never whether this turn is allowed to receive output. - self.less_computer_session = session_id; - self.pending_approval = None; - if *fresh { - self.less_computer_output.clear(); - } else if !self.less_computer_output.is_empty() { - self.less_computer_output.push_str("\n\n"); - } - self.less_computer_turn_start = self.less_computer_output.len(); - self.less_computer_input = text.clone(); - if *fresh { - self.less_computer_entries.clear(); - } - self.less_computer_entries - .push(openless_linux_egui::LessComputerEntry { - kind: "user".to_string(), - text: text.clone(), - }); - } else if session_id != self.less_computer_session { - return; - } - match event.kind { - // Linux already has its own recording display; new typed feedback is - // for the Host/UI team taking over to wire up. - LessComputerEventKind::VoiceState { .. } => {} - LessComputerEventKind::User { .. } => {} - LessComputerEventKind::Started => { - self.status = tr_l10n(lang, "status.less_running").to_string(); - self.less_computer_working = true; - } - LessComputerEventKind::Delta { text } => { - self.less_computer_output.push_str(&text); - append_assistant_entry(&mut self.less_computer_entries, &text); - } - LessComputerEventKind::Tool { name } => { - self.status = fmt_l10n(lang, "status.less_tool", &[&name]); - self.less_computer_entries.push( - openless_linux_egui::LessComputerEntry { - kind: "tool".to_string(), - // 行内标记的文案在宿主侧本地化:面板只画文本。 - text: fmt_l10n(lang, "less_computer.tool", &[&name]), - }, - ); - } - LessComputerEventKind::Compaction => { - self.status = tr_l10n(lang, "status.less_compacted").to_string(); - self.less_computer_entries.push( - openless_linux_egui::LessComputerEntry { - kind: "compaction".to_string(), - text: tr_l10n(lang, "less_computer.compaction").to_string(), - }, - ); - } - LessComputerEventKind::Completed { text, cost_usd } => { - // A terminal is authoritative even for final-only - // providers or after a missed partial event. - self.less_computer_output - .truncate(self.less_computer_turn_start); - self.less_computer_output.push_str(&text); - self.pending_approval = None; - self.less_computer_working = false; - // 终局正文替换掉流式累积的那条助手条目。 - match self - .less_computer_entries - .iter_mut() - .rev() - .find(|entry| entry.kind == "assistant") - { - Some(entry) => entry.text = text.clone(), - None => self.less_computer_entries.push( - openless_linux_egui::LessComputerEntry { - kind: "assistant".to_string(), - text: text.clone(), - }, - ), - } - if let Some(cost) = cost_usd { - let cost_text = - fmt_l10n(lang, "less_computer.cost", &[&format!("{cost:.3}")]); - self.less_computer_entries.push( - openless_linux_egui::LessComputerEntry { - kind: "note".to_string(), - text: cost_text, - }, - ); - } - self.status = tr_l10n(lang, "less_computer.done").to_string(); - } - LessComputerEventKind::Approval { token, command, .. } => { - self.pending_approval = Some((token, command)); - self.status = tr_l10n(lang, "status.less_waiting").to_string(); - } - LessComputerEventKind::Error { message } => { - self.pending_approval = None; - self.less_computer_working = false; - self.less_computer_entries.push( - openless_linux_egui::LessComputerEntry { - kind: "error".to_string(), - text: message.clone(), - }, - ); - self.status = message; - } - LessComputerEventKind::Cancelled => { - self.pending_approval = None; - self.less_computer_working = false; - self.status = tr_l10n(lang, "less_computer.cancelled").to_string(); - } - } - } BackendEventKind::VocabularySuggestionsChanged(_) => { // Core 只负责攒候选;「要不要记住」的确认入口是胶囊窗上的卡片 // (Tauri `VocabSuggestionCard`)。没有这张卡,自动学词永远落不了库。 @@ -4031,17 +3769,6 @@ mod linux_app { } self.preferences = Some(self.settings_save.rebase(&latest)); } - if self - .backend() - .is_some_and(|backend| !backend.get_preferences().testing_features_enabled) - { - // Dropping the supervisor closes a feature that is no longer opted in. - self.less_computer_popup = None; - self.less_computer_working = false; - self.pending_approval = None; - self.less_computer_session = None; - self.less_computer_entries.clear(); - } self.load_remote_status(); self.load_library(); } @@ -4493,36 +4220,6 @@ mod linux_app { at, }, }), - LocalHotkey::LessComputer => { - if !single_shot { - continue; - } - events.push(match edge.kind { - LocalHotkeyEdgeKind::Pressed => LinuxHotkeyEvent::LessComputerPressed { - symbol: 0, - states: 0, - press_id: edge.press_id, - at, - }, - LocalHotkeyEdgeKind::Released => continue, - LocalHotkeyEdgeKind::Combined => { - LinuxHotkeyEvent::LessComputerCombined { - symbol: 0, - states: 0, - press_id: edge.press_id, - at, - } - } - LocalHotkeyEdgeKind::Cancelled => { - LinuxHotkeyEvent::LessComputerCombined { - symbol: 0, - states: 0, - press_id: edge.press_id, - at, - } - } - }); - } LocalHotkey::Translation => { if single_shot { if let Some(event) = @@ -4574,10 +4271,6 @@ mod linux_app { .as_ref() .and_then(|state| state.session_id.clone()) .unwrap_or_else(|| "qa".to_string()), - PopupKind::LessComputer => self - .less_computer_session - .map(|session| session.to_string()) - .unwrap_or_else(|| "less-computer".to_string()), // 胶囊不接受键盘焦点,本地匹配对它没有意义。 PopupKind::Capsule => return, }; @@ -4607,7 +4300,6 @@ mod linux_app { }); } self.send_popup_hotkeys(PopupKind::Qa); - self.send_popup_hotkeys(PopupKind::LessComputer); self.hotkeys_sent = Some(bindings); } @@ -4683,12 +4375,6 @@ mod linux_app { // 不是用户动作:宿主不因此拉起窗口进程,否则弹一次面板就可能 // 冒出一个主窗口。真正的用户动作是托盘「显示主窗口」。 } - HostAction::ShowLessComputer => { - // Core 在每次 Less Computer 轮次开始前发这个动作(Tauri 里 - // 它显示 `less-computer` 窗口)。宿主是序列所有者,这里 - // 拉起/刷新面板即可。 - self.show_less_computer_popup(); - } HostAction::FocusMain => { // 宿主没有窗口可聚焦;已有窗口的聚焦由 UI 进程自己处理。 // 这里刻意什么都不做:把它当成「用户想打开主窗口」会让 @@ -4717,6 +4403,11 @@ mod linux_app { HostAction::RequestRestart => { self.status = tr_l10n(lang, "status.request_restart").to_string(); } + HostAction::ShowLessComputer => { + // Linux 版不再提供 Less Computer 面板(该功能在 Linux 上 + // 整体删除);Core 仍会在功能轮次开始时发这个动作, + // 这里刻意忽略,不拉起任何窗口。 + } HostAction::ShowSelectionPreview => { // 核心照旧发这个动作;现在它只负责把**选区助手面板** // 拉到「润色结果」模式(独立预览窗口已下线)。 @@ -4810,10 +4501,6 @@ mod linux_app { self.transcript_state = TranscriptAccumulator::default(); self.transcript.clear(); self.transcript_session = snapshot.dictation.session_id; - self.less_computer_input.clear(); - self.less_computer_output.clear(); - self.less_computer_turn_start = 0; - self.less_computer_session = None; self.pending_approval = None; self.qa_state = None; self.qa_visible = false; @@ -5758,11 +5445,6 @@ mod linux_app { .as_ref() .map(openless_linux_egui::shortcut_display_label) .unwrap_or_default(); - vm.coding_agent_hotkey = prefs - .coding_agent_voice_hotkey - .as_ref() - .map(openless_linux_egui::shortcut_display_label) - .unwrap_or_default(); } // The drawer's Save button is enabled only while the draft differs @@ -5899,25 +5581,6 @@ mod linux_app { s.system_proxy = prefs.use_system_proxy; s.llm_thinking_enabled = prefs.llm_thinking_enabled; s.multimodal = prefs.multimodal_pipeline_enabled; - s.less_computer = prefs.coding_agent_enabled; - s.testing_features = prefs.testing_features_enabled; - s.coding_agent_provider = match prefs.coding_agent_provider.as_str() { - "opencode-cli" => 1, - "codex-cli" => 2, - "dsh-cli" => 3, - _ => 0, - }; - s.coding_agent_permission = match prefs.coding_agent_permission_mode.as_str() { - "plan" => 1, - "default" => 2, - "bypassPermissions" => 3, - _ => 0, - }; - if hydrate_text { - s.coding_agent_model = prefs.coding_agent_model.clone().unwrap_or_default(); - s.coding_agent_workdir = prefs.coding_agent_workdir.clone().unwrap_or_default(); - s.coding_agent_exe = prefs.coding_agent_exe.clone().unwrap_or_default(); - } s.selection_polish_delivery = match prefs.selection_polish_output_mode { openless_core::shared_types::SelectionPolishOutputMode::DirectReplace => 0, openless_core::shared_types::SelectionPolishOutputMode::PreviewConfirm => 1, @@ -9345,8 +9008,6 @@ focus_was_stolen={} focus_restored={} warnings={:?}", incoming: mpsc::Receiver, outgoing: mpsc::Sender, qa_input: String, - /// Less Computer 面板的输入框(与 QA 的 composer 各自独立)。 - less_computer_input: String, outgoing_sequence: u64, ready_sent: bool, avatar: QaAvatar, @@ -9395,11 +9056,6 @@ focus_was_stolen={} focus_restored={} warnings={:?}", session_id, sequence, }, - PopupKind::LessComputer => PopupToHost::DismissLessComputer { - version, - session_id, - sequence, - }, }; self.send(message); ctx.send_viewport_cmd(egui::ViewportCommand::Close); @@ -9774,64 +9430,6 @@ focus_was_stolen={} focus_restored={} warnings={:?}", frontend::popups::QaAction::None => {} } } - PopupKind::LessComputer => { - // 运行中的一轮需要连续重绘(「执行中…」标记 + 滚动到底)。 - animated = self.state.less_computer.working; - let action = frontend::popups::less_computer( - ui, - &self.state.less_computer, - &mut self.less_computer_input, - lang, - ); - match action { - frontend::popups::LessComputerAction::None => {} - frontend::popups::LessComputerAction::Dismiss => { - if let Some(session_id) = self.session_id() { - let sequence = self.next_sequence(); - self.send(PopupToHost::DismissLessComputer { - version: POPUP_PROTOCOL_VERSION, - session_id, - sequence, - }); - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - } - frontend::popups::LessComputerAction::Cancel => { - if let Some(session_id) = self.session_id() { - let sequence = self.next_sequence(); - self.send(PopupToHost::CancelLessComputer { - version: POPUP_PROTOCOL_VERSION, - session_id, - sequence, - }); - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - } - frontend::popups::LessComputerAction::Submit(text) => { - if let Some(session_id) = self.session_id() { - let sequence = self.next_sequence(); - self.send(PopupToHost::SubmitLessComputer { - version: POPUP_PROTOCOL_VERSION, - session_id, - sequence, - text, - }); - } - } - frontend::popups::LessComputerAction::Approve { token, approved } => { - if let Some(session_id) = self.session_id() { - let sequence = self.next_sequence(); - self.send(PopupToHost::ApproveLessComputer { - version: POPUP_PROTOCOL_VERSION, - session_id, - sequence, - token, - approved, - }); - } - } - } - } PopupKind::Capsule => { let capsule_phase = self.state.capsule.phase.to_ascii_lowercase(); animated = matches!( @@ -9936,29 +9534,11 @@ focus_was_stolen={} focus_restored={} warnings={:?}", Some(PopupKind::Qa) } else if args.iter().any(|arg| arg == "--capsule") { Some(PopupKind::Capsule) - } else if args.iter().any(|arg| arg == "--less-computer") { - Some(PopupKind::LessComputer) } else { None } } - /// Append a streaming delta to the trailing assistant entry, creating it on - /// the first delta of a turn. Keeps one assistant bubble per turn instead of - /// one per delta, matching the Tauri panel's message list. - fn append_assistant_entry( - entries: &mut Vec, - delta: &str, - ) { - match entries.last_mut() { - Some(entry) if entry.kind == "assistant" => entry.text.push_str(delta), - _ => entries.push(openless_linux_egui::LessComputerEntry { - kind: "assistant".to_string(), - text: delta.to_string(), - }), - } - } - /// stdin/stdout JSONL plumbing shared by the eframe popup window and the /// layer-shell capsule: both talk to the host through the same protocol. fn popup_stdio() -> Result<(mpsc::Receiver, mpsc::Sender), String> { @@ -10021,7 +9601,6 @@ focus_was_stolen={} focus_restored={} warnings={:?}", incoming, outgoing, qa_input: String::new(), - less_computer_input: String::new(), outgoing_sequence: 0, ready_sent: false, warming_started: None, @@ -10106,15 +9685,11 @@ focus_was_stolen={} focus_restored={} warnings={:?}", // 胶囊窗口贴着药丸尺寸(Tauri 经典药丸 176×42),并用透明背景让圆角 // 真正透出桌面;QA / 预览是实心卡片窗口。 let size = match kind { - // 面板尺寸一律取自共享常量(Tauri:qa/less-computer 420×540)。 + // 面板尺寸一律取自共享常量(Tauri:qa 420×540)。 PopupKind::Qa => [ openless_linux_egui::QA_WINDOW_SIZE.0 as f32, openless_linux_egui::QA_WINDOW_SIZE.1 as f32, ], - PopupKind::LessComputer => [ - openless_linux_egui::LESS_COMPUTER_WINDOW_SIZE.0 as f32, - openless_linux_egui::LESS_COMPUTER_WINDOW_SIZE.1 as f32, - ], // Recording keeps a fixed 460x180 Siri stage plus the transcript rail; // only an idle vocabulary card changes the host size and placement. PopupKind::Capsule => [ @@ -10177,7 +9752,6 @@ focus_was_stolen={} focus_restored={} warnings={:?}", incoming: rx, outgoing: outgoing_tx, qa_input: String::new(), - less_computer_input: String::new(), outgoing_sequence: 0, ready_sent: false, warming_started: None, @@ -11006,18 +10580,13 @@ Internal flags (set by OpenLess itself, not for regular use): settings_section: frontend::view_model::SettingsSection::Shortcuts, ..Default::default() }; - vm.settings.testing_features = true; let _ = render(&mut vm, Vec::new()); let painted = render(&mut vm, Vec::new()); - for key in [ - "settings.selection_workspace.polish_hotkey_desc", - "settings.coding_agent.voice_hotkey_desc", - ] { - assert!( - painted.contains(tr_l10n(zh, key)), - "{key} must be rendered on the shortcuts page:\n{painted}" - ); - } + let key = "settings.selection_workspace.polish_hotkey_desc"; + assert!( + painted.contains(tr_l10n(zh, key)), + "{key} must be rendered on the shortcuts page:\n{painted}" + ); } /// 输入框回归:宿主推新快照时,用户还没提交的输入不能被抹掉。 @@ -11507,7 +11076,6 @@ Internal flags (set by OpenLess itself, not for regular use): for (label, value) in [ ("switch_style", &vm.switch_style_hotkey), ("open_app", &vm.open_app_hotkey), - ("coding_agent", &vm.coding_agent_hotkey), ] { assert!( !value.trim().is_empty(), @@ -11516,25 +11084,6 @@ Internal flags (set by OpenLess itself, not for regular use): } } - #[test] - fn assistant_deltas_accumulate_into_one_entry_per_turn() { - // 一个轮次里流式增量只应形成一条助手条目;工具标记之后的新增量属于 - // 新一轮正文,要另起一条(否则工具行会被并进正文里)。 - let mut entries = Vec::new(); - append_assistant_entry(&mut entries, "he"); - append_assistant_entry(&mut entries, "llo"); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].kind, "assistant"); - assert_eq!(entries[0].text, "hello"); - entries.push(openless_linux_egui::LessComputerEntry { - kind: "tool".to_string(), - text: "Used bash".to_string(), - }); - append_assistant_entry(&mut entries, "done"); - assert_eq!(entries.len(), 3); - assert_eq!(entries[2].text, "done"); - } - #[test] fn help_and_version_exit_before_anything_launches() { let args = |extra: &str| vec!["openless".to_string(), extra.to_string()]; @@ -11585,7 +11134,6 @@ Internal flags (set by OpenLess itself, not for regular use): incoming, outgoing, qa_input: String::new(), - less_computer_input: String::new(), outgoing_sequence: 0, ready_sent: false, warming_started: None, @@ -12149,82 +11697,6 @@ Internal flags (set by OpenLess itself, not for regular use): assert!(flags.revert_available); } - #[test] - fn continuation_turn_keeps_receiving_output_and_approval() { - let mut app = OpenLessEguiApp::new( - Arc::new(tokio::runtime::Runtime::new().unwrap()), - Err("fixture".into()), - None, - false, - ); - let first = openless_core::SessionId::new(); - let second = openless_core::SessionId::new(); - for (sequence, session, kind) in [ - ( - 1, - first, - LessComputerEventKind::User { - text: "first".into(), - fresh: true, - }, - ), - ( - 2, - first, - LessComputerEventKind::Completed { - text: "first answer".into(), - cost_usd: None, - }, - ), - ( - 3, - second, - LessComputerEventKind::User { - text: "follow up".into(), - fresh: false, - }, - ), - ( - 4, - second, - LessComputerEventKind::Delta { - text: "second answer".into(), - }, - ), - ( - 5, - second, - LessComputerEventKind::Approval { - token: "approval".into(), - command: "echo test".into(), - reason: "test".into(), - }, - ), - ( - 6, - first, - LessComputerEventKind::Delta { - text: "stale".into(), - }, - ), - ] { - app.apply_event(BackendEvent { - sequence, - session_id: Some(session), - kind: BackendEventKind::LessComputerEvent(openless_core::LessComputerEvent { - seq: None, - kind, - }), - }); - } - assert_eq!(app.less_computer_session, Some(second)); - assert!(app.less_computer_output.ends_with("second answer")); - assert_eq!( - app.pending_approval, - Some(("approval".into(), "echo test".into())) - ); - } - #[test] fn qa_deltas_accumulate_without_hiding_conversation_history() { let mut app = OpenLessEguiApp::new( diff --git a/openless-all/app/linux-egui/src/popup.rs b/openless-all/app/linux-egui/src/popup.rs index 0cb3d09ff..fa728aa13 100644 --- a/openless-all/app/linux-egui/src/popup.rs +++ b/openless-all/app/linux-egui/src/popup.rs @@ -27,7 +27,6 @@ pub enum PopupKind { /// 选区助手面板:提问对话 + 润色结果编辑(合并后唯一的选区弹窗)。 Qa, Capsule, - LessComputer, } impl PopupKind { @@ -35,33 +34,10 @@ impl PopupKind { match self { Self::Qa => "--qa", Self::Capsule => "--capsule", - Self::LessComputer => "--less-computer", } } } -/// One rendered Less Computer turn entry. -/// -/// Mirrors Core's `LessComputerEventKind` presentation: the panel prints the -/// entries in order and never re-derives product intent, so a new Core event -/// variant only needs a host-side translation into `kind` + display text. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct LessComputerEntry { - /// `user` / `assistant` / `tool` / `compaction` / `error` / `note`. - pub kind: String, - #[serde(default)] - pub text: String, -} - -/// A blocked command waiting for the user's decision. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct LessComputerApproval { - pub token: String, - pub command: String, - #[serde(default)] - pub reason: String, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PopupChatMessage { pub role: String, @@ -144,22 +120,6 @@ pub enum HostToPopup { session_id: String, sequence: u64, }, - /// Less Computer 面板状态(Tauri `LessComputerPanel.tsx`)。 - /// - /// `entries` 是已发生的事件序列(用户指令 / 工具 / 压缩 / 助手正文 / 错误), - /// `working` 表示本轮尚未终结,`approval` 是等待用户批准的阻塞命令。 - LessComputer { - version: u16, - session_id: String, - sequence: u64, - entries: Vec, - #[serde(default)] - working: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - approval: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - error: Option, - }, /// 本地热键匹配所需的配置(见 `crate::local_hotkeys` 的模块文档)。 /// /// 面板有焦点时 fcitx5 收不到按键,所以面板自己按这份配置匹配,命中后 @@ -185,7 +145,6 @@ impl HostToPopup { Self::PolishPreview { sequence, .. } | Self::QaSnapshot { sequence, .. } | Self::Capsule { sequence, .. } - | Self::LessComputer { sequence, .. } | Self::Hotkeys { sequence, .. } | Self::Hide { sequence, .. } | Self::Shutdown { sequence, .. } => *sequence = value, @@ -197,7 +156,6 @@ impl HostToPopup { Self::PolishPreview { version, .. } | Self::QaSnapshot { version, .. } | Self::Capsule { version, .. } - | Self::LessComputer { version, .. } | Self::Hotkeys { version, .. } | Self::Hide { version, .. } | Self::Shutdown { version, .. } => *version, @@ -209,7 +167,6 @@ impl HostToPopup { Self::PolishPreview { session_id, .. } | Self::QaSnapshot { session_id, .. } | Self::Capsule { session_id, .. } - | Self::LessComputer { session_id, .. } | Self::Hotkeys { session_id, .. } | Self::Hide { session_id, .. } | Self::Shutdown { session_id, .. } => session_id, @@ -221,7 +178,6 @@ impl HostToPopup { Self::PolishPreview { sequence, .. } | Self::QaSnapshot { sequence, .. } | Self::Capsule { sequence, .. } - | Self::LessComputer { sequence, .. } | Self::Hotkeys { sequence, .. } | Self::Hide { sequence, .. } | Self::Shutdown { sequence, .. } => *sequence, @@ -234,7 +190,6 @@ impl HostToPopup { Self::PolishPreview { .. } => Some(PopupKind::Qa), Self::QaSnapshot { .. } => Some(PopupKind::Qa), Self::Capsule { .. } => Some(PopupKind::Capsule), - Self::LessComputer { .. } => Some(PopupKind::LessComputer), // 配置不属于任何一种面板内容, // 不能拿它当“这个帧是给谁的”。 Self::Hotkeys { .. } => None, @@ -341,33 +296,6 @@ pub enum PopupToHost { session_id: String, sequence: u64, }, - /// Less Computer 输入框:提交一条指令(Tauri `lessComputerSubmitText`)。 - SubmitLessComputer { - version: u16, - session_id: String, - sequence: u64, - text: String, - }, - /// 批准/拒绝被阻塞的命令(Tauri `lessComputerApprove`)。 - ApproveLessComputer { - version: u16, - session_id: String, - sequence: u64, - token: String, - approved: bool, - }, - /// 停止当前这一轮(Esc / 关闭时的收尾,Tauri `less_computer_window_dismiss`)。 - CancelLessComputer { - version: u16, - session_id: String, - sequence: u64, - }, - /// ✕:只收起面板,不动已完成的对话(Tauri `cancel()` / `minimize()` 语义)。 - DismissLessComputer { - version: u16, - session_id: String, - sequence: u64, - }, } impl PopupToHost { @@ -387,11 +315,7 @@ impl PopupToHost { | Self::SetPinned { version, .. } | Self::SetEditInstructionMode { version, .. } | Self::ApplyEdit { version, .. } - | Self::RevertEdit { version, .. } - | Self::SubmitLessComputer { version, .. } - | Self::ApproveLessComputer { version, .. } - | Self::CancelLessComputer { version, .. } - | Self::DismissLessComputer { version, .. } => *version, + | Self::RevertEdit { version, .. } => *version, } } @@ -411,11 +335,7 @@ impl PopupToHost { | Self::SetPinned { session_id, .. } | Self::SetEditInstructionMode { session_id, .. } | Self::ApplyEdit { session_id, .. } - | Self::RevertEdit { session_id, .. } - | Self::SubmitLessComputer { session_id, .. } - | Self::ApproveLessComputer { session_id, .. } - | Self::CancelLessComputer { session_id, .. } - | Self::DismissLessComputer { session_id, .. } => session_id, + | Self::RevertEdit { session_id, .. } => session_id, } } @@ -435,11 +355,7 @@ impl PopupToHost { | Self::SetPinned { sequence, .. } | Self::SetEditInstructionMode { sequence, .. } | Self::ApplyEdit { sequence, .. } - | Self::RevertEdit { sequence, .. } - | Self::SubmitLessComputer { sequence, .. } - | Self::ApproveLessComputer { sequence, .. } - | Self::CancelLessComputer { sequence, .. } - | Self::DismissLessComputer { sequence, .. } => *sequence, + | Self::RevertEdit { sequence, .. } => *sequence, } } @@ -461,10 +377,6 @@ impl PopupToHost { | Self::VocabSuggestion { .. } | Self::CancelDictation { .. } | Self::StopDictation { .. } => PopupKind::Capsule, - Self::SubmitLessComputer { .. } - | Self::ApproveLessComputer { .. } - | Self::CancelLessComputer { .. } - | Self::DismissLessComputer { .. } => PopupKind::LessComputer, } } } @@ -481,7 +393,6 @@ struct PopupActionSlot { pub struct PopupActionGuard { qa: PopupActionSlot, capsule: PopupActionSlot, - less_computer: PopupActionSlot, } impl PopupActionGuard { @@ -489,7 +400,6 @@ impl PopupActionGuard { match kind { PopupKind::Qa => &mut self.qa, PopupKind::Capsule => &mut self.capsule, - PopupKind::LessComputer => &mut self.less_computer, } } @@ -746,14 +656,6 @@ pub struct CapsuleSuggestion { pub replacement: String, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct LessComputerPopupState { - pub entries: Vec, - pub working: bool, - pub approval: Option, - pub error: Option, -} - #[derive(Debug, Clone, Default, PartialEq)] pub struct PopupState { pub session_id: Option, @@ -762,7 +664,6 @@ pub struct PopupState { pub shutdown_requested: bool, pub qa: QaPopupState, pub capsule: CapsulePopupState, - pub less_computer: LessComputerPopupState, /// 宿主下发的本地热键配置;没有它就不做本地匹配。 pub hotkeys: Option, retired_sessions: HashSet, @@ -798,7 +699,6 @@ impl PopupState { HostToPopup::PolishPreview { .. } | HostToPopup::QaSnapshot { .. } | HostToPopup::Capsule { .. } - | HostToPopup::LessComputer { .. } ); if !starts_session || self.retired_sessions.contains(&session_id) { return ApplyOutcome::Stale; @@ -869,21 +769,6 @@ impl PopupState { }; self.visible = true; } - HostToPopup::LessComputer { - entries, - working, - approval, - error, - .. - } => { - self.less_computer = LessComputerPopupState { - entries, - working, - approval, - error, - }; - self.visible = true; - } HostToPopup::Hide { .. } => self.visible = false, HostToPopup::Shutdown { .. } => { self.visible = false; @@ -1279,78 +1164,6 @@ where mod tests { use super::*; - fn less_computer_snapshot(sequence: u64, text: &str) -> HostToPopup { - HostToPopup::LessComputer { - version: POPUP_PROTOCOL_VERSION, - session_id: "session".to_string(), - sequence, - entries: vec![LessComputerEntry { - kind: "assistant".to_string(), - text: text.to_string(), - }], - working: true, - approval: None, - error: None, - } - } - - #[test] - fn less_computer_snapshots_drive_the_panel_state() { - // 面板只呈现宿主序列:应用快照后要能看到条目、working 与可见性。 - let mut state = PopupState::default(); - assert_eq!( - state.apply(less_computer_snapshot(1, "first")), - ApplyOutcome::Applied - ); - assert!(state.visible); - assert!(state.less_computer.working); - assert_eq!(state.less_computer.entries[0].text, "first"); - - // 单调序号:迟到的旧帧不得覆盖新正文。 - assert_eq!( - state.apply(less_computer_snapshot(2, "first+second")), - ApplyOutcome::Applied - ); - assert_eq!(state.less_computer.entries[0].text, "first+second"); - assert_eq!( - state.apply(less_computer_snapshot(1, "stale")), - ApplyOutcome::Stale - ); - assert_eq!(state.less_computer.entries[0].text, "first+second"); - - // Hide 只收起面板,不动对话内容(✕ 的语义)。 - assert_eq!( - state.apply(HostToPopup::Hide { - version: POPUP_PROTOCOL_VERSION, - session_id: "session".to_string(), - sequence: 3, - }), - ApplyOutcome::Applied - ); - assert!(!state.visible); - assert_eq!(state.less_computer.entries[0].text, "first+second"); - } - - #[test] - fn less_computer_actions_are_routed_to_their_kind() { - let submit = PopupToHost::SubmitLessComputer { - version: POPUP_PROTOCOL_VERSION, - session_id: "session".to_string(), - sequence: 1, - text: "open the editor".to_string(), - }; - assert_eq!(submit.kind(), PopupKind::LessComputer); - assert_eq!(PopupKind::LessComputer.argument(), "--less-computer"); - let approve = PopupToHost::ApproveLessComputer { - version: POPUP_PROTOCOL_VERSION, - session_id: "session".to_string(), - sequence: 2, - token: "token".to_string(), - approved: false, - }; - assert_eq!(approve.kind(), PopupKind::LessComputer); - } - #[test] fn capsule_uses_x11_only_in_a_native_x11_session() { assert!(force_x11_for(PopupKind::Capsule, Some(":0"), None)); diff --git a/openless-all/app/linux-egui/src/popup_window.rs b/openless-all/app/linux-egui/src/popup_window.rs index 10be695ed..d2cc39d72 100644 --- a/openless-all/app/linux-egui/src/popup_window.rs +++ b/openless-all/app/linux-egui/src/popup_window.rs @@ -35,9 +35,6 @@ pub const CAPSULE_WINDOW_SIZE: (u32, u32) = (460, 228); pub const CAPSULE_BOTTOM_GAP: i32 = 12; /// Selection-ask panel size (the chat panel) — Tauri `qa` window is 420×540. pub const QA_WINDOW_SIZE: (u32, u32) = (420, 540); -/// Less Computer panel size — Tauri `less-computer` is 420×540, the same -/// footprint as the selection-ask panel. -pub const LESS_COMPUTER_WINDOW_SIZE: (u32, u32) = (420, 540); // 旧的独立润色预览窗口(Tauri `selection-polish-preview` 640×440 / min 480×320) // 连同 `PREVIEW_WINDOW_SIZE`、`PREVIEW_MIN_SIZE` 一起下线,Tauri 侧的独立窗口也已 // 删除:润色结果现在由选区助手面板(420×540)承载,见 `HostToPopup::PolishPreview`。 @@ -48,7 +45,6 @@ pub fn popup_size(kind: crate::popup::PopupKind) -> (u32, u32) { match kind { PopupKind::Capsule => CAPSULE_WINDOW_SIZE, PopupKind::Qa => QA_WINDOW_SIZE, - PopupKind::LessComputer => LESS_COMPUTER_WINDOW_SIZE, } } @@ -69,7 +65,6 @@ pub fn popup_position( match kind { PopupKind::Capsule => environment.position_for(CAPSULE_WINDOW_SIZE, CAPSULE_BOTTOM_GAP), PopupKind::Qa => environment.centred_for(QA_WINDOW_SIZE), - PopupKind::LessComputer => environment.centred_for(LESS_COMPUTER_WINDOW_SIZE), } } @@ -1020,12 +1015,6 @@ mod tests { assert_eq!(popup_size(PopupKind::Capsule), CAPSULE_WINDOW_SIZE); assert_eq!(CAPSULE_WINDOW_SIZE, (460, 228)); assert_eq!(popup_size(PopupKind::Qa), QA_WINDOW_SIZE); - // Tauri 的 `less-computer` 窗口与 qa 同为 420×540。 - assert_eq!( - popup_size(PopupKind::LessComputer), - LESS_COMPUTER_WINDOW_SIZE - ); - assert_eq!(LESS_COMPUTER_WINDOW_SIZE, (420, 540)); } /// The capsule hugs the bottom edge of the work area, centred. diff --git a/openless-all/app/linux-egui/src/settings.rs b/openless-all/app/linux-egui/src/settings.rs index c5f934908..9d96d7367 100644 --- a/openless-all/app/linux-egui/src/settings.rs +++ b/openless-all/app/linux-egui/src/settings.rs @@ -126,6 +126,14 @@ impl SettingsRuntime for LinuxSettingsRuntime { let Some(change) = &plan.hotkeys else { return Ok(()); }; + // Retiring the Core-only binding is not a native hotkey change. + if change.previous.coding_agent_voice != change.next.coding_agent_voice { + let mut previous = change.previous.clone(); + previous.coding_agent_voice = change.next.coding_agent_voice.clone(); + if previous == change.next { + return Ok(()); + } + } if !receipt.applied.contains(&SettingsEffectKind::Hotkeys) { receipt.applied.push(SettingsEffectKind::Hotkeys); } @@ -199,6 +207,8 @@ impl LinuxSettingsEffects for Fcitx5SettingsEffects { "[fcitx] hotkey registration: {}", registration_summary(target) ); + // 兼容无法重启的旧插件:先注销已删除功能的绑定,再注册普通快捷键。 + tolerate_optional_fcitx_method(apply_action_hotkey("SetLessComputerHotkeyRaw", None))?; apply_dictation_hotkey(&target.dictation)?; // 宿主只把热键注册给插件,**从不改动输入法自己的配置**。曾短暂加过 // 「检测到引擎占用主键就去清空拼音的快速短语触发键」,实测证明那是假象: @@ -245,18 +255,7 @@ impl LinuxSettingsEffects for Fcitx5SettingsEffects { } } tolerate_optional_fcitx_method(crate::fcitx5::set_style_pack_hotkeys(style_pack_hotkeys))?; - let (symbol, states) = target - .coding_agent_voice - .as_ref() - // The configured binding survives a disabled feature, but the - // native hook must be removed until the user enables it again. - .filter(|_| target.coding_agent_enabled) - .map(registerable_raw) - .transpose()? - .unwrap_or((0, 0)); - let result = crate::fcitx5::set_less_computer_hotkey_raw(symbol, states); - log_registration_result("SetLessComputerHotkeyRaw", (symbol, states), &result); - tolerate_optional_fcitx_method(result) + Ok(()) } fn set_active_asr_provider(&self, provider_id: &str) -> Result<(), BackendError> { @@ -416,7 +415,7 @@ pub(crate) fn registration_summary(target: &HotkeyRuntimeTarget) -> String { } } format!( - "dictation={} qa={} selection_polish={} translation={} switch_style={} open_app={} style_packs={} less_computer={}", + "dictation={} qa={} selection_polish={} translation={} switch_style={} open_app={} style_packs={}", describe(Some(&target.dictation)), describe(target.qa.as_ref()), describe(target.selection_polish.as_ref()), @@ -424,11 +423,6 @@ pub(crate) fn registration_summary(target: &HotkeyRuntimeTarget) -> String { describe(target.switch_style.as_ref()), describe(target.open_app.as_ref()), target.style_packs.len(), - if target.coding_agent_enabled { - describe(target.coding_agent_voice.as_ref()) - } else { - "disabled".to_string() - }, ) } @@ -755,10 +749,6 @@ mod tests { let summary = registration_summary(&target); assert!(summary.contains("dictation=alt+A"), "{summary}"); assert!(summary.contains("qa=ctrl+shift+:"), "{summary}"); - assert!( - summary.contains("less_computer=modifier(LeftControl)"), - "{summary}" - ); } #[test] @@ -785,7 +775,9 @@ mod tests { #[test] fn legacy_addon_may_omit_optional_extended_hotkey_methods() { + assert!(tolerate_optional_fcitx_method(Ok(())).is_ok()); for message in [ + "Unknown method SetLessComputerHotkeyRaw", "Unknown method SetSwitchStyleHotkeyRaw", "org.freedesktop.DBus.Error.UnknownMethod", ] { diff --git a/openless-all/app/linux-egui/src/ui/frontend/icons.rs b/openless-all/app/linux-egui/src/ui/frontend/icons.rs index e4f44afae..67d1ad6db 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/icons.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/icons.rs @@ -47,6 +47,10 @@ pub enum IconName { Info, Help, External, + /// Tauri 侧 `Icon name="mac"` 的对位图标。Linux 上原本只给 Less Computer + /// 设置页用;该功能整体移除后没有调用点,但 Lucide 同步脚本要求每个图标 + /// 都有 Rust 分支(`scripts/egui-icons-contract.test.mjs`),所以保留映射。 + #[allow(dead_code)] Monitor, } diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index 873cae560..bab5de2cb 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -927,70 +927,6 @@ mod tests { ); } - #[test] - fn less_computer_rows_follow_the_enable_toggle() { - // Tauri `CodingAgentSection` shows 后端/权限/模型等配置行 only while the - // feature is enabled; a disabled section is just the toggle. - let ctx = egui::Context::default(); - let zh = openless_linux_egui::Lang::ZhCn; - let provider = openless_linux_egui::tr_l10n(zh, "settings.coding_agent.provider"); - for (enabled, expected) in [(false, false), (true, true)] { - let mut vm = FrontendViewModel { - lang: zh, - active_page: Page::Settings, - settings_open: true, - settings_section: super::view_model::SettingsSection::Advanced, - advanced_open: 0, - ..Default::default() - }; - vm.settings.less_computer = enabled; - vm.settings.testing_features = true; - let mut painted = String::new(); - for _ in 0..2 { - ctx.begin_pass(egui::RawInput { - screen_rect: Some(viewport()), - ..Default::default() - }); - let mut actions = Vec::new(); - render(&ctx, &mut vm, &mut actions); - painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); - } - assert_eq!( - painted.lines().any(|line| line.trim() == provider), - expected, - "Less Computer config rows must follow the enable toggle" - ); - } - } - - #[test] - fn testing_features_hide_root_entries_and_revoke_an_open_detail_page() { - use super::view_model::SettingsSection; - let mut vm = FrontendViewModel::default(); - let root = painted_settings_lines(SettingsSection::Advanced, &mut vm); - let title = openless_linux_egui::tr_l10n( - openless_linux_egui::Lang::ZhCn, - "settings.testing_features.title", - ); - assert!(root.iter().any(|line| line.contains(title))); - assert!(!root.iter().any(|line| line == "Less Computer")); - vm.advanced_open = 3; - let off = painted_settings_lines(SettingsSection::Advanced, &mut vm); - assert!(!off.iter().any(|line| line == "Less Computer")); - vm.settings.testing_features = true; - let on = painted_settings_lines(SettingsSection::Advanced, &mut vm); - assert!(on.iter().any(|line| line == "Less Computer")); - vm.advanced_open = 0; - vm.settings.less_computer = true; - vm.settings.testing_features = false; - let revoked = painted_settings_lines(SettingsSection::Advanced, &mut vm); - assert_eq!(vm.advanced_open, 3); - assert!(!revoked.iter().any(|line| line == "Less Computer")); - let shortcuts = painted_settings_lines(SettingsSection::Shortcuts, &mut vm); - let agent_voice = openless_linux_egui::tr_l10n(vm.lang, "settings.shortcuts.agent_voice"); - assert!(!shortcuts.iter().any(|line| line == agent_voice)); - } - /// Render two frames so layout and text are settled. fn painted_settings_lines( section: super::view_model::SettingsSection, @@ -1741,10 +1677,9 @@ mod tests { active_page: Page::Settings, settings_open: true, settings_section: super::view_model::SettingsSection::Shortcuts, - shortcut_recording: Some(ShortcutField::CodingAgentVoice), + shortcut_recording: Some(ShortcutField::OpenApp), ..Default::default() }; - vm.settings.testing_features = true; let held = egui::Modifiers { ctrl: true, command: true, @@ -1767,7 +1702,7 @@ mod tests { } } assert_eq!(captured.len(), 1, "exactly one capture after the release"); - assert_eq!(captured[0].0, ShortcutField::CodingAgentVoice); + assert_eq!(captured[0].0, ShortcutField::OpenApp); assert_eq!(captured[0].1, "LeftControl"); assert!(captured[0].2.is_empty(), "a bare modifier carries no tags"); } diff --git a/openless-all/app/linux-egui/src/ui/frontend/popups.rs b/openless-all/app/linux-egui/src/ui/frontend/popups.rs index 8269dd5ba..271780efa 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/popups.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/popups.rs @@ -16,7 +16,7 @@ use eframe::egui; use super::{capsule_motion, icons, layout, siri_wgpu, theme}; use openless_linux_egui::{ - fmt_l10n, tr_l10n, CapsulePopupState, Lang, LessComputerPopupState, PopupChatMessage, + fmt_l10n, tr_l10n, CapsulePopupState, Lang, PopupChatMessage, QaPolishState, QaPopupState, }; @@ -2006,322 +2006,6 @@ pub fn inserted_message(lang: Lang, chars: usize) -> String { fmt_l10n(lang, "capsule.inserted", &[&chars]) } -// ── Less Computer 面板 ────────────────────────────────────────────────────── - -/// Less Computer 浮窗的动作(宿主转成 `PopupToHost` 消息)。 -pub enum LessComputerAction { - None, - /// ✕ → 只收起面板(已完成的一轮保留)。 - Dismiss, - /// Esc / 停止 → 取消当前这一轮。 - Cancel, - /// 输入框回车 / 发送。 - Submit(String), - /// 阻塞命令的批准或拒绝。 - Approve { - token: String, - approved: bool, - }, -} - -/// Less Computer 语音 Agent 浮窗(Tauri `LessComputerPanel.tsx`)。 -/// -/// 面板只呈现宿主推来的事件序列(`LessComputerPopupState::entries`),不解释产品意图: -/// 用户指令是右对齐气泡、工具调用与上下文压缩是行内标记、助手正文走 markdown, -/// 阻塞命令在输入框上方给出批准 / 拒绝。 -pub fn less_computer( - root_ui: &mut egui::Ui, - state: &LessComputerPopupState, - composer: &mut String, - lang: Lang, -) -> LessComputerAction { - let mut action = LessComputerAction::None; - egui::CentralPanel::default() - .frame( - egui::Frame::NONE - .fill(theme::SURFACE) - .corner_radius(egui::CornerRadius::same(14)) - .stroke(egui::Stroke::new(0.5, theme::LINE)) - .inner_margin(egui::Margin::same(CARD_SPACING as i8)), - ) - .show(root_ui, |ui| { - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.label( - egui::RichText::new(tr_l10n(lang, "less_computer.title")) - .size(16.0) - .strong() - .color(theme::INK), - ); - ui.add_space(3.0); - // 运行中显示「执行中…」,否则是那句「想让电脑做什么?」的副标题。 - let subtitle = if state.working { - tr_l10n(lang, "less_computer.working") - } else { - tr_l10n(lang, "less_computer.subtitle") - }; - ui.label(egui::RichText::new(subtitle).size(12.0).color(theme::INK_4)); - }); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { - if icon_button(ui, icons::IconName::Close, theme::INK_3).clicked() { - action = LessComputerAction::Dismiss; - } - }); - }); - ui.add_space(10.0); - - // 审批卡的实际高度随命令/警告文字行数变化(警告会换行到两行),预留值取 - // “单行标题 + 等宽命令 + 两行警告 + 按钮行 + 内边距”。取小了会把 - // 底部输入框挤出卡片外(越出窗口下缘),这是实测发现的。 - let approval_height = if state.approval.is_some() { 158.0 } else { 0.0 }; - let list_height = - (ui.available_height() - COMPOSER_HEIGHT - approval_height - 12.0).max(120.0); - ui.allocate_ui(egui::vec2(ui.available_width(), list_height), |ui| { - egui::ScrollArea::vertical() - .id_salt("openless-less-computer") - .auto_shrink([false, false]) - .stick_to_bottom(true) - .show(ui, |ui| { - if state.entries.is_empty() && !state.working { - ui.add_space(24.0); - ui.vertical_centered(|ui| { - ui.label( - egui::RichText::new(tr_l10n(lang, "less_computer.subtitle")) - .size(13.0) - .color(theme::INK_4), - ); - }); - } - for entry in &state.entries { - match entry.kind.as_str() { - "user" => user_bubble(ui, &entry.text), - "assistant" => { - ui.add_space(2.0); - render_markdown(ui, &entry.text); - ui.add_space(2.0); - } - "tool" | "note" => marker_row(ui, &entry.text, theme::INK_3), - "compaction" => compaction_marker(ui, &entry.text), - "error" => { - ui.label( - egui::RichText::new(&entry.text) - .size(12.5) - .color(theme::ERR), - ); - } - _ => marker_row(ui, &entry.text, theme::INK_3), - } - } - if state.working { - ui.add_space(2.0); - marker_row(ui, tr_l10n(lang, "less_computer.working"), theme::INK_3); - } - }); - }); - - if let Some(approval) = &state.approval { - ui.add_space(6.0); - egui::Frame::new() - .fill(theme::WARN_SOFT) - .stroke(egui::Stroke::new(0.5, theme::WARN)) - .corner_radius(egui::CornerRadius::same(10)) - .inner_margin(egui::Margin::same(10)) - .show(ui, |ui| { - ui.label( - egui::RichText::new(tr_l10n(lang, "less_computer.approval_title")) - .size(12.5) - .strong() - .color(theme::INK), - ); - ui.add_space(4.0); - ui.label( - egui::RichText::new(&approval.command) - .font(egui::FontId::monospace(11.5)) - .color(theme::INK_2), - ); - if !approval.reason.is_empty() { - ui.add_space(3.0); - ui.label( - egui::RichText::new(&approval.reason) - .size(11.0) - .color(theme::INK_3), - ); - } - ui.add_space(6.0); - ui.horizontal(|ui| { - if small_action_button(ui, tr_l10n(lang, "less_computer.approve"), true) - .clicked() - { - action = LessComputerAction::Approve { - token: approval.token.clone(), - approved: true, - }; - } - if small_action_button(ui, tr_l10n(lang, "less_computer.deny"), false) - .clicked() - { - action = LessComputerAction::Approve { - token: approval.token.clone(), - approved: false, - }; - } - }); - }); - } - - ui.add_space(6.0); - let width = ui.available_width(); - let (rect, _) = - ui.allocate_exact_size(egui::vec2(width, COMPOSER_HEIGHT), egui::Sense::hover()); - ui.painter() - .rect_filled(rect, egui::CornerRadius::same(12), theme::SURFACE); - ui.painter().rect_stroke( - rect, - egui::CornerRadius::same(12), - egui::Stroke::new(0.5, theme::LINE_STRONG), - egui::StrokeKind::Inside, - ); - let send_rect = egui::Rect::from_center_size( - egui::pos2(rect.right() - 20.0, rect.center().y), - egui::vec2(28.0, 28.0), - ); - let send = ui - .interact( - send_rect, - egui::Id::unique("less-computer-send"), - egui::Sense::click(), - ) - .on_hover_text(tr_l10n(lang, "less_computer.send")); - if !composer.trim().is_empty() { - ui.painter().rect_filled( - send_rect, - egui::CornerRadius::same(14), - if send.hovered() { - theme::INK_2 - } else { - theme::INK - }, - ); - icons::draw_icon( - ui, - send_rect.center(), - icons::IconName::Send, - theme::SURFACE, - ); - } - let text_rect = egui::Rect::from_min_max( - egui::pos2(rect.left() + 12.0, rect.top()), - egui::pos2(send_rect.left() - 6.0, rect.bottom()), - ); - let mut child = ui.new_child( - egui::UiBuilder::new() - .id_salt("less-computer-composer") - .max_rect(text_rect) - .layout(egui::Layout::left_to_right(egui::Align::Center)), - ); - let response = child.add( - egui::TextEdit::singleline(composer) - .id(egui::Id::unique("less-computer-composer-input")) - .hint_text(tr_l10n(lang, "less_computer.input_placeholder")) - .font(egui::FontId::proportional(13.5)) - .text_color(theme::INK) - .frame(egui::Frame::NONE) - .desired_width(text_rect.width()) - .align(egui::Align2::CENTER_CENTER), - ); - let submitted = - response.lost_focus() && child.input(|input| input.key_pressed(egui::Key::Enter)); - if submitted || send.clicked() { - let text = composer.trim().to_string(); - if !text.is_empty() { - action = LessComputerAction::Submit(text); - } - } - // Esc 取消当前这一轮(Tauri 面板的 Esc 语义)。 - if ui.input(|input| input.key_pressed(egui::Key::Escape)) { - action = LessComputerAction::Cancel; - } - }); - action -} - -/// 右对齐的用户指令气泡(Tauri `Bubble align="end"`)。 -fn user_bubble(ui: &mut egui::Ui, text: &str) { - ui.add_space(2.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { - let max = (ui.available_width() * 0.82).max(80.0); - ui.set_max_width(max); - egui::Frame::new() - .fill(theme::BLUE_SOFT) - .corner_radius(egui::CornerRadius::same(10)) - .inner_margin(egui::Margin::symmetric(9, 6)) - .show(ui, |ui| { - ui.set_max_width(max - 18.0); - ui.label(egui::RichText::new(text).size(12.5).color(theme::INK)); - }); - }); - ui.add_space(2.0); -} - -/// 行内标记:小圆点 + 辅助色文字(Tauri `Marker`)。 -fn marker_row(ui: &mut egui::Ui, text: &str, color: egui::Color32) { - ui.horizontal(|ui| { - let (rect, _) = ui.allocate_exact_size(egui::vec2(8.0, 8.0), egui::Sense::hover()); - ui.painter().circle_filled(rect.center(), 2.0, color); - ui.label(egui::RichText::new(text).size(11.5).color(color)); - }); -} - -/// 上下文压缩标记(Tauri `Marker variant="separator"`)。 -fn compaction_marker(ui: &mut egui::Ui, text: &str) { - ui.add_space(2.0); - ui.horizontal(|ui| { - let width = ui.available_width(); - let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 16.0), egui::Sense::hover()); - ui.painter().line_segment( - [rect.left_center(), rect.right_center()], - egui::Stroke::new(0.5, theme::LINE_SOFT), - ); - let galley = ui.painter().layout_no_wrap( - text.to_owned(), - egui::FontId::proportional(11.0), - theme::INK_4, - ); - let center = rect.center(); - ui.painter().rect_filled( - egui::Rect::from_center_size(center, galley.size() + egui::vec2(10.0, 0.0)), - egui::CornerRadius::same(7), - theme::SURFACE, - ); - ui.painter().galley( - egui::pos2( - center.x - galley.rect.width() / 2.0, - center.y - galley.rect.height() / 2.0, - ), - galley, - theme::INK_4, - ); - }); - ui.add_space(2.0); -} - -/// 批准 / 拒绝按钮(Tauri `Button`)。 -fn small_action_button(ui: &mut egui::Ui, label: &str, primary: bool) -> egui::Response { - let text = egui::RichText::new(label).size(11.5); - let button = if primary { - egui::Button::new(text.color(theme::SURFACE)).fill(theme::INK) - } else { - egui::Button::new(text.color(theme::INK_2)) - .fill(theme::SURFACE) - .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) - }; - ui.add( - button - .corner_radius(egui::CornerRadius::same(7)) - .min_size(egui::vec2(56.0, 26.0)), - ) -} - #[cfg(test)] mod tests { use super::*; diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index a07b21be3..5b99a8a75 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -19,7 +19,6 @@ enum SettingsIcon { Settings, Mic, Sparkle, - Monitor, Cloud, Shield, Bolt, @@ -337,7 +336,6 @@ fn draw_rail_icon(ui: &egui::Ui, center: egui::Pos2, icon: SettingsIcon, color: SettingsIcon::Settings => IconName::Settings, SettingsIcon::Mic => IconName::Mic, SettingsIcon::Sparkle => IconName::Sparkle, - SettingsIcon::Monitor => IconName::Monitor, SettingsIcon::Cloud => IconName::Cloud, SettingsIcon::Shield => IconName::Shield, SettingsIcon::Bolt => IconName::Bolt, @@ -376,9 +374,6 @@ fn panel(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec Option<(&'static str, &'static str)> { match vm.advanced_open { - 0 => Some(( - tr_l10n(lang, "settings.coding_agent.title"), - tr_l10n(lang, "modal.advanced_pages.less_computer"), - )), 2 => Some(( tr_l10n(lang, "settings.debug.title"), tr_l10n(lang, "modal.advanced_pages.debug"), )), - 3 => Some(( - tr_l10n(lang, "settings.testing_features.title"), - tr_l10n(lang, "modal.advanced_pages.testing_features"), - )), _ => None, } } @@ -1105,7 +1092,7 @@ fn shortcuts(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec = vec![ + let rows: Vec = vec![ ShortcutRow { field: ShortcutField::Dictation, label: tr_l10n(lang, "settings.shortcuts.start_stop"), @@ -1155,18 +1142,7 @@ fn shortcuts(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec 0, ShortcutField::SwitchStyle => 1, - ShortcutField::OpenApp | ShortcutField::CodingAgentVoice => 2, + ShortcutField::OpenApp => 2, ShortcutField::StylePack(_) | ShortcutField::StyleDraft => 1, }; let matches = |label: &str, value: &str| -> bool { @@ -3063,140 +3039,15 @@ fn advanced(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec { - toggle_row( - ui, - tr_l10n(lang, "settings.coding_agent.enable"), - tr_l10n(lang, "settings.coding_agent.hotkey_hint"), - vm.settings.less_computer, - || { - actions.push(FrontendAction::SettingsToggle(SettingsField::LessComputer)); - }, - ); - // Tauri `CodingAgentSection`:后端 / 模型等高级项只在启用后展开。 - if !vm.settings.less_computer { - return; - } - combo_index_row( - ui, - tr_l10n(lang, "settings.coding_agent.provider"), - tr_l10n(lang, "settings.coding_agent.coming_soon_note"), - vm.settings.coding_agent_provider.min(3), - &["Claude Code", "OpenCode", "Codex", "dsh"], - |val| { - actions.push(FrontendAction::SettingsCombo( - SettingsComboField::CodingAgentProvider, - val, - )); - }, - ); - combo_index_row( - ui, - tr_l10n(lang, "settings.coding_console.permission_mode"), - "", - vm.settings.coding_agent_permission.min(3), - &[ - tr_l10n(lang, "settings.coding_console.mode.accept_edits"), - tr_l10n(lang, "settings.coding_console.mode.plan"), - tr_l10n(lang, "settings.coding_console.mode.default"), - tr_l10n(lang, "settings.coding_console.mode.bypass_permissions"), - ], - |val| { - actions.push(FrontendAction::SettingsCombo( - SettingsComboField::CodingAgentPermission, - val, - )); - }, - ); - text_edit_row( - ui, - tr_l10n(lang, "settings.coding_agent.model"), - tr_l10n(lang, "settings.coding_agent.model_hint"), - &mut vm.settings.coding_agent_model, - tr_l10n(lang, "settings.coding_agent.model_placeholder"), - |text| { - actions.push(FrontendAction::SettingsText( - SettingsTextField::CodingAgentModel, - text.to_owned(), - )); - }, - ); - text_edit_row( - ui, - tr_l10n(lang, "settings.coding_console.workdir"), - tr_l10n(lang, "settings.coding_console.workdir_desc"), - &mut vm.settings.coding_agent_workdir, - tr_l10n(lang, "settings.coding_console.workdir_placeholder"), - |text| { - actions.push(FrontendAction::SettingsText( - SettingsTextField::CodingAgentWorkdir, - text.to_owned(), - )); - }, - ); - text_edit_row( - ui, - tr_l10n(lang, "settings.coding_agent.exe"), - "", - &mut vm.settings.coding_agent_exe, - "claude", - |text| { - actions.push(FrontendAction::SettingsText( - SettingsTextField::CodingAgentExe, - text.to_owned(), - )); - }, - ); - } - 1 => {} - _ => { + card(ui, "", "", |ui| { toggle_row( ui, tr_l10n(lang, "settings.recording.record_audio_for_debug_label"), @@ -3242,23 +3093,12 @@ fn advanced(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec/logs/level`, /// 重启后生效;不是 Core 偏好,所以不上云、不影响 Tauri。 LogLevel, @@ -603,9 +596,6 @@ pub enum SettingsTextField { PolishSkipMaxChars, /// 调试录音最多保留条数。 AudioRecordingMaxEntries, - CodingAgentModel, - CodingAgentWorkdir, - CodingAgentExe, /// 选区助手:额外问句线索(每行一个)。 SelectionVoiceKeywords, /// 选区助手:编辑方案系统提示词(留空 = 用风格包 / 内置默认)。 @@ -1003,8 +993,6 @@ pub struct FrontendViewModel { pub switch_style_hotkey: String, /// Display label for the open-app shortcut. pub open_app_hotkey: String, - /// Display label for the Less Computer voice shortcut. - pub coding_agent_hotkey: String, /// Display label for the selection-polish shortcut. pub selection_polish_hotkey: String, /// Pipeline mode is 多模态 → the task strip hides the legacy LLM/ASR views. @@ -1167,7 +1155,6 @@ impl Default for FrontendViewModel { translation_hotkey: String::new(), switch_style_hotkey: String::new(), open_app_hotkey: String::new(), - coding_agent_hotkey: String::new(), } } } @@ -1232,14 +1219,6 @@ pub struct SettingsFields { pub selection_voice_system_prompt: String, pub activity_heatmap: bool, pub system_proxy: bool, - pub testing_features: bool, - pub less_computer: bool, - /// Less Computer(Coding Agent)配置,全部直连 `coding_agent_*` 偏好。 - pub coding_agent_provider: usize, - pub coding_agent_permission: usize, - pub coding_agent_model: String, - pub coding_agent_workdir: String, - pub coding_agent_exe: String, pub multimodal: bool, pub language: usize, pub theme: usize, @@ -1291,13 +1270,6 @@ impl Default for SettingsFields { selection_voice_system_prompt: String::new(), activity_heatmap: true, system_proxy: false, - testing_features: false, - less_computer: false, - coding_agent_provider: 0, - coding_agent_permission: 0, - coding_agent_model: String::new(), - coding_agent_workdir: String::new(), - coding_agent_exe: String::new(), multimodal: false, language: 0, theme: 0, diff --git a/openless-all/app/linux-egui/tests/fcitx5_config_contract.rs b/openless-all/app/linux-egui/tests/fcitx5_config_contract.rs index 8cc2f4882..754515333 100644 --- a/openless-all/app/linux-egui/tests/fcitx5_config_contract.rs +++ b/openless-all/app/linux-egui/tests/fcitx5_config_contract.rs @@ -64,6 +64,23 @@ const INPUT_METHOD_CONFIG_DIR: &str = ".config/fcitx5"; /// 引擎的按键保留设置:我们曾去清空它,绝不允许再次出现。 const ENGINE_RESERVED_KEY_SETTING: &str = "QuickPhraseKey"; +#[test] +fn hotkey_registration_clears_the_retired_binding_before_registering_active_keys() { + let source = + strip_comments(&std::fs::read_to_string(crate_root().join("src/settings.rs")).unwrap()); + let compact: String = source.chars().filter(|ch| !ch.is_whitespace()).collect(); + let cleanup = compact + .find("tolerate_optional_fcitx_method(apply_action_hotkey(\"SetLessComputerHotkeyRaw\",None))?") + .expect("clear the old plugin's persisted Agent key and tolerate only UnknownMethod"); + let active = compact + .find("apply_dictation_hotkey(&target.dictation)?") + .unwrap(); + assert!( + cleanup < active, + "retire the old binding before reusing its key" + ); +} + #[test] fn the_host_never_rewrites_the_input_method_configuration() { let root = crate_root(); diff --git a/openless-all/app/linux-egui/tests/fcitx5_contract.rs b/openless-all/app/linux-egui/tests/fcitx5_contract.rs index ca5a3fe56..45d2f038c 100644 --- a/openless-all/app/linux-egui/tests/fcitx5_contract.rs +++ b/openless-all/app/linux-egui/tests/fcitx5_contract.rs @@ -3,7 +3,7 @@ use openless_core::BackendErrorCode; use openless_linux_egui::{ fcitx5_available, fcitx5_commit_text, fcitx5_selection_text, set_fcitx5_hotkeys, - set_fcitx5_less_computer_hotkey_raw, Fcitx5HotkeyListener, + Fcitx5HotkeyListener, }; /// Exercise the real fcitx5 DBus object and listener lifecycle. Key event @@ -16,7 +16,6 @@ fn fcitx5_dbus_methods_and_listener_have_stable_platform_semantics() { // 曾经这里注册 Shift_L + 'A',在开发机上跑 --ignored 会真的让插件吞掉 // Shift/字母输入(用户踩过这个坑),所以现在只发清零请求。 set_fcitx5_hotkeys(Vec::new()).expect("clear the legacy fcitx5 hotkey list"); - set_fcitx5_less_computer_hotkey_raw(0, 0).expect("clear the Less Computer hotkey"); let listener = Fcitx5HotkeyListener::start().expect("start fcitx5 hotkey listener"); assert!(listener.take_error().is_none()); @@ -25,9 +24,6 @@ fn fcitx5_dbus_methods_and_listener_have_stable_platform_semantics() { ("DictationKeyEvent", true), ("DictationKeyEvent", false), ("DictationKeyCombined", true), - ("LessComputerKeyEvent", true), - ("LessComputerKeyEvent", false), - ("LessComputerKeyCombined", true), ("TranslationModifierEvent", true), ] { let status = std::process::Command::new("dbus-send") @@ -46,7 +42,7 @@ fn fcitx5_dbus_methods_and_listener_have_stable_platform_semantics() { } std::thread::sleep(std::time::Duration::from_millis(100)); let mut events = Vec::new(); - assert_eq!(listener.drain(|event| events.push(event)), 7); + assert_eq!(listener.drain(|event| events.push(event)), 4); assert!(matches!( events[0], openless_linux_egui::LinuxHotkeyEvent::DictationPressed { .. } @@ -61,18 +57,6 @@ fn fcitx5_dbus_methods_and_listener_have_stable_platform_semantics() { )); assert!(matches!( events[3], - openless_linux_egui::LinuxHotkeyEvent::LessComputerPressed { .. } - )); - assert!(matches!( - events[4], - openless_linux_egui::LinuxHotkeyEvent::LessComputerReleased { .. } - )); - assert!(matches!( - events[5], - openless_linux_egui::LinuxHotkeyEvent::LessComputerCombined { .. } - )); - assert!(matches!( - events[6], openless_linux_egui::LinuxHotkeyEvent::TranslationPressed { .. } )); drop(listener); diff --git a/openless-all/app/linux-egui/tests/host_contract.rs b/openless-all/app/linux-egui/tests/host_contract.rs index d049227ad..7328e8d67 100644 --- a/openless-all/app/linux-egui/tests/host_contract.rs +++ b/openless-all/app/linux-egui/tests/host_contract.rs @@ -473,29 +473,155 @@ fn linux_public_settings_contract_is_validated_transactional_and_runtime_backed( assert_eq!(unsupported.code, BackendErrorCode::Unsupported); assert_eq!(backend.snapshot().preferences_revision, revision + 2); assert_eq!(events.try_recv(), Err(EventRecvError::Empty)); + // Less Computer 在 Linux 端已删除:即使 Core 偏好里打开它,宿主也不再注册 + // 任何触发键、不再拉起面板。这里只确认设置仍能被接受(字段属于 Core)。 for enabled in [true, false] { let mut preferences = backend.get_preferences(); preferences.coding_agent_enabled = enabled; preferences.testing_features_enabled = true; - preferences.coding_agent_voice_hotkey = Some(ShortcutBinding { - primary: "F10".into(), - modifiers: vec!["ctrl".into()], - }); host.update_settings_strict(preferences, backend.snapshot().preferences_revision) - .expect("Linux must allow enabling and disabling its wired Less Computer action"); - assert_eq!(backend.get_preferences().coding_agent_enabled, enabled); + .expect("Core preferences stay writable even when Linux does not surface the feature"); + } + let _ = std::fs::remove_dir_all(data_dir); +} + +#[test] +fn retired_binding_migration_preserves_native_registration_semantics() { + use openless_core::{ + SettingsEffectKind, SettingsEffectPlan, SettingsEffectReceipt, SettingsRuntime, + SettingsValueChange, UserPreferences, + }; + let effects = Arc::new(RecordingSettingsEffects::default()); + let runtime = LinuxSettingsRuntime::with_effects(effects.clone()); + let previous = HotkeyRuntimeTarget::from(&UserPreferences::default()); + let mut migrated = previous.clone(); + migrated.coding_agent_voice = None; + let mut changed = migrated.clone(); + changed.dictation = ShortcutBinding { + primary: "F12".into(), + modifiers: Vec::new(), + }; + let mut registrations = 0; + for (case, previous, next, applies) in [ + ("retired-only", previous, migrated.clone(), false), + ( + "startup hydration", + migrated.clone(), + migrated.clone(), + true, + ), + ("active binding edit", migrated, changed, true), + ] { + let mut receipt = SettingsEffectReceipt::default(); + runtime + .commit( + &SettingsEffectPlan { + hotkeys: Some(SettingsValueChange { previous, next }), + ..Default::default() + }, + &mut receipt, + ) + .unwrap(); + registrations += usize::from(applies); assert_eq!( - effects - .hotkeys - .lock() - .unwrap() - .last() - .unwrap() - .coding_agent_enabled, - enabled + effects.hotkeys.lock().unwrap().len(), + registrations, + "{case}" + ); + assert_eq!( + receipt.applied.contains(&SettingsEffectKind::Hotkeys), + applies, + "{case}" ); } - let _ = std::fs::remove_dir_all(data_dir); +} + +#[test] +fn linux_settings_saves_release_removed_agent_bindings() { + for document_save in [false, true] { + let data_dir = std::env::temp_dir().join(format!( + "openless-retired-agent-hotkey-{}", + uuid::Uuid::new_v4() + )); + let config = BackendConfig { + data_dir: data_dir.clone(), + ..Default::default() + }; + let backend = Arc::new( + OpenLessBackend::new(config.clone(), BackendDependencies::unsupported()).unwrap(), + ); + let host = LinuxHost::with_settings_runtime( + Arc::clone(&backend), + Arc::new(openless_core::NoopSettingsRuntime), + ); + let style_pack = backend + .create_style_pack(StylePack { + name: "Retired Agent shortcut".into(), + ..Default::default() + }) + .unwrap(); + let mut legacy = backend.get_preferences(); + let binding = if document_save { + ShortcutBinding { + primary: "K".into(), + modifiers: vec!["ctrl".into(), "alt".into()], + } + } else { + legacy.coding_agent_voice_hotkey.clone().unwrap() + }; + legacy.coding_agent_voice_hotkey = Some(binding.clone()); + legacy.coding_agent_enabled = document_save; + legacy.testing_features_enabled = document_save; + legacy.coding_agent_model = Some("retained-model".into()); + // Seed the old on-disk configuration without the Linux save boundary. + backend + .repositories() + .preferences + .set(legacy.clone()) + .unwrap(); + + let outcome = if document_save { + let mut next = legacy.clone(); + next.style_pack_hotkeys = vec![openless_core::shared_types::StylePackHotkey { + pack_id: style_pack.id, + binding: binding.clone(), + }]; + let outcome = host + .save_settings(next, host.snapshot().preferences_revision) + .unwrap(); + assert_eq!(outcome.preferences.style_pack_hotkeys.len(), 1); + assert_eq!(outcome.preferences.style_pack_hotkeys[0].binding, binding); + outcome + } else { + host.update_preference_fields(&std::collections::BTreeMap::from([( + "/dictationHotkey".into(), + serde_json::to_value(&binding).unwrap(), + )])) + .expect("the default retired Agent key must be available for dictation") + }; + assert_eq!(outcome.reconciled_hotkey_count, 0); + assert!(outcome.preferences.coding_agent_voice_hotkey.is_none()); + assert_eq!( + outcome.preferences.coding_agent_enabled, + legacy.coding_agent_enabled + ); + assert_eq!( + outcome.preferences.coding_agent_model, + legacy.coding_agent_model + ); + if !document_save { + assert_eq!(outcome.preferences.dictation_hotkey, binding); + } + drop(host); + drop(backend); + let reopened = OpenLessBackend::new(config, BackendDependencies::unsupported()).unwrap(); + assert!(reopened + .get_preferences() + .coding_agent_voice_hotkey + .is_none()); + drop(reopened); + std::fs::remove_dir_all(data_dir).unwrap(); + } } #[tokio::test] diff --git a/openless-all/app/scripts/check-linux-public-surface.ps1 b/openless-all/app/scripts/check-linux-public-surface.ps1 index e1020b336..e1b0c3cc2 100644 --- a/openless-all/app/scripts/check-linux-public-surface.ps1 +++ b/openless-all/app/scripts/check-linux-public-surface.ps1 @@ -70,14 +70,11 @@ foreach ($action in @('ShowQa', 'HideQa', 'ShowSelectionPreview', 'HideSelection } } +# Less Computer 在 Linux 端已整体删除:Core 仍会发 `LessComputerEvent`,Linux +# 宿主刻意不消费它,所以这里不再要求 reducer 出现 LessComputer 相关分支。 foreach ($replayToken in @( 'replay_events_after', - 'replay.truncated', - 'LessComputerEventKind::User', - 'LessComputerEventKind::Started', - 'LessComputerEventKind::Tool', - 'LessComputerEventKind::Compaction', - 'LessComputerEventKind::Approval' + 'replay.truncated' )) { if ($mainSource -notmatch [regex]::Escape($replayToken)) { Write-Error "Linux event reducer/replay is missing '$replayToken'" diff --git a/openless-all/app/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/app/scripts/linux-fcitx5-plugin/openless.cpp index e67e649c5..f37a5ae2e 100644 --- a/openless-all/app/scripts/linux-fcitx5-plugin/openless.cpp +++ b/openless-all/app/scripts/linux-fcitx5-plugin/openless.cpp @@ -16,7 +16,6 @@ * SetCustomDictationTrigger(s: keyString) — 设置自定义组合键 (Key::parse 格式) * SetQaHotkeyRaw(uu: sym, states) — 直接设 QA 面板触发 sym+states * SetTranslationHotkeyRaw(uu: sym, states) — 直接设翻译模式触发 sym+states - * SetLessComputerHotkeyRaw(uu: sym, states) — 直接设 Less Computer 触发 sym+states * GetSelectionText() -> s — 读取**当前** PRIMARY 选区文本:探针可用时以合成器为准, * 当前选区没有 text mime 时返回空,而不是 clipboard * addon 里那份可能过期的缓存(见 primary_selection.h) @@ -28,8 +27,6 @@ * CancelSelectionTarget(s: ticket) -> b — 释放未使用的目标 * 信号: * DictationKeyEvent(uub: sym, states, isPress) — 听写热键按下/抬起 - * LessComputerKeyEvent(uub: sym, states, isPress) — Less Computer 热键按下/抬起 - * LessComputerKeyCombined(uub: sym, states, isPress) — Less Computer 组合键撤销 * QaShortcutEvent(uub: sym, states, isPress) — QA 快捷键按下/抬起 * SelectionPolishEvent(uub: sym, states, isPress) — 选区润色快捷键按下/抬起 * TranslationModifierEvent(uub: sym, states, isPress) — 翻译修饰键按下/抬起 @@ -97,8 +94,6 @@ class OpenLess final : public AddonInstance, selectionPolishRawStates_(0), translationRawSym_(0), translationRawStates_(0), - lessComputerRawSym_(0), - lessComputerRawStates_(0), switchStyleRawSym_(0), switchStyleRawStates_(0), openAppRawSym_(0), @@ -106,8 +101,6 @@ class OpenLess final : public AddonInstance, hasCustomDictationKey_(false), dictationTriggerHeld_(false), dictationTriggerCombined_(false), - lessComputerTriggerHeld_(false), - lessComputerTriggerCombined_(false), savedIc_(nullptr), selectionIc_(nullptr), primarySelectionReader_(openless_selection::readPrimarySelection), @@ -180,21 +173,6 @@ class OpenLess final : public AddonInstance, logHotkeyNearMiss(sym, states); } - if (lessComputerRawSym_ != 0 && - hit(lessComputerRawSym_, lessComputerRawStates_)) { - lessComputerTriggerHeld_ = isPress; - if (isPress) { - lessComputerTriggerCombined_ = false; - } - lessComputerKeyEvent(sym, states, isPress); - consume(lessComputerRawSym_); - return; - } - if (isPress && lessComputerTriggerHeld_ && !isModifierKeySym(sym) && - !lessComputerTriggerCombined_) { - lessComputerTriggerCombined_ = true; - lessComputerKeyCombined(sym, states, true); - } // 自定义组合键:Alt 状态下字母 sym 可能大写(A vs a),归一化比较 if (hasCustomDictationKey_ && @@ -668,18 +646,6 @@ class OpenLess final : public AddonInstance, << "SetTranslationHotkeyRaw: sym=" << sym << " states=" << states; } - void setLessComputerHotkeyRaw(uint32_t sym, uint32_t states) { - lessComputerRawSym_ = sym; - lessComputerRawStates_ = states; - lessComputerTriggerHeld_ = false; - lessComputerTriggerCombined_ = false; - RawConfig raw; - readAsIni(raw, configFile()); - raw.setValueByPath("LessComputerRawSym", std::to_string(sym)); - raw.setValueByPath("LessComputerRawStates", std::to_string(states)); - safeSaveAsIni(raw, configFile()); - } - void setSwitchStyleHotkeyRaw(uint32_t sym, uint32_t states) { switchStyleRawSym_ = sym; switchStyleRawStates_ = states; @@ -767,7 +733,6 @@ class OpenLess final : public AddonInstance, FCITX_OBJECT_VTABLE_METHOD(setQuickNoteHotkeyRaw, "SetQuickNoteHotkeyRaw", "uu", ""); FCITX_OBJECT_VTABLE_METHOD(setSelectionPolishHotkeyRaw, "SetSelectionPolishHotkeyRaw", "uu", ""); FCITX_OBJECT_VTABLE_METHOD(setTranslationHotkeyRaw, "SetTranslationHotkeyRaw", "uu", ""); - FCITX_OBJECT_VTABLE_METHOD(setLessComputerHotkeyRaw, "SetLessComputerHotkeyRaw", "uu", ""); FCITX_OBJECT_VTABLE_METHOD(setSwitchStyleHotkeyRaw, "SetSwitchStyleHotkeyRaw", "uu", ""); FCITX_OBJECT_VTABLE_METHOD(setOpenAppHotkeyRaw, "SetOpenAppHotkeyRaw", "uu", ""); FCITX_OBJECT_VTABLE_METHOD(setStylePackHotkeys, "SetStylePackHotkeys", "a(suu)", ""); @@ -775,8 +740,6 @@ class OpenLess final : public AddonInstance, FCITX_OBJECT_VTABLE_METHOD(setClipboardText, "SetClipboardText", "s", "b"); FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyEvent, "DictationKeyEvent", "uub"); FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyCombined, "DictationKeyCombined", "uub"); - FCITX_OBJECT_VTABLE_SIGNAL(lessComputerKeyEvent, "LessComputerKeyEvent", "uub"); - FCITX_OBJECT_VTABLE_SIGNAL(lessComputerKeyCombined, "LessComputerKeyCombined", "uub"); FCITX_OBJECT_VTABLE_SIGNAL(qaShortcutEvent, "QaShortcutEvent", "uub"); FCITX_OBJECT_VTABLE_SIGNAL(quickNoteEvent, "QuickNoteEvent", "uub"); FCITX_OBJECT_VTABLE_SIGNAL(selectionPolishEvent, "SelectionPolishEvent", "uub"); @@ -833,14 +796,6 @@ class OpenLess final : public AddonInstance, auto *v = raw.valueByPath("TranslationRawStates"); translationRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0; } - { - auto *v = raw.valueByPath("LessComputerRawSym"); - lessComputerRawSym_ = v ? std::stoul(*v, nullptr, 0) : 0; - } - { - auto *v = raw.valueByPath("LessComputerRawStates"); - lessComputerRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0; - } loadRawHotkey(raw, "SwitchStyle", switchStyleRawSym_, switchStyleRawStates_); loadRawHotkey(raw, "OpenApp", openAppRawSym_, openAppRawStates_); stylePackHotkeys_.clear(); @@ -859,8 +814,6 @@ class OpenLess final : public AddonInstance, } } } - lessComputerTriggerHeld_ = false; - lessComputerTriggerCombined_ = false; rebuildTriggerKeys(); hasCustomDictationKey_ = false; if (auto *value = raw.valueByPath("CustomDictationKey"); value && !value->empty()) { @@ -986,7 +939,6 @@ class OpenLess final : public AddonInstance, {"translation", translationRawSym_, translationRawStates_}, {"switch_style", switchStyleRawSym_, switchStyleRawStates_}, {"open_app", openAppRawSym_, openAppRawStates_}, - {"less_computer", lessComputerRawSym_, lessComputerRawStates_}, }; if (hasCustomDictationKey_) { entries.push_back({"dictation_custom", @@ -1106,8 +1058,6 @@ class OpenLess final : public AddonInstance, uint32_t selectionPolishRawStates_; uint32_t translationRawSym_; uint32_t translationRawStates_; - uint32_t lessComputerRawSym_; - uint32_t lessComputerRawStates_; uint32_t switchStyleRawSym_; uint32_t switchStyleRawStates_; uint32_t openAppRawSym_; @@ -1117,8 +1067,6 @@ class OpenLess final : public AddonInstance, bool hasCustomDictationKey_; bool dictationTriggerHeld_; bool dictationTriggerCombined_; - bool lessComputerTriggerHeld_; - bool lessComputerTriggerCombined_; /// 近似未命中诊断日志的限速时间戳(见 logHotkeyNearMiss)。 std::chrono::steady_clock::time_point lastNearMissLog_{}; /// 快捷键按下时保存的输入上下文指针,用于 commitText 在失焦后仍能提交文字。 diff --git a/openless-all/app/scripts/sync-egui-i18n.mjs b/openless-all/app/scripts/sync-egui-i18n.mjs index 629ea17cf..5ae0c4d8f 100644 --- a/openless-all/app/scripts/sync-egui-i18n.mjs +++ b/openless-all/app/scripts/sync-egui-i18n.mjs @@ -45,8 +45,8 @@ const KEEP_EGUI = new Set([ 'nav.corrections', // Screenshot labels the detail action "重新转写"; Tauri uses "重新转录". 'history.retranscribe', - // The egui status line prefixes the product name. - 'less_computer.done', + // Linux 已删除 Less Computer,代理说明里不能再提它(Tauri 仍保留该功能)。 + 'settings.network.use_system_proxy_desc', ]); function placeholderCount(text) { From caa1b204424863e17be59eef09e774d4ee9935c5 Mon Sep 17 00:00:00 2001 From: aeoform <128159516+aeoform@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:31:53 +0800 Subject: [PATCH 14/31] perf(audio): optimize cross-platform processing and complete native audio settings (#1258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(audio): optimize shared DSP and complete audio settings UIs * fix(ci): gate Android spectrum emitter and refresh IPC audit * perf(audio): add cross-revision PCM checks and native iOS audio settings * fix(ios): localize native audio settings and preserve test compatibility * fix(ios): use host executor in audio flush regression test * fix(ios): preserve converter output capacity when reusing PCM buffers * fix(ios): consume converter input completely across variable audio packets * feat(audio): 调试录音可独立选择保存处理前/处理后的音频 原来的「保留原始录音(调试)」只会落一份处理后的 WAV,没法判断某个识别问题是 麦克风原始信号差,还是降噪/增益/AGC 处理坏了。 Core: - `UserPreferences` 新增 `debug_archive_processed` / `debug_archive_raw`,两个独立 开关,默认都关;旧 preferences.json 走 serde default,不迁移。 - `RecordingPlan` 增加 `archive_processed` / `archive_raw`,由上面两个开关推出。 quick note / undecided 这类功能必需档案始终 `archive_processed = true`,不受开关 影响;调试录音两个开关都关时 `archive_successful_recording = false`(不落盘)。 - 新增 `openless_core::pcm_f32_to_i16_le`,让宿主把「处理前」的交织 f32 帧也量化 成同格式 PCM,与处理器内部规则一致。 - 偏好注册表补两条(Portable),云同步与契约测试同步跟上。 linux-egui: - 主档案 `.wav` 按 `archive_processed` 决定写处理后还是原始 PCM;只有两份 都要时才额外创建 `.raw.wav`。 - 音频处理线程在处理前就拿到原始帧,因此不做常态额外分配:`raw` 只在开关打开时 才量化。 - 设置页在「保留原始录音(调试)」下面新增两个开关:保存处理后的音频 / 另存一份 处理前的音频;打开主开关且两项都关时自动勾上「处理后」,避免主开关看起来没效果。 五语文案补在 i18n 目录里。 测试:`dictation_context` 新增用例覆盖四种开关组合与 quick note 例外; `openless-core --lib` 1391 passed,`openless-linux-egui --lib` 266 passed, `clippy -D warnings` 干净。 * feat(audio-dsp): 补全 MicYou 链路的 AEC 与输出缓冲,egui 设置页跟上 MicYou 的 DSP 参数是 8 项:增益 / 声学回声消除 / 降噪 / 去混响 / 自动增益 / 人声活动检测 / 处理链路 / 缓冲区大小。Core 的 `AudioDspConfig` 一直缺 AEC 与 缓冲区,而且因为 `processing_order` 只有 6 个阶段、`settings()` 里用 `..Default::default()` 兜底,MicYou 的 `processing_chain` 里根本没有 "AEC" 这一级 —— 即使把 `aec_enabled` 打开也不会执行。 Core: - `AudioDspStage` 新增 `AcousticEchoCancellation`(链名 "AEC"), `processing_order` 由 6 级变 7 级,默认顺序与 MicYou 一致(AEC 在最前)。 - `AudioDspConfig` 新增 `aec_enabled` 与 `output_buffer_ms`(100..=1200,默认 300), `normalized()` 夹紧范围,`settings()` 映射到 micyou 的 `AudioDspSettings`。 - `processing_order` 反序列化兼容老配置:6 级数组把 AEC 前置补齐,其余顺序原样 保留(既符合 AEC 必须在降噪之前,也不会制造重复项触发「重复即重置」)。 - 测试:老 6 级配置反序列化保留自定义顺序 + AEC 前置;AEC/缓冲区能到达 DSP 设置;越界缓冲夹到 1200。 linux-egui: - 设置 → 录音 → 语音增强设置新增「回声消除与缓冲」段:AEC 开关(附「需要扬声器 参考信号」说明)+ 输出缓冲区 100–1200 ms 滑杆(附延迟代价说明)。 - 处理链路列表把 AEC 作为一级显示,未开启时置灰;五语文案补齐。 这样 MicYou 的 8 项参数在 egui 前端全部可达。 * feat(tauri): 语音增强设置补上 AEC 与输出缓冲 Core 侧已经支持 `aec_enabled` / `output_buffer_ms`(并真正传给 MicYou 的 `processing_chain`),Tauri 的设置页还停在 6 级链路: - `AudioDspStage` 类型加 `acousticEchoCancellation`,`AudioDspConfig` 加 `aecEnabled` / `outputBufferMs`,`defaultAudioDsp` 同步成 7 级链路(AEC 在最前)。 - `AudioDspSettings.tsx` 新增「回声消除」开关(附「需要扬声器参考信号」说明)与 输出缓冲 100–1200 ms 滑杆;处理顺序列表自动多出 AEC 一级。 - 八语文案补齐(en / zh-CN / zh-TW / ja / ko / de / fr / es),`UserPreferences` 类型与 mock 数据跟上 Core 新增的两个调试录音字段。 验证:`tsc --noEmit` 干净、`vite build` 成功、`frontend-test-runner` 全过。 * feat(aec): 采集扬声器回环作为 AEC 参考,并允许选择参考源 AEC 打开却一直没效果:MicYou 需要「扬声器此刻在放什么」当参考,而宿主从来没有 把输出信号喂进来。这次把整条链补上。 Core: - 新增 `FarEndQueue`:宿主在独立采集线程里 push(任意采样率/通道),DSP 线程在 每帧处理前 drain。队列按 96k 样本(约 2 秒 @48k)限长,丢最旧的;非有限值与 空帧直接丢弃。 - `AudioProcessor::attach_far_end(queue)` + `process()` 里把参考重采样到 48 kHz 单声道后交给 `micyou set_far_end_audio`(MicYou 用最近历史做自适应滤波,所以 参考必须先于本帧到达)。 - `RecordingPlan` 增加 `aec_reference_device_name`,来自新增偏好 `aec_reference_device_name`(空 = 自动,`"off"` = 不采集;注册表记为 Excluded/local_hardware,不同步到云端)。 linux-egui: - `is_loopback_device_name()` 识别 PulseAudio/PipeWire 的 `.monitor`、 `Monitor of …` 与 ALSA `snd-aloop`;带单测确认普通麦克风不会被误判。 - `list_loopback_devices()` 按 `pipewire → pulseaudio → alsa` 顺序枚举去重。 - `start_reference_capture()`:另起一条 cpal 采集流抓回环,带自己的启动握手与 停止标志;同样按三后端顺序尝试,ALSA 无回环时只记一条 warn —— 录音本身照常。 - 录音侧:AEC 打开且参考未被显式关闭时创建队列、挂到处理器、启动参考采集; `LinuxActiveRecording` 持有它并在 Drop 时**先**停参考流再拆主线程。 - 设置页:语音增强里 AEC 打开后出现「扬声器参考源」下拉(自动 / 不采集 / 具名 监听设备),五语文案补齐。 验证(MicYou micyou-dsp 已含 AEC7 模型,beta 上无冲突改动): ``` openless-core --lib 1399 passed openless-linux-egui --lib 268 passed localization_contract 5 passed clippy -D warnings clean deb/rpm verify PASS(2.0.2-Beta.2-146) ``` * fix(aec): 增强关闭时 AEC 也能独立工作 之前 `process()` 在 `enhancement_enabled == false` 时直接走归一化,AEC 永远不执行 —— 但「外放听写、只想去回声、不想动音色」正是最需要 AEC 的场景。 现在只要 AEC 打开且本次录音接了参考队列,就仍然走 MicYou 链路(其余阶段由各自 开关控制,不会顺带改音色)。 顺带记录实测:单核上 aec7 模型约 2.0x 实时(≈50% 单核),NLMS 类方案约 31x (≈3%),差 16 倍 —— 弱机上要用轻量参考抵消,别默认开模型。 * feat(aec): 轻量 NLMS 与 aec7 模型两种回声消除并存,用户可选 「能不能两者都留、让用户选」——可以,而且实测下来两者正好互补。 - 新增 `AecMode { Off, Light, Model }` 取代原来的布尔 `aec_enabled`; 老 preferences 里的 `aecEnabled: true/false` 由自定义反序列化映射成 `Light`/`Off`,不会丢配置。 - `Light`:把 #1228 的有界归一化 LMS(1024 抽头、双讲时暂停自适应)移植进来, 在 Core 里对麦克风原始样本做抵消,再进降噪/增益链。 - `Model`:仍走 MicYou 的 aec7 神经网络(经 `FarEndQueue` → `set_far_end_audio`)。 - 两种模式共用同一套参考采集(linux-egui 的 `.monitor` 与扬声器选择)。 关键修正:早先轻量路径把参考重采样到 16 kHz 却拿 48 kHz 的麦克风样本做抵消, 采样率不一致导致 ERLE ≈ 0(等于没做)。现在参考按麦克风采样率重采样。 实测(单核,120 s 音频,单讲段,回声延迟 15 ms、衰减 0.45): | 模式 | ERLE | 实时倍率 | 单核占用 | |---|---:|---:|---:| | 关闭 | 0 dB | 5719x | ~0% | | 轻量 NLMS | **22.9 dB** | 6.2x | **~16%** | | aec7 模型 | **26.9 dB** | 1.9x | **~53%** | 模型只多 4 dB,却贵 3.3 倍 —— 所以默认关、弱机推轻量、强机可选模型。 UI:egui 与 Tauri 的语音增强里都改成三选一(关闭 / 轻量(省 CPU)/ 模型(更强)), 选模型时提示 CPU 代价;八语 + 五语文案补齐。 * refactor(audio-dsp): 去掉十段均衡器,把「去混响 / 人声检测」的真实能力写清楚 对照 MicYou 的实现逐个核对后,界面上三处名不副实: 1. **均衡器**:用户不需要,整块从 egui 设置里移除(`equalizer_*` 配置字段保留, 旧 preferences 仍能反序列化;处理链路里的 Equalizer 阶段默认不启用)。 2. **去混响**:MicYou 的 `apply_dereverb` 其实是 `x[n] - mix·x[n-10ms]` —— 一个 固定延迟的**梳状滤波**,不是去混响。界面改名为「梳状滤波(简易去混响)」, 提示里写清它会给音色染金属感、真正去混响需要频谱或模型方法(当前没做)。 3. **人声检测**:MicYou 的 `apply_vad` 是 RMS 门限 + 淡入淡出**噪声门**,不输出 语音/非语音判定。界面改名为「噪声门限(不是人声检测)」,并说明低于门限只是 衰减、不会停止录音。 结果:设置页现在只暴露真实存在且自解释的开关,不再有「看起来有、其实没做」的项。 * feat(tauri): 扬声器回环参考 + 处理前/处理后档案落盘 把 egui 侧已经跑通的两件事在 Tauri 侧补齐(Linux 上 Tauri crate 被 `compile_error!` 拦住,只能靠 CI 的 macOS/Windows job 真编,这里做了 rustfmt 语法校验 + 前端 tsc/vite/144 个前端测试)。 一、AEC 的扬声器参考(`start_reference_stream`) - 与 egui 同样的思路:拿宿主**默认输出设备**开一条 capture 流 —— Windows 的 WASAPI 允许在 render 设备上做 loopback,Linux 的 PulseAudio/PipeWire 把输出 映射到 monitor 源,macOS 走 CoreAudio 同一设备。 - 参考帧按设备原生采样率/通道推入 `openless_core::FarEndQueue`,由 Core 重采样 后交给 AEC(轻量 NLMS 与 aec7 模型都吃这一路)。 - 没有回环源时只记一条 warn,录音照常进行;移动端 stub 直接返回不可用。 - 参考流挂在 `Recorder` 上,跟录音同生命周期,Drop 即停。 二、处理前/处理后档案 - 新增 `ArchiveTargets { processed, raw, write_processed }` 与 `Recorder::start_with_archive_targets`。 - 关键约束:原始 PCM 只存在于 DSP 工作线程里,而 staged 路径的档案是 `commit` 时才创建的。所以引入 `RawTap`:`CaptureSink::promote` 把 tap 写进 共享槽,DSP 线程每帧读一次槽。 - 两份都要 → 主档案写处理后,另存 `.raw.wav` 写原始; - 只要处理前 → 主档案直接写原始 PCM(回退/播放功能不变,且不多出文件); - 都不要 → 不落盘。 - `ForwardTarget.archive` 在 `write_processed == false` 时为 `None`,避免同一 段 PCM 被写两次。 三、Tauri 设置页 - 调试页在「保留原始录音(调试)」下面恢复两个开关(保存处理后的音频 / 另存 一份处理前的音频),打开主开关且两项都空时自动勾上「处理后」。 * chore(linux-egui): 2.0.2-Beta.3-149 * fix(tauri): 测试里的 promote 调用补齐 raw_tap 参数,按 rustfmt 收尾 * fix(ci): recorder 模块测试的 shim 补上新增的 core 导出 scripts/test-recorder-module.py 会生成一个只导出少量 core 符号的临时 crate, / / 没加进去,Windows 的 `openless-recorder-tests` 编译因此失败(7 个 E0425/E0433)。本地 `--check` 与完整跑 60 个测试均通过。 * fix(audio): close PR 1258 processing and archive gaps --------- Co-authored-by: Chris233 --- .../app/crates/micyou-dsp/UPSTREAM.md | 83 ++- openless-all/app/crates/micyou-dsp/src/lib.rs | 184 +++-- .../app/crates/micyou-infer/src/ops.rs | 79 ++- .../examples/audio_equivalence.rs | 88 +++ .../examples/audio_performance.rs | 56 ++ .../app/crates/openless-core/src/audio.rs | 477 ++++++++++++- .../openless-core/src/audio/dsp_tests.rs | 99 ++- .../app/crates/openless-core/src/audio_dsp.rs | 228 ++++++- .../src/cloud_sync_e2ee_documents/registry.rs | 21 + .../openless-core/src/dictation_context.rs | 110 ++- .../openless-core/src/external_audio.rs | 126 +++- .../src/external_audio/archive.rs | 104 ++- .../app/crates/openless-core/src/lib.rs | 6 +- .../crates/openless-core/src/shared_types.rs | 29 + openless-all/app/harmony/ipc-audit.json | 18 +- openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/audio.rs | 409 ++++++++++- openless-all/app/linux-egui/src/i18n.rs | 110 ++- openless-all/app/linux-egui/src/lib.rs | 2 + .../linux-egui/src/linux_app/settings_save.rs | 38 ++ openless-all/app/linux-egui/src/main.rs | 29 + .../linux-egui/src/ui/frontend/audio_dsp.rs | 54 +- .../linux-egui/src/ui/frontend/settings.rs | 50 ++ .../linux-egui/src/ui/frontend/view_model.rs | 18 + .../app/scripts/compare-audio-outputs.py | 47 ++ .../audio-equivalence-2026-10-24.json | 635 ++++++++++++++++++ openless-all/app/scripts/micyou-dsp.patch | 404 ++++++++++- openless-all/app/scripts/micyou-upstream.json | 6 +- .../app/scripts/test-compare-audio-outputs.py | 35 + .../app/scripts/test-recorder-module.py | 4 +- .../src-tauri/src/android/native_bridge.rs | 8 + .../app/src-tauri/src/commands/settings.rs | 29 +- .../app/src-tauri/src/core_adapters.rs | 31 +- openless-all/app/src-tauri/src/lib.rs | 2 + openless-all/app/src-tauri/src/recorder.rs | 520 ++++++++++++-- .../src-tauri/src/recorder/android_capture.rs | 341 ++++++++++ openless-all/app/src/i18n/de.ts | 33 + openless-all/app/src/i18n/en.ts | 33 + openless-all/app/src/i18n/es.ts | 33 + openless-all/app/src/i18n/fr.ts | 33 + openless-all/app/src/i18n/ja.ts | 33 + openless-all/app/src/i18n/ko.ts | 33 + openless-all/app/src/i18n/zh-CN.ts | 33 + openless-all/app/src/i18n/zh-TW.ts | 33 + openless-all/app/src/lib/audioDsp.ts | 24 +- openless-all/app/src/lib/ipc/android-audio.ts | 27 + openless-all/app/src/lib/ipc/mock-data.ts | 2 + openless-all/app/src/lib/types.ts | 17 +- .../pages/settings/AndroidAudioEffects.tsx | 198 ++++++ .../src/pages/settings/AudioDspSettings.tsx | 62 +- .../src/pages/settings/DebugToolsSection.tsx | 38 +- openless-all/ios/App/CoreBridge.swift | 2 +- openless-all/ios/App/SettingsView.swift | 154 ++++- openless-all/ios/App/Speech/AudioPCM.swift | 91 ++- openless-all/ios/CoreHost/src/control.rs | 20 +- .../ios/CoreHost/src/control_tests.rs | 71 ++ openless-all/ios/CoreHost/src/lib.rs | 25 +- .../src/recording_maintenance_tests.rs | 1 + .../ios/Resources/LocalizationSources.json | 226 +++++++ .../Resources/en.lproj/Localizable.strings | 39 ++ .../zh-Hans.lproj/Localizable.strings | 39 ++ .../ios/scripts/ReferencePCMWorker.swift | 60 ++ openless-all/ios/scripts/audio-smoke.swift | 58 +- openless-all/ios/scripts/test-audio.sh | 2 +- 64 files changed, 5535 insertions(+), 367 deletions(-) create mode 100644 openless-all/app/crates/openless-core/examples/audio_equivalence.rs create mode 100644 openless-all/app/crates/openless-core/examples/audio_performance.rs create mode 100644 openless-all/app/scripts/compare-audio-outputs.py create mode 100644 openless-all/app/scripts/fixtures/audio-equivalence-2026-10-24.json create mode 100644 openless-all/app/scripts/test-compare-audio-outputs.py create mode 100644 openless-all/app/src-tauri/src/recorder/android_capture.rs create mode 100644 openless-all/app/src/lib/ipc/android-audio.ts create mode 100644 openless-all/app/src/pages/settings/AndroidAudioEffects.tsx create mode 100644 openless-all/ios/scripts/ReferencePCMWorker.swift diff --git a/openless-all/app/crates/micyou-dsp/UPSTREAM.md b/openless-all/app/crates/micyou-dsp/UPSTREAM.md index 4be8b707e..07cbd15e6 100644 --- a/openless-all/app/crates/micyou-dsp/UPSTREAM.md +++ b/openless-all/app/crates/micyou-dsp/UPSTREAM.md @@ -5,8 +5,9 @@ Source: [MicYou-Dev/MicYou](https://github.com/MicYou-Dev/MicYou), commit `src/lib.rs` comes from `tauri-app/crates/micyou-audio/src/dsp.rs`. `../micyou-infer/src/{lib,ops}.rs` and the two `src/assets/*.mcy` blobs -come from the corresponding upstream paths. The inference runtime and model -blobs are imported without algorithm changes. Original copyright notices are +come from the corresponding upstream paths. The model blobs are imported unchanged. The inference runtime retains the +upstream algorithms, with baseline x86-64 SSE and ARM64 NEON row projections using separate +multiply/add operations and the original accumulation order. Original copyright notices are retained in all source files. See `LICENSE` for MicYou's complete license and plugin exception; `LICENSE-PureVox.txt` and `LICENSE-AEC7.txt` are the upstream MIT model notices. The inference crate also carries MicYou's license. @@ -24,6 +25,9 @@ OpenLess changes: PureVox wet/dry energy ratio. This prevents AGC from undoing suppression when a conservative amount of dry signal is mixed back in. - Add serde defaults for DSP settings. +- Make the true FFT spectrum analyzer opt-in and throttle snapshots to 10 Hz. + Closing monitoring releases its FFT resources. +- Use reusable host buffers and cached rational-phase resampling kernels. - Define the small AEC failure enum locally instead of depending on MicYou's audio host. @@ -32,9 +36,17 @@ resampling, fixed 480-sample hops, model-delay compensation, end-of-stream flush and exact output-duration accounting in `openless-core/src/audio.rs` and `audio_dsp.rs`. The main microphone path does not install plugin hooks. +Android capture requests 48 kHz directly, and all enhancement algorithms keep +that processing rate. The original input filter is preserved even at 48 kHz, +using cached coefficients; ASR output is downsampled to 16 kHz. CPAL retains a device-native fallback if +48 kHz cannot be opened. Capture queues are not enlarged for playback or +monitoring. + AEC7 and its aligned-reference API are retained and tested here. OpenLess's microphone hosts do not yet provide synchronized speaker-reference capture, -so the application does not expose or claim active acoustic echo cancellation. +so the application does not expose AEC7 as an active microphone effect. +Android may instead use its session-bound platform AcousticEchoCanceler when +the device supports it; availability and activation are reported separately. The optional comb filter follows upstream's simple delay subtraction; it is not a learned room dereverberation model. @@ -81,3 +93,68 @@ do not automatically trigger separate PR workflows. Most upstream implementation/model fixes can follow this route without manual copying. Upstream API/dependency/format changes that break the adapter or tests still require review and a deliberate patch/dependency update. + +## Shared performance changes (2026-10-22) + +All hosts that use `AudioProcessor` (including desktop Tauri and Linux egui) +reuse mono, frame and resampled output scratch buffers. Rational resampling +phases are precomputed once per stream, capped at 1024 phases; unusual clocks +fall back to the original kernel calculation. The output duration and filter +formula are unchanged, including the original filter at native 48 kHz. + +Contiguous matrix and GRU projection rows use separate SIMD multiply and add: +SSE on baseline x86-64, NEON on ARM64 targets with NEON enabled. Other targets +keep the scalar fallback. No additional inference runtime or worker pool is +loaded. This does not change OS-specific capture APIs or require AVX support. + +The synthetic full-chain probe defaults to 48 kHz and checks exact output +length. Run on the target device before drawing battery or latency conclusions: + +```sh +cargo run --release --manifest-path openless-all/app/Cargo.toml -p openless-core --example audio_performance -- rnnoise 48000 +``` + +## Before/after equivalence validation (2026-10-24) + +The frozen baseline is commit `f0744e10`. The same `openless-core` example +`audio_equivalence` was built against that baseline and the optimized sources, +using their locked dependencies, in separate release build directories. +Each build exports 48 cases: 16/44.1/48 kHz, mono/stereo, four denoiser modes, +and default/combined gain-EQ-comb-VAD settings. Inputs contain silence, +harmonics, deterministic noise, high-frequency interference, an impulse, +irregular chunk boundaries, and a partial final frame. + +On Linux x86-64, all 48 output PCM files were byte-identical to the baseline: +maximum difference 0 LSB, with matching durations. The full hashes, errors and +diagnostic processing times are committed in +`../../scripts/fixtures/audio-equivalence-2026-10-24.json`. This establishes +equivalence for those inputs and host, not a listening study or ASR accuracy. + +Export both builds to separate directories with `audio_equivalence OUTPUT_DIR`, +then run `python3 scripts/compare-audio-outputs.py BEFORE AFTER --report REPORT`. +The comparator fails on any PCM difference or duration/case-set mismatch; +`python3 scripts/test-compare-audio-outputs.py` verifies a one-LSB mutation +and a truncated output cannot pass. The probe sources must be identical in +both builds. Original baseline sources/models must remain unchanged. + +Desktop Tauri and Linux egui use the shared processor directly. Harmony uses +the external PCM recorder, which reuses conversion scratch per session. +The iOS native recorder now optionally applies the same processor with +session-frozen settings and a reused input buffer; the stop/drain barrier +flushes its tail before finishing the archive. Enhancement remains off by +default. Its native Swift settings page persists the same software DSP +controls as egui and Tauri, with writes blocked while a capture is active. +Native iOS conversion uses a separate +Swift AVAudioConverter worker; its output scratch is now actor-owned and +reused with exact output capacities and a two-entry bound. +`ios/scripts/test-audio.sh` compares that worker byte-for-byte against +the frozen pre-optimization Swift worker using its complete, fixed 100 ms +input path, over up to 32 format/ownership/packetization combinations and +end-of-stream flush (older macOS runtimes only exercise legacy ownership). +The old variable-packet path itself lost 960 samples in the 44.1 kHz CI case. +The new worker honors the converter's requested input frame count and drains +available output before accepting another packet. Variable-packet output must +match the complete fixed-packet baseline, not its truncated old counterpart; +the duration assertion remains enabled. That Apple SDK test +was added but cannot be executed on this Linux host. No claim of on-device +equivalence, battery savings, or real-time capture latency is made here. diff --git a/openless-all/app/crates/micyou-dsp/src/lib.rs b/openless-all/app/crates/micyou-dsp/src/lib.rs index 7db9773db..c84c5032e 100644 --- a/openless-all/app/crates/micyou-dsp/src/lib.rs +++ b/openless-all/app/crates/micyou-dsp/src/lib.rs @@ -79,10 +79,6 @@ pub struct AudioDspSettings { pub vad_threshold: f32, // dB, -100..0 pub aec_enabled: bool, - /// Output playback ring buffer headroom in milliseconds (100..1200). - #[serde(default = "default_output_buffer_ms")] - pub output_buffer_ms: u32, - #[serde(default)] pub processing_chain: Vec, #[serde(default)] @@ -105,7 +101,6 @@ impl Default for AudioDspSettings { vad_enabled: false, vad_threshold: -40.0, aec_enabled: false, - output_buffer_ms: 300, processing_chain: vec![ "AEC".to_string(), "NoiseReduction".to_string(), @@ -128,11 +123,6 @@ impl AudioDspSettings { } } -/// Default output playback buffer headroom in milliseconds. -fn default_output_buffer_ms() -> u32 { - 300 -} - // ─── Shared STFT window helpers ─────────────────────────────────────────── #[cfg(feature = "noise-suppression")] @@ -939,6 +929,9 @@ pub struct DspProcessor { agc_smoothed_gain: f32, // VAD fade state (0.0 = muted, 1.0 = full) vad_fade: f32, + #[cfg(feature = "noise-suppression")] + spectrum_analyzer: Option, + spectrum_tick: usize, // Spectrum snapshots raw_spectrum: Vec, processed_spectrum: Vec, @@ -981,6 +974,53 @@ pub fn parse_plugin_chain_node(node: &str) -> Option<&str> { const RNNOISE_FRAME_SIZE: usize = 480; +#[cfg(feature = "noise-suppression")] +struct SpectrumAnalyzer { + fft: Arc>, + buffer: Vec>, + scratch: Vec>, + window: Vec, +} + +#[cfg(feature = "noise-suppression")] +impl SpectrumAnalyzer { + fn new() -> Self { + let fft = rustfft::FftPlanner::new().plan_fft_forward(512); + let scratch = vec![Complex::default(); fft.get_inplace_scratch_len()]; + Self { + fft, + scratch, + buffer: vec![Complex::default(); 512], + window: (0..512) + .map(|i| 0.5 - 0.5 * (std::f32::consts::TAU * i as f32 / 511.0).cos()) + .collect(), + } + } + + fn analyze(&mut self, data: &[f32], target: &mut [f32]) { + self.buffer.fill(Complex::default()); + let count = data.len().min(512); + for (i, sample) in data[data.len() - count..].iter().enumerate() { + self.buffer[i].re = sample * self.window[i]; + } + self.fft + .process_with_scratch(&mut self.buffer, &mut self.scratch); + for (band, value) in target.iter_mut().enumerate() { + let low = 20.0 * 1200.0_f32.powf(band as f32 / 64.0); + let high = 20.0 * 1200.0_f32.powf((band + 1) as f32 / 64.0); + let start = ((low * 512.0 / 48_000.0) as usize).min(256); + let end = ((high * 512.0 / 48_000.0).ceil() as usize).clamp(start + 1, 257); + let magnitude = self.buffer[start..end] + .iter() + .map(|bin| bin.norm()) + .fold(0.0_f32, f32::max) + / 128.0; + let normalized = ((20.0 * magnitude.max(1e-8).log10() + 60.0) / 60.0).clamp(0.0, 1.0); + *value = normalized.max(*value * 0.8); + } + } +} + impl DspProcessor { pub fn new(settings: Arc>, _model_dir: Option) -> Self { Self { @@ -1020,6 +1060,9 @@ impl DspProcessor { agc_envelope: 0.0, agc_smoothed_gain: 1.0, vad_fade: 1.0, + #[cfg(feature = "noise-suppression")] + spectrum_analyzer: None, + spectrum_tick: 0, raw_spectrum: vec![0.0; 64], processed_spectrum: vec![0.0; 64], accum_buffer: Vec::new(), @@ -1045,6 +1088,7 @@ impl DspProcessor { } let raw_rms = compute_rms(data); + self.spectrum_tick = (self.spectrum_tick + 1) % 10; self.compute_spectrum(data, true); // Frame accumulation: align to 480*channels samples before processing, @@ -1670,59 +1714,34 @@ impl DspProcessor { // ── Spectrum ───────────────────────────────────────────────────────────── - fn compute_spectrum(&mut self, data: &[f32], is_raw: bool) { - let bands = 64; - let target = if is_raw { - &mut self.raw_spectrum - } else { - &mut self.processed_spectrum - }; - if target.len() != bands { - target.resize(bands, 0.0); + pub fn set_spectrum_enabled(&mut self, enabled: bool) { + #[cfg(feature = "noise-suppression")] + if enabled && self.spectrum_analyzer.is_none() { + self.spectrum_analyzer = Some(SpectrumAnalyzer::new()); + } else if !enabled { + self.spectrum_analyzer = None; + self.raw_spectrum.fill(0.0); + self.processed_spectrum.fill(0.0); } - if data.is_empty() { - for v in target.iter_mut() { - *v = 0.0; - } + #[cfg(not(feature = "noise-suppression"))] + let _ = enabled; + } + + fn compute_spectrum(&mut self, data: &[f32], is_raw: bool) { + if self.spectrum_tick != 1 { return; } - - const BANDS: usize = 64; - static BAND_LIMITS: std::sync::OnceLock<[f32; BANDS + 1]> = std::sync::OnceLock::new(); - let limits = BAND_LIMITS.get_or_init(|| { - let mut array = [0.0; BANDS + 1]; - for (i, limit) in array.iter_mut().enumerate() { - *limit = (i as f32 / BANDS as f32).powf(1.5); - } - array - }); - - for (band_idx, band_val) in target.iter_mut().enumerate() { - let start = limits[band_idx] * data.len() as f32; - let end = limits[band_idx + 1] * data.len() as f32; - let start = start as usize; - let end = (end as usize).min(data.len()); - if start >= end { - *band_val *= 0.85; - continue; - } - let mut sum = 0.0_f32; - for sample in &data[start..end] { - sum += sample * sample; - } - let rms = (sum / (end - start) as f32).sqrt(); - let db = if rms > 1e-10 { - 20.0 * rms.log10() + #[cfg(feature = "noise-suppression")] + if let Some(analyzer) = &mut self.spectrum_analyzer { + let target = if is_raw { + &mut self.raw_spectrum } else { - -100.0 + &mut self.processed_spectrum }; - let normalized = ((db + 60.0) / 60.0).clamp(0.0, 1.0); - if normalized > *band_val { - *band_val = normalized; - } else { - *band_val = *band_val * 0.85 + normalized * 0.15; - } + analyzer.analyze(data, target); } + #[cfg(not(feature = "noise-suppression"))] + let _ = (data, is_raw); } } @@ -2095,11 +2114,13 @@ mod native_tests { #[test] fn dsp_processor_chain_smoke() { // Full chain: AEC → PureVox NS through the public DspProcessor API. - let mut settings = AudioDspSettings::default(); - settings.ns_enabled = true; - settings.ns_type = "PureVox".to_string(); - settings.ns_intensity = 100.0; - settings.aec_enabled = true; + let settings = AudioDspSettings { + ns_enabled: true, + ns_type: "PureVox".to_string(), + ns_intensity: 100.0, + aec_enabled: true, + ..Default::default() + }; let settings = Arc::new(RwLock::new(settings)); let mut dsp = DspProcessor::new(settings, None); @@ -2126,3 +2147,42 @@ mod native_tests { ); } } + +#[cfg(all(test, feature = "noise-suppression"))] +mod spectrum_tests { + use super::*; + + #[test] + fn spectrum_is_opt_in_and_tracks_frequency_without_changing_audio() { + let settings = Arc::new(RwLock::new(AudioDspSettings { + gain: 6.0, + ..Default::default() + })); + let mut plain = DspProcessor::new(Arc::clone(&settings), None); + let mut monitored = DspProcessor::new(settings, None); + assert!(plain.spectrum_analyzer.is_none()); + monitored.set_spectrum_enabled(true); + let tone: Vec = (0..480) + .map(|i| (std::f32::consts::TAU * 1000.0 * i as f32 / 48_000.0).sin() * 0.05) + .collect(); + for _ in 0..12 { + let mut a = tone.clone(); + let mut b = tone.clone(); + plain.process(&mut a, 1, 0.0); + monitored.process(&mut b, 1, 0.0); + assert_eq!(a, b); + } + let (raw, processed) = monitored.get_spectrums(); + let peak = raw + .iter() + .enumerate() + .max_by(|a, b| a.1.total_cmp(b.1)) + .unwrap() + .0; + assert!((30..=40).contains(&peak), "1 kHz peak at {peak}"); + assert!(processed[peak] > raw[peak]); + monitored.set_spectrum_enabled(false); + assert!(monitored.spectrum_analyzer.is_none()); + assert!(monitored.get_spectrums().0.iter().all(|v| *v == 0.0)); + } +} diff --git a/openless-all/app/crates/micyou-infer/src/ops.rs b/openless-all/app/crates/micyou-infer/src/ops.rs index 23bdda258..ba94bbac8 100644 --- a/openless-all/app/crates/micyou-infer/src/ops.rs +++ b/openless-all/app/crates/micyou-infer/src/ops.rs @@ -396,9 +396,7 @@ pub(crate) unsafe fn matmul_impl( for kk in 0..k { let av = arow[kk]; let brow = &bbase[kk * n..(kk + 1) * n]; - for j in 0..n { - orow[j] += av * brow[j]; - } + axpy(orow, brow, av); } } if nb > 0 { @@ -501,9 +499,7 @@ pub(crate) unsafe fn conv_impl( for ci in 0..cpg { let wv = wg[mi * cpg + ci]; let xrow = &xg[ci * hw_in..(ci + 1) * hw_in]; - for p in 0..hw_out { - orow[p] += wv * xrow[p]; - } + axpy(orow, &xrow[..hw_out], wv); } } } @@ -753,9 +749,7 @@ pub(crate) unsafe fn gru_impl( for i in 0..batch { let xv = xb[i * in_dim + kk]; let prow = &mut pb[i * h3..(i + 1) * h3]; - for j in 0..h3 { - prow[j] += xv * wrow[j]; - } + axpy(prow, wrow, xv); } } } @@ -784,9 +778,7 @@ pub(crate) unsafe fn gru_impl( for i in 0..batch { let sv = state_s[i * h + kk]; let orow = &mut rproj_s[i * h3..(i + 1) * h3]; - for j in 0..h3 { - orow[j] += sv * wrow[j]; - } + axpy(orow, wrow, sv); } } // gates (elementwise passes — vectorize; rounding order matches @@ -1508,6 +1500,50 @@ fn resize_pass( // ─── AVX2+FMA runtime dispatch ───────────────────────────────────────────── // +// Use baseline SIMD on x86-64 and NEON-enabled ARM64 targets. Vectorize rows +// without changing reduction order or using fused multiply-add: recurrent +// models retain the scalar rounding contract. No extra model/runtime/thread +// pool is loaded alongside local ASR. +#[inline(always)] +fn axpy(output: &mut [f32], input: &[f32], scale: f32) { + assert_eq!(output.len(), input.len()); + #[cfg(all(target_arch = "aarch64", target_feature = "neon"))] + let offset = unsafe { + let mut offset = 0; + use std::arch::aarch64::{vaddq_f32, vld1q_f32, vmulq_n_f32, vst1q_f32}; + while offset + 4 <= output.len() { + let value = vmulq_n_f32(vld1q_f32(input.as_ptr().add(offset)), scale); + let sum = vaddq_f32(vld1q_f32(output.as_ptr().add(offset)), value); + vst1q_f32(output.as_mut_ptr().add(offset), sum); + offset += 4; + } + offset + }; + #[cfg(target_arch = "x86_64")] + let offset = unsafe { + // SSE2 is part of the x86-64 baseline. Unaligned loads/stores support + // every tensor row; no AVX CPU requirement or FMA rounding changes. + use std::arch::x86_64::{_mm_add_ps, _mm_loadu_ps, _mm_mul_ps, _mm_set1_ps, _mm_storeu_ps}; + let factor = _mm_set1_ps(scale); + let mut offset = 0; + while offset + 4 <= output.len() { + let value = _mm_mul_ps(_mm_loadu_ps(input.as_ptr().add(offset)), factor); + let sum = _mm_add_ps(_mm_loadu_ps(output.as_ptr().add(offset)), value); + _mm_storeu_ps(output.as_mut_ptr().add(offset), sum); + offset += 4; + } + offset + }; + #[cfg(not(any( + target_arch = "x86_64", + all(target_arch = "aarch64", target_feature = "neon") + )))] + let offset = 0; + for (dst, src) in output[offset..].iter_mut().zip(&input[offset..]) { + *dst += scale * src; + } +} + // The workspace builds for baseline x86-64 (SSE2) so the shipped binaries // run everywhere; ONNX Runtime's MLAS gets its speed from runtime CPUID // dispatch to AVX kernels. We mirror that: each hot kernel body is compiled @@ -1755,3 +1791,22 @@ unsafe fn layer_norm_avx2( ) { layer_norm_impl(xp, xsh, scale, bias, op_, axis, eps) } + +#[cfg(test)] +mod projection_tests { + #[test] + fn projections_keep_scalar_rounding_and_handle_vector_tails() { + for size in [0, 1, 3, 4, 5, 17, 192, 257] { + let input: Vec = (0..size).map(|i| (i as f32 * 0.7).sin()).collect(); + let mut actual = vec![0.17; size]; + let mut expected = actual.clone(); + for scale in [0.0, -0.37, 0.81, 1e-12] { + for (dst, src) in expected.iter_mut().zip(&input) { + *dst += scale * src; + } + super::axpy(&mut actual, &input, scale); + } + assert_eq!(actual, expected, "projection width {size}"); + } + } +} diff --git a/openless-all/app/crates/openless-core/examples/audio_equivalence.rs b/openless-all/app/crates/openless-core/examples/audio_equivalence.rs new file mode 100644 index 000000000..a3cd8c703 --- /dev/null +++ b/openless-all/app/crates/openless-core/examples/audio_equivalence.rs @@ -0,0 +1,88 @@ +//! Export deterministic PCM for comparison across revisions and architectures. +//! Usage: audio_equivalence OUTPUT_DIRECTORY +use openless_core::{AudioDspConfig, AudioProcessingConfig, AudioProcessor, NoiseReduction}; +use std::{fs, path::PathBuf, time::Instant}; + +fn main() -> Result<(), Box> { + let root = PathBuf::from(std::env::args().nth(1).ok_or("output directory required")?); + fs::create_dir(&root)?; + let mut rows = Vec::new(); + for rate in [16_000, 44_100, 48_000] { + for channels in [1, 2] { + for (name, algorithm) in [ + ("off", NoiseReduction::Off), + ("rnnoise", NoiseReduction::Rnnoise), + ("purevox", NoiseReduction::Purevox), + ("spectral", NoiseReduction::Spectral), + ] { + for effects in [false, true] { + let config = AudioDspConfig { + noise_reduction: algorithm, + gain_db: if effects { 3 } else { 0 }, + equalizer_enabled: effects, + equalizer_bands_db: if effects { + [-2, -1, 0, 1, 2, 3, 4, 4, 3, 2] + } else { + [0; 10] + }, + dereverb_level: if effects { 12 } else { 0 }, + vad_enabled: effects, + vad_threshold_db: -55, + ..Default::default() + }; + let mut processor = AudioProcessor::new(AudioProcessingConfig { + enhancement_enabled: true, + echo_cancellation_enabled: false, + dsp: config, + }); + let mut random = 42u32; + let mut input = Vec::new(); + // Silence, speech-band harmonics with deterministic noise, + // near-Nyquist interference, impulses, and an unaligned tail. + for i in 0..rate + 37 { + random = random.wrapping_mul(1664525).wrapping_add(1013904223); + let t = i as f32 / rate as f32; + let noise = ((random >> 8) as f32 / 16777216.0 - 0.5) * 0.025; + let signal = if i < rate / 5 { + 0.0 + } else { + 0.13 * (std::f32::consts::TAU * 230.0 * t).sin() + + 0.04 * (std::f32::consts::TAU * 1733.0 * t).sin() + + 0.02 * (std::f32::consts::TAU * rate as f32 * 0.43 * t).sin() + + noise + } + if i == rate / 2 { 0.4 } else { 0.0 }; + for channel in 0..channels { + input.push(signal * if channel == 0 { 1.0 } else { 0.7 }); + } + } + let start = Instant::now(); + let mut pcm = Vec::new(); + let mut offset = 0; + for size in [37, 960, 127, 4096].into_iter().cycle() { + if offset == input.len() { + break; + } + let end = (offset + size).min(input.len()); + if let Some(chunk) = processor.process(&input[offset..end], channels, rate) + { + pcm.extend(chunk.pcm_i16_le); + } + offset = end; + } + if let Some(chunk) = processor.finish() { + pcm.extend(chunk.pcm_i16_le); + } + assert_eq!( + pcm.len(), + ((rate as u64 + 37) * 16000).div_ceil(rate as u64) as usize * 2 + ); + let file = format!("{name}-{rate}-{channels}-{effects}.pcm"); + rows.push(serde_json::json!({"file":file,"seconds":start.elapsed().as_secs_f64(),"samples":pcm.len()/2})); + fs::write(root.join(file), pcm)?; + } + } + } + } + fs::write(root.join("results.json"), serde_json::to_vec_pretty(&rows)?)?; + Ok(()) +} diff --git a/openless-all/app/crates/openless-core/examples/audio_performance.rs b/openless-all/app/crates/openless-core/examples/audio_performance.rs new file mode 100644 index 000000000..6c88c8b23 --- /dev/null +++ b/openless-all/app/crates/openless-core/examples/audio_performance.rs @@ -0,0 +1,56 @@ +//! Full capture-processing throughput probe; no ASR/network/device timing. +use openless_core::{AudioDspConfig, AudioProcessingConfig, AudioProcessor, NoiseReduction}; +use std::time::Instant; + +fn main() { + let args: Vec = std::env::args().collect(); + let name = args.get(1).map(String::as_str).unwrap_or("rnnoise"); + let rate: u32 = args.get(2).map(|s| s.parse().unwrap()).unwrap_or(48_000); + let mut processor = AudioProcessor::new(AudioProcessingConfig { + enhancement_enabled: name != "disabled", + echo_cancellation_enabled: false, + dsp: AudioDspConfig { + noise_reduction: match name { + "purevox" => NoiseReduction::Purevox, + "spectral" => NoiseReduction::Spectral, + "off" | "disabled" => NoiseReduction::Off, + _ => NoiseReduction::Rnnoise, + }, + ..Default::default() + }, + }); + let samples: Vec = (0..rate / 50) + .map(|i| { + let t = i as f32 / rate as f32; + 0.1 * (std::f32::consts::TAU * 230.0 * t).sin() + + 0.015 * (std::f32::consts::TAU * 1733.0 * t).sin() + }) + .collect(); + let cold = Instant::now(); + let mut output_bytes = processor + .process(&samples, 1, rate) + .map_or(0, |c| c.pcm_i16_le.len()); + let first_ms = cold.elapsed().as_secs_f64() * 1000.0; + let mut times = Vec::with_capacity(1499); + let steady = Instant::now(); + for _ in 1..1500 { + let start = Instant::now(); + output_bytes += processor + .process(&samples, 1, rate) + .map_or(0, |c| c.pcm_i16_le.len()); + times.push(start.elapsed().as_secs_f64() * 1000.0); + } + let seconds = steady.elapsed().as_secs_f64(); + output_bytes += processor.finish().map_or(0, |c| c.pcm_i16_le.len()); + assert_eq!(output_bytes, 30 * 16_000 * 2); + times.sort_by(f64::total_cmp); + println!("algorithm={name} rate={rate} audio_seconds=30 first_callback_ms={first_ms:.3} steady_seconds={seconds:.3} rtf={:.4} p50_ms={:.3} p95_ms={:.3} max_ms={:.3} output_bytes={output_bytes}", seconds / 29.98, times[times.len()/2], times[times.len()*95/100], times[times.len()-1]); + if let Ok(status) = std::fs::read_to_string("/proc/self/status") { + for line in status + .lines() + .filter(|s| s.starts_with("VmRSS:") || s.starts_with("VmHWM:")) + { + println!("{line}"); + } + } +} diff --git a/openless-all/app/crates/openless-core/src/audio.rs b/openless-all/app/crates/openless-core/src/audio.rs index 3b680888d..9c4e3431c 100644 --- a/openless-all/app/crates/openless-core/src/audio.rs +++ b/openless-all/app/crates/openless-core/src/audio.rs @@ -21,6 +21,141 @@ pub struct AudioProcessingConfig { pub dsp: crate::audio_dsp::AudioDspConfig, } +/// 扬声器回环(far-end)参考音频的交接队列。 +/// +/// AEC 需要「扬声器此刻在放什么」。宿主在一条独立的采集流里拿到输出音频后, +/// 按自己的采样率塞进这里;DSP 线程在处理每一帧之前把它取走、重采样到 48 kHz +/// 单声道后交给 MicYou。队列有上限:AEC 只关心最近的参考,堆积只会拖慢。 +#[derive(Clone, Default)] +pub struct FarEndQueue { + inner: std::sync::Arc>>, +} + +#[derive(Debug)] +struct FarEndFrame { + rate: u32, + channels: usize, + samples: Vec, +} + +/// 参考队列最多保留的样本数(约 2 秒 @48 kHz)。超出时丢弃最旧的。 +const FAR_END_QUEUE_LIMIT: usize = 96_000; + +impl FarEndQueue { + /// 宿主采集线程调用。非有限值与空帧直接丢弃。 + pub fn push(&self, samples: &[f32], channels: usize, rate: u32) { + if samples.is_empty() || channels == 0 || rate == 0 { + return; + } + let clean: Vec = samples + .iter() + .map(|sample| if sample.is_finite() { sample.clamp(-1.0, 1.0) } else { 0.0 }) + .collect(); + let mut queue = self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + queue.push_back(FarEndFrame { + rate, + channels, + samples: clean, + }); + let mut total: usize = queue.iter().map(|frame| frame.samples.len()).sum(); + while total > FAR_END_QUEUE_LIMIT { + let Some(dropped) = queue.pop_front() else { break }; + total -= dropped.samples.len(); + } + } + + /// DSP 线程调用:取走全部待处理参考帧(保持到达顺序)。 + fn drain(&self) -> Vec { + let mut queue = self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + queue.drain(..).collect() + } + + fn is_empty(&self) -> bool { + self.inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_empty() + } +} + +const ECHO_FILTER_TAPS: usize = 1_024; +const ECHO_ADAPTATION_RATE: f32 = 0.12; +const ECHO_DOUBLE_TALK_RATIO: f32 = 2.5; +const ECHO_MIN_REFERENCE_POWER: f32 = 0.000_001; + +/// 轻量回声消除:有界归一化 LMS(沿用 #1228 的实现)。 +/// +/// 学的是「扬声器 → 麦克风」这条声学路径;近端能量明显强于估计回声(双讲)时 +/// 暂停自适应,避免把说话人自己的声音学掉。单核开销约 3%,弱机可用。 +#[derive(Debug, Clone)] +struct LightEchoCanceller { + coefficients: Vec, + history: Vec, + history_cursor: usize, +} + +impl Default for LightEchoCanceller { + fn default() -> Self { + Self { + coefficients: vec![0.0; ECHO_FILTER_TAPS], + history: vec![0.0; ECHO_FILTER_TAPS], + history_cursor: 0, + } + } +} + +impl LightEchoCanceller { + fn reset(&mut self) { + self.coefficients.fill(0.0); + self.history.fill(0.0); + self.history_cursor = 0; + } + + fn process(&mut self, microphone: &[f32], reference: &[f32]) -> Vec { + if microphone.is_empty() || reference.is_empty() { + return microphone.to_vec(); + } + let mut output = Vec::with_capacity(microphone.len()); + for (index, &sample) in microphone.iter().enumerate() { + // 块边界处重采样可能少一个样本:保留麦克风样本、等下一块重建对齐, + // 不要把上一个参考值铺满,那样会教坏滤波器。 + let Some(&reference_sample) = reference.get(index) else { + output.push(sample); + continue; + }; + self.history[self.history_cursor] = reference_sample; + let mut estimate = 0.0; + let mut reference_power = 0.0; + for tap in 0..ECHO_FILTER_TAPS { + let history_index = + (self.history_cursor + ECHO_FILTER_TAPS - tap) % ECHO_FILTER_TAPS; + let value = self.history[history_index]; + estimate += self.coefficients[tap] * value; + reference_power += value * value; + } + let error = sample - estimate; + let near_power = sample * sample; + let should_adapt = reference_power > ECHO_MIN_REFERENCE_POWER + && (estimate.abs() < 0.01 + || estimate * estimate * ECHO_DOUBLE_TALK_RATIO >= near_power + || near_power < ECHO_MIN_REFERENCE_POWER); + if should_adapt { + let normalization = reference_power + 0.000_01; + for tap in 0..ECHO_FILTER_TAPS { + let history_index = + (self.history_cursor + ECHO_FILTER_TAPS - tap) % ECHO_FILTER_TAPS; + self.coefficients[tap] += + ECHO_ADAPTATION_RATE * error * self.history[history_index] / normalization; + self.coefficients[tap] = self.coefficients[tap].clamp(-2.0, 2.0); + } + } + output.push(if error.is_finite() { error } else { 0.0 }); + self.history_cursor = (self.history_cursor + 1) % ECHO_FILTER_TAPS; + } + output + } +} + /// Shared processor for native and external microphone hosts. /// Enhancement starts with the session's settings and may be disabled under /// load. Hosts must deliver every returned tail before feeding more input or @@ -29,8 +164,17 @@ pub struct AudioProcessor { config: AudioProcessingConfig, normalizer: PcmNormalizer, enhanced: Option, + /// AEC 的扬声器参考输入(宿主在独立的回环采集流里喂)。 + far_end: Option, + /// 轻量 NLMS 抵消器(`AecMode::Light`)。 + light_aec: Option, + /// 轻量路径把参考重采样到输出采样率(16 kHz 单声道)。 + light_reference_resampler: Option, + light_reference_rate: Option<(u32, u32)>, + light_reference_pending: Vec, echo: Option, finished: bool, + monitoring: bool, } impl std::fmt::Debug for AudioProcessor { @@ -42,22 +186,134 @@ impl std::fmt::Debug for AudioProcessor { } impl AudioProcessor { - pub fn new(config: AudioProcessingConfig) -> Self { + pub fn new(mut config: AudioProcessingConfig) -> Self { + // The independent playback-cancellation switch owns AEC when enabled; never stack cancellers. + if config.echo_cancellation_enabled { + config.dsp.aec_mode = crate::audio_dsp::AecMode::Off; + } Self { config, normalizer: Default::default(), enhanced: None, + far_end: None, + light_aec: matches!(config.dsp.aec_mode, crate::audio_dsp::AecMode::Light) + .then(LightEchoCanceller::default), + light_reference_resampler: None, + light_reference_rate: None, + light_reference_pending: Vec::new(), echo: config .echo_cancellation_enabled .then(echo::EchoProcessor::default), finished: false, + monitoring: false, } } + /// 把参考队列里的帧转成「与麦克风同一采样率」的单声道,供轻量抵消器使用。 + /// + /// NLMS 的抽头是按样本对齐的,采样率不一致就完全对不上 —— 早先版本把参考重 + /// 采样到 16 kHz 却拿 48 kHz 的麦克风样本做抵消,实测 ERLE ≈ 0(等于没做)。 + fn light_reference_at( + &mut self, + queue: &FarEndQueue, + rate: u32, + needed_frames: usize, + ) -> Option> { + for frame in queue.drain() { + let key = (frame.rate, rate); + if self.light_reference_rate != Some(key) { + self.light_reference_rate = Some(key); + self.light_reference_resampler = Some( + crate::audio_dsp::SpeechResampler::new(frame.rate, rate), + ); + } + let mono = downmix_to_mono(&frame.samples, frame.channels); + if let Some(resampler) = self.light_reference_resampler.as_mut() { + resampler.push_into(&mono, false, &mut self.light_reference_pending); + } + } + if self.light_reference_pending.is_empty() { + return None; + } + let take = needed_frames.min(self.light_reference_pending.len()); + Some(self.light_reference_pending.drain(..take).collect()) + } + + fn apply_light_aec( + &mut self, + input: &[f32], + channels: usize, + reference: &[f32], + ) -> Vec { + if channels <= 1 { + return self + .light_aec + .as_mut() + .expect("light AEC is enabled") + .process(input, reference); + } + let frame_count = input.len() / channels; + let mono = input + .chunks_exact(channels) + .map(|frame| frame.iter().copied().sum::() / channels as f32) + .collect::>(); + let clean = self + .light_aec + .as_mut() + .expect("light AEC is enabled") + .process(&mono, reference); + let mut output = input.to_vec(); + for frame in 0..frame_count { + let correction = clean[frame] - mono[frame]; + for channel in 0..channels { + output[frame * channels + channel] += correction; + } + } + output + } + + /// 会话结束时清掉抵消器状态,避免上一场的声学路径带到下一场。 + pub fn reset_echo_cancellers(&mut self) { + if let Some(canceller) = self.light_aec.as_mut() { + canceller.reset(); + } + self.light_reference_resampler = None; + self.light_reference_rate = None; + self.light_reference_pending.clear(); + } + + /// 接上扬声器回环参考队列。只有 AEC 打开且宿主真的采到了输出音频时才有意义; + /// 没接或队列为空时 AEC 按「无参考」处理(MicYou 内部会退化)。 + pub fn attach_far_end(&mut self, queue: FarEndQueue) { + self.far_end = Some(queue); + } + pub fn reset(&mut self) { *self = Self::new(self.config); } + /// Visual analysis is opt-in and does not open a microphone or retain PCM. + pub fn set_monitoring_enabled(&mut self, enabled: bool) { + self.monitoring = enabled; + if let Some(stream) = &mut self.enhanced { + stream.dsp.set_spectrum_enabled(enabled); + } + } + + pub fn monitoring_sample_rate(&self) -> Option { + self.enhanced.as_ref().map(|stream| stream.work_rate) + } + + pub fn spectrums(&self) -> Option<(Vec, Vec)> { + self.monitoring + .then(|| { + self.enhanced + .as_ref() + .map(|stream| stream.dsp.get_spectrums()) + }) + .flatten() + } + /// Flush delayed enhanced samples before switching to basic normalization. /// The change lasts until the processor is replaced for a new capture. pub fn disable_enhancement(&mut self) -> Option { @@ -80,12 +336,29 @@ impl AudioProcessor { if self.finished || input.is_empty() || channels == 0 || rate == 0 { return None; } - if !self.config.enhancement_enabled { + // 轻量 AEC 在主链之前做:抵消后的信号再进降噪/增益,顺序与 MicYou 的 + // 链路一致(AEC 必须拿到尚未被其它处理改过的信号)。 + let mut cancelled: Option> = None; + if self.light_aec.is_some() { + let needed_frames = input.len() / channels; + if let Some(queue) = self.far_end.clone() { + if let Some(reference) = self.light_reference_at(&queue, rate, needed_frames) { + cancelled = Some(self.apply_light_aec(input, channels, &reference)); + } + } + } + let input = cancelled.as_deref().unwrap_or(input); + + // 模型 AEC(`AecMode::Model`)走 MicYou 链路;增强关掉时也要能单独跑。 + let aec_standalone = matches!(self.config.dsp.aec_mode, crate::audio_dsp::AecMode::Model) + && self.far_end.is_some(); + if !self.config.enhancement_enabled && !aec_standalone { return self.normalizer.process(input, channels, rate); } let stream = self .enhanced .get_or_insert_with(|| EnhancedStream::new(self.config.dsp, channels, rate)); + stream.dsp.set_spectrum_enabled(self.monitoring); if stream.channels != channels || stream.rate != rate { log::error!("audio format changed during an enhancement session; reset required"); return None; @@ -98,12 +371,26 @@ impl AudioProcessor { } })); let complete = stream.interleaved.len() / channels * channels; - let mono = downmix_to_mono(&stream.interleaved[..complete], channels); + stream.mono.clear(); + stream.mono.extend( + stream.interleaved[..complete] + .chunks_exact(channels) + .map(|frame| frame.iter().sum::() / channels as f32), + ); stream.interleaved.drain(..complete); - stream.input_frames += mono.len() as u64; - let working = stream.input_resampler.push(&mono, false); - let processed = stream.frames(&working, false); - pcm_chunk(&processed) + stream.input_frames += stream.mono.len() as u64; + // 参考音频必须早于本帧进入 AEC:MicYou 用最近的历史做自适应滤波。 + if let Some(far_end) = self.far_end.as_ref().filter(|queue| !queue.is_empty()) { + let frames = far_end.drain(); + stream.push_far_end(frames); + } + stream.working.clear(); + if let Some(resampler) = &mut stream.input_resampler { + resampler.push_into(&stream.mono, false, &mut stream.working); + } else { + stream.working.extend_from_slice(&stream.mono); + } + pcm_chunk(stream.frames(false)) } /// A playback gap bypasses only cancellation; enhancement keeps its format and tail. @@ -129,70 +416,127 @@ impl AudioProcessor { } self.finished = true; let stream = self.enhanced.as_mut()?; - let working = stream.input_resampler.push(&[], true); - let processed = stream.frames(&working, true); - pcm_chunk(&processed) + stream.working.clear(); + if let Some(resampler) = &mut stream.input_resampler { + resampler.push_into(&[], true, &mut stream.working); + } + pcm_chunk(stream.frames(true)) } } struct EnhancedStream { channels: usize, rate: u32, - input_resampler: crate::audio_dsp::SpeechResampler, - output_resampler: crate::audio_dsp::SpeechResampler, + input_resampler: Option, + output_resampler: Option, + work_rate: u32, + hop: usize, dsp: micyou_dsp::DspProcessor, interleaved: Vec, + mono: Vec, + working: Vec, + frame: Vec, + output: Vec, pending: std::collections::VecDeque, skip: usize, model_delay: usize, input_frames: u64, emitted_frames: u64, + /// 参考音频的重采样器,按输入采样率惰性重建(回环流通常固定,重建仅一次)。 + far_end_rate: Option, + far_end_resampler: Option, + far_end_pending: Vec, } impl EnhancedStream { fn new(config: crate::audio_dsp::AudioDspConfig, channels: usize, rate: u32) -> Self { let config = config.normalized(); + let work_rate = 48_000; Self { channels, rate, - input_resampler: crate::audio_dsp::SpeechResampler::new(rate, 48_000), - output_resampler: crate::audio_dsp::SpeechResampler::new(48_000, DICTATION_SAMPLE_RATE), + // Preserve the original input filter even at 48 kHz: cached + // coefficients remove redundant computation without changing + // the signal presented to the denoiser. + input_resampler: Some(crate::audio_dsp::SpeechResampler::new(rate, work_rate)), + output_resampler: (work_rate != DICTATION_SAMPLE_RATE) + .then(|| crate::audio_dsp::SpeechResampler::new(work_rate, DICTATION_SAMPLE_RATE)), + work_rate, + hop: (work_rate / 100) as usize, dsp: micyou_dsp::DspProcessor::new( std::sync::Arc::new(std::sync::RwLock::new(config.settings())), None, ), interleaved: Vec::new(), + mono: Vec::new(), + working: Vec::new(), + frame: Vec::with_capacity(480), + output: Vec::new(), pending: Default::default(), skip: config.model_delay(), model_delay: config.model_delay(), input_frames: 0, emitted_frames: 0, + far_end_rate: None, + far_end_resampler: None, + far_end_pending: Vec::new(), + } + } + + /// 把宿主采到的回环参考帧转成 48 kHz 单声道后交给 AEC。 + /// 把宿主采到的回环参考帧转成 48 kHz 单声道后交给 AEC。 + fn push_far_end(&mut self, frames: Vec) { + for frame in frames { + let mono = downmix_to_mono(&frame.samples, frame.channels); + if self.far_end_rate != Some(frame.rate) { + self.far_end_rate = Some(frame.rate); + self.far_end_resampler = + Some(crate::audio_dsp::SpeechResampler::new(frame.rate, 48_000)); + self.far_end_pending.clear(); + } + if let Some(resampler) = self.far_end_resampler.as_mut() { + resampler.push_into(&mono, false, &mut self.far_end_pending); + } + } + if self.far_end_pending.is_empty() { + return; } + let reference = std::mem::take(&mut self.far_end_pending); + self.dsp.set_far_end_audio(&reference); } - fn frames(&mut self, input: &[f32], finish: bool) -> Vec { - self.pending.extend(input); + + fn frames(&mut self, finish: bool) -> &[f32] { + self.pending.extend(&self.working); if finish { - let tail = self.pending.len().div_ceil(480) * 480 + self.model_delay; + let tail = self.pending.len().div_ceil(self.hop) * self.hop + self.model_delay; self.pending.resize(tail, 0.0); } - let mut output = Vec::new(); - while self.pending.len() >= 480 { - let mut frame: Vec = self.pending.drain(..480).collect(); - self.dsp.process(&mut frame, 1, 0.0); - let skip = self.skip.min(frame.len()); + self.output.clear(); + while self.pending.len() >= self.hop { + self.frame.clear(); + self.frame.extend(self.pending.drain(..self.hop)); + self.dsp.process(&mut self.frame, 1, 0.0); + let skip = self.skip.min(self.frame.len()); self.skip -= skip; - output.extend(self.output_resampler.push(&frame[skip..], false)); + if let Some(resampler) = &mut self.output_resampler { + resampler.push_into(&self.frame[skip..], false, &mut self.output); + } else { + self.output.extend_from_slice(&self.frame[skip..]); + } } if finish { - output.extend(self.output_resampler.push(&[], true)); + if let Some(resampler) = &mut self.output_resampler { + resampler.push_into(&[], true, &mut self.output); + } } // The last padded model frame may produce additional samples. Bound // output by real capture duration, independently of callback sizes. let valid = (self.input_frames * DICTATION_SAMPLE_RATE as u64).div_ceil(self.rate as u64); - output.truncate(valid.saturating_sub(self.emitted_frames) as usize); - self.emitted_frames += output.len() as u64; - output + self.output + .truncate(valid.saturating_sub(self.emitted_frames) as usize); + self.emitted_frames += self.output.len() as u64; + &self.output } } @@ -378,6 +722,17 @@ fn downmix_to_mono(interleaved: &[f32], channels: usize) -> Vec { .collect() } +/// f32 交织采样 → 小端 i16 字节流,供宿主把「处理前」的原始 PCM 也写成 WAV +/// (调试时与处理后那份做 A/B 对照)。量化规则与 [`AudioProcessor`] 内部一致。 +pub fn pcm_f32_to_i16_le(samples: &[f32]) -> Vec { + let mut bytes = Vec::with_capacity(samples.len() * 2); + for sample in samples { + let quantized = (sample.clamp(-1.0, 1.0) * i16::MAX as f32) as i16; + bytes.extend_from_slice(&quantized.to_le_bytes()); + } + bytes +} + fn quantize_to_i16_le(samples: &[f32]) -> (Vec, f32) { let mut bytes = Vec::with_capacity(samples.len() * 2); let mut square_sum = 0.0_f64; @@ -435,6 +790,74 @@ mod tests { assert!(normalizer.process(&[0.0], 1, 0).is_none()); } + #[test] + fn light_aec_resamples_reference_and_preserves_backlog() { + let mut processor = AudioProcessor::new(AudioProcessingConfig { + dsp: crate::audio_dsp::AudioDspConfig { + aec_mode: crate::audio_dsp::AecMode::Light, + ..Default::default() + }, + ..Default::default() + }); + let queue = FarEndQueue::default(); + queue.push(&vec![0.1; 480], 1, 16_000); + let first = processor.light_reference_at(&queue, 48_000, 480).unwrap(); + assert_eq!(first.len(), 480); + assert!(!processor.light_reference_pending.is_empty()); + let second = processor + .light_reference_at(&queue, 48_000, 480) + .unwrap(); + assert!(!second.is_empty()); + } + + #[test] + fn light_aec_applies_one_mono_correction_to_all_input_channels() { + let mut processor = AudioProcessor::new(AudioProcessingConfig { + dsp: crate::audio_dsp::AudioDspConfig { + aec_mode: crate::audio_dsp::AecMode::Light, + ..Default::default() + }, + ..Default::default() + }); + processor.light_aec.as_mut().unwrap().coefficients[0] = 1.0; + let input = [0.2, 0.4, 0.2, 0.4]; + let reference = [0.1, 0.1]; + let output = processor.apply_light_aec(&input, 2, &reference); + assert_eq!(output.len(), input.len()); + assert_eq!(output[0] - output[1], input[0] - input[1]); + assert_eq!(output[2] - output[3], input[2] - input[3]); + assert!(output[0] < input[0]); + } + + #[test] + fn standalone_echo_switch_does_not_stack_enhancement_aec() { + for enhancement_enabled in [false, true] { + let config = AudioProcessingConfig { + enhancement_enabled, + echo_cancellation_enabled: true, + ..Default::default() + }; + for mode in [crate::AecMode::Light, crate::AecMode::Model] { + let mut selected = AudioProcessor::new(AudioProcessingConfig { + dsp: crate::AudioDspConfig { aec_mode: mode, ..config.dsp }, + ..config + }); + let mut standalone = AudioProcessor::new(config); + assert!(selected.light_aec.is_none()); + assert_eq!(selected.config.dsp.aec_mode, crate::AecMode::Off); + let microphone = vec![0.2; 480]; + let reference = vec![0.1; 240]; + for input_reference in [Some(reference.as_slice()), None, Some(reference.as_slice())] { + assert_eq!( + selected.process_with_reference(µphone, 2, 48_000, input_reference), + standalone.process_with_reference(µphone, 2, 48_000, input_reference), + ); + } + assert_eq!(selected.finish(), standalone.finish()); + } + } + } + #[test] fn processor_disabled_output_matches_normalizer() { let input = [0.0, 1.0, 0.5, -0.5]; diff --git a/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs b/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs index 59e82629d..3104764f7 100644 --- a/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs +++ b/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs @@ -155,7 +155,7 @@ fn partial_preferences_migrate_and_invalid_controls_are_bounded() { config.noise_strength = 255; config.processing_order.fill(crate::AudioDspStage::Agc); let normalized = config.normalized(); - assert_eq!(normalized.gain_db, 30); + assert_eq!(normalized.gain_db, 50); assert_eq!(normalized.noise_strength, 100); assert_eq!( normalized.processing_order, @@ -197,3 +197,100 @@ fn default_agc_does_not_amplify_denoised_room_noise_back_to_original_level() { ); } } + +#[test] +fn native_48k_capture_preserves_input_filter_for_every_denoiser() { + for algorithm in [ + NoiseReduction::Off, + NoiseReduction::Rnnoise, + NoiseReduction::Spectral, + NoiseReduction::Purevox, + ] { + let mut processor = AudioProcessor::new(config(algorithm)); + processor.process(&signal(960), 1, 48_000); + let stream = processor.enhanced.as_ref().expect("enhanced stream"); + assert_eq!(stream.work_rate, 48_000); + assert_eq!(stream.hop, 480); + assert!(stream.input_resampler.is_some()); + assert!(stream.output_resampler.is_some()); + } +} + +/// 老 preferences 里 `processingOrder` 只有 6 个阶段(没有 AEC)。数组长度从 6 +/// 变 7 之后必须仍能反序列化,缺的 AEC 补在默认位置,否则升级后用户的增强配置 +/// 会被整个丢掉。 +#[test] +fn legacy_six_stage_processing_order_still_deserializes() { + let legacy = r#"{ + "noiseReduction": "rnnoise", + "noiseStrength": 80, + "gainDb": 4, + "agcEnabled": true, + "agcTarget": 35, + "agcAttack": 50, + "agcDecay": 50, + "vadEnabled": false, + "vadThresholdDb": -50, + "dereverbLevel": 0, + "equalizerEnabled": false, + "equalizerPreampDb": 0, + "equalizerBandsDb": [0,0,0,0,0,0,0,0,0,0], + "processingOrder": ["noiseReduction","dereverb","equalizer","amplifier","agc","vad"] + }"#; + let config: crate::AudioDspConfig = serde_json::from_str(legacy).unwrap(); + assert_eq!(config.aec_mode, crate::AecMode::Off, "AEC 默认关"); + assert_eq!(config.gain_db, 4, "老配置里的值必须保留"); + assert_eq!( + config.processing_order, + [ + // AEC 补到最前,老配置的 6 级顺序原样保留。 + crate::AudioDspStage::AcousticEchoCancellation, + crate::AudioDspStage::NoiseReduction, + crate::AudioDspStage::Dereverb, + crate::AudioDspStage::Equalizer, + crate::AudioDspStage::Amplifier, + crate::AudioDspStage::Agc, + crate::AudioDspStage::Vad, + ], + "老配置的 6 级顺序必须保留,AEC 前置补齐" + ); +} + +#[test] +fn aec_reaches_the_dsp_settings() { + let config = crate::AudioDspConfig { + aec_mode: crate::AecMode::Model, + ..Default::default() + }; + let settings = config.settings(); + assert!(settings.aec_enabled, "只有 Model 模式才走 MicYou 的 aec7"); +} + +/// 参考队列限长:AEC 只关心最近的扬声器信号,堆积只会把 AEC 拖慢。 +#[test] +fn far_end_queue_drops_oldest_frames_beyond_the_limit() { + let queue = crate::FarEndQueue::default(); + // 每帧 48 kHz 单声道 1 秒 = 48000 样本;塞 3 秒应当留下最近 2 秒。 + for _ in 0..3 { + queue.push(&vec![0.1_f32; 48_000], 1, 48_000); + } + let frames = queue.drain(); + let total: usize = frames.iter().map(|frame| frame.samples.len()).sum(); + assert!(total <= 96_000, "队列长度应被限制在 2 秒内,实际 {total}"); + assert_eq!(frames.len(), 2, "最旧的一秒应当被丢弃"); +} + +/// 空帧/非法采样率不该进队列;非有限值被清洗成 0。 +#[test] +fn far_end_queue_rejects_empty_and_cleans_non_finite_samples() { + let queue = crate::FarEndQueue::default(); + queue.push(&[], 1, 48_000); + queue.push(&[0.0; 8], 0, 48_000); + queue.push(&[0.0; 8], 1, 0); + assert!(queue.is_empty(), "空帧与零通道/零采样率必须被拒绝"); + + queue.push(&[f32::NAN, f32::INFINITY, 0.5], 1, 48_000); + let frames = queue.drain(); + assert_eq!(frames.len(), 1); + assert!(frames[0].samples.iter().all(|sample| sample.is_finite())); +} diff --git a/openless-all/app/crates/openless-core/src/audio_dsp.rs b/openless-all/app/crates/openless-core/src/audio_dsp.rs index 3e639acaf..62e800301 100644 --- a/openless-all/app/crates/openless-core/src/audio_dsp.rs +++ b/openless-all/app/crates/openless-core/src/audio_dsp.rs @@ -11,9 +11,26 @@ pub enum NoiseReduction { Spectral, } +/// 回声消除算法。 +/// +/// - `Off`:不做回声消除。 +/// - `Light`:Core 自带的归一化 LMS(单核约 3%),对稳态回声有效,双讲时暂停自适应。 +/// - `Model`:MicYou 的 aec7 神经网络(单核约 50%),非线性/强混响下更强,弱机慎用。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AecMode { + #[default] + Off, + Light, + Model, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum AudioDspStage { + /// 声学回声消除:需要远端参考信号,只有拿到 far-end 时才真正生效(MicYou 的 + /// 链路里它是第一级)。 + AcousticEchoCancellation, NoiseReduction, Dereverb, Equalizer, @@ -25,6 +42,7 @@ pub enum AudioDspStage { impl AudioDspStage { fn name(self) -> &'static str { match self { + Self::AcousticEchoCancellation => "AEC", Self::NoiseReduction => "NoiseReduction", Self::Dereverb => "Dereverb", Self::Equalizer => "Equalizer", @@ -39,6 +57,10 @@ impl AudioDspStage { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct AudioDspConfig { + /// 声学回声消除算法。老版本 preferences 里存的是布尔 `aecEnabled`, + /// 反序列化时 `true` 映射为 `Light`(轻量、弱机可用),`false` 映射为 `Off`。 + #[serde(default, deserialize_with = "deserialize_aec_mode")] + pub aec_mode: AecMode, pub noise_reduction: NoiseReduction, pub noise_strength: u8, pub gain_db: i8, @@ -54,13 +76,73 @@ pub struct AudioDspConfig { pub equalizer_enabled: bool, pub equalizer_preamp_db: i8, pub equalizer_bands_db: [i8; 10], - pub processing_order: [AudioDspStage; 6], + /// 处理链路顺序,7 个阶段各一次。老版本 preferences 里存的是 6 个阶段(没有 + /// AEC),反序列化时把 AEC 前插补齐,不会因为数组长度变化而丢配置。 + #[serde( + default = "default_processing_order", + deserialize_with = "deserialize_processing_order" + )] + pub processing_order: [AudioDspStage; 7], + /// Android AudioRecord effects. Other hosts ignore these local choices. + pub android_aec_enabled: bool, + pub android_noise_suppression_enabled: bool, + pub android_agc_enabled: bool, +} + +fn default_processing_order() -> [AudioDspStage; 7] { + AudioDspConfig::default().processing_order +} + +fn deserialize_processing_order<'de, D>( + deserializer: D, +) -> Result<[AudioDspStage; 7], D::Error> +where + D: serde::Deserializer<'de>, +{ + let stored = Vec::::deserialize(deserializer)?; + let defaults = default_processing_order(); + // 老配置(6 级,没有 AEC):把 AEC 补到最前面,其余顺序原样保留 —— 这既是 + // MicYou 的默认位置(AEC 必须拿到尚未被降噪改过的信号),也不会制造重复项 + // 而触发 `normalized()` 的「重复即重置」。 + if stored.len() == defaults.len() - 1 && !stored.contains(&AudioDspStage::AcousticEchoCancellation) + { + let mut order = [AudioDspStage::AcousticEchoCancellation; 7]; + for (slot, stage) in stored.into_iter().enumerate() { + order[slot + 1] = stage; + } + return Ok(order); + } + let mut order = defaults; + for (slot, stage) in order.iter_mut().enumerate() { + // 其它长度(损坏或未来版本):按位置取,缺的用默认,多的丢弃。 + *stage = stored.get(slot).copied().unwrap_or(defaults[slot]); + } + Ok(order) +} + +/// 接受三种写法:新枚举名(`off`/`light`/`model`)、老布尔值(`true`→`light`)。 +fn deserialize_aec_mode<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum Wire { + Mode(AecMode), + Enabled(bool), + } + Ok(match Wire::deserialize(deserializer)? { + Wire::Mode(mode) => mode, + Wire::Enabled(true) => AecMode::Light, + Wire::Enabled(false) => AecMode::Off, + }) } impl Default for AudioDspConfig { fn default() -> Self { use AudioDspStage::*; Self { + aec_mode: AecMode::Off, noise_reduction: crate::audio_dsp::NoiseReduction::Rnnoise, noise_strength: 80, gain_db: 0, @@ -74,7 +156,20 @@ impl Default for AudioDspConfig { equalizer_enabled: false, equalizer_preamp_db: 0, equalizer_bands_db: [0; 10], - processing_order: [NoiseReduction, Dereverb, Equalizer, Amplifier, Agc, Vad], + // 与 MicYou 的默认链路一致:AEC 在最前,其后是降噪、去混响、均衡、 + // 增益、AGC、VAD。 + processing_order: [ + AcousticEchoCancellation, + NoiseReduction, + Dereverb, + Equalizer, + Amplifier, + Agc, + Vad, + ], + android_aec_enabled: false, + android_noise_suppression_enabled: false, + android_agc_enabled: false, } } } @@ -82,14 +177,14 @@ impl Default for AudioDspConfig { impl AudioDspConfig { pub fn normalized(mut self) -> Self { self.noise_strength = self.noise_strength.min(100); - self.gain_db = self.gain_db.clamp(-30, 30); - self.agc_target = self.agc_target.clamp(5, 90); + self.gain_db = self.gain_db.clamp(-50, 50); + self.agc_target = self.agc_target.min(100); self.agc_attack = self.agc_attack.clamp(1, 100); self.agc_decay = self.agc_decay.clamp(1, 100); - self.vad_threshold_db = self.vad_threshold_db.clamp(-80, -10); + self.vad_threshold_db = self.vad_threshold_db.clamp(-100, 0); self.dereverb_level = self.dereverb_level.min(100); self.equalizer_preamp_db = self.equalizer_preamp_db.clamp(-20, 20); - self.equalizer_bands_db = self.equalizer_bands_db.map(|gain| gain.clamp(-12, 12)); + self.equalizer_bands_db = self.equalizer_bands_db.map(|gain| gain.clamp(-15, 15)); if self .processing_order .iter() @@ -117,6 +212,7 @@ impl AudioDspConfig { pub(crate) fn settings(self) -> micyou_dsp::AudioDspSettings { let c = self.normalized(); micyou_dsp::AudioDspSettings { + aec_enabled: matches!(c.aec_mode, AecMode::Model), gain: c.gain_db as f32, ns_enabled: c.noise_reduction != NoiseReduction::Off && c.noise_strength > 0, ns_type: match c.noise_reduction { @@ -140,7 +236,6 @@ impl AudioDspConfig { gains: c.equalizer_bands_db.map(f32::from).to_vec(), }, processing_chain: c.processing_order.map(|stage| stage.name().into()).to_vec(), - ..Default::default() } } } @@ -154,12 +249,33 @@ pub(crate) struct SpeechResampler { base: i64, total: u64, emitted: u64, + kernels: Vec, +} + +struct ResampleKernel { + weights: [f64; 48], + sum: f64, } impl SpeechResampler { const HALF: i64 = 24; pub fn new(source_rate: u32, target_rate: u32) -> Self { + let mut a = source_rate; + let mut b = target_rate; + while b != 0 { + (a, b) = (b, a % b); + } + let phases = target_rate / a; + // Common audio clocks need at most 640 phases. Bound uncommon, + // relatively-prime rates instead of allocating megabytes per stream. + let kernels = if phases <= 1024 { + (0..phases) + .map(|phase| Self::kernel(source_rate, target_rate, phase as f64 / phases as f64)) + .collect() + } else { + Vec::new() + }; Self { source_rate, target_rate, @@ -167,15 +283,32 @@ impl SpeechResampler { base: 0, total: 0, emitted: 0, + kernels, } } - pub fn push(&mut self, samples: &[f32], finish: bool) -> Vec { + fn kernel(source_rate: u32, target_rate: u32, fraction: f64) -> ResampleKernel { + let cutoff = (target_rate as f64 / source_rate as f64).min(1.0) * 0.94; + let weights = std::array::from_fn(|tap| { + let distance = tap as f64 - Self::HALF as f64 + 1.0 - fraction; + let x = std::f64::consts::PI * distance * cutoff; + let sinc = if x.abs() < 1e-12 { 1.0 } else { x.sin() / x }; + let window = 0.5 + 0.5 * (std::f64::consts::PI * distance / Self::HALF as f64).cos(); + sinc * window * cutoff + }); + ResampleKernel { + sum: weights.iter().sum(), + weights, + } + } + + /// Append into caller-owned scratch storage; steady-state processing + /// does not allocate a new vector for each 10 ms model frame. + pub fn push_into(&mut self, samples: &[f32], finish: bool, output: &mut Vec) { self.samples .extend(samples.iter().map(|s| if s.is_finite() { *s } else { 0.0 })); self.total += samples.len() as u64; let limit = (self.total * self.target_rate as u64).div_ceil(self.source_rate as u64); - let mut output = Vec::new(); while self.emitted < limit { let numerator = self.emitted * self.source_rate as u64; let center = (numerator / self.target_rate as u64) as i64; @@ -183,25 +316,26 @@ impl SpeechResampler { break; } let fraction = (numerator % self.target_rate as u64) as f64 / self.target_rate as f64; - let cutoff = (self.target_rate as f64 / self.source_rate as f64).min(1.0) * 0.94; + let uncached; + let kernel = if self.kernels.is_empty() { + uncached = Self::kernel(self.source_rate, self.target_rate, fraction); + &uncached + } else { + let phase = ((numerator % self.target_rate as u64) * self.kernels.len() as u64 + / self.target_rate as u64) as usize; + &self.kernels[phase] + }; let mut value = 0.0; - let mut weight_sum = 0.0; - for index in center - Self::HALF + 1..=center + Self::HALF { - let distance = (index - center) as f64 - fraction; - let x = std::f64::consts::PI * distance * cutoff; - let sinc = if x.abs() < 1e-12 { 1.0 } else { x.sin() / x }; - let window = - 0.5 + 0.5 * (std::f64::consts::PI * distance / Self::HALF as f64).cos(); - let weight = sinc * window * cutoff; + for (tap, weight) in kernel.weights.iter().enumerate() { + let index = center - Self::HALF + 1 + tap as i64; let sample = if index >= self.base && index < self.total as i64 { self.samples[(index - self.base) as usize] as f64 } else { 0.0 }; value += sample * weight; - weight_sum += weight; } - output.push((value / weight_sum) as f32); + output.push((value / kernel.sum) as f32); self.emitted += 1; } let next_center = (self.emitted * self.source_rate as u64 / self.target_rate as u64) as i64; @@ -210,6 +344,58 @@ impl SpeechResampler { let discard = discard.min(self.samples.len()); self.samples.drain(..discard); self.base += discard as i64; - output + } +} + +#[cfg(test)] +mod resampler_tests { + use super::SpeechResampler; + + #[test] + fn cached_phases_match_the_original_sinc_and_bound_unusual_clocks() { + let input: Vec = (0..997).map(|i| (i as f32 * 0.037).sin() * 0.3).collect(); + for (source, target) in [ + (16_000, 48_000), + (48_000, 16_000), + (44_100, 48_000), + (48_000, 48_000), + (48_001, 48_000), + ] { + let mut resampler = SpeechResampler::new(source, target); + assert!(resampler.kernels.len() <= 1024); + if source == 48_001 { + assert!(resampler.kernels.is_empty()); + } + let mut actual = Vec::new(); + for block in input.chunks(71) { + resampler.push_into(block, false, &mut actual); + } + resampler.push_into(&[], true, &mut actual); + assert_eq!( + actual.len(), + (input.len() as u64 * target as u64).div_ceil(source as u64) as usize + ); + for (i, sample) in actual.iter().enumerate() { + let numerator = i as u64 * source as u64; + let center = (numerator / target as u64) as i64; + let fraction = (numerator % target as u64) as f64 / target as f64; + let cutoff = (target as f64 / source as f64).min(1.0) * 0.94; + let (mut value, mut sum) = (0.0, 0.0); + for tap in center - 23..=center + 24 { + let distance = (tap - center) as f64 - fraction; + let x = std::f64::consts::PI * distance * cutoff; + let sinc = if x.abs() < 1e-12 { 1.0 } else { x.sin() / x }; + let weight = sinc + * (0.5 + 0.5 * (std::f64::consts::PI * distance / 24.0).cos()) + * cutoff; + value += input.get(tap as usize).copied().unwrap_or(0.0) as f64 * weight; + sum += weight; + } + assert!( + (*sample as f64 - value / sum).abs() < 1e-7, + "clock {source}/{target}, sample {i}" + ); + } + } } } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs index 420b9e4c7..d21e97f64 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -858,6 +858,27 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ shape: PreferenceShape::Boolean, reason: "portable_setting", }, + PreferenceField { + rust_name: "debug_archive_processed", + key: "debugArchiveProcessed", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "aec_reference_device_name", + key: "aecReferenceDeviceName", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Text, + reason: "local_hardware", + }, + PreferenceField { + rust_name: "debug_archive_raw", + key: "debugArchiveRaw", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, PreferenceField { rust_name: "audio_recording_max_entries", key: "audioRecordingMaxEntries", diff --git a/openless-all/app/crates/openless-core/src/dictation_context.rs b/openless-all/app/crates/openless-core/src/dictation_context.rs index 52992f475..e997f5534 100644 --- a/openless-all/app/crates/openless-core/src/dictation_context.rs +++ b/openless-all/app/crates/openless-core/src/dictation_context.rs @@ -171,6 +171,8 @@ pub struct DictationInsertionContext { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RecordingPlan { pub microphone_device_name: Option, + /// AEC 的扬声器参考源:空字符串 = 自动选默认监听源,`"off"` = 不采集参考。 + pub aec_reference_device_name: String, /// The physical hotkey generation that owns a provisional capture, when /// this context is promoted from the native staging path. pub capture_generation: Option, @@ -181,6 +183,11 @@ pub struct RecordingPlan { /// keep PCM in memory; successful-recording retention is a separate policy. pub archive_enabled: bool, pub archive_successful_recording: bool, + /// 主档案(`.wav`)写「处理后」的 PCM。调试录音两个开关都关时为 + /// false;quick note / undecided 这类功能必需档案永远为 true。 + pub archive_processed: bool, + /// 额外再写一份「处理前」的 `.raw.wav` 供 A/B 对照。 + pub archive_raw: bool, /// A quick-note or undecided Android capture must not silently continue /// without a disk archive when its path cannot be created. pub archive_required: bool, @@ -305,12 +312,21 @@ impl DictationContext { &asr, crate::shared_types::PlatformCapabilities::current().supports_local_asr, ); + // 老版本只有总开关 `recordAudioForDebug`,两个子开关是后加的。升级后这两个 + // 字段都是 false,如果直接按「两个都没开就不落盘」处理,已经打开调试录音的 + // 用户会突然一段都录不到。所以两个子开关都没开时按「保存处理后」算。 + let debug_both_off = + !preferences.debug_archive_processed && !preferences.debug_archive_raw; + let debug_keep_processed = + preferences.record_audio_for_debug && (preferences.debug_archive_processed || debug_both_off); + let debug_keep_raw = preferences.record_audio_for_debug && preferences.debug_archive_raw; Self { audio_source: options.audio_source, output_target: options.output_target, normal_archive_successful_recording: preferences.record_audio_for_debug, recording: RecordingPlan { microphone_device_name: non_blank(&preferences.microphone_device_name), + aec_reference_device_name: preferences.aec_reference_device_name.clone(), capture_generation: None, mute_during_recording: preferences.mute_during_recording, // TextStream is explicitly a live ASR module; do not let an old @@ -321,7 +337,15 @@ impl DictationContext { archive_successful_recording: matches!( options.output_target, DictationOutputTarget::QuickNote | DictationOutputTarget::Undecided - ) || preferences.record_audio_for_debug, + ) || debug_keep_processed + || debug_keep_raw, + // 功能必需档案(quick note / undecided)始终存处理后的 PCM; + // 调试录音则由两个开关决定,见上面 `debug_keep_*` 的迁移说明。 + archive_processed: matches!( + options.output_target, + DictationOutputTarget::QuickNote | DictationOutputTarget::Undecided + ) || debug_keep_processed, + archive_raw: debug_keep_raw, archive_required: matches!( options.output_target, DictationOutputTarget::QuickNote | DictationOutputTarget::Undecided @@ -417,11 +441,17 @@ impl DictationContext { pub fn with_output_target(&self, target: DictationOutputTarget) -> Self { let mut next = self.clone(); next.output_target = target; + let normal_archive_processed = self.normal_archive_successful_recording + && (self.recording.archive_processed || !self.recording.archive_raw); + let normal_archive_raw = + self.normal_archive_successful_recording && self.recording.archive_raw; match target { DictationOutputTarget::CloudNote => { next.insertion.enabled = false; next.insertion.streaming = false; next.recording.archive_successful_recording = false; + next.recording.archive_processed = false; + next.recording.archive_raw = false; next.recording.archive_required = false; } DictationOutputTarget::Qa => { @@ -430,6 +460,8 @@ impl DictationContext { next.insertion.streaming = false; next.recording.archive_successful_recording = self.normal_archive_successful_recording; + next.recording.archive_processed = normal_archive_processed; + next.recording.archive_raw = normal_archive_raw; next.recording.archive_required = false; } DictationOutputTarget::VoiceEdit => { @@ -437,12 +469,15 @@ impl DictationContext { next.insertion.streaming = false; next.recording.archive_successful_recording = self.normal_archive_successful_recording; + next.recording.archive_processed = normal_archive_processed; + next.recording.archive_raw = normal_archive_raw; next.recording.archive_required = false; } DictationOutputTarget::QuickNote => { next.insertion.enabled = false; next.insertion.streaming = false; next.recording.archive_successful_recording = true; + next.recording.archive_processed = true; next.recording.archive_required = true; } DictationOutputTarget::ForegroundApp => { @@ -452,12 +487,15 @@ impl DictationContext { } next.recording.archive_successful_recording = next.normal_archive_successful_recording(); + next.recording.archive_processed = normal_archive_processed; + next.recording.archive_raw = normal_archive_raw; next.recording.archive_required = false; } DictationOutputTarget::Undecided => { next.insertion.enabled = true; next.insertion.streaming = false; next.recording.archive_successful_recording = true; + next.recording.archive_processed = true; next.recording.archive_required = true; } } @@ -929,6 +967,76 @@ mod tests { assert!(prompt.chars().count() <= ASR_PROMPT_CHAR_BUDGET); } + // 调试录音的两个开关独立生效: + // - 总开关关 → 不落盘 + // - 总开关开、子开关都关(老版本只有总开关的配置)→ 主档案存处理后 + // - 只开处理后 → 主档案存处理后 + // - 只开处理前 → 主档案存原始(功能不减),不额外多一份 + // - 都开 → 主档案 + `.raw.wav` + // quick note 这类功能必需档案不受开关影响。 + #[test] + fn debug_archive_switches_decide_what_the_host_writes() { + fn plan(processed: bool, raw: bool) -> (bool, bool, bool) { + let preferences = UserPreferences { + record_audio_for_debug: true, + debug_archive_processed: processed, + debug_archive_raw: raw, + ..UserPreferences::default() + }; + let pack = builtin_style_pack_for_mode(preferences.default_mode); + let context = DictationContext::capture( + &preferences, + &pack, + DictationProviderInvocations::new( + ProviderInvocation::for_provider(preferences.active_asr_provider.clone()), + ProviderInvocation::for_provider(preferences.active_llm_provider.clone()), + ProviderInvocation::for_provider("omni"), + ), + Vec::new(), + Vec::new(), + &DictationStartOptions::default(), + ); + ( + context.recording.archive_successful_recording, + context.recording.archive_processed, + context.recording.archive_raw, + ) + } + + // 老配置迁移:只有总开关,两个子开关都没开 → 仍然保存处理后。 + assert_eq!(plan(false, false), (true, true, false)); + assert_eq!(plan(true, false), (true, true, false)); + assert_eq!(plan(false, true), (true, false, true)); + assert_eq!(plan(true, true), (true, true, true)); + + // quick note 必须始终有处理后档案,否则播放与本地回退会失去 PCM。 + let preferences = UserPreferences { + record_audio_for_debug: false, + debug_archive_processed: false, + debug_archive_raw: false, + ..UserPreferences::default() + }; + let pack = builtin_style_pack_for_mode(preferences.default_mode); + let context = DictationContext::capture( + &preferences, + &pack, + DictationProviderInvocations::new( + ProviderInvocation::for_provider(preferences.active_asr_provider.clone()), + ProviderInvocation::for_provider(preferences.active_llm_provider.clone()), + ProviderInvocation::for_provider("omni"), + ), + Vec::new(), + Vec::new(), + &DictationStartOptions { + insert_text: false, + output_target: DictationOutputTarget::QuickNote, + ..DictationStartOptions::default() + }, + ); + assert!(context.recording.archive_processed); + assert!(!context.recording.archive_raw); + } + #[test] fn quick_note_target_forces_permanent_archive_and_skips_insertion() { let preferences = UserPreferences::default(); diff --git a/openless-all/app/crates/openless-core/src/external_audio.rs b/openless-all/app/crates/openless-core/src/external_audio.rs index 7e42b1ab7..370b9c5cd 100644 --- a/openless-all/app/crates/openless-core/src/external_audio.rs +++ b/openless-all/app/crates/openless-core/src/external_audio.rs @@ -29,7 +29,10 @@ pub struct ExternalAudioRecorder { struct ExternalRecordingSession { state: Mutex, + /// Primary archive: processed PCM when requested, otherwise raw PCM. archive: Option>, + /// Optional sidecar raw archive when both forms are requested. + raw_archive: Option>, } struct ExternalRecordingState { @@ -38,6 +41,9 @@ struct ExternalRecordingState { consumer: Arc, progress: Arc, processor: Option, + samples: Vec, + archive_processed: bool, + archive_raw: bool, } struct ExternalActiveRecording { @@ -83,8 +89,10 @@ impl ExternalAudioRecorder { .and_then(crate::AudioProcessor::finish) { state.consumer.consume_pcm_chunk(&chunk.pcm_i16_le); - if let Some(archive) = &expected.archive { - archive.append(&chunk.pcm_i16_le); + if state.archive_processed { + if let Some(archive) = &expected.archive { + archive.append(&chunk.pcm_i16_le); + } } } state.active = false; @@ -92,6 +100,9 @@ impl ExternalAudioRecorder { if let Some(archive) = &expected.archive { archive.finish(); } + if let Some(archive) = &expected.raw_archive { + archive.finish(); + } sessions.remove(&session_id); Ok(()) } @@ -127,18 +138,40 @@ impl AudioRecorder for ExternalAudioRecorder { )) }); } - let archive = self + let archive_processed = context.recording.archive_processed || !context.recording.archive_raw; + let archive_raw = context.recording.archive_raw; + let wants_archive = context.recording.archive_enabled; + let (archive, raw_archive) = self .recordings_dir .as_ref() - .filter(|_| context.recording.archive_enabled) - .and_then(|directory| { - ExternalRecordingArchive::create(directory, session_id, &context.recording) + .filter(|_| wants_archive) + .map(|directory| { + let primary = if archive_processed { + ExternalRecordingArchive::create(directory, session_id, &context.recording) + } else { + ExternalRecordingArchive::create_raw(directory, session_id, &context.recording) + }; + let archive = primary .map(Arc::new) .map_err(|error| log::warn!("[remote-input] 录音归档不可用:{error}")) - .ok() - }); + .ok(); + let raw_archive = (archive_processed && archive_raw) + .then(|| ExternalRecordingArchive::create_raw(directory, session_id, &context.recording)) + .and_then(|result| { + result + .map(Arc::new) + .map_err(|error| log::warn!("[remote-input] 原始录音归档不可用:{error}")) + .ok() + }); + (archive, raw_archive) + }) + .unwrap_or((None, None)); + if let (Some(archive), Some(raw_archive)) = (&archive, &raw_archive) { + archive.add_cleanup_path(raw_archive.path()); + } let session = Arc::new(ExternalRecordingSession { state: Mutex::new(ExternalRecordingState { + samples: Vec::new(), active: true, bytes_received: 0, consumer, @@ -148,8 +181,11 @@ impl AudioRecorder for ExternalAudioRecorder { .audio_processing .enhancement_enabled .then(|| crate::AudioProcessor::new(context.recording.audio_processing)), + archive_processed, + archive_raw, }), archive, + raw_archive, }); sessions.insert(session_id, Arc::clone(&session)); drop(sessions); @@ -192,18 +228,38 @@ impl AudioRecorder for ExternalAudioRecorder { } state.bytes_received = state.bytes_received.saturating_add(pcm.len() as u64); let elapsed_ms = state.bytes_received.saturating_mul(1_000) / 32_000; - if let Some(processor) = &mut state.processor { - let samples: Vec = pcm - .as_chunks::<2>() - .0 - .iter() - .map(|pair| i16::from_le_bytes([pair[0], pair[1]]) as f32 / 32768.0) - .collect(); - if let Some(chunk) = processor.process(&samples, 1, crate::audio::DICTATION_SAMPLE_RATE) + let archive_processed = state.archive_processed; + let archive_raw = state.archive_raw; + if archive_raw && archive_processed { + if let Some(raw_archive) = &session.raw_archive { + raw_archive.append(pcm); + } + } + if let ExternalRecordingState { + processor: Some(processor), + samples, + .. + } = &mut *state + { + samples.clear(); + samples.extend( + pcm.as_chunks::<2>() + .0 + .iter() + .map(|pair| i16::from_le_bytes([pair[0], pair[1]]) as f32 / 32768.0), + ); + if archive_raw && !archive_processed { + if let Some(archive) = &session.archive { + archive.append(pcm); + } + } + if let Some(chunk) = processor.process(samples, 1, crate::audio::DICTATION_SAMPLE_RATE) { state.consumer.consume_pcm_chunk(&chunk.pcm_i16_le); - if let Some(archive) = &session.archive { - archive.append(&chunk.pcm_i16_le); + if archive_processed { + if let Some(archive) = &session.archive { + archive.append(&chunk.pcm_i16_le); + } } return state.progress.publish_level(elapsed_ms, chunk.level); } @@ -301,6 +357,40 @@ mod tests { use super::*; + #[tokio::test] + async fn external_archive_flags_create_processed_and_raw_files() { + let directory = std::env::temp_dir().join(format!( + "openless-external-archive-flags-{}", + uuid::Uuid::new_v4() + )); + let recorder = ExternalAudioRecorder::with_recordings_directory(directory.clone()); + let id = SessionId::new(); + let mut context = DictationContext { + audio_source: DictationAudioSource::External, + ..DictationContext::default() + }; + context.recording.archive_enabled = true; + context.recording.archive_processed = true; + context.recording.archive_raw = true; + let recording = recorder + .start( + id, + Arc::new(context), + Arc::new(RecordingConsumer::default()), + Arc::new(RecordingProgress::default()), + ) + .await + .unwrap(); + let archive = recording.archive().unwrap(); + recorder.feed_pcm(id, &[1, 0, 2, 0]).unwrap(); + recording.stop().await.unwrap(); + assert!(directory.join(format!("{id}.wav")).is_file()); + assert!(directory.join(format!("{id}.raw.wav")).is_file()); + archive.discard().await.unwrap(); + assert!(!directory.join(format!("{id}.raw.wav")).exists()); + let _ = std::fs::remove_dir_all(directory); + } + #[tokio::test] async fn enhanced_external_input_flushes_the_same_complete_pcm_to_asr_and_archive() { let directory = std::env::temp_dir().join(format!( diff --git a/openless-all/app/crates/openless-core/src/external_audio/archive.rs b/openless-all/app/crates/openless-core/src/external_audio/archive.rs index 513dc1125..57efdfacf 100644 --- a/openless-all/app/crates/openless-core/src/external_audio/archive.rs +++ b/openless-all/app/crates/openless-core/src/external_audio/archive.rs @@ -1,7 +1,9 @@ +use std::collections::HashMap; use std::io::{Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; +use std::time::SystemTime; use futures_util::future::BoxFuture; @@ -10,6 +12,7 @@ use crate::{BackendError, BackendErrorCode, RecordingArchive, RecordingPlan, Ses pub(super) struct ExternalRecordingArchive { path: PathBuf, writer: Mutex<(Option, u32)>, + cleanup_paths: Mutex>, available: AtomicBool, } @@ -18,48 +21,79 @@ impl ExternalRecordingArchive { directory: &Path, session_id: SessionId, plan: &RecordingPlan, + ) -> std::io::Result { + Self::create_named(directory, format!("{session_id}.wav"), plan) + } + + pub(super) fn create_raw( + directory: &Path, + session_id: SessionId, + plan: &RecordingPlan, + ) -> std::io::Result { + Self::create_named(directory, format!("{session_id}.raw.wav"), plan) + } + + fn create_named( + directory: &Path, + file_name: String, + plan: &RecordingPlan, ) -> std::io::Result { std::fs::create_dir_all(directory)?; - // Only clean up UUID.wav files this app generated, and reserve one slot for - // the recording about to start. - let mut entries = Vec::new(); + let mut groups: HashMap)> = HashMap::new(); for entry in std::fs::read_dir(directory)?.flatten() { let path = entry.path(); - if path.extension().and_then(|ext| ext.to_str()) != Some("wav") - || path - .file_stem() - .and_then(|stem| stem.to_str()) - .and_then(|stem| uuid::Uuid::parse_str(stem).ok()) - .is_none() - { + if path.extension().and_then(|ext| ext.to_str()) != Some("wav") { continue; } + let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + let session_stem = stem.strip_suffix(".raw").unwrap_or(stem); + let Ok(session_id) = uuid::Uuid::parse_str(session_stem) else { + continue; + }; let Ok(metadata) = entry.metadata() else { continue; }; if !metadata.is_file() { continue; } - let modified = metadata.modified().unwrap_or(std::time::UNIX_EPOCH); - if plan.retention_days > 0 - && modified - .elapsed() - .is_ok_and(|age| age.as_secs() > u64::from(plan.retention_days) * 86_400) - { - let _ = std::fs::remove_file(path); - } else { - entries.push((path, modified)); - } + let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH); + groups + .entry(session_id) + .and_modify(|group| { + group.0 = group.0.max(modified); + group.1.push(path.clone()); + }) + .or_insert_with(|| (modified, vec![path])); } - entries.sort_by_key(|entry| std::cmp::Reverse(entry.1)); + let now = SystemTime::now(); + let retention = plan.retention_days; + groups.retain(|_, (modified, paths)| { + let expired = retention > 0 + && now + .duration_since(*modified) + .is_ok_and(|age| age.as_secs() > u64::from(retention) * 86_400); + if expired { + for path in paths { + let _ = std::fs::remove_file(path); + } + } + !expired + }); let cap = plan .max_entries .map(|count| (count as usize).clamp(1, crate::history::HISTORY_CAP)) .unwrap_or(crate::history::HISTORY_CAP); - for (path, _) in entries.into_iter().skip(cap - 1) { - let _ = std::fs::remove_file(path); + let mut ordered: Vec<_> = groups.into_iter().collect(); + ordered.sort_by_key(|(_, (modified, _))| std::cmp::Reverse(*modified)); + for (_, (_, paths)) in ordered.into_iter().skip(cap.saturating_sub(1)) { + for path in paths { + let _ = std::fs::remove_file(path); + } } - let path = directory.join(format!("{session_id}.wav")); + + let path = directory.join(file_name); let mut options = std::fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] @@ -72,10 +106,22 @@ impl ExternalRecordingArchive { Ok(Self { path, writer: Mutex::new((Some(file), 0)), + cleanup_paths: Mutex::new(Vec::new()), available: AtomicBool::new(false), }) } + pub(super) fn path(&self) -> PathBuf { + self.path.clone() + } + + pub(super) fn add_cleanup_path(&self, path: PathBuf) { + self.cleanup_paths + .lock() + .expect("external archive cleanup lock poisoned") + .push(path); + } + pub(super) fn append(&self, pcm: &[u8]) { let mut writer = self.writer.lock().expect("external archive lock poisoned"); if writer.0.is_none() { @@ -89,8 +135,6 @@ impl ExternalRecordingArchive { .ok_or_else(|| std::io::Error::other("remote recording exceeds WAV size limit"))?; let file = writer.0.as_mut().unwrap(); file.write_all(pcm)?; - // Fix the standard header every frame; the audio received so far is - // readable on disk without waiting for the phone's stop. file.seek(SeekFrom::Start(4))?; file.write_all(&(36 + size).to_le_bytes())?; file.seek(SeekFrom::Start(40))?; @@ -162,6 +206,14 @@ impl RecordingArchive for ExternalRecordingArchive { }; if result.is_ok() { self.available.store(false, Ordering::Release); + for path in self + .cleanup_paths + .lock() + .expect("external archive cleanup lock poisoned") + .drain(..) + { + let _ = std::fs::remove_file(path); + } } Box::pin(async move { result }) } diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 6ee3c0331..1ee73c76a 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -10,7 +10,7 @@ pub mod api; pub mod asr; pub mod audio; pub mod audio_dsp; -pub use audio_dsp::{AudioDspConfig, AudioDspStage, NoiseReduction}; +pub use audio_dsp::{AecMode, AudioDspConfig, AudioDspStage, NoiseReduction}; pub mod auxiliary; pub mod cli; mod cloud_providers; @@ -227,8 +227,8 @@ pub use api::{ QaVoiceCaptureSession, StartupSnapshot, VoiceTranscriptionSession, }; pub use audio::{ - encode_dictation_wav, AudioProcessingConfig, AudioProcessor, NormalizedPcmChunk, - PcmNormalizer, ReferenceAudioBuffer, DICTATION_SAMPLE_RATE, + encode_dictation_wav, pcm_f32_to_i16_le, AudioProcessingConfig, AudioProcessor, FarEndQueue, + NormalizedPcmChunk, PcmNormalizer, ReferenceAudioBuffer, DICTATION_SAMPLE_RATE, }; pub use auxiliary::{ AsrCallLabel, AuxiliaryApi, RepolishRequest, RetranscriptionFailure, RetranscriptionResult, diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index a7f5a9e34..4a49657e3 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -873,6 +873,20 @@ pub struct UserPreferences { /// policy as `history_retention_days`. #[serde(default)] pub record_audio_for_debug: bool, + /// 调试录音里保留「处理后」的音频(降噪/增益/AGC 之后)。占用现有档案名 + /// `.wav`,quick note 播放与本地 ASR 回退都用它。默认 false: + /// 只有显式选择才会落盘。 + #[serde(default)] + pub debug_archive_processed: bool, + /// 调试录音里额外保留「处理前」的原始音频,写成同目录的 + /// `.raw.wav`,用于和 `debug_archive_processed` 的那份做 A/B 对照。 + /// 只选它、不选处理后时,主档案直接存原始 PCM(功能不受影响)。 + #[serde(default)] + pub debug_archive_raw: bool, + /// AEC 的扬声器参考源(回环/监听设备名)。空字符串 = 自动选默认扬声器的 + /// 监听源;`"off"` = 不采集参考(AEC 退化为无参考)。 + #[serde(default)] + pub aec_reference_device_name: String, /// How many recent wav files `recordings/` keeps (newest first by mtime). /// `None` = follow `HISTORY_CAP` (200); `Some(n)` clamps to 1..=200. /// Trimmed before each new session. Lets users tune combinations like @@ -1230,6 +1244,12 @@ struct UserPreferencesWire { #[serde(default)] record_audio_for_debug: bool, #[serde(default)] + debug_archive_processed: bool, + #[serde(default)] + debug_archive_raw: bool, + #[serde(default)] + aec_reference_device_name: String, + #[serde(default)] audio_recording_max_entries: Option, #[serde(default)] quick_note_export_directory: String, @@ -1423,6 +1443,9 @@ impl Default for UserPreferencesWire { auto_update_check: prefs.auto_update_check, history_max_entries: prefs.history_max_entries, record_audio_for_debug: prefs.record_audio_for_debug, + debug_archive_processed: prefs.debug_archive_processed, + debug_archive_raw: prefs.debug_archive_raw, + aec_reference_device_name: prefs.aec_reference_device_name.clone(), audio_recording_max_entries: prefs.audio_recording_max_entries, quick_note_export_directory: prefs.quick_note_export_directory.clone(), marketplace_base_url: prefs.marketplace_base_url, @@ -1661,6 +1684,9 @@ impl<'de> Deserialize<'de> for UserPreferences { auto_update_check: wire.auto_update_check, history_max_entries: wire.history_max_entries, record_audio_for_debug: wire.record_audio_for_debug, + debug_archive_processed: wire.debug_archive_processed, + debug_archive_raw: wire.debug_archive_raw, + aec_reference_device_name: wire.aec_reference_device_name, audio_recording_max_entries: wire.audio_recording_max_entries, quick_note_export_directory: wire.quick_note_export_directory, marketplace_base_url: wire.marketplace_base_url, @@ -2042,6 +2068,9 @@ impl Default for UserPreferences { auto_update_check: true, history_max_entries: None, record_audio_for_debug: false, + debug_archive_processed: false, + debug_archive_raw: false, + aec_reference_device_name: String::new(), audio_recording_max_entries: None, quick_note_export_directory: String::new(), marketplace_base_url: String::new(), diff --git a/openless-all/app/harmony/ipc-audit.json b/openless-all/app/harmony/ipc-audit.json index d051d9e62..a07c01ce3 100644 --- a/openless-all/app/harmony/ipc-audit.json +++ b/openless-all/app/harmony/ipc-audit.json @@ -1,8 +1,8 @@ { "scope": "Static shared IPC command coverage; excludes event subscriptions, dynamic commands and desktop-only calls outside src/lib/ipc.", - "total": 198, + "total": 200, "wired": 103, - "missing": 95, + "missing": 97, "commands": [ { "name": "accept_pending_correction", @@ -452,6 +452,13 @@ ], "implementation": "rust" }, + { + "name": "get_android_audio_effects_status", + "calledFrom": [ + "src/lib/ipc/android-audio.ts" + ], + "implementation": "unimplemented" + }, { "name": "get_capsule_snapshot", "calledFrom": [ @@ -1089,6 +1096,13 @@ ], "implementation": "rust" }, + { + "name": "set_android_audio_monitoring", + "calledFrom": [ + "src/lib/ipc/android-audio.ts" + ], + "implementation": "unimplemented" + }, { "name": "set_capsule_language_menu", "calledFrom": [ diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index b4f334f26..15c44e939 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -141 +149 diff --git a/openless-all/app/linux-egui/src/audio.rs b/openless-all/app/linux-egui/src/audio.rs index 86722120b..6d63173a9 100644 --- a/openless-all/app/linux-egui/src/audio.rs +++ b/openless-all/app/linux-egui/src/audio.rs @@ -51,6 +51,12 @@ struct LinuxRecordingOptions { require_archive: bool, mute_during_recording: bool, audio_processing: openless_core::AudioProcessingConfig, + /// 主档案(`.wav`)写「处理后」的 PCM。 + archive_processed: bool, + /// 额外再写一份「处理前」的 `.raw.wav` 做 A/B 对照。 + archive_raw: bool, + /// AEC 扬声器参考源:空 = 自动,`"off"` = 不采集。 + aec_reference_device_name: String, } #[cfg(target_os = "linux")] @@ -59,6 +65,12 @@ struct LinuxAudioThreadArgs { consumer: Arc, progress: Arc, writer: Option>>, + /// 主档案是否写「处理后」的 PCM;false 时主档案存原始 PCM。 + write_processed: bool, + /// 「处理前」的额外档案(只在同时要两份时创建)。 + raw_writer: Option>>, + /// AEC 的扬声器参考队列(None = 本次录音不接参考)。 + far_end: Option, audio_processing: openless_core::AudioProcessingConfig, stop: Arc, runtime_error: Arc>>, @@ -72,11 +84,15 @@ struct AudioProcessingWorker { #[cfg(target_os = "linux")] impl AudioProcessingWorker { + #[allow(clippy::too_many_arguments)] fn start( processing: openless_core::AudioProcessingConfig, consumer: Arc, progress: Arc, writer: Option>>, + write_processed: bool, + raw_writer: Option>>, + far_end: Option, reference_slot: Arc>>>, ) -> Result<(Self, SyncSender), BackendError> { let (sender, receiver) = sync_channel::(AUDIO_PROCESSING_QUEUE_CAPACITY); @@ -85,7 +101,34 @@ impl AudioProcessingWorker { .name("openless-linux-audio-processor".to_string()) .spawn(move || { let mut processor = openless_core::AudioProcessor::new(processing); + if let Some(queue) = far_end { + processor.attach_far_end(queue); + } + // 处理前的原始 PCM:只在需要时量化,避免常态多一次分配。 + let write_raw = + |frame: &RawAudioFrame, sink: &Option>>| { + let Some(writer) = sink.as_ref() else { + return; + }; + let pcm = openless_core::pcm_f32_to_i16_le(&frame.samples); + if let Err(error) = writer + .lock() + .expect("Linux WAV writer lock poisoned") + .append(&pcm) + { + log::warn!("Linux raw recording write failed: {error}"); + } + }; while let Ok(frame) = receiver.recv() { + // 「处理前」的额外档案永远拿原始帧。 + if raw_writer.is_some() { + write_raw(&frame, &raw_writer); + } + // 只要了处理前、没要处理后:主档案也存原始帧,功能(回退/quick + // note 播放)继续可用,且不会多出一份重复文件。 + if !write_processed { + write_raw(&frame, &writer); + } let reference = reference_slot .lock() .expect("Linux echo reference lock poisoned") @@ -104,13 +147,15 @@ impl AudioProcessingWorker { reference_samples.as_deref(), ) { consumer.consume_pcm_chunk(&chunk.pcm_i16_le); - if let Some(writer) = &writer { - if let Err(error) = writer - .lock() - .expect("Linux WAV writer lock poisoned") - .append(&chunk.pcm_i16_le) - { - log::warn!("Linux recording archive write failed: {error}"); + if write_processed { + if let Some(writer) = &writer { + if let Err(error) = writer + .lock() + .expect("Linux WAV writer lock poisoned") + .append(&chunk.pcm_i16_le) + { + log::warn!("Linux recording archive write failed: {error}"); + } } } let _ = progress @@ -119,7 +164,7 @@ impl AudioProcessingWorker { } if let Some(chunk) = processor.finish() { consumer.consume_pcm_chunk(&chunk.pcm_i16_le); - if let Some(writer) = &writer { + if let Some(writer) = writer.as_ref().filter(|_| write_processed) { if let Err(error) = writer .lock() .expect("Linux WAV writer lock poisoned") @@ -179,6 +224,9 @@ impl AudioRecorder for LinuxCpalRecorder { let recordings_dir = self.recordings_dir.clone(); let require_archive = context.output_target == openless_core::DictationOutputTarget::QuickNote; + let archive_processed = context.recording.archive_processed; + let archive_raw = context.recording.archive_raw; + let aec_reference_device_name = context.recording.aec_reference_device_name.clone(); Box::pin(async move { #[cfg(target_os = "linux")] { @@ -191,6 +239,9 @@ impl AudioRecorder for LinuxCpalRecorder { require_archive, mute_during_recording, audio_processing, + archive_processed, + archive_raw, + aec_reference_device_name, }, consumer, progress, @@ -225,10 +276,16 @@ struct LinuxActiveRecording { /// Holds the output-mute guard while capture is live. Its `Drop` restores /// the sink on every terminal path (stop/cancel/error/drop/shutdown). mute: Option, + /// AEC 的扬声器参考采集;随录音一起停(字段顺序即 Drop 顺序,先停参考流再 + /// 拆主线程,避免参考线程还在往已释放的队列里写)。 + reference: Option, } impl Drop for LinuxActiveRecording { fn drop(&mut self) { + // 先显式停掉 AEC 参考流:它只在录音期间有意义,且必须在主采集线程之前 + // 结束,避免参考线程还在往已释放的队列写。 + drop(self.reference.take()); // Taking the guard here forces the field to be consumed (and therefore // restored) even if `stop()` is never reached — e.g. the handle is // dropped directly on an early error or during Core shutdown before it @@ -338,8 +395,35 @@ fn start_linux_recording( require_archive, mute_during_recording, audio_processing, + archive_processed, + archive_raw, + aec_reference_device_name, } = options; + // AEC 打开且参考没被显式关掉时,单独起一条回环采集流喂参考队列。 + let far_end = if !audio_processing.echo_cancellation_enabled + && audio_processing.enhancement_enabled + && audio_processing.dsp.aec_mode != openless_core::AecMode::Off + && aec_reference_device_name != "off" + { + Some(openless_core::FarEndQueue::default()) + } else { + None + }; + let mut reference_capture = None; + if let Some(queue) = far_end.clone() { + let preferred = (!aec_reference_device_name.is_empty()) + .then(|| aec_reference_device_name.clone()); + match start_reference_capture(preferred, queue) { + Ok(capture) => reference_capture = Some(capture), + // 没有回环源(典型是纯 ALSA)不该让整次录音失败:AEC 退化为无参考。 + Err(error) => log::warn!( + "[aec] speaker reference unavailable; continuing without it: {}", + error.message + ), + } + } + // Mute is best-effort and independent of capture availability (mirrors the // Tauri reference): if it fails we log and continue recording. If capture // later fails on this path, the guard drops here and restores the sink. @@ -359,11 +443,33 @@ fn start_linux_recording( let (startup_tx, startup_rx) = std::sync::mpsc::sync_channel(1); let stop_for_thread = Arc::clone(&stop); let runtime_error_for_thread = Arc::clone(&runtime_error); - let (writer, archive) = match recordings_dir { + // 调试录音两个开关都关时不落盘;quick note / undecided 这类功能必需档案由 + // Core 把 archive_processed 置为 true,所以这里统一按开关走。 + let wants_archive = archive_processed || archive_raw; + let (writer, archive, raw_writer) = match recordings_dir.filter(|_| wants_archive) { Some(directory) => { match LinuxWavWriter::create(directory.join(format!("{session_id}.wav"))) { Ok((writer, archive)) => { - (Some(Arc::new(std::sync::Mutex::new(writer))), Some(archive)) + // 两份都要时,另开一个 .raw.wav 存处理前的 PCM。 + let raw = if archive_processed && archive_raw { + match LinuxWavWriter::create(directory.join(format!("{session_id}.raw.wav"))) + { + Ok((raw_writer, _raw_archive)) => { + Some(Arc::new(std::sync::Mutex::new(raw_writer))) + } + Err(error) => { + log::warn!("failed to create raw recording archive: {error}"); + None + } + } + } else { + None + }; + ( + Some(Arc::new(std::sync::Mutex::new(writer))), + Some(archive), + raw, + ) } Err(error) => { if require_archive { @@ -373,7 +479,7 @@ fn start_linux_recording( )); } log::warn!("failed to create Linux recording archive: {error}"); - (None, None) + (None, None, None) } } } @@ -383,7 +489,7 @@ fn start_linux_recording( "quick note recording archive directory is missing", )) } - None => (None, None), + None => (None, None, None), }; let thread = std::thread::Builder::new() .name("openless-linux-recorder".to_string()) @@ -393,6 +499,9 @@ fn start_linux_recording( consumer, progress, writer, + write_processed: archive_processed, + raw_writer, + far_end, audio_processing, stop: stop_for_thread, runtime_error: runtime_error_for_thread, @@ -413,6 +522,7 @@ fn start_linux_recording( runtime_error, archive, mute, + reference: reference_capture, })), Ok(Err(error)) => { let _ = thread.join(); @@ -437,25 +547,32 @@ fn run_audio_thread(args: LinuxAudioThreadArgs) { consumer, progress, writer, + write_processed, + raw_writer, + far_end, audio_processing, stop, runtime_error, startup, } = args; let reference_slot = Arc::new(std::sync::Mutex::new(None)); - let (processor, frame_tx) = match AudioProcessingWorker::start( - audio_processing, - consumer, - progress, - writer, - Arc::clone(&reference_slot), - ) { - Ok(worker) => worker, - Err(error) => { - let _ = startup.send(Err(error)); - return; - } - }; + let (processor, frame_tx) = + match AudioProcessingWorker::start( + audio_processing, + consumer, + progress, + writer, + write_processed, + raw_writer, + far_end, + Arc::clone(&reference_slot), + ) { + Ok(worker) => worker, + Err(error) => { + let _ = startup.send(Err(error)); + return; + } + }; let mut result = Err(BackendError::new( BackendErrorCode::Platform, "no Linux audio backend is available", @@ -533,6 +650,248 @@ fn run_audio_thread(args: LinuxAudioThreadArgs) { let _ = processor.finish(); } +/// 判断一个输入设备名是否是「扬声器回环 / 监听」源。 +/// +/// - PulseAudio / PipeWire(走 pulse 兼容层):`.monitor`,例如 +/// `alsa_output.pci-0000_00_1f.3.analog-stereo.monitor` +/// - 部分 PipeWire 版本直接暴露为 `...output...` 的 monitor 节点 +/// - ALSA 没有回环源(除非加载 `snd-aloop`,那会表现为 `Loopback, ...`) +fn is_loopback_device_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.contains(".monitor") + || lower.contains("monitor of") + || lower.starts_with("loopback") + || lower.contains(", loopback") +} + +/// 列出所有可用的扬声器回环源,按后端顺序(pipewire → pulseaudio → alsa)去重。 +/// +/// 用户在下拉里选的是设备名原文;空字符串表示「自动」。返回空列表说明当前 +/// 环境没有回环源(典型是纯 ALSA),此时 AEC 只能在没有参考信号的情况下工作。 +pub fn list_loopback_devices() -> Vec { + use cpal::traits::HostTrait; + + let hosts = cpal::available_hosts(); + let mut names: Vec = Vec::new(); + for backend in audio_backend_order() { + let Some(host_id) = hosts + .iter() + .copied() + .find(|id| id.name().eq_ignore_ascii_case(backend)) + else { + continue; + }; + let Ok(host) = cpal::host_from_id(host_id) else { + continue; + }; + let Ok(devices) = host.input_devices() else { + continue; + }; + for device in devices { + let name = device.to_string(); + if is_loopback_device_name(&name) && !names.contains(&name) { + names.push(name); + } + } + } + names +} + +/// 一次录音期间的扬声器参考采集。Drop 时停流并回收线程。 +#[cfg(target_os = "linux")] +pub struct ReferenceCapture { + stop: Arc, + thread: Option>, +} + +#[cfg(target_os = "linux")] +impl Drop for ReferenceCapture { + fn drop(&mut self) { + self.stop + .store(true, std::sync::atomic::Ordering::Release); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +/// 启动扬声器回环采集,把参考音频喂进 AEC 队列。 +/// +/// 与麦克风采集一样按 `pipewire → pulseaudio → alsa` 顺序尝试:只有前两者提供 +/// `.monitor`;ALSA 在没有 `snd-aloop` 时必然失败,此时返回错误,调用方 +/// 只记一条日志(AEC 退化为无参考,录音本身照常进行)。 +#[cfg(target_os = "linux")] +pub fn start_reference_capture( + preferred_device_name: Option, + queue: openless_core::FarEndQueue, +) -> Result { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stop_for_thread = Arc::clone(&stop); + let (startup_tx, startup_rx) = std::sync::mpsc::sync_channel(1); + let thread = std::thread::Builder::new() + .name("openless-linux-aec-reference".to_string()) + .spawn(move || { + let hosts = cpal::available_hosts(); + let mut stream_slot: Option = None; + let mut last_error = None; + for backend in audio_backend_order() { + let Some(host_id) = hosts + .iter() + .copied() + .find(|id| id.name().eq_ignore_ascii_case(backend)) + else { + continue; + }; + let Ok(host) = cpal::host_from_id(host_id) else { + continue; + }; + let Ok(devices) = host.input_devices() else { + continue; + }; + let wanted = preferred_device_name.as_deref().filter(|name| !name.is_empty()); + let device = devices.into_iter().find(|device| { + let name = device.to_string(); + match wanted { + Some(wanted) => name == wanted, + None => is_loopback_device_name(&name), + } + }); + let Some(device) = device else { + last_error = Some(BackendError::new( + BackendErrorCode::Platform, + format!("{backend}: no speaker loopback source"), + )); + continue; + }; + let Ok(config) = device.default_input_config() else { + last_error = Some(BackendError::new( + BackendErrorCode::Platform, + format!("{backend}: loopback device has no input config"), + )); + continue; + }; + let channels = usize::from(config.channels()); + let rate = config.sample_rate(); + let queue_for_stream = queue.clone(); + let built = match config.sample_format() { + cpal::SampleFormat::F32 => device.build_input_stream( + config.config(), + move |data: &[f32], _| queue_for_stream.push(data, channels, rate), + |error| log::warn!("[aec] reference stream error: {error}"), + None, + ), + cpal::SampleFormat::I16 => { + let queue_for_stream = queue.clone(); + device.build_input_stream( + config.config(), + move |data: &[i16], _| { + let converted: Vec = + data.iter().map(|s| *s as f32 / i16::MAX as f32).collect(); + queue_for_stream.push(&converted, channels, rate) + }, + |error| log::warn!("[aec] reference stream error: {error}"), + None, + ) + } + other => { + last_error = Some(BackendError::new( + BackendErrorCode::Unsupported, + format!("{backend}: unsupported loopback sample format {other:?}"), + )); + continue; + } + }; + match built { + Ok(stream) => { + if let Err(error) = stream.play() { + last_error = Some(classify_audio_error( + "start loopback stream", + error.to_string(), + )); + continue; + } + log::info!( + "[aec] speaker reference capturing via {backend}: {} ({rate} Hz, {channels} ch)", + device + ); + stream_slot = Some(stream); + break; + } + Err(error) => { + last_error = Some(classify_audio_error( + "build loopback stream", + error.to_string(), + )); + } + } + } + match stream_slot { + Some(stream) => { + let _ = startup_tx.send(Ok(())); + while !stop_for_thread.load(std::sync::atomic::Ordering::Acquire) { + std::thread::park_timeout(std::time::Duration::from_millis(25)); + } + drop(stream); + } + None => { + let error = last_error.unwrap_or_else(|| { + BackendError::new( + BackendErrorCode::Platform, + "no speaker loopback source is available", + ) + }); + let _ = startup_tx.send(Err(error)); + } + } + }) + .map_err(|error| { + BackendError::new( + BackendErrorCode::Platform, + format!("spawn AEC reference thread: {error}"), + ) + })?; + + match startup_rx.recv() { + Ok(Ok(())) => Ok(ReferenceCapture { + stop, + thread: Some(thread), + }), + Ok(Err(error)) => { + let _ = thread.join(); + Err(error) + } + Err(error) => { + let _ = thread.join(); + Err(BackendError::new( + BackendErrorCode::Platform, + format!("AEC reference thread exited during startup: {error}"), + )) + } + } +} + +#[cfg(all(test, target_os = "linux"))] +mod loopback_tests { + use super::is_loopback_device_name; + + #[test] + fn recognises_pulse_and_pipewire_monitor_sources() { + // PulseAudio / PipeWire 的经典形态 + assert!(is_loopback_device_name( + "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" + )); + // 部分版本带 "Monitor of" 前缀 + assert!(is_loopback_device_name("Monitor of Built-in Audio Analog Stereo")); + // ALSA 的 snd-aloop 模块 + assert!(is_loopback_device_name("Loopback, PCM Capture")); + // 普通麦克风不能被误判成回环源,否则 AEC 会拿麦克风当参考 + assert!(!is_loopback_device_name("alsa_input.pci-0000_00_1f.3.analog-stereo")); + assert!(!is_loopback_device_name("Yeti Stereo Microphone")); + } +} + #[cfg(target_os = "linux")] fn audio_backend_order() -> [&'static str; 3] { // Native desktop servers are preferred because they handle device policy, diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index f0caf8257..82dcb4219 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -149,11 +149,85 @@ const fn row( // that the zh-CN column is fully populated and that every key actually // referenced resolves. pub const CATALOG: &[Msg] = &[ + Msg { key: "settings.audioDsp.eqPresets", text: row("均衡器预设", "均衡器預設", "Equalizer presets", "イコライザープリセット", "이퀄라이저 프리셋") }, + Msg { key: "settings.audioDsp.eqNormal", text: row("平直", "平直", "Flat", "フラット", "플랫") }, + Msg { key: "settings.audioDsp.eqBright", text: row("明亮人声", "明亮人聲", "Bright vocal", "明るい声", "밝은 음성") }, + Msg { key: "settings.audioDsp.eqDeep", text: row("低沉人声", "低沉人聲", "Deep voice", "低い声", "낮은 음성") }, + Msg { key: "settings.audioDsp.eqPodcast", text: row("播客", "播客", "Podcast", "ポッドキャスト", "팟캐스트") }, + Msg { key: "settings.audioDsp.eqClarity", text: row("清晰人声", "清晰人聲", "Vocal clarity", "明瞭な声", "선명한 음성") }, + Msg { key: "settings.audioDsp.eqWarm", text: row("温暖人声", "溫暖人聲", "Warm vocal", "温かい声", "따뜻한 음성") }, Msg { key: "settings.audioDsp.disabledHint", text: row("开启上方语音增强开关后可调整,当前配置已保留。", "開啟上方語音增強開關後可調整,目前設定已保留。", "Enable speech enhancement above to adjust these settings. Your configuration is retained.", "上の音声強調を有効にすると調整できます。設定は保持されています。", "위에서 음성 향상을 켜면 조정할 수 있습니다. 설정은 유지됩니다.") }, Msg { key: "settings.audioDsp.moveUp", text: row("上移处理阶段", "上移處理階段", "Move stage up", "処理を上へ移動", "처리 단계 위로 이동") }, Msg { key: "settings.audioDsp.moveDown", text: row("下移处理阶段", "下移處理階段", "Move stage down", "処理を下へ移動", "처리 단계 아래로 이동") }, + Msg { + key: "settings.audioDsp.aecStage", + text: row("回声消除", "回聲消除", "Echo cancellation", "エコーキャンセル", "에코 제거"), + }, + Msg { + key: "settings.audioDsp.echoStage", + text: row("回声消除与缓冲", "回聲消除與緩衝", "Echo cancellation and buffering", "エコーキャンセルとバッファ", "에코 제거 및 버퍼"), + }, + Msg { + key: "settings.audioDsp.aecOff", + text: row("关闭", "關閉", "Off", "オフ", "끄기"), + }, + Msg { + key: "settings.audioDsp.aecLight", + text: row("轻量(省 CPU)", "輕量(省 CPU)", "Light (low CPU)", "軽量(低 CPU)", "경량(저 CPU)"), + }, + Msg { + key: "settings.audioDsp.aecModel", + text: row("模型(更强)", "模型(更強)", "Model (stronger)", "モデル(高精度)", "모델(강화)"), + }, + Msg { + key: "settings.audioDsp.aecModelCost", + text: row( + "模型版抑制更强,但约是轻量版的 3 倍 CPU;老机器建议用轻量。", + "模型版抑制更強,但約是輕量版的 3 倍 CPU;舊機器建議用輕量。", + "The model cancels more echo but costs about 3× the CPU; prefer Light on older machines.", + "モデル版はより強く抑圧しますが CPU は約 3 倍です。古い端末では軽量版を推奨します。", + "모델 버전은 더 강하게 제거하지만 CPU를 약 3배 사용합니다. 오래된 기기에서는 경량을 권장합니다.", + ), + }, + Msg { + key: "settings.audioDsp.aecReferenceLabel", + text: row("扬声器参考源", "揚聲器參考源", "Speaker reference", "スピーカー参照", "스피커 참조"), + }, + Msg { + key: "settings.audioDsp.aecReferenceDesc", + text: row( + "采集哪台扬声器的回环信号供回声消除使用;没有回环源时 AEC 只能在没有参考的情况下工作。", + "擷取哪台揚聲器的回環訊號供回聲消除使用;沒有回環源時 AEC 只能在沒有參考的情況下運作。", + "Which speaker loopback feeds echo cancellation; without a loopback source AEC runs without a reference.", + "どのスピーカーのループバックをエコーキャンセルに使うか。ループバック元がない場合、AEC は参照なしで動作します。", + "어떤 스피커의 루프백을 에코 제거에 사용할지 선택합니다. 루프백 소스가 없으면 AEC는 참조 없이 동작합니다.", + ), + }, + Msg { + key: "settings.audioDsp.aecReferenceAuto", + text: row("自动(默认扬声器)", "自動(預設揚聲器)", "Automatic (default speaker)", "自動(既定のスピーカー)", "자동(기본 스피커)"), + }, + Msg { + key: "settings.audioDsp.aecReferenceOff", + text: row("不采集参考", "不擷取參考", "Do not capture a reference", "参照を取得しない", "참조를 수집하지 않음"), + }, + Msg { + key: "settings.audioDsp.acousticEchoCancellation", + text: row("声学回声消除(AEC)", "聲學回聲消除(AEC)", "Acoustic echo cancellation (AEC)", "音響エコーキャンセル(AEC)", "음향 에코 제거(AEC)"), + }, + Msg { + key: "settings.audioDsp.AEC_needs_a_loudspeaker_reference", + text: row( + "外放时压掉扬声器回授;没有参考信号时不会起作用,戴耳机可以关掉。", + "外放時壓掉揚聲器回授;沒有參考訊號時不會起作用,戴耳機可以關掉。", + "Suppresses loudspeaker feedback while on speaker; it does nothing without a reference signal, so leave it off with headphones.", + "スピーカー使用時の回り込みを抑えます。参照信号がないと効果はないため、ヘッドホンならオフのままで構いません。", + "스피커 사용 시 되먹임을 줄입니다. 참조 신호가 없으면 효과가 없으므로 헤드폰이면 꺼 두세요.", + ), + }, Msg { key: "settings.audioDsp.noiseStage", text: row("降噪", "降噪", "Noise reduction", "ノイズ低減", "잡음 감소") }, - Msg { key: "settings.audioDsp.combStage", text: row("梳状滤波", "梳狀濾波", "Comb filter", "コムフィルター", "콤 필터") }, + Msg { key: "settings.audioDsp.roomStage", text: row("音色与房间", "音色與房間", "Tone and room", "音色と残響", "음색과 잔향") }, + Msg { key: "settings.audioDsp.combStage", text: row("梳状滤波(简易去混响)", "梳狀濾波(簡易去混響)", "Comb filter (simple dereverb)", "コムフィルター(簡易デリバーブ)", "콤 필터(간이 디버브)") }, Msg { key: "settings.audioDsp.Speech_enhancement_settings", text: row("语音增强设置", "語音增強設置", "Speech enhancement settings", "音声強調設定", "음성 향상 설정") }, Msg { key: "settings.audioDsp.RNNoise_is_lightweight_PureVox_uses_more_CPU_Changes_apply_to_the_next_recording", text: row("RNNoise 更轻量,PureVox 模型降噪耗时较高。设置在下一次录音生效。", "RNNoise 更輕量,PureVox 模型降噪耗時較高。設置在下一次錄音生效。", "RNNoise is lightweight; PureVox uses more CPU. Changes apply to the next recording.", "RNNoise は軽量、PureVox は CPU 使用量が多めです。設定は次の録音に適用されます。", "RNNoise는 가볍고 PureVox는 CPU를 더 사용합니다. 설정은 다음 녹음에 적용됩니다.") }, Msg { key: "settings.audioDsp.Noise_reduction_off", text: row("关闭降噪", "關闭降噪", "Noise reduction off", "ノイズ低減オフ", "잡음 감소 끄기") }, @@ -165,18 +239,18 @@ pub const CATALOG: &[Msg] = &[ Msg { key: "settings.audioDsp.Target_level", text: row("目标电平 %", "目標電平 %", "Target level %", "目標レベル %", "목표 레벨 %") }, Msg { key: "settings.audioDsp.Attack_speed", text: row("启动速度", "啟動速度", "Attack speed", "アタック速度", "어택 속도") }, Msg { key: "settings.audioDsp.Release_speed", text: row("释放速度", "釋放速度", "Release speed", "リリース速度", "릴리스 속도") }, - Msg { key: "settings.audioDsp.Speech_gate_does_not_stop_recording", text: row("语音门限(不自动停止录音)", "語音門限(不自動停止錄音)", "Speech gate (does not stop recording)", "音声ゲート(録音は停止しません)", "음성 게이트 (녹음을 중지하지 않음)") }, + Msg { key: "settings.audioDsp.Speech_gate_does_not_stop_recording", text: row("低于门限的片段被衰减,录音不会因此停止。", "低於門檻的片段被衰減,錄音不會因此停止。", "Passages below the threshold are attenuated; recording never stops for them.", "しきい値を下回る区間は減衰されますが、録音は停止しません。", "임계값 미만 구간은 감쇠되지만 녹음은 중단되지 않습니다.") }, Msg { key: "settings.audioDsp.Threshold_dB", text: row("门限 dB", "門限 dB", "Threshold dB", "しきい値 dB", "임계값 dB") }, Msg { key: "settings.audioDsp.A_high_threshold_can_remove_quiet_speech", text: row("门限过高可能丢失轻声。", "門限過高可能丟失輕聲。", "A high threshold can remove quiet speech.", "しきい値が高いと小さな声が失われる場合があります。", "임계값이 높으면 작은 목소리가 사라질 수 있습니다.") }, Msg { key: "settings.audioDsp.Tone_and_room_adjustments", text: row("音色与房间调整", "音色與房間調整", "Tone and room adjustments", "音色と室内音響の調整", "음색 및 실내 음향 조정") }, - Msg { key: "settings.audioDsp.The_MicYou_comb_filter_can_color_speech_Start_at_0", text: row("梳状滤波沿用 MicYou 的延迟相减方式,可能改变音色,建议从 0% 开始。", "梳狀濾波沿用 MicYou 的延遲相減方式,可能改變音色,建議從 0% 开始。", "The MicYou comb filter can color speech. Start at 0%.", "MicYou のコムフィルターは音色を変える場合があります。0% から始めてください。", "MicYou 콤 필터는 음색을 바꿀 수 있습니다. 0%부터 시작하세요.") }, + Msg { key: "settings.audioDsp.The_MicYou_comb_filter_can_color_speech_Start_at_0", text: row("把 10 ms 前的信号按比例减掉,能压掉一点早期反射,但会给音色染上金属感;从 0 开始试。真正的去混响需要频谱或模型方法,这里没做。", "把 10 ms 前的訊號按比例減掉,能壓掉一點早期反射,但會讓音色帶金屬感;從 0 開始試。真正的去混響需要頻譜或模型方法,這裡沒做。", "Subtracts the signal from 10 ms ago, trimming some early reflections at the cost of a metallic color; start at 0. Real dereverberation needs spectral or model-based methods, which this is not.", "10 ms 前の信号を差し引いて初期反射を少し抑えますが、金属的な音色になります。0 から試してください。本格的なデリバーブは周波数領域かモデル方式が必要で、ここでは未実装です。", "10 ms 전 신호를 비율로 빼서 초기 반사를 조금 줄이지만 금속성 음색이 생깁니다. 0부터 시작하세요. 실제 디버브는 주파수 영역이나 모델 방식이 필요하며 여기서는 구현되지 않았습니다.") }, Msg { key: "settings.audioDsp.Comb_filter", text: row("梳状滤波 %", "梳狀濾波 %", "Comb filter %", "コムフィルター %", "콤 필터 %") }, Msg { key: "settings.audioDsp.10_band_equalizer", text: row("十段均衡器", "十段均衡器", "10-band equalizer", "10 バンドイコライザー", "10밴드 이퀄라이저") }, Msg { key: "settings.audioDsp.Preamp_dB", text: row("前置增益 dB", "前置增益 dB", "Preamp dB", "プリアンプ dB", "프리앰프 dB") }, Msg { key: "settings.audioDsp.Processing_order", text: row("处理顺序", "處理順序", "Processing order", "処理順序", "처리 순서") }, Msg { key: "settings.audioDsp.Equalizer", text: row("均衡器", "均衡器", "Equalizer", "イコライザー", "이퀄라이저") }, Msg { key: "settings.audioDsp.Input_gain", text: row("输入增益", "輸入增益", "Input gain", "入力ゲイン", "입력 게인") }, - Msg { key: "settings.audioDsp.Speech_gate", text: row("语音门限", "語音門限", "Speech gate", "音声ゲート", "음성 게이트") }, + Msg { key: "settings.audioDsp.Speech_gate", text: row("噪声门限(不是人声检测)", "雜訊門檻(不是人聲偵測)", "Noise gate (not speech detection)", "ノイズゲート(音声検出ではありません)", "노이즈 게이트(음성 검출 아님)") }, Msg { key: "settings.audioDsp.Reset_enhancement_settings", text: row("恢复增强默认设置", "恢復增強默認設置", "Reset enhancement settings", "強調設定を初期化", "향상 설정 초기화") }, Msg { key: "settings.providers.presets.chatgptSiwc", text: row("ChatGPT Plan", "ChatGPT Plan", "ChatGPT Plan", "ChatGPT Plan", "ChatGPT Plan") }, Msg { key: "siwc.description", text: row("符合条件的文本润色和翻译使用你的 ChatGPT 计划或积分余额。ASR 仍使用原渠道;额度不足时不会自动切换到 API Key。", "符合條件的文字潤飾和翻譯使用你的 ChatGPT 方案或點數。ASR 仍使用原管道;額度不足時不會自動切換至 API Key。", "Eligible text polishing and translation use your ChatGPT plan or credits. ASR keeps its existing provider. Usage limits never switch you to an API key automatically.", "対象の文章校正と翻訳には ChatGPT プランまたはクレジットを使用します。音声認識は従来のプロバイダーを使い、上限到達時に API キーへ自動切り替えしません。", "지원되는 텍스트 다듬기와 번역에 ChatGPT 요금제 또는 크레딧을 사용합니다. 음성 인식은 기존 서비스를 사용하며 한도에 도달해도 API 키로 자동 전환하지 않습니다.") }, @@ -3671,6 +3745,34 @@ pub const CATALOG: &[Msg] = &[ "debug는 하트비트와 IPC 세부 정보를 기록합니다(문제 해결용). OpenLess를 다시 시작해야 적용됩니다.", ), }, + Msg { + key: "settings.recording.archive_processed_label", + text: row("保存处理后的音频", "儲存處理後的音訊", "Keep processed audio", "処理後の音声を保存", "처리된 오디오 저장"), + }, + Msg { + key: "settings.recording.archive_processed_desc", + text: row( + "降噪与增益之后的版本,仍保存在原来的录制文件里(quick note 播放、本地模型回退都用它)。", + "降噪與增益之後的版本,仍存於原本的錄製檔案(quick note 播放、本機模型回退都用它)。", + "The version after noise reduction and gain, still stored in the existing recording file (used by quick-note playback and the local model fallback).", + "ノイズ低減とゲイン適用後の音声で、従来の録音ファイルに保存されます(クイックノート再生とローカルモデルのフォールバックが使います)。", + "노이즈 감소와 게인 적용 후의 오디오로, 기존 녹음 파일에 저장됩니다(퀵노트 재생과 로컬 모델 폴백이 사용).", + ), + }, + Msg { + key: "settings.recording.archive_raw_label", + text: row("另存一份处理前的音频", "另存一份處理前的音訊", "Also keep pre-processing audio", "処理前の音声も保存", "처리 전 오디오도 저장"), + }, + Msg { + key: "settings.recording.archive_raw_desc", + text: row( + "同一目录下另存 <会话>.raw.wav,可与上面那份逐段对照;只开这一项时,原始音频直接存进主档案。", + "同一目錄下另存 .raw.wav,可與上面那份逐段對照;只開這一項時,原始音訊直接存進主檔案。", + "Writes .raw.wav next to it so you can compare segment by segment; with only this enabled the raw audio goes into the main archive.", + "同じフォルダに .raw.wav を追加保存し、上記と区間ごとに比較できます。これだけを有効にすると元音声がメインファイルに保存されます。", + "같은 폴더에 .raw.wav를 함께 저장해 구간별로 비교할 수 있습니다. 이것만 켜면 원본 오디오가 기본 파일에 저장됩니다.", + ), + }, Msg { key: "settings.recording.record_audio_for_debug_label", text: row( diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index 6c0dcf211..4a9e3aa89 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -49,6 +49,8 @@ pub mod wgpu_device; pub mod siri_wgpu; pub use audio::LinuxCpalRecorder; +#[cfg(target_os = "linux")] +pub use audio::{list_loopback_devices, start_reference_capture, ReferenceCapture}; pub use audio_cue::{play_cue_start, play_cue_stop, CueTone}; pub use audio_mute::AudioMuteGuard; pub use audio_player::ClipPlayer; diff --git a/openless-all/app/linux-egui/src/linux_app/settings_save.rs b/openless-all/app/linux-egui/src/linux_app/settings_save.rs index f19dd2a81..726e29828 100644 --- a/openless-all/app/linux-egui/src/linux_app/settings_save.rs +++ b/openless-all/app/linux-egui/src/linux_app/settings_save.rs @@ -232,6 +232,20 @@ impl OpenLessEguiApp { } frontend::view_model::SettingsField::RecordAudioForDebug => { preferences.record_audio_for_debug = !preferences.record_audio_for_debug; + // 打开主开关时默认选「处理后」,否则主开关打开了却什么都不落盘、 + // 用户以为坏了;「处理前」保持用户上一次的选择。 + if preferences.record_audio_for_debug + && !preferences.debug_archive_processed + && !preferences.debug_archive_raw + { + preferences.debug_archive_processed = true; + } + } + frontend::view_model::SettingsField::DebugArchiveProcessed => { + preferences.debug_archive_processed = !preferences.debug_archive_processed; + } + frontend::view_model::SettingsField::DebugArchiveRaw => { + preferences.debug_archive_raw = !preferences.debug_archive_raw; } frontend::view_model::SettingsField::StreamingSaveClipboard => { preferences.streaming_insert_save_clipboard = @@ -250,6 +264,28 @@ impl OpenLessEguiApp { field: frontend::view_model::SettingsComboField, index: usize, ) { + // AEC 参考源是 Core 偏好里的字符串(空 = 自动,"off" = 不采集), + // 界面用下标表达,这里换算回去。 + if matches!(field, frontend::view_model::SettingsComboField::AecReference) { + let value = match index { + 0 => String::new(), + 1 => "off".to_string(), + other => self + .frontend_vm + .settings + .aec_reference_devices + .get(other - 2) + .cloned() + .unwrap_or_default(), + }; + self.frontend_vm.settings.aec_reference_index = index; + if let Some(preferences) = self.preferences.as_mut() { + preferences.aec_reference_device_name = value; + self.save_settings_if_dirty(); + } + return; + } + // 日志级别是宿主本地设置(`/logs/level`),不经过 Core 偏好, // 因此要放在下面 `self.preferences` 的守卫之前 —— 否则偏好还没加载时 // 连日志级别都改不了。 @@ -284,6 +320,8 @@ impl OpenLessEguiApp { match field { // 已在函数开头处理(不走 Core 偏好)。 frontend::view_model::SettingsComboField::LogLevel => {} + // 已在函数开头处理(换算成偏好里的设备名字符串)。 + frontend::view_model::SettingsComboField::AecReference => {} frontend::view_model::SettingsComboField::Theme => { preferences.theme_mode = match index { 0 => openless_core::shared_types::ThemeMode::System, diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 4dbcb0f8d..ee4bf1a70 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -588,6 +588,8 @@ mod linux_app { hydrate_text_fields: bool, overview: OverviewState, microphones: Vec, + /// AEC 可选的扬声器回环源(缓存一次 cpal 枚举结果)。 + aec_reference_devices: Vec, /// 麦克风枚举失败的原因(设置页据此显示 `microphoneLoadError`)。 microphone_error: Option, transcript: String, @@ -841,6 +843,7 @@ mod linux_app { hydrate_text_fields: true, overview: OverviewState::Loading, microphones: Vec::new(), + aec_reference_devices: Vec::new(), microphone_error: None, transcript: String::new(), transcript_state: TranscriptAccumulator::default(), @@ -955,6 +958,7 @@ mod linux_app { service_configured: [false; 2], overview: OverviewState::Loading, microphones: Vec::new(), + aec_reference_devices: Vec::new(), microphone_error: None, transcript: String::new(), transcript_state: TranscriptAccumulator::default(), @@ -5377,6 +5381,27 @@ mod linux_app { // 文本型设置行只在偏好载入 / 外部变更时回灌一次,避免把输入中的 // 内容每帧弹回旧值。 let hydrate_text = std::mem::replace(&mut self.hydrate_text_fields, false); + // AEC 参考源:设备列表要枚举一次 cpal 主机,缓存到应用里;下标由 + // 偏好里的设备名换算(0 = 自动,1 = 不采集)。 + if self.aec_reference_devices.is_empty() && hydrate_text { + self.aec_reference_devices = openless_linux_egui::list_loopback_devices(); + } + let aec_reference_devices = self.aec_reference_devices.clone(); + let aec_reference_index = match self + .preferences + .as_ref() + .map(|prefs| prefs.aec_reference_device_name.as_str()) + .unwrap_or_default() + { + "" => 0, + "off" => 1, + current => self + .aec_reference_devices + .iter() + .position(|name| name == current) + .map_or(0, |index| index + 2), + }; + // 日志级别是宿主本地文件(不在 Core 偏好里),要在借用 vm 之前读。 let log_level_index = self .backend() @@ -5562,8 +5587,12 @@ mod linux_app { s.launch_at_login = prefs.launch_at_login; s.streaming_save_clipboard = prefs.streaming_insert_save_clipboard; s.record_audio_for_debug = prefs.record_audio_for_debug; + s.debug_archive_processed = prefs.debug_archive_processed; + s.debug_archive_raw = prefs.debug_archive_raw; // 日志级别是宿主本地文件,不在 Core 偏好里;读不到就按 info 显示。 s.log_level = log_level_index; + s.aec_reference_devices = aec_reference_devices.clone(); + s.aec_reference_index = aec_reference_index; if hydrate_text { s.history_max_entries = prefs .history_max_entries diff --git a/openless-all/app/linux-egui/src/ui/frontend/audio_dsp.rs b/openless-all/app/linux-egui/src/ui/frontend/audio_dsp.rs index 0430a5424..fc59135c3 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/audio_dsp.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/audio_dsp.rs @@ -1,5 +1,5 @@ use eframe::egui; -use openless_core::{AudioDspConfig, AudioDspStage, NoiseReduction}; +use openless_core::{AecMode, AudioDspConfig, AudioDspStage, NoiseReduction}; use openless_linux_egui::{tr_l10n, Lang}; pub fn settings(ui: &mut egui::Ui, config: &mut AudioDspConfig, lang: Lang, enabled: bool) -> bool { @@ -11,6 +11,23 @@ pub fn settings(ui: &mut egui::Ui, config: &mut AudioDspConfig, lang: Lang, enab ui.add_enabled_ui(enabled, |ui| { ui.small(label("settings.audioDsp.RNNoise_is_lightweight_PureVox_uses_more_CPU_Changes_apply_to_the_next_recording")); ui.add_space(8.0); + ui.strong(label("settings.audioDsp.echoStage")); + // 三选一:轻量 NLMS 省 CPU(弱机可用),aec7 模型抑制更强但约 3 倍开销。 + let aec_labels = [ + (AecMode::Off, label("settings.audioDsp.aecOff")), + (AecMode::Light, label("settings.audioDsp.aecLight")), + (AecMode::Model, label("settings.audioDsp.aecModel")), + ]; + ui.horizontal_wrapped(|ui| { + for (mode, name) in aec_labels { + ui.selectable_value(&mut config.aec_mode, mode, name); + } + }); + ui.small(label("settings.audioDsp.AEC_needs_a_loudspeaker_reference")); + if config.aec_mode == AecMode::Model { + ui.small(label("settings.audioDsp.aecModelCost")); + } + ui.separator(); ui.strong(label("settings.audioDsp.noiseStage")); egui::ComboBox::from_id_salt("audio-dsp-algorithm") .width(ui.available_width().min(280.0)) @@ -30,10 +47,10 @@ pub fn settings(ui: &mut egui::Ui, config: &mut AudioDspConfig, lang: Lang, enab } ui.separator(); ui.strong(label("settings.audioDsp.Input_gain")); - slider(ui, label("settings.audioDsp.Input_gain_dB"), &mut config.gain_db, -30..=30, " dB"); + slider(ui, label("settings.audioDsp.Input_gain_dB"), &mut config.gain_db, -50..=50, " dB"); ui.checkbox(&mut config.agc_enabled, label("settings.audioDsp.Automatic_gain")); if config.agc_enabled { - slider(ui, label("settings.audioDsp.Target_level"), &mut config.agc_target, 5..=90, "%"); + slider(ui, label("settings.audioDsp.Target_level"), &mut config.agc_target, 0..=100, "%"); slider(ui, label("settings.audioDsp.Attack_speed"), &mut config.agc_attack, 1..=100, ""); slider(ui, label("settings.audioDsp.Release_speed"), &mut config.agc_decay, 1..=100, ""); } @@ -41,35 +58,21 @@ pub fn settings(ui: &mut egui::Ui, config: &mut AudioDspConfig, lang: Lang, enab ui.strong(label("settings.audioDsp.Speech_gate")); ui.checkbox(&mut config.vad_enabled, label("settings.audioDsp.Speech_gate_does_not_stop_recording")); if config.vad_enabled { - slider(ui, label("settings.audioDsp.Threshold_dB"), &mut config.vad_threshold_db, -80..=-10, " dB"); + slider(ui, label("settings.audioDsp.Threshold_dB"), &mut config.vad_threshold_db, -100..=0, " dB"); ui.small(label("settings.audioDsp.A_high_threshold_can_remove_quiet_speech")); } ui.add_space(8.0); - ui.collapsing(label("settings.audioDsp.Tone_and_room_adjustments"), |ui| { - ui.small(label("settings.audioDsp.The_MicYou_comb_filter_can_color_speech_Start_at_0")); - slider(ui, label("settings.audioDsp.Comb_filter"), &mut config.dereverb_level, 0..=100, "%"); - ui.checkbox(&mut config.equalizer_enabled, label("settings.audioDsp.10_band_equalizer")); - if config.equalizer_enabled { - slider(ui, label("settings.audioDsp.Preamp_dB"), &mut config.equalizer_preamp_db, -20..=20, " dB"); - let columns = if ui.available_width() >= 540.0 { 2 } else { 1 }; - egui::Grid::new("audio-dsp-eq").num_columns(columns).spacing([16.0, 8.0]).show(ui, |ui| { - let width = ((ui.available_width() - 16.0 * (columns - 1) as f32) / columns as f32).max(100.0); - for (index, (band, name)) in config.equalizer_bands_db.iter_mut().zip(["31 Hz", "63 Hz", "125 Hz", "250 Hz", "500 Hz", "1 kHz", "2 kHz", "4 kHz", "8 kHz", "16 kHz"]).enumerate() { - ui.push_id(index, |ui| ui.vertical(|ui| { - ui.set_width(width); - ui.label(name); - ui.spacing_mut().slider_width = (width - 80.0).clamp(60.0, 220.0); - ui.add(egui::Slider::new(band, -12..=12).suffix(" dB")); - })); - if (index + 1) % columns == 0 { ui.end_row(); } - } - }); - } - }); + // 房间/音色只剩「梳状滤波」一项:十段均衡器按需求移除(配置字段保留, + // 旧 preferences 仍能反序列化),界面上不再暴露。 + ui.add_space(8.0); + ui.strong(label("settings.audioDsp.roomStage")); + ui.small(label("settings.audioDsp.The_MicYou_comb_filter_can_color_speech_Start_at_0")); + slider(ui, label("settings.audioDsp.Comb_filter"), &mut config.dereverb_level, 0..=100, "%"); ui.collapsing(label("settings.audioDsp.Processing_order"), |ui| { let mut swap = None; for (i, stage) in config.processing_order.iter().enumerate() { let name = match stage { + AudioDspStage::AcousticEchoCancellation => label("settings.audioDsp.aecStage"), AudioDspStage::NoiseReduction => label("settings.audioDsp.noiseStage"), AudioDspStage::Dereverb => label("settings.audioDsp.combStage"), AudioDspStage::Equalizer => label("settings.audioDsp.Equalizer"), @@ -80,6 +83,7 @@ pub fn settings(ui: &mut egui::Ui, config: &mut AudioDspConfig, lang: Lang, enab ui.push_id(i, |ui| ui.horizontal(|ui| { ui.monospace(format!("{}", i + 1)); let active = match stage { + AudioDspStage::AcousticEchoCancellation => config.aec_mode != AecMode::Off, AudioDspStage::NoiseReduction => config.noise_reduction != NoiseReduction::Off && config.noise_strength > 0, AudioDspStage::Dereverb => config.dereverb_level > 0, AudioDspStage::Equalizer => config.equalizer_enabled, diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index 5b99a8a75..479b8ebb3 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -626,6 +626,28 @@ fn general(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec = vec![ + tr_l10n(lang, "settings.audioDsp.aecReferenceAuto"), + tr_l10n(lang, "settings.audioDsp.aecReferenceOff"), + ]; + options.extend(vm.settings.aec_reference_devices.iter().map(String::as_str)); + combo_index_row( + ui, + tr_l10n(lang, "settings.audioDsp.aecReferenceLabel"), + tr_l10n(lang, "settings.audioDsp.aecReferenceDesc"), + vm.settings.aec_reference_index.min(options.len().saturating_sub(1)), + &options, + |index| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::AecReference, + index, + )); + }, + ); + } // #4:录音胶囊开关与样式(Tauri `capsuleLabel` / `capsuleStyleLabel`)。 toggle_row( ui, @@ -3059,6 +3081,34 @@ fn advanced(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec/logs/level`, /// 重启后生效;不是 Core 偏好,所以不上云、不影响 Tauri。 LogLevel, @@ -1194,6 +1200,14 @@ pub struct SettingsFields { #[serde(default)] pub llm_thinking_enabled: bool, pub record_audio_for_debug: bool, + /// 调试录音:保留「处理后」的音频(主档案 `.wav`)。 + pub debug_archive_processed: bool, + /// 调试录音:额外保留「处理前」的 `.raw.wav`,用于 A/B 对照。 + pub debug_archive_raw: bool, + /// AEC 的扬声器参考源选项:0 = 自动,1 = 不采集,其余为回环设备名。 + pub aec_reference_devices: Vec, + /// 当前选择的下标(见 `aec_reference_devices`)。 + pub aec_reference_index: usize, /// 日志级别:0 = info,1 = debug,2 = trace。宿主本地设置,不进 Core 偏好。 pub log_level: usize, pub history_max_entries: String, @@ -1256,6 +1270,10 @@ impl Default for SettingsFields { capsule_transcript_font_size: 14, llm_thinking_enabled: false, record_audio_for_debug: false, + debug_archive_processed: false, + debug_archive_raw: false, + aec_reference_devices: Vec::new(), + aec_reference_index: 0, log_level: 0, history_max_entries: String::new(), style_pack_hotkeys: Vec::new(), diff --git a/openless-all/app/scripts/compare-audio-outputs.py b/openless-all/app/scripts/compare-audio-outputs.py new file mode 100644 index 000000000..6eea95b50 --- /dev/null +++ b/openless-all/app/scripts/compare-audio-outputs.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Compare audio_equivalence exports from two builds; exact PCM is the default.""" +import argparse +import array +import hashlib +import json +import math +from pathlib import Path +import sys + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("before", type=Path) +parser.add_argument("after", type=Path) +parser.add_argument("--report", type=Path, required=True) +args = parser.parse_args() +before = {row["file"]: row for row in json.loads((args.before / "results.json").read_text())} +after = {row["file"]: row for row in json.loads((args.after / "results.json").read_text())} +if not before or before.keys() != after.keys(): + raise SystemExit("nonempty matching case sets required") +rows = [] +for name in sorted(before): + raw_a, raw_b = (args.before / name).read_bytes(), (args.after / name).read_bytes() + if not raw_a or len(raw_a) != len(raw_b) or len(raw_a) % 2: + raise SystemExit(f"invalid or different PCM duration: {name}") + a, b = array.array("h"), array.array("h") + a.frombytes(raw_a) + b.frombytes(raw_b) + if sys.byteorder != "little": + a.byteswap() + b.byteswap() + differences = [int(x) - int(y) for x, y in zip(a, b)] + error = sum(d * d for d in differences) + power = sum(int(x) * int(x) for x in a) + rows.append({"file": name, "samples": len(a), "identical": raw_a == raw_b, + "different_samples": sum(d != 0 for d in differences), + "max_error_lsb": max(map(abs, differences)), + "rms_error_lsb": math.sqrt(error / len(a)), + "snr_db": 10 * math.log10(power / error) if power and error else None, + "before_sha256": hashlib.sha256(raw_a).hexdigest(), + "after_sha256": hashlib.sha256(raw_b).hexdigest(), + "before_seconds": before[name]["seconds"], + "after_seconds": after[name]["seconds"]}) +report = {"cases": len(rows), "identical_cases": sum(r["identical"] for r in rows), + "max_error_lsb": max(r["max_error_lsb"] for r in rows), "results": rows} +args.report.write_text(json.dumps(report, indent=2) + "\n") +print(json.dumps({k: v for k, v in report.items() if k != "results"})) +raise SystemExit(0 if report["identical_cases"] == report["cases"] else 1) diff --git a/openless-all/app/scripts/fixtures/audio-equivalence-2026-10-24.json b/openless-all/app/scripts/fixtures/audio-equivalence-2026-10-24.json new file mode 100644 index 000000000..18743747e --- /dev/null +++ b/openless-all/app/scripts/fixtures/audio-equivalence-2026-10-24.json @@ -0,0 +1,635 @@ +{ + "cases": 48, + "identical_cases": 48, + "max_error_lsb": 0, + "results": [ + { + "file": "off-16000-1-false.pcm", + "samples": 16037, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "e11f9e2e59fb5a868ab9a22fbf5d74adfc47f22aade92cdad6c89d19a3d19919", + "after_sha256": "e11f9e2e59fb5a868ab9a22fbf5d74adfc47f22aade92cdad6c89d19a3d19919", + "before_seconds": 0.116754236, + "after_seconds": 0.005358786 + }, + { + "file": "off-16000-1-true.pcm", + "samples": 16037, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "933e8f5a23b37571a1cc3facb349aad0e307493f57b1026e19f6b5f6dc48e48e", + "after_sha256": "933e8f5a23b37571a1cc3facb349aad0e307493f57b1026e19f6b5f6dc48e48e", + "before_seconds": 0.086573904, + "after_seconds": 0.007005361 + }, + { + "file": "off-16000-2-false.pcm", + "samples": 16037, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "ee16525ad1f9a00fb458058a140c9008563fb2f24d96bdc64e0d4cda82617e9c", + "after_sha256": "ee16525ad1f9a00fb458058a140c9008563fb2f24d96bdc64e0d4cda82617e9c", + "before_seconds": 0.07387148, + "after_seconds": 0.005151878 + }, + { + "file": "off-16000-2-true.pcm", + "samples": 16037, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "b7e12695689c18329aaabee7e7775f183729ea107ccb2fabbde685d22e6549b6", + "after_sha256": "b7e12695689c18329aaabee7e7775f183729ea107ccb2fabbde685d22e6549b6", + "before_seconds": 0.075765705, + "after_seconds": 0.006915069 + }, + { + "file": "off-44100-1-false.pcm", + "samples": 16014, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "e6efeaadc65c0a5ce9273ca3bbbd1de2a8d8a8012ef2209a9a729eb5ef4c6aa7", + "after_sha256": "e6efeaadc65c0a5ce9273ca3bbbd1de2a8d8a8012ef2209a9a729eb5ef4c6aa7", + "before_seconds": 0.077063813, + "after_seconds": 0.005544203 + }, + { + "file": "off-44100-1-true.pcm", + "samples": 16014, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "8b2bc6fe8230137b73b18ac65d021b521c8bda8560260dcd8a62437eb26841a0", + "after_sha256": "8b2bc6fe8230137b73b18ac65d021b521c8bda8560260dcd8a62437eb26841a0", + "before_seconds": 0.078247096, + "after_seconds": 0.007225978 + }, + { + "file": "off-44100-2-false.pcm", + "samples": 16014, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "5daffa30fba17c9c10ecc9c84114fe228972960828c3e9d624b7ff329bf0f812", + "after_sha256": "5daffa30fba17c9c10ecc9c84114fe228972960828c3e9d624b7ff329bf0f812", + "before_seconds": 0.075811189, + "after_seconds": 0.005497613 + }, + { + "file": "off-44100-2-true.pcm", + "samples": 16014, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "271671a84913ac8e3a6037a3b87ed102dbd6ca156c7c237e7ceade030c459636", + "after_sha256": "271671a84913ac8e3a6037a3b87ed102dbd6ca156c7c237e7ceade030c459636", + "before_seconds": 0.077602554, + "after_seconds": 0.007278443 + }, + { + "file": "off-48000-1-false.pcm", + "samples": 16013, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "0a844c08b6ee7276ec91da9ff9cdc3f3c84be8023179611ee49bd08c3fdd7167", + "after_sha256": "0a844c08b6ee7276ec91da9ff9cdc3f3c84be8023179611ee49bd08c3fdd7167", + "before_seconds": 0.073624321, + "after_seconds": 0.00515933 + }, + { + "file": "off-48000-1-true.pcm", + "samples": 16013, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "23c03deecbfa201441d73bf4f3fab6bf1734aa4e35ea56b75794b0bf91d2faed", + "after_sha256": "23c03deecbfa201441d73bf4f3fab6bf1734aa4e35ea56b75794b0bf91d2faed", + "before_seconds": 0.074634896, + "after_seconds": 0.006924537 + }, + { + "file": "off-48000-2-false.pcm", + "samples": 16013, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "497b5e23fd6b5dec50b1917f69c8d904e8ca89308e716d2f4c74392162146f0d", + "after_sha256": "497b5e23fd6b5dec50b1917f69c8d904e8ca89308e716d2f4c74392162146f0d", + "before_seconds": 0.072974345, + "after_seconds": 0.005360569 + }, + { + "file": "off-48000-2-true.pcm", + "samples": 16013, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "4638ce855ba7f2ed394be976efdf2b89e430c6b0da497baa07dc91e140e57e7d", + "after_sha256": "4638ce855ba7f2ed394be976efdf2b89e430c6b0da497baa07dc91e140e57e7d", + "before_seconds": 0.074942485, + "after_seconds": 0.006904952 + }, + { + "file": "purevox-16000-1-false.pcm", + "samples": 16037, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "9c0341d43644e43cfd6080afbcbb46fc2eb49f84ef070c544e46c5de39505044", + "after_sha256": "9c0341d43644e43cfd6080afbcbb46fc2eb49f84ef070c544e46c5de39505044", + "before_seconds": 0.185454531, + "after_seconds": 0.159913137 + }, + { + "file": "purevox-16000-1-true.pcm", + "samples": 16037, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "6525c13175d121b01200a1b6a1c098dad7190cb6ae2c3123ca63a0da674f301f", + "after_sha256": "6525c13175d121b01200a1b6a1c098dad7190cb6ae2c3123ca63a0da674f301f", + "before_seconds": 0.181579766, + "after_seconds": 0.159810681 + }, + { + "file": "purevox-16000-2-false.pcm", + "samples": 16037, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "812bcdd8544a26c8531599f37b6103ad114819e9bdf4bfa3305fe2784c6efa7c", + "after_sha256": "812bcdd8544a26c8531599f37b6103ad114819e9bdf4bfa3305fe2784c6efa7c", + "before_seconds": 0.196011856, + "after_seconds": 0.159011761 + }, + { + "file": "purevox-16000-2-true.pcm", + "samples": 16037, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "3ffc5a0ca033af72680a72eb99a8c3fc97ca97ae99a8be6e7b22b7057e632960", + "after_sha256": "3ffc5a0ca033af72680a72eb99a8c3fc97ca97ae99a8be6e7b22b7057e632960", + "before_seconds": 0.186484676, + "after_seconds": 0.161438696 + }, + { + "file": "purevox-44100-1-false.pcm", + "samples": 16014, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "06c38614f964c17a034c20084951107954dd13f694bfb1b907321c9295ece6ee", + "after_sha256": "06c38614f964c17a034c20084951107954dd13f694bfb1b907321c9295ece6ee", + "before_seconds": 0.184356045, + "after_seconds": 0.158482353 + }, + { + "file": "purevox-44100-1-true.pcm", + "samples": 16014, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "1b06992420881943e8ae5bf912a98ff5cde2a6739a78b9f4cd324ae2d757f311", + "after_sha256": "1b06992420881943e8ae5bf912a98ff5cde2a6739a78b9f4cd324ae2d757f311", + "before_seconds": 0.183797703, + "after_seconds": 0.161311846 + }, + { + "file": "purevox-44100-2-false.pcm", + "samples": 16014, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "889a0cfd345f69bf3128aa3a0cee81991f671e951a04e2f5ebc19318d7c255e3", + "after_sha256": "889a0cfd345f69bf3128aa3a0cee81991f671e951a04e2f5ebc19318d7c255e3", + "before_seconds": 0.184001547, + "after_seconds": 0.159463295 + }, + { + "file": "purevox-44100-2-true.pcm", + "samples": 16014, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "a2a1613f24bde1dd9d0e0a8114f1954a31e59db429cf1a6e5f48f593d812818d", + "after_sha256": "a2a1613f24bde1dd9d0e0a8114f1954a31e59db429cf1a6e5f48f593d812818d", + "before_seconds": 0.187264544, + "after_seconds": 0.158695499 + }, + { + "file": "purevox-48000-1-false.pcm", + "samples": 16013, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "6f824bdcd15d83886f8ee7c174030608e9ad3cb408676f4af140e263374d1a0c", + "after_sha256": "6f824bdcd15d83886f8ee7c174030608e9ad3cb408676f4af140e263374d1a0c", + "before_seconds": 0.179845299, + "after_seconds": 0.15897143 + }, + { + "file": "purevox-48000-1-true.pcm", + "samples": 16013, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "978083461995c4b03f050f1660c2f85af5be94d40eeeee81a309a70da35367ac", + "after_sha256": "978083461995c4b03f050f1660c2f85af5be94d40eeeee81a309a70da35367ac", + "before_seconds": 0.186480047, + "after_seconds": 0.156610284 + }, + { + "file": "purevox-48000-2-false.pcm", + "samples": 16013, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "438512f7739fcb258ff0b0bbd179b811ce4996106bb6651834821cea9cb0fe1b", + "after_sha256": "438512f7739fcb258ff0b0bbd179b811ce4996106bb6651834821cea9cb0fe1b", + "before_seconds": 0.185758329, + "after_seconds": 0.155331091 + }, + { + "file": "purevox-48000-2-true.pcm", + "samples": 16013, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "8bdbc81e9dc86b0b493996be66239d86d299fbc7347d253f7080977c52498ebd", + "after_sha256": "8bdbc81e9dc86b0b493996be66239d86d299fbc7347d253f7080977c52498ebd", + "before_seconds": 0.184863034, + "after_seconds": 0.158239955 + }, + { + "file": "rnnoise-16000-1-false.pcm", + "samples": 16037, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "b8ef881c4e9ec584b79531e3df91d28e8cd276573e8053e0afc5044868990df6", + "after_sha256": "b8ef881c4e9ec584b79531e3df91d28e8cd276573e8053e0afc5044868990df6", + "before_seconds": 0.08139987, + "after_seconds": 0.011418394 + }, + { + "file": "rnnoise-16000-1-true.pcm", + "samples": 16037, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "02dcab55e0551531fb50d77b451d4593faa8bf83025a3cd1cdfc1c24afa3d245", + "after_sha256": "02dcab55e0551531fb50d77b451d4593faa8bf83025a3cd1cdfc1c24afa3d245", + "before_seconds": 0.081542452, + "after_seconds": 0.013149335 + }, + { + "file": "rnnoise-16000-2-false.pcm", + "samples": 16037, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "81dc85cc66c69d9905db67626449ff5a803e9669734da610e9ab055a45f94e31", + "after_sha256": "81dc85cc66c69d9905db67626449ff5a803e9669734da610e9ab055a45f94e31", + "before_seconds": 0.080572818, + "after_seconds": 0.011260605 + }, + { + "file": "rnnoise-16000-2-true.pcm", + "samples": 16037, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "301b0fa3828238acf28e3f99a330f4eff5784e15dc2af4741100ad2be1efa1e6", + "after_sha256": "301b0fa3828238acf28e3f99a330f4eff5784e15dc2af4741100ad2be1efa1e6", + "before_seconds": 0.083529118, + "after_seconds": 0.013055125 + }, + { + "file": "rnnoise-44100-1-false.pcm", + "samples": 16014, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "007bb3e0089d91fa6a8c8c72c2352e290648aa3f99f850c296755906cf25c94b", + "after_sha256": "007bb3e0089d91fa6a8c8c72c2352e290648aa3f99f850c296755906cf25c94b", + "before_seconds": 0.082900206, + "after_seconds": 0.011355191 + }, + { + "file": "rnnoise-44100-1-true.pcm", + "samples": 16014, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "2330fe8baf4efd698802249dfaab992baa8ceae6d116c9e03c7d156be201aac0", + "after_sha256": "2330fe8baf4efd698802249dfaab992baa8ceae6d116c9e03c7d156be201aac0", + "before_seconds": 0.08490873, + "after_seconds": 0.013156918 + }, + { + "file": "rnnoise-44100-2-false.pcm", + "samples": 16014, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "c8f936ab76cffcdf588509aa3ec02fd74361ca093701a36a4cc7efb8f93069e8", + "after_sha256": "c8f936ab76cffcdf588509aa3ec02fd74361ca093701a36a4cc7efb8f93069e8", + "before_seconds": 0.082212834, + "after_seconds": 0.011510045 + }, + { + "file": "rnnoise-44100-2-true.pcm", + "samples": 16014, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "5044542f4def4e3c50488fcf5b8581bd9e11d99fa8d7a16a6ae73095a665d538", + "after_sha256": "5044542f4def4e3c50488fcf5b8581bd9e11d99fa8d7a16a6ae73095a665d538", + "before_seconds": 0.083604619, + "after_seconds": 0.013184362 + }, + { + "file": "rnnoise-48000-1-false.pcm", + "samples": 16013, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "2de2be3daf47ce414848687a74dcf0828ff5d37a387f4c713907ae1c4a5986d6", + "after_sha256": "2de2be3daf47ce414848687a74dcf0828ff5d37a387f4c713907ae1c4a5986d6", + "before_seconds": 0.079465597, + "after_seconds": 0.011077441 + }, + { + "file": "rnnoise-48000-1-true.pcm", + "samples": 16013, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "87bb872eee2fb5e79808131ef64799931ff15476f8661557d72e80438c946660", + "after_sha256": "87bb872eee2fb5e79808131ef64799931ff15476f8661557d72e80438c946660", + "before_seconds": 0.080882969, + "after_seconds": 0.012818846 + }, + { + "file": "rnnoise-48000-2-false.pcm", + "samples": 16013, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "ef43447a58302e67ec75ab8b51b659d4330a7110509769ac851481c8e37f502b", + "after_sha256": "ef43447a58302e67ec75ab8b51b659d4330a7110509769ac851481c8e37f502b", + "before_seconds": 0.08029677, + "after_seconds": 0.01115677 + }, + { + "file": "rnnoise-48000-2-true.pcm", + "samples": 16013, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "dad5f57a9fb1658502b154707aa9e4ac7e090a5f9de39f3c3d44ccfd888e3774", + "after_sha256": "dad5f57a9fb1658502b154707aa9e4ac7e090a5f9de39f3c3d44ccfd888e3774", + "before_seconds": 0.082389417, + "after_seconds": 0.012953246 + }, + { + "file": "spectral-16000-1-false.pcm", + "samples": 16037, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "6a59e58a8dd0620b06265ae20d36fd7d602c3fc28791b3b4ab18d5d19ea64bdb", + "after_sha256": "6a59e58a8dd0620b06265ae20d36fd7d602c3fc28791b3b4ab18d5d19ea64bdb", + "before_seconds": 0.073881205, + "after_seconds": 0.006052521 + }, + { + "file": "spectral-16000-1-true.pcm", + "samples": 16037, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "a40ab5a854c09ec5f2cc8625cf674a6033f40aa3cfcfbb63a948d67d333ff1b1", + "after_sha256": "a40ab5a854c09ec5f2cc8625cf674a6033f40aa3cfcfbb63a948d67d333ff1b1", + "before_seconds": 0.076846195, + "after_seconds": 0.007796042 + }, + { + "file": "spectral-16000-2-false.pcm", + "samples": 16037, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "d5abc138be586b562c9ce6717f29ee5388399d68f7242e2eea927f74e7118dba", + "after_sha256": "d5abc138be586b562c9ce6717f29ee5388399d68f7242e2eea927f74e7118dba", + "before_seconds": 0.075972349, + "after_seconds": 0.006051525 + }, + { + "file": "spectral-16000-2-true.pcm", + "samples": 16037, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "82ff018705bbb851226213ef58d2da8767d6a68b748f440e6a4f50428aed93a3", + "after_sha256": "82ff018705bbb851226213ef58d2da8767d6a68b748f440e6a4f50428aed93a3", + "before_seconds": 0.076897096, + "after_seconds": 0.007781968 + }, + { + "file": "spectral-44100-1-false.pcm", + "samples": 16014, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "a01b6eaa9641f3e450ec3725127ace6106c8a3e3af447b7fdb5019a8e8131cce", + "after_sha256": "a01b6eaa9641f3e450ec3725127ace6106c8a3e3af447b7fdb5019a8e8131cce", + "before_seconds": 0.076590793, + "after_seconds": 0.006390747 + }, + { + "file": "spectral-44100-1-true.pcm", + "samples": 16014, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "e09f3c92c03fd4813d5439621b5ccbdb9dd9a5eefae620b3760b2e31e75a20e6", + "after_sha256": "e09f3c92c03fd4813d5439621b5ccbdb9dd9a5eefae620b3760b2e31e75a20e6", + "before_seconds": 0.078474036, + "after_seconds": 0.008173782 + }, + { + "file": "spectral-44100-2-false.pcm", + "samples": 16014, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "a5592134a4b01c4538d8c41ec4cb625cfcaab70e36a0f7a4861bc57304d5b19f", + "after_sha256": "a5592134a4b01c4538d8c41ec4cb625cfcaab70e36a0f7a4861bc57304d5b19f", + "before_seconds": 0.076863817, + "after_seconds": 0.006393915 + }, + { + "file": "spectral-44100-2-true.pcm", + "samples": 16014, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "77c84798a964dc8d2884530a0bf69b4036c3ebcf0cca9f61d151809ded9f6338", + "after_sha256": "77c84798a964dc8d2884530a0bf69b4036c3ebcf0cca9f61d151809ded9f6338", + "before_seconds": 0.078872631, + "after_seconds": 0.008147194 + }, + { + "file": "spectral-48000-1-false.pcm", + "samples": 16013, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "3e40836ca79ea2ef9ee7e12016071fcdc4d0b3e995979062ba3d99f39e9a1a59", + "after_sha256": "3e40836ca79ea2ef9ee7e12016071fcdc4d0b3e995979062ba3d99f39e9a1a59", + "before_seconds": 0.073998635, + "after_seconds": 0.006071965 + }, + { + "file": "spectral-48000-1-true.pcm", + "samples": 16013, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "1f0b6258dfd5bdfb99cec2dc702378cc1cc71add5800e06d31058f18a925fcd8", + "after_sha256": "1f0b6258dfd5bdfb99cec2dc702378cc1cc71add5800e06d31058f18a925fcd8", + "before_seconds": 0.075704484, + "after_seconds": 0.007758046 + }, + { + "file": "spectral-48000-2-false.pcm", + "samples": 16013, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "cdbe91ced337c6e5264a3b2129dfcf642314ad3419a3e2703d317056a9a5e617", + "after_sha256": "cdbe91ced337c6e5264a3b2129dfcf642314ad3419a3e2703d317056a9a5e617", + "before_seconds": 0.073898392, + "after_seconds": 0.005995752 + }, + { + "file": "spectral-48000-2-true.pcm", + "samples": 16013, + "identical": true, + "different_samples": 0, + "max_error_lsb": 0, + "rms_error_lsb": 0.0, + "snr_db": null, + "before_sha256": "c99581bdbd1bd17c340fb0a536d5cc1fea8fd160529a5e9dcf216d9a38ed076d", + "after_sha256": "c99581bdbd1bd17c340fb0a536d5cc1fea8fd160529a5e9dcf216d9a38ed076d", + "before_seconds": 0.075725974, + "after_seconds": 0.008444904 + } + ], + "baseline_revision": "f0744e10", + "optimized_revision": "e690e5b3 plus external PCM scratch reuse", + "platform": "Linux x86_64, release, synthetic fixtures", + "limits": "No listening study or on-device validation. Timings are host-load dependent. Apple PCM comparison pending Apple SDK execution." +} diff --git a/openless-all/app/scripts/micyou-dsp.patch b/openless-all/app/scripts/micyou-dsp.patch index 12d2d8819..fdf3d5d0a 100644 --- a/openless-all/app/scripts/micyou-dsp.patch +++ b/openless-all/app/scripts/micyou-dsp.patch @@ -47,7 +47,38 @@ pub struct AudioDspSettings { pub gain: f32, // dB, -50 to +50 pub ns_enabled: bool, -@@ -874,76 +883,13 @@ +@@ -69,10 +78,6 @@ + pub vad_enabled: bool, + pub vad_threshold: f32, // dB, -100..0 + pub aec_enabled: bool, +- +- /// Output playback ring buffer headroom in milliseconds (100..1200). +- #[serde(default = "default_output_buffer_ms")] +- pub output_buffer_ms: u32, + + #[serde(default)] + pub processing_chain: Vec, +@@ -96,7 +101,6 @@ + vad_enabled: false, + vad_threshold: -40.0, + aec_enabled: false, +- output_buffer_ms: 300, + processing_chain: vec![ + "AEC".to_string(), + "NoiseReduction".to_string(), +@@ -117,11 +121,6 @@ + self.ns_type = Self::default().ns_type; + } + } +-} +- +-/// Default output playback buffer headroom in milliseconds. +-fn default_output_buffer_ms() -> u32 { +- 300 + } + + // ─── Shared STFT window helpers ─────────────────────────────────────────── +@@ -874,76 +873,13 @@ } } @@ -126,7 +157,7 @@ // RNNoise states - separate per channel to avoid RNN state cross-contamination #[cfg(feature = "dsp")] denoiser_left: Box>, -@@ -983,9 +929,6 @@ +@@ -983,9 +919,6 @@ speex_ns: SpeexStyleNS, // Equalizer equalizer: EqualizerEffect, @@ -136,7 +167,68 @@ // Dereverb state dereverb_buffer_left: Vec, dereverb_buffer_right: Vec, -@@ -1042,6 +985,8 @@ +@@ -996,6 +929,9 @@ + agc_smoothed_gain: f32, + // VAD fade state (0.0 = muted, 1.0 = full) + vad_fade: f32, ++ #[cfg(feature = "noise-suppression")] ++ spectrum_analyzer: Option, ++ spectrum_tick: usize, + // Spectrum snapshots + raw_spectrum: Vec, + processed_spectrum: Vec, +@@ -1038,10 +974,59 @@ + + const RNNOISE_FRAME_SIZE: usize = 480; + ++#[cfg(feature = "noise-suppression")] ++struct SpectrumAnalyzer { ++ fft: Arc>, ++ buffer: Vec>, ++ scratch: Vec>, ++ window: Vec, ++} ++ ++#[cfg(feature = "noise-suppression")] ++impl SpectrumAnalyzer { ++ fn new() -> Self { ++ let fft = rustfft::FftPlanner::new().plan_fft_forward(512); ++ let scratch = vec![Complex::default(); fft.get_inplace_scratch_len()]; ++ Self { ++ fft, ++ scratch, ++ buffer: vec![Complex::default(); 512], ++ window: (0..512) ++ .map(|i| 0.5 - 0.5 * (std::f32::consts::TAU * i as f32 / 511.0).cos()) ++ .collect(), ++ } ++ } ++ ++ fn analyze(&mut self, data: &[f32], target: &mut [f32]) { ++ self.buffer.fill(Complex::default()); ++ let count = data.len().min(512); ++ for (i, sample) in data[data.len() - count..].iter().enumerate() { ++ self.buffer[i].re = sample * self.window[i]; ++ } ++ self.fft ++ .process_with_scratch(&mut self.buffer, &mut self.scratch); ++ for (band, value) in target.iter_mut().enumerate() { ++ let low = 20.0 * 1200.0_f32.powf(band as f32 / 64.0); ++ let high = 20.0 * 1200.0_f32.powf((band + 1) as f32 / 64.0); ++ let start = ((low * 512.0 / 48_000.0) as usize).min(256); ++ let end = ((high * 512.0 / 48_000.0).ceil() as usize).clamp(start + 1, 257); ++ let magnitude = self.buffer[start..end] ++ .iter() ++ .map(|bin| bin.norm()) ++ .fold(0.0_f32, f32::max) ++ / 128.0; ++ let normalized = ((20.0 * magnitude.max(1e-8).log10() + 60.0) / 60.0).clamp(0.0, 1.0); ++ *value = normalized.max(*value * 0.8); ++ } ++ } ++} ++ + impl DspProcessor { pub fn new(settings: Arc>, _model_dir: Option) -> Self { Self { settings: settings.clone(), @@ -145,7 +237,7 @@ #[cfg(feature = "dsp")] denoiser_left: DenoiseState::new(), #[cfg(feature = "dsp")] -@@ -1069,7 +1014,6 @@ +@@ -1069,13 +1054,15 @@ #[cfg(feature = "dsp")] speex_ns: SpeexStyleNS::new(), equalizer: EqualizerEffect::new(), @@ -153,7 +245,24 @@ dereverb_buffer_left: vec![0.0; 480], dereverb_buffer_right: vec![0.0; 480], dereverb_index: 0, -@@ -1176,6 +1120,14 @@ + agc_envelope: 0.0, + agc_smoothed_gain: 1.0, + vad_fade: 1.0, ++ #[cfg(feature = "noise-suppression")] ++ spectrum_analyzer: None, ++ spectrum_tick: 0, + raw_spectrum: vec![0.0; 64], + processed_spectrum: vec![0.0; 64], + accum_buffer: Vec::new(), +@@ -1101,6 +1088,7 @@ + } + + let raw_rms = compute_rms(data); ++ self.spectrum_tick = (self.spectrum_tick + 1) % 10; + self.compute_spectrum(data, true); + + // Frame accumulation: align to 480*channels samples before processing, +@@ -1176,6 +1164,14 @@ settings.agc_target, attack_rate, decay_rate, @@ -168,7 +277,7 @@ ); } } -@@ -1186,7 +1138,12 @@ +@@ -1186,7 +1182,12 @@ // Legacy synthetic plugin stage (may be absent): runs // every registered plugin at this chain position. if let Some(hook) = &mut self.external_hook { @@ -182,7 +291,7 @@ } } node if node.starts_with(PLUGIN_NODE_PREFIX) => { -@@ -1200,8 +1157,8 @@ +@@ -1200,8 +1201,8 @@ } } @@ -193,7 +302,7 @@ // Soft clip — avoids harsh hard-clipping artifacts (crackling/pops) for sample in self.output_buffer.iter_mut() { -@@ -1368,14 +1325,45 @@ +@@ -1368,14 +1369,45 @@ channels: usize, settings: &AudioDspSettings, ) { @@ -243,7 +352,7 @@ } } -@@ -1397,18 +1385,19 @@ +@@ -1397,18 +1429,19 @@ } // Process each channel with its own denoiser (no RNN state cross-contamination) @@ -265,7 +374,7 @@ data.clear(); for i in 0..frames { -@@ -1416,7 +1405,7 @@ +@@ -1416,7 +1449,7 @@ data.push(right[i]); } } else { @@ -274,7 +383,7 @@ data, &mut self.ns_buffer_left, &mut self.denoiser_left, -@@ -1431,12 +1420,13 @@ +@@ -1431,12 +1464,13 @@ ns_buffer: &mut Vec, denoiser: &mut DenoiseState<'static>, intensity: f32, @@ -289,7 +398,7 @@ while ns_buffer.len() >= RNNOISE_FRAME_SIZE { let frame: Vec = ns_buffer.drain(..RNNOISE_FRAME_SIZE).collect(); -@@ -1444,7 +1434,8 @@ +@@ -1444,7 +1478,8 @@ let input_frame: Vec = frame.iter().map(|s| s * 32767.0).collect(); let mut output_frame = vec![0.0f32; RNNOISE_FRAME_SIZE]; @@ -299,7 +408,7 @@ for i in 0..RNNOISE_FRAME_SIZE { let clean = output_frame[i] / 32767.0; -@@ -1463,6 +1454,7 @@ +@@ -1463,6 +1498,7 @@ } data.copy_from_slice(&output); @@ -307,7 +416,7 @@ } #[cfg(feature = "noise-suppression")] -@@ -1618,7 +1610,14 @@ +@@ -1618,7 +1654,14 @@ // ── AGC ───────────────────────────────────────────────────────────────── @@ -323,7 +432,7 @@ let target_linear = target / 32767.0; // Noise gate threshold: below this level, don't apply AGC gain // (prevents amplifying hiss/noise floor during speech pauses) -@@ -1633,7 +1632,7 @@ +@@ -1633,7 +1676,7 @@ } if self.agc_envelope > gate_threshold { let desired_gain = target_linear / self.agc_envelope; @@ -332,6 +441,151 @@ // Smooth gain transition to avoid pops (exponential moving average) let smooth_factor = 0.005_f32; self.agc_smoothed_gain += smooth_factor * (clamped_gain - self.agc_smoothed_gain); +@@ -1671,59 +1714,34 @@ + + // ── Spectrum ───────────────────────────────────────────────────────────── + ++ pub fn set_spectrum_enabled(&mut self, enabled: bool) { ++ #[cfg(feature = "noise-suppression")] ++ if enabled && self.spectrum_analyzer.is_none() { ++ self.spectrum_analyzer = Some(SpectrumAnalyzer::new()); ++ } else if !enabled { ++ self.spectrum_analyzer = None; ++ self.raw_spectrum.fill(0.0); ++ self.processed_spectrum.fill(0.0); ++ } ++ #[cfg(not(feature = "noise-suppression"))] ++ let _ = enabled; ++ } ++ + fn compute_spectrum(&mut self, data: &[f32], is_raw: bool) { +- let bands = 64; +- let target = if is_raw { +- &mut self.raw_spectrum +- } else { +- &mut self.processed_spectrum +- }; +- if target.len() != bands { +- target.resize(bands, 0.0); +- } +- if data.is_empty() { +- for v in target.iter_mut() { +- *v = 0.0; +- } ++ if self.spectrum_tick != 1 { + return; + } +- +- const BANDS: usize = 64; +- static BAND_LIMITS: std::sync::OnceLock<[f32; BANDS + 1]> = std::sync::OnceLock::new(); +- let limits = BAND_LIMITS.get_or_init(|| { +- let mut array = [0.0; BANDS + 1]; +- for (i, limit) in array.iter_mut().enumerate() { +- *limit = (i as f32 / BANDS as f32).powf(1.5); +- } +- array +- }); +- +- for (band_idx, band_val) in target.iter_mut().enumerate() { +- let start = limits[band_idx] * data.len() as f32; +- let end = limits[band_idx + 1] * data.len() as f32; +- let start = start as usize; +- let end = (end as usize).min(data.len()); +- if start >= end { +- *band_val *= 0.85; +- continue; +- } +- let mut sum = 0.0_f32; +- for sample in &data[start..end] { +- sum += sample * sample; +- } +- let rms = (sum / (end - start) as f32).sqrt(); +- let db = if rms > 1e-10 { +- 20.0 * rms.log10() ++ #[cfg(feature = "noise-suppression")] ++ if let Some(analyzer) = &mut self.spectrum_analyzer { ++ let target = if is_raw { ++ &mut self.raw_spectrum + } else { +- -100.0 ++ &mut self.processed_spectrum + }; +- let normalized = ((db + 60.0) / 60.0).clamp(0.0, 1.0); +- if normalized > *band_val { +- *band_val = normalized; +- } else { +- *band_val = *band_val * 0.85 + normalized * 0.15; +- } +- } ++ analyzer.analyze(data, target); ++ } ++ #[cfg(not(feature = "noise-suppression"))] ++ let _ = (data, is_raw); + } + } + +@@ -2096,11 +2114,13 @@ + #[test] + fn dsp_processor_chain_smoke() { + // Full chain: AEC → PureVox NS through the public DspProcessor API. +- let mut settings = AudioDspSettings::default(); +- settings.ns_enabled = true; +- settings.ns_type = "PureVox".to_string(); +- settings.ns_intensity = 100.0; +- settings.aec_enabled = true; ++ let settings = AudioDspSettings { ++ ns_enabled: true, ++ ns_type: "PureVox".to_string(), ++ ns_intensity: 100.0, ++ aec_enabled: true, ++ ..Default::default() ++ }; + let settings = Arc::new(RwLock::new(settings)); + let mut dsp = DspProcessor::new(settings, None); + +@@ -2127,3 +2147,42 @@ + ); + } + } ++ ++#[cfg(all(test, feature = "noise-suppression"))] ++mod spectrum_tests { ++ use super::*; ++ ++ #[test] ++ fn spectrum_is_opt_in_and_tracks_frequency_without_changing_audio() { ++ let settings = Arc::new(RwLock::new(AudioDspSettings { ++ gain: 6.0, ++ ..Default::default() ++ })); ++ let mut plain = DspProcessor::new(Arc::clone(&settings), None); ++ let mut monitored = DspProcessor::new(settings, None); ++ assert!(plain.spectrum_analyzer.is_none()); ++ monitored.set_spectrum_enabled(true); ++ let tone: Vec = (0..480) ++ .map(|i| (std::f32::consts::TAU * 1000.0 * i as f32 / 48_000.0).sin() * 0.05) ++ .collect(); ++ for _ in 0..12 { ++ let mut a = tone.clone(); ++ let mut b = tone.clone(); ++ plain.process(&mut a, 1, 0.0); ++ monitored.process(&mut b, 1, 0.0); ++ assert_eq!(a, b); ++ } ++ let (raw, processed) = monitored.get_spectrums(); ++ let peak = raw ++ .iter() ++ .enumerate() ++ .max_by(|a, b| a.1.total_cmp(b.1)) ++ .unwrap() ++ .0; ++ assert!((30..=40).contains(&peak), "1 kHz peak at {peak}"); ++ assert!(processed[peak] > raw[peak]); ++ monitored.set_spectrum_enabled(false); ++ assert!(monitored.spectrum_analyzer.is_none()); ++ assert!(monitored.get_spectrums().0.iter().all(|v| *v == 0.0)); ++ } ++} --- a/crates/micyou-infer/src/lib.rs +++ b/crates/micyou-infer/src/lib.rs @@ -48,6 +48,19 @@ @@ -354,3 +608,123 @@ use std::fmt; mod ops; +--- a/crates/micyou-infer/src/ops.rs ++++ b/crates/micyou-infer/src/ops.rs +@@ -396,9 +396,7 @@ + for kk in 0..k { + let av = arow[kk]; + let brow = &bbase[kk * n..(kk + 1) * n]; +- for j in 0..n { +- orow[j] += av * brow[j]; +- } ++ axpy(orow, brow, av); + } + } + if nb > 0 { +@@ -501,9 +499,7 @@ + for ci in 0..cpg { + let wv = wg[mi * cpg + ci]; + let xrow = &xg[ci * hw_in..(ci + 1) * hw_in]; +- for p in 0..hw_out { +- orow[p] += wv * xrow[p]; +- } ++ axpy(orow, &xrow[..hw_out], wv); + } + } + } +@@ -753,9 +749,7 @@ + for i in 0..batch { + let xv = xb[i * in_dim + kk]; + let prow = &mut pb[i * h3..(i + 1) * h3]; +- for j in 0..h3 { +- prow[j] += xv * wrow[j]; +- } ++ axpy(prow, wrow, xv); + } + } + } +@@ -784,9 +778,7 @@ + for i in 0..batch { + let sv = state_s[i * h + kk]; + let orow = &mut rproj_s[i * h3..(i + 1) * h3]; +- for j in 0..h3 { +- orow[j] += sv * wrow[j]; +- } ++ axpy(orow, wrow, sv); + } + } + // gates (elementwise passes — vectorize; rounding order matches +@@ -1508,6 +1500,50 @@ + + // ─── AVX2+FMA runtime dispatch ───────────────────────────────────────────── + // ++// Use baseline SIMD on x86-64 and NEON-enabled ARM64 targets. Vectorize rows ++// without changing reduction order or using fused multiply-add: recurrent ++// models retain the scalar rounding contract. No extra model/runtime/thread ++// pool is loaded alongside local ASR. ++#[inline(always)] ++fn axpy(output: &mut [f32], input: &[f32], scale: f32) { ++ assert_eq!(output.len(), input.len()); ++ #[cfg(all(target_arch = "aarch64", target_feature = "neon"))] ++ let offset = unsafe { ++ let mut offset = 0; ++ use std::arch::aarch64::{vaddq_f32, vld1q_f32, vmulq_n_f32, vst1q_f32}; ++ while offset + 4 <= output.len() { ++ let value = vmulq_n_f32(vld1q_f32(input.as_ptr().add(offset)), scale); ++ let sum = vaddq_f32(vld1q_f32(output.as_ptr().add(offset)), value); ++ vst1q_f32(output.as_mut_ptr().add(offset), sum); ++ offset += 4; ++ } ++ offset ++ }; ++ #[cfg(target_arch = "x86_64")] ++ let offset = unsafe { ++ // SSE2 is part of the x86-64 baseline. Unaligned loads/stores support ++ // every tensor row; no AVX CPU requirement or FMA rounding changes. ++ use std::arch::x86_64::{_mm_add_ps, _mm_loadu_ps, _mm_mul_ps, _mm_set1_ps, _mm_storeu_ps}; ++ let factor = _mm_set1_ps(scale); ++ let mut offset = 0; ++ while offset + 4 <= output.len() { ++ let value = _mm_mul_ps(_mm_loadu_ps(input.as_ptr().add(offset)), factor); ++ let sum = _mm_add_ps(_mm_loadu_ps(output.as_ptr().add(offset)), value); ++ _mm_storeu_ps(output.as_mut_ptr().add(offset), sum); ++ offset += 4; ++ } ++ offset ++ }; ++ #[cfg(not(any( ++ target_arch = "x86_64", ++ all(target_arch = "aarch64", target_feature = "neon") ++ )))] ++ let offset = 0; ++ for (dst, src) in output[offset..].iter_mut().zip(&input[offset..]) { ++ *dst += scale * src; ++ } ++} ++ + // The workspace builds for baseline x86-64 (SSE2) so the shipped binaries + // run everywhere; ONNX Runtime's MLAS gets its speed from runtime CPUID + // dispatch to AVX kernels. We mirror that: each hot kernel body is compiled +@@ -1755,3 +1791,22 @@ + ) { + layer_norm_impl(xp, xsh, scale, bias, op_, axis, eps) + } ++ ++#[cfg(test)] ++mod projection_tests { ++ #[test] ++ fn projections_keep_scalar_rounding_and_handle_vector_tails() { ++ for size in [0, 1, 3, 4, 5, 17, 192, 257] { ++ let input: Vec = (0..size).map(|i| (i as f32 * 0.7).sin()).collect(); ++ let mut actual = vec![0.17; size]; ++ let mut expected = actual.clone(); ++ for scale in [0.0, -0.37, 0.81, 1e-12] { ++ for (dst, src) in expected.iter_mut().zip(&input) { ++ *dst += scale * src; ++ } ++ super::axpy(&mut actual, &input, scale); ++ } ++ assert_eq!(actual, expected, "projection width {size}"); ++ } ++ } ++} diff --git a/openless-all/app/scripts/micyou-upstream.json b/openless-all/app/scripts/micyou-upstream.json index 611eefcf8..b170cdfa9 100644 --- a/openless-all/app/scripts/micyou-upstream.json +++ b/openless-all/app/scripts/micyou-upstream.json @@ -1,11 +1,11 @@ { "repository": "MicYou-Dev/MicYou", "revision": "d821da032e2b72330ced09678dd127acd2714bef", - "patchSha256": "7f204189f5ce7bb9f0250093bf6f5823e81e9c97777936d5f9bed54c210dd5ee", + "patchSha256": "5fdacc4530a0a09f8c179bbb956f52b08636c88eeaa1f7226c1ac9cd6a990c72", "files": { "crates/micyou-dsp/src/lib.rs": { "upstreamSha256": "9e3cb90b48c843554d75704188d4490d197f0134f57cf2fbc2f4a2aad1753d24", - "localSha256": "33174dfb93c42d4fd45dd36b721def47393bdce780324e8c58694f66f9585fa4" + "localSha256": "6a366a2e5831f8fb9d68086f57a696f150855e094073e1fd6a8410bf10f2651e" }, "crates/micyou-infer/src/lib.rs": { "upstreamSha256": "a2d412f007ee15148e9a3684db8f59559e1057f62109f6ed5a1ca5fe951f5883", @@ -13,7 +13,7 @@ }, "crates/micyou-infer/src/ops.rs": { "upstreamSha256": "718c52a97d87a18043822f55f876ee03231429d5989361938f7e5b8d6dd459e9", - "localSha256": "718c52a97d87a18043822f55f876ee03231429d5989361938f7e5b8d6dd459e9" + "localSha256": "3963fbf11d5bce52ca042d56e64402d7ff0dfe2631e88eae7dce13fb8523263c" }, "crates/micyou-dsp/src/assets/purevox6.mcy": { "upstreamSha256": "88fe0a1f44b93942770c97c576eab84ff193370b96cb311671900c07ef213147", diff --git a/openless-all/app/scripts/test-compare-audio-outputs.py b/openless-all/app/scripts/test-compare-audio-outputs.py new file mode 100644 index 000000000..3b74c7436 --- /dev/null +++ b/openless-all/app/scripts/test-compare-audio-outputs.py @@ -0,0 +1,35 @@ +"""Negative controls for the before/after PCM comparison gate.""" +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +class ComparisonTests(unittest.TestCase): + def test_exact_difference_and_truncation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + before, after = root / "before", root / "after" + for folder in (before, after): + folder.mkdir() + (folder / "results.json").write_text(json.dumps([ + {"file": "signal.pcm", "seconds": 1, "samples": 3} + ])) + (folder / "signal.pcm").write_bytes(b"\x00\x00\x01\x00\xff\xff") + report = root / "report.json" + command = [sys.executable, str(Path(__file__).with_name("compare-audio-outputs.py")), + str(before), str(after), "--report", str(report)] + def run(): + return subprocess.run(command, capture_output=True, text=True).returncode + self.assertEqual(run(), 0) + (after / "signal.pcm").write_bytes(b"\x01\x00\x01\x00\xff\xff") + self.assertEqual(run(), 1) + self.assertEqual(json.loads(report.read_text())["max_error_lsb"], 1) + (after / "signal.pcm").write_bytes(b"\x00\x00") + self.assertNotEqual(run(), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/openless-all/app/scripts/test-recorder-module.py b/openless-all/app/scripts/test-recorder-module.py index 44db24747..c79a77df2 100644 --- a/openless-all/app/scripts/test-recorder-module.py +++ b/openless-all/app/scripts/test-recorder-module.py @@ -46,9 +46,9 @@ "#[derive(Debug, Clone)] pub struct BackendError { pub code: BackendErrorCode, pub message: String }\n" "impl BackendError { pub fn new(code: BackendErrorCode, message: impl Into) -> Self { Self { code, message: message.into() } } }\n" + "#[path = " + json.dumps(str(recorder.parents[2] / "crates/openless-core/src/audio_dsp.rs"), ensure_ascii=False) + "]\npub mod audio_dsp;\n" - "pub use audio_dsp::{AudioDspConfig, AudioDspStage, NoiseReduction};\n" + "pub use audio_dsp::{AecMode, AudioDspConfig, AudioDspStage, NoiseReduction};\n" + "#[path = " + json.dumps(str(recorder.parents[2] / "crates/openless-core/src/audio.rs"), ensure_ascii=False) + "]\npub mod audio;\n" - "pub use audio::{AudioProcessingConfig, AudioProcessor, ReferenceAudioBuffer};\n" + "pub use audio::{pcm_f32_to_i16_le, AudioProcessingConfig, AudioProcessor, FarEndQueue, ReferenceAudioBuffer};\n" "pub trait AudioConsumer: Send + Sync { fn consume_pcm_chunk(&self, pcm: &[u8]); }\n" + "#[path = " + json.dumps(str(recorder), ensure_ascii=False) + "]\npub mod recorder;\n" + "#[path = " + json.dumps(str(recorder.with_name("native_audio_cue.rs")), ensure_ascii=False) + "]\npub mod native_audio_cue;\n" diff --git a/openless-all/app/src-tauri/src/android/native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs index 268185d36..3754ca609 100644 --- a/openless-all/app/src-tauri/src/android/native_bridge.rs +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -22,6 +22,14 @@ static OVERLAY_VISIBLE: std::sync::atomic::AtomicBool = std::sync::atomic::Atomi #[cfg(target_os = "android")] static APP_HANDLE: OnceLock = OnceLock::new(); +#[cfg(target_os = "android")] +pub(crate) fn emit_audio_spectrum(raw: Vec, processed: Vec) { + use tauri::Emitter; + if let Some(app) = APP_HANDLE.get() { + let _ = app.emit("android-audio-spectrum", serde_json::json!({"raw": raw, "processed": processed})); + } +} + // A panic from a synchronously polled future must not cross an extern JNI // boundary. Return an ordinary error so each caller keeps its wire envelope. fn catch_jni_panic( diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index b3288cfb5..739092098 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -1,5 +1,25 @@ use super::*; +#[tauri::command] +pub fn get_android_audio_effects_status() -> serde_json::Value { + #[cfg(target_os = "android")] + { + crate::recorder::android_capture::status() + } + #[cfg(not(target_os = "android"))] + { + serde_json::json!({"aecAvailable": false, "noiseSuppressionAvailable": false, "agcAvailable": false, "runtime": null}) + } +} + +#[tauri::command] +pub fn set_android_audio_monitoring(enabled: bool) { + #[cfg(target_os = "android")] + crate::recorder::android_capture::set_monitoring(enabled); + #[cfg(not(target_os = "android"))] + let _ = enabled; +} + #[tauri::command] pub fn get_settings(core: CoreState<'_>) -> UserPreferences { let mut prefs = core.get_preferences(); @@ -240,7 +260,10 @@ pub(crate) fn sync_low_latency_capture_preferences(prefs: &UserPreferences) { #[cfg(not(target_os = "windows"))] pub(crate) fn sync_low_latency_capture_preferences(_prefs: &UserPreferences) {} -fn voice_activation_preferences_changed(previous: &UserPreferences, next: &UserPreferences) -> bool { +fn voice_activation_preferences_changed( + previous: &UserPreferences, + next: &UserPreferences, +) -> bool { previous.voice_activation_enabled != next.voice_activation_enabled || previous.voice_activation_wake_enabled != next.voice_activation_wake_enabled || previous.voice_activation_finish_enabled != next.voice_activation_finish_enabled @@ -251,9 +274,7 @@ fn voice_activation_preferences_changed(previous: &UserPreferences, next: &UserP || previous.microphone_device_name != next.microphone_device_name } -fn validate_voice_activation_preferences( - prefs: &UserPreferences, -) -> Result<(), String> { +fn validate_voice_activation_preferences(prefs: &UserPreferences) -> Result<(), String> { openless_core::VoiceActivationConfig { enabled: prefs.voice_activation_enabled, wake_enabled: prefs.voice_activation_wake_enabled, diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index e59e41bb0..4e4b70861 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -2967,13 +2967,13 @@ impl TauriStagedCapture { self, consumer: Arc, level_handler: Arc, - archive_path: Option, + archives: crate::recorder::ArchiveTargets, processing: openless_core::AudioProcessingConfig, ) -> Result { match self { Self::Cold(staged) => { let (recorder, runtime_errors, archive_active) = - staged.commit(consumer, level_handler, archive_path)?; + staged.commit(consumer, level_handler, archives)?; Ok(TauriCommittedCapture::Cold { recorder, runtime_errors, @@ -2986,8 +2986,8 @@ impl TauriStagedCapture { let fallback_hub = staged.capture_hub(); let fallback_consumer = Arc::clone(&consumer); let fallback_level_handler = Arc::clone(&level_handler); - let fallback_archive_path = archive_path.clone(); - match staged.commit(consumer, level_handler, archive_path) { + let fallback_archives = archives.clone(); + match staged.commit(consumer, level_handler, archives) { Ok((capture, runtime_errors, archive_active)) => { Ok(TauriCommittedCapture::Warm { capture, @@ -3025,7 +3025,7 @@ impl TauriStagedCapture { }) .unwrap_or(fallback_consumer), fallback_level_handler, - fallback_archive_path, + fallback_archives, ) { Ok(committed) => committed, Err(error) => { @@ -3104,10 +3104,7 @@ impl PendingAudioCapture for TauriPendingAudioCapture { openless_core::DictationOutputTarget::QuickNote | openless_core::DictationOutputTarget::Undecided ); - let archive_path = context - .recording - .archive_enabled - .then(|| { + let archive_path = context.recording.archive_enabled.then(|| { if permanent_archive { crate::persistence::quick_note_recording_path_for_session( &session_id.to_string(), @@ -3131,6 +3128,15 @@ impl PendingAudioCapture for TauriPendingAudioCapture { }) .ok() .flatten(); + // 调试录音:主档案写处理后(或只勾处理前时写原始),需要对照时 + // 同目录再存一份 `.raw.wav`。 + let archives = crate::recorder::ArchiveTargets { + processed: archive_path.clone(), + raw: (recording_plan.archive_raw && archive_path.is_some()) + .then(|| archive_path.as_ref().map(|path| path.with_extension("raw.wav"))) + .flatten(), + write_processed: recording_plan.archive_processed, + }; let started_at = Instant::now(); let level_progress = Arc::clone(&progress); let level_handler: Arc = Arc::new(move |level| { @@ -3157,7 +3163,7 @@ impl PendingAudioCapture for TauriPendingAudioCapture { .commit( consumer, level_handler, - archive_path.clone(), + archives.clone(), recording_plan.audio_processing, ) .map_err(map_recorder_error) @@ -3704,10 +3710,7 @@ impl AudioRecorder for TauriAudioRecorder { ); // Undecided Android captures use the permanent quick-note spool // until the terminal tap/gesture classifies the session. - let archive_path = context - .recording - .archive_enabled - .then(|| { + let archive_path = context.recording.archive_enabled.then(|| { if permanent_archive { crate::persistence::quick_note_recording_path_for_session( &session_id.to_string(), diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index e0cf76be2..c385a1df8 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -560,6 +560,8 @@ macro_rules! app_invoke_handler_mobile { tauri::generate_handler![ $crate::commands::get_startup_snapshot, $crate::commands::get_settings, + $crate::commands::get_android_audio_effects_status, + $crate::commands::set_android_audio_monitoring, $crate::commands::get_settings_snapshot, $crate::commands::update_setting_fields, $crate::commands::get_default_style_system_prompts, diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index 9b437481a..a437e4bd2 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -13,6 +13,25 @@ //! `drop`ped inside that thread. use std::collections::{HashMap, HashSet, VecDeque}; +#[cfg(target_os = "android")] +#[path = "recorder/android_capture.rs"] +pub(crate) mod android_capture; + +struct CaptureStream { + _cpal: Option, + #[cfg(target_os = "android")] + _android: Option, +} + +impl From for CaptureStream { + fn from(stream: cpal::Stream) -> Self { + Self { + _cpal: Some(stream), + #[cfg(target_os = "android")] + _android: None, + } + } +} use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::mpsc::{channel, sync_channel, Receiver, Sender, SyncSender, TrySendError}; @@ -82,17 +101,29 @@ pub(crate) fn voice_diagnostic( }); let writer = WRITER.get_or_init(|| { let (tx, rx) = sync_channel::(4096); - std::thread::Builder::new().name("voice-1003-diagnostics".into()).spawn(move || { - let path = std::env::temp_dir().join("openless-1003-3c02ffc1.ndjson"); - let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) else { - return; - }; - for record in rx { - let Ok(mut line) = serde_json::to_vec(&record) else { continue; }; - line.push(b'\n'); - if file.write_all(&line).is_err() { break; } - } - }).ok().map(|_| tx) + std::thread::Builder::new() + .name("voice-1003-diagnostics".into()) + .spawn(move || { + let path = std::env::temp_dir().join("openless-1003-3c02ffc1.ndjson"); + let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + else { + return; + }; + for record in rx { + let Ok(mut line) = serde_json::to_vec(&record) else { + continue; + }; + line.push(b'\n'); + if file.write_all(&line).is_err() { + break; + } + } + }) + .ok() + .map(|_| tx) }); if let Some(writer) = writer { let record = serde_json::json!({ @@ -105,7 +136,9 @@ pub(crate) fn voice_diagnostic( "ts": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) .unwrap_or_default().as_millis().to_string(), }); - if writer.try_send(record).is_err() { DROPPED.fetch_add(1, Ordering::Relaxed); } + if writer.try_send(record).is_err() { + DROPPED.fetch_add(1, Ordering::Relaxed); + } } } #[cfg(any(not(target_os = "windows"), test))] @@ -383,6 +416,71 @@ impl WavArchiveWriter { } } +/// 调试录音要落哪几份盘(与 egui 侧同名同义)。 +/// +/// - `processed`:主档案 `.wav`,`write_processed` 时写降噪/增益之后的 +/// PCM,否则写原始 PCM(只勾「处理前」时用,回退/播放功能不变)。 +/// - `raw`:额外的 `.raw.wav`,只在同时要两份时创建,用于 A/B 对照。 +#[derive(Clone, Default)] +pub(crate) struct ArchiveTargets { + pub processed: Option, + pub raw: Option, + pub write_processed: bool, +} + +impl ArchiveTargets { + pub(crate) fn processed_only(path: Option) -> Self { + Self { + processed: path, + raw: None, + write_processed: true, + } + } + + fn canonical(&self) -> Option { + self.processed.clone().or_else(|| self.raw.clone()) + } +} + +/// 「处理前」音频的落盘通道:DSP 线程在处理每一帧之前先按这里落原始 PCM。 +#[derive(Clone)] +pub(crate) struct RawTap { + /// 额外的 `.raw.wav`(只在两份都要时存在)。 + raw_writer: Option>, + /// 主档案;`write_processed == false` 时原始 PCM 直接写进这里。 + canonical: Option>, + write_processed: bool, +} + +impl RawTap { + pub(crate) fn new( + canonical: Option>, + raw_writer: Option>, + write_processed: bool, + ) -> Self { + Self { + raw_writer, + canonical, + write_processed, + } + } + + fn append_raw(&self, samples: &[f32]) { + if self.raw_writer.is_none() && self.write_processed { + return; + } + let pcm = openless_core::pcm_f32_to_i16_le(samples); + if let Some(raw) = self.raw_writer.as_ref() { + raw.append(&pcm); + } + if !self.write_processed { + if let Some(canonical) = self.canonical.as_ref() { + canonical.append(&pcm); + } + } + } +} + #[derive(Clone)] struct ForwardTarget { consumer: Arc, @@ -402,9 +500,16 @@ struct CaptureSink { state: Mutex, delivery_gate: Mutex<()>, max_bytes: usize, + /// 处理前音频的落盘通道,由 `promote` 设置;DSP 线程每帧读一次。 + raw_tap: Arc>>>, } impl CaptureSink { + /// DSP 线程用它读「处理前」落盘通道;`promote` 时写入。 + fn raw_tap_slot(&self) -> Arc>>> { + Arc::clone(&self.raw_tap) + } + fn new(max_bytes: usize) -> Arc { let max_bytes = (max_bytes.max(2) & !1).min(STAGED_CAPTURE_MAX_BYTES.max(2)); Arc::new(Self { @@ -413,6 +518,7 @@ impl CaptureSink { }), delivery_gate: Mutex::new(()), max_bytes, + raw_tap: Arc::new(Mutex::new(None)), }) } @@ -428,7 +534,11 @@ impl CaptureSink { consumer: Arc, level_handler: Arc, archive: Option>, + raw_tap: Option>, ) -> Result<(), RecorderError> { + // 只要处理前时,主档案由 DSP 线程写原始 PCM,这里就不能再写处理后。 + let write_processed = raw_tap.as_ref().map_or(true, |tap| tap.write_processed); + *self.raw_tap.lock() = raw_tap; let _delivery = self.delivery_gate.lock(); let prefix = { let mut state = self.state.lock(); @@ -447,7 +557,7 @@ impl CaptureSink { *state = CaptureSinkState::Promoted(ForwardTarget { consumer, level_handler, - archive, + archive: if write_processed { archive } else { None }, }); prefix }; @@ -535,6 +645,8 @@ struct WarmCaptureSink { activation_fault_handler: Mutex)>>, activation_vad: Mutex, max_bytes: usize, + /// 处理前音频通道:与 `CaptureSink` 同样的机制,热麦克风路径也要能落原始 PCM。 + raw_tap: Arc>>>, } struct ActivationVadOutput { @@ -641,6 +753,7 @@ impl WarmCaptureSink { activation_fault_handler: Mutex::new(None), activation_vad: Mutex::new(ActivationVad::default()), max_bytes, + raw_tap: Arc::new(Mutex::new(None)), }) } @@ -776,8 +889,10 @@ impl WarmCaptureSink { consumer: Arc, level_handler: Arc, archive: Option>, + raw_tap: Option>, ) -> Result<(), RecorderError> { let _delivery = self.delivery_gate.lock(); + *self.raw_tap.lock() = raw_tap; let (prefix, staged_level) = { let mut state = self.state.lock(); let WarmCaptureSinkState::Ring { claims, .. } = &mut *state; @@ -1015,9 +1130,20 @@ impl WarmStagedRecorder { mut self, consumer: Arc, level_handler: Arc, - audio_archive_path: Option, + archives: ArchiveTargets, ) -> Result<(WarmActiveCapture, Receiver, bool), RecorderError> { - let archive_path = audio_archive_path; + let archive_path = archives.canonical(); + let raw_writer = archives + .raw + .as_ref() + .filter(|_| archives.processed.is_some()) + .and_then(|path| match WavArchiveWriter::create(path) { + Ok(writer) => Some(Arc::new(writer)), + Err(error) => { + log::warn!("[recorder] raw archive create failed at {path:?}: {error}"); + None + } + }); let archive_writer = archive_path .as_ref() .and_then(|path| match WavArchiveWriter::create(path) { @@ -1027,12 +1153,18 @@ impl WarmStagedRecorder { None } }); + let raw_tap = Some(Arc::new(RawTap::new( + archive_writer.clone(), + raw_writer, + archives.write_processed, + ))); let archive_active = archive_writer.is_some(); let result = self.hub.promote( self.press_id, consumer, level_handler, archive_writer.clone(), + raw_tap.clone(), ); let runtime_errors = match result { Ok(runtime_errors) => runtime_errors, @@ -1298,12 +1430,13 @@ impl WarmCaptureHub { let level_handler: Arc = Arc::new(move |level| { sink_for_level.publish_level(level); }); - let (recorder, runtime_errors, _) = Recorder::start_with_processing( + let (recorder, runtime_errors, _) = Recorder::start_with_archive_targets( device_name, consumer, level_handler, - None, + ArchiveTargets::default(), *self.processing_config.lock(), + Some(Arc::clone(&self.sink.raw_tap)), )?; let generation = self.stream_generation.fetch_add(1, Ordering::AcqRel) + 1; *self.recorder.lock() = Some(recorder); @@ -1360,12 +1493,13 @@ impl WarmCaptureHub { consumer: Arc, level_handler: Arc, archive: Option>, + raw_tap: Option>, ) -> Result, RecorderError> { let _lifecycle = self.lifecycle_gate.lock(); let (runtime_error_tx, runtime_error_rx) = channel(); if let Err(error) = self .sink - .promote(press_id, consumer, level_handler, archive) + .promote(press_id, consumer, level_handler, archive, raw_tap) { let fallback_allowed = self.runtime_fallback_claims.lock().remove(&press_id); if !fallback_allowed @@ -1687,6 +1821,26 @@ impl Recorder { level_handler: Arc, audio_archive_path: Option, processing: openless_core::AudioProcessingConfig, + ) -> Result<(Self, Receiver, bool), RecorderError> { + Self::start_with_archive_targets( + microphone_device_name, + consumer, + level_handler, + ArchiveTargets::processed_only(audio_archive_path), + processing, + None, + ) + } + + /// 由调用方决定主档案写哪份 PCM、是否额外写一份处理前的 `.raw.wav`, + /// 以及 AEC 的扬声器参考来源。 + pub fn start_with_archive_targets( + microphone_device_name: Option, + consumer: Arc, + level_handler: Arc, + archives: ArchiveTargets, + processing: openless_core::AudioProcessingConfig, + raw_tap_slot: Option>>>>, ) -> Result<(Self, Receiver, bool), RecorderError> { // Startup signal: the child thread reports the result via startup_tx once the Stream // is constructed. @@ -1703,15 +1857,38 @@ impl Recorder { // immediately and passed to the caller to decide whether history marks // has_audio_recording. Failure only logs a warning, no error; the main path continues. let archive_writer = - audio_archive_path.and_then(|path| match WavArchiveWriter::create(&path) { + archives + .canonical() + .and_then(|path| match WavArchiveWriter::create(&path) { + Ok(writer) => Some(Arc::new(writer)), + Err(err) => { + log::warn!("[recorder] wav archive create failed at {path:?}: {err}"); + None + } + }); + // 只有两份都要时才创建第二份档案。 + let raw_writer = archives + .raw + .as_ref() + .filter(|_| archives.processed.is_some()) + .and_then(|path| match WavArchiveWriter::create(path) { Ok(writer) => Some(Arc::new(writer)), Err(err) => { - log::warn!("[recorder] wav archive create failed at {path:?}: {err}"); + log::warn!("[recorder] raw archive create failed at {path:?}: {err}"); None } }); + let write_processed = archives.write_processed; let archive_active = archive_writer.is_some(); let archive_for_thread = archive_writer.clone(); + let raw_for_thread = raw_writer.clone(); + // AEC 的扬声器参考:与录音同生命周期,Drop 时停流。 + let far_end = (!processing.echo_cancellation_enabled + && processing.enhancement_enabled + && processing.dsp.aec_mode != openless_core::AecMode::Off) + .then(openless_core::FarEndQueue::default); + let far_end_for_thread = far_end.clone(); + let raw_tap_slot_for_thread = raw_tap_slot.clone(); let join_handle = match thread::Builder::new() .name("openless-recorder".into()) @@ -1721,6 +1898,10 @@ impl Recorder { consumer, level_handler, archive_for_thread, + raw_for_thread, + write_processed, + far_end_for_thread, + raw_tap_slot_for_thread, processing, state_for_thread, stop_for_thread, @@ -1829,12 +2010,14 @@ impl Recorder { let level_handler: Arc = Arc::new(move |level| { sink_for_level.publish_level(level); }); - let (recorder, runtime_errors, _) = Self::start_with_processing( + let raw_tap_slot = sink.raw_tap_slot(); + let (recorder, runtime_errors, _) = Recorder::start_with_archive_targets( microphone_device_name, consumer, level_handler, - None, + ArchiveTargets::default(), processing, + Some(raw_tap_slot), )?; Ok(StagedRecorder { recorder: Some(recorder), @@ -1856,9 +2039,9 @@ impl StagedRecorder { mut self, consumer: Arc, level_handler: Arc, - audio_archive_path: Option, + archives: ArchiveTargets, ) -> Result<(Recorder, Receiver, bool), RecorderError> { - let archive_path = audio_archive_path; + let archive_path = archives.canonical(); let archive_writer = archive_path .as_ref() .and_then(|path| match WavArchiveWriter::create(path) { @@ -1868,10 +2051,26 @@ impl StagedRecorder { None } }); + let raw_writer = archives + .raw + .as_ref() + .filter(|_| archives.processed.is_some()) + .and_then(|path| match WavArchiveWriter::create(path) { + Ok(writer) => Some(Arc::new(writer)), + Err(error) => { + log::warn!("[recorder] raw archive create failed at {path:?}: {error}"); + None + } + }); + let raw_tap = Some(Arc::new(RawTap::new( + archive_writer.clone(), + raw_writer, + archives.write_processed, + ))); let archive_active = archive_writer.is_some(); - if let Err(error) = self - .sink - .promote(consumer, level_handler, archive_writer.clone()) + if let Err(error) = + self.sink + .promote(consumer, level_handler, archive_writer.clone(), raw_tap) { if let Some(archive) = archive_writer { archive.finish(); @@ -1941,6 +2140,94 @@ pub fn list_input_devices() -> Result, RecorderError> { Ok(result) } +/// 打开「扬声器回环」参考采集,供 AEC 使用。 +/// +/// 走的是宿主默认**输出**设备:Windows 的 WASAPI 允许在 render 设备上开 capture +/// 流(loopback),Linux 的 PulseAudio/PipeWire 会把输出映射到 monitor 源,macOS +/// 走 CoreAudio 的同一设备。没有可用回环源时返回错误,调用方记一条 warn 后继续 +/// 录音(AEC 退化为无参考),绝不让整次录音失败。 +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn start_reference_stream( + stop_flag: &Arc, + queue: &openless_core::FarEndQueue, +) -> Result { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| RecorderError::EngineFailed("no default output device".into()))?; + let supported = device + .default_output_config() + .map_err(|error| RecorderError::EngineFailed(format!("default output config: {error}")))?; + let config = supported.config(); + let channels = usize::from(config.channels); + let rate = config.sample_rate.0; + let device_name = device.name().unwrap_or_default(); + let queue = queue.clone(); + let stop_for_callback = Arc::clone(stop_flag); + + macro_rules! make_reference_stream { + ($t:ty, $to_f32:expr) => {{ + let queue = queue.clone(); + let stop_for_callback = Arc::clone(&stop_for_callback); + device.build_input_stream( + &config, + move |data: &[$t], _: &cpal::InputCallbackInfo| { + if stop_for_callback.load(Ordering::Acquire) { + return; + } + let samples: Vec = data.iter().copied().map($to_f32).collect(); + queue.push(&samples, channels, rate); + }, + |error| log::warn!("[recorder] speaker reference stream error: {error}"), + None, + ) + }}; + } + + let stream = match supported.sample_format() { + SampleFormat::F32 => make_reference_stream!(f32, |sample: f32| sample), + SampleFormat::I16 => { + make_reference_stream!(i16, |sample: i16| sample as f32 / i16::MAX as f32) + } + SampleFormat::I32 => { + make_reference_stream!(i32, |sample: i32| sample as f32 / i32::MAX as f32) + } + SampleFormat::I8 => make_reference_stream!(i8, |sample: i8| sample as f32 / i8::MAX as f32), + SampleFormat::U8 => { + make_reference_stream!(u8, |sample: u8| (sample as f32 - 128.0) / 128.0) + } + other => { + return Err(RecorderError::EngineFailed(format!( + "unsupported speaker reference sample format: {other:?}" + ))) + } + } + .map_err(|error| { + RecorderError::EngineFailed(format!("build speaker reference stream: {error}")) + })?; + stream + .play() + .map_err(|error| RecorderError::EngineFailed(format!("play speaker reference: {error}")))?; + log::info!( + "[recorder] speaker reference capturing outputDevice={} rate={} channels={channels}", + device_name, + rate + ); + Ok(stream) +} + +#[cfg(any(target_os = "android", target_os = "ios"))] +fn start_reference_stream( + _stop_flag: &Arc, + _queue: &openless_core::FarEndQueue, +) -> Result { + Err(RecorderError::EngineFailed( + "speaker loopback is not available on mobile".into(), + )) +} + /// Audio thread body: build Stream -> report via startup_tx -> loop until stop_flag. /// `archiver` was already attempted by the caller on the synchronous path (Ok -> Some / /// failed -> None); this only threads it through to build_input_stream for the cpal callback. @@ -1949,12 +2236,17 @@ fn run_audio_thread( consumer: Arc, level_handler: Arc, archiver: Option>, + raw_archiver: Option>, + write_processed: bool, + far_end: Option, + raw_tap_slot: Option>>>>, processing: openless_core::AudioProcessingConfig, state: Arc, stop_flag: Arc, startup_tx: Sender>, runtime_error_tx: Sender, ) { + let far_end_for_reference = far_end.clone(); let (reference_stream, reference) = if processing.echo_cancellation_enabled { match playback_reference::start(&stop_flag, &state.diagnostics) { Ok((stream, buffer)) => (Some(stream), Some(buffer)), @@ -1984,12 +2276,16 @@ fn run_audio_thread( ), ); *state.reference.lock() = reference; - let (processor, frame_tx) = match AudioProcessingWorker::start( + let (processor, frame_tx) = match AudioProcessingWorker::start_with_targets( processing, consumer, level_handler, archiver, + raw_archiver, Arc::clone(&state), + write_processed, + far_end, + raw_tap_slot, ) { Ok(worker) => worker, Err(error) => { @@ -1998,12 +2294,26 @@ fn run_audio_thread( } }; *state.processor_sender.lock() = Some(frame_tx.clone()); + // 扬声器回环参考:`cpal::Stream` 不是 Send,只能在音频线程里创建, + // 并随本线程结束一起释放(和麦克风流一样)。 + let _reference_stream = far_end_for_reference.as_ref().and_then(|queue| { + match start_reference_stream(&stop_flag, queue) { + Ok(stream) => Some(stream), + Err(error) => { + log::warn!( + "[recorder] speaker reference unavailable; continuing without AEC: {error}" + ); + None + } + } + }); let (stream, state) = match build_input_stream( microphone_device_name, frame_tx.clone(), Arc::clone(&state), Arc::clone(&stop_flag), runtime_error_tx.clone(), + processing, ) { Ok(s) => s, Err(err) => { @@ -2136,8 +2446,10 @@ fn run_audio_thread( // backend it calls AudioOutputUnitStop directly, terminating the callback synchronously. // The subsequent drop handles dispose / resource release. If pause fails, warn only and // do not block the drop. - if let Err(err) = stream.pause() { - log::warn!("[recorder] cpal Stream pause before drop failed: {err}"); + if let Some(cpal_stream) = &stream._cpal { + if let Err(err) = cpal_stream.pause() { + log::warn!("[recorder] cpal Stream pause before drop failed: {err}"); + } } drop(stream); drop(reference_stream); @@ -2160,7 +2472,49 @@ fn build_input_stream( state: Arc, stop_flag: Arc, runtime_error_tx: Sender, -) -> Result<(cpal::Stream, Arc), RecorderError> { + _processing: openless_core::AudioProcessingConfig, +) -> Result<(CaptureStream, Arc), RecorderError> { + #[cfg(target_os = "android")] + if _processing.enhancement_enabled { + let config = _processing.dsp; + if config.android_aec_enabled + || config.android_noise_suppression_enabled + || config.android_agc_enabled + { + if microphone_device_name + .as_ref() + .is_some_and(|name| !name.trim().is_empty()) + { + android_capture::record_failure( + "System audio effects require the default Android input".into(), + ); + } else { + match android_capture::AndroidCapture::start( + config, + frame_tx.clone(), + Arc::clone(&state), + runtime_error_tx.clone(), + ) { + Ok(capture) => { + return Ok(( + CaptureStream { + _cpal: None, + _android: Some(capture), + }, + state, + )) + } + Err(error) => { + android_capture::record_failure(error.clone()); + if error.starts_with("busy:") { + return Err(RecorderError::EngineFailed(error)); + } + log::warn!("[android-audio] system effects unavailable; falling back to native capture: {error}"); + } + } + } + } + } let selecting_at = std::time::Instant::now(); let host = cpal::default_host(); let selected = select_input_device(&host, microphone_device_name.as_deref())?; @@ -2168,6 +2522,7 @@ fn build_input_stream( let selected_name = selected.name().ok(); let start = |device: &cpal::Device| { start_device_stream(device, &frame_tx, &state, &stop_flag, &runtime_error_tx) + .map(|(stream, state)| (stream.into(), state)) }; let initial_error = match start(&selected) { Ok(stream) => return Ok(stream), @@ -2214,6 +2569,24 @@ fn start_device_stream( stop_flag: &Arc, runtime_error_tx: &Sender, ) -> Result<(cpal::Stream, Arc), RecorderError> { + // Android DSP and neural denoisers share a 48 kHz capture contract. + // Keep native fallback for devices that cannot open this rate. + #[cfg(target_os = "android")] + if let Ok(ranges) = device.supported_input_configs() { + for range in ranges.filter(|range| { + range.min_sample_rate().0 <= 48_000 && range.max_sample_rate().0 >= 48_000 + }) { + let config = range.with_sample_rate(cpal::SampleRate(48_000)); + match start_native_config(device, config, frame_tx, state, stop_flag, runtime_error_tx) + { + Ok(stream) => return Ok(stream), + Err(RecorderError::PermissionDenied) => { + return Err(RecorderError::PermissionDenied) + } + Err(error) => log::warn!("[recorder] Android 48 kHz capture failed: {error}"), + } + } + } let mut default = None; let initial_error = match device.default_input_config() { Ok(config) => { @@ -2614,6 +2987,33 @@ impl AudioProcessingWorker { level_handler: Arc, archiver: Option>, state: Arc, + ) -> Result<(Self, AudioProcessingSender), RecorderError> { + Self::start_with_targets( + processing, + consumer, + level_handler, + archiver, + None, + state, + true, + None, + None, + ) + } + + /// `write_processed = false` 时主档案写原始 PCM;`raw_archiver` 是额外的 + /// 「处理前」档案(只在两份都要时给出)。`far_end` 是 AEC 的扬声器参考队列。 + #[allow(clippy::too_many_arguments)] + fn start_with_targets( + processing: openless_core::AudioProcessingConfig, + consumer: Arc, + level_handler: Arc, + archiver: Option>, + raw_archiver: Option>, + state: Arc, + write_processed: bool, + far_end: Option, + raw_tap_slot: Option>>>>, ) -> Result<(Self, AudioProcessingSender), RecorderError> { let (sender, receiver) = sync_channel::(AUDIO_PROCESSING_QUEUE_CAPACITY); @@ -2625,13 +3025,20 @@ impl AudioProcessingWorker { let join_handle = thread::Builder::new() .name("openless-audio-processor".into()) .spawn(move || { + #[cfg(target_os = "android")] + android_capture::prioritize_current_thread(); let mut processor = openless_core::AudioProcessor::new(processing); + if let Some(queue) = far_end.clone() { + processor.attach_far_end(queue); + } let mut enhancement_enabled = processing.enhancement_enabled; let deliver = |chunk: openless_core::audio::NormalizedPcmChunk| { state.note_first_pcm(true); consumer.consume_pcm_chunk(&chunk.pcm_i16_le); - if let Some(archiver) = &archiver { - archiver.append(&chunk.pcm_i16_le); + if write_processed { + if let Some(archiver) = &archiver { + archiver.append(&chunk.pcm_i16_le); + } } level_handler(chunk.level); update_peak(&state.peak_output_rms_milli, chunk.level); @@ -2641,6 +3048,27 @@ impl AudioProcessingWorker { let QueuedAudioProcessingMessage { message, _reservation } = queued; match message { AudioProcessingMessage::Frame(frame) => { + #[cfg(target_os = "android")] + processor.set_monitoring_enabled(android_capture::monitoring_enabled()); + // 「处理前」的原始帧:额外 raw 档案永远拿原始 PCM; + // 只要处理前、不要处理后时,主档案也写原始 PCM,回退与 + // 播放继续可用,且不会多出一份重复文件。 + if raw_archiver.is_some() || !write_processed { + let pcm = openless_core::pcm_f32_to_i16_le(&frame.samples); + if let Some(raw) = &raw_archiver { + raw.append(&pcm); + } + if !write_processed { + if let Some(canonical) = &archiver { + canonical.append(&pcm); + } + } + } + if let Some(slot) = raw_tap_slot.as_ref() { + if let Some(tap) = slot.lock().clone() { + tap.append_raw(&frame.samples); + } + } let queued_us = backlog.micros.load(Ordering::Acquire); if enhancement_enabled && queued_us >= AUDIO_PROCESSING_SHED_ENHANCEMENT_US { log::warn!("[recorder] disabling enhancement for this capture: processing backlog {} ms", queued_us / 1_000); @@ -2659,6 +3087,8 @@ impl AudioProcessingWorker { ) { deliver(chunk); } + #[cfg(target_os = "android")] + android_capture::publish_monitor(&processor); } AudioProcessingMessage::Flush(done) => { if let Some(chunk) = processor.finish() { @@ -3424,6 +3854,7 @@ mod tests { Arc::clone(&formal) as Arc, Arc::new(|_| {}), None, + None, ) .unwrap(); @@ -3447,6 +3878,7 @@ mod tests { Arc::clone(&formal) as Arc, Arc::new(|_| {}), None, + None, ) .unwrap(); @@ -3776,6 +4208,7 @@ mod tests { Arc::clone(&consumer) as Arc, Arc::new(move |level| levels_for_handler.lock().unwrap().push(level)), None, + None, ) .unwrap(); sink.consume_pcm_chunk(&[5, 6]); @@ -3798,7 +4231,12 @@ mod tests { sink.consume_pcm_chunk(&[1, 2]); sink.discard(); assert!(sink - .promote(consumer as Arc, Arc::new(|_| {}), None) + .promote( + consumer as Arc, + Arc::new(|_| {}), + None, + None + ) .is_err()); sink.consume_pcm_chunk(&[3, 4]); let is_discarded = matches!(&*sink.state.lock(), CaptureSinkState::Discarded); @@ -3821,6 +4259,7 @@ mod tests { consumer.clone(), Arc::new(move |level| levels_for_handler.lock().unwrap().push(level)), None, + None, ) .unwrap(); sink.consume_pcm_chunk(&[11, 12]); @@ -3859,7 +4298,7 @@ mod tests { sink.arm(19, false).unwrap(); sink.consume_pcm_chunk(&[5, 6]); let consumer = Arc::new(RecordingConsumer::default()); - sink.promote(19, consumer.clone(), Arc::new(|_| {}), None) + sink.promote(19, consumer.clone(), Arc::new(|_| {}), None, None) .unwrap(); assert_eq!(consumer.chunks.lock().unwrap().as_slice(), &[vec![5, 6]]); } @@ -3872,7 +4311,7 @@ mod tests { sink.consume_pcm_chunk(&[17, 18, 19, 20, 21, 22]); let consumer = Arc::new(RecordingConsumer::default()); - sink.promote(17, consumer.clone(), Arc::new(|_| {}), None) + sink.promote(17, consumer.clone(), Arc::new(|_| {}), None, None) .unwrap(); assert_eq!( @@ -3890,7 +4329,7 @@ mod tests { sink.consume_pcm_chunk(&[17, 18, 19, 20, 21, 22]); let consumer = Arc::new(RecordingConsumer::default()); - sink.promote(17, consumer.clone(), Arc::new(|_| {}), None) + sink.promote(17, consumer.clone(), Arc::new(|_| {}), None, None) .unwrap(); assert_eq!( @@ -3910,7 +4349,7 @@ mod tests { let first = Arc::new(RecordingConsumer::default()); let second = Arc::new(RecordingConsumer::default()); - sink.promote(18, second.clone(), Arc::new(|_| {}), None) + sink.promote(18, second.clone(), Arc::new(|_| {}), None, None) .unwrap(); sink.consume_pcm_chunk(&[5, 6]); @@ -3919,7 +4358,9 @@ mod tests { second.chunks.lock().unwrap().as_slice(), &[vec![1, 2, 3, 4], vec![5, 6]] ); - assert!(sink.promote(17, first, Arc::new(|_| {}), None).is_err()); + assert!(sink + .promote(17, first, Arc::new(|_| {}), None, None) + .is_err()); } fn complete_hub_operation(operation: impl FnOnce() + Send + 'static) { @@ -3988,6 +4429,7 @@ mod tests { Arc::new(RecordingConsumer::default()), Arc::new(|_| {}), None, + None, ) .unwrap(); } diff --git a/openless-all/app/src-tauri/src/recorder/android_capture.rs b/openless-all/app/src-tauri/src/recorder/android_capture.rs new file mode 100644 index 000000000..fe0490618 --- /dev/null +++ b/openless-all/app/src-tauri/src/recorder/android_capture.rs @@ -0,0 +1,341 @@ +//! Optional Android system preprocessing, attached to the actual recording +//! session. Inspired by MicYou's AudioRecord lifecycle; no playback capture. +use super::*; +use crate::android::jni::android::with_android_env; +use jni::{ + objects::{GlobalRef, JValue}, + JNIEnv, JavaVM, +}; +use std::time::Duration; + +static OWNER: AtomicBool = AtomicBool::new(false); +static STATUS: std::sync::Mutex> = std::sync::Mutex::new(None); +const RATE: u32 = 48_000; +const SAMPLES: usize = 960; // 20 ms; bounds JNI work and processing latency. +static MONITOR_UNTIL: AtomicU64 = AtomicU64::new(0); +static MONITOR_NEXT: AtomicU64 = AtomicU64::new(0); + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +pub fn set_monitoring(enabled: bool) { + MONITOR_UNTIL.store(if enabled { now_ms() + 2500 } else { 0 }, Ordering::Release); +} + +pub fn monitoring_enabled() -> bool { + now_ms() < MONITOR_UNTIL.load(Ordering::Acquire) +} + +pub fn publish_monitor(processor: &openless_core::AudioProcessor) { + let now = now_ms(); + if now < MONITOR_NEXT.load(Ordering::Relaxed) || !monitoring_enabled() { + return; + } + MONITOR_NEXT.store(now + 100, Ordering::Relaxed); + if let Some((raw, processed)) = processor.spectrums() { + crate::android::native_bridge::emit_audio_spectrum(raw, processed); + } +} +const EFFECTS: [&str; 3] = [ + "android/media/audiofx/AcousticEchoCanceler", + "android/media/audiofx/NoiseSuppressor", + "android/media/audiofx/AutomaticGainControl", +]; + +pub fn record_failure(error: String) { + *STATUS.lock().unwrap() = Some(serde_json::json!({"active": false, "error": error})); +} + +fn available(env: &mut JNIEnv, class: &str) -> bool { + let result = env + .call_static_method(class, "isAvailable", "()Z", &[]) + .and_then(|v| v.z()); + if result.is_err() { + let _ = env.exception_clear(); + } + result.unwrap_or(false) +} + +pub fn status() -> serde_json::Value { + let capabilities = with_android_env(|env, _| Ok(EFFECTS.map(|class| available(env, class)))) + .unwrap_or([false; 3]); + serde_json::json!({ + "aecAvailable": capabilities[0], "noiseSuppressionAvailable": capabilities[1], + "agcAvailable": capabilities[2], "runtime": STATUS.lock().unwrap().clone(), + }) +} + +pub fn prioritize_current_thread() { + let _ = with_android_env(|env, _| { + let result = env.call_static_method( + "android/os/Process", + "setThreadPriority", + "(I)V", + &[JValue::Int(-16)], + ); + if result.is_err() { + let _ = env.exception_clear(); + } + result.map(|_| ()).map_err(|e| e.to_string()) + }); +} + +struct Owner; +impl Drop for Owner { + fn drop(&mut self) { + OWNER.store(false, Ordering::Release); + } +} + +struct Record { + vm: JavaVM, + recorder: GlobalRef, + effects: Vec, + _owner: Owner, +} + +impl Drop for Record { + fn drop(&mut self) { + if let Ok(mut env) = self.vm.attach_current_thread() { + let _ = env.call_method(self.recorder.as_obj(), "stop", "()V", &[]); + let _ = env.exception_clear(); + for effect in &self.effects { + let _ = env.call_method(effect.as_obj(), "release", "()V", &[]); + let _ = env.exception_clear(); + } + let _ = env.call_method(self.recorder.as_obj(), "release", "()V", &[]); + let _ = env.exception_clear(); + } + *STATUS.lock().unwrap() = Some(serde_json::json!({"active": false, "error": null})); + } +} + +pub struct AndroidCapture { + stop: Arc, + done: Receiver<()>, +} + +impl AndroidCapture { + pub fn start( + config: openless_core::AudioDspConfig, + sender: AudioProcessingSender, + state: Arc, + errors: Sender, + ) -> Result { + OWNER + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| { + "busy: previous Android AudioRecord teardown has not completed".to_string() + })?; + let owner = Owner; + let requested = [ + config.android_aec_enabled, + config.android_noise_suppression_enabled, + config.android_agc_enabled, + ]; + let record = with_android_env(|env, _| { + let result = (|| -> Result { + let min = env + .call_static_method( + "android/media/AudioRecord", + "getMinBufferSize", + "(III)I", + &[JValue::Int(RATE as i32), JValue::Int(16), JValue::Int(2)], + )? + .i()?; + if min <= 0 { + return Err(jni::errors::Error::NullPtr("AudioRecord minimum buffer")); + } + let recorder = env.new_object( + "android/media/AudioRecord", + "(IIIII)V", + &[ + JValue::Int(if requested[0] { 7 } else { 6 }), // communication / recognition + JValue::Int(RATE as i32), + JValue::Int(16), + JValue::Int(2), + JValue::Int(min.max((SAMPLES * 4) as i32)), + ], + )?; + let mut record = Record { + vm: env.get_java_vm()?, + recorder: env.new_global_ref(&recorder)?, + effects: Vec::new(), + _owner: owner, + }; + if env.call_method(&recorder, "getState", "()I", &[])?.i()? != 1 { + return Err(jni::errors::Error::NullPtr("AudioRecord initialization")); + } + let session = env + .call_method(&recorder, "getAudioSessionId", "()I", &[])? + .i()?; + for (class, enabled) in EFFECTS.into_iter().zip(requested) { + if !available(env, class) { + if enabled { + return Err(jni::errors::Error::NullPtr( + "requested Android audio effect unavailable", + )); + } + continue; + } + let signature = format!("(I)L{class};"); + let effect = env + .call_static_method(class, "create", &signature, &[JValue::Int(session)])? + .l()?; + if effect.is_null() { + if enabled { + return Err(jni::errors::Error::NullPtr( + "Android audio effect creation", + )); + } + continue; + } + // Own it before enabling so failed setup also releases it. + record.effects.push(env.new_global_ref(&effect)?); + let code = env + .call_method( + &effect, + "setEnabled", + "(Z)I", + &[JValue::Bool(u8::from(enabled))], + )? + .i()?; + let actual = env.call_method(&effect, "getEnabled", "()Z", &[])?.z()?; + if code != 0 || actual != enabled { + return Err(jni::errors::Error::NullPtr( + "Android audio effect rejected setting", + )); + } + } + env.call_method(&recorder, "startRecording", "()V", &[])?; + if env + .call_method(&recorder, "getRecordingState", "()I", &[])? + .i()? + != 3 + { + return Err(jni::errors::Error::NullPtr( + "Android recording did not start", + )); + } + Ok(record) + })(); + if result.is_err() { + let _ = env.exception_clear(); + } + result.map_err(|error| error.to_string()) + })?; + *STATUS.lock().unwrap() = Some( + serde_json::json!({"active": true, "aec": requested[0], "noiseSuppression": requested[1], "agc": requested[2], "error": null}), + ); + let stop = Arc::new(AtomicBool::new(false)); + let worker_stop = Arc::clone(&stop); + let (done_tx, done) = channel(); + state.accepting_audio.store(true, Ordering::Release); + thread::Builder::new() + .name("openless-android-audio".into()) + .spawn(move || { + let result = read_audio(&record, sender, &state, &worker_stop); + if let Err(error) = result { + if !worker_stop.load(Ordering::Acquire) { + let _ = errors.send(RecorderError::EngineFailed(format!( + "Android AudioRecord: {error}" + ))); + } + } + drop(record); + let _ = done_tx.send(()); + }) + .map_err(|error| error.to_string())?; + Ok(Self { stop, done }) + } +} + +fn read_audio( + record: &Record, + sender: AudioProcessingSender, + state: &StreamState, + stop: &AtomicBool, +) -> Result<(), String> { + let mut env = record + .vm + .attach_current_thread() + .map_err(|e| e.to_string())?; + let result = env.call_static_method( + "android/os/Process", + "setThreadPriority", + "(I)V", + &[JValue::Int(-16)], + ); + if result.is_err() { + let _ = env.exception_clear(); + } + let array = env + .new_short_array(SAMPLES as i32) + .map_err(|e| e.to_string())?; + let mut samples = vec![0i16; SAMPLES]; + let result = (|| -> Result<(), jni::errors::Error> { + while !stop.load(Ordering::Acquire) && state.accepting_audio.load(Ordering::Acquire) { + // Nonblocking reads keep stop independent of an OEM's blocking read. + let count = env + .call_method( + record.recorder.as_obj(), + "read", + "([SIII)I", + &[ + JValue::Object(array.as_ref()), + JValue::Int(0), + JValue::Int(SAMPLES as i32), + JValue::Int(1), + ], + )? + .i()?; + if count < 0 { + return Err(jni::errors::Error::NullPtr("AudioRecord read failed")); + } + if count == 0 { + thread::sleep(Duration::from_millis(2)); + continue; + } + env.get_short_array_region(&array, 0, &mut samples[..count as usize])?; + if stop.load(Ordering::Acquire) || !state.accepting_audio.load(Ordering::Acquire) { + break; + } + state.note_first_pcm(false); + *state.last_callback_time.lock() = Some(std::time::Instant::now()); + let message = AudioProcessingMessage::Frame(RawAudioFrame { + captured_at: std::time::Instant::now(), + samples: samples[..count as usize] + .iter() + .map(|s| *s as f32 / 32768.0) + .collect(), + channels: 1, + input_sample_rate: RATE, + }); + if sender.try_send(message).is_err() { + return Err(jni::errors::Error::NullPtr( + "Android processing queue unavailable", + )); + } + } + Ok(()) + })(); + if result.is_err() { + let _ = env.exception_clear(); + } + result.map_err(|error| error.to_string()) +} + +impl Drop for AndroidCapture { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + // Some OEMs block stop/release. Keep their owner gate held in that + // thread, but never make UI cancellation wait for native teardown. + if self.done.recv_timeout(Duration::from_millis(250)).is_err() { + log::warn!("[android-audio] AudioRecord teardown is still pending"); + } + } +} diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index ad0cd7975..4fcaf1f6e 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1575,6 +1575,35 @@ export const de: typeof zhCN = { lowLatencyDictationDesc: 'Hält einen Mikrofonstream bereit und bewahrt bis zu 500 ms im Speicher, damit Sprache direkt nach dem Kurzbefehl seltener verloren geht. Vor der Bestätigung wird Audio weder gespeichert noch hochgeladen; der Stromverbrauch steigt und die Mikrofonanzeige bleibt aktiv.', dsp: { + aec: 'System echo cancellation', + systemNoise: 'System noise suppression', + systemAgc: 'System automatic gain', + unavailable: 'Unavailable on this device', + androidEffectsHint: + 'Android system effects apply before the software chain. Avoid enabling the same effect twice. Unsupported startup falls back to normal capture.', + effectsActive: 'System effects are active for this recording.', + spectrum: 'Input / processed spectrum', + spectrumHint: + 'Shows the current enhanced recording only. This does not start a microphone, save audio, or upload it. Monitoring stops when this panel closes or goes to the background.', + spectrumLegend: 'Gray: input · Color: processed · 20 Hz–24 kHz', + spectrumWaiting: 'Start a recording with speech enhancement enabled to see the spectrum.', + presets: 'Equalizer presets', + choosePreset: 'Choose a preset', + preset_normal: 'Flat', + preset_brightVocal: 'Bright vocal', + preset_deepVoice: 'Deep voice', + preset_podcast: 'Podcast', + preset_vocalClarity: 'Vocal clarity', + preset_warmVocal: 'Warm vocal', + + aecOff: 'Aus', + aecLight: 'Leicht (wenig CPU)', + aecModel: 'Modell (stärker)', + aecModelHint: + 'Das Modell unterdrückt mehr Echo, kostet aber rund 3x CPU; auf älteren Geräten besser Leicht.', + aecHint: + 'Unterdrückt Lautsprecher-Rückkopplung. Braucht ein Referenzsignal; mit Kopfhörern aus lassen.', + buffer: 'Ausgabepuffer', noise: 'Noise reduction', dereverb: 'Comb filter', equalizer: 'Equalizer', @@ -1684,6 +1713,10 @@ export const de: typeof zhCN = { polishContextWindowLabel: 'Kontextfenster für Überarbeitung (Minuten)', polishContextWindowDesc: 'Verwendet überarbeitete Transkripte der letzten N Minuten als Gesprächskontext. 0 = deaktiviert.', + debugArchiveProcessedLabel: 'Verarbeitetes Audio behalten', + debugArchiveProcessedDesc: 'Die Version nach Rauschunterdrückung und Verstärkung, weiterhin in der bisherigen Aufnahmedatei.', + debugArchiveRawLabel: 'Auch Audio vor der Verarbeitung behalten', + debugArchiveRawDesc: 'Schreibt zusätzlich .raw.wav daneben. Ist nur dies aktiv, landet das Rohaudio in der Hauptdatei.', recordAudioForDebugLabel: 'Rohaufnahmen behalten (Diagnose)', recordAudioForDebugDesc: 'Speichert das rohe Mikrofonaudio als WAV, um Erkennungsprobleme zu untersuchen.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index ffb814568..a2057bb11 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1545,6 +1545,35 @@ export const en: typeof zhCN = { lowLatencyDictationDesc: 'Keep a microphone stream ready and retain up to 500 ms in memory so speech immediately after the hotkey is less likely to be missed. Audio is not saved or uploaded until you confirm recording; this uses more power and keeps the microphone indicator active.', dsp: { + aec: 'System echo cancellation', + systemNoise: 'System noise suppression', + systemAgc: 'System automatic gain', + unavailable: 'Unavailable on this device', + androidEffectsHint: + 'Android system effects apply before the software chain. Avoid enabling the same effect twice. Unsupported startup falls back to normal capture.', + effectsActive: 'System effects are active for this recording.', + spectrum: 'Input / processed spectrum', + spectrumHint: + 'Shows the current enhanced recording only. This does not start a microphone, save audio, or upload it. Monitoring stops when this panel closes or goes to the background.', + spectrumLegend: 'Gray: input · Color: processed · 20 Hz–24 kHz', + spectrumWaiting: 'Start a recording with speech enhancement enabled to see the spectrum.', + presets: 'Equalizer presets', + choosePreset: 'Choose a preset', + preset_normal: 'Flat', + preset_brightVocal: 'Bright vocal', + preset_deepVoice: 'Deep voice', + preset_podcast: 'Podcast', + preset_vocalClarity: 'Vocal clarity', + preset_warmVocal: 'Warm vocal', + + aecOff: 'Off', + aecLight: 'Light (low CPU)', + aecModel: 'Model (stronger)', + aecModelHint: + 'The model cancels more echo but costs about 3x the CPU; prefer Light on older machines.', + aecHint: + 'Suppresses loudspeaker feedback while on speaker. It needs a speaker reference signal, so leave it off with headphones.', + buffer: 'Output buffer', noise: 'Noise reduction', dereverb: 'Comb filter', equalizer: 'Equalizer', @@ -1652,6 +1681,10 @@ export const en: typeof zhCN = { polishContextWindowLabel: 'Polish context window (minutes)', polishContextWindowDesc: 'Use the last N minutes of polished transcripts as multi-turn context; 0 = disabled.', + debugArchiveProcessedLabel: 'Keep processed audio', + debugArchiveProcessedDesc: 'The version after noise reduction and gain, stored in the existing recording file.', + debugArchiveRawLabel: 'Also keep pre-processing audio', + debugArchiveRawDesc: 'Writes .raw.wav next to it so you can compare. With only this enabled the raw audio goes into the main archive.', recordAudioForDebugLabel: 'Keep raw recording (debug)', recordAudioForDebugDesc: 'Save raw microphone audio as wav for diagnosing recognition issues.', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index ad2b8b5f8..a6b5cce3d 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1566,6 +1566,35 @@ export const es: typeof zhCN = { lowLatencyDictationDesc: 'Mantén preparado el flujo del micrófono y conserva hasta 500 ms en memoria para reducir la pérdida de voz justo después del atajo. El audio no se guarda ni se sube antes de confirmar la grabación; consume más energía y mantiene activo el indicador del micrófono.', dsp: { + aec: 'System echo cancellation', + systemNoise: 'System noise suppression', + systemAgc: 'System automatic gain', + unavailable: 'Unavailable on this device', + androidEffectsHint: + 'Android system effects apply before the software chain. Avoid enabling the same effect twice. Unsupported startup falls back to normal capture.', + effectsActive: 'System effects are active for this recording.', + spectrum: 'Input / processed spectrum', + spectrumHint: + 'Shows the current enhanced recording only. This does not start a microphone, save audio, or upload it. Monitoring stops when this panel closes or goes to the background.', + spectrumLegend: 'Gray: input · Color: processed · 20 Hz–24 kHz', + spectrumWaiting: 'Start a recording with speech enhancement enabled to see the spectrum.', + presets: 'Equalizer presets', + choosePreset: 'Choose a preset', + preset_normal: 'Flat', + preset_brightVocal: 'Bright vocal', + preset_deepVoice: 'Deep voice', + preset_podcast: 'Podcast', + preset_vocalClarity: 'Vocal clarity', + preset_warmVocal: 'Warm vocal', + + aecOff: 'Desactivado', + aecLight: 'Ligero (poca CPU)', + aecModel: 'Modelo (más potente)', + aecModelHint: + 'El modelo cancela más pero cuesta unas 3x más CPU; en equipos antiguos usa Ligero.', + aecHint: + 'Suprime la realimentación del altavoz. Necesita una señal de referencia; con auriculares puedes dejarlo apagado.', + buffer: 'Búfer de salida', noise: 'Noise reduction', dereverb: 'Comb filter', equalizer: 'Equalizer', @@ -1675,6 +1704,10 @@ export const es: typeof zhCN = { polishContextWindowLabel: 'Ventana de contexto para mejorar texto (minutos)', polishContextWindowDesc: 'Usa las transcripciones mejoradas de los últimos N minutos como contexto de varias intervenciones. 0 = desactivado.', + debugArchiveProcessedLabel: 'Conservar el audio procesado', + debugArchiveProcessedDesc: 'La versión tras reducción de ruido y ganancia, en el archivo de grabación existente.', + debugArchiveRawLabel: 'Conservar también el audio sin procesar', + debugArchiveRawDesc: 'Escribe además .raw.wav junto a él. Si solo activas esto, el audio sin procesar va al archivo principal.', recordAudioForDebugLabel: 'Conservar grabación original (depuración)', recordAudioForDebugDesc: 'Guarda el audio original del micrófono en WAV para investigar problemas de reconocimiento.', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 89873a641..8c128a739 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1586,6 +1586,35 @@ export const fr: typeof zhCN = { lowLatencyDictationDesc: 'Garde un flux microphone prêt et conserve jusqu’à 500 ms en mémoire afin de réduire les mots perdus juste après le raccourci. Aucun audio n’est enregistré ni envoyé avant confirmation ; ce mode consomme davantage et maintient l’indicateur du microphone actif.', dsp: { + aec: 'System echo cancellation', + systemNoise: 'System noise suppression', + systemAgc: 'System automatic gain', + unavailable: 'Unavailable on this device', + androidEffectsHint: + 'Android system effects apply before the software chain. Avoid enabling the same effect twice. Unsupported startup falls back to normal capture.', + effectsActive: 'System effects are active for this recording.', + spectrum: 'Input / processed spectrum', + spectrumHint: + 'Shows the current enhanced recording only. This does not start a microphone, save audio, or upload it. Monitoring stops when this panel closes or goes to the background.', + spectrumLegend: 'Gray: input · Color: processed · 20 Hz–24 kHz', + spectrumWaiting: 'Start a recording with speech enhancement enabled to see the spectrum.', + presets: 'Equalizer presets', + choosePreset: 'Choose a preset', + preset_normal: 'Flat', + preset_brightVocal: 'Bright vocal', + preset_deepVoice: 'Deep voice', + preset_podcast: 'Podcast', + preset_vocalClarity: 'Vocal clarity', + preset_warmVocal: 'Warm vocal', + + aecOff: 'Désactivé', + aecLight: 'Léger (CPU réduit)', + aecModel: 'Modèle (plus fort)', + aecModelHint: + 'Le modèle annule davantage mais coûte environ 3x plus de CPU ; préférez Léger sur les machines anciennes.', + aecHint: + 'Réduit le retour du haut-parleur. Nécessite un signal de référence; inutile au casque.', + buffer: 'Tampon de sortie', noise: 'Noise reduction', dereverb: 'Comb filter', equalizer: 'Equalizer', @@ -1696,6 +1725,10 @@ export const fr: typeof zhCN = { polishContextWindowLabel: 'Fenêtre de contexte d’amélioration (minutes)', polishContextWindowDesc: 'Utilise les transcriptions améliorées des N dernières minutes comme contexte de plusieurs interventions. 0 = désactivé.', + debugArchiveProcessedLabel: 'Conserver l’audio traité', + debugArchiveProcessedDesc: 'La version après réduction de bruit et gain, toujours dans le fichier d’enregistrement existant.', + debugArchiveRawLabel: 'Conserver aussi l’audio avant traitement', + debugArchiveRawDesc: 'Écrit aussi .raw.wav à côté. Si seule cette option est active, l’audio brut va dans l’archive principale.', recordAudioForDebugLabel: 'Conserver l’enregistrement brut (débogage)', recordAudioForDebugDesc: 'Enregistre l’audio brut du microphone en WAV pour diagnostiquer les problèmes de reconnaissance.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index ade100dea..27cbb5ff5 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1533,6 +1533,35 @@ export const ja: typeof zhCN = { lowLatencyDictationDesc: 'マイクストリームを準備し、最大 500 ミリ秒をメモリに保持して、ホットキー直後の音声が欠落しにくくします。録音を確定するまで音声は保存・アップロードされません。電力を多く使い、マイク使用中の表示が続きます。', dsp: { + aec: 'System echo cancellation', + systemNoise: 'System noise suppression', + systemAgc: 'System automatic gain', + unavailable: 'Unavailable on this device', + androidEffectsHint: + 'Android system effects apply before the software chain. Avoid enabling the same effect twice. Unsupported startup falls back to normal capture.', + effectsActive: 'System effects are active for this recording.', + spectrum: 'Input / processed spectrum', + spectrumHint: + 'Shows the current enhanced recording only. This does not start a microphone, save audio, or upload it. Monitoring stops when this panel closes or goes to the background.', + spectrumLegend: 'Gray: input · Color: processed · 20 Hz–24 kHz', + spectrumWaiting: 'Start a recording with speech enhancement enabled to see the spectrum.', + presets: 'Equalizer presets', + choosePreset: 'Choose a preset', + preset_normal: 'Flat', + preset_brightVocal: 'Bright vocal', + preset_deepVoice: 'Deep voice', + preset_podcast: 'Podcast', + preset_vocalClarity: 'Vocal clarity', + preset_warmVocal: 'Warm vocal', + + aecOff: 'オフ', + aecLight: '軽量(低 CPU)', + aecModel: 'モデル(高精度)', + aecModelHint: + 'モデル版はより強く抑圧しますが CPU は約 3 倍です。古い端末では軽量版を推奨します。', + aecHint: + 'スピーカー使用時の回り込みを抑えます。参照信号が必要なので、ヘッドホンならオフで構いません。', + buffer: '出力バッファ', noise: 'Noise reduction', dereverb: 'Comb filter', equalizer: 'Equalizer', @@ -1640,6 +1669,10 @@ export const ja: typeof zhCN = { polishContextWindowLabel: '会話コンテキスト窓(分)', polishContextWindowDesc: '直近 N 分間の整文済み転写をマルチターン文脈として渡します。0 = 無効。', + debugArchiveProcessedLabel: '処理後の音声を保存', + debugArchiveProcessedDesc: 'ノイズ低減とゲイン適用後の音声で、従来の録音ファイルに保存されます。', + debugArchiveRawLabel: '処理前の音声も保存', + debugArchiveRawDesc: '同じフォルダに .raw.wav を追加保存します。これだけを有効にすると元音声がメインに保存されます。', recordAudioForDebugLabel: '元の録音を保持(デバッグ)', recordAudioForDebugDesc: '生のマイク音声を wav で保存し、認識問題の診断に利用。', audioRecordingMaxEntriesLabel: '元音声の保持件数', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 4c970c55b..ce3a52c83 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1520,6 +1520,35 @@ export const ko: typeof zhCN = { lowLatencyDictationDesc: '마이크 스트림을 준비하고 최대 500ms를 메모리에 보관해 단축키 직후의 음성이 누락될 가능성을 줄입니다. 녹음을 확정하기 전에는 오디오를 저장하거나 업로드하지 않습니다. 전력 사용량이 늘고 마이크 표시가 계속 켜져 있습니다.', dsp: { + aec: 'System echo cancellation', + systemNoise: 'System noise suppression', + systemAgc: 'System automatic gain', + unavailable: 'Unavailable on this device', + androidEffectsHint: + 'Android system effects apply before the software chain. Avoid enabling the same effect twice. Unsupported startup falls back to normal capture.', + effectsActive: 'System effects are active for this recording.', + spectrum: 'Input / processed spectrum', + spectrumHint: + 'Shows the current enhanced recording only. This does not start a microphone, save audio, or upload it. Monitoring stops when this panel closes or goes to the background.', + spectrumLegend: 'Gray: input · Color: processed · 20 Hz–24 kHz', + spectrumWaiting: 'Start a recording with speech enhancement enabled to see the spectrum.', + presets: 'Equalizer presets', + choosePreset: 'Choose a preset', + preset_normal: 'Flat', + preset_brightVocal: 'Bright vocal', + preset_deepVoice: 'Deep voice', + preset_podcast: 'Podcast', + preset_vocalClarity: 'Vocal clarity', + preset_warmVocal: 'Warm vocal', + + aecOff: '끄기', + aecLight: '경량(저 CPU)', + aecModel: '모델(강화)', + aecModelHint: + '모델 버전은 더 강하게 제거하지만 CPU를 약 3배 사용합니다. 오래된 기기에서는 경량을 권장합니다.', + aecHint: + '스피커 사용 시 되먹임을 줄입니다. 참조 신호가 필요하므로 헤드폰이면 꺼 두세요.', + buffer: '출력 버퍼', noise: 'Noise reduction', dereverb: 'Comb filter', equalizer: 'Equalizer', @@ -1627,6 +1656,10 @@ export const ko: typeof zhCN = { polishContextWindowLabel: '대화 컨텍스트 윈도(분)', polishContextWindowDesc: '최근 N 분간 정리된 전사를 멀티턴 컨텍스트로 전달합니다. 0 = 비활성화.', + debugArchiveProcessedLabel: '처리된 오디오 저장', + debugArchiveProcessedDesc: '노이즈 감소와 게인 적용 후의 오디오로, 기존 녹음 파일에 저장됩니다.', + debugArchiveRawLabel: '처리 전 오디오도 저장', + debugArchiveRawDesc: '같은 폴더에 .raw.wav를 함께 저장합니다. 이것만 켜면 원본이 기본 파일에 저장됩니다.', recordAudioForDebugLabel: '원본 녹음 보관(디버그)', recordAudioForDebugDesc: '원시 마이크 오디오를 wav 로 저장하여 인식 문제 진단.', audioRecordingMaxEntriesLabel: '원본 녹음 보관 개수', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 4666e8162..00c418bc5 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1470,6 +1470,35 @@ export const zhCN = { lowLatencyDictationDesc: '保持麦克风流就绪,并在内存中保留最多 500 毫秒音频,减少按下热键后第一句话被漏识别的情况。确认录音前不会保存或上传音频;此模式会增加功耗并保持系统麦克风指示灯亮起。', dsp: { + aec: '系统回声消除', + systemNoise: '系统降噪', + systemAgc: '系统自动增益', + unavailable: '当前设备不支持', + androidEffectsHint: + 'Android 系统音效在软件处理链之前生效。建议避免重复开启同类效果。启动不支持时回退到常规采集。', + effectsActive: '本次录音已启用系统音效。', + spectrum: '输入/处理后频谱', + spectrumHint: + '仅显示当前增强录音,不会启动麦克风、保存或上传音频。关闭面板或进入后台后停止监测。', + spectrumLegend: '灰色:输入 · 彩色:处理后 · 20 Hz–24 kHz', + spectrumWaiting: '开启语音增强并开始录音后显示频谱。', + presets: '均衡器预设', + choosePreset: '选择预设', + preset_normal: '平直', + preset_brightVocal: '明亮人声', + preset_deepVoice: '低沉人声', + preset_podcast: '播客', + preset_vocalClarity: '清晰人声', + preset_warmVocal: '温暖人声', + + aecOff: '关闭', + aecLight: '轻量(省 CPU)', + aecModel: '模型(更强)', + aecModelHint: + '模型版抑制更强,但约是轻量版的 3 倍 CPU;老机器建议用轻量。', + aecHint: + '外放时压掉扬声器回授;需要扬声器参考信号,戴耳机可以关掉。', + buffer: '输出缓冲区', title: '语音增强设置', description: 'RNNoise 更轻量;PureVox 模型降噪耗时较高。设置在下一次录音生效。', noise: '降噪算法', @@ -1581,6 +1610,10 @@ export const zhCN = { '转写不超过 N 个字(不计空白)时直接插入原文,不调用润色模型。0 = 关闭。翻译模式始终润色。', polishContextWindowLabel: '对话上下文窗口(分钟)', polishContextWindowDesc: '把最近 N 分钟内已润色的转写作为多轮上下文,0 = 关闭。', + debugArchiveProcessedLabel: '保存处理后的音频', + debugArchiveProcessedDesc: '降噪与增益之后的版本,仍保存在原来的录制文件里。', + debugArchiveRawLabel: '另存一份处理前的音频', + debugArchiveRawDesc: '同目录下另存 .raw.wav 便于对照;只开这一项时,原始音频直接存进主档案。', recordAudioForDebugLabel: '保留原始录音(调试)', recordAudioForDebugDesc: '保存原始麦克风音频为 wav,便于排查识别问题。', audioRecordingMaxEntriesLabel: '原始录音保留条数', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index ed0393c74..93285a3d1 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1472,6 +1472,35 @@ export const zhTW: typeof zhCN = { lowLatencyDictationDesc: '保持麥克風串流就緒,並在記憶體中保留最多 500 毫秒音訊,減少按下熱鍵後第一句話被漏辨識的情況。確認錄音前不會儲存或上傳音訊;此模式會增加耗電並保持系統麥克風指示燈亮起。', dsp: { + aec: '系統回聲消除', + systemNoise: '系統降噪', + systemAgc: '系統自動增益', + unavailable: '目前裝置不支援', + androidEffectsHint: + 'Android 系統音效在軟體處理鏈之前生效。建議避免重複開啟同類效果。啟動不支援時退回一般擷取。', + effectsActive: '本次錄音已啟用系統音效。', + spectrum: '輸入/處理後頻譜', + spectrumHint: + '僅顯示目前增強錄音,不會啟動麥克風、儲存或上傳音訊。關閉面板或進入背景後停止監測。', + spectrumLegend: '灰色:輸入 · 彩色:處理後 · 20 Hz–24 kHz', + spectrumWaiting: '開啟語音增強並開始錄音後顯示頻譜。', + presets: '均衡器預設', + choosePreset: '選擇預設', + preset_normal: '平直', + preset_brightVocal: '明亮人聲', + preset_deepVoice: '低沉人聲', + preset_podcast: '播客', + preset_vocalClarity: '清晰人聲', + preset_warmVocal: '溫暖人聲', + + aecOff: '關閉', + aecLight: '輕量(省 CPU)', + aecModel: '模型(更強)', + aecModelHint: + '模型版抑制更強,但約是輕量版的 3 倍 CPU;舊機器建議用輕量。', + aecHint: + '外放時壓掉揚聲器回授;需要揚聲器參考訊號,戴耳機可以關掉。', + buffer: '輸出緩衝區', title: '語音增強設定', description: 'RNNoise 更輕量;PureVox 模型降噪耗時較高。設定於下一次錄音生效。', noise: '降噪演算法', @@ -1569,6 +1598,10 @@ export const zhTW: typeof zhCN = { '轉寫不超過 N 個字(不計空白)時直接插入原文,不呼叫潤色模型。0 = 關閉。翻譯模式始終潤色。', polishContextWindowLabel: '對話上下文視窗(分鐘)', polishContextWindowDesc: '把最近 N 分鐘內已潤色的轉寫作為多輪上下文,0 = 關閉。', + debugArchiveProcessedLabel: '儲存處理後的音訊', + debugArchiveProcessedDesc: '降噪與增益之後的版本,仍存於原本的錄製檔案。', + debugArchiveRawLabel: '另存一份處理前的音訊', + debugArchiveRawDesc: '同一目錄下另存 .raw.wav 便於對照;只開這一項時,原始音訊直接存進主檔案。', recordAudioForDebugLabel: '保留原始錄音(除錯)', recordAudioForDebugDesc: '儲存原始麥克風音訊為 wav,便於排查識別問題。', audioRecordingMaxEntriesLabel: '原始錄音保留條數', diff --git a/openless-all/app/src/lib/audioDsp.ts b/openless-all/app/src/lib/audioDsp.ts index 9bce1b732..beec1d6d7 100644 --- a/openless-all/app/src/lib/audioDsp.ts +++ b/openless-all/app/src/lib/audioDsp.ts @@ -1,6 +1,10 @@ import type { AudioDspConfig } from './types'; export const defaultAudioDsp: AudioDspConfig = { + aecMode: 'off', + androidAecEnabled: false, + androidNoiseSuppressionEnabled: false, + androidAgcEnabled: false, noiseReduction: 'rnnoise', noiseStrength: 80, gainDb: 0, @@ -14,7 +18,25 @@ export const defaultAudioDsp: AudioDspConfig = { equalizerEnabled: false, equalizerPreampDb: 0, equalizerBandsDb: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - processingOrder: ['noiseReduction', 'dereverb', 'equalizer', 'amplifier', 'agc', 'vad'], + processingOrder: [ + 'acousticEchoCancellation', + 'noiseReduction', + 'dereverb', + 'equalizer', + 'amplifier', + 'agc', + 'vad', + ], +}; + +// MicYou EqualizerPanel.vue, pinned upstream revision in micyou-upstream.json. +export const audioEqualizerPresets = { + normal: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + brightVocal: [-2, -1, 0, 1, 2, 3, 4, 4, 3, 2], + deepVoice: [3, 4, 4, 3, 1, 0, -1, -2, -2, -1], + podcast: [2, 3, 2, 0, -1, -1, 1, 2, 3, 2], + vocalClarity: [-1, -1, 0, 1, 2, 3, 4, 3, 2, 1], + warmVocal: [1, 2, 3, 2, 1, 0, -1, -1, 0, 1], }; export const equalizerFrequencies = [ diff --git a/openless-all/app/src/lib/ipc/android-audio.ts b/openless-all/app/src/lib/ipc/android-audio.ts new file mode 100644 index 000000000..bc7bfb7f1 --- /dev/null +++ b/openless-all/app/src/lib/ipc/android-audio.ts @@ -0,0 +1,27 @@ +import { invokeOrMock } from './shared'; + +export interface AndroidAudioEffectsStatus { + aecAvailable: boolean; + noiseSuppressionAvailable: boolean; + agcAvailable: boolean; + runtime: null | { + active: boolean; + aec?: boolean; + noiseSuppression?: boolean; + agc?: boolean; + error: string | null; + }; +} + +export function getAndroidAudioEffectsStatus(): Promise { + return invokeOrMock('get_android_audio_effects_status', undefined, () => ({ + aecAvailable: false, + noiseSuppressionAvailable: false, + agcAvailable: false, + runtime: null, + })); +} + +export function setAndroidAudioMonitoring(enabled: boolean): Promise { + return invokeOrMock('set_android_audio_monitoring', { enabled }, () => undefined); +} diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 48a7178bb..1c5014401 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -146,6 +146,8 @@ export let mockSettings: UserPreferences = { autoUpdateCheck: true, historyMaxEntries: null, recordAudioForDebug: false, + debugArchiveProcessed: false, + debugArchiveRaw: false, audioRecordingMaxEntries: null, quickNoteExportDirectory: '', marketplaceBaseUrl: 'https://apic.openless.top', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 34042ce47..32e2b5618 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -341,8 +341,19 @@ export interface StylePackRuntimeDiagnostics { } export type AudioDspStage = - 'noiseReduction' | 'dereverb' | 'equalizer' | 'amplifier' | 'agc' | 'vad'; + | 'acousticEchoCancellation' + | 'noiseReduction' + | 'dereverb' + | 'equalizer' + | 'amplifier' + | 'agc' + | 'vad'; export interface AudioDspConfig { + /** 回声消除算法:轻量 NLMS 省 CPU,aec7 模型抑制更强但约 3 倍开销。 */ + aecMode: 'off' | 'light' | 'model'; + androidAecEnabled?: boolean; + androidNoiseSuppressionEnabled?: boolean; + androidAgcEnabled?: boolean; noiseReduction: 'off' | 'rnnoise' | 'purevox' | 'spectral'; noiseStrength: number; gainDb: number; @@ -594,6 +605,10 @@ export interface UserPreferences { /** Whether to keep the raw microphone audio file (wav) per session, for debugging ASR misrecognition / mic sensitivity. * Default false. When on, it uses disk space and follows the same cleanup policy as historyRetentionDays. */ recordAudioForDebug: boolean; + /** 调试录音:追加保存「处理后」的音频(主档案 `.wav`)。 */ + debugArchiveProcessed: boolean; + /** 调试录音:额外保存「处理前」的 `.raw.wav`,用于 A/B 对照。 */ + debugArchiveRaw: boolean; /** Number of recent wav files kept in recordings/. null = follows the 200 hard cap; 1..=200 for user-defined values. * Decoupled from historyMaxEntries — many text entries with only the 5 most recent wavs is a valid combination. */ audioRecordingMaxEntries: number | null; diff --git a/openless-all/app/src/pages/settings/AndroidAudioEffects.tsx b/openless-all/app/src/pages/settings/AndroidAudioEffects.tsx new file mode 100644 index 000000000..9b052a25d --- /dev/null +++ b/openless-all/app/src/pages/settings/AndroidAudioEffects.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { AudioDspConfig } from '../../lib/types'; +import { + getAndroidAudioEffectsStatus, + setAndroidAudioMonitoring, + type AndroidAudioEffectsStatus, +} from '../../lib/ipc/android-audio'; +import { isTauri } from '../../lib/ipc/shared'; +import { SettingRow, Toggle } from './shared'; + +type Spectrum = { raw: number[]; processed: number[] }; + +export function AndroidAudioEffects({ + value, + onChange, +}: { + value: AudioDspConfig; + onChange: (patch: Partial) => void; +}) { + const { t } = useTranslation(); + const label = (key: string, fallback: string) => + t(`settings.recording.dsp.${key}`, { defaultValue: fallback }); + const [status, setStatus] = useState(null); + const [monitoring, setMonitoring] = useState(false); + const [spectrum, setSpectrum] = useState(null); + const [error, setError] = useState(''); + useEffect(() => { + let disposed = false; + const refresh = () => { + if (document.hidden) return; + void getAndroidAudioEffectsStatus() + .then((next) => { + if (!disposed) setStatus(next); + }) + .catch(() => {}); + }; + refresh(); + const timer = window.setInterval(refresh, 2000); + return () => { + disposed = true; + window.clearInterval(timer); + }; + }, []); + useEffect(() => { + if (!monitoring || !isTauri) { + setSpectrum(null); + return; + } + let disposed = false; + let unlisten: (() => void) | undefined; + let timer: number | undefined; + const renew = () => { + if (document.hidden) setSpectrum(null); + void setAndroidAudioMonitoring(!document.hidden).catch((reason: unknown) => { + if (!disposed) setError(String(reason)); + }); + }; + void import('@tauri-apps/api/event') + .then(async ({ listen }) => { + const stop = await listen('android-audio-spectrum', ({ payload }) => { + if (disposed || document.hidden) return; + if (payload.raw.length === 64 && payload.processed.length === 64) setSpectrum(payload); + }); + if (disposed) { + stop(); + return; + } + unlisten = stop; + renew(); + timer = window.setInterval(renew, 1000); + }) + .catch((reason: unknown) => { + if (!disposed) setError(String(reason)); + }); + document.addEventListener('visibilitychange', renew); + return () => { + disposed = true; + unlisten?.(); + window.clearInterval(timer); + document.removeEventListener('visibilitychange', renew); + void setAndroidAudioMonitoring(false).catch(() => {}); + }; + }, [monitoring]); + const toggle = ( + field: 'androidAecEnabled' | 'androidNoiseSuppressionEnabled' | 'androidAgcEnabled', + title: string, + available: boolean, + ) => ( +
+ + { + if (available || value[field]) onChange({ [field]: !value[field] }); + }} + /> + +
+ ); + return ( +
+

+ {label( + 'androidEffectsHint', + 'Android system effects use the microphone session. They apply before the software chain; avoid enabling the same effect twice. Unsupported startup falls back to normal capture.', + )} +

+ {toggle( + 'androidAecEnabled', + label('aec', 'System echo cancellation'), + !!status?.aecAvailable, + )} + {toggle( + 'androidNoiseSuppressionEnabled', + label('systemNoise', 'System noise suppression'), + !!status?.noiseSuppressionAvailable, + )} + {toggle( + 'androidAgcEnabled', + label('systemAgc', 'System automatic gain'), + !!status?.agcAvailable, + )} + {status?.runtime?.error && ( +

+ {status.runtime.error} +

+ )} + {status?.runtime?.active && ( +

+ {label('effectsActive', 'System effects are active for this recording.')} +

+ )} + + { + setError(''); + setMonitoring(!monitoring); + }} + /> + + {monitoring && ( + <> + + {Array.from({ length: 64 }, (_, i) => { + const raw = Math.max(0, Math.min(1, spectrum?.raw[i] || 0)) * 76; + const processed = Math.max(0, Math.min(1, spectrum?.processed[i] || 0)) * 76; + return ( + + + + + ); + })} + + {label('spectrumLegend', 'Gray: input · Color: processed · 20 Hz–24 kHz')} + {!spectrum && ( +

+ {label( + 'spectrumWaiting', + 'Start a recording with speech enhancement enabled to see the spectrum.', + )} +

+ )} + + )} + {error &&

{error}

} +
+ ); +} diff --git a/openless-all/app/src/pages/settings/AudioDspSettings.tsx b/openless-all/app/src/pages/settings/AudioDspSettings.tsx index 971e63c1a..908848129 100644 --- a/openless-all/app/src/pages/settings/AudioDspSettings.tsx +++ b/openless-all/app/src/pages/settings/AudioDspSettings.tsx @@ -1,7 +1,9 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { AudioDspConfig, AudioDspStage } from '../../lib/types'; -import { defaultAudioDsp, equalizerFrequencies } from '../../lib/audioDsp'; +import { audioEqualizerPresets, defaultAudioDsp, equalizerFrequencies } from '../../lib/audioDsp'; +import { isAndroid } from '../../lib/platform'; +import { AndroidAudioEffects } from './AndroidAudioEffects'; import { SelectLite } from '../../components/ui/SelectLite'; import { SettingRow, Toggle } from './shared'; @@ -13,6 +15,7 @@ export function AudioDspSettings({ onChange: (value: AudioDspConfig) => void; }) { const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); const [draft, setDraft] = useState({ ...defaultAudioDsp, ...value }); useEffect(() => setDraft({ ...defaultAudioDsp, ...value }), [value]); const label = (key: string, fallback: string) => @@ -53,6 +56,7 @@ export function AudioDspSettings({
); const stageLabels: Record = { + acousticEchoCancellation: label('aec', 'Echo cancellation'), noiseReduction: label('noise', 'Noise reduction'), dereverb: label('dereverb', 'Comb filter'), equalizer: label('equalizer', 'Equalizer'), @@ -68,7 +72,10 @@ export function AudioDspSettings({ save({ processingOrder: order }); }; return ( -
+
setExpanded(event.currentTarget.open)} + > {label('title', 'Speech enhancement settings')} @@ -78,6 +85,30 @@ export function AudioDspSettings({ 'RNNoise is lightweight; PureVox offers model-based noise reduction with higher CPU use. Changes apply to the next recording.', )}

+ + save({ aecMode: v as AudioDspConfig['aecMode'] })} + options={[ + { value: 'off', label: label('aecOff', 'Off') }, + { value: 'light', label: label('aecLight', 'Light (low CPU)') }, + { value: 'model', label: label('aecModel', 'Model (stronger)') }, + ]} + /> + + {draft.aecMode === 'model' && + label( + 'aecModelHint', + 'The model cancels more echo but costs about 3x the CPU; prefer Light on older machines.', + )} + {isAndroid() && expanded && } {draft.noiseReduction !== 'off' && slider('noiseStrength', label('strength', 'Noise reduction strength'), 0, 100, '%')} - {slider('gainDb', stageLabels.amplifier, -30, 30, ' dB')} + {slider('gainDb', stageLabels.amplifier, -50, 50, ' dB')} save({ agcEnabled: !draft.agcEnabled })} /> {draft.agcEnabled && ( <> - {slider('agcTarget', label('target', 'Target level'), 5, 90, '%')} + {slider('agcTarget', label('target', 'Target level'), 0, 100, '%')} {slider('agcAttack', label('attack', 'Attack speed'), 1, 100)} {slider('agcDecay', label('decay', 'Release speed'), 1, 100)} @@ -114,7 +145,7 @@ export function AudioDspSettings({ save({ vadEnabled: !draft.vadEnabled })} /> {draft.vadEnabled && - slider('vadThresholdDb', label('threshold', 'Speech gate threshold'), -80, -10, ' dB')} + slider('vadThresholdDb', label('threshold', 'Speech gate threshold'), -100, 0, ' dB')}
{label('tone', 'Tone and room adjustments')} @@ -134,6 +165,23 @@ export function AudioDspSettings({ {draft.equalizerEnabled && ( <> + + ({ + value: key, + label: label(`preset_${key}`, key), + })), + ]} + onChange={(key) => { + const bands = audioEqualizerPresets[key as keyof typeof audioEqualizerPresets]; + if (bands) save({ equalizerBandsDb: [...bands] }); + }} + /> + {slider('equalizerPreampDb', label('preamp', 'Equalizer preamp'), -20, 20, ' dB')}
- savePrefs({ ...prefs, recordAudioForDebug }); + const onRecordAudioForDebugChange = (recordAudioForDebug: boolean) => { + // 打开主开关时如果两项都没勾,默认勾上「处理后」,否则开了开关却什么都不写。 + const keepSomething = + prefs.debugArchiveProcessed || prefs.debugArchiveRaw || !recordAudioForDebug; + void savePrefs({ + ...prefs, + recordAudioForDebug, + debugArchiveProcessed: keepSomething ? prefs.debugArchiveProcessed : true, + }); + }; + const onDebugArchiveProcessedChange = (debugArchiveProcessed: boolean) => + void savePrefs({ ...prefs, debugArchiveProcessed }); + const onDebugArchiveRawChange = (debugArchiveRaw: boolean) => + void savePrefs({ ...prefs, debugArchiveRaw }); // Empty means unlimited, falling back to null → the backend applies its 200 default. const onAudioRecordingMaxEntriesChange = (raw: string) => { const trimmed = raw.trim(); @@ -112,6 +124,28 @@ export function DebugToolsSection() { + {prefs.recordAudioForDebug && ( + <> + + onDebugArchiveProcessedChange(!prefs.debugArchiveProcessed)} + /> + + + onDebugArchiveRawChange(!prefs.debugArchiveRaw)} + /> + + + )}
= ["snapshot", "library", "searchHistory", "historyDetail", "audioAsset", "vocabulary", "styleLibrary", "styleDetail", "capabilitySnapshot", "translationConfiguration", "marketplaceList", "marketplaceDetail", "listModels", "validate"] + let reads: Set = ["audioSettings", "snapshot", "library", "searchHistory", "historyDetail", "audioAsset", "vocabulary", "styleLibrary", "styleDetail", "capabilitySnapshot", "translationConfiguration", "marketplaceList", "marketplaceDetail", "listModels", "validate"] if let name = action["action"] as? String { let nested = (action["request"] as? [String: Any])?["command"] as? String let localRead = (name == "vocabularyBridge" && ["snapshot", "proposals"].contains(nested ?? "")) diff --git a/openless-all/ios/App/SettingsView.swift b/openless-all/ios/App/SettingsView.swift index 6d0a75f3f..c4b168ecc 100644 --- a/openless-all/ios/App/SettingsView.swift +++ b/openless-all/ios/App/SettingsView.swift @@ -108,8 +108,12 @@ struct SettingsView: View { DisclosureGroup { SystemRecordingInstructions() } label: { SettingsLabel("在其他 App 中录音", symbol: "mic.badge.plus") } } } - if matches("语音识别 准备 识别方式 speech recognition") { + if matches("语音识别 准备 识别方式 语音增强 降噪 增益 均衡器 speech recognition audio enhancement noise gain equalizer") { Section("语音识别") { + NavigationLink { + NativeAudioSettingsView(core: controller.styleLibraryCore, blocked: controller.styleLibraryActivityBusy) + } label: { SettingsLabel("语音增强", symbol: "waveform.path") } + .accessibilityIdentifier("audioEnhancementSettings") AdaptiveRow(title: controller.speechPrepared ? "识别已就绪" : "准备语音识别", icon: "waveform") { Button(controller.preparing ? "准备中…" : "准备") { Task { await controller.prepareSpeech(allowDownload: false) } @@ -182,6 +186,154 @@ struct SettingsView: View { } } +private struct NativeAudioDsp: Codable { + // Keep shared fields round-trippable even though iOS does not expose every host-specific control. + var aecMode = "off" + var androidAecEnabled = false + var androidNoiseSuppressionEnabled = false + var androidAgcEnabled = false + var noiseReduction = "rnnoise" + var noiseStrength = 80 + var gainDb = 0 + var agcEnabled = true + var agcTarget = 35 + var agcAttack = 50 + var agcDecay = 50 + var vadEnabled = false + var vadThresholdDb = -50 + var dereverbLevel = 0 + var equalizerEnabled = false + var equalizerPreampDb = 0 + var equalizerBandsDb = Array(repeating: 0, count: 10) + var processingOrder = ["noiseReduction", "dereverb", "equalizer", "amplifier", "agc", "vad"] +} + +private struct NativeAudioSettings: Codable { + var enabled = false + var dsp = NativeAudioDsp() +} + +private struct NativeAudioSettingsView: View { + @EnvironmentObject private var interfacePreferences: InterfacePreferences + let core: CoreBridge? + let blocked: Bool + @State private var value = NativeAudioSettings() + @State private var loaded = false + @State private var saving = false + @State private var message: String? + private let presets: [(String, [Int])] = [ + ("平直", Array(repeating: 0, count: 10)), + ("明亮人声", [-2,-1,0,1,2,3,4,4,3,2]), + ("低沉人声", [3,4,4,3,1,0,-1,-2,-2,-1]), + ("播客", [2,3,2,0,-1,-1,1,2,3,2]), + ("清晰人声", [-1,-1,0,1,2,3,4,3,2,1]), + ("温暖人声", [1,2,3,2,1,0,-1,-1,0,1]) + ] + var body: some View { + Form { + if let message { + Section { Text(t(message)); Button("重新加载") { Task { await load() } }.disabled(saving || blocked) } + } + Group { + Section { + Toggle("语音增强", isOn: $value.enabled) + .accessibilityIdentifier("nativeAudioEnhancementToggle") + } footer: { + Text("设置保存后在下一次录音生效。RNNoise 较轻量,PureVox 使用更多计算资源。默认保持原有音频效果。") + } + if value.enabled { + Section("降噪与增益") { + Picker("降噪算法", selection: $value.dsp.noiseReduction) { + Text("关闭").tag("off"); Text("RNNoise").tag("rnnoise") + Text("PureVox 6").tag("purevox"); Text("频谱减法").tag("spectral") + }.accessibilityIdentifier("nativeAudioAlgorithmPicker") + control("降噪强度", \.noiseStrength, 0...100) + control("输入增益 dB", \.gainDb, -50...50) + Toggle("自动增益", isOn: $value.dsp.agcEnabled) + if value.dsp.agcEnabled { + control("目标电平 %", \.agcTarget, 0...100) + control("起音速度", \.agcAttack, 1...100) + control("释放速度", \.agcDecay, 1...100) + } + Toggle("语音门限", isOn: $value.dsp.vadEnabled) + if value.dsp.vadEnabled { control("门限 dB", \.vadThresholdDb, -100...0) } + } + Section { + control("梳状滤波 %", \.dereverbLevel, 0...100) + Toggle("十段均衡器", isOn: $value.dsp.equalizerEnabled) + if value.dsp.equalizerEnabled { + Menu("均衡器预设") { + ForEach(presets.indices, id: \.self) { i in + Button(t(presets[i].0)) { value.dsp.equalizerBandsDb = presets[i].1 } + } + } + control("前级增益 dB", \.equalizerPreampDb, -20...20) + ForEach(value.dsp.equalizerBandsDb.indices, id: \.self) { i in + VStack(alignment: .leading) { + Text("\(["31 Hz","63 Hz","125 Hz","250 Hz","500 Hz","1 kHz","2 kHz","4 kHz","8 kHz","16 kHz"][i]): \(value.dsp.equalizerBandsDb[i]) dB") + Slider(value: Binding(get: { Double(value.dsp.equalizerBandsDb[i]) }, set: { value.dsp.equalizerBandsDb[i] = Int($0.rounded()) }), in: -15...15, step: 1) + } + } + } + } header: { Text("音色与房间调整") } footer: { + Text("梳状滤波可能改变音色,建议从 0 开始。语音门限不会自动停止录音,过高会丢失轻声。") + } + Section("处理顺序") { + ForEach(value.dsp.processingOrder, id: \.self) { stage in + HStack { + Text(stageName(stage)) + Spacer() + Button { move(stage, -1) } label: { Image(systemName: "arrow.up") }.buttonStyle(.borderless) + .accessibilityLabel("上移 \(stageName(stage))") + Button { move(stage, 1) } label: { Image(systemName: "arrow.down") }.buttonStyle(.borderless) + .accessibilityLabel("下移 \(stageName(stage))") + } + } + } + Section { Button("恢复默认增强设置") { value.dsp = NativeAudioDsp() } } + } + }.disabled(!loaded || blocked || saving) + } + .navigationTitle("语音增强") + .toolbar { Button(t(saving ? "保存中…" : "保存")) { Task { await save() } }.disabled(!loaded || blocked || saving || core == nil).accessibilityIdentifier("nativeAudioSave") } + .task { await load() } + } + private func control(_ title: String, _ key: WritableKeyPath, _ range: ClosedRange) -> some View { + VStack(alignment: .leading) { + Text("\(t(title)): \(value.dsp[keyPath: key])") + Slider(value: Binding(get: { Double(value.dsp[keyPath: key]) }, set: { value.dsp[keyPath: key] = Int($0.rounded()) }), in: Double(range.lowerBound)...Double(range.upperBound), step: 1) + } + } + private func stageName(_ key: String) -> String { + t(["noiseReduction":"降噪", "dereverb":"梳状滤波", "equalizer":"均衡器", "amplifier":"输入增益", "agc":"自动增益", "vad":"语音门限"][key] ?? key) + } + private func t(_ key: String) -> String { interfacePreferences.localizer.text(key) } + private func move(_ stage: String, _ delta: Int) { + guard let i = value.dsp.processingOrder.firstIndex(of: stage), value.dsp.processingOrder.indices.contains(i + delta) else { return } + value.dsp.processingOrder.swapAt(i, i + delta) + } + private func load() async { + guard let core else { message = "音频设置暂不可用,请稍后重试。"; return } + saving = true + defer { saving = false } + do { + value = try JSONDecoder().decode(NativeAudioSettings.self, from: await core.control(["action":"audioSettings"])) + loaded = true; message = nil + } catch { message = "无法读取音频设置,请重试。" } + } + private func save() async { + guard let core, !blocked else { return } + saving = true + defer { saving = false } + do { + let dsp = try JSONSerialization.jsonObject(with: JSONEncoder().encode(value.dsp)) + let data = try await core.control(["action":"updateAudioSettings", "enabled":value.enabled, "dsp":dsp]) + value = try JSONDecoder().decode(NativeAudioSettings.self, from: data) + message = "已保存,下次录音生效。" + } catch { message = "保存失败,当前修改尚未应用。" } + } +} + private struct AccountSettingsDestination: View { @ObservedObject var controller: AppController @ObservedObject var productLibrary: ProductLibrary diff --git a/openless-all/ios/App/Speech/AudioPCM.swift b/openless-all/ios/App/Speech/AudioPCM.swift index a1993bd2a..b1e8333b0 100644 --- a/openless-all/ios/App/Speech/AudioPCM.swift +++ b/openless-all/ios/App/Speech/AudioPCM.swift @@ -60,6 +60,23 @@ nonisolated struct AudioPacket: Sendable { /// Owns the resampler off the UI and real-time audio callback. actor PCMWorker { private var converter: AVAudioConverter? + private var scratch: [AVAudioPCMBuffer] = [] + + private func outputBuffer(format: AVAudioFormat, capacity: AVAudioFrameCount) throws -> AVAudioPCMBuffer { + // Converter output capacity affects its pull requests at fractional + // ratios (e.g. 44.1 -> 16 kHz). Preserve the original capacity + // exactly, including flush; do not substitute a larger cached buffer. + if let output = scratch.first(where: { $0.format == format && $0.frameCapacity == capacity }) { + output.frameLength = 0 + return output + } + guard let output = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: capacity) else { throw VoiceFailure.format } + // Two entries cover alternating packet sizes without retaining an + // unbounded set of device callback capacities. + if scratch.count == 2 { scratch.removeFirst() } + scratch.append(output) + return output + } func convert(_ packet: AudioPacket) throws -> Data { let input = try packet.makeBuffer() let inputFormat = input.format @@ -75,31 +92,69 @@ actor PCMWorker { guard let converter else { throw VoiceFailure.format } let capacity = AVAudioFrameCount( ceil(Double(input.frameLength) * 16_000 / inputFormat.sampleRate) + 32) - guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { - throw VoiceFailure.format - } - var supplied = false - var error: NSError? - let status = converter.convert(to: output, error: &error) { _, flag in - if supplied { - flag.pointee = .noDataNow - return nil + var cursor: AVAudioFrameCount = 0 + var invalidInput = false + var retainedInput: AVAudioPCMBuffer? + var pcm = Data() + // A filled output buffer does not imply that the converter has consumed + // the entire input packet. Drain .haveData before accepting the next + // packet, otherwise alternating packet sizes can silently lose frames. + for _ in 0..<8 { + let output = try outputBuffer(format: outputFormat, capacity: capacity) + var error: NSError? + let status = converter.convert(to: output, error: &error) { requested, flag in + if cursor == input.frameLength { + flag.pointee = .noDataNow + return nil + } + let frames = min(AVAudioFrameCount(requested), input.frameLength - cursor) + guard frames > 0 else { invalidInput = true; flag.pointee = .noDataNow; return nil } + flag.pointee = .haveData + if cursor == 0 && frames == input.frameLength { + cursor = input.frameLength + return input + } + // Respect the converter's requested frame count. Returning a whole + // large packet and marking it supplied loses its unconsumed suffix. + guard let slice = AVAudioPCMBuffer(pcmFormat: inputFormat, frameCapacity: frames) else { + invalidInput = true; flag.pointee = .noDataNow; return nil + } + slice.frameLength = frames + let source = UnsafeMutableAudioBufferListPointer(input.mutableAudioBufferList) + let target = UnsafeMutableAudioBufferListPointer(slice.mutableAudioBufferList) + let stride = Int(inputFormat.streamDescription.pointee.mBytesPerFrame) + let offset = Int(cursor) * stride + let count = Int(frames) * stride + guard stride > 0, source.count == target.count else { invalidInput = true; flag.pointee = .noDataNow; return nil } + for index in source.indices { + guard let from = source[index].mData, let to = target[index].mData, + offset + count <= Int(source[index].mDataByteSize), count <= Int(target[index].mDataByteSize) + else { invalidInput = true; flag.pointee = .noDataNow; return nil } + to.copyMemory(from: from.advanced(by: offset), byteCount: count) + } + cursor += frames + retainedInput = slice + return slice + } + withExtendedLifetime(retainedInput) {} + guard !invalidInput else { throw VoiceFailure.format } + if let error { throw error } + guard status != .error else { throw VoiceFailure.format } + if output.frameLength > 0, let bytes = output.int16ChannelData?[0] { + pcm.append(Data(bytes: bytes, count: Int(output.frameLength) * 2)) + } + if status == .inputRanDry || status == .endOfStream || output.frameLength == 0 { + guard cursor == input.frameLength else { throw VoiceFailure.format } + return pcm } - supplied = true - flag.pointee = .haveData - return input } - if let error { throw error } - guard status != .error else { throw VoiceFailure.format } - guard output.frameLength > 0, let bytes = output.int16ChannelData?[0] else { return Data() } - return Data(bytes: bytes, count: Int(output.frameLength) * 2) + throw VoiceFailure.format } func finish() throws -> [Data] { guard let converter else { return [] } var tail: [Data] = [] for _ in 0..<8 { - guard let output = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: 2048) - else { throw VoiceFailure.format } + let output = try outputBuffer(format: converter.outputFormat, capacity: 2048) var error: NSError? let status = converter.convert(to: output, error: &error) { _, flag in flag.pointee = .endOfStream diff --git a/openless-all/ios/CoreHost/src/control.rs b/openless-all/ios/CoreHost/src/control.rs index 54e4691c6..1e6b7e667 100644 --- a/openless-all/ios/CoreHost/src/control.rs +++ b/openless-all/ios/CoreHost/src/control.rs @@ -25,6 +25,8 @@ pub(crate) enum ControlAction { AccountSync { request: Value }, VocabularyBridge { request: Value }, Snapshot, + AudioSettings, + UpdateAudioSettings { enabled: bool, dsp: openless_core::AudioDspConfig }, StyleLibrary, StyleDetail { id: String }, SetStyleEnabled { id: String, enabled: bool }, @@ -194,6 +196,7 @@ impl Host { return; } let product_action = matches!(action, + ControlAction::AudioSettings | ControlAction::UpdateAudioSettings { .. } | ControlAction::AccountSync { .. } | ControlAction::VocabularyBridge { .. } | ControlAction::Library | ControlAction::SearchHistory { .. } | ControlAction::HistoryDetail { .. } | ControlAction::DeleteHistory { .. } | ControlAction::ClearLibraryHistory | ControlAction::AudioAsset { .. } | ControlAction::Vocabulary { .. } @@ -216,7 +219,7 @@ impl Host { // interval before Core has installed its own session. No start/save race. let admission = self.admission.lock().unwrap(); if admission.is_some() && !matches!(action, - ControlAction::Snapshot | ControlAction::Library | ControlAction::SearchHistory { .. } + ControlAction::AudioSettings | ControlAction::Snapshot | ControlAction::Library | ControlAction::SearchHistory { .. } | ControlAction::HistoryDetail { .. } | ControlAction::Vocabulary { .. } | ControlAction::StyleLibrary | ControlAction::StyleDetail { .. } | ControlAction::CapabilitySnapshot | ControlAction::TranslationConfiguration) { @@ -355,6 +358,21 @@ impl Host { } pub(super) async fn control(&self, action: ControlAction) -> Result { match action { + ControlAction::AudioSettings => { + let prefs = self.backend.get_preferences(); + Ok(json!({"enabled":prefs.audio_enhancement_enabled,"dsp":prefs.audio_dsp})) + } + ControlAction::UpdateAudioSettings { enabled, dsp } => { + let mut prefs = self.backend.get_preferences(); + prefs.audio_enhancement_enabled = enabled; + prefs.audio_dsp = dsp.normalized(); + prefs.audio_dsp.android_aec_enabled = false; + prefs.audio_dsp.android_noise_suppression_enabled = false; + prefs.audio_dsp.android_agc_enabled = false; + self.backend.update_settings(prefs, SettingsUpdateOptions::STRICT, &NoopSettingsRuntime)?; + let prefs = self.backend.get_preferences(); + Ok(json!({"enabled":prefs.audio_enhancement_enabled,"dsp":prefs.audio_dsp})) + } ControlAction::AccountSync { request } => self.account_sync.execute(request).await, ControlAction::VocabularyBridge { request } => vocabulary_bridge::execute(&self.backend, self.vocabulary_metadata_directory()?, request), ControlAction::Snapshot => self.control_snapshot().await, diff --git a/openless-all/ios/CoreHost/src/control_tests.rs b/openless-all/ios/CoreHost/src/control_tests.rs index 14616fdfb..bcc9e4016 100644 --- a/openless-all/ios/CoreHost/src/control_tests.rs +++ b/openless-all/ios/CoreHost/src/control_tests.rs @@ -3,6 +3,73 @@ use super::*; use credentials::{IosCredentialStore, MemoryVault}; use openless_core::credentials::{CredentialKey, CredentialNamespace, SecretValue}; +#[test] +fn audio_settings_are_available_without_developer_mode_and_persist() { + let directory = tempfile::tempdir().unwrap(); + let storage = Arc::new(MemoryVault::default()); + let (handle, context) = open_at(directory.path(), false, storage.clone()); + let initial = result(control(handle, context, json!({"action":"audioSettings"}))); + assert_eq!(initial["enabled"], false); + let saved = result(control(handle, context, json!({"action":"updateAudioSettings","enabled":true,"dsp":{"noiseReduction":"off","gainDb":100,"androidAecEnabled":true}}))); + assert_eq!(saved["enabled"], true); + assert_eq!(saved["dsp"]["gainDb"], 50); + assert_eq!(saved["dsp"]["androidAecEnabled"], false); + ol_ios_destroy(handle); + let (handle, context) = open_at(directory.path(), false, storage); + assert_eq!(result(control(handle, context, json!({"action":"audioSettings"}))), saved); + ol_ios_destroy(handle); +} + +#[test] +fn native_capture_enhancement_and_stop_flush_match_shared_processor() { + #[derive(Default)] + struct Sink(Mutex>); + impl AudioConsumer for Sink { + fn consume_pcm_chunk(&self, pcm: &[u8]) { self.0.lock().unwrap().extend_from_slice(pcm); } + } + impl RecordingProgressSink for Sink { + fn publish_level(&self, _: u64, _: f32) -> Result<(), BackendError> { Ok(()) } + } + let directory = tempfile::tempdir().unwrap(); + let (handle, context) = open_at(directory.path(), false, Arc::new(MemoryVault::default())); + let owner = host(handle).unwrap(); + let id = SessionId::new(); + let config = openless_core::AudioProcessingConfig { + enhancement_enabled: true, + echo_cancellation_enabled: false, + dsp: openless_core::AudioDspConfig { noise_reduction: openless_core::NoiseReduction::Off, gain_db: 6, agc_enabled: false, ..Default::default() }, + }; + let sink = Arc::new(Sink::default()); + owner.broker.captures.lock().unwrap().insert(id, Arc::new(Capture { + processing: Some(Mutex::new((openless_core::AudioProcessor::new(config), Vec::new()))), + consumer: sink.clone(), progress: sink.clone(), bytes: AtomicU64::new(0), archive: None, + })); + let mut reference = openless_core::AudioProcessor::new(config); + let mut expected = Vec::new(); + let pcm: Vec = (0..1001).flat_map(|i| (((i as f32 * 0.07).sin() * 3000.0) as i16).to_le_bytes()).collect(); + let session = CString::new(id.to_string()).unwrap(); + for bytes in pcm.chunks(640) { + assert_eq!(unsafe { ol_ios_feed_pcm(handle, session.as_ptr(), bytes.as_ptr(), bytes.len()) }, 0); + let samples: Vec = bytes.chunks_exact(2).map(|p| i16::from_le_bytes([p[0],p[1]]) as f32 / 32768.0).collect(); + if let Some(chunk) = reference.process(&samples, 1, 16_000) { expected.extend(chunk.pcm_i16_le); } + } + if let Some(chunk) = reference.finish() { expected.extend(chunk.pcm_i16_le); } + let task = owner.executor.spawn(Box::new(Recording { broker: owner.broker.clone(), id, archive: None }).stop()); + loop { + let request = next(context); + if request["kind"] == "nativeRequest" { + assert_eq!(request["operation"], "captureStop"); + reply(handle, &request, Value::Null); + break; + } + } + owner.executor.block_on(task).unwrap().unwrap(); + assert_eq!(*sink.0.lock().unwrap(), expected); + assert_eq!(expected.len(), pcm.len()); + assert_ne!(expected, pcm); + ol_ios_destroy(handle); +} + pub(super) fn open_at(directory: &std::path::Path, developer: bool, storage: Arc) -> (u64, u64) { static CONTEXT: AtomicU64 = AtomicU64::new(10_000); let context = CONTEXT.fetch_add(1, Ordering::Relaxed); @@ -250,6 +317,10 @@ fn management_is_rejected_during_start_admission_and_rewrite_requires_llm() { ); assert_eq!(busy["ok"], false); assert_eq!(busy["message"], "recording or processing is active"); + let audio = result(control(handle, context, json!({"action":"audioSettings"}))); + let busy_audio = control(handle, context, json!({"action":"updateAudioSettings","enabled":true,"dsp":audio["dsp"]})); + assert_eq!(busy_audio["ok"], false); + assert_eq!(busy_audio["message"], "recording or processing is active"); // Snapshot is allowed during capture and doesn't mutate settings. result(control(handle, context, json!({"action":"snapshot"}))); assert_eq!( diff --git a/openless-all/ios/CoreHost/src/lib.rs b/openless-all/ios/CoreHost/src/lib.rs index 3e6055de6..e66d4bf92 100644 --- a/openless-all/ios/CoreHost/src/lib.rs +++ b/openless-all/ios/CoreHost/src/lib.rs @@ -77,6 +77,7 @@ struct Callbacks { context: u64, } struct Capture { + processing: Option)>>, consumer: Arc, progress: Arc, bytes: AtomicU64, @@ -185,6 +186,7 @@ impl AudioRecorder for NativeRecorder { broker.captures.lock().unwrap().insert( id, Arc::new(Capture { + processing: context.recording.audio_processing.enhancement_enabled.then(|| Mutex::new((openless_core::AudioProcessor::new(context.recording.audio_processing), Vec::new()))), consumer, progress, bytes: AtomicU64::new(0), @@ -212,7 +214,16 @@ impl ActiveRecording for Recording { .broker .request("captureStop", self.id, Value::Null) .await; - self.broker.captures.lock().unwrap().remove(&self.id); + let capture = self.broker.captures.lock().unwrap().remove(&self.id); + if result.is_ok() { + if let Some(capture) = capture { + let tail = capture.processing.as_ref().and_then(|processing| processing.lock().unwrap().0.finish()); + if let Some(tail) = tail { + capture.consumer.consume_pcm_chunk(&tail.pcm_i16_le); + if let Some(archive) = &capture.archive { archive.append(&tail.pcm_i16_le); } + } + } + } if let Some(archive) = &self.archive { archive.finish(); } result.map(|_| ()) }) @@ -799,9 +810,19 @@ pub unsafe extern "C" fn ol_ios_feed_pcm( let capture = owner.broker.captures.lock().unwrap().get(&session).cloned(); let Some(capture) = capture else { return 1; }; let bytes = unsafe { std::slice::from_raw_parts(pcm,len) }; + let chunk = if let Some(processing) = &capture.processing { + let mut processing = processing.lock().unwrap(); + let (processor, samples) = &mut *processing; + samples.clear(); + samples.extend(bytes.chunks_exact(2).map(|p| i16::from_le_bytes([p[0], p[1]]) as f32 / 32768.0)); + Some(processor.process(samples, 1, 16_000)) + } else { None }; + let elapsed = capture.bytes.fetch_add(len as u64,Ordering::Relaxed).saturating_add(len as u64) * 1000 / 32_000; + if matches!(chunk, Some(None)) { return 0; } + let processed = chunk.flatten(); + let bytes = processed.as_ref().map(|chunk| chunk.pcm_i16_le.as_slice()).unwrap_or(bytes); if let Some(archive) = &capture.archive { archive.append(bytes); } capture.consumer.consume_pcm_chunk(bytes); - let elapsed = capture.bytes.fetch_add(len as u64,Ordering::Relaxed).saturating_add(len as u64) * 1000 / 32_000; let level = openless_core::external_audio::pcm_i16_le_rms(bytes); let _ = capture.progress.publish_level(elapsed,level); owner.broker.emit(json!({"kind":"level","sessionID":session.to_string(),"level":level,"elapsedMs":elapsed})); diff --git a/openless-all/ios/CoreHost/src/recording_maintenance_tests.rs b/openless-all/ios/CoreHost/src/recording_maintenance_tests.rs index a71bf0383..d90d10be9 100644 --- a/openless-all/ios/CoreHost/src/recording_maintenance_tests.rs +++ b/openless-all/ios/CoreHost/src/recording_maintenance_tests.rs @@ -229,6 +229,7 @@ fn idle_gate_never_scans_starting_capture_or_concurrent_management() { owner.broker.captures.lock().unwrap().insert( session, Arc::new(Capture { + processing: None, consumer: Arc::new(Sink), progress: Arc::new(Sink), bytes: AtomicU64::new(0), diff --git a/openless-all/ios/Resources/LocalizationSources.json b/openless-all/ios/Resources/LocalizationSources.json index 825542855..7c1bea1f2 100644 --- a/openless-all/ios/Resources/LocalizationSources.json +++ b/openless-all/ios/Resources/LocalizationSources.json @@ -6737,6 +6737,232 @@ "Keyboard/KeyboardViewController.swift" ], "translationSource": "Native iOS translation" + }, + "语音识别 准备 识别方式 语音增强 降噪 增益 均衡器 speech recognition audio enhancement noise gain equalizer": { + "callsites": [ + "App/SettingsView.swift:111" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "语音增强": { + "callsites": [ + "App/SettingsView.swift:115", + "App/SettingsView.swift:234", + "App/SettingsView.swift:292" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "平直": { + "callsites": [ + "App/SettingsView.swift:220" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "明亮人声": { + "callsites": [ + "App/SettingsView.swift:221" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "低沉人声": { + "callsites": [ + "App/SettingsView.swift:222" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "播客": { + "callsites": [ + "App/SettingsView.swift:223" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "清晰人声": { + "callsites": [ + "App/SettingsView.swift:224" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "温暖人声": { + "callsites": [ + "App/SettingsView.swift:225" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "设置保存后在下一次录音生效。RNNoise 较轻量,PureVox 使用更多计算资源。默认保持原有音频效果。": { + "callsites": [ + "App/SettingsView.swift:237" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "降噪与增益": { + "callsites": [ + "App/SettingsView.swift:240" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "降噪算法": { + "callsites": [ + "App/SettingsView.swift:241" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "频谱减法": { + "callsites": [ + "App/SettingsView.swift:243" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "降噪强度": { + "callsites": [ + "App/SettingsView.swift:245" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "输入增益 dB": { + "callsites": [ + "App/SettingsView.swift:246" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "自动增益": { + "callsites": [ + "App/SettingsView.swift:247", + "App/SettingsView.swift:303" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "目标电平 %": { + "callsites": [ + "App/SettingsView.swift:249" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "起音速度": { + "callsites": [ + "App/SettingsView.swift:250" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "释放速度": { + "callsites": [ + "App/SettingsView.swift:251" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "语音门限": { + "callsites": [ + "App/SettingsView.swift:253", + "App/SettingsView.swift:303" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "门限 dB": { + "callsites": [ + "App/SettingsView.swift:254" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "梳状滤波 %": { + "callsites": [ + "App/SettingsView.swift:257" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "十段均衡器": { + "callsites": [ + "App/SettingsView.swift:258" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "均衡器预设": { + "callsites": [ + "App/SettingsView.swift:260" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "前级增益 dB": { + "callsites": [ + "App/SettingsView.swift:265" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "音色与房间调整": { + "callsites": [ + "App/SettingsView.swift:273" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "梳状滤波可能改变音色,建议从 0 开始。语音门限不会自动停止录音,过高会丢失轻声。": { + "callsites": [ + "App/SettingsView.swift:274" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "处理顺序": { + "callsites": [ + "App/SettingsView.swift:276" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "恢复默认增强设置": { + "callsites": [ + "App/SettingsView.swift:288" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "保存中…": { + "callsites": [ + "App/SettingsView.swift:293" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "降噪": { + "callsites": [ + "App/SettingsView.swift:303" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "梳状滤波": { + "callsites": [ + "App/SettingsView.swift:303" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "均衡器": { + "callsites": [ + "App/SettingsView.swift:303" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "输入增益": { + "callsites": [ + "App/SettingsView.swift:303" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "音频设置暂不可用,请稍后重试。": { + "callsites": [ + "App/SettingsView.swift:311" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "无法读取音频设置,请重试。": { + "callsites": [ + "App/SettingsView.swift:317" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "已保存,下次录音生效。": { + "callsites": [ + "App/SettingsView.swift:327" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" + }, + "保存失败,当前修改尚未应用。": { + "callsites": [ + "App/SettingsView.swift:328" + ], + "translationSource": "Native iOS audio settings (2026-10-24)" } }, "infoPlistEntries": { diff --git a/openless-all/ios/Resources/en.lproj/Localizable.strings b/openless-all/ios/Resources/en.lproj/Localizable.strings index f681321ff..5991689dd 100644 --- a/openless-all/ios/Resources/en.lproj/Localizable.strings +++ b/openless-all/ios/Resources/en.lproj/Localizable.strings @@ -1015,3 +1015,42 @@ "中" = "ZH"; "展开候选" = "Expand candidates"; "收起候选" = "Collapse candidates"; + +// Native audio settings +"低沉人声" = "Deep voice"; +"保存中…" = "Saving..."; +"保存失败,当前修改尚未应用。" = "Save failed. Your changes have not been applied."; +"前级增益 dB" = "Preamp gain dB"; +"十段均衡器" = "Ten-band equalizer"; +"均衡器" = "Equalizer"; +"均衡器预设" = "Equalizer presets"; +"处理顺序" = "Processing order"; +"已保存,下次录音生效。" = "Saved. Applies to the next recording."; +"平直" = "Flat"; +"恢复默认增强设置" = "Reset enhancement settings"; +"播客" = "Podcast"; +"无法读取音频设置,请重试。" = "Unable to read audio settings. Please retry."; +"明亮人声" = "Bright vocal"; +"梳状滤波" = "Comb filter"; +"梳状滤波 %" = "Comb filter %"; +"梳状滤波可能改变音色,建议从 0 开始。语音门限不会自动停止录音,过高会丢失轻声。" = "The comb filter can change voice tone; start at 0. The speech gate does not stop recording, and a high threshold can remove quiet speech."; +"清晰人声" = "Vocal clarity"; +"温暖人声" = "Warm vocal"; +"目标电平 %" = "Target level %"; +"自动增益" = "Automatic gain"; +"设置保存后在下一次录音生效。RNNoise 较轻量,PureVox 使用更多计算资源。默认保持原有音频效果。" = "Saved settings apply to the next recording. RNNoise is lighter; PureVox uses more processing resources. Defaults preserve the existing audio behavior."; +"语音增强" = "Speech enhancement"; +"语音识别 准备 识别方式 语音增强 降噪 增益 均衡器 speech recognition audio enhancement noise gain equalizer" = "speech recognition prepare method audio enhancement noise gain equalizer"; +"语音门限" = "Speech gate"; +"起音速度" = "Attack speed"; +"输入增益" = "Input gain"; +"输入增益 dB" = "Input gain dB"; +"释放速度" = "Release speed"; +"门限 dB" = "Threshold dB"; +"降噪" = "Noise reduction"; +"降噪与增益" = "Noise reduction and gain"; +"降噪强度" = "Noise reduction strength"; +"降噪算法" = "Noise reduction algorithm"; +"音色与房间调整" = "Tone and room adjustments"; +"音频设置暂不可用,请稍后重试。" = "Audio settings are unavailable. Please retry later."; +"频谱减法" = "Spectral subtraction"; diff --git a/openless-all/ios/Resources/zh-Hans.lproj/Localizable.strings b/openless-all/ios/Resources/zh-Hans.lproj/Localizable.strings index a0e494059..ba99fe2e8 100644 --- a/openless-all/ios/Resources/zh-Hans.lproj/Localizable.strings +++ b/openless-all/ios/Resources/zh-Hans.lproj/Localizable.strings @@ -1015,3 +1015,42 @@ "中" = "中"; "展开候选" = "展开候选"; "收起候选" = "收起候选"; + +// Native audio settings +"低沉人声" = "低沉人声"; +"保存中…" = "保存中…"; +"保存失败,当前修改尚未应用。" = "保存失败,当前修改尚未应用。"; +"前级增益 dB" = "前级增益 dB"; +"十段均衡器" = "十段均衡器"; +"均衡器" = "均衡器"; +"均衡器预设" = "均衡器预设"; +"处理顺序" = "处理顺序"; +"已保存,下次录音生效。" = "已保存,下次录音生效。"; +"平直" = "平直"; +"恢复默认增强设置" = "恢复默认增强设置"; +"播客" = "播客"; +"无法读取音频设置,请重试。" = "无法读取音频设置,请重试。"; +"明亮人声" = "明亮人声"; +"梳状滤波" = "梳状滤波"; +"梳状滤波 %" = "梳状滤波 %"; +"梳状滤波可能改变音色,建议从 0 开始。语音门限不会自动停止录音,过高会丢失轻声。" = "梳状滤波可能改变音色,建议从 0 开始。语音门限不会自动停止录音,过高会丢失轻声。"; +"清晰人声" = "清晰人声"; +"温暖人声" = "温暖人声"; +"目标电平 %" = "目标电平 %"; +"自动增益" = "自动增益"; +"设置保存后在下一次录音生效。RNNoise 较轻量,PureVox 使用更多计算资源。默认保持原有音频效果。" = "设置保存后在下一次录音生效。RNNoise 较轻量,PureVox 使用更多计算资源。默认保持原有音频效果。"; +"语音增强" = "语音增强"; +"语音识别 准备 识别方式 语音增强 降噪 增益 均衡器 speech recognition audio enhancement noise gain equalizer" = "语音识别 准备 识别方式 语音增强 降噪 增益 均衡器 speech recognition audio enhancement noise gain equalizer"; +"语音门限" = "语音门限"; +"起音速度" = "起音速度"; +"输入增益" = "输入增益"; +"输入增益 dB" = "输入增益 dB"; +"释放速度" = "释放速度"; +"门限 dB" = "门限 dB"; +"降噪" = "降噪"; +"降噪与增益" = "降噪与增益"; +"降噪强度" = "降噪强度"; +"降噪算法" = "降噪算法"; +"音色与房间调整" = "音色与房间调整"; +"音频设置暂不可用,请稍后重试。" = "音频设置暂不可用,请稍后重试。"; +"频谱减法" = "频谱减法"; diff --git a/openless-all/ios/scripts/ReferencePCMWorker.swift b/openless-all/ios/scripts/ReferencePCMWorker.swift new file mode 100644 index 000000000..a25337fbd --- /dev/null +++ b/openless-all/ios/scripts/ReferencePCMWorker.swift @@ -0,0 +1,60 @@ +// Frozen pre-optimization oracle from f0744e10. Test-only. +import AVFoundation +import Foundation +actor ReferencePCMWorker { + private var converter: AVAudioConverter? + func convert(_ packet: AudioPacket) throws -> Data { + let input = try packet.makeBuffer() + let inputFormat = input.format + guard inputFormat.sampleRate > 0, input.frameLength > 0, + let outputFormat = AVAudioFormat( + commonFormat: .pcmFormatInt16, sampleRate: 16_000, channels: 1, interleaved: true) + else { + throw VoiceFailure.format + } + if converter?.inputFormat != inputFormat { + converter = AVAudioConverter(from: inputFormat, to: outputFormat) + } + guard let converter else { throw VoiceFailure.format } + let capacity = AVAudioFrameCount( + ceil(Double(input.frameLength) * 16_000 / inputFormat.sampleRate) + 32) + guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { + throw VoiceFailure.format + } + var supplied = false + var error: NSError? + let status = converter.convert(to: output, error: &error) { _, flag in + if supplied { + flag.pointee = .noDataNow + return nil + } + supplied = true + flag.pointee = .haveData + return input + } + if let error { throw error } + guard status != .error else { throw VoiceFailure.format } + guard output.frameLength > 0, let bytes = output.int16ChannelData?[0] else { return Data() } + return Data(bytes: bytes, count: Int(output.frameLength) * 2) + } + func finish() throws -> [Data] { + guard let converter else { return [] } + var tail: [Data] = [] + for _ in 0..<8 { + guard let output = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: 2048) + else { throw VoiceFailure.format } + var error: NSError? + let status = converter.convert(to: output, error: &error) { _, flag in + flag.pointee = .endOfStream + return nil + } + if let error { throw error } + guard status != .error else { throw VoiceFailure.format } + if output.frameLength > 0, let bytes = output.int16ChannelData?[0] { + tail.append(Data(bytes: bytes, count: Int(output.frameLength) * 2)) + } + if status == .endOfStream || output.frameLength == 0 { return tail } + } + throw VoiceFailure.format + } +} diff --git a/openless-all/ios/scripts/audio-smoke.swift b/openless-all/ios/scripts/audio-smoke.swift index 418814d8c..82020a6b2 100644 --- a/openless-all/ios/scripts/audio-smoke.swift +++ b/openless-all/ios/scripts/audio-smoke.swift @@ -2,6 +2,24 @@ import AVFoundation import Foundation @main struct AudioSmoke { + static func packet(rate: Double, channels: AVAudioChannelCount, frames: AVAudioFrameCount, offset: Int, legacy: Bool) throws -> AudioPacket { + let format = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: rate, channels: channels, interleaved: false)! + let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames)! + buffer.frameLength = frames + for channel in 0.. 0.005 && power < 0.1, "invalid PCM amplitude") - print("PCM \(legacy ? "legacy copy" : "immutable") \(Int(rate))Hz × \(channels) → 16kHz mono: \(samples.count) samples") + print("PCM \(legacy ? "legacy copy" : "immutable") \(Int(rate))Hz × \(channels), variable=\(variablePackets) → 16kHz mono: \(samples.count) samples") combinations += 1 + } } } } diff --git a/openless-all/ios/scripts/test-audio.sh b/openless-all/ios/scripts/test-audio.sh index 7b31fa0de..39396d2ff 100755 --- a/openless-all/ios/scripts/test-audio.sh +++ b/openless-all/ios/scripts/test-audio.sh @@ -3,6 +3,6 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" mkdir -p "$ROOT/.build/tests" xcrun swiftc -swift-version 6 -target "$(uname -m)-apple-macos14.0" -parse-as-library \ - "$ROOT/App/Speech/AudioPCM.swift" "$ROOT/scripts/audio-smoke.swift" \ + "$ROOT/App/Speech/AudioPCM.swift" "$ROOT/scripts/ReferencePCMWorker.swift" "$ROOT/scripts/audio-smoke.swift" \ -o "$ROOT/.build/tests/audio-smoke" "$ROOT/.build/tests/audio-smoke" From 51d417a06c36dd735c3d5bb684cbd7fbce3f6590 Mon Sep 17 00:00:00 2001 From: aeoform <128159516+aeoform@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:27:14 +0800 Subject: [PATCH 15/31] =?UTF-8?q?ci(release):=20tag=20=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E5=90=8E=E8=87=AA=E5=8A=A8=E8=A1=A5=E9=B8=BF=E8=92=99=E4=B8=8E?= =?UTF-8?q?=20Linux=20=E5=8C=85=20(#1273)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(release): tag 推送后自动补鸿蒙与 Linux 包 `Release HarmonyOS` 与 `Release Linux egui` 原本是 dispatch-only,但没有任何东西 把它们和 `v*-tauri` 标签连起来:两个 workflow 各自的运行次数是 0,于是 2.0.x 每个 Release 都没有 `.hap`、`.deb`、`.rpm`,AppGallery 上传也从未发生,而且整个过程没有 任何报错——Silent miss。 在 `release-tauri.yml` 末尾加 `dispatch-platform-releases`:桌面/Android 矩阵结束后 用 `GITHUB_TOKEN`(`workflow_dispatch` 是文档里允许的两个例外之一,不需要 PAT,只要 `actions: write`)把同一个标签派发给这两个 workflow。之所以挂在标签构建尾部而不是给 它们加 `push:` 触发,是因为它们的 attach 步骤只等 10 分钟,而桌面矩阵要跑 40 分钟; 等到矩阵结束再派发,Release 一定已经存在。 - 鸿蒙:`channel` 取 `vars.HARMONY_AUTO_CHANNEL || 'none'`。默认走仓库分发路径(未签名 HAP 挂到 Release,不碰商店);把仓库变量设为 `release` / `test` 即自动上传签名包作为 AGC 候选包(该上传不会提交审核,提交仍是人工决定)。 - Linux:`publish=true`,与鸿蒙一致自动挂到同一个 Release。因此 `docs/linux-egui-handoff/07-acceptance.md` 的真机验收从「打包后」前移为「打标签前」 的 gate,workflow 与 RELEASING.md 都已写明。 - 两个 workflow 仍然是 dispatch-only(契约测试要求),人工 dispatch 保留为修复路径。 - 补契约测试:`harmony-release-contract`、`linux-egui-release-contract` 断言尾部派发 存在、只用标签推送触发、默认不带签名渠道。 * fix(harmony): release 流水线补 OHOS target 与 npm 依赖 第一次真正 dispatch `Release HarmonyOS` 就暴露了两个从未被发现的问题——这个 workflow 在有史以来的运行次数是 0,所以谁都没发现它其实跑不通: ``` error[E0463]: can't find crate for `core` = note: the `aarch64-unknown-linux-ohos` target may not be installed ``` `build-harmony-backend.sh` 用 `cargo build --target aarch64-unknown-linux-ohos`, `build-harmony-frontend.sh` 用 `npm run build`,而 hosted runner 既没有 Tier-2 OHOS target std 也没有 `node_modules`。`ci.yml` 的 `harmony-check` 两者都装了,release 这条 路径漏了,于是签名、证书校验、SDK 下载全部成功后卡在编译阶段。 按 `ci.yml` 的写法补齐:`cache-npm` + `npm ci`、`dtolnay/rust-toolchain@stable` 带 `targets: aarch64-unknown-linux-ohos`、`swatinem/rust-cache`(复用 `harmony-check` 的 `key: harmony-host`,标签构建只读不写)。 契约测试补断言:OHOS target 必须安装、必须 `npm ci`,且 target 列表要与 `ci.yml` 里 真正打 HAP 的那个 job 保持一致——只在一边加 target 是这次 bug 的形态。 * fix(harmony): AGC 上传步骤的折叠标量把命令注释掉了 上一个 run(40337344929)构建、签名、校验、挂 Release 全部成功,只有 AppGallery 上传失败: ``` /home/runner/work/_temp/...sh: line 2: --channel: command not found ##[error]Process completed with exit code 127 ``` 原因是那一步用的是 `run: >-` 折叠标量,且以注释开头。折叠后第一行变成 ``` # Only the signed package is uploaded; ... node scripts/upload-appgallery.mjs \ ``` 整行都是注释,命令连同续行反斜杠一起被吃掉,第二行 `--channel` 成了独立命令。 改成 `run: |` 字面块,注释移到 YAML 层(`#` 开头不会被折叠进脚本)。 契约测试加断言:`release-harmony.yml` 里任何 `run: >-` 之后都不允许紧跟注释行, 以及上传命令必须是可执行的 `node scripts/upload-appgallery.mjs \` 形式。 * docs(release): 记录 HARMONY_AUTO_CHANNEL 变量 * fix(release): pin platform dispatch and release checksums --------- Co-authored-by: Chris233 --- .github/workflows/release-harmony.yml | 45 ++++++++++- .github/workflows/release-linux-egui.yml | 19 +++-- .github/workflows/release-tauri.yml | 73 ++++++++++++++++++ RELEASING.md | 43 +++++++---- .../scripts/harmony-release-contract.test.mjs | 74 ++++++++++++++++++- .../linux-egui-release-contract.test.mjs | 12 +++ .../app/scripts/package-harmony-hap.sh | 3 +- 7 files changed, 243 insertions(+), 26 deletions(-) diff --git a/.github/workflows/release-harmony.yml b/.github/workflows/release-harmony.yml index 053d64ac9..6b0352f8d 100644 --- a/.github/workflows/release-harmony.yml +++ b/.github/workflows/release-harmony.yml @@ -14,6 +14,11 @@ name: Release HarmonyOS # # Dispatch it after the tag exists; it rebuilds that tag, signs it with material # from Secrets, attaches the HAP to the Release and uploads it to AppGallery. +# `release-tauri.yml` dispatches it automatically once the desktop/Android assets +# for a `v*-tauri` tag are published (see `dispatch-platform-releases`), so a tag +# push ships the HAP instead of depending on somebody remembering this page. The +# manual dispatch stays the repair path for a published tag, and the channel it +# runs with is `vars.HARMONY_AUTO_CHANNEL` (default `none`) on the automatic one. on: workflow_dispatch: inputs: @@ -113,6 +118,33 @@ jobs: with: node-version: '22' + # The HAP build shells out to `npm run build` for the ArkWeb bundle and to + # `cargo build --target aarch64-unknown-linux-ohos` for the Rust host, so it + # needs the app's `node_modules` and the Tier-2 OHOS target std. A hosted + # runner ships neither, and both gaps only surface minutes into the build - + # the first real dispatch of this workflow died with `can't find crate for + # `core`` (error[E0463]) after compiling the Rime/NDK tree. Install them up + # front, exactly like `ci.yml`'s `harmony-check` does. + - name: Cache npm from beta + uses: ./.github/actions/cache-npm + + - name: Install frontend dependencies + working-directory: openless-all/app + run: npm ci + + - uses: dtolnay/rust-toolchain@stable + with: + # Tier-2 OHOS target; `rustup target list` lists it for stable. + targets: aarch64-unknown-linux-ohos + + - uses: swatinem/rust-cache@v2 + with: + workspaces: 'openless-all/app -> target' + # Same key as `ci.yml`'s `harmony-check`: a tag build restores the tree + # that job already paid for, and a tag never writes to the cache. + key: harmony-host + save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} + # No toolchain cache here either: `prepare-harmony-sdk.sh` downloads the # checksum-pinned official tools in under two minutes, and a 1.77 GB entry # that a tag build rarely restores would sit in the 10 GB budget and evict @@ -287,10 +319,15 @@ jobs: if: inputs.upload_to_appgallery && inputs.channel != 'none' working-directory: openless-all/app # A fork without AGC credentials still gets the GitHub Release; the step - # reports that it skipped instead of failing the whole release. - run: >- - # Only the signed package is uploaded; the unsigned one is what the - # GitHub Release distributes. + # reports that it skipped instead of failing the whole release. Only the + # signed package is uploaded - the Release carries the unsigned one. + # + # Keep this a literal block: a folded `>-` scalar starting with a comment + # swallowed the command line (`# ... node scripts/upload-appgallery.mjs`) + # and the step died with `--channel: command not found` (127) the first + # time it ever ran. + run: | + set -euo pipefail node scripts/upload-appgallery.mjs \ --channel "${{ inputs.channel }}" \ --file target/harmony-packages/OpenLess_harmony_arm64-signed.app \ diff --git a/.github/workflows/release-linux-egui.yml b/.github/workflows/release-linux-egui.yml index 7c2787485..9e59990b0 100644 --- a/.github/workflows/release-linux-egui.yml +++ b/.github/workflows/release-linux-egui.yml @@ -1,13 +1,18 @@ name: Release Linux egui -# Linux ships in the same GitHub Release as the desktop and Android assets. +# Linux ships in the same GitHub Release as the desktop, Android and HarmonyOS +# assets. # -# The entry point stays manual: the tag is created by an administrator after the -# Linux host/UI acceptance in docs/linux-egui-handoff/07-acceptance.md, and this -# workflow only rebuilds that already-reviewed tag. It never reacts to Tauri -# tags, so a desktop release can never ship an unaccepted Linux package by -# accident; but once dispatched, the packages are attached to the Release -# automatically instead of being uploaded by hand. +# The entry point is dispatch-only (never a `push:` trigger), so nothing can +# rebuild Linux packages from an arbitrary commit: `release-tauri.yml` dispatches +# it automatically for a `v*-tauri` tag once the desktop assets are published, +# and an administrator keeps the manual dispatch as the repair path. Packaging +# runs the same Core/host/deb/rpm verification as CI and then attaches the +# verified packages to that Release automatically - nothing is uploaded by hand. +# +# Because the tag now ships Linux unattended, the L01-L12 device acceptance in +# docs/linux-egui-handoff/07-acceptance.md is a **pre-tag** gate: a tag must not +# be cut for a Linux state that has not been accepted on real hardware. on: workflow_dispatch: inputs: diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index feecb025a..660194600 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -725,3 +725,76 @@ jobs: openless-all/app/src-tauri/target/release/bundle/msi/*.msi.sig openless-all/app/src-tauri/target/release/bundle/latest-*.json openless-all/app/src-tauri/target/release/downloads/* + + # ── Hand the finished tag to the platform workflows that ride the same Release ── + # `Release HarmonyOS` and `Release Linux egui` are dispatch-only on purpose: an + # unaccepted HAP or deb/rpm must never reach users because someone pushed a + # Tauri tag. Nothing used to link them to that tag, though, so both entry + # points had **zero runs in their entire history** and every 2.0.x Release + # shipped without a `.hap`, a `.deb` or a `.rpm` - the AppGallery upload never + # ran either, and the omission was silent. Dispatch them here, once the desktop + # matrix finished, so both find the Release they attach to (they wait only ten + # minutes for it, less than the desktop matrix needs) and a tag push produces + # one complete Release. + # + # `workflow_dispatch` is one of the two events `GITHUB_TOKEN` may still + # trigger, so this needs no PAT - only `actions: write` for this job. The + # downstream runs use the same release tag for their workflow definition and + # checkout, so the package, workflow and cache inputs stay on one immutable ref. + dispatch-platform-releases: + needs: build + # Independent of the desktop result: the HAP and the Linux packages only need + # the tag, so a Windows-only or notarization failure must not cost them their + # Release entry. A cancelled run still dispatches nothing. + if: >- + !cancelled() && github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref_name, '-tauri') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: write + contents: read + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + # Repository distribution path by default: the HAP is attached to the + # GitHub Release unsigned and nothing reaches AppGallery. Set the + # `HARMONY_AUTO_CHANNEL` repository variable to `release` (production) or + # `test` (test channel) to upload the signed package as the AGC candidate + # automatically - the upload never submits it for review, so that decision + # stays human either way. + HARMONY_CHANNEL: ${{ vars.HARMONY_AUTO_CHANNEL || 'none' }} + steps: + - name: Dispatch the HarmonyOS and Linux releases + run: | + set -euo pipefail + case "$HARMONY_CHANNEL" in + none | release | test) ;; + *) echo "::error::HARMONY_AUTO_CHANNEL must be none, release or test (got '$HARMONY_CHANNEL')"; exit 1 ;; + esac + gh workflow run release-harmony.yml \ + --ref "$RELEASE_TAG" \ + -f "tag=$RELEASE_TAG" \ + -f "channel=$HARMONY_CHANNEL" \ + -f publish=true \ + -f upload_to_appgallery=true + gh workflow run release-linux-egui.yml \ + --ref "$RELEASE_TAG" \ + -f "tag=$RELEASE_TAG" \ + -f publish=true + + - name: Report the dispatched runs + # Both dispatches already returned a run id; this listing is the log line + # that makes the old silent miss visible, so a missing run must not fail + # the tag build after every asset is already published. + continue-on-error: true + run: | + set -euo pipefail + sleep 10 + for workflow in release-harmony.yml release-linux-egui.yml; do + echo "### $workflow" + gh run list --workflow="$workflow" --limit 3 \ + --json createdAt,status,conclusion,url \ + --jq '.[] | "\(.createdAt) \(.status) \(.conclusion // "-") \(.url)"' + done diff --git a/RELEASING.md b/RELEASING.md index d142901fd..0a87893b8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -7,7 +7,7 @@ day-to-day development. **Only repository administrators may create version tags and publish releases.** -- Only an admin may create a release tag (`v*-tauri`) or publish a GitHub Release. Linux assets require a separate accepted build and explicit administrator upload. +- Only an admin may create a release tag (`v*-tauri`) or publish a GitHub Release. Linux assets ride the same tag, but the L01-L12 device acceptance must be finished *before* the tag is cut. - Contributors (including AI agents) **must not** create release tags, publish releases, or trigger release automation. If a release is needed, **request an admin to cut it** — open an issue or ping a maintainer with the target version and the @@ -43,18 +43,34 @@ component, which is a 16-bit field. The full version still appears in the app an updater manifest; NSIS uses its supported numeric fallback for file metadata. See [the pinned NSIS bundler](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.10.1/crates/tauri-bundler/src/bundle/windows/nsis/mod.rs#L149). -These tags build the macOS, Windows, and Android Tauri hosts in parallel. Linux is -triggered separately so an unaccepted package can never ride along with a desktop -release: `.github/workflows/release-linux-egui.yml` is manual-only, requires the -release tag as an input, checks it out, runs the same Core/host/deb/rpm -verification as CI, and then **attaches the verified packages to that same -Release automatically**. Nothing is uploaded by hand. Linux has no in-app updater -manifest or AppImage. - -HarmonyOS rides in the same Release through `.github/workflows/release-harmony.yml`. -It is manual-only as well and requires the same tag. It builds, signs and attaches -`OpenLess_harmony_arm64.hap` plus the `OpenLess_harmony.hap` alias, then uploads -the same HAP to HUAWEI AppGallery Connect. +These tags build the macOS, Windows, and Android Tauri hosts in parallel. + +Linux and HarmonyOS ride in the same Release through two dispatch-only entry +points, `.github/workflows/release-linux-egui.yml` and +`.github/workflows/release-harmony.yml`. Neither reacts to a `push:` event, so an +arbitrary commit can never rebuild a package; `release-tauri.yml` dispatches both +through `dispatch-platform-releases` as soon as the desktop/Android assets of a +`v*-tauri` tag are published. That dispatch is what makes a tag push ship a +complete Release: the two workflows only wait ten minutes for the Release to +exist, less than the desktop matrix needs, which is why they cannot be triggered +by the tag itself. To rebuild one platform for an already-published tag, dispatch +it by hand on the same tag - that stays the repair path. + +Linux runs the same Core/host/deb/rpm verification as CI and **attaches the +verified packages to that Release automatically**; nothing is uploaded by hand, +and there is no in-app updater manifest or AppImage. Because the tag now ships +Linux unattended, the device acceptance in +[docs/linux-egui-handoff/07-acceptance.md](docs/linux-egui-handoff/07-acceptance.md) +is a **pre-tag** gate rather than a post-build one. + +HarmonyOS builds, signs and attaches `OpenLess_harmony_arm64.hap` plus the +`OpenLess_harmony.hap` alias, and can upload the signed package (App Pack) to +HUAWEI AppGallery Connect. The automatic dispatch uses `channel: none`, the +repository path: the HAP is attached to the GitHub Release unsigned and nothing +reaches the store. Set the repository variable `HARMONY_AUTO_CHANNEL` to +`release` (production) or `test` (test channel) to have the signed package +uploaded as the AGC candidate package automatically; the upload never submits it +for review, so store submission remains a human decision either way. The build needs the official HarmonyOS command-line tools (SDK + hvigor + ohpm + bundled node). Huawei's download page wants an account, but the same archive is @@ -248,6 +264,7 @@ candidate package. | Variable (optional) | `HARMONY_TOOLCHAIN` | absolute path of the DevEco / HarmonyOS SDK on a self-hosted runner | | Variable (optional) | `HARMONY_SDK_URL` | override the tools archive URL | | Variable (optional) | `HARMONY_SDK_SHA256` | SHA-256 of that archive | +| Variable (optional) | `HARMONY_AUTO_CHANNEL` | `none` (default) / `release` / `test` - the channel the automatic tag-build dispatch runs with; `release` or `test` upload the signed App Pack to AppGallery as the candidate package (never submitted for review) | The `test` channel reads the same three Secrets (`HARMONY_KEYSTORE_BASE64`, `HARMONY_CERT_BASE64`, `HARMONY_PROFILE_BASE64`). Store the test profile there diff --git a/openless-all/app/scripts/harmony-release-contract.test.mjs b/openless-all/app/scripts/harmony-release-contract.test.mjs index ceacf3491..79aa68d5d 100644 --- a/openless-all/app/scripts/harmony-release-contract.test.mjs +++ b/openless-all/app/scripts/harmony-release-contract.test.mjs @@ -17,6 +17,10 @@ const packaging = readFileSync(join(appRoot, 'scripts/package-harmony-hap.sh'), const signing = readFileSync(join(appRoot, 'scripts/sign-harmony-hap.py'), 'utf8'); const upload = readFileSync(join(appRoot, 'scripts/upload-appgallery.mjs'), 'utf8'); const releasing = readFileSync(join(appRoot, '../../RELEASING.md'), 'utf8'); +const tauriRelease = readFileSync( + join(appRoot, '../../.github/workflows/release-tauri.yml'), + 'utf8', +); // Dispatch-only, with the tag as a required input: the HAP can never come from a // branch or ride along with an unrelated push. @@ -33,6 +37,26 @@ assert.match(workflow, /does not match package\.json version/); // that `prepare-harmony-sdk.sh` pins by checksum - no secrets, no self-hosted box. assert.match(workflow, /default: '\["ubuntu-latest"\]'/); assert.match(workflow, /prepare-harmony-sdk\.sh/); + +// A hosted runner ships neither the app's `node_modules` (`build-harmony-frontend.sh` +// runs `npm run build`) nor the Tier-2 OHOS target std (`build-harmony-backend.sh` +// runs `cargo build --target aarch64-unknown-linux-ohos`). CI installs both; the +// first real dispatch of this workflow did not and died with `can't find crate for +// `core`` (error[E0463]) after the Rime/NDK compile. +assert.match(workflow, /dtolnay\/rust-toolchain@stable/); +assert.match(workflow, /targets: aarch64-unknown-linux-ohos/); +assert.match(workflow, /run: npm ci/); +{ + // Keep the target list in step with the CI job that really builds the HAP: a + // target added to one workflow only breaks the tag build. + const ciWorkflow = readFileSync(join(appRoot, '../../.github/workflows/ci.yml'), 'utf8'); + const ohosTargets = (source) => + [...source.matchAll(/targets:\s*([^\n]+)/g)] + .map((match) => match[1].trim()) + .filter((targets) => targets.includes('ohos')); + assert.ok(ohosTargets(workflow).length > 0, 'release-harmony.yml must install the OHOS target'); + assert.deepEqual(ohosTargets(workflow), ohosTargets(ciWorkflow)); +} const prepare = readFileSync(join(appRoot, 'scripts/prepare-harmony-sdk.sh'), 'utf8'); assert.match(prepare, /DEFAULT_URL=https:\/\//); assert.match(prepare, /DEFAULT_SHA256=[0-9a-f]{64}/); @@ -94,7 +118,11 @@ assert.doesNotMatch( assert.ok(existsSync(join(appRoot, 'scripts/package-harmony-hap.sh'))); assert.match(packaging, /sign-harmony-hap\.py/); assert.match(packaging, /--prefix "\$SIGN_PREFIX"/); -assert.match(packaging, /sha256sum \.\/\*\.hap > SHA256SUMS/); +assert.match( + packaging, + /sha256sum \.\/OpenLess_harmony_arm64\.hap \.\/OpenLess_harmony\.hap > SHA256SUMS/, + 'Release checksums must cover only the unsigned HAP pair uploaded to GitHub', +); assert.match(packaging, /OpenLess_harmony_arm64\.hap/); assert.match(packaging, /OpenLess_harmony\.hap/); assert.match(signing, /HARMONY_KEYSTORE_PASSWORD/); @@ -107,11 +135,55 @@ assert.match(upload, /api\/publish\/v3\/app-package-info/); assert.match(upload, /--dry-run/); assert.match(upload, /--skip-if-unconfigured/); assert.match(workflow, /upload-appgallery\.mjs/); +// The upload step must stay a literal `run: |` block. As a folded `>-` scalar its +// leading explanatory comment joined the command line, so bash saw +// `# ... node scripts/upload-appgallery.mjs \` and then `--channel` on its own +// line: `command not found` (127) - the first time the step ever executed. +assert.doesNotMatch( + workflow, + /run:\s*>-[ ]*\n\s*#/, + 'a folded run scalar must not start with a comment; it comments out the command', +); +assert.match(workflow, /node scripts\/upload-appgallery\.mjs \\/); // Both destinations are wired. assert.match(workflow, /gh release upload/); assert.match(workflow, /OpenLess_harmony_arm64\.hap/); +// This entry point is dispatch-only, so the Tauri tag build has to hand the tag +// over explicitly. Without that hand-off the workflow sits at zero runs and every +// Release ships without a HAP - which is exactly what happened to 2.0.x. +assert.match(tauriRelease, /^ dispatch-platform-releases:$/m, 'release-tauri.yml must hand the tag to this workflow'); +assert.match( + tauriRelease, + /gh workflow run release-harmony\.yml[\s\S]{0,300}-f "tag=\$RELEASE_TAG"/, + 'the hand-off must dispatch with the tag as an input', +); +assert.match( + tauriRelease, + /gh workflow run release-harmony\.yml[\s\S]{0,80}--ref "\$RELEASE_TAG"[\s\S]{0,180}-f "tag=\$RELEASE_TAG"/, + 'the downstream workflow definition must be loaded from the same immutable release tag', +); +assert.match(tauriRelease, /gh workflow run release-harmony\.yml[\s\S]{0,400}-f publish=true/); +assert.match( + tauriRelease, + /gh workflow run release-linux-egui\.yml[\s\S]{0,80}--ref "\$RELEASE_TAG"[\s\S]{0,180}-f "tag=\$RELEASE_TAG"/, + 'the Linux workflow definition must use the same immutable release tag', +); +assert.match( + tauriRelease, + /github\.event_name == 'push'[\s\S]{0,120}refs\/tags\/v/, + 'the hand-off must be tag-push only, so a branch or schedule run dispatches nothing', +); +assert.match(tauriRelease, /actions: write/, 'GITHUB_TOKEN needs actions: write to dispatch'); +assert.match( + tauriRelease, + /HARMONY_CHANNEL: \$\{\{ vars\.HARMONY_AUTO_CHANNEL \|\| 'none' \}\}/, + 'the automatic hand-off must default to the unsigned repository path', +); +assert.match(tauriRelease, /channel=\$HARMONY_CHANNEL/); +assert.match(releasing, /HARMONY_AUTO_CHANNEL/); + // `env:` at job level outranks `$GITHUB_ENV`, so a placeholder key here would // shadow the value the signing step exports and the build would look for // signing material in `~/.local/share/harmony/signing/release`. diff --git a/openless-all/app/scripts/linux-egui-release-contract.test.mjs b/openless-all/app/scripts/linux-egui-release-contract.test.mjs index 804e647c1..113814301 100644 --- a/openless-all/app/scripts/linux-egui-release-contract.test.mjs +++ b/openless-all/app/scripts/linux-egui-release-contract.test.mjs @@ -96,6 +96,18 @@ assert.ok(!tauri.includes('build-linux-egui:'), 'Linux does not build Tauri'); assert.match(release, /release_tag: \$\{\{ inputs\.tag \}\}/); assert.match(release, /checkout_ref: \$\{\{ inputs\.tag \}\}/); +// `release-linux-egui.yml` is dispatch-only above, so the Tauri tag build must +// dispatch it: the workflow had zero runs before that hand-off existed and every +// 2.0.x Release shipped without a `.deb` or `.rpm`. Publishing stays implicit in +// the hand-off (`publish=true`), which is safe because the tag is admin-created +// after acceptance. +assert.match(tauri, /^ dispatch-platform-releases:$/m, 'release-tauri.yml must hand the tag to this workflow'); +assert.match( + tauri, + /gh workflow run release-linux-egui\.yml[\s\S]{0,200}-f "tag=\$RELEASE_TAG"[\s\S]{0,80}-f publish=true/, + 'the Linux hand-off must attach the packages for the same tag', +); + // Keep deb/rpm only, including dynamically-loaded desktop libraries and the // fcitx5 addon. No AppImage, minisign, embedded downloader or Qwen ASR runtime. // diff --git a/openless-all/app/scripts/package-harmony-hap.sh b/openless-all/app/scripts/package-harmony-hap.sh index c8674c214..4f871b669 100755 --- a/openless-all/app/scripts/package-harmony-hap.sh +++ b/openless-all/app/scripts/package-harmony-hap.sh @@ -152,6 +152,7 @@ if [ -n "$SIGNED" ]; then # of the Release. cp "$SIGNED" "$OUTPUT/OpenLess_harmony_arm64-signed.hap" fi -( cd "$OUTPUT" && sha256sum ./*.hap > SHA256SUMS ) +# SHA256SUMS belongs to the GitHub Release, which contains only the unsigned HAP pair. +( cd "$OUTPUT" && sha256sum ./OpenLess_harmony_arm64.hap ./OpenLess_harmony.hap > SHA256SUMS ) echo "HAP packages ready in $OUTPUT" ls -l "$OUTPUT" From d37394ec99d75ca29c07841079ce4b362147968c Mon Sep 17 00:00:00 2001 From: solodddd Date: Tue, 27 Oct 2026 20:11:42 +0800 Subject: [PATCH 16/31] =?UTF-8?q?fix(textstream):=20=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E8=A2=AB=E6=94=B9=E5=86=99=E6=97=B6=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E6=9D=83=E5=A8=81=E5=85=A8=E6=96=87=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E4=B8=AD=E6=AD=A2=E6=95=B4=E6=9D=A1=E8=BD=AC=E5=86=99?= =?UTF-8?q?=20(#1257)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(textstream): keep the authoritative text when a decode rewrites a stable prefix * fix(textstream): keep the authoritative text when a decode rewrites a stable prefix --- .../asr/local/experimental/textstream/test_worker.py | 12 ++++++++++-- .../src/asr/local/experimental/textstream/worker.py | 12 +++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/local/experimental/textstream/test_worker.py b/openless-all/app/src-tauri/src/asr/local/experimental/textstream/test_worker.py index c4ac6997d..df9ce4efe 100644 --- a/openless-all/app/src-tauri/src/asr/local/experimental/textstream/test_worker.py +++ b/openless-all/app/src-tauri/src/asr/local/experimental/textstream/test_worker.py @@ -136,8 +136,16 @@ def test_draft_is_revisable_and_final_comes_from_decoder(self): self.assertEqual(snapshots.update("你好", "你好")["draft"], "") final = snapshots.update("你好", "你好!", terminal=True) self.assertEqual((final["finalized"], final["draft"]), ("你好!", "")) - with self.assertRaises(ValueError): - snapshots.update("cannot match", "different") + # A rewritten decode must re-commit only the agreed prefix instead of + # failing: audio is already captured and the terminal text wins. + recovered = snapshots.update("cannot match", "different") + self.assertEqual(recovered["finalized"], "") + self.assertEqual(recovered["finalized"] + recovered["draft"], "different") + partial = snapshots.update("我们去了Co", "我们去了科蒂纳。") + self.assertEqual( + (partial["finalized"], partial["draft"]), ("我们去了", "科蒂纳。") + ) + self.assertNotEqual(snapshots.sequence, 0) def run_fake(self, finish=True, gpu_available=True): FakeSession.calls = [] diff --git a/openless-all/app/src-tauri/src/asr/local/experimental/textstream/worker.py b/openless-all/app/src-tauri/src/asr/local/experimental/textstream/worker.py index 7677307fb..3369e6233 100644 --- a/openless-all/app/src-tauri/src/asr/local/experimental/textstream/worker.py +++ b/openless-all/app/src-tauri/src/asr/local/experimental/textstream/worker.py @@ -99,7 +99,17 @@ def update(self, finalized: str, full: str, terminal: bool = False) -> dict: if terminal: finalized = full if not full.startswith(finalized): - raise ValueError("decoder snapshot does not contain its finalized prefix") + # A decode may rewrite text that an earlier snapshot reported as + # stable (script or normalisation flips). The protocol carries whole + # previews, and the terminal snapshot is authoritative, so keep the + # authoritative text and re-commit only the prefix both agree on + # instead of failing the whole transcription. + agreed = 0 + for previous, current in zip(finalized, full): + if previous != current: + break + agreed += 1 + finalized = finalized[:agreed] self.finalized, self.draft = finalized, full[len(finalized) :] self.sequence += 1 return { From 1fe6ad50d24ba11d4a0fa582e8857ac6c60f083a Mon Sep 17 00:00:00 2001 From: aeoform <128159516+aeoform@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:28:50 +0800 Subject: [PATCH 17/31] =?UTF-8?q?feat(updater):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E6=94=B9=E7=94=B1=20CDN=20=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=EF=BC=8C=E5=AE=A2=E6=88=B7=E7=AB=AF=E4=B8=8D=E5=86=8D=E8=AF=BB?= =?UTF-8?q?=20release=20=E9=87=8C=E7=9A=84=20json=20(#1277)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(updater): 更新清单改由 CDN 提供,客户端不再读 release 里的 json 窗口内的更新器原先从 GitHub Release 资产取清单 (releases/latest/download/latest-{target}-{arch}[-beta].json),于是每个 release 要挂 14 个 latest-*.json(7 个 target/arch × mirror 双份),且 Stable 走滚动 指针 → 每次发版都要把同名文件重新传一遍。 改成读项目自己的 CDN: https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json - 每个平台/渠道只有一个滚动文件,发版覆盖即可,不再往 release 累积副本。 - 渠道隔离从「文件名后缀」变成「URL 路径」,Stable 客户端拿不到 Beta 清单。 - 清单格式不变(version/pub_date/url/signature),plugin-updater 继续负责下载、 minisign 校验与安装;清单里的下载地址也指向 CDN,国内下载不再经过 github.com。 - Beta 不再需要拉 releases.atom 找 tag,整条链路去掉一个 github.com 依赖。 - Android 更新检查同步改为 CDN 单地址;SSRF 白名单加入 CDN,同时保留旧 GitHub/fastgit 前缀,已缓存的旧清单仍可安装。 release 侧发布流程与既有资产完全不动。 * ci(release): 清单不再挂到 Release,改为 workflow 产物供 CDN 同步 客户端已经改读 dl.aeoform.com 的 /updates/{stable,beta}/ 清单(上一提交), release 里那 14 个 latest-*.json 没有任何客户端再读,继续上传只会让每个 release 多背 14 个几乎相同的文件。 - release-tauri.yml:softprops files 去掉 bundle/latest-*.json。桌面清单仍然 通过既有的 openless-macos--updater / openless-windows-x64-updater 产物离开 workflow(CDN 同步从那里取)。 - android-apk.yml:release 文件列表去掉 latest-android-*.json;新增 openless-android-updater-manifests 产物,否则 Android 清单会随删除一起丢失 (原来它只存在于 release 资产里)。顺带把 release 正文里的清单说明改成 CDN 口径。 - 契约测试跟着收紧: - release-publication-contract:两个 workflow 的发布者 files 不得含 latest-*.json,且必须有且仅有一个 upload-artifact 携带这些清单。 - android-apk-workflow-contract:把清单文件放进 fixture,断言它们不会进 release 资产,并断言清单产物步骤存在。 - README / README.zh / RELEASING:说明清单由运行产物手动同步到 CDN,以及 改动前的客户端读的是旧 GitHub 路径、因此需要一次手动下载。 * fix(updater): use CDN artifacts and validate Android URLs --------- Co-authored-by: Chris233 --- .github/workflows/android-apk.yml | 21 ++- .github/workflows/release-tauri.yml | 21 +-- README.md | 13 +- README.zh.md | 13 +- RELEASING.md | 31 ++++- .../crates/openless-core/src/shared_types.rs | 9 +- .../android-apk-workflow-contract.test.mjs | 25 ++-- .../release-publication-contract.test.mjs | 18 +++ .../repository-owner-contract.test.mjs | 31 ++++- .../write-updater-manifest-contract.test.mjs | 66 +++++++++ .../app/scripts/write-updater-manifest.mjs | 19 ++- .../app/src-tauri/src/android/updater.rs | 73 ++++------ .../src-tauri/src/android/updater_logic.rs | 76 +--------- .../app/src-tauri/src/commands/settings.rs | 107 ++++++++------ openless-all/app/src-tauri/src/lib.rs | 1 + .../app/src-tauri/src/update_source.rs | 130 ++++++++++++++++++ openless-all/app/src-tauri/tauri.conf.json | 3 +- 17 files changed, 444 insertions(+), 213 deletions(-) create mode 100644 openless-all/app/scripts/write-updater-manifest-contract.test.mjs create mode 100644 openless-all/app/src-tauri/src/update_source.rs diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 7fbe29d4f..5e15404b1 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -515,8 +515,7 @@ jobs: working-directory: openless-all/app env: OPENLESS_UPDATE_APK_DIR: ${{ steps.stage.outputs.out_dir }} - OPENLESS_UPDATE_REPO: Open-Less/openless - OPENLESS_UPDATE_MIRROR_BASE_URL: https://fastgit.cc/https://github.com + OPENLESS_UPDATE_CDN_BASE_URL: https://dl.aeoform.com OPENLESS_RELEASE_CHANNEL: ${{ env.OPENLESS_RELEASE_CHANNEL }} OPENLESS_RELEASE_TAG: ${{ github.ref_name }} run: | @@ -526,7 +525,17 @@ jobs: node scripts/write-updater-manifest.mjs done - - name: Append release files (manifests + signatures) + # The manifests are not release assets any more (the in-app updater reads them from the + # project CDN), but they must still leave the run so the CDN sync can publish them. + - name: Upload Android updater manifests + uses: actions/upload-artifact@v4 + with: + name: openless-android-updater-manifests + if-no-files-found: error + path: | + ${{ steps.stage.outputs.out_dir }}/latest-android-*.json + + - name: Append release files (signatures + aliases) id: release_assets shell: bash run: | @@ -534,7 +543,7 @@ jobs: out_dir="${{ steps.stage.outputs.out_dir }}" { echo "${{ steps.stage.outputs.release_files }}" - for f in "$out_dir"/*.apk.sig "$out_dir"/latest-android-*.json "$out_dir"/OpenLess_android_*.apk; do + for f in "$out_dir"/*.apk.sig "$out_dir"/OpenLess_android_*.apk; do [ -e "$f" ] && echo "$f" done } > "$RUNNER_TEMP/android-release-files.txt" @@ -555,8 +564,8 @@ jobs: ### Android 应用内更新 - - Stable 用户检查 `latest-android-aarch64.json`(及 mirror 变体)。 - - Beta 用户(设置 → 高级 → 加入 Beta)检查带 `-beta` 后缀的 manifest。 + - 清单由项目 CDN 提供:Stable 读 `/updates/stable/latest-android-.json`,Beta 读 `/updates/beta/latest-android--beta.json`。 + - 应用内会自动检查并拉起系统安装器,无需手动下载清单。 EOF - name: Attach Android assets to GitHub Release diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 660194600..ed7880b9a 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -531,15 +531,11 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} OPENLESS_UPDATE_TARGET: ${{ matrix.updater-target }} OPENLESS_UPDATE_ARCH: ${{ matrix.updater-arch }} - OPENLESS_UPDATE_REPO: ${{ github.repository }} - OPENLESS_UPDATE_MIRROR_BASE_URL: https://fastgit.cc/https://github.com + OPENLESS_UPDATE_CDN_BASE_URL: https://dl.aeoform.com # On the beta channel output latest-{tgt}-{arch}-beta.json; stable keeps the old filename. OPENLESS_RELEASE_CHANNEL: ${{ env.OPENLESS_RELEASE_CHANNEL }} - # On the beta channel the script must write manifest.url as releases/download//... - # rather than releases/latest (the latter is always = Stable, so beta users downloading - # via url would fetch the wrong file). - # On workflow_dispatch github.ref_name is not a tag — but then channel=stable and the - # script does not read this field, so it is safe. + # Manifest URLs use the CDN's tag-scoped `releases//` path. Stable + # workflow validation without a release tag falls back to the CDN `latest` path. OPENLESS_RELEASE_TAG: ${{ env.OPENLESS_RELEASE_TAG }} run: node scripts/write-updater-manifest.mjs @@ -704,8 +700,9 @@ jobs: # The administrator explicitly publishes the shared release afterwards. draft: true # Beta releases must be marked prerelease=true: GitHub UI collapses them and regular - # users do not see them; only latest-*-beta.json is uploaded, so stable users' - # endpoint (latest-*.json) is never overwritten and beta never leaks into stable. + # users do not see them. Channel isolation for the updater is a CDN URL path + # (`/updates/stable/` vs `/updates/beta/`, see src-tauri/src/update_source.rs), not a + # filename suffix in these assets. prerelease: ${{ env.OPENLESS_RELEASE_CHANNEL == 'beta' }} # Non-leader jobs have OPENLESS_RELEASE_BODY_PATH as an empty string; softprops keeps # the existing content via the `body || existing.body` branch when body is empty. @@ -715,6 +712,11 @@ jobs: body_path: ${{ env.OPENLESS_RELEASE_BODY_PATH }} # generate_release_notes also runs only on the leader, avoiding duplicates from matrix jobs. generate_release_notes: ${{ matrix.updater-target == 'darwin' && matrix.updater-arch == 'aarch64' }} + # `latest-*.json` is deliberately NOT attached: the in-app updater reads its manifests + # from the project CDN (`/updates/{stable,beta}/`, see src-tauri/src/update_source.rs), + # and cloning them here added fourteen near-identical files to every release. The + # manifests still leave the workflow through the `*-updater` artifacts above, which is + # where the CDN sync job picks them up. files: | openless-all/app/src-tauri/target/release/bundle/dmg/*.dmg openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz @@ -723,7 +725,6 @@ jobs: openless-all/app/src-tauri/target/release/bundle/nsis/*.exe.sig openless-all/app/src-tauri/target/release/bundle/msi/*.msi openless-all/app/src-tauri/target/release/bundle/msi/*.msi.sig - openless-all/app/src-tauri/target/release/bundle/latest-*.json openless-all/app/src-tauri/target/release/downloads/* # ── Hand the finished tag to the platform workflows that ride the same Release ── diff --git a/README.md b/README.md index a80e238a1..0d0a74735 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ Go to [Releases](https://github.com/Open-Less/openless/releases) and download: - **Older 32-bit ARM**: `OpenLess__armeabi-v7a.apk` - **64-bit emulator**: `OpenLess__x86_64.apk` - **32-bit emulator**: `OpenLess__x86.apk` - - In-app updates (Settings → About) use `latest-android-{arch}.json` manifests; Beta users join Beta in Advanced settings. + - In-app updates (Settings → About) read `https://dl.aeoform.com/updates/stable/latest-android-{arch}.json`; Beta users join Beta in Advanced settings and read the `.../updates/beta/latest-android-{arch}-beta.json` counterpart. - Debug smoke builds: `OpenLess-android-debug-{abi}-*.apk` from workflow_dispatch artifacts. - If unsure, run `adb shell getprop ro.product.cpu.abi` and pick the matching APK. - **Linux**: the Tauri/WebView build has been retired. The native `eframe` host uses the shared Core 2.0 services. After CI and real Ubuntu audio, input, install, upgrade and rollback acceptance, the shared release includes deb/rpm packages and `SHA256SUMS`. Linux requires glibc ≥ 2.39 (Ubuntu 24.04 or a comparable distribution); there is no AppImage or in-app updater. @@ -330,7 +330,16 @@ Rules of thumb: - **Beta work must not leak to Stable.** `main` receives merges only from `beta`, performed by maintainers after a successful two-platform smoke build. No direct pushes to `main`. - **Stable releases are cut from `main`** by pushing a `v-tauri` tag — see the maintainer release checklist below. -Beta release distribution (manual-download opt-in): the in-app updater always reads the Stable manifest, so regular users never receive Beta builds via auto-update. Users who want to try Beta open **Settings → About**, enable "Join Beta channel", and download the latest Beta installer manually from the link the app fetches from GitHub. Tag convention: `v-beta-tauri` produces the Beta release (marked as a GitHub pre-release; manifest written as `latest-{tgt}-{arch}-beta.json`), while `v-tauri` produces the Stable release. The two manifest files never overlap, so Stable users' updater feed cannot pick up Beta releases. +Beta release distribution (manual-download opt-in): the in-app updater's Stable channel only ever reads the Stable manifest path, so regular users never receive Beta builds via auto-update. Users who want to try Beta open **Settings → About**, enable "Join Beta channel", and download the latest Beta installer manually from the link the app fetches from GitHub. Tag convention: `v-beta-tauri` produces the Beta release (marked as a GitHub pre-release), while `v-tauri` produces the Stable release. + +Updater manifests are served from the project CDN, one rolling file per platform and channel: + +```text +https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json +https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json +``` + +The channel split is the URL path (`/updates/stable/` vs `/updates/beta/`), so a Stable client cannot reach a Beta manifest even if it asked for the wrong URL. Artifact URLs inside the manifest point at the same CDN (`https://dl.aeoform.com/releases/{tag}/{asset}`), which is what mainland-China users download from. The manifests are generated by the release workflows as run artifacts and are **not** attached to the GitHub Release, so maintainers must upload them to the CDN after a build — see `RELEASING.md`. Clients built before this switch read the old `releases/latest/download/latest-*.json` assets and therefore cannot auto-update past the last release that still carries them; they need one manual download. ## Credentials diff --git a/README.zh.md b/README.zh.md index 10215d759..119bd6e41 100644 --- a/README.zh.md +++ b/README.zh.md @@ -243,7 +243,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 - **旧款 32 位 ARM**:`OpenLess__armeabi-v7a.apk` - **64 位模拟器**:`OpenLess__x86_64.apk` - **32 位模拟器**:`OpenLess__x86.apk` - - 应用内更新(设置 → 关于)读取 `latest-android-{arch}.json`;Beta 用户在高级设置加入 Beta 渠道。 + - 应用内更新(设置 → 关于)读取 `https://dl.aeoform.com/updates/stable/latest-android-{arch}.json`;Beta 用户在高级设置加入 Beta 渠道,读取 `.../updates/beta/latest-android-{arch}-beta.json`。 - 调试包:`OpenLess-android-debug-{abi}-*.apk`(workflow_dispatch 产物)。 - 不确定时执行 `adb shell getprop ro.product.cpu.abi`,下载对应 ABI 的包。 - **Linux**:原 Tauri/WebView 构建已退出 Linux 路线。基于共享 Core 2.0 服务的原生 `eframe` 宿主经 CI 和 Ubuntu 真实音频、输入、安装、升级、回滚验收后,随共用 Release 提供 deb/rpm 和 `SHA256SUMS`。要求 glibc ≥ 2.39(Ubuntu 24.04 或同等级发行版);没有 AppImage 或应用内更新。 @@ -337,7 +337,16 @@ your fork / topic branch - **Beta 的工作不得泄漏到 Stable。** `main` 只接受来自 `beta` 的合并,由维护者在一次成功的双平台冒烟构建之后执行。不允许直接向 `main` 推送。 - **Stable 发布从 `main` 切出**,通过推送 `v-tauri` 标签完成——见下方的维护者发布清单。 -Beta 发布的分发(手动下载、可选加入):应用内的更新器始终读取 Stable 清单,因此普通用户绝不会通过自动更新拿到 Beta 构建。想尝试 Beta 的用户打开 **设置 → 关于**,启用“加入 Beta 频道”,再从应用从 GitHub 获取的链接处手动下载最新的 Beta 安装包。标签约定:`v-beta-tauri` 产出 Beta 发布(标记为 GitHub pre-release;清单写为 `latest-{tgt}-{arch}-beta.json`),而 `v-tauri` 产出 Stable 发布。两份清单文件互不重叠,因此 Stable 用户的更新器源不会拾取到 Beta 发布。 +Beta 发布的分发(手动下载、可选加入):应用内更新器的 Stable 渠道只读 Stable 清单路径,因此普通用户绝不会通过自动更新拿到 Beta 构建。想尝试 Beta 的用户打开 **设置 → 关于**,启用“加入 Beta 频道”,再从应用从 GitHub 获取的链接处手动下载最新的 Beta 安装包。标签约定:`v-beta-tauri` 产出 Beta 发布(标记为 GitHub pre-release),而 `v-tauri` 产出 Stable 发布。 + +更新清单由项目 CDN 提供,每个平台/渠道一个滚动文件: + +```text +https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json +https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json +``` + +渠道隔离就是 URL 路径(`/updates/stable/` vs `/updates/beta/`),Stable 客户端即使请求错地址也拿不到 Beta 清单。清单里的下载地址同样指向 CDN(`https://dl.aeoform.com/releases/{tag}/{asset}`),国内用户直接从这里下载。清单由发布 workflow 生成为运行产物(run artifact),**不再挂到 GitHub Release**;因此发版后需要维护者手动把清单上传到 CDN,见 `RELEASING.md`。改动前装的客户端读的是旧的 `releases/latest/download/latest-*.json`,因此无法自动更新到最后一个还带这些文件的版本之后,需要手动下载一次。 ## 凭据 diff --git a/RELEASING.md b/RELEASING.md index 0a87893b8..d15806b04 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -165,9 +165,34 @@ The script takes a plain `X.Y.Z`; for a prerelease version such as 4. The applicable [desktop feature and device acceptance](docs/2.0-desktop-acceptance.md), signing, and distribution requirements are met; green builds alone do not establish product readiness. Linux acceptance below is required before including Linux assets. 5. Then, and only then, push the release tag. 6. Both Beta and Stable tag workflows upload Tauri and Android assets to a shared - draft. Wait for **both** workflows to succeed, verify the packages and channel - updater manifests, then publish that draft (Beta as a prerelease; Stable as latest). + draft. Wait for **both** workflows to succeed, verify the packages, then publish that + draft (Beta as a prerelease; Stable as latest). Do not rerun an asset workflow after publication without first returning the release to draft. + + The workflows also write the channel updater manifests (`latest-{target}-{arch}.json` and + `latest-{target}-{arch}-beta.json`) but deliberately keep them **out** of the release assets — + current clients read the CDN, not the release. Download them from the run and publish them to + the CDN as part of publishing the release: + + | platform | workflow artifact | + | --- | --- | + | macOS | `openless-macos--updater` | + | Windows | `openless-windows-x64-updater` | + | Android | `openless-android-updater-manifests` | + + Destination (overwrite in place; the file name is per platform and channel, never per + version): + + ```text + https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json + https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json + ``` + + Their `url` fields must point at the CDN artifacts (`.../releases/{tag}/{asset}`). + Manifest upload is the step that makes a release reachable by auto-update: a published + release whose manifests were not synced is downloadable by hand but invisible to the updater. + Clients built before the CDN switch read the old `releases/latest/download/latest-*.json` + assets, which new releases no longer carry, so they need one manual download. 7. Publishing Stable with the administrator's CLI/UI credential automatically triggers `sync-homebrew-cask.yml`. It accepts only the published latest Stable tag, preserves application build metadata in DMG filenames, verifies matching @@ -341,6 +366,6 @@ change the published channel. The same format applies to subsequent releases. 2. For a Stable release, a maintainer merges `beta → main`. 3. An **admin** bumps the Tauri version (five-location sync), verifies CI is green, and pushes the release tag, which triggers the macOS/Windows/Android asset - workflows and their channel-specific updater manifests. + workflows and their channel updater manifests. 4. After both release workflows pass, an admin verifies and publishes the shared draft. Linux packages are attached independently only after Linux acceptance. diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index 4a49657e3..57b0ff7b8 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -140,10 +140,11 @@ pub enum MacosNewlineMode { } /// Auto-update channel. Picks which manifest the background AutoUpdateGate -/// fetches. `Stable` = `latest-android-{arch}.json` (or the desktop -/// plugin-updater stable endpoints). `Beta` = `latest-android-{arch}-beta.json` -/// (or the desktop beta endpoints). The Settings manual "check stable / check -/// beta" buttons pass the channel explicitly and ignore this pref. +/// fetches. `Stable` = `/updates/stable/latest-{target}-{arch}.json` on the CDN +/// (`latest-android-{arch}.json` for the Android client). `Beta` = the +/// `/updates/beta/` counterpart with a `-beta` filename suffix. The Settings +/// manual "check stable / check beta" buttons pass the channel explicitly and +/// ignore this pref. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum UpdateChannel { diff --git a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs index 33fffe94f..90312c3cd 100644 --- a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs +++ b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs @@ -169,16 +169,15 @@ try { const apk = `OpenLess_${version}_${gradleAbi}.apk`; const alias = `OpenLess_android_${gradleAbi}.apk`; originals.push(`${directory}/${apk}`); - return [ - apk, - `${apk}.sig`, - alias, - `${alias}.sig`, - `latest-android-${abi}.json`, - `latest-android-${abi}-mirror.json`, - ]; + return [apk, `${apk}.sig`, alias, `${alias}.sig`]; }); - for (const name of names) writeFileSync(join(releaseTmp, name), 'fixture'); + // The manifests are still written (they feed the CDN sync through a workflow artifact) but + // must never be re-attached to the release: the in-app updater reads them from the CDN. + const manifests = ANDROID_ABI_MATRIX.flatMap(({ abi }) => [ + `latest-android-${abi}.json`, + `latest-android-${abi}-mirror.json`, + ]); + for (const name of [...names, ...manifests]) writeFileSync(join(releaseTmp, name), 'fixture'); const step = parsed.jobs['publish-android-release'].steps.find( (step) => step.id === 'release_assets', ); @@ -199,6 +198,14 @@ try { .trim() .split(/\r?\n/); assert.deepEqual(assets.sort(), names.map((name) => `${directory}/${name}`).sort()); + const manifestUpload = parsed.jobs['publish-android-release'].steps.find((step) => + step.uses?.startsWith('actions/upload-artifact@'), + ); + assert.match( + manifestUpload?.with.path, + /latest-android-\*\.json/, + 'manifests must leave the run as an artifact so the CDN sync can publish them', + ); } finally { rmSync(releaseTmp, { recursive: true, force: true }); } diff --git a/openless-all/app/scripts/release-publication-contract.test.mjs b/openless-all/app/scripts/release-publication-contract.test.mjs index b8d3e21cb..a56ee867a 100644 --- a/openless-all/app/scripts/release-publication-contract.test.mjs +++ b/openless-all/app/scripts/release-publication-contract.test.mjs @@ -15,6 +15,24 @@ for (const name of ['release-tauri.yml', 'android-apk.yml']) { `${name}: no channel may publish a partially built release`, ); assert.match(publishers[0].with.prerelease, /OPENLESS_RELEASE_CHANNEL == 'beta'/); + // Updater manifests belong to the CDN, not to the release assets: current clients read + // `/updates/{stable,beta}/` (see src-tauri/src/update_source.rs), and cloning them into the + // release produced fourteen near-identical files per tag. + assert.doesNotMatch( + publishers[0].with.files, + /latest-[^\s]*\.json/, + `${name}: updater manifests must not be attached to the release`, + ); + const manifestUploads = steps.filter( + (step) => + step.uses?.startsWith('actions/upload-artifact@') && + /latest-[^\s]*\.json/.test(step.with?.path ?? ''), + ); + assert.equal( + manifestUploads.length, + 1, + `${name}: exactly one artifact must carry the updater manifests for the CDN sync`, + ); } const sync = workflow('sync-homebrew-cask.yml'); assert.deepEqual(sync.on.release.types, ['published']); diff --git a/openless-all/app/scripts/repository-owner-contract.test.mjs b/openless-all/app/scripts/repository-owner-contract.test.mjs index 50c56e94b..6bf991801 100644 --- a/openless-all/app/scripts/repository-owner-contract.test.mjs +++ b/openless-all/app/scripts/repository-owner-contract.test.mjs @@ -4,7 +4,6 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)); -const currentRepo = 'Open-Less/openless'; const legacyRepo = 'appergb/openless'; const repositoryReferences = [ @@ -18,6 +17,7 @@ const repositoryReferences = [ 'openless-all/app/src-tauri/src/android/updater_logic.rs', 'openless-all/app/src-tauri/src/commands/mod.rs', 'openless-all/app/src-tauri/src/commands/settings.rs', + 'openless-all/app/src-tauri/src/update_source.rs', 'openless-all/app/src-tauri/tauri.conf.json', 'openless-all/app/src/components/AutoUpdate.tsx', 'openless-all/app/src/components/SettingsModal.tsx', @@ -41,9 +41,34 @@ assert( Array.isArray(updaterEndpoints) && updaterEndpoints.length > 0, 'desktop updater endpoints are missing', ); + +// Updater manifests are served by the project CDN, not by the GitHub Release assets. Both +// halves of that arrangement live in different files, so pin them together here: the endpoint +// in tauri.conf.json must come from the same CDN base that `update_source.rs` declares, and it +// must be the Stable path. +const updateSource = await readFile( + join(repoRoot, 'openless-all/app/src-tauri/src/update_source.rs'), + 'utf8', +); +const cdnBase = /CDN_BASE: &str = "(https:\/\/[^"]+)"/.exec(updateSource)?.[1]; +assert(cdnBase, 'update_source.rs must declare the CDN_BASE constant'); assert( - updaterEndpoints.every((endpoint) => endpoint.includes(currentRepo)), - 'desktop updater endpoints must use the current GitHub repository', + updateSource.includes('/updates/{path}/latest-'), + 'update_source.rs must derive manifest URLs from the CDN update path', ); +for (const endpoint of updaterEndpoints) { + assert( + endpoint.startsWith(`${cdnBase}/updates/stable/latest-`), + `desktop updater endpoint must point at the CDN Stable manifest: ${endpoint}`, + ); + assert( + !endpoint.includes('github.com'), + `desktop updater endpoint must not read GitHub Release assets: ${endpoint}`, + ); + assert( + endpoint.includes('{{target}}') && endpoint.includes('{{arch}}'), + `desktop updater endpoint must keep the {{target}}/{{arch}} placeholders: ${endpoint}`, + ); +} console.log('repository-owner-contract.test.mjs passed'); diff --git a/openless-all/app/scripts/write-updater-manifest-contract.test.mjs b/openless-all/app/scripts/write-updater-manifest-contract.test.mjs new file mode 100644 index 000000000..4acf2e332 --- /dev/null +++ b/openless-all/app/scripts/write-updater-manifest-contract.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const script = fileURLToPath(new URL('./write-updater-manifest.mjs', import.meta.url)); +const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); +const releaseTag = 'v2.0.2-Beta.3-tauri'; +const tempDir = mkdtempSync(join(tmpdir(), 'openless-updater-')); +const assetName = `OpenLess_${packageJson.version}_arm64-v8a.apk`; + +try { + writeFileSync(join(tempDir, assetName), 'fixture'); + writeFileSync(join(tempDir, `${assetName}.sig`), 'signature'); + execFileSync(process.execPath, [script], { + env: { + ...process.env, + OPENLESS_UPDATE_APK_DIR: tempDir, + OPENLESS_UPDATE_ARCH: 'aarch64', + OPENLESS_UPDATE_CDN_BASE_URL: 'https://cdn.example/', + OPENLESS_UPDATE_TARGET: 'android', + OPENLESS_RELEASE_CHANNEL: 'beta', + OPENLESS_RELEASE_TAG: releaseTag, + }, + stdio: 'pipe', + }); + + const expected = + `https://cdn.example/releases/${encodeURIComponent(releaseTag)}/` + + encodeURIComponent(assetName); + for (const suffix of ['', '-mirror']) { + const manifest = JSON.parse( + readFileSync(join(tempDir, `latest-android-aarch64-beta${suffix}.json`), 'utf8'), + ); + assert.equal(manifest.url, expected); + } + + const stableTag = 'v2.0.1-tauri'; + execFileSync(process.execPath, [script], { + env: { + ...process.env, + OPENLESS_UPDATE_APK_DIR: tempDir, + OPENLESS_UPDATE_ARCH: 'aarch64', + OPENLESS_UPDATE_CDN_BASE_URL: 'https://cdn.example/', + OPENLESS_UPDATE_TARGET: 'android', + OPENLESS_RELEASE_CHANNEL: 'stable', + OPENLESS_RELEASE_TAG: stableTag, + }, + stdio: 'pipe', + }); + const stableExpected = + `https://cdn.example/releases/${encodeURIComponent(stableTag)}/` + + encodeURIComponent(assetName); + for (const suffix of ['', '-mirror']) { + const manifest = JSON.parse( + readFileSync(join(tempDir, `latest-android-aarch64${suffix}.json`), 'utf8'), + ); + assert.equal(manifest.url, stableExpected); + } +} finally { + rmSync(tempDir, { recursive: true, force: true }); +} + +console.log('write-updater-manifest-contract.test.mjs passed'); diff --git a/openless-all/app/scripts/write-updater-manifest.mjs b/openless-all/app/scripts/write-updater-manifest.mjs index d9512ed98..6c1258c95 100755 --- a/openless-all/app/scripts/write-updater-manifest.mjs +++ b/openless-all/app/scripts/write-updater-manifest.mjs @@ -6,9 +6,10 @@ import { fileURLToPath } from 'node:url'; const target = process.env.OPENLESS_UPDATE_TARGET; const arch = process.env.OPENLESS_UPDATE_ARCH; -const repo = process.env.OPENLESS_UPDATE_REPO || 'Open-Less/openless'; -const mirrorBaseUrl = - process.env.OPENLESS_UPDATE_MIRROR_BASE_URL || 'https://fastgit.cc/https://github.com'; +const cdnBaseUrl = (process.env.OPENLESS_UPDATE_CDN_BASE_URL || 'https://dl.aeoform.com').replace( + /\/$/, + '', +); const rawChannel = (process.env.OPENLESS_RELEASE_CHANNEL || 'stable').toLowerCase(); if (rawChannel !== 'stable' && rawChannel !== 'beta') { throw new Error( @@ -111,21 +112,17 @@ if (!existsSync(signaturePath)) { const assetName = basename(artifact); const manifestName = `latest-${target}-${arch}${channelSuffix}.json`; const mirrorManifestName = `latest-${target}-${arch}${channelSuffix}-mirror.json`; -const downloadPath = - rawChannel === 'beta' - ? `releases/download/${releaseTag}/${assetName}` - : `releases/latest/download/${assetName}`; -const githubAssetUrl = `https://github.com/${repo}/${downloadPath}`; -const mirrorAssetUrl = `${mirrorBaseUrl.replace(/\/$/, '')}/${repo}/${downloadPath}`; +const releasePath = + rawChannel === 'beta' ? releaseTag : /^v.+-tauri$/.test(releaseTag) ? releaseTag : 'latest'; +const cdnAssetUrl = `${cdnBaseUrl}/releases/${encodeURIComponent(releasePath)}/${encodeURIComponent(assetName)}`; const manifest = { version: packageJson.version, pub_date: new Date().toISOString(), - url: githubAssetUrl, + url: cdnAssetUrl, signature: readFileSync(signaturePath, 'utf8').trim(), }; const mirrorManifest = { ...manifest, - url: mirrorAssetUrl, }; const outDir = target === 'android' ? bundleDir : bundleDir; diff --git a/openless-all/app/src-tauri/src/android/updater.rs b/openless-all/app/src-tauri/src/android/updater.rs index ab54d13e8..eea55ba01 100644 --- a/openless-all/app/src-tauri/src/android/updater.rs +++ b/openless-all/app/src-tauri/src/android/updater.rs @@ -8,12 +8,10 @@ mod android_impl { use tauri::{AppHandle, Emitter}; use crate::android::updater_logic::{ - beta_manifest_urls, format_manifest_error, map_abi_to_arch, stable_manifest_urls, - verify_updater_signature, version_is_newer, INSTALLER_NOT_OPENED_MSG, UPDATER_PUBKEY_B64, - }; - use crate::commands::{ - fetch_latest_beta_release, parse_latest_beta_from_atom, AppUpdateMetadata, + format_manifest_error, map_abi_to_arch, verify_updater_signature, version_is_newer, + INSTALLER_NOT_OPENED_MSG, UPDATER_PUBKEY_B64, }; + use crate::commands::AppUpdateMetadata; use crate::net; use crate::types::UpdateChannel; @@ -80,47 +78,33 @@ mod android_impl { pub async fn check_update(channel: UpdateChannel) -> Result, String> { let arch = device_arch()?; - let urls = match channel { - UpdateChannel::Stable => stable_manifest_urls(arch), - UpdateChannel::Beta => { - let Some(latest) = fetch_latest_beta_release().await? else { - return Err("尚未发布过 Beta 版本".to_string()); - }; - beta_manifest_urls(arch, &latest.tag_name) - } - }; + // Manifests live on the project CDN; channel is a URL path, not a filename suffix, so + // there is no tag discovery and no GitHub dependency (see `crate::update_source`). + let url = crate::update_source::manifest_url(channel, "android", arch); let current = env!("CARGO_PKG_VERSION").to_string(); - let mut last_err = String::new(); - for url in urls { - match fetch_manifest(&url).await { - Ok(manifest) => { - if !version_is_newer(&manifest.version, ¤t) { - return Ok(None); - } - let raw_json = serde_json::json!({ - "version": manifest.version, - "url": manifest.url, - "signature": manifest.signature, - "pubDate": manifest.pub_date, - "notes": manifest.notes, - }); - return Ok(Some(AppUpdateMetadata { - rid: 0, - current_version: current, - version: manifest.version, - date: manifest.pub_date, - body: manifest.notes, - raw_json, - })); + match fetch_manifest(&url).await { + Ok(manifest) => { + if !version_is_newer(&manifest.version, ¤t) { + return Ok(None); } - Err(err) => last_err = err, + let raw_json = serde_json::json!({ + "version": manifest.version, + "url": manifest.url, + "signature": manifest.signature, + "pubDate": manifest.pub_date, + "notes": manifest.notes, + }); + Ok(Some(AppUpdateMetadata { + rid: 0, + current_version: current, + version: manifest.version, + date: manifest.pub_date, + body: manifest.notes, + raw_json, + })) } + Err(err) => Err(err), } - Err(if last_err.is_empty() { - "无法获取更新清单".to_string() - } else { - last_err - }) } fn verify_signature(apk_bytes: &[u8], signature_b64: &str) -> Result<(), String> { @@ -207,11 +191,6 @@ mod android_impl { } Ok(()) } - - #[allow(dead_code)] - pub fn parse_beta_atom(body: &str) -> Option { - parse_latest_beta_from_atom(body) - } } #[cfg(target_os = "android")] diff --git a/openless-all/app/src-tauri/src/android/updater_logic.rs b/openless-all/app/src-tauri/src/android/updater_logic.rs index 7e8e4fc26..b992e58f5 100644 --- a/openless-all/app/src-tauri/src/android/updater_logic.rs +++ b/openless-all/app/src-tauri/src/android/updater_logic.rs @@ -1,7 +1,4 @@ -//! Pure Android updater helpers (manifest URLs, version compare). Testable on all targets. - -pub const MIRROR_BASE: &str = "https://fastgit.cc/https://github.com/Open-Less/openless"; -pub const DIRECT_BASE: &str = "https://github.com/Open-Less/openless"; +//! Pure Android updater helpers (version compare, minisign verify). Testable on all targets. /// Must match `plugins.updater.pubkey` in `tauri.conf.json` (TAURI_SIGNING_PRIVATE_KEY pair). pub const UPDATER_PUBKEY_B64: &str = @@ -31,20 +28,6 @@ pub fn version_is_newer(remote: &str, current: &str) -> bool { remote > current } -pub fn stable_manifest_urls(arch: &str) -> Vec { - vec![ - format!("{MIRROR_BASE}/releases/latest/download/latest-android-{arch}-mirror.json"), - format!("{DIRECT_BASE}/releases/latest/download/latest-android-{arch}.json"), - ] -} - -pub fn beta_manifest_urls(arch: &str, tag: &str) -> Vec { - vec![ - format!("{MIRROR_BASE}/releases/download/{tag}/latest-android-{arch}-beta-mirror.json"), - format!("{DIRECT_BASE}/releases/download/{tag}/latest-android-{arch}-beta.json"), - ] -} - /// Human-readable manifest fetch failure for UI tooltips. pub fn format_manifest_error(status: u16, url: &str) -> String { if status == 404 { @@ -120,63 +103,6 @@ mod tests { assert!(!version_is_newer("not-a-version", "1.3.18-Beta.7")); } - #[test] - fn stable_manifest_urls_use_latest_download_path() { - let urls = stable_manifest_urls("aarch64"); - assert_eq!(urls.len(), 2); - assert!(urls[0].contains("latest-android-aarch64-mirror.json")); - assert!(urls[1].ends_with("latest-android-aarch64.json")); - assert!(!urls[1].contains("-beta")); - } - - #[test] - fn beta_manifest_urls_include_tag_and_beta_suffix() { - let urls = beta_manifest_urls("aarch64", "v1.3.8-1-beta-tauri"); - assert!(urls[0].contains("/releases/download/v1.3.8-1-beta-tauri/")); - assert!(urls[1].ends_with("latest-android-aarch64-beta.json")); - } - - #[test] - fn beta_manifest_urls_use_newest_modern_beta_tag_from_atom() { - let body = r#" - - 2026-07-15T07:00:00Z - - - - 2026-06-17T15:41:46Z - - -"#; - let latest = crate::commands::parse_latest_beta_from_atom(body) - .expect("Android updater must discover the modern Beta tag"); - - let urls = beta_manifest_urls("aarch64", &latest.tag_name); - - assert_eq!(latest.tag_name, "v1.3.15-Beta.1-tauri"); - assert!(urls - .iter() - .all(|url| url.contains("/releases/download/v1.3.15-Beta.1-tauri/"))); - } - - #[test] - fn beta_manifest_urls_skip_malformed_modern_tags_from_atom() { - let body = r#" - - - -"#; - let latest = crate::commands::parse_latest_beta_from_atom(body) - .expect("Android updater must skip malformed Beta tags"); - - let urls = beta_manifest_urls("aarch64", &latest.tag_name); - - assert_eq!(latest.tag_name, "v1.3.15-Beta.1-tauri"); - assert!(urls - .iter() - .all(|url| url.contains("/releases/download/v1.3.15-Beta.1-tauri/"))); - } - #[test] fn map_abi_to_arch_maps_arm64() { assert_eq!(map_abi_to_arch("arm64-v8a"), "aarch64"); diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 739092098..4ee82123e 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -539,6 +539,28 @@ mod tests { assert_eq!(stale_payload.update_channel, UpdateChannel::Stable); assert!(stale_payload.update_channel_explicit); } + + #[test] + fn android_update_url_validation_requires_exact_https_release_hosts() { + assert!(is_allowed_android_update_url( + "https://dl.aeoform.com/releases/v2.0.2-tauri/OpenLess_aarch64.app.tar.gz" + )); + assert!(is_allowed_android_update_url( + "https://github.com/Open-Less/openless/releases/latest/download/OpenLess.apk" + )); + assert!(is_allowed_android_update_url( + "https://fastgit.cc/https://github.com/Open-Less/openless/releases/latest/download/OpenLess.apk" + )); + assert!(!is_allowed_android_update_url( + "https://dl.aeoform.com@attacker.example/package.apk" + )); + assert!(!is_allowed_android_update_url( + "https://dl.aeoform.com.attacker.example/package.apk" + )); + assert!(!is_allowed_android_update_url( + "http://dl.aeoform.com/releases/v2.0.2-tauri/OpenLess.apk" + )); + } } // ─────────────────────────── release channel (Beta opt-in) ─────────────────────────── @@ -712,20 +734,21 @@ fn extract_between(haystack: &str, open: &str, close: &str) -> Option { // ─────────────────────── Channel-aware updater check ──────────────────────── // // Replaces the frontend's previous direct import('@tauri-apps/plugin-updater').check() path: -// - Stable channel: the builder keeps tauri.conf's stable manifest URL endpoints untouched. -// - Beta channel: fetch_latest_beta_release gets the latest prerelease tag, builds the -beta -// manifest URL (as a mirror + direct pair), then +// - Stable channel: the builder keeps tauri.conf's Stable manifest endpoint untouched, which +// is `https://dl.aeoform.com/updates/stable/latest-{{target}}-{{arch}}.json`. +// - Beta channel: swap in `https://dl.aeoform.com/updates/beta/...-beta.json` through // builder.endpoints(vec![url])?.build()?.check(). // +// Manifests live on the project CDN instead of the GitHub Release assets (see +// `crate::update_source`), so a release no longer accumulates fourteen `latest-*.json` copies. +// The channel split is now a URL *path* (`/updates/stable/` vs `/updates/beta/`) instead of a +// filename suffix, and artifact URLs inside the manifest point at the same CDN — a Stable +// client can never reach a Beta package, and mainland-China downloads never touch github.com. +// // The returned Metadata shape exactly matches plugin-updater's JS UpdateMetadata (rid + // currentVersion and other camelCase fields), so the frontend can call // `new Update(metadata)` directly and reuse the plugin's download / install / close // implementations instead of writing our own download and signature verification. -// -// Physical isolation: manifests published from Beta tags carry a `-beta` filename suffix -// (see the comment at line 382 of release-tauri.yml), separate from Stable's -// `latest-{tgt}-{arch}.json` in the GitHub Release assets — even if buggy code passed a -// Beta URL to a Stable user, HTTP returns 404 outright; the wrong artifact is unreachable. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -742,7 +765,7 @@ pub struct AppUpdateMetadata { pub raw_json: serde_json::Value, } -/// Resolves the manifest source, then runs plugin-updater's standard check flow. +/// Resolves the update endpoint, then runs plugin-updater's standard check flow. /// Channel: uses an explicitly passed `channel` (the About page always checks Stable, the /// advanced page's Beta section checks Beta); when absent, uses the user's explicit /// channel choice; when none chosen yet, follows the current build type. @@ -764,10 +787,9 @@ pub async fn app_check_update_with_channel( builder = builder.timeout(std::time::Duration::from_millis(ms)); } if matches!(channel, UpdateChannel::Beta) { - let urls = resolve_beta_manifest_endpoints().await?; builder = builder - .endpoints(urls) - .map_err(|e| format!("set beta endpoints: {e}"))?; + .endpoints(vec![beta_manifest_endpoint()?]) + .map_err(|e| format!("set beta endpoint: {e}"))?; } let updater = builder.build().map_err(|e| format!("build updater: {e}"))?; let update = updater @@ -792,28 +814,17 @@ pub async fn app_check_update_with_channel( Ok(Some(metadata)) } -/// Builds the -beta manifest URL pair from the latest prerelease tag found by -/// fetch_latest_beta_release. -/// Order: mirror first (fastgit.cc proxies GitHub), then direct — matching tauri.conf's -/// existing Stable endpoints so mainland-China access hits the CDN first. +/// The Beta manifest endpoint, with `{{target}}` / `{{arch}}` left in place for +/// `tauri-plugin-updater` to substitute at check time. +/// +/// Beta is a URL *path* on the CDN (`/updates/beta/`), so there is no tag discovery step and +/// no dependency on `releases.atom` or the GitHub Release assets. #[cfg(not(mobile))] -async fn resolve_beta_manifest_endpoints() -> Result, String> { - let Some(latest) = fetch_latest_beta_release().await? else { - return Err("尚未发布过 Beta 版本".to_string()); - }; - let tag = latest.tag_name; - // {{target}} / {{arch}} placeholders are substituted by the plugin at check time. A - // Rust raw string (r#""#) needs no escaped double braces and is cleaner than format!-ing - // the literal. - let mirror = format!( - "https://fastgit.cc/https://github.com/Open-Less/openless/releases/download/{tag}/latest-{{{{target}}}}-{{{{arch}}}}-beta-mirror.json" - ); - let direct = format!( - "https://github.com/Open-Less/openless/releases/download/{tag}/latest-{{{{target}}}}-{{{{arch}}}}-beta.json" - ); - let mirror_url = url::Url::parse(&mirror).map_err(|e| format!("parse beta mirror url: {e}"))?; - let direct_url = url::Url::parse(&direct).map_err(|e| format!("parse beta direct url: {e}"))?; - Ok(vec![mirror_url, direct_url]) +fn beta_manifest_endpoint() -> Result { + let template = crate::update_source::endpoint_template(UpdateChannel::Beta); + // `url` percent-encodes the `{{target}}` / `{{arch}}` braces; the plugin substitutes both + // the literal and the `%7B%7B…%7D%7D` form, so either spelling works at check time. + url::Url::parse(&template).map_err(|e| format!("parse beta manifest url: {e}")) } #[cfg(mobile)] @@ -836,6 +847,27 @@ pub async fn app_check_update_with_channel( } } +fn is_allowed_android_update_url(raw: &str) -> bool { + let Ok(parsed) = url::Url::parse(raw) else { + return false; + }; + if parsed.scheme() != "https" + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.port().is_some() + { + return false; + } + match (parsed.host_str(), parsed.path()) { + (Some("dl.aeoform.com"), path) => path.starts_with("/releases/"), + (Some("github.com"), path) => path.starts_with("/Open-Less/openless/releases/"), + (Some("fastgit.cc"), path) => { + path.starts_with("/https://github.com/Open-Less/openless/releases/") + } + _ => false, + } +} + #[cfg(mobile)] #[tauri::command] pub async fn app_download_and_install_android_update( @@ -844,12 +876,9 @@ pub async fn app_download_and_install_android_update( signature: String, version: String, ) -> Result<(), String> { - // Security: validate the URL before downloading to prevent SSRF (e.g. intranet metadata - // endpoints, localhost services). Only the known GitHub direct links and the fastgit - // mirror prefix are allowed. - const DIRECT_BASE: &str = "https://github.com/Open-Less/openless"; - const MIRROR_BASE: &str = "https://fastgit.cc/https://github.com/Open-Less/openless"; - if !url.starts_with(DIRECT_BASE) && !url.starts_with(MIRROR_BASE) { + // Parse and validate the host before downloading so userinfo and hostname-prefix tricks + // cannot turn the signed update fetch into an SSRF primitive. + if !is_allowed_android_update_url(&url) { return Err(format!("不信任的更新 URL,拒绝下载: {url}")); } #[cfg(target_os = "android")] diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index c385a1df8..ab628ec7d 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -116,6 +116,7 @@ mod unicode_keystroke; #[cfg(mobile)] #[path = "mobile_stubs/unicode_keystroke.rs"] mod unicode_keystroke; +mod update_source; #[cfg(target_os = "windows")] mod windows_ime_ipc; mod windows_ime_profile; diff --git a/openless-all/app/src-tauri/src/update_source.rs b/openless-all/app/src-tauri/src/update_source.rs new file mode 100644 index 000000000..26ca3f528 --- /dev/null +++ b/openless-all/app/src-tauri/src/update_source.rs @@ -0,0 +1,130 @@ +//! Where the in-app updater looks for release manifests. +//! +//! # Background +//! +//! The updater used to read its manifest from the GitHub Release assets +//! (`releases/latest/download/latest-{target}-{arch}.json`, and a `-beta` twin per tag). That +//! forced every release to carry fourteen `latest-*.json` files — seven target/arch entries +//! times a `-mirror` twin — all repeating the same three fields (`version`, `url`, `signature`), +//! and every new release re-uploaded the same file names because the Stable endpoint was a +//! rolling pointer. +//! +//! The manifests now live on the project's own CDN: +//! +//! ```text +//! https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json +//! https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json +//! ``` +//! +//! Two properties matter: +//! +//! - **One rolling file per platform.** The CDN name is stable per channel, so a release never +//! accumulates manifest copies; publishing overwrites in place. +//! - **Channel separation is a path, not a filename suffix.** `/updates/stable/` and +//! `/updates/beta/` are distinct locations, so a Stable client cannot walk into a Beta +//! manifest by accident. +//! +//! The manifest body is unchanged (`version`, `pub_date`, `url`, `signature`), so +//! `tauri-plugin-updater` keeps owning download, minisign verification and platform install. +//! Artifact URLs inside the manifest point at the same CDN (`/releases/{tag}/{asset}`), which +//! is what mainland-China users download from. + +use crate::types::UpdateChannel; + +/// CDN that serves both the update manifests and the mirrored release artifacts. +pub(crate) const CDN_BASE: &str = "https://dl.aeoform.com"; + +/// Manifest URL for `channel`, with the `{{target}}` / `{{arch}}` tokens left in place for +/// `tauri-plugin-updater` to substitute at check time. +/// +/// `format!` doubles the braces, so `{{{{target}}}}` renders as the literal `{{target}}`. +pub fn endpoint_template(channel: UpdateChannel) -> String { + let (path, suffix) = match channel { + UpdateChannel::Stable => ("stable", ""), + UpdateChannel::Beta => ("beta", "-beta"), + }; + format!("{CDN_BASE}/updates/{path}/latest-{{{{target}}}}-{{{{arch}}}}{suffix}.json") +} + +/// Concrete manifest URL for clients that fetch the manifest themselves (Android). +/// +/// `target`/`arch` use the updater token convention (`android` + `aarch64`, `darwin` + +/// `x86_64`, …), i.e. the same values the plugin would substitute into +/// [`endpoint_template`]. +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +pub fn manifest_url(channel: UpdateChannel, target: &str, arch: &str) -> String { + endpoint_template(channel) + .replace("{{target}}", target) + .replace("{{arch}}", arch) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_endpoint_has_no_beta_marker() { + let url = endpoint_template(UpdateChannel::Stable); + assert!( + !url.ends_with('/'), + "endpoint must not end with a slash: {url}" + ); + assert!(url.starts_with("https://dl.aeoform.com/updates/stable/")); + assert!(url.contains("{{target}}") && url.contains("{{arch}}")); + assert!(!url.contains("beta")); + } + + #[test] + fn beta_endpoint_is_a_separate_path_with_suffix() { + let url = endpoint_template(UpdateChannel::Beta); + assert!(url.starts_with("https://dl.aeoform.com/updates/beta/")); + assert!(url.ends_with("-{{target}}-{{arch}}-beta.json")); + } + + #[test] + fn manifest_url_substitutes_every_token() { + for (channel, expected) in [ + ( + UpdateChannel::Stable, + "https://dl.aeoform.com/updates/stable/latest-darwin-aarch64.json", + ), + ( + UpdateChannel::Beta, + "https://dl.aeoform.com/updates/beta/latest-darwin-aarch64-beta.json", + ), + ] { + let url = manifest_url(channel, "darwin", "aarch64"); + assert_eq!(url, expected); + assert!(!url.contains("{{"), "unsubstituted token in {url}"); + } + } + + #[test] + fn manifest_url_covers_every_published_target() { + for (target, arch, expected) in [ + ("darwin", "aarch64", "latest-darwin-aarch64.json"), + ("darwin", "x86_64", "latest-darwin-x86_64.json"), + ("windows", "x86_64", "latest-windows-x86_64.json"), + ("android", "aarch64", "latest-android-aarch64.json"), + ("android", "armv7", "latest-android-armv7.json"), + ("android", "x86_64", "latest-android-x86_64.json"), + ("android", "i686", "latest-android-i686.json"), + ] { + assert!( + manifest_url(UpdateChannel::Stable, target, arch).ends_with(expected), + "{target}/{arch} should resolve to {expected}" + ); + assert!( + manifest_url(UpdateChannel::Beta, target, arch) + .ends_with(&expected.replace(".json", "-beta.json")), + "{target}/{arch} beta should carry the -beta suffix" + ); + } + } + + #[test] + fn cdn_base_has_no_trailing_slash() { + assert!(!CDN_BASE.ends_with('/')); + assert!(endpoint_template(UpdateChannel::Stable).starts_with(CDN_BASE)); + } +} diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 697be3b27..a73d7ad72 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -109,8 +109,7 @@ "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFERUFBODAzNTY0QzMyM0YKUldRL01reFdBNmpxSGE1K0JadlpONXNWTzhJcGZCRGxjUVdIWExNNFJpeUNsSGZwazdlQThhemkK", "endpoints": [ - "https://github.com/Open-Less/openless/releases/latest/download/latest-{{target}}-{{arch}}.json", - "https://fastgit.cc/https://github.com/Open-Less/openless/releases/latest/download/latest-{{target}}-{{arch}}-mirror.json" + "https://dl.aeoform.com/updates/stable/latest-{{target}}-{{arch}}.json" ] } } From bddad88d56d31bc90e904022ea25fc3daba0b4f2 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Wed, 28 Oct 2026 14:49:50 +0800 Subject: [PATCH 18/31] fix: stabilize Sherpa offline decode diagnostics (#1282) * fix: stabilize Sherpa offline decode diagnostics * fix: keep Android Sherpa threading unchanged --- .../src-tauri/src/asr/local/sherpa_runtime.rs | 99 +++++++++++++++++-- 1 file changed, 93 insertions(+), 6 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/local/sherpa_runtime.rs b/openless-all/app/src-tauri/src/asr/local/sherpa_runtime.rs index 88d2e5944..2bdcb92c3 100644 --- a/openless-all/app/src-tauri/src/asr/local/sherpa_runtime.rs +++ b/openless-all/app/src-tauri/src/asr/local/sherpa_runtime.rs @@ -530,9 +530,15 @@ async fn load_model(alias: &str, _dir: &Path) -> Result { #[cfg(local_asr_sherpa)] fn create_offline_recognizer(alias: &str, dir: &Path) -> Result { let mut config = OfflineRecognizerConfig::default(); - config.model_config.num_threads = std::thread::available_parallelism() + let default_num_threads = std::thread::available_parallelism() .map(|n| n.get().clamp(1, 4) as i32) .unwrap_or(2); + config.model_config.num_threads = default_num_threads; + #[cfg(target_os = "windows")] + { + // Keep Windows offline decode on one ONNX worker until native multi-threaded decode is proven stable. + config.model_config.num_threads = 1; + } config.model_config.provider = Some("cpu".into()); match sherpa_target(alias)?.sherpa_family() { Some(openless_core::SherpaModelFamily::SenseVoice) => { @@ -570,13 +576,25 @@ fn create_offline_recognizer(alias: &str, dir: &Path) -> Result { anyhow::bail!("zipformer is not supported by offline batch M2") } None => anyhow::bail!("unknown sherpa-onnx model family: {alias}"), } + log::info!( + "[sherpa-asr] create offline recognizer model={} threads={}", + alias, + config.model_config.num_threads + ); OfflineRecognizer::create(&config) .ok_or_else(|| anyhow::anyhow!("create sherpa-onnx offline recognizer failed")) } @@ -626,33 +644,102 @@ async fn transcribe_loaded_model( language_hint: Option, audio_timeout: std::time::Duration, ) -> Result { - run_serialized_decode( + let model_alias = loaded.alias.clone(); + let chunk_count = pcm_chunks.len(); + let decode_model_alias = model_alias.clone(); + log::info!( + "[sherpa-asr] native decode begin model={} chunks={}", + model_alias, + chunk_count + ); + let result = run_serialized_decode( Arc::clone(&loaded.decode_gate), audio_timeout, move |cancelled| { let mut texts = Vec::with_capacity(pcm_chunks.len()); - for pcm in pcm_chunks { + for (chunk_index, pcm) in pcm_chunks.into_iter().enumerate() { if cancelled.load(Ordering::Acquire) { anyhow::bail!("sherpa-onnx transcribe cancelled"); } + let chunk_number = chunk_index + 1; + let chunk_audio_ms = pcm_duration_ms(&pcm); + log::info!( + "[sherpa-asr] native decode chunk model={} chunk={}/{} audio_ms={} stage=chunk_begin", + decode_model_alias, + chunk_number, + chunk_count, + chunk_audio_ms + ); let samples = pcm_s16le_to_f32(&pcm)?; + log::info!( + "[sherpa-asr] native decode chunk model={} chunk={}/{} samples={} stage=stream_create", + decode_model_alias, + chunk_number, + chunk_count, + samples.len() + ); let stream = loaded.recognizer.create_stream(); + log::info!( + "[sherpa-asr] native decode chunk model={} chunk={}/{} stage=stream_created", + decode_model_alias, + chunk_number, + chunk_count + ); if let Some(language) = language_hint.as_deref().filter(|value| !value.is_empty()) { if stream.has_option("language") { stream.set_option("language", language); } } stream.accept_waveform(16_000, &samples); + log::info!( + "[sherpa-asr] native decode chunk model={} chunk={}/{} stage=waveform_accepted", + decode_model_alias, + chunk_number, + chunk_count + ); loaded.recognizer.decode(&stream); + log::info!( + "[sherpa-asr] native decode chunk model={} chunk={}/{} stage=decoded", + decode_model_alias, + chunk_number, + chunk_count + ); let result = stream .get_result() .ok_or_else(|| anyhow::anyhow!("sherpa-onnx returned no result"))?; + log::info!( + "[sherpa-asr] native decode chunk model={} chunk={}/{} text_chars={} stage=result_received", + decode_model_alias, + chunk_number, + chunk_count, + result.text.chars().count() + ); texts.push(result.text); } - Ok(openless_core::asr::whisper::join_transcript_chunks(&texts)) + let text = openless_core::asr::whisper::join_transcript_chunks(&texts); + log::info!( + "[sherpa-asr] native decode complete model={} chunks={} text_chars={}", + decode_model_alias, + chunk_count, + text.chars().count() + ); + Ok(text) }, ) - .await + .await; + match &result { + Ok(text) => log::info!( + "[sherpa-asr] native decode returned model={} text_chars={}", + model_alias, + text.chars().count() + ), + Err(error) => log::warn!( + "[sherpa-asr] native decode failed model={} error={:#}", + model_alias, + error + ), + } + result } #[cfg(not(local_asr_sherpa))] From 3bebfcaf08e35e7e06946a2498b416f80a7add94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=A9=E5=BD=AC=28=5E-=5E=29?= <146098469+HKLHaoBin@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:28:25 +0800 Subject: [PATCH 19/31] =?UTF-8?q?fix(windows):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=A4=9A=E5=B1=8F=E8=83=B6=E5=9B=8A=E5=88=87=E5=9B=9E=E5=8D=95?= =?UTF-8?q?=E5=B1=8F=E6=97=B6=E5=8E=9F=E6=96=87=E5=8F=8D=E5=A4=8D=E5=BC=B9?= =?UTF-8?q?=E5=87=BA=EF=BC=8C=E6=97=A0=E9=9C=80=E9=87=8D=E5=90=AF=20(#1284?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(capsule): isolate mirror events for live display switching * fix(windows): restore transcript after mirror reuse --------- Co-authored-by: Chris233 --- .../app/e2e/capsule-display-mode.spec.ts | 20 +- .../app/e2e/capsule-live-display.spec.ts | 91 +++++++ .../app/e2e/fixtures/capsule-motion.tsx | 30 +- .../app/scripts/capsule-ready-replay.test.mjs | 256 +++++++++++++++++- .../app/scripts/windows-ui-config.test.mjs | 7 +- .../app/src-tauri/src/commands/dictation.rs | 6 + openless-all/app/src/components/AudioCue.tsx | 28 +- openless-all/app/src/components/Capsule.tsx | 39 ++- .../app/src/components/LiveTranscriptPill.tsx | 77 ++++-- openless-all/app/src/lib/ipc/dictation.ts | 12 +- .../pages/settings/RecordingInputSection.tsx | 71 +---- 11 files changed, 509 insertions(+), 128 deletions(-) create mode 100644 openless-all/app/e2e/capsule-live-display.spec.ts diff --git a/openless-all/app/e2e/capsule-display-mode.spec.ts b/openless-all/app/e2e/capsule-display-mode.spec.ts index 976e08b5e..d19fa3314 100644 --- a/openless-all/app/e2e/capsule-display-mode.spec.ts +++ b/openless-all/app/e2e/capsule-display-mode.spec.ts @@ -35,24 +35,20 @@ for (const os of ['mac', 'win']) { await remountInputSection(); await expect(control).toContainText('仅主显示器'); - const expectRestartWarning = async (mode: string) => { + const expectLiveSwitch = async (mode: string) => { await control.click(); await page.getByRole('option', { name: '所有屏幕显示', exact: true }).click(); await control.click(); await page.getByRole('option', { name: mode, exact: true }).click(); - await expect( - page.getByRole('dialog', { name: '重启 OpenLess 以应用显示模式' }), - ).toBeVisible(); - await page - .getByRole('dialog', { name: '重启 OpenLess 以应用显示模式' }) - .getByRole('button', { name: '取消', exact: true }) - .click(); + await expect(control).toContainText(mode); await expect( page.getByRole('dialog', { name: '重启 OpenLess 以应用显示模式' }), ).toBeHidden(); + await remountInputSection(); + await expect(control).toContainText(mode); }; - await expectRestartWarning('当前前台窗口所在显示器'); - await expectRestartWarning('仅主显示器'); + await expectLiveSwitch('当前前台窗口所在显示器'); + await expectLiveSwitch('仅主显示器'); } else { await expect(page.getByRole('option', { name: '仅主显示器', exact: true })).toHaveCount(0); await expect( @@ -77,9 +73,9 @@ for (const os of ['mac', 'win']) { const restartDialog = page.getByRole('dialog', { name: '重启 OpenLess 以应用显示模式', }); - await expect(restartDialog).toBeVisible(); - await restartDialog.getByRole('button', { name: '现在重启', exact: true }).click(); await expect(restartDialog).toBeHidden(); + await remountInputSection(); + await expect(control).toContainText('鼠标所在屏幕'); } }); } diff --git a/openless-all/app/e2e/capsule-live-display.spec.ts b/openless-all/app/e2e/capsule-live-display.spec.ts new file mode 100644 index 000000000..aa4b2994a --- /dev/null +++ b/openless-all/app/e2e/capsule-live-display.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test'; + +const fixture = 'http://127.0.0.1:5326/e2e/fixtures/capsule-motion.html'; + +for (const rail of [false, true]) { + test(`hidden mirror cleanup cannot restart the live ${rail ? 'transcript rail' : 'capsule'}`, async ({ + page, + }) => { + const label = rail ? 'capsule-rail' : 'capsule'; + const mirror = rail ? 'capsule-rail-mirror-0' : 'capsule-mirror-0'; + await page.goto(`${fixture}?os=win&transcript&label=${label}&${rail ? 'rail' : 'full'}`); + await page.waitForFunction(() => (window as any).fixture.hasListener('backend:event')); + await page.evaluate((label) => { + (window as any).fixture.emitTo(label, 'capsule:state', { + state: 'recording', + sessionId: 'recording-a', + level: 0.2, + }); + }, label); + const view = page.locator(rail ? '.ol-capsule-transcript-overlay' : '.ol-capsule-pill'); + if (!rail) { + await expect(view).toHaveAttribute('data-recording', 'true'); + await view.evaluate((node) => node.setAttribute('data-test-retained', 'true')); + } + for (let i = 1; i <= 12; i += 1) { + await page.evaluate( + ({ label, mirror, i }) => { + const bridge = (window as any).fixture; + bridge.emitTo(label, 'capsule:state', { + state: 'recording', + sessionId: 'recording-a', + level: 0.2, + }); + bridge.emit('backend:event', { + sequence: i, + sessionId: 'recording-a', + kind: { + type: 'transcript_delta', + payload: { text: 'x'.repeat(i), offset: 0, isFinal: true }, + }, + }); + bridge.emitTo(mirror, 'capsule:state', { state: 'idle', sessionId: 'recording-a' }); + }, + { label, mirror, i }, + ); + if (rail) { + await expect(view).toHaveAttribute('data-transcript-visible', 'true'); + await expect(view.getByRole('status')).toHaveText('x'.repeat(i)); + const stage = view.locator('.ol-live-transcript-stage'); + if (i === 1) + await stage.evaluate((node) => node.setAttribute('data-test-retained', 'true')); + await expect(stage).toHaveAttribute('data-test-retained', 'true'); + } else { + await expect(view).toHaveAttribute('data-recording', 'true'); + await expect(view).toHaveAttribute('data-leaving', 'false'); + await expect(view).toHaveAttribute('data-test-retained', 'true'); + } + } + if (!rail) { + await expect + .poll(() => page.evaluate(() => (window as any).fixture.transcriptVisibility)) + .toEqual([false, true]); + } + }); +} + +test('a retained mirror never drives the primary native transcript rail', async ({ page }) => { + await page.goto(`${fixture}?full&os=win&transcript&label=capsule-mirror-0`); + await page.waitForFunction(() => (window as any).fixture.hasListener('backend:event')); + await page.evaluate(() => { + const bridge = (window as any).fixture; + bridge.emitTo('capsule-mirror-0', 'capsule:state', { + state: 'recording', + sessionId: 'recording-a', + }); + bridge.emit('backend:event', { + sequence: 1, + sessionId: 'recording-a', + kind: { type: 'transcript_delta', payload: { text: 'example', offset: 0, isFinal: true } }, + }); + }); + await expect(page.locator('.ol-capsule-pill')).toHaveAttribute('data-recording', 'true'); + await page.evaluate(() => + (window as any).fixture.emitTo('capsule-mirror-0', 'capsule:state', { + state: 'idle', + sessionId: 'recording-a', + }), + ); + await expect(page.locator('.ol-capsule-pill')).toHaveAttribute('data-leaving', 'true'); + expect(await page.evaluate(() => (window as any).fixture.transcriptVisibility)).toEqual([]); +}); diff --git a/openless-all/app/e2e/fixtures/capsule-motion.tsx b/openless-all/app/e2e/fixtures/capsule-motion.tsx index ee712f097..ec4c42714 100644 --- a/openless-all/app/e2e/fixtures/capsule-motion.tsx +++ b/openless-all/app/e2e/fixtures/capsule-motion.tsx @@ -7,12 +7,23 @@ import '../../src/styles/global.css'; const callbacks = new Map void>(); const listeners = new Map>(); +const listenerTargets = new Map(); let callbackId = 0; const qa = new URLSearchParams(location.search).has('qa'); +const params = new URLSearchParams(location.search); +const windowLabel = params.get('label') ?? (qa ? 'qa' : 'capsule'); const bridge = { emit(event: string, payload: unknown) { for (const id of listeners.get(event) ?? []) callbacks.get(id)?.({ event, id, payload }); }, + emitTo(label: string, event: string, payload: unknown) { + for (const id of listeners.get(event) ?? []) { + const target = listenerTargets.get(id); + if (!target || target.kind === 'Any' || target.label === label) + callbacks.get(id)?.({ event, id, payload }); + } + }, + transcriptVisibility: [] as boolean[], hasListener: (event: string) => Boolean(listeners.get(event)?.size), setCapsule: (_: Record) => {}, sizes: [] as boolean[], @@ -30,6 +41,10 @@ const bridge = { }; Object.assign(window, { fixture: bridge }); window.__TAURI_INTERNALS__ = { + metadata: { + currentWindow: { label: windowLabel }, + currentWebview: { label: windowLabel }, + }, transformCallback(callback: (value: unknown) => void) { const id = ++callbackId; callbacks.set(id, callback); @@ -43,10 +58,12 @@ window.__TAURI_INTERNALS__ = { const ids = listeners.get(args.event) ?? new Set(); ids.add(args.handler); listeners.set(args.event, ids); + listenerTargets.set(args.handler, args.target ?? { kind: 'Any' }); return args.handler; } if (command === 'plugin:event|unlisten') { listeners.get(args.event)?.delete(args.eventId); + listenerTargets.delete(args.eventId); return; } if (command === 'get_startup_snapshot') @@ -55,8 +72,12 @@ window.__TAURI_INTERNALS__ = { return { translationTargetLanguage: bridge.target.targetLanguage, capsuleStyle: 'classic', - capsuleTranscriptEnabled: false, + capsuleTranscriptEnabled: params.has('transcript'), }; + if (command === 'set_capsule_transcript_visible') { + bridge.transcriptVisibility.push(args.visible); + return; + } if (command === 'get_dictation_translation_target') return { ...bridge.target }; if (command === 'set_capsule_language_menu') { if (args.open && (!bridge.target.mutable || args.sessionId !== bridge.target.sessionId)) @@ -97,7 +118,8 @@ Object.assign(window, { __TAURI_EVENT_PLUGIN_INTERNALS__: { unregisterListener() const { i18nReady } = await import('../../src/i18n'); await i18nReady; const { OpenLessPill } = await import('../../src/components/OpenLessCapsule'); -const { CapsuleTranslationBadge } = await import('../../src/components/LiveTranscriptPill'); +const { CapsuleTranslationBadge, CapsuleTranscriptOverlay } = + await import('../../src/components/LiveTranscriptPill'); const { QaPanel } = await import('../../src/pages/QaPanel'); const { Capsule } = await import('../../src/components/Capsule'); function CapsuleFixture() { @@ -136,7 +158,9 @@ function CapsuleFixture() { } document.body.style.background = '#0b1215'; createRoot(document.getElementById('root')!).render( - new URLSearchParams(location.search).has('full') ? ( + params.has('rail') ? ( + + ) : params.has('full') ? (
handlers.delete(entry); + }, + emit(label, name, payload) { + for (const entry of handlers) { + const target = entry.target; + if ( + entry.name === name && + (label === null || target.kind === 'Any' || target.label === label || target === label) + ) { + entry.callback({ payload }); + } + } + }, + get size() { + return handlers.size; + }, + }; +} + +// Optional, single session-owned diagnostic log. Never record transcript content. +function probe(probeId, event, safe) { + if (!process.env.OPENLESS_CAPSULE_DIAG_LOG) return; + appendFileSync( + process.env.OPENLESS_CAPSULE_DIAG_LOG, + JSON.stringify({ + schema: 'diagnosing-bugs.v1', + session_id: 'capsule-live-switch', + run_id: process.env.OPENLESS_CAPSULE_DIAG_RUN ?? 'regression', + probe_id: probeId, + hypothesis_ids: ['H1', 'H2'], + event, + outcome: 'observed', + safe, + ts: new Date().toISOString(), + }) + '\n', + ); +} const tick = () => new Promise((resolve) => setImmediate(resolve)); function deferred() { let resolve; @@ -60,6 +130,9 @@ function mount({ auxiliary = () => { throw new Error('unsupported'); }, + label = 'capsule', + bus, + overlay = false, } = {}) { const listeners = new Map(); const stopped = []; @@ -77,6 +150,11 @@ function mount({ stylePreferenceReadyRef: { current: true }, insertedCharsRef: { current: 0 }, operatingRef: { current: false }, + transcriptRef: { current: backend.exports.createTranscriptViewState() }, + stateRef: { current: 'idle' }, + snapshotRetryDelays: [0, 16, 32, 64, 128, 256], + capsuleTranscriptFontSize: (value) => value ?? 14, + getSettings: async () => ({ capsuleStyle: 'classic' }), parseCapsuleStyle: () => undefined, console: { info() {}, warn: (...args) => warnings.push(args) }, window: { @@ -86,11 +164,17 @@ function mount({ }, }, getCapsuleSnapshot: async () => { - assert.equal(listeners.size, 4, 'all event subscriptions must precede snapshot reads'); + assert.equal( + listeners.size, + overlay ? 3 : 4, + 'all event subscriptions must precede snapshot reads', + ); requests += 1; return snapshot(requests); }, require: (name) => { + if (name === '@tauri-apps/api/webviewWindow') + return { getCurrentWebviewWindow: () => ({ label }) }; if (name === '@tauri-apps/api/core') return { invoke: async (command) => { @@ -101,10 +185,12 @@ function mount({ }; assert.equal(name, '@tauri-apps/api/event'); return { - listen: async (name, callback) => { + listen: async (name, callback, options) => { await beforeListen(name); listeners.set(name, callback); + const stopBus = bus?.listen(label, name, callback, options); return () => { + stopBus?.(); stopped.push(name); listeners.delete(name); }; @@ -123,13 +209,17 @@ function mount({ 'setCapsuleStyle', 'setSuggestions', 'setInsertFallback', + 'setText', + 'setStyle', + 'setEnabled', + 'setFontSize', ]) { scope[name] = (value) => { values[name] = value; writes.push([name, value]); }; } - vm.runInNewContext(effectCode, scope); + vm.runInNewContext(overlay ? railEffectCode : effectCode, scope); const cleanup = scope.replayEffect(); return { cleanup, @@ -355,4 +445,160 @@ for (const snapshot of [() => frame({ payloadRevision: 9 }), () => null]) { await tick(); assert.equal(view.writes.length, writes); } -console.log('capsule ready replay: session, card ordering, bounded retries and cleanup passed'); +// Real primary/mirror effects: leaving allScreens must not clear or re-enter the active view. +for (const overlay of [false, true]) { + const bus = eventBus(); + const primaryLabel = overlay ? 'capsule-rail' : 'capsule'; + const mirrorLabel = overlay ? 'capsule-rail-mirror-0' : 'capsule-mirror-0'; + const primary = mount({ bus, overlay, label: primaryLabel, snapshot: () => frame() }); + const mirror = mount({ bus, overlay, label: mirrorLabel, snapshot: () => frame() }); + await until( + () => primary.values.setState === 'recording' && mirror.values.setState === 'recording', + ); + const textKey = overlay ? 'setText' : 'setLocalAsrText'; + const primaryWrites = primary.writes.length; + probe('P1', 'before_switch', { + overlay, + primary_active: primary.values.setState === 'recording', + }); + bus.emit(mirrorLabel, 'capsule:state', { state: 'idle', sessionId: 'session' }); + probe('P2', 'mirror_hidden', { + overlay, + primary_active: primary.values.setState === 'recording', + primary_text_present: Boolean(primary.values[textKey]), + }); + assert.equal(primary.values.setState, 'recording', 'mirror-only idle must not exit the primary'); + assert.equal( + primary.values[textKey], + 'existing text', + 'mirror-only idle must not clear primary text', + ); + assert.equal(mirror.values.setState, 'idle'); + for (let i = 11; i < 31; i += 1) { + bus.emit(primaryLabel, 'capsule:state', { state: 'recording', sessionId: 'session' }); + bus.emit(null, 'backend:event', { + sequence: i, + sessionId: 'session', + kind: { + type: 'transcript_delta', + payload: { text: 'x'.repeat(i), offset: 0, isFinal: true }, + }, + }); + // Repeat a hidden mirror cleanup, including the feedback induced by native layout sync. + bus.emit(mirrorLabel, 'capsule:state', { state: 'idle', sessionId: 'session' }); + assert.equal(primary.values.setState, 'recording'); + assert.equal(primary.values[textKey], 'x'.repeat(i)); + } + assert.ok( + primary.writes + .slice(primaryWrites) + .every(([key, value]) => key !== 'setState' || value !== 'idle'), + ); + probe('P3', 'stream_finished', { overlay, updates: 20, primary_active: true }); + // Returning to allScreens must replay the active session rather than reopening the old-session gate. + bus.emit(mirrorLabel, 'capsule:state', { state: 'recording', sessionId: 'session' }); + await until(() => mirror.values.setState === 'recording'); + transcript(mirror, 31, 'reactivated text'); + assert.equal(mirror.values[textKey], 'reactivated text'); + assert.equal(primary.values[textKey], 'x'.repeat(30)); + release(primary); + release(mirror); + assert.equal(bus.size, 0); + probe('P4', 'cleanup_finished', { overlay, listeners: bus.size }); +} +// Only the primary capsule may drive native transcript visibility, including teardown. +{ + const code = compile( + readFileSync(new URL('../src/lib/ipc/dictation.ts', import.meta.url), 'utf8'), + ); + for (const label of [ + 'capsule', + 'capsule-mirror-0', + 'capsule-rail', + 'capsule-rail-mirror-0', + 'main', + ]) { + const calls = []; + const module = { exports: {} }; + vm.runInNewContext(code, { + exports: module.exports, + require(name) { + if (name === './platform-exports') return { isDesktop: () => true }; + if (name === './shared') + return { + isTauriNow: () => true, + invokeOrMock: async (command, args) => calls.push({ command, visible: args.visible }), + }; + assert.equal(name, '@tauri-apps/api/webviewWindow'); + return { getCurrentWebviewWindow: () => ({ label }) }; + }, + }); + await module.exports.setCapsuleTranscriptVisible(true); + await module.exports.setCapsuleTranscriptVisible(false); + assert.equal( + calls.length, + label === 'capsule' ? 2 : 0, + 'mirror updates and cleanup must not change primary transcript visibility', + ); + } +} +// The main window's recording cue must not see synthetic mirror lifecycle events either. +{ + const cueSource = readFileSync( + new URL('../src/components/AudioCue.tsx', import.meta.url), + 'utf8', + ); + const cueAst = ts.createSourceFile( + 'AudioCue.tsx', + cueSource, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + let cueEffect; + function visitCue(node) { + if ( + ts.isCallExpression(node) && + node.expression.getText(cueAst) === 'useEffect' && + node.arguments[0]?.getText(cueAst).includes("'capsule:state'") + ) + cueEffect = node.arguments[0].getText(cueAst); + ts.forEachChild(node, visitCue); + } + visitCue(cueAst); + assert.ok(cueEffect); + const bus = eventBus(); + let plays = 0; + let stops = 0; + const scope = { + isTauri: true, + audioCueRuntimeEnabled: true, + prevStateRef: { current: 'idle' }, + audioCueEnabledRef: { current: true }, + playRecordStartCue: () => plays++, + stopAudioCue: () => stops++, + require(name) { + if (name === '@tauri-apps/api/webviewWindow') + return { getCurrentWebviewWindow: () => ({ label: 'main' }) }; + assert.equal(name, '@tauri-apps/api/event'); + return { + listen: async (name, callback, options) => bus.listen('main', name, callback, options), + }; + }, + }; + vm.runInNewContext(compile(`globalThis.effect = (${cueEffect});`), scope); + const cleanup = scope.effect(); + await until(() => bus.size === 1); + bus.emit('main', 'capsule:state', { state: 'recording' }); + bus.emit('capsule-mirror-0', 'capsule:state', { state: 'idle' }); + bus.emit('main', 'capsule:state', { state: 'recording' }); + assert.equal(plays, 1, 'hidden mirror cleanup must not replay the recording cue'); + assert.equal(stops, 0); + bus.emit('main', 'capsule:state', { state: 'idle' }); + assert.equal(stops, 1); + cleanup(); + assert.equal(bus.size, 0); +} +console.log( + 'capsule ready replay: session, card ordering, bounded retries, cleanup and live display isolation passed', +); diff --git a/openless-all/app/scripts/windows-ui-config.test.mjs b/openless-all/app/scripts/windows-ui-config.test.mjs index cc7b263bb..c9de5bca8 100644 --- a/openless-all/app/scripts/windows-ui-config.test.mjs +++ b/openless-all/app/scripts/windows-ui-config.test.mjs @@ -483,7 +483,7 @@ assertMatch( ); assertMatch( liveTranscriptTsx, - /export function CapsuleTranscriptOverlay[\s\S]*?listen\('capsule:state'[\s\S]*?listen\('backend:event'[\s\S]*?prefs:changed/, + /export function CapsuleTranscriptOverlay[\s\S]*?listen\(\s*'capsule:state'[\s\S]*?listen\(\s*'backend:event'[\s\S]*?prefs:changed/, 'rail should own state, transcript, and preference subscriptions', ); assertMatch( @@ -536,6 +536,11 @@ assertMatch( /fn set_capsule_window_topmost_no_activate[\s\S]*?let position_result = if show[\s\S]*?SetWindowPos\([\s\S]*?if let Err\(error\) = position_result[\s\S]*?return false/, 'no-activate display should return false when SetWindowPos fails', ); +assertMatch( + dictationRs, + /set_capsule_transcript_visible\([\s\S]*?window: tauri::WebviewWindow[\s\S]*?if window\.label\(\) != "capsule"[\s\S]*?return Ok\(\(\)\);[\s\S]*?let host = coord\.tauri_host\(\)/, + 'only the Tauri-injected primary capsule caller may mutate shared transcript visibility', +); assertMatch( dictationRs, /get_capsule_snapshot\([\s\S]*?Result, String>[\s\S]*?capsule_snapshot/, diff --git a/openless-all/app/src-tauri/src/commands/dictation.rs b/openless-all/app/src-tauri/src/commands/dictation.rs index 2d224ba44..d0f11b05f 100644 --- a/openless-all/app/src-tauri/src/commands/dictation.rs +++ b/openless-all/app/src-tauri/src/commands/dictation.rs @@ -60,8 +60,14 @@ pub async fn cancel_dictation( #[tauri::command] pub async fn set_capsule_transcript_visible( coord: CoordinatorState<'_>, + window: tauri::WebviewWindow, visible: bool, ) -> Result<(), String> { + // The caller is injected by Tauri, not supplied in the IPC payload. Retained mirror + // webviews must not change the primary capsule's shared native layout state. + if window.label() != "capsule" { + return Ok(()); + } let host = coord.tauri_host(); let Some(capsule) = host.capsule_window() else { return Ok(()); diff --git a/openless-all/app/src/components/AudioCue.tsx b/openless-all/app/src/components/AudioCue.tsx index e8ca5a425..57aa5ef7d 100644 --- a/openless-all/app/src/components/AudioCue.tsx +++ b/openless-all/app/src/components/AudioCue.tsx @@ -94,16 +94,24 @@ export function AudioCueListener() { let cancelled = false; (async () => { const { listen } = await import('@tauri-apps/api/event'); - listen('capsule:state', (event) => { - const state = event.payload.state; - const prev = prevStateRef.current; - prevStateRef.current = state; - if (state === 'recording' && prev !== 'recording') { - if (audioCueEnabledRef.current) playRecordStartCue(); - } else if (state !== 'recording' && prev === 'recording') { - stopAudioCue(); - } - }) + const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow'); + if (cancelled) return; + const target = { kind: 'WebviewWindow' as const, label: getCurrentWebviewWindow().label }; + listen( + 'capsule:state', + (event) => { + if (cancelled) return; + 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(); + } + }, + { target }, + ) .then((fn) => { if (cancelled) fn(); else unlisten = fn; diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index 5a2322fcb..e3384771a 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -452,11 +452,19 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { }; (async () => { const { listen } = await import('@tauri-apps/api/event'); + const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow'); + const target = { kind: 'WebviewWindow' as const, label: getCurrentWebviewWindow().label }; if (cancelled) return; const subscribe = async (name: string, apply: (payload: T) => void) => { - const unlisten = await listen(name, (event) => { - if (!cancelled) apply(event.payload); - }); + // Tauri's default Any listener also receives emit_to(otherWindow). In particular, + // hiding a mirror sends it idle; that must never exit the primary capsule. + const unlisten = await listen( + name, + (event) => { + if (!cancelled) apply(event.payload); + }, + name === 'backend:event' ? undefined : { target }, + ); if (cancelled) { unlisten(); return false; @@ -464,6 +472,19 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { unlisteners.push(unlisten); return true; }; + const replayActiveSessionTranscript = async (sessionId: string) => { + const snapshot = await getCapsuleSnapshot().catch((error) => { + if (!cancelled) console.warn('[capsule] active session snapshot replay failed', error); + return undefined; + }); + if (cancelled || !snapshot || snapshot.sessionId !== sessionId) return; + const current = transcriptViewRef.current; + if (shouldRetryTranscriptSnapshot(current, snapshot)) return; + const next = applyTranscriptSnapshot(current, snapshot); + if (next === current) return; + transcriptViewRef.current = next; + setLocalAsrText(next.text); + }; const applyPayload = (p: CapsulePayload) => { if (!capsuleStateFirstLogged) { capsuleStateFirstLogged = true; @@ -483,7 +504,17 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { // Any new recording session must drop prior ASR text — including when // the previous capsule was still transcribing/polishing/done from // dictation (selection-voice Start used to keep the stale stream). - if (p.state === 'recording' && previous !== 'recording' && !sessionChanged) { + const sessionId = p.sessionId; + const reactivatedSession = + p.state === 'recording' && + previous !== 'recording' && + !sessionChanged && + !previousTranscript.sessionStartObserved && + sessionId != null && + nextTranscript.sessionId === sessionId; + if (reactivatedSession) { + void replayActiveSessionTranscript(sessionId); + } else if (p.state === 'recording' && previous !== 'recording' && !sessionChanged) { nextTranscript = beginTranscriptGeneration(nextTranscript); } if ( diff --git a/openless-all/app/src/components/LiveTranscriptPill.tsx b/openless-all/app/src/components/LiveTranscriptPill.tsx index 1e978e962..acfc55390 100644 --- a/openless-all/app/src/components/LiveTranscriptPill.tsx +++ b/openless-all/app/src/components/LiveTranscriptPill.tsx @@ -145,29 +145,60 @@ export function CapsuleTranscriptOverlay() { let unlisten: (() => void) | undefined; (async () => { const { listen } = await import('@tauri-apps/api/event'); - const stateHandle = await listen('capsule:state', (event) => { - const payload = event.payload; - const previousState = stateRef.current; - const previousTranscript = transcriptRef.current; - const sessionTranscript = applyTranscriptSessionId(previousTranscript, payload.sessionId); - const sessionChanged = sessionTranscript !== previousTranscript; - transcriptRef.current = sessionTranscript; - stateRef.current = payload.state; - setState(payload.state); - if (payload.capsuleStyle) setStyle(parseCapsuleStyle(payload.capsuleStyle) ?? 'classic'); - setSelectionPolish(payload.selectionPolish === true); - setTranslation(payload.translation === true); - if (payload.state === 'recording' && previousState !== 'recording' && !sessionChanged) { - transcriptRef.current = beginTranscriptGeneration(transcriptRef.current); - setText(transcriptRef.current.text); - } else if (sessionChanged) { - setText(sessionTranscript.text); - } - if (['idle', 'done', 'cancelled', 'error'].includes(payload.state)) { - transcriptRef.current = clearTranscriptText(transcriptRef.current); - setText(''); - } - }); + const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow'); + if (cancelled) return; + const target = { kind: 'WebviewWindow' as const, label: getCurrentWebviewWindow().label }; + const replayActiveSessionTranscript = async (sessionId: string) => { + const snapshot = await getCapsuleSnapshot(); + if (cancelled || !snapshot || snapshot.sessionId !== sessionId) return; + const current = transcriptRef.current; + if (shouldRetryTranscriptSnapshot(current, snapshot)) return; + const next = applyTranscriptSnapshot(current, snapshot); + if (next === current) return; + transcriptRef.current = next; + setText(next.text); + }; + const stateHandle = await listen( + 'capsule:state', + (event) => { + const payload = event.payload; + const previousState = stateRef.current; + const previousTranscript = transcriptRef.current; + const sessionTranscript = applyTranscriptSessionId(previousTranscript, payload.sessionId); + const sessionChanged = sessionTranscript !== previousTranscript; + transcriptRef.current = sessionTranscript; + stateRef.current = payload.state; + setState(payload.state); + if (payload.capsuleStyle) setStyle(parseCapsuleStyle(payload.capsuleStyle) ?? 'classic'); + setSelectionPolish(payload.selectionPolish === true); + setTranslation(payload.translation === true); + const sessionId = payload.sessionId; + const reactivatedSession = + payload.state === 'recording' && + previousState !== 'recording' && + !sessionChanged && + !previousTranscript.sessionStartObserved && + sessionId != null && + sessionTranscript.sessionId === sessionId; + if (reactivatedSession) { + void replayActiveSessionTranscript(sessionId); + } else if ( + payload.state === 'recording' && + previousState !== 'recording' && + !sessionChanged + ) { + transcriptRef.current = beginTranscriptGeneration(transcriptRef.current); + setText(transcriptRef.current.text); + } else if (sessionChanged) { + setText(sessionTranscript.text); + } + if (['idle', 'done', 'cancelled', 'error'].includes(payload.state)) { + transcriptRef.current = clearTranscriptText(transcriptRef.current); + setText(''); + } + }, + { target }, + ); const transcriptHandle = await listen('backend:event', (event) => { const next = applyTranscriptEvent(transcriptRef.current, event.payload); transcriptRef.current = next; diff --git a/openless-all/app/src/lib/ipc/dictation.ts b/openless-all/app/src/lib/ipc/dictation.ts index dd4e3a8ce..512aed043 100644 --- a/openless-all/app/src/lib/ipc/dictation.ts +++ b/openless-all/app/src/lib/ipc/dictation.ts @@ -1,5 +1,5 @@ import { isDesktop } from './platform-exports'; -import { invokeOrMock, platformCapabilities } from './shared'; +import { invokeOrMock, isTauriNow, platformCapabilities } from './shared'; import type { CapsuleSnapshot } from '../types'; export function startDictation(): Promise { @@ -14,8 +14,14 @@ export function cancelDictation(): Promise { return invokeOrMock('cancel_dictation', undefined, () => undefined); } -export function setCapsuleTranscriptVisible(visible: boolean): Promise { - if (!isDesktop()) return Promise.resolve(); +export async function setCapsuleTranscriptVisible(visible: boolean): Promise { + if (!isDesktop()) return; + if (isTauriNow()) { + const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow'); + // Mirrors render the shared transcript but never own the primary rail's native layout. + // This also prevents a retained/closing mirror's cleanup from hiding the active rail. + if (getCurrentWebviewWindow().label !== 'capsule') return; + } return invokeOrMock('set_capsule_transcript_visible', { visible }, () => undefined); } diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index 21631bfd5..b87266a81 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -1,7 +1,7 @@ // Recording & input settings: recording mode, audio cues, input behavior, and // platform-specific options. -import { useCallback, useEffect, useId, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { CapsuleStylePreview } from '../../components/OpenLessCapsule'; import { parseCapsuleStyle } from '../../lib/capsuleLayout'; @@ -41,7 +41,7 @@ import type { } from '../../lib/types'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { SelectLite } from '../../components/ui/SelectLite'; -import { PresenceModal } from '../../components/ui/Modal'; + import { Card, Collapsible, Btn } from '../_atoms'; import { Segmented } from '../../components/ui/Segmented'; import { restartApp } from '../../lib/ipc'; @@ -86,10 +86,6 @@ export function RecordingInputSection({ const [microphoneDevicesError, setMicrophoneDevicesError] = useState(null); const [voiceActivationModelMissing, setVoiceActivationModelMissing] = useState(false); const voiceActivationStateObservedRef = useRef(false); - const [displayModeRestartPending, setDisplayModeRestartPending] = useState(false); - const [displayModeRestarting, setDisplayModeRestarting] = useState(false); - const displayModeRestartTitleId = useId(); - const displayModeChangeSequenceRef = useRef(0); useEffect(() => { void getPlatformCapabilities().then(setPlatformCaps); @@ -240,30 +236,8 @@ export function RecordingInputSection({ ? (prefs.capsuleDisplayMode ?? 'cursorScreen') : 'cursorScreen'; - const onCapsuleDisplayModeChange = async (next: UserPreferences['capsuleDisplayMode']) => { - const previous = prefs.capsuleDisplayMode ?? 'cursorScreen'; - const changeSequence = ++displayModeChangeSequenceRef.current; - await savePrefs({ ...prefs, capsuleDisplayMode: next }); - if ( - changeSequence === displayModeChangeSequenceRef.current && - os === 'win' && - previous === 'allScreens' && - next !== 'allScreens' - ) { - setDisplayModeRestartPending(true); - } - }; - const onRestartForDisplayMode = async () => { - setDisplayModeRestarting(true); - try { - await restartApp(); - setDisplayModeRestartPending(false); - } catch (error) { - console.error('[settings] restart after capsule display mode change failed', error); - } finally { - setDisplayModeRestarting(false); - } - }; + const onCapsuleDisplayModeChange = (next: UserPreferences['capsuleDisplayMode']) => + savePrefs({ ...prefs, capsuleDisplayMode: next }); const onModeChange = (mode: HotkeyMode) => savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, mode } }); @@ -977,43 +951,6 @@ export function RecordingInputSection({ )} - { - if (!displayModeRestarting) setDisplayModeRestartPending(false); - }} - zIndex={1000} - width="min(420px, calc(100vw - 32px))" - style={{ padding: 18 }} - labelledBy={displayModeRestartTitleId} - render={() => ( - <> -

- {t('settings.recording.capsuleDisplayModeRestartTitle')} -

-

- {t('settings.recording.capsuleDisplayModeRestartDesc')} -

-
- setDisplayModeRestartPending(false)} - > - {t('common.cancel')} - - void onRestartForDisplayMode()} - > - {t('settings.about.updateDialog.restartNow')} - -
- - )} - /> ); } From 6d258df8eacdbc0a1507895fb7a7f49c8781836c Mon Sep 17 00:00:00 2001 From: aeoform <128159516+aeoform@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:47:52 +0800 Subject: [PATCH 20/31] =?UTF-8?q?ci:=20=E6=8C=89=20Cargo=20=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E5=9B=BE=E5=88=A4=E5=AE=9A=E5=B9=B3=E5=8F=B0=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=EF=BC=8C=E7=A0=8D=E6=8E=89=E9=87=8D=E5=A4=8D=E8=B7=91?= =?UTF-8?q?=E4=B8=8E=E7=8B=AC=E7=AB=8B=E8=83=B6=E5=9B=8A=20job=20(#1279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: 按 Cargo 依赖图判定平台范围,砍掉重复跑与独立胶囊 job 一次 PR 要拉起 12 台 VM,且 feature 分支的 push 和它的 PR 各跑一遍完整矩阵。 实测最近 42 次 CI run:平均每次 86 VM-min、峰值 12 台并发;42 次里 8 次是 同一 commit 的重复 push run。 1. push 只在默认分支跑(main/beta)。feature 分支由它的 PR 覆盖;默认分支的 push run 仍保留,因为它是基线证据、也是 Rust/Playwright 缓存的唯一写入方。 文档本来就写着「push → main/beta」,实现是 ['**'],这次对齐。 2. 平台范围判定从手写路径表换成 Cargo 自己的包图: scripts/ci-impact.mjs 用 cargo metadata --no-deps --offline 读出每个 workspace 包的目录、仓内依赖边和边上的 cfg(...) 守卫,从各平台根包走闭包 —— 文件在范围 内当且仅当拥有它的包在该闭包里。于是: - iOS 工程层(ios-device)只看 openless-all/ios/**:共享 crate 已由 iOS 的 test job 编译校验,device-build(13min × 10x 计价的 macOS runner)不再被 crates/openless-core 的改动无谓拉起; - ArkTS 只跑鸿蒙;linux-egui 只跑 Linux;纯前端 bundle 改动不再跑 Linux 构建 (Linux 宿主是原生 egui,不读 dist/)也不再跑 Android cargo check; - 契约测试(*.test.mjs)只跑桌面两个 runner,不触发 Playwright 动效套件; - 新增 frontend / android / ios-device 三个区域,判定全部 fail-open。 结果:src-tauri 改动 → 桌面+Android;core 改动 → 五个平台(真的都依赖它); 纯 TSX → 桌面+鸿蒙+动效,Linux/iOS 跳过。 3. ui-motion(15.5min)改由 frontend 区域门控。 4. native-capsule 的 macOS/Windows 双 job(payload 只有 ~40s 的 cargo check) 并入 cross-platform:那个 job 本来就为 generate_context! 构建同一个 app workspace target 目录,增量编译几乎免费,省掉两个 VM 分配和一个缓存前缀。 契约测试同步:ci-changed-areas 用真实 git diff 双向断言新区域; ci-cache-sharing 断言胶囊命令在 cross-platform 内且不再有独立缓存键; workflow-concurrency 断言 push 范围;docs/ci-trigger-and-cache-policy 更新判定表。 * ci: 把区域判定结果打进 job 日志 跳过某平台时,事后要能一眼分清「改动够不到」和「读不到差异」。两处 detect 步 骤现在都把 `=` 打到日志,同时仍写进 GITHUB_OUTPUT。 * ci: 胶囊 job 保留但按区域门控(实测否决合并方案) run 40510306688 的真实 step 计时: Windows checks 里三步 capsule = 43 + 53 + 21 = 117 s(关键路径 job,直接 +2 分钟墙钟) macOS checks 里三步 capsule = 40 + 41 + 6 = 87 s 独立 job 原本平均 1.4 / 0.7 分钟——它有专属 warm cache,且只编 capsule 自己。 合并省了 2 台 VM,却把 2 分钟加到最慢的 job 上,加权分钟反而更贵。 改成:native-capsule job 保持原样,新增 `capsule` 区域,只有拥有改动文件的包是 openless-capsule / openless-native-capsule 时才跑。常见 PR 因此少 2 台 VM 且不增加 任何墙钟时间;胶囊相关 PR 与今天一致。 契约测试同步:胶囊 job 必须带自己的区域门控;ci-changed-areas 场景新增 capsule 列。 * test(e2e): 修掉 switch-motion 的采样竞态(CI 14% 误报) 失败快照给出了确定答案:controlledXs 是空数组(Expected length 3 / Received length 0 / Received array []),而同一 case 前面几条断言都过了——开关确实动了, 只是采样钩子没挂上。 原因:flip() 用 control.evaluate() 派发采样函数(在里面 addEventListener 'transitionrun'),但没有 await 就发 mouse.down()/up()。evaluate 和 click 是两条 独立的协议消息,CI 上负载高时 click 先到页面,监听还没挂上 → 抓不到 3 个受控 采样点。这就是最近 43 次 CI run 里失败 6 次(14%)、落在 5 个互不相关分支上 (含 beta 自身)的那条 `语音编辑 exposed its transform transition`。 改法(不放宽断言): - 把「挂监听」拆成一次 await 的 evaluate,采样结果存进页面侧 WeakMap(按控件 元素键),确认挂好后才点击; - 采样循环仍在点击时启动、750ms 后收尾,所以 xs.at(-1) 依然是静止位置; - 三条断言(状态翻转、滑块位移、transform 过渡跑完、3 个受控中间位置)全部 保持原样,重挂载仍会因为 xs 为 NaN 而失败。 本地验证:webkit project 全文件 --repeat-each=6 → 30/30 通过(8.7 分钟), 即那条 flaky case 连跑 6 次全绿。tsc --noEmit 干净。 * test(e2e): 采样循环改到点击之后启动,去掉对点击延迟的依赖 上一版修掉了监听竞态,但采样循环仍在点击前就开始了 45 帧(~750ms); 如果 CI 把 click 延迟到 800ms,end 会取到动画前的位置。改成 挂监听(await)→ 点击 → 采样循环,45 帧仍覆盖 180ms 的滑动并收尾, xs.at(-1) 恒为静止位置。 本地 webkit:全文件 5/5 通过;含 --repeat-each=4 的 20/20。 * fix(ci): keep platform gates for native consumers --------- Co-authored-by: Chris233 --- .github/workflows/check-ios.yml | 32 +- .github/workflows/ci.yml | 38 +- docs/ci-trigger-and-cache-policy.md | 31 +- openless-all/app/e2e/switch-motion.spec.ts | 50 ++- .../app/scripts/ci-cache-sharing.test.mjs | 14 +- openless-all/app/scripts/ci-changed-areas.sh | 86 ++--- .../app/scripts/ci-changed-areas.test.mjs | 250 ++++++++---- openless-all/app/scripts/ci-impact.mjs | 364 ++++++++++++++++++ .../workflow-concurrency-contract.test.mjs | 8 +- 9 files changed, 684 insertions(+), 189 deletions(-) create mode 100644 openless-all/app/scripts/ci-impact.mjs diff --git a/.github/workflows/check-ios.yml b/.github/workflows/check-ios.yml index b2a33c758..50e065ac9 100644 --- a/.github/workflows/check-ios.yml +++ b/.github/workflows/check-ios.yml @@ -23,6 +23,35 @@ env: CARGO_PROFILE_TEST_DEBUG: 0 jobs: + changes: + # `test` builds and lints the shared Core for iOS, so it must run whenever the + # Rust graph moves. `device-build` (13 minutes on a 10x-cost macOS runner) + # only proves the Xcode/Rime project layer still builds — shared-crate edits + # cannot reach it, so it is gated on the project layer alone. + name: Changed areas + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + # Dashes are not valid inside an output reference, so the area name and the + # output key differ here on purpose. + device: ${{ steps.detect.outputs.device }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Detect iOS project changes + id: detect + working-directory: openless-all/app + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + verdict=$(bash scripts/ci-changed-areas.sh ios-device "$BASE_SHA") + # Fail open: only an explicit `false` skips the device build. + decision=$( [ "$verdict" = 'false' ] && echo false || echo true ) + echo "ios-device=$decision" + echo "device=$decision" >> "$GITHUB_OUTPUT" + test: runs-on: xcode-27 timeout-minutes: 30 @@ -54,7 +83,8 @@ jobs: run: git diff --exit-code -- openless-all/app device-build: - needs: test + needs: [changes, test] + if: needs.changes.outputs.device != 'false' runs-on: xcode-27 timeout-minutes: 60 defaults: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a5b847e1..1c0d7187d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,8 +8,12 @@ name: CI # macOS/Windows/Android 不跑完整 Tauri bundle;Linux 则运行完整 deb/rpm 发版构建校验。 on: + # Feature branches are covered by their pull request. Running both produced two full + # matrices for the same commit (measured: 8 of 42 CI runs were such duplicates). + # The default branches keep a push run because that run is the baseline evidence and + # the only writer of the Rust/Playwright caches. push: - branches: ['**'] + branches: [main, beta] pull_request: branches: [main, beta] workflow_dispatch: @@ -43,6 +47,9 @@ jobs: msrv: ${{ steps.detect.outputs.msrv }} linux: ${{ steps.detect.outputs.linux }} harmony: ${{ steps.detect.outputs.harmony }} + frontend: ${{ steps.detect.outputs.frontend }} + android: ${{ steps.detect.outputs.android }} + capsule: ${{ steps.detect.outputs.capsule }} # Validate the pinned minimum compiler whenever native sources can change. msrv_needed: ${{ steps.detect.outputs.msrv }} steps: @@ -58,16 +65,21 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | set -euo pipefail - for area in tauri msrv linux harmony; do + for area in tauri msrv linux harmony frontend android capsule; do verdict=$(bash scripts/ci-changed-areas.sh "$area" "$BASE_SHA") # 失败即运行:脚本读不到差异时返回 true,这里也只接受明确的 false。 - echo "$area=$( [ "$verdict" = 'false' ] && echo false || echo true )" >> "$GITHUB_OUTPUT" + decision=$( [ "$verdict" = 'false' ] && echo false || echo true ) + # 打出来才能事后判断某次跳过是「够不到」还是「读不到差异」。 + echo "$area=$decision" + echo "$area=$decision" >> "$GITHUB_OUTPUT" done ui-motion: name: UI motion (Chromium + WebKit) needs: [changes] - if: (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.tauri != 'false' + # Browser-rendered surface only: 15 minutes of Playwright on a change that + # cannot alter what Chromium/WebKit paints is pure cost. + if: (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.frontend != 'false' runs-on: ubuntu-24.04 defaults: run: @@ -115,7 +127,8 @@ jobs: android-check: needs: [changes] - if: (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.tauri != 'false' + # Android compiles its own Rust graph: a bundle-only change cannot break it. + if: (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.android != 'false' name: Android cargo check runs-on: ubuntu-latest timeout-minutes: 60 @@ -554,15 +567,14 @@ jobs: run: node scripts/frontend-test-runner.mjs --platform-only windows native-capsule: - # The capsule crates compile into the desktop shells, so a break there would - # otherwise only surface inside the long Tauri matrix. They are checked on - # both desktop platforms inside this workflow rather than in a second - # workflow: one run per pull request, one `Changed areas` gate, one cache - # policy. Being a `push` job on the default branches is also what keeps the - # Rust cache warm - a pull-request-only workflow can never save a tree the - # next pull request is allowed to restore. + # The capsule crates compile into the desktop shells. They get their own macOS + Windows + # runners only because their `ui,host` feature set is not built anywhere else (the shells + # use `build:native-capsule:dev`), and that warm cache makes the job ~40 s. It is gated on + # its own area, so the common PR pays zero runners for it: measured on run 40510306688, + # moving these three commands into `cross-platform` cost 117 s on the critical-path Windows + # job, which is more than the two runners it saved. needs: [changes] - if: needs.changes.outputs.tauri != 'false' + if: needs.changes.outputs.capsule != 'false' name: Native capsule (${{ matrix.os }}) strategy: fail-fast: false diff --git a/docs/ci-trigger-and-cache-policy.md b/docs/ci-trigger-and-cache-policy.md index f27e47478..544447d98 100644 --- a/docs/ci-trigger-and-cache-policy.md +++ b/docs/ci-trigger-and-cache-policy.md @@ -7,7 +7,8 @@ | 事件 | 行为 | |---|---| | `pull_request` → `main` / `beta` | 按改动范围门控:改动够不到的平台直接跳过(见下) | -| `push` → `main` / `beta` | 全量验证(合并后的基线必须有完整证据),不跳过任何 job | +| `push` → `main` / `beta` | 全量验证(合并后的基线必须有完整证据),不跳过任何 job;也是 Rust / Playwright 缓存的唯一写入方 | +| `push` → 其他分支 | **不跑**。feature 分支由它的 PR 覆盖;两边都跑等于同一个 commit 交两遍完整矩阵(实测 42 次 run 里有 8 次是这种重复,每次 ~86 VM-min、峰值 12 台 VM) | | tag `v*-tauri` | 发布 Tauri 桌面与 Android;Linux 包不随标签自动发布 | | `workflow_dispatch` | 全量;`platform=macos` 只跑 macOS(仅 macOS 验证入口) | @@ -15,21 +16,22 @@ ### 改动范围判定 -`openless-all/app/scripts/ci-changed-areas.sh` 对每个重平台 job 判定“改动物理上够不够得到它”,CI 的 `changes` job 调用它填 `changes.outputs.tauri` / `changes.outputs.msrv`: +`openless-all/app/scripts/ci-impact.mjs`(由 `ci-changed-areas.sh` 调用)回答一个问题:**这份改动能不能被某个平台的编译过程看见**。 -| 区域 | 跳过条件(全部改动都命中才算跳过) | 覆盖的 job | -|---|---|---| -| `tauri` | 仅 `linux-egui/**`(但**不含**它的 `Cargo.toml`,那会动全工作区 lock)、`app/scripts/linux-fcitx5-plugin/**`、`docs/**`、`*.md`、`LICENSE` | macOS / Windows 桌面检查、Android 检查、Chromium/WebKit UI 动效回归 | -| `msrv` | 全部改动里没有任何 `.rs`、`Cargo.toml`、`Cargo.lock`、`rust-toolchain*`、`.github/**` | macOS Rust 1.99 MSRV | -| `linux` | 全部改动都是散文(`docs/**`、`*.md`、`LICENSE`、`NOTICE`) | Linux Core/egui 测试与 deb/rpm 打包链 | - -规则是**失败即运行**,而且这一原则贯穿三层: +Rust 部分不靠手写路径表,而是走 Cargo 自己的包图:`cargo metadata --no-deps --offline` 给出每个 workspace 包、它的目录、仓库内部依赖边,以及每条边上的 `cfg(...)` 守卫(例如 `openless → openless-capsule` 带 `cfg(not(any(target_os = "android", target_os = "ios")))`)。从每个平台的根包出发走一遍闭包,就得到该平台真正会编译的 crate 集合;一个文件在范围内,当且仅当**拥有它的包**在该集合里。非 Rust 的树(前端 bundle、ArkTS、Xcode 工程、工具链脚本)由脚本里的显式规则覆盖。 -1. 脚本层:没有基线提交(push / tag / 手动)、git 读取失败、diff 为空、区域名未知,一律返回 `true`。 -2. 工作流层:`changes` job 只把明确的 `false` 当作跳过依据,其余(含输出缺失)都算运行;`linux` 的 `scope` 输入默认 `full`。 -3. 契约层:`scripts/ci-changed-areas.test.mjs` 既驱动脚本对真实 git 差异做正反判定,也检查 ci.yml 是否把每个区域都发布成 job 输出、每个门控是否写成 fail-open、可复用工作流是否默认全量构建。区域名或输出接线写错会直接测试失败——这正是它抓到的一次真实故障(`linux` 输出漏写导致 Linux job 被误跳过)。 +| 区域 | 覆盖的 job | 判定 | +|---|---|---| +| `tauri` | macOS / Windows 桌面检查 | 改动能到达 `openless`(含 vendor 路径依赖)或桌面专属的 `openless-native-capsule` | +| `android` | Android cargo check | 改动能到达 `openless` 的 Android 闭包,或影响 Android 项目生成/构建的 npm manifest;纯 bundle-only 改动不单独触发 | +| `msrv` | macOS Rust 1.99 MSRV | 有任何 `.rs` / `Cargo.toml` / `Cargo.lock` / `rust-toolchain*` / `.github/**` | +| `linux` | Linux Core/egui 测试与 deb/rpm 打包链 | 改动能到达 `openless-linux-egui` 的闭包。Linux 宿主是原生 egui,不读前端 `dist/` | +| `harmony` | 鸿蒙 ArkTS 契约、Rust host、HAP | 改动能到达 `openless-harmony-host` 的闭包,或动了它要打包的前端 bundle | +| `frontend` | Chromium/WebKit UI 动效 | 只动浏览器会渲染的面:`src/**`、`e2e/**`、样式与 vite/playwright 配置;`app/assets/**` / `app/public/**` 按 native consumer 处理 | +| `capsule` | 胶囊 crates(macOS + Windows 双 runner) | 拥有改动文件的包是 `openless-capsule` / `openless-native-capsule`。它们的 `ui,host` feature 组合不在别处构建,所以保留独立 job,但常见 PR 不再为它付出两台 runner | +| `ios-device` | iOS `device-build`(Xcode/Rime 与真实 device Core 编译) | 改动能到达 iOS Core 闭包,或动了 `openless-all/ios/**`;共享 Rust 代码也要保留 device target 证据 | -跳过只允许是“改动可达性可证明”的结果,不允许是“读不到差异”或“接线漏了”的兜底。 +契约测试 `scripts/ci-changed-areas.test.mjs` 用真实 git diff 驱动这些判定,双向断言:够不到的必须跳过,其余的必须运行。改动前它抓到的真实故障(`linux` 输出漏写导致 Linux job 被误跳过)就是这条规则存在的理由。 `linux-egui-package` 通过可复用工作流的 `scope` 输入接收结果(`full` / `none`,**默认 `full`**):纯散文改动跳过整个 Linux 构建与打包链。独立 Linux 手动构建沿用完整检查,只上传 Actions 产物;管理员在产品验收后,针对已接受的现有 tag 构建并单独附加包,见 [发布规范](../RELEASING.md)。 @@ -83,7 +85,8 @@ GitHub 每仓库保留 10 GB 缓存,超出按 LRU 静默淘汰;淘汰后 job - `playwright` 与 `mobile-rime` 使用 restore/save 拆分;`swatinem/rust-cache` 用 `save-if` 达到同样效果。 - `build-linux-egui` 保留 PR 内自写并开 `cache-on-failure: true`:失败的运行也会把依赖缓存存下来,下一次迭代仍然热;同时关掉 dev/test debug info,缓存更小、链接更快。 - key 结构为 `v0-rust---<环境哈希>-`:环境哈希含工具链与 `CARGO_PROFILE_*`,所以 stable / MSRV / release 天然分开,不要用跨平台 `shared-key` 混合。 -- 原生胶囊(`native-capsule` job)原本是一个独立的 `pull_request`-only workflow:缓存写在 `refs/pull//merge`,这个作用域此后再不会被恢复;而 beta 上没有任何 job 产出同 key 的树,所以 PR 永远冷编译。现在它已并入 `ci.yml`(同一个 `Changed areas` 门禁、同一次运行),用 `shared-key: native-capsule` + beta 门控的 `save-if`。 +- 原生胶囊(`native-capsule` job)原本是一个 `pull_request`-only workflow:缓存写在 `refs/pull//merge`,这个作用域此后再不会被恢复,而 beta 上没有 job 产出同 key 的树,所以 PR 永远冷编译。现在它在 `ci.yml` 里,用 `shared-key: native-capsule` + beta 门控的 `save-if`,并且由 `capsule` 区域门控——常见 PR 完全不会为它拉起 runner。 + 曾试过把它并进 `cross-platform`(省两台 VM),实测否决:run 40510306688 里那三个命令在 Windows 关键路径上花了 43+53+21 s,比它省下的两台 runner 更贵。要合并先要确认那套 `ui,host` feature 在桌面 job 的缓存里是热的。 - `Cargo.lock` 变化时精确键失效,但 `restore-keys` 前缀回退会拿到同前缀的旧缓存,所以“锁变了”不等于“全冷”——前提是那份没被淘汰,这正是要守预算的原因。 ## 4. 维护 diff --git a/openless-all/app/e2e/switch-motion.spec.ts b/openless-all/app/e2e/switch-motion.spec.ts index 035899065..06aa65511 100644 --- a/openless-all/app/e2e/switch-motion.spec.ts +++ b/openless-all/app/e2e/switch-motion.spec.ts @@ -49,10 +49,15 @@ async function flip(page: Page, control: ElementHandle): Promise('.ol-switch-thumb')!; return knob.getBoundingClientRect().left; }); - const sampling = control.evaluate(async (element) => { + // Arm the sampling listeners and *wait* for them before clicking. `evaluate` only + // dispatches its function; the click is a separate protocol message and on a loaded CI + // runner it can reach the page first. When that happened the `transitionrun` listener was + // not attached yet, so `controlledXs` came back empty — "语音编辑 exposed its transform + // transition", which failed 6 of 43 CI runs across unrelated branches, beta included. The + // switch animated every one of those times; only the hook missed it. + await control.evaluate((element) => { const knob = element.querySelector('.ol-switch-thumb')!; - const finished: number[] = []; - const controlledXs: number[] = []; + const state = { finished: [] as number[], controlledXs: [] as number[] }; const onRun = (event: TransitionEvent) => { if (event.target !== knob || event.propertyName !== 'transform') return; const transition = knob @@ -72,31 +77,54 @@ async function flip(page: Page, control: ElementHandle): Promise { if (event.target === knob && event.propertyName === 'transform') { - finished.push(event.elapsedTime); + state.finished.push(event.elapsedTime); } }; knob.addEventListener('transitionrun', onRun); knob.addEventListener('transitionend', onEnd); + const store = window as unknown as { + __switchMotion?: WeakMap void }>; + }; + store.__switchMotion ??= new WeakMap(); + store.__switchMotion.set(element, { + state, + stop: () => { + knob.removeEventListener('transitionrun', onRun); + knob.removeEventListener('transitionend', onEnd); + }, + }); + }); + await page.mouse.down(); + await page.mouse.up(); + // The frame loop starts *after* the click: 45 frames (~750ms) still covers the 180ms slide + // and settles, so `xs.at(-1)` is the resting position regardless of how long the click takes + // to arrive. Listening already started above, so a late click cannot lose the transition. + const { xs, controlledXs, finished } = await control.evaluate(async (element) => { + const knob = element.querySelector('.ol-switch-thumb')!; + const armed = ( + window as unknown as { + __switchMotion: WeakMap< + Element, + { state: { finished: number[]; controlledXs: number[] }; stop: () => void } + >; + } + ).__switchMotion.get(element)!; const xs: number[] = []; for (let frame = 0; frame < 45; frame++) { await new Promise(requestAnimationFrame); // A remount would leave this node detached and frozen: count it as a jump. xs.push(element.isConnected ? knob.getBoundingClientRect().left : Number.NaN); } - knob.removeEventListener('transitionrun', onRun); - knob.removeEventListener('transitionend', onEnd); - return { xs, controlledXs, finished }; + armed.stop(); + return { xs, controlledXs: armed.state.controlledXs, finished: armed.state.finished }; }); - await page.mouse.down(); - await page.mouse.up(); - const { xs, controlledXs, finished } = await sampling; const after = (await control.getAttribute('aria-checked')) ?? ''; return { name, checked: [before, after], start, end: xs.at(-1)!, xs, controlledXs, finished }; } diff --git a/openless-all/app/scripts/ci-cache-sharing.test.mjs b/openless-all/app/scripts/ci-cache-sharing.test.mjs index bd9d40766..1e21611db 100644 --- a/openless-all/app/scripts/ci-cache-sharing.test.mjs +++ b/openless-all/app/scripts/ci-cache-sharing.test.mjs @@ -94,15 +94,21 @@ assert.match(linux, /cargo build --locked --release -p openless-linux-egui/); assert.match(linuxRelease, /release_tag: \$\{\{ inputs\.tag \}\}/); assert.match(linuxRelease, /checkout_ref: \$\{\{ inputs\.tag \}\}/); -// The capsule crates used to run in their own pull-request-only workflow: it -// wrote a dependency tree under `refs/pull//merge` (never restored again) -// while beta produced nothing to restore. It now lives here, behind the same -// `Changed areas` verdict, and saves only from beta. +// The capsule crates used to run in a pull-request-only workflow that wrote a +// dependency tree under `refs/pull//merge` (never restored again) while beta +// produced nothing to restore. They now live in `ci.yml` behind their own area +// gate and save only from beta, so a run costs ~40 s and the common PR pays no +// runner for them at all. { const capsuleJob = ci.match(/\n native-capsule:[\s\S]*?(?=\n [a-z-]+:\n)/)?.[0] ?? ''; assert.ok(capsuleJob, 'ci.yml must run the capsule crates as the native-capsule job'); assert.match(capsuleJob, /shared-key: native-capsule/); assert.match(capsuleJob, /save-if: \$\{\{ github\.ref == 'refs\/heads\/beta'/); + assert.match( + capsuleJob, + /if: needs\.changes\.outputs\.capsule != 'false'/, + 'the capsule job must be gated on its own area, not run for every desktop change', + ); assert.ok( !existsSync(join(root, '.github/workflows/native-capsule.yml')), 'native-capsule.yml must stay merged into ci.yml', diff --git a/openless-all/app/scripts/ci-changed-areas.sh b/openless-all/app/scripts/ci-changed-areas.sh index 928083d56..7503efef8 100755 --- a/openless-all/app/scripts/ci-changed-areas.sh +++ b/openless-all/app/scripts/ci-changed-areas.sh @@ -4,13 +4,18 @@ # # ci-changed-areas.sh [base-sha] # -# Areas: -# tauri macOS / Windows desktop Tauri builds and the Android build that -# shares the same dependency graph. -# msrv The Rust 1.88 minimum-supported-toolchain compile. -# linux The Linux host build, its tests and the deb/rpm package chain. -# harmony The HarmonyOS host: ArkTS contracts, the ArkWeb bundle and the HAP -# release path. +# Areas: tauri | msrv | linux | harmony | frontend | android | capsule | ios-device +# +# The decision is made by scripts/ci-impact.mjs, which walks Cargo's own package +# graph (`cargo metadata`) from each platform's root package: a file is in scope +# for a platform only when the crate that owns it is reachable from that +# platform's root. Non-Rust trees (frontend bundle, ArkTS, Xcode project, +# tooling) are covered by explicit rules there. +# +# This wrapper exists for two reasons only: it keeps the shell entry point that +# ci.yml and the contract test use, and it re-asserts the fail-open rule on top +# of the script — if the decision cannot be computed at all (node missing, the +# script crashing, unparsable output), the answer is "run". # # Prints "true" when the check must run and "false" only when every changed file # is provably out of scope for that area. Anything unknown - no base revision @@ -19,69 +24,22 @@ # cannot reach it, never of a failure to read the change set. set -uo pipefail -area=${1:?usage: ci-changed-areas.sh [base-sha]} +area=${1:?usage: ci-changed-areas.sh [base-sha]} base=${2:-} +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd) +script="$repo_root/openless-all/app/scripts/ci-impact.mjs" run() { echo true exit 0 } -case "$area" in - tauri | msrv | linux | harmony) ;; - *) - echo "unknown area: $area" >&2 - run - ;; -esac - -# Push, tag and manual runs verify every platform. -[ -n "$base" ] || run - -files=$(git diff --name-only "${base}...HEAD" 2> /dev/null) || run -[ -n "$files" ] || run +command -v node > /dev/null 2>&1 || run +[ -f "$script" ] || run -while IFS= read -r file; do - [ -n "$file" ] || continue - case "$area" in - tauri) - # Only the Linux host, its fcitx5 plugin and prose are out of scope for - # the desktop Tauri build. The Linux host manifest stays in scope: it is - # part of the shared workspace and moves Cargo.lock for every platform. - case "$file" in - openless-all/app/linux-egui/Cargo.toml) run ;; - openless-all/app/linux-egui/*) ;; - openless-all/app/scripts/linux-fcitx5-plugin/*) ;; - docs/* | *.md | LICENSE* | NOTICE*) ;; - *) run ;; - esac - ;; - msrv) - # The 1.88 promise is about Rust sources and their dependency graph; - # only change sets that contain neither may skip that compile. - case "$file" in - *.rs | *Cargo.toml | *Cargo.lock | *rust-toolchain* | .github/*) run ;; - *) ;; - esac - ;; - linux) - # Prose cannot change what the host builds, tests or packages. - case "$file" in - docs/* | *.md | LICENSE* | NOTICE*) ;; - *) run ;; - esac - ;; - harmony) - # The HarmonyOS job builds the shared frontend bundle and the Rust host, so - # only prose and the Linux-only trees are provably out of reach. - case "$file" in - docs/* | *.md | LICENSE* | NOTICE*) ;; - openless-all/app/linux-egui/*) ;; - openless-all/app/scripts/linux-fcitx5-plugin/*) ;; - *) run ;; - esac - ;; - esac -done <<< "$files" +verdict=$(node "$script" "$area" "$base" 2> /dev/null) || run -echo false +case "$verdict" in + true | false) echo "$verdict" ;; + *) run ;; +esac diff --git a/openless-all/app/scripts/ci-changed-areas.test.mjs b/openless-all/app/scripts/ci-changed-areas.test.mjs index 8957e75a8..176f16461 100644 --- a/openless-all/app/scripts/ci-changed-areas.test.mjs +++ b/openless-all/app/scripts/ci-changed-areas.test.mjs @@ -1,9 +1,13 @@ -// Contract test for scripts/ci-changed-areas.sh. +// Contract test for scripts/ci-changed-areas.sh (and the ci-impact.mjs graph +// walk behind it). // -// The CI jobs that verify the desktop Tauri builds and the Rust 1.88 MSRV are -// skipped for change sets that cannot reach them. That decision must never be -// made on a guess: this test drives the script against real git diffs and -// asserts both directions - out-of-scope change sets skip, everything else runs. +// The CI jobs that verify each platform are skipped for change sets that cannot +// reach them. That decision must never be made on a guess: this test drives the +// script against real git diffs and asserts both directions - out-of-scope +// change sets skip, everything else runs. The Rust half of the answer comes from +// `cargo metadata`, which is why the scratch repository below has no Cargo files +// of its own: the classifier reads the package graph from this checkout and the +// diff from the scratch repository. import { execFileSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -56,80 +60,185 @@ const reset = () => { }; try { - // Documentation cannot reach either check. + const ALL_AREAS = [ + 'tauri', + 'msrv', + 'linux', + 'harmony', + 'frontend', + 'android', + 'capsule', + 'ios-device', + ]; + const every = (verdict) => Object.fromEntries(ALL_AREAS.map((area) => [area, verdict])); + const expect = (label, expectations) => { + for (const [area, expected] of Object.entries(expectations)) { + check(label + ': ' + area, decide(area), expected); + } + }; + + // Prose cannot reach anything. commit({ 'docs/architecture.md': 'docs\n' }, 'docs only'); - check('docs only: tauri', decide('tauri'), 'false'); - check('docs only: msrv', decide('msrv'), 'false'); - check('docs only: linux', decide('linux'), 'false'); - check('docs only: harmony', decide('harmony'), 'false'); + expect('docs only', every('false')); reset(); - // The Linux host is a separate crate: only its own Rust sources stay in. + // The Linux host is a separate crate in its own target graph. commit({ 'openless-all/app/linux-egui/src/ui/theme.rs': 'fn x() {}\n' }, 'linux host source'); - check('linux host source: tauri', decide('tauri'), 'false'); - check('linux host source: msrv', decide('msrv'), 'true'); - check('linux host source: linux', decide('linux'), 'true'); - check('linux host source: harmony', decide('harmony'), 'false'); + expect('linux host source', { + tauri: 'false', + msrv: 'true', + linux: 'true', + harmony: 'false', + frontend: 'false', + android: 'false', + 'ios-device': 'false', + }); reset(); - // HarmonyOS work touches the shared bundle and the Rust host, never the Linux - // package chain or the Tauri desktop build. + // ArkTS lives only in the HarmonyOS host. commit( { 'openless-all/app/harmony/entry/src/main/ets/pages/Index.ets': 'export {};\n' }, 'arkts', ); - check('arkts source: harmony', decide('harmony'), 'true'); - check('arkts source: tauri', decide('tauri'), 'true'); + expect('arkts source', { + tauri: 'false', + harmony: 'true', + linux: 'false', + frontend: 'false', + 'ios-device': 'false', + }); reset(); - // ...but its manifest moves the shared workspace dependency graph. + // The Linux workflow owns Harmony Rust unit tests as well as the egui host. + commit({ 'openless-all/app/harmony/rust/src/lib.rs': 'pub fn x() {}\n' }, 'harmony rust'); + expect('harmony rust', { + tauri: 'false', + harmony: 'true', + linux: 'true', + frontend: 'false', + 'ios-device': 'false', + }); + reset(); + + // The shared Core is linked by every platform build. + commit({ 'openless-all/app/crates/openless-core/src/lib.rs': 'pub fn x() {}\n' }, 'shared core'); + expect('shared core', { + tauri: 'true', + msrv: 'true', + linux: 'true', + harmony: 'true', + android: 'true', + // The device build compiles this shared Core for aarch64-apple-ios. + 'ios-device': 'true', + frontend: 'false', + }); + reset(); + + // ...but its manifest moves the workspace dependency graph for everyone. commit( { 'openless-all/app/linux-egui/Cargo.toml': '[package]\nname = "x"\n' }, 'linux host manifest', ); - check('linux host manifest: tauri', decide('tauri'), 'true'); - check('linux host manifest: msrv', decide('msrv'), 'true'); + expect('linux host manifest', { tauri: 'true', msrv: 'true', linux: 'true', harmony: 'true' }); + reset(); + + // Desktop-only crates never reach the Linux, HarmonyOS or iOS builds. + commit({ 'openless-all/app/src-tauri/src/lib.rs': 'pub fn x() {}\n' }, 'native source'); + expect('native source', { + tauri: 'true', + msrv: 'true', + linux: 'false', + harmony: 'false', + android: 'true', + capsule: 'false', + 'ios-device': 'false', + }); reset(); - // Frontend-only work still needs the Tauri job (it runs the frontend tests) - // but cannot change what the Rust compiler has to accept. + commit({ 'openless-all/app/native-capsule/src/lib.rs': 'pub fn x() {}\n' }, 'capsule source'); + expect('capsule source', { + tauri: 'true', + msrv: 'true', + capsule: 'true', + linux: 'false', + harmony: 'false', + }); + reset(); + + // The rendered bundle is embedded by every shell that paints it; the Linux + // host draws a native egui UI and never reads `dist/`, and the Android job only + // compiles Rust, so neither is in scope. commit({ 'openless-all/app/src/pages/Style.tsx': 'export const x = 1;\n' }, 'frontend only'); - check('frontend only: tauri', decide('tauri'), 'true'); - check('frontend only: msrv', decide('msrv'), 'false'); - check('frontend only: linux', decide('linux'), 'true'); - check('frontend only: harmony', decide('harmony'), 'true'); + expect('frontend only', { + tauri: 'true', + msrv: 'false', + linux: 'false', + harmony: 'true', + frontend: 'true', + android: 'false', + 'ios-device': 'false', + }); reset(); - // Native code keeps every check on. - commit({ 'openless-all/app/src-tauri/src/lib.rs': 'pub fn x() {}\n' }, 'native source'); - check('native source: tauri', decide('tauri'), 'true'); - check('native source: msrv', decide('msrv'), 'true'); + // Contract tests are consumed by Linux, Harmony and Android as well as desktop. + commit( + { 'openless-all/app/scripts/foo-contract.test.mjs': 'console.log(1);\n' }, + 'contract suite', + ); + expect('contract suite', every('true')); reset(); - // A dependency bump moves both the Tauri build and the MSRV promise. + // Native resources have both bundle and direct native consumers. + commit({ 'openless-all/app/assets/vocab-presets.json': '{}\n' }, 'native asset'); + expect('native asset', every('true')); + reset(); + + // npm manifests also control Android project generation, not only the bundle. + commit( + { + 'openless-all/app/package.json': '{}\n', + 'openless-all/app/package-lock.json': '{}\n', + }, + 'android tooling', + ); + expect('android tooling', { + tauri: 'true', + msrv: 'false', + linux: 'false', + harmony: 'true', + frontend: 'true', + android: 'true', + capsule: 'false', + 'ios-device': 'false', + }); + reset(); + + // iOS project work is the only thing that can break the Xcode build. + commit({ 'openless-all/ios/Packages/OpenLessShared/Sources/X.swift': 'let x = 1\n' }, 'swift'); + expect('ios project source', { + tauri: 'false', + msrv: 'false', + frontend: 'false', + 'ios-device': 'true', + }); + reset(); + + // A dependency bump moves every Rust build and the MSRV promise. commit({ 'openless-all/app/Cargo.lock': '# lock\n' }, 'lockfile'); - check('lockfile: tauri', decide('tauri'), 'true'); - check('lockfile: msrv', decide('msrv'), 'true'); + expect('lockfile', { tauri: 'true', msrv: 'true', linux: 'true', harmony: 'true' }); reset(); // Workflow changes always verify everything. commit({ '.github/workflows/ci.yml': 'name: CI\n' }, 'workflow'); - check('workflow change: tauri', decide('tauri'), 'true'); - check('workflow change: msrv', decide('msrv'), 'true'); - check('workflow change: linux', decide('linux'), 'true'); - check('workflow change: harmony', decide('harmony'), 'true'); + expect('workflow change', every('true')); reset(); // Unreadable or empty change sets never skip a platform check. - check('no base revision: tauri', decide('tauri', null), 'true'); - check('no base revision: msrv', decide('msrv', null), 'true'); - check('no base revision: linux', decide('linux', null), 'true'); - check('no base revision: harmony', decide('harmony', null), 'true'); - check('empty diff: tauri', decide('tauri'), 'true'); - check('empty diff: msrv', decide('msrv'), 'true'); - check('empty diff: linux', decide('linux'), 'true'); - check('empty diff: harmony', decide('harmony'), 'true'); - check('unknown revision: tauri', decide('tauri', 'deadbeefdeadbeef'), 'true'); + for (const area of ALL_AREAS) check('no base revision: ' + area, decide(area, null), 'true'); + for (const area of ALL_AREAS) check('empty diff: ' + area, decide(area), 'true'); + for (const area of ALL_AREAS) { + check('unknown revision: ' + area, decide(area, 'deadbeefdeadbeef'), 'true'); + } check('unknown area', decide('nonsense'), 'true'); } finally { rmSync(scratch, { recursive: true, force: true }); @@ -152,7 +261,7 @@ const reusable = readFileSync( ), 'utf8', ); -const areas = ['tauri', 'msrv', 'linux', 'harmony']; +const areas = ['tauri', 'msrv', 'linux', 'harmony', 'frontend', 'android', 'capsule']; check( 'workflow publishes every area', areas.every((area) => workflow.includes(area + ': ${{ steps.detect.outputs.' + area + ' }}')), @@ -160,7 +269,7 @@ check( ); check( 'workflow computes every area in the detect step', - /for area in tauri msrv linux harmony; do/.test(workflow), + /for area in tauri msrv linux harmony frontend android capsule; do/.test(workflow), true, ); check( @@ -206,7 +315,17 @@ check( ); check( 'android gate is fail-open', - /inputs\.platform != 'macos'\) && needs\.changes\.outputs\.tauri != 'false'/.test(workflow), + /inputs\.platform != 'macos'\) && needs\.changes\.outputs\.android != 'false'/.test(workflow), + true, +); +check( + 'capsule gate is fail-open', + workflow.includes("needs.changes.outputs.capsule != 'false'"), + true, +); +check( + 'ui motion gate follows the rendered surface', + /inputs\.platform != 'macos'\) && needs\.changes\.outputs\.frontend != 'false'/.test(workflow), true, ); check( @@ -258,35 +377,6 @@ check( true, ); -const windowsJob = workflow.match(/\n windows-frontend:[\s\S]*?(?=\n [a-z-]+:)/)?.[0] ?? ''; -const capsuleJob = workflow.match(/\n native-capsule:[\s\S]*?(?=\n [a-z-]+:)/)?.[0] ?? ''; -const windowsGate = windowsJob.match(/\n if: (.+)/)?.[1]; -const capsuleMatrix = capsuleJob.match(/\n os: \$\{\{ (.+) \}\}/)?.[1]; -for (const [event, platform, tauri, expectedWindows, expectedPlatforms] of [ - ['workflow_dispatch', 'macos', 'true', false, ['macos-latest']], - ['workflow_dispatch', 'all', 'true', true, ['macos-latest', 'windows-latest']], - ['pull_request', '', 'true', true, ['macos-latest', 'windows-latest']], - ['push', '', undefined, true, ['macos-latest', 'windows-latest']], - ['pull_request', '', 'false', false, ['macos-latest', 'windows-latest']], -]) { - const context = { - github: { event_name: event }, - inputs: { platform }, - needs: { changes: { outputs: { tauri } } }, - fromJSON: JSON.parse, - }; - check( - event + '/' + platform + ': Windows frontend gate', - Boolean(vm.runInNewContext(windowsGate ?? 'false', context)), - expectedWindows, - ); - check( - event + '/' + platform + ': capsule platforms', - JSON.stringify(vm.runInNewContext(capsuleMatrix ?? '[]', context)), - JSON.stringify(expectedPlatforms), - ); -} - // Execute the real wiring checks with changed text, without spawning Git again. const source = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const wiringStart = source.indexOf('const areas ='); diff --git a/openless-all/app/scripts/ci-impact.mjs b/openless-all/app/scripts/ci-impact.mjs new file mode 100644 index 000000000..8e7c373e3 --- /dev/null +++ b/openless-all/app/scripts/ci-impact.mjs @@ -0,0 +1,364 @@ +#!/usr/bin/env node +// Platform reachability for a change set, derived from Cargo's own package graph. +// +// node scripts/ci-impact.mjs [base-sha] → prints "true" | "false" +// +// Why this exists: CI used to decide "can this change reach platform X" with a +// hand-written list of path globs, which errs in both directions — an iOS-only +// change still spun up the whole desktop matrix, and a new crate under an +// unlisted path silently fell through to "run everything". The Rust half of the +// answer is not a guess: `cargo metadata` reports every workspace package, its +// directory, its intra-repo dependencies and the `cfg(...)` that guards each +// one. Walking that graph from each platform's root package gives the exact set +// of crates that platform compiles, so a file is in scope for a platform iff +// the package that owns it is reachable from that platform's root. +// +// Anything the graph cannot answer — no base revision, a git failure, an empty +// diff, a file outside every package and rule, a metadata error — prints "true". +// Skipping a check must be the result of a provable "cannot reach", never of a +// failure to classify. +// +// Areas: +// tauri macOS / Windows desktop shells and the Android build that +// shares their dependency graph. +// msrv The pinned Rust 1.99 compile. +// linux The Linux egui host, its tests and the deb/rpm chain. +// harmony The HarmonyOS host (ArkTS contracts, Rust host, HAP). +// frontend Browser-rendered surface: the Chromium/WebKit motion suite. +// ios-device The Xcode/Rime project layer and the shared Rust crates that +// are compiled for the real iOS device target. + +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(SCRIPT_DIR, '../../..'); + +/** Everything runs. */ +const ALL = Symbol('all'); + +const AREAS = ['tauri', 'msrv', 'linux', 'harmony', 'frontend', 'android', 'capsule', 'ios-device']; + +/** Cargo workspaces that describe this repository's own packages. */ +const WORKSPACES = ['openless-all/app/Cargo.toml', 'openless-all/app/src-tauri/Cargo.toml']; + +/** + * Platform roots: the package each platform actually builds. The closure is + * walked from here, so a crate only counts if the platform really links it. + * + * `openless-native-capsule` is not linked by the app binary — it is compiled + * into the desktop shells — so it is listed explicitly, on desktop only. + * iOS copies `openless-core` into its own tree (see ios/scripts/prepare-core.py) + * and never sees the Tauri crate. + */ +const PLATFORM_ROOTS = { + macos: ['openless', 'openless-native-capsule'], + windows: ['openless', 'openless-native-capsule'], + android: ['openless'], + // The Linux reusable workflow also owns Harmony Rust unit tests. + linux: ['openless-linux-egui', 'openless-harmony-host'], + harmony: ['openless-harmony-host'], + ios: ['openless-core'], +}; + +/** + * Capsule crates: compiled into the desktop shells and covered by their own + * macOS + Windows runners, which only earn their keep when these change. + */ +const CAPSULE_PACKAGES = new Set(['openless-capsule', 'openless-native-capsule']); + +/** Rust target_os names used when evaluating `cfg(...)` guards. */ +const PLATFORM_TARGET_OS = { + macos: 'macos', + windows: 'windows', + android: 'android', + linux: 'linux', + harmony: 'ohos', + ios: 'ios', +}; + +const rust = () => { + try { + execFileSync('cargo', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +}; + +function cargoMetadata(manifestRelativePath) { + const manifestPath = join(REPO_ROOT, manifestRelativePath); + if (!existsSync(manifestPath)) return null; + const out = execFileSync( + 'cargo', + [ + 'metadata', + '--no-deps', + '--offline', + '--format-version', + '1', + '--manifest-path', + manifestPath, + ], + { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20 }, + ); + return JSON.parse(out); +} + +/** Minimal `cfg(...)` evaluator — enough for the guards Cargo reports. */ +function evaluateCfg(expression, targetOs) { + const text = String(expression ?? '').trim(); + if (!text) return true; + const body = text.startsWith('cfg(') && text.endsWith(')') ? text.slice(4, -1) : text; + return evaluatePredicate(body, targetOs); +} + +function evaluatePredicate(body, targetOs) { + const source = body.trim(); + const call = /^(any|all|not)\(([\s\S]*)\)$/.exec(source); + if (call) { + const [, name, inner] = call; + const terms = splitTopLevel(inner); + if (name === 'any') return terms.some((term) => evaluatePredicate(term, targetOs)); + if (name === 'all') return terms.every((term) => evaluatePredicate(term, targetOs)); + return !evaluatePredicate(terms[0] ?? '', targetOs); + } + const targetOsMatch = /^target_os\s*=\s*"([^"]+)"$/.exec(source); + if (targetOsMatch) return targetOsMatch[1] === targetOs; + // Unknown predicate (feature = ..., target_arch = ..., a newer cfg key): never + // skip on it. + return true; +} + +function splitTopLevel(inner) { + const terms = []; + let depth = 0; + let current = ''; + for (const char of inner) { + if (char === '(') depth += 1; + if (char === ')') depth -= 1; + if (char === ',' && depth === 0) { + terms.push(current); + current = ''; + continue; + } + current += char; + } + if (current.trim()) terms.push(current); + return terms.map((term) => term.trim()).filter(Boolean); +} + +/** Package directory → package name, plus intra-repo dependency edges. */ +function buildGraph() { + const packages = new Map(); + const edges = new Map(); + for (const manifest of WORKSPACES) { + let metadata; + try { + metadata = cargoMetadata(manifest); + } catch { + return null; + } + if (!metadata) continue; + for (const pkg of metadata.packages) { + const dir = relative(REPO_ROOT, dirname(pkg.manifest_path)).split(sep).join('/'); + packages.set(pkg.name, { dir, manifest: `${dir}/Cargo.toml` }); + const deps = (pkg.dependencies ?? []) + .filter((dep) => dep.path) + .map((dep) => ({ name: dep.name, target: dep.target ?? null })); + edges.set(pkg.name, deps); + } + } + return packages.size ? { packages, edges } : null; +} + +function closureFor(graph, platform) { + const targetOs = PLATFORM_TARGET_OS[platform]; + const seen = new Set(); + const queue = [...(PLATFORM_ROOTS[platform] ?? [])]; + while (queue.length) { + const name = queue.pop(); + if (seen.has(name) || !graph.packages.has(name)) continue; + seen.add(name); + for (const dep of graph.edges.get(name) ?? []) { + if (!graph.packages.has(dep.name)) continue; + if (!evaluateCfg(dep.target, targetOs)) continue; + queue.push(dep.name); + } + } + return seen; +} + +function changedFiles(base) { + if (!base) return null; + try { + const out = execFileSync('git', ['diff', '--name-only', `${base}...HEAD`], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const files = out + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + return files.length ? files : null; + } catch { + return null; + } +} + +const prose = (file) => + file.endsWith('.md') || + file.startsWith('docs/') || + /(^|\/)(LICENSE|NOTICE)/.test(file) || + file.startsWith('.github/ISSUE_TEMPLATE/'); + +const workflowOrTooling = (file) => + file.startsWith('.github/') || + file === 'Cargo.toml' || + file.endsWith('/Cargo.toml') || + file.endsWith('/Cargo.lock') || + /(^|\/)rust-toolchain(\.toml)?$/.test(file) || + file.startsWith('openless-all/app/contract/') || + file.startsWith('scripts/'); + +/** + * Contract tests are consumed by multiple platform jobs (and some run during + * Android/Harmony prebuilds). Changing a gate test must not skip the gate it + * describes, so classify every app script test as fail-open. + */ +const contractSuite = (file) => /^openless-all\/app\/scripts\/[^/]+\.test\.(mjs|js|ts)$/.test(file); + +const nativeResource = (file) => + file.startsWith('openless-all/app/assets/') || file.startsWith('openless-all/app/public/'); + +const androidTooling = (file) => + /^openless-all\/app\/(package\.json|package-lock\.json)$/.test(file); + +/** Browser-rendered surface: sources, styles, the bundle entry and its specs. */ +const motionSurface = (file) => + /^openless-all\/app\/(src\/|public\/|e2e\/|assets\/)/.test(file) || + /^openless-all\/app\/(index\.html|package\.json|package-lock\.json|vite\.config\.[^/]+|tailwind\.[^/]+|postcss\.[^/]+|tsconfig[^/]*\.json)$/.test( + file, + ) || + /^openless-all\/app\/[^/]+\.css$/.test(file) || + file.startsWith('openless-all/app/playwright'); + +const iosProject = (file) => file.startsWith('openless-all/ios/'); + +/** + * Platforms a file's change can reach. `ALL` when the file is tooling or a + * shared resource whose native consumers cannot be proven by one package. + */ +function classify(file, context) { + if (prose(file)) return new Set(); + if (contractSuite(file)) return ALL; + if (workflowOrTooling(file)) return ALL; + if (iosProject(file)) return new Set(['ios', 'ios-device']); + if (nativeResource(file)) return ALL; + if (androidTooling(file)) return new Set(['frontend', 'macos', 'windows', 'harmony', 'android']); + + const tags = new Set(); + if (motionSurface(file)) { + // The bundle is embedded in every shell that renders it. The Linux host uses + // a native egui UI and does not consume `dist/` at all, and the Android job + // only compiles the Rust crate (`npm run build` there just satisfies + // `generate_context!`), so neither is in scope for a bundle-only change. + for (const tag of ['frontend', 'macos', 'windows', 'harmony']) tags.add(tag); + } + + const owner = ownerPackage(file, context); + if (owner) { + if (CAPSULE_PACKAGES.has(owner)) tags.add('capsule'); + for (const [platform, packages] of context.closures) { + if (packages.has(owner)) { + tags.add(platform); + if (platform === 'ios') tags.add('ios-device'); + } + } + } + // Files outside every package still have to be evaluated by the shells that + // embed them (assets, fixtures, icons). + if (tags.size === 0) { + if (file.startsWith('openless-all/app/harmony/')) return new Set(['harmony']); + if (file.startsWith('openless-all/app/linux-egui/')) return new Set(['linux']); + return ALL; + } + return tags; +} + +function ownerPackage(file, context) { + let best = null; + for (const [name, pkg] of context.graph.packages) { + if (file === pkg.manifest || file.startsWith(`${pkg.dir}/`)) { + if (!best || pkg.dir.length > best.dir.length) best = { name, dir: pkg.dir }; + } + } + return best?.name ?? null; +} + +function isRustSource(file) { + return file.endsWith('.rs') || file.endsWith('Cargo.toml') || file.endsWith('Cargo.lock'); +} + +function decide(area, context, files) { + const reached = new Set(); + for (const file of files) { + const tags = classify(file, context); + if (tags === ALL) return true; + for (const tag of tags) reached.add(tag); + } + switch (area) { + case 'msrv': + return files.some(isRustSource) || files.some(workflowOrTooling); + case 'tauri': + return ['macos', 'windows', 'android'].some((tag) => reached.has(tag)); + case 'linux': + return reached.has('linux'); + case 'harmony': + return reached.has('harmony'); + case 'frontend': + return reached.has('frontend'); + case 'android': + return reached.has('android'); + case 'capsule': + return reached.has('capsule'); + case 'ios-device': + return reached.has('ios-device'); + default: + return true; + } +} + +function main() { + const [area, base = ''] = process.argv.slice(2); + const run = () => { + process.stdout.write('true\n'); + process.exit(0); + }; + if (!AREAS.includes(area)) run(); + const files = changedFiles(base); + if (!files) run(); + if (!rust()) run(); + const graph = buildGraph(); + if (!graph) run(); + const closures = new Map(); + for (const platform of Object.keys(PLATFORM_ROOTS)) { + closures.set(platform, closureFor(graph, platform)); + } + const verdict = decide(area, { graph, closures }, files); + process.stdout.write(`${verdict ? 'true' : 'false'}\n`); +} + +const invokedDirectly = + Boolean(process.argv[1]) && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) { + try { + main(); + } catch { + process.stdout.write('true\n'); + } +} + +export { AREAS, classify, decide, buildGraph, closureFor, evaluateCfg }; diff --git a/openless-all/app/scripts/workflow-concurrency-contract.test.mjs b/openless-all/app/scripts/workflow-concurrency-contract.test.mjs index 680886384..a1581f8f8 100644 --- a/openless-all/app/scripts/workflow-concurrency-contract.test.mjs +++ b/openless-all/app/scripts/workflow-concurrency-contract.test.mjs @@ -41,8 +41,12 @@ assert.ok( ); assert.ok( - workflows.ci.includes("branches: ['**']"), - 'CI must run on every branch push so branch changes receive the quality gate', + workflows.ci.includes('branches: [main, beta]'), + 'CI must run on default-branch pushes (baseline evidence and the only cache writer)', +); +assert.ok( + !workflows.ci.includes("branches: ['**']"), + 'CI must not spend a second full matrix on feature-branch pushes that their pull request already covers', ); // Release workflows are tag-only: a per-branch push rebuilt a 1-2 GB cache entry // on every commit and evicted the beta scope, while `ci.yml` already builds the From ace8c3a5fb921e5ac90b6a1b2ff4ab788259bdbb Mon Sep 17 00:00:00 2001 From: CanTakki Date: Wed, 28 Oct 2026 17:59:56 +0900 Subject: [PATCH 21/31] fix(macos): only reset TCC grants when the signing requirement changes (#1245) Ad-hoc builds still reset Accessibility / Microphone on every install, since their cdhash-based requirement changes each build. A stable signing identity keeps the same designated requirement, so its grants now survive reinstalls. Claude-Session: https://claude.ai/code/session_01YJvDYNu6QoSEhJ4yGSuRqk Co-authored-by: DragonNameLeo <15801369+DragonNameLeo@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- openless-all/app/scripts/build-mac.sh | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/openless-all/app/scripts/build-mac.sh b/openless-all/app/scripts/build-mac.sh index 2b8cd6c32..c8dcf810f 100755 --- a/openless-all/app/scripts/build-mac.sh +++ b/openless-all/app/scripts/build-mac.sh @@ -152,11 +152,17 @@ if [ "$INSTALL" = "1" ]; then echo "▶ 装到 /Applications" pkill -f "OpenLess.app/Contents/MacOS/openless" 2> /dev/null || true sleep 1 - # Reset TCC before every reinstall: the ad-hoc signing hash changes on each build, so old - # authorizations break immediately; without a reset, "checked in System Settings" silently - # stops working. - tccutil reset Accessibility com.openless.app 2> /dev/null || true - tccutil reset Microphone com.openless.app 2> /dev/null || true + # TCC grants are bound to the designated requirement. An ad-hoc signature pins the cdhash, + # which changes on each build, so old authorizations break immediately; without a reset, + # "checked in System Settings" silently stops working. A stable signing identity keeps the + # same requirement, so its grants survive the reinstall and are left alone. + OLD_REQUIREMENT="$(codesign -d -r- /Applications/OpenLess.app 2> /dev/null || true)" + NEW_REQUIREMENT="$(codesign -d -r- "$APP" 2> /dev/null || true)" + if [ -z "$NEW_REQUIREMENT" ] || [ "$OLD_REQUIREMENT" != "$NEW_REQUIREMENT" ]; then + echo "▶ 签名要求已变化,重置辅助功能 / 麦克风授权" + tccutil reset Accessibility com.openless.app 2> /dev/null || true + tccutil reset Microphone com.openless.app 2> /dev/null || true + fi rm -rf /Applications/OpenLess.app cp -R "$APP" /Applications/ xattr -dr com.apple.quarantine /Applications/OpenLess.app 2> /dev/null || true From ac0b159da8630e00543e9b5a6a9dddc55839b2c3 Mon Sep 17 00:00:00 2001 From: DepengWang Date: Wed, 28 Oct 2026 17:47:08 +0800 Subject: [PATCH 22/31] feat(android): IME association fixes, learning, English typing and speed (stroke/English, rebased on Rime) (#1230) * feat(android-ime): candidate, learning and speed improvements for stroke and English Rebased onto the Rime pinyin backend (#1196): the LitePinyin changes (full-pinyin phrase index, combo learning) are dropped; what remains applies to the stroke and English keyboards and to the pinyin row's association and candidate plumbing that Rime still uses. Associations - Drop the phrase the context already completes from the association results, so the row clears once the last association is picked. - Clear associations (and the stroke code row) when the host empties the field, e.g. a chat app's send button. - Rank associations the user picked before ahead of unpicked ones; typing an offered association out by hand counts as picking it (stroke, and Rime commits through RimePinyinController's association context). Replaces the stroke commit record whose key never matched an association lookup. Stroke - Mark, digit, space and Return keys drop the half-typed code, its segments and the code row before committing; a new input session clears the code row and candidates on screen too. - Keep recording picks of a character that is on top only because of earlier picks, so its score stays fresh. English - Sentence punctuation replaces the space a candidate pick added. - A quick double space after a word becomes ". ". - Capitalize at the start of a sentence, line or field (not in email, URL, password or number fields); Shift turns an automatic capital off. Speed - Stroke dictionary sorted by frequency once at load; a query puts the user's picks first and stops at 36 matches instead of scoring and sorting up to ~48k entries (first stroke now 0-2 ms on a OnePlus). - Usage stores parsed into one process-wide in-memory cache, kept current by a preference listener. - Shift changes relabel the English keys in place instead of rebuilding the keyboard; the English/pinyin candidate row reuses its views. Co-Authored-By: Claude Opus 5.5 * fix(android-ime): skip the field-emptied check while the draft box is open With #1187's draft bar open, typing goes to the draft box, so an empty host field must not clear the stroke code row or the associations. Co-Authored-By: Claude Opus 5.5 * fix(android-ime): bind automatic edits to caret and learn split associations Guard candidate spaces and double-space full stops with editor and selection anchors. Retain typed continuation boundaries so separately committed characters train the originally offered association. Add JVM and Android emulator regressions plus safe CI evidence collection. Co-Authored-By: Claude Fable 5 * fix(android-ime): reset pinyin learning context on editor deletion Invalidate confirmed association text and continuation history when Pinyin Backspace deletes editor text without an active composition. Add an asynchronous service regression for stale-context learning. Co-Authored-By: Claude Fable 5 * test(android-ime): initialize Rime before backspace queue deadline Complete cold dictionary deployment on the instrumentation test thread before exercising the editor fallback, matching the existing Rime integration test setup. Co-Authored-By: Claude Fable 5 * fix(android-ime): preserve learning and capitals --------- Co-authored-by: DepengWang <2818245+DepengWang@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: HKLHaoBin Co-authored-by: Chris233 --- .github/workflows/ci.yml | 2 + .../app/android/kotlin/EnglishAutoText.kt | 65 +++ .../android/kotlin/EnglishUserFrequency.kt | 15 +- .../app/android/kotlin/FrequencyStampCache.kt | 112 +++++ .../app/android/kotlin/OpenLessImeService.kt | 425 ++++++++++++++---- .../android/kotlin/RimePinyinController.kt | 5 +- .../app/android/kotlin/StrokeInput.kt | 115 +++-- .../android/kotlin/StrokeInputController.kt | 70 ++- .../android/kotlin/StrokePhraseRepository.kt | 98 +++- .../app/android/kotlin/StrokeUserFrequency.kt | 25 +- .../androidTest/ImeAutoTextIntegrationTest.kt | 217 +++++++++ .../kotlin/test/EnglishAutoTextTest.kt | 63 +++ .../kotlin/test/FrequencyStampCacheTest.kt | 81 ++++ .../kotlin/test/InputLearningRulesTest.kt | 70 +++ .../kotlin/test/StrokePhraseSuffixTest.kt | 121 +++++ .../android-ime-key-click-contract.test.mjs | 11 + .../app/scripts/copy-android-scaffolding.mjs | 7 + 17 files changed, 1340 insertions(+), 162 deletions(-) create mode 100644 openless-all/app/android/kotlin/EnglishAutoText.kt create mode 100644 openless-all/app/android/kotlin/FrequencyStampCache.kt create mode 100644 openless-all/app/android/kotlin/androidTest/ImeAutoTextIntegrationTest.kt create mode 100644 openless-all/app/android/kotlin/test/EnglishAutoTextTest.kt create mode 100644 openless-all/app/android/kotlin/test/FrequencyStampCacheTest.kt create mode 100644 openless-all/app/android/kotlin/test/InputLearningRulesTest.kt create mode 100644 openless-all/app/android/kotlin/test/StrokePhraseSuffixTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c0d7187d..dda2bb711 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -417,6 +417,8 @@ jobs: # Rime is staged for this emulator ABI; do not launch the Tauri Rust build. src-tauri/gen/android/gradlew --project-dir src-tauri/gen/android \ :app:connectedX86_64DebugAndroidTest -x :app:rustBuildX86_64Debug --no-daemon + # Regression evidence written by ImeAutoTextIntegrationTest (#1230). + adb -s emulator-5554 logcat -d -s Ime1230Evidence:I '*:S' - name: Upload Android emulator failure log if: failure() diff --git a/openless-all/app/android/kotlin/EnglishAutoText.kt b/openless-all/app/android/kotlin/EnglishAutoText.kt new file mode 100644 index 000000000..32dc49fc6 --- /dev/null +++ b/openless-all/app/android/kotlin/EnglishAutoText.kt @@ -0,0 +1,65 @@ +package com.openless.app + +import android.text.InputType + +/** + * The English keyboard's small typing conveniences — sentence-start + * capitals, double-space full stop, punctuation pulling in the space a + * candidate pick added — as pure decisions on the text around the caret. + * [OpenLessImeService] reads the field and carries them out. + */ +internal object EnglishAutoText { + /** Marks that take the place of a candidate's trailing space: "word " + "," becomes "word, ". */ + private val SPACE_SWALLOWING_PUNCTUATION = setOf(".", ",", "!", "?", ";", ":") + private val SENTENCE_ENDS = setOf('.', '!', '?') + + /** How much text before the caret [isSentenceStart] needs to see. */ + const val SENTENCE_LOOKBEHIND = 64 + + /** + * True at the start of the field, of a line, or after a sentence-ending + * mark followed by whitespace. [before] is up to [SENTENCE_LOOKBEHIND] + * characters before the caret. + */ + fun isSentenceStart(before: CharSequence): Boolean { + if (before.isBlank()) return true + val last = before.last() + if (last == '\n') return true + if (!last.isWhitespace()) return false + val mark = before.trimEnd().lastOrNull() ?: return true + return mark in SENTENCE_ENDS + } + + /** Fields where a capital nobody asked for would be wrong: addresses, URLs, passwords, numbers. */ + fun allowsAutoCapitals(inputType: Int): Boolean { + if (inputType and InputType.TYPE_MASK_CLASS != InputType.TYPE_CLASS_TEXT) return false + return when (inputType and InputType.TYPE_MASK_VARIATION) { + InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS, + InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS, + InputType.TYPE_TEXT_VARIATION_URI, + InputType.TYPE_TEXT_VARIATION_PASSWORD, + InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, + InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD -> false + else -> true + } + } + + /** Preserve the capitalization already typed when a completion replaces its prefix. */ + fun candidateText(word: String, typedPrefix: CharSequence, capsLock: Boolean): String = when { + capsLock || (typedPrefix.length > 1 && typedPrefix.all { it.isUpperCase() }) -> word.uppercase() + typedPrefix.firstOrNull()?.isUpperCase() == true -> word.replaceFirstChar { it.titlecase() } + else -> word + } + + /** + * A second quick space turns "word " into "word. ". Only right after a + * letter or digit and its single space — never after punctuation, a + * second space or a line break. + */ + fun doubleSpaceMakesFullStop(before: CharSequence): Boolean { + if (before.length < 2 || before.last() != ' ') return false + return before[before.length - 2].isLetterOrDigit() + } + + fun swallowsAutoSpace(char: String): Boolean = char in SPACE_SWALLOWING_PUNCTUATION +} diff --git a/openless-all/app/android/kotlin/EnglishUserFrequency.kt b/openless-all/app/android/kotlin/EnglishUserFrequency.kt index ce9b85b5f..4f8e967e1 100644 --- a/openless-all/app/android/kotlin/EnglishUserFrequency.kt +++ b/openless-all/app/android/kotlin/EnglishUserFrequency.kt @@ -22,6 +22,7 @@ import kotlin.math.ln internal class EnglishUserFrequency(context: Context) { private val preferences = context.applicationContext.getSharedPreferences(STORE, Context.MODE_PRIVATE) private val customWordsPrefs = context.applicationContext.getSharedPreferences(CUSTOM_STORE, Context.MODE_PRIVATE) + private val cache = FrequencyStampCache.of(context, STORE) // Tracks entry count without paying preferences.all's full-map-copy cost // on every record() — see trimIfNeeded(). -1 means "not yet known", @@ -30,22 +31,19 @@ internal class EnglishUserFrequency(context: Context) { private var approxSize = -1 fun score(word: String, now: Long = System.currentTimeMillis()): Double { - val value = preferences.getString(key(word), null) ?: return 0.0 - val parts = value.split(',') - val count = parts.getOrNull(0)?.toDoubleOrNull() ?: return 0.0 - val last = parts.getOrNull(1)?.toLongOrNull() ?: return 0.0 - val ageDays = ((now - last).coerceAtLeast(0L)).toDouble() / MILLIS_PER_DAY - return ln(1.0 + count) * COUNT_WEIGHT + exp(-ageDays / RECENCY_HALF_LIFE_DAYS) * RECENCY_WEIGHT + val stamp = cache.get(key(word)) ?: return 0.0 + val ageDays = ((now - stamp.lastUsedMs).coerceAtLeast(0L)).toDouble() / MILLIS_PER_DAY + return ln(1.0 + stamp.count) * COUNT_WEIGHT + exp(-ageDays / RECENCY_HALF_LIFE_DAYS) * RECENCY_WEIGHT } fun record(word: String) { if (word.isBlank()) return val entryKey = key(word) val isNewEntry = !preferences.contains(entryKey) - val old = preferences.getString(entryKey, null)?.split(',') - val count = (old?.getOrNull(0)?.toIntOrNull() ?: 0) + 1 + val count = (cache.get(entryKey)?.count ?: 0) + 1 val now = System.currentTimeMillis() preferences.edit().putString(entryKey, "$count,$now").apply() + cache.put(entryKey, FrequencyStampCache.Stamp(count, now)) // Re-recording an existing word (by far the common case) never // touches preferences.all at all now — only a genuinely new word // can push the count past MAX_ENTRIES, so that's the only case @@ -59,6 +57,7 @@ internal class EnglishUserFrequency(context: Context) { fun clear() { preferences.edit().clear().apply() + cache.invalidate() approxSize = 0 } diff --git a/openless-all/app/android/kotlin/FrequencyStampCache.kt b/openless-all/app/android/kotlin/FrequencyStampCache.kt new file mode 100644 index 000000000..b9d6bb16a --- /dev/null +++ b/openless-all/app/android/kotlin/FrequencyStampCache.kt @@ -0,0 +1,112 @@ +package com.openless.app + +import android.content.Context +import android.content.SharedPreferences +import java.util.concurrent.ConcurrentHashMap + +/** + * Parsed, in-memory copy of one "count,lastUsedMs" usage store, shared by + * every reader of that store in the process. + * + * Ranking looks a score up for every candidate it considers — reading and + * splitting the stored string each time made that the slow part of a + * stroke query. Every StrokeUserFrequency/EnglishUserFrequency instance + * for a store gets this one cache, kept + * current by a preference listener, so a write from any instance (the + * keyboard, the settings page clearing it, an import) is seen by all. + */ +internal class FrequencyStampCache private constructor( + private val preferences: SharedPreferences, + private val groupOf: ((String) -> String?)?, +) { + data class Stamp(val count: Int, val lastUsedMs: Long) + + private val stamps = ConcurrentHashMap() + // groupOf(key) -> keys; lets the stroke store list every character + // picked for one code without scanning the whole store. + private val groups = ConcurrentHashMap>() + @Volatile + private var loaded = false + + // Held as a field: SharedPreferences only keeps a weak reference to it. + private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == null) invalidate() else refresh(key) + } + + init { + preferences.registerOnSharedPreferenceChangeListener(listener) + } + + fun get(key: String): Stamp? { + ensureLoaded() + return stamps[key] + } + + fun keysInGroup(group: String): List { + ensureLoaded() + return groups[group]?.toList().orEmpty() + } + + /** Call alongside the preference write, so a query right after sees it before the listener runs. */ + @Synchronized + fun put(key: String, stamp: Stamp) { + ensureLoaded() + store(key, stamp) + } + + /** After a wholesale change (clear, import) — clear() alone doesn't notify listeners before API 30. */ + fun invalidate() { + synchronized(this) { + loaded = false + stamps.clear() + groups.clear() + } + } + + private fun ensureLoaded() { + if (loaded) return + synchronized(this) { + if (loaded) return + preferences.all.forEach { (key, value) -> parseStamp(value as? String)?.let { store(key, it) } } + loaded = true + } + } + + @Synchronized + private fun refresh(key: String) { + if (!loaded) return + val stamp = parseStamp(preferences.getString(key, null)) + if (stamp == null) { + stamps.remove(key) + groupOf?.invoke(key)?.let { group -> groups[group]?.remove(key) } + } else { + store(key, stamp) + } + } + + private fun store(key: String, stamp: Stamp) { + stamps[key] = stamp + groupOf?.invoke(key)?.let { group -> groups.getOrPut(group) { ConcurrentHashMap.newKeySet() }.add(key) } + } + + companion object { + private val registry = HashMap() + + /** The process-wide cache for [store]; [groupOf] only takes effect for whoever opens it first. */ + fun of(context: Context, store: String, groupOf: ((String) -> String?)? = null): FrequencyStampCache = + synchronized(registry) { + registry.getOrPut(store) { + FrequencyStampCache(context.applicationContext.getSharedPreferences(store, Context.MODE_PRIVATE), groupOf) + } + } + + /** "count,lastUsedMs" -> Stamp, or null for anything else. */ + fun parseStamp(value: String?): Stamp? { + val comma = value?.indexOf(',') ?: return null + if (comma <= 0) return null + val count = value.substring(0, comma).toIntOrNull() ?: return null + val last = value.substring(comma + 1).toLongOrNull() ?: return null + return Stamp(count, last) + } + } +} diff --git a/openless-all/app/android/kotlin/OpenLessImeService.kt b/openless-all/app/android/kotlin/OpenLessImeService.kt index 8787b73a6..e352c51e7 100644 --- a/openless-all/app/android/kotlin/OpenLessImeService.kt +++ b/openless-all/app/android/kotlin/OpenLessImeService.kt @@ -292,6 +292,16 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt private var clipboardHistoryMode = false private var clipboardHistoryCategory = OpenLessClipboardHistory.Category.ALL private var shiftState = ShiftState.OFF + // SHIFT_ONCE that updateAutoShift() turned on for a sentence start, as + // opposed to the user's own Shift tap — only this kind is turned back off + // when the caret leaves the sentence start. + private var autoShift = false + // The space selectEnglishCandidate() appended, still right before the + // caret: sentence punctuation typed next takes its place ("word, "). + private data class EnglishEditAnchor(val connection: android.view.inputmethod.InputConnection, val offset: Int) + private var englishAutoSpace: EnglishEditAnchor? = null + private var lastEnglishSpace: EnglishEditAnchor? = null + private var lastEnglishSpaceAt = 0L private var state = "idle" private var currentMessage = "点击开始说话" private var status: TextView? = null @@ -318,6 +328,15 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // view tree away via refreshInputView() and would otherwise leave // stale View keys behind. private val englishSwipeSymbols = HashMap() + // The letter keys and Shift key of the English panel on screen, so a + // Shift change can relabel them in place (applyShiftState()) instead of + // rebuilding the whole keyboard. Entries from an older panel are simply + // no longer attached to the window and get skipped. + private val englishLetterKeys = ArrayList>() + private var englishShiftKey: ShiftKeyView? = null + // Binding behind each reusable candidate view in englishCandidateRow — + // see populateLatinCandidateRow(). + private val latinCandidateSlots = java.util.WeakHashMap() private var voiceButton: VoiceButton? = null // Silence-detection for the main voice panel: if the mic capture never // reports a meaningful level for a while after recording starts, the @@ -556,8 +575,10 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt rimePinyinController.clear() latinInputMode = latinInputMode.toggled() saveLatinInputMode(latinInputMode) + clearEnglishAutoText() refreshLatinCandidateBar() performKeyHaptic() + updateAutoShift(refresh = false) refreshInputView() } @@ -1266,6 +1287,9 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt clipboardHistoryMode = false clipboardHistoryCategory = OpenLessClipboardHistory.Category.ALL shiftState = ShiftState.OFF + autoShift = false + clearEnglishAutoText() + updateAutoShift(refresh = false) if (slideDirection != null) refreshInputView(slideDirection) else refreshInputView() } @@ -1421,7 +1445,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } finalizeEnglishComposingWord() rimePinyinController.clear() - currentInputConnection?.commitText(" ", 1) + commitEnglishSpace() }, swipeUpAction = { commitEnglishChar(".") }, swipePreview = ".", longPressAction = { toggleLatinInputMode() }).apply { // Same size as Return (17sp — keyboardKey()'s own special case // for that label), per real-device feedback. @@ -1573,7 +1597,10 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt * add straight into the same now-fresh map without re-clearing it. */ private fun addEnglishCharRow(parent: LinearLayout, keys: List, swipeSymbols: List? = null) { - if (swipeSymbols != null) englishSwipeSymbols.clear() + if (swipeSymbols != null) { + englishSwipeSymbols.clear() + englishLetterKeys.clear() + } val row = LinearLayout(this).apply { gravity = android.view.Gravity.CENTER } keys.forEachIndexed { index, key -> row.addView(buildEnglishCharKey(key, 1f, swipeSymbol = swipeSymbols?.getOrNull(index))) @@ -1604,7 +1631,9 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt private fun handleEnglishBottomModeToggle() { englishLayer = if (englishLayer == EnglishLayer.LETTERS) EnglishLayer.NUMBERS else EnglishLayer.LETTERS shiftState = ShiftState.OFF + autoShift = false finalizeEnglishComposingWord() + updateAutoShift(refresh = false) refreshInputView() } @@ -1615,21 +1644,102 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } private fun cycleEnglishShift() { - // Cycles lowercase -> capitalize-next -> caps-lock -> lowercase. - shiftState = when (shiftState) { - ShiftState.OFF -> ShiftState.SHIFT_ONCE - ShiftState.SHIFT_ONCE -> ShiftState.CAPS_LOCK - ShiftState.CAPS_LOCK -> ShiftState.OFF + // Cycles lowercase -> capitalize-next -> caps-lock -> lowercase. A + // capital turned on for a sentence start goes straight back off: the + // user tapping Shift there wants lowercase, not caps lock. + shiftState = when { + autoShift && shiftState == ShiftState.SHIFT_ONCE -> ShiftState.OFF + shiftState == ShiftState.OFF -> ShiftState.SHIFT_ONCE + shiftState == ShiftState.SHIFT_ONCE -> ShiftState.CAPS_LOCK + else -> ShiftState.OFF + } + autoShift = false + applyShiftState() + } + + /** + * Turns a one-shot capital on at a sentence start (see + * EnglishAutoText.isSentenceStart) and back off once the caret leaves + * it — but only a capital it turned on itself; the user's own Shift or + * caps lock is never overridden. Pass refresh = false when the caller + * rebuilds the input view anyway. + */ + private fun updateAutoShift(refresh: Boolean = true) { + if (shiftState == ShiftState.CAPS_LOCK || (shiftState == ShiftState.SHIFT_ONCE && !autoShift)) return + val applies = inputMode == InputMode.ENGLISH && + latinInputMode == LatinInputMode.ENGLISH && + englishLayer == EnglishLayer.LETTERS && + EnglishAutoText.allowsAutoCapitals(currentInputEditorInfo?.inputType ?: 0) + val wanted = applies && currentInputConnection + ?.getTextBeforeCursor(EnglishAutoText.SENTENCE_LOOKBEHIND, 0) + ?.let { EnglishAutoText.isSentenceStart(it) } == true + val next = when { + wanted && shiftState == ShiftState.OFF -> ShiftState.SHIFT_ONCE + !wanted && autoShift -> ShiftState.OFF + else -> return + } + shiftState = next + autoShift = next == ShiftState.SHIFT_ONCE + if (refresh) applyShiftState() + } + + private fun englishEditAnchor(connection: android.view.inputmethod.InputConnection): EnglishEditAnchor? { + val extracted = connection.getExtractedText(android.view.inputmethod.ExtractedTextRequest(), 0) ?: return null + if (extracted.selectionStart < 0 || extracted.selectionStart != extracted.selectionEnd) return null + return EnglishEditAnchor(connection, extracted.startOffset + extracted.selectionStart) + } + + private fun clearEnglishAutoText() { + englishAutoSpace = null + lastEnglishSpace = null + lastEnglishSpaceAt = 0L + } + + private fun invalidateEnglishAutoTextSelection(start: Int, end: Int) { + if (start != end || start < 0) { + clearEnglishAutoText() + return + } + if (englishAutoSpace?.offset != start) englishAutoSpace = null + if (lastEnglishSpace?.offset != start) { + lastEnglishSpace = null + lastEnglishSpaceAt = 0L + } + } + + /** Space on the English/pinyin keyboard; in English mode a second quick tap after a word turns "word " into "word. ". */ + private fun commitEnglishSpace() { + val connection = currentInputConnection ?: return + val now = android.os.SystemClock.uptimeMillis() + val position = englishEditAnchor(connection) + val elapsed = now - lastEnglishSpaceAt + val quickSecondTap = lastEnglishSpace != null && lastEnglishSpace == position && + elapsed >= 0L && elapsed < DOUBLE_SPACE_WINDOW_MS + clearEnglishAutoText() + if (quickSecondTap && + latinInputMode == LatinInputMode.ENGLISH && + !ImeLearningPolicy.isPassword(currentInputEditorInfo?.inputType ?: 0) && + EnglishAutoText.doubleSpaceMakesFullStop(connection.getTextBeforeCursor(2, 0) ?: "") + ) { + if (connection.deleteSurroundingText(1, 0)) connection.commitText(". ", 1) + return + } + if (connection.commitText(" ", 1)) { + lastEnglishSpace = englishEditAnchor(connection) + lastEnglishSpaceAt = now } - refreshInputView() } /** Backspace on the English keyboard: same field edit as everywhere else, plus keeping englishComposingWord (and therefore the candidate bar) in sync. In Pinyin mode, deletes from the (never-committed) encoding instead — falls back to the normal field edit only once the encoding is already empty (plan 3.3: "编码为空时 Backspace 恢复现有普通删除"). */ private fun englishDeleteBackward() { if (latinInputMode == LatinInputMode.PINYIN) { - rimePinyinController.backspace({ renderPinyinCandidates(it) }, { deleteBackward() }) + rimePinyinController.backspace({ renderPinyinCandidates(it) }, { + rimePinyinController.resetAssociationContext() + deleteBackward() + }) return } + clearEnglishAutoText() deleteBackward() if (englishComposingWord.isNotEmpty()) { englishComposingWord.deleteCharAt(englishComposingWord.length - 1) @@ -1692,6 +1802,84 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } } + /** What a reusable candidate view in englishCandidateRow does when tapped, swapped on every reuse. */ + private class LatinCandidateSlot(val hasLongPress: Boolean) { + var action: () -> Unit = {} + var longPress: () -> Unit = {} + } + + private class LatinCandidate(val label: String, val longPress: (() -> Unit)?, val action: () -> Unit) + + private var pinyinEncodingLabel: TextView? = null + + /** + * Fills englishCandidateRow with [candidates] (after an optional + * non-clickable [leadingLabel], pinyin's in-progress encoding), reusing + * the candidate views already there — same idea as the stroke row's + * populateCandidateRow(): this runs on every keystroke, and creating a + * dozen keyboardKey() views each time was the costly part. A view's tap + * and long-press go through its LatinCandidateSlot, so reusing it only + * swaps the slot's actions; keyboardKey()'s own long-press handling + * (including swallowing the tap that ends a long press) stays intact. + * Only a view of the same kind is reused — whether it has a long press + * is fixed when keyboardKey() builds it. + */ + private fun populateLatinCandidateRow(candidates: List, leadingLabel: String? = null) { + val row = englishCandidateRow ?: return + val firstIsLabel = row.childCount > 0 && row.getChildAt(0) === pinyinEncodingLabel + var offset = 0 + if (leadingLabel != null) { + val label = if (firstIsLabel) pinyinEncodingLabel!! else TextView(this).apply { + textSize = 16f + setSingleLine(true) + gravity = android.view.Gravity.CENTER + setTextColor(tone(Color.rgb(180, 180, 180), Color.rgb(120, 120, 125))) + setPadding(dp(9), 0, dp(9), 0) + isClickable = false + }.also { + pinyinEncodingLabel = it + row.addView(it, 0, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)) + } + label.text = leadingLabel + offset = 1 + } else if (firstIsLabel) { + row.removeViewAt(0) + } + candidates.forEachIndexed { index, candidate -> + val position = offset + index + val existing = row.getChildAt(position) as? TextView + val existingSlot = existing?.let { latinCandidateSlots[it] } + val view: TextView + val slot: LatinCandidateSlot + if (existing != null && existingSlot != null && existingSlot.hasLongPress == (candidate.longPress != null)) { + view = existing + slot = existingSlot + } else { + val fresh = LatinCandidateSlot(candidate.longPress != null) + slot = fresh + view = candidateItemView( + candidate.label, + isFirst = index == 0, + onLongPress = if (fresh.hasLongPress) ({ fresh.longPress() }) else null, + action = { fresh.action() }, + ) + latinCandidateSlots[view] = fresh + if (existing != null || position < row.childCount) row.removeViewAt(position) + row.addView(view, position, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)) + } + slot.action = candidate.action + candidate.longPress?.let { slot.longPress = it } + if (view.text.toString() != candidate.label) view.text = candidate.label + view.contentDescription = candidate.label + styleCandidateFirstState(view, index == 0) + } + while (row.childCount > offset + candidates.size) row.removeViewAt(row.childCount - 1) + // Shared with the stroke panel's own candidate row (only one of the + // two is ever visible at a time) — see showCandidateOverlay(). + candidateOverlayEntries = candidates.map { it.label to it.action } + if (candidates.isEmpty()) englishExpandCandidatesButton?.visibility = View.GONE + } + /** * "Show more candidates" overlay — a PopupWindow anchored below the * candidate row, wrapping the current full candidate/association list @@ -2420,7 +2608,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt StrokeActionView(this, graphicActionCode, graphicRotation) } label == "←" || label == "↵" -> ActionSymbolView(this, label, isDarkTheme) - label == "⇧" -> ShiftKeyView(this, shiftState, isDarkTheme) + label == "⇧" -> ShiftKeyView(this, shiftState, isDarkTheme).also { englishShiftKey = it } midDivider -> MidDividerTextView(this, tone(Color.rgb(100, 100, 100), Color.rgb(205, 205, 209))) else -> TextView(this) } @@ -2732,11 +2920,36 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt * This is a dedicated builder, not a keyboardKey() variant, precisely * so none of this touch handling can affect any other panel's keys. */ - private fun buildEnglishCharKey(baseChar: String, weight: Float, swipeSymbol: String? = null): View { - fun charFor(view: View): String { - val base = view.tag as? String ?: return "" - return if (shiftState != ShiftState.OFF && base.length == 1 && base[0].isLetter()) base.uppercase() else base + /** What a char key types right now: its base character, uppercased for a letter while Shift is on. */ + private fun englishCharFor(view: View): String { + val base = view.tag as? String ?: return "" + return if (shiftState != ShiftState.OFF && base.length == 1 && base[0].isLetter()) base.uppercase() else base + } + + /** + * Shows the current shiftState on the English panel: relabels the letter + * keys and redraws Shift in place. Typing already reads shiftState at the + * moment of the tap (englishCharFor), so only the labels were stale — + * rebuilding the whole keyboard for that ran on every sentence start and + * every capital. Falls back to a rebuild when that panel isn't on screen. + */ + private fun applyShiftState() { + val keys = englishLetterKeys.filter { it.first.isAttachedToWindow } + val shiftKey = englishShiftKey?.takeIf { it.isAttachedToWindow } + if (keys.isEmpty() || shiftKey == null) { + refreshInputView() + return } + keys.forEach { (key, letter) -> + val char = englishCharFor(key) + letter.text = char + key.contentDescription = char + } + shiftKey.state = shiftState + } + + private fun buildEnglishCharKey(baseChar: String, weight: Float, swipeSymbol: String? = null): View { + fun charFor(view: View): String = englishCharFor(view) val letterView = TextView(this).apply { textSize = 22f typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) @@ -2753,6 +2966,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt tag = baseChar if (swipeSymbol != null) englishSwipeSymbols[this] = swipeSymbol letterView.text = charFor(this) + if (baseChar.length == 1 && baseChar[0].isLetter()) englishLetterKeys += this to letterView addView(letterView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) if (swipeSymbol != null) { addView( @@ -2944,16 +3158,24 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // encoding buffer in Pinyin mode; see // RimePinyinController's own doc comment. rimePinyinController.appendLetter(char[0]) { renderPinyinCandidates(it) } - if (shiftState == ShiftState.SHIFT_ONCE) { - shiftState = ShiftState.OFF - refreshInputView() - } + consumeShiftOnce() return } rimePinyinController.insertLiteral(char) { renderPinyinCandidates(it) } return } - if (currentInputConnection?.commitText(char, 1) != true) return + val connection = currentInputConnection ?: return + val replaceAutoSpace = englishAutoSpace != null && englishAutoSpace == englishEditAnchor(connection) && + EnglishAutoText.swallowsAutoSpace(char) && connection.getTextBeforeCursor(1, 0)?.toString() == " " + clearEnglishAutoText() + if (replaceAutoSpace) { + if (!connection.deleteSurroundingText(1, 0) || !connection.commitText("$char ", 1)) return + englishAutoSpace = englishEditAnchor(connection) + finalizeEnglishComposingWord() + consumeShiftOnce() + return + } + if (!connection.commitText(char, 1)) return if (personalizedLearningAllowed() && char.length == 1 && char[0].isLetter()) { englishComposingWord.append(char.lowercase()) updateEnglishCandidates() @@ -2964,10 +3186,15 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // benefit for that particular boundary. finalizeEnglishComposingWord() } - if (shiftState == ShiftState.SHIFT_ONCE) { - shiftState = ShiftState.OFF - refreshInputView() - } + consumeShiftOnce() + } + + /** A one-shot capital (the user's or updateAutoShift()'s) is spent by the character just typed. */ + private fun consumeShiftOnce() { + if (shiftState != ShiftState.SHIFT_ONCE) return + shiftState = ShiftState.OFF + autoShift = false + applyShiftState() } /** Ends the current word: records it for user-frequency learning (if long enough to be a real word) and clears the tracking buffer. */ @@ -3080,25 +3307,9 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } private fun renderEnglishCandidates(words: List) { - val row = englishCandidateRow ?: return - row.removeAllViews() - val overlayEntries = mutableListOf Unit>>() - words.forEachIndexed { index, word -> - row.addView( - candidateItemView( - word, - isFirst = index == 0, - action = { selectEnglishCandidate(word) }, - onLongPress = { forgetEnglishCandidate(word) }, - ), - LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT), - ) - overlayEntries.add(word to { selectEnglishCandidate(word) }) - } - // Shared with the stroke panel's own candidate row (only one of the - // two is ever visible at a time) — see showCandidateOverlay(). - candidateOverlayEntries = overlayEntries - if (words.isEmpty()) englishExpandCandidatesButton?.visibility = View.GONE + populateLatinCandidateRow( + words.map { word -> LatinCandidate(word, longPress = { forgetEnglishCandidate(word) }) { selectEnglishCandidate(word) } }, + ) } /** @@ -3114,14 +3325,19 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt if (!personalizedLearningAllowed()) return val connection = currentInputConnection ?: return val typed = englishComposingWord.toString() + var typedPrefix = "" for (length in typed.length downTo 1) { val actual = connection.getTextBeforeCursor(length, 0)?.toString() if (actual != null && actual.equals(typed.takeLast(length), ignoreCase = true)) { + typedPrefix = actual connection.deleteSurroundingText(length, 0) break } } - if (!connection.commitText("$word ", 1)) return + val candidate = EnglishAutoText.candidateText(word, typedPrefix, shiftState == ShiftState.CAPS_LOCK) + clearEnglishAutoText() + if (!connection.commitText("$candidate ", 1)) return + englishAutoSpace = englishEditAnchor(connection) englishCandidateProvider.recordCommit(word) englishComposingWord.clear() updateEnglishCandidates() @@ -3153,37 +3369,10 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt * or container so the fixed 300dp panel height is never touched. */ private fun renderPinyinCandidates(candidates: List) { - val row = englishCandidateRow ?: return - row.removeAllViews() - val encoding = rimePinyinController.currentEncoding() - if (encoding.isNotEmpty()) { - val label = TextView(this).apply { - text = encoding - textSize = 16f - setSingleLine(true) - gravity = android.view.Gravity.CENTER - setTextColor(tone(Color.rgb(180, 180, 180), Color.rgb(120, 120, 125))) - setPadding(dp(9), 0, dp(9), 0) - isClickable = false - } - row.addView(label, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)) - } - val overlayEntries = mutableListOf Unit>>() - candidates.forEachIndexed { index, char -> - row.addView( - candidateItemView( - char, - isFirst = index == 0, - action = { selectPinyinCandidate(char) }, - ), - LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT), - ) - overlayEntries.add(char to { selectPinyinCandidate(char) }) - } - // Shared with the English/stroke candidate rows (only one of the - // three is ever visible at a time) — see showCandidateOverlay(). - candidateOverlayEntries = overlayEntries - if (candidates.isEmpty()) englishExpandCandidatesButton?.visibility = View.GONE + populateLatinCandidateRow( + candidates.map { char -> LatinCandidate(char, longPress = null) { selectPinyinCandidate(char) } }, + leadingLabel = rimePinyinController.currentEncoding().takeIf { it.isNotEmpty() }, + ) } /** Candidate tap is the ONLY way a pinyin candidate reaches the field — see toggleLatinInputMode()'s doc comment on why Space deliberately never does this. */ @@ -3218,23 +3407,9 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt /** Same row/candidateItemView() machinery as renderPinyinCandidates(), but no encoding label (there's no in-progress encoding here) and suffix-based commit (see selectPinyinAssociation()) instead of committing the whole label — mirrors StrokeInputController.refreshAssociations() exactly. */ private fun renderPinyinAssociations(candidates: List) { - val row = englishCandidateRow ?: return - row.removeAllViews() - val overlayEntries = mutableListOf Unit>>() - candidates.forEachIndexed { index, candidate -> - val displayText = outputScript(candidate.text) - row.addView( - candidateItemView( - displayText, - isFirst = index == 0, - action = { selectPinyinAssociation(candidate) }, - ), - LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT), - ) - overlayEntries.add(displayText to { selectPinyinAssociation(candidate) }) - } - candidateOverlayEntries = overlayEntries - if (candidates.isEmpty()) englishExpandCandidatesButton?.visibility = View.GONE + populateLatinCandidateRow( + candidates.map { candidate -> LatinCandidate(outputScript(candidate.text), longPress = null) { selectPinyinAssociation(candidate) } }, + ) } /** @@ -3276,6 +3451,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } internal fun sendEnterKey() { + clearEnglishAutoText() if (inputMode == InputMode.ENGLISH && latinInputMode == LatinInputMode.PINYIN && !ImeLearningPolicy.isPassword(currentInputEditorInfo?.inputType ?: 0)) { rimePinyinController.confirmFirst({ renderPinyinCandidates(it) }, { sendEditorEnterKey() }) @@ -3305,6 +3481,11 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt refreshLanguage() strokeController.resetForNewInputSession() englishLayer = EnglishLayer.LETTERS + // An automatic capital belonged to the old field's sentence start; + // onStartInputView() works out the new field's. + if (autoShift) shiftState = ShiftState.OFF + autoShift = false + clearEnglishAutoText() englishComposingWord.clear() englishCandidateQueryEpoch++ rimePinyinController.clear() @@ -3313,6 +3494,14 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt rimePinyinController.bindCommit { text -> if (ImeLearningPolicy.isPassword(currentInputEditorInfo?.inputType ?: 0)) false else if (currentInputConnection?.commitText(text, 1) == true) { + // Typing an offered association out by hand counts as picking it. + if (personalizedLearningAllowed() && OpenLessAndroidPreferences.strokeUsageEnabled(this)) { + strokeController.phraseRepository.recordTypedContinuation( + rimePinyinController.associationLearning.advance(rimePinyinController.associationContext(), text), + ) + } else { + rimePinyinController.associationLearning.reset() + } rimePinyinController.recordCommittedText(text) performKeyHaptic() true @@ -3348,9 +3537,13 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // A channel may have been turned off in settings since the last show. if (isBackendReady()) refreshAsrRoute() val key = keyboardFootprintKey() - if (appliedKeyboardFootprintKey != key) { + val rebuild = appliedKeyboardFootprintKey != key + updateAutoShift(refresh = false) + if (rebuild) { appliedKeyboardFootprintKey = key refreshInputView() + } else if (inputMode == InputMode.ENGLISH && englishLayer == EnglishLayer.LETTERS) { + applyShiftState() } } @@ -3358,6 +3551,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt "${keyboardPanelHeightDp()}:${raiseHeightDp(this)}:$inputMode:$latinInputMode:${personalizedLearningAllowed()}" override fun onFinishInputView(finishingInput: Boolean) { + clearEnglishAutoText() if (draftEdit != null && (recording || processing)) cancelDictation() closeDraft() super.onFinishInputView(finishingInput) @@ -3380,6 +3574,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } override fun onFinishInput() { + clearEnglishAutoText() if (draftEdit != null && (recording || processing)) cancelDictation() closeDraft() cancelImeSession() @@ -3425,9 +3620,38 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt candidatesEnd: Int, ) { super.onUpdateSelection(oldSelStart, oldSelEnd, newSelStart, newSelEnd, candidatesStart, candidatesEnd) + if (draftEdit == null) invalidateEnglishAutoTextSelection(newSelStart, newSelEnd) // Selecting another span must not discard the user's pending undo/redo. if (newSelStart < 0 || newSelStart != newSelEnd) return invalidateDictationResultIfTextChanged() + if (newSelStart == 0) clearCandidatesIfFieldEmptied() + updateAutoShift() + } + + /** + * The host cleared the whole field without restarting input — a chat + * app's send button does exactly this. Associations (and an English word + * being typed) refer to text that is gone, so they must not linger in the + * candidate row; the stroke panel also drops a half-typed code and its + * code row. A pinyin encoding still being composed is left alone. + */ + private fun clearCandidatesIfFieldEmptied() { + // Typing goes to the draft box while it is open; the host field being + // empty says nothing about what the candidate row refers to. + if (draftEdit != null) return + val connection = currentInputConnection ?: return + val before = connection.getTextBeforeCursor(1, 0) ?: return + val after = connection.getTextAfterCursor(1, 0) ?: return + if (before.isNotEmpty() || after.isNotEmpty()) return + strokeController.clearAfterFieldEmptied() + rimePinyinController.resetAssociationContext() + if (inputMode != InputMode.ENGLISH) return + if (latinInputMode == LatinInputMode.PINYIN) { + if (rimePinyinController.isEmpty()) renderPinyinAssociations(emptyList()) + } else if (englishComposingWord.isNotEmpty()) { + englishComposingWord.clear() + updateEnglishCandidates() + } } internal fun toggleDictation(initialCloud: Boolean = false) { @@ -3984,6 +4208,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt /** Composition and dictation-result state belongs to one text target; drop it when the target flips between the host field and the draft. */ private fun resetEditingStateForTargetChange() { + clearEnglishAutoText() englishComposingWord.clear() englishCandidateQueryEpoch++ rimePinyinController.clear() @@ -4005,7 +4230,12 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt draftMessage = null draftMessageIsError = false draftConnection = null - draftEdit = android.widget.EditText(this).apply { + draftEdit = object : android.widget.EditText(this) { + override fun onSelectionChanged(selStart: Int, selEnd: Int) { + super.onSelectionChanged(selStart, selEnd) + invalidateEnglishAutoTextSelection(selStart, selEnd) + } + }.apply { inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI @@ -5757,9 +5987,15 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt */ private class ShiftKeyView( context: android.content.Context, - private val state: ShiftState, + initialState: ShiftState, private val darkTheme: Boolean, ) : TextView(context) { + var state: ShiftState = initialState + set(value) { + if (field == value) return + field = value + invalidate() + } private val mutedColor = if (darkTheme) Color.rgb(190, 190, 190) else Color.rgb(120, 120, 125) private val activeColor = if (darkTheme) Color.rgb(245, 245, 245) else Color.rgb(30, 30, 34) private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { @@ -6225,6 +6461,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // keystroke; comfortably more than can fit on screen at once so // scrolling actually reveals more real options. private const val ENGLISH_CANDIDATE_QUERY_LIMIT = 10 + private const val DOUBLE_SPACE_WINDOW_MS = 500L // buildClipboardView()'s "recent clips" strip. Originally 92dp for // 4 equal-weight rows + 5 divider lines (dp(1) each), raised ~1.2x // to 109dp per user request: (92-5)/4=21.75dp/row * 1.2 = 26.1, diff --git a/openless-all/app/android/kotlin/RimePinyinController.kt b/openless-all/app/android/kotlin/RimePinyinController.kt index 188831dc1..3d40f023d 100644 --- a/openless-all/app/android/kotlin/RimePinyinController.kt +++ b/openless-all/app/android/kotlin/RimePinyinController.kt @@ -27,6 +27,7 @@ internal class RimePinyinController(context: Context) { private var pendingLiteral = "" private var commit: (String) -> Boolean = { false } private val confirmedText = StringBuilder() + val associationLearning = TypedContinuationHistory() private var associationEpoch = 0L fun bindCommit(handler: (String) -> Boolean) { commit = handler } fun currentEncoding(): String = encoding @@ -180,7 +181,9 @@ internal class RimePinyinController(context: Context) { if (confirmedText.length > StrokeInputController.MAX_ASSOCIATION_CONTEXT) confirmedText.delete(0, confirmedText.length - StrokeInputController.MAX_ASSOCIATION_CONTEXT) } - fun resetAssociationContext() { confirmedText.clear(); associationEpoch++ } + /** What was committed just before the next commit, for StrokePhraseRepository.recordTypedContinuation(). */ + fun associationContext(): String = confirmedText.toString() + fun resetAssociationContext() { confirmedText.clear(); associationLearning.reset(); associationEpoch++ } fun queryAssociations(repository: StrokePhraseRepository, update: (List) -> Unit) { val text = confirmedText.toString() if (text.isEmpty()) { update(emptyList()); return } diff --git a/openless-all/app/android/kotlin/StrokeInput.kt b/openless-all/app/android/kotlin/StrokeInput.kt index 0946b16e3..5a5a5a3eb 100644 --- a/openless-all/app/android/kotlin/StrokeInput.kt +++ b/openless-all/app/android/kotlin/StrokeInput.kt @@ -27,8 +27,11 @@ internal class StrokeInputRepository(context: Context) { "六" to "n", "七" to "h", "八" to "p", "九" to "p", "零" to "n", ) + // Every list in here is in corpus-frequency order (see ensureLoaded()), + // so a query only walks it until it has enough matches. private val index = AtomicReference>>>(emptyMap()) private val frequency = AtomicReference>(emptyMap()) + private val codesByChar = AtomicReference>>(emptyMap()) private val loading = Any() // Personal per-code preference, learned from picking a non-top candidate. @@ -74,33 +77,44 @@ internal class StrokeInputRepository(context: Context) { fun searchAsync(pattern: String, callback: (List) -> Unit) { executor.execute { ensureLoaded() - val lookupKey = pattern.takeWhile { it != '*' }.take(PREFIX_INDEX_LENGTH) - val freq = frequency.get() - val result = if (pattern.isEmpty()) emptyList() else index.get() - .getOrDefault(lookupKey, emptyList()) - .asSequence() - .filter { (_, code) -> matches(pattern, code) } - .map { it.first } - .distinct() - .toList() - // Personal pick for this exact code always outranks the - // static corpus frequency — corpus counts span 1 to several - // million, so adding a personal score would never move the - // needle for common characters. Candidates with no personal - // score (0.0, the common case) fall through to the corpus - // order unchanged. - .sortedWith( - compareByDescending { userFrequency.score(pattern, it) } - .thenByDescending { freq[it] ?: 0L }, - ) - .take(MAX_CANDIDATES) + val startedAt = android.os.SystemClock.elapsedRealtime() + val result = if (pattern.isEmpty()) emptyList() else { + val freq = frequency.get() + val codes = codesByChar.get() + // Only characters still spelled by this code: the store + // outlives dictionary updates. + val picks = userFrequency.picksFor(pattern).filter { (char, _) -> + codes[char]?.any { strokeCodeMatches(pattern, it) } == true + } + rankStrokeCandidates( + index.get().getOrDefault(pattern.takeWhile { it != '*' }.take(PREFIX_INDEX_LENGTH), emptyList()), + pattern, + picks, + MAX_CANDIDATES, + ) { freq[it] ?: 0L } + } + android.util.Log.d( + "OpenLessStroke", + "query pattern=$pattern results=${result.size} elapsed=${android.os.SystemClock.elapsedRealtime() - startedAt}ms", + ) android.os.Handler(android.os.Looper.getMainLooper()).post { callback(result) } } } - /** Records that `candidate` was hand-picked over the top result for `pattern`, off the caller's thread. */ - fun recordPersonalPick(pattern: String, candidate: String) { - executor.execute { userFrequency.record(pattern, candidate) } + /** + * Records a pick of `candidate` for `pattern`, off the caller's thread — + * when it was hand-picked over the top result ([wasTop] false), or when + * it is on top only because of earlier picks. The latter keeps a learned + * character's score fresh: recording corrections alone let it decay and + * lose its place to the next one-off pick. Picking a corpus-top + * character that was never learned records nothing. + */ + fun recordPick(pattern: String, candidate: String, wasTop: Boolean) { + executor.execute { + if (shouldRecordStrokePick(wasTop, userFrequency.score(pattern, candidate) > 0.0)) { + userFrequency.record(pattern, candidate) + } + } } fun shutdown() = executor.shutdownNow() @@ -110,16 +124,23 @@ internal class StrokeInputRepository(context: Context) { synchronized(loading) { if (index.get().isNotEmpty()) return val startedAt = android.os.SystemClock.elapsedRealtime() - val entries = loadEntries(appContext) + val freq = loadFrequency(appContext) + // Sorted once here (stable, so equal counts keep file order) and + // bucketed in that order: every bucket comes out frequency-sorted + // without sorting each one, and no query ever sorts again. + val entries = loadEntries(appContext).sortedByDescending { freq[it.first] ?: 0L } val buckets = HashMap>>() + val codes = HashMap>() buckets[""] = entries.toMutableList() entries.forEach { entry -> val code = entry.second (1..minOf(PREFIX_INDEX_LENGTH, code.length)).forEach { length -> buckets.getOrPut(code.substring(0, length)) { ArrayList() }.add(entry) } + codes.getOrPut(entry.first) { ArrayList(1) }.add(code) } - frequency.set(loadFrequency(appContext)) + frequency.set(freq) + codesByChar.set(codes) index.set(buckets) android.util.Log.i( "OpenLessStroke", @@ -129,13 +150,47 @@ internal class StrokeInputRepository(context: Context) { } } - private fun matches(pattern: String, code: String): Boolean { - if (pattern.length > code.length) return false - return pattern.indices.all { index -> pattern[index] == '*' || pattern[index] == code[index] } - } - private companion object { const val PREFIX_INDEX_LENGTH = 4 const val MAX_CANDIDATES = 36 } } + +/** A stroke code matches a typed pattern when it starts with it, '*' standing for any one stroke. */ +internal fun strokeCodeMatches(pattern: String, code: String): Boolean { + if (pattern.length > code.length) return false + return pattern.indices.all { index -> pattern[index] == '*' || pattern[index] == code[index] } +} + +/** + * Candidates for [pattern]: the user's own picks for this exact code first + * (highest score first, corpus frequency breaking ties), then the remaining + * matches from [entries] — which must already be in corpus-frequency order — + * each character once, until [limit]. A personal pick always outranks the + * corpus: counts there span 1 to several million, so adding a personal score + * would never move a common character. + * + * Same order as sorting every match by (score, frequency), but it stops as + * soon as the row is full — the first stroke alone matches up to ~48k + * entries, and scoring and sorting all of them made that keystroke slow. + */ +internal fun rankStrokeCandidates( + entries: List>, + pattern: String, + picks: List>, + limit: Int, + frequencyOf: (String) -> Long, +): List { + val result = LinkedHashSet() + picks.filter { it.second > 0.0 } + .sortedWith(compareByDescending> { it.second }.thenByDescending { frequencyOf(it.first) }) + .forEach { if (result.size < limit) result += it.first } + for ((char, code) in entries) { + if (result.size >= limit) break + if (char !in result && strokeCodeMatches(pattern, code)) result += char + } + return result.toList() +} + +/** See StrokeInputRepository.recordPick(): a correction always counts, a top pick only once it was learned. */ +internal fun shouldRecordStrokePick(wasTop: Boolean, alreadyLearned: Boolean): Boolean = !wasTop || alreadyLearned diff --git a/openless-all/app/android/kotlin/StrokeInputController.kt b/openless-all/app/android/kotlin/StrokeInputController.kt index 25f7fb564..c64955068 100644 --- a/openless-all/app/android/kotlin/StrokeInputController.kt +++ b/openless-all/app/android/kotlin/StrokeInputController.kt @@ -41,6 +41,7 @@ internal class StrokeInputController(private val service: OpenLessImeService) { private val wordSegments = mutableListOf() private var lastStrokeCandidates: List = emptyList() private var confirmedText = "" + private val associationLearning = TypedContinuationHistory() private var phraseQueryEpoch = 0L private var strokePreview: TextView? = null private var clearStrokeButton: TextView? = null @@ -79,6 +80,7 @@ internal class StrokeInputController(private val service: OpenLessImeService) { /** Matches selectInputMode()'s own old inline reset exactly — mode-switch also resets the number/symbol sub-panel and the punctuation rail's page, which resetForNewInputSession() below does not. */ fun resetForModeSwitch() { + associationLearning.reset() strokeNumberMode = false numberSymbolMode = false symbolPageIndex = 0 @@ -91,17 +93,39 @@ internal class StrokeInputController(private val service: OpenLessImeService) { /** Matches onStartInput()'s own old inline reset exactly — a fresh input session also drops any in-progress word segments/candidates, but leaves the punctuation rail's page alone. */ fun resetForNewInputSession() { + associationLearning.reset() strokeNumberMode = false numberSymbolMode = false symbolPageIndex = 0 confirmedText = "" phraseQueryEpoch++ strokeCode = "" + strokeQueryEpoch++ wordSegments.clear() lastStrokeCandidates = emptyList() + // The panel isn't rebuilt for a new session, so the views still show the old field's code and candidates. + strokePreview?.text = "" + strokeCandidates?.removeAllViews() updateClearStrokeButtonVisibility() } + /** + * The host emptied the field (e.g. a chat app's send button): start over + * — no association context, no half-typed code or 分词 segments, and an + * empty code row and candidate row. The candidate overlay entries are + * shared with the English panel, so they're only touched while this + * panel is the one showing. + */ + fun clearAfterFieldEmptied() { + associationLearning.reset() + if (service.inputMode == OpenLessImeService.InputMode.STROKE) { + abandonComposition() + } else { + confirmedText = "" + phraseQueryEpoch++ + } + } + fun buildStrokeView(): View { // Matches the punctuation rail's own 0.16f width share below (body's // "0.16f/0.65f/0.19f" split) so a downward drag anywhere on the rail @@ -234,7 +258,7 @@ internal class StrokeInputController(private val service: OpenLessImeService) { background = service.roundedButton(service.tone(Color.rgb(45, 45, 45), Color.rgb(230, 230, 234)), service.dp(4)) } punctuationGroups[punctuationGroupIndex].forEachIndexed { index, mark -> - punctuation.addView(service.keyboardKey(mark, 1f, action = { service.currentInputConnection?.commitText(mark, 1) }).apply { + punctuation.addView(service.keyboardKey(mark, 1f, action = { commitDirect(mark) }).apply { textSize = 18f // The rail is one connected key surface; separators provide the only visual split. background = GradientDrawable().apply { setColor(Color.TRANSPARENT) } @@ -266,9 +290,9 @@ internal class StrokeInputController(private val service: OpenLessImeService) { background = service.roundedButton(service.tone(Color.rgb(112, 78, 92), Color.rgb(232, 205, 213)), service.dp(5)) } } else if (code == "voice") service.keyboardKey("0", 1f, action = { - service.currentInputConnection?.commitText(" ", 1) + commitDirect(" ") }, swipeUpAction = { - service.currentInputConnection?.commitText("0", 1) + commitDirect("0") }, swipePreview = "0", microphoneIcon = true, longPressDelayMs = 900L, longPressAction = { service.inputMode = OpenLessImeService.InputMode.VOICE service.saveInputMode(service.inputMode) @@ -286,10 +310,10 @@ internal class StrokeInputController(private val service: OpenLessImeService) { service.refreshInputView() } " " -> segmentStroke() - else -> if (code in listOf("h", "s", "p", "n", "z", "*")) appendStroke(code) else service.currentInputConnection?.commitText(code, 1) + else -> if (code in listOf("h", "s", "p", "n", "z", "*")) appendStroke(code) else commitDirect(code) } }, swipeUpAction = swipeDigit?.let { digit -> - { service.currentInputConnection?.commitText(digit, 1) } + { commitDirect(digit) } }, swipePreview = swipeDigit, strokeIconCode = code.takeIf { it in listOf("h", "s", "p", "n", "z") }, graphicCode = code.takeIf { @@ -313,7 +337,7 @@ internal class StrokeInputController(private val service: OpenLessImeService) { body.addView(grid, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 0.65f)) val actions = LinearLayout(service).apply { orientation = LinearLayout.VERTICAL; gravity = Gravity.CENTER } - listOf("←" to { deleteStroke() }, "↵" to { service.sendEnterKey() }, "清除" to { clearStrokes() }, "123" to { + listOf("←" to { deleteStroke() }, "↵" to { abandonComposition(); service.sendEnterKey() }, "清除" to { clearStrokes() }, "123" to { strokeNumberMode = true numberSymbolMode = false symbolPageIndex = 0 @@ -422,7 +446,7 @@ internal class StrokeInputController(private val service: OpenLessImeService) { symbolPageIndex = 0 service.refreshInputView() }) - else -> ({ service.currentInputConnection?.commitText(label, 1) }) + else -> ({ commitDirect(label) }) } row.addView(service.keyboardKey(label, 1f, action, repeatOnLongPress = rowIndex == 0 && isAction, repeatAction = action).apply { textSize = if (isAction) 15f else 20f @@ -563,10 +587,10 @@ internal class StrokeInputController(private val service: OpenLessImeService) { /** Commits the current character together with any segments already marked via 分词. */ private fun commitStrokeCandidate(candidate: String) { val code = strokeCode - val correction = candidate != lastStrokeCandidates.firstOrNull() - if (commitWord((wordSegments + candidate).joinToString("")) && correction && + val wasTop = candidate == lastStrokeCandidates.firstOrNull() + if (commitWord((wordSegments + candidate).joinToString("")) && service.personalizedLearningAllowed() && OpenLessAndroidPreferences.strokeUsageEnabled(service) && code.isNotEmpty()) { - strokeRepository.recordPersonalPick(code, candidate) + strokeRepository.recordPick(code, candidate, wasTop) } } @@ -576,7 +600,9 @@ internal class StrokeInputController(private val service: OpenLessImeService) { val contextBeforeCommit = confirmedText.takeLast(MAX_ASSOCIATION_CONTEXT) if (!connection.commitText(service.outputScript(word), 1)) return false if (service.personalizedLearningAllowed() && OpenLessAndroidPreferences.strokeUsageEnabled(service)) { - phraseRepository.recordUsage(contextBeforeCommit, word) + phraseRepository.recordTypedContinuation(associationLearning.advance(contextBeforeCommit, word)) + } else { + associationLearning.reset() } if (service.personalizedLearningAllowed()) confirmedText = (confirmedText + word).takeLast(MAX_ASSOCIATION_CONTEXT) clearStrokes() @@ -614,12 +640,14 @@ internal class StrokeInputController(private val service: OpenLessImeService) { if (service.personalizedLearningAllowed() && OpenLessAndroidPreferences.strokeUsageEnabled(service)) { phraseRepository.recordUsage(matchedContext, displayText) } + associationLearning.reset() confirmedText = (confirmedText + suffix).takeLast(MAX_ASSOCIATION_CONTEXT) clearStrokes() refreshAssociations() } private fun deleteStroke() { + associationLearning.reset() if (strokeCode.isNotEmpty()) { strokeCode = strokeCode.dropLast(1) strokeQueryEpoch++ @@ -656,6 +684,26 @@ internal class StrokeInputController(private val service: OpenLessImeService) { updateClearStrokeButtonVisibility() } + /** + * Something other than a candidate is about to reach the field (a mark, + * digit, space, Return): drop the half-typed code, its segments and the + * association context with them — what's on screen next no longer + * follows the last committed word, and a stale code row would sit there + * meaning nothing. Same as pinyin abandoning its encoding on punctuation. + */ + private fun abandonComposition() { + associationLearning.reset() + clearStrokes() + confirmedText = "" + phraseQueryEpoch++ + service.candidateOverlayEntries = emptyList() + } + + private fun commitDirect(text: String) { + abandonComposition() + service.currentInputConnection?.commitText(text, 1) + } + // internal, not private: RimePinyinController's own association context // buffer reuses the same cap for consistency (see phraseRepository's own // doc comment on why the two share one StrokePhraseRepository instance). diff --git a/openless-all/app/android/kotlin/StrokePhraseRepository.kt b/openless-all/app/android/kotlin/StrokePhraseRepository.kt index 7a90611f9..41a31d2f1 100644 --- a/openless-all/app/android/kotlin/StrokePhraseRepository.kt +++ b/openless-all/app/android/kotlin/StrokePhraseRepository.kt @@ -47,9 +47,9 @@ internal class StrokePhraseRepository(context: Context) { executor.execute { val startedAt = android.os.SystemClock.elapsedRealtime() ensureLoaded() - val result = findLongestSuffix(prefix).sortedWith(compareByDescending { - it.baseWeight + userFrequency.score(it.matchedPrefix.ifEmpty { prefix }, it.text).toInt() - }) + val result = rankAssociations(findLongestSuffix(prefix)) { + userFrequency.score(it.matchedPrefix.ifEmpty { prefix }, it.text) + } // Temporary-diagnostic-grade, not removed after: cheap (one // log line per commit) and directly answers "is this slow // because of the cold trie build, the query itself, or is it @@ -70,6 +70,17 @@ internal class StrokePhraseRepository(context: Context) { executor.execute { userFrequency.record(context, candidate) } } + /** Records completed associations across one or more successful editor commits. */ + fun recordTypedContinuation(continuations: List) { + if (continuations.isEmpty()) return + executor.execute { + ensureLoaded() + continuations.mapNotNull { typedContinuation(it.contextBefore, it.word, ::find) } + .distinctBy { it.matchedPrefix to it.text } + .forEach { userFrequency.record(it.matchedPrefix, it.text) } + } + } + fun shutdown() = executor.shutdownNow() private fun ensureLoaded() { @@ -151,13 +162,7 @@ internal class StrokePhraseRepository(context: Context) { return result } - private fun findLongestSuffix(prefix: String): List { - for (length in prefix.length downTo 1) { - val result = find(prefix.takeLast(length)) - if (result.isNotEmpty()) return result - } - return emptyList() - } + private fun findLongestSuffix(prefix: String): List = longestSuffixExtensions(prefix, ::find) private companion object { const val NODE_TOP_N = 12 @@ -170,3 +175,76 @@ internal class StrokePhraseRepository(context: Context) { private val userFrequency = StrokeUserFrequency(context) } + +/** + * Association candidates for [context]: the phrases under its longest + * trailing substring that [lookup] knows, minus the phrase that substring + * already completes. Every phrase sits in the trie node of its own last + * character too, so once a whole phrase is on screen the lookup returns it + * again — a candidate with nothing left to commit, which tapping can't + * clear. When that phrase is all there is, the result is empty rather than + * a retry with a shorter substring: the row should clear, not jump to + * phrases that only share the last character or two. + */ +internal fun longestSuffixExtensions( + context: String, + lookup: (String) -> List, +): List { + for (length in context.length downTo 1) { + val result = lookup(context.takeLast(length)) + if (result.isNotEmpty()) return result.filter { it.text != it.matchedPrefix } + } + return emptyList() +} + +/** + * Order for the association row: phrases the user has picked before (a + * positive [userScore]) first, by that score, then the rest by corpus + * weight. Corpus weights run from 1 to several hundred thousand while a + * user score stays in the hundreds, so adding the two — the old order — + * let a habit move only rare phrases; same reasoning as the stroke + * candidate order in StrokeInputRepository.searchAsync(). + */ +internal fun rankAssociations( + candidates: List, + userScore: (StrokePhraseRepository.Candidate) -> Double, +): List = candidates + .map { it to userScore(it) } + .sortedWith( + compareByDescending> { it.second } + .thenByDescending { it.first.baseWeight }, + ) + .map { it.first } + +/** The association offered for [contextBefore] that [word], typed right after it, completes exactly — or null. */ +internal fun typedContinuation( + contextBefore: String, + word: String, + lookup: (String) -> List, +): StrokePhraseRepository.Candidate? = + longestSuffixExtensions(contextBefore, lookup).firstOrNull { it.text == it.matchedPrefix + word } + +internal data class TypedContinuation(val contextBefore: String, val word: String) + +/** Main-thread commit boundaries; background lookups receive immutable snapshots. */ +internal class TypedContinuationHistory { + private var expectedContext: String? = null + private var continuations = emptyList() + + fun reset() { + expectedContext = null + continuations = emptyList() + } + + fun advance(contextBefore: String, word: String): List { + if (word.isEmpty()) return emptyList() + if (contextBefore != expectedContext) reset() + val starts = continuations + if (contextBefore.isNotEmpty()) { + listOf(TypedContinuation(contextBefore, "")) + } else emptyList() + continuations = starts.map { it.copy(word = it.word + word) } + .filter { it.word.length < StrokeInputController.MAX_ASSOCIATION_CONTEXT } + expectedContext = (contextBefore + word).takeLast(StrokeInputController.MAX_ASSOCIATION_CONTEXT) + return continuations + } +} diff --git a/openless-all/app/android/kotlin/StrokeUserFrequency.kt b/openless-all/app/android/kotlin/StrokeUserFrequency.kt index 1cd3bef38..971144791 100644 --- a/openless-all/app/android/kotlin/StrokeUserFrequency.kt +++ b/openless-all/app/android/kotlin/StrokeUserFrequency.kt @@ -11,6 +11,9 @@ import kotlin.math.ln */ internal class StrokeUserFrequency(context: Context) { private val preferences = context.applicationContext.getSharedPreferences(STORE, Context.MODE_PRIVATE) + private val cache = FrequencyStampCache.of(context, STORE) { key -> + key.indexOf(SEPARATOR).takeIf { it >= 0 }?.let { key.substring(0, it) } + } // Tracks entry count without paying preferences.all's full-map-copy cost // on every record() — see trimIfNeeded(). -1 means "not yet known", @@ -19,23 +22,27 @@ internal class StrokeUserFrequency(context: Context) { private var approxSize = -1 fun score(context: String, candidate: String, now: Long = System.currentTimeMillis()): Double { - val value = preferences.getString(key(context, candidate), null) ?: return 0.0 - val parts = value.split(',') - val count = parts.getOrNull(0)?.toDoubleOrNull() ?: return 0.0 - val last = parts.getOrNull(1)?.toLongOrNull() ?: return 0.0 - val ageDays = ((now - last).coerceAtLeast(0L)).toDouble() / MILLIS_PER_DAY - return ln(1.0 + count) * COUNT_WEIGHT + exp(-ageDays / RECENCY_HALF_LIFE_DAYS) * RECENCY_WEIGHT + val stamp = cache.get(key(context, candidate)) ?: return 0.0 + val ageDays = ((now - stamp.lastUsedMs).coerceAtLeast(0L)).toDouble() / MILLIS_PER_DAY + return ln(1.0 + stamp.count) * COUNT_WEIGHT + exp(-ageDays / RECENCY_HALF_LIFE_DAYS) * RECENCY_WEIGHT } + /** Every candidate recorded for exactly [context], with its score — read from memory, no store scan. */ + fun picksFor(context: String, now: Long = System.currentTimeMillis()): List> = + cache.keysInGroup(context).map { key -> + val candidate = key.substring(context.length + SEPARATOR.length) + candidate to score(context, candidate, now) + } + fun record(context: String, candidate: String) { if (candidate.isBlank()) return OpenLessMigrationState.learn { val entryKey = key(context, candidate) val isNewEntry = !preferences.contains(entryKey) - val old = preferences.getString(entryKey, null)?.split(',') - val count = (old?.getOrNull(0)?.toIntOrNull() ?: 0) + 1 + val count = (cache.get(entryKey)?.count ?: 0) + 1 val now = System.currentTimeMillis() preferences.edit().putString(entryKey, "$count,$now").apply() + cache.put(entryKey, FrequencyStampCache.Stamp(count, now)) // Re-recording an existing entry (by far the common case — the same // handful of characters/phrases typed over and over) never touches // preferences.all at all now. Only a genuinely new key can push the @@ -51,6 +58,7 @@ internal class StrokeUserFrequency(context: Context) { fun clear() { check(OpenLessMigrationState.settings { preferences.edit().clear().apply() + cache.invalidate() approxSize = 0 }) { "migration_android_busy" } } @@ -70,6 +78,7 @@ internal class StrokeUserFrequency(context: Context) { fun importAll(entries: Map) { check(OpenLessMigrationState.settings { check(preferences.edit().clear().apply { entries.forEach { (key, value) -> putString(key, value) } }.commit()) { "Stroke learning save failed" } + cache.invalidate() approxSize = entries.size }) { "migration_android_busy" } } diff --git a/openless-all/app/android/kotlin/androidTest/ImeAutoTextIntegrationTest.kt b/openless-all/app/android/kotlin/androidTest/ImeAutoTextIntegrationTest.kt new file mode 100644 index 000000000..e4d57a2d7 --- /dev/null +++ b/openless-all/app/android/kotlin/androidTest/ImeAutoTextIntegrationTest.kt @@ -0,0 +1,217 @@ +package com.openless.app + +import android.content.ContextWrapper +import android.text.Editable +import android.view.inputmethod.BaseInputConnection +import android.view.inputmethod.ExtractedText +import android.view.inputmethod.ExtractedTextRequest +import android.view.inputmethod.InputConnection +import android.view.inputmethod.InputConnectionWrapper +import android.widget.EditText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +class ImeAutoTextIntegrationTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + + @Test fun candidateSpaceDoesNotFollowCaretMoves() = instrumentation.runOnMainSync { + val (service, edit) = editor("old new ") + armCandidateSpace(service) + edit.setSelection(4) + service.onUpdateSelection(8, 8, 4, 4, -1, -1) + call(service, "commitEnglishChar", ",") + val changedUnrelatedSpace = edit.text.toString() == "old, new " + probe("P1", "H1", changedUnrelatedSpace) + assertFalse(changedUnrelatedSpace) + assertEquals("old ,new ", edit.text.toString()) + } + + @Test fun doubleSpaceDoesNotFollowCaretMoves() = instrumentation.runOnMainSync { + val (service, edit) = editor("one two ") + edit.setSelection(3) + call(service, "commitEnglishSpace") + edit.setSelection(edit.length()) + service.onUpdateSelection(4, 4, 9, 9, -1, -1) + call(service, "commitEnglishSpace") + val changedUnrelatedSpace = edit.text.toString().endsWith("two. ") + probe("P2", "H2", changedUnrelatedSpace) + assertFalse(changedUnrelatedSpace) + assertEquals("one two ", edit.text.toString()) + } + + @Test fun splitAssociationLearningRetainsTheOriginalPrefix() { + val lookup: (String) -> List = { prefix -> + listOf("天气", "天气预报").filter { it.startsWith(prefix) } + .map { StrokePhraseRepository.Candidate(it, 1, prefix) } + } + val history = TypedContinuationHistory() + assertEquals(emptyList(), history.advance("天气", "预").mapNotNull { + typedContinuation(it.contextBefore, it.word, lookup) + }) + val completed = history.advance("天气预", "报").mapNotNull { + typedContinuation(it.contextBefore, it.word, lookup) + } + val missesOriginalPrefix = completed.none { it.matchedPrefix == "天气" } + probe("P3", "H3", missesOriginalPrefix) + assertFalse(missesOriginalPrefix) + assertEquals(listOf("天气", "天气预"), completed.map { it.matchedPrefix }) + } + + @Test fun candidatePunctuationAndOwnSelectionUpdatesStillWork() = instrumentation.runOnMainSync { + val (service, edit) = editor("word ") + armCandidateSpace(service) + service.onUpdateSelection(4, 4, 5, 5, -1, -1) + call(service, "commitEnglishChar", "?") + service.onUpdateSelection(5, 5, 6, 6, -1, -1) + call(service, "commitEnglishChar", "!") + assertEquals("word?! ", edit.text.toString()) + } + + @Test fun consecutiveSpacesStillMakeOneFullStop() = instrumentation.runOnMainSync { + val (service, edit) = editor("word") + call(service, "commitEnglishSpace") + service.onUpdateSelection(4, 4, 5, 5, -1, -1) + call(service, "commitEnglishSpace") + assertEquals("word. ", edit.text.toString()) + call(service, "commitEnglishSpace") + assertEquals("word. ", edit.text.toString()) + } + + @Test fun selectedTextAndUnknownPositionsDoNotDeleteNeighboringSpaces() = instrumentation.runOnMainSync { + val (service, edit) = editor("a word ") + armCandidateSpace(service) + edit.setSelection(2, 6) + call(service, "commitEnglishChar", ",") + assertEquals("a , ", edit.text.toString()) + val (unknownService, unknownEdit) = editor("word ", reportsPosition = false) + armCandidateSpace(unknownService) + call(unknownService, "commitEnglishChar", ",") + assertEquals("word ,", unknownEdit.text.toString()) + call(unknownService, "commitEnglishSpace") + call(unknownService, "commitEnglishSpace") + assertEquals("word , ", unknownEdit.text.toString()) + } + + @Test fun replacingTheInputConnectionDropsTheOldCandidateSpace() = instrumentation.runOnMainSync { + val (service, edit) = editor("word ") + armCandidateSpace(service) + setField(service, "draftConnection", connection(edit, true)) + call(service, "commitEnglishChar", ",") + assertEquals("word ,", edit.text.toString()) + } + + @Test fun pinyinEditorBackspaceDropsDeletedAssociationContext() { + lateinit var controller: RimePinyinController + var controllerCreated = false + lateinit var edit: EditText + val deleted = CountDownLatch(1) + instrumentation.targetContext.getSharedPreferences("openless_ime_ui", android.content.Context.MODE_PRIVATE) + .edit().putBoolean("public_dictionary_updates", false).commit() + OpenLessRime.initialize(instrumentation.targetContext) + try { + instrumentation.runOnMainSync { + val (service, field) = editor("天气预") + edit = field + setField(service, "latinInputMode", OpenLessImeService.LatinInputMode.PINYIN) + controller = OpenLessImeService::class.java.getDeclaredMethod("getRimePinyinController") + .apply { isAccessible = true }.invoke(service) as RimePinyinController + controllerCreated = true + controller.setLearningAllowed(true) + controller.recordCommittedText("天气") + controller.associationLearning.advance("天气", "预") + controller.recordCommittedText("预") + setField(service, "draftConnection", object : InputConnectionWrapper(connection(edit, true), false) { + override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { + val result = super.deleteSurroundingText(beforeLength, afterLength) + deleted.countDown() + return result + } + }) + call(service, "englishDeleteBackward") + } + assertTrue("Empty-encoding Backspace must reach the editor", deleted.await(20, TimeUnit.SECONDS)) + instrumentation.runOnMainSync { + assertEquals("天气", edit.text.toString()) + assertEquals("", controller.associationContext()) + val lookup: (String) -> List = { prefix -> + listOf("天气预报").filter { it.startsWith(prefix) } + .map { StrokePhraseRepository.Candidate(it, 1, prefix) } + } + val learned = controller.associationLearning.advance(controller.associationContext(), "报") + .mapNotNull { typedContinuation(it.contextBefore, it.word, lookup) } + assertEquals(emptyList(), learned) + } + } finally { + instrumentation.runOnMainSync { if (controllerCreated) controller.shutdown() } + } + } + + private fun editor(text: String, reportsPosition: Boolean = true): Pair { + val service = OpenLessImeService() + ContextWrapper::class.java.getDeclaredMethod("attachBaseContext", android.content.Context::class.java) + .apply { isAccessible = true }.invoke(service, instrumentation.targetContext) + service.inputMode = OpenLessImeService.InputMode.ENGLISH + val edit = EditText(instrumentation.targetContext).apply { + setText(text) + setSelection(length()) + } + setField(service, "draftEdit", edit) + setField(service, "draftConnection", connection(edit, reportsPosition)) + return service to edit + } + + private fun connection(edit: EditText, reportsPosition: Boolean): InputConnection = object : BaseInputConnection(edit, true) { + override fun getEditable(): Editable = edit.text + override fun getExtractedText(request: ExtractedTextRequest?, flags: Int): ExtractedText? { + if (!reportsPosition) return null + return ExtractedText().apply { + text = edit.text.toString() + startOffset = 0 + selectionStart = edit.selectionStart + selectionEnd = edit.selectionEnd + } + } + } + + private fun armCandidateSpace(service: OpenLessImeService) { + val anchor = OpenLessImeService::class.java.getDeclaredMethod("englishEditAnchor", InputConnection::class.java) + .apply { isAccessible = true }.invoke(service, service.currentInputConnection) + setField(service, "englishAutoSpace", anchor) + } + + private fun setField(service: OpenLessImeService, name: String, value: Any?) { + OpenLessImeService::class.java.getDeclaredField(name).apply { isAccessible = true }.set(service, value) + } + + private fun call(service: OpenLessImeService, name: String, vararg args: String) { + OpenLessImeService::class.java.getDeclaredMethod(name, *args.map { String::class.java }.toTypedArray()) + .apply { isAccessible = true }.invoke(service, *args) + } + + private fun probe(id: String, hypothesis: String, observed: Boolean) { + val record = JSONObject().apply { + put("schema", "diagnosing-bugs.v1") + put("session_id", "a5809584") + put("run_id", "ime1230-fixed") + put("probe_id", id) + put("hypothesis_ids", org.json.JSONArray(listOf(hypothesis))) + put("event", "regression_boundary") + put("outcome", if (observed) "bug_observed" else "bug_absent") + put("safe", JSONObject().put("matched", observed)) + } + android.util.Log.i("Ime1230Evidence", record.toString()) + synchronized(ImeAutoTextIntegrationTest::class.java) { + java.io.File(instrumentation.targetContext.filesDir, "ime1230-a5809584.ndjson") + .appendText("$record\n") + } + } +} diff --git a/openless-all/app/android/kotlin/test/EnglishAutoTextTest.kt b/openless-all/app/android/kotlin/test/EnglishAutoTextTest.kt new file mode 100644 index 000000000..2240c0de2 --- /dev/null +++ b/openless-all/app/android/kotlin/test/EnglishAutoTextTest.kt @@ -0,0 +1,63 @@ +package com.openless.app + +import android.text.InputType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class EnglishAutoTextTest { + @Test fun sentenceStartsAtTheFieldLineOrAfterAFinishedSentence() { + assertTrue(EnglishAutoText.isSentenceStart("")) + assertTrue(EnglishAutoText.isSentenceStart(" ")) + assertTrue(EnglishAutoText.isSentenceStart("Hello there\n")) + assertTrue(EnglishAutoText.isSentenceStart("Done. ")) + assertTrue(EnglishAutoText.isSentenceStart("Really? ")) + assertTrue(EnglishAutoText.isSentenceStart("Wow! ")) + } + + @Test fun midSentenceIsNotASentenceStart() { + assertFalse(EnglishAutoText.isSentenceStart("Hello")) + assertFalse(EnglishAutoText.isSentenceStart("Hello ")) + assertFalse(EnglishAutoText.isSentenceStart("one, ")) + // The mark must be followed by whitespace: "3." or "e.g." mid-word isn't an end yet. + assertFalse(EnglishAutoText.isSentenceStart("Version 3.")) + } + + @Test fun capitalsOnlyInOrdinaryTextFields() { + val text = InputType.TYPE_CLASS_TEXT + assertTrue(EnglishAutoText.allowsAutoCapitals(text)) + assertTrue(EnglishAutoText.allowsAutoCapitals(text or InputType.TYPE_TEXT_FLAG_MULTI_LINE)) + assertTrue(EnglishAutoText.allowsAutoCapitals(text or InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE)) + assertFalse(EnglishAutoText.allowsAutoCapitals(text or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS)) + assertFalse(EnglishAutoText.allowsAutoCapitals(text or InputType.TYPE_TEXT_VARIATION_URI)) + assertFalse(EnglishAutoText.allowsAutoCapitals(text or InputType.TYPE_TEXT_VARIATION_PASSWORD)) + assertFalse(EnglishAutoText.allowsAutoCapitals(text or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD)) + assertFalse(EnglishAutoText.allowsAutoCapitals(InputType.TYPE_CLASS_NUMBER)) + } + + @Test fun candidatesKeepTheTypedCapitalization() { + assertEquals("hello", EnglishAutoText.candidateText("hello", "", capsLock = false)) + assertEquals("hello", EnglishAutoText.candidateText("hello", "he", capsLock = false)) + assertEquals("Hello", EnglishAutoText.candidateText("hello", "H", capsLock = false)) + assertEquals("Hello", EnglishAutoText.candidateText("hello", "He", capsLock = false)) + assertEquals("HELLO", EnglishAutoText.candidateText("hello", "HE", capsLock = false)) + assertEquals("HELLO", EnglishAutoText.candidateText("hello", "H", capsLock = true)) + assertEquals("HELLO", EnglishAutoText.candidateText("hello", "", capsLock = true)) + } + + @Test fun doubleSpaceFullStopOnlyRightAfterAWord() { + assertTrue(EnglishAutoText.doubleSpaceMakesFullStop("hello ")) + assertTrue(EnglishAutoText.doubleSpaceMakesFullStop("at 5 ")) + assertFalse(EnglishAutoText.doubleSpaceMakesFullStop(" ")) + assertFalse(EnglishAutoText.doubleSpaceMakesFullStop("hello")) + assertFalse(EnglishAutoText.doubleSpaceMakesFullStop("hello. ")) + assertFalse(EnglishAutoText.doubleSpaceMakesFullStop("hello ")) + assertFalse(EnglishAutoText.doubleSpaceMakesFullStop("hello\n ")) + } + + @Test fun onlySentencePunctuationSwallowsTheAutoSpace() { + listOf(".", ",", "!", "?", ";", ":").forEach { assertTrue(it, EnglishAutoText.swallowsAutoSpace(it)) } + listOf("(", "'", "-", "@", "a", "1").forEach { assertFalse(it, EnglishAutoText.swallowsAutoSpace(it)) } + } +} diff --git a/openless-all/app/android/kotlin/test/FrequencyStampCacheTest.kt b/openless-all/app/android/kotlin/test/FrequencyStampCacheTest.kt new file mode 100644 index 000000000..0f6f0cae4 --- /dev/null +++ b/openless-all/app/android/kotlin/test/FrequencyStampCacheTest.kt @@ -0,0 +1,81 @@ +package com.openless.app + +import android.content.SharedPreferences +import java.lang.reflect.Proxy +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.FutureTask +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class FrequencyStampCacheTest { + @Test fun aPreferenceRefreshCannotOverwriteANewerWrite() { + val cache = afterConcurrentRefresh { values, cache -> + values["word"] = "2,2000" + cache.put("word", FrequencyStampCache.Stamp(2, 2000)) + } + assertEquals(FrequencyStampCache.Stamp(2, 2000), cache.get("word")) + } + + @Test fun aPreferenceRefreshCannotRestoreAClearedEntry() { + val cache = afterConcurrentRefresh { values, cache -> + values.clear() + cache.invalidate() + } + assertNull(cache.get("word")) + } + + private fun afterConcurrentRefresh( + change: (MutableMap, FrequencyStampCache) -> Unit, + ): FrequencyStampCache { + val values = ConcurrentHashMap().apply { put("word", "1,1000") } + val snapshotRead = CountDownLatch(1) + val resumeRefresh = CountDownLatch(1) + var listener: SharedPreferences.OnSharedPreferenceChangeListener? = null + val preferences = Proxy.newProxyInstance( + SharedPreferences::class.java.classLoader, + arrayOf(SharedPreferences::class.java), + ) { _, method, args -> + when (method.name) { + "getAll" -> HashMap(values) + "getString" -> { + val snapshot = values[args!![0] as String] + snapshotRead.countDown() + check(resumeRefresh.await(5, TimeUnit.SECONDS)) + snapshot + } + "registerOnSharedPreferenceChangeListener" -> { + listener = args!![0] as SharedPreferences.OnSharedPreferenceChangeListener + null + } + else -> error(method.name) + } + } as SharedPreferences + val constructor = FrequencyStampCache::class.java.declaredConstructors + .single { it.parameterCount == 2 }.apply { isAccessible = true } + val cache = constructor.newInstance(preferences, null) as FrequencyStampCache + assertEquals(FrequencyStampCache.Stamp(1, 1000), cache.get("word")) + val refresh = FutureTask { listener!!.onSharedPreferenceChanged(preferences, "word") } + val write = FutureTask { change(values, cache) } + val writer = Thread(write) + try { + Thread(refresh).start() + assertTrue(snapshotRead.await(5, TimeUnit.SECONDS)) + writer.start() + // The old implementation finishes the write; the fixed one waits on the cache monitor. + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (!write.isDone && writer.state != Thread.State.BLOCKED && System.nanoTime() < deadline) { + Thread.yield() + } + assertTrue("Writer must finish or wait for the refresh", write.isDone || writer.state == Thread.State.BLOCKED) + } finally { + resumeRefresh.countDown() + } + refresh.get(5, TimeUnit.SECONDS) + write.get(5, TimeUnit.SECONDS) + return cache + } +} diff --git a/openless-all/app/android/kotlin/test/InputLearningRulesTest.kt b/openless-all/app/android/kotlin/test/InputLearningRulesTest.kt new file mode 100644 index 000000000..07cf276e4 --- /dev/null +++ b/openless-all/app/android/kotlin/test/InputLearningRulesTest.kt @@ -0,0 +1,70 @@ +package com.openless.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class InputLearningRulesTest { + /** The old query: every match, scored and fully sorted. */ + private fun naiveStrokeRank( + fileOrder: List>, + pattern: String, + scores: Map, + freq: Map, + limit: Int, + ): List = fileOrder + .filter { (_, code) -> strokeCodeMatches(pattern, code) } + .map { it.first } + .distinct() + .sortedWith(compareByDescending { scores[it] ?: 0.0 }.thenByDescending { freq[it] ?: 0L }) + .take(limit) + + @Test fun strokeRankingMatchesTheOldFullSortOnRandomData() { + val random = java.util.Random(42) + val chars = (0 until 60).map { "字$it" } + val freq = chars.associateWith { (random.nextInt(5) * 100).toLong() } // plenty of ties + val fileOrder = (0 until 400).map { + chars[random.nextInt(chars.size)] to (1..(1 + random.nextInt(5))).map { "hspnz"[random.nextInt(5)] }.joinToString("") + } + val sorted = fileOrder.sortedByDescending { freq[it.first] ?: 0L } + listOf("h", "s", "hs", "p*", "*z", "hsp", "nn", "zzzzzz").forEach { pattern -> + val matching = fileOrder.filter { strokeCodeMatches(pattern, it.second) }.map { it.first }.distinct() + // Distinct scores: real ones carry a millisecond timestamp, so two picks never tie. + val scores = matching.shuffled(random).take(3).withIndex().associate { (i, char) -> char to i * 50.0 + 1.0 } + listOf(1, 5, 36).forEach { limit -> + assertEquals( + "pattern=$pattern limit=$limit", + naiveStrokeRank(fileOrder, pattern, scores, freq, limit), + rankStrokeCandidates(sorted, pattern, scores.toList(), limit) { freq[it] ?: 0L }, + ) + } + } + } + + @Test fun aPickedCharacterLeadsEvenWithTheLowestFrequency() { + val entries = listOf("的" to "p", "了" to "z", "人" to "p", "仁" to "pp") + val freq = mapOf("的" to 1000L, "人" to 500L, "仁" to 1L) + assertEquals(listOf("仁", "的", "人"), rankStrokeCandidates(entries, "p", listOf("仁" to 79.0), 36) { freq[it] ?: 0L }) + assertEquals(listOf("仁"), rankStrokeCandidates(entries, "p", listOf("仁" to 79.0), 1) { freq[it] ?: 0L }) + assertEquals(listOf("的", "人", "仁"), rankStrokeCandidates(entries, "p", emptyList(), 36) { freq[it] ?: 0L }) + } + + @Test fun storedUsageParsesOnlyWellFormedValues() { + assertEquals(FrequencyStampCache.Stamp(3, 1700000000000L), FrequencyStampCache.parseStamp("3,1700000000000")) + assertNull(FrequencyStampCache.parseStamp(null)) + assertNull(FrequencyStampCache.parseStamp("")) + assertNull(FrequencyStampCache.parseStamp("3")) + assertNull(FrequencyStampCache.parseStamp(",17")) + assertNull(FrequencyStampCache.parseStamp("x,17")) + assertNull(FrequencyStampCache.parseStamp("3,abc")) + } + + @Test fun strokePickIsRecordedWhenCorrectedOrAlreadyLearned() { + assertTrue(shouldRecordStrokePick(wasTop = false, alreadyLearned = false)) + assertTrue(shouldRecordStrokePick(wasTop = false, alreadyLearned = true)) + assertTrue(shouldRecordStrokePick(wasTop = true, alreadyLearned = true)) + assertFalse(shouldRecordStrokePick(wasTop = true, alreadyLearned = false)) + } +} diff --git a/openless-all/app/android/kotlin/test/StrokePhraseSuffixTest.kt b/openless-all/app/android/kotlin/test/StrokePhraseSuffixTest.kt new file mode 100644 index 000000000..8fc5e9dcd --- /dev/null +++ b/openless-all/app/android/kotlin/test/StrokePhraseSuffixTest.kt @@ -0,0 +1,121 @@ +package com.openless.app + +import com.openless.app.StrokePhraseRepository.Candidate +import org.junit.Assert.assertEquals +import org.junit.Test + +class StrokePhraseSuffixTest { + /** A trie stand-in: every phrase is listed under each of its prefixes, itself included. */ + private fun lookupOf(vararg phrases: String): (String) -> List = { prefix -> + phrases.filter { it.startsWith(prefix) }.map { Candidate(it, 1, prefix) } + } + + private fun texts(context: String, lookup: (String) -> List) = + longestSuffixExtensions(context, lookup).map { it.text } + + @Test fun phrasesContinuingTheLongestSuffixAreOffered() { + assertEquals(listOf("天气好", "天气预报"), texts("今天天气", lookupOf("天气好", "天气预报", "气球"))) + } + + @Test fun thePhraseTheSuffixAlreadyCompletesIsDropped() { + assertEquals(listOf("天气预报"), texts("今天天气", lookupOf("天气", "天气预报"))) + } + + /** The reported bug: after the last association was picked, it stayed in the row. */ + @Test fun nothingIsOfferedOnceTheLastPhraseIsOnScreen() { + assertEquals(emptyList(), texts("今天天气预报", lookupOf("天气预报", "预报员", "报告"))) + } + + @Test fun aShorterSuffixIsTriedOnlyWhenTheLongerOneMatchesNothing() { + assertEquals(listOf("好的"), texts("你好", lookupOf("好的"))) + } + + @Test fun emptyContextOrNoMatchGivesNothing() { + assertEquals(emptyList(), texts("", lookupOf("天气"))) + assertEquals(emptyList(), texts("你好", lookupOf("天气"))) + } + + private fun ranked(weights: Map, scores: Map) = + rankAssociations(weights.map { (text, weight) -> Candidate(text, weight, "天气") }) { scores[it.text] ?: 0.0 } + .map { it.text } + + /** The old additive order could never lift 天气图 (weight 7) over 天气预报 (2054), however often it was picked. */ + @Test fun aPickedPhraseOutranksAnUnpickedOneWhateverTheCorpusWeight() { + val weights = mapOf("天气预报" to 2054, "天气形势" to 12, "天气图" to 7) + assertEquals(listOf("天气图", "天气预报", "天气形势"), ranked(weights, mapOf("天气图" to 79.0))) + } + + @Test fun pickedPhrasesFollowTheirScoreAndTheRestTheirWeight() { + val weights = mapOf("天气预报" to 2054, "天气形势" to 12, "天气图" to 7, "天气变化" to 1) + assertEquals( + listOf("天气变化", "天气图", "天气预报", "天气形势"), + ranked(weights, mapOf("天气图" to 79.0, "天气变化" to 216.0)), + ) + assertEquals(listOf("天气预报", "天气形势", "天气图", "天气变化"), ranked(weights, emptyMap())) + } + + @Test fun typingOutAnOfferedAssociationCountsAsPickingIt() { + val lookup = lookupOf("天气", "天气预报", "天气好") + val hit = typedContinuation("今天天气", "预报", lookup) + assertEquals("天气预报", hit?.text) + assertEquals("天气", hit?.matchedPrefix) + } + + @Test fun splitCommitsLearnTheOriginalAssociationPrefix() { + val lookup = lookupOf("天气", "天气预报") + val history = TypedContinuationHistory() + assertEquals(emptyList(), history.advance("天气", "预").mapNotNull { + typedContinuation(it.contextBefore, it.word, lookup) + }) + val completed = history.advance("天气预", "报").mapNotNull { + typedContinuation(it.contextBefore, it.word, lookup) + } + assertEquals(listOf("天气", "天气预"), completed.map { it.matchedPrefix }) + assertEquals(listOf("天气预报", "天气预报"), completed.map { it.text }) + } + + @Test fun batchCommitsStillLearnAndUnrelatedTextDoesNot() { + val lookup = lookupOf("天气", "天气预报") + val history = TypedContinuationHistory() + val batch = history.advance("天气", "预报").mapNotNull { + typedContinuation(it.contextBefore, it.word, lookup) + } + assertEquals(listOf("天气"), batch.map { it.matchedPrefix }) + assertEquals(emptyList(), history.advance("天气预报", "雨").mapNotNull { + typedContinuation(it.contextBefore, it.word, lookup) + }) + } + + @Test fun resetAndContextChangesDiscardPendingContinuations() { + val history = TypedContinuationHistory() + history.advance("天气", "预") + history.reset() + assertEquals(listOf(TypedContinuation("天气预", "报")), history.advance("天气预", "报")) + history.advance("天气", "预") + assertEquals(listOf(TypedContinuation("别处", "报")), history.advance("别处", "报")) + } + + @Test fun historyIsBoundedAndDoesNotInventCommitBoundaries() { + val history = TypedContinuationHistory() + assertEquals(emptyList(), history.advance("", "天气")) + assertEquals(listOf(TypedContinuation("天气", "预报")), history.advance("天气", "预报")) + var context = "天气预报" + repeat(20) { + val continuations = history.advance(context, "字") + assertEquals(true, continuations.size <= 7) + assertEquals(true, continuations.all { it.word.length < 8 }) + context = (context + "字").takeLast(8) + } + } + + @Test fun typingSomethingElseCountsAsNothing() { + val lookup = lookupOf("天气", "天气预报") + // Only part of the association, more than it, or unrelated text. + assertEquals(null, typedContinuation("今天天气", "预", lookup)) + assertEquals(null, typedContinuation("今天天气", "预报员", lookup)) + assertEquals(null, typedContinuation("今天天气", "很好", lookup)) + // The phrase that is already complete is never offered, so it can't be "typed out". + assertEquals(null, typedContinuation("今天", "天气", lookupOf("今天"))) + assertEquals(null, typedContinuation("", "天气", lookup)) + } +} diff --git a/openless-all/app/scripts/android-ime-key-click-contract.test.mjs b/openless-all/app/scripts/android-ime-key-click-contract.test.mjs index e2b72804e..467b58310 100644 --- a/openless-all/app/scripts/android-ime-key-click-contract.test.mjs +++ b/openless-all/app/scripts/android-ime-key-click-contract.test.mjs @@ -24,3 +24,14 @@ test('a swipe still suppresses the click from the same touch sequence', () => { assert.match(keyboardKey, /setOnClickListener \{\s*if \(suppressNextClick\) \{\s*suppressNextClick = false\s*\} else \{\s*action\(\)/); }); + +test('a reused English letter panel synchronizes Shift even when its state does not change', () => { + const startView = source.slice( + source.indexOf('override fun onStartInputView('), + source.indexOf('private fun keyboardFootprintKey('), + ); + assert.match(startView, /updateAutoShift\(refresh = false\)/); + assert.match(startView, + /else if \(inputMode == InputMode\.ENGLISH && englishLayer == EnglishLayer\.LETTERS\) \{\s*applyShiftState\(\)/, + 'onStartInput resets automatic Shift before a cached panel is shown again'); +}); diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs index bfe5677c8..3eb1f1f17 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -75,8 +75,10 @@ const KOTLIN_FILES = [ 'OpenLessSettingsExport.kt', 'StrokePhraseRepository.kt', 'StrokeUserFrequency.kt', + 'FrequencyStampCache.kt', 'EnglishCandidateProvider.kt', 'EnglishUserFrequency.kt', + 'EnglishAutoText.kt', 'OpenLessRuntimeService.kt', 'OpenLessBackendWarmupActivity.kt', 'OpenLessClipboardHistory.kt', @@ -93,6 +95,10 @@ const KOTLIN_TEST_FILES = [ 'DictationSpanTest.kt', 'ImePrivacyPolicyTest.kt', 'ImeDraftReplacementTest.kt', + 'StrokePhraseSuffixTest.kt', + 'EnglishAutoTextTest.kt', + 'InputLearningRulesTest.kt', + 'FrequencyStampCacheTest.kt', 'LatinInputModeTest.kt', 'OpenLessContentReaderTest.kt', 'OpenLessMigrationBundleContractTest.kt', @@ -111,6 +117,7 @@ const KOTLIN_ANDROID_TEST_FILES = [ 'ImeLearningMigrationTest.kt', 'OpenLessMigrationBundleInstrumentedTest.kt', 'RimeEngineIntegrationTest.kt', + 'ImeAutoTextIntegrationTest.kt', ]; const XML_FILES = [ From 0212024ab8990a0955f70bbdb580aa1fb8f3b004 Mon Sep 17 00:00:00 2001 From: DepengWang <2818245+DepengWang@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:55:44 +0800 Subject: [PATCH 23/31] fix(capsule): touch Tauri handles only on the event-loop thread Tauri handles share the event loop through a non-atomic Rc (tao EventLoopWindowTarget::runner_shared). The capsule path ran at ~30 Hz from the cpal callback and the backend event task, and on every frame cloned AppHandle / TauriCapsuleWindow there and dropped the closure on the main thread. The racing refcount eventually freed the EventLoopRunner while it was running: WinDbg on a full dump shows HEAP_CORRUPTION 0xc0000374 in Rc::drop_slow under emit_capsule_payload_locked, matching the silent exits during streaming dictation. emit_capsule_payload_locked now keeps only the handle-free work (epoch, Esc exclusivity, snapshot commit) on the caller and posts the presentation through TauriCoordinatorHost::on_main_thread, which borrows the bound AppHandle to queue the task instead of cloning it. Co-Authored-By: Claude Opus 5.5 --- .../src/coordinator/capsule_focus.rs | 26 ++++++++++++++++--- .../src-tauri/src/tauri_coordinator_host.rs | 26 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) 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 c597cb068..526d8bee9 100644 --- a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs +++ b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs @@ -308,16 +308,37 @@ fn emit_capsule_payload_locked( log::debug!("[capsule] skipped stale payload commit at revision {captured_revision}"); return event_epoch; } + // Callers include the cpal audio callback (~30 Hz) and the backend event task. Everything + // below clones Tauri handles, which must stay on the event-loop thread (see + // `TauriCoordinatorHost::on_main_thread`); the queue keeps frames in emission order. + let inner_for_main = Arc::clone(inner); + inner.host.on_main_thread(move || { + present_capsule_payload(&inner_for_main, payload, event_epoch, prev_state) + }); + event_epoch +} + +/// Main-thread half of [`emit_capsule_payload_locked`]: native or WebView capsule presentation +/// plus the `capsule:state` events. +fn present_capsule_payload( + inner: &Arc, + payload: CapsulePayload, + event_epoch: u64, + prev_state: Option, +) { + let state = payload.state; #[cfg(not(mobile))] if inner .host .present_native_capsule(&payload, event_epoch, prev_state) { inner.host.emit_capsule_state_to_main(&payload); - return event_epoch; + return; } + #[cfg(mobile)] + let _ = event_epoch; let Some(capsule) = inner.host.capsule_window() else { - return event_epoch; + return; }; #[cfg(target_os = "android")] @@ -386,7 +407,6 @@ fn emit_capsule_payload_locked( // recording cues. On Linux the cue must still work while the capsule is hidden, so it is // also sent to the main window. Always immediate, decoupled from capsule window show timing. inner.host.emit_capsule_state_to_main(&payload); - event_epoch } /// Whether a selection-polish terminal timer is still eligible to collapse the capsule. diff --git a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs index 86df885be..f41e21656 100644 --- a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs +++ b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs @@ -21,6 +21,9 @@ use tauri::{AppHandle, Emitter, Manager}; use crate::types::{CapsuleDisplayMode, CapsulePayload, CapsuleState, CapsuleStyle}; +/// The Tauri event-loop thread, recorded by [`TauriCoordinatorHost::bind`]. +static MAIN_THREAD: OnceLock = OnceLock::new(); + static CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED: AtomicBool = AtomicBool::new(false); static CAPSULE_FIRST_SHOW_LOGGED: AtomicBool = AtomicBool::new(false); static CAPSULE_NO_ACTIVATE_FALLBACK_WARNED: AtomicBool = AtomicBool::new(false); @@ -2040,6 +2043,8 @@ impl TauriCoordinatorHost { } pub(crate) fn bind(&self, app: AppHandle) { + // `bind` runs in Tauri's `setup`, on the event-loop thread. + let _ = MAIN_THREAD.set(std::thread::current().id()); #[cfg(not(mobile))] if crate::desktop_resources::active() { match crate::native_capsule::NativeCapsule::new(app.clone()) { @@ -2056,6 +2061,27 @@ impl TauriCoordinatorHost { *self.app.lock() = Some(app); } + /// Runs `task` on the event-loop thread. + /// + /// Tauri handles (`AppHandle`, windows, `TauriCapsuleWindow`) share the event loop through a + /// non-atomic `Rc`, so cloning or dropping one off the main thread races the main thread's + /// refcount and eventually frees the event loop while it runs (heap corruption 0xc0000374). + /// This posts through the bound handle by reference instead of cloning it on the caller. + pub(crate) fn on_main_thread(&self, task: F) + where + F: FnOnce() + Send + 'static, + { + if MAIN_THREAD.get() == Some(&std::thread::current().id()) { + task(); + return; + } + // On a worker thread `run_on_main_thread` only queues through the event-loop proxy, so + // holding the slot lock here cannot re-enter it. + if let Some(app) = self.app.lock().as_ref() { + let _ = app.run_on_main_thread(task); + } + } + /// `(available, last_error)` for the disposable capsule sidecar. #[cfg(not(mobile))] pub(crate) fn native_capsule_status(&self) -> (bool, Option) { From a689f204f1dcbf4505293c5dd3b792ce8fc6853a Mon Sep 17 00:00:00 2001 From: CanTakki Date: Thu, 29 Oct 2026 11:11:40 +0900 Subject: [PATCH 24/31] =?UTF-8?q?fix(llm):=20=E4=BF=AE=E5=A4=8D=20Gemini?= =?UTF-8?q?=20=E5=9C=A8=E8=A3=B8=20base=20URL=20=E4=B8=8E=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E6=A8=A1=E5=9E=8B=E4=B8=8B=E6=A0=A1=E9=AA=8C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=20(#1246)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(llm): make Gemini validation work with bare base URL and current models - Default a bare https://generativelanguage.googleapis.com to /v1beta for generateContent / streamGenerateContent, matching model listing and the Gemini ASR path (validation previously 404'd while model listing worked). - Retry once without thinkingConfig when the model rejects the channel-level thinking-off config with a 400, instead of failing the call. - Switch the LLM / Omni default model from gemini-2.5-flash (no longer available to new users) to gemini-3.5-flash-lite. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YJvDYNu6QoSEhJ4yGSuRqk * fix(llm): ask to turn thinking on when Gemini rejects thinking turned off gemini-3.5-flash-lite (the Gemini channel default) answers the thinking-off config (thinkingBudget 0) with the generic "Request contains an invalid argument.", so the earlier retry that looked for "thinking" in the error body never fired and validation kept failing with a bare 400. - On a 400 for a request that carried thinkingConfig, re-send it once without thinkingConfig. If that passes, fail with llmThinkingRequired instead of the HTTP error; any other 400 keeps its original error. - Pass the code through validation (polish failures arrive as "LLM provider failed: parse error: ") and show it in the provider settings and the channel list as "turn Thinking on", in all eight UI languages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Abbfv5hvANpC7Sku5KEXJ3 * fix(gemini): complete thinking controls and cancel probes * test(ci): align emulator mocks with bounded shutdown --------- Co-authored-by: DragonNameLeo <15801369+DragonNameLeo@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Chris233 --- .github/workflows/ci.yml | 14 +- .../crates/openless-core/src/llm_gemini.rs | 381 ++++++++++++++++-- .../openless-core/src/provider_rules.rs | 6 +- .../openless-core/src/provider_service.rs | 19 +- .../app/e2e/settings-feedback.spec.ts | 35 ++ openless-all/app/linux-egui/src/i18n.rs | 37 ++ openless-all/app/linux-egui/src/lib.rs | 4 +- .../linux-egui/src/ui/frontend/settings.rs | 71 +++- .../scripts/android-emulator-cleanup.test.sh | 55 +++ .../app/scripts/ci-workflow-behavior.test.mjs | 3 +- openless-all/app/src/i18n/de.ts | 3 + openless-all/app/src/i18n/en.ts | 3 + openless-all/app/src/i18n/es.ts | 3 + openless-all/app/src/i18n/fr.ts | 3 + openless-all/app/src/i18n/ja.ts | 3 + openless-all/app/src/i18n/ko.ts | 3 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/i18n/zh-TW.ts | 2 + .../lib/ipc/mock-provider-descriptors.json | 4 +- .../ipc/provider-descriptors.generated.json | 4 +- .../app/src/pages/settings/ChannelList.tsx | 1 + .../src/pages/settings/ProvidersSection.tsx | 16 + 22 files changed, 622 insertions(+), 50 deletions(-) create mode 100644 openless-all/app/scripts/android-emulator-cleanup.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dda2bb711..4d59a2fce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -276,6 +276,9 @@ jobs: -x :app:rustBuildX86_64Debug --no-daemon + - name: Check Android emulator cleanup + run: bash scripts/android-emulator-cleanup.test.sh + - name: Enable KVM for Android instrumentation tests run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ @@ -391,8 +394,17 @@ jobs: trap - EXIT if [ -n "$emulator_pid" ]; then if kill -0 "$emulator_pid" 2>/dev/null; then - timeout 10s adb -s emulator-5554 emu kill >/dev/null 2>&1 || true + timeout --kill-after=5s 10s adb -s emulator-5554 emu kill >/dev/null 2>&1 || true kill "$emulator_pid" 2>/dev/null || true + # The emulator can ignore SIGTERM while shutting down. + for attempt in {1..10}; do + kill -0 "$emulator_pid" 2>/dev/null || break + sleep 1 + done + if kill -0 "$emulator_pid" 2>/dev/null; then + echo 'Android emulator did not exit; sending SIGKILL' + kill -KILL "$emulator_pid" 2>/dev/null || true + fi fi wait "$emulator_pid" 2>/dev/null || true fi diff --git a/openless-all/app/crates/openless-core/src/llm_gemini.rs b/openless-all/app/crates/openless-core/src/llm_gemini.rs index 329080e14..0528ba4dc 100644 --- a/openless-all/app/crates/openless-core/src/llm_gemini.rs +++ b/openless-all/app/crates/openless-core/src/llm_gemini.rs @@ -319,21 +319,58 @@ impl GeminiProvider { }) } - async fn send_unary(&self, url: &str, body: &Value) -> Result { + async fn post( + &self, + url: &str, + body: &Value, + sse: bool, + ) -> Result { let mut request = self .client .post(url) .header("Content-Type", "application/json"); + if sse { + request = request.header("Accept", "text/event-stream"); + } if !self.config.api_key.trim().is_empty() { request = request.header("x-goog-api-key", self.config.api_key.as_str()); } - let request = request.json(body); + request + .json(body) + .send() + .await + .map_err(llm_error_from_reqwest) + } - let response = match request.send().await { - Ok(r) => r, - Err(e) => return Err(llm_error_from_reqwest(e)), + /// Whether a 400 came from the thinking-off config: Gemini rejects it + /// with the generic "Request contains an invalid argument." + /// (gemini-3.5-flash-lite), so only re-sending the request without + /// `thinkingConfig` tells it apart from other bad arguments. + async fn rejects_thinking_off( + &self, + url: &str, + status: reqwest::StatusCode, + body: &Value, + sse: bool, + ) -> bool { + let Some(probe_body) = body_without_thinking_config(status, body) else { + return false; }; + let rejected = self + .post(url, &probe_body, sse) + .await + .is_ok_and(|probe| probe.status().is_success()); + if rejected { + log::warn!( + "[llm] gemini model={} rejects thinking turned off; the channel needs thinking on", + self.config.model + ); + } + rejected + } + async fn send_unary(&self, url: &str, body: &Value) -> Result { + let response = self.post(url, body, false).await?; let status = response.status(); let body_text = response.text().await.map_err(llm_error_from_reqwest)?; @@ -342,6 +379,9 @@ impl GeminiProvider { log::info!("[llm] HTTP {} body={}", status.as_u16(), preview); if !status.is_success() { + if self.rejects_thinking_off(url, status, body, false).await { + return Err(thinking_required_error()); + } return Err(LLMError::InvalidResponse { status: status.as_u16(), body: preview.to_string(), @@ -362,34 +402,31 @@ impl GeminiProvider { F: Fn(&str) + Send + Sync, C: Fn() -> bool + Send + Sync, { - let mut request = self - .client - .post(url) - .header("Content-Type", "application/json") - .header("Accept", "text/event-stream"); - if !self.config.api_key.trim().is_empty() { - request = request.header("x-goog-api-key", self.config.api_key.as_str()); - } - let request = request.json(body); - - let response = match request.send().await { - Ok(r) => r, - Err(e) => return Err(llm_error_from_reqwest(e)), - }; - + let mut response = self.post(url, body, true).await?; let status = response.status(); if !status.is_success() { let body_text = response.text().await.map_err(llm_error_from_reqwest)?; - let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len()); - let preview = safe_str_slice(&body_text, preview_end); - log::error!("[llm] HTTP {} body={}", status.as_u16(), preview); - return Err(LLMError::InvalidResponse { - status: status.as_u16(), - body: preview.to_string(), - }); + let error = http_failure(status, &body_text); + if should_cancel() { + return Err(LLMError::Network("cancelled".into())); + } + // Match the shared streaming client's cancellation while waiting on network data. + let cancellation = async { + while !should_cancel() { + tokio::time::sleep(Duration::from_millis(25)).await; + } + }; + let thinking_required = tokio::select! { + biased; + _ = cancellation => return Err(LLMError::Network("cancelled".into())), + rejected = self.rejects_thinking_off(url, status, body, true) => rejected, + }; + if thinking_required { + return Err(thinking_required_error()); + } + return Err(error); } - let mut response = response; // Byte-level buffering — `reqwest::chunk()` may split in the middle // of a multi-byte UTF-8 character (CJK / emoji); calling from_utf8 on // each chunk independently would treat a valid SSE stream as @@ -465,6 +502,16 @@ impl GeminiProvider { // ─────────────────────── internal helpers ─────────────────────── +fn http_failure(status: reqwest::StatusCode, body_text: &str) -> LLMError { + let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len()); + let preview = safe_str_slice(body_text, preview_end); + log::error!("[llm] HTTP {} body={}", status.as_u16(), preview); + LLMError::InvalidResponse { + status: status.as_u16(), + body: preview.to_string(), + } +} + fn user_content(text: &str) -> Value { json!({ "role": "user", "parts": [{ "text": text }] }) } @@ -585,20 +632,58 @@ fn qa_messages_to_contents(messages: &[QaChatMessage]) -> Vec { /// /// OpenLess keeps no per-model adaptation table: when thinking is enabled, /// no thinkingConfig is sent; when disabled, it sends the official -/// `thinkingBudget = 0` that expresses "no thinking". If a specific model -/// doesn't support the field or can't fully disable thinking, the Gemini API -/// itself handles that. +/// `thinkingBudget = 0` that expresses "no thinking". A model that rejects +/// it (Gemini 3+ models only take `thinkingLevel`, some can't stop thinking) +/// fails with `llmThinkingRequired`, which asks the user to turn thinking +/// on — see `GeminiProvider::rejects_thinking_off`. fn disabled_thinking_config() -> Value { json!({ "thinkingBudget": 0 }) } +/// The same request with `thinkingConfig` dropped, to probe a 400. `None` +/// when the failure is anything else or the request carried no +/// thinkingConfig. +fn body_without_thinking_config( + status: reqwest::StatusCode, + request_body: &Value, +) -> Option { + if status != reqwest::StatusCode::BAD_REQUEST { + return None; + } + request_body["generationConfig"].get("thinkingConfig")?; + let mut probe_body = request_body.clone(); + probe_body["generationConfig"] + .as_object_mut()? + .remove("thinkingConfig"); + Some(probe_body) +} + +/// Shown by the provider settings as "turn thinking on" +/// (`settings.providers.llmThinkingRequired`). +fn thinking_required_error() -> LLMError { + LLMError::ParseError("llmThinkingRequired".to_string()) +} + +/// Base path with the API version defaulted like model listing and Gemini +/// ASR (`asr::gemini::endpoint_url`): a bare +/// `https://generativelanguage.googleapis.com` would otherwise POST to +/// `/models/...` and 404. +fn api_base_path(url: &reqwest::Url) -> String { + let path = url.path().trim_end_matches('/'); + if path.is_empty() { + "/v1beta".to_owned() + } else { + path.to_owned() + } +} + fn generate_content_url(base_url: &str, model: &str) -> String { let trimmed = base_url.trim(); let Ok(mut url) = reqwest::Url::parse(trimmed) else { let fallback = trimmed.trim_end_matches('/'); return format!("{fallback}/models/{model}:generateContent"); }; - let path = url.path().trim_end_matches('/'); + let path = api_base_path(&url); url.set_path(&format!("{path}/models/{model}:generateContent")); url.to_string() } @@ -609,7 +694,7 @@ fn stream_generate_content_url(base_url: &str, model: &str) -> String { let fallback = trimmed.trim_end_matches('/'); return format!("{fallback}/models/{model}:streamGenerateContent?alt=sse"); }; - let path = url.path().trim_end_matches('/'); + let path = api_base_path(&url); url.set_path(&format!("{path}/models/{model}:streamGenerateContent")); let existing_query = url .query_pairs() @@ -693,6 +778,236 @@ mod tests { ); } + #[test] + fn content_urls_default_bare_host_to_v1beta() { + // Model listing and Gemini ASR already default a bare host to + // /v1beta; the LLM call must too, or validation 404s. + for base in [ + "https://generativelanguage.googleapis.com", + "https://generativelanguage.googleapis.com/", + ] { + assert_eq!( + generate_content_url(base, "gemini-3.5-flash-lite"), + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash-lite:generateContent" + ); + assert_eq!( + stream_generate_content_url(base, "gemini-3.5-flash-lite"), + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash-lite:streamGenerateContent?alt=sse" + ); + } + assert_eq!( + generate_content_url("https://proxy.example/v1", "m"), + "https://proxy.example/v1/models/m:generateContent" + ); + } + + const GENERIC_INVALID_ARGUMENT: &str = r#"{"error":{"code":400,"message":"Request contains an invalid argument.","status":"INVALID_ARGUMENT"}}"#; + + /// Answers each request on its own connection: Gemini's generic 400 when + /// `reject(body)` holds, else a one-word reply. Returns the base URL and + /// the request bodies seen so far. + async fn gemini_server( + reject: fn(&str) -> bool, + probe_delay: Duration, + ) -> (String, std::sync::Arc>>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}/v1beta", listener.local_addr().unwrap()); + let bodies = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let seen = std::sync::Arc::clone(&bodies); + tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0u8; 4096]; + let body = loop { + let read = stream.read(&mut buffer).await.unwrap(); + assert_ne!(read, 0, "request ended before body"); + request.extend_from_slice(&buffer[..read]); + let Some(index) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") + else { + continue; + }; + let header = String::from_utf8_lossy(&request[..index]).to_ascii_lowercase(); + let length: usize = header + .lines() + .find_map(|line| line.strip_prefix("content-length:")) + .unwrap() + .trim() + .parse() + .unwrap(); + if request.len() >= index + 4 + length { + break String::from_utf8_lossy(&request[index + 4..index + 4 + length]) + .into_owned(); + } + }; + let (status, reply) = if reject(&body) { + ("400 Bad Request", GENERIC_INVALID_ARGUMENT) + } else { + ( + "200 OK", + r#"{"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}"#, + ) + }; + seen.lock().unwrap().push(body); + if status == "200 OK" { + tokio::time::sleep(probe_delay).await; + } + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}", + reply.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + } + }); + (base_url, bodies) + } + + fn provider_for(base_url: &str, thinking_enabled: bool) -> GeminiProvider { + // Do not inherit machine-specific proxy settings or the global cache. + GeminiProvider { + config: GeminiConfig::new("k", "gemini-3.5-flash-lite", base_url) + .with_thinking_enabled(thinking_enabled), + client: reqwest::Client::builder().no_proxy().build().unwrap(), + } + } + + fn is_thinking_required(result: &Result) -> bool { + matches!(result, Err(LLMError::ParseError(code)) if code == "llmThinkingRequired") + } + + #[tokio::test] + async fn rejected_thinking_off_asks_to_turn_thinking_on() { + // gemini-3.5-flash-lite answers thinkingBudget 0 with the generic + // INVALID_ARGUMENT and accepts the request without thinkingConfig. + let (base_url, bodies) = + gemini_server(|body| body.contains("thinkingConfig"), Duration::ZERO).await; + let provider = provider_for(&base_url, false); + let body = provider.build_generate_body("SYS", vec![user_content("hi")]); + + let unary = provider + .send_unary(&generate_content_url(&base_url, "m"), &body) + .await; + assert!(is_thinking_required(&unary)); + let streaming = provider + .send_streaming( + &stream_generate_content_url(&base_url, "m"), + &body, + |_| {}, + || false, + ) + .await; + assert!(is_thinking_required(&streaming)); + + // Each call: the rejected request, then one probe without thinkingConfig. + let bodies = bodies.lock().unwrap(); + assert_eq!(bodies.len(), 4); + assert!(!bodies[1].contains("thinkingConfig")); + assert!(!bodies[3].contains("thinkingConfig")); + } + + #[tokio::test] + async fn other_invalid_arguments_keep_the_http_error() { + let (base_url, bodies) = gemini_server(|_| true, Duration::ZERO).await; + let url = generate_content_url(&base_url, "m"); + + // The probe fails too, so thinking is not the cause. + let provider = provider_for(&base_url, false); + let body = provider.build_generate_body("SYS", vec![user_content("hi")]); + let result = provider.send_unary(&url, &body).await; + assert!(matches!( + result, + Err(LLMError::InvalidResponse { status: 400, .. }) + )); + assert_eq!(bodies.lock().unwrap().len(), 2); + + // Thinking on sends no thinkingConfig, so there is nothing to probe. + let provider = provider_for(&base_url, true); + let body = provider.build_generate_body("SYS", vec![user_content("hi")]); + let result = provider.send_unary(&url, &body).await; + assert!(matches!( + result, + Err(LLMError::InvalidResponse { status: 400, .. }) + )); + assert_eq!(bodies.lock().unwrap().len(), 3); + } + + #[tokio::test] + async fn cancelled_stream_does_not_probe_thinking_config() { + let (base_url, bodies) = gemini_server(|_| true, Duration::ZERO).await; + let provider = provider_for(&base_url, false); + let body = provider.build_generate_body("SYS", vec![user_content("hi")]); + let result = provider + .send_streaming( + &stream_generate_content_url(&base_url, "m"), + &body, + |_| panic!("cancelled request emitted text"), + || !bodies.lock().unwrap().is_empty(), + ) + .await; + assert!( + matches!(result, Err(LLMError::Network(ref message)) if message == "cancelled") + ); + assert_eq!(bodies.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn cancelled_stream_interrupts_a_pending_thinking_probe() { + use std::sync::atomic::{AtomicBool, Ordering}; + let (base_url, bodies) = gemini_server( + |body| body.contains("thinkingConfig"), + Duration::from_secs(10), + ) + .await; + let provider = provider_for(&base_url, false); + let body = provider.build_generate_body("SYS", vec![user_content("hi")]); + let cancelled = std::sync::Arc::new(AtomicBool::new(false)); + let cancel = std::sync::Arc::clone(&cancelled); + let seen = std::sync::Arc::clone(&bodies); + tokio::spawn(async move { + while seen.lock().unwrap().len() < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + cancel.store(true, Ordering::Release); + }); + let result = tokio::time::timeout( + Duration::from_secs(2), + provider.send_streaming( + &stream_generate_content_url(&base_url, "m"), + &body, + |_| panic!("cancelled request emitted text"), + || cancelled.load(Ordering::Acquire), + ), + ) + .await + .expect("cancellation must not wait for the probe response"); + assert!( + matches!(result, Err(LLMError::Network(ref message)) if message == "cancelled") + ); + assert_eq!(bodies.lock().unwrap().len(), 2); + } + + #[test] + fn only_a_400_with_thinking_config_is_probed() { + let cfg = GeminiConfig::new("k", "gemini-3.8-flash", "https://x/v1beta"); + let body = GeminiProvider::new(cfg).build_generate_body("SYS", vec![user_content("hi")]); + + let probe = body_without_thinking_config(reqwest::StatusCode::BAD_REQUEST, &body) + .expect("a 400 on the thinking-off request should be probed"); + assert!(probe["generationConfig"].get("thinkingConfig").is_none()); + assert_eq!( + probe["generationConfig"]["temperature"], + body["generationConfig"]["temperature"] + ); + assert_eq!(probe["contents"], body["contents"]); + assert_eq!(probe["systemInstruction"], body["systemInstruction"]); + + assert!(body_without_thinking_config(reqwest::StatusCode::NOT_FOUND, &body).is_none()); + assert!( + body_without_thinking_config(reqwest::StatusCode::TOO_MANY_REQUESTS, &body).is_none() + ); + } + #[test] fn stream_generate_content_url_appends_alt_sse() { let a = stream_generate_content_url("https://x/v1beta", "gemini-2.5-flash"); diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index 968113506..5c6f3b424 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -652,7 +652,7 @@ pub fn default_llm_model(provider_type: &str) -> Option<&'static str> { "siliconflow" => Some("Qwen/Qwen2.5-7B-Instruct"), "atlascloud" => Some("qwen/qwen3.5-flash"), "openai" | "cometapi" => Some("gpt-4o"), - "gemini" => Some("gemini-2.5-flash"), + "gemini" => Some("gemini-3.5-flash-lite"), crate::polish::CODEX_OAUTH_PROVIDER_ID => Some(crate::polish::CODEX_DEFAULT_MODEL), "mimo" => Some("xiaomi/mimo-v2-flash"), "openrouterFree" => Some("qwen/qwen3-coder:free"), @@ -746,7 +746,7 @@ pub fn default_omni_endpoint(provider_type: &str) -> Option<&'static str> { pub fn default_omni_model(provider_type: &str) -> Option<&'static str> { match provider_type { "openai" => Some("gpt-4o-audio-preview"), - "gemini" => Some("gemini-2.5-flash"), + "gemini" => Some("gemini-3.5-flash-lite"), "dashscope-omni" => Some("qwen3-omni-flash"), _ => None, } @@ -1357,7 +1357,7 @@ mod tests { "https://api.openai.com/v1/chat/completions/", default_llm_endpoint("openai").unwrap() )); - assert_eq!(default_llm_model("gemini"), Some("gemini-2.5-flash")); + assert_eq!(default_llm_model("gemini"), Some("gemini-3.5-flash-lite")); assert_eq!( default_omni_endpoint("dashscope-omni"), Some("https://dashscope.aliyuncs.com/compatible-mode/v1") diff --git a/openless-all/app/crates/openless-core/src/provider_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index abf797bcc..fed2432e7 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -701,8 +701,10 @@ fn sanitize_validation_error(error: BackendError) -> BackendError { "llmTokenLimitInvalid", "llmThinkingBudgetInvalid", "llmProtocolHeaderConflict", + "llmThinkingRequired", ] { - if message == code || message == format!("parse error: {code}") { + // Polish failures arrive as "LLM provider failed: parse error: {code}". + if message == code || message.ends_with(&format!("parse error: {code}")) { return provider_error(code); } } @@ -2410,4 +2412,19 @@ mod tests { assert_eq!(unique.len(), actual.len()); } } + + #[test] + fn validation_keeps_llm_codes_from_polish_failures() { + for message in [ + "llmThinkingRequired", + "LLM provider failed: parse error: llmThinkingRequired", + ] { + let error = sanitize_validation_error(provider_error(message)); + assert_eq!(error.message, "llmThinkingRequired"); + } + let error = sanitize_validation_error(provider_error( + "LLM provider failed: invalid response: status 400, body: secret", + )); + assert_eq!(error.message, "providerHttpStatus:400"); + } } diff --git a/openless-all/app/e2e/settings-feedback.spec.ts b/openless-all/app/e2e/settings-feedback.spec.ts index 517a216aa..a462aca68 100644 --- a/openless-all/app/e2e/settings-feedback.spec.ts +++ b/openless-all/app/e2e/settings-feedback.spec.ts @@ -189,3 +189,38 @@ test('style card lists mount and switch without the React 18 PopChild ref warnin await tabs.nth(0).click(); await expect(page.locator('[data-ol-style-pack]').first()).toBeVisible(); }); + +test('Gemini Omni exposes the shared thinking setting and hides it for transcription models', async ({ + page, +}, testInfo) => { + await settings(page, 'AI 服务与模型'); + await page.getByRole('button', { name: '多模态模式', exact: true }).click(); + const card = page.locator('.ol-omni-settings'); + await card.getByRole('combobox', { name: '供应商', exact: true }).click(); + await page.getByRole('option', { name: 'Google Gemini', exact: true }).click(); + const thinking = card.getByRole('switch', { name: '思考', exact: true }); + await expect(thinking).toHaveAttribute('aria-checked', 'false'); + await thinking.click(); + await expect(thinking).toHaveAttribute('aria-checked', 'true'); + await expect + .poll(() => + page.evaluate(async () => { + const { mockSettings } = await import('/src/lib/ipc/mock-data.ts'); + return mockSettings.llmThinkingEnabled; + }), + ) + .toBe(true); + await page.screenshot({ path: testInfo.outputPath('gemini-omni-desktop.png') }); + + await page.setViewportSize({ width: 375, height: 812 }); + await expect(thinking).toBeInViewport({ ratio: 1 }); + const model = card.getByLabel('模型', { exact: true }); + await model.fill('gemini-3.5-transcribe'); + await model.blur(); + await expect(thinking).toHaveCount(0); + await model.fill('gemini-3.5-flash-lite'); + await model.blur(); + await expect(thinking).toHaveAttribute('aria-checked', 'true'); + await thinking.click(); + await expect(thinking).toHaveAttribute('aria-checked', 'false'); +}); diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index 82dcb4219..9f23e88e8 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -468,6 +468,16 @@ pub const CATALOG: &[Msg] = &[ "사고 예산은 1024 이상이며 고정 예산 모드에서는 출력 한도보다 작아야 합니다.", ), }, + Msg { + key: "settings.providers.llmThinkingRequired", + text: row( + "该模型不接受关闭思考。请打开模型旁的「思考」开关后重新验证。", + "此模型不接受關閉思考。請開啟模型旁的「思考」開關後重新驗證。", + "This model does not accept thinking turned off. Turn on Thinking next to the model, then verify again.", + "このモデルは思考をオフにできません。モデル欄の横の「思考」をオンにしてから、もう一度検証してください。", + "이 모델은 사고를 끌 수 없습니다. 모델 옆의 \"사고\"를 켠 뒤 다시 검증하세요.", + ), + }, // ---- Shell / navigation ------------------------------------------------ Msg { @@ -7997,6 +8007,15 @@ pub fn tr_catalog(lang: L, key: &'static str) -> &'static str { tr(CATALOG, lang, key) } +/// Localize provider guidance at display time; stored validation codes stay unchanged. +pub fn provider_error_text(lang: Lang, message: &str) -> &str { + if message == "llmThinkingRequired" || message.ends_with(": llmThinkingRequired") { + tr_catalog(lang, "settings.providers.llmThinkingRequired") + } else { + message + } +} + /// Global formatted lookup against [`CATALOG`]. pub fn fmt_catalog(lang: L, key: &str, args: &[&dyn Display]) -> String { fmt(CATALOG, lang, key, args) @@ -8050,6 +8069,24 @@ mod tests { assert_eq!(tr(TEST_CATALOG, Lang::En, "k.full"), "Full"); } + #[test] + fn thinking_required_provider_errors_are_localized_without_changing_other_errors() { + for lang in LANGS { + let expected = tr_catalog(lang, "settings.providers.llmThinkingRequired"); + assert_ne!(expected, "settings.providers.llmThinkingRequired"); + for message in [ + "llmThinkingRequired", + "Provider: llmThinkingRequired", + "LLM provider failed: parse error: llmThinkingRequired", + ] { + assert_eq!(provider_error_text(lang, message), expected); + } + for message in ["providerHttpStatus:401", "notllmThinkingRequired"] { + assert_eq!(provider_error_text(lang, message), message); + } + } + } + #[test] fn unknown_key_returns_the_key_itself() { assert_eq!(tr(TEST_CATALOG, Lang::Ja, "k.unknown"), "k.unknown"); diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index 4a9e3aa89..a1814718f 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -90,7 +90,9 @@ pub fn apply_selection_voice_target( fcitx5::apply_selection_target(session_id, source, replacement) } pub use hotkeys::{Fcitx5HotkeyListener, LinuxHotkeyEvent}; -pub use i18n::{fmt_catalog as fmt_l10n, tr_catalog as tr_l10n, Lang, LocalePref, LANGS}; +pub use i18n::{ + fmt_catalog as fmt_l10n, provider_error_text, tr_catalog as tr_l10n, Lang, LocalePref, LANGS, +}; pub use local_hotkeys::{ next_local_press_id, plugin_event_hotkey, style_pack_raw, translation_hotkey_event, HotkeyDeduplicator, LocalHotkey, LocalHotkeyEdge, LocalHotkeyEdgeKind, HOTKEY_DEDUPE_WINDOW, diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index 479b8ebb3..6bc777858 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -1,5 +1,5 @@ use eframe::egui; -use openless_linux_egui::{fmt_l10n, tr_l10n, Lang}; +use openless_linux_egui::{fmt_l10n, provider_error_text, tr_l10n, Lang}; use super::icons::{self, IconName}; use super::layout; @@ -445,7 +445,11 @@ fn panel(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec "$work/adb" << 'ADB' +#!/usr/bin/env bash +if [[ "${STUCK_ADB:-}" == 1 ]]; then + trap '' TERM + exec sleep 10 +fi +exit 0 +ADB +chmod +x "$work/adb" + +for scenario in normal stuck stuck-adb failure no-process; do + expected=0 + if [[ "$scenario" == failure || "$scenario" == no-process ]]; then expected=7; fi + rm -f "$work/ready" + actual=0 + # The outer timeout also kills the fake emulator if the old cleanup hangs. + timeout --kill-after=1s 3s bash -c ' + set -euo pipefail + export PATH="$3:$PATH" + if [[ "$4" == stuck-adb ]]; then export STUCK_ADB=1; fi + timeout() { + local args=("${@/10s/0.1s}") + command timeout "${args[@]/5s/0.1s}" + } + log="$3/emulator.log" + echo emulator-failure > "$log" + emulator_pid="" + if [[ "$4" != no-process ]]; then + ( + if [[ "$4" != normal ]]; then trap "" TERM; fi + echo ready > "$3/ready" + while :; do command sleep 0.1; done + ) & + emulator_pid=$! + until [[ -s "$3/ready" ]]; do command sleep 0.01; done + fi + # Exercise the real loop without waiting ten wall-clock seconds per case. + sleep() { command sleep 0.01; } + eval "$1" + trap cleanup EXIT + (exit "$2") + ' bash "$cleanup" "$expected" "$work" "$scenario" || actual=$? + if [[ "$actual" != "$expected" ]]; then + echo "FAIL: $scenario cleanup returned $actual, expected $expected" >&2 + exit 1 + fi + echo "ok: $scenario cleanup preserves exit status $expected" +done diff --git a/openless-all/app/scripts/ci-workflow-behavior.test.mjs b/openless-all/app/scripts/ci-workflow-behavior.test.mjs index f9c1b8959..a87f03a11 100644 --- a/openless-all/app/scripts/ci-workflow-behavior.test.mjs +++ b/openless-all/app/scripts/ci-workflow-behavior.test.mjs @@ -118,7 +118,7 @@ case "$name" in exec "$TEST_SED" "$@" ;; python3) exec "$TEST_PYTHON" "$@" ;; - sleep) ;; + sleep) "$TEST_SLEEP" 0.02 ;; avdmanager) [ -n "$ANDROID_AVD_HOME" ] || { echo 'SDK tools and emulator require an explicit shared AVD registry' >&2; exit 1; } [ -s "$ANDROID_HOME/emulator/package.xml" ] || { echo 'Error: "emulator" package must be installed!' >&2; exit 1; } @@ -127,6 +127,7 @@ case "$name" in printf 'path=%s\n' "$2" > "$ANDROID_AVD_HOME/openless-ci.ini" ;; timeout) + if [[ "$1" == --kill-after=* ]]; then shift; fi if [ "$1" = 10s ]; then shift; exec "$@"; fi [ "$1" = 600s ] || exit 99 until [ -s "$RUNNER_TEMP/emulator.pid" ]; do "$TEST_SLEEP" 0.01; done diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 4fcaf1f6e..ae3411246 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1801,6 +1801,7 @@ export const de: typeof zhCN = { nameHint: 'Dieser Name unterscheidet Kanäle desselben Anbieters. Er beeinflusst weder Modell noch Verbindung.', errModel: 'Modell', + errThinking: 'Denkmodus einschalten', verify: 'Prüfen', verifyHint: 'Prüft diesen Kanal mit einem echten API-Aufruf auf aktuelle Funktionsfähigkeit', errTimeout: 'Zeitüberschreitung', @@ -2029,6 +2030,8 @@ export const de: typeof zhCN = { 'Messages setzt die Authentifizierungs- und Versionsheader automatisch. Entferne x-api-key und anthropic-version aus den zusätzlichen Headern.', llmStreamError: 'Der Server hat einen Streamfehler gemeldet. Prüfe das Modell und die Anfrageparameter.', + llmThinkingRequired: + 'Dieses Modell lässt sich nicht mit ausgeschaltetem Denkmodus nutzen. Schalte „Denkmodus“ neben dem Modell ein und prüfe erneut.', saveProtocol: 'Protokolleinstellungen speichern', thinkingModeHint: 'Aktiviere, deaktiviere oder reduziere das Denken mit den vom Anfrageformat und Modell unterstützten Parametern. Dem Prompt werden keine Steueranweisungen hinzugefügt.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index a2057bb11..20d04ad12 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1765,6 +1765,7 @@ export const en: typeof zhCN = { nameHint: 'This name distinguishes channels from the same provider. It does not affect the model or connection.', errModel: 'Model', + errThinking: 'turn on Thinking', verify: 'Verify', verifyHint: 'Makes one real API call to check this channel works right now', errTimeout: 'timeout', @@ -1991,6 +1992,8 @@ export const en: typeof zhCN = { llmProtocolHeaderConflict: 'Messages sets authentication and version headers automatically. Remove x-api-key and anthropic-version from extra headers.', llmStreamError: 'The server returned a stream error. Check the model and request parameters.', + llmThinkingRequired: + 'This model does not accept thinking turned off. Turn on Thinking next to the model, then verify again.', saveProtocol: 'Save protocol settings', thinkingModeHint: 'Enable, disable, or reduce thinking using parameters supported by the selected request format and model. No control instructions are injected into prompts.', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index a6b5cce3d..6fe87ebd2 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1791,6 +1791,7 @@ export const es: typeof zhCN = { nameHint: 'El nombre permite distinguir canales del mismo proveedor. No afecta al modelo ni a la conexión.', errModel: 'Modelo', + errThinking: 'activa Razonamiento', verify: 'Verificar', verifyHint: 'Hace una llamada real a la API para comprobar si este canal funciona ahora', errTimeout: 'tiempo agotado', @@ -2015,6 +2016,8 @@ export const es: typeof zhCN = { 'Messages establece automáticamente las cabeceras de autenticación y versión. Elimina x-api-key y anthropic-version de las cabeceras adicionales.', llmStreamError: 'El servidor devolvió un error de transmisión. Comprueba el modelo y los parámetros de la solicitud.', + llmThinkingRequired: + 'Este modelo no admite el razonamiento desactivado. Activa «Razonamiento» junto al modelo y vuelve a verificar.', saveProtocol: 'Guardar ajustes del protocolo', thinkingModeHint: 'Activa, desactiva o reduce el razonamiento mediante los parámetros admitidos por el formato y el modelo. No se añaden instrucciones de control al prompt.', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 8c128a739..a3e8d6ad4 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1813,6 +1813,7 @@ export const fr: typeof zhCN = { nameHint: 'Ce nom distingue les canaux d’un même fournisseur. Il n’affecte ni le modèle ni la connexion.', errModel: 'Modèle', + errThinking: 'activez Raisonnement', verify: 'Vérifier', verifyHint: 'Effectue un appel réel à l’API pour vérifier que ce canal fonctionne actuellement', @@ -2043,6 +2044,8 @@ export const fr: typeof zhCN = { 'Messages définit automatiquement les en-têtes d’authentification et de version. Retirez x-api-key et anthropic-version des en-têtes supplémentaires.', llmStreamError: 'Le serveur a renvoyé une erreur de flux. Vérifiez le modèle et les paramètres de la requête.', + llmThinkingRequired: + 'Ce modèle n’accepte pas le raisonnement désactivé. Activez « Raisonnement » à côté du modèle, puis vérifiez à nouveau.', saveProtocol: 'Enregistrer les réglages du protocole', thinkingModeHint: 'Activez, désactivez ou réduisez le raisonnement avec les paramètres pris en charge par le format et le modèle. Aucune instruction de contrôle n’est ajoutée au prompt.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 27cbb5ff5..9e3935d69 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1752,6 +1752,7 @@ export const ja: typeof zhCN = { nameHint: '同じプロバイダーのチャンネルを区別するための名前です。モデルや接続には影響しません。', errModel: 'モデル', + errThinking: '思考をオンに', verify: '検証', verifyHint: '実際に API を1回呼んで、このチャネルが今使えるか確認します', errTimeout: 'タイムアウト', @@ -1977,6 +1978,8 @@ export const ja: typeof zhCN = { 'Messages の認証とバージョンヘッダーは自動設定されます。追加ヘッダーから x-api-key と anthropic-version を削除してください。', llmStreamError: 'サーバーがストリームエラーを返しました。モデルとリクエスト設定を確認してください。', + llmThinkingRequired: + 'このモデルは思考をオフにできません。モデル欄の横の「思考」をオンにしてから、もう一度検証してください。', saveProtocol: 'プロトコル設定を保存', thinkingModeHint: '選択したリクエスト形式とモデルが対応するパラメータで思考を有効化、無効化または軽減します。プロンプトに制御指示は追加しません。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index ce3a52c83..126a76a5f 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1740,6 +1740,7 @@ export const ko: typeof zhCN = { nameHint: '같은 제공업체의 여러 채널을 구분하는 이름입니다. 모델이나 연결에는 영향을 주지 않습니다.', errModel: '모델', + errThinking: '사고 켜기 필요', verify: '검증', verifyHint: '실제로 API를 한 번 호출해 이 채널이 지금 되는지 확인합니다', errTimeout: '시간 초과', @@ -1964,6 +1965,8 @@ export const ko: typeof zhCN = { llmProtocolHeaderConflict: 'Messages 인증 및 버전 헤더는 자동 설정됩니다. 추가 헤더에서 x-api-key와 anthropic-version을 제거하세요.', llmStreamError: '서버가 스트림 오류를 반환했습니다. 모델과 요청 설정을 확인하세요.', + llmThinkingRequired: + '이 모델은 사고를 끌 수 없습니다. 모델 옆의 "사고"를 켠 뒤 다시 검증하세요.', saveProtocol: '프로토콜 설정 저장', thinkingModeHint: '선택한 요청 형식과 모델이 지원하는 매개변수로 사고를 켜거나 끄거나 줄입니다. 프롬프트에 제어 지시를 추가하지 않습니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 00c418bc5..05a8ea038 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1676,6 +1676,7 @@ export const zhCN = { autoSaveHint: '字段修改后自动保存;完成配置后,可手动验证连接。', nameHint: '名称仅用于区分同一供应商的多个渠道,不影响模型或连接。', errModel: '模型', + errThinking: '需开启思考', verify: '验证', verifyHint: '点一下真实调用一次接口,确认这张卡现在能用', errTimeout: '超时', @@ -1887,6 +1888,7 @@ export const zhCN = { llmProtocolHeaderConflict: 'Messages 已自动设置鉴权和版本请求头,请移除额外 Headers 中的 x-api-key 和 anthropic-version。', llmStreamError: '服务端返回流式错误,请检查模型和请求参数。', + llmThinkingRequired: '该模型不接受关闭思考。请打开模型旁的「思考」开关后重新验证。', saveProtocol: '保存协议设置', thinkingModeHint: '按所选请求格式和模型支持的参数启用、关闭或降低思考,不向提示词注入控制指令。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 93285a3d1..bfbf7d59c 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1676,6 +1676,7 @@ export const zhTW: typeof zhCN = { autoSaveHint: '修改欄位後會自動儲存;完成設定後,可手動驗證連線。', nameHint: '名稱僅用於區分同一供應商的多個渠道,不影響模型或連線。', errModel: '模型', + errThinking: '需開啟思考', verify: '驗證', verifyHint: '點一下會真實呼叫一次介面,確認這張卡現在可用', errTimeout: '逾時', @@ -1887,6 +1888,7 @@ export const zhTW: typeof zhCN = { llmProtocolHeaderConflict: 'Messages 已自動設定驗證與版本標頭,請移除額外 Headers 中的 x-api-key 與 anthropic-version。', llmStreamError: '伺服器回傳串流錯誤,請檢查模型和請求引數。', + llmThinkingRequired: '此模型不接受關閉思考。請開啟模型旁的「思考」開關後重新驗證。', saveProtocol: '儲存協定設定', thinkingModeHint: '依所選請求格式與模型支援的引數啟用、關閉或降低思考,不在提示詞注入控制指令。', diff --git a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json index d1c5f3d19..009f69219 100644 --- a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json +++ b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json @@ -96,7 +96,7 @@ "providerType": "gemini", "labelKey": "gemini", "defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta", - "defaultModel": "gemini-2.5-flash", + "defaultModel": "gemini-3.5-flash-lite", "authRequirement": "api_key", "validationProbe": "llm_text", "staticModels": [], @@ -776,7 +776,7 @@ "providerType": "gemini", "labelKey": "omniGemini", "defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta", - "defaultModel": "gemini-2.5-flash", + "defaultModel": "gemini-3.5-flash-lite", "authRequirement": "api_key", "validationProbe": "omni_text", "staticModels": [], diff --git a/openless-all/app/src/lib/ipc/provider-descriptors.generated.json b/openless-all/app/src/lib/ipc/provider-descriptors.generated.json index 5c1be1f81..618826b6d 100644 --- a/openless-all/app/src/lib/ipc/provider-descriptors.generated.json +++ b/openless-all/app/src/lib/ipc/provider-descriptors.generated.json @@ -477,7 +477,7 @@ { "authRequirement": "api_key", "defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta", - "defaultModel": "gemini-2.5-flash", + "defaultModel": "gemini-3.5-flash-lite", "defaultRequestFormat": null, "kind": "llm", "labelKey": "gemini", @@ -768,7 +768,7 @@ { "authRequirement": "api_key", "defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta", - "defaultModel": "gemini-2.5-flash", + "defaultModel": "gemini-3.5-flash-lite", "defaultRequestFormat": null, "kind": "omni", "labelKey": "omniGemini", diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index 45420fd2a..f309dedbb 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -183,6 +183,7 @@ function shortErrorLabel(raw: string | null, t: ReturnType { + void updatePrefs((current) => ({ + ...current, + llmThinkingEnabled: enabled, + })).catch((error) => + console.error('[settings] failed to update Omni thinking mode', error), + ); + }} + /> + ) : undefined + } />
{committedOmniProvider === 'gemini' && ( From 2fd7d53db63adf0086dddbf875e4ae24f68df107 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Thu, 29 Oct 2026 11:31:54 +0800 Subject: [PATCH 25/31] =?UTF-8?q?docs(sponsors):=20=E6=9B=B4=E6=96=B0=20Mi?= =?UTF-8?q?vra=20=E8=B5=9E=E5=8A=A9=E5=95=86=E4=BF=A1=E6=81=AF=20(#1292)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(sponsors): 更新 Mivra 赞助商信息 * docs(sponsors): 统一使用 Mivra 品牌名称 --- README.md | 10 +++++++--- README.zh.md | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0d0a74735..c3d9a7aaf 100644 --- a/README.md +++ b/README.md @@ -51,11 +51,15 @@

- - 悠雾云数据 (Youwu Cloud Data) + + Mivra
- 悠雾云数据 · Youwu Cloud Data (knin.net) + Mivra (mivra.dev) +

+ +

+ Mivra provides cloud servers and bare-metal servers, with nodes in Hong Kong and the United States, gaming cloud services, and on-demand configurations, delivering stable, reliable computing power for small and medium-sized businesses.

Developers

diff --git a/README.zh.md b/README.zh.md index 119bd6e41..21ffea606 100644 --- a/README.zh.md +++ b/README.zh.md @@ -51,11 +51,15 @@

- - 悠雾云数据 (Youwu Cloud Data) + + Mivra
- 悠雾云数据 · Youwu Cloud Data (knin.net) + Mivra (mivra.dev) +

+ +

+ Mivra 专注云服务器与物理裸机,节点覆盖香港、美国与游戏云,支持按需选配,为中小企业提供稳定可靠的算力。

开发者

From e4457efd33ecc0cc0d18404f4fcfd08149683122 Mon Sep 17 00:00:00 2001 From: lxcadoza993 Date: Thu, 29 Oct 2026 13:39:05 +0800 Subject: [PATCH 26/31] fix(windows): stop trusting recycled HWNDs in the capsule hit-test subclass (#1267) * fix(windows): stop trusting recycled HWNDs in the capsule hit-test subclass `install_capsule_hit_test_proc` keys its bookkeeping on the numeric HWND, but Windows recycles HWND values. Once a capsule/card window is destroyed and its handle value is handed to a new window, a lookup by that key still finds the dead window's entry, and three things go wrong: - the fast path reuses the stale entry and returns early, so the *new* window never gets the subclass procedure installed -- hit-testing silently stops working for it; - `WM_NCHITTEST` hit-tests the live window against the dead window's geometry; - message forwarding can call a `previous_proc` that belonged to another window. The only reliable identity check is whether *our own* procedure is still installed on the handle: a recycled HWND carries whatever procedure its new owner installed. Every read of the map now proves that first, and an entry that fails the check is dropped and re-installed instead of trusted. Two supporting fixes while in here: - `install_capsule_hit_test_proc` now refuses to record itself as the predecessor. When the map has been wiped but the procedure is already on the window, `SetWindowLongPtrW` returns our own address; storing it would make every forwarded message recurse into this same procedure. - `WM_NCDESTROY` only retires the entry this window owns. Adds `reinstall_keeps_the_real_predecessor_and_never_self_wraps`, which uses a real `HWND_MESSAGE` window because the identity check reads the live `GWLP_WNDPROC` and cannot be mocked. Note on running tests: `cargo test --lib` exits with `0xC0000139` (STATUS_ENTRYPOINT_NOT_FOUND) on Windows before any test runs. The crate imports `comctl32!TaskDialogIndirect`, which lives in Common-Controls v6, and v6 is bound only through the `build.rs` `/manifestdependency` link arg -- the cargo test harness links separately and does not get that manifest. Embedding a v6 manifest in the test binary makes it run: mt.exe -nologo -manifest cc6.manifest "-outputresource:;#1" With that, the new test passes (1 passed; 0 failed), as does `cargo check` (0 errors) and `cargo build --release`. * fix(windows): preserve capsule subclass chains Track hit-test state with the window's system subclass, not the current outer WndProc. Preserve message forwarding and cleanup when another callback wraps the capsule, and keep recycled HWNDs state-free. Run real-window regressions in the standalone backend tests with a Common Controls v6 manifest. Refs #1267 --------- Co-authored-by: Zhongjie Li Co-authored-by: Chris233 --- .../app/scripts/windows-ui-config.test.mjs | 26 +- .../app/src-tauri/backend-tests/Cargo.toml | 2 +- .../app/src-tauri/backend-tests/build.rs | 11 + .../backend-tests/tests/capsule_hit_test.rs | 4 + .../app/src-tauri/src/capsule_hit_test.rs | 365 ++++++++++++++++++ .../src-tauri/src/tauri_coordinator_host.rs | 151 +------- 6 files changed, 404 insertions(+), 155 deletions(-) create mode 100644 openless-all/app/src-tauri/backend-tests/build.rs create mode 100644 openless-all/app/src-tauri/backend-tests/tests/capsule_hit_test.rs create mode 100644 openless-all/app/src-tauri/src/capsule_hit_test.rs diff --git a/openless-all/app/scripts/windows-ui-config.test.mjs b/openless-all/app/scripts/windows-ui-config.test.mjs index c9de5bca8..be3479c27 100644 --- a/openless-all/app/scripts/windows-ui-config.test.mjs +++ b/openless-all/app/scripts/windows-ui-config.test.mjs @@ -33,6 +33,10 @@ const sharedTypesRs = await readFile( ); // Contract check over the union of the capsule subsystem compiled into the binary; native // window manipulation belongs to the explicit Tauri Host. +const capsuleHitTestRs = await readFile( + new URL('../src-tauri/src/capsule_hit_test.rs', import.meta.url), + 'utf-8', +); const coordinatorRs = (await readFile(new URL('../src-tauri/src/coordinator.rs', import.meta.url), 'utf-8')) + '\n' + @@ -43,7 +47,9 @@ const coordinatorRs = '\n' + (await readFile(new URL('../src-tauri/src/capsule_snapshot.rs', import.meta.url), 'utf-8')) + '\n' + - (await readFile(new URL('../src-tauri/src/tauri_coordinator_host.rs', import.meta.url), 'utf-8')); + (await readFile(new URL('../src-tauri/src/tauri_coordinator_host.rs', import.meta.url), 'utf-8')) + + '\n' + + capsuleHitTestRs; const tauriEventsRs = await readFile( new URL('../src-tauri/src/tauri_events.rs', import.meta.url), 'utf-8', @@ -404,9 +410,9 @@ assertMatch( 'capsule hit testing should cover X/Y and use a real native input region', ); assertMatch( - coordinatorRs, - /control_left:[\s\S]*?control_right:[\s\S]*?control_top:[\s\S]*?control_bottom:/, - 'capsule hit-test state should store both X and Y bounds', + capsuleHitTestRs, + /struct CapsuleHitTestRect \{[\s\S]*?left: i32,[\s\S]*?right: i32,[\s\S]*?top: i32,[\s\S]*?bottom: i32,/, + 'window-owned capsule hit-test state should store both X and Y bounds', ); assertMatch( coordinatorRs, @@ -414,14 +420,14 @@ assertMatch( 'capsule input region should be native', ); assertMatch( - coordinatorRs, - /WM_NCDESTROY[\s\S]*?remove\([\s\S]*?SetWindowLongPtrW/, - 'subclass teardown should restore the old WndProc and remove its table entry', + capsuleHitTestRs, + /WM_NCDESTROY[\s\S]*?RemoveWindowSubclass[\s\S]*?drop\(Box::from_raw\([\s\S]*?DefSubclassProc/, + 'subclass teardown should release window-owned state and preserve message forwarding', ); assertMatch( - coordinatorRs, - /SetLastError[\s\S]*?GetLastError/, - 'WndProc installation should detect SetWindowLongPtrW failure', + capsuleHitTestRs, + /if !SetWindowSubclass\([\s\S]*?\.as_bool\(\)[\s\S]*?drop\(Box::from_raw\(data\)\);[\s\S]*?return Err/, + 'failed subclass installation should release its allocation and return an error', ); assertMatch( coordinatorRs, diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.toml b/openless-all/app/src-tauri/backend-tests/Cargo.toml index 5544befb7..557aef18d 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.toml +++ b/openless-all/app/src-tauri/backend-tests/Cargo.toml @@ -35,7 +35,7 @@ parking_lot = "0.12" futures-util = "0.3" [target.'cfg(windows)'.dev-dependencies] -windows = { version = "0.58", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] } +windows = { version = "0.58", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] } [target.'cfg(target_os = "linux")'.dev-dependencies] # Exercise the production recorder worker without the retired Linux Tauri host. diff --git a/openless-all/app/src-tauri/backend-tests/build.rs b/openless-all/app/src-tauri/backend-tests/build.rs new file mode 100644 index 000000000..4c9cabc97 --- /dev/null +++ b/openless-all/app/src-tauri/backend-tests/build.rs @@ -0,0 +1,11 @@ +fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") + && std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") + { + // Subclass helper exports require Common Controls v6 in the test executables. + println!("cargo:rustc-link-arg-tests=/MANIFEST:EMBED"); + println!( + r#"cargo:rustc-link-arg-tests=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'"# + ); + } +} diff --git a/openless-all/app/src-tauri/backend-tests/tests/capsule_hit_test.rs b/openless-all/app/src-tauri/backend-tests/tests/capsule_hit_test.rs new file mode 100644 index 000000000..a6bf8eb04 --- /dev/null +++ b/openless-all/app/src-tauri/backend-tests/tests/capsule_hit_test.rs @@ -0,0 +1,4 @@ +#![cfg(target_os = "windows")] + +#[path = "../../src/capsule_hit_test.rs"] +mod capsule_hit_test; diff --git a/openless-all/app/src-tauri/src/capsule_hit_test.rs b/openless-all/app/src-tauri/src/capsule_hit_test.rs new file mode 100644 index 000000000..c5927882f --- /dev/null +++ b/openless-all/app/src-tauri/src/capsule_hit_test.rs @@ -0,0 +1,365 @@ +//! Window-owned hit-test state; repeated installs must preserve other subclasses. + +use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM}; +use windows::Win32::UI::Shell::{ + DefSubclassProc, GetWindowSubclass, RemoveWindowSubclass, SetWindowSubclass, +}; +use windows::Win32::UI::WindowsAndMessaging::{ + GetWindowRect, HTTRANSPARENT, WM_NCDESTROY, WM_NCHITTEST, +}; + +const SUBCLASS_ID: usize = 1; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct CapsuleHitTestRect { + pub(crate) left: i32, + pub(crate) right: i32, + pub(crate) top: i32, + pub(crate) bottom: i32, +} + +unsafe extern "system" fn capsule_hit_test_subclass_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + subclass_id: usize, + data: usize, +) -> LRESULT { + if msg == WM_NCDESTROY { + let _ = RemoveWindowSubclass(hwnd, Some(capsule_hit_test_subclass_proc), subclass_id); + drop(Box::from_raw(data as *mut CapsuleHitTestRect)); + return DefSubclassProc(hwnd, msg, wparam, lparam); + } + + if msg == WM_NCHITTEST { + let control = *(data as *const CapsuleHitTestRect); + let screen_x = (lparam.0 as i32 & 0xffff) as u16 as i16 as i32; + let screen_y = ((lparam.0 >> 16) & 0xffff) as u16 as i16 as i32; + let mut rect = RECT::default(); + if GetWindowRect(hwnd, &mut rect).is_ok() { + let client_x = screen_x - rect.left; + let client_y = screen_y - rect.top; + if client_x < control.left + || client_x >= control.right + || client_y < control.top + || client_y >= control.bottom + { + return LRESULT(HTTRANSPARENT as isize); + } + } + } + DefSubclassProc(hwnd, msg, wparam, lparam) +} + +/// Called on the window's owning thread by the capsule/card layout paths. +pub(crate) fn install_capsule_hit_test_proc( + hwnd: HWND, + rect: CapsuleHitTestRect, +) -> windows::core::Result<()> { + if hwnd.0.is_null() { + return Err(windows::core::Error::from_hresult( + windows::Win32::Foundation::E_INVALIDARG, + )); + } + + let mut data = 0; + unsafe { + // A subclass can remain in the chain even when another callback is on top. + if GetWindowSubclass( + hwnd, + Some(capsule_hit_test_subclass_proc), + SUBCLASS_ID, + Some(&mut data), + ) + .as_bool() + { + *(data as *mut CapsuleHitTestRect) = rect; + return Ok(()); + } + let data = Box::into_raw(Box::new(rect)); + if !SetWindowSubclass( + hwnd, + Some(capsule_hit_test_subclass_proc), + SUBCLASS_ID, + data as usize, + ) + .as_bool() + { + drop(Box::from_raw(data)); + return Err(windows::core::Error::from_hresult( + windows::Win32::Foundation::E_FAIL, + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use std::sync::atomic::{AtomicUsize, Ordering}; + use windows::core::PCWSTR; + use windows::Win32::Foundation::HINSTANCE; + use windows::Win32::UI::WindowsAndMessaging::{ + CallWindowProcW, CreateWindowExW, DefWindowProcW, DestroyWindow, GetWindowLongPtrW, + RegisterClassW, SendMessageW, SetWindowLongPtrW, UnregisterClassW, GWLP_USERDATA, + GWLP_WNDPROC, HTCLIENT, HWND_MESSAGE, WINDOW_EX_STYLE, WINDOW_STYLE, WM_APP, WNDCLASSW, + }; + + const PROBE_MESSAGE: u32 = WM_APP + 42; + const PROBE_RESULT: isize = 12345; + static NEXT_CLASS: AtomicUsize = AtomicUsize::new(0); + + #[derive(Default)] + struct Probe { + messages: Cell, + destroyed: Cell, + upper_calls: Cell, + upper_previous: Cell, + } + + unsafe extern "system" fn original_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + ) -> LRESULT { + let data = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *const Probe; + if !data.is_null() { + let probe = &*data; + match msg { + PROBE_MESSAGE => { + probe.messages.set(probe.messages.get() + 1); + return LRESULT(PROBE_RESULT); + } + WM_NCDESTROY => probe.destroyed.set(true), + WM_NCHITTEST => return LRESULT(HTCLIENT as isize), + _ => {} + } + } + DefWindowProcW(hwnd, msg, wparam, lparam) + } + + unsafe extern "system" fn upper_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + ) -> LRESULT { + let probe = &*(GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *const Probe); + if msg == PROBE_MESSAGE { + probe.upper_calls.set(probe.upper_calls.get() + 1); + // Bound the failing case instead of overflowing the test process stack. + if probe.upper_calls.get() > 4 { + return LRESULT(-1); + } + } + CallWindowProcW( + std::mem::transmute::( + probe.upper_previous.get(), + ), + hwnd, + msg, + wparam, + lparam, + ) + } + + struct TestWindow { + hwnd: HWND, + class: Vec, + probe: Box, + } + + impl TestWindow { + fn new() -> Self { + let class: Vec = format!( + "OpenLessHitTest{}", + NEXT_CLASS.fetch_add(1, Ordering::Relaxed) + ) + .encode_utf16() + .chain(Some(0)) + .collect(); + let wc = WNDCLASSW { + lpfnWndProc: Some(original_proc), + lpszClassName: PCWSTR(class.as_ptr()), + ..Default::default() + }; + assert_ne!(unsafe { RegisterClassW(&wc) }, 0); + let hwnd = unsafe { + CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(class.as_ptr()), + PCWSTR::null(), + WINDOW_STYLE(0), + 0, + 0, + 100, + 100, + HWND_MESSAGE, + None, + HINSTANCE::default(), + None, + ) + } + .expect("CreateWindowExW"); + let probe = Box::::default(); + unsafe { + SetWindowLongPtrW(hwnd, GWLP_USERDATA, &*probe as *const Probe as isize); + } + Self { hwnd, class, probe } + } + + fn add_upper_subclass(&self) { + let previous = unsafe { + SetWindowLongPtrW(self.hwnd, GWLP_WNDPROC, upper_proc as *const () as isize) + }; + assert_ne!(previous, 0); + self.probe.upper_previous.set(previous); + } + + fn send_probe(&self) { + assert_eq!( + unsafe { SendMessageW(self.hwnd, PROBE_MESSAGE, WPARAM(0), LPARAM(0)) }.0, + PROBE_RESULT, + ); + } + + fn hit_test(&self, x: i32, y: i32) -> isize { + let mut bounds = RECT::default(); + unsafe { GetWindowRect(self.hwnd, &mut bounds) }.unwrap(); + let x = (bounds.left + x) as u16 as u32; + let y = (bounds.top + y) as u16 as u32; + unsafe { + SendMessageW( + self.hwnd, + WM_NCHITTEST, + WPARAM(0), + LPARAM((x | (y << 16)) as isize), + ) + } + .0 + } + + fn destroy(&mut self) { + unsafe { DestroyWindow(self.hwnd) }.expect("DestroyWindow"); + self.hwnd = HWND::default(); + } + } + + impl Drop for TestWindow { + fn drop(&mut self) { + if !self.hwnd.0.is_null() { + self.destroy(); + } + unsafe { UnregisterClassW(PCWSTR(self.class.as_ptr()), HINSTANCE::default()) } + .expect("UnregisterClassW"); + } + } + + fn rect() -> CapsuleHitTestRect { + CapsuleHitTestRect { + left: 10, + right: 20, + top: 30, + bottom: 40, + } + } + + #[test] + fn reinstall_refreshes_geometry_and_keeps_message_forwarding() { + let window = TestWindow::new(); + install_capsule_hit_test_proc(window.hwnd, rect()).unwrap(); + assert_eq!(window.hit_test(15, 35), HTCLIENT as isize); + assert_eq!(window.hit_test(0, 0), HTTRANSPARENT as isize); + install_capsule_hit_test_proc( + window.hwnd, + CapsuleHitTestRect { + left: 0, + right: 5, + top: 0, + bottom: 5, + }, + ) + .unwrap(); + assert_eq!(window.hit_test(15, 35), HTTRANSPARENT as isize); + assert_eq!(window.hit_test(1, 1), HTCLIENT as isize); + window.send_probe(); + assert_eq!(window.probe.messages.get(), 1); + } + + #[test] + fn upper_subclass_survives_forwarding_and_reinstall() { + let window = TestWindow::new(); + install_capsule_hit_test_proc(window.hwnd, rect()).unwrap(); + window.add_upper_subclass(); + window.send_probe(); + install_capsule_hit_test_proc(window.hwnd, rect()).unwrap(); + assert_eq!( + unsafe { GetWindowLongPtrW(window.hwnd, GWLP_WNDPROC) }, + upper_proc as *const () as isize + ); + window.send_probe(); + assert_eq!(window.probe.messages.get(), 2); + assert_eq!(window.probe.upper_calls.get(), 2); + assert_eq!(window.hit_test(0, 0), HTTRANSPARENT as isize); + } + + #[test] + fn destroy_through_upper_subclass_reaches_original_cleanup() { + let mut window = TestWindow::new(); + install_capsule_hit_test_proc(window.hwnd, rect()).unwrap(); + window.add_upper_subclass(); + window.destroy(); + assert!(window.probe.destroyed.get()); + } + + #[test] + fn rectangles_belong_to_their_window_and_do_not_survive_recreation() { + let mut first = TestWindow::new(); + let second = TestWindow::new(); + install_capsule_hit_test_proc(first.hwnd, rect()).unwrap(); + install_capsule_hit_test_proc( + second.hwnd, + CapsuleHitTestRect { + left: 0, + right: 5, + top: 0, + bottom: 5, + }, + ) + .unwrap(); + assert_eq!(first.hit_test(15, 35), HTCLIENT as isize); + assert_eq!(second.hit_test(15, 35), HTTRANSPARENT as isize); + first.destroy(); + assert!(first.probe.destroyed.get()); + let recreated = TestWindow::new(); + assert!(!unsafe { + GetWindowSubclass( + recreated.hwnd, + Some(capsule_hit_test_subclass_proc), + SUBCLASS_ID, + None, + ) + } + .as_bool()); + install_capsule_hit_test_proc(recreated.hwnd, rect()).unwrap(); + assert_eq!(recreated.hit_test(15, 35), HTCLIENT as isize); + second.send_probe(); + } + + #[test] + fn destroyed_window_rejects_installation() { + let mut window = TestWindow::new(); + let hwnd = window.hwnd; + window.destroy(); + assert!(install_capsule_hit_test_proc(hwnd, rect()).is_err()); + } + + #[test] + fn null_window_is_rejected() { + assert!(install_capsule_hit_test_proc(HWND::default(), rect()).is_err()); + } +} diff --git a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs index 86df885be..950f52e95 100644 --- a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs +++ b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs @@ -4,13 +4,15 @@ //! [`tauri::AppHandle`] and keeps window, main-thread and managed-state access //! out of the coordinator's business paths. -#[cfg(target_os = "windows")] -use std::collections::HashMap; use std::future::Future; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}; use std::sync::Arc; + #[cfg(target_os = "windows")] -use std::sync::OnceLock; +#[path = "capsule_hit_test.rs"] +mod capsule_hit_test; +#[cfg(target_os = "windows")] +use capsule_hit_test::{install_capsule_hit_test_proc, CapsuleHitTestRect}; #[path = "capsule_snapshot.rs"] mod capsule_snapshot; @@ -138,34 +140,6 @@ fn position_rail_for_capsule( Ok(()) } -#[cfg(target_os = "windows")] -#[derive(Clone, Copy)] -struct CapsuleHitTestEntry { - previous_proc: isize, - control_left: i32, - control_right: i32, - control_top: i32, - control_bottom: i32, -} - -#[cfg(target_os = "windows")] -static CAPSULE_HIT_TEST_ENTRIES: OnceLock>> = - OnceLock::new(); - -#[cfg(target_os = "windows")] -fn capsule_hit_test_entries() -> &'static Mutex> { - CAPSULE_HIT_TEST_ENTRIES.get_or_init(|| Mutex::new(HashMap::new())) -} - -#[cfg(target_os = "windows")] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct CapsuleHitTestRect { - left: i32, - right: i32, - top: i32, - bottom: i32, -} - #[cfg(target_os = "windows")] fn capsule_control_hit_rect( style: CapsuleStyle, @@ -230,118 +204,6 @@ fn capsule_card_hit_rect(window_width: i32, window_height: i32) -> CapsuleHitTes } } -#[cfg(target_os = "windows")] -unsafe extern "system" fn capsule_hit_test_window_proc( - hwnd: windows::Win32::Foundation::HWND, - msg: u32, - wparam: windows::Win32::Foundation::WPARAM, - lparam: windows::Win32::Foundation::LPARAM, -) -> windows::Win32::Foundation::LRESULT { - use windows::Win32::Foundation::RECT; - use windows::Win32::UI::WindowsAndMessaging::{ - CallWindowProcW, DefWindowProcW, GetWindowRect, SetWindowLongPtrW, GWLP_WNDPROC, - HTTRANSPARENT, WM_NCDESTROY, WM_NCHITTEST, - }; - - if msg == WM_NCDESTROY { - let previous_proc = capsule_hit_test_entries() - .lock() - .remove(&(hwnd.0 as isize)) - .map(|entry| entry.previous_proc) - .unwrap_or_default(); - if previous_proc != 0 { - let previous: windows::Win32::UI::WindowsAndMessaging::WNDPROC = - std::mem::transmute(previous_proc); - let _ = SetWindowLongPtrW(hwnd, GWLP_WNDPROC, previous_proc); - return CallWindowProcW(previous, hwnd, msg, wparam, lparam); - } - return DefWindowProcW(hwnd, msg, wparam, lparam); - } - - if msg == WM_NCHITTEST { - let key = hwnd.0 as isize; - let entry = capsule_hit_test_entries().lock().get(&key).copied(); - if let Some(entry) = entry { - let screen_x = (lparam.0 as i32 & 0xffff) as u16 as i16 as i32; - let screen_y = ((lparam.0 >> 16) & 0xffff) as u16 as i16 as i32; - let mut rect = RECT::default(); - if GetWindowRect(hwnd, &mut rect).is_ok() { - let client_x = screen_x - rect.left; - let client_y = screen_y - rect.top; - if client_x < entry.control_left - || client_x >= entry.control_right - || client_y < entry.control_top - || client_y >= entry.control_bottom - { - return windows::Win32::Foundation::LRESULT(HTTRANSPARENT as isize); - } - } - } - } - - let previous_proc = capsule_hit_test_entries() - .lock() - .get(&(hwnd.0 as isize)) - .map(|entry| entry.previous_proc) - .unwrap_or_default(); - if previous_proc != 0 { - let previous: windows::Win32::UI::WindowsAndMessaging::WNDPROC = - std::mem::transmute(previous_proc); - CallWindowProcW(previous, hwnd, msg, wparam, lparam) - } else { - DefWindowProcW(hwnd, msg, wparam, lparam) - } -} - -#[cfg(target_os = "windows")] -fn install_capsule_hit_test_proc( - hwnd: windows::Win32::Foundation::HWND, - rect: CapsuleHitTestRect, -) -> tauri::Result<()> { - use windows::Win32::Foundation::{GetLastError, SetLastError, ERROR_SUCCESS}; - use windows::Win32::UI::WindowsAndMessaging::{SetWindowLongPtrW, GWLP_WNDPROC}; - - if hwnd.0.is_null() { - return Err(tauri::Error::Anyhow(anyhow::anyhow!( - "cannot install hit-test proc for a null HWND" - ))); - } - - let key = hwnd.0 as isize; - let mut entries = capsule_hit_test_entries().lock(); - if let Some(entry) = entries.get_mut(&key) { - entry.control_left = rect.left; - entry.control_right = rect.right; - entry.control_top = rect.top; - entry.control_bottom = rect.bottom; - return Ok(()); - } - unsafe { SetLastError(ERROR_SUCCESS) }; - let previous = unsafe { - SetWindowLongPtrW( - hwnd, - GWLP_WNDPROC, - capsule_hit_test_window_proc as usize as isize, - ) - }; - if previous == 0 && unsafe { GetLastError() } != ERROR_SUCCESS { - return Err(tauri::Error::Anyhow(anyhow::anyhow!( - "SetWindowLongPtrW(GWLP_WNDPROC) failed" - ))); - } - entries.insert( - key, - CapsuleHitTestEntry { - previous_proc: previous, - control_left: rect.left, - control_right: rect.right, - control_top: rect.top, - control_bottom: rect.bottom, - }, - ); - Ok(()) -} - #[cfg(target_os = "windows")] fn set_capsule_input_region( hwnd: windows::Win32::Foundation::HWND, @@ -426,7 +288,7 @@ fn configure_capsule_hit_test( set_capsule_input_region(hwnd, rect)?; if let Err(error) = install_capsule_hit_test_proc(hwnd, rect) { clear_capsule_input_region(hwnd); - return Err(error); + return Err(tauri::Error::Anyhow(error.into())); } Ok(()) } @@ -458,6 +320,7 @@ fn configure_card_hit_test( let rect = capsule_card_hit_rect(client.right - client.left, client.bottom - client.top); clear_capsule_input_region(hwnd); install_capsule_hit_test_proc(hwnd, rect) + .map_err(|error| tauri::Error::Anyhow(error.into())) } #[cfg(target_os = "windows")] From 5dc6e7b39cb1e33fc632038aef10dc983d0981bd Mon Sep 17 00:00:00 2001 From: Chris233 Date: Thu, 29 Oct 2026 16:34:10 +0800 Subject: [PATCH 27/31] fix(ci): scope capsule main-thread dispatch to Windows --- .../src/coordinator/capsule_focus.rs | 23 +++++++++++-------- .../src-tauri/src/tauri_coordinator_host.rs | 3 +++ 2 files changed, 17 insertions(+), 9 deletions(-) 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 526d8bee9..40c6a7967 100644 --- a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs +++ b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs @@ -308,18 +308,23 @@ fn emit_capsule_payload_locked( log::debug!("[capsule] skipped stale payload commit at revision {captured_revision}"); return event_epoch; } - // Callers include the cpal audio callback (~30 Hz) and the backend event task. Everything - // below clones Tauri handles, which must stay on the event-loop thread (see - // `TauriCoordinatorHost::on_main_thread`); the queue keeps frames in emission order. - let inner_for_main = Arc::clone(inner); - inner.host.on_main_thread(move || { - present_capsule_payload(&inner_for_main, payload, event_epoch, prev_state) - }); + // On Windows, callers include the cpal audio callback (~30 Hz) and the backend event + // task. Keep Tauri handle access on the event-loop thread there; other platforms retain + // their existing presentation path. + #[cfg(target_os = "windows")] + { + let inner_for_main = Arc::clone(inner); + inner.host.on_main_thread(move || { + present_capsule_payload(&inner_for_main, payload, event_epoch, prev_state) + }); + } + #[cfg(not(target_os = "windows"))] + present_capsule_payload(inner, payload, event_epoch, prev_state); event_epoch } -/// Main-thread half of [`emit_capsule_payload_locked`]: native or WebView capsule presentation -/// plus the `capsule:state` events. +/// Native or WebView capsule presentation plus the `capsule:state` events. On Windows this +/// runs on the event-loop thread; other platforms keep their existing dispatch behavior. fn present_capsule_payload( inner: &Arc, payload: CapsulePayload, diff --git a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs index f41e21656..b9d3e1a84 100644 --- a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs +++ b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs @@ -22,6 +22,7 @@ use tauri::{AppHandle, Emitter, Manager}; use crate::types::{CapsuleDisplayMode, CapsulePayload, CapsuleState, CapsuleStyle}; /// The Tauri event-loop thread, recorded by [`TauriCoordinatorHost::bind`]. +#[cfg(target_os = "windows")] static MAIN_THREAD: OnceLock = OnceLock::new(); static CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED: AtomicBool = AtomicBool::new(false); @@ -2044,6 +2045,7 @@ impl TauriCoordinatorHost { pub(crate) fn bind(&self, app: AppHandle) { // `bind` runs in Tauri's `setup`, on the event-loop thread. + #[cfg(target_os = "windows")] let _ = MAIN_THREAD.set(std::thread::current().id()); #[cfg(not(mobile))] if crate::desktop_resources::active() { @@ -2067,6 +2069,7 @@ impl TauriCoordinatorHost { /// non-atomic `Rc`, so cloning or dropping one off the main thread races the main thread's /// refcount and eventually frees the event loop while it runs (heap corruption 0xc0000374). /// This posts through the bound handle by reference instead of cloning it on the caller. + #[cfg(target_os = "windows")] pub(crate) fn on_main_thread(&self, task: F) where F: FnOnce() + Send + 'static, From 54ef6303329299f626022916c2273e786690a2b7 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Thu, 29 Oct 2026 16:36:23 +0800 Subject: [PATCH 28/31] fix(ci): keep main-thread storage independent of moved Windows imports --- openless-all/app/src-tauri/src/tauri_coordinator_host.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs index b9d3e1a84..5174df76c 100644 --- a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs +++ b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs @@ -23,7 +23,7 @@ use crate::types::{CapsuleDisplayMode, CapsulePayload, CapsuleState, CapsuleStyl /// The Tauri event-loop thread, recorded by [`TauriCoordinatorHost::bind`]. #[cfg(target_os = "windows")] -static MAIN_THREAD: OnceLock = OnceLock::new(); +static MAIN_THREAD: std::sync::OnceLock = std::sync::OnceLock::new(); static CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED: AtomicBool = AtomicBool::new(false); static CAPSULE_FIRST_SHOW_LOGGED: AtomicBool = AtomicBool::new(false); From 1328820b4c016ff9826958ba4a24e3f63b9dedde Mon Sep 17 00:00:00 2001 From: Chris233 Date: Thu, 29 Oct 2026 17:47:57 +0800 Subject: [PATCH 29/31] fix(windows): resolve capsule layout-watch handles on the main thread --- .../src-tauri/src/tauri_coordinator_host.rs | 65 +++++++++++++------ 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs index 5174df76c..417684bf9 100644 --- a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs +++ b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs @@ -1039,6 +1039,8 @@ impl CapsuleWindowState { #[derive(Clone)] pub(crate) struct TauriCapsuleWindow { app: AppHandle, + #[cfg(target_os = "windows")] + host: TauriCoordinatorHost, state: Arc, qa_context: Arc, } @@ -1843,38 +1845,59 @@ impl TauriCapsuleWindow { .layout_watch_epoch .fetch_add(1, Ordering::SeqCst) .wrapping_add(1); + let state = Arc::clone(&self.state); + // The Windows timer must not own a Tauri handle: both cloning it on a Tokio + // worker and dropping it when the timer exits can race the event loop's Rc. + #[cfg(target_os = "windows")] + let host = self.host.clone(); + #[cfg(not(target_os = "windows"))] let capsule = self.clone(); tauri::async_runtime::spawn(async move { loop { tokio::time::sleep(std::time::Duration::from_millis(500)).await; - if capsule.state.layout_watch_epoch.load(Ordering::SeqCst) != epoch { + if state.layout_watch_epoch.load(Ordering::SeqCst) != epoch { break; } + #[cfg(target_os = "windows")] + { + let host_for_main = host.clone(); + host.on_main_thread(move || { + if let Some(capsule) = host_for_main.capsule_window() { + capsule.refresh_watched_layout(epoch); + } + }); + } + #[cfg(not(target_os = "windows"))] let _ = capsule.run_on_main_thread(move |capsule| { - if capsule.state.layout_watch_epoch.load(Ordering::SeqCst) != epoch { - return; - } - let Some(window) = capsule.window() else { - capsule.stop_layout_watch(); - return; - }; - if !window.is_visible().unwrap_or(false) { - capsule.stop_layout_watch(); - return; - } - let payload = capsule.state.snapshot.lock().payload.clone(); - if let Some(payload) = payload { - capsule.maybe_position_capsule_bottom_center( - &window, - payload.translation, - capsule.state.cached_style(), - ); - } + capsule.refresh_watched_layout(epoch); }); } }); } + fn refresh_watched_layout(&self, epoch: u64) { + // A stopped/restarted watch can still have an old tick queued on the main thread. + if self.state.layout_watch_epoch.load(Ordering::SeqCst) != epoch { + return; + } + let Some(window) = self.window() else { + self.stop_layout_watch(); + return; + }; + if !window.is_visible().unwrap_or(false) { + self.stop_layout_watch(); + return; + } + let payload = self.state.snapshot.lock().payload.clone(); + if let Some(payload) = payload { + self.maybe_position_capsule_bottom_center( + &window, + payload.translation, + self.state.cached_style(), + ); + } + } + fn update_cursor_for_payload(&self, payload: &CapsulePayload, style: CapsuleStyle) { #[cfg(not(mobile))] { @@ -2163,6 +2186,8 @@ impl TauriCoordinatorHost { pub(crate) fn capsule_window(&self) -> Option { self.app().map(|app| TauriCapsuleWindow { app, + #[cfg(target_os = "windows")] + host: self.clone(), state: Arc::clone(&self.capsule), qa_context: Arc::clone(&self.qa_context), }) From cde4131e0e4849c64caf8acfea2d8aa8eb2c526c Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Fri, 30 Oct 2026 14:15:12 +0800 Subject: [PATCH 30/31] =?UTF-8?q?Revert=20"feat(updater):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=B8=85=E5=8D=95=E6=94=B9=E7=94=B1=20CDN=20=E6=8F=90?= =?UTF-8?q?=E4=BE=9B=EF=BC=8C=E5=AE=A2=E6=88=B7=E7=AB=AF=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=AF=BB=20release=20=E9=87=8C=E7=9A=84=20json=20(#1277)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 1fe6ad50d24ba11d4a0fa582e8857ac6c60f083a. --- .github/workflows/android-apk.yml | 21 +-- .github/workflows/release-tauri.yml | 21 ++- README.md | 13 +- README.zh.md | 13 +- RELEASING.md | 31 +---- .../crates/openless-core/src/shared_types.rs | 9 +- .../android-apk-workflow-contract.test.mjs | 25 ++-- .../release-publication-contract.test.mjs | 18 --- .../repository-owner-contract.test.mjs | 31 +---- .../write-updater-manifest-contract.test.mjs | 66 --------- .../app/scripts/write-updater-manifest.mjs | 19 +-- .../app/src-tauri/src/android/updater.rs | 73 ++++++---- .../src-tauri/src/android/updater_logic.rs | 76 +++++++++- .../app/src-tauri/src/commands/settings.rs | 107 ++++++-------- openless-all/app/src-tauri/src/lib.rs | 1 - .../app/src-tauri/src/update_source.rs | 130 ------------------ openless-all/app/src-tauri/tauri.conf.json | 3 +- 17 files changed, 213 insertions(+), 444 deletions(-) delete mode 100644 openless-all/app/scripts/write-updater-manifest-contract.test.mjs delete mode 100644 openless-all/app/src-tauri/src/update_source.rs diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 5e15404b1..7fbe29d4f 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -515,7 +515,8 @@ jobs: working-directory: openless-all/app env: OPENLESS_UPDATE_APK_DIR: ${{ steps.stage.outputs.out_dir }} - OPENLESS_UPDATE_CDN_BASE_URL: https://dl.aeoform.com + OPENLESS_UPDATE_REPO: Open-Less/openless + OPENLESS_UPDATE_MIRROR_BASE_URL: https://fastgit.cc/https://github.com OPENLESS_RELEASE_CHANNEL: ${{ env.OPENLESS_RELEASE_CHANNEL }} OPENLESS_RELEASE_TAG: ${{ github.ref_name }} run: | @@ -525,17 +526,7 @@ jobs: node scripts/write-updater-manifest.mjs done - # The manifests are not release assets any more (the in-app updater reads them from the - # project CDN), but they must still leave the run so the CDN sync can publish them. - - name: Upload Android updater manifests - uses: actions/upload-artifact@v4 - with: - name: openless-android-updater-manifests - if-no-files-found: error - path: | - ${{ steps.stage.outputs.out_dir }}/latest-android-*.json - - - name: Append release files (signatures + aliases) + - name: Append release files (manifests + signatures) id: release_assets shell: bash run: | @@ -543,7 +534,7 @@ jobs: out_dir="${{ steps.stage.outputs.out_dir }}" { echo "${{ steps.stage.outputs.release_files }}" - for f in "$out_dir"/*.apk.sig "$out_dir"/OpenLess_android_*.apk; do + for f in "$out_dir"/*.apk.sig "$out_dir"/latest-android-*.json "$out_dir"/OpenLess_android_*.apk; do [ -e "$f" ] && echo "$f" done } > "$RUNNER_TEMP/android-release-files.txt" @@ -564,8 +555,8 @@ jobs: ### Android 应用内更新 - - 清单由项目 CDN 提供:Stable 读 `/updates/stable/latest-android-.json`,Beta 读 `/updates/beta/latest-android--beta.json`。 - - 应用内会自动检查并拉起系统安装器,无需手动下载清单。 + - Stable 用户检查 `latest-android-aarch64.json`(及 mirror 变体)。 + - Beta 用户(设置 → 高级 → 加入 Beta)检查带 `-beta` 后缀的 manifest。 EOF - name: Attach Android assets to GitHub Release diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index ed7880b9a..660194600 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -531,11 +531,15 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} OPENLESS_UPDATE_TARGET: ${{ matrix.updater-target }} OPENLESS_UPDATE_ARCH: ${{ matrix.updater-arch }} - OPENLESS_UPDATE_CDN_BASE_URL: https://dl.aeoform.com + OPENLESS_UPDATE_REPO: ${{ github.repository }} + OPENLESS_UPDATE_MIRROR_BASE_URL: https://fastgit.cc/https://github.com # On the beta channel output latest-{tgt}-{arch}-beta.json; stable keeps the old filename. OPENLESS_RELEASE_CHANNEL: ${{ env.OPENLESS_RELEASE_CHANNEL }} - # Manifest URLs use the CDN's tag-scoped `releases//` path. Stable - # workflow validation without a release tag falls back to the CDN `latest` path. + # On the beta channel the script must write manifest.url as releases/download//... + # rather than releases/latest (the latter is always = Stable, so beta users downloading + # via url would fetch the wrong file). + # On workflow_dispatch github.ref_name is not a tag — but then channel=stable and the + # script does not read this field, so it is safe. OPENLESS_RELEASE_TAG: ${{ env.OPENLESS_RELEASE_TAG }} run: node scripts/write-updater-manifest.mjs @@ -700,9 +704,8 @@ jobs: # The administrator explicitly publishes the shared release afterwards. draft: true # Beta releases must be marked prerelease=true: GitHub UI collapses them and regular - # users do not see them. Channel isolation for the updater is a CDN URL path - # (`/updates/stable/` vs `/updates/beta/`, see src-tauri/src/update_source.rs), not a - # filename suffix in these assets. + # users do not see them; only latest-*-beta.json is uploaded, so stable users' + # endpoint (latest-*.json) is never overwritten and beta never leaks into stable. prerelease: ${{ env.OPENLESS_RELEASE_CHANNEL == 'beta' }} # Non-leader jobs have OPENLESS_RELEASE_BODY_PATH as an empty string; softprops keeps # the existing content via the `body || existing.body` branch when body is empty. @@ -712,11 +715,6 @@ jobs: body_path: ${{ env.OPENLESS_RELEASE_BODY_PATH }} # generate_release_notes also runs only on the leader, avoiding duplicates from matrix jobs. generate_release_notes: ${{ matrix.updater-target == 'darwin' && matrix.updater-arch == 'aarch64' }} - # `latest-*.json` is deliberately NOT attached: the in-app updater reads its manifests - # from the project CDN (`/updates/{stable,beta}/`, see src-tauri/src/update_source.rs), - # and cloning them here added fourteen near-identical files to every release. The - # manifests still leave the workflow through the `*-updater` artifacts above, which is - # where the CDN sync job picks them up. files: | openless-all/app/src-tauri/target/release/bundle/dmg/*.dmg openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz @@ -725,6 +723,7 @@ jobs: openless-all/app/src-tauri/target/release/bundle/nsis/*.exe.sig openless-all/app/src-tauri/target/release/bundle/msi/*.msi openless-all/app/src-tauri/target/release/bundle/msi/*.msi.sig + openless-all/app/src-tauri/target/release/bundle/latest-*.json openless-all/app/src-tauri/target/release/downloads/* # ── Hand the finished tag to the platform workflows that ride the same Release ── diff --git a/README.md b/README.md index c3d9a7aaf..da70eb23f 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ Go to [Releases](https://github.com/Open-Less/openless/releases) and download: - **Older 32-bit ARM**: `OpenLess__armeabi-v7a.apk` - **64-bit emulator**: `OpenLess__x86_64.apk` - **32-bit emulator**: `OpenLess__x86.apk` - - In-app updates (Settings → About) read `https://dl.aeoform.com/updates/stable/latest-android-{arch}.json`; Beta users join Beta in Advanced settings and read the `.../updates/beta/latest-android-{arch}-beta.json` counterpart. + - In-app updates (Settings → About) use `latest-android-{arch}.json` manifests; Beta users join Beta in Advanced settings. - Debug smoke builds: `OpenLess-android-debug-{abi}-*.apk` from workflow_dispatch artifacts. - If unsure, run `adb shell getprop ro.product.cpu.abi` and pick the matching APK. - **Linux**: the Tauri/WebView build has been retired. The native `eframe` host uses the shared Core 2.0 services. After CI and real Ubuntu audio, input, install, upgrade and rollback acceptance, the shared release includes deb/rpm packages and `SHA256SUMS`. Linux requires glibc ≥ 2.39 (Ubuntu 24.04 or a comparable distribution); there is no AppImage or in-app updater. @@ -334,16 +334,7 @@ Rules of thumb: - **Beta work must not leak to Stable.** `main` receives merges only from `beta`, performed by maintainers after a successful two-platform smoke build. No direct pushes to `main`. - **Stable releases are cut from `main`** by pushing a `v-tauri` tag — see the maintainer release checklist below. -Beta release distribution (manual-download opt-in): the in-app updater's Stable channel only ever reads the Stable manifest path, so regular users never receive Beta builds via auto-update. Users who want to try Beta open **Settings → About**, enable "Join Beta channel", and download the latest Beta installer manually from the link the app fetches from GitHub. Tag convention: `v-beta-tauri` produces the Beta release (marked as a GitHub pre-release), while `v-tauri` produces the Stable release. - -Updater manifests are served from the project CDN, one rolling file per platform and channel: - -```text -https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json -https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json -``` - -The channel split is the URL path (`/updates/stable/` vs `/updates/beta/`), so a Stable client cannot reach a Beta manifest even if it asked for the wrong URL. Artifact URLs inside the manifest point at the same CDN (`https://dl.aeoform.com/releases/{tag}/{asset}`), which is what mainland-China users download from. The manifests are generated by the release workflows as run artifacts and are **not** attached to the GitHub Release, so maintainers must upload them to the CDN after a build — see `RELEASING.md`. Clients built before this switch read the old `releases/latest/download/latest-*.json` assets and therefore cannot auto-update past the last release that still carries them; they need one manual download. +Beta release distribution (manual-download opt-in): the in-app updater always reads the Stable manifest, so regular users never receive Beta builds via auto-update. Users who want to try Beta open **Settings → About**, enable "Join Beta channel", and download the latest Beta installer manually from the link the app fetches from GitHub. Tag convention: `v-beta-tauri` produces the Beta release (marked as a GitHub pre-release; manifest written as `latest-{tgt}-{arch}-beta.json`), while `v-tauri` produces the Stable release. The two manifest files never overlap, so Stable users' updater feed cannot pick up Beta releases. ## Credentials diff --git a/README.zh.md b/README.zh.md index 21ffea606..bcdceb470 100644 --- a/README.zh.md +++ b/README.zh.md @@ -247,7 +247,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 - **旧款 32 位 ARM**:`OpenLess__armeabi-v7a.apk` - **64 位模拟器**:`OpenLess__x86_64.apk` - **32 位模拟器**:`OpenLess__x86.apk` - - 应用内更新(设置 → 关于)读取 `https://dl.aeoform.com/updates/stable/latest-android-{arch}.json`;Beta 用户在高级设置加入 Beta 渠道,读取 `.../updates/beta/latest-android-{arch}-beta.json`。 + - 应用内更新(设置 → 关于)读取 `latest-android-{arch}.json`;Beta 用户在高级设置加入 Beta 渠道。 - 调试包:`OpenLess-android-debug-{abi}-*.apk`(workflow_dispatch 产物)。 - 不确定时执行 `adb shell getprop ro.product.cpu.abi`,下载对应 ABI 的包。 - **Linux**:原 Tauri/WebView 构建已退出 Linux 路线。基于共享 Core 2.0 服务的原生 `eframe` 宿主经 CI 和 Ubuntu 真实音频、输入、安装、升级、回滚验收后,随共用 Release 提供 deb/rpm 和 `SHA256SUMS`。要求 glibc ≥ 2.39(Ubuntu 24.04 或同等级发行版);没有 AppImage 或应用内更新。 @@ -341,16 +341,7 @@ your fork / topic branch - **Beta 的工作不得泄漏到 Stable。** `main` 只接受来自 `beta` 的合并,由维护者在一次成功的双平台冒烟构建之后执行。不允许直接向 `main` 推送。 - **Stable 发布从 `main` 切出**,通过推送 `v-tauri` 标签完成——见下方的维护者发布清单。 -Beta 发布的分发(手动下载、可选加入):应用内更新器的 Stable 渠道只读 Stable 清单路径,因此普通用户绝不会通过自动更新拿到 Beta 构建。想尝试 Beta 的用户打开 **设置 → 关于**,启用“加入 Beta 频道”,再从应用从 GitHub 获取的链接处手动下载最新的 Beta 安装包。标签约定:`v-beta-tauri` 产出 Beta 发布(标记为 GitHub pre-release),而 `v-tauri` 产出 Stable 发布。 - -更新清单由项目 CDN 提供,每个平台/渠道一个滚动文件: - -```text -https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json -https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json -``` - -渠道隔离就是 URL 路径(`/updates/stable/` vs `/updates/beta/`),Stable 客户端即使请求错地址也拿不到 Beta 清单。清单里的下载地址同样指向 CDN(`https://dl.aeoform.com/releases/{tag}/{asset}`),国内用户直接从这里下载。清单由发布 workflow 生成为运行产物(run artifact),**不再挂到 GitHub Release**;因此发版后需要维护者手动把清单上传到 CDN,见 `RELEASING.md`。改动前装的客户端读的是旧的 `releases/latest/download/latest-*.json`,因此无法自动更新到最后一个还带这些文件的版本之后,需要手动下载一次。 +Beta 发布的分发(手动下载、可选加入):应用内的更新器始终读取 Stable 清单,因此普通用户绝不会通过自动更新拿到 Beta 构建。想尝试 Beta 的用户打开 **设置 → 关于**,启用“加入 Beta 频道”,再从应用从 GitHub 获取的链接处手动下载最新的 Beta 安装包。标签约定:`v-beta-tauri` 产出 Beta 发布(标记为 GitHub pre-release;清单写为 `latest-{tgt}-{arch}-beta.json`),而 `v-tauri` 产出 Stable 发布。两份清单文件互不重叠,因此 Stable 用户的更新器源不会拾取到 Beta 发布。 ## 凭据 diff --git a/RELEASING.md b/RELEASING.md index d15806b04..0a87893b8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -165,34 +165,9 @@ The script takes a plain `X.Y.Z`; for a prerelease version such as 4. The applicable [desktop feature and device acceptance](docs/2.0-desktop-acceptance.md), signing, and distribution requirements are met; green builds alone do not establish product readiness. Linux acceptance below is required before including Linux assets. 5. Then, and only then, push the release tag. 6. Both Beta and Stable tag workflows upload Tauri and Android assets to a shared - draft. Wait for **both** workflows to succeed, verify the packages, then publish that - draft (Beta as a prerelease; Stable as latest). + draft. Wait for **both** workflows to succeed, verify the packages and channel + updater manifests, then publish that draft (Beta as a prerelease; Stable as latest). Do not rerun an asset workflow after publication without first returning the release to draft. - - The workflows also write the channel updater manifests (`latest-{target}-{arch}.json` and - `latest-{target}-{arch}-beta.json`) but deliberately keep them **out** of the release assets — - current clients read the CDN, not the release. Download them from the run and publish them to - the CDN as part of publishing the release: - - | platform | workflow artifact | - | --- | --- | - | macOS | `openless-macos--updater` | - | Windows | `openless-windows-x64-updater` | - | Android | `openless-android-updater-manifests` | - - Destination (overwrite in place; the file name is per platform and channel, never per - version): - - ```text - https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json - https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json - ``` - - Their `url` fields must point at the CDN artifacts (`.../releases/{tag}/{asset}`). - Manifest upload is the step that makes a release reachable by auto-update: a published - release whose manifests were not synced is downloadable by hand but invisible to the updater. - Clients built before the CDN switch read the old `releases/latest/download/latest-*.json` - assets, which new releases no longer carry, so they need one manual download. 7. Publishing Stable with the administrator's CLI/UI credential automatically triggers `sync-homebrew-cask.yml`. It accepts only the published latest Stable tag, preserves application build metadata in DMG filenames, verifies matching @@ -366,6 +341,6 @@ change the published channel. The same format applies to subsequent releases. 2. For a Stable release, a maintainer merges `beta → main`. 3. An **admin** bumps the Tauri version (five-location sync), verifies CI is green, and pushes the release tag, which triggers the macOS/Windows/Android asset - workflows and their channel updater manifests. + workflows and their channel-specific updater manifests. 4. After both release workflows pass, an admin verifies and publishes the shared draft. Linux packages are attached independently only after Linux acceptance. diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index 57b0ff7b8..4a49657e3 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -140,11 +140,10 @@ pub enum MacosNewlineMode { } /// Auto-update channel. Picks which manifest the background AutoUpdateGate -/// fetches. `Stable` = `/updates/stable/latest-{target}-{arch}.json` on the CDN -/// (`latest-android-{arch}.json` for the Android client). `Beta` = the -/// `/updates/beta/` counterpart with a `-beta` filename suffix. The Settings -/// manual "check stable / check beta" buttons pass the channel explicitly and -/// ignore this pref. +/// fetches. `Stable` = `latest-android-{arch}.json` (or the desktop +/// plugin-updater stable endpoints). `Beta` = `latest-android-{arch}-beta.json` +/// (or the desktop beta endpoints). The Settings manual "check stable / check +/// beta" buttons pass the channel explicitly and ignore this pref. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum UpdateChannel { diff --git a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs index 90312c3cd..33fffe94f 100644 --- a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs +++ b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs @@ -169,15 +169,16 @@ try { const apk = `OpenLess_${version}_${gradleAbi}.apk`; const alias = `OpenLess_android_${gradleAbi}.apk`; originals.push(`${directory}/${apk}`); - return [apk, `${apk}.sig`, alias, `${alias}.sig`]; + return [ + apk, + `${apk}.sig`, + alias, + `${alias}.sig`, + `latest-android-${abi}.json`, + `latest-android-${abi}-mirror.json`, + ]; }); - // The manifests are still written (they feed the CDN sync through a workflow artifact) but - // must never be re-attached to the release: the in-app updater reads them from the CDN. - const manifests = ANDROID_ABI_MATRIX.flatMap(({ abi }) => [ - `latest-android-${abi}.json`, - `latest-android-${abi}-mirror.json`, - ]); - for (const name of [...names, ...manifests]) writeFileSync(join(releaseTmp, name), 'fixture'); + for (const name of names) writeFileSync(join(releaseTmp, name), 'fixture'); const step = parsed.jobs['publish-android-release'].steps.find( (step) => step.id === 'release_assets', ); @@ -198,14 +199,6 @@ try { .trim() .split(/\r?\n/); assert.deepEqual(assets.sort(), names.map((name) => `${directory}/${name}`).sort()); - const manifestUpload = parsed.jobs['publish-android-release'].steps.find((step) => - step.uses?.startsWith('actions/upload-artifact@'), - ); - assert.match( - manifestUpload?.with.path, - /latest-android-\*\.json/, - 'manifests must leave the run as an artifact so the CDN sync can publish them', - ); } finally { rmSync(releaseTmp, { recursive: true, force: true }); } diff --git a/openless-all/app/scripts/release-publication-contract.test.mjs b/openless-all/app/scripts/release-publication-contract.test.mjs index a56ee867a..b8d3e21cb 100644 --- a/openless-all/app/scripts/release-publication-contract.test.mjs +++ b/openless-all/app/scripts/release-publication-contract.test.mjs @@ -15,24 +15,6 @@ for (const name of ['release-tauri.yml', 'android-apk.yml']) { `${name}: no channel may publish a partially built release`, ); assert.match(publishers[0].with.prerelease, /OPENLESS_RELEASE_CHANNEL == 'beta'/); - // Updater manifests belong to the CDN, not to the release assets: current clients read - // `/updates/{stable,beta}/` (see src-tauri/src/update_source.rs), and cloning them into the - // release produced fourteen near-identical files per tag. - assert.doesNotMatch( - publishers[0].with.files, - /latest-[^\s]*\.json/, - `${name}: updater manifests must not be attached to the release`, - ); - const manifestUploads = steps.filter( - (step) => - step.uses?.startsWith('actions/upload-artifact@') && - /latest-[^\s]*\.json/.test(step.with?.path ?? ''), - ); - assert.equal( - manifestUploads.length, - 1, - `${name}: exactly one artifact must carry the updater manifests for the CDN sync`, - ); } const sync = workflow('sync-homebrew-cask.yml'); assert.deepEqual(sync.on.release.types, ['published']); diff --git a/openless-all/app/scripts/repository-owner-contract.test.mjs b/openless-all/app/scripts/repository-owner-contract.test.mjs index 6bf991801..50c56e94b 100644 --- a/openless-all/app/scripts/repository-owner-contract.test.mjs +++ b/openless-all/app/scripts/repository-owner-contract.test.mjs @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)); +const currentRepo = 'Open-Less/openless'; const legacyRepo = 'appergb/openless'; const repositoryReferences = [ @@ -17,7 +18,6 @@ const repositoryReferences = [ 'openless-all/app/src-tauri/src/android/updater_logic.rs', 'openless-all/app/src-tauri/src/commands/mod.rs', 'openless-all/app/src-tauri/src/commands/settings.rs', - 'openless-all/app/src-tauri/src/update_source.rs', 'openless-all/app/src-tauri/tauri.conf.json', 'openless-all/app/src/components/AutoUpdate.tsx', 'openless-all/app/src/components/SettingsModal.tsx', @@ -41,34 +41,9 @@ assert( Array.isArray(updaterEndpoints) && updaterEndpoints.length > 0, 'desktop updater endpoints are missing', ); - -// Updater manifests are served by the project CDN, not by the GitHub Release assets. Both -// halves of that arrangement live in different files, so pin them together here: the endpoint -// in tauri.conf.json must come from the same CDN base that `update_source.rs` declares, and it -// must be the Stable path. -const updateSource = await readFile( - join(repoRoot, 'openless-all/app/src-tauri/src/update_source.rs'), - 'utf8', -); -const cdnBase = /CDN_BASE: &str = "(https:\/\/[^"]+)"/.exec(updateSource)?.[1]; -assert(cdnBase, 'update_source.rs must declare the CDN_BASE constant'); assert( - updateSource.includes('/updates/{path}/latest-'), - 'update_source.rs must derive manifest URLs from the CDN update path', + updaterEndpoints.every((endpoint) => endpoint.includes(currentRepo)), + 'desktop updater endpoints must use the current GitHub repository', ); -for (const endpoint of updaterEndpoints) { - assert( - endpoint.startsWith(`${cdnBase}/updates/stable/latest-`), - `desktop updater endpoint must point at the CDN Stable manifest: ${endpoint}`, - ); - assert( - !endpoint.includes('github.com'), - `desktop updater endpoint must not read GitHub Release assets: ${endpoint}`, - ); - assert( - endpoint.includes('{{target}}') && endpoint.includes('{{arch}}'), - `desktop updater endpoint must keep the {{target}}/{{arch}} placeholders: ${endpoint}`, - ); -} console.log('repository-owner-contract.test.mjs passed'); diff --git a/openless-all/app/scripts/write-updater-manifest-contract.test.mjs b/openless-all/app/scripts/write-updater-manifest-contract.test.mjs deleted file mode 100644 index 4acf2e332..000000000 --- a/openless-all/app/scripts/write-updater-manifest-contract.test.mjs +++ /dev/null @@ -1,66 +0,0 @@ -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const script = fileURLToPath(new URL('./write-updater-manifest.mjs', import.meta.url)); -const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); -const releaseTag = 'v2.0.2-Beta.3-tauri'; -const tempDir = mkdtempSync(join(tmpdir(), 'openless-updater-')); -const assetName = `OpenLess_${packageJson.version}_arm64-v8a.apk`; - -try { - writeFileSync(join(tempDir, assetName), 'fixture'); - writeFileSync(join(tempDir, `${assetName}.sig`), 'signature'); - execFileSync(process.execPath, [script], { - env: { - ...process.env, - OPENLESS_UPDATE_APK_DIR: tempDir, - OPENLESS_UPDATE_ARCH: 'aarch64', - OPENLESS_UPDATE_CDN_BASE_URL: 'https://cdn.example/', - OPENLESS_UPDATE_TARGET: 'android', - OPENLESS_RELEASE_CHANNEL: 'beta', - OPENLESS_RELEASE_TAG: releaseTag, - }, - stdio: 'pipe', - }); - - const expected = - `https://cdn.example/releases/${encodeURIComponent(releaseTag)}/` + - encodeURIComponent(assetName); - for (const suffix of ['', '-mirror']) { - const manifest = JSON.parse( - readFileSync(join(tempDir, `latest-android-aarch64-beta${suffix}.json`), 'utf8'), - ); - assert.equal(manifest.url, expected); - } - - const stableTag = 'v2.0.1-tauri'; - execFileSync(process.execPath, [script], { - env: { - ...process.env, - OPENLESS_UPDATE_APK_DIR: tempDir, - OPENLESS_UPDATE_ARCH: 'aarch64', - OPENLESS_UPDATE_CDN_BASE_URL: 'https://cdn.example/', - OPENLESS_UPDATE_TARGET: 'android', - OPENLESS_RELEASE_CHANNEL: 'stable', - OPENLESS_RELEASE_TAG: stableTag, - }, - stdio: 'pipe', - }); - const stableExpected = - `https://cdn.example/releases/${encodeURIComponent(stableTag)}/` + - encodeURIComponent(assetName); - for (const suffix of ['', '-mirror']) { - const manifest = JSON.parse( - readFileSync(join(tempDir, `latest-android-aarch64${suffix}.json`), 'utf8'), - ); - assert.equal(manifest.url, stableExpected); - } -} finally { - rmSync(tempDir, { recursive: true, force: true }); -} - -console.log('write-updater-manifest-contract.test.mjs passed'); diff --git a/openless-all/app/scripts/write-updater-manifest.mjs b/openless-all/app/scripts/write-updater-manifest.mjs index 6c1258c95..d9512ed98 100755 --- a/openless-all/app/scripts/write-updater-manifest.mjs +++ b/openless-all/app/scripts/write-updater-manifest.mjs @@ -6,10 +6,9 @@ import { fileURLToPath } from 'node:url'; const target = process.env.OPENLESS_UPDATE_TARGET; const arch = process.env.OPENLESS_UPDATE_ARCH; -const cdnBaseUrl = (process.env.OPENLESS_UPDATE_CDN_BASE_URL || 'https://dl.aeoform.com').replace( - /\/$/, - '', -); +const repo = process.env.OPENLESS_UPDATE_REPO || 'Open-Less/openless'; +const mirrorBaseUrl = + process.env.OPENLESS_UPDATE_MIRROR_BASE_URL || 'https://fastgit.cc/https://github.com'; const rawChannel = (process.env.OPENLESS_RELEASE_CHANNEL || 'stable').toLowerCase(); if (rawChannel !== 'stable' && rawChannel !== 'beta') { throw new Error( @@ -112,17 +111,21 @@ if (!existsSync(signaturePath)) { const assetName = basename(artifact); const manifestName = `latest-${target}-${arch}${channelSuffix}.json`; const mirrorManifestName = `latest-${target}-${arch}${channelSuffix}-mirror.json`; -const releasePath = - rawChannel === 'beta' ? releaseTag : /^v.+-tauri$/.test(releaseTag) ? releaseTag : 'latest'; -const cdnAssetUrl = `${cdnBaseUrl}/releases/${encodeURIComponent(releasePath)}/${encodeURIComponent(assetName)}`; +const downloadPath = + rawChannel === 'beta' + ? `releases/download/${releaseTag}/${assetName}` + : `releases/latest/download/${assetName}`; +const githubAssetUrl = `https://github.com/${repo}/${downloadPath}`; +const mirrorAssetUrl = `${mirrorBaseUrl.replace(/\/$/, '')}/${repo}/${downloadPath}`; const manifest = { version: packageJson.version, pub_date: new Date().toISOString(), - url: cdnAssetUrl, + url: githubAssetUrl, signature: readFileSync(signaturePath, 'utf8').trim(), }; const mirrorManifest = { ...manifest, + url: mirrorAssetUrl, }; const outDir = target === 'android' ? bundleDir : bundleDir; diff --git a/openless-all/app/src-tauri/src/android/updater.rs b/openless-all/app/src-tauri/src/android/updater.rs index eea55ba01..ab54d13e8 100644 --- a/openless-all/app/src-tauri/src/android/updater.rs +++ b/openless-all/app/src-tauri/src/android/updater.rs @@ -8,10 +8,12 @@ mod android_impl { use tauri::{AppHandle, Emitter}; use crate::android::updater_logic::{ - format_manifest_error, map_abi_to_arch, verify_updater_signature, version_is_newer, - INSTALLER_NOT_OPENED_MSG, UPDATER_PUBKEY_B64, + beta_manifest_urls, format_manifest_error, map_abi_to_arch, stable_manifest_urls, + verify_updater_signature, version_is_newer, INSTALLER_NOT_OPENED_MSG, UPDATER_PUBKEY_B64, + }; + use crate::commands::{ + fetch_latest_beta_release, parse_latest_beta_from_atom, AppUpdateMetadata, }; - use crate::commands::AppUpdateMetadata; use crate::net; use crate::types::UpdateChannel; @@ -78,33 +80,47 @@ mod android_impl { pub async fn check_update(channel: UpdateChannel) -> Result, String> { let arch = device_arch()?; - // Manifests live on the project CDN; channel is a URL path, not a filename suffix, so - // there is no tag discovery and no GitHub dependency (see `crate::update_source`). - let url = crate::update_source::manifest_url(channel, "android", arch); + let urls = match channel { + UpdateChannel::Stable => stable_manifest_urls(arch), + UpdateChannel::Beta => { + let Some(latest) = fetch_latest_beta_release().await? else { + return Err("尚未发布过 Beta 版本".to_string()); + }; + beta_manifest_urls(arch, &latest.tag_name) + } + }; let current = env!("CARGO_PKG_VERSION").to_string(); - match fetch_manifest(&url).await { - Ok(manifest) => { - if !version_is_newer(&manifest.version, ¤t) { - return Ok(None); + let mut last_err = String::new(); + for url in urls { + match fetch_manifest(&url).await { + Ok(manifest) => { + if !version_is_newer(&manifest.version, ¤t) { + return Ok(None); + } + let raw_json = serde_json::json!({ + "version": manifest.version, + "url": manifest.url, + "signature": manifest.signature, + "pubDate": manifest.pub_date, + "notes": manifest.notes, + }); + return Ok(Some(AppUpdateMetadata { + rid: 0, + current_version: current, + version: manifest.version, + date: manifest.pub_date, + body: manifest.notes, + raw_json, + })); } - let raw_json = serde_json::json!({ - "version": manifest.version, - "url": manifest.url, - "signature": manifest.signature, - "pubDate": manifest.pub_date, - "notes": manifest.notes, - }); - Ok(Some(AppUpdateMetadata { - rid: 0, - current_version: current, - version: manifest.version, - date: manifest.pub_date, - body: manifest.notes, - raw_json, - })) + Err(err) => last_err = err, } - Err(err) => Err(err), } + Err(if last_err.is_empty() { + "无法获取更新清单".to_string() + } else { + last_err + }) } fn verify_signature(apk_bytes: &[u8], signature_b64: &str) -> Result<(), String> { @@ -191,6 +207,11 @@ mod android_impl { } Ok(()) } + + #[allow(dead_code)] + pub fn parse_beta_atom(body: &str) -> Option { + parse_latest_beta_from_atom(body) + } } #[cfg(target_os = "android")] diff --git a/openless-all/app/src-tauri/src/android/updater_logic.rs b/openless-all/app/src-tauri/src/android/updater_logic.rs index b992e58f5..7e8e4fc26 100644 --- a/openless-all/app/src-tauri/src/android/updater_logic.rs +++ b/openless-all/app/src-tauri/src/android/updater_logic.rs @@ -1,4 +1,7 @@ -//! Pure Android updater helpers (version compare, minisign verify). Testable on all targets. +//! Pure Android updater helpers (manifest URLs, version compare). Testable on all targets. + +pub const MIRROR_BASE: &str = "https://fastgit.cc/https://github.com/Open-Less/openless"; +pub const DIRECT_BASE: &str = "https://github.com/Open-Less/openless"; /// Must match `plugins.updater.pubkey` in `tauri.conf.json` (TAURI_SIGNING_PRIVATE_KEY pair). pub const UPDATER_PUBKEY_B64: &str = @@ -28,6 +31,20 @@ pub fn version_is_newer(remote: &str, current: &str) -> bool { remote > current } +pub fn stable_manifest_urls(arch: &str) -> Vec { + vec![ + format!("{MIRROR_BASE}/releases/latest/download/latest-android-{arch}-mirror.json"), + format!("{DIRECT_BASE}/releases/latest/download/latest-android-{arch}.json"), + ] +} + +pub fn beta_manifest_urls(arch: &str, tag: &str) -> Vec { + vec![ + format!("{MIRROR_BASE}/releases/download/{tag}/latest-android-{arch}-beta-mirror.json"), + format!("{DIRECT_BASE}/releases/download/{tag}/latest-android-{arch}-beta.json"), + ] +} + /// Human-readable manifest fetch failure for UI tooltips. pub fn format_manifest_error(status: u16, url: &str) -> String { if status == 404 { @@ -103,6 +120,63 @@ mod tests { assert!(!version_is_newer("not-a-version", "1.3.18-Beta.7")); } + #[test] + fn stable_manifest_urls_use_latest_download_path() { + let urls = stable_manifest_urls("aarch64"); + assert_eq!(urls.len(), 2); + assert!(urls[0].contains("latest-android-aarch64-mirror.json")); + assert!(urls[1].ends_with("latest-android-aarch64.json")); + assert!(!urls[1].contains("-beta")); + } + + #[test] + fn beta_manifest_urls_include_tag_and_beta_suffix() { + let urls = beta_manifest_urls("aarch64", "v1.3.8-1-beta-tauri"); + assert!(urls[0].contains("/releases/download/v1.3.8-1-beta-tauri/")); + assert!(urls[1].ends_with("latest-android-aarch64-beta.json")); + } + + #[test] + fn beta_manifest_urls_use_newest_modern_beta_tag_from_atom() { + let body = r#" + + 2026-07-15T07:00:00Z + + + + 2026-06-17T15:41:46Z + + +"#; + let latest = crate::commands::parse_latest_beta_from_atom(body) + .expect("Android updater must discover the modern Beta tag"); + + let urls = beta_manifest_urls("aarch64", &latest.tag_name); + + assert_eq!(latest.tag_name, "v1.3.15-Beta.1-tauri"); + assert!(urls + .iter() + .all(|url| url.contains("/releases/download/v1.3.15-Beta.1-tauri/"))); + } + + #[test] + fn beta_manifest_urls_skip_malformed_modern_tags_from_atom() { + let body = r#" + + + +"#; + let latest = crate::commands::parse_latest_beta_from_atom(body) + .expect("Android updater must skip malformed Beta tags"); + + let urls = beta_manifest_urls("aarch64", &latest.tag_name); + + assert_eq!(latest.tag_name, "v1.3.15-Beta.1-tauri"); + assert!(urls + .iter() + .all(|url| url.contains("/releases/download/v1.3.15-Beta.1-tauri/"))); + } + #[test] fn map_abi_to_arch_maps_arm64() { assert_eq!(map_abi_to_arch("arm64-v8a"), "aarch64"); diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 4ee82123e..739092098 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -539,28 +539,6 @@ mod tests { assert_eq!(stale_payload.update_channel, UpdateChannel::Stable); assert!(stale_payload.update_channel_explicit); } - - #[test] - fn android_update_url_validation_requires_exact_https_release_hosts() { - assert!(is_allowed_android_update_url( - "https://dl.aeoform.com/releases/v2.0.2-tauri/OpenLess_aarch64.app.tar.gz" - )); - assert!(is_allowed_android_update_url( - "https://github.com/Open-Less/openless/releases/latest/download/OpenLess.apk" - )); - assert!(is_allowed_android_update_url( - "https://fastgit.cc/https://github.com/Open-Less/openless/releases/latest/download/OpenLess.apk" - )); - assert!(!is_allowed_android_update_url( - "https://dl.aeoform.com@attacker.example/package.apk" - )); - assert!(!is_allowed_android_update_url( - "https://dl.aeoform.com.attacker.example/package.apk" - )); - assert!(!is_allowed_android_update_url( - "http://dl.aeoform.com/releases/v2.0.2-tauri/OpenLess.apk" - )); - } } // ─────────────────────────── release channel (Beta opt-in) ─────────────────────────── @@ -734,21 +712,20 @@ fn extract_between(haystack: &str, open: &str, close: &str) -> Option { // ─────────────────────── Channel-aware updater check ──────────────────────── // // Replaces the frontend's previous direct import('@tauri-apps/plugin-updater').check() path: -// - Stable channel: the builder keeps tauri.conf's Stable manifest endpoint untouched, which -// is `https://dl.aeoform.com/updates/stable/latest-{{target}}-{{arch}}.json`. -// - Beta channel: swap in `https://dl.aeoform.com/updates/beta/...-beta.json` through +// - Stable channel: the builder keeps tauri.conf's stable manifest URL endpoints untouched. +// - Beta channel: fetch_latest_beta_release gets the latest prerelease tag, builds the -beta +// manifest URL (as a mirror + direct pair), then // builder.endpoints(vec![url])?.build()?.check(). // -// Manifests live on the project CDN instead of the GitHub Release assets (see -// `crate::update_source`), so a release no longer accumulates fourteen `latest-*.json` copies. -// The channel split is now a URL *path* (`/updates/stable/` vs `/updates/beta/`) instead of a -// filename suffix, and artifact URLs inside the manifest point at the same CDN — a Stable -// client can never reach a Beta package, and mainland-China downloads never touch github.com. -// // The returned Metadata shape exactly matches plugin-updater's JS UpdateMetadata (rid + // currentVersion and other camelCase fields), so the frontend can call // `new Update(metadata)` directly and reuse the plugin's download / install / close // implementations instead of writing our own download and signature verification. +// +// Physical isolation: manifests published from Beta tags carry a `-beta` filename suffix +// (see the comment at line 382 of release-tauri.yml), separate from Stable's +// `latest-{tgt}-{arch}.json` in the GitHub Release assets — even if buggy code passed a +// Beta URL to a Stable user, HTTP returns 404 outright; the wrong artifact is unreachable. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -765,7 +742,7 @@ pub struct AppUpdateMetadata { pub raw_json: serde_json::Value, } -/// Resolves the update endpoint, then runs plugin-updater's standard check flow. +/// Resolves the manifest source, then runs plugin-updater's standard check flow. /// Channel: uses an explicitly passed `channel` (the About page always checks Stable, the /// advanced page's Beta section checks Beta); when absent, uses the user's explicit /// channel choice; when none chosen yet, follows the current build type. @@ -787,9 +764,10 @@ pub async fn app_check_update_with_channel( builder = builder.timeout(std::time::Duration::from_millis(ms)); } if matches!(channel, UpdateChannel::Beta) { + let urls = resolve_beta_manifest_endpoints().await?; builder = builder - .endpoints(vec![beta_manifest_endpoint()?]) - .map_err(|e| format!("set beta endpoint: {e}"))?; + .endpoints(urls) + .map_err(|e| format!("set beta endpoints: {e}"))?; } let updater = builder.build().map_err(|e| format!("build updater: {e}"))?; let update = updater @@ -814,17 +792,28 @@ pub async fn app_check_update_with_channel( Ok(Some(metadata)) } -/// The Beta manifest endpoint, with `{{target}}` / `{{arch}}` left in place for -/// `tauri-plugin-updater` to substitute at check time. -/// -/// Beta is a URL *path* on the CDN (`/updates/beta/`), so there is no tag discovery step and -/// no dependency on `releases.atom` or the GitHub Release assets. +/// Builds the -beta manifest URL pair from the latest prerelease tag found by +/// fetch_latest_beta_release. +/// Order: mirror first (fastgit.cc proxies GitHub), then direct — matching tauri.conf's +/// existing Stable endpoints so mainland-China access hits the CDN first. #[cfg(not(mobile))] -fn beta_manifest_endpoint() -> Result { - let template = crate::update_source::endpoint_template(UpdateChannel::Beta); - // `url` percent-encodes the `{{target}}` / `{{arch}}` braces; the plugin substitutes both - // the literal and the `%7B%7B…%7D%7D` form, so either spelling works at check time. - url::Url::parse(&template).map_err(|e| format!("parse beta manifest url: {e}")) +async fn resolve_beta_manifest_endpoints() -> Result, String> { + let Some(latest) = fetch_latest_beta_release().await? else { + return Err("尚未发布过 Beta 版本".to_string()); + }; + let tag = latest.tag_name; + // {{target}} / {{arch}} placeholders are substituted by the plugin at check time. A + // Rust raw string (r#""#) needs no escaped double braces and is cleaner than format!-ing + // the literal. + let mirror = format!( + "https://fastgit.cc/https://github.com/Open-Less/openless/releases/download/{tag}/latest-{{{{target}}}}-{{{{arch}}}}-beta-mirror.json" + ); + let direct = format!( + "https://github.com/Open-Less/openless/releases/download/{tag}/latest-{{{{target}}}}-{{{{arch}}}}-beta.json" + ); + let mirror_url = url::Url::parse(&mirror).map_err(|e| format!("parse beta mirror url: {e}"))?; + let direct_url = url::Url::parse(&direct).map_err(|e| format!("parse beta direct url: {e}"))?; + Ok(vec![mirror_url, direct_url]) } #[cfg(mobile)] @@ -847,27 +836,6 @@ pub async fn app_check_update_with_channel( } } -fn is_allowed_android_update_url(raw: &str) -> bool { - let Ok(parsed) = url::Url::parse(raw) else { - return false; - }; - if parsed.scheme() != "https" - || !parsed.username().is_empty() - || parsed.password().is_some() - || parsed.port().is_some() - { - return false; - } - match (parsed.host_str(), parsed.path()) { - (Some("dl.aeoform.com"), path) => path.starts_with("/releases/"), - (Some("github.com"), path) => path.starts_with("/Open-Less/openless/releases/"), - (Some("fastgit.cc"), path) => { - path.starts_with("/https://github.com/Open-Less/openless/releases/") - } - _ => false, - } -} - #[cfg(mobile)] #[tauri::command] pub async fn app_download_and_install_android_update( @@ -876,9 +844,12 @@ pub async fn app_download_and_install_android_update( signature: String, version: String, ) -> Result<(), String> { - // Parse and validate the host before downloading so userinfo and hostname-prefix tricks - // cannot turn the signed update fetch into an SSRF primitive. - if !is_allowed_android_update_url(&url) { + // Security: validate the URL before downloading to prevent SSRF (e.g. intranet metadata + // endpoints, localhost services). Only the known GitHub direct links and the fastgit + // mirror prefix are allowed. + const DIRECT_BASE: &str = "https://github.com/Open-Less/openless"; + const MIRROR_BASE: &str = "https://fastgit.cc/https://github.com/Open-Less/openless"; + if !url.starts_with(DIRECT_BASE) && !url.starts_with(MIRROR_BASE) { return Err(format!("不信任的更新 URL,拒绝下载: {url}")); } #[cfg(target_os = "android")] diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index ab628ec7d..c385a1df8 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -116,7 +116,6 @@ mod unicode_keystroke; #[cfg(mobile)] #[path = "mobile_stubs/unicode_keystroke.rs"] mod unicode_keystroke; -mod update_source; #[cfg(target_os = "windows")] mod windows_ime_ipc; mod windows_ime_profile; diff --git a/openless-all/app/src-tauri/src/update_source.rs b/openless-all/app/src-tauri/src/update_source.rs deleted file mode 100644 index 26ca3f528..000000000 --- a/openless-all/app/src-tauri/src/update_source.rs +++ /dev/null @@ -1,130 +0,0 @@ -//! Where the in-app updater looks for release manifests. -//! -//! # Background -//! -//! The updater used to read its manifest from the GitHub Release assets -//! (`releases/latest/download/latest-{target}-{arch}.json`, and a `-beta` twin per tag). That -//! forced every release to carry fourteen `latest-*.json` files — seven target/arch entries -//! times a `-mirror` twin — all repeating the same three fields (`version`, `url`, `signature`), -//! and every new release re-uploaded the same file names because the Stable endpoint was a -//! rolling pointer. -//! -//! The manifests now live on the project's own CDN: -//! -//! ```text -//! https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json -//! https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json -//! ``` -//! -//! Two properties matter: -//! -//! - **One rolling file per platform.** The CDN name is stable per channel, so a release never -//! accumulates manifest copies; publishing overwrites in place. -//! - **Channel separation is a path, not a filename suffix.** `/updates/stable/` and -//! `/updates/beta/` are distinct locations, so a Stable client cannot walk into a Beta -//! manifest by accident. -//! -//! The manifest body is unchanged (`version`, `pub_date`, `url`, `signature`), so -//! `tauri-plugin-updater` keeps owning download, minisign verification and platform install. -//! Artifact URLs inside the manifest point at the same CDN (`/releases/{tag}/{asset}`), which -//! is what mainland-China users download from. - -use crate::types::UpdateChannel; - -/// CDN that serves both the update manifests and the mirrored release artifacts. -pub(crate) const CDN_BASE: &str = "https://dl.aeoform.com"; - -/// Manifest URL for `channel`, with the `{{target}}` / `{{arch}}` tokens left in place for -/// `tauri-plugin-updater` to substitute at check time. -/// -/// `format!` doubles the braces, so `{{{{target}}}}` renders as the literal `{{target}}`. -pub fn endpoint_template(channel: UpdateChannel) -> String { - let (path, suffix) = match channel { - UpdateChannel::Stable => ("stable", ""), - UpdateChannel::Beta => ("beta", "-beta"), - }; - format!("{CDN_BASE}/updates/{path}/latest-{{{{target}}}}-{{{{arch}}}}{suffix}.json") -} - -/// Concrete manifest URL for clients that fetch the manifest themselves (Android). -/// -/// `target`/`arch` use the updater token convention (`android` + `aarch64`, `darwin` + -/// `x86_64`, …), i.e. the same values the plugin would substitute into -/// [`endpoint_template`]. -#[cfg_attr(not(target_os = "android"), allow(dead_code))] -pub fn manifest_url(channel: UpdateChannel, target: &str, arch: &str) -> String { - endpoint_template(channel) - .replace("{{target}}", target) - .replace("{{arch}}", arch) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn stable_endpoint_has_no_beta_marker() { - let url = endpoint_template(UpdateChannel::Stable); - assert!( - !url.ends_with('/'), - "endpoint must not end with a slash: {url}" - ); - assert!(url.starts_with("https://dl.aeoform.com/updates/stable/")); - assert!(url.contains("{{target}}") && url.contains("{{arch}}")); - assert!(!url.contains("beta")); - } - - #[test] - fn beta_endpoint_is_a_separate_path_with_suffix() { - let url = endpoint_template(UpdateChannel::Beta); - assert!(url.starts_with("https://dl.aeoform.com/updates/beta/")); - assert!(url.ends_with("-{{target}}-{{arch}}-beta.json")); - } - - #[test] - fn manifest_url_substitutes_every_token() { - for (channel, expected) in [ - ( - UpdateChannel::Stable, - "https://dl.aeoform.com/updates/stable/latest-darwin-aarch64.json", - ), - ( - UpdateChannel::Beta, - "https://dl.aeoform.com/updates/beta/latest-darwin-aarch64-beta.json", - ), - ] { - let url = manifest_url(channel, "darwin", "aarch64"); - assert_eq!(url, expected); - assert!(!url.contains("{{"), "unsubstituted token in {url}"); - } - } - - #[test] - fn manifest_url_covers_every_published_target() { - for (target, arch, expected) in [ - ("darwin", "aarch64", "latest-darwin-aarch64.json"), - ("darwin", "x86_64", "latest-darwin-x86_64.json"), - ("windows", "x86_64", "latest-windows-x86_64.json"), - ("android", "aarch64", "latest-android-aarch64.json"), - ("android", "armv7", "latest-android-armv7.json"), - ("android", "x86_64", "latest-android-x86_64.json"), - ("android", "i686", "latest-android-i686.json"), - ] { - assert!( - manifest_url(UpdateChannel::Stable, target, arch).ends_with(expected), - "{target}/{arch} should resolve to {expected}" - ); - assert!( - manifest_url(UpdateChannel::Beta, target, arch) - .ends_with(&expected.replace(".json", "-beta.json")), - "{target}/{arch} beta should carry the -beta suffix" - ); - } - } - - #[test] - fn cdn_base_has_no_trailing_slash() { - assert!(!CDN_BASE.ends_with('/')); - assert!(endpoint_template(UpdateChannel::Stable).starts_with(CDN_BASE)); - } -} diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index a73d7ad72..697be3b27 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -109,7 +109,8 @@ "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFERUFBODAzNTY0QzMyM0YKUldRL01reFdBNmpxSGE1K0JadlpONXNWTzhJcGZCRGxjUVdIWExNNFJpeUNsSGZwazdlQThhemkK", "endpoints": [ - "https://dl.aeoform.com/updates/stable/latest-{{target}}-{{arch}}.json" + "https://github.com/Open-Less/openless/releases/latest/download/latest-{{target}}-{{arch}}.json", + "https://fastgit.cc/https://github.com/Open-Less/openless/releases/latest/download/latest-{{target}}-{{arch}}-mirror.json" ] } } From 93704bf302d32eb6ca183c7b61743c5fbc923b81 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Fri, 30 Oct 2026 14:18:48 +0800 Subject: [PATCH 31/31] fix(ci): fail open for impact classifier changes --- openless-all/app/scripts/ci-changed-areas.test.mjs | 12 ++++++++++++ openless-all/app/scripts/ci-impact.mjs | 1 + 2 files changed, 13 insertions(+) diff --git a/openless-all/app/scripts/ci-changed-areas.test.mjs b/openless-all/app/scripts/ci-changed-areas.test.mjs index 176f16461..e65fde17c 100644 --- a/openless-all/app/scripts/ci-changed-areas.test.mjs +++ b/openless-all/app/scripts/ci-changed-areas.test.mjs @@ -188,6 +188,18 @@ try { expect('contract suite', every('true')); reset(); + // The impact classifier and its shell wrapper define the platform gates. A + // change to either must fail open and run every platform check. + commit( + { + 'openless-all/app/scripts/ci-impact.mjs': 'console.log(1);\n', + 'openless-all/app/scripts/ci-changed-areas.sh': '#!/usr/bin/env bash\n', + }, + 'CI impact tooling', + ); + expect('CI impact tooling', every('true')); + reset(); + // Native resources have both bundle and direct native consumers. commit({ 'openless-all/app/assets/vocab-presets.json': '{}\n' }, 'native asset'); expect('native asset', every('true')); diff --git a/openless-all/app/scripts/ci-impact.mjs b/openless-all/app/scripts/ci-impact.mjs index 8e7c373e3..4337fd0e8 100644 --- a/openless-all/app/scripts/ci-impact.mjs +++ b/openless-all/app/scripts/ci-impact.mjs @@ -221,6 +221,7 @@ const workflowOrTooling = (file) => file.endsWith('/Cargo.lock') || /(^|\/)rust-toolchain(\.toml)?$/.test(file) || file.startsWith('openless-all/app/contract/') || + file.startsWith('openless-all/app/scripts/ci-') || file.startsWith('scripts/'); /**