Compare commits

..

1 commit

Author SHA1 Message Date
Albert Armea
4dc5842949 Add keyboard navigation for launcher grid 2026-02-07 18:16:00 -05:00
59 changed files with 779 additions and 1509 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
*.webm filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text

View file

@ -120,46 +120,6 @@ jobs:
. "$HOME/.cargo/env" . "$HOME/.cargo/env"
cargo clippy --all-targets -- -D warnings cargo clippy --all-targets -- -D warnings
fmt:
name: Rustfmt
runs-on: ubuntu-latest
container:
image: ubuntu:25.10
steps:
- name: Install git
run: |
apt-get update
apt-get install -y git curl
- uses: actions/checkout@v4
- name: Install build dependencies
run: ./scripts/shepherd deps install build
- name: Add rustfmt component
run: |
. "$HOME/.cargo/env"
rustup component add rustfmt
- name: Add Rust to PATH
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Cache cargo registry and build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Check formatting
run: |
. "$HOME/.cargo/env"
cargo fmt --all -- --check
shellcheck: shellcheck:
name: ShellCheck name: ShellCheck
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -2,9 +2,7 @@ Agents: please use the existing documentation for setup.
<CONTRIBUTING.md> describes environment setup and build, test, and lint, including helper scripts and exact commands. <CONTRIBUTING.md> describes environment setup and build, test, and lint, including helper scripts and exact commands.
Please ensure that your changes build and pass tests and lint, and run `cargo fmt --all` to match your changes to the rest of the code. Please ensure that your changes build and pass tests and lint. If you changed the example configuration at <config.example.toml>, make sure that it passes config validation.
If you changed the example configuration at <config.example.toml>, make sure that it passes config validation.
Each of the Rust crates in <crates> contains a README.md that describes each at a high level. Each of the Rust crates in <crates> contains a README.md that describes each at a high level.

174
Cargo.lock generated
View file

@ -315,12 +315,6 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.31"
@ -465,40 +459,6 @@ dependencies = [
"wasip2", "wasip2",
] ]
[[package]]
name = "gilrs"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fa85c2e35dc565c90511917897ea4eae16b77f2773d5223536f7b602536d462"
dependencies = [
"fnv",
"gilrs-core",
"log",
"uuid",
"vec_map",
]
[[package]]
name = "gilrs-core"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d23f2cc5144060a7f8d9e02d3fce5d06705376568256a509cdbc3c24d47e4f04"
dependencies = [
"inotify",
"js-sys",
"libc",
"libudev-sys",
"log",
"nix 0.30.1",
"objc2-core-foundation",
"objc2-io-kit",
"uuid",
"vec_map",
"wasm-bindgen",
"web-sys",
"windows",
]
[[package]] [[package]]
name = "gio" name = "gio"
version = "0.20.12" version = "0.20.12"
@ -802,26 +762,6 @@ dependencies = [
"hashbrown 0.16.1", "hashbrown 0.16.1",
] ]
[[package]]
name = "inotify"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
dependencies = [
"bitflags",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.2" version = "1.70.2"
@ -883,16 +823,6 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "libudev-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
dependencies = [
"libc",
"pkg-config",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.11.0" version = "0.11.0"
@ -962,18 +892,6 @@ dependencies = [
"memoffset", "memoffset",
] ]
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@ -992,26 +910,6 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
]
[[package]]
name = "objc2-io-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
dependencies = [
"bitflags",
"libc",
"objc2-core-foundation",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
@ -1358,7 +1256,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dirs", "dirs",
"nix 0.29.0", "nix",
"serde", "serde",
"shell-escape", "shell-escape",
"shepherd-api", "shepherd-api",
@ -1393,7 +1291,7 @@ dependencies = [
name = "shepherd-ipc" name = "shepherd-ipc"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"nix 0.29.0", "nix",
"serde", "serde",
"serde_json", "serde_json",
"shepherd-api", "shepherd-api",
@ -1412,7 +1310,6 @@ dependencies = [
"chrono", "chrono",
"clap", "clap",
"dirs", "dirs",
"gilrs",
"gtk4", "gtk4",
"serde", "serde",
"serde_json", "serde_json",
@ -1823,12 +1720,6 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vec_map"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
@ -1901,37 +1792,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "web-sys"
version = "0.3.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@ -1945,17 +1805,6 @@ dependencies = [
"windows-strings", "windows-strings",
] ]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]] [[package]]
name = "windows-implement" name = "windows-implement"
version = "0.60.2" version = "0.60.2"
@ -1984,16 +1833,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"
@ -2096,15 +1935,6 @@ dependencies = [
"windows_x86_64_msvc 0.53.1", "windows_x86_64_msvc 0.53.1",
] ]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.48.5" version = "0.48.5"

View file

