Skip to content

OneOhCloud/onebox-lifecycle

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

36 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

onebox-lifecycle

Cross-platform system lifecycle monitoring for Rust — shutdown notification, sleep/wake, and network events.

Feature matrix

Feature Windows macOS
Shutdown notification WM_QUERYENDSESSION NSTerminateLater
Sleep / wake WM_POWERBROADCAST NSWorkspace notifications
Network up / down NotifyNetworkConnectivityHintChange ¹ NWPathMonitor
Async cleanup ✓ handle-based, tokio-compatible

¹ Windows minimum requirement: Windows 10, version 2004 (build 19041) — required by the NotifyNetworkConnectivityHintChange API.

Quick start

use onebox_lifecycle::{Sentinel, SystemEvent};

fn main() {
    // macOS: must be called from the main thread.
    let sentinel = Sentinel::start();

    while let Some(event) = sentinel.recv() {
        match event {
            SystemEvent::ShuttingDown(handle) => {
                // Do cleanup work, then allow the OS to proceed.
                handle.allow();
            }
            SystemEvent::WillSleep    => println!("going to sleep"),
            SystemEvent::DidWake      => println!("woke up"),
            SystemEvent::NetworkUp    => println!("network up"),
            SystemEvent::NetworkDown  => println!("network down"),
            _ => {}
        }
    }
}

Async cleanup with Tokio

use onebox_lifecycle::{Sentinel, SystemEvent};

#[tokio::main]
async fn main() {
    let sentinel = Sentinel::start();

    while let Some(event) = sentinel.recv() {
        if let SystemEvent::ShuttingDown(handle) = event {
            tokio::spawn(async move {
                do_cleanup().await;
                handle.allow();
            });
        }
    }
}

Tauri integration

Tauri already owns the NSApplication main thread, so calling Sentinel::start() directly inside setup works — but Sentinel itself cannot be moved to a background thread because its macOS guard is MainThreadOnly.

Use into_receiver() to split: the platform guard (NSApplicationDelegate, NSWorkspace observer, NWPathMonitor) is leaked and stays alive for the process lifetime, while the EventReceiver — which is Send — is forwarded to a background thread.

src-tauri/Cargo.toml

[dependencies]
onebox_lifecycle = { git = "https://github.com/OneOhCloud/onebox-lifecycle", tag = "v0.0.2" }

src-tauri/src/lib.rs

use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Emitter};

#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct LifecycleEvent {
    kind: &'static str,
    message: String,
    timestamp_ms: u64,
}

fn emit(handle: &AppHandle, kind: &'static str, message: impl Into<String>) {
    let _ = handle.emit("lifecycle-event", LifecycleEvent {
        kind,
        message: message.into(),
        timestamp_ms: SystemTime::now()
            .duration_since(UNIX_EPOCH).unwrap_or_default()
            .as_millis() as u64,
    });
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            let handle = app.handle().clone();

            // Must be called from the main thread (Tauri's setup satisfies this).
            // into_receiver() leaks the macOS guard and returns a Send receiver.
            let rx = onebox_lifecycle::Sentinel::start().into_receiver();

            std::thread::Builder::new()
                .name("lifecycle-events".into())
                .spawn(move || {
                    while let Some(event) = rx.recv() {
                        use onebox_lifecycle::SystemEvent;
                        match event {
                            SystemEvent::WillSleep => {
                                emit(&handle, "WillSleep", "System going to sleep");
                            }
                            SystemEvent::DidWake => {
                                emit(&handle, "DidWake", "System resumed from sleep");
                            }
                            SystemEvent::NetworkUp => {
                                emit(&handle, "NetworkUp", "Network UP");
                            }
                            SystemEvent::NetworkDown => {
                                emit(&handle, "NetworkDown", "Network DOWN");
                            }
                            SystemEvent::ShuttingDown(shutdown_handle) => {
                                emit(&handle, "ShuttingDown", "Shutdown requested — cleaning up…");
                                let h = handle.clone();
                                std::thread::spawn(move || {
                                    // Do real cleanup here…
                                    std::thread::sleep(std::time::Duration::from_secs(3));
                                    emit(&h, "ShutdownComplete", "Done — allowing OS to proceed");
                                    shutdown_handle.allow();
                                });
                            }
                            _ => {}
                        }
                    }
                })
                .expect("failed to spawn lifecycle thread");

            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Frontend (JavaScript)

const { listen } = window.__TAURI__.event;

await listen('lifecycle-event', ({ payload }) => {
    const { kind, message, timestampMs } = payload;
    console.log(`[${new Date(timestampMs).toLocaleTimeString()}] ${kind}: ${message}`);
    // update your UI here
});

Why Tauri instead of a plain CLI for shutdown notification? A bare CLI process has no .app bundle. macOS does not send applicationShouldTerminate: to it — the process is simply SIGKILLed during shutdown with no opportunity to do cleanup or log. A Tauri app is a proper .app that macOS recognises, shows the "waiting for app…" spinner, and waits for replyToApplicationShouldTerminate: before proceeding.

Running the demo

# Build and run (log written to ./onebox_lifecycle_demo.log)
make run

# Run detached from Terminal (required to test shutdown notification)
make run-detached
make log      # tail the log in another terminal
make stop     # kill the background process

# Install as a launchd user agent (recommended for shutdown testing)
make install-agent
make uninstall-agent

Why run detached from Terminal?

On macOS, shutdown asks Terminal to quit first. If Terminal kills its child processes before they can call replyToApplicationShouldTerminate:, the OS hangs indefinitely. Running detached (make run-detached) or as a launchd agent (make install-agent) avoids this.

Module structure

src/
├── lib.rs           Public API: Sentinel, SystemEvent
├── common/mod.rs    Shared types: ShutdownHandle, EventReceiver/Sender
├── macos/mod.rs     macOS backend: NSApplicationDelegate, NSWorkspace, NWPathMonitor
└── windows/mod.rs   Windows backend: hidden Win32 message-loop window

examples/
└── demo_full.rs     Full demo with file logging and async cleanup

中文说明

About

Cross-platform system lifecycle monitoring for Rust — shutdown blocking, sleep/wake, and network events.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors