Compare commits

...

18 commits

Author SHA1 Message Date
82ffcafe0f Actually fix videos in README
Some checks failed
CI / Build (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Rustfmt (push) Has been cancelled
CI / ShellCheck (push) Has been cancelled
2026-03-22 20:02:40 +00:00
dc6ba0d829 Fix videos in README
Some checks are pending
CI / Build (push) Waiting to run
CI / Test (push) Waiting to run
CI / Clippy (push) Waiting to run
CI / Rustfmt (push) Waiting to run
CI / ShellCheck (push) Waiting to run
2026-03-22 20:00:52 +00:00
97036ddfab Migrate README to Forgejo
Some checks are pending
CI / Build (push) Waiting to run
CI / Test (push) Waiting to run
CI / Clippy (push) Waiting to run
CI / Rustfmt (push) Waiting to run
CI / ShellCheck (push) Waiting to run
2026-03-22 19:57:53 +00:00
1f66c2ae91 Remove LFS
Some checks failed
CI / Build (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Rustfmt (push) Has been cancelled
CI / ShellCheck (push) Has been cancelled
We only used it for the screenshots, and even then, they don't change
much
2026-03-22 15:42:34 -04:00
f7ae46cda2
Age verification is a non-goal 2026-03-04 22:46:23 -05:00
873560bdeb
Merge pull request #29 from aarmea/u/aarmea/fix/nvidia
Always pass `--unsupported-gpu` to Sway
2026-02-14 23:03:15 -05:00
d3cd4a9015 Always pass --unsupported-gpu to Sway
On NVIDIA GPUs, this allows the launcher to come up at all. If it is not
provided, Sway will exit immediately after printing a warning to the
journal. This can be very hard to debug if you don't know what you're
looking for.

On other GPUs, including those that play nice with Linux like Intel and
AMD, the option is completely harmless.
2026-02-14 22:26:10 -05:00
af10a1ca19
Merge pull request #28 from aarmea/u/aarmea/20/keyboard-gamepad-2
Add keyboard and gamepad launch support
2026-02-08 14:24:25 -05:00
becb091612 Update packages for gamepad support 2026-02-08 14:20:35 -05:00
fbb01127fa Fix rustfmt in CI
Just use --check, don't call git
2026-02-08 14:12:49 -05:00
3861092d3d Run rustfmt 2026-02-08 14:01:49 -05:00
c8675be472 Make CI and agents run rustfmt 2026-02-08 14:01:15 -05:00
b840a7d694 Implement Ctrl+w and Alt+F4 to close the current activity
Mouse not required

This is done by sending StopCommand to shepherdd, not by natively
closing the window. Sway initiates this to avoid any issues with
shepherd-launcher not having focus (as it won't when an activity is
running).
2026-02-08 13:54:17 -05:00
64ee1d7bc6 Implement controller exit
Use the "Home" button (or Xbox or PlayStation, depending on the controller)
2026-02-08 13:43:17 -05:00
b12b42b13d Fix analog stick up/down
It's not inverted anymore
2026-02-08 12:45:12 -05:00
2538403acd Fix up/down moves 2026-02-08 12:11:20 -05:00
e5a4dbdce7 WIP: keyboard and controller support 2026-02-08 11:14:09 -05:00
8bba628d98
Merge pull request #26 from aarmea/u/aarmea/9/connectivity-check-new
Internet connectivity check
2026-02-07 17:53:35 -05:00
58 changed files with 1528 additions and 692 deletions

2
.gitattributes vendored
View file

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

View file

@ -120,6 +120,46 @@ 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,7 +2,9 @@ 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. If you changed the example configuration at <config.example.toml>, make sure that it passes config validation. 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.
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,6 +315,12 @@ 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"
@ -459,6 +465,40 @@ 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"
@ -762,6 +802,26 @@ 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"
@ -823,6 +883,16 @@ 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"
@ -892,6 +962,18 @@ 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"
@ -910,6 +992,26 @@ 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"
@ -1256,7 +1358,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dirs", "dirs",
"nix", "nix 0.29.0",
"serde", "serde",
"shell-escape", "shell-escape",
"shepherd-api", "shepherd-api",
@ -1291,7 +1393,7 @@ dependencies = [
name = "shepherd-ipc" name = "shepherd-ipc"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"nix", "nix 0.29.0",
"serde", "serde",
"serde_json", "serde_json",
"shepherd-api", "shepherd-api",
@ -1310,6 +1412,7 @@ dependencies = [
"chrono", "chrono",
"clap", "clap",
"dirs", "dirs",
"gilrs",
"gtk4", "gtk4",
"serde", "serde",
"serde_json", "serde_json",
@ -1720,6 +1823,12 @@ 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"
@ -1792,6 +1901,37 @@ 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"
@ -1805,6 +1945,17 @@ 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"
@ -1833,6 +1984,16 @@ 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"
@ -1935,6 +2096,15 @@ 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.
["Happy path" demo showing home screen --> GCompris --> home screen](https://github.com/user-attachments/assets/1aed2040-b381-4022-8353-5ce076b1eee0) <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>
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
[TuxMath session shown about to expire, including warnings and automatic termination](https://github.com/user-attachments/assets/541aa456-ef7c-4974-b918-5b143c5304c3) <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>
### Anything on Linux ### Anything on Linux
@ -84,9 +84,37 @@ If it can run on Linux in *any way, shape, or form*, it can be supervised by
## Non-goals ## Non-goals
* Modifying or patching third-party applications 1. Modifying or patching third-party applications
* Circumventing DRM or platform protections 2. Circumventing DRM or platform protections
* Replacing parental involvement with automation 3. Replacing parental involvement with automation or third-party content moderation
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
@ -149,9 +177,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, look on If you'd like to help out, you can find potential work items on
[GitHub Issues](https://github.com/aarmea/shepherd-launcher/issues) for [the Issues page](https://git.armeafamily.com/albert/shepherd-launcher/issues).
potential work items. You may email me patch sets at <shepherd-launcher-patch@albertarmea.com>.
## 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::{ClientRole, StopMode, API_VERSION}; use crate::{API_VERSION, ClientRole, StopMode};
/// Request wrapper with metadata /// Request wrapper with metadata
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -128,7 +128,6 @@ pub enum Command {
GetHealth, GetHealth,
// Volume control commands // Volume control commands
/// Get current volume status /// Get current volume status
GetVolume, GetVolume,
@ -142,7 +141,6 @@ 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::{ServiceStateSnapshot, SessionEndReason, WarningSeverity, API_VERSION}; use crate::{API_VERSION, ServiceStateSnapshot, SessionEndReason, WarningSeverity};
/// Event envelope /// Event envelope
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -51,9 +51,7 @@ pub enum EventPayload {
}, },
/// Session is expiring (termination initiated) /// Session is expiring (termination initiated)
SessionExpiring { SessionExpiring { session_id: SessionId },
session_id: SessionId,
},
/// Session has ended /// Session has ended
SessionEnded { SessionEnded {
@ -64,21 +62,13 @@ pub enum EventPayload {
}, },
/// Policy was reloaded /// Policy was reloaded
PolicyReloaded { PolicyReloaded { entry_count: usize },
entry_count: usize,
},
/// Entry availability changed (for UI updates) /// Entry availability changed (for UI updates)
EntryAvailabilityChanged { EntryAvailabilityChanged { entry_id: EntryId, enabled: bool },
entry_id: EntryId,
enabled: bool,
},
/// Volume status changed /// Volume status changed
VolumeChanged { VolumeChanged { percent: u8, muted: bool },
percent: u8,
muted: bool,
},
/// Service is shutting down /// Service is shutting down
Shutdown, Shutdown,
@ -107,7 +97,10 @@ 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!(parsed.payload, EventPayload::SessionStarted { .. })); assert!(matches!(
parsed.payload,
EventPayload::SessionStarted { .. }
));
} }
#[test] #[test]

View file

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

View file

@ -29,7 +29,10 @@ fn main() -> ExitCode {
// Check file exists // Check file exists
if !config_path.exists() { if !config_path.exists() {
eprintln!("Error: Configuration file not found: {}", config_path.display()); eprintln!(
"Error: Configuration file not found: {}",
config_path.display()
);
return ExitCode::from(1); return ExitCode::from(1);
} }
@ -39,7 +42,10 @@ fn main() -> ExitCode {
println!("✓ Configuration is valid"); println!("✓ Configuration is valid");
println!(); println!();
println!("Summary:"); println!("Summary:");
println!(" Config version: {}", shepherd_config::CURRENT_CONFIG_VERSION); println!(
" 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,8 +65,9 @@ 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 => port_opt InternetCheckScheme::Tcp => {
.ok_or_else(|| "tcp check requires explicit port".to_string())?, port_opt.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()),
}; };
@ -149,4 +150,3 @@ 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,16 +1,19 @@
//! Validated policy structures //! Validated policy structures
use crate::schema::{
RawConfig, RawEntry, RawEntryKind, RawInternetConfig, RawVolumeConfig, RawServiceConfig,
RawWarningThreshold,
};
use crate::internet::{ use crate::internet::{
EntryInternetPolicy, InternetCheckTarget, InternetConfig, DEFAULT_INTERNET_CHECK_INTERVAL, DEFAULT_INTERNET_CHECK_INTERVAL, DEFAULT_INTERNET_CHECK_TIMEOUT, EntryInternetPolicy,
DEFAULT_INTERNET_CHECK_TIMEOUT, InternetCheckTarget, InternetConfig,
};
use crate::schema::{
RawConfig, RawEntry, RawEntryKind, RawInternetConfig, RawServiceConfig, RawVolumeConfig,
RawWarningThreshold,
}; };
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::{DaysOfWeek, EntryId, TimeWindow, WallClock, default_data_dir, default_log_dir, socket_path_without_env}; use shepherd_util::{
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;
@ -94,24 +97,17 @@ pub struct ServiceConfig {
impl ServiceConfig { impl ServiceConfig {
fn from_raw(raw: RawServiceConfig) -> Self { fn from_raw(raw: RawServiceConfig) -> Self {
let log_dir = raw let log_dir = raw.log_dir.clone().unwrap_or_else(default_log_dir);
.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: raw.socket_path.unwrap_or_else(socket_path_without_env),
.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: raw.data_dir.unwrap_or_else(default_data_dir),
.data_dir
.unwrap_or_else(default_data_dir),
internet, internet,
} }
} }
@ -126,7 +122,11 @@ 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(None, DEFAULT_INTERNET_CHECK_INTERVAL, DEFAULT_INTERNET_CHECK_TIMEOUT), internet: InternetConfig::new(
None,
DEFAULT_INTERNET_CHECK_INTERVAL,
DEFAULT_INTERNET_CHECK_TIMEOUT,
),
} }
} }
} }
@ -212,10 +212,7 @@ impl AvailabilityPolicy {
} }
/// Get remaining time in current window /// Get remaining time in current window
pub fn remaining_in_window( pub fn remaining_in_window(&self, dt: &chrono::DateTime<chrono::Local>) -> Option<Duration> {
&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
} }
@ -269,8 +266,28 @@ impl VolumePolicy {
fn convert_entry_kind(raw: RawEntryKind) -> EntryKind { fn convert_entry_kind(raw: RawEntryKind) -> EntryKind {
match raw { match raw {
RawEntryKind::Process { command, args, env, cwd } => EntryKind::Process { command, args, env, cwd }, RawEntryKind::Process {
RawEntryKind::Snap { snap_name, command, args, env } => EntryKind::Snap { snap_name, command, args, env }, command,
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 },
@ -347,7 +364,10 @@ fn seconds_to_duration_or_unlimited(secs: u64) -> Option<Duration> {
} }
} }
fn convert_limits(raw: crate::schema::RawLimits, default_max_run: Option<Duration>) -> LimitsPolicy { fn convert_limits(
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::schema::{RawConfig, RawDays, RawEntry, RawEntryKind, RawTimeWindow};
use crate::internet::InternetCheckTarget; use crate::internet::InternetCheckTarget;
use crate::schema::{RawConfig, RawDays, RawEntry, RawEntryKind, RawTimeWindow};
use std::collections::HashSet; use std::collections::HashSet;
use thiserror::Error; use thiserror::Error;
@ -38,26 +38,29 @@ 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!( {
"Invalid internet check '{}': {}", errors.push(ValidationError::GlobalError(format!(
check, e "Invalid internet check '{}': {}",
))); 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( {
"Internet check interval_seconds must be > 0".into(), errors.push(ValidationError::GlobalError(
)); "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( {
"Internet check timeout_ms must be > 0".into(), errors.push(ValidationError::GlobalError(
)); "Internet check timeout_ms must be > 0".into(),
} ));
}
} }
// Check for duplicate entry IDs // Check for duplicate entry IDs
@ -156,28 +159,30 @@ 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 { {
if warning.seconds_before >= max_run { for warning in warnings {
errors.push(ValidationError::WarningExceedsMaxRun { if warning.seconds_before >= max_run {
entry_id: entry.id.clone(), errors.push(ValidationError::WarningExceedsMaxRun {
seconds: warning.seconds_before, entry_id: entry.id.clone(),
max_run, seconds: warning.seconds_before,
}); 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 { {
entry_id: entry.id.clone(), errors.push(ValidationError::EntryError {
message: format!("Invalid internet check '{}': {}", check, e), entry_id: entry.id.clone(),
}); 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()
@ -236,12 +241,8 @@ 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] let hour: u8 = parts[0].parse().map_err(|_| "Invalid hour".to_string())?;
.parse() let minute: u8 = parts[1].parse().map_err(|_| "Invalid minute".to_string())?;
.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());
@ -299,12 +300,23 @@ mod tests {
#[test] #[test]
fn test_parse_days() { fn test_parse_days() {
assert_eq!(parse_days(&RawDays::Preset("weekdays".into())).unwrap(), 0x1F); assert_eq!(
assert_eq!(parse_days(&RawDays::Preset("weekends".into())).unwrap(), 0x60); parse_days(&RawDays::Preset("weekdays".into())).unwrap(),
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!["mon".into(), "wed".into(), "fri".into()])).unwrap(), parse_days(&RawDays::List(vec![
"mon".into(),
"wed".into(),
"fri".into()
]))
.unwrap(),
0b10101 0b10101
); );
} }
@ -355,6 +367,10 @@ mod tests {
}; };
let errors = validate_config(&config); let errors = validate_config(&config);
assert!(errors.iter().any(|e| matches!(e, ValidationError::DuplicateEntryId(_)))); assert!(
errors
.iter()
.any(|e| matches!(e, ValidationError::DuplicateEntryId(_)))
);
} }
} }

View file

@ -2,10 +2,9 @@
use chrono::{DateTime, Local}; use chrono::{DateTime, Local};
use shepherd_api::{ use shepherd_api::{
ServiceStateSnapshot, EntryView, ReasonCode, SessionEndReason, API_VERSION, EntryView, ReasonCode, ServiceStateSnapshot, SessionEndReason, WarningSeverity,
WarningSeverity, API_VERSION,
}; };
use shepherd_config::{Entry, Policy, InternetCheckTarget}; use shepherd_config::{Entry, InternetCheckTarget, Policy};
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};
@ -44,11 +43,7 @@ pub struct CoreEngine {
impl CoreEngine { impl CoreEngine {
/// Create a new core engine /// Create a new core engine
pub fn new( pub fn new(policy: Policy, store: Arc<dyn Store>, capabilities: HostCapabilities) -> Self {
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"
@ -79,9 +74,11 @@ impl CoreEngine {
let entry_count = policy.entries.len(); let entry_count = policy.entries.len();
self.policy = policy; self.policy = policy;
let _ = self.store.append_audit(AuditEvent::new(AuditEventType::PolicyLoaded { let _ = self
entry_count, .store
})); .append_audit(AuditEvent::new(AuditEventType::PolicyLoaded {
entry_count,
}));
info!(entry_count, "Policy reloaded"); info!(entry_count, "Policy reloaded");
@ -137,11 +134,12 @@ impl CoreEngine {
// Check internet requirement // Check internet requirement
if entry.internet.required { if entry.internet.required {
let check = entry let check = entry.internet.check.as_ref().or(self
.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);
@ -165,19 +163,23 @@ 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; {
reasons.push(ReasonCode::CooldownActive { available_at: until }); enabled = false;
} 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; {
reasons.push(ReasonCode::QuotaExhausted { used, quota }); enabled = false;
} 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)
@ -227,11 +229,7 @@ impl CoreEngine {
} }
/// Request to launch an entry /// Request to launch an entry
pub fn request_launch( pub fn request_launch(&self, entry_id: &EntryId, now: DateTime<Local>) -> LaunchDecision {
&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,
@ -249,10 +247,12 @@ impl CoreEngine {
if !view.enabled { if !view.enabled {
// Log denial // Log denial
let _ = self.store.append_audit(AuditEvent::new(AuditEventType::LaunchDenied { let _ = self
entry_id: entry_id.clone(), .store
reasons: view.reasons.iter().map(|r| format!("{:?}", r)).collect(), .append_audit(AuditEvent::new(AuditEventType::LaunchDenied {
})); 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,12 +302,14 @@ impl CoreEngine {
}; };
// Log to audit // Log to audit
let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionStarted { let _ = self
session_id: session.plan.session_id.clone(), .store
entry_id: session.plan.entry_id.clone(), .append_audit(AuditEvent::new(AuditEventType::SessionStarted {
label: session.plan.label.clone(), session_id: session.plan.session_id.clone(),
deadline: session.deadline, entry_id: session.plan.entry_id.clone(),
})); label: session.plan.label.clone(),
deadline: session.deadline,
}));
if let Some(deadline) = session.deadline { if let Some(deadline) = session.deadline {
info!( info!(
@ -384,10 +386,12 @@ impl CoreEngine {
session.mark_warning_issued(threshold); session.mark_warning_issued(threshold);
// Log to audit // Log to audit
let _ = self.store.append_audit(AuditEvent::new(AuditEventType::WarningIssued { let _ = self
session_id: session.plan.session_id.clone(), .store
threshold_seconds: threshold, .append_audit(AuditEvent::new(AuditEventType::WarningIssued {
})); session_id: session.plan.session_id.clone(),
threshold_seconds: threshold,
}));
info!( info!(
session_id = %session.plan.session_id, session_id = %session.plan.session_id,
@ -443,22 +447,27 @@ impl CoreEngine {
// Update usage accounting // Update usage accounting
let today = now.date_naive(); let today = now.date_naive();
let _ = self.store.add_usage(&session.plan.entry_id, today, duration); let _ = self
.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 _ = self.store.set_cooldown_until(&session.plan.entry_id, until); let until = now + chrono::Duration::from_std(cooldown).unwrap();
} let _ = self.store.set_cooldown_until(&session.plan.entry_id, until);
}
// Log to audit // Log to audit
let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionEnded { let _ = self
session_id: session.plan.session_id.clone(), .store
entry_id: session.plan.entry_id.clone(), .append_audit(AuditEvent::new(AuditEventType::SessionEnded {
reason: reason.clone(), session_id: session.plan.session_id.clone(),
duration, entry_id: session.plan.entry_id.clone(),
})); reason: reason.clone(),
duration,
}));
info!( info!(
session_id = %session.plan.session_id, session_id = %session.plan.session_id,
@ -492,22 +501,27 @@ impl CoreEngine {
// Update usage accounting // Update usage accounting
let today = now.date_naive(); let today = now.date_naive();
let _ = self.store.add_usage(&session.plan.entry_id, today, duration); let _ = self
.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 _ = self.store.set_cooldown_until(&session.plan.entry_id, until); let until = now + chrono::Duration::from_std(cooldown).unwrap();
} let _ = self.store.set_cooldown_until(&session.plan.entry_id, until);
}
// Log to audit // Log to audit
let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionEnded { let _ = self
session_id: session.plan.session_id.clone(), .store
entry_id: session.plan.entry_id.clone(), .append_audit(AuditEvent::new(AuditEventType::SessionEnded {
reason: reason.clone(), session_id: session.plan.session_id.clone(),
duration, entry_id: session.plan.entry_id.clone(),
})); reason: reason.clone(),
duration,
}));
info!( info!(
session_id = %session.plan.session_id, session_id = %session.plan.session_id,
@ -525,9 +539,10 @@ 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.current_session.as_ref().map(|s| { let current_session = self
s.to_session_info(MonotonicInstant::now()) .current_session
}); .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());
@ -577,11 +592,13 @@ impl CoreEngine {
session.deadline = Some(new_deadline); session.deadline = Some(new_deadline);
// Log to audit // Log to audit
let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionExtended { let _ = self
session_id: session.plan.session_id.clone(), .store
extended_by: by, .append_audit(AuditEvent::new(AuditEventType::SessionExtended {
new_deadline, session_id: session.plan.session_id.clone(),
})); extended_by: by,
new_deadline,
}));
info!( info!(
session_id = %session.plan.session_id, session_id = %session.plan.session_id,
@ -597,8 +614,8 @@ impl CoreEngine {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use shepherd_config::{AvailabilityPolicy, Entry, LimitsPolicy};
use shepherd_api::EntryKind; use shepherd_api::EntryKind;
use shepherd_config::{AvailabilityPolicy, Entry, LimitsPolicy};
use shepherd_store::SqliteStore; use shepherd_store::SqliteStore;
use std::collections::HashMap; use std::collections::HashMap;
@ -736,19 +753,34 @@ 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.iter().filter(|e| matches!(e, CoreEvent::Warning { .. })).collect(); let warning_events: Vec<_> = events
.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.iter().filter(|e| matches!(e, CoreEvent::Warning { .. })).collect(); let warning_events: Vec<_> = events
.iter()
.filter(|e| matches!(e, CoreEvent::Warning { .. }))
.collect();
assert_eq!(warning_events.len(), 1); assert_eq!(warning_events.len(), 1);
assert!(matches!(warning_events[0], CoreEvent::Warning { threshold_seconds: 60, .. })); assert!(matches!(
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.iter().filter(|e| matches!(e, CoreEvent::Warning { .. })).collect(); let warning_events: Vec<_> = events
.iter()
.filter(|e| matches!(e, CoreEvent::Warning { .. }))
.collect();
assert!(warning_events.is_empty()); assert!(warning_events.is_empty());
} }
@ -803,7 +835,10 @@ 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.iter().filter(|e| matches!(e, CoreEvent::ExpireDue { .. })).collect(); let expiry_events: Vec<_> = events
.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,9 +30,7 @@ pub enum CoreEvent {
}, },
/// Session is expiring (termination initiated) /// Session is expiring (termination initiated)
ExpireDue { ExpireDue { session_id: SessionId },
session_id: SessionId,
},
/// Session has ended /// Session has ended
SessionEnded { SessionEnded {
@ -43,13 +41,8 @@ pub enum CoreEvent {
}, },
/// Entry availability changed /// Entry availability changed
EntryAvailabilityChanged { EntryAvailabilityChanged { entry_id: EntryId, enabled: bool },
entry_id: EntryId,
enabled: bool,
},
/// Policy was reloaded /// Policy was reloaded
PolicyReloaded { PolicyReloaded { entry_count: usize },
entry_count: usize,
},
} }

View file

@ -29,8 +29,7 @@ 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 = let trigger_after = max_duration - Duration::from_secs(w.seconds_before);
max_duration - Duration::from_secs(w.seconds_before);
(w.seconds_before, trigger_after) (w.seconds_before, trigger_after)
}) })
.collect() .collect()
@ -67,11 +66,7 @@ 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( pub fn new(plan: SessionPlan, now: DateTime<Local>, now_mono: MonotonicInstant) -> Self {
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();
@ -101,7 +96,8 @@ 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.map(|deadline| deadline.saturating_duration_until(now_mono)) self.deadline_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)
@ -220,7 +216,10 @@ 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!(session.time_remaining(now_mono), Some(Duration::from_secs(300))); assert_eq!(
session.time_remaining(now_mono),
Some(Duration::from_secs(300))
);
} }
#[test] #[test]

View file

@ -18,7 +18,10 @@ 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 { session_id, payload } Self {
session_id,
payload,
}
} }
pub fn payload(&self) -> &HostHandlePayload { pub fn payload(&self) -> &HostHandlePayload {
@ -31,27 +34,16 @@ 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 { Linux { pid: u32, pgid: u32 },
pid: u32,
pgid: u32,
},
/// Windows: job object handle (serialized as name/id) /// Windows: job object handle (serialized as name/id)
Windows { Windows { job_name: String, process_id: u32 },
job_name: String,
process_id: u32,
},
/// macOS: bundle or process identifier /// macOS: bundle or process identifier
MacOs { MacOs { pid: u32, bundle_id: Option<String> },
pid: u32,
bundle_id: Option<String>,
},
/// Mock for testing /// Mock for testing
Mock { Mock { id: u64 },
id: u64,
},
} }
impl HostHandlePayload { impl HostHandlePayload {
@ -117,7 +109,10 @@ mod tests {
fn handle_serialization() { fn handle_serialization() {
let handle = HostSessionHandle::new( let handle = HostSessionHandle::new(
SessionId::new(), SessionId::new(),
HostHandlePayload::Linux { pid: 1234, pgid: 1234 }, HostHandlePayload::Linux {
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, ExitStatus, HostAdapter, HostCapabilities, HostError, HostEvent, HostHandlePayload, HostResult,
HostResult, HostSessionHandle, SpawnOptions, StopMode, HostSessionHandle, SpawnOptions, StopMode,
}; };
/// Mock session state for testing /// Mock session state for testing
@ -79,7 +79,9 @@ 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 { id: session.mock_id }, HostHandlePayload::Mock {
id: session.mock_id,
},
); );
let _ = self.event_tx.send(HostEvent::Exited { handle, status }); let _ = self.event_tx.send(HostEvent::Exited { handle, status });
} }
@ -122,12 +124,13 @@ impl HostAdapter for MockHost {
exit_delay: *self.auto_exit_delay.lock().unwrap(), exit_delay: *self.auto_exit_delay.lock().unwrap(),
}; };
self.sessions.lock().unwrap().insert(mock_id, session.clone()); self.sessions
.lock()
.unwrap()
.insert(mock_id, session.clone());
let handle = HostSessionHandle::new( let handle =
session_id.clone(), HostSessionHandle::new(session_id.clone(), HostHandlePayload::Mock { id: mock_id });
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,9 +82,7 @@ pub enum HostEvent {
}, },
/// Window is ready (for UI notification) /// Window is ready (for UI notification)
WindowReady { WindowReady { handle: HostSessionHandle },
handle: HostSessionHandle,
},
/// Spawn failed after handle was created /// Spawn failed after handle was created
SpawnFailed { SpawnFailed {
@ -141,6 +139,8 @@ mod tests {
#[test] #[test]
fn stop_mode_default() { fn stop_mode_default() {
let mode = StopMode::default(); let mode = StopMode::default();
assert!(matches!(mode, StopMode::Graceful { timeout } if timeout == Duration::from_secs(5))); assert!(
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, ExitStatus, HostAdapter, HostCapabilities, HostError, HostEvent, HostHandlePayload, HostResult,
HostResult, HostSessionHandle, SpawnOptions, StopMode, 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::{
find_steam_game_pids, init, kill_by_command, kill_flatpak_cgroup, kill_snap_cgroup, ManagedProcess, find_steam_game_pids, init, kill_by_command, kill_flatpak_cgroup,
kill_steam_game_processes, ManagedProcess, kill_snap_cgroup, kill_steam_game_processes,
}; };
/// 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,14 +93,8 @@ 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 { steam_sessions.lock().unwrap().keys().cloned().collect() };
.lock()
.unwrap()
.keys()
.cloned()
.collect()
};
{ {
let mut procs = processes.lock().unwrap(); let mut procs = processes.lock().unwrap();
@ -140,14 +134,8 @@ 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 { steam_sessions.lock().unwrap().values().cloned().collect() };
.lock()
.unwrap()
.values()
.cloned()
.collect()
};
let mut ended = Vec::new(); let mut ended = Vec::new();
@ -206,24 +194,35 @@ 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 { command, args, env, cwd } => { EntryKind::Process {
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.as_ref().map(|c| { let expanded_cwd = cwd
std::path::PathBuf::from(expand_tilde(&c.to_string_lossy())) .as_ref()
}); .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 { snap_name, command, args, env } => { EntryKind::Snap {
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)
} }
@ -257,13 +256,19 @@ impl HostAdapter for LinuxHost {
} }
(argv, HashMap::new(), None, None, None, None) (argv, HashMap::new(), None, None, None, None)
} }
EntryKind::Media { library_id, args: _ } => { EntryKind::Media {
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 { type_name: _, payload: _ } => { EntryKind::Custom {
type_name: _,
payload: _,
} => {
return Err(HostError::UnsupportedKind); return Err(HostError::UnsupportedKind);
} }
}; };
@ -301,13 +306,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.lock().unwrap().insert(session_id.clone(), session_info_entry); self.session_info
.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( let handle = HostSessionHandle::new(session_id, HostHandlePayload::Linux { pid, pgid });
session_id,
HostHandlePayload::Linux { pid, pgid },
);
self.processes.lock().unwrap().insert(pid, proc); self.processes.lock().unwrap().insert(pid, proc);
@ -354,11 +359,15 @@ 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 _ = kill_steam_game_processes(app_id, nix::sys::signal::Signal::SIGTERM); let _ =
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!(steam_app_id = app_id, "Sent SIGTERM to Steam game processes"); info!(
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");
@ -370,7 +379,10 @@ 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.as_ref().and_then(|info| info.steam_app_id).is_some(); let is_steam = session_info
.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) {
@ -388,13 +400,22 @@ 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(app_id, nix::sys::signal::Signal::SIGKILL); let _ = kill_steam_game_processes(
info!(steam_app_id = app_id, "Sent SIGKILL to Steam game processes (timeout)"); app_id,
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(&info.command_name, nix::sys::signal::Signal::SIGKILL); kill_by_command(
&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)");
} }
} }
@ -433,11 +454,15 @@ 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 _ = kill_steam_game_processes(app_id, nix::sys::signal::Signal::SIGKILL); let _ =
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!(steam_app_id = app_id, "Sent SIGKILL to Steam game processes"); info!(
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");
@ -448,7 +473,10 @@ 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.as_ref().and_then(|info| info.steam_app_id).is_some(); let is_steam = session_info
.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,7 +83,10 @@ pub fn kill_snap_cgroup(snap_name: &str, _signal: Signal) -> bool {
} }
if stopped_any { if stopped_any {
info!(snap = snap_name, "Killed snap scope(s) via systemctl SIGKILL"); info!(
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");
} }
@ -147,7 +150,10 @@ pub fn kill_flatpak_cgroup(app_id: &str, _signal: Signal) -> bool {
} }
if stopped_any { if stopped_any {
info!(app_id = app_id, "Killed flatpak scope(s) via systemctl SIGKILL"); info!(
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");
} }
@ -226,11 +232,18 @@ 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!(command = command_name, signal = signal_name, "Killed processes by command name"); info!(
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!(command = command_name, "No processes found matching command name"); debug!(
command = command_name,
"No processes found matching command name"
);
false false
} }
} }
@ -276,7 +289,8 @@ 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.iter() let original_cmd = argv
.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(" ");
@ -465,23 +479,27 @@ 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| { nix::unistd::setsid().map_err(|e| std::io::Error::other(e.to_string()))?;
std::io::Error::other(e.to_string())
})?;
Ok(()) Ok(())
}); });
} }
let child = cmd.spawn().map_err(|e| { let child = cmd
HostError::SpawnFailed(format!("Failed to spawn {}: {}", program, e)) .spawn()
})?; .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 { child, pid, pgid, command_name, snap_name }) Ok(Self {
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
@ -508,10 +526,11 @@ 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); {
to_check.push(pid); descendants.push(pid);
} to_check.push(pid);
}
} }
} }
} }

