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:
1 parent
06e7e634b6
commit
06db7d2978
46 files changed
+1817
-302
No files matched your search
@@ -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(
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
Generated
+1
-1
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1253,6 +1253,7 @@ export const zhCN = {
|
||||
mimo: '小米 MiMo',
|
||||
cometapi: 'CometAPI',
|
||||
openrouterFree: 'OpenRouter(免费模型)',
|
||||
requesty: 'Requesty',
|
||||
orcarouter: 'OrcaRouter',
|
||||
alibabaCoding: '阿里云 Coding Plan',
|
||||
codingPlanX: 'CodingPlanX',
|
||||
|
||||
@@ -1254,6 +1254,7 @@ export const zhTW: typeof zhCN = {
|
||||
mimo: '小米 MiMo',
|
||||
cometapi: 'CometAPI',
|
||||
openrouterFree: 'OpenRouter(免費模型)',
|
||||
requesty: 'Requesty',
|
||||
orcarouter: 'OrcaRouter',
|
||||
alibabaCoding: '阿里雲 Coding Plan',
|
||||
codingPlanX: 'CodingPlanX',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in new issue
Block a user