Skip to content
Closed
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
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,7 @@ pub struct SettingsSnapshot {
global_shortcut: String,
codex_custom_sessions_dirs: Vec<String>,
agent_sessions_enabled: bool,
stay_awake_enabled: bool,
agent_session_ssh_hosts: Vec<String>,
hooks_enabled: bool,
http_proxy_enabled: bool,
Expand Down Expand Up @@ -800,6 +801,7 @@ impl From<Settings> for SettingsSnapshot {
global_shortcut: settings.global_shortcut,
codex_custom_sessions_dirs: settings.codex_custom_sessions_dirs,
agent_sessions_enabled: settings.agent_sessions_enabled,
stay_awake_enabled: settings.stay_awake_enabled,
agent_session_ssh_hosts: settings.agent_session_ssh_hosts,
hooks_enabled: settings.hooks_enabled,
http_proxy_enabled: settings.http_proxy_enabled,
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ pub struct SettingsUpdate {
pub global_shortcut: Option<String>,
pub codex_custom_sessions_dirs: Option<Vec<String>>,
pub agent_sessions_enabled: Option<bool>,
pub stay_awake_enabled: Option<bool>,
pub agent_session_ssh_hosts: Option<Vec<String>>,
pub hooks_enabled: Option<bool>,
pub http_proxy_enabled: Option<bool>,
Expand Down Expand Up @@ -327,6 +328,9 @@ impl SettingsUpdate {
if let Some(v) = self.agent_sessions_enabled {
settings.agent_sessions_enabled = v;
}
if let Some(v) = self.stay_awake_enabled {
settings.stay_awake_enabled = v;
}
if let Some(v) = self.agent_session_ssh_hosts.clone() {
settings.agent_session_ssh_hosts =
codexbar::agent_sessions::RemoteSessionFetcher::sanitized_hosts(&v);
Expand Down Expand Up @@ -509,6 +513,7 @@ pub async fn update_settings(
let clear_local_usage_cache = patch.codex_custom_sessions_dirs.is_some();
let rebuild_tray_menu = patch.rebuilds_tray_menu();
let refresh_tray_presentation = patch.refreshes_tray_presentation();
let stay_awake_changed = patch.stay_awake_enabled.is_some();
let tray_promotion_changed = patch.changes_tray_promotion();
let tray_panel_always_on_top_changed = patch.tray_panel_always_on_top.is_some();
let previous_promoted = settings.promote_tray_icon;
Expand Down Expand Up @@ -536,6 +541,9 @@ pub async fn update_settings(
}

crate::floatbar::after_settings_saved(&app, &float_bar_patch, &settings, notify_float_bar);
if stay_awake_changed {
crate::stay_awake::settings_changed(&app, settings.stay_awake_enabled);
}
if rebuild_tray_menu {
crate::tray_bridge::rebuild_tray_menu(&app);
}
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop-tauri/src-tauri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub const REFRESH_COMPLETE: &str = "refresh-complete";
pub const UPDATE_STATE_CHANGED: &str = "update-state-changed";
pub const LOCALE_CHANGED: &str = "locale-changed";
pub const SETTINGS_CHANGED: &str = "settings-changed";
pub const STAY_AWAKE_CHANGED: &str = "stay-awake-changed";
pub const CODEX_ACCOUNTS_UPDATED: &str = "codex-accounts-updated";
pub const LOGIN_PHASE: &str = "login-phase";

Expand Down Expand Up @@ -112,6 +113,10 @@ pub fn emit_settings_changed(app: &AppHandle) {
let _emit_settings = app.emit(SETTINGS_CHANGED, ());
}

pub fn emit_stay_awake_changed(app: &AppHandle, held: bool) {
let _emit_stay_awake = app.emit(STAY_AWAKE_CHANGED, held);
}

pub fn emit_login_phase(app: &AppHandle, provider_id: &str, phase: &str, auth_link: Option<&str>) {
let _emit_login_phase = app.emit(
LOGIN_PHASE,
Expand Down
12 changes: 10 additions & 2 deletions apps/desktop-tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ mod proof_harness;
mod shell;
mod shortcut_bridge;
mod state;
mod stay_awake;
mod surface;
mod surface_target;
mod tray_accounts;
Expand Down Expand Up @@ -159,6 +160,7 @@ fn main() {
commands::get_bootstrap_state,
commands::get_provider_catalog,
commands::get_settings_snapshot,
stay_awake::get_stay_awake_status,
commands::list_agent_sessions,
commands::focus_agent_session,
commands::update_settings,
Expand Down Expand Up @@ -288,6 +290,7 @@ fn main() {
shortcut_bridge::register(app.handle());
floatbar::install(app.handle());
auto_refresh::install(app.handle().clone());
stay_awake::install(app.handle().clone());
if settings.powertoys_status_pipe_enabled {
powertoys::install(app.handle().clone());
}
Expand Down Expand Up @@ -415,8 +418,13 @@ fn main() {
_ => {}
}
})
.run(tauri::generate_context!())
.expect("failed to run CodexBar desktop shell");
.build(tauri::generate_context!())
.expect("failed to build CodexBar desktop shell")
.run(|_app, event| {
if matches!(event, tauri::RunEvent::Exit) {
stay_awake::shutdown();
}
});
}

#[cfg(test)]
Expand Down
101 changes: 101 additions & 0 deletions apps/desktop-tauri/src-tauri/src/stay_awake.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//! Stay Awake scheduler: keeps Windows from idle-sleeping while a local agent
//! (Codex, Claude, Pi/OMP) process is running.
//!
//! While `stay_awake_enabled` is on, a local-only process scan runs every 30
//! seconds, independent of `agent_sessions_enabled`. It never touches SSH or
//! Tailscale hosts and never reads transcript files, so file-only rollouts and
//! remote sessions cannot hold the machine awake. The policy itself lives in
//! `codexbar::power_assertion`. The setting reaches the controller through
//! [`settings_changed`] (every save goes through `update_settings`) and once at
//! startup; the scan loop never re-reads the file, so it cannot re-enable a
//! toggle the user just turned off.

use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::Duration;

use codexbar::agent_sessions::LocalAgentSessionScanner;
use codexbar::power_assertion::{StayAwake, SystemPowerAssertion};
use codexbar::settings::Settings;
use tokio::sync::Notify;

const SCAN_INTERVAL: Duration = Duration::from_secs(30);

type Controller = StayAwake<SystemPowerAssertion>;

static CONTROLLER: OnceLock<Mutex<Controller>> = OnceLock::new();
static WAKE: Notify = Notify::const_new();

fn controller() -> MutexGuard<'static, Controller> {
CONTROLLER
.get_or_init(|| Mutex::new(StayAwake::new(SystemPowerAssertion::new())))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}

/// Whether an idle-sleep prevention is currently held (tray status line).
pub fn is_held() -> bool {
controller().is_held()
}

#[tauri::command]
pub fn get_stay_awake_status() -> bool {
is_held()
}

fn publish_status_change(app: &tauri::AppHandle) {
crate::tray_bridge::rebuild_tray_menu(app);
crate::events::emit_stay_awake_changed(app, is_held());
}

/// Apply a saved settings change: turning Stay Awake off releases right away,
/// turning it on triggers a scan without waiting for the next interval.
pub fn settings_changed(app: &tauri::AppHandle, enabled: bool) {
let changed = controller().set_enabled(enabled);
if changed {
publish_status_change(app);
}
if enabled {
WAKE.notify_one();
}
}

/// Release for good on app exit.
pub fn shutdown() {
controller().shutdown();
}

pub fn install(app: tauri::AppHandle) {
controller().set_enabled(Settings::load().stay_awake_enabled);
tauri::async_runtime::spawn(async move {
loop {
scan_once(&app).await;
// Sleep one interval, or wake early when the setting is turned on.
tokio::time::timeout(SCAN_INTERVAL, WAKE.notified())
.await
.ok();
}
});
}

async fn scan_once(app: &tauri::AppHandle) {
if !controller().is_enabled() {
return;
}
let changed =
match tokio::task::spawn_blocking(LocalAgentSessionScanner::has_live_agent_process).await {
// The controller drops this result if the setting was turned off or
// the app shut down while the scan was running.
Ok(Ok(live)) => controller().observe(live),
Ok(Err(error)) => {
tracing::warn!("Stay Awake process scan failed: {error}");
false
}
Err(error) => {
tracing::warn!("Stay Awake scan task failed: {error}");
false
}
};
if changed {
publish_status_change(app);
}
}
7 changes: 5 additions & 2 deletions apps/desktop-tauri/src-tauri/src/tray_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use crate::surface::SurfaceMode;
use crate::surface_target::SurfaceTarget;
#[cfg(test)]
use crate::tray_menu::build_tray_menu;
use crate::tray_menu::{TrayMenuEntry, build_tray_menu_with};
use crate::tray_menu::{MenuState, TrayMenuEntry, build_tray_menu_with};

#[derive(Debug, Clone, Copy)]
struct MonitorScaleInfo {
Expand Down Expand Up @@ -134,7 +134,10 @@ fn build_native_tray_menu(
providers,
status_labels,
&enabled,
settings.float_bar_enabled,
MenuState {
float_bar_enabled: settings.float_bar_enabled,
stay_awake_held: crate::stay_awake::is_held(),
},
settings.ui_language,
);
crate::tray_accounts::prepend_account_menus(&mut spec, &settings);
Expand Down
54 changes: 47 additions & 7 deletions apps/desktop-tauri/src-tauri/src/tray_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ impl TrayMenuEntry {
}
}

/// Live app state the menu mirrors beyond the provider catalog.
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct MenuState {
pub(crate) float_bar_enabled: bool,
/// Stay Awake currently holds an idle-sleep prevention.
pub(crate) stay_awake_held: bool,
}

#[cfg(test)]
pub(crate) fn build_tray_menu(
providers: &[ProviderCatalogEntry],
Expand All @@ -92,7 +100,7 @@ pub(crate) fn build_tray_menu(
providers,
status_labels,
enabled_providers,
false,
MenuState::default(),
Language::English,
)
}
Expand All @@ -101,7 +109,7 @@ pub(crate) fn build_tray_menu_with(
providers: &[ProviderCatalogEntry],
status_labels: &[(String, String)],
enabled_providers: &HashSet<String>,
float_bar_enabled: bool,
state: MenuState,
lang: Language,
) -> Vec<TrayMenuEntry> {
let mut menu: Vec<TrayMenuEntry> = Vec::new();
Expand All @@ -111,7 +119,13 @@ pub(crate) fn build_tray_menu_with(
for (id, label) in status_labels {
menu.push(TrayMenuEntry::status_row(format!("status_{id}"), label));
}
if !status_labels.is_empty() {
if state.stay_awake_held {
menu.push(TrayMenuEntry::status_row(
"stay_awake_active",
text(LocaleKey::TrayStayAwakeActive),
));
}
if !status_labels.is_empty() || state.stay_awake_held {
menu.push(TrayMenuEntry::separator());
}

Expand All @@ -130,7 +144,7 @@ pub(crate) fn build_tray_menu_with(
menu.push(TrayMenuEntry::check_item(
"toggle_float_bar",
text(LocaleKey::TrayShowFloatBar),
float_bar_enabled,
state.float_bar_enabled,
));
menu.push(TrayMenuEntry::separator());

Expand Down Expand Up @@ -239,7 +253,10 @@ mod tests {
&sample_provider_catalog(),
&[],
&both_enabled(),
/* float_bar_enabled = */ true,
MenuState {
float_bar_enabled: true,
..MenuState::default()
},
Language::English,
);
let toggle = menu_on
Expand All @@ -253,7 +270,7 @@ mod tests {
&sample_provider_catalog(),
&[],
&both_enabled(),
/* float_bar_enabled = */ false,
MenuState::default(),
Language::English,
);
let toggle = menu_off
Expand All @@ -269,7 +286,7 @@ mod tests {
&sample_provider_catalog(),
&[],
&both_enabled(),
false,
MenuState::default(),
Language::Japanese,
);
fn label_for<'a>(menu: &'a [TrayMenuEntry], id: &'a str) -> &'a str {
Expand Down Expand Up @@ -311,4 +328,27 @@ mod tests {
// Third item should be a separator.
assert!(menu[2].is_separator);
}

#[test]
fn stay_awake_row_appears_only_while_held() {
let build = |stay_awake_held| {
build_tray_menu_with(
&sample_provider_catalog(),
&[],
&both_enabled(),
MenuState {
stay_awake_held,
..MenuState::default()
},
Language::English,
)
};
assert!(!menu_contains(&build(false), "stay_awake_active"));

let held = build(true);
assert_eq!(held[0].id.as_deref(), Some("stay_awake_active"));
assert_eq!(held[0].label, "Stay Awake: local agent session is live");
assert!(held[0].disabled);
assert!(held[1].is_separator);
}
}
46 changes: 46 additions & 0 deletions apps/desktop-tauri/src/hooks/useStayAwakeStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { getStayAwakeStatus } from "../lib/tauri";

const STAY_AWAKE_CHANGED = "stay-awake-changed";

export function useStayAwakeStatus(): boolean {
const [held, setHeld] = useState(false);

useEffect(() => {
let cancelled = false;
let updates = 0;
let unlisten = () => {};

const initialize = async () => {
try {
unlisten = await listen<boolean>(STAY_AWAKE_CHANGED, (event) => {
updates += 1;
if (!cancelled) setHeld(event.payload);
});
} catch {
// The status query below still provides a useful initial value.
}
if (cancelled) {
unlisten();
return;
}

const observedUpdates = updates;
try {
const current = await getStayAwakeStatus();
if (!cancelled && updates === observedUpdates) setHeld(current);
} catch {
// Keep the last event value if the status query is unavailable.
}
};

void initialize();
return () => {
cancelled = true;
unlisten();
};
}, []);

return held;
}
Loading