diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a1ad91..345fcc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,13 +26,17 @@ jobs: rustup toolchain install stable --profile minimal --component clippy,rustfmt rustup default stable - name: Format - run: cargo fmt --all -- --check 2>&1 | tee fmt.log + shell: bash + run: set -o pipefail; cargo fmt --all -- --check 2>&1 | tee fmt.log - name: Build - run: cargo build --locked 2>&1 | tee build.log + shell: bash + run: set -o pipefail; cargo build --locked 2>&1 | tee build.log - name: Clippy (warnings are errors) - run: cargo clippy --all-targets --locked -- -D warnings 2>&1 | tee clippy.log + shell: bash + run: set -o pipefail; cargo clippy --all-targets --locked -- -D warnings 2>&1 | tee clippy.log - name: Test - run: cargo test --locked 2>&1 | tee test.log + shell: bash + run: set -o pipefail; cargo test --locked 2>&1 | tee test.log - name: Upload CI diagnostics if: failure() uses: actions/upload-artifact@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index dc179a6..f63b113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to Keyjitsu are documented here. This project follows ## [Unreleased] +## [0.10.0-beta.1] - 2026-10-18 + +### Added +- Native CI and release archives for macOS, Linux x86_64 and Windows x86_64. +- Cross-platform Voyager discovery and firmware-target validation, including + rejection of ambiguous or non-Voyager bootloaders and mismatched MCU images. +- Regression coverage for safe generated QMK `rules.mk` assignments. + +### Changed +- Updated release dependencies and removed obsolete audit exceptions. +- Documented that stock Oryx firmware cannot expose enough state for an exact + readback; Keyjitsu-authored firmware embeds a state marker instead. + +### Fixed +- CI failures now propagate through diagnostic log pipelines on every runner. +- Local builds reject executable or unknown Oryx make rules instead of copying + them into generated firmware sources. +- Disconnect and shutdown paths clear transient input/effect state, release RGB + ownership and report worker panics instead of silently discarding them. + ## [0.9.14] - 2026-10-08 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 5002ba1..8659df3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,15 +223,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "arboard" version = "3.6.1" @@ -928,17 +919,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "directories" version = "5.0.1" @@ -1284,6 +1264,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1900,7 +1881,7 @@ dependencies = [ [[package]] name = "keyjitsu" -version = "0.9.14" +version = "0.10.0-beta.1" dependencies = [ "anyhow", "clap", @@ -2756,9 +2737,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] @@ -2924,9 +2905,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", @@ -3316,7 +3297,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -3497,6 +3478,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "uds_windows" version = "1.2.1" @@ -3802,9 +3789,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", "quick-xml", @@ -4758,21 +4745,25 @@ dependencies = [ [[package]] name = "zip" -version = "2.4.2" +version = "9.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ - "arbitrary", "crc32fast", - "crossbeam-utils", - "displaydoc", "flate2", "indexmap", "memchr", - "thiserror 2.0.18", + "nix", + "typed-path", "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index e31fb19..b8059f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "keyjitsu" -version = "0.9.14" +version = "0.10.0-beta.1" edition = "2021" description = "GUI and CLI manager for the ZSA Voyager: live layout, heatmap, per-key RGB and effects, local QMK builds with tap dances, a layer minimap. No daemon, no cloud." repository = "https://github.com/martinezooo/keyjitsu" @@ -27,7 +27,7 @@ eframe = "0.32.3" tempfile = "3.27" zapp-core = { git = "https://github.com/zsa/zapp" } -zip = { version = "2", default-features = false, features = ["deflate"] } +zip = { version = "9", default-features = false, features = ["deflate"] } [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.10" diff --git a/README.md b/README.md index b9b5af4..2406a91 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,45 @@ of treating the last saved app config as device truth. It never writes back to the portal. So remapping here does not carry over to Oryx, and re-flashing from Oryx later would overwrite your Keyjitsu changes. +## Device state, Oryx, and mixing workflows + +There is an important limitation in ZSA's current Oryx raw-HID protocol: +**stock Oryx firmware does not expose the complete keymap back to the desktop +app**. The keyboard can report runtime information and its firmware identity +(`layout-hash/revision-hash`), but there is no supported HID command that +returns every key assignment, layer definition, combo, and persistent RGB +setting from the compiled firmware. + +For stock Oryx firmware, keyjitsu can use the `hash/revision` reported by the +physical keyboard to load the matching Oryx layout. That is a reconstruction +from Oryx data selected by the device's own revision id; it is **not** a full +keymap extracted from the firmware image itself. + +Keyjitsu-built firmware adds its own compact state id to the firmware serial. +That lets keyjitsu reconstruct the exact Keyjitsu-authored additions from its +matching local firmware-state record. It still does not make stock Oryx +firmware suddenly readable as a complete keymap. + +### Practical rule: pick one source of authoring truth + +For the least surprising workflow, use **either Oryx or keyjitsu as the main +place where you author a keyboard configuration**. + +- If you edit and flash in Oryx, treat that Oryx revision as the source of + truth. Keyjitsu can recognize the revision reported by the keyboard and load + the corresponding Oryx layout. +- If you edit and flash in keyjitsu, treat the Keyjitsu firmware state as the + source of truth. Those local-only edits are not written back to Oryx. +- Re-flashing from Oryx overwrites Keyjitsu-only firmware changes. +- Re-flashing from keyjitsu does not update your Oryx layout. +- Switching between the two workflows is possible, but save the configuration + you want to keep before flashing and expect the two representations to + diverge unless you deliberately synchronize them. + +Live/device status should always be driven by the **currently connected +keyboard's identity**. A saved layout or restore point must never silently +replace what Live claims is on the physical keyboard. + ## How it works - **Protocol.** ZSA's open Oryx raw-HID protocol v4 (32-byte reports, usage diff --git a/src/cmd_autolayer.rs b/src/cmd_autolayer.rs index 0b43e81..e20e776 100644 --- a/src/cmd_autolayer.rs +++ b/src/cmd_autolayer.rs @@ -197,7 +197,7 @@ pub fn running_apps() -> Vec<(String, String)> { } } } - apps.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); + apps.sort_by_key(|a| a.0.to_lowercase()); apps.dedup_by(|a, b| a.1 == b.1); apps } diff --git a/src/cmd_flash.rs b/src/cmd_flash.rs index bfe835b..1a79168 100644 --- a/src/cmd_flash.rs +++ b/src/cmd_flash.rs @@ -22,6 +22,73 @@ const ORYX_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); const MAX_FIRMWARE_BYTES: u64 = 16 * 1024 * 1024; const MAX_LATEST_RESPONSE_BYTES: u64 = 64 * 1024; +fn is_voyager_bootloader(vid: u16, pid: u16) -> bool { + vid == device::ids::ZSA_VID && matches!(pid, 0x0791 | 0x1791) +} + +fn is_voyager_firmware_target(vid: u16, pid: u16) -> bool { + is_voyager_bootloader(vid, pid) +} + +fn validate_voyager_firmware(firmware: &Firmware) -> Result<()> { + let is_voyager_image = match firmware { + Firmware::DfuBinary { vid, pid, .. } => is_voyager_firmware_target(*vid, *pid), + Firmware::IgnitionDual { primary, alternate } => { + is_voyager_bootloader(primary.vid, primary.pid) + && is_voyager_bootloader(alternate.vid, alternate.pid) + && primary.pid != alternate.pid + } + Firmware::IntelHex { .. } => false, + }; + if !is_voyager_image { + bail!("refusing to flash firmware whose target is not Voyager Ignition"); + } + Ok(()) +} + +fn validate_voyager_flash_target(vid: u16, pid: u16) -> Result<()> { + if !is_voyager_bootloader(vid, pid) { + bail!("refusing to flash non-Voyager bootloader {vid:04x}:{pid:04x}"); + } + Ok(()) +} + +/// Fail closed unless the image matches the exact Ignition MCU variant. +/// zapp-core checks the protocol family, but not the exact single-image PID. +pub fn validate_voyager_flash_pair(vid: u16, pid: u16, firmware: &Firmware) -> Result<()> { + validate_voyager_flash_target(vid, pid)?; + validate_voyager_firmware(firmware)?; + + match firmware { + Firmware::DfuBinary { + vid: image_vid, + pid: image_pid, + .. + } if *image_vid == vid && *image_pid == pid => Ok(()), + Firmware::IgnitionDual { primary, alternate } + if (primary.vid == vid && primary.pid == pid) + || (alternate.vid == vid && alternate.pid == pid) => + { + Ok(()) + } + _ => bail!( + "refusing to flash firmware variant that does not match Voyager bootloader {vid:04x}:{pid:04x}" + ), + } +} + +/// Select a single candidate before opening USB; ambiguity must never result +/// in flashing whichever device happened to enumerate first. +fn unique_voyager_bootloader(ids: &[(u16, u16)]) -> Result> { + let mut match_index = None; + for (index, &(vid, pid)) in ids.iter().enumerate() { + if is_voyager_bootloader(vid, pid) && match_index.replace(index).is_some() { + bail!("multiple Voyager bootloaders found; disconnect the others before flashing"); + } + } + Ok(match_index) +} + /// Resolve a firmware image from `--latest` / URL / file path. Shared with /// the GUI flash tab. pub fn acquire_firmware( @@ -29,7 +96,7 @@ pub fn acquire_firmware( latest: bool, current_serial: Option<&str>, ) -> Result { - match (latest, target) { + let firmware = match (latest, target) { (true, _) => { let serial = current_serial .context("--latest needs the connected keyboard's firmware identity")?; @@ -44,7 +111,9 @@ pub fn acquire_firmware( (false, None) => { bail!("pass a firmware file or an Oryx URL, or use --latest to update in place") } - } + }?; + validate_voyager_firmware(&firmware)?; + Ok(firmware) } pub fn wait_for_bootloader( @@ -53,24 +122,70 @@ pub fn wait_for_bootloader( on_found: impl Fn(&'static str), ) -> Result { let deadline = Instant::now() + timeout; + // Require a fresh manual reset, rather than selecting a stale bootloader + // already connected when flashing is requested. + let initial: Vec<_> = nusb::list_devices() + .wait() + .context("listing USB devices")? + .map(|info| (info.vendor_id(), info.product_id())) + .collect(); + if unique_voyager_bootloader(&initial)?.is_some() { + bail!( + "a Voyager is already in bootloader mode; disconnect it, restart flashing, then press RESET" + ); + } + loop { if cancel.is_some_and(|c| c.load(Ordering::SeqCst)) { bail!("canceled"); } - for info in nusb::list_devices().wait().context("listing USB devices")? { - let vid = info.vendor_id(); - let pid = info.product_id(); - let Some(kind) = device::ids::identify_bootloader(vid, pid) else { - continue; - }; + let devices: Vec<_> = nusb::list_devices() + .wait() + .context("listing USB devices")? + .collect(); + let identities: Vec<_> = devices + .iter() + .map(|info| (info.vendor_id(), info.product_id())) + .collect(); + if let Some(index) = unique_voyager_bootloader(&identities)? { + let info = &devices[index]; + let (vid, pid) = identities[index]; + let original_bus = info.bus_id().to_owned(); + let original_address = info.device_address(); + let kind = device::ids::identify_bootloader(vid, pid) + .context("Voyager bootloader is not supported by zapp-core")?; std::thread::sleep(Duration::from_millis(500)); if cancel.is_some_and(|c| c.load(Ordering::SeqCst)) { bail!("canceled"); } - let usb = info.open().wait().context("opening bootloader device")?; + // Re-enumerate before opening so that a second device connected + // during the debounce interval cannot silently redirect flashing. + let current: Vec<_> = nusb::list_devices() + .wait() + .context("rechecking USB devices")? + .collect(); + let current_ids: Vec<_> = current + .iter() + .map(|item| (item.vendor_id(), item.product_id())) + .collect(); + let Some(current_index) = unique_voyager_bootloader(¤t_ids)? else { + continue; + }; + let current_info = ¤t[current_index]; + if current_ids[current_index] != (vid, pid) + || current_info.bus_id() != original_bus + || current_info.device_address() != original_address + { + bail!("Voyager bootloader changed while waiting; refusing to flash"); + } + + let usb = current_info + .open() + .wait() + .context("opening bootloader device")?; let name = device::ids::friendly_name(vid, pid); on_found(name); return Ok(device::BootloaderDevice { @@ -119,6 +234,7 @@ pub fn run( }) .context("bootloader detection failed")?; + validate_voyager_flash_pair(dev.vid, dev.pid, &fw)?; flash::flash_device(&dev, &fw, &|p| match p { FlashProgress::Erasing { bytes_erased, @@ -142,11 +258,7 @@ pub fn run( } fn pct(done: usize, total: usize) -> usize { - if total == 0 { - 100 - } else { - done * 100 / total - } + done.saturating_mul(100).checked_div(total).unwrap_or(100) } fn read_connected_firmware_serial(serial_filter: Option<&str>) -> Result { @@ -259,6 +371,7 @@ pub fn firmware_summary(fw: &Firmware) -> String { #[cfg(test)] mod tests { use super::*; + use zapp_core::firmware::DfuImage; #[test] fn latest_requires_a_captured_connected_identity() { @@ -278,4 +391,124 @@ mod tests { assert_eq!(pct(0, 0), 100); assert_eq!(pct(50, 100), 50); } + + #[test] + fn voyager_ignition_bootloader_ids_are_allowed() { + assert!(is_voyager_bootloader(device::ids::ZSA_VID, 0x0791)); + assert!(is_voyager_bootloader(device::ids::ZSA_VID, 0x1791)); + } + + #[test] + fn other_zapp_bootloader_ids_are_rejected() { + assert!(!is_voyager_bootloader(device::ids::ZSA_VID, 0x2003)); + assert!(!is_voyager_bootloader( + device::ids::STM32_VID, + device::ids::STM32_DFU_PID + )); + assert!(!is_voyager_bootloader( + device::ids::HALFKAY_VID, + device::ids::HALFKAY_PID + )); + assert!(!is_voyager_bootloader(0x1234, 0x0791)); + } + + #[test] + fn flash_target_validation_rejects_wrong_device_and_firmware() { + let voyager = Firmware::DfuBinary { + data: vec![], + vid: device::ids::ZSA_VID, + pid: 0x0791, + }; + assert!(validate_voyager_firmware(&voyager).is_ok()); + assert!(validate_voyager_flash_target(device::ids::ZSA_VID, 0x0791).is_ok()); + assert!(validate_voyager_flash_target(device::ids::ZSA_VID, 0x2003).is_err()); + + let voyager_normal_pid = Firmware::DfuBinary { + data: vec![], + vid: device::ids::ZSA_VID, + pid: 0x1977, + }; + assert!(validate_voyager_firmware(&voyager_normal_pid).is_err()); + + let moonlander = Firmware::DfuBinary { + data: vec![], + vid: device::ids::ZSA_VID, + pid: 0x2003, + }; + assert!(validate_voyager_firmware(&moonlander).is_err()); + + let voyager_dual = Firmware::IgnitionDual { + primary: DfuImage { + data: vec![], + vid: device::ids::ZSA_VID, + pid: 0x0791, + }, + alternate: DfuImage { + data: vec![], + vid: device::ids::ZSA_VID, + pid: 0x1791, + }, + }; + assert!(validate_voyager_firmware(&voyager_dual).is_ok()); + } + + #[test] + fn flash_pair_rejects_cross_mcu_image_and_wrong_vendor() { + for image_pid in [0x0791, 0x1791] { + let firmware = Firmware::DfuBinary { + data: vec![1, 2, 3], + vid: device::ids::ZSA_VID, + pid: image_pid, + }; + assert!( + validate_voyager_flash_pair(device::ids::ZSA_VID, image_pid, &firmware).is_ok() + ); + let other_pid = if image_pid == 0x0791 { 0x1791 } else { 0x0791 }; + assert!( + validate_voyager_flash_pair(device::ids::ZSA_VID, other_pid, &firmware).is_err() + ); + assert!(validate_voyager_flash_pair(0x1234, image_pid, &firmware).is_err()); + } + } + + #[test] + fn dual_flash_pair_allows_both_mcu_variants() { + let firmware = Firmware::IgnitionDual { + primary: DfuImage { + data: vec![1], + vid: device::ids::ZSA_VID, + pid: 0x0791, + }, + alternate: DfuImage { + data: vec![2], + vid: device::ids::ZSA_VID, + pid: 0x1791, + }, + }; + for pid in [0x0791, 0x1791] { + assert!(validate_voyager_flash_pair(device::ids::ZSA_VID, pid, &firmware).is_ok()); + } + } + + #[test] + fn bootloader_selection_fails_closed_on_ambiguity() { + assert_eq!(unique_voyager_bootloader(&[]).unwrap(), None); + assert_eq!( + unique_voyager_bootloader(&[(device::ids::ZSA_VID, 0x2003)]).unwrap(), + None + ); + assert_eq!( + unique_voyager_bootloader(&[ + (device::ids::ZSA_VID, 0x2003), + (device::ids::ZSA_VID, 0x0791), + ]) + .unwrap(), + Some(1) + ); + assert!(unique_voyager_bootloader(&[ + (device::ids::ZSA_VID, 0x0791), + (device::ids::ZSA_VID, 0x1791), + ]) + .is_err()); + } } diff --git a/src/config.rs b/src/config.rs index 57d2214..83ed27a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -482,6 +482,7 @@ pub fn load() -> Config { /// Read the current config without creating recovery files on parse errors. /// Used by the running GUI for hot reloads, where a third-party editor may /// briefly expose an incomplete write before its final atomic replacement. +#[cfg(target_os = "macos")] pub fn load_snapshot() -> Result { let p = path()?; let bytes = match std::fs::read(&p) { diff --git a/src/device.rs b/src/device.rs index efe7605..d4ba838 100644 --- a/src/device.rs +++ b/src/device.rs @@ -13,6 +13,10 @@ pub const ZSA_VID: u16 = 0x3297; pub const RAW_USAGE_PAGE: u16 = 0xFF60; pub const RAW_USAGE: u16 = 0x61; +fn is_voyager(vid: u16, pid: u16) -> bool { + vid == ZSA_VID && matches!(pid, 0x1977 | 0x1978) +} + pub fn model_name(pid: u16) -> &'static str { match pid { 0x1977 | 0x1978 => "Voyager", @@ -44,7 +48,10 @@ pub fn hid_api() -> Result { pub fn enumerate(api: &HidApi) -> Vec { let mut found: Vec = Vec::new(); for d in api.device_list() { - if d.vendor_id() != ZSA_VID || d.usage_page() != RAW_USAGE_PAGE || d.usage() != RAW_USAGE { + if !is_voyager(d.vendor_id(), d.product_id()) + || d.usage_page() != RAW_USAGE_PAGE + || d.usage() != RAW_USAGE + { continue; } if found.iter().any(|f| f.path.as_c_str() == d.path()) { @@ -217,3 +224,21 @@ impl Keyboard { let _ = self.send(Command::Disconnect); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn voyager_normal_mode_ids_are_allowed() { + assert!(is_voyager(ZSA_VID, 0x1977)); + assert!(is_voyager(ZSA_VID, 0x1978)); + } + + #[test] + fn non_voyager_normal_mode_ids_are_rejected() { + assert!(!is_voyager(ZSA_VID, 0x1969)); + assert!(!is_voyager(ZSA_VID, 0x1972)); + assert!(!is_voyager(0x1234, 0x1977)); + } +} diff --git a/src/gui/heatmap_page.rs b/src/gui/heatmap_page.rs index 5ed111c..29b1856 100644 --- a/src/gui/heatmap_page.rs +++ b/src/gui/heatmap_page.rs @@ -83,7 +83,7 @@ impl App { ui.add_space(10.0); egui::Frame::new() .fill(pal::CARD) - .stroke(egui::Stroke::new(1.0, pal::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, pal::BORDER)) .corner_radius(egui::CornerRadius::same(12)) .inner_margin(egui::Margin::symmetric(16, 12)) .show(ui, |ui| { @@ -200,7 +200,7 @@ impl App { ui.add_space(pad); egui::Frame::new() .fill(pal::CARD) - .stroke(egui::Stroke::new(1.0, pal::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, pal::BORDER)) .corner_radius(egui::CornerRadius::same(14)) .inner_margin(egui::Margin::symmetric(14, 16)) .show(ui, |ui| { @@ -283,7 +283,7 @@ impl App { ui.set_width(right_w); egui::Frame::new() .fill(pal::CARD) - .stroke(egui::Stroke::new(1.0, pal::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, pal::BORDER)) .corner_radius(egui::CornerRadius::same(12)) .inner_margin(egui::Margin::same(14)) .show(ui, |ui| { diff --git a/src/gui/live.rs b/src/gui/live.rs index fc3d069..bec2e7a 100644 --- a/src/gui/live.rs +++ b/src/gui/live.rs @@ -88,7 +88,7 @@ impl App { let mut request_remove = false; egui::Frame::new() .fill(pal::CARD) - .stroke(egui::Stroke::new(1.0, pal::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, pal::BORDER)) .corner_radius(egui::CornerRadius::same(10)) .inner_margin(egui::Margin::symmetric(10, 7)) .show(ui, |ui| { @@ -115,7 +115,7 @@ impl App { )) .fill(if n == view { pal::VIOLET } else { pal::INPUT }) .stroke(egui::Stroke::new( - 1.0, + 1.0_f32, if n == active { pal::CYAN } else { pal::BORDER }, )); if ui.add(button).clicked() { @@ -275,7 +275,7 @@ impl App { ui.add_space(7.0); egui::Frame::new() .fill(pal::RED.gamma_multiply(0.12)) - .stroke(egui::Stroke::new(1.0, pal::RED.gamma_multiply(0.55))) + .stroke(egui::Stroke::new(1.0_f32, pal::RED.gamma_multiply(0.55))) .corner_radius(egui::CornerRadius::same(8)) .inner_margin(egui::Margin::symmetric(10, 7)) .show(ui, |ui| { @@ -418,7 +418,7 @@ impl App { let clicked = { let frame = egui::Frame::new() .fill(pal::CARD) - .stroke(egui::Stroke::new(1.0, pal::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, pal::BORDER)) .corner_radius(egui::CornerRadius::same(14)) .inner_margin(egui::Margin::symmetric(14, 16)) .shadow(egui::epaint::Shadow { @@ -524,7 +524,7 @@ impl App { ui.add_space(2.0); egui::Frame::new() .fill(pal::CARD) - .stroke(egui::Stroke::new(1.0, pal::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, pal::BORDER)) .corner_radius(egui::CornerRadius::same(9)) .inner_margin(egui::Margin::symmetric(12, 6)) .show(ui, |ui| { @@ -532,7 +532,7 @@ impl App { ui.horizontal_wrapped(|ui| { egui::Frame::new() .fill(pal::INPUT) - .stroke(egui::Stroke::new(2.0, key_col)) + .stroke(egui::Stroke::new(2.0_f32, key_col)) .corner_radius(egui::CornerRadius::same(7)) .inner_margin(egui::Margin::symmetric(9, 3)) .show(ui, |ui| { @@ -603,7 +603,7 @@ impl App { ui.add_space(6.0); egui::Frame::new() .fill(pal::CARD) - .stroke(egui::Stroke::new(1.0, pal::AMBER.gamma_multiply(0.55))) + .stroke(egui::Stroke::new(1.0_f32, pal::AMBER.gamma_multiply(0.55))) .corner_radius(egui::CornerRadius::same(8)) .inner_margin(egui::Margin::symmetric(10, 6)) .show(ui, |ui| { @@ -646,7 +646,7 @@ impl App { } egui::Frame::new() .fill(pal::CARD) - .stroke(egui::Stroke::new(1.0, pal::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, pal::BORDER)) .corner_radius(egui::CornerRadius::same(9)) .inner_margin(egui::Margin::symmetric(10, 8)) .show(ui, |ui| { @@ -655,7 +655,7 @@ impl App { let tc = SLOT_COLORS[slot]; egui::Frame::new() .fill(tc.gamma_multiply(0.28)) - .stroke(egui::Stroke::new(1.2, tc)) + .stroke(egui::Stroke::new(1.2_f32, tc)) .corner_radius(egui::CornerRadius::same(7)) .inner_margin(egui::Margin::symmetric(10, 3)) .show(ui, |ui| { @@ -684,7 +684,7 @@ impl App { RichText::new("- pick…").size(13.0).color(pal::TEXT), ) .fill(pal::INPUT) - .stroke(egui::Stroke::new(1.0, pal::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, pal::BORDER)) .min_size(egui::vec2(96.0, 22.0)); if ui .add_enabled(assignment_editable, chip_btn) @@ -712,7 +712,7 @@ impl App { .color(pal::TEXT), ) .fill(pal::INPUT) - .stroke(egui::Stroke::new(1.0, pal::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, pal::BORDER)) .min_size(egui::vec2(80.0, 22.0)); if ui .add_enabled(assignment_editable, chip_btn) @@ -1050,7 +1050,7 @@ impl App { .show(ui, |ui| { egui::Frame::new() .fill(pal::BG) - .stroke(egui::Stroke::new(1.0, pal::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, pal::BORDER)) .corner_radius(egui::CornerRadius::same(6)) .inner_margin(egui::Margin::same(8)) .show(ui, |ui| { diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 3ac68e3..62d5697 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -642,31 +642,31 @@ fn setup_style(ctx: &egui::Context) { v.override_text_color = Some(pal::TEXT); v.panel_fill = pal::SURFACE; v.window_fill = pal::SURFACE; - v.window_stroke = Stroke::new(1.0, pal::BORDER); + v.window_stroke = Stroke::new(1.0_f32, pal::BORDER); v.extreme_bg_color = pal::INPUT; // text-edit background v.faint_bg_color = pal::CARD; v.hyperlink_color = pal::CYAN; v.selection.bg_fill = pal::VIOLET.gamma_multiply(0.40); - v.selection.stroke = Stroke::new(1.0, pal::VIOLET); + v.selection.stroke = Stroke::new(1.0_f32, pal::VIOLET); v.window_corner_radius = CornerRadius::same(12); v.menu_corner_radius = CornerRadius::same(8); let round = CornerRadius::same(7); let w = &mut v.widgets; w.noninteractive.corner_radius = round; - w.noninteractive.bg_stroke = Stroke::new(1.0, pal::BORDER); + w.noninteractive.bg_stroke = Stroke::new(1.0_f32, pal::BORDER); w.inactive.corner_radius = round; w.inactive.bg_fill = pal::RAISED; w.inactive.weak_bg_fill = pal::RAISED; - w.inactive.bg_stroke = Stroke::new(1.0, pal::BORDER); + w.inactive.bg_stroke = Stroke::new(1.0_f32, pal::BORDER); w.hovered.corner_radius = round; w.hovered.bg_fill = pal::HOVER; w.hovered.weak_bg_fill = pal::HOVER; - w.hovered.bg_stroke = Stroke::new(1.0, pal::VIOLET.gamma_multiply(0.7)); + w.hovered.bg_stroke = Stroke::new(1.0_f32, pal::VIOLET.gamma_multiply(0.7)); w.active.corner_radius = round; w.active.bg_fill = pal::VIOLET.gamma_multiply(0.65); w.active.weak_bg_fill = pal::VIOLET.gamma_multiply(0.6); - w.active.bg_stroke = Stroke::new(1.0, pal::VIOLET); + w.active.bg_stroke = Stroke::new(1.0_f32, pal::VIOLET); w.open.corner_radius = round; style.visuals = v; @@ -1599,7 +1599,7 @@ impl App { .frame( egui::Frame::new() .fill(pal::SURFACE) - .stroke(egui::Stroke::new(1.0, pal::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, pal::BORDER)) .inner_margin(egui::Margin::symmetric(12, 12)), ) .show(ctx, |ui| { @@ -1738,7 +1738,7 @@ impl App { .frame( egui::Frame::new() .fill(pal::CARD) - .stroke(egui::Stroke::new(1.0, pal::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, pal::BORDER)) .inner_margin(egui::Margin::symmetric(16, 9)), ) .show(ctx, |ui| self.ui_publish_bar(ui)); @@ -1750,7 +1750,7 @@ impl App { .frame( egui::Frame::new() .fill(pal::SURFACE) - .stroke(egui::Stroke::new(1.0, pal::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, pal::BORDER)) .inner_margin(egui::Margin::symmetric(14, 12)), ) .show(ctx, |ui| { @@ -2012,7 +2012,7 @@ fn sidebar_status_row( fn status_pill(ui: &mut egui::Ui, text: &str, color: egui::Color32) { egui::Frame::new() .fill(color.gamma_multiply(0.16)) - .stroke(egui::Stroke::new(1.0, color.gamma_multiply(0.55))) + .stroke(egui::Stroke::new(1.0_f32, color.gamma_multiply(0.55))) .corner_radius(egui::CornerRadius::same(20)) .inner_margin(egui::Margin::symmetric(10, 3)) .show(ui, |ui| { @@ -2057,7 +2057,7 @@ fn tool_card( ) { egui::Frame::new() .fill(pal::CARD) - .stroke(egui::Stroke::new(1.0, pal::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, pal::BORDER)) .corner_radius(egui::CornerRadius::same(12)) .inner_margin(egui::Margin::same(16)) .show(ui, |ui| { @@ -2198,7 +2198,7 @@ fn page_header(ui: &mut egui::Ui, title: &str, subtitle: &str) { fn card(ui: &mut egui::Ui, title: &str, body: impl FnOnce(&mut egui::Ui)) { egui::Frame::new() .fill(pal::CARD) - .stroke(egui::Stroke::new(1.0, pal::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, pal::BORDER)) .corner_radius(egui::CornerRadius::same(10)) .inner_margin(egui::Margin::same(14)) .show(ui, |ui| { @@ -2253,7 +2253,7 @@ fn toggle(ui: &mut egui::Ui, on: &mut bool) -> egui::Response { ui.painter().rect_stroke( rect, egui::CornerRadius::same(radius as u8), - egui::Stroke::new(1.0, if *on { pal::VIOLET } else { pal::BORDER }), + egui::Stroke::new(1.0_f32, if *on { pal::VIOLET } else { pal::BORDER }), egui::StrokeKind::Inside, ); let cx = egui::lerp((rect.left() + radius)..=(rect.right() - radius), t); @@ -2307,7 +2307,7 @@ fn position_grid(ui: &mut egui::Ui, valign: &mut VAlign, halign: &mut HAlign) { ]; egui::Frame::new() .fill(pal::INPUT) - .stroke(egui::Stroke::new(1.0, pal::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, pal::BORDER)) .corner_radius(egui::CornerRadius::same(8)) .inner_margin(egui::Margin::same(4)) .show(ui, |ui| { @@ -2415,7 +2415,10 @@ fn combo_strip(ui: &mut egui::Ui, entries: &[ComboChip], a: f32, accent: Color32 }; egui::Frame::new() .fill(fill) - .stroke(egui::Stroke::new(if c.held { 1.6 } else { 1.0 }, border)) + .stroke(egui::Stroke::new( + if c.held { 1.6_f32 } else { 1.0_f32 }, + border, + )) .corner_radius(egui::CornerRadius::same(6)) .inner_margin(egui::Margin::symmetric(7, 3)) .show(ui, |ui| { diff --git a/src/gui/peek.rs b/src/gui/peek.rs index 31820a1..89f4ca0 100644 --- a/src/gui/peek.rs +++ b/src/gui/peek.rs @@ -783,7 +783,7 @@ impl App { }; let card_stroke = if show_bg { egui::Stroke::new( - 1.0, + 1.0_f32, Color32::from_rgba_unmultiplied( accent.r(), accent.g(), @@ -920,7 +920,7 @@ fn minimap_instruction_panel( (a as f32 * 0.82) as u8, )) .stroke(egui::Stroke::new( - 1.0, + 1.0_f32, Color32::from_rgba_unmultiplied( accent.r(), accent.g(), diff --git a/src/gui/rgb_anim.rs b/src/gui/rgb_anim.rs index 0f90933..c3a11f1 100644 --- a/src/gui/rgb_anim.rs +++ b/src/gui/rgb_anim.rs @@ -301,7 +301,9 @@ impl Drop for AnimHandle { fn drop(&mut self) { self.stop.store(true, Ordering::SeqCst); if let Some(thread) = self.thread.take() { - let _ = thread.join(); + if thread.join().is_err() { + eprintln!("keyjitsu: RGB animation worker panicked during shutdown"); + } } } } diff --git a/src/gui/runtime.rs b/src/gui/runtime.rs index 64b0bf0..d04ac82 100644 --- a/src/gui/runtime.rs +++ b/src/gui/runtime.rs @@ -103,7 +103,12 @@ impl App { self.connection_generation = None; self.pressed.iter_mut().for_each(|p| *p = false); self.combo_down.clear(); + self.combo_log.clear(); + self.last_press_at.clear(); self.peek_until = None; + if let Ok(mut anim) = self.anim.lock() { + anim.events.clear(); + } let heat_save_error = self .heat .as_mut() diff --git a/src/gui/settings.rs b/src/gui/settings.rs index c862c29..f24c777 100644 --- a/src/gui/settings.rs +++ b/src/gui/settings.rs @@ -597,6 +597,7 @@ impl App { } } + #[cfg(target_os = "macos")] pub(super) fn ui_guard(&mut self, ui: &mut egui::Ui) { #[cfg(target_os = "macos")] { diff --git a/src/gui/sidebar.rs b/src/gui/sidebar.rs index e18dc32..32847d5 100644 --- a/src/gui/sidebar.rs +++ b/src/gui/sidebar.rs @@ -769,7 +769,7 @@ impl App { // Key chip. egui::Frame::new() .fill(pal::INPUT) - .stroke(egui::Stroke::new(1.0, pal::BORDER)) + .stroke(egui::Stroke::new(1.0_f32, pal::BORDER)) .corner_radius(egui::CornerRadius::same(6)) .inner_margin(egui::Margin::symmetric(8, 3)) .show(ui, |ui| { diff --git a/src/gui/widget.rs b/src/gui/widget.rs index d46aab5..6f0b1a7 100644 --- a/src/gui/widget.rs +++ b/src/gui/widget.rs @@ -224,7 +224,7 @@ pub fn draw_keyboard( painter.add(eframe::egui::Shape::convex_polygon( key_poly(center, kw, kh, r, angle, 0.0), fade(Color32::WHITE, alpha * fill_a), - Stroke::new(if is_pressed { 2.6 } else { 1.6 }, fade(ink, alpha)), + Stroke::new(if is_pressed { 2.6_f32 } else { 1.6_f32 }, fade(ink, alpha)), )); } else { if let Some(c) = color { @@ -250,20 +250,20 @@ pub fn draw_keyboard( painter.add(eframe::egui::Shape::convex_polygon( key_poly(center, kw, kh, r, angle, 0.0), fade(tint, alpha), - Stroke::new(1.4, fade(edge, alpha)), + Stroke::new(1.4_f32, fade(edge, alpha)), )); if is_pending { painter.add(eframe::egui::Shape::convex_polygon( key_poly(center, kw, kh, r, angle, 1.5), Color32::TRANSPARENT, - Stroke::new(2.2, fade(PENDING, alpha)), + Stroke::new(2.2_f32, fade(PENDING, alpha)), )); } if selected == Some(i) { painter.add(eframe::egui::Shape::convex_polygon( key_poly(center, kw, kh, r, angle, 3.0), Color32::TRANSPARENT, - Stroke::new(2.4, fade(SELECTED, alpha)), + Stroke::new(2.4_f32, fade(SELECTED, alpha)), )); } } @@ -363,14 +363,14 @@ pub fn draw_keyboard( painter.rect_stroke( cap, radius, - Stroke::new(if is_pressed { 2.6 } else { 1.6 }, fade(ink, alpha)), + Stroke::new(if is_pressed { 2.6_f32 } else { 1.6_f32 }, fade(ink, alpha)), StrokeKind::Inside, ); if selected == Some(i) { painter.rect_stroke( cap.expand(3.0), CornerRadius::same(key_round + 2), - Stroke::new(2.4, fade(SELECTED, alpha)), + Stroke::new(2.4_f32, fade(SELECTED, alpha)), StrokeKind::Outside, ); } @@ -433,7 +433,7 @@ pub fn draw_keyboard( painter.rect_stroke( cap, radius, - Stroke::new(1.4, fade(edge, alpha)), + Stroke::new(1.4_f32, fade(edge, alpha)), StrokeKind::Inside, ); @@ -441,7 +441,7 @@ pub fn draw_keyboard( painter.rect_stroke( cap.expand(0.5), radius, - Stroke::new(2.2, fade(PENDING, alpha)), + Stroke::new(2.2_f32, fade(PENDING, alpha)), StrokeKind::Inside, ); } @@ -451,13 +451,13 @@ pub fn draw_keyboard( painter.rect_stroke( cap.expand(1.5), radius, - Stroke::new(1.5, fade(SELECTED.gamma_multiply(0.4), alpha)), + Stroke::new(1.5_f32, fade(SELECTED.gamma_multiply(0.4), alpha)), StrokeKind::Outside, ); painter.rect_stroke( cap.expand(3.0), CornerRadius::same(key_round + 2), - Stroke::new(2.4, fade(SELECTED, alpha)), + Stroke::new(2.4_f32, fade(SELECTED, alpha)), StrokeKind::Outside, ); } diff --git a/src/gui/worker.rs b/src/gui/worker.rs index 9159d76..62281ed 100644 --- a/src/gui/worker.rs +++ b/src/gui/worker.rs @@ -41,6 +41,7 @@ pub enum DevEvent { #[derive(Debug, Clone)] pub enum KbCmd { + #[cfg(target_os = "macos")] SetLayer { on: bool, layer: u8, @@ -70,6 +71,7 @@ impl KbCmd { /// The discrete (non-frame) commands map 1:1 to a protocol command. fn to_protocol(&self) -> Option { match self { + #[cfg(target_os = "macos")] KbCmd::SetLayer { on, layer } => Some(Command::SetLayer { on: *on, layer: *layer, @@ -100,7 +102,9 @@ impl Drop for DeviceWorkerHandle { fn drop(&mut self) { self.stop.store(true, Ordering::SeqCst); if let Some(thread) = self.thread.take() { - let _ = thread.join(); + if thread.join().is_err() { + eprintln!("keyjitsu: device worker panicked during shutdown"); + } } } } @@ -257,6 +261,7 @@ fn device_loop( let mut rgb_released = false; while let Ok(cmd) = cmd_rx.try_recv() { match cmd { + #[cfg(target_os = "macos")] KbCmd::SetLayer { on: false, layer } => { let _ = kb.send(Command::SetLayer { on: false, layer }); } @@ -422,6 +427,10 @@ pub fn spawn_flash( Err(e) => return send(FlashState::Failed(format!("{e:#}"))), }; + if let Err(e) = crate::cmd_flash::validate_voyager_flash_pair(dev.vid, dev.pid, &fw) { + return send(FlashState::Failed(format!("{e:#}"))); + } + let res = zapp_core::flash::flash_device(&dev, &fw, &|p| { use zapp_core::flash::FlashProgress as P; let state = match p { @@ -481,7 +490,9 @@ impl Drop for AutolayerHandle { fn drop(&mut self) { self.stop.store(true, Ordering::SeqCst); if let Some(thread) = self.thread.take() { - let _ = thread.join(); + if thread.join().is_err() { + eprintln!("keyjitsu: autolayer worker panicked during shutdown"); + } } } } diff --git a/src/localbuild.rs b/src/localbuild.rs index 81f6da6..03f69f1 100644 --- a/src/localbuild.rs +++ b/src/localbuild.rs @@ -176,6 +176,8 @@ pub fn build(spec: &BuildSpec, cancel: &Arc, log: &dyn Fn(String)) - .ok_or_else(|| anyhow!("no keymap.c in the generated source"))?; let rules_mk = take_file(&mut files, "rules.mk") .ok_or_else(|| anyhow!("generated source has no rules.mk; refusing to rebuild from an incomplete source bundle"))?; + // Source archives must not supply executable Make directives to the host. + validate_generated_rules(&rules_mk)?; // Plain multi-step slots become generated custom keycodes. let mut macros: Vec = Vec::new(); @@ -392,6 +394,75 @@ fn take_file(files: &mut Vec<(String, Vec)>, name: &str) -> Option { Some(String::from_utf8_lossy(&bytes).into_owned()) } +/// Accept only inert QMK feature assignments from Oryx source. Make expands +/// functions such as $(shell ...) while reading rules.mk, before compiling. +fn validate_generated_rules(rules: &str) -> Result<()> { + for (index, raw) in rules.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if raw.bytes().any(|b| matches!(b, b'$' | b'`' | b'\\')) { + bail!( + "unsafe Make expansion or line continuation in Oryx rules.mk line {}", + index + 1 + ); + } + let (lhs, rhs) = line + .split_once('=') + .ok_or_else(|| anyhow!("unsupported Oryx rules.mk directive at line {}", index + 1))?; + let lhs = lhs.trim(); + let append = lhs.ends_with('+'); + let key = if append { + lhs.trim_end_matches('+').trim_end() + } else { + lhs + }; + let value = rhs.split('#').next().unwrap_or("").trim(); + let key_is_ident = !key.is_empty() + && key + .bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_'); + if !key_is_ident || (append && key != "SRC") { + bail!("unsupported Oryx rules.mk setting at line {}", index + 1); + } + let valid = if key.ends_with("_ENABLE") + || matches!( + key, + "RGB_MATRIX_CUSTOM_KB" | "I2C_DRIVER_REQUIRED" | "FORCE_NKRO" + ) { + matches!(value, "yes" | "no") + } else if matches!( + key, + "POINTING_DEVICE_DRIVER" | "DEBOUNCE_TYPE" | "RGB_MATRIX_DRIVER" + ) { + !value.is_empty() + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_') + } else if key == "SRC" { + !value.is_empty() + && value.split_whitespace().all(|name| { + name.ends_with(".c") + && name.len() <= 128 + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')) + && !name.starts_with('.') + }) + } else { + false + }; + if !valid { + bail!( + "unsafe or unsupported Oryx rules.mk value for {key} at line {}", + index + 1 + ); + } + } + Ok(()) +} + /// Set `KEY = value` in a `rules.mk`, replacing the line if present (any value) /// or appending it. Matches `KEY = ...` allowing arbitrary spacing. fn set_rule(rules: &str, key: &str, value: &str) -> String { @@ -507,7 +578,10 @@ fn fetch_source_files(revision: &str) -> Result)>> { if !f.is_file() { continue; } - let name = f.name().to_string(); + let name = f + .name() + .context("reading source zip entry name")? + .into_owned(); // Only the compilable source, not build.log / README / the prebuilt bin. let is_source = name.contains("_source/") && (name.ends_with(".c") @@ -637,7 +711,7 @@ mod tests { use super::{ clear_build_artifact, prepare_keymap_dir, set_rule, valid_firmware_serial, - validate_revision_id, validate_source_basename, BuildEnv, + validate_generated_rules, validate_revision_id, validate_source_basename, BuildEnv, }; #[test] @@ -726,4 +800,23 @@ mod tests { assert!(out.contains("ORYX_ENABLE = yes")); assert!(out.contains("EXTRAKEY_ENABLE = yes")); } + + #[test] + fn generated_rules_accept_only_inert_oryx_assignments() { + let rules = r#" +# Generated by Oryx +ORYX_ENABLE = yes +RGB_MATRIX_DRIVER = aw20216s +SRC += matrix.c rgb.c +"#; + assert!(validate_generated_rules(rules).is_ok()); + } + + #[test] + fn generated_rules_reject_make_execution_and_unknown_directives() { + assert!(validate_generated_rules("SRC += $(shell touch /tmp/pwned).c\n").is_err()); + assert!(validate_generated_rules("include ../../host.mk\n").is_err()); + assert!(validate_generated_rules("UNKNOWN_FLAG = yes\n").is_err()); + assert!(validate_generated_rules("SRC += ../escape.c\n").is_err()); + } } diff --git a/src/oryx_api.rs b/src/oryx_api.rs index f6f98f1..a6e2368 100644 --- a/src/oryx_api.rs +++ b/src/oryx_api.rs @@ -483,7 +483,7 @@ pub fn any_cached_layout(geometry: &str) -> Option<(LayoutId, Layout)> { Some((mtime, e.path())) }) .collect(); - hits.sort_by(|a, b| b.0.cmp(&a.0)); // newest first + hits.sort_by_key(|a| std::cmp::Reverse(a.0)); // newest first hits.into_iter().find_map(|(_, path)| { let bytes = read_layout_cache(&path).ok()?; let layout = parse_layout(&bytes).ok()?; diff --git a/src/platform.rs b/src/platform.rs index 98dd334..4749135 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -1,4 +1,6 @@ -use std::path::{Path, PathBuf}; +use std::path::Path; +#[cfg(target_os = "macos")] +use std::path::PathBuf; use std::process::Command; use anyhow::{Context, Result}; @@ -66,11 +68,7 @@ pub fn autostart_location() -> Option { }) } -#[cfg(not(target_os = "macos"))] -pub fn autostart_location() -> Option { - None -} - +#[cfg(target_os = "macos")] pub fn autostart_enabled() -> bool { autostart_location().is_some_and(|p| p.exists()) }