View file

@ -148,9 +148,10 @@ 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
@ -185,9 +186,10 @@ 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;
@ -210,7 +212,11 @@ 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(["set-sink-volume", "@DEFAULT_SINK@", &format!("{}%", percent)]) .args([
"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(())
@ -323,7 +329,10 @@ 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.percent.saturating_add(step).min(self.capabilities.max_volume); let new_volume = current
.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.clone().unwrap_or_else(|| { let warning_text = message
format!("Only {} seconds remaining!", remaining) .clone()
}); .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,16 +35,18 @@ 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.charging = charging; status.percent = Some(percent);
} 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,8 +43,7 @@ fn main() -> Result<()> {
// Initialize logging // Initialize logging
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_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)),
) )
.init(); .init();

View file

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

View file

@ -60,9 +60,7 @@ 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) => { shepherd_api::ResponseResult::Err(e) => Err(anyhow::anyhow!("Error: {}", e.message)),
Err(anyhow::anyhow!("Error: {}", e.message))
}
_ => Err(anyhow::anyhow!("Unexpected response")), _ => Err(anyhow::anyhow!("Unexpected response")),
} }
}) })
@ -83,9 +81,7 @@ 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) => { shepherd_api::ResponseResult::Err(e) => Err(anyhow::anyhow!("Error: {}", e.message)),
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::{broadcast, mpsc, Mutex, RwLock}; use tokio::sync::{Mutex, RwLock, broadcast, mpsc};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use crate::{IpcError, IpcResult}; use crate::{IpcError, IpcResult};
@ -75,10 +75,9 @@ 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) = std::fs::set_permissions( if let Err(err) =
&self.socket_path, std::fs::set_permissions(&self.socket_path, std::fs::Permissions::from_mode(0o660))
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(),
@ -190,7 +189,8 @@ 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,13 +342,14 @@ 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!( {
"Skipping IPC server start test due to permission error: {}", eprintln!(
io_err "Skipping IPC server start test due to permission error: {}",
); io_err
return; );
} return;
}
panic!("IPC server start failed: {err}"); panic!("IPC server start failed: {err}");
} }