@ -25,7 +25,7 @@ or write your own.
The flow of manually opening and closing activities should be familiar. The flow of manually opening and closing activities should be familiar.
<video controls src="https://git.armeafamily.com/albert/shepherd-launcher/raw/branch/main/docs/readme/basic-flow.webm" alt="Happy path demo showing home screen --> GCompris --> home screen"></video> ["Happy path" demo showing home screen --> GCompris --> home screen](https://github.com/user-attachments/assets/1aed2040-b381-4022-8353-5ce076b1eee0)
Activities can be made selectively available at certain times of day. Activities can be made selectively available at certain times of day.
@ -40,7 +40,7 @@ Activities can have configurable time limits, including:
* total usage per day * total usage per day
* cooldown periods before that particular activity can be restarted * cooldown periods before that particular activity can be restarted
<video controls src="https://git.armeafamily.com/albert/shepherd-launcher/raw/branch/main/docs/readme/tuxmath-expiring.webm" alt="TuxMath session shown about to expire, including warnings and automatic termination"></video> [TuxMath session shown about to expire, including warnings and automatic termination](https://github.com/user-attachments/assets/541aa456-ef7c-4974-b918-5b143c5304c3)
### Anything on Linux ### Anything on Linux
@ -84,37 +84,9 @@ If it can run on Linux in *any way, shape, or form*, it can be supervised by
## Non-goals ## Non-goals
1. Modifying or patching third-party applications * Modifying or patching third-party applications
2. Circumventing DRM or platform protections * Circumventing DRM or platform protections
3. Replacing parental involvement with automation or third-party content moderation * Replacing parental involvement with automation
4. Remotely monitoring users with telemetry
5. Collecting, storing, or reporting personally identifying information (PII)
### Regarding age verification
`shepherd-launcher` may be considered "operating system software" under the
[Digital Age Assurance Act][age-california] and similar legislation,
and therefore subject to an age verification requirement.
[age-california]: https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202520260AB1043
As legislated, such requirements are fundamentally incompatible with non-goals 3, 4, and 5.
`shepherd-launcher` will *never* collect telemetry or PII, and as such, it will never implement this type of age verification.
As a result, `shepherd-launcher` is not licensed for use in any region that requires OS-level age verification by law.
**If you reside in any such region, you may not download, install, or redistribute `shepherd-launcher`.**
This includes, but is not limited to:
* California
* Louisiana
* Texas
* Utah
[Many other states are considering similar legislation.](https://actonline.org/2025/01/14/the-abcs-of-age-verification-in-the-united-states/)
If you disagree with this assessment and you reside in an affected region, **please contact your representatives.**
## Installation ## Installation
@ -177,9 +149,9 @@ See [config.example.toml](./config.example.toml) for more.
Build instructions and contribution guidelines are described in Build instructions and contribution guidelines are described in
[CONTRIBUTING.md](./CONTRIBUTING.md). [CONTRIBUTING.md](./CONTRIBUTING.md).
If you'd like to help out, you can find potential work items on If you'd like to help out, look on
[the Issues page](https://git.armeafamily.com/albert/shepherd-launcher/issues). [GitHub Issues](https://github.com/aarmea/shepherd-launcher/issues) for
You may email me patch sets at <shepherd-launcher-patch@albertarmea.com>. potential work items.
## Written in 2025, responsibly ## Written in 2025, responsibly

View file

@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use shepherd_util::{ClientId, EntryId}; use shepherd_util::{ClientId, EntryId};
use std::time::Duration; use std::time::Duration;
use crate::{API_VERSION, ClientRole, StopMode}; use crate::{ClientRole, StopMode, API_VERSION};
/// Request wrapper with metadata /// Request wrapper with metadata
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -128,6 +128,7 @@ pub enum Command {
GetHealth, GetHealth,
// Volume control commands // Volume control commands
/// Get current volume status /// Get current volume status
GetVolume, GetVolume,
@ -141,6 +142,7 @@ pub enum Command {
SetMute { muted: bool }, SetMute { muted: bool },
// Admin commands // Admin commands
/// Extend the current session (admin only) /// Extend the current session (admin only)
ExtendCurrent { by: Duration }, ExtendCurrent { by: Duration },

View file

@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use shepherd_util::{EntryId, SessionId}; use shepherd_util::{EntryId, SessionId};
use std::time::Duration; use std::time::Duration;
use crate::{API_VERSION, ServiceStateSnapshot, SessionEndReason, WarningSeverity}; use crate::{ServiceStateSnapshot, SessionEndReason, WarningSeverity, API_VERSION};
/// Event envelope /// Event envelope
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -51,7 +51,9 @@ pub enum EventPayload {
}, },
/// Session is expiring (termination initiated) /// Session is expiring (termination initiated)
SessionExpiring { session_id: SessionId }, SessionExpiring {
session_id: SessionId,
},
/// Session has ended /// Session has ended
SessionEnded { SessionEnded {
@ -62,13 +64,21 @@ pub enum EventPayload {
}, },
/// Policy was reloaded /// Policy was reloaded
PolicyReloaded { entry_count: usize }, PolicyReloaded {
entry_count: usize,
},
/// Entry availability changed (for UI updates) /// Entry availability changed (for UI updates)
EntryAvailabilityChanged { entry_id: EntryId, enabled: bool }, EntryAvailabilityChanged {
entry_id: EntryId,
enabled: bool,
},
/// Volume status changed /// Volume status changed
VolumeChanged { percent: u8, muted: bool }, VolumeChanged {
percent: u8,
muted: bool,
},
/// Service is shutting down /// Service is shutting down
Shutdown, Shutdown,
@ -97,10 +107,7 @@ mod tests {
let parsed: Event = serde_json::from_str(&json).unwrap(); let parsed: Event = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.api_version, API_VERSION); assert_eq!(parsed.api_version, API_VERSION);
assert!(matches!( assert!(matches!(parsed.payload, EventPayload::SessionStarted { .. }));
parsed.payload,
EventPayload::SessionStarted { .. }
));
} }
#[test] #[test]

View file

@ -125,9 +125,14 @@ pub enum ReasonCode {
next_window_start: Option<DateTime<Local>>, next_window_start: Option<DateTime<Local>>,
}, },
/// Daily quota exhausted /// Daily quota exhausted
QuotaExhausted { used: Duration, quota: Duration }, QuotaExhausted {
used: Duration,
quota: Duration,
},
/// Cooldown period active /// Cooldown period active
CooldownActive { available_at: DateTime<Local> }, CooldownActive {
available_at: DateTime<Local>,
},
/// Another session is active /// Another session is active
SessionActive { SessionActive {
entry_id: EntryId, entry_id: EntryId,
@ -135,11 +140,17 @@ pub enum ReasonCode {
remaining: Option<Duration>, remaining: Option<Duration>,
}, },
/// Host doesn't support this entry kind /// Host doesn't support this entry kind
UnsupportedKind { kind: EntryKindTag }, UnsupportedKind {
kind: EntryKindTag,
},
/// Entry is explicitly disabled /// Entry is explicitly disabled
Disabled { reason: Option<String> }, Disabled {
reason: Option<String>,
},
/// Internet connectivity is required but unavailable /// Internet connectivity is required but unavailable
InternetUnavailable { check: Option<String> }, InternetUnavailable {
check: Option<String>,
},
} }
/// Warning severity level /// Warning severity level

View file

@ -29,10 +29,7 @@ fn main() -> ExitCode {
// Check file exists // Check file exists
if !config_path.exists() { if !config_path.exists() {
eprintln!( eprintln!("Error: Configuration file not found: {}", config_path.display());
"Error: Configuration file not found: {}",
config_path.display()
);
return ExitCode::from(1); return ExitCode::from(1);
} }
@ -42,10 +39,7 @@ fn main() -> ExitCode {
println!("✓ Configuration is valid"); println!("✓ Configuration is valid");
println!(); println!();
println!("Summary:"); println!("Summary:");
println!( println!(" Config version: {}", shepherd_config::CURRENT_CONFIG_VERSION);
" Config version: {}",
shepherd_config::CURRENT_CONFIG_VERSION
);
println!(" Entries: {}", policy.entries.len()); println!(" Entries: {}", policy.entries.len());
// Show entry summary // Show entry summary

View file

@ -65,9 +65,8 @@ impl InternetCheckTarget {
let (host, port_opt) = parse_host_port(host_port)?; let (host, port_opt) = parse_host_port(host_port)?;
let port = match scheme { let port = match scheme {
InternetCheckScheme::Tcp => { InternetCheckScheme::Tcp => port_opt
port_opt.ok_or_else(|| "tcp check requires explicit port".to_string())? .ok_or_else(|| "tcp check requires explicit port".to_string())?,
}
_ => port_opt.unwrap_or_else(|| scheme.default_port()), _ => port_opt.unwrap_or_else(|| scheme.default_port()),
}; };
@ -150,3 +149,4 @@ pub struct EntryInternetPolicy {
pub required: bool, pub required: bool,
pub check: Option<InternetCheckTarget>, pub check: Option<InternetCheckTarget>,
} }

View file

@ -6,15 +6,15 @@
//! - Time windows, limits, and warnings //! - Time windows, limits, and warnings
//! - Validation with clear error messages //! - Validation with clear error messages
mod internet;
mod policy; mod policy;
mod schema; mod schema;
mod validation; mod validation;
mod internet;
pub use internet::*;
pub use policy::*; pub use policy::*;
pub use schema::*; pub use schema::*;
pub use validation::*; pub use validation::*;
pub use internet::*;
use std::path::Path; use std::path::Path;
use thiserror::Error; use thiserror::Error;

View file

@ -1,19 +1,16 @@
//! Validated policy structures //! Validated policy structures
use crate::internet::{
DEFAULT_INTERNET_CHECK_INTERVAL, DEFAULT_INTERNET_CHECK_TIMEOUT, EntryInternetPolicy,
InternetCheckTarget, InternetConfig,
};
use crate::schema::{ use crate::schema::{
RawConfig, RawEntry, RawEntryKind, RawInternetConfig, RawServiceConfig, RawVolumeConfig, RawConfig, RawEntry, RawEntryKind, RawInternetConfig, RawVolumeConfig, RawServiceConfig,
RawWarningThreshold, RawWarningThreshold,
}; };
use crate::internet::{
EntryInternetPolicy, InternetCheckTarget, InternetConfig, DEFAULT_INTERNET_CHECK_INTERVAL,
DEFAULT_INTERNET_CHECK_TIMEOUT,
};
use crate::validation::{parse_days, parse_time}; use crate::validation::{parse_days, parse_time};
use shepherd_api::{EntryKind, WarningSeverity, WarningThreshold}; use shepherd_api::{EntryKind, WarningSeverity, WarningThreshold};
use shepherd_util::{ use shepherd_util::{DaysOfWeek, EntryId, TimeWindow, WallClock, default_data_dir, default_log_dir, socket_path_without_env};
DaysOfWeek, EntryId, TimeWindow, WallClock, default_data_dir, default_log_dir,
socket_path_without_env,
};
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
@ -97,17 +94,24 @@ pub struct ServiceConfig {
impl ServiceConfig { impl ServiceConfig {
fn from_raw(raw: RawServiceConfig) -> Self { fn from_raw(raw: RawServiceConfig) -> Self {
let log_dir = raw.log_dir.clone().unwrap_or_else(default_log_dir); let log_dir = raw
.log_dir
.clone()
.unwrap_or_else(default_log_dir);
let child_log_dir = raw let child_log_dir = raw
.child_log_dir .child_log_dir
.unwrap_or_else(|| log_dir.join("sessions")); .unwrap_or_else(|| log_dir.join("sessions"));
let internet = convert_internet_config(raw.internet.as_ref()); let internet = convert_internet_config(raw.internet.as_ref());
Self { Self {
socket_path: raw.socket_path.unwrap_or_else(socket_path_without_env), socket_path: raw
.socket_path
.unwrap_or_else(socket_path_without_env),
log_dir, log_dir,
capture_child_output: raw.capture_child_output, capture_child_output: raw.capture_child_output,
child_log_dir, child_log_dir,
data_dir: raw.data_dir.unwrap_or_else(default_data_dir), data_dir: raw
.data_dir
.unwrap_or_else(default_data_dir),
internet, internet,
} }
} }
@ -122,11 +126,7 @@ impl Default for ServiceConfig {
log_dir, log_dir,
data_dir: default_data_dir(), data_dir: default_data_dir(),
capture_child_output: false, capture_child_output: false,
internet: InternetConfig::new( internet: InternetConfig::new(None, DEFAULT_INTERNET_CHECK_INTERVAL, DEFAULT_INTERNET_CHECK_TIMEOUT),
None,
DEFAULT_INTERNET_CHECK_INTERVAL,
DEFAULT_INTERNET_CHECK_TIMEOUT,
),
} }
} }
} }
@ -212,7 +212,10 @@ impl AvailabilityPolicy {
} }
/// Get remaining time in current window /// Get remaining time in current window
pub fn remaining_in_window(&self, dt: &chrono::DateTime<chrono::Local>) -> Option<Duration> { pub fn remaining_in_window(
&self,
dt: &chrono::DateTime<chrono::Local>,
) -> Option<Duration> {
if self.always { if self.always {
return None; // No limit from windows return None; // No limit from windows
} }
@ -266,28 +269,8 @@ impl VolumePolicy {
fn convert_entry_kind(raw: RawEntryKind) -> EntryKind { fn convert_entry_kind(raw: RawEntryKind) -> EntryKind {
match raw { match raw {
RawEntryKind::Process { RawEntryKind::Process { command, args, env, cwd } => EntryKind::Process { command, args, env, cwd },
command, RawEntryKind::Snap { snap_name, command, args, env } => EntryKind::Snap { snap_name, command, args, env },
args,
env,
cwd,
} => EntryKind::Process {
command,
args,
env,
cwd,
},
RawEntryKind::Snap {
snap_name,
command,
args,
env,
} => EntryKind::Snap {
snap_name,
command,
args,
env,
},
RawEntryKind::Steam { app_id, args, env } => EntryKind::Steam { app_id, args, env }, RawEntryKind::Steam { app_id, args, env } => EntryKind::Steam { app_id, args, env },
RawEntryKind::Flatpak { app_id, args, env } => EntryKind::Flatpak { app_id, args, env }, RawEntryKind::Flatpak { app_id, args, env } => EntryKind::Flatpak { app_id, args, env },
RawEntryKind::Vm { driver, args } => EntryKind::Vm { driver, args }, RawEntryKind::Vm { driver, args } => EntryKind::Vm { driver, args },
@ -364,10 +347,7 @@ fn seconds_to_duration_or_unlimited(secs: u64) -> Option<Duration> {
} }
} }
fn convert_limits( fn convert_limits(raw: crate::schema::RawLimits, default_max_run: Option<Duration>) -> LimitsPolicy {
raw: crate::schema::RawLimits,
default_max_run: Option<Duration>,
) -> LimitsPolicy {
LimitsPolicy { LimitsPolicy {
max_run: raw max_run: raw
.max_run_seconds .max_run_seconds

View file

@ -1,7 +1,7 @@
//! Configuration validation //! Configuration validation
use crate::internet::InternetCheckTarget;
use crate::schema::{RawConfig, RawDays, RawEntry, RawEntryKind, RawTimeWindow}; use crate::schema::{RawConfig, RawDays, RawEntry, RawEntryKind, RawTimeWindow};
use crate::internet::InternetCheckTarget;
use std::collections::HashSet; use std::collections::HashSet;
use thiserror::Error; use thiserror::Error;
@ -38,29 +38,26 @@ pub fn validate_config(config: &RawConfig) -> Vec<ValidationError> {
// Validate global internet check (if set) // Validate global internet check (if set)
if let Some(internet) = &config.service.internet if let Some(internet) = &config.service.internet
&& let Some(check) = &internet.check && let Some(check) = &internet.check
&& let Err(e) = InternetCheckTarget::parse(check) && let Err(e) = InternetCheckTarget::parse(check) {
{ errors.push(ValidationError::GlobalError(format!(
errors.push(ValidationError::GlobalError(format!( "Invalid internet check '{}': {}",
"Invalid internet check '{}': {}", check, e
check, e )));
))); }
}
if let Some(internet) = &config.service.internet { if let Some(internet) = &config.service.internet {
if let Some(interval) = internet.interval_seconds if let Some(interval) = internet.interval_seconds
&& interval == 0 && interval == 0 {
{ errors.push(ValidationError::GlobalError(
errors.push(ValidationError::GlobalError( "Internet check interval_seconds must be > 0".into(),
"Internet check interval_seconds must be > 0".into(), ));
)); }
}
if let Some(timeout) = internet.timeout_ms if let Some(timeout) = internet.timeout_ms
&& timeout == 0 && timeout == 0 {
{ errors.push(ValidationError::GlobalError(
errors.push(ValidationError::GlobalError( "Internet check timeout_ms must be > 0".into(),
"Internet check timeout_ms must be > 0".into(), ));
)); }
}
} }
// Check for duplicate entry IDs // Check for duplicate entry IDs
@ -159,30 +156,28 @@ fn validate_entry(entry: &RawEntry, config: &RawConfig) -> Vec<ValidationError>
// Only validate warnings if max_run is Some and not 0 (unlimited) // Only validate warnings if max_run is Some and not 0 (unlimited)
if let (Some(warnings), Some(max_run)) = (&entry.warnings, max_run) if let (Some(warnings), Some(max_run)) = (&entry.warnings, max_run)
&& max_run > 0 && max_run > 0 {
{ for warning in warnings {
for warning in warnings { if warning.seconds_before >= max_run {
if warning.seconds_before >= max_run { errors.push(ValidationError::WarningExceedsMaxRun {
errors.push(ValidationError::WarningExceedsMaxRun { entry_id: entry.id.clone(),
entry_id: entry.id.clone(), seconds: warning.seconds_before,
seconds: warning.seconds_before, max_run,
max_run, });
}); }
} }
}
// Note: warnings are ignored for unlimited entries (max_run = 0) // Note: warnings are ignored for unlimited entries (max_run = 0)
} }
// Validate internet requirements // Validate internet requirements
if let Some(internet) = &entry.internet { if let Some(internet) = &entry.internet {
if let Some(check) = &internet.check if let Some(check) = &internet.check
&& let Err(e) = InternetCheckTarget::parse(check) && let Err(e) = InternetCheckTarget::parse(check) {
{ errors.push(ValidationError::EntryError {
errors.push(ValidationError::EntryError { entry_id: entry.id.clone(),
entry_id: entry.id.clone(), message: format!("Invalid internet check '{}': {}", check, e),
message: format!("Invalid internet check '{}': {}", check, e), });
}); }
}
if internet.required { if internet.required {
let has_check = internet.check.is_some() let has_check = internet.check.is_some()
@ -241,8 +236,12 @@ pub fn parse_time(s: &str) -> Result<(u8, u8), String> {
return Err("Expected HH:MM format".into()); return Err("Expected HH:MM format".into());
} }
let hour: u8 = parts[0].parse().map_err(|_| "Invalid hour".to_string())?; let hour: u8 = parts[0]
let minute: u8 = parts[1].parse().map_err(|_| "Invalid minute".to_string())?; .parse()
.map_err(|_| "Invalid hour".to_string())?;
let minute: u8 = parts[1]
.parse()
.map_err(|_| "Invalid minute".to_string())?;
if hour >= 24 { if hour >= 24 {
return Err("Hour must be 0-23".into()); return Err("Hour must be 0-23".into());
@ -300,23 +299,12 @@ mod tests {
#[test] #[test]
fn test_parse_days() { fn test_parse_days() {
assert_eq!( assert_eq!(parse_days(&RawDays::Preset("weekdays".into())).unwrap(), 0x1F);
parse_days(&RawDays::Preset("weekdays".into())).unwrap(), assert_eq!(parse_days(&RawDays::Preset("weekends".into())).unwrap(), 0x60);
0x1F
);
assert_eq!(
parse_days(&RawDays::Preset("weekends".into())).unwrap(),
0x60
);
assert_eq!(parse_days(&RawDays::Preset("all".into())).unwrap(), 0x7F); assert_eq!(parse_days(&RawDays::Preset("all".into())).unwrap(), 0x7F);
assert_eq!( assert_eq!(
parse_days(&RawDays::List(vec![ parse_days(&RawDays::List(vec!["mon".into(), "wed".into(), "fri".into()])).unwrap(),
"mon".into(),
"wed".into(),
"fri".into()
]))
.unwrap(),
0b10101 0b10101
); );
} }
@ -367,10 +355,6 @@ mod tests {
}; };
let errors = validate_config(&config); let errors = validate_config(&config);
assert!( assert!(errors.iter().any(|e| matches!(e, ValidationError::DuplicateEntryId(_))));
errors
.iter()
.any(|e| matches!(e, ValidationError::DuplicateEntryId(_)))
);
} }
} }

View file

@ -2,9 +2,10 @@
use chrono::{DateTime, Local}; use chrono::{DateTime, Local};
use shepherd_api::{ use shepherd_api::{
API_VERSION, EntryView, ReasonCode, ServiceStateSnapshot, SessionEndReason, WarningSeverity, ServiceStateSnapshot, EntryView, ReasonCode, SessionEndReason,
WarningSeverity, API_VERSION,
}; };
use shepherd_config::{Entry, InternetCheckTarget, Policy}; use shepherd_config::{Entry, Policy, InternetCheckTarget};
use shepherd_host_api::{HostCapabilities, HostSessionHandle}; use shepherd_host_api::{HostCapabilities, HostSessionHandle};
use shepherd_store::{AuditEvent, AuditEventType, Store}; use shepherd_store::{AuditEvent, AuditEventType, Store};
use shepherd_util::{EntryId, MonotonicInstant, SessionId}; use shepherd_util::{EntryId, MonotonicInstant, SessionId};
@ -43,7 +44,11 @@ pub struct CoreEngine {
impl CoreEngine { impl CoreEngine {
/// Create a new core engine /// Create a new core engine
pub fn new(policy: Policy, store: Arc<dyn Store>, capabilities: HostCapabilities) -> Self { pub fn new(
policy: Policy,
store: Arc<dyn Store>,
capabilities: HostCapabilities,
) -> Self {
info!( info!(
entry_count = policy.entries.len(), entry_count = policy.entries.len(),
"Core engine initialized" "Core engine initialized"
@ -74,11 +79,9 @@ impl CoreEngine {
let entry_count = policy.entries.len(); let entry_count = policy.entries.len();
self.policy = policy; self.policy = policy;
let _ = self let _ = self.store.append_audit(AuditEvent::new(AuditEventType::PolicyLoaded {
.store entry_count,
.append_audit(AuditEvent::new(AuditEventType::PolicyLoaded { }));
entry_count,
}));
info!(entry_count, "Policy reloaded"); info!(entry_count, "Policy reloaded");
@ -134,12 +137,11 @@ impl CoreEngine {
// Check internet requirement // Check internet requirement
if entry.internet.required { if entry.internet.required {
let check = entry.internet.check.as_ref().or(self let check = entry
.policy
.service
.internet .internet
.check .check
.as_ref()); .as_ref()
.or(self.policy.service.internet.check.as_ref());
let available = check let available = check
.map(|target| self.internet_available(target)) .map(|target| self.internet_available(target))
.unwrap_or(false); .unwrap_or(false);
@ -163,23 +165,19 @@ impl CoreEngine {
// Check cooldown // Check cooldown
if let Ok(Some(until)) = self.store.get_cooldown_until(&entry.id) if let Ok(Some(until)) = self.store.get_cooldown_until(&entry.id)
&& until > now && until > now {
{ enabled = false;
enabled = false; reasons.push(ReasonCode::CooldownActive { available_at: until });
reasons.push(ReasonCode::CooldownActive { }
available_at: until,
});
}
// Check daily quota // Check daily quota
if let Some(quota) = entry.limits.daily_quota { if let Some(quota) = entry.limits.daily_quota {
let today = now.date_naive(); let today = now.date_naive();
if let Ok(used) = self.store.get_usage(&entry.id, today) if let Ok(used) = self.store.get_usage(&entry.id, today)
&& used >= quota && used >= quota {
{ enabled = false;
enabled = false; reasons.push(ReasonCode::QuotaExhausted { used, quota });
reasons.push(ReasonCode::QuotaExhausted { used, quota }); }
}
} }
// Calculate max run if enabled (None when disabled, Some(None) flattened for unlimited) // Calculate max run if enabled (None when disabled, Some(None) flattened for unlimited)
@ -229,7 +227,11 @@ impl CoreEngine {
} }
/// Request to launch an entry /// Request to launch an entry
pub fn request_launch(&self, entry_id: &EntryId, now: DateTime<Local>) -> LaunchDecision { pub fn request_launch(
&self,
entry_id: &EntryId,
now: DateTime<Local>,
) -> LaunchDecision {
// Find entry // Find entry
let entry = match self.policy.get_entry(entry_id) { let entry = match self.policy.get_entry(entry_id) {
Some(e) => e, Some(e) => e,
@ -247,12 +249,10 @@ impl CoreEngine {
if !view.enabled { if !view.enabled {
// Log denial // Log denial
let _ = self let _ = self.store.append_audit(AuditEvent::new(AuditEventType::LaunchDenied {
.store entry_id: entry_id.clone(),
.append_audit(AuditEvent::new(AuditEventType::LaunchDenied { reasons: view.reasons.iter().map(|r| format!("{:?}", r)).collect(),
entry_id: entry_id.clone(), }));
reasons: view.reasons.iter().map(|r| format!("{:?}", r)).collect(),
}));
return LaunchDecision::Denied { return LaunchDecision::Denied {
reasons: view.reasons, reasons: view.reasons,
@ -302,14 +302,12 @@ impl CoreEngine {
}; };
// Log to audit // Log to audit
let _ = self let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionStarted {
.store session_id: session.plan.session_id.clone(),
.append_audit(AuditEvent::new(AuditEventType::SessionStarted { entry_id: session.plan.entry_id.clone(),
session_id: session.plan.session_id.clone(), label: session.plan.label.clone(),
entry_id: session.plan.entry_id.clone(), deadline: session.deadline,
label: session.plan.label.clone(), }));
deadline: session.deadline,
}));
if let Some(deadline) = session.deadline { if let Some(deadline) = session.deadline {
info!( info!(
@ -386,12 +384,10 @@ impl CoreEngine {
session.mark_warning_issued(threshold); session.mark_warning_issued(threshold);
// Log to audit // Log to audit
let _ = self let _ = self.store.append_audit(AuditEvent::new(AuditEventType::WarningIssued {
.store session_id: session.plan.session_id.clone(),
.append_audit(AuditEvent::new(AuditEventType::WarningIssued { threshold_seconds: threshold,
session_id: session.plan.session_id.clone(), }));
threshold_seconds: threshold,
}));
info!( info!(
session_id = %session.plan.session_id, session_id = %session.plan.session_id,
@ -447,27 +443,22 @@ impl CoreEngine {
// Update usage accounting // Update usage accounting
let today = now.date_naive(); let today = now.date_naive();
let _ = self let _ = self.store.add_usage(&session.plan.entry_id, today, duration);
.store
.add_usage(&session.plan.entry_id, today, duration);
// Set cooldown if configured // Set cooldown if configured
if let Some(entry) = self.policy.get_entry(&session.plan.entry_id) if let Some(entry) = self.policy.get_entry(&session.plan.entry_id)
&& let Some(cooldown) = entry.limits.cooldown && let Some(cooldown) = entry.limits.cooldown {
{ let until = now + chrono::Duration::from_std(cooldown).unwrap();
let until = now + chrono::Duration::from_std(cooldown).unwrap(); let _ = self.store.set_cooldown_until(&session.plan.entry_id, until);
let _ = self.store.set_cooldown_until(&session.plan.entry_id, until); }
}
// Log to audit // Log to audit
let _ = self let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionEnded {
.store session_id: session.plan.session_id.clone(),
.append_audit(AuditEvent::new(AuditEventType::SessionEnded { entry_id: session.plan.entry_id.clone(),
session_id: session.plan.session_id.clone(), reason: reason.clone(),
entry_id: session.plan.entry_id.clone(), duration,
reason: reason.clone(), }));
duration,
}));
info!( info!(
session_id = %session.plan.session_id, session_id = %session.plan.session_id,
@ -501,27 +492,22 @@ impl CoreEngine {
// Update usage accounting // Update usage accounting
let today = now.date_naive(); let today = now.date_naive();
let _ = self let _ = self.store.add_usage(&session.plan.entry_id, today, duration);
.store
.add_usage(&session.plan.entry_id, today, duration);
// Set cooldown if configured // Set cooldown if configured
if let Some(entry) = self.policy.get_entry(&session.plan.entry_id) if let Some(entry) = self.policy.get_entry(&session.plan.entry_id)
&& let Some(cooldown) = entry.limits.cooldown && let Some(cooldown) = entry.limits.cooldown {
{ let until = now + chrono::Duration::from_std(cooldown).unwrap();
let until = now + chrono::Duration::from_std(cooldown).unwrap(); let _ = self.store.set_cooldown_until(&session.plan.entry_id, until);
let _ = self.store.set_cooldown_until(&session.plan.entry_id, until); }
}
// Log to audit // Log to audit
let _ = self let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionEnded {
.store session_id: session.plan.session_id.clone(),
.append_audit(AuditEvent::new(AuditEventType::SessionEnded { entry_id: session.plan.entry_id.clone(),
session_id: session.plan.session_id.clone(), reason: reason.clone(),
entry_id: session.plan.entry_id.clone(), duration,
reason: reason.clone(), }));
duration,
}));
info!( info!(
session_id = %session.plan.session_id, session_id = %session.plan.session_id,
@ -539,10 +525,9 @@ impl CoreEngine {
/// Get current service state snapshot /// Get current service state snapshot
pub fn get_state(&self) -> ServiceStateSnapshot { pub fn get_state(&self) -> ServiceStateSnapshot {
let current_session = self let current_session = self.current_session.as_ref().map(|s| {
.current_session s.to_session_info(MonotonicInstant::now())
.as_ref() });
.map(|s| s.to_session_info(MonotonicInstant::now()));
// Build entry views for the snapshot // Build entry views for the snapshot
let entries = self.list_entries(shepherd_util::now()); let entries = self.list_entries(shepherd_util::now());
@ -592,13 +577,11 @@ impl CoreEngine {
session.deadline = Some(new_deadline); session.deadline = Some(new_deadline);
// Log to audit // Log to audit
let _ = self let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionExtended {
.store session_id: session.plan.session_id.clone(),
.append_audit(AuditEvent::new(AuditEventType::SessionExtended { extended_by: by,
session_id: session.plan.session_id.clone(), new_deadline,
extended_by: by, }));
new_deadline,
}));
info!( info!(
session_id = %session.plan.session_id, session_id = %session.plan.session_id,
@ -614,8 +597,8 @@ impl CoreEngine {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use shepherd_api::EntryKind;
use shepherd_config::{AvailabilityPolicy, Entry, LimitsPolicy}; use shepherd_config::{AvailabilityPolicy, Entry, LimitsPolicy};
use shepherd_api::EntryKind;
use shepherd_store::SqliteStore; use shepherd_store::SqliteStore;
use std::collections::HashMap; use std::collections::HashMap;
@ -753,34 +736,19 @@ mod tests {
// No warnings initially (first tick may emit AvailabilitySetChanged) // No warnings initially (first tick may emit AvailabilitySetChanged)
let events = engine.tick(now_mono, now); let events = engine.tick(now_mono, now);
// Filter to just warning events for this test // Filter to just warning events for this test
let warning_events: Vec<_> = events let warning_events: Vec<_> = events.iter().filter(|e| matches!(e, CoreEvent::Warning { .. })).collect();
.iter()
.filter(|e| matches!(e, CoreEvent::Warning { .. }))
.collect();
assert!(warning_events.is_empty()); assert!(warning_events.is_empty());
// At 70 seconds (10 seconds past warning threshold), warning should fire // At 70 seconds (10 seconds past warning threshold), warning should fire
let later = now_mono + Duration::from_secs(70); let later = now_mono + Duration::from_secs(70);
let events = engine.tick(later, now); let events = engine.tick(later, now);
let warning_events: Vec<_> = events let warning_events: Vec<_> = events.iter().filter(|e| matches!(e, CoreEvent::Warning { .. })).collect();
.iter()
.filter(|e| matches!(e, CoreEvent::Warning { .. }))
.collect();
assert_eq!(warning_events.len(), 1); assert_eq!(warning_events.len(), 1);
assert!(matches!( assert!(matches!(warning_events[0], CoreEvent::Warning { threshold_seconds: 60, .. }));
warning_events[0],
CoreEvent::Warning {
threshold_seconds: 60,
..
}
));
// Warning shouldn't fire twice // Warning shouldn't fire twice
let events = engine.tick(later, now); let events = engine.tick(later, now);
let warning_events: Vec<_> = events let warning_events: Vec<_> = events.iter().filter(|e| matches!(e, CoreEvent::Warning { .. })).collect();
.iter()
.filter(|e| matches!(e, CoreEvent::Warning { .. }))
.collect();
assert!(warning_events.is_empty()); assert!(warning_events.is_empty());
} }
@ -835,10 +803,7 @@ mod tests {
let later = now_mono + Duration::from_secs(61); let later = now_mono + Duration::from_secs(61);
let events = engine.tick(later, now); let events = engine.tick(later, now);
// Filter to just expiry events for this test // Filter to just expiry events for this test
let expiry_events: Vec<_> = events let expiry_events: Vec<_> = events.iter().filter(|e| matches!(e, CoreEvent::ExpireDue { .. })).collect();
.iter()
.filter(|e| matches!(e, CoreEvent::ExpireDue { .. }))
.collect();
assert_eq!(expiry_events.len(), 1); assert_eq!(expiry_events.len(), 1);
assert!(matches!(expiry_events[0], CoreEvent::ExpireDue { .. })); assert!(matches!(expiry_events[0], CoreEvent::ExpireDue { .. }));
} }

View file

@ -30,7 +30,9 @@ pub enum CoreEvent {
}, },
/// Session is expiring (termination initiated) /// Session is expiring (termination initiated)
ExpireDue { session_id: SessionId }, ExpireDue {
session_id: SessionId,
},
/// Session has ended /// Session has ended
SessionEnded { SessionEnded {
@ -41,8 +43,13 @@ pub enum CoreEvent {
}, },
/// Entry availability changed /// Entry availability changed
EntryAvailabilityChanged { entry_id: EntryId, enabled: bool }, EntryAvailabilityChanged {
entry_id: EntryId,
enabled: bool,
},
/// Policy was reloaded /// Policy was reloaded
PolicyReloaded { entry_count: usize }, PolicyReloaded {
entry_count: usize,
},
} }

View file

@ -29,7 +29,8 @@ impl SessionPlan {
.iter() .iter()
.filter(|w| Duration::from_secs(w.seconds_before) < max_duration) .filter(|w| Duration::from_secs(w.seconds_before) < max_duration)
.map(|w| { .map(|w| {
let trigger_after = max_duration - Duration::from_secs(w.seconds_before); let trigger_after =
max_duration - Duration::from_secs(w.seconds_before);
(w.seconds_before, trigger_after) (w.seconds_before, trigger_after)
}) })
.collect() .collect()
@ -66,7 +67,11 @@ pub struct ActiveSession {
impl ActiveSession { impl ActiveSession {
/// Create a new session from an approved plan /// Create a new session from an approved plan
pub fn new(plan: SessionPlan, now: DateTime<Local>, now_mono: MonotonicInstant) -> Self { pub fn new(
plan: SessionPlan,
now: DateTime<Local>,
now_mono: MonotonicInstant,
) -> Self {
let (deadline, deadline_mono) = match plan.max_duration { let (deadline, deadline_mono) = match plan.max_duration {
Some(max_dur) => { Some(max_dur) => {
let deadline = now + chrono::Duration::from_std(max_dur).unwrap(); let deadline = now + chrono::Duration::from_std(max_dur).unwrap();
@ -96,8 +101,7 @@ impl ActiveSession {
/// Get time remaining using monotonic time. None means unlimited. /// Get time remaining using monotonic time. None means unlimited.
pub fn time_remaining(&self, now_mono: MonotonicInstant) -> Option<Duration> { pub fn time_remaining(&self, now_mono: MonotonicInstant) -> Option<Duration> {
self.deadline_mono self.deadline_mono.map(|deadline| deadline.saturating_duration_until(now_mono))
.map(|deadline| deadline.saturating_duration_until(now_mono))
} }
/// Check if session is expired (never true for unlimited sessions) /// Check if session is expired (never true for unlimited sessions)
@ -216,10 +220,7 @@ mod tests {
assert_eq!(session.state, SessionState::Launching); assert_eq!(session.state, SessionState::Launching);
assert!(session.warnings_issued.is_empty()); assert!(session.warnings_issued.is_empty());
assert_eq!( assert_eq!(session.time_remaining(now_mono), Some(Duration::from_secs(300)));
session.time_remaining(now_mono),
Some(Duration::from_secs(300))
);
} }
#[test] #[test]

View file

@ -18,10 +18,7 @@ pub struct HostSessionHandle {
impl HostSessionHandle { impl HostSessionHandle {
pub fn new(session_id: SessionId, payload: HostHandlePayload) -> Self { pub fn new(session_id: SessionId, payload: HostHandlePayload) -> Self {
Self { Self { session_id, payload }
session_id,
payload,
}
} }
pub fn payload(&self) -> &HostHandlePayload { pub fn payload(&self) -> &HostHandlePayload {
@ -34,16 +31,27 @@ impl HostSessionHandle {
#[serde(tag = "platform", rename_all = "snake_case")] #[serde(tag = "platform", rename_all = "snake_case")]
pub enum HostHandlePayload { pub enum HostHandlePayload {
/// Linux: process group ID /// Linux: process group ID
Linux { pid: u32, pgid: u32 }, Linux {
pid: u32,
pgid: u32,
},
/// Windows: job object handle (serialized as name/id) /// Windows: job object handle (serialized as name/id)
Windows { job_name: String, process_id: u32 }, Windows {
job_name: String,
process_id: u32,
},
/// macOS: bundle or process identifier /// macOS: bundle or process identifier
MacOs { pid: u32, bundle_id: Option<String> }, MacOs {
pid: u32,
bundle_id: Option<String>,
},
/// Mock for testing /// Mock for testing
Mock { id: u64 }, Mock {
id: u64,
},
} }
impl HostHandlePayload { impl HostHandlePayload {
@ -109,10 +117,7 @@ mod tests {
fn handle_serialization() { fn handle_serialization() {
let handle = HostSessionHandle::new( let handle = HostSessionHandle::new(
SessionId::new(), SessionId::new(),
HostHandlePayload::Linux { HostHandlePayload::Linux { pid: 1234, pgid: 1234 },
pid: 1234,
pgid: 1234,
},
); );
let json = serde_json::to_string(&handle).unwrap(); let json = serde_json::to_string(&handle).unwrap();

View file

@ -10,8 +10,8 @@ use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::{ use crate::{
ExitStatus, HostAdapter, HostCapabilities, HostError, HostEvent, HostHandlePayload, HostResult, ExitStatus, HostAdapter, HostCapabilities, HostError, HostEvent, HostHandlePayload,
HostSessionHandle, SpawnOptions, StopMode, HostResult, HostSessionHandle, SpawnOptions, StopMode,
}; };
/// Mock session state for testing /// Mock session state for testing
@ -79,9 +79,7 @@ impl MockHost {
if let Some(session) = sessions.values().find(|s| &s.session_id == session_id) { if let Some(session) = sessions.values().find(|s| &s.session_id == session_id) {
let handle = HostSessionHandle::new( let handle = HostSessionHandle::new(
session.session_id.clone(), session.session_id.clone(),
HostHandlePayload::Mock { HostHandlePayload::Mock { id: session.mock_id },
id: session.mock_id,
},
); );
let _ = self.event_tx.send(HostEvent::Exited { handle, status }); let _ = self.event_tx.send(HostEvent::Exited { handle, status });
} }
@ -124,13 +122,12 @@ impl HostAdapter for MockHost {
exit_delay: *self.auto_exit_delay.lock().unwrap(), exit_delay: *self.auto_exit_delay.lock().unwrap(),
}; };
self.sessions self.sessions.lock().unwrap().insert(mock_id, session.clone());
.lock()
.unwrap()
.insert(mock_id, session.clone());
let handle = let handle = HostSessionHandle::new(
HostSessionHandle::new(session_id.clone(), HostHandlePayload::Mock { id: mock_id }); session_id.clone(),
HostHandlePayload::Mock { id: mock_id },
);
// If auto-exit is configured, spawn a task to send exit event // If auto-exit is configured, spawn a task to send exit event
if let Some(delay) = session.exit_delay { if let Some(delay) = session.exit_delay {

View file

@ -82,7 +82,9 @@ pub enum HostEvent {
}, },
/// Window is ready (for UI notification) /// Window is ready (for UI notification)
WindowReady { handle: HostSessionHandle }, WindowReady {
handle: HostSessionHandle,
},
/// Spawn failed after handle was created /// Spawn failed after handle was created
SpawnFailed { SpawnFailed {
@ -139,8 +141,6 @@ mod tests {
#[test] #[test]
fn stop_mode_default() { fn stop_mode_default() {
let mode = StopMode::default(); let mode = StopMode::default();
assert!( assert!(matches!(mode, StopMode::Graceful { timeout } if timeout == Duration::from_secs(5)));
matches!(mode, StopMode::Graceful { timeout } if timeout == Duration::from_secs(5))
);
} }
} }

View file

@ -3,8 +3,8 @@
use async_trait::async_trait; use async_trait::async_trait;
use shepherd_api::EntryKind; use shepherd_api::EntryKind;
use shepherd_host_api::{ use shepherd_host_api::{
ExitStatus, HostAdapter, HostCapabilities, HostError, HostEvent, HostHandlePayload, HostResult, ExitStatus, HostAdapter, HostCapabilities, HostError, HostEvent, HostHandlePayload,
HostSessionHandle, SpawnOptions, StopMode, HostResult, HostSessionHandle, SpawnOptions, StopMode,
}; };
use shepherd_util::SessionId; use shepherd_util::SessionId;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@ -14,8 +14,8 @@ use tokio::sync::mpsc;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::process::{ use crate::process::{
ManagedProcess, find_steam_game_pids, init, kill_by_command, kill_flatpak_cgroup, find_steam_game_pids, init, kill_by_command, kill_flatpak_cgroup, kill_snap_cgroup,
kill_snap_cgroup, kill_steam_game_processes, kill_steam_game_processes, ManagedProcess,
}; };
/// Expand `~` at the beginning of a path to the user's home directory /// Expand `~` at the beginning of a path to the user's home directory
@ -93,8 +93,14 @@ impl LinuxHost {
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;
let mut exited = Vec::new(); let mut exited = Vec::new();
let steam_pids: HashSet<u32> = let steam_pids: HashSet<u32> = {
{ steam_sessions.lock().unwrap().keys().cloned().collect() }; steam_sessions
.lock()
.unwrap()
.keys()
.cloned()
.collect()
};
{ {
let mut procs = processes.lock().unwrap(); let mut procs = processes.lock().unwrap();
@ -134,8 +140,14 @@ impl LinuxHost {
} }
// Track Steam sessions by Steam App ID instead of process exit // Track Steam sessions by Steam App ID instead of process exit
let steam_snapshot: Vec<SteamSession> = let steam_snapshot: Vec<SteamSession> = {
{ steam_sessions.lock().unwrap().values().cloned().collect() }; steam_sessions
.lock()
.unwrap()
.values()
.cloned()
.collect()
};
let mut ended = Vec::new(); let mut ended = Vec::new();
@ -194,35 +206,24 @@ impl HostAdapter for LinuxHost {
) -> HostResult<HostSessionHandle> { ) -> HostResult<HostSessionHandle> {
// Extract argv, env, cwd, snap_name, flatpak_app_id, and steam_app_id based on entry kind // Extract argv, env, cwd, snap_name, flatpak_app_id, and steam_app_id based on entry kind
let (argv, env, cwd, snap_name, flatpak_app_id, steam_app_id) = match entry_kind { let (argv, env, cwd, snap_name, flatpak_app_id, steam_app_id) = match entry_kind {
EntryKind::Process { EntryKind::Process { command, args, env, cwd } => {
command,
args,
env,
cwd,
} => {
let mut argv = vec![expand_tilde(command)]; let mut argv = vec![expand_tilde(command)];
argv.extend(expand_args(args)); argv.extend(expand_args(args));
let expanded_cwd = cwd let expanded_cwd = cwd.as_ref().map(|c| {
.as_ref() std::path::PathBuf::from(expand_tilde(&c.to_string_lossy()))
.map(|c| std::path::PathBuf::from(expand_tilde(&c.to_string_lossy()))); });
(argv, env.clone(), expanded_cwd, None, None, None) (argv, env.clone(), expanded_cwd, None, None, None)
} }
EntryKind::Snap { EntryKind::Snap { snap_name, command, args, env } => {
snap_name,
command,
args,
env,
} => {
// For snap apps, we need to use 'snap run <snap_name>' to launch them. // For snap apps, we need to use 'snap run <snap_name>' to launch them.
// The command (if specified) is passed as an argument after the snap name, // The command (if specified) is passed as an argument after the snap name,
// followed by any additional args. // followed by any additional args.
let mut argv = vec!["snap".to_string(), "run".to_string(), snap_name.clone()]; let mut argv = vec!["snap".to_string(), "run".to_string(), snap_name.clone()];
// If a custom command is specified (different from snap_name), add it // If a custom command is specified (different from snap_name), add it
if let Some(cmd) = command if let Some(cmd) = command
&& cmd != snap_name && cmd != snap_name {
{ argv.push(cmd.clone());
argv.push(cmd.clone()); }
}
argv.extend(expand_args(args)); argv.extend(expand_args(args));
(argv, env.clone(), None, Some(snap_name.clone()), None, None) (argv, env.clone(), None, Some(snap_name.clone()), None, None)
} }
@ -256,19 +257,13 @@ impl HostAdapter for LinuxHost {
} }
(argv, HashMap::new(), None, None, None, None) (argv, HashMap::new(), None, None, None, None)
} }
EntryKind::Media { EntryKind::Media { library_id, args: _ } => {
library_id,
args: _,
} => {
// For media, we'd typically launch a media player // For media, we'd typically launch a media player
// This is a placeholder - real implementation would integrate with a player // This is a placeholder - real implementation would integrate with a player
let argv = vec!["xdg-open".to_string(), expand_tilde(library_id)]; let argv = vec!["xdg-open".to_string(), expand_tilde(library_id)];
(argv, HashMap::new(), None, None, None, None) (argv, HashMap::new(), None, None, None, None)
} }
EntryKind::Custom { EntryKind::Custom { type_name: _, payload: _ } => {
type_name: _,
payload: _,
} => {
return Err(HostError::UnsupportedKind); return Err(HostError::UnsupportedKind);
} }
}; };
@ -306,13 +301,13 @@ impl HostAdapter for LinuxHost {
flatpak_app_id: flatpak_app_id.clone(), flatpak_app_id: flatpak_app_id.clone(),
steam_app_id, steam_app_id,
}; };
self.session_info self.session_info.lock().unwrap().insert(session_id.clone(), session_info_entry);
.lock()
.unwrap()
.insert(session_id.clone(), session_info_entry);
info!(session_id = %session_id, command = %command_name, snap = ?snap_name, flatpak = ?flatpak_app_id, "Tracking session info"); info!(session_id = %session_id, command = %command_name, snap = ?snap_name, flatpak = ?flatpak_app_id, "Tracking session info");
let handle = HostSessionHandle::new(session_id, HostHandlePayload::Linux { pid, pgid }); let handle = HostSessionHandle::new(
session_id,
HostHandlePayload::Linux { pid, pgid },
);
self.processes.lock().unwrap().insert(pid, proc); self.processes.lock().unwrap().insert(pid, proc);
@ -359,15 +354,11 @@ impl HostAdapter for LinuxHost {
kill_snap_cgroup(snap, nix::sys::signal::Signal::SIGTERM); kill_snap_cgroup(snap, nix::sys::signal::Signal::SIGTERM);
info!(snap = %snap, "Sent SIGTERM via snap cgroup"); info!(snap = %snap, "Sent SIGTERM via snap cgroup");
} else if let Some(app_id) = info.steam_app_id { } else if let Some(app_id) = info.steam_app_id {
let _ = let _ = kill_steam_game_processes(app_id, nix::sys::signal::Signal::SIGTERM);
kill_steam_game_processes(app_id, nix::sys::signal::Signal::SIGTERM);
if let Ok(mut map) = self.steam_sessions.lock() { if let Ok(mut map) = self.steam_sessions.lock() {
map.entry(pid).and_modify(|entry| entry.seen_game = true); map.entry(pid).and_modify(|entry| entry.seen_game = true);
} }
info!( info!(steam_app_id = app_id, "Sent SIGTERM to Steam game processes");
steam_app_id = app_id,
"Sent SIGTERM to Steam game processes"
);
} else if let Some(ref app_id) = info.flatpak_app_id { } else if let Some(ref app_id) = info.flatpak_app_id {
kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGTERM); kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGTERM);
info!(flatpak = %app_id, "Sent SIGTERM via flatpak cgroup"); info!(flatpak = %app_id, "Sent SIGTERM via flatpak cgroup");
@ -379,10 +370,7 @@ impl HostAdapter for LinuxHost {
} }
// Also send SIGTERM via process handle (skip for Steam sessions) // Also send SIGTERM via process handle (skip for Steam sessions)
let is_steam = session_info let is_steam = session_info.as_ref().and_then(|info| info.steam_app_id).is_some();
.as_ref()
.and_then(|info| info.steam_app_id)
.is_some();
if !is_steam { if !is_steam {
let procs = self.processes.lock().unwrap(); let procs = self.processes.lock().unwrap();
if let Some(p) = procs.get(&pid) { if let Some(p) = procs.get(&pid) {
@ -400,22 +388,13 @@ impl HostAdapter for LinuxHost {
kill_snap_cgroup(snap, nix::sys::signal::Signal::SIGKILL); kill_snap_cgroup(snap, nix::sys::signal::Signal::SIGKILL);
info!(snap = %snap, "Sent SIGKILL via snap cgroup (timeout)"); info!(snap = %snap, "Sent SIGKILL via snap cgroup (timeout)");
} else if let Some(app_id) = info.steam_app_id { } else if let Some(app_id) = info.steam_app_id {
let _ = kill_steam_game_processes( let _ = kill_steam_game_processes(app_id, nix::sys::signal::Signal::SIGKILL);
app_id, info!(steam_app_id = app_id, "Sent SIGKILL to Steam game processes (timeout)");
nix::sys::signal::Signal::SIGKILL,
);
info!(
steam_app_id = app_id,
"Sent SIGKILL to Steam game processes (timeout)"
);
} else if let Some(ref app_id) = info.flatpak_app_id { } else if let Some(ref app_id) = info.flatpak_app_id {
kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGKILL); kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGKILL);
info!(flatpak = %app_id, "Sent SIGKILL via flatpak cgroup (timeout)"); info!(flatpak = %app_id, "Sent SIGKILL via flatpak cgroup (timeout)");
} else { } else {
kill_by_command( kill_by_command(&info.command_name, nix::sys::signal::Signal::SIGKILL);
&info.command_name,
nix::sys::signal::Signal::SIGKILL,
);
info!(command = %info.command_name, "Sent SIGKILL via command name (timeout)"); info!(command = %info.command_name, "Sent SIGKILL via command name (timeout)");
} }
} }
@ -454,15 +433,11 @@ impl HostAdapter for LinuxHost {
kill_snap_cgroup(snap, nix::sys::signal::Signal::SIGKILL); kill_snap_cgroup(snap, nix::sys::signal::Signal::SIGKILL);
info!(snap = %snap, "Sent SIGKILL via snap cgroup"); info!(snap = %snap, "Sent SIGKILL via snap cgroup");
} else if let Some(app_id) = info.steam_app_id { } else if let Some(app_id) = info.steam_app_id {
let _ = let _ = kill_steam_game_processes(app_id, nix::sys::signal::Signal::SIGKILL);
kill_steam_game_processes(app_id, nix::sys::signal::Signal::SIGKILL);
if let Ok(mut map) = self.steam_sessions.lock() { if let Ok(mut map) = self.steam_sessions.lock() {
map.entry(pid).and_modify(|entry| entry.seen_game = true); map.entry(pid).and_modify(|entry| entry.seen_game = true);
} }
info!( info!(steam_app_id = app_id, "Sent SIGKILL to Steam game processes");
steam_app_id = app_id,
"Sent SIGKILL to Steam game processes"
);
} else if let Some(ref app_id) = info.flatpak_app_id { } else if let Some(ref app_id) = info.flatpak_app_id {
kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGKILL); kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGKILL);
info!(flatpak = %app_id, "Sent SIGKILL via flatpak cgroup"); info!(flatpak = %app_id, "Sent SIGKILL via flatpak cgroup");
@ -473,10 +448,7 @@ impl HostAdapter for LinuxHost {
} }
// Also force kill via process handle (skip for Steam sessions) // Also force kill via process handle (skip for Steam sessions)
let is_steam = session_info let is_steam = session_info.as_ref().and_then(|info| info.steam_app_id).is_some();
.as_ref()
.and_then(|info| info.steam_app_id)
.is_some();
if !is_steam { if !is_steam {
let procs = self.processes.lock().unwrap(); let procs = self.processes.lock().unwrap();
if let Some(p) = procs.get(&pid) { if let Some(p) = procs.get(&pid) {

View file

@ -83,10 +83,7 @@ pub fn kill_snap_cgroup(snap_name: &str, _signal: Signal) -> bool {
} }
if stopped_any { if stopped_any {
info!( info!(snap = snap_name, "Killed snap scope(s) via systemctl SIGKILL");
snap = snap_name,
"Killed snap scope(s) via systemctl SIGKILL"
);
} else { } else {
debug!(snap = snap_name, "No snap scope found to kill"); debug!(snap = snap_name, "No snap scope found to kill");
} }
@ -150,10 +147,7 @@ pub fn kill_flatpak_cgroup(app_id: &str, _signal: Signal) -> bool {
} }
if stopped_any { if stopped_any {
info!( info!(app_id = app_id, "Killed flatpak scope(s) via systemctl SIGKILL");
app_id = app_id,
"Killed flatpak scope(s) via systemctl SIGKILL"
);
} else { } else {
debug!(app_id = app_id, "No flatpak scope found to kill"); debug!(app_id = app_id, "No flatpak scope found to kill");
} }
@ -232,18 +226,11 @@ pub fn kill_by_command(command_name: &str, signal: Signal) -> bool {
Ok(output) => { Ok(output) => {
// pkill returns 0 if processes were found and signaled // pkill returns 0 if processes were found and signaled
if output.status.success() { if output.status.success() {
info!( info!(command = command_name, signal = signal_name, "Killed processes by command name");
command = command_name,
signal = signal_name,
"Killed processes by command name"
);
true true
} else { } else {
// No processes found is not an error // No processes found is not an error
debug!( debug!(command = command_name, "No processes found matching command name");
command = command_name,
"No processes found matching command name"
);
false false
} }
} }
@ -289,8 +276,7 @@ impl ManagedProcess {
// Build command: script -q -c "original command" logfile // Build command: script -q -c "original command" logfile
// -q: quiet mode (no start/done messages) // -q: quiet mode (no start/done messages)
// -c: command to run // -c: command to run
let original_cmd = argv let original_cmd = argv.iter()
.iter()
.map(|arg| shell_escape::escape(std::borrow::Cow::Borrowed(arg))) .map(|arg| shell_escape::escape(std::borrow::Cow::Borrowed(arg)))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" "); .join(" ");
@ -479,27 +465,23 @@ impl ManagedProcess {
// SAFETY: This is safe in the pre-exec context // SAFETY: This is safe in the pre-exec context
unsafe { unsafe {
cmd.pre_exec(|| { cmd.pre_exec(|| {
nix::unistd::setsid().map_err(|e| std::io::Error::other(e.to_string()))?; nix::unistd::setsid().map_err(|e| {
std::io::Error::other(e.to_string())
})?;
Ok(()) Ok(())
}); });
} }
let child = cmd let child = cmd.spawn().map_err(|e| {
.spawn() HostError::SpawnFailed(format!("Failed to spawn {}: {}", program, e))
.map_err(|e| HostError::SpawnFailed(format!("Failed to spawn {}: {}", program, e)))?; })?;
let pid = child.id(); let pid = child.id();
let pgid = pid; // After setsid, pid == pgid let pgid = pid; // After setsid, pid == pgid
info!(pid = pid, pgid = pgid, program = %program, snap = ?snap_name, "Process spawned"); info!(pid = pid, pgid = pgid, program = %program, snap = ?snap_name, "Process spawned");
Ok(Self { Ok(Self { child, pid, pgid, command_name, snap_name })
child,
pid,
pgid,
command_name,
snap_name,
})
} }
/// Get all descendant PIDs of this process using /proc /// Get all descendant PIDs of this process using /proc
@ -526,11 +508,10 @@ impl ManagedProcess {
let fields: Vec<&str> = after_comm.split_whitespace().collect(); let fields: Vec<&str> = after_comm.split_whitespace().collect();
if fields.len() >= 2 if fields.len() >= 2
&& let Ok(ppid) = fields[1].parse::<i32>() && let Ok(ppid) = fields[1].parse::<i32>()
&& ppid == parent_pid && ppid == parent_pid {
{ descendants.push(pid);
descendants.push(pid); to_check.push(pid);
to_check.push(pid); }
}
} }
} }
} }

View file

@ -148,10 +148,9 @@ impl LinuxVolumeController {
// Output: "Volume: front-left: 65536 / 100% / -0.00 dB, front-right: ..." // Output: "Volume: front-left: 65536 / 100% / -0.00 dB, front-right: ..."
if let Some(percent_str) = stdout.split('/').nth(1) if let Some(percent_str) = stdout.split('/').nth(1)
&& let Ok(percent) = percent_str.trim().trim_end_matches('%').parse::<u8>() && let Ok(percent) = percent_str.trim().trim_end_matches('%').parse::<u8>() {
{ status.percent = percent;
status.percent = percent; }
}
} }
// Check mute status // Check mute status
@ -186,10 +185,9 @@ impl LinuxVolumeController {
// Extract percentage: [100%] // Extract percentage: [100%]
if let Some(start) = line.find('[') if let Some(start) = line.find('[')
&& let Some(end) = line[start..].find('%') && let Some(end) = line[start..].find('%')
&& let Ok(percent) = line[start + 1..start + end].parse::<u8>() && let Ok(percent) = line[start + 1..start + end].parse::<u8>() {
{ status.percent = percent;
status.percent = percent; }
}
// Check mute status: [on] or [off] // Check mute status: [on] or [off]
status.muted = line.contains("[off]"); status.muted = line.contains("[off]");
break; break;
@ -212,11 +210,7 @@ impl LinuxVolumeController {
/// Set volume via PulseAudio /// Set volume via PulseAudio
fn set_volume_pulseaudio(percent: u8) -> VolumeResult<()> { fn set_volume_pulseaudio(percent: u8) -> VolumeResult<()> {
Command::new("pactl") Command::new("pactl")
.args([ .args(["set-sink-volume", "@DEFAULT_SINK@", &format!("{}%", percent)])
"set-sink-volume",
"@DEFAULT_SINK@",
&format!("{}%", percent),
])
.status() .status()
.map_err(|e| VolumeError::Backend(e.to_string()))?; .map_err(|e| VolumeError::Backend(e.to_string()))?;
Ok(()) Ok(())
@ -329,10 +323,7 @@ impl VolumeController for LinuxVolumeController {
async fn volume_up(&self, step: u8) -> VolumeResult<()> { async fn volume_up(&self, step: u8) -> VolumeResult<()> {
let current = self.get_status().await?; let current = self.get_status().await?;
let new_volume = current let new_volume = current.percent.saturating_add(step).min(self.capabilities.max_volume);
.percent
.saturating_add(step)
.min(self.capabilities.max_volume);
self.set_volume(new_volume).await self.set_volume(new_volume).await
} }

View file

@ -414,9 +414,9 @@ fn build_hud_content(state: SharedState) -> gtk4::Box {
let remaining = time_remaining_at_warning.saturating_sub(elapsed); let remaining = time_remaining_at_warning.saturating_sub(elapsed);
time_display_clone.set_remaining(Some(remaining)); time_display_clone.set_remaining(Some(remaining));
// Use configuration-defined message if present, otherwise show time-based message // Use configuration-defined message if present, otherwise show time-based message
let warning_text = message let warning_text = message.clone().unwrap_or_else(|| {
.clone() format!("Only {} seconds remaining!", remaining)
.unwrap_or_else(|| format!("Only {} seconds remaining!", remaining)); });
warning_label_clone.set_text(&warning_text); warning_label_clone.set_text(&warning_text);
// Apply severity-based CSS classes // Apply severity-based CSS classes

View file

@ -35,18 +35,16 @@ impl BatteryStatus {
// Check for battery // Check for battery
if name_str.starts_with("BAT") if name_str.starts_with("BAT")
&& let Some((percent, charging)) = read_battery_info(&path) && let Some((percent, charging)) = read_battery_info(&path) {
{ status.percent = Some(percent);
status.percent = Some(percent); status.charging = charging;
status.charging = charging; }
}
// Check for AC adapter // Check for AC adapter
if (name_str.starts_with("AC") || name_str.contains("ADP")) if (name_str.starts_with("AC") || name_str.contains("ADP"))
&& let Some(online) = read_ac_status(&path) && let Some(online) = read_ac_status(&path) {
{ status.ac_connected = online;
status.ac_connected = online; }
}
} }
} }

View file

@ -43,7 +43,8 @@ fn main() -> Result<()> {
// Initialize logging // Initialize logging
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level)), EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(&args.log_level)),
) )
.init(); .init();

View file

@ -218,18 +218,17 @@ impl SharedState {
entry_name, entry_name,
.. ..
} = state } = state
&& sid == session_id && sid == session_id {
{ *state = SessionState::Warning {
*state = SessionState::Warning { session_id: session_id.clone(),
session_id: session_id.clone(), entry_id: entry_id.clone(),
entry_id: entry_id.clone(), entry_name: entry_name.clone(),
entry_name: entry_name.clone(), warning_issued_at: std::time::Instant::now(),
warning_issued_at: std::time::Instant::now(), time_remaining_at_warning: time_remaining.as_secs(),
time_remaining_at_warning: time_remaining.as_secs(), message: message.clone(),
message: message.clone(), severity: *severity,
severity: *severity, };
}; }
}
}); });
} }

View file

@ -60,7 +60,9 @@ pub fn toggle_mute() -> anyhow::Result<()> {
shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeDenied { reason }) => { shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeDenied { reason }) => {
Err(anyhow::anyhow!("Volume denied: {}", reason)) Err(anyhow::anyhow!("Volume denied: {}", reason))
} }
shepherd_api::ResponseResult::Err(e) => Err(anyhow::anyhow!("Error: {}", e.message)), shepherd_api::ResponseResult::Err(e) => {
Err(anyhow::anyhow!("Error: {}", e.message))
}
_ => Err(anyhow::anyhow!("Unexpected response")), _ => Err(anyhow::anyhow!("Unexpected response")),
} }
}) })
@ -81,7 +83,9 @@ pub fn set_volume(percent: u8) -> anyhow::Result<()> {
shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeDenied { reason }) => { shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeDenied { reason }) => {
Err(anyhow::anyhow!("Volume denied: {}", reason)) Err(anyhow::anyhow!("Volume denied: {}", reason))
} }
shepherd_api::ResponseResult::Err(e) => Err(anyhow::anyhow!("Error: {}", e.message)), shepherd_api::ResponseResult::Err(e) => {
Err(anyhow::anyhow!("Error: {}", e.message))
}
_ => Err(anyhow::anyhow!("Unexpected response")), _ => Err(anyhow::anyhow!("Unexpected response")),
} }
}) })

View file

@ -8,7 +8,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream}; use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; use tokio::sync::{broadcast, mpsc, Mutex, RwLock};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use crate::{IpcError, IpcResult}; use crate::{IpcError, IpcResult};
@ -75,9 +75,10 @@ impl IpcServer {
let listener = UnixListener::bind(&self.socket_path)?; let listener = UnixListener::bind(&self.socket_path)?;
// Set socket permissions (readable/writable by owner and group) // Set socket permissions (readable/writable by owner and group)
if let Err(err) = if let Err(err) = std::fs::set_permissions(
std::fs::set_permissions(&self.socket_path, std::fs::Permissions::from_mode(0o660)) &self.socket_path,
{ std::fs::Permissions::from_mode(0o660),
) {
if err.kind() == std::io::ErrorKind::PermissionDenied { if err.kind() == std::io::ErrorKind::PermissionDenied {
warn!( warn!(
path = %self.socket_path.display(), path = %self.socket_path.display(),
@ -189,8 +190,7 @@ impl IpcServer {
match serde_json::from_str::<Request>(line) { match serde_json::from_str::<Request>(line) {
Ok(request) => { Ok(request) => {
// Check for subscribe command // Check for subscribe command
if matches!(request.command, shepherd_api::Command::SubscribeEvents) if matches!(request.command, shepherd_api::Command::SubscribeEvents) {
{
let mut clients = clients.write().await; let mut clients = clients.write().await;
if let Some(handle) = clients.get_mut(&client_id_clone) { if let Some(handle) = clients.get_mut(&client_id_clone) {
handle.subscribed = true; handle.subscribed = true;
@ -342,14 +342,13 @@ mod tests {
let mut server = IpcServer::new(&socket_path); let mut server = IpcServer::new(&socket_path);
if let Err(err) = server.start().await { if let Err(err) = server.start().await {
if let IpcError::Io(ref io_err) = err if let IpcError::Io(ref io_err) = err
&& io_err.kind() == std::io::ErrorKind::PermissionDenied && io_err.kind() == std::io::ErrorKind::PermissionDenied {
{ eprintln!(
eprintln!( "Skipping IPC server start test due to permission error: {}",
"Skipping IPC server start test due to permission error: {}", io_err
io_err );
); return;
return; }
}
panic!("IPC server start failed: {err}"); panic!("IPC server start failed: {err}");
} }

View file

@ -24,7 +24,6 @@ tracing-subscriber = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
dirs = "5.0" dirs = "5.0"
gilrs = "0.11"
[features] [features]
default = [] default = []

View file

@ -1,13 +1,13 @@
//! Main GTK4 application for the launcher //! Main GTK4 application for the launcher
use gtk4::glib; use gtk4::glib;
use gtk4::gdk;
use gtk4::prelude::*; use gtk4::prelude::*;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info};
use crate::client::{CommandClient, ServiceClient}; use crate::client::{CommandClient, ServiceClient};
use crate::grid::LauncherGrid; use crate::grid::LauncherGrid;
@ -46,7 +46,8 @@ window {
.launcher-tile:focus-visible { .launcher-tile:focus-visible {
background: #1f3460; background: #1f3460;
background-color: #1f3460; background-color: #1f3460;
border-color: #ffd166; border-color: #f8c24e;
outline: none;
} }
.launcher-tile:active { .launcher-tile:active {
@ -164,6 +165,61 @@ impl LauncherApp {
window.set_child(Some(&stack)); window.set_child(Some(&stack));
let grid_for_keys = grid.downgrade();
let window_for_keys = window.downgrade();
let key_controller = gtk4::EventControllerKey::new();
key_controller.connect_key_pressed(move |_, key, _, modifiers| {
let Some(window) = window_for_keys.upgrade() else {
return glib::Propagation::Proceed;
};
let Some(grid) = grid_for_keys.upgrade() else {
return glib::Propagation::Proceed;
};
let is_alt = modifiers.contains(gdk::ModifierType::ALT_MASK);
let is_ctrl = modifiers.contains(gdk::ModifierType::CONTROL_MASK);
if (key == gdk::Key::w || key == gdk::Key::W) && is_ctrl {
window.close();
return glib::Propagation::Stop;
}
let handled = match key {
gdk::Key::Up | gdk::Key::KP_Up | gdk::Key::w | gdk::Key::W => {
grid.move_focus(gtk4::DirectionType::Up);
true
}
gdk::Key::Down | gdk::Key::KP_Down | gdk::Key::s | gdk::Key::S => {
grid.move_focus(gtk4::DirectionType::Down);
true
}
gdk::Key::Left | gdk::Key::KP_Left | gdk::Key::a | gdk::Key::A => {
grid.move_focus(gtk4::DirectionType::Left);
true
}
gdk::Key::Right | gdk::Key::KP_Right | gdk::Key::d | gdk::Key::D => {
grid.move_focus(gtk4::DirectionType::Right);
true
}
gdk::Key::Home | gdk::Key::KP_Home => {
window.close();
true
}
gdk::Key::F4 if is_alt => {
window.close();
true
}
_ => false,
};
if handled {
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
});
window.add_controller(key_controller);
// Create shared state // Create shared state
let state = SharedState::new(); let state = SharedState::new();
let state_receiver = state.subscribe(); let state_receiver = state.subscribe();
@ -176,14 +232,6 @@ impl LauncherApp {
// Create command client for sending commands // Create command client for sending commands
let command_client = Arc::new(CommandClient::new(&socket_path)); let command_client = Arc::new(CommandClient::new(&socket_path));
Self::setup_keyboard_input(&window, &grid);
Self::setup_gamepad_input(
&window,
&grid,
command_client.clone(),
runtime.clone(),
state.clone(),
);
// Connect grid launch callback // Connect grid launch callback
let cmd_client = command_client.clone(); let cmd_client = command_client.clone();
@ -312,8 +360,7 @@ impl LauncherApp {
let state_for_client = state.clone(); let state_for_client = state.clone();
let socket_for_client = socket_path.clone(); let socket_for_client = socket_path.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new() let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime for event loop");
.expect("Failed to create tokio runtime for event loop");
rt.block_on(async move { rt.block_on(async move {
let client = ServiceClient::new(socket_for_client, state_for_client, command_rx); let client = ServiceClient::new(socket_for_client, state_for_client, command_rx);
client.run().await; client.run().await;
@ -359,7 +406,6 @@ impl LauncherApp {
if let Some(grid) = grid { if let Some(grid) = grid {
grid.set_entries(entries); grid.set_entries(entries);
grid.set_tiles_sensitive(true); grid.set_tiles_sensitive(true);
grid.grab_focus();
} }
if let Some(ref win) = window { if let Some(ref win) = window {
win.set_visible(true); win.set_visible(true);
@ -399,199 +445,6 @@ impl LauncherApp {
window.present(); window.present();
} }
fn setup_keyboard_input(window: &gtk4::ApplicationWindow, grid: &LauncherGrid) {
let key_controller = gtk4::EventControllerKey::new();
key_controller.set_propagation_phase(gtk4::PropagationPhase::Capture);
let grid_weak = grid.downgrade();
key_controller.connect_key_pressed(move |_, key, _, _| {
let Some(grid) = grid_weak.upgrade() else {
return glib::Propagation::Proceed;
};
let handled = match key {
gtk4::gdk::Key::Up | gtk4::gdk::Key::w | gtk4::gdk::Key::W => {
grid.move_selection(0, -1);
true
}
gtk4::gdk::Key::Down | gtk4::gdk::Key::s | gtk4::gdk::Key::S => {
grid.move_selection(0, 1);
true
}
gtk4::gdk::Key::Left | gtk4::gdk::Key::a | gtk4::gdk::Key::A => {
grid.move_selection(-1, 0);
true
}
gtk4::gdk::Key::Right | gtk4::gdk::Key::d | gtk4::gdk::Key::D => {
grid.move_selection(1, 0);
true
}
gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter | gtk4::gdk::Key::space => {
grid.launch_selected();
true
}
_ => false,
};
if handled {
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
});
window.add_controller(key_controller);
}
fn setup_gamepad_input(
_window: &gtk4::ApplicationWindow,
grid: &LauncherGrid,
command_client: Arc<CommandClient>,
runtime: Arc<Runtime>,
state: SharedState,
) {
let mut gilrs = match gilrs::Gilrs::new() {
Ok(gilrs) => gilrs,
Err(e) => {
warn!(error = %e, "Gamepad input unavailable");
return;
}
};
let grid_weak = grid.downgrade();
let cmd_client = command_client.clone();
let rt = runtime.clone();
let state_clone = state.clone();
let mut axis_state = GamepadAxisState::default();
glib::timeout_add_local(Duration::from_millis(16), move || {
while let Some(event) = gilrs.next_event() {
let Some(grid) = grid_weak.upgrade() else {
return glib::ControlFlow::Break;
};
match event.event {
gilrs::EventType::ButtonPressed(button, _) => match button {
gilrs::Button::DPadUp => grid.move_selection(0, -1),
gilrs::Button::DPadDown => grid.move_selection(0, 1),
gilrs::Button::DPadLeft => grid.move_selection(-1, 0),
gilrs::Button::DPadRight => grid.move_selection(1, 0),
gilrs::Button::South | gilrs::Button::East | gilrs::Button::Start => {
grid.launch_selected();
}
gilrs::Button::Mode => {
Self::request_stop_current(
cmd_client.clone(),
rt.clone(),
state_clone.clone(),
);
}
_ => {}
},
gilrs::EventType::AxisChanged(axis, value, _) => {
Self::handle_gamepad_axis(&grid, axis, value, &mut axis_state);
}
_ => {}
}
}
glib::ControlFlow::Continue
});
}
fn request_stop_current(
command_client: Arc<CommandClient>,
runtime: Arc<Runtime>,
state: SharedState,
) {
runtime.spawn(async move {
match command_client.stop_current().await {
Ok(response) => match response.result {
shepherd_api::ResponseResult::Ok(shepherd_api::ResponsePayload::Stopped) => {
info!("StopCurrent acknowledged");
}
shepherd_api::ResponseResult::Err(err) => {
debug!(error = %err.message, "StopCurrent request denied");
}
_ => {
debug!("Unexpected StopCurrent response payload");
}
},
Err(e) => {
error!(error = %e, "StopCurrent request failed");
state.set(LauncherState::Error {
message: format!("Failed to stop current activity: {}", e),
});
}
}
});
}
fn handle_gamepad_axis(
grid: &LauncherGrid,
axis: gilrs::Axis,
value: f32,
axis_state: &mut GamepadAxisState,
) {
const THRESHOLD: f32 = 0.65;
match axis {
gilrs::Axis::LeftStickX | gilrs::Axis::DPadX => {
if value <= -THRESHOLD {
if !axis_state.left {
grid.move_selection(-1, 0);
}
axis_state.left = true;
axis_state.right = false;
} else if value >= THRESHOLD {
if !axis_state.right {
grid.move_selection(1, 0);
}
axis_state.right = true;
axis_state.left = false;
} else {
axis_state.left = false;
axis_state.right = false;
}
}
gilrs::Axis::LeftStickY => {
if value <= -THRESHOLD {
if !axis_state.down {
grid.move_selection(0, 1);
}
axis_state.down = true;
axis_state.up = false;
} else if value >= THRESHOLD {
if !axis_state.up {
grid.move_selection(0, -1);
}
axis_state.up = true;
axis_state.down = false;
} else {
axis_state.up = false;
axis_state.down = false;
}
}
gilrs::Axis::DPadY => {
if value <= -THRESHOLD {
if !axis_state.up {
grid.move_selection(0, -1);
}
axis_state.up = true;
axis_state.down = false;
} else if value >= THRESHOLD {
if !axis_state.down {
grid.move_selection(0, 1);
}
axis_state.down = true;
axis_state.up = false;
} else {
axis_state.up = false;
axis_state.down = false;
}
}
_ => {}
}
}
fn create_loading_view() -> gtk4::Box { fn create_loading_view() -> gtk4::Box {
let container = gtk4::Box::new(gtk4::Orientation::Vertical, 16); let container = gtk4::Box::new(gtk4::Orientation::Vertical, 16);
container.set_halign(gtk4::Align::Center); container.set_halign(gtk4::Align::Center);
@ -669,11 +522,3 @@ impl LauncherApp {
(container, retry_button) (container, retry_button)
} }
} }
#[derive(Default)]
struct GamepadAxisState {
left: bool,
right: bool,
up: bool,
down: bool,
}

View file

@ -162,17 +162,11 @@ impl ServiceClient {
} }
ResponsePayload::Entries(entries) => { ResponsePayload::Entries(entries) => {
// Only update if we're in idle state // Only update if we're in idle state
if matches!( if matches!(self.state.get(), LauncherState::Idle { .. } | LauncherState::Connecting) {
self.state.get(),
LauncherState::Idle { .. } | LauncherState::Connecting
) {
self.state.set(LauncherState::Idle { entries }); self.state.set(LauncherState::Idle { entries });
} }
} }
ResponsePayload::LaunchApproved { ResponsePayload::LaunchApproved { session_id, deadline } => {
session_id,
deadline,
} => {
let now = shepherd_util::now(); let now = shepherd_util::now();
// For unlimited sessions (deadline=None), time_remaining is None // For unlimited sessions (deadline=None), time_remaining is None
let time_remaining = deadline.and_then(|d| { let time_remaining = deadline.and_then(|d| {
@ -201,7 +195,9 @@ impl ServiceClient {
Ok(()) Ok(())
} }
ResponseResult::Err(e) => { ResponseResult::Err(e) => {
self.state.set(LauncherState::Error { message: e.message }); self.state.set(LauncherState::Error {
message: e.message,
});
Ok(()) Ok(())
} }
} }
@ -222,23 +218,17 @@ impl CommandClient {
pub async fn launch(&self, entry_id: &EntryId) -> Result<Response> { pub async fn launch(&self, entry_id: &EntryId) -> Result<Response> {
let mut client = IpcClient::connect(&self.socket_path).await?; let mut client = IpcClient::connect(&self.socket_path).await?;
client client.send(Command::Launch {
.send(Command::Launch { entry_id: entry_id.clone(),
entry_id: entry_id.clone(), }).await.map_err(Into::into)
})
.await
.map_err(Into::into)
} }
#[allow(dead_code)] #[allow(dead_code)]
pub async fn stop_current(&self) -> Result<Response> { pub async fn stop_current(&self) -> Result<Response> {
let mut client = IpcClient::connect(&self.socket_path).await?; let mut client = IpcClient::connect(&self.socket_path).await?;
client client.send(Command::StopCurrent {
.send(Command::StopCurrent { mode: shepherd_api::StopMode::Graceful,
mode: shepherd_api::StopMode::Graceful, }).await.map_err(Into::into)
})
.await
.map_err(Into::into)
} }
pub async fn get_state(&self) -> Result<Response> { pub async fn get_state(&self) -> Result<Response> {
@ -249,10 +239,7 @@ impl CommandClient {
#[allow(dead_code)] #[allow(dead_code)]
pub async fn list_entries(&self) -> Result<Response> { pub async fn list_entries(&self) -> Result<Response> {
let mut client = IpcClient::connect(&self.socket_path).await?; let mut client = IpcClient::connect(&self.socket_path).await?;
client client.send(Command::ListEntries { at_time: None }).await.map_err(Into::into)
.send(Command::ListEntries { at_time: None })
.await
.map_err(Into::into)
} }
} }

View file

@ -51,8 +51,7 @@ mod imp {
// Configure flow box // Configure flow box
self.flow_box.set_homogeneous(true); self.flow_box.set_homogeneous(true);
self.flow_box self.flow_box.set_selection_mode(gtk4::SelectionMode::None);
.set_selection_mode(gtk4::SelectionMode::Single);
self.flow_box.set_max_children_per_line(6); self.flow_box.set_max_children_per_line(6);
self.flow_box.set_min_children_per_line(2); self.flow_box.set_min_children_per_line(2);
self.flow_box.set_row_spacing(24); self.flow_box.set_row_spacing(24);
@ -61,7 +60,7 @@ mod imp {
self.flow_box.set_valign(gtk4::Align::Center); self.flow_box.set_valign(gtk4::Align::Center);
self.flow_box.set_hexpand(true); self.flow_box.set_hexpand(true);
self.flow_box.set_vexpand(true); self.flow_box.set_vexpand(true);
self.flow_box.set_focusable(true); self.flow_box.set_can_focus(true);
self.flow_box.add_css_class("launcher-grid"); self.flow_box.add_css_class("launcher-grid");
// Wrap in a scrolled window // Wrap in a scrolled window
@ -119,17 +118,16 @@ impl LauncherGrid {
let on_launch = imp.on_launch.clone(); let on_launch = imp.on_launch.clone();
tile.connect_clicked(move |tile| { tile.connect_clicked(move |tile| {
if let Some(entry_id) = tile.entry_id() if let Some(entry_id) = tile.entry_id()
&& let Some(callback) = on_launch.borrow().as_ref() && let Some(callback) = on_launch.borrow().as_ref() {
{ callback(entry_id);
callback(entry_id); }
}
}); });
imp.flow_box.insert(&tile, -1); imp.flow_box.insert(&tile, -1);
imp.tiles.borrow_mut().push(tile); imp.tiles.borrow_mut().push(tile);
} }
self.select_first(); self.focus_first_tile();
} }
/// Enable or disable all tiles /// Enable or disable all tiles
@ -139,111 +137,28 @@ impl LauncherGrid {
} }
} }
pub fn select_first(&self) { pub fn move_focus(&self, direction: gtk4::DirectionType) {
let imp = self.imp(); self.ensure_focus();
if let Some(child) = imp.flow_box.child_at_index(0) { self.imp().flow_box.child_focus(direction);
imp.flow_box.select_child(&child);
child.grab_focus();
}
} }
pub fn move_selection(&self, dx: i32, dy: i32) { fn ensure_focus(&self) {
let imp = self.imp(); if self
if imp.tiles.borrow().is_empty() { .imp()
.tiles
.borrow()
.iter()
.any(|tile| tile.has_focus())
{
return; return;
} }
let current_child = imp self.focus_first_tile();
.flow_box
.selected_children()
.first()
.cloned()
.or_else(|| imp.flow_box.child_at_index(0));
let Some(current_child) = current_child else {
return;
};
let current_alloc = current_child.allocation();
let current_x = current_alloc.x();
let current_y = current_alloc.y();
let mut best: Option<(gtk4::FlowBoxChild, i32, i32)> = None;
let tile_count = imp.tiles.borrow().len() as i32;
for idx in 0..tile_count {
let Some(candidate) = imp.flow_box.child_at_index(idx) else {
continue;
};
if candidate == current_child {
continue;
}
let alloc = candidate.allocation();
let x = alloc.x();
let y = alloc.y();
let is_direction_match = match (dx, dy) {
(-1, 0) => y == current_y && x < current_x,
(1, 0) => y == current_y && x > current_x,
(0, -1) => y < current_y,
(0, 1) => y > current_y,
_ => false,
};
if !is_direction_match {
continue;
}
let primary_dist = match (dx, dy) {
(-1, 0) | (1, 0) => (x - current_x).abs(),
(0, -1) | (0, 1) => (y - current_y).abs(),
_ => i32::MAX,
};
let secondary_dist = match (dx, dy) {
(-1, 0) | (1, 0) => (y - current_y).abs(),
(0, -1) | (0, 1) => (x - current_x).abs(),
_ => i32::MAX,
};
let replace = match best {
None => true,
Some((_, best_primary, best_secondary)) => {
primary_dist < best_primary
|| (primary_dist == best_primary && secondary_dist < best_secondary)
}
};
if replace {
best = Some((candidate, primary_dist, secondary_dist));
}
}
if let Some((child, _, _)) = best {
imp.flow_box.select_child(&child);
child.grab_focus();
}
} }
pub fn launch_selected(&self) { fn focus_first_tile(&self) {
let imp = self.imp(); if let Some(tile) = self.imp().tiles.borrow().first() {
let maybe_child = imp.flow_box.selected_children().first().cloned(); tile.grab_focus();
let Some(child) = maybe_child else {
return;
};
let index = child.index();
if index < 0 {
return;
}
let tile = imp.tiles.borrow().get(index as usize).cloned();
if let Some(tile) = tile {
if !tile.is_sensitive() {
return;
}
if let Some(entry_id) = tile.entry_id()
&& let Some(callback) = imp.on_launch.borrow().as_ref()
{
callback(entry_id);
}
} }
} }
} }

View file

@ -9,10 +9,8 @@ mod grid;
mod state; mod state;
mod tile; mod tile;
use crate::client::CommandClient;
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::Parser;
use shepherd_api::{ErrorCode, ResponsePayload, ResponseResult};
use shepherd_util::default_socket_path; use shepherd_util::default_socket_path;
use std::path::PathBuf; use std::path::PathBuf;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@ -29,10 +27,6 @@ struct Args {
/// Log level /// Log level
#[arg(short, long, default_value = "info")] #[arg(short, long, default_value = "info")]
log_level: String, log_level: String,
/// Send StopCurrent to shepherdd and exit (for compositor keybindings)
#[arg(long)]
stop_current: bool,
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@ -41,7 +35,8 @@ fn main() -> Result<()> {
// Initialize logging // Initialize logging
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level)), EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(&args.log_level)),
) )
.init(); .init();
@ -50,33 +45,6 @@ fn main() -> Result<()> {
// Determine socket path with fallback to default // Determine socket path with fallback to default
let socket_path = args.socket.unwrap_or_else(default_socket_path); let socket_path = args.socket.unwrap_or_else(default_socket_path);
if args.stop_current {
let runtime = tokio::runtime::Runtime::new()?;
runtime.block_on(async move {
let client = CommandClient::new(&socket_path);
match client.stop_current().await {
Ok(response) => match response.result {
ResponseResult::Ok(ResponsePayload::Stopped) => {
tracing::info!("StopCurrent succeeded");
Ok(())
}
ResponseResult::Err(err) if err.code == ErrorCode::NoActiveSession => {
tracing::debug!("No active session to stop");
Ok(())
}
ResponseResult::Err(err) => {
anyhow::bail!("StopCurrent failed: {}", err.message)
}
ResponseResult::Ok(payload) => {
anyhow::bail!("Unexpected StopCurrent response: {:?}", payload)
}
},
Err(e) => anyhow::bail!("Failed to send StopCurrent: {}", e),
}
})?;
return Ok(());
}
// Run GTK application // Run GTK application
let application = app::LauncherApp::new(socket_path); let application = app::LauncherApp::new(socket_path);
let exit_code = application.run(); let exit_code = application.run();

View file

@ -1,6 +1,6 @@
//! Launcher application state management //! Launcher application state management
use shepherd_api::{EntryView, Event, EventPayload, ServiceStateSnapshot}; use shepherd_api::{ServiceStateSnapshot, EntryView, Event, EventPayload};
use shepherd_util::SessionId; use shepherd_util::SessionId;
use std::time::Duration; use std::time::Duration;
use tokio::sync::watch; use tokio::sync::watch;
@ -18,7 +18,7 @@ pub enum LauncherState {
/// Launch requested, waiting for response /// Launch requested, waiting for response
Launching { Launching {
#[allow(dead_code)] #[allow(dead_code)]
entry_id: String, entry_id: String
}, },
/// Session is running /// Session is running
SessionActive { SessionActive {
@ -62,10 +62,7 @@ impl SharedState {
tracing::info!(event = ?event.payload, "Received event from shepherdd"); tracing::info!(event = ?event.payload, "Received event from shepherdd");
match event.payload { match event.payload {
EventPayload::StateChanged(snapshot) => { EventPayload::StateChanged(snapshot) => {
tracing::info!( tracing::info!(has_session = snapshot.current_session.is_some(), "Applying state snapshot");
has_session = snapshot.current_session.is_some(),
"Applying state snapshot"
);
self.apply_snapshot(snapshot); self.apply_snapshot(snapshot);
} }
EventPayload::SessionStarted { EventPayload::SessionStarted {
@ -90,12 +87,7 @@ impl SharedState {
time_remaining, time_remaining,
}); });
} }
EventPayload::SessionEnded { EventPayload::SessionEnded { session_id, entry_id, reason, .. } => {
session_id,
entry_id,
reason,
..
} => {
tracing::info!(session_id = %session_id, entry_id = %entry_id, reason = ?reason, "Session ended event - setting Connecting"); tracing::info!(session_id = %session_id, entry_id = %entry_id, reason = ?reason, "Session ended event - setting Connecting");
// Will be followed by StateChanged, but set to connecting // Will be followed by StateChanged, but set to connecting
// to ensure grid reloads // to ensure grid reloads

View file

@ -51,6 +51,7 @@ mod imp {
obj.add_css_class("launcher-tile"); obj.add_css_class("launcher-tile");
obj.add_css_class("flat"); obj.add_css_class("flat");
obj.set_size_request(160, 160); obj.set_size_request(160, 160);
obj.set_can_focus(true);
} }
} }
@ -143,11 +144,7 @@ impl LauncherTile {
} }
pub fn entry_id(&self) -> Option<shepherd_util::EntryId> { pub fn entry_id(&self) -> Option<shepherd_util::EntryId> {
self.imp() self.imp().entry.borrow().as_ref().map(|e| e.entry_id.clone())
.entry
.borrow()
.as_ref()
.map(|e| e.entry_id.clone())
} }
} }

View file

@ -1,7 +1,7 @@
//! SQLite-based store implementation //! SQLite-based store implementation
use chrono::{DateTime, Local, NaiveDate}; use chrono::{DateTime, Local, NaiveDate};
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{params, Connection, OptionalExtension};
use shepherd_util::EntryId; use shepherd_util::EntryId;
use std::path::Path; use std::path::Path;
use std::sync::Mutex; use std::sync::Mutex;
@ -98,8 +98,9 @@ impl Store for SqliteStore {
fn get_recent_audits(&self, limit: usize) -> StoreResult<Vec<AuditEvent>> { fn get_recent_audits(&self, limit: usize) -> StoreResult<Vec<AuditEvent>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let mut stmt = conn let mut stmt = conn.prepare(
.prepare("SELECT id, timestamp, event_json FROM audit_log ORDER BY id DESC LIMIT ?")?; "SELECT id, timestamp, event_json FROM audit_log ORDER BY id DESC LIMIT ?",
)?;
let rows = stmt.query_map([limit], |row| { let rows = stmt.query_map([limit], |row| {
let id: i64 = row.get(0)?; let id: i64 = row.get(0)?;
@ -180,7 +181,11 @@ impl Store for SqliteStore {
Ok(result) Ok(result)
} }
fn set_cooldown_until(&self, entry_id: &EntryId, until: DateTime<Local>) -> StoreResult<()> { fn set_cooldown_until(
&self,
entry_id: &EntryId,
until: DateTime<Local>,
) -> StoreResult<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
conn.execute( conn.execute(
@ -199,10 +204,7 @@ impl Store for SqliteStore {
fn clear_cooldown(&self, entry_id: &EntryId) -> StoreResult<()> { fn clear_cooldown(&self, entry_id: &EntryId) -> StoreResult<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
conn.execute( conn.execute("DELETE FROM cooldowns WHERE entry_id = ?", [entry_id.as_str()])?;
"DELETE FROM cooldowns WHERE entry_id = ?",
[entry_id.as_str()],
)?;
Ok(()) Ok(())
} }
@ -210,11 +212,9 @@ impl Store for SqliteStore {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let json: Option<String> = conn let json: Option<String> = conn
.query_row( .query_row("SELECT snapshot_json FROM snapshot WHERE id = 1", [], |row| {
"SELECT snapshot_json FROM snapshot WHERE id = 1", row.get(0)
[], })
|row| row.get(0),
)
.optional()?; .optional()?;
match json { match json {
@ -246,7 +246,9 @@ impl Store for SqliteStore {
fn is_healthy(&self) -> bool { fn is_healthy(&self) -> bool {
match self.conn.lock() { match self.conn.lock() {
Ok(conn) => conn.query_row("SELECT 1", [], |_| Ok(())).is_ok(), Ok(conn) => {
conn.query_row("SELECT 1", [], |_| Ok(())).is_ok()
}
Err(_) => { Err(_) => {
warn!("Store lock poisoned"); warn!("Store lock poisoned");
false false

View file

@ -30,7 +30,11 @@ pub trait Store: Send + Sync {
fn get_cooldown_until(&self, entry_id: &EntryId) -> StoreResult<Option<DateTime<Local>>>; fn get_cooldown_until(&self, entry_id: &EntryId) -> StoreResult<Option<DateTime<Local>>>;
/// Set cooldown expiry time for an entry /// Set cooldown expiry time for an entry
fn set_cooldown_until(&self, entry_id: &EntryId, until: DateTime<Local>) -> StoreResult<()>; fn set_cooldown_until(
&self,
entry_id: &EntryId,
until: DateTime<Local>,
) -> StoreResult<()>;
/// Clear cooldown for an entry /// Clear cooldown for an entry
fn clear_cooldown(&self, entry_id: &EntryId) -> StoreResult<()>; fn clear_cooldown(&self, entry_id: &EntryId) -> StoreResult<()>;

View file

@ -40,9 +40,7 @@ pub fn default_socket_path() -> PathBuf {
pub fn socket_path_without_env() -> PathBuf { pub fn socket_path_without_env() -> PathBuf {
// Try XDG_RUNTIME_DIR first (typically /run/user/<uid>) // Try XDG_RUNTIME_DIR first (typically /run/user/<uid>)
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
return PathBuf::from(runtime_dir) return PathBuf::from(runtime_dir).join(APP_DIR).join(SOCKET_FILENAME);
.join(APP_DIR)
.join(SOCKET_FILENAME);
} }
// Fallback to /tmp with username // Fallback to /tmp with username
@ -111,13 +109,10 @@ pub fn default_log_dir() -> PathBuf {
/// Get the parent directory of the socket (for creating it) /// Get the parent directory of the socket (for creating it)
pub fn socket_dir() -> PathBuf { pub fn socket_dir() -> PathBuf {
let socket_path = socket_path_without_env(); let socket_path = socket_path_without_env();
socket_path socket_path.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| {
.parent() // Should never happen with our paths, but just in case
.map(|p| p.to_path_buf()) PathBuf::from("/tmp").join(APP_DIR)
.unwrap_or_else(|| { })
// Should never happen with our paths, but just in case
PathBuf::from("/tmp").join(APP_DIR)
})
} }
/// Configuration subdirectory name (uses "shepherd" not "shepherdd") /// Configuration subdirectory name (uses "shepherd" not "shepherdd")

View file

@ -42,13 +42,10 @@ impl RateLimiter {
pub fn check(&mut self, client_id: &ClientId) -> bool { pub fn check(&mut self, client_id: &ClientId) -> bool {
let now = Instant::now(); let now = Instant::now();
let bucket = self let bucket = self.clients.entry(client_id.clone()).or_insert(ClientBucket {
.clients tokens: self.max_tokens,
.entry(client_id.clone()) last_refill: now,
.or_insert(ClientBucket { });
tokens: self.max_tokens,
last_refill: now,
});
// Refill tokens if interval has passed // Refill tokens if interval has passed
let elapsed = now.duration_since(bucket.last_refill); let elapsed = now.duration_since(bucket.last_refill);
@ -75,8 +72,9 @@ impl RateLimiter {
/// Clean up stale client entries /// Clean up stale client entries
pub fn cleanup(&mut self, stale_after: Duration) { pub fn cleanup(&mut self, stale_after: Duration) {
let now = Instant::now(); let now = Instant::now();
self.clients self.clients.retain(|_, bucket| {
.retain(|_, bucket| now.duration_since(bucket.last_refill) < stale_after); now.duration_since(bucket.last_refill) < stale_after
});
} }
} }

View file

@ -37,9 +37,7 @@ fn get_mock_time_offset() -> Option<chrono::Duration> {
{ {
if let Ok(mock_time_str) = std::env::var(MOCK_TIME_ENV_VAR) { if let Ok(mock_time_str) = std::env::var(MOCK_TIME_ENV_VAR) {
// Parse the mock time string // Parse the mock time string
if let Ok(naive_dt) = if let Ok(naive_dt) = NaiveDateTime::parse_from_str(&mock_time_str, "%Y-%m-%d %H:%M:%S") {
NaiveDateTime::parse_from_str(&mock_time_str, "%Y-%m-%d %H:%M:%S")
{
if let Some(mock_dt) = Local.from_local_datetime(&naive_dt).single() { if let Some(mock_dt) = Local.from_local_datetime(&naive_dt).single() {
let real_now = chrono::Local::now(); let real_now = chrono::Local::now();
let offset = mock_dt.signed_duration_since(real_now); let offset = mock_dt.signed_duration_since(real_now);
@ -203,8 +201,9 @@ impl DaysOfWeek {
pub const SATURDAY: u8 = 1 << 5; pub const SATURDAY: u8 = 1 << 5;
pub const SUNDAY: u8 = 1 << 6; pub const SUNDAY: u8 = 1 << 6;
pub const WEEKDAYS: DaysOfWeek = pub const WEEKDAYS: DaysOfWeek = DaysOfWeek(
DaysOfWeek(Self::MONDAY | Self::TUESDAY | Self::WEDNESDAY | Self::THURSDAY | Self::FRIDAY); Self::MONDAY | Self::TUESDAY | Self::WEDNESDAY | Self::THURSDAY | Self::FRIDAY,
);
pub const WEEKENDS: DaysOfWeek = DaysOfWeek(Self::SATURDAY | Self::SUNDAY); pub const WEEKENDS: DaysOfWeek = DaysOfWeek(Self::SATURDAY | Self::SUNDAY);
pub const ALL_DAYS: DaysOfWeek = DaysOfWeek(0x7F); pub const ALL_DAYS: DaysOfWeek = DaysOfWeek(0x7F);
pub const NONE: DaysOfWeek = DaysOfWeek(0); pub const NONE: DaysOfWeek = DaysOfWeek(0);
@ -447,14 +446,14 @@ mod tests {
fn test_parse_mock_time_invalid_formats() { fn test_parse_mock_time_invalid_formats() {
// Test that invalid formats are rejected // Test that invalid formats are rejected
let invalid_formats = [ let invalid_formats = [
"2025-12-25", // Missing time "2025-12-25", // Missing time
"14:30:00", // Missing date "14:30:00", // Missing date
"2025/12/25 14:30:00", // Wrong date separator "2025/12/25 14:30:00", // Wrong date separator
"2025-12-25T14:30:00", // ISO format (not supported) "2025-12-25T14:30:00", // ISO format (not supported)
"Dec 25, 2025 14:30", // Wrong format "Dec 25, 2025 14:30", // Wrong format
"25-12-2025 14:30:00", // Wrong date order "25-12-2025 14:30:00", // Wrong date order
"", // Empty string "", // Empty string
"not a date", // Invalid string "not a date", // Invalid string
]; ];
for format_str in &invalid_formats { for format_str in &invalid_formats {
@ -529,30 +528,21 @@ mod tests {
let window = TimeWindow::new( let window = TimeWindow::new(
DaysOfWeek::ALL_DAYS, DaysOfWeek::ALL_DAYS,
WallClock::new(14, 0).unwrap(), // 2 PM WallClock::new(14, 0).unwrap(), // 2 PM
WallClock::new(18, 0).unwrap(), // 6 PM WallClock::new(18, 0).unwrap(), // 6 PM
); );
// Time within window // Time within window
let in_window = Local.with_ymd_and_hms(2025, 12, 25, 15, 0, 0).unwrap(); let in_window = Local.with_ymd_and_hms(2025, 12, 25, 15, 0, 0).unwrap();
assert!( assert!(window.contains(&in_window), "15:00 should be within 14:00-18:00 window");
window.contains(&in_window),
"15:00 should be within 14:00-18:00 window"
);
// Time before window // Time before window
let before_window = Local.with_ymd_and_hms(2025, 12, 25, 10, 0, 0).unwrap(); let before_window = Local.with_ymd_and_hms(2025, 12, 25, 10, 0, 0).unwrap();
assert!( assert!(!window.contains(&before_window), "10:00 should be before 14:00-18:00 window");
!window.contains(&before_window),
"10:00 should be before 14:00-18:00 window"
);
// Time after window // Time after window
let after_window = Local.with_ymd_and_hms(2025, 12, 25, 20, 0, 0).unwrap(); let after_window = Local.with_ymd_and_hms(2025, 12, 25, 20, 0, 0).unwrap();
assert!( assert!(!window.contains(&after_window), "20:00 should be after 14:00-18:00 window");
!window.contains(&after_window),
"20:00 should be after 14:00-18:00 window"
);
} }
#[test] #[test]
@ -566,24 +556,15 @@ mod tests {
// Thursday at 3 PM - should be available (weekday, in time window) // Thursday at 3 PM - should be available (weekday, in time window)
let thursday = Local.with_ymd_and_hms(2025, 12, 25, 15, 0, 0).unwrap(); // Christmas 2025 is Thursday let thursday = Local.with_ymd_and_hms(2025, 12, 25, 15, 0, 0).unwrap(); // Christmas 2025 is Thursday
assert!( assert!(window.contains(&thursday), "Thursday 15:00 should be in weekday afternoon window");
window.contains(&thursday),
"Thursday 15:00 should be in weekday afternoon window"
);
// Saturday at 3 PM - should NOT be available (weekend) // Saturday at 3 PM - should NOT be available (weekend)
let saturday = Local.with_ymd_and_hms(2025, 12, 27, 15, 0, 0).unwrap(); let saturday = Local.with_ymd_and_hms(2025, 12, 27, 15, 0, 0).unwrap();
assert!( assert!(!window.contains(&saturday), "Saturday should not be in weekday window");
!window.contains(&saturday),
"Saturday should not be in weekday window"
);
// Sunday at 3 PM - should NOT be available (weekend) // Sunday at 3 PM - should NOT be available (weekend)
let sunday = Local.with_ymd_and_hms(2025, 12, 28, 15, 0, 0).unwrap(); let sunday = Local.with_ymd_and_hms(2025, 12, 28, 15, 0, 0).unwrap();
assert!( assert!(!window.contains(&sunday), "Sunday should not be in weekday window");
!window.contains(&sunday),
"Sunday should not be in weekday window"
);
} }
} }

View file

@ -26,10 +26,9 @@ impl InternetMonitor {
for entry in &policy.entries { for entry in &policy.entries {
if entry.internet.required if entry.internet.required
&& let Some(check) = entry.internet.check.clone() && let Some(check) = entry.internet.check.clone()
&& !targets.contains(&check) && !targets.contains(&check) {
{ targets.push(check);
targets.push(check); }
}
} }
if targets.is_empty() { if targets.is_empty() {

View file

@ -12,20 +12,20 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::Parser; use clap::Parser;
use shepherd_api::{ use shepherd_api::{
Command, ErrorCode, ErrorInfo, Event, EventPayload, HealthStatus, Response, ResponsePayload, Command, ErrorCode, ErrorInfo, Event, EventPayload, HealthStatus,
SessionEndReason, StopMode, VolumeInfo, VolumeRestrictions, Response, ResponsePayload, SessionEndReason, StopMode, VolumeInfo, VolumeRestrictions,
}; };
use shepherd_config::{VolumePolicy, load_config}; use shepherd_config::{load_config, VolumePolicy};
use shepherd_core::{CoreEngine, CoreEvent, LaunchDecision, StopDecision}; use shepherd_core::{CoreEngine, CoreEvent, LaunchDecision, StopDecision};
use shepherd_host_api::{HostAdapter, HostEvent, StopMode as HostStopMode, VolumeController}; use shepherd_host_api::{HostAdapter, HostEvent, StopMode as HostStopMode, VolumeController};
use shepherd_host_linux::{LinuxHost, LinuxVolumeController}; use shepherd_host_linux::{LinuxHost, LinuxVolumeController};
use shepherd_ipc::{IpcServer, ServerMessage}; use shepherd_ipc::{IpcServer, ServerMessage};
use shepherd_store::{AuditEvent, AuditEventType, SqliteStore, Store}; use shepherd_store::{AuditEvent, AuditEventType, SqliteStore, Store};
use shepherd_util::{ClientId, MonotonicInstant, RateLimiter, default_config_path}; use shepherd_util::{default_config_path, ClientId, MonotonicInstant, RateLimiter};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::signal::unix::{SignalKind, signal}; use tokio::signal::unix::{signal, SignalKind};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@ -180,11 +180,12 @@ impl Service {
}); });
// Set up signal handlers // Set up signal handlers
let mut sigterm = let mut sigterm = signal(SignalKind::terminate())
signal(SignalKind::terminate()).context("Failed to create SIGTERM handler")?; .context("Failed to create SIGTERM handler")?;
let mut sigint = let mut sigint = signal(SignalKind::interrupt())
signal(SignalKind::interrupt()).context("Failed to create SIGINT handler")?; .context("Failed to create SIGINT handler")?;
let mut sighup = signal(SignalKind::hangup()).context("Failed to create SIGHUP handler")?; let mut sighup = signal(SignalKind::hangup())
.context("Failed to create SIGHUP handler")?;
// Main event loop // Main event loop
let tick_interval = Duration::from_millis(100); let tick_interval = Duration::from_millis(100);
@ -245,16 +246,9 @@ impl Service {
let engine = engine.lock().await; let engine = engine.lock().await;
if let Some(session) = engine.current_session() { if let Some(session) = engine.current_session() {
info!(session_id = %session.plan.session_id, "Stopping active session"); info!(session_id = %session.plan.session_id, "Stopping active session");
if let Some(handle) = &session.host_handle if let Some(handle) = &session.host_handle && let Err(e) = host.stop(handle, HostStopMode::Graceful {
&& let Err(e) = host timeout: Duration::from_secs(5),
.stop( }).await {
handle,
HostStopMode::Graceful {
timeout: Duration::from_secs(5),
},
)
.await
{
warn!(error = %e, "Failed to stop session gracefully"); warn!(error = %e, "Failed to stop session gracefully");
} }
} }
@ -307,7 +301,9 @@ impl Service {
// Get the host handle and stop it // Get the host handle and stop it
let handle = { let handle = {
let engine = engine.lock().await; let engine = engine.lock().await;
engine.current_session().and_then(|s| s.host_handle.clone()) engine
.current_session()
.and_then(|s| s.host_handle.clone())
}; };
if let Some(handle) = handle if let Some(handle) = handle
@ -319,10 +315,10 @@ impl Service {
}, },
) )
.await .await
{ {
warn!(error = %e, "Failed to stop session gracefully, forcing"); warn!(error = %e, "Failed to stop session gracefully, forcing");
let _ = host.stop(&handle, HostStopMode::Force).await; let _ = host.stop(&handle, HostStopMode::Force).await;
} }
ipc.broadcast_event(Event::new(EventPayload::SessionExpiring { ipc.broadcast_event(Event::new(EventPayload::SessionExpiring {
session_id: session_id.clone(), session_id: session_id.clone(),
@ -409,10 +405,7 @@ impl Service {
engine.notify_session_exited(status.code, now_mono, now) engine.notify_session_exited(status.code, now_mono, now)
}; };
info!( info!(has_event = core_event.is_some(), "notify_session_exited result");
has_event = core_event.is_some(),
"notify_session_exited result"
);
if let Some(CoreEvent::SessionEnded { if let Some(CoreEvent::SessionEnded {
session_id, session_id,
@ -420,29 +413,29 @@ impl Service {
reason, reason,
duration, duration,
}) = core_event }) = core_event
{ {
info!( info!(
session_id = %session_id, session_id = %session_id,
entry_id = %entry_id, entry_id = %entry_id,
reason = ?reason, reason = ?reason,
duration_secs = duration.as_secs(), duration_secs = duration.as_secs(),
"Broadcasting SessionEnded" "Broadcasting SessionEnded"
); );
ipc.broadcast_event(Event::new(EventPayload::SessionEnded { ipc.broadcast_event(Event::new(EventPayload::SessionEnded {
session_id, session_id,
entry_id, entry_id,
reason, reason,
duration, duration,
})); }));
// Broadcast state change // Broadcast state change
let state = { let state = {
let engine = engine.lock().await; let engine = engine.lock().await;
engine.get_state() engine.get_state()
}; };
info!("Broadcasting StateChanged"); info!("Broadcasting StateChanged");
ipc.broadcast_event(Event::new(EventPayload::StateChanged(state))); ipc.broadcast_event(Event::new(EventPayload::StateChanged(state)));
} }
} }
HostEvent::WindowReady { handle } => { HostEvent::WindowReady { handle } => {
@ -479,17 +472,9 @@ impl Service {
} }
} }
let response = Self::handle_command( let response =
engine, Self::handle_command(engine, host, volume, ipc, store, &client_id, request.request_id, request.command)
host, .await;
volume,
ipc,
store,
&client_id,
request.request_id,
request.command,
)
.await;
let _ = ipc.send_response(&client_id, response).await; let _ = ipc.send_response(&client_id, response).await;
} }
@ -502,19 +487,23 @@ impl Service {
"Client connected" "Client connected"
); );
let _ = store.append_audit(AuditEvent::new(AuditEventType::ClientConnected { let _ = store.append_audit(AuditEvent::new(
client_id: client_id.to_string(), AuditEventType::ClientConnected {
role: format!("{:?}", info.role), client_id: client_id.to_string(),
uid: info.uid, role: format!("{:?}", info.role),
})); uid: info.uid,
},
));
} }
ServerMessage::ClientDisconnected { client_id } => { ServerMessage::ClientDisconnected { client_id } => {
debug!(client_id = %client_id, "Client disconnected"); debug!(client_id = %client_id, "Client disconnected");
let _ = store.append_audit(AuditEvent::new(AuditEventType::ClientDisconnected { let _ = store.append_audit(AuditEvent::new(
client_id: client_id.to_string(), AuditEventType::ClientDisconnected {
})); client_id: client_id.to_string(),
},
));
// Clean up rate limiter // Clean up rate limiter
let mut limiter = rate_limiter.lock().await; let mut limiter = rate_limiter.lock().await;
@ -558,7 +547,10 @@ impl Service {
let event = eng.start_session(plan.clone(), now, now_mono); let event = eng.start_session(plan.clone(), now, now_mono);
// Get the entry kind for spawning // Get the entry kind for spawning
let entry_kind = eng.policy().get_entry(&entry_id).map(|e| e.kind.clone()); let entry_kind = eng
.policy()
.get_entry(&entry_id)
.map(|e| e.kind.clone());
// Build spawn options with log path if capture_child_output is enabled // Build spawn options with log path if capture_child_output is enabled
let spawn_options = if eng.policy().service.capture_child_output { let spawn_options = if eng.policy().service.capture_child_output {
@ -585,7 +577,11 @@ impl Service {
if let Some(kind) = entry_kind { if let Some(kind) = entry_kind {
match host match host
.spawn(plan.session_id.clone(), &kind, spawn_options) .spawn(
plan.session_id.clone(),
&kind,
spawn_options,
)
.await .await
{ {
Ok(handle) => { Ok(handle) => {
@ -601,14 +597,12 @@ impl Service {
deadline, deadline,
} = event } = event
{ {
ipc.broadcast_event(Event::new( ipc.broadcast_event(Event::new(EventPayload::SessionStarted {
EventPayload::SessionStarted { session_id: session_id.clone(),
session_id: session_id.clone(), entry_id,
entry_id, label,
label, deadline,
deadline, }));
},
));
Response::success( Response::success(
request_id, request_id,
@ -620,10 +614,7 @@ impl Service {
} else { } else {
Response::error( Response::error(
request_id, request_id,
ErrorInfo::new( ErrorInfo::new(ErrorCode::InternalError, "Unexpected event"),
ErrorCode::InternalError,
"Unexpected event",
),
) )
} }
} }
@ -636,22 +627,18 @@ impl Service {
reason, reason,
duration, duration,
}) = eng.notify_session_exited(Some(-1), now_mono, now) }) = eng.notify_session_exited(Some(-1), now_mono, now)
{ {
ipc.broadcast_event(Event::new( ipc.broadcast_event(Event::new(EventPayload::SessionEnded {
EventPayload::SessionEnded {
session_id, session_id,
entry_id, entry_id,
reason, reason,
duration, duration,
}, }));
));
// Broadcast state change so clients return to idle // Broadcast state change so clients return to idle
let state = eng.get_state(); let state = eng.get_state();
ipc.broadcast_event(Event::new( ipc.broadcast_event(Event::new(EventPayload::StateChanged(state)));
EventPayload::StateChanged(state), }
));
}
Response::error( Response::error(
request_id, request_id,
@ -679,7 +666,9 @@ impl Service {
let mut eng = engine.lock().await; let mut eng = engine.lock().await;
// Get handle before stopping in engine // Get handle before stopping in engine
let handle = eng.current_session().and_then(|s| s.host_handle.clone()); let handle = eng
.current_session()
.and_then(|s| s.host_handle.clone());
let reason = match mode { let reason = match mode {
StopMode::Graceful => SessionEndReason::UserStop, StopMode::Graceful => SessionEndReason::UserStop,
@ -730,13 +719,12 @@ impl Service {
Command::ReloadConfig => { Command::ReloadConfig => {
// Check permission // Check permission
if let Some(info) = ipc.get_client_info(client_id).await if let Some(info) = ipc.get_client_info(client_id).await
&& !info.role.can_reload_config() && !info.role.can_reload_config() {
{ return Response::error(
return Response::error( request_id,
request_id, ErrorInfo::new(ErrorCode::PermissionDenied, "Admin role required"),
ErrorInfo::new(ErrorCode::PermissionDenied, "Admin role required"), );
); }
}
// TODO: Reload from original config path // TODO: Reload from original config path
Response::error( Response::error(
@ -745,12 +733,14 @@ impl Service {
) )
} }
Command::SubscribeEvents => Response::success( Command::SubscribeEvents => {
request_id, Response::success(
ResponsePayload::Subscribed { request_id,
client_id: client_id.clone(), ResponsePayload::Subscribed {
}, client_id: client_id.clone(),
), },
)
}
Command::UnsubscribeEvents => { Command::UnsubscribeEvents => {
Response::success(request_id, ResponsePayload::Unsubscribed) Response::success(request_id, ResponsePayload::Unsubscribed)
@ -771,28 +761,21 @@ impl Service {
Command::ExtendCurrent { by } => { Command::ExtendCurrent { by } => {
// Check permission // Check permission
if let Some(info) = ipc.get_client_info(client_id).await if let Some(info) = ipc.get_client_info(client_id).await
&& !info.role.can_extend() && !info.role.can_extend() {
{ return Response::error(
return Response::error( request_id,
request_id, ErrorInfo::new(ErrorCode::PermissionDenied, "Admin role required"),
ErrorInfo::new(ErrorCode::PermissionDenied, "Admin role required"), );
); }
}
let mut eng = engine.lock().await; let mut eng = engine.lock().await;
match eng.extend_current(by, now_mono, now) { match eng.extend_current(by, now_mono, now) {
Some(new_deadline) => Response::success( Some(new_deadline) => {
request_id, Response::success(request_id, ResponsePayload::Extended { new_deadline: Some(new_deadline) })
ResponsePayload::Extended { }
new_deadline: Some(new_deadline),
},
),
None => Response::error( None => Response::error(
request_id, request_id,
ErrorInfo::new( ErrorInfo::new(ErrorCode::NoActiveSession, "No active session or session is unlimited"),
ErrorCode::NoActiveSession,
"No active session or session is unlimited",
),
), ),
} }
} }
@ -934,10 +917,9 @@ impl Service {
// Check if there's an active session with volume restrictions // Check if there's an active session with volume restrictions
if let Some(session) = eng.current_session() if let Some(session) = eng.current_session()
&& let Some(entry) = eng.policy().get_entry(&session.plan.entry_id) && let Some(entry) = eng.policy().get_entry(&session.plan.entry_id)
&& let Some(ref vol_policy) = entry.volume && let Some(ref vol_policy) = entry.volume {
{ return Self::convert_volume_policy(vol_policy);
return Self::convert_volume_policy(vol_policy); }
}
// Fall back to global policy // Fall back to global policy
Self::convert_volume_policy(&eng.policy().volume) Self::convert_volume_policy(&eng.policy().volume)
@ -958,15 +940,18 @@ async fn main() -> Result<()> {
let args = Args::parse(); let args = Args::parse();
// Initialize logging // Initialize logging
let filter = let filter = EnvFilter::try_from_default_env()
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level)); .unwrap_or_else(|_| EnvFilter::new(&args.log_level));
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(filter) .with_env_filter(filter)
.with_target(true) .with_target(true)
.init(); .init();
info!(version = env!("CARGO_PKG_VERSION"), "shepherdd starting"); info!(
version = env!("CARGO_PKG_VERSION"),
"shepherdd starting"
);
// Create and run the service // Create and run the service
let service = Service::new(&args).await?; let service = Service::new(&args).await?;

View file

@ -15,42 +15,44 @@ use std::time::Duration;
fn make_test_policy() -> Policy { fn make_test_policy() -> Policy {
Policy { Policy {
service: Default::default(), service: Default::default(),
entries: vec![Entry { entries: vec![
id: EntryId::new("test-game"), Entry {
label: "Test Game".into(), id: EntryId::new("test-game"),
icon_ref: None, label: "Test Game".into(),
kind: EntryKind::Process { icon_ref: None,
command: "sleep".into(), kind: EntryKind::Process {
args: vec!["999".into()], command: "sleep".into(),
env: HashMap::new(), args: vec!["999".into()],
cwd: None, env: HashMap::new(),
}, cwd: None,
availability: AvailabilityPolicy {
windows: vec![],
always: true,
},
limits: LimitsPolicy {
max_run: Some(Duration::from_secs(10)), // Short for testing
daily_quota: None,
cooldown: None,
},
warnings: vec![
WarningThreshold {
seconds_before: 5,
severity: WarningSeverity::Warn,
message_template: Some("5 seconds left".into()),
}, },
WarningThreshold { availability: AvailabilityPolicy {
seconds_before: 2, windows: vec![],
severity: WarningSeverity::Critical, always: true,
message_template: Some("2 seconds left!".into()),
}, },
], limits: LimitsPolicy {
volume: None, max_run: Some(Duration::from_secs(10)), // Short for testing
disabled: false, daily_quota: None,
disabled_reason: None, cooldown: None,
internet: Default::default(), },
}], warnings: vec![
WarningThreshold {
seconds_before: 5,
severity: WarningSeverity::Warn,
message_template: Some("5 seconds left".into()),
},
WarningThreshold {
seconds_before: 2,
severity: WarningSeverity::Critical,
message_template: Some("2 seconds left!".into()),
},
],
volume: None,
disabled: false,
disabled_reason: None,
internet: Default::default(),
},
],
default_warnings: vec![], default_warnings: vec![],
default_max_run: Some(Duration::from_secs(3600)), default_max_run: Some(Duration::from_secs(3600)),
volume: Default::default(), volume: Default::default(),
@ -89,9 +91,7 @@ fn test_launch_approval() {
let entry_id = EntryId::new("test-game"); let entry_id = EntryId::new("test-game");
let decision = engine.request_launch(&entry_id, shepherd_util::now()); let decision = engine.request_launch(&entry_id, shepherd_util::now());
assert!( assert!(matches!(decision, LaunchDecision::Approved(plan) if plan.max_duration == Some(Duration::from_secs(10))));
matches!(decision, LaunchDecision::Approved(plan) if plan.max_duration == Some(Duration::from_secs(10)))
);
} }
#[test] #[test]
@ -150,26 +150,14 @@ fn test_warning_emission() {
let at_6s = now + chrono::Duration::seconds(6); let at_6s = now + chrono::Duration::seconds(6);
let events = engine.tick(at_6s_mono, at_6s); let events = engine.tick(at_6s_mono, at_6s);
assert_eq!(events.len(), 1); assert_eq!(events.len(), 1);
assert!(matches!( assert!(matches!(&events[0], CoreEvent::Warning { threshold_seconds: 5, .. }));
&events[0],
CoreEvent::Warning {
threshold_seconds: 5,
..
}
));
// At 9 seconds (1 second remaining), 2-second warning should fire // At 9 seconds (1 second remaining), 2-second warning should fire
let at_9s_mono = now_mono + Duration::from_secs(9); let at_9s_mono = now_mono + Duration::from_secs(9);
let at_9s = now + chrono::Duration::seconds(9); let at_9s = now + chrono::Duration::seconds(9);
let events = engine.tick(at_9s_mono, at_9s); let events = engine.tick(at_9s_mono, at_9s);
assert_eq!(events.len(), 1); assert_eq!(events.len(), 1);
assert!(matches!( assert!(matches!(&events[0], CoreEvent::Warning { threshold_seconds: 2, .. }));
&events[0],
CoreEvent::Warning {
threshold_seconds: 2,
..
}
));
// Warnings shouldn't repeat // Warnings shouldn't repeat
let events = engine.tick(at_9s_mono, at_9s); let events = engine.tick(at_9s_mono, at_9s);
@ -200,9 +188,7 @@ fn test_session_expiry() {
let events = engine.tick(at_11s_mono, at_11s); let events = engine.tick(at_11s_mono, at_11s);
// Should have both remaining warnings + expiry // Should have both remaining warnings + expiry
let has_expiry = events let has_expiry = events.iter().any(|e| matches!(e, CoreEvent::ExpireDue { .. }));
.iter()
.any(|e| matches!(e, CoreEvent::ExpireDue { .. }));
assert!(has_expiry, "Expected ExpireDue event"); assert!(has_expiry, "Expected ExpireDue event");
} }
@ -305,18 +291,9 @@ fn test_config_parsing() {
let policy = parse_config(config).unwrap(); let policy = parse_config(config).unwrap();
assert_eq!(policy.entries.len(), 1); assert_eq!(policy.entries.len(), 1);
assert_eq!(policy.entries[0].id.as_str(), "scummvm"); assert_eq!(policy.entries[0].id.as_str(), "scummvm");
assert_eq!( assert_eq!(policy.entries[0].limits.max_run, Some(Duration::from_secs(3600)));
policy.entries[0].limits.max_run, assert_eq!(policy.entries[0].limits.daily_quota, Some(Duration::from_secs(7200)));
Some(Duration::from_secs(3600)) assert_eq!(policy.entries[0].limits.cooldown, Some(Duration::from_secs(300)));
);
assert_eq!(
policy.entries[0].limits.daily_quota,
Some(Duration::from_secs(7200))
);
assert_eq!(
policy.entries[0].limits.cooldown,
Some(Duration::from_secs(300))
);
assert_eq!(policy.entries[0].warnings.len(), 1); assert_eq!(policy.entries[0].warnings.len(), 1);
} }
@ -339,11 +316,7 @@ fn test_session_extension() {
engine.start_session(plan, now, now_mono); engine.start_session(plan, now, now_mono);
// Get original deadline (should be Some for this test) // Get original deadline (should be Some for this test)
let original_deadline = engine let original_deadline = engine.current_session().unwrap().deadline.expect("Expected deadline");
.current_session()
.unwrap()
.deadline
.expect("Expected deadline");
// Extend by 5 minutes // Extend by 5 minutes
let new_deadline = engine.extend_current(Duration::from_secs(300), now_mono, now); let new_deadline = engine.extend_current(Duration::from_secs(300), now_mono, now);

View file

@ -1,22 +0,0 @@
# Controller And Keyboard Launching
Issue: <https://github.com/aarmea/shepherd-launcher/issues/20>
Prompt summary:
- Launching activities required pointer input.
- Requested non-pointer controls:
- Selection via arrow keys, WASD, D-pad, or analog stick
- Launch via Enter, Space, controller A/B/Start
- Exit via Alt+F4, Ctrl+W, controller home
- Goal was better accessibility and support for pointer-less handheld systems.
Implemented summary:
- Added keyboard navigation and activation support in launcher UI grid.
- Added explicit keyboard exit shortcuts at the window level.
- Added gamepad input handling via `gilrs` for D-pad, analog stick, A/B/Start launch, and home exit.
- Added focused tile styling so non-pointer selection is visible.
Key files:
- crates/shepherd-launcher-ui/src/app.rs
- crates/shepherd-launcher-ui/src/grid.rs
- crates/shepherd-launcher-ui/Cargo.toml

View file

@ -0,0 +1,13 @@
# Launcher Keyboard & Controller Navigation
Issue: <https://github.com/aarmea/shepherd-launcher/issues/20>
Summary:
- Added keyboard/controller navigation for the launcher grid (arrow keys, WASD, D-pad) with focus movement between tiles.
- Enabled focus styling on tiles and ensured the grid focuses the first tile on state updates.
- Added keyboard shortcuts to close the launcher (Alt+F4, Ctrl+W, Home).
Key files:
- crates/shepherd-launcher-ui/src/app.rs
- crates/shepherd-launcher-ui/src/grid.rs
- crates/shepherd-launcher-ui/src/tile.rs

View file

@ -1,15 +0,0 @@
# Sway StopCurrent Keybinds
Prompt summary:
- Move keyboard exit handling out of launcher UI code and into `sway.conf`.
- Keep controller home behavior as-is.
- Ensure "exit" uses the API (`StopCurrent`) rather than closing windows directly.
Implemented summary:
- Added a `--stop-current` mode to `shepherd-launcher` that sends `StopCurrent` to shepherdd over IPC and exits.
- Added Sway keybindings for `Alt+F4`, `Ctrl+W`, and `Home` that execute `shepherd-launcher --stop-current`.
- Kept controller home behavior in launcher UI unchanged.
Key files:
- `sway.conf`
- `crates/shepherd-launcher-ui/src/main.rs`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 130 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

View file

@ -17,7 +17,6 @@ libgdk-pixbuf-xlib-2.0-dev
# Wayland development libraries # Wayland development libraries
libwayland-dev libwayland-dev
libxkbcommon-dev libxkbcommon-dev
libudev-dev
# X11 (for XWayland support) # X11 (for XWayland support)
libx11-dev libx11-dev

View file

@ -13,4 +13,3 @@ xdg-desktop-portal-wlr
libgtk-4-1 libgtk-4-1
libadwaita-1-0 libadwaita-1-0
libgtk4-layer-shell0 libgtk4-layer-shell0
libudev1

View file

@ -113,7 +113,7 @@ install_desktop_entry() {
[Desktop Entry] [Desktop Entry]
Name=Shepherd Kiosk Name=Shepherd Kiosk
Comment=Shepherd game launcher kiosk mode Comment=Shepherd game launcher kiosk mode
Exec=sway -c $SWAY_CONFIG_DIR/$SHEPHERD_SWAY_CONFIG --unsupported-gpu Exec=sway -c $SWAY_CONFIG_DIR/$SHEPHERD_SWAY_CONFIG
Type=Application Type=Application
DesktopNames=shepherd DesktopNames=shepherd
EOF EOF

View file

@ -100,7 +100,7 @@ sway_start_nested() {
trap sway_cleanup EXIT trap sway_cleanup EXIT
# Start sway with wayland backend (nested in current session) # Start sway with wayland backend (nested in current session)
WLR_BACKENDS=wayland WLR_LIBINPUT_NO_DEVICES=1 sway -c "$sway_config" --unsupported-gpu & WLR_BACKENDS=wayland WLR_LIBINPUT_NO_DEVICES=1 sway -c "$sway_config" &
SWAY_PID=$! SWAY_PID=$!
info "Sway started with PID $SWAY_PID" info "Sway started with PID $SWAY_PID"

View file

@ -11,7 +11,6 @@ exec_always dbus-update-activation-environment --systemd \
### Variables ### Variables
set $launcher ./target/debug/shepherd-launcher set $launcher ./target/debug/shepherd-launcher
set $hud ./target/debug/shepherd-hud set $hud ./target/debug/shepherd-hud
set $stop_current $launcher --stop-current
### Output configuration ### Output configuration
# Set up displays (adjust as needed for your hardware) # Set up displays (adjust as needed for your hardware)
@ -130,11 +129,8 @@ focus_follows_mouse no
# Hide any title/tab text by using minimal font size # Hide any title/tab text by using minimal font size
font pango:monospace 1 font pango:monospace 1
# Session stop keybindings via shepherdd API (does not close windows directly) # Prevent window closing via keybindings (no Alt+F4)
# Handled in sway so they work regardless of which client currently has focus. # Windows can only be closed by the application itself
bindsym --locked Alt+F4 exec $stop_current
bindsym --locked Ctrl+w exec $stop_current
bindsym --locked Home exec $stop_current
# Hide mouse cursor after inactivity # Hide mouse cursor after inactivity
seat * hide_cursor 5000 seat * hide_cursor 5000