diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f06d228 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,185 @@ +name: release + +# Rolling "latest" release: every push to `main` rebuilds all release binaries +# and (re)publishes them to a single GitHub Release tagged `latest`, fully +# replacing the previous one. The release is marked as a pre-release so it never +# shadows a real versioned release (e.g. v0.1.0) as the repo's "latest release". +# +# Not triggered on pull requests (building five release binaries per PR is +# wasteful); run it manually with the "Run workflow" button to test a branch. +on: + push: + branches: [main] + workflow_dispatch: + +# Avoid overlapping publish runs racing on the single `latest` tag: a newer push +# cancels an in-flight build so only the most recent commit's binaries publish. +concurrency: + group: release-latest + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + # --------------------------------------------------------------------------- # + # Linux: x86_64 (native) + aarch64 (cross). zbus/x11rb/wayland are pure Rust, + # so aarch64 only needs the cross-linker — no arm64 sysroot/libdbus. + # --------------------------------------------------------------------------- # + build-linux: + name: build (${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + cross: false + - target: aarch64-unknown-linux-gnu + cross: true + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - name: Install aarch64 cross-linker + if: matrix.cross + run: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Build + env: + # Only consumed when target == aarch64-unknown-linux-gnu; harmless elsewhere. + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + run: cargo build --locked --release --target ${{ matrix.target }} + + - name: Archive + run: tar -czf ow-${{ matrix.target }}.tar.gz -C target/${{ matrix.target }}/release ow + + - uses: actions/upload-artifact@v4 + with: + name: ow-${{ matrix.target }} + path: ow-${{ matrix.target }}.tar.gz + if-no-files-found: error + + # --------------------------------------------------------------------------- # + # Windows: x86_64 + aarch64, both MSVC. windows-latest ships the VS ARM64 C++ + # tools, so aarch64 is a zero-config cross-compile (build-only — the ARM64 .exe + # cannot run on the x64 runner). + # --------------------------------------------------------------------------- # + build-windows: + name: build (${{ matrix.target }}) + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + target: + - x86_64-pc-windows-msvc + - aarch64-pc-windows-msvc + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - name: Build + run: cargo build --locked --release --target ${{ matrix.target }} + + - name: Archive + shell: pwsh + run: Compress-Archive -Path target/${{ matrix.target }}/release/ow.exe -DestinationPath ow-${{ matrix.target }}.zip + + - uses: actions/upload-artifact@v4 + with: + name: ow-${{ matrix.target }} + path: ow-${{ matrix.target }}.zip + if-no-files-found: error + + # --------------------------------------------------------------------------- # + # macOS: one universal (x86_64 + aarch64) binary via lipo, built on an + # Apple-Silicon runner. The crate is pure Rust on macOS (no C deps), so no + # special SDK/linker config is needed. + # --------------------------------------------------------------------------- # + build-macos: + name: build (universal-apple-darwin) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin + - uses: Swatinem/rust-cache@v2 + with: + key: universal-apple-darwin + + - name: Build both architectures + run: | + cargo build --locked --release --target aarch64-apple-darwin + cargo build --locked --release --target x86_64-apple-darwin + + - name: Create universal binary + run: | + lipo -create \ + target/aarch64-apple-darwin/release/ow \ + target/x86_64-apple-darwin/release/ow \ + -output ow + # Fail loudly if lipo silently produced a single-arch binary. + echo "universal arches: $(lipo -archs ow)" + lipo -archs ow | grep -qw x86_64 + lipo -archs ow | grep -qw arm64 + + - name: Archive + run: tar -czf ow-universal-apple-darwin.tar.gz ow + + - uses: actions/upload-artifact@v4 + with: + name: ow-universal-apple-darwin + path: ow-universal-apple-darwin.tar.gz + if-no-files-found: error + + # --------------------------------------------------------------------------- # + # Publish: gather every target's archive, delete the previous `latest` + # release + tag, and create a fresh pre-release at the current commit. GitHub's + # API will NOT move an already-existing tag, so delete-first is what makes the + # "override" deterministic (softprops/action-gh-release alone would append). + # --------------------------------------------------------------------------- # + release: + name: publish rolling 'latest' release + needs: [build-linux, build-windows, build-macos] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Sanity-check collected assets + run: ls -l dist/ + + - name: Generate checksums + run: | + cd dist + sha256sum *.tar.gz *.zip > SHA256SUMS.txt + cat SHA256SUMS.txt + + - name: Publish rolling 'latest' release + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ github.sha }} + run: | + short=$(printf '%s' "$SHA" | cut -c1-7) + # First run has no prior release; `|| true` makes the delete non-fatal. + gh release delete latest --yes --cleanup-tag || true + gh release create latest dist/* \ + --target "$SHA" \ + --title "Latest (main @ ${short})" \ + --notes "Automated rolling build from \`main\` @ ${short} (commit ${SHA}). Rebuilt on every push to main and replaces any previous build. Marked as a pre-release — not a stable release." \ + --prerelease diff --git a/Cargo.toml b/Cargo.toml index 202f974..ab59c92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ path = "src/main.rs" # Feature model is "backend support", mirroring setup.md. D-Bus backends all # ride on zbus; X11/Wayland are opt-in because they pull heavier native deps. [features] -default = ["linux-logind", "linux-portal", "linux-screensaver", "linux-gnome", "linux-kde"] +default = ["linux-logind", "linux-portal", "linux-screensaver", "linux-gnome", "linux-kde", "macos-caffeinate"] linux-logind = ["dep:zbus"] linux-portal = ["dep:zbus"] linux-screensaver = ["dep:zbus"] @@ -30,6 +30,9 @@ linux-gnome = ["dep:zbus"] linux-kde = ["dep:zbus"] linux-x11 = ["dep:x11rb"] linux-wayland = ["dep:wayland-client", "dep:wayland-protocols"] +# macOS wake-lock backend. `caffeinate(8)` is a bundled macOS subprocess, so +# this feature carries no crate dependency; it only gates the backend module. +macos-caffeinate = [] [dependencies] clap = { version = "4", features = ["derive"] } diff --git a/src/backend/macos.rs b/src/backend/macos.rs new file mode 100644 index 0000000..5f5389b --- /dev/null +++ b/src/backend/macos.rs @@ -0,0 +1,159 @@ +//! macOS wake-lock backend via the bundled `caffeinate(8)` tool. +//! +//! Every macOS ships `/usr/bin/caffeinate`, which creates the same kind of +//! `IOPMAssertionCreateWithName` power assertions a native backend would — but +//! with no FFI, no `unsafe`, and no crate dependency. Oxiwake models the wake +//! lock as a spawned `caffeinate` child process: the assertion lives exactly as +//! long as that process, so killing it on `ow off` (or when the daemon exits) +//! releases the lock. That makes the guard's `Drop` — `child.kill()` — the +//! macOS counterpart of closing the logind inhibitor FD on Linux and calling +//! `PowerClearRequest` + `CloseHandle` on Windows, preserving the project's +//! core leak-safe RAII invariant. +//! +//! Flag mapping (`caffeinate(8)`): +//! - `-i` prevent idle sleep ([`WakeTarget::Idle`]) +//! - `-s` prevent system sleep (AC only) ([`WakeTarget::SystemSleep`]) +//! - `-d` prevent display sleep ([`WakeTarget::Display`] / `req.display`) +//! +//! This module is compiled only on macOS with the `macos-caffeinate` feature +//! (on by default for macOS builds; a no-op elsewhere). + +#![cfg(all(target_os = "macos", feature = "macos-caffeinate"))] + +use std::path::Path; +use std::process::{Child, Command, Stdio}; + +use crate::error::{OxiwakeError, Result}; +use crate::model::{ + DoctorReport, WakeBackend, WakeGuard, WakeMode, WakeRequest, WakeStatus, WakeTarget, +}; + +/// The bundled caffeinate binary. Hard-coded because it is part of every macOS +/// install; resolving it via `PATH` would only add a way to pick up a +/// user-placed imposter with the same name. +const CAFFEINATE: &str = "/usr/bin/caffeinate"; + +/// The macOS `caffeinate` wake-lock backend. +/// +/// Stateless beyond the compiled binary path; the per-lock state (the spawned +/// child) lives in the [`CaffeinateGuard`] returned by [`acquire`](WakeBackend::acquire). +pub struct CaffeinateBackend; + +impl CaffeinateBackend { + /// Construct the backend. Cheap — no I/O. + pub fn new() -> Self { + CaffeinateBackend + } +} + +impl Default for CaffeinateBackend { + fn default() -> Self { + Self::new() + } +} + +impl WakeBackend for CaffeinateBackend { + fn name(&self) -> &'static str { + "caffeinate" + } + + fn supported(&self) -> bool { + // Compiled in on macOS with the feature; availability of the binary is + // checked at acquire/doctor time (see [`acquire`] / [`doctor`]). + true + } + + fn acquire(&self, req: &WakeRequest) -> Result> { + // Translate the requested targets into caffeinate flags. Each target + // maps to at most one flag; a bare request still gets `-i` so the + // machine stays awake even with an empty target set. + let mut flags: Vec<&str> = Vec::new(); + if req.targets.contains(&WakeTarget::SystemSleep) { + flags.push("-s"); + } + if req.targets.contains(&WakeTarget::Idle) { + flags.push("-i"); + } + if req.display || req.targets.contains(&WakeTarget::Display) { + flags.push("-d"); + } + if flags.is_empty() { + flags.push("-i"); + } + + // Detached-in-spirit: caffeinate itself holds the assertion regardless + // of how oxiwake's process tree looks, so we need no setsid/creation + // flags here. Null streams keep it from touching the daemon's stdio. + let child = Command::new(CAFFEINATE) + .args(&flags) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| OxiwakeError::BackendUnavailable { + backend: "caffeinate", + reason: format!("could not spawn {CAFFEINATE}: {e}"), + })?; + Ok(Box::new(CaffeinateGuard { child: Some(child) })) + } + + fn status(&self) -> Result { + // A static description of what this backend holds: the macOS default + // (system + idle). The active lock's exact targets are persisted by the + // daemon from the request, not from here. + Ok(WakeStatus { + backend: "caffeinate".to_string(), + targets: vec![WakeTarget::SystemSleep, WakeTarget::Idle], + mode: WakeMode::Block, + display: false, + }) + } + + fn doctor(&self) -> Result { + // `available` tracks whether the binary is actually present — a stripped + // macOS or a broken install would have the backend compiled in but no + // tool to spawn. + let available = Path::new(CAFFEINATE).exists(); + Ok(DoctorReport { + backend: "caffeinate".to_string(), + supported: true, + available, + guarantees: vec![ + "the assertion is held only while the spawned caffeinate process \ + is alive; oxiwake kills it on `ow off` (or if the daemon exits)" + .to_string(), + "`caffeinate -s` prevents system sleep on AC power; on battery \ + macOS may still sleep once the system idle timeout elapses" + .to_string(), + ], + notes: vec![], + }) + } +} + +/// RAII handle to an active `caffeinate` wake lock. +/// +/// Owns the spawned child. Dropping the guard kills the child (ending its power +/// assertion) and reaps it, so the lock is released exactly when the guard goes +/// out of scope — the leak-safe invariant the whole project depends on. +pub struct CaffeinateGuard { + /// `Option` so `Drop` can `take()` the child; `None` means already released. + child: Option, +} + +impl WakeGuard for CaffeinateGuard { + fn backend(&self) -> &'static str { + "caffeinate" + } +} + +impl Drop for CaffeinateGuard { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() { + // Killing caffeinate ends its assertion — releasing the wake lock. + // `wait` reaps the corpse so it does not linger as a zombie. + let _ = child.kill(); + let _ = child.wait(); + } + } +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 75c46d6..a5f53ea 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -35,6 +35,8 @@ pub mod gnome; pub mod kde; #[cfg(all(target_os = "linux", feature = "linux-logind"))] pub mod logind; +#[cfg(all(target_os = "macos", feature = "macos-caffeinate"))] +pub mod macos; #[cfg(all(target_os = "linux", feature = "linux-portal"))] pub mod portal; #[cfg(all(target_os = "linux", feature = "linux-screensaver"))] @@ -65,6 +67,7 @@ pub fn supported_backends() -> Vec> { all(target_os = "linux", feature = "linux-screensaver"), all(target_os = "linux", feature = "linux-x11"), all(target_os = "linux", feature = "linux-wayland"), + all(target_os = "macos", feature = "macos-caffeinate"), windows ))] let mut v: Vec> = Vec::new(); @@ -76,6 +79,7 @@ pub fn supported_backends() -> Vec> { all(target_os = "linux", feature = "linux-screensaver"), all(target_os = "linux", feature = "linux-x11"), all(target_os = "linux", feature = "linux-wayland"), + all(target_os = "macos", feature = "macos-caffeinate"), windows )))] let v: Vec> = Vec::new(); @@ -96,6 +100,8 @@ pub fn supported_backends() -> Vec> { v.push(Box::new(wayland::WaylandBackend::new())); #[cfg(windows)] v.push(Box::new(windows::Win32PowerBackend::new())); + #[cfg(all(target_os = "macos", feature = "macos-caffeinate"))] + v.push(Box::new(macos::CaffeinateBackend::new())); v } diff --git a/src/cli.rs b/src/cli.rs index 9a30a2c..8ab98b7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -152,8 +152,9 @@ impl RequestFlags { /// Build a [`WakeRequest`] from parsed flags, starting from the platform /// default and layering the user's choices on top. /// -/// The platform default is [`WakeRequest::default_linux`] on Linux and -/// [`WakeRequest::default_windows`] on Windows. When `--aggressive-lid` is set, +/// The platform default is [`WakeRequest::default_linux`] on Linux, +/// [`WakeRequest::default_macos`] on macOS, and [`WakeRequest::default_windows`] +/// on Windows. When `--aggressive-lid` is set, /// [`WakeTarget::LidSwitch`] is added to the target set (de-duplicated) so the /// request faithfully reflects the user's intent regardless of which backend /// ends up honoring it. @@ -164,9 +165,11 @@ pub fn build_request(flags: &RequestFlags) -> WakeRequest { // Start from the platform-appropriate good default. #[cfg(target_os = "linux")] let mut req = WakeRequest::default_linux(); + #[cfg(target_os = "macos")] + let mut req = WakeRequest::default_macos(); #[cfg(windows)] let mut req = WakeRequest::default_windows(); - #[cfg(not(any(target_os = "linux", windows)))] + #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] let mut req = WakeRequest::default_linux(); req.display = flags.display; diff --git a/src/daemon.rs b/src/daemon.rs index 47ad002..a9a3c94 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -459,10 +459,10 @@ fn read_pending(path: &std::path::Path) -> Result> { /// `pending_path` is the per-invocation request hand-off file the CLI wrote. /// /// All standard streams are wired to `Null` so the daemon never holds the -/// CLI's terminal hostage. On Linux a `pre_exec` hook calls `setsid` so the -/// child escapes the CLI's process group / controlling terminal and survives -/// the CLI's exit (`setsid` is what fully detaches it from signals like SIGHUP). -/// On Windows the child is created `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP +/// CLI's terminal hostage. On Linux and macOS a `pre_exec` hook calls `setsid` +/// so the child escapes the CLI's process group / controlling terminal and +/// survives the CLI's exit (`setsid` is what fully detaches it from signals like +/// SIGHUP). On Windows the child is created `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP /// | CREATE_NO_WINDOW` so it owns no console and survives the CLI's console /// closing (a plain `spawn` would otherwise be killed with the console group). fn spawn_detached_daemon(pending_path: &std::path::Path) -> Result<()> { @@ -478,7 +478,7 @@ fn spawn_detached_daemon(pending_path: &std::path::Path) -> Result<()> { cmd.stdout(std::process::Stdio::null()); cmd.stderr(std::process::Stdio::null()); - #[cfg(target_os = "linux")] + #[cfg(unix)] { use std::os::unix::process::CommandExt; // Detach into a new session so the daemon outlives the CLI and is not @@ -515,15 +515,16 @@ fn spawn_detached_daemon(pending_path: &std::path::Path) -> Result<()> { /// `setsid(2)` FFI, kept behind a thin wrapper so the unsafe is isolated. /// -/// On Linux this calls `setsid`; on other platforms it is a no-op so the -/// `pre_exec` closure compiles. (The closure itself is only installed under -/// `cfg(target_os = "linux")`, but this helper is referenced there.) -#[cfg(target_os = "linux")] +/// On Unix (Linux, macOS) this calls `setsid` to detach the daemon into a new +/// session so it survives the CLI's exit. Both this helper and the `pre_exec` +/// closure that calls it are gated on `cfg(unix)`; the Windows detach path uses +/// `creation_flags` instead (see `spawn_detached_daemon`). +#[cfg(unix)] unsafe fn libc_setsid() -> std::io::Result<()> { // Call setsid via a raw syscall through libc would require a libc dep; // instead use the unstable-but-stable-in-practice `nix`-free path: emit // the syscall directly. To avoid pulling in `libc`/`nix`, we link the C - // library symbol `setsid`, which glibc/musl/bionic all export. + // library symbol `setsid`, which glibc/musl/bionic/macOS libSystem export. extern "C" { fn setsid() -> i32; } diff --git a/src/doctor.rs b/src/doctor.rs index 2a04fa6..20a0667 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -28,7 +28,7 @@ use crate::model::DoctorReport; /// [`crate::backend::doctor_all`]). #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct DoctorOutput { - /// Short platform family string: `"linux"` or `"windows"`. + /// Short platform family string: `"linux"`, `"macos"`, or `"windows"`. pub platform: String, /// Ordered `(key, value)` environment facts (OS release, session vars, ...). pub env: Vec<(String, String)>, @@ -37,13 +37,15 @@ pub struct DoctorOutput { } impl DoctorOutput { - /// The platform family this binary was built for: `"linux"` or `"windows"`. + /// The platform family this binary was built for: `"linux"`, `"macos"`, or `"windows"`. /// /// Selected at compile time so the platform column is always truthful about /// the build target, independent of any runtime detection. pub fn platform_string() -> &'static str { if cfg!(target_os = "linux") { "linux" + } else if cfg!(target_os = "macos") { + "macos" } else if cfg!(windows) { "windows" } else { @@ -75,6 +77,9 @@ fn collect_env() -> Vec<(String, String)> { #[cfg(target_os = "linux")] collect_linux(&mut out); + #[cfg(target_os = "macos")] + collect_macos(&mut out); + #[cfg(windows)] collect_windows(&mut out); @@ -83,7 +88,7 @@ fn collect_env() -> Vec<(String, String)> { /// Push `(key, value)` from the process environment, but only if the variable /// is set and non-empty. Unset variables are simply omitted from the report. -#[cfg(any(target_os = "linux", windows))] +#[cfg(any(target_os = "linux", target_os = "macos", windows))] fn env_kv(out: &mut Vec<(String, String)>, key: &str) { if let Ok(val) = std::env::var(key) { if !val.is_empty() { @@ -92,6 +97,30 @@ fn env_kv(out: &mut Vec<(String, String)>, key: &str) { } } +// --------------------------------------------------------------------------- +// macOS probes +// --------------------------------------------------------------------------- + +/// Append the macOS environment probes to `out`. +/// +/// Reports `HOME`/`TMPDIR` (the inputs [`crate::paths`] reads the runtime dir +/// from) and whether the `caffeinate` binary the backend depends on is present. +/// Per-backend reachability stays in each backend's own `doctor()`; this only +/// contributes environment facts, mirroring the Linux/Windows collectors. +#[cfg(target_os = "macos")] +fn collect_macos(out: &mut Vec<(String, String)>) { + env_kv(out, "HOME"); + env_kv(out, "TMPDIR"); + if std::path::Path::new("/usr/bin/caffeinate").exists() { + out.push(("caffeinate".to_string(), "/usr/bin/caffeinate".to_string())); + } else { + out.push(( + "caffeinate".to_string(), + "missing — /usr/bin/caffeinate not found".to_string(), + )); + } +} + // --------------------------------------------------------------------------- // Linux probes // --------------------------------------------------------------------------- diff --git a/src/model.rs b/src/model.rs index bbbdd5c..458061d 100644 --- a/src/model.rs +++ b/src/model.rs @@ -118,6 +118,18 @@ impl WakeRequest { } } + /// macOS default: prevent idle sleep and (on AC) system sleep via + /// `caffeinate -i -s`. Mirrors the Linux default's intent — keep the + /// machine awake — translated to the flags `caffeinate(8)` understands. + pub fn default_macos() -> Self { + WakeRequest { + targets: vec![WakeTarget::SystemSleep, WakeTarget::Idle], + reason: "Oxiwake wake lock enabled".to_string(), + display: false, + aggressive_lid: false, + } + } + /// Build the colon-separated `what` string for systemd-logind from `targets` /// (plus `handle-lid-switch` when `aggressive_lid` is set), in priority order /// and de-duplicated. diff --git a/src/paths.rs b/src/paths.rs index cb160df..9dfb949 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -17,6 +17,11 @@ //! `LOCALAPPDATA` environment variable is read directly; if it is unset, //! [`Paths::resolve`] returns an [`OxiwakeError::State`] error. (A future //! version should prefer `SHGetKnownFolderPath(FOLDERID_LocalAppData)`.) +//! - **macOS:** `$TMPDIR/oxiwake/` — the per-user runtime directory macOS's +//! `launchd` seeds (stable for the lifetime of a login session, where the +//! daemon lives). It is short enough to stay under the ~104-byte Unix socket +//! path limit even for long user names, and is already per-user. If `TMPDIR` +//! is unset, [`Paths::resolve`] returns an [`OxiwakeError::State`] error. use std::path::{Path, PathBuf}; @@ -80,12 +85,12 @@ impl Paths { // Create the directory (and any missing parents). std::fs::create_dir_all(base)?; - // On Linux, ensure the directory is private to the owning user. The - // XDG_RUNTIME_DIR base is conventionally 0700, but be defensive: if a + // On Unix (Linux, macOS), ensure the directory is private to the owning + // user. The runtime base is conventionally 0700, but be defensive: if a // previous (or user-created) directory is looser, tighten it. This is // best-effort — a failure to chmod is reported but does not mask the // successful resolution. - #[cfg(target_os = "linux")] + #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; const PRIVATE_MODE: u32 = 0o700; @@ -141,8 +146,19 @@ impl Paths { .map(|base| base.join("Freeoxide").join("Oxiwake")) } + /// macOS variant: reads `$TMPDIR` and appends `oxiwake`. Never errors — + /// intended for `ow doctor`. `$TMPDIR` is short (stays under the Unix socket + /// path limit) and per-user; it is stable for a login session, which is the + /// daemon's lifetime. + #[cfg(target_os = "macos")] + pub fn runtime_dir_or_none() -> Option { + std::env::var_os("TMPDIR") + .map(PathBuf::from) + .map(|base| base.join("oxiwake")) + } + /// Fallback for unsupported platforms: no runtime directory is known. - #[cfg(not(any(target_os = "linux", windows)))] + #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] pub fn runtime_dir_or_none() -> Option { None } @@ -163,7 +179,11 @@ fn base_dir_unset_message() -> &'static str { { "LOCALAPPDATA is not set; cannot determine oxiwake runtime directory" } - #[cfg(not(any(target_os = "linux", windows)))] + #[cfg(target_os = "macos")] + { + "TMPDIR is not set; cannot determine oxiwake runtime directory" + } + #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] { "no runtime directory is configured for this platform" } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 866ada5..c7e602b 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -1,8 +1,8 @@ //! Platform transport for the daemon IPC. //! //! The IPC protocol itself ([`crate::ipc`]) is platform-neutral; this module -//! supplies the byte stream it runs over. On Linux that is a Unix domain -//! socket in the runtime directory; on Windows it is a named pipe. +//! supplies the byte stream it runs over. On Linux and macOS that is a Unix +//! domain socket in the runtime directory; on Windows it is a named pipe. //! //! The surface is intentionally small and identical on every platform: //! @@ -24,15 +24,15 @@ //! never both reach OS-lock acquisition. //! //! Only one platform module is compiled per build: the `cfg` declarations -//! below pull in `linux` on `target_os = "linux"` and `windows` on `windows`, -//! and re-export that module's `connect` / `bind_and_serve` at the crate-root -//! of this module. The other platform's code is simply absent, so the crate -//! links on either host. +//! below pull in `unix` on any Unix-family target (Linux, macOS) and `windows` +//! on Windows, and re-export that module's `connect` / `bind_and_serve` at the +//! crate-root of this module. The other platform's code is simply absent, so +//! the crate links on either host. -#[cfg(target_os = "linux")] -mod linux; -#[cfg(target_os = "linux")] -pub use linux::{acquire_singleton_lock, bind_and_serve, connect, SingletonLock}; +#[cfg(unix)] +mod unix; +#[cfg(unix)] +pub use unix::{acquire_singleton_lock, bind_and_serve, connect, SingletonLock}; #[cfg(windows)] mod windows; diff --git a/src/platform/linux.rs b/src/platform/unix.rs similarity index 96% rename from src/platform/linux.rs rename to src/platform/unix.rs index 527c145..ab58a45 100644 --- a/src/platform/linux.rs +++ b/src/platform/unix.rs @@ -1,10 +1,11 @@ -//! Linux IPC transport: Unix domain sockets. +//! Unix-family IPC transport: Unix domain sockets (Linux and macOS). //! -//! The daemon listens on a Unix domain socket at -//! `$XDG_RUNTIME_DIR/oxiwake/oxiwake.sock`; clients connect to the same path. -//! Because the runtime directory is created mode `0700` by -//! [`crate::paths::Paths`], the socket is reachable only by the owning user, -//! which is exactly the threat model we want for a per-user wake-lock daemon. +//! The daemon listens on a Unix domain socket at `/oxiwake.sock` +//! (`$XDG_RUNTIME_DIR/oxiwake/oxiwake.sock` on Linux, `$TMPDIR/oxiwake/oxiwake.sock` +//! on macOS); clients connect to the same path. Because the runtime directory is +//! created mode `0700` by [`crate::paths::Paths`], the socket is reachable only +//! by the owning user, which is exactly the threat model we want for a per-user +//! wake-lock daemon. //! //! [`connect`] maps a missing socket or a refused connection to //! [`OxiwakeError::NotRunning`] so the CLI can say "oxiwake is not running"