View file

@ -24,6 +24,7 @@ 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

@ -4,9 +4,10 @@ use gtk4::glib;
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}; use tracing::{debug, error, info, warn};
use crate::client::{CommandClient, ServiceClient}; use crate::client::{CommandClient, ServiceClient};
use crate::grid::LauncherGrid; use crate::grid::LauncherGrid;
@ -41,6 +42,13 @@ window {
border-color: #4a90d9; border-color: #4a90d9;
} }
.launcher-tile:focus,
.launcher-tile:focus-visible {
background: #1f3460;
background-color: #1f3460;
border-color: #ffd166;
}
.launcher-tile:active { .launcher-tile:active {
background: #0f3460; background: #0f3460;
background-color: #0f3460; background-color: #0f3460;
@ -168,6 +176,14 @@ 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();
@ -296,7 +312,8 @@ 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().expect("Failed to create tokio runtime for event loop"); let rt = tokio::runtime::Runtime::new()
.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;
@ -342,6 +359,7 @@ 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);
@ -381,6 +399,199 @@ 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);
@ -458,3 +669,11 @@ impl LauncherApp {
(container, retry_button) (container, retry_button)
} }
} }
#[derive(Default)]
struct GamepadAxisState {
left: bool,
right: bool,
up: bool,
down: bool,
}

