diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 2eb3a1f..7fe19b0 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -88,3 +88,25 @@ jobs: # run them against the host toolchain instead of the cross target. - name: "Run tests" run: cargo test --workspace --exclude microfetch --verbose ${{ matrix.build_std && '--target x86_64-unknown-linux-gnu' || '' }} + + # macOS is libSystem-backed (no raw syscalls, no static linking), so it + # builds natively on an Apple Silicon runner rather than cross-compiling. + build-macos: + name: Test on aarch64-apple-darwin + runs-on: macos-14 + steps: + - name: "Checkout" + uses: actions/checkout@v6 + + - name: "Setup Rust toolchain" + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + target: aarch64-apple-darwin + rustflags: "" + + - name: "Build" + run: cargo build --verbose --target aarch64-apple-darwin + + - name: "Run tests" + run: cargo test --workspace --exclude microfetch --verbose diff --git a/crates/asm/src/darwin.rs b/crates/asm/src/darwin.rs new file mode 100644 index 0000000..33625da --- /dev/null +++ b/crates/asm/src/darwin.rs @@ -0,0 +1,288 @@ +//! macOS does not support a stable raw-syscall ABI the way Linux does, cannot +//! be linked statically, and exposes none of the `/proc`, `/sys`, or +//! `/etc/os-release` interfaces the Linux path relies on. So instead of +//! handwritten `svc #0` traps, this module calls into `libSystem` (which is +//! always linked on macOS) via plain FFI: +//! +//! * file/IO + exit -> `open`/`read`/`write`/`close`/`_exit` +//! * `uname(3)` -> kernel name/release/machine +//! * `statfs(2)` -> root filesystem usage +//! * `sysctl`/`sysctlbyname` -> CPU model, core counts, RAM size, OS version +//! * `host_statistics64` (Mach) -> live VM page statistics for memory usage +//! +//! You could argue that linking libSystem is no different than linking libc, +//! and while yes you would be correct, I'm not doing this without libSystem. +//! Talk is cheap, send patches. +use core::{ + ffi::{c_char, c_int, c_void}, + mem::size_of, + ptr, +}; + +use super::{StatfsBuf, SysInfo, UtsNameBuf}; + +#[link(name = "System", kind = "dylib")] +unsafe extern "C" { + fn open(path: *const c_char, flags: c_int, ...) -> c_int; + fn read(fd: c_int, buf: *mut c_void, count: usize) -> isize; + fn write(fd: c_int, buf: *const c_void, count: usize) -> isize; + fn close(fd: c_int) -> c_int; + fn _exit(code: c_int) -> !; + + fn uname(buf: *mut UtsNameBuf) -> c_int; + fn statfs(path: *const c_char, buf: *mut StatfsBuf) -> c_int; + + fn sysctl( + name: *const c_int, + namelen: u32, + oldp: *mut c_void, + oldlenp: *mut usize, + newp: *const c_void, + newlen: usize, + ) -> c_int; + fn sysctlbyname( + name: *const c_char, + oldp: *mut c_void, + oldlenp: *mut usize, + newp: *const c_void, + newlen: usize, + ) -> c_int; + fn gettimeofday(tp: *mut Timeval, tzp: *mut c_void) -> c_int; + + // Mach host interfaces (live in libsystem_kernel, part of libSystem). + fn mach_host_self() -> u32; // mach_port_t + fn host_statistics64( + host: u32, + flavor: c_int, + info: *mut i32, // host_info64_t == integer_t* + count: *mut u32, + ) -> c_int; +} + +pub(super) unsafe fn sys_open(path: *const u8, flags: i32) -> i32 { + unsafe { open(path.cast::(), flags) } +} + +pub(super) unsafe fn sys_read(fd: i32, buf: *mut u8, count: usize) -> isize { + unsafe { read(fd, buf.cast::(), count) } +} + +pub(super) unsafe fn sys_write(fd: i32, buf: *const u8, count: usize) -> isize { + unsafe { write(fd, buf.cast::(), count) } +} + +pub(super) unsafe fn sys_close(fd: i32) -> i32 { + unsafe { close(fd) } +} + +pub(super) unsafe fn sys_uname(buf: *mut UtsNameBuf) -> i32 { + unsafe { uname(buf) } +} + +pub(super) unsafe fn sys_statfs(path: *const u8, buf: *mut StatfsBuf) -> i32 { + unsafe { statfs(path.cast::(), buf) } +} + +/// macOS has no `sysinfo(2)`; uptime comes from [`macos_uptime_secs`] instead. +/// This stub exists only so the generic wrapper in `lib.rs` keeps compiling. +pub(super) unsafe fn sys_sysinfo(_info: *mut SysInfo) -> i64 { + -1 +} + +/// macOS has no `sched_getaffinity(2)`; core counts come from `sysctl` +/// (`hw.logicalcpu` / `hw.physicalcpu`). This stub keeps the generic wrapper +/// compiling. +pub(super) unsafe fn sys_sched_getaffinity( + _pid: i32, + _mask_size: usize, + _mask: *mut u8, +) -> i32 { + -1 +} + +pub(super) unsafe fn sys_exit(code: i32) -> ! { + unsafe { _exit(code) } +} + +/// Read a string-valued sysctl by name into `out`. `name` must be a +/// NUL-terminated byte string (e.g. `b"machdep.cpu.brand_string\0"`). +/// +/// Returns the number of bytes written, excluding the trailing NUL. +#[must_use] +pub fn macos_sysctl_str(name: &[u8], out: &mut [u8]) -> Option { + let mut len = out.len(); + let ret = unsafe { + sysctlbyname( + name.as_ptr().cast::(), + out.as_mut_ptr().cast::(), + &mut len, + ptr::null(), + 0, + ) + }; + if ret != 0 { + return None; + } + // `len` includes the terminating NUL on success. + Some(len.saturating_sub(1)) +} + +/// Read a 32-bit integer sysctl by name (e.g. `b"hw.logicalcpu\0"`). +#[must_use] +pub fn macos_sysctl_u32(name: &[u8]) -> Option { + let mut val: u32 = 0; + let mut len = size_of::(); + let ret = unsafe { + sysctlbyname( + name.as_ptr().cast::(), + ptr::from_mut(&mut val).cast::(), + &mut len, + ptr::null(), + 0, + ) + }; + if ret == 0 && len == size_of::() { + Some(val) + } else { + None + } +} + +/// Read a 64-bit integer sysctl by name (e.g. `b"hw.memsize\0"`). +fn macos_sysctl_u64(name: &[u8]) -> Option { + let mut val: u64 = 0; + let mut len = size_of::(); + let ret = unsafe { + sysctlbyname( + name.as_ptr().cast::(), + ptr::from_mut(&mut val).cast::(), + &mut len, + ptr::null(), + 0, + ) + }; + if ret == 0 && len == size_of::() { + Some(val) + } else { + None + } +} + +const HOST_VM_INFO64: c_int = 4; + +/// Mirror of the kernel's `vm_statistics64` (``). +/// `natural_t` is `u32`. Declared in full so the integer count handed to +/// `host_statistics64` matches the kernel's expectation exactly (38 ints). +#[repr(C)] +#[derive(Default)] +#[expect(dead_code, reason = "most fields exist only to fix the struct layout")] +struct VmStatistics64 { + free_count: u32, + active_count: u32, + inactive_count: u32, + wire_count: u32, + zero_fill_count: u64, + reactivations: u64, + pageins: u64, + pageouts: u64, + faults: u64, + cow_faults: u64, + lookups: u64, + hits: u64, + purges: u64, + purgeable_count: u32, + speculative_count: u32, + decompressions: u64, + compressions: u64, + swapins: u64, + swapouts: u64, + compressor_page_count: u32, + throttled_count: u32, + external_page_count: u32, + internal_page_count: u32, + total_uncompressed_pages_in_compressor: u64, +} + +/// Returns `(used_bytes, total_bytes)` of physical memory. +/// +/// `total` is `hw.memsize`. `used` approximates Activity Monitor's "Memory +/// Used" as `(active + wired + compressed) * page_size`; inactive and free +/// pages are treated as available. This is an approximation and the exact +/// formula can be tuned against real hardware. +#[must_use] +pub fn macos_meminfo() -> Option<(u64, u64)> { + let total = macos_sysctl_u64(b"hw.memsize\0")?; + // Apple Silicon uses 16 KiB pages; fall back to that if the sysctl is absent. + let page = u64::from(macos_sysctl_u32(b"hw.pagesize\0").unwrap_or(16384)); + + let mut vm = VmStatistics64::default(); + let mut count = (size_of::() / size_of::()) as u32; + let kr = unsafe { + host_statistics64( + mach_host_self(), + HOST_VM_INFO64, + ptr::from_mut(&mut vm).cast::(), + &mut count, + ) + }; + if kr != 0 { + // Mach call failed; report total with unknown usage rather than erroring. + return Some((0, total)); + } + + let used = (u64::from(vm.active_count) + + u64::from(vm.wire_count) + + u64::from(vm.compressor_page_count)) + * page; + Some((used.min(total), total)) +} + +/// `struct timeval` (`time_t` is 64-bit, `suseconds_t` is 32-bit on macOS). +#[repr(C)] +#[derive(Default)] +#[expect( + dead_code, + reason = "tv_usec/_pad exist only to fix the struct layout" +)] +struct Timeval { + tv_sec: i64, + tv_usec: i32, + _pad: i32, +} + +const CTL_KERN: c_int = 1; +const KERN_BOOTTIME: c_int = 21; + +/// Returns the system uptime in seconds (`now - kern.boottime`). +#[must_use] +pub fn macos_uptime_secs() -> Option { + let mib = [CTL_KERN, KERN_BOOTTIME]; + let mut boot = Timeval::default(); + let mut len = size_of::(); + let ret = unsafe { + sysctl( + mib.as_ptr(), + mib.len() as u32, + ptr::from_mut(&mut boot).cast::(), + &mut len, + ptr::null(), + 0, + ) + }; + if ret != 0 { + return None; + } + + let mut now = Timeval::default(); + if unsafe { gettimeofday(&mut now, ptr::null_mut()) } != 0 { + return None; + } + + let secs = now.tv_sec - boot.tv_sec; + if secs < 0 { + None + } else { + // `secs` is non-negative here, so this is the exact value as `u64`. + Some(secs.unsigned_abs()) + } +} diff --git a/crates/asm/src/lib.rs b/crates/asm/src/lib.rs index fff8d7f..b16d7b2 100644 --- a/crates/asm/src/lib.rs +++ b/crates/asm/src/lib.rs @@ -21,7 +21,11 @@ )] // Per-arch syscall implementations live in their own module files. +// macOS is matched first; there `target_arch` is `aarch64`, but the +// implementation is libSystem-backed rather than raw `svc` traps. core::cfg_select! { + all(target_os = "macos", target_arch = "aarch64") => { #[path = "darwin.rs" ] mod arch; } + target_os = "macos" => { compile_error!("microfetch on macOS supports aarch64 (Apple Silicon) only"); } target_arch = "x86_64" => { #[path = "x86_64.rs" ] mod arch; } target_arch = "aarch64" => { #[path = "aarch64.rs" ] mod arch; } target_arch = "riscv64" => { #[path = "riscv64.rs" ] mod arch; } @@ -39,12 +43,24 @@ core::cfg_select! { _ => { compile_error!("Unsupported architecture"); } } +/// macOS-only helpers backed by `sysctl`/Mach (see `darwin.rs`). +#[cfg(target_os = "macos")] +pub use arch::{ + macos_meminfo, + macos_sysctl_str, + macos_sysctl_u32, + macos_uptime_secs, +}; + /// Copies `n` bytes from `src` to `dest`. /// /// # Safety /// /// `dest` and `src` must be valid pointers to non-overlapping regions of /// memory of at least `n` bytes. +// On macOS these symbols are supplied by libSystem; defining our own would +// clash at link time, so the freestanding implementations are Linux-only. +#[cfg(not(target_os = "macos"))] #[unsafe(no_mangle)] pub unsafe extern "C" fn memcpy( dest: *mut u8, @@ -65,6 +81,7 @@ pub unsafe extern "C" fn memcpy( /// /// `s` must be a valid pointer to memory of at least `n` bytes. /// The value in `c` is treated as unsigned (lower 8 bits used). +#[cfg(not(target_os = "macos"))] #[unsafe(no_mangle)] pub unsafe extern "C" fn memset(s: *mut u8, c: i32, n: usize) -> *mut u8 { for i in 0..n { @@ -80,6 +97,7 @@ pub unsafe extern "C" fn memset(s: *mut u8, c: i32, n: usize) -> *mut u8 { /// # Safety /// /// `s1` and `s2` must be valid pointers to memory of at least `n` bytes. +#[cfg(not(target_os = "macos"))] #[unsafe(no_mangle)] pub unsafe extern "C" fn bcmp(s1: *const u8, s2: *const u8, n: usize) -> i32 { for i in 0..n { @@ -97,6 +115,7 @@ pub unsafe extern "C" fn bcmp(s1: *const u8, s2: *const u8, n: usize) -> i32 { /// # Safety /// /// `s1` and `s2` must be valid pointers to memory of at least `n` bytes. +#[cfg(not(target_os = "macos"))] #[unsafe(no_mangle)] pub unsafe extern "C" fn memcmp(s1: *const u8, s2: *const u8, n: usize) -> i32 { unsafe { bcmp(s1, s2, n) } @@ -107,6 +126,7 @@ pub unsafe extern "C" fn memcmp(s1: *const u8, s2: *const u8, n: usize) -> i32 { /// # Safety /// /// `s` must be a valid pointer to a null-terminated string. +#[cfg(not(target_os = "macos"))] #[unsafe(no_mangle)] pub const unsafe extern "C" fn strlen(s: *const u8) -> usize { let mut len = 0; @@ -118,15 +138,17 @@ pub const unsafe extern "C" fn strlen(s: *const u8) -> usize { /// Function pointer type for the main application entry point. /// The function receives argc and argv and should return an exit code. -#[cfg(not(test))] +// On macOS the C runtime calls `main` directly, so the custom `_start` / +// `entry_rust` path below is Linux-only. +#[cfg(all(not(test), not(target_os = "macos")))] pub type MainFn = unsafe extern "C" fn(i32, *const *const u8) -> i32; -#[cfg(not(test))] +#[cfg(all(not(test), not(target_os = "macos")))] static mut MAIN_FN: Option = None; /// Register the main function to be called from the entry point. /// This must be called before the program starts (e.g., in a constructor). -#[cfg(not(test))] +#[cfg(all(not(test), not(target_os = "macos")))] pub fn register_main(main_fn: MainFn) { unsafe { MAIN_FN = Some(main_fn); @@ -148,7 +170,7 @@ pub fn register_main(main_fn: MainFn) { /// ```rust,ignore /// unsafe extern "C" fn main(argc: i32, argv: *const *const u8) -> i32` /// ``` -#[cfg(not(test))] +#[cfg(all(not(test), not(target_os = "macos")))] #[unsafe(no_mangle)] pub unsafe extern "C" fn entry_rust(stack: *const usize) -> i32 { // Read argc and argv from stack @@ -165,7 +187,7 @@ pub unsafe extern "C" fn entry_rust(stack: *const usize) -> i32 { // External main function that must be defined by the binary using this crate. // Signature: `unsafe extern "C" fn main(argc: i32, argv: *const *const u8) -> // i32` -#[cfg(not(test))] +#[cfg(all(not(test), not(target_os = "macos")))] unsafe extern "C" { fn main(argc: i32, argv: *const *const u8) -> i32; } @@ -241,6 +263,7 @@ pub unsafe fn sys_close(fd: i32) -> i32 { /// not used, nor any useful to us here. #[repr(C)] #[allow(dead_code)] +#[cfg(not(target_os = "macos"))] pub struct UtsNameBuf { pub sysname: [i8; 65], pub nodename: [i8; 65], @@ -250,6 +273,19 @@ pub struct UtsNameBuf { pub domainname: [i8; 65], // GNU extension, included for correct struct size } +/// macOS `struct utsname`: five `char[_SYS_NAMELEN]` fields (`_SYS_NAMELEN` +/// is 256) and no `domainname`. Field names match the Linux layout so the +/// `UtsName` accessors in `microfetch-lib` work unchanged. +#[repr(C)] +#[cfg(target_os = "macos")] +pub struct UtsNameBuf { + pub sysname: [i8; 256], + pub nodename: [i8; 256], + pub release: [i8; 256], + pub version: [i8; 256], + pub machine: [i8; 256], +} + /// Direct `uname(2)` syscall /// /// # Returns @@ -272,6 +308,7 @@ pub unsafe fn sys_uname(buf: *mut UtsNameBuf) -> i32 { /// declared; the remainder of the 120-byte struct is covered by `_pad`. #[repr(C)] #[cfg(not(any( + target_os = "macos", target_arch = "s390x", target_arch = "arm", target_arch = "riscv32", @@ -298,6 +335,31 @@ pub struct StatfsBuf { pub _pad: [i64; 4], } +/// macOS `struct statfs` (the 64-bit/`INODE64` ABI, which is the only one on +/// arm64). Only `f_bsize`, `f_blocks`, and `f_bavail` are read; the rest is +/// declared to give the struct its correct size and field offsets. +#[repr(C)] +#[cfg(target_os = "macos")] +pub struct StatfsBuf { + pub f_bsize: u32, + pub f_iosize: i32, + pub f_blocks: u64, + pub f_bfree: u64, + pub f_bavail: u64, + pub f_files: u64, + pub f_ffree: u64, + pub f_fsid: [i32; 2], + pub f_owner: u32, + pub f_type: u32, + pub f_flags: u32, + pub f_fssubtype: u32, + pub f_fstypename: [i8; 16], + pub f_mntonname: [i8; 1024], + pub f_mntfromname: [i8; 1024], + pub f_flags_ext: u32, + pub f_reserved: [u32; 7], +} + /// on s390x `f_type` and `f_bsize` are 32-bit. #[repr(C)] #[cfg(target_arch = "s390x")] diff --git a/crates/lib/src/cpu.rs b/crates/lib/src/cpu.rs index 479451d..4e5d45a 100644 --- a/crates/lib/src/cpu.rs +++ b/crates/lib/src/cpu.rs @@ -1,13 +1,35 @@ use alloc::string::String; -use crate::{Error, syscall::read_file_fast, system::write_u64}; +#[cfg(target_os = "linux")] +use crate::syscall::read_file_fast; +use crate::{Error, system::write_u64}; /// Gets CPU model name (trimmed), or empty string if unavailable. +#[cfg(target_os = "linux")] #[cfg_attr(feature = "hotpath", hotpath::measure)] pub fn get_cpu_name() -> String { get_model_name().unwrap_or_default() } +/// Gets CPU model name from `machdep.cpu.brand_string` (macOS), +/// e.g. `Apple M2 Pro`. Returns an empty string if unavailable. +#[cfg(target_os = "macos")] +#[cfg_attr(feature = "hotpath", hotpath::measure)] +pub fn get_cpu_name() -> String { + let mut buf = [0u8; 128]; + match crate::syscall::macos_sysctl_str( + b"machdep.cpu.brand_string\0", + &mut buf, + ) { + Some(n) => { + core::str::from_utf8(&buf[..n]) + .map(String::from) + .unwrap_or_default() + }, + None => String::new(), + } +} + /// Gets CPU core/thread info as a string. /// /// Format: `{cores} cores ({p}p/{e}e), {threads} threads` on hybrid Intel, @@ -16,17 +38,54 @@ pub fn get_cpu_name() -> String { /// # Errors /// /// Returns an error if the thread count cannot be determined. +#[cfg(target_os = "linux")] #[cfg_attr(feature = "hotpath", hotpath::measure)] pub fn get_cpu_cores() -> Result { let threads = get_thread_count()?; let cores = get_core_count(threads); + Ok(format_cores(cores, get_pe_cores(), threads)) +} + +/// Gets CPU core/thread info via `sysctl` (macOS). +/// +/// On Apple Silicon `hw.perflevel0`/`hw.perflevel1` expose the performance +/// (P) and efficiency (E) core counts respectively. +/// +/// # Errors +/// +/// Returns an error if the logical CPU count cannot be determined. +#[cfg(target_os = "macos")] +#[cfg_attr(feature = "hotpath", hotpath::measure)] +pub fn get_cpu_cores() -> Result { + use crate::syscall::macos_sysctl_u32; + + let threads = + macos_sysctl_u32(b"hw.logicalcpu\0").ok_or(Error::OsError(0))?; + let cores = macos_sysctl_u32(b"hw.physicalcpu\0").unwrap_or(threads); + + // Performance/efficiency split (Apple Silicon). Only reported when both + // perf levels are present. + let pe = match ( + macos_sysctl_u32(b"hw.perflevel0.physicalcpu\0"), + macos_sysctl_u32(b"hw.perflevel1.physicalcpu\0"), + ) { + (Some(p), Some(e)) => Some((p, e)), + _ => None, + }; + + Ok(format_cores(cores, pe, threads)) +} +/// Formats core/thread counts identically across platforms: +/// `{cores} cores ({p}p/{e}e), {threads} threads`, omitting the P/E group and +/// the thread suffix when not applicable. +fn format_cores(cores: u32, pe: Option<(u32, u32)>, threads: u32) -> String { let mut result = String::new(); write_u64(&mut result, u64::from(cores)); result.push_str(" cores"); - if let Some((p, e)) = get_pe_cores() { + if let Some((p, e)) = pe { result.push_str(" ("); write_u64(&mut result, u64::from(p)); result.push_str("p/"); @@ -40,10 +99,11 @@ pub fn get_cpu_cores() -> Result { result.push_str(" threads"); } - Ok(result) + result } /// Count online threads via `sched_getaffinity(2)`. +#[cfg(target_os = "linux")] fn get_thread_count() -> Result { let mut mask = [0u8; 128]; let ret = unsafe { @@ -63,6 +123,7 @@ fn get_thread_count() -> Result { } /// Derive physical core count from thread count and topology. +#[cfg(target_os = "linux")] fn get_core_count(threads: u32) -> u32 { let Some(smt_width) = count_cpulist("/sys/devices/system/cpu/cpu0/topology/thread_siblings_list") @@ -77,6 +138,7 @@ fn get_core_count(threads: u32) -> u32 { /// Detect P-core and E-core counts via sysfs PMU device files, which is done /// by reading `/sys/devices/cpu_core/cpus` and `/sys/devices/cpu_atom/cpus`. +#[cfg(target_os = "linux")] fn get_pe_cores() -> Option<(u32, u32)> { let p = count_cpulist("/sys/devices/cpu_core/cpus")?; let e = count_cpulist("/sys/devices/cpu_atom/cpus").unwrap_or(0); @@ -84,6 +146,7 @@ fn get_pe_cores() -> Option<(u32, u32)> { } /// Parse a cpulist file and count listed CPUs. +#[cfg(target_os = "linux")] fn count_cpulist(path: &str) -> Option { let mut buf = [0u8; 64]; let n = read_file_fast(path, &mut buf).ok()?; @@ -112,6 +175,7 @@ fn count_cpulist(path: &str) -> Option { } /// Parse a decimal number from a byte slice, advancing the index. +#[cfg(target_os = "linux")] fn parse_num(data: &[u8], i: &mut usize) -> u32 { let mut n = 0u32; while *i < data.len() && data[*i].is_ascii_digit() { @@ -123,6 +187,7 @@ fn parse_num(data: &[u8], i: &mut usize) -> u32 { /// Build `/sys/devices/system/cpu/cpu{n}/cpufreq/cpuinfo_max_freq` into buf, /// returning the byte length written. +#[cfg(target_os = "linux")] fn format_cpufreq_path(buf: &mut [u8; 64], cpu: u32) -> usize { const PREFIX: &[u8] = b"/sys/devices/system/cpu/cpu"; const SUFFIX: &[u8] = b"/cpufreq/cpuinfo_max_freq"; @@ -149,6 +214,7 @@ fn format_cpufreq_path(buf: &mut [u8; 64], cpu: u32) -> usize { } /// Read CPU frequency in MHz. Tries sysfs first, then cpuinfo fields. +#[cfg(target_os = "linux")] fn get_cpu_freq_mhz() -> Option { // Read cpuinfo_max_freq across all CPUs (in kHz) and take the max so // heterogeneous (big.LITTLE) topologies report the performance cluster. @@ -247,6 +313,7 @@ fn get_cpu_freq_mhz() -> Option { } /// Parse CPU model name from `/proc/cpuinfo` and append frequency. +#[cfg(target_os = "linux")] fn get_model_name() -> Option { let mut buf = [0u8; 2048]; let n = read_file_fast("/proc/cpuinfo", &mut buf).ok()?; @@ -275,6 +342,7 @@ fn get_model_name() -> Option { /// Extract a human-readable CPU name. Tries cpuinfo fields first, then /// falls back to the device-tree `compatible` string on SoCs that don't /// expose a model through cpuinfo. +#[cfg(target_os = "linux")] fn extract_name(data: &[u8]) -> Option { for key in &[ b"model name" as &[u8], @@ -300,6 +368,7 @@ fn extract_name(data: &[u8]) -> Option { /// The file holds NUL-separated `vendor,model` strings from most-specific /// (board) to most-generic (SoC); we take the last entry and return just /// the model portion after the comma. +#[cfg(target_os = "linux")] fn parse_dt_compatible() -> Option { let mut buf = [0u8; 256]; let n = read_file_fast("/sys/firmware/devicetree/base/compatible", &mut buf) @@ -320,6 +389,7 @@ fn parse_dt_compatible() -> Option { } /// Extract value of first occurrence of `key` in cpuinfo. +#[cfg(target_os = "linux")] fn extract_field<'a>(data: &'a [u8], key: &[u8]) -> Option<&'a str> { let mut i = 0; while i < data.len() { @@ -350,6 +420,7 @@ fn extract_field<'a>(data: &'a [u8], key: &[u8]) -> Option<&'a str> { } /// Strip noise from model names. +#[cfg(target_os = "linux")] fn trim(name: &str) -> &str { let b = name.as_bytes(); let mut end = b.len(); diff --git a/crates/lib/src/desktop.rs b/crates/lib/src/desktop.rs index b9aa362..c4317ec 100644 --- a/crates/lib/src/desktop.rs +++ b/crates/lib/src/desktop.rs @@ -5,16 +5,27 @@ use crate::getenv_str; #[must_use] #[cfg_attr(feature = "hotpath", hotpath::measure)] pub fn get_desktop_info() -> String { - let desktop_raw = getenv_str("XDG_CURRENT_DESKTOP").unwrap_or("Unknown"); - let session_raw = getenv_str("XDG_SESSION_TYPE").unwrap_or(""); + let desktop_raw = getenv_str("XDG_CURRENT_DESKTOP"); + let session_raw = getenv_str("XDG_SESSION_TYPE"); - let desktop_str = desktop_raw.strip_prefix("none+").unwrap_or(desktop_raw); + let desktop_str = desktop_raw.map_or_else( + || { + if cfg!(target_os = "macos") { + "Aqua" + } else { + "Unknown" + } + }, + |s| s.strip_prefix("none+").unwrap_or(s), + ); - let backend_str = if session_raw.is_empty() { - "Unknown" - } else { - session_raw - }; + let backend_str = session_raw.unwrap_or({ + if cfg!(target_os = "macos") { + "Quartz" + } else { + "Unknown" + } + }); // Pre-calculate capacity: desktop_len + " (" + backend_len + ")" // Capitalize first char needs temporary allocation only if backend exists diff --git a/crates/lib/src/release.rs b/crates/lib/src/release.rs index 299c1c9..881e73c 100644 --- a/crates/lib/src/release.rs +++ b/crates/lib/src/release.rs @@ -1,6 +1,8 @@ use alloc::string::String; -use crate::{Error, UtsName, syscall::read_file_fast}; +#[cfg(target_os = "linux")] +use crate::syscall::read_file_fast; +use crate::{Error, UtsName}; #[must_use] #[cfg_attr(feature = "hotpath", hotpath::measure)] @@ -24,11 +26,36 @@ pub fn get_system_info(utsname: &UtsName) -> String { result } +/// Gets the pretty name of the OS via `kern.osproductversion` (macOS), +/// e.g. `macOS 14.5`. +/// +/// # Errors +/// +/// Never errors; falls back to `macOS` if the version sysctl is unavailable. +#[cfg(target_os = "macos")] +#[cfg_attr(feature = "hotpath", hotpath::measure)] +pub fn get_os_pretty_name() -> Result { + let mut name = String::from("macOS"); + let mut buf = [0u8; 64]; + if let Some(n) = + crate::syscall::macos_sysctl_str(b"kern.osproductversion\0", &mut buf) + { + if let Ok(ver) = core::str::from_utf8(&buf[..n]) { + if !ver.is_empty() { + name.push(' '); + name.push_str(ver); + } + } + } + Ok(name) +} + /// Gets the pretty name of the OS from `/etc/os-release`. /// /// # Errors /// /// Returns an error if `/etc/os-release` cannot be read. +#[cfg(target_os = "linux")] #[cfg_attr(feature = "hotpath", hotpath::measure)] pub fn get_os_pretty_name() -> Result { // Fast byte-level scanning for PRETTY_NAME= diff --git a/crates/lib/src/system.rs b/crates/lib/src/system.rs index 1db8510..f69dd23 100644 --- a/crates/lib/src/system.rs +++ b/crates/lib/src/system.rs @@ -1,11 +1,13 @@ use alloc::string::String; use core::mem::MaybeUninit; +#[cfg(target_os = "linux")] +use crate::syscall::read_file_fast; use crate::{ Error, UtsName, colors::Colors, - syscall::{StatfsBuf, read_file_fast, sys_statfs}, + syscall::{StatfsBuf, sys_statfs}, }; #[must_use] @@ -169,6 +171,7 @@ pub fn write_u64(s: &mut String, mut n: u64) { } /// Fast integer parsing without stdlib overhead +#[cfg(target_os = "linux")] #[inline] fn parse_u64_fast(s: &[u8]) -> u64 { let mut result = 0u64; @@ -182,11 +185,38 @@ fn parse_u64_fast(s: &[u8]) -> u64 { result } +/// Gets the system memory usage information via `sysctl`/Mach (macOS). +/// +/// # Errors +/// +/// Returns an error if the memory statistics cannot be retrieved. +#[cfg(target_os = "macos")] +#[cfg_attr(feature = "hotpath", hotpath::measure)] +pub fn get_memory_usage() -> Result { + let (used_bytes, total_bytes) = + crate::syscall::macos_meminfo().ok_or(Error::OsError(0))?; + + const GIB: f64 = 1024.0 * 1024.0 * 1024.0; + #[expect( + clippy::cast_precision_loss, + reason = "GiB display tolerates f64 rounding" + )] + let used_memory = used_bytes as f64 / GIB; + #[expect( + clippy::cast_precision_loss, + reason = "GiB display tolerates f64 rounding" + )] + let total_memory = total_bytes as f64 / GIB; + + Ok(format_memory(used_memory, total_memory)) +} + /// Gets the system memory usage information. /// /// # Errors /// /// Returns an error if `/proc/meminfo` cannot be read. +#[cfg(target_os = "linux")] #[cfg_attr(feature = "hotpath", hotpath::measure)] pub fn get_memory_usage() -> Result { #[cfg_attr(feature = "hotpath", hotpath::measure)] @@ -246,6 +276,11 @@ pub fn get_memory_usage() -> Result { } let (used_memory, total_memory) = parse_memory_info()?; + Ok(format_memory(used_memory, total_memory)) +} + +/// Formats `used`/`total` memory (both in GiB) into the display string. +fn format_memory(used_memory: f64, total_memory: f64) -> String { #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] let percentage_used = round_f64(used_memory / total_memory * 100.0) as u64; @@ -264,5 +299,5 @@ pub fn get_memory_usage() -> Result { result.push_str(colors.reset); result.push(')'); - Ok(result) + result } diff --git a/crates/lib/src/uptime.rs b/crates/lib/src/uptime.rs index 4ed2200..1b503d4 100644 --- a/crates/lib/src/uptime.rs +++ b/crates/lib/src/uptime.rs @@ -1,7 +1,6 @@ use alloc::string::String; -use core::mem::MaybeUninit; -use crate::{Error, syscall::sys_sysinfo}; +use crate::Error; /// Faster integer to string conversion without the formatting overhead. #[inline] @@ -26,11 +25,14 @@ fn itoa(mut n: u64, buf: &mut [u8]) -> &str { /// # Errors /// /// Returns an error if the system uptime cannot be retrieved. +#[cfg(target_os = "linux")] #[cfg_attr(feature = "hotpath", hotpath::measure)] pub fn get_current() -> Result { + use core::mem::MaybeUninit; + let uptime_seconds = { let mut info = MaybeUninit::uninit(); - if unsafe { sys_sysinfo(info.as_mut_ptr()) } != 0 { + if unsafe { crate::syscall::sys_sysinfo(info.as_mut_ptr()) } != 0 { return Err(Error::last_os_error()); } #[allow(clippy::cast_sign_loss)] @@ -39,6 +41,24 @@ pub fn get_current() -> Result { } }; + Ok(format_uptime(uptime_seconds)) +} + +/// Gets the current system uptime via `kern.boottime` (macOS). +/// +/// # Errors +/// +/// Returns an error if the uptime cannot be retrieved. +#[cfg(target_os = "macos")] +#[cfg_attr(feature = "hotpath", hotpath::measure)] +pub fn get_current() -> Result { + let uptime_seconds = + crate::syscall::macos_uptime_secs().ok_or(Error::OsError(0))?; + Ok(format_uptime(uptime_seconds)) +} + +/// Formats a duration in seconds as a human-readable uptime string. +fn format_uptime(uptime_seconds: u64) -> String { let days = uptime_seconds / 86400; let hours = (uptime_seconds / 3600) % 24; let minutes = (uptime_seconds / 60) % 60; @@ -68,5 +88,5 @@ pub fn get_current() -> Result { result.push_str("less than a minute"); } - Ok(result) + result } diff --git a/flake.nix b/flake.nix index 3024a27..6c73fac 100644 --- a/flake.nix +++ b/flake.nix @@ -6,7 +6,7 @@ self, nixpkgs, }: let - systems = ["x86_64-linux" "aarch64-linux"]; + systems = ["x86_64-linux" "aarch64-linux" "aarch64-darwin"]; forEachSystem = nixpkgs.lib.genAttrs systems; pkgsForEach = system: nixpkgs.legacyPackages.${system}; in { diff --git a/microfetch/build.rs b/microfetch/build.rs index 3f43fef..8c63739 100644 --- a/microfetch/build.rs +++ b/microfetch/build.rs @@ -2,6 +2,14 @@ fn main() { // These flags only apply to the microfetch binary, not to proc-macro crates // or other host-compiled artifacts. + // macOS cannot link statically, requires the standard C runtime startup, and + // uses Mach-O (not ELF), so none of the flags below apply. The default + // linker driver produces a correct (and ad-hoc code-signed) binary there. + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os == "macos" { + return; + } + // No C runtime, we provide _start ourselves println!("cargo:rustc-link-arg-bin=microfetch=-nostartfiles"); // Fully static, no dynamic linker, no .interp/.dynsym/.dynamic overhead diff --git a/microfetch/src/main.rs b/microfetch/src/main.rs index d1b39b0..cdd5793 100644 --- a/microfetch/src/main.rs +++ b/microfetch/src/main.rs @@ -12,12 +12,18 @@ extern crate alloc; -use core::{arch::naked_asm, panic::PanicInfo}; +#[cfg(not(target_os = "macos"))] use core::arch::naked_asm; +use core::panic::PanicInfo; use microfetch_alloc::BumpAllocator; -use microfetch_asm::{entry_rust, sys_exit, sys_write}; -// Re-export libc replacement functions from asm crate +// The custom `_start` (and the `entry_rust` it calls) is Linux-only. +#[cfg(not(target_os = "macos"))] +use microfetch_asm::entry_rust; +// Re-export libc replacement functions from asm crate. On macOS these are +// provided by libSystem, so we don't define (or re-export) our own. +#[cfg(not(target_os = "macos"))] pub use microfetch_asm::{memcpy, memset, strlen}; +use microfetch_asm::{sys_exit, sys_write}; #[cfg(target_arch = "x86_64")] #[unsafe(no_mangle)] @@ -51,7 +57,9 @@ unsafe extern "C" fn _start() { ); } -#[cfg(target_arch = "aarch64")] +// Linux aarch64 only. On macOS the C runtime provides the entry point and +// calls `main` directly, so no custom `_start` (or raw `svc` exit) is used. +#[cfg(all(target_arch = "aarch64", not(target_os = "macos")))] #[unsafe(no_mangle)] #[unsafe(naked)] unsafe extern "C" fn _start() { diff --git a/nix/package.nix b/nix/package.nix index ba16ffd..e8df447 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,13 +1,21 @@ { lib, + stdenv, rustPlatform, llvm, }: let pname = "microfetch"; toml = (lib.importTOML ../Cargo.toml).workspace.package; inherit (toml) version; + # On Linux the build drives the mold linker wrapper, which expects the + # LLVM/clang stdenv. macOS cannot link statically and uses the default + # (Apple clang) stdenv to link against libSystem instead. + stdenv' = + if stdenv.isDarwin + then rustPlatform.buildRustPackage + else rustPlatform.buildRustPackage.override {inherit (llvm) stdenv;}; in - rustPlatform.buildRustPackage.override {inherit (llvm) stdenv;} (finalAttrs: { + stdenv' (finalAttrs: { __structuredAttrs = true; inherit pname version; @@ -37,7 +45,8 @@ in description = "Microscopic fetch script in Rust, for NixOS systems"; homepage = "https://github.com/NotAShelf/microfetch"; license = lib.licenses.gpl3Only; - platforms = lib.platforms.linux; + # aarch64-darwin only: x86_64-darwin would mis-route to the Linux x86_64 + platforms = lib.platforms.linux ++ ["aarch64-darwin"]; maintainers = [lib.maintainers.NotAShelf]; mainProgram = "microfetch"; }; diff --git a/nix/shell.nix b/nix/shell.nix index a43cacb..259518f 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -1,4 +1,6 @@ { + lib, + stdenv, mkShell, cargo, rustc, @@ -13,17 +15,19 @@ mkShell { name = "microfetch"; strictDeps = true; - nativeBuildInputs = [ - cargo - rustc - mold - clang + nativeBuildInputs = + [ + cargo + rustc + clang - rust-analyzer - (rustfmt.override {asNightly = true;}) - clippy - taplo + rust-analyzer + (rustfmt.override {asNightly = true;}) + clippy + taplo - gnuplot # for Criterion.rs plots - ]; + gnuplot # for Criterion.rs plots + ] + # mold is the Linux linker wrapper; macOS uses the default linker. + ++ lib.optionals stdenv.isLinux [mold]; }