chore(release): integrate reviewed changes for Beta 3 build 20260925

Integrate Android CI acceleration, modifier-only dictation chords,
Requesty, and remote-input generation guards over the verified Beta 2
baseline and macOS CI improvements. Fix review findings in signing
configuration caching, APK validation, and modifier arbitration/recording.
Keep Linux egui and its release workflows identical to Beta 2.

Co-authored-by: HKLHaoBin <we3q@qq.com>
Co-authored-by: leolulu <35873316+leolulu@users.noreply.github.com>
Co-authored-by: Thibault Jaigu <thibault.jaigu@gmail.com>
Co-authored-by: Ethan <257484394+EthanYoQ@users.noreply.github.com>
This commit is contained in:
committed 2026-09-25 23:52:47 +08:00
1 parent 06e7e634b6
commit 06db7d2978
46 files changed
+1817 -302

No files matched your search

+12 -7
View File
@@ -1537,8 +1537,10 @@
setStatus(L.preparingMic, 'work');
clearResult(); // 清掉上一次的识别结果,避免新录音时还显示旧文字
var gen = audioGen;
withTimeout(ensureAudio(), MIC_PREP_TIMEOUT_MS, 'TIMEOUT')
.then(function () {
if (gen !== audioGen) return;
if (!recording) {
// 期间已被取消/松手
teardownAudioCapture();
@@ -1549,6 +1551,7 @@
setStatus(L.preparingBackend, 'work');
})
.catch(function (err) {
if (gen !== audioGen) return;
recording = false;
resetRemoteStreamState();
// 超时多半是 audioCtx 卡死(resume 永不 settle),彻底重建,否则下次重试会继续卡在
@@ -1639,6 +1642,7 @@
// 确保 AudioContext / getUserMedia / 采集节点就绪并开始推流。
// 必须在用户手势调用栈内(startRecording 由手势触发)。
function ensureAudio() {
var gen = audioGen;
// 不支持 getUserMedia
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
return Promise.reject(new Error('UNSUPPORTED:浏览器不支持录音,请升级或换浏览器'));
@@ -1665,11 +1669,10 @@
return resumeP
.then(function () {
if (gen !== audioGen) return null;
// 2) 麦克风流(已存在则复用)
if (mediaStream) return mediaStream;
// 捕获当前代际:迟到 resolve 时若代际已变(超时重置/断线释放),停掉轨道并放弃,
// 避免泄漏麦克风或覆盖重试成功的新流。
var gen = audioGen;
// 使用准备开始时的代际;停止/取消后迟到的流必须释放,不能覆盖下一次录音。
return navigator.mediaDevices
.getUserMedia({
audio: {
@@ -1701,7 +1704,7 @@
.then(function (stream) {
// 3) 建立采集图(若已建好则跳过)。audioCtx 可能在准备超时后被 resetAudioContext
// 置空(本次 getUserMedia 迟到 resolve),此时直接放弃,避免对 null ctx 建图报错。
if (sourceNode || !audioCtx || !stream) return;
if (gen !== audioGen || sourceNode || !audioCtx || !stream) return;
sourceNode = audioCtx.createMediaStreamSource(stream);
return buildCaptureGraph();
});
@@ -1709,12 +1712,14 @@
// 建立 AudioWorklet(优先)或 ScriptProcessor(兜底)
function buildCaptureGraph() {
var gen = audioGen;
var inSr = audioCtx.sampleRate || 48000;
// 优先 AudioWorklet
if (audioCtx.audioWorklet && typeof AudioWorkletNode !== 'undefined') {
return loadWorklet()
.then(function () {
if (gen !== audioGen) return;
workletNode = new AudioWorkletNode(audioCtx, 'ol-pcm-worklet', {
numberOfInputs: 1,
numberOfOutputs: 0,
@@ -1723,12 +1728,13 @@
});
workletNode.port.onmessage = function (e) {
// e.data 是已转换好的 Int16 LE ArrayBuffer
sendAudio(e.data);
if (gen === audioGen) sendAudio(e.data);
};
sourceNode.connect(workletNode);
usingWorklet = true;
})
.catch(function () {
if (gen !== audioGen) return;
// worklet 加载失败 → 回退 ScriptProcessor
usingWorklet = false;
buildScriptProcessor(inSr);
@@ -2035,6 +2041,7 @@
// ============================================================
// 仅停止"采集/推流"(断开节点),保留 audioCtx & mediaStream 以便快速重启。
function teardownAudioCapture() {
audioGen++; // 停止/取消也作废在途的 resume、麦克风、worklet 和准备超时回调。
releaseWakeLock();
if (wakeLockHint) wakeLockHint.textContent = L.wakeLockHint;
try {
@@ -2072,7 +2079,6 @@
// 彻底释放(断线时):停止麦克风轨道并关闭 ctx。
function teardownAudio() {
audioGen++; // 代际推进:作废所有在途的 getUserMedia 迟到回调
teardownAudioCapture();
if (mediaStream) {
try {
@@ -2093,7 +2099,6 @@
// 与 teardownAudio 的区别:这里 close 并置空 audioCtx —— 超时根因往往是 ctx 自身坏掉
// (resume 永不 settle),保留它只会让下次继续卡。
function resetAudioContext() {
audioGen++; // 代际推进:作废所有在途的 getUserMedia 迟到回调
teardownAudioCapture();
if (mediaStream) {
try {
@@ -3399,7 +3399,9 @@ impl OpenLessBackend {
};
let preferences = self.get_preferences();
let mode = preferences.hotkey.mode;
let modifier_only = crate::hotkey_interpreter::modifier_arbitration_required(
let modifier_only = crate::shortcut_types::is_modifier_chord_binding(
&preferences.dictation_hotkey,
) || crate::hotkey_interpreter::modifier_arbitration_required(
crate::shortcut_types::legacy_modifier_trigger(&preferences.dictation_hotkey),
mode,
);
@@ -9447,7 +9449,19 @@ mod tests {
}
}
backend.cancel_dictation(Some(first)).await.unwrap();
let second = backend.start_dictation().await.unwrap();
let second = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
match backend.start_dictation().await {
Ok(id) => break id,
Err(error) if error.code == BackendErrorCode::Busy => {
tokio::task::yield_now().await;
}
Err(error) => panic!("unexpected start failure: {error}"),
}
}
})
.await
.expect("successor dictation should become available after cancel");
release_guard.release();
settings.join().unwrap().unwrap();
stopping.await.unwrap().unwrap();
@@ -12735,6 +12749,62 @@ mod tests {
assert!(!saw_recording_after_cancel);
}
#[tokio::test]
async fn modifier_chord_companion_does_not_start_dictation_engine() {
for mode in [
crate::shared_types::HotkeyMode::Hold,
crate::shared_types::HotkeyMode::Auto,
crate::shared_types::HotkeyMode::Toggle,
] {
let data_dir = TestDataDir::new("modifier-chord-companion");
let engine = crate::testing::FixtureDictationEngine::successful("raw", "polished");
let backend =
backend_with_dictation_engine(data_dir.path().to_path_buf(), Arc::new(engine.clone()));
backend.start().await.unwrap();
let mut preferences = backend.get_preferences();
preferences.hotkey.mode = mode;
preferences.dictation_hotkey = crate::shared_types::ShortcutBinding {
primary: "ModifierChord".into(),
modifiers: vec!["ctrl-left".into(), "cmd-left".into()],
};
backend.set_preferences(preferences).unwrap();
let at = std::time::Instant::now();
let mut press = std::pin::pin!(backend
.dispatch_dictation_hotkey_edge(DictationHotkeyEdge::Pressed { press_id: 1, at }));
// Poll the real public API until it waits. A companion key arrives
// before the grace expires, so even native startup must stay idle.
assert!(futures_util::poll!(press.as_mut()).is_pending());
assert_eq!(backend.snapshot().dictation.phase, DictationPhase::Idle);
assert!(engine.actions().is_empty());
assert_eq!(
backend
.dispatch_dictation_hotkey_edge(DictationHotkeyEdge::Combined {
press_id: 1,
at: at + std::time::Duration::from_millis(1),
})
.await
.unwrap(),
CliDispatchOutcome::Noop
);
assert_eq!(press.await.unwrap(), CliDispatchOutcome::Noop);
assert!(engine.actions().is_empty());
// A later chord without a companion remains a working trigger.
assert!(matches!(
backend
.dispatch_dictation_hotkey_edge(DictationHotkeyEdge::Pressed {
press_id: 2,
at: at + std::time::Duration::from_secs(1),
})
.await
.unwrap(),
CliDispatchOutcome::DictationStarted(_)
));
backend.cancel_dictation(None).await.unwrap();
}
}
#[tokio::test]
async fn hotkey_combined_edge_cancels_the_same_generation_during_start_await() {
let entered = Arc::new(tokio::sync::Notify::new());
@@ -78,6 +78,7 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[
"mimo",
"cometapi",
"openrouterFree",
"requesty",
"orcarouter",
"alibabaCoding",
"codingPlanX",
@@ -326,7 +326,8 @@ pub use shared_types::{
};
pub use shortcut_types::{
binding_from_legacy_trigger, binding_requires_side_aware_hook, bindings_overlap,
is_side_specific_modifier_tag, legacy_modifier_trigger, normalize_side_modifier_tag,
is_modifier_chord_binding, is_side_specific_modifier_tag, legacy_modifier_trigger,
normalize_side_modifier_tag,
reconcile_hotkey_collisions, reject_bare_shift_dictation_shortcut,
reject_dictation_qa_hotkey_overlap, reject_dictation_translation_hotkey_overlap,
reject_hotkey_collisions, reject_modifier_only_action_shortcut,
@@ -190,6 +190,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool {
| "mimo"
| "cometapi"
| "openrouterFree"
| "requesty"
| "orcarouter"
| "alibabaCoding"
| "codingPlanX"
@@ -62,6 +62,7 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[
("mimo", "mimo"),
("cometapi", "cometapi"),
("openrouterFree", "openrouterFree"),
("requesty", "requesty"),
("orcarouter", "orcarouter"),
("alibabaCoding", "alibabaCoding"),
("codingPlanX", "codingPlanX"),
@@ -601,6 +602,7 @@ pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> {
"mimo" => Some("https://api.xiaomimimo.com/v1"),
"cometapi" => Some("https://api.cometapi.com/v1"),
"openrouterFree" => Some("https://openrouter.ai/api/v1"),
"requesty" => Some("https://router.requesty.ai/v1"),
"orcarouter" => Some(crate::asr::mimo::ORCAROUTER_DEFAULT_ENDPOINT),
"alibabaCoding" => Some("https://coding-intl.dashscope.aliyuncs.com/v1"),
"codingPlanX" => Some("https://api.codingplanx.ai/v1"),
@@ -623,6 +625,7 @@ pub fn default_llm_model(provider_type: &str) -> Option<&'static str> {
crate::polish::CODEX_OAUTH_PROVIDER_ID => Some(crate::polish::CODEX_DEFAULT_MODEL),
"mimo" => Some("xiaomi/mimo-v2-flash"),
"openrouterFree" => Some("qwen/qwen3-coder:free"),
"requesty" => Some("openai/gpt-4o-mini"),
"orcarouter" => Some("orcarouter/fusion-flash"),
"alibabaCoding" => Some("qwen3-coder-plus"),
"codingPlanX" => Some("gpt-5-mini"),
@@ -1341,6 +1344,42 @@ mod tests {
assert!(llm_configured("opencode", &configuration));
}
#[test]
fn requesty_descriptor_supplies_defaults_formats_and_credentials() {
use crate::llm_protocol::LlmRequestFormat;
assert!(crate::cloud_providers::SHARED_CLOUD_LLM_PROVIDER_TYPES.contains(&"requesty"));
let descriptor = provider_descriptor(ProviderKind::Llm, "requesty").unwrap();
assert_eq!(descriptor.label_key, "requesty");
assert_eq!(
descriptor.default_endpoint.as_deref(),
Some("https://router.requesty.ai/v1")
);
assert_eq!(
descriptor.default_model.as_deref(),
Some("openai/gpt-4o-mini")
);
assert_eq!(
descriptor.default_request_format,
Some(LlmRequestFormat::ChatCompletions)
);
assert_eq!(descriptor.supported_request_formats, LlmRequestFormat::ALL);
assert_eq!(descriptor.validation_probe, ValidationProbe::LlmText);
assert!(api_key_required(
ProviderKind::Llm,
"requesty",
Some("https://router.requesty.ai/v1/chat/completions")
));
let mut configuration = CredentialConfiguration {
llm_endpoint: true,
llm_api_key_required: true,
llm_model: true,
..CredentialConfiguration::default()
};
assert!(!llm_configured("requesty", &configuration));
configuration.llm_api_key = true;
assert!(llm_configured("requesty", &configuration));
}
#[test]
fn tencent_cloud_asr_and_tokenhub_supply_defaults_and_credentials() {
assert!(crate::cloud_providers::SHARED_CLOUD_ASR_PROVIDER_TYPES.contains(&"tencent-cloud"));
@@ -27,6 +27,15 @@ const SIDE_MODIFIER_TAGS: &[&str] = &[
"super-right",
];
const MODIFIER_CHORD_PRIMARY: &str = "ModifierChord";
pub fn is_modifier_chord_binding(binding: &ShortcutBinding) -> bool {
binding
.primary
.trim()
.eq_ignore_ascii_case(MODIFIER_CHORD_PRIMARY)
}
pub fn normalize_side_modifier_tag(raw: &str) -> String {
match raw.trim().to_ascii_lowercase().as_str() {
"super-left" => "cmd-left".into(),
@@ -206,6 +215,24 @@ pub fn validate_shortcut_binding(binding: &ShortcutBinding) -> Result<(), Shortc
if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") {
return Ok(());
}
if is_modifier_chord_binding(binding) {
if binding.modifiers.len() < 2 {
return Err(ShortcutBindingError::UnsupportedKey(
binding.primary.trim().to_string(),
));
}
let mut unique = BTreeSet::new();
for raw in &binding.modifiers {
if !is_side_specific_modifier_tag(raw) {
return Err(ShortcutBindingError::UnsupportedModifier(raw.clone()));
}
let normalized = normalize_side_modifier_tag(raw);
if !unique.insert(normalized) {
return Err(ShortcutBindingError::UnsupportedModifier(raw.clone()));
}
}
return Ok(());
}
validate_primary(&binding.primary)?;
for raw in &binding.modifiers {
@@ -871,6 +898,21 @@ mod tests {
assert!(validate_shortcut_binding(&combo("D", &["cmd-left", "shift"])).is_err());
}
#[test]
fn modifier_chords_require_multiple_unique_side_specific_modifiers() {
let chord = combo("ModifierChord", &["ctrl-left", "super-left"]);
assert!(validate_shortcut_binding(&chord).is_ok());
assert!(binding_requires_side_aware_hook(&chord));
assert!(is_modifier_chord_binding(&chord));
assert!(validate_shortcut_binding(&combo("ModifierChord", &["ctrl-left"])).is_err());
assert!(validate_shortcut_binding(&combo("ModifierChord", &["ctrl", "super-left"])).is_err());
assert!(
validate_shortcut_binding(&combo("ModifierChord", &["cmd-left", "super-left"]))
.is_err()
);
}
#[test]
fn overlap_and_legacy_conversion_have_one_shared_implementation() {
assert!(bindings_overlap(
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openless-app",
"version": "2.0.0-Beta.2+build.20260924",
"version": "2.0.0-Beta.3+build.20260925",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openless-app",
"version": "2.0.0-Beta.2+build.20260924",
"version": "2.0.0-Beta.3+build.20260925",
"dependencies": {
"@base-ui/react": "^1.6.0",
"@shadcn/react": "^0.2.1",
+5 -4
View File
@@ -1,7 +1,7 @@
{
"name": "openless-app",
"private": true,
"version": "2.0.0-Beta.2+build.20260924",
"version": "2.0.0-Beta.3+build.20260925",
"type": "module",
"scripts": {
"pretest": "npm run build",
@@ -14,9 +14,10 @@
"tauri": "tauri",
"tauri:android:init": "tauri android init",
"tauri:android:dev": "tauri android dev",
"tauri:android:build": "tauri android build --apk --debug --target aarch64 armv7 i686 x86_64 --split-per-abi",
"tauri:android:build:debug": "tauri android build --apk --debug --target aarch64 armv7 i686 x86_64 --split-per-abi",
"tauri:android:build:release": "tauri android build --apk --target aarch64 armv7 i686 x86_64 --split-per-abi",
"tauri:android:build": "node scripts/run-android-tauri-build.mjs --debug",
"tauri:android:build:debug": "node scripts/run-android-tauri-build.mjs --debug",
"tauri:android:build:release": "node scripts/run-android-tauri-build.mjs --release",
"check:android-apk-workflow": "node scripts/android-apk-workflow-contract.test.mjs",
"merge:android-v1-manifest": "node scripts/merge-android-v1-manifest.mjs",
"merge:android-overlay-manifest": "node scripts/merge-android-overlay-manifest.mjs",
"merge:android-shizuku-manifest": "node scripts/merge-android-shizuku-manifest.mjs",
@@ -0,0 +1,123 @@
/**
* Android ABI matrix helpers for CI (#1103).
*
* CLI target names match `tauri android build --target`.
* Gradle ABI folder names match APK lib/ layout.
*/
export const ANDROID_ABI_MATRIX = [
{
abi: 'aarch64',
rustTarget: 'aarch64-linux-android',
gradleAbi: 'arm64-v8a',
outputKey: 'arm64_v8a',
},
{
abi: 'armv7',
rustTarget: 'armv7-linux-androideabi',
gradleAbi: 'armeabi-v7a',
outputKey: 'armeabi_v7a',
},
{
abi: 'i686',
rustTarget: 'i686-linux-android',
gradleAbi: 'x86',
outputKey: 'x86',
},
{
abi: 'x86_64',
rustTarget: 'x86_64-linux-android',
gradleAbi: 'x86_64',
outputKey: 'x86_64',
},
];
const BY_ABI = new Map(ANDROID_ABI_MATRIX.map((entry) => [entry.abi, entry]));
const BY_GRADLE = new Map(ANDROID_ABI_MATRIX.map((entry) => [entry.gradleAbi, entry]));
export function entryForAbi(abi) {
const entry = BY_ABI.get(abi);
if (!entry) {
throw new Error(
`Unknown Android ABI "${abi}". Expected one of: ${ANDROID_ABI_MATRIX.map((e) => e.abi).join(', ')}`,
);
}
return entry;
}
export function entryForGradleAbi(gradleAbi) {
const entry = BY_GRADLE.get(gradleAbi);
if (!entry) {
throw new Error(`Unknown Gradle ABI "${gradleAbi}"`);
}
return entry;
}
/**
* Parse a comma/space-separated ABI list or the keyword "all".
* Empty / whitespace → defaultAbis.
*/
export function parseAndroidAbis(raw, { defaultAbis = ['aarch64'] } = {}) {
const text = (raw ?? '').trim();
if (!text) {
return defaultAbis.map(entryForAbi);
}
if (text.toLowerCase() === 'all') {
return [...ANDROID_ABI_MATRIX];
}
const tokens = text
.split(/[,\s]+/)
.map((t) => t.trim())
.filter(Boolean);
if (tokens.length === 0) {
return defaultAbis.map(entryForAbi);
}
const seen = new Set();
const out = [];
for (const token of tokens) {
const entry = entryForAbi(token);
if (seen.has(entry.abi)) continue;
seen.add(entry.abi);
out.push(entry);
}
return out;
}
/** GitHub Actions matrix.include payload for selected ABIs. */
export function toGithubMatrixInclude(entries) {
return entries.map((entry) => ({
abi: entry.abi,
rust_target: entry.rustTarget,
gradle_abi: entry.gradleAbi,
output_key: entry.outputKey,
}));
}
function main() {
const mode = process.argv[2] || 'parse';
if (mode === 'parse') {
const raw = process.argv[3] ?? process.env.OPENLESS_ANDROID_ABIS ?? '';
const defaultRaw = process.env.OPENLESS_ANDROID_ABIS_DEFAULT ?? 'aarch64';
const entries = parseAndroidAbis(raw, {
defaultAbis: parseAndroidAbis(defaultRaw, { defaultAbis: ['aarch64'] }).map((e) => e.abi),
});
process.stdout.write(JSON.stringify(toGithubMatrixInclude(entries)));
return;
}
if (mode === 'list-all') {
process.stdout.write(JSON.stringify(toGithubMatrixInclude(ANDROID_ABI_MATRIX)));
return;
}
if (mode === 'rust-targets') {
const raw = process.argv[3] ?? process.env.OPENLESS_ANDROID_ABIS ?? 'all';
const entries = parseAndroidAbis(raw, { defaultAbis: ANDROID_ABI_MATRIX.map((e) => e.abi) });
process.stdout.write(entries.map((e) => e.rustTarget).join(','));
return;
}
console.error(`Usage: node android-abi-matrix.mjs <parse|list-all|rust-targets> [abis]`);
process.exit(1);
}
if (process.argv[1]?.replace(/\\/g, '/').endsWith('android-abi-matrix.mjs')) {
main();
}
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
ANDROID_ABI_MATRIX,
parseAndroidAbis,
toGithubMatrixInclude,
entryForAbi,
} from './android-abi-matrix.mjs';
import { collectSplitApks } from './collect-android-split-apks.mjs';
const scriptDir = fileURLToPath(new URL('.', import.meta.url));
const workflowPath = fileURLToPath(
new URL('../../../.github/workflows/android-apk.yml', import.meta.url),
);
// --- ABI matrix ---
assert.equal(
parseAndroidAbis('')
.map((e) => e.abi)
.join(','),
'aarch64',
);
assert.equal(parseAndroidAbis('all').length, 4);
assert.equal(
parseAndroidAbis('aarch64,armv7')
.map((e) => e.abi)
.join(','),
'aarch64,armv7',
);
assert.equal(
parseAndroidAbis('aarch64 aarch64,armv7')
.map((e) => e.abi)
.join(','),
'aarch64,armv7',
);
assert.throws(() => parseAndroidAbis('riscv'), /Unknown Android ABI/);
assert.equal(entryForAbi('x86_64').gradleAbi, 'x86_64');
assert.equal(toGithubMatrixInclude(ANDROID_ABI_MATRIX)[0].rust_target, 'aarch64-linux-android');
const cli = spawnSync(
process.execPath,
[join(scriptDir, 'android-abi-matrix.mjs'), 'parse', 'aarch64'],
{ encoding: 'utf8' },
);
assert.equal(cli.status, 0);
assert.equal(JSON.parse(cli.stdout)[0].gradle_abi, 'arm64-v8a');
// --- collectSplitApks with a minimal zip APK ---
function writeMinimalApk(path, ...abis) {
const python = process.platform === 'win32' ? 'python' : 'python3';
const result = spawnSync(
python,
[
'-c',
`
import sys, zipfile
with zipfile.ZipFile(sys.argv[1], 'w') as apk:
for abi in sys.argv[2:]:
apk.writestr('lib/' + abi + '/libdummy.so', b'so')
`,
path,
...abis,
],
{ encoding: 'utf8' },
);
assert.equal(result.status, 0, result.stderr);
}
const tmp = mkdtempSync(join(tmpdir(), 'openless-apk-collect-'));
try {
const androidRoot = join(tmp, 'android');
const outDir = join(tmp, 'out');
const apkDir = join(androidRoot, 'app', 'build', 'outputs', 'apk', 'release');
mkdirSync(apkDir, { recursive: true });
const apk = join(apkDir, 'app-arm64-v8a-release.apk');
writeMinimalApk(apk, 'arm64-v8a');
const options = {
mode: 'release',
label: 'test',
expectedGradleAbis: ['arm64-v8a'],
androidRoot,
outDir,
version: '2.0.0-Beta.3+build.20260925',
};
const { outputs } = collectSplitApks(options);
assert.match(
outputs.arm64_v8a_path.replace(/\\/g, '/'),
/OpenLess_2\.0\.0-Beta\.3\+build\.20260925_arm64-v8a\.apk$/,
);
assert.equal(outputs.arm64_v8a_arch, 'aarch64');
writeMinimalApk(apk, 'arm64-v8a', 'unexpected-abi');
assert.throws(() => collectSplitApks(options), /Unknown ABI/);
writeMinimalApk(apk, 'arm64-v8a', 'x86');
assert.throws(() => collectSplitApks(options), /expected exactly one ABI/);
writeMinimalApk(apk, 'arm64-v8a');
writeFileSync(apk, readFileSync(apk).subarray(0, -22));
assert.throws(() => collectSplitApks(options), /Invalid APK ZIP/);
writeMinimalApk(apk, 'arm64-v8a');
const corrupt = readFileSync(apk);
corrupt[30 + Buffer.byteLength('lib/arm64-v8a/libdummy.so')] ^= 0xff;
writeFileSync(apk, corrupt);
assert.throws(() => collectSplitApks(options), /Invalid APK ZIP/);
writeMinimalApk(apk, 'x86');
assert.throws(() => collectSplitApks(options), /Missing split APKs/);
writeMinimalApk(apk, 'arm64-v8a');
writeMinimalApk(join(apkDir, 'duplicate.apk'), 'arm64-v8a');
assert.throws(() => collectSplitApks(options), /Duplicate APKs/);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
// --- workflow contract (#1103) ---
const workflow = readFileSync(workflowPath, 'utf8');
assert.match(workflow, /prefix-key:\s*v1-rust-android-1103/);
assert.doesNotMatch(workflow, /Free disk before artifact upload/);
assert.doesNotMatch(workflow, /rm -rf src-tauri\/target/);
assert.doesNotMatch(workflow, /rm -rf ~\/\.cargo\/registry/);
assert.doesNotMatch(workflow, /rm -rf ~\/\.gradle\/caches/);
assert.match(workflow, /abis:/);
assert.match(workflow, /default:\s*['"]aarch64['"]/);
assert.match(workflow, /fast_profile:/);
assert.match(workflow, /strategy:[\s\S]*matrix:/);
assert.match(workflow, /OPENLESS_ANDROID_TARGETS/);
assert.match(workflow, /CARGO_PROFILE_RELEASE_LTO/);
assert.match(workflow, /first ABI may compile twice|android-studio-script/);
assert.match(workflow, /publish-android-release/);
assert.match(workflow, /download-artifact/);
assert.match(workflow, /Rust cache/);
console.log('android-apk-workflow-contract checks passed');
@@ -0,0 +1,202 @@
/**
* Collect split-per-ABI APKs from gen/android outputs.
*
* Env:
* OPENLESS_APK_MODE debug|release
* OPENLESS_APK_LABEL artifact label suffix
* OPENLESS_EXPECTED_GRADLE_ABIS comma-separated Gradle ABI folders (required)
* RUNNER_TEMP output parent (required in CI)
* GITHUB_OUTPUT optional; writes paths when set
*/
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join, relative } from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { entryForGradleAbi } from './android-abi-matrix.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const KNOWN_ABIS = new Set(['arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86']);
function walkApks(root) {
const out = [];
if (!existsSync(root)) return out;
const stack = [root];
while (stack.length) {
const dir = stack.pop();
for (const name of readdirSync(dir)) {
const path = join(dir, name);
const st = statSync(path);
if (st.isDirectory()) {
stack.push(path);
} else if (name.endsWith('.apk')) {
out.push(path);
}
}
}
return out.sort();
}
function listApkAbis(apkPath) {
// Use the same standard ZIP reader as the original workflow. Scanning for
// central-directory magic accepts truncated archives and bytes inside entries.
const script = `
import json, sys, zipfile
with zipfile.ZipFile(sys.argv[1]) as apk:
bad = apk.testzip()
if bad is not None:
raise ValueError("CRC failure in " + bad)
print(json.dumps(sorted({name.split('/')[1] for name in apk.namelist()
if name.startswith('lib/') and len(name.split('/')) >= 3})))
`;
const commands = process.platform === 'win32' ? ['python', 'python3'] : ['python3', 'python'];
for (const command of commands) {
const result = spawnSync(command, ['-c', script, apkPath], {
encoding: 'utf8',
maxBuffer: 1024 * 1024,
});
if (result.error?.code === 'ENOENT') continue;
if (result.error || result.status !== 0) {
throw new Error(`Invalid APK ZIP ${apkPath}: ${result.error?.message || result.stderr}`);
}
const abis = JSON.parse(result.stdout);
for (const abi of abis) {
if (!KNOWN_ABIS.has(abi)) throw new Error(`Unknown ABI ${abi} in ${apkPath}`);
}
return abis;
}
throw new Error('Python 3 is required to validate APK ZIP contents');
}
export function collectSplitApks({
mode,
label,
expectedGradleAbis,
androidRoot = join(appRoot, 'src-tauri/gen/android'),
outDir,
version,
} = {}) {
if (!mode || !label) {
throw new Error('mode and label are required');
}
if (!expectedGradleAbis?.length) {
throw new Error('expectedGradleAbis must be a non-empty array');
}
if (!outDir) {
throw new Error('outDir is required');
}
if (!version) {
throw new Error('version is required');
}
mkdirSync(outDir, { recursive: true });
const candidates = walkApks(androidRoot).filter((apk) =>
apk.replace(/\\/g, '/').includes('/outputs/'),
);
if (candidates.length === 0) {
const all = walkApks(androidRoot);
const hint = all.length ? all.map((p) => relative(androidRoot, p)).join('\n') : '(none)';
throw new Error(`No APK found under ${androidRoot}/**/outputs/\nOther APKs:\n${hint}`);
}
const expected = new Set(expectedGradleAbis);
const found = new Map();
for (const apk of candidates) {
const abis = listApkAbis(apk);
if (abis.length !== 1) {
throw new Error(
`${apk} contains ABI directories [${abis.join(', ')}]; expected exactly one ABI per APK`,
);
}
const abi = abis[0];
if (!expected.has(abi)) {
// Ignore extras from previous local builds; only enforce expected set.
console.warn(`Skipping unexpected ABI ${abi} from ${apk}`);
continue;
}
if (found.has(abi)) {
throw new Error(`Duplicate APKs for ABI ${abi}: ${found.get(abi)} and ${apk}`);
}
const destName =
mode === 'release'
? `OpenLess_${version}_${abi}.apk`
: `OpenLess-android-debug-${abi}-${label}.apk`;
const dest = join(outDir, destName);
copyFileSync(apk, dest);
found.set(abi, dest);
console.log(`Collected ${abi}: ${apk} -> ${dest}`);
}
const missing = [...expected].filter((abi) => !found.has(abi)).sort();
if (missing.length) {
throw new Error(`Missing split APKs for ABI(s): ${missing.join(', ')}`);
}
const releaseFiles = [...expected].map((abi) => found.get(abi));
const outputs = {};
for (const abi of expected) {
const entry = entryForGradleAbi(abi);
outputs[`${entry.outputKey}_path`] = found.get(abi);
outputs[`${entry.outputKey}_arch`] = entry.abi;
}
outputs.out_dir = outDir;
outputs.release_files = releaseFiles.join('\n');
// Single-ABI CI jobs consume these stable keys.
if (expected.size === 1) {
const onlyAbi = [...expected][0];
const entry = entryForGradleAbi(onlyAbi);
outputs.apk_path = found.get(onlyAbi);
outputs.gradle_abi = onlyAbi;
outputs.cli_abi = entry.abi;
}
return { found, outputs, releaseFiles };
}
function main() {
const mode = process.env.OPENLESS_APK_MODE;
const label = process.env.OPENLESS_APK_LABEL;
const expectedRaw = process.env.OPENLESS_EXPECTED_GRADLE_ABIS || '';
const expectedGradleAbis = expectedRaw
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean);
const runnerTemp = process.env.RUNNER_TEMP;
if (!runnerTemp) {
throw new Error('RUNNER_TEMP is required');
}
const version = JSON.parse(readFileSync(join(appRoot, 'package.json'), 'utf8')).version;
const outDir = join(runnerTemp, `openless-android-${mode}-split`);
const { outputs } = collectSplitApks({
mode,
label,
expectedGradleAbis,
outDir,
version,
});
const githubOutput = process.env.GITHUB_OUTPUT;
if (githubOutput) {
const lines = [];
for (const [key, value] of Object.entries(outputs)) {
if (key === 'release_files') {
lines.push(`${key}<<EOF`, value, 'EOF');
} else {
lines.push(`${key}=${value}`);
}
}
writeFileSync(githubOutput, `${lines.join('\n')}\n`, { flag: 'a' });
}
}
if (process.argv[1]?.replace(/\\/g, '/').endsWith('collect-android-split-apks.mjs')) {
main();
}
@@ -2,7 +2,7 @@
/**
* Decode ANDROID_KEYSTORE_* env vars and patch gen/android signing for release APK builds.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
@@ -21,39 +21,46 @@ function requireEnv(name) {
function main() {
const base64 = requireEnv('ANDROID_KEYSTORE_BASE64');
const storePassword = requireEnv('ANDROID_KEYSTORE_PASSWORD');
const keyAlias = requireEnv('ANDROID_KEY_ALIAS');
const keyPassword = requireEnv('ANDROID_KEY_PASSWORD');
requireEnv('ANDROID_KEYSTORE_PASSWORD');
requireEnv('ANDROID_KEY_ALIAS');
requireEnv('ANDROID_KEY_PASSWORD');
if (!existsSync(gradlePath)) {
throw new Error(`Gradle file not found: ${gradlePath} (run tauri android init first)`);
}
mkdirSync(dirname(keystorePath), { recursive: true });
writeFileSync(keystorePath, Buffer.from(base64, 'base64'));
const escapedStore = storePassword.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const escapedKey = keyPassword.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const escapedAlias = keyAlias.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const escapedKeystore = keystorePath.replace(/\\/g, '/');
writeFileSync(keystorePath, Buffer.from(base64, 'base64'), { mode: 0o600 });
chmodSync(keystorePath, 0o600);
let content = readFileSync(gradlePath, 'utf8');
if (content.includes('openlessRelease')) {
console.log('Release signing already configured in build.gradle.kts');
return;
if (!/android\s*\{/.test(content)) {
throw new Error('Android Gradle configuration block not found');
}
const releaseSigningConfig = `create("openlessRelease") {
storeFile = rootProject.file("openless-release.keystore")
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
}`;
const signingConfigsBlock = `
signingConfigs {
create("openlessRelease") {
storeFile = file("${escapedKeystore}")
storePassword = "${escapedStore}"
keyAlias = "${escapedAlias}"
keyPassword = "${escapedKey}"
}
${releaseSigningConfig}
}`;
if (!/signingConfigs\s*\{/.test(content)) {
// Migrate an existing generated block too, so local rebuilds cannot retain
// the old password literals in cacheable Kotlin DSL source.
if (/create\("openlessRelease"\)\s*\{/.test(content)) {
const block = /create\("openlessRelease"\)\s*\{(?:[^{}"]|"(?:\\.|[^"\\])*")*\}/;
if (!block.test(content)) throw new Error('Unrecognized existing release signing block');
content = content.replace(block, () => releaseSigningConfig);
} else if (/signingConfigs\s*\{/.test(content)) {
content = content.replace(
/signingConfigs\s*\{/,
`signingConfigs {\n ${releaseSigningConfig}`,
);
} else {
content = content.replace(/android\s*\{/, `android {${signingConfigsBlock}`);
}
@@ -70,10 +77,8 @@ function main() {
`buildTypes {\n getByName("release") {\n signingConfig = signingConfigs.getByName("openlessRelease")\n }`,
);
} else {
content = content.replace(
/android\s*\{/,
`android {${signingConfigsBlock}\n buildTypes {\n getByName("release") {\n signingConfig = signingConfigs.getByName("openlessRelease")\n }\n }`,
);
// Create the signing config before looking it up in this later block.
content += `\nandroid {\n buildTypes {\n getByName("release") {\n signingConfig = signingConfigs.getByName("openlessRelease")\n }\n }\n}\n`;
}
writeFileSync(gradlePath, content);
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import {
chmodSync,
copyFileSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
const root = mkdtempSync(join(tmpdir(), 'openless-signing-'));
try {
mkdirSync(join(root, 'scripts'));
mkdirSync(join(root, 'src-tauri/gen/android/app'), { recursive: true });
const script = join(root, 'scripts/configure-android-release-signing.mjs');
copyFileSync(new URL('./configure-android-release-signing.mjs', import.meta.url), script);
const gradle = join(root, 'src-tauri/gen/android/app/build.gradle.kts');
const env = {
...process.env,
ANDROID_KEYSTORE_BASE64: Buffer.from('fixture-keystore').toString('base64'),
ANDROID_KEYSTORE_PASSWORD: 'fixture-store-$"}\\secret',
ANDROID_KEY_ALIAS: 'fixture-alias',
ANDROID_KEY_PASSWORD: 'fixture-key-secret',
};
for (const template of [
'android {\n buildTypes { getByName("release") { isMinifyEnabled = false } }\n}',
'android {\n namespace = "test"\n}',
'android {\n signingConfigs { create("debug") {} }\n buildTypes {}\n}',
'android {\n signingConfigs { create("openlessRelease") { storePassword = "old-}secret" } }\n buildTypes { getByName("release") {} }\n}',
]) {
writeFileSync(gradle, template);
const keystore = join(root, 'src-tauri/gen/android/openless-release.keystore');
writeFileSync(keystore, 'old-keystore');
chmodSync(keystore, 0o644);
const run = () => spawnSync(process.execPath, [script], { env, encoding: 'utf8' });
const first = run();
assert.equal(first.status, 0, first.stderr);
const content = readFileSync(gradle, 'utf8');
for (const name of ['ANDROID_KEYSTORE_PASSWORD', 'ANDROID_KEY_ALIAS', 'ANDROID_KEY_PASSWORD']) {
assert.ok(content.includes(`System.getenv("${name}")`));
assert.ok(!content.includes(env[name]), `${name} must not be embedded in cacheable source`);
assert.ok(!first.stdout.includes(env[name]));
}
assert.ok(!content.includes('old-}secret'));
assert.ok(
content.indexOf('create("openlessRelease")') <
content.indexOf('signingConfigs.getByName("openlessRelease")'),
);
if (process.platform !== 'win32') assert.equal(statSync(keystore).mode & 0o777, 0o600);
assert.equal(content.match(/create\("openlessRelease"\)/g)?.length, 1);
assert.equal(
content.match(/signingConfig = signingConfigs.getByName\("openlessRelease"\)/g)?.length,
1,
);
const second = run();
assert.equal(second.status, 0, second.stderr);
assert.equal(readFileSync(gradle, 'utf8'), content);
assert.equal(
readFileSync(join(root, 'src-tauri/gen/android/openless-release.keystore'), 'utf8'),
'fixture-keystore',
);
}
} finally {
rmSync(root, { recursive: true, force: true });
}
console.log('Android release signing keeps credentials out of Gradle source');
@@ -0,0 +1,331 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
// Exercise the production lifecycle functions, with only browser/transport effects
// replaced. Deferred promises and explicit timers make late callbacks deterministic.
const source = readFileSync(new URL('../assets/remote-input/app.js', import.meta.url), 'utf8');
const names = [
'withTimeout',
'startRecording',
'stopRecording',
'cancelRecording',
'ensureAudio',
'buildCaptureGraph',
'clearPendingPcm',
'resetRemoteStreamState',
'teardownAudioCapture',
'teardownAudio',
'resetAudioContext',
];
const lifecycle = names
.map((name) => {
const start = source.indexOf(` function ${name}(`);
const end = source.indexOf('\n }', start);
assert.ok(start >= 0 && end > start, `production function ${name} must exist`);
return source.slice(start, end + '\n }'.length);
})
.join('\n');
const settle = () => new Promise((resolve) => setImmediate(resolve));
const deferred = () => {
let resolve;
let reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
};
const makeStream = () => {
const track = {
stopped: false,
stop() {
this.stopped = true;
},
};
return { track, getTracks: () => [track] };
};
const makeNode = () => ({
disconnected: false,
port: {},
connect() {},
disconnect() {
this.disconnected = true;
},
});
function harness({ worklet = false, suspended = false } = {}) {
const calls = { mic: [], resume: [], worklet: [], nodes: [], sent: [], statuses: [], pcm: [] };
const timers = new Map();
let timerId = 0;
let scriptBuilds = 0;
class AudioContext {
constructor() {
this.state = suspended ? 'suspended' : 'running';
this.sampleRate = 48000;
this.audioWorklet = worklet ? {} : undefined;
}
resume() {
const d = deferred();
calls.resume.push(d);
return d.promise;
}
suspend() {
this.state = 'suspended';
return Promise.resolve();
}
close() {
this.state = 'closed';
return Promise.resolve();
}
createMediaStreamSource() {
return makeNode();
}
}
const state = {
recording: false,
startSent: false,
awaitingResult: false,
ws: { readyState: 1 },
audioGen: 0,
audioCtx: null,
mediaStream: null,
sourceNode: null,
workletNode: null,
scriptNode: null,
usingWorklet: false,
remoteSessionId: '',
remoteSequence: 0,
finishAfterStarted: '',
pendingPcm: [],
pendingPcmBytes: 0,
resampleState: { phase: 0, last: 0, hasLast: false },
wakeLockHint: null,
TARGET_SR: 16000,
MIC_PREP_TIMEOUT_MS: 10000,
L: { preparingMic: 'mic', preparingBackend: 'backend', micTimeout: 'timeout', ready: 'ready' },
window: { AudioContext },
navigator: {
mediaDevices: {
getUserMedia() {
const d = deferred();
calls.mic.push(d);
return d.promise;
},
},
},
AudioWorkletNode: class {
constructor() {
const n = makeNode();
calls.nodes.push(n);
return n;
}
},
setTimeout(fn) {
const id = ++timerId;
timers.set(id, fn);
return id;
},
clearTimeout(id) {
timers.delete(id);
},
loadWorklet() {
const d = deferred();
calls.worklet.push(d);
return d.promise;
},
buildScriptProcessor() {
scriptBuilds++;
state.scriptNode = makeNode();
},
wsSendJSON(message) {
calls.sent.push(message.type);
},
sendAudio(pcm) {
calls.pcm.push(pcm);
},
setStatus(message) {
calls.statuses.push(message);
},
micErrorText(error) {
return error.name;
},
interruptRecording() {
throw new Error('unexpected interruption');
},
};
for (const name of [
'clearRecoveryTimer',
'acquireWakeLock',
'releaseWakeLock',
'clearReadyTimer',
'clearWorkTimeout',
'updateRecordBtnUI',
'clearResult',
'detachHoldEnd',
'setLevel',
'enterTranscribing',
'armWorkTimeout',
'saveRecoverySession',
])
state[name] = () => {};
vm.runInNewContext(lifecycle, state);
return {
state,
calls,
timers,
get scriptBuilds() {
return scriptBuilds;
},
};
}
async function startPending(h) {
h.state.startRecording();
await settle();
}
async function resolveMic(h, index) {
const stream = makeStream();
h.calls.mic[index].resolve(stream);
await settle();
return stream;
}
function assertActive(h, stream, node) {
assert.equal(h.state.recording, true, 'old callback must not stop the new recording');
assert.equal(h.state.startSent, true);
assert.equal(h.state.mediaStream, stream, 'old stream must not replace the new microphone');
assert.equal(h.state.sourceNode, node);
assert.equal(node.disconnected, false);
assert.equal(stream.track.stopped, false);
assert.deepEqual(h.calls.sent, ['start'], 'only the current attempt may send start');
}
for (const end of ['stopRecording', 'cancelRecording']) {
test(`late microphone success after ${end} cannot replace a retry`, async () => {
const h = harness();
await startPending(h);
h.state[end]();
await startPending(h);
const current = await resolveMic(h, 1);
const node = h.state.sourceNode;
const stale = await resolveMic(h, 0);
assert.equal(stale.track.stopped, true, 'cancelled microphone request must release its tracks');
assertActive(h, current, node);
});
}
test('late microphone rejection cannot stop a retry', async () => {
const h = harness();
await startPending(h);
h.state.stopRecording();
await startPending(h);
const current = await resolveMic(h, 1);
const node = h.state.sourceNode;
h.calls.mic[0].reject(Object.assign(new Error('old request'), { name: 'NotAllowedError' }));
await settle();
assertActive(h, current, node);
assert.equal(h.calls.statuses.at(-1), 'backend');
});
test('old preparation timeout cannot close the current AudioContext', async () => {
const h = harness();
await startPending(h);
const oldTimeout = [...h.timers.values()][0];
h.state.stopRecording();
await startPending(h);
const current = await resolveMic(h, 1);
const node = h.state.sourceNode;
const context = h.state.audioCtx;
oldTimeout();
await settle();
assertActive(h, current, node);
assert.equal(h.state.audioCtx, context);
assert.equal(context.state, 'running');
});
test('late resume cannot start another microphone request after cancellation', async () => {
const h = harness({ suspended: true });
await startPending(h);
h.state.stopRecording();
h.calls.resume[0].resolve();
await settle();
assert.equal(h.calls.mic.length, 0, 'cancelled resume must not request microphone permission');
assert.deepEqual(h.calls.sent, []);
});
test('late resume cannot borrow a new recording and send a second start', async () => {
const h = harness({ suspended: true });
await startPending(h);
h.state.stopRecording();
await startPending(h);
h.state.audioCtx.state = 'running';
h.calls.resume[1].resolve();
await settle();
const current = await resolveMic(h, 0);
const node = h.state.sourceNode;
h.calls.resume[0].resolve();
await settle();
assertActive(h, current, node);
});
for (const outcome of ['resolve', 'reject']) {
test(`late worklet ${outcome} cannot rebuild the current capture graph`, async () => {
const h = harness({ worklet: true });
await startPending(h);
const current = await resolveMic(h, 0);
h.state.stopRecording();
await startPending(h);
h.calls.worklet[1].resolve();
await settle();
const node = h.state.sourceNode;
const currentWorklet = h.state.workletNode;
h.calls.worklet[0][outcome](new Error('old module'));
await settle();
assertActive(h, current, node);
assert.equal(h.state.workletNode, currentWorklet);
assert.equal(h.calls.nodes.length, 1);
assert.equal(h.scriptBuilds, 0, 'stale worklet failure must not start fallback capture');
assert.equal(h.state.usingWorklet, true);
});
}
test('current worklet failure still falls back to ScriptProcessor', async () => {
const h = harness({ worklet: true });
await startPending(h);
await resolveMic(h, 0);
h.calls.worklet[0].reject(new Error('worklet unavailable'));
await settle();
assert.equal(h.scriptBuilds, 1);
assert.equal(h.state.recording, true);
assert.deepEqual(h.calls.sent, ['start']);
});
test('current timeout still resets the context and releases a late microphone', async () => {
const h = harness();
await startPending(h);
[...h.timers.values()][0]();
await settle();
assert.equal(h.state.recording, false);
assert.equal(h.state.audioCtx, null);
assert.equal(h.calls.statuses.at(-1), 'timeout');
const stale = await resolveMic(h, 0);
assert.equal(stale.track.stopped, true);
assert.deepEqual(h.calls.sent, []);
});
test('normal stop still pairs start/stop and reuses the microphone on the next attempt', async () => {
const h = harness();
await startPending(h);
const stream = await resolveMic(h, 0);
h.state.remoteSessionId = 'active-session';
h.state.stopRecording();
assert.deepEqual(h.calls.sent, ['start', 'stop']);
assert.equal(h.state.awaitingResult, true);
assert.equal(stream.track.stopped, false);
h.state.awaitingResult = false; // Backend completed the preceding recording.
await startPending(h);
assert.equal(h.calls.mic.length, 1);
assert.equal(h.state.mediaStream, stream);
assert.deepEqual(h.calls.sent, ['start', 'stop', 'start']);
});
@@ -0,0 +1,56 @@
/**
* Run `tauri android build` with ABI targets from OPENLESS_ANDROID_TARGETS
* (space/comma-separated CLI names: aarch64 armv7 i686 x86_64).
*
* Usage:
* node scripts/run-android-tauri-build.mjs --debug
* node scripts/run-android-tauri-build.mjs --release
*/
import { spawnSync } from 'node:child_process';
import process from 'node:process';
import { parseAndroidAbis, ANDROID_ABI_MATRIX } from './android-abi-matrix.mjs';
function resolveTargets() {
const raw = process.env.OPENLESS_ANDROID_TARGETS ?? '';
if (!raw.trim()) {
return ANDROID_ABI_MATRIX.map((e) => e.abi);
}
return parseAndroidAbis(raw, {
defaultAbis: ANDROID_ABI_MATRIX.map((e) => e.abi),
}).map((e) => e.abi);
}
function main() {
const mode = process.argv[2];
if (mode !== '--debug' && mode !== '--release') {
console.error('Usage: node scripts/run-android-tauri-build.mjs --debug|--release');
process.exit(1);
}
const targets = resolveTargets();
const args = ['tauri', 'android', 'build', '--apk', '--split-per-abi', '--target', ...targets];
if (mode === '--debug') {
args.push('--debug');
}
console.log(`[android-build] targets=${targets.join(',')} mode=${mode.slice(2)}`);
// #1103: Tauri CLI runs an initial cargo build for the first target to
// "initialize plugins", then Gradle invokes `android-studio-script` per ABI
// (including the first). Expect two cargo passes for the first ABI; do not
// bypass the Gradle/native path.
console.log(
'[android-build] note: first ABI may compile twice (Tauri plugin init + android-studio-script)',
);
const bin = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const result = spawnSync(bin, args, {
stdio: 'inherit',
env: process.env,
shell: process.platform === 'win32',
});
if (result.error) {
throw result.error;
}
process.exit(result.status ?? 1);
}
main();
+1 -1
View File
@@ -4257,7 +4257,7 @@ dependencies = [
[[package]]
name = "openless"
version = "2.0.0-Beta.2+build.20260924"
version = "2.0.0-Beta.3+build.20260925"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "openless"
version = "2.0.0-Beta.2+build.20260924"
version = "2.0.0-Beta.3+build.20260925"
license = "AGPL-3.0-only"
description = "OpenLess — local voice input that types where your cursor is"
authors = ["OpenLess"]
@@ -1099,14 +1099,15 @@ impl Coordinator {
if crate::shortcut_binding::binding_requires_side_aware_hook(&binding) {
take_combo_hotkey_on_main_thread(&self.inner);
self.inner.side_aware_combo.lock().take();
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) {
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
let combo_tx = spawn_combo_abort_bridge(&self.inner, handle_trigger_combined);
match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx, combo_tx) {
Ok(monitor) => {
*self.inner.side_aware_combo.lock() = Some(monitor);
let bridge_inner = Arc::clone(&self.inner);
std::thread::Builder::new()
.name("openless-side-combo-bridge".into())
.spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx))
.spawn(move || hotkey_bridge_loop(bridge_inner, rx))
.ok();
log::info!("[coord] side-aware combo hotkey listener installed (via update)");
}
@@ -1292,14 +1292,15 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc<Inner>) {
if inner.side_aware_combo.lock().is_some() {
return;
}
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) {
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
let combo_tx = spawn_combo_abort_bridge(&inner, handle_trigger_combined);
match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx, combo_tx) {
Ok(monitor) => {
*inner.side_aware_combo.lock() = Some(monitor);
let inner_clone = Arc::clone(&inner);
std::thread::Builder::new()
.name("openless-side-combo-bridge".into())
.spawn(move || combo_hotkey_bridge_loop(inner_clone, rx))
.spawn(move || hotkey_bridge_loop(inner_clone, rx))
.ok();
return;
}
@@ -47,14 +47,17 @@ impl Coordinator {
.update_binding(binding)
.map_err(|error| error.to_string())?;
} else {
let (tx, rx) = mpsc::channel();
let monitor =
crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx)
.map_err(|error| error.to_string())?;
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
let combo_tx =
spawn_combo_abort_bridge(&inner, handle_trigger_combined);
let monitor = crate::side_aware_combo::SideAwareComboMonitor::start(
binding, tx, combo_tx,
)
.map_err(|error| error.to_string())?;
let bridge_inner = Arc::clone(&inner);
std::thread::Builder::new()
.name("openless-side-combo-bridge".into())
.spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx))
.spawn(move || hotkey_bridge_loop(bridge_inner, rx))
.map_err(|error| error.to_string())?;
*slot = Some(monitor);
}
+55 -3
View File
@@ -931,6 +931,7 @@ mod platform {
fn handle_key_down(ctx: &CallbackContext, event: CgEventRef) {
let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) };
crate::side_aware_combo::handle_companion_key_down();
if keycode == ESC_KEYCODE {
note_companion_key_down(ctx);
send_cancel_or_log(&ctx.cancel_tx);
@@ -1570,6 +1571,7 @@ mod platform {
}
let pressed = matches!(message, WM_KEYDOWN | WM_SYSKEYDOWN);
if vk_code == VK_ESCAPE && (message == WM_KEYDOWN || message == WM_SYSKEYDOWN) {
crate::side_aware_combo::handle_companion_key_down();
note_companion_key_down(ctx);
send_cancel_or_log(&ctx.cancel_tx);
// 会话激活期间独占消费 Esc(返回 true → LRESULT(1) 吞掉),宿主应用收不到,
@@ -1580,6 +1582,7 @@ mod platform {
crate::side_aware_combo::platform::dispatch_vk(vk_code, pressed);
if pressed && !is_modifier_vk(vk_code) {
crate::side_aware_combo::handle_companion_key_down();
note_companion_key_down(ctx);
}
@@ -2153,16 +2156,17 @@ mod platform {
#[test]
fn windows_shift_side_combo_receives_pressed_via_dispatch_keyboard_event() {
use crate::combo_hotkey::ComboHotkeyEvent;
use crate::side_aware_combo::SideAwareComboMonitor;
use crate::types::ShortcutBinding;
let (combo_tx, combo_rx) = mpsc::channel();
let (abort_tx, _abort_rx) = mpsc::channel();
let binding = ShortcutBinding {
primary: "D".into(),
modifiers: vec!["shift-left".into()],
};
let monitor = SideAwareComboMonitor::start(binding, combo_tx).expect("start monitor");
let monitor =
SideAwareComboMonitor::start(binding, combo_tx, abort_tx).expect("start monitor");
let shared = shared(HotkeyTrigger::Custom);
let (ctx, hotkey_rx) = callback_context(shared);
@@ -2172,7 +2176,7 @@ mod platform {
assert!(matches!(
combo_rx.recv().unwrap(),
ComboHotkeyEvent::Pressed { .. }
HotkeyEvent::Pressed { .. }
));
assert!(hotkey_rx
.try_iter()
@@ -2180,6 +2184,54 @@ mod platform {
drop(monitor);
}
#[test]
fn windows_modifier_chord_uses_existing_companion_abort_semantics() {
use crate::side_aware_combo::SideAwareComboMonitor;
use crate::types::ShortcutBinding;
let (tx, rx) = mpsc::channel();
let (abort_tx, abort_rx) = mpsc::channel();
let binding = ShortcutBinding {
primary: "ModifierChord".into(),
modifiers: vec!["ctrl-left".into(), "cmd-left".into()],
};
let monitor =
SideAwareComboMonitor::start(binding, tx, abort_tx).expect("start monitor");
let shared = shared(HotkeyTrigger::Custom);
let (ctx, _main_rx) = callback_context(shared);
dispatch_keyboard_event(&ctx, VK_LCONTROL, WM_KEYDOWN);
assert!(rx.try_recv().is_err());
dispatch_keyboard_event(&ctx, VK_LWIN, WM_KEYDOWN);
let press_id = match rx.recv().unwrap() {
HotkeyEvent::Pressed { press_id, .. } => press_id,
other => panic!("expected modifier chord Pressed, got {other:?}"),
};
dispatch_keyboard_event(&ctx, 0x44, WM_KEYDOWN);
assert!(matches!(
abort_rx.recv().unwrap(),
HotkeyCombinedEdge {
press_id: combined_id,
..
} if combined_id == press_id
));
dispatch_keyboard_event(&ctx, 0x44, WM_KEYDOWN);
assert!(abort_rx.try_recv().is_err());
dispatch_keyboard_event(&ctx, VK_LWIN, WM_KEYUP);
assert!(matches!(
rx.recv().unwrap(),
HotkeyEvent::Released {
press_id: released_id,
..
} if released_id == press_id
));
drop(monitor);
}
}
}
@@ -2,7 +2,8 @@
use std::sync::mpsc::Sender;
use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent};
use crate::combo_hotkey::ComboHotkeyError;
use crate::hotkey::{HotkeyCombinedEdge, HotkeyEvent};
use crate::types::ShortcutBinding;
#[derive(Debug, Clone, Copy)]
@@ -22,7 +23,8 @@ pub struct SideAwareComboMonitor;
impl SideAwareComboMonitor {
pub fn start(
_binding: ShortcutBinding,
_tx: Sender<ComboHotkeyEvent>,
_tx: Sender<HotkeyEvent>,
_combo_tx: Sender<HotkeyCombinedEdge>,
) -> Result<Self, ComboHotkeyError> {
Err(ComboHotkeyError::RegisterFailed(
"Side-specific combo hotkeys are not available on mobile".into(),
@@ -34,6 +36,8 @@ pub fn handle_side_modifier(_side: SideModifier, _pressed: bool) {}
pub fn handle_primary_key(_primary: &str, _pressed: bool) {}
pub fn handle_companion_key_down() {}
#[cfg(target_os = "macos")]
pub mod platform {
pub fn dispatch_keycode(_keycode: i64, _flags_changed: bool, _flags: u64, _pressed: bool) {}
@@ -1,7 +1,7 @@
//! Side-specific combo hotkey matching (e.g. Left Cmd + D).
//!
//! `global-hotkey` cannot distinguish left/right modifiers. This module maintains
//! physical modifier state and matches combos registered via [`SideAwareComboMonitor`].
//! physical modifier state and matches side-aware bindings registered via [`SideAwareComboMonitor`].
use std::sync::mpsc::Sender;
use std::sync::{OnceLock, RwLock};
@@ -9,14 +9,16 @@ use std::time::Instant;
use parking_lot::Mutex;
use crate::combo_hotkey::ComboHotkeyEvent;
use crate::hotkey::{HotkeyCombinedEdge, HotkeyEvent};
use crate::shortcut_binding::{is_side_specific_modifier_tag, normalize_side_modifier_tag};
use crate::types::ShortcutBinding;
use openless_core::is_modifier_chord_binding;
static ACTIVE_MONITOR: OnceLock<RwLock<Option<ActiveSideCombo>>> = OnceLock::new();
struct ActiveSideCombo {
tx: Sender<ComboHotkeyEvent>,
tx: Sender<HotkeyEvent>,
combo_tx: Sender<HotkeyCombinedEdge>,
state: Mutex<SideAwareComboState>,
}
@@ -36,7 +38,8 @@ struct ModifierSideState {
struct SideAwareComboState {
binding: ShortcutBinding,
modifiers: ModifierSideState,
combo_active: bool,
active_press_id: Option<u64>,
companion_seen: bool,
}
impl SideAwareComboState {
@@ -44,7 +47,8 @@ impl SideAwareComboState {
Self {
binding,
modifiers: ModifierSideState::default(),
combo_active: false,
active_press_id: None,
companion_seen: false,
}
}
@@ -106,50 +110,73 @@ impl SideAwareComboState {
self.expected_modifier_tags() == self.pressed_modifier_tags()
}
fn on_primary(&mut self, primary: &str, pressed: bool) -> Option<ComboHotkeyEvent> {
fn activate(&mut self) -> Option<HotkeyEvent> {
if self.active_press_id.is_some() {
return None;
}
let press_id = crate::hotkey::next_press_id();
self.active_press_id = Some(press_id);
self.companion_seen = false;
Some(HotkeyEvent::Pressed {
at: Instant::now(),
press_id,
})
}
fn release_active(&mut self) -> Option<HotkeyEvent> {
let press_id = self.active_press_id.take()?;
self.companion_seen = false;
Some(HotkeyEvent::Released {
at: Instant::now(),
press_id,
})
}
fn on_modifier_press(&mut self, side: SideModifier) -> Option<HotkeyEvent> {
self.set_side(side, true);
if is_modifier_chord_binding(&self.binding) && self.modifiers_match() {
return self.activate();
}
None
}
fn on_primary(&mut self, primary: &str, pressed: bool) -> Option<HotkeyEvent> {
if is_modifier_chord_binding(&self.binding) {
return None;
}
if !primary_eq(&self.binding.primary, primary) {
return None;
}
if pressed {
if self.modifiers_match() {
// `modifiers_match()` is the authoritative activation gate. If
// `combo_active` is still true here, the previous `Released` was
// dropped by the OS: we must NOT emit a second `Pressed` (that
// would break the pairing invariant), so treat the flag as
// already reflecting an active combo and swallow this edge.
if !self.combo_active {
self.combo_active = true;
return Some(ComboHotkeyEvent::Pressed { at: Instant::now() });
}
return None;
return self.activate();
}
// Modifiers no longer match — this is the single authoritative reset
// condition. If `combo_active` is stuck true (a modifier release was
// dropped so no `Released` was ever emitted), self-heal by emitting
// the terminal `Released` now so the recording latch cannot stick.
if self.combo_active {
self.combo_active = false;
return Some(ComboHotkeyEvent::Released { at: Instant::now() });
if self.active_press_id.is_some() {
return self.release_active();
}
return None;
}
// Primary key up is the absolute termination signal for the combo.
if self.combo_active {
self.combo_active = false;
return Some(ComboHotkeyEvent::Released { at: Instant::now() });
self.release_active()
}
fn on_modifier_release(&mut self, side: SideModifier) -> Option<HotkeyEvent> {
self.set_side(side, false);
if self.active_press_id.is_some() && !self.modifiers_match() {
return self.release_active();
}
None
}
fn on_modifier_release(&mut self, side: SideModifier) -> Option<ComboHotkeyEvent> {
self.set_side(side, false);
// Modifiers no longer matching is the authoritative reset condition:
// once the required side-modifier set is broken, the combo is over.
if self.combo_active && !self.modifiers_match() {
self.combo_active = false;
return Some(ComboHotkeyEvent::Released { at: Instant::now() });
fn on_companion_key_down(&mut self) -> Option<HotkeyCombinedEdge> {
if !is_modifier_chord_binding(&self.binding) || self.companion_seen {
return None;
}
None
let press_id = self.active_press_id?;
self.companion_seen = true;
Some(HotkeyCombinedEdge {
at: Instant::now(),
press_id,
})
}
}
@@ -170,7 +197,8 @@ pub struct SideAwareComboMonitor;
impl SideAwareComboMonitor {
pub fn start(
binding: ShortcutBinding,
tx: Sender<ComboHotkeyEvent>,
tx: Sender<HotkeyEvent>,
combo_tx: Sender<HotkeyCombinedEdge>,
) -> Result<Self, crate::combo_hotkey::ComboHotkeyError> {
// Linux has no side-aware platform dispatch (no CGEventTap / WH_KEYBOARD_LL
// equivalent wired here). Accepting the binding would leave the user with a
@@ -178,7 +206,7 @@ impl SideAwareComboMonitor {
// so the caller can surface an actionable error instead.
#[cfg(target_os = "linux")]
{
let _ = (&binding, &tx);
let _ = (&binding, &tx, &combo_tx);
return Err(crate::combo_hotkey::ComboHotkeyError::UnsupportedModifier(
"侧向修饰键组合键在 Linux 暂不支持".into(),
));
@@ -192,6 +220,7 @@ impl SideAwareComboMonitor {
let mut guard = slot.write().expect("side combo monitor lock poisoned");
*guard = Some(ActiveSideCombo {
tx,
combo_tx,
state: Mutex::new(SideAwareComboState::new(binding)),
});
Ok(Self)
@@ -213,6 +242,17 @@ fn validate_side_binding(
"binding is not side-specific".into(),
));
}
if is_modifier_chord_binding(binding) {
return openless_core::validate_shortcut_binding(binding).map_err(|error| match error {
openless_core::ShortcutBindingError::UnsupportedModifier(value) => {
crate::combo_hotkey::ComboHotkeyError::UnsupportedModifier(value)
}
openless_core::ShortcutBindingError::UnsupportedKey(value) => {
crate::combo_hotkey::ComboHotkeyError::UnsupportedKey(value)
}
});
}
crate::shortcut_binding::parse_primary(&binding.primary)
.map_err(|e| crate::combo_hotkey::ComboHotkeyError::UnsupportedKey(e.to_string()))?;
@@ -261,18 +301,23 @@ where
guard.as_ref().map(f)
}
fn send_event(tx: &Sender<ComboHotkeyEvent>, evt: ComboHotkeyEvent) {
fn send_event(tx: &Sender<HotkeyEvent>, evt: HotkeyEvent) {
if let Err(err) = tx.send(evt) {
log::warn!("[side-aware-combo] event send failed: {err}");
}
}
fn send_combo_abort(tx: &Sender<HotkeyCombinedEdge>, edge: HotkeyCombinedEdge) {
if let Err(err) = tx.send(edge) {
log::warn!("[side-aware-combo] abort send failed: {err}");
}
}
pub fn handle_side_modifier(side: SideModifier, pressed: bool) {
if let Some(evt) = with_active(|active| {
let mut state = active.state.lock();
if pressed {
state.set_side(side, true);
None
state.on_modifier_press(side)
} else {
state.on_modifier_release(side)
}
@@ -294,6 +339,12 @@ pub fn handle_primary_key(primary: &str, pressed: bool) {
}
}
pub fn handle_companion_key_down() {
if let Some(edge) = with_active(|active| active.state.lock().on_companion_key_down()).flatten() {
with_active(|active| send_combo_abort(&active.combo_tx, edge));
}
}
fn primary_eq(expected: &str, actual: &str) -> bool {
expected.trim().eq_ignore_ascii_case(actual.trim())
}
@@ -594,7 +645,8 @@ mod tests {
modifiers: vec!["ctrl-right".into()],
};
let (tx, rx) = mpsc::channel();
let monitor = SideAwareComboMonitor::start(binding.clone(), tx).unwrap();
let (combo_tx, _combo_rx) = mpsc::channel();
let monitor = SideAwareComboMonitor::start(binding.clone(), tx, combo_tx).unwrap();
let press_and_release = |primary: &str| {
handle_side_modifier(SideModifier::CtrlRight, true);
handle_primary_key(primary, true);
@@ -602,11 +654,11 @@ mod tests {
handle_side_modifier(SideModifier::CtrlRight, false);
assert!(matches!(
rx.try_recv(),
Ok(ComboHotkeyEvent::Pressed { .. })
Ok(HotkeyEvent::Pressed { .. })
));
assert!(matches!(
rx.try_recv(),
Ok(ComboHotkeyEvent::Released { .. })
Ok(HotkeyEvent::Released { .. })
));
assert!(rx.try_recv().is_err());
};
@@ -686,7 +738,7 @@ mod tests {
state.set_side(SideModifier::CmdLeft, true);
assert!(state.modifiers_match());
let evt = state.on_primary("D", true);
assert!(matches!(evt, Some(ComboHotkeyEvent::Pressed { .. })));
assert!(matches!(evt, Some(HotkeyEvent::Pressed { .. })));
}
#[test]
@@ -702,7 +754,7 @@ mod tests {
assert!(state.modifiers_match());
assert!(matches!(
state.on_primary("D", true),
Some(ComboHotkeyEvent::Pressed { .. })
Some(HotkeyEvent::Pressed { .. })
));
}
@@ -741,7 +793,7 @@ mod tests {
assert!(state.modifiers_match());
assert!(matches!(
state.on_primary("D", true),
Some(ComboHotkeyEvent::Pressed { .. })
Some(HotkeyEvent::Pressed { .. })
));
}
@@ -757,6 +809,63 @@ mod tests {
assert_eq!(state.on_primary("D", true), None);
}
#[test]
fn modifier_chord_activates_on_final_modifier_and_releases_with_same_generation() {
let mut state = SideAwareComboState::new(ShortcutBinding {
primary: "ModifierChord".into(),
modifiers: vec!["ctrl-left".into(), "cmd-left".into()],
});
assert_eq!(state.on_modifier_press(SideModifier::CtrlLeft), None);
let press_id = match state.on_modifier_press(SideModifier::CmdLeft) {
Some(HotkeyEvent::Pressed { press_id, .. }) => press_id,
other => panic!("expected modifier chord Pressed, got {other:?}"),
};
assert_ne!(press_id, 0);
assert_eq!(state.on_modifier_press(SideModifier::CmdLeft), None);
assert!(matches!(
state.on_modifier_release(SideModifier::CtrlLeft),
Some(HotkeyEvent::Released { press_id: released_id, .. }) if released_id == press_id
));
assert!(state.active_press_id.is_none());
}
#[test]
fn modifier_chord_companion_aborts_once_but_still_pairs_release() {
let mut state = SideAwareComboState::new(ShortcutBinding {
primary: "ModifierChord".into(),
modifiers: vec!["ctrl-left".into(), "cmd-left".into()],
});
state.on_modifier_press(SideModifier::CtrlLeft);
let press_id = match state.on_modifier_press(SideModifier::CmdLeft) {
Some(HotkeyEvent::Pressed { press_id, .. }) => press_id,
other => panic!("expected modifier chord Pressed, got {other:?}"),
};
assert!(matches!(
state.on_companion_key_down(),
Some(HotkeyCombinedEdge { press_id: combined_id, .. }) if combined_id == press_id
));
assert_eq!(state.on_companion_key_down(), None);
assert!(matches!(
state.on_modifier_release(SideModifier::CmdLeft),
Some(HotkeyEvent::Released { press_id: released_id, .. }) if released_id == press_id
));
}
#[test]
fn extra_modifier_does_not_activate_modifier_chord_when_it_is_released() {
let mut state = SideAwareComboState::new(ShortcutBinding {
primary: "ModifierChord".into(),
modifiers: vec!["ctrl-left".into(), "cmd-left".into()],
});
assert_eq!(state.on_modifier_press(SideModifier::CtrlLeft), None);
assert_eq!(state.on_modifier_press(SideModifier::ShiftLeft), None);
assert_eq!(state.on_modifier_press(SideModifier::CmdLeft), None);
assert_eq!(state.on_modifier_release(SideModifier::ShiftLeft), None);
assert!(state.active_press_id.is_none());
}
#[cfg(target_os = "macos")]
#[test]
fn macos_side_keycodes_are_distinct() {
@@ -782,16 +891,16 @@ mod tests {
state.set_side(SideModifier::CmdLeft, true);
assert!(matches!(
state.on_primary("D", true),
Some(ComboHotkeyEvent::Pressed { .. })
Some(HotkeyEvent::Pressed { .. })
));
// Primary key up terminates the combo with exactly one Released.
assert!(matches!(
state.on_primary("D", false),
Some(ComboHotkeyEvent::Released { .. })
Some(HotkeyEvent::Released { .. })
));
// No trailing events; a second key-up must not emit anything.
assert_eq!(state.on_primary("D", false), None);
assert!(!state.combo_active);
assert!(state.active_press_id.is_none());
}
#[test]
@@ -800,50 +909,50 @@ mod tests {
state.set_side(SideModifier::CmdLeft, true);
assert!(matches!(
state.on_primary("D", true),
Some(ComboHotkeyEvent::Pressed { .. })
Some(HotkeyEvent::Pressed { .. })
));
// Modifier lifts while primary is still down -> combo terminates once.
assert!(matches!(
state.on_modifier_release(SideModifier::CmdLeft),
Some(ComboHotkeyEvent::Released { .. })
Some(HotkeyEvent::Released { .. })
));
assert!(!state.combo_active);
assert!(state.active_press_id.is_none());
// A now-orphaned primary key-up must NOT emit a second Released.
assert_eq!(state.on_primary("D", false), None);
}
#[test]
fn dropped_modifier_release_is_recovered_on_primary_up() {
// Simulate the OS dropping the modifier-up event: combo_active stays true
// Simulate the OS dropping the modifier-up event: the active press stays latched
// and modifiers still "match" from the state's perspective. The primary
// key-up (absolute termination) must still emit the paired Released.
let mut state = cmd_left_d_state();
state.set_side(SideModifier::CmdLeft, true);
assert!(matches!(
state.on_primary("D", true),
Some(ComboHotkeyEvent::Pressed { .. })
Some(HotkeyEvent::Pressed { .. })
));
// Modifier physically released but the release event never arrived, so the
// side flag is still set here. Primary up is the fallback terminator.
assert!(matches!(
state.on_primary("D", false),
Some(ComboHotkeyEvent::Released { .. })
Some(HotkeyEvent::Released { .. })
));
assert!(!state.combo_active);
assert!(state.active_press_id.is_none());
}
#[test]
fn stale_combo_active_reset_when_modifiers_stop_matching() {
// Reproduce a stuck latch: a prior Released was lost so combo_active is true,
// Reproduce a stuck latch: a prior Released was lost so an active press is still latched,
// yet the required modifier is no longer held (modifiers_match() == false).
// The next primary-down must self-heal by emitting the terminal Released
// (NOT swallow it, and NOT emit a second Pressed) so recording can't stick.
let mut state = cmd_left_d_state();
state.combo_active = true; // stale flag from a dropped Released
state.active_press_id = Some(crate::hotkey::next_press_id()); // stale flag from a dropped Released
assert!(!state.modifiers_match()); // cmd-left is not held
let evt = state.on_primary("D", true);
assert!(matches!(evt, Some(ComboHotkeyEvent::Released { .. })));
assert!(!state.combo_active);
assert!(matches!(evt, Some(HotkeyEvent::Released { .. })));
assert!(state.active_press_id.is_none());
}
#[test]
@@ -852,26 +961,26 @@ mod tests {
// must be able to fire a fresh Pressed. Guards against the combo becoming
// permanently unrepeatable (the historical #545/#468 stuck-latch class).
let mut state = cmd_left_d_state();
state.combo_active = true; // leftover from a dropped Released
state.active_press_id = Some(crate::hotkey::next_press_id()); // leftover from a dropped Released
// Releasing the required side-modifier breaks the match, so the stale latch
// self-heals by emitting the terminal Released here (pairing the Pressed whose
// Released was dropped). Either way combo_active must end up cleared.
// Released was dropped). Either way the active press must end up cleared.
assert!(matches!(
state.on_modifier_release(SideModifier::CmdLeft),
Some(ComboHotkeyEvent::Released { .. })
Some(HotkeyEvent::Released { .. })
));
assert!(!state.combo_active);
assert!(state.active_press_id.is_none());
// Fresh, clean press cycle now behaves normally.
state.set_side(SideModifier::CmdLeft, true);
assert!(matches!(
state.on_primary("D", true),
Some(ComboHotkeyEvent::Pressed { .. })
Some(HotkeyEvent::Pressed { .. })
));
assert!(matches!(
state.on_primary("D", false),
Some(ComboHotkeyEvent::Released { .. })
Some(HotkeyEvent::Released { .. })
));
}
@@ -882,13 +991,13 @@ mod tests {
// must NOT emit a second Pressed — that would break the pairing invariant.
let mut state = cmd_left_d_state();
state.set_side(SideModifier::CmdLeft, true);
state.combo_active = true; // pretend previous Released was dropped
state.active_press_id = Some(crate::hotkey::next_press_id()); // pretend previous Released was dropped
assert!(state.modifiers_match());
assert_eq!(state.on_primary("D", true), None);
// The real terminator (primary up) still yields exactly one Released.
assert!(matches!(
state.on_primary("D", false),
Some(ComboHotkeyEvent::Released { .. })
Some(HotkeyEvent::Released { .. })
));
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenLess",
"version": "2.0.0-Beta.2+build.20260924",
"version": "2.0.0-Beta.3+build.20260925",
"identifier": "com.openless.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -2,7 +2,12 @@ import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } f
import { AnimatePresence, motion } from 'framer-motion';
import { ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { formatComboParts, modifiersFromPressedCodes } from '../lib/hotkey';
import {
chordModifiersFromPressedCodes,
formatComboParts,
MODIFIER_CHORD_PRIMARY,
modifiersFromPressedCodes,
} from '../lib/hotkey';
import { functionKeyPrimaryFromEvent } from '../lib/hotkeyRecorder';
import { KbdGroup } from './Kbd';
import { setShortcutRecordingActive, validateShortcutBinding } from '../lib/ipc';
@@ -194,6 +199,20 @@ export function ShortcutRecorder({
if (comboOnly) {
return;
}
if (sideSpecificModifiers) {
const modifiers = chordModifiersFromPressedCodes(pressedCodes.current);
if (modifiers.length >= 2) {
clearPendingModifier();
const binding = { primary: MODIFIER_CHORD_PRIMARY, modifiers };
pendingModifier.current = binding;
pendingTimer.current = window.setTimeout(() => {
if (pendingModifier.current === binding) {
void finish(binding);
}
}, 650);
return;
}
}
const primary = modifierPrimaryFromCode(e.code, e.key);
if (!primary || pendingModifier.current?.primary === primary) return;
clearPendingModifier();
@@ -222,6 +241,12 @@ export function ShortcutRecorder({
e.stopPropagation();
pressedCodes.current.delete(e.code);
if (comboOnly) return;
if (pendingModifier.current?.primary === MODIFIER_CHORD_PRIMARY) {
const binding = pendingModifier.current;
clearPendingModifier();
void finish(binding);
return;
}
const primary = modifierPrimaryFromCode(e.code, e.key);
if (primary && pendingModifier.current?.primary === primary) {
const binding = pendingModifier.current;
+1
View File
@@ -1354,6 +1354,7 @@ export const de: typeof zhCN = {
mimo: 'Xiaomi MiMo',
cometapi: 'CometAPI',
openrouterFree: 'OpenRouter (kostenlose Modelle)',
requesty: 'Requesty',
orcarouter: 'OrcaRouter',
alibabaCoding: 'Alibaba Cloud Coding Plan',
codingPlanX: 'CodingPlanX',
+1
View File
@@ -1315,6 +1315,7 @@ export const en: typeof zhCN = {
mimo: 'Xiaomi MiMo',
cometapi: 'CometAPI',
openrouterFree: 'OpenRouter (free models)',
requesty: 'Requesty',
orcarouter: 'OrcaRouter',
alibabaCoding: 'Alibaba Cloud Coding Plan',
codingPlanX: 'CodingPlanX',
+1
View File
@@ -1346,6 +1346,7 @@ export const es: typeof zhCN = {
mimo: 'Xiaomi MiMo',
cometapi: 'CometAPI',
openrouterFree: 'OpenRouter (modelos gratuitos)',
requesty: 'Requesty',
orcarouter: 'OrcaRouter',
alibabaCoding: 'Alibaba Cloud Coding Plan',
codingPlanX: 'CodingPlanX',
+1
View File
@@ -1363,6 +1363,7 @@ export const fr: typeof zhCN = {
mimo: 'Xiaomi MiMo',
cometapi: 'CometAPI',
openrouterFree: 'OpenRouter (modèles gratuits)',
requesty: 'Requesty',
orcarouter: 'OrcaRouter',
alibabaCoding: 'Alibaba Cloud Coding Plan',
codingPlanX: 'CodingPlanX',
+1
View File
@@ -1303,6 +1303,7 @@ export const ja: typeof zhCN = {
mimo: 'Xiaomi MiMo',
cometapi: 'CometAPI',
openrouterFree: 'OpenRouter(無料モデル)',
requesty: 'Requesty',
orcarouter: 'OrcaRouter',
alibabaCoding: 'Alibaba Cloud Coding Plan',
codingPlanX: 'CodingPlanX',
+1
View File
@@ -1295,6 +1295,7 @@ export const ko: typeof zhCN = {
mimo: 'Xiaomi MiMo',
cometapi: 'CometAPI',
openrouterFree: 'OpenRouter(무료 모델)',
requesty: 'Requesty',
orcarouter: 'OrcaRouter',
alibabaCoding: 'Alibaba Cloud Coding Plan',
codingPlanX: 'CodingPlanX',
+1
View File
@@ -1253,6 +1253,7 @@ export const zhCN = {
mimo: '小米 MiMo',
cometapi: 'CometAPI',
openrouterFree: 'OpenRouter(免费模型)',
requesty: 'Requesty',
orcarouter: 'OrcaRouter',
alibabaCoding: '阿里云 Coding Plan',
codingPlanX: 'CodingPlanX',
+1
View File
@@ -1254,6 +1254,7 @@ export const zhTW: typeof zhCN = {
mimo: '小米 MiMo',
cometapi: 'CometAPI',
openrouterFree: 'OpenRouter(免費模型)',
requesty: 'Requesty',
orcarouter: 'OrcaRouter',
alibabaCoding: '阿里雲 Coding Plan',
codingPlanX: 'CodingPlanX',
+21 -1
View File
@@ -8,6 +8,8 @@ import type {
ShortcutBinding,
} from './types';
export const MODIFIER_CHORD_PRIMARY = 'ModifierChord';
export function defaultQaShortcut(): ShortcutBinding {
return {
primary: ';',
@@ -269,7 +271,9 @@ export function formatComboParts(
}
}
parts.push(formatPrimary(binding.primary));
if (binding.primary !== MODIFIER_CHORD_PRIMARY) {
parts.push(formatPrimary(binding.primary));
}
return parts;
}
@@ -304,6 +308,22 @@ export function sideModifiersFromPressedCodes(codes: Iterable<string>): string[]
return modifiers;
}
/** A modifier-only chord preserves every physical side, including Ctrl+Ctrl. */
export function chordModifiersFromPressedCodes(codes: Iterable<string>): string[] {
const set = codes instanceof Set ? codes : new Set(codes);
const pairs = [
['MetaLeft', 'cmd-left'],
['MetaRight', 'cmd-right'],
['ControlLeft', 'ctrl-left'],
['ControlRight', 'ctrl-right'],
['AltLeft', 'alt-left'],
['AltRight', 'alt-right'],
['ShiftLeft', 'shift-left'],
['ShiftRight', 'shift-right'],
];
return pairs.filter(([code]) => set.has(code)).map(([, modifier]) => modifier);
}
/** Build generic modifier tags (cmd/super/ctrl/alt/shift) from pressed key codes. */
export function genericModifiersFromPressedCodes(codes: Iterable<string>): string[] {
const set = codes instanceof Set ? codes : new Set(codes);
@@ -1,5 +1,8 @@
import {
chordModifiersFromPressedCodes,
formatComboParts,
genericModifiersFromPressedCodes,
MODIFIER_CHORD_PRIMARY,
modifiersFromPressedCodes,
shortcutFromLegacyTrigger,
sideModifiersFromPressedCodes,
@@ -62,6 +65,15 @@ assertDeepEqual(
'default recording on non-mac uses super',
);
assertDeepEqual(
formatComboParts({
primary: MODIFIER_CHORD_PRIMARY,
modifiers: ['ctrl-left', 'cmd-left'],
}),
['左 Win', '左 Ctrl'],
'modifier chord display contains only its physical modifiers',
);
assertDeepEqual(
sideModifiersFromPressedCodes(new Set(['MetaRight', 'KeyD'])),
['cmd-right'],
@@ -74,4 +86,22 @@ assertDeepEqual(
'left cmd wins when both meta keys are tracked',
);
for (const [left, right, modifier] of [
['MetaLeft', 'MetaRight', 'cmd'],
['ControlLeft', 'ControlRight', 'ctrl'],
['AltLeft', 'AltRight', 'alt'],
['ShiftLeft', 'ShiftRight', 'shift'],
]) {
assertDeepEqual(
chordModifiersFromPressedCodes(new Set([right, left])),
[`${modifier}-left`, `${modifier}-right`],
'modifier-only recording keeps both physical sides independent of press order',
);
}
assertDeepEqual(
chordModifiersFromPressedCodes(new Set(['MetaLeft', 'ControlLeft', 'KeyD'])),
['cmd-left', 'ctrl-left'],
'modifier chord ignores ordinary key codes',
);
console.log('hotkeySideModifiers.test.ts passed');
@@ -168,6 +168,22 @@
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://router.requesty.ai/v1",
"defaultModel": "openai/gpt-4o-mini",
"defaultRequestFormat": "chat_completions",
"kind": "llm",
"labelKey": "requesty",
"providerType": "requesty",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.orcarouter.ai/v1",
@@ -500,6 +500,22 @@
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://router.requesty.ai/v1",
"defaultModel": "openai/gpt-4o-mini",
"defaultRequestFormat": "chat_completions",
"kind": "llm",
"labelKey": "requesty",
"providerType": "requesty",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.orcarouter.ai/v1",
+1
View File
@@ -59,6 +59,7 @@ const LLM_NAME_KEY_BY_ID: Record<string, string> = {
mimo: 'mimo',
cometapi: 'cometapi',
openrouterFree: 'openrouterFree',
requesty: 'requesty',
orcarouter: 'orcarouter',
alibabaCoding: 'alibabaCoding',
codingPlanX: 'codingPlanX',
@@ -139,6 +139,7 @@ export const LLM_LABELS = [
['mimo', 'mimo'],
['cometapi', 'cometapi'],
['openrouterFree', 'openrouterFree'],
['requesty', 'requesty'],
['orcarouter', 'orcarouter'],
['alibabaCoding', 'alibabaCoding'],
['codingPlanX', 'codingPlanX'],