View file

@ -162,11 +162,17 @@ 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!(self.state.get(), LauncherState::Idle { .. } | LauncherState::Connecting) { if matches!(
self.state.get(),
LauncherState::Idle { .. } | LauncherState::Connecting
) {
self.state.set(LauncherState::Idle { entries }); self.state.set(LauncherState::Idle { entries });
} }
} }
ResponsePayload::LaunchApproved { session_id, deadline } => { ResponsePayload::LaunchApproved {
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| {
@ -195,9 +201,7 @@ impl ServiceClient {
Ok(()) Ok(())
} }
ResponseResult::Err(e) => { ResponseResult::Err(e) => {
self.state.set(LauncherState::Error { self.state.set(LauncherState::Error { message: e.message });
message: e.message,
});
Ok(()) Ok(())
} }
} }
@ -218,17 +222,23 @@ 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.send(Command::Launch { client
entry_id: entry_id.clone(), .send(Command::Launch {
}).await.map_err(Into::into) entry_id: entry_id.clone(),
})
.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.send(Command::StopCurrent { client
mode: shepherd_api::StopMode::Graceful, .send(Command::StopCurrent {
}).await.map_err(Into::into) mode: shepherd_api::StopMode::Graceful,
})
.await
.map_err(Into::into)
} }
pub async fn get_state(&self) -> Result<Response> { pub async fn get_state(&self) -> Result<Response> {
@ -239,7 +249,10 @@ 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.send(Command::ListEntries { at_time: None }).await.map_err(Into::into) client
.send(Command::ListEntries { at_time: None })
.await
.map_err(Into::into)
} }
} }

