Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,17 @@ 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"]
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"] }
Expand Down
159 changes: 159 additions & 0 deletions src/backend/macos.rs
Original file line number Diff line number Diff line change
@@ -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<Box<dyn WakeGuard>> {
// 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<WakeStatus> {
// 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<DoctorReport> {
// `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<Child>,
}

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();
}
}
}
6 changes: 6 additions & 0 deletions src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))]
Expand Down Expand Up @@ -65,6 +67,7 @@ pub fn supported_backends() -> Vec<Box<dyn WakeBackend>> {
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<Box<dyn WakeBackend>> = Vec::new();
Expand All @@ -76,6 +79,7 @@ pub fn supported_backends() -> Vec<Box<dyn WakeBackend>> {
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<Box<dyn WakeBackend>> = Vec::new();
Expand All @@ -96,6 +100,8 @@ pub fn supported_backends() -> Vec<Box<dyn WakeBackend>> {
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
}
Expand Down
Loading
Loading