View file

@ -51,7 +51,8 @@ mod imp {
// Configure flow box // Configure flow box
self.flow_box.set_homogeneous(true); self.flow_box.set_homogeneous(true);
self.flow_box.set_selection_mode(gtk4::SelectionMode::None); self.flow_box
.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);
@ -60,6 +61,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.add_css_class("launcher-grid"); self.flow_box.add_css_class("launcher-grid");
// Wrap in a scrolled window // Wrap in a scrolled window
@ -117,14 +119,17 @@ 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();
} }
/// Enable or disable all tiles /// Enable or disable all tiles
@ -133,6 +138,114 @@ impl LauncherGrid {
tile.set_sensitive(sensitive); tile.set_sensitive(sensitive);
} }
} }
pub fn select_first(&self) {
let imp = self.imp();
if let Some(child) = imp.flow_box.child_at_index(0) {
imp.flow_box.select_child(&child);
child.grab_focus();
}
}
pub fn move_selection(&self, dx: i32, dy: i32) {
let imp = self.imp();
if imp.tiles.borrow().is_empty() {
return;
}
let current_child = imp
.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) {
let imp = self.imp();
let maybe_child = imp.flow_box.selected_children().first().cloned();
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);
}
}
}
} }
impl Default for LauncherGrid { impl Default for LauncherGrid {

View file

@ -9,8 +9,10 @@ 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;
@ -27,6 +29,10 @@ 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<()> {
@ -35,8 +41,7 @@ fn main() -> Result<()> {
// Initialize logging // Initialize logging
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_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)),
) )
.init(); .init();
@ -45,6 +50,33 @@ 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::{ServiceStateSnapshot, EntryView, Event, EventPayload}; use shepherd_api::{EntryView, Event, EventPayload, ServiceStateSnapshot};
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,7 +62,10 @@ 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!(has_session = snapshot.current_session.is_some(), "Applying state snapshot"); tracing::info!(
has_session = snapshot.current_session.is_some(),
"Applying state snapshot"
);
self.apply_snapshot(snapshot); self.apply_snapshot(snapshot);
} }
EventPayload::SessionStarted { EventPayload::SessionStarted {
@ -87,7 +90,12 @@ impl SharedState {
time_remaining, time_remaining,
}); });
} }
EventPayload::SessionEnded { session_id, entry_id, reason, .. } => { EventPayload::SessionEnded {
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

@ -143,7 +143,11 @@ impl LauncherTile {
} }
pub fn entry_id(&self) -> Option<shepherd_util::EntryId> { pub fn entry_id(&self) -> Option<shepherd_util::EntryId> {
self.imp().entry.borrow().as_ref().map(|e| e.entry_id.clone()) self.imp()
.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::{params, Connection, OptionalExtension}; use rusqlite::{Connection, OptionalExtension, params};
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,9 +98,8 @@ 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.prepare( let mut stmt = conn
"SELECT id, timestamp, event_json FROM audit_log ORDER BY id DESC LIMIT ?", .prepare("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)?;
@ -181,11 +180,7 @@ impl Store for SqliteStore {
Ok(result) Ok(result)
} }
fn set_cooldown_until( fn set_cooldown_until(&self, entry_id: &EntryId, until: DateTime<Local>) -> StoreResult<()> {
&self,
entry_id: &EntryId,
until: DateTime<Local>,
) -> StoreResult<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
conn.execute( conn.execute(
@ -204,7 +199,10 @@ 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("DELETE FROM cooldowns WHERE entry_id = ?", [entry_id.as_str()])?; conn.execute(
"DELETE FROM cooldowns WHERE entry_id = ?",
[entry_id.as_str()],
)?;
Ok(()) Ok(())
} }
@ -212,9 +210,11 @@ 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("SELECT snapshot_json FROM snapshot WHERE id = 1", [], |row| { .query_row(
row.get(0) "SELECT snapshot_json FROM snapshot WHERE id = 1",
}) [],
|row| row.get(0),
)
.optional()?; .optional()?;
match json { match json {
@ -246,9 +246,7 @@ impl Store for SqliteStore {
fn is_healthy(&self) -> bool { fn is_healthy(&self) -> bool {
match self.conn.lock() { match self.conn.lock() {
Ok(conn) => { Ok(conn) => conn.query_row("SELECT 1", [], |_| Ok(())).is_ok(),
conn.query_row("SELECT 1", [], |_| Ok(())).is_ok()
}
Err(_) => { Err(_) => {
warn!("Store lock poisoned"); warn!("Store lock poisoned");
false false

View file

@ -30,11 +30,7 @@ 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( fn set_cooldown_until(&self, entry_id: &EntryId, until: DateTime<Local>) -> StoreResult<()>;
&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,7 +40,9 @@ 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).join(APP_DIR).join(SOCKET_FILENAME); return PathBuf::from(runtime_dir)
.join(APP_DIR)
.join(SOCKET_FILENAME);
} }
// Fallback to /tmp with username // Fallback to /tmp with username
@ -109,10 +111,13 @@ 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.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| { socket_path
// Should never happen with our paths, but just in case .parent()
PathBuf::from("/tmp").join(APP_DIR) .map(|p| p.to_path_buf())
}) .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,10 +42,13 @@ 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.clients.entry(client_id.clone()).or_insert(ClientBucket { let bucket = self
tokens: self.max_tokens, .clients
last_refill: now, .entry(client_id.clone())
}); .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);
@ -72,9 +75,8 @@ 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.retain(|_, bucket| { self.clients
now.duration_since(bucket.last_refill) < stale_after .retain(|_, bucket| now.duration_since(bucket.last_refill) < stale_after);
});
} }
} }

View file

@ -37,7 +37,9 @@ 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) = NaiveDateTime::parse_from_str(&mock_time_str, "%Y-%m-%d %H:%M:%S") { if let Ok(naive_dt) =
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);
@ -201,9 +203,8 @@ 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 = DaysOfWeek( pub const WEEKDAYS: DaysOfWeek =
Self::MONDAY | Self::TUESDAY | Self::WEDNESDAY | Self::THURSDAY | Self::FRIDAY, DaysOfWeek(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);
@ -446,14 +447,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 {
@ -528,21 +529,30 @@ 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!(window.contains(&in_window), "15:00 should be within 14:00-18:00 window"); assert!(
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!(!window.contains(&before_window), "10:00 should be before 14:00-18:00 window"); assert!(
!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!(!window.contains(&after_window), "20:00 should be after 14:00-18:00 window"); assert!(
!window.contains(&after_window),
"20:00 should be after 14:00-18:00 window"
);
} }
#[test] #[test]
@ -556,15 +566,24 @@ 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!(window.contains(&thursday), "Thursday 15:00 should be in weekday afternoon window"); assert!(
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!(!window.contains(&saturday), "Saturday should not be in weekday window"); assert!(
!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!(!window.contains(&sunday), "Sunday should not be in weekday window"); assert!(
!window.contains(&sunday),
"Sunday should not be in weekday window"
);
} }
} }

View file

@ -26,9 +26,10 @@ 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, Command, ErrorCode, ErrorInfo, Event, EventPayload, HealthStatus, Response, ResponsePayload,
Response, ResponsePayload, SessionEndReason, StopMode, VolumeInfo, VolumeRestrictions, SessionEndReason, StopMode, VolumeInfo, VolumeRestrictions,
}; };
use shepherd_config::{load_config, VolumePolicy}; use shepherd_config::{VolumePolicy, load_config};
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::{default_config_path, ClientId, MonotonicInstant, RateLimiter}; use shepherd_util::{ClientId, MonotonicInstant, RateLimiter, default_config_path};
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::{signal, SignalKind}; use tokio::signal::unix::{SignalKind, signal};
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,12 +180,11 @@ impl Service {
}); });
// Set up signal handlers // Set up signal handlers
let mut sigterm = signal(SignalKind::terminate()) let mut sigterm =
.context("Failed to create SIGTERM handler")?; signal(SignalKind::terminate()).context("Failed to create SIGTERM handler")?;
let mut sigint = signal(SignalKind::interrupt()) let mut sigint =
.context("Failed to create SIGINT handler")?; signal(SignalKind::interrupt()).context("Failed to create SIGINT handler")?;
let mut sighup = signal(SignalKind::hangup()) let mut sighup = signal(SignalKind::hangup()).context("Failed to create SIGHUP handler")?;
.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);
@ -246,9 +245,16 @@ 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 && let Err(e) = host.stop(handle, HostStopMode::Graceful { if let Some(handle) = &session.host_handle
timeout: Duration::from_secs(5), && let Err(e) = host
}).await { .stop(
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");
} }
} }
@ -301,9 +307,7 @@ 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 engine.current_session().and_then(|s| s.host_handle.clone())
.current_session()
.and_then(|s| s.host_handle.clone())
}; };
if let Some(handle) = handle if let Some(handle) = handle
@ -315,10 +319,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(),
@ -405,7 +409,10 @@ impl Service {
engine.notify_session_exited(status.code, now_mono, now) engine.notify_session_exited(status.code, now_mono, now)
}; };
info!(has_event = core_event.is_some(), "notify_session_exited result"); info!(
has_event = core_event.is_some(),
"notify_session_exited result"
);
if let Some(CoreEvent::SessionEnded { if let Some(CoreEvent::SessionEnded {
session_id, session_id,
@ -413,29 +420,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 } => {
@ -472,9 +479,17 @@ impl Service {
} }
} }
let response = let response = Self::handle_command(
Self::handle_command(engine, host, volume, ipc, store, &client_id, request.request_id, request.command) engine,
.await; host,
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;
} }
@ -487,23 +502,19 @@ impl Service {
"Client connected" "Client connected"
); );
let _ = store.append_audit(AuditEvent::new( let _ = store.append_audit(AuditEvent::new(AuditEventType::ClientConnected {
AuditEventType::ClientConnected { client_id: client_id.to_string(),
client_id: client_id.to_string(), role: format!("{:?}", info.role),
role: format!("{:?}", info.role), uid: info.uid,
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( let _ = store.append_audit(AuditEvent::new(AuditEventType::ClientDisconnected {
AuditEventType::ClientDisconnected { client_id: client_id.to_string(),
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;
@ -547,10 +558,7 @@ 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 let entry_kind = eng.policy().get_entry(&entry_id).map(|e| e.kind.clone());
.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 {
@ -577,11 +585,7 @@ impl Service {
if let Some(kind) = entry_kind { if let Some(kind) = entry_kind {
match host match host
.spawn( .spawn(plan.session_id.clone(), &kind, spawn_options)
plan.session_id.clone(),
&kind,
spawn_options,
)
.await .await
{ {
Ok(handle) => { Ok(handle) => {
@ -597,12 +601,14 @@ impl Service {
deadline, deadline,
} = event } = event
{ {
ipc.broadcast_event(Event::new(EventPayload::SessionStarted { ipc.broadcast_event(Event::new(
session_id: session_id.clone(), EventPayload::SessionStarted {
entry_id, session_id: session_id.clone(),
label, entry_id,
deadline, label,
})); deadline,
},
));
Response::success( Response::success(
request_id, request_id,
@ -614,7 +620,10 @@ impl Service {
} else { } else {
Response::error( Response::error(
request_id, request_id,
ErrorInfo::new(ErrorCode::InternalError, "Unexpected event"), ErrorInfo::new(
ErrorCode::InternalError,
"Unexpected event",
),
) )
} }
} }
@ -627,18 +636,22 @@ 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(EventPayload::SessionEnded { ipc.broadcast_event(Event::new(
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(EventPayload::StateChanged(state))); ipc.broadcast_event(Event::new(
} EventPayload::StateChanged(state),
));
}
Response::error( Response::error(
request_id, request_id,
@ -666,9 +679,7 @@ 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 let handle = eng.current_session().and_then(|s| s.host_handle.clone());
.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,
@ -719,12 +730,13 @@ 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( {
request_id, return Response::error(
ErrorInfo::new(ErrorCode::PermissionDenied, "Admin role required"), request_id,
); ErrorInfo::new(ErrorCode::PermissionDenied, "Admin role required"),
} );
}
// TODO: Reload from original config path // TODO: Reload from original config path
Response::error( Response::error(
@ -733,14 +745,12 @@ impl Service {
) )
} }
Command::SubscribeEvents => { Command::SubscribeEvents => Response::success(
Response::success( request_id,
request_id, ResponsePayload::Subscribed {
ResponsePayload::Subscribed { client_id: client_id.clone(),
client_id: client_id.clone(), },
}, ),
)
}
Command::UnsubscribeEvents => { Command::UnsubscribeEvents => {
Response::success(request_id, ResponsePayload::Unsubscribed) Response::success(request_id, ResponsePayload::Unsubscribed)
@ -761,21 +771,28 @@ 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( {
request_id, return Response::error(
ErrorInfo::new(ErrorCode::PermissionDenied, "Admin role required"), request_id,
); 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) => { Some(new_deadline) => Response::success(
Response::success(request_id, ResponsePayload::Extended { new_deadline: Some(new_deadline) }) request_id,
} ResponsePayload::Extended {
new_deadline: Some(new_deadline),
},
),
None => Response::error( None => Response::error(
request_id, request_id,
ErrorInfo::new(ErrorCode::NoActiveSession, "No active session or session is unlimited"), ErrorInfo::new(
ErrorCode::NoActiveSession,
"No active session or session is unlimited",
),
), ),
} }
} }
@ -917,9 +934,10 @@ 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)
@ -940,18 +958,15 @@ async fn main() -> Result<()> {
let args = Args::parse(); let args = Args::parse();
// Initialize logging // Initialize logging
let filter = EnvFilter::try_from_default_env() let filter =
.unwrap_or_else(|_| EnvFilter::new(&args.log_level)); EnvFilter::try_from_default_env().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!( info!(version = env!("CARGO_PKG_VERSION"), "shepherdd starting");
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,44 +15,42 @@ use std::time::Duration;
fn make_test_policy() -> Policy { fn make_test_policy() -> Policy {
Policy { Policy {
service: Default::default(), service: Default::default(),
entries: vec![ entries: vec![Entry {
Entry { id: EntryId::new("test-game"),
id: EntryId::new("test-game"), label: "Test Game".into(),
label: "Test Game".into(), icon_ref: None,
icon_ref: None, kind: EntryKind::Process {
kind: EntryKind::Process { command: "sleep".into(),
command: "sleep".into(), args: vec!["999".into()],
args: vec!["999".into()], env: HashMap::new(),
env: HashMap::new(), cwd: None,
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 {
seconds_before: 2,
severity: WarningSeverity::Critical,
message_template: Some("2 seconds left!".into()),
},
],
volume: None,
disabled: false,
disabled_reason: None,
internet: Default::default(),
}, },
], 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 {
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(),
@ -91,7 +89,9 @@ 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!(matches!(decision, LaunchDecision::Approved(plan) if plan.max_duration == Some(Duration::from_secs(10)))); assert!(
matches!(decision, LaunchDecision::Approved(plan) if plan.max_duration == Some(Duration::from_secs(10)))
);
} }
#[test] #[test]
@ -150,14 +150,26 @@ 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!(&events[0], CoreEvent::Warning { threshold_seconds: 5, .. })); assert!(matches!(
&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!(&events[0], CoreEvent::Warning { threshold_seconds: 2, .. })); assert!(matches!(
&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);
@ -188,7 +200,9 @@ 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.iter().any(|e| matches!(e, CoreEvent::ExpireDue { .. })); let has_expiry = events
.iter()
.any(|e| matches!(e, CoreEvent::ExpireDue { .. }));
assert!(has_expiry, "Expected ExpireDue event"); assert!(has_expiry, "Expected ExpireDue event");
} }
@ -291,9 +305,18 @@ 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!(policy.entries[0].limits.max_run, Some(Duration::from_secs(3600))); assert_eq!(
assert_eq!(policy.entries[0].limits.daily_quota, Some(Duration::from_secs(7200))); policy.entries[0].limits.max_run,
assert_eq!(policy.entries[0].limits.cooldown, Some(Duration::from_secs(300))); Some(Duration::from_secs(3600))
);
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);
} }
@ -316,7 +339,11 @@ 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.current_session().unwrap().deadline.expect("Expected deadline"); let original_deadline = engine
.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

@ -0,0 +1,22 @@
# 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,15 @@
# 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: 131 B

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 B

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 B

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 B

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 B

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 B

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

View file

@ -17,6 +17,7 @@ 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,3 +13,4 @@ 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 Exec=sway -c $SWAY_CONFIG_DIR/$SHEPHERD_SWAY_CONFIG --unsupported-gpu
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" & WLR_BACKENDS=wayland WLR_LIBINPUT_NO_DEVICES=1 sway -c "$sway_config" --unsupported-gpu &
SWAY_PID=$! SWAY_PID=$!
info "Sway started with PID $SWAY_PID" info "Sway started with PID $SWAY_PID"

View file

@ -11,6 +11,7 @@ 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)
@ -129,8 +130,11 @@ 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
# Prevent window closing via keybindings (no Alt+F4) # Session stop keybindings via shepherdd API (does not close windows directly)
# Windows can only be closed by the application itself # Handled in sway so they work regardless of which client currently has focus.
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