Compare commits

..

27 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
ffa8d7f07a Implement connection check 2026-02-07 17:47:16 -05:00
8659b11450 Add AGENTS.md 2026-02-07 17:47:16 -05:00
6e64e8e69d
Merge pull request #25 from aarmea/u/aarmea/4/steam-type
Add "steam"-specific type
2026-02-07 16:55:09 -05:00
02ba2965d5 lint 2026-02-07 16:51:17 -05:00
9da95a27b3 Add "steam"-specific type
This implementation allows each platform to choose how to launch Steam (on Linux, we use the snap as the examples suggested before), and keeps Steam alive after an activity exits so that save sync, game updates, etc. can continue to run.

Change written by Codex 5.2 on medium:

Consider this GitHub issue https://github.com/aarmea/shepherd-launcher/issues/4. On Linux, an activity that uses the "steam" type should launch Steam via the snap as shown in the example configuration in this repository.

Go ahead and implement the feature. I'm expecting one of the tricky bits to be killing the activity while keeping Steam alive, as we can no longer just kill the Steam snap cgroup.
2026-02-07 16:22:55 -05:00
4446f518a7
Update README with new Minecraft usage
I recommend Prism Launcher now
2026-01-09 23:02:25 -05:00
b7f2294a81
Merge pull request #22 from aarmea/u/aarmea/18/flatpak-type
Implement first-class Flatpak support
2026-01-09 22:32:04 -05:00
d823ed0a19 Implement Flatpak application type 2026-01-09 22:26:26 -05:00
b98fbc598f Add --force argument to install scripts
This overwrites the existing configuration
2026-01-09 22:14:15 -05:00
68 changed files with 2301 additions and 1132 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

13
AGENTS.md Normal file
View file

@ -0,0 +1,13 @@
Agents: please use the existing documentation for setup.
<CONTRIBUTING.md> describes environment setup and build, test, and lint, including helper scripts and exact commands.
Please ensure that your changes build and pass tests and lint, and run `cargo fmt --all` to match your changes to the rest of the code.
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.
<.github/workflows/ci.yml> and <docs/INSTALL.md> describes exact environment setup, especially if coming from Ubuntu 24.04 (shepherd-launcher requires 25.10).
Historical prompts and design docs provided to agents are placed in <docs/ai/history>. Please refer there for history, and if this prompt is substantial, write it along with any relevant context (like the GitHub issue) to that directory as well.

61
Cargo.lock generated
View file

@ -238,16 +238,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@ -477,9 +467,9 @@ dependencies = [
[[package]] [[package]]
name = "gilrs" name = "gilrs"
version = "0.11.0" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb2c998745a3c1ac90f64f4f7b3a54219fd3612d7705e7798212935641ed18f" checksum = "3fa85c2e35dc565c90511917897ea4eae16b77f2773d5223536f7b602536d462"
dependencies = [ dependencies = [
"fnv", "fnv",
"gilrs-core", "gilrs-core",
@ -490,18 +480,18 @@ dependencies = [
[[package]] [[package]]
name = "gilrs-core" name = "gilrs-core"
version = "0.6.6" version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be11a71ac3564f6965839e2ed275bf4fcf5ce16d80d396e1dfdb7b2d80bd587e" checksum = "d23f2cc5144060a7f8d9e02d3fce5d06705376568256a509cdbc3c24d47e4f04"
dependencies = [ dependencies = [
"core-foundation",
"inotify", "inotify",
"io-kit-sys",
"js-sys", "js-sys",
"libc", "libc",
"libudev-sys", "libudev-sys",
"log", "log",
"nix 0.30.1", "nix 0.30.1",
"objc2-core-foundation",
"objc2-io-kit",
"uuid", "uuid",
"vec_map", "vec_map",
"wasm-bindgen", "wasm-bindgen",
@ -832,16 +822,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "io-kit-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
dependencies = [
"core-foundation-sys",
"mach2",
]
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.2" version = "1.70.2"
@ -934,15 +914,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@ -1021,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"

View file

@ -68,8 +68,5 @@ clap = { version = "4.5", features = ["derive", "env"] }
gtk4 = "0.9" gtk4 = "0.9"
gtk4-layer-shell = "0.4" gtk4-layer-shell = "0.4"
# Gamepad input
gilrs = "0.11"
# Testing # Testing
tempfile = "3.9" tempfile = "3.9"

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
@ -64,7 +64,7 @@ If it can run on Linux in *any way, shape, or form*, it can be supervised by
![Minecraft hosted within shepherd-launcher UI](./docs/readme/apps-minecraft.jpg) ![Minecraft hosted within shepherd-launcher UI](./docs/readme/apps-minecraft.jpg)
> [Minecraft](https://www.minecraft.net/) running via the > [Minecraft](https://www.minecraft.net/) running via the
> [mc-installer Snap](https://snapcraft.io/mc-installer) > [Prism Launcher Flatpak](https://flathub.org/en/apps/org.prismlauncher.PrismLauncher)
![Celeste hosted within shepherd-launcher UI](./docs/readme/apps-celeste.png) ![Celeste hosted within shepherd-launcher UI](./docs/readme/apps-celeste.png)
@ -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
@ -104,16 +132,16 @@ All behavior shown above is driven entirely by declarative configuration.
For the Minecraft example shown above: For the Minecraft example shown above:
```toml ```toml
# Minecraft via mc-installer snap # Prism Launcher - Minecraft launcher (Flatpak)
# Ubuntu: sudo snap install mc-installer # Install: flatpak install flathub org.prismlauncher.PrismLauncher
[[entries]] [[entries]]
id = "minecraft" id = "prism-launcher"
label = "Minecraft" label = "Prism Launcher"
icon = "minecraft" icon = "org.prismlauncher.PrismLauncher"
[entries.kind] [entries.kind]
type = "snap" type = "flatpak"
snap_name = "mc-installer" app_id = "org.prismlauncher.PrismLauncher"
[entries.availability] [entries.availability]
[[entries.availability.windows]] [[entries.availability.windows]]
@ -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
@ -160,7 +188,7 @@ compatibility infrastructure:
* Wayland and Sway * Wayland and Sway
* Rust * Rust
* Snap * Flatpak and Snap
* Proton and WINE * Proton and WINE
This project was written with the assistance of generative AI-based coding This project was written with the assistance of generative AI-based coding

View file

@ -30,6 +30,14 @@ max_volume = 80 # Maximum volume percentage (0-100)
allow_mute = true # Whether mute toggle is allowed allow_mute = true # Whether mute toggle is allowed
allow_change = true # Whether volume changes are allowed at all allow_change = true # Whether volume changes are allowed at all
# Internet connectivity check (optional)
# Entries can require internet and will be hidden when offline.
# Supported schemes: https://, http://, tcp://
[service.internet]
check = "https://connectivitycheck.gstatic.com/generate_204"
interval_seconds = 300 # Keep this high to avoid excessive network requests
timeout_ms = 1500
# Default warning thresholds # Default warning thresholds
[[service.default_warnings]] [[service.default_warnings]]
seconds_before = 300 seconds_before = 300
@ -165,16 +173,23 @@ max_run_seconds = 0 # Unlimited
daily_quota_seconds = 0 # Unlimited daily_quota_seconds = 0 # Unlimited
cooldown_seconds = 0 # No cooldown cooldown_seconds = 0 # No cooldown
# Minecraft via mc-installer snap ## === Steam games ===
# Ubuntu: sudo snap install mc-installer # Steam can be used via Canonical's Steam snap package:
# https://snapcraft.io/steam
# Install it with: sudo snap install steam
# Steam must be set up and logged in before using these entries.
# You must have the games installed in your Steam library.
# Celeste via Steam
# https://store.steampowered.com/app/504230/Celeste
[[entries]] [[entries]]
id = "minecraft" id = "steam-celeste"
label = "Minecraft" label = "Celeste"
icon = "~/.minecraft/launcher/icons/minecraft256.png" icon = "~/Games/Icons/Celeste.png"
[entries.kind] [entries.kind]
type = "snap" type = "steam"
snap_name = "mc-installer" app_id = 504230 # Steam App ID
[entries.availability] [entries.availability]
[[entries.availability.windows]] [[entries.availability.windows]]
@ -187,6 +202,62 @@ days = "weekends"
start = "10:00" start = "10:00"
end = "20:00" end = "20:00"
# No [entries.limits] section - uses service defaults
# Omitting limits entirely uses default_max_run_seconds
# A Short Hike via Steam
# https://store.steampowered.com/app/1055540/A_Short_Hike/
[[entries]]
id = "steam-a-short-hike"
label = "A Short Hike"
icon = "~/Games/Icons/A_Short_Hike.png"
[entries.kind]
type = "steam"
app_id = 1055540 # Steam App ID
[entries.availability]
[[entries.availability.windows]]
days = "weekdays"
start = "15:00"
end = "18:00"
[[entries.availability.windows]]
days = "weekends"
start = "10:00"
end = "20:00"
## === Flatpak-based applications ===
# Flatpak entries use the "flatpak" type for proper process management.
# Similar to Snap, Flatpak apps run in sandboxed environments and use
# systemd scopes for process management.
# Prism Launcher - Minecraft launcher (Flatpak)
# Install: flatpak install flathub org.prismlauncher.PrismLauncher
[[entries]]
id = "prism-launcher"
label = "Prism Launcher"
icon = "org.prismlauncher.PrismLauncher"
[entries.kind]
type = "flatpak"
app_id = "org.prismlauncher.PrismLauncher"
[entries.availability]
[[entries.availability.windows]]
days = "weekdays"
start = "15:00"
end = "18:00"
[[entries.availability.windows]]
days = "weekends"
start = "10:00"
end = "20:00"
[entries.internet]
required = true
check = "http://www.msftconnecttest.com/connecttest.txt" # Use Microsoft's test URL (Minecraft is owned by Microsoft)
[entries.limits] [entries.limits]
max_run_seconds = 1800 # 30 minutes (roughly 3 in-game days) max_run_seconds = 1800 # 30 minutes (roughly 3 in-game days)
daily_quota_seconds = 3600 # 1 hour per day daily_quota_seconds = 3600 # 1 hour per day
@ -211,61 +282,24 @@ message = "30 seconds! Save NOW!"
[entries.volume] [entries.volume]
max_volume = 60 # Limit volume during gaming sessions max_volume = 60 # Limit volume during gaming sessions
## === Steam games === # Krita - digital painting (Flatpak)
# Steam can be used via Canonical's Steam snap package: # Install: flatpak install flathub org.kde.krita
# https://snapcraft.io/steam
# Install it with: sudo snap install steam
# Steam must be set up and logged in before using these entries.
# You must have the games installed in your Steam library.
# Celeste via Steam
# https://store.steampowered.com/app/504230/Celeste
[[entries]] [[entries]]
id = "steam-celeste" id = "krita"
label = "Celeste" label = "Krita"
icon = "~/Games/Icons/Celeste.png" icon = "org.kde.krita"
[entries.kind] [entries.kind]
type = "snap" type = "flatpak"
snap_name = "steam" app_id = "org.kde.krita"
args = ["steam://rungameid/504230"] # Steam App ID (passed to 'snap run steam')
[entries.availability] [entries.availability]
[[entries.availability.windows]] always = true
days = "weekdays"
start = "15:00"
end = "18:00"
[[entries.availability.windows]] [entries.limits]
days = "weekends" max_run_seconds = 0 # Unlimited
start = "10:00" daily_quota_seconds = 0 # Unlimited
end = "20:00" cooldown_seconds = 0 # No cooldown
# No [entries.limits] section - uses service defaults
# Omitting limits entirely uses default_max_run_seconds
# A Short Hike via Steam
# https://store.steampowered.com/app/1055540/A_Short_Hike/
[[entries]]
id = "steam-a-short-hike"
label = "A Short Hike"
icon = "~/Games/Icons/A_Short_Hike.png"
[entries.kind]
type = "snap"
snap_name = "steam"
args = ["steam://rungameid/1055540"] # Steam App ID (passed to 'snap run steam')
[entries.availability]
[[entries.availability.windows]]
days = "weekdays"
start = "15:00"
end = "18:00"
[[entries.availability.windows]]
days = "weekends"
start = "10:00"
end = "20:00"
## === Media === ## === Media ===
# Just use `mpv` to play media (for now). # Just use `mpv` to play media (for now).

View file

@ -104,6 +104,7 @@ if view.enabled {
ReasonCode::QuotaExhausted { used, quota } => { /* ... */ } ReasonCode::QuotaExhausted { used, quota } => { /* ... */ }
ReasonCode::CooldownActive { available_at } => { /* ... */ } ReasonCode::CooldownActive { available_at } => { /* ... */ }
ReasonCode::SessionActive { entry_id, remaining } => { /* ... */ } ReasonCode::SessionActive { entry_id, remaining } => { /* ... */ }
ReasonCode::InternetUnavailable { check } => { /* ... */ }
// ... // ...
} }
} }

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

@ -13,6 +13,8 @@ use std::time::Duration;
pub enum EntryKindTag { pub enum EntryKindTag {
Process, Process,
Snap, Snap,
Steam,
Flatpak,
Vm, Vm,
Media, Media,
Custom, Custom,
@ -46,6 +48,28 @@ pub enum EntryKind {
#[serde(default)] #[serde(default)]
env: HashMap<String, String>, env: HashMap<String, String>,
}, },
/// Steam game launched via the Steam snap (Linux)
Steam {
/// Steam App ID (e.g., 504230 for Celeste)
app_id: u32,
/// Additional command-line arguments passed to Steam
#[serde(default)]
args: Vec<String>,
/// Additional environment variables
#[serde(default)]
env: HashMap<String, String>,
},
/// Flatpak application - uses systemd scope-based process management
Flatpak {
/// The Flatpak application ID (e.g., "org.prismlauncher.PrismLauncher")
app_id: String,
/// Additional command-line arguments
#[serde(default)]
args: Vec<String>,
/// Additional environment variables
#[serde(default)]
env: HashMap<String, String>,
},
Vm { Vm {
driver: String, driver: String,
#[serde(default)] #[serde(default)]
@ -67,6 +91,8 @@ impl EntryKind {
match self { match self {
EntryKind::Process { .. } => EntryKindTag::Process, EntryKind::Process { .. } => EntryKindTag::Process,
EntryKind::Snap { .. } => EntryKindTag::Snap, EntryKind::Snap { .. } => EntryKindTag::Snap,
EntryKind::Steam { .. } => EntryKindTag::Steam,
EntryKind::Flatpak { .. } => EntryKindTag::Flatpak,
EntryKind::Vm { .. } => EntryKindTag::Vm, EntryKind::Vm { .. } => EntryKindTag::Vm,
EntryKind::Media { .. } => EntryKindTag::Media, EntryKind::Media { .. } => EntryKindTag::Media,
EntryKind::Custom { .. } => EntryKindTag::Custom, EntryKind::Custom { .. } => EntryKindTag::Custom,
@ -99,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,
@ -114,13 +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
}, InternetUnavailable { check: Option<String> },
} }
/// Warning severity level /// Warning severity level

View file

@ -23,6 +23,12 @@ socket_path = "/run/shepherdd/shepherdd.sock"
data_dir = "/var/lib/shepherdd" data_dir = "/var/lib/shepherdd"
default_max_run_seconds = 1800 # 30 minutes default default_max_run_seconds = 1800 # 30 minutes default
# Internet connectivity check (optional)
[service.internet]
check = "https://connectivitycheck.gstatic.com/generate_204"
interval_seconds = 10
timeout_ms = 1500
# Global volume restrictions # Global volume restrictions
[service.volume] [service.volume]
max_volume = 80 max_volume = 80
@ -49,6 +55,9 @@ label = "Minecraft"
icon = "minecraft" icon = "minecraft"
kind = { type = "snap", snap_name = "mc-installer" } kind = { type = "snap", snap_name = "mc-installer" }
[entries.internet]
required = true
[entries.availability] [entries.availability]
[[entries.availability.windows]] [[entries.availability.windows]]
days = "weekdays" days = "weekdays"
@ -110,6 +119,9 @@ kind = { type = "process", command = "/usr/bin/game", args = ["--fullscreen"] }
# Snap application # Snap application
kind = { type = "snap", snap_name = "mc-installer" } kind = { type = "snap", snap_name = "mc-installer" }
# Steam game (via Steam snap)
kind = { type = "steam", app_id = 504230 }
# Virtual machine (future) # Virtual machine (future)
kind = { type = "vm", driver = "qemu", args = { disk = "game.qcow2" } } kind = { type = "vm", driver = "qemu", args = { disk = "game.qcow2" } }
@ -148,6 +160,22 @@ daily_quota_seconds = 7200 # Total daily limit
cooldown_seconds = 600 # Wait time between sessions cooldown_seconds = 600 # Wait time between sessions
``` ```
### Internet Requirements
Entries can require internet connectivity. When the device is offline, those entries are hidden.
```toml
[service.internet]
check = "https://connectivitycheck.gstatic.com/generate_204"
interval_seconds = 300
timeout_ms = 1500
[entries.internet]
required = true
# Optional per-entry override:
# check = "tcp://1.1.1.1:53"
```
## Validation ## Validation
The configuration is validated at load time. Validation catches: The configuration is validated at load time. Validation catches:

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
@ -54,6 +60,12 @@ fn main() -> ExitCode {
EntryKind::Snap { snap_name, .. } => { EntryKind::Snap { snap_name, .. } => {
format!("snap ({})", snap_name) format!("snap ({})", snap_name)
} }
EntryKind::Steam { app_id, .. } => {
format!("steam ({})", app_id)
}
EntryKind::Flatpak { app_id, .. } => {
format!("flatpak ({})", app_id)
}
EntryKind::Vm { driver, .. } => { EntryKind::Vm { driver, .. } => {
format!("vm ({})", driver) format!("vm ({})", driver)
} }

View file

@ -0,0 +1,152 @@
//! Internet connectivity configuration and parsing.
use std::time::Duration;
/// Default interval between connectivity checks.
pub const DEFAULT_INTERNET_CHECK_INTERVAL: Duration = Duration::from_secs(10);
/// Default timeout for a single connectivity check.
pub const DEFAULT_INTERNET_CHECK_TIMEOUT: Duration = Duration::from_millis(1500);
/// Supported connectivity check schemes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum InternetCheckScheme {
Tcp,
Http,
Https,
}
impl InternetCheckScheme {
fn from_str(value: &str) -> Result<Self, String> {
match value.to_lowercase().as_str() {
"tcp" => Ok(Self::Tcp),
"http" => Ok(Self::Http),
"https" => Ok(Self::Https),
other => Err(format!("unsupported scheme '{}'", other)),
}
}
fn default_port(self) -> u16 {
match self {
Self::Tcp => 0,
Self::Http => 80,
Self::Https => 443,
}
}
}
/// Connectivity check target.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct InternetCheckTarget {
pub scheme: InternetCheckScheme,
pub host: String,
pub port: u16,
pub original: String,
}
impl InternetCheckTarget {
/// Parse a connectivity check string (e.g., "https://example.com" or "tcp://1.1.1.1:53").
pub fn parse(value: &str) -> Result<Self, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("check target cannot be empty".into());
}
let (scheme_raw, rest) = trimmed
.split_once("://")
.ok_or_else(|| "missing scheme (expected scheme://)".to_string())?;
let scheme = InternetCheckScheme::from_str(scheme_raw)?;
let rest = rest.trim();
if rest.is_empty() {
return Err("missing host".into());
}
let host_port = rest.split('/').next().unwrap_or(rest);
let (host, port_opt) = parse_host_port(host_port)?;
let port = match scheme {
InternetCheckScheme::Tcp => {
port_opt.ok_or_else(|| "tcp check requires explicit port".to_string())?
}
_ => port_opt.unwrap_or_else(|| scheme.default_port()),
};
if port == 0 {
return Err("invalid port".into());
}
Ok(Self {
scheme,
host,
port,
original: trimmed.to_string(),
})
}
}
fn parse_host_port(value: &str) -> Result<(String, Option<u16>), String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("missing host".into());
}
if trimmed.starts_with('[') {
let end = trimmed
.find(']')
.ok_or_else(|| "invalid IPv6 host".to_string())?;
let host = trimmed[1..end].trim();
if host.is_empty() {
return Err("missing host".into());
}
let port = if let Some(port_str) = trimmed[end + 1..].strip_prefix(':') {
Some(parse_port(port_str)?)
} else {
None
};
return Ok((host.to_string(), port));
}
let mut parts = trimmed.splitn(2, ':');
let host = parts.next().unwrap_or("").trim();
if host.is_empty() {
return Err("missing host".into());
}
let port = parts.next().map(parse_port).transpose()?;
Ok((host.to_string(), port))
}
fn parse_port(value: &str) -> Result<u16, String> {
let port: u16 = value
.trim()
.parse()
.map_err(|_| "invalid port".to_string())?;
if port == 0 {
return Err("invalid port".into());
}
Ok(port)
}
/// Service-level internet connectivity configuration.
#[derive(Debug, Clone)]
pub struct InternetConfig {
pub check: Option<InternetCheckTarget>,
pub interval: Duration,
pub timeout: Duration,
}
impl InternetConfig {
pub fn new(check: Option<InternetCheckTarget>, interval: Duration, timeout: Duration) -> Self {
Self {
check,
interval,
timeout,
}
}
}
/// Entry-level internet requirement.
#[derive(Debug, Clone, Default)]
pub struct EntryInternetPolicy {
pub required: bool,
pub check: Option<InternetCheckTarget>,
}

View file

@ -6,10 +6,12 @@
//! - 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;
pub use internet::*;
pub use policy::*; pub use policy::*;
pub use schema::*; pub use schema::*;
pub use validation::*; pub use validation::*;

View file

@ -1,9 +1,19 @@
//! Validated policy structures //! Validated policy structures
use crate::schema::{RawConfig, RawEntry, RawEntryKind, RawVolumeConfig, RawServiceConfig, RawWarningThreshold}; use crate::internet::{
DEFAULT_INTERNET_CHECK_INTERVAL, DEFAULT_INTERNET_CHECK_TIMEOUT, EntryInternetPolicy,
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;
@ -81,27 +91,24 @@ pub struct ServiceConfig {
pub capture_child_output: bool, pub capture_child_output: bool,
/// Directory for child application logs /// Directory for child application logs
pub child_log_dir: PathBuf, pub child_log_dir: PathBuf,
/// Internet connectivity configuration
pub internet: InternetConfig,
} }
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());
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 internet,
.unwrap_or_else(default_data_dir),
} }
} }
} }
@ -115,6 +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,
),
} }
} }
} }
@ -132,6 +144,7 @@ pub struct Entry {
pub volume: Option<VolumePolicy>, pub volume: Option<VolumePolicy>,
pub disabled: bool, pub disabled: bool,
pub disabled_reason: Option<String>, pub disabled_reason: Option<String>,
pub internet: EntryInternetPolicy,
} }
impl Entry { impl Entry {
@ -159,6 +172,7 @@ impl Entry {
.map(|w| w.into_iter().map(convert_warning).collect()) .map(|w| w.into_iter().map(convert_warning).collect())
.unwrap_or_else(|| default_warnings.to_vec()); .unwrap_or_else(|| default_warnings.to_vec());
let volume = raw.volume.as_ref().map(convert_volume_config); let volume = raw.volume.as_ref().map(convert_volume_config);
let internet = convert_entry_internet(raw.internet.as_ref());
Self { Self {
id: EntryId::new(raw.id), id: EntryId::new(raw.id),
@ -171,6 +185,7 @@ impl Entry {
volume, volume,
disabled: raw.disabled, disabled: raw.disabled,
disabled_reason: raw.disabled_reason, disabled_reason: raw.disabled_reason,
internet,
} }
} }
} }
@ -197,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
} }
@ -254,8 +266,30 @@ 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::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 },
RawEntryKind::Media { library_id, args } => EntryKind::Media { library_id, args }, RawEntryKind::Media { library_id, args } => EntryKind::Media { library_id, args },
RawEntryKind::Custom { type_name, payload } => EntryKind::Custom { RawEntryKind::Custom { type_name, payload } => EntryKind::Custom {
@ -282,6 +316,33 @@ fn convert_volume_config(raw: &RawVolumeConfig) -> VolumePolicy {
} }
} }
fn convert_internet_config(raw: Option<&RawInternetConfig>) -> InternetConfig {
let check = raw
.and_then(|cfg| cfg.check.as_ref())
.and_then(|value| InternetCheckTarget::parse(value).ok());
let interval = raw
.and_then(|cfg| cfg.interval_seconds)
.map(Duration::from_secs)
.unwrap_or(DEFAULT_INTERNET_CHECK_INTERVAL);
let timeout = raw
.and_then(|cfg| cfg.timeout_ms)
.map(Duration::from_millis)
.unwrap_or(DEFAULT_INTERNET_CHECK_TIMEOUT);
InternetConfig::new(check, interval, timeout)
}
fn convert_entry_internet(raw: Option<&crate::schema::RawEntryInternet>) -> EntryInternetPolicy {
let required = raw.map(|cfg| cfg.required).unwrap_or(false);
let check = raw
.and_then(|cfg| cfg.check.as_ref())
.and_then(|value| InternetCheckTarget::parse(value).ok());
EntryInternetPolicy { required, check }
}
fn convert_time_window(raw: crate::schema::RawTimeWindow) -> TimeWindow { fn convert_time_window(raw: crate::schema::RawTimeWindow) -> TimeWindow {
let days_mask = parse_days(&raw.days).unwrap_or(0x7F); let days_mask = parse_days(&raw.days).unwrap_or(0x7F);
let (start_h, start_m) = parse_time(&raw.start).unwrap_or((0, 0)); let (start_h, start_m) = parse_time(&raw.start).unwrap_or((0, 0));
@ -303,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

@ -48,6 +48,10 @@ pub struct RawServiceConfig {
/// Global volume restrictions /// Global volume restrictions
#[serde(default)] #[serde(default)]
pub volume: Option<RawVolumeConfig>, pub volume: Option<RawVolumeConfig>,
/// Internet connectivity check settings
#[serde(default)]
pub internet: Option<RawInternetConfig>,
} }
/// Raw entry definition /// Raw entry definition
@ -87,6 +91,10 @@ pub struct RawEntry {
/// Reason for disabling /// Reason for disabling
pub disabled_reason: Option<String>, pub disabled_reason: Option<String>,
/// Internet requirement for this entry
#[serde(default)]
pub internet: Option<RawEntryInternet>,
} }
/// Raw entry kind /// Raw entry kind
@ -116,6 +124,28 @@ pub enum RawEntryKind {
#[serde(default)] #[serde(default)]
env: HashMap<String, String>, env: HashMap<String, String>,
}, },
/// Steam game launched via the Steam snap (Linux)
Steam {
/// Steam App ID (e.g., 504230 for Celeste)
app_id: u32,
/// Additional command-line arguments passed to Steam
#[serde(default)]
args: Vec<String>,
/// Additional environment variables
#[serde(default)]
env: HashMap<String, String>,
},
/// Flatpak application - uses systemd scope-based process management
Flatpak {
/// The Flatpak application ID (e.g., "org.prismlauncher.PrismLauncher")
app_id: String,
/// Additional command-line arguments
#[serde(default)]
args: Vec<String>,
/// Additional environment variables
#[serde(default)]
env: HashMap<String, String>,
},
Vm { Vm {
driver: String, driver: String,
#[serde(default)] #[serde(default)]
@ -193,6 +223,30 @@ pub struct RawWarningThreshold {
pub message: Option<String>, pub message: Option<String>,
} }
/// Internet connectivity check configuration
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct RawInternetConfig {
/// Connectivity check target (e.g., "https://example.com" or "tcp://1.1.1.1:53")
pub check: Option<String>,
/// Interval between checks (seconds)
pub interval_seconds: Option<u64>,
/// Timeout per check (milliseconds)
pub timeout_ms: Option<u64>,
}
/// Per-entry internet requirement
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct RawEntryInternet {
/// Whether this entry requires internet connectivity
#[serde(default)]
pub required: bool,
/// Override connectivity check target for this entry
pub check: Option<String>,
}
fn default_severity() -> String { fn default_severity() -> String {
"warn".to_string() "warn".to_string()
} }

View file

@ -1,5 +1,6 @@
//! Configuration validation //! Configuration validation
use crate::internet::InternetCheckTarget;
use crate::schema::{RawConfig, RawDays, RawEntry, RawEntryKind, RawTimeWindow}; use crate::schema::{RawConfig, RawDays, RawEntry, RawEntryKind, RawTimeWindow};
use std::collections::HashSet; use std::collections::HashSet;
use thiserror::Error; use thiserror::Error;
@ -34,6 +35,34 @@ pub enum ValidationError {
pub fn validate_config(config: &RawConfig) -> Vec<ValidationError> { pub fn validate_config(config: &RawConfig) -> Vec<ValidationError> {
let mut errors = Vec::new(); let mut errors = Vec::new();
// Validate global internet check (if set)
if let Some(internet) = &config.service.internet
&& let Some(check) = &internet.check
&& let Err(e) = InternetCheckTarget::parse(check)
{
errors.push(ValidationError::GlobalError(format!(
"Invalid internet check '{}': {}",
check, e
)));
}
if let Some(internet) = &config.service.internet {
if let Some(interval) = internet.interval_seconds
&& interval == 0
{
errors.push(ValidationError::GlobalError(
"Internet check interval_seconds must be > 0".into(),
));
}
if let Some(timeout) = internet.timeout_ms
&& timeout == 0
{
errors.push(ValidationError::GlobalError(
"Internet check timeout_ms must be > 0".into(),
));
}
}
// Check for duplicate entry IDs // Check for duplicate entry IDs
let mut seen_ids = HashSet::new(); let mut seen_ids = HashSet::new();
for entry in &config.entries { for entry in &config.entries {
@ -71,6 +100,22 @@ fn validate_entry(entry: &RawEntry, config: &RawConfig) -> Vec<ValidationError>
}); });
} }
} }
RawEntryKind::Steam { app_id, .. } => {
if *app_id == 0 {
errors.push(ValidationError::EntryError {
entry_id: entry.id.clone(),
message: "app_id must be > 0".into(),
});
}
}
RawEntryKind::Flatpak { app_id, .. } => {
if app_id.is_empty() {
errors.push(ValidationError::EntryError {
entry_id: entry.id.clone(),
message: "app_id cannot be empty".into(),
});
}
}
RawEntryKind::Vm { driver, .. } => { RawEntryKind::Vm { driver, .. } => {
if driver.is_empty() { if driver.is_empty() {
errors.push(ValidationError::EntryError { errors.push(ValidationError::EntryError {
@ -114,7 +159,8 @@ fn validate_entry(entry: &RawEntry, config: &RawConfig) -> Vec<ValidationError>
// Only validate warnings if max_run is Some and not 0 (unlimited) // Only validate warnings if max_run is Some and not 0 (unlimited)
if let (Some(warnings), Some(max_run)) = (&entry.warnings, max_run) if let (Some(warnings), Some(max_run)) = (&entry.warnings, max_run)
&& max_run > 0 { && max_run > 0
{
for warning in warnings { for warning in warnings {
if warning.seconds_before >= max_run { if warning.seconds_before >= max_run {
errors.push(ValidationError::WarningExceedsMaxRun { errors.push(ValidationError::WarningExceedsMaxRun {
@ -127,6 +173,34 @@ fn validate_entry(entry: &RawEntry, config: &RawConfig) -> Vec<ValidationError>
// Note: warnings are ignored for unlimited entries (max_run = 0) // Note: warnings are ignored for unlimited entries (max_run = 0)
} }
// Validate internet requirements
if let Some(internet) = &entry.internet {
if let Some(check) = &internet.check
&& let Err(e) = InternetCheckTarget::parse(check)
{
errors.push(ValidationError::EntryError {
entry_id: entry.id.clone(),
message: format!("Invalid internet check '{}': {}", check, e),
});
}
if internet.required {
let has_check = internet.check.is_some()
|| config
.service
.internet
.as_ref()
.and_then(|cfg| cfg.check.as_ref())
.is_some();
if !has_check {
errors.push(ValidationError::EntryError {
entry_id: entry.id.clone(),
message: "internet is required but no check is configured (set service.internet.check or entries.internet.check)".into(),
});
}
}
}
errors errors
} }
@ -167,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());
@ -230,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
); );
} }
@ -262,6 +343,7 @@ mod tests {
volume: None, volume: None,
disabled: false, disabled: false,
disabled_reason: None, disabled_reason: None,
internet: None,
}, },
RawEntry { RawEntry {
id: "game".into(), id: "game".into(),
@ -279,11 +361,16 @@ mod tests {
volume: None, volume: None,
disabled: false, disabled: false,
disabled_reason: None, disabled_reason: None,
internet: None,
}, },
], ],
}; };
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,14 +2,13 @@
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}; 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};
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tracing::{debug, info}; use tracing::{debug, info};
@ -38,15 +37,13 @@ pub struct CoreEngine {
current_session: Option<ActiveSession>, current_session: Option<ActiveSession>,
/// Tracks which entries were enabled on the last tick, to detect availability changes /// Tracks which entries were enabled on the last tick, to detect availability changes
last_availability_set: HashSet<EntryId>, last_availability_set: HashSet<EntryId>,
/// Latest known internet connectivity status per check target
internet_status: HashMap<InternetCheckTarget, bool>,
} }
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"
@ -63,6 +60,7 @@ impl CoreEngine {
capabilities, capabilities,
current_session: None, current_session: None,
last_availability_set: HashSet::new(), last_availability_set: HashSet::new(),
internet_status: HashMap::new(),
} }
} }
@ -76,7 +74,9 @@ 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
.store
.append_audit(AuditEvent::new(AuditEventType::PolicyLoaded {
entry_count, entry_count,
})); }));
@ -85,6 +85,16 @@ impl CoreEngine {
CoreEvent::PolicyReloaded { entry_count } CoreEvent::PolicyReloaded { entry_count }
} }
/// Update internet connectivity status for a check target.
pub fn set_internet_status(&mut self, target: InternetCheckTarget, available: bool) -> bool {
let previous = self.internet_status.insert(target, available);
previous != Some(available)
}
fn internet_available(&self, target: &InternetCheckTarget) -> bool {
self.internet_status.get(target).copied().unwrap_or(false)
}
/// List all entries with availability status /// List all entries with availability status
pub fn list_entries(&self, now: DateTime<Local>) -> Vec<EntryView> { pub fn list_entries(&self, now: DateTime<Local>) -> Vec<EntryView> {
self.policy self.policy
@ -122,6 +132,26 @@ impl CoreEngine {
}); });
} }
// Check internet requirement
if entry.internet.required {
let check = entry.internet.check.as_ref().or(self
.policy
.service
.internet
.check
.as_ref());
let available = check
.map(|target| self.internet_available(target))
.unwrap_or(false);
if !available {
enabled = false;
reasons.push(ReasonCode::InternetUnavailable {
check: check.map(|target| target.original.clone()),
});
}
}
// Check if another session is active // Check if another session is active
if let Some(session) = &self.current_session { if let Some(session) = &self.current_session {
enabled = false; enabled = false;
@ -133,16 +163,20 @@ impl CoreEngine {
// Check cooldown // Check cooldown
if let Ok(Some(until)) = self.store.get_cooldown_until(&entry.id) if let Ok(Some(until)) = self.store.get_cooldown_until(&entry.id)
&& until > now { && until > now
{
enabled = false; enabled = false;
reasons.push(ReasonCode::CooldownActive { available_at: until }); reasons.push(ReasonCode::CooldownActive {
available_at: until,
});
} }
// Check daily quota // Check daily quota
if let Some(quota) = entry.limits.daily_quota { if let Some(quota) = entry.limits.daily_quota {
let today = now.date_naive(); let today = now.date_naive();
if let Ok(used) = self.store.get_usage(&entry.id, today) if let Ok(used) = self.store.get_usage(&entry.id, today)
&& used >= quota { && used >= quota
{
enabled = false; enabled = false;
reasons.push(ReasonCode::QuotaExhausted { used, quota }); reasons.push(ReasonCode::QuotaExhausted { used, quota });
} }
@ -195,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,
@ -217,7 +247,9 @@ impl CoreEngine {
if !view.enabled { if !view.enabled {
// Log denial // Log denial
let _ = self.store.append_audit(AuditEvent::new(AuditEventType::LaunchDenied { let _ = self
.store
.append_audit(AuditEvent::new(AuditEventType::LaunchDenied {
entry_id: entry_id.clone(), entry_id: entry_id.clone(),
reasons: view.reasons.iter().map(|r| format!("{:?}", r)).collect(), reasons: view.reasons.iter().map(|r| format!("{:?}", r)).collect(),
})); }));
@ -270,7 +302,9 @@ impl CoreEngine {
}; };
// Log to audit // Log to audit
let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionStarted { let _ = self
.store
.append_audit(AuditEvent::new(AuditEventType::SessionStarted {
session_id: session.plan.session_id.clone(), session_id: session.plan.session_id.clone(),
entry_id: session.plan.entry_id.clone(), entry_id: session.plan.entry_id.clone(),
label: session.plan.label.clone(), label: session.plan.label.clone(),
@ -352,7 +386,9 @@ 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
.store
.append_audit(AuditEvent::new(AuditEventType::WarningIssued {
session_id: session.plan.session_id.clone(), session_id: session.plan.session_id.clone(),
threshold_seconds: threshold, threshold_seconds: threshold,
})); }));
@ -411,17 +447,22 @@ 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 until = now + chrono::Duration::from_std(cooldown).unwrap();
let _ = self.store.set_cooldown_until(&session.plan.entry_id, until); let _ = self.store.set_cooldown_until(&session.plan.entry_id, until);
} }
// Log to audit // Log to audit
let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionEnded { let _ = self
.store
.append_audit(AuditEvent::new(AuditEventType::SessionEnded {
session_id: session.plan.session_id.clone(), session_id: session.plan.session_id.clone(),
entry_id: session.plan.entry_id.clone(), entry_id: session.plan.entry_id.clone(),
reason: reason.clone(), reason: reason.clone(),
@ -460,17 +501,22 @@ 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 until = now + chrono::Duration::from_std(cooldown).unwrap();
let _ = self.store.set_cooldown_until(&session.plan.entry_id, until); let _ = self.store.set_cooldown_until(&session.plan.entry_id, until);
} }
// Log to audit // Log to audit
let _ = self.store.append_audit(AuditEvent::new(AuditEventType::SessionEnded { let _ = self
.store
.append_audit(AuditEvent::new(AuditEventType::SessionEnded {
session_id: session.plan.session_id.clone(), session_id: session.plan.session_id.clone(),
entry_id: session.plan.entry_id.clone(), entry_id: session.plan.entry_id.clone(),
reason: reason.clone(), reason: reason.clone(),
@ -493,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());
@ -545,7 +592,9 @@ 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
.store
.append_audit(AuditEvent::new(AuditEventType::SessionExtended {
session_id: session.plan.session_id.clone(), session_id: session.plan.session_id.clone(),
extended_by: by, extended_by: by,
new_deadline, new_deadline,
@ -565,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;
@ -596,6 +645,7 @@ mod tests {
volume: None, volume: None,
disabled: false, disabled: false,
disabled_reason: None, 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)),
@ -679,6 +729,7 @@ mod tests {
volume: None, volume: None,
disabled: false, disabled: false,
disabled_reason: None, disabled_reason: None,
internet: Default::default(),
}], }],
service: Default::default(), service: Default::default(),
default_warnings: vec![], default_warnings: vec![],
@ -702,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());
} }
@ -744,6 +810,7 @@ mod tests {
volume: None, volume: None,
disabled: false, disabled: false,
disabled_reason: None, disabled_reason: None,
internet: Default::default(),
}], }],
service: Default::default(), service: Default::default(),
default_warnings: vec![], default_warnings: vec![],
@ -768,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

@ -30,6 +30,7 @@ let caps = host.capabilities();
// Check supported entry kinds // Check supported entry kinds
if caps.supports_kind(EntryKindTag::Process) { /* ... */ } if caps.supports_kind(EntryKindTag::Process) { /* ... */ }
if caps.supports_kind(EntryKindTag::Snap) { /* ... */ } if caps.supports_kind(EntryKindTag::Snap) { /* ... */ }
if caps.supports_kind(EntryKindTag::Steam) { /* ... */ }
// Check enforcement capabilities // Check enforcement capabilities
if caps.can_kill_forcefully { /* Can use SIGKILL/TerminateProcess */ } if caps.can_kill_forcefully { /* Can use SIGKILL/TerminateProcess */ }

View file

@ -59,6 +59,8 @@ impl HostCapabilities {
let mut spawn_kinds = HashSet::new(); let mut spawn_kinds = HashSet::new();
spawn_kinds.insert(EntryKindTag::Process); spawn_kinds.insert(EntryKindTag::Process);
spawn_kinds.insert(EntryKindTag::Snap); spawn_kinds.insert(EntryKindTag::Snap);
spawn_kinds.insert(EntryKindTag::Steam);
spawn_kinds.insert(EntryKindTag::Flatpak);
spawn_kinds.insert(EntryKindTag::Vm); spawn_kinds.insert(EntryKindTag::Vm);
spawn_kinds.insert(EntryKindTag::Media); spawn_kinds.insert(EntryKindTag::Media);

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

@ -90,6 +90,21 @@ let entry_kind = EntryKind::Snap {
let handle = host.spawn(session_id, &entry_kind, options).await?; let handle = host.spawn(session_id, &entry_kind, options).await?;
``` ```
### Spawning Steam Games
Steam games are launched via the Steam snap:
```rust
let entry_kind = EntryKind::Steam {
app_id: 504230,
args: vec![],
env: Default::default(),
};
// Spawns via: snap run steam steam://rungameid/504230
let handle = host.spawn(session_id, &entry_kind, options).await?;
```
### Stopping Sessions ### Stopping Sessions
```rust ```rust

View file

@ -3,17 +3,20 @@
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::{
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; use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::process::{init, kill_by_command, kill_snap_cgroup, ManagedProcess}; use crate::process::{
ManagedProcess, find_steam_game_pids, init, kill_by_command, kill_flatpak_cgroup,
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
fn expand_tilde(path: &str) -> String { fn expand_tilde(path: &str) -> String {
@ -39,6 +42,16 @@ fn expand_args(args: &[String]) -> Vec<String> {
struct SessionInfo { struct SessionInfo {
command_name: String, command_name: String,
snap_name: Option<String>, snap_name: Option<String>,
flatpak_app_id: Option<String>,
steam_app_id: Option<u32>,
}
#[derive(Clone, Debug)]
struct SteamSession {
pid: u32,
pgid: u32,
app_id: u32,
seen_game: bool,
} }
/// Linux host adapter /// Linux host adapter
@ -47,6 +60,7 @@ pub struct LinuxHost {
processes: Arc<Mutex<HashMap<u32, ManagedProcess>>>, processes: Arc<Mutex<HashMap<u32, ManagedProcess>>>,
/// Track session info for killing /// Track session info for killing
session_info: Arc<Mutex<HashMap<SessionId, SessionInfo>>>, session_info: Arc<Mutex<HashMap<SessionId, SessionInfo>>>,
steam_sessions: Arc<Mutex<HashMap<u32, SteamSession>>>,
event_tx: mpsc::UnboundedSender<HostEvent>, event_tx: mpsc::UnboundedSender<HostEvent>,
event_rx: Arc<Mutex<Option<mpsc::UnboundedReceiver<HostEvent>>>>, event_rx: Arc<Mutex<Option<mpsc::UnboundedReceiver<HostEvent>>>>,
} }
@ -62,6 +76,7 @@ impl LinuxHost {
capabilities: HostCapabilities::linux_full(), capabilities: HostCapabilities::linux_full(),
processes: Arc::new(Mutex::new(HashMap::new())), processes: Arc::new(Mutex::new(HashMap::new())),
session_info: Arc::new(Mutex::new(HashMap::new())), session_info: Arc::new(Mutex::new(HashMap::new())),
steam_sessions: Arc::new(Mutex::new(HashMap::new())),
event_tx: tx, event_tx: tx,
event_rx: Arc::new(Mutex::new(Some(rx))), event_rx: Arc::new(Mutex::new(Some(rx))),
} }
@ -70,6 +85,7 @@ impl LinuxHost {
/// Start the background process monitor /// Start the background process monitor
pub fn start_monitor(&self) -> tokio::task::JoinHandle<()> { pub fn start_monitor(&self) -> tokio::task::JoinHandle<()> {
let processes = self.processes.clone(); let processes = self.processes.clone();
let steam_sessions = self.steam_sessions.clone();
let event_tx = self.event_tx.clone(); let event_tx = self.event_tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
@ -77,13 +93,16 @@ 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> =
{ steam_sessions.lock().unwrap().keys().cloned().collect() };
{ {
let mut procs = processes.lock().unwrap(); let mut procs = processes.lock().unwrap();
for (pid, proc) in procs.iter_mut() { for (pid, proc) in procs.iter_mut() {
match proc.try_wait() { match proc.try_wait() {
Ok(Some(status)) => { Ok(Some(status)) => {
exited.push((*pid, proc.pgid, status)); let is_steam = steam_pids.contains(pid);
exited.push((*pid, proc.pgid, status, is_steam));
} }
Ok(None) => {} Ok(None) => {}
Err(e) => { Err(e) => {
@ -92,12 +111,16 @@ impl LinuxHost {
} }
} }
for (pid, _, _) in &exited { for (pid, _, _, _) in &exited {
procs.remove(pid); procs.remove(pid);
} }
} }
for (pid, pgid, status) in exited { for (pid, pgid, status, is_steam) in exited {
if is_steam {
info!(pid = pid, pgid = pgid, status = ?status, "Steam launch process exited");
continue;
}
info!(pid = pid, pgid = pgid, status = ?status, "Process exited - sending HostEvent::Exited"); info!(pid = pid, pgid = pgid, status = ?status, "Process exited - sending HostEvent::Exited");
// We don't have the session_id here, so we use a placeholder // We don't have the session_id here, so we use a placeholder
@ -109,6 +132,43 @@ impl LinuxHost {
let _ = event_tx.send(HostEvent::Exited { handle, status }); let _ = event_tx.send(HostEvent::Exited { handle, status });
} }
// Track Steam sessions by Steam App ID instead of process exit
let steam_snapshot: Vec<SteamSession> =
{ steam_sessions.lock().unwrap().values().cloned().collect() };
let mut ended = Vec::new();
for session in &steam_snapshot {
let has_game = !find_steam_game_pids(session.app_id).is_empty();
if has_game {
if let Ok(mut map) = steam_sessions.lock() {
map.entry(session.pid)
.and_modify(|entry| entry.seen_game = true);
}
} else if session.seen_game {
ended.push((session.pid, session.pgid));
}
}
if !ended.is_empty() {
let mut map = steam_sessions.lock().unwrap();
let mut procs = processes.lock().unwrap();
for (pid, pgid) in ended {
map.remove(&pid);
procs.remove(&pid);
let handle = HostSessionHandle::new(
SessionId::new(),
HostHandlePayload::Linux { pid, pgid },
);
let _ = event_tx.send(HostEvent::Exited {
handle,
status: ExitStatus::success(),
});
}
}
} }
}) })
} }
@ -132,28 +192,56 @@ impl HostAdapter for LinuxHost {
entry_kind: &EntryKind, entry_kind: &EntryKind,
options: SpawnOptions, options: SpawnOptions,
) -> HostResult<HostSessionHandle> { ) -> HostResult<HostSessionHandle> {
// Extract argv, env, cwd, and snap_name 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) = 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) (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())) (argv, env.clone(), None, Some(snap_name.clone()), None, None)
}
EntryKind::Steam { app_id, args, env } => {
// Steam games are launched via the Steam snap: snap run steam steam://rungameid/<app_id>
let mut argv = vec![
"snap".to_string(),
"run".to_string(),
"steam".to_string(),
format!("steam://rungameid/{}", app_id),
];
argv.extend(expand_args(args));
(argv, env.clone(), None, None, None, Some(*app_id))
}
EntryKind::Flatpak { app_id, args, env } => {
// For Flatpak apps, we use 'flatpak run <app_id>' to launch them.
let mut argv = vec!["flatpak".to_string(), "run".to_string(), app_id.clone()];
argv.extend(expand_args(args));
(argv, env.clone(), None, None, Some(app_id.clone()), None)
} }
EntryKind::Vm { driver, args } => { EntryKind::Vm { driver, args } => {
// Construct command line from VM driver // Construct command line from VM driver
@ -166,33 +254,46 @@ impl HostAdapter for LinuxHost {
argv.push(value.to_string()); argv.push(value.to_string());
} }
} }
(argv, HashMap::new(), 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) (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);
} }
}; };
// Get the command name for fallback killing // Get the command name for fallback killing
// For snap apps, use the snap_name (not "snap") to avoid killing unrelated processes // For snap/flatpak apps, use the app name (not "snap"/"flatpak") to avoid killing unrelated processes
let command_name = if let Some(ref snap) = snap_name { let command_name = if let Some(ref snap) = snap_name {
snap.clone() snap.clone()
} else if steam_app_id.is_some() {
"steam".to_string()
} else if let Some(ref app_id) = flatpak_app_id {
app_id.clone()
} else { } else {
argv.first().cloned().unwrap_or_default() argv.first().cloned().unwrap_or_default()
}; };
// Determine if this is a sandboxed app (snap or flatpak)
let sandboxed_app_name = snap_name.clone().or_else(|| flatpak_app_id.clone());
let proc = ManagedProcess::spawn( let proc = ManagedProcess::spawn(
&argv, &argv,
&env, &env,
cwd.as_ref(), cwd.as_ref(),
options.log_path.clone(), options.log_path.clone(),
snap_name.clone(), sandboxed_app_name,
)?; )?;
let pid = proc.pid; let pid = proc.pid;
@ -202,17 +303,31 @@ impl HostAdapter for LinuxHost {
let session_info_entry = SessionInfo { let session_info_entry = SessionInfo {
command_name: command_name.clone(), command_name: command_name.clone(),
snap_name: snap_name.clone(), snap_name: snap_name.clone(),
flatpak_app_id: flatpak_app_id.clone(),
steam_app_id,
}; };
self.session_info.lock().unwrap().insert(session_id.clone(), session_info_entry); self.session_info
info!(session_id = %session_id, command = %command_name, snap = ?snap_name, "Tracking 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");
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);
if let Some(app_id) = steam_app_id {
self.steam_sessions.lock().unwrap().insert(
pid,
SteamSession {
pid,
pgid,
app_id,
seen_game: false,
},
);
}
info!(pid = pid, pgid = pgid, "Spawned process"); info!(pid = pid, pgid = pgid, "Spawned process");
Ok(handle) Ok(handle)
@ -238,20 +353,37 @@ impl HostAdapter for LinuxHost {
match mode { match mode {
StopMode::Graceful { timeout } => { StopMode::Graceful { timeout } => {
// If this is a snap app, use cgroup-based killing (most reliable) // If this is a snap or flatpak app, use cgroup-based killing (most reliable)
if let Some(ref info) = session_info { if let Some(ref info) = session_info {
if let Some(ref snap) = info.snap_name { if let Some(ref snap) = info.snap_name {
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 {
let _ =
kill_steam_game_processes(app_id, nix::sys::signal::Signal::SIGTERM);
if let Ok(mut map) = self.steam_sessions.lock() {
map.entry(pid).and_modify(|entry| entry.seen_game = true);
}
info!(
steam_app_id = app_id,
"Sent SIGTERM to Steam game processes"
);
} else if let Some(ref app_id) = info.flatpak_app_id {
kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGTERM);
info!(flatpak = %app_id, "Sent SIGTERM via flatpak cgroup");
} else { } else {
// Fall back to command name for non-snap apps // Fall back to command name for non-sandboxed apps
kill_by_command(&info.command_name, nix::sys::signal::Signal::SIGTERM); kill_by_command(&info.command_name, nix::sys::signal::Signal::SIGTERM);
info!(command = %info.command_name, "Sent SIGTERM via command name"); info!(command = %info.command_name, "Sent SIGTERM via command name");
} }
} }
// Also send SIGTERM via process handle // 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();
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) {
let _ = p.terminate(); let _ = p.terminate();
@ -262,27 +394,51 @@ impl HostAdapter for LinuxHost {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
loop { loop {
if start.elapsed() >= timeout { if start.elapsed() >= timeout {
// Force kill after timeout using snap cgroup or command name // Force kill after timeout using snap/flatpak cgroup or command name
if let Some(ref info) = session_info { if let Some(ref info) = session_info {
if let Some(ref snap) = info.snap_name { if let Some(ref snap) = info.snap_name {
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 {
let _ = kill_steam_game_processes(
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 {
kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGKILL);
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)");
} }
} }
// Also force kill via process handle // Also force kill via process handle (skip for Steam sessions)
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) {
let _ = p.kill(); let _ = p.kill();
} }
}
break; break;
} }
// Check if process is still running // Check if process is still running
let still_running = self.processes.lock().unwrap().contains_key(&pid); let still_running = if is_steam {
let app_id = session_info.as_ref().and_then(|info| info.steam_app_id);
app_id
.map(|id| !find_steam_game_pids(id).is_empty())
.unwrap_or(false)
} else {
self.processes.lock().unwrap().contains_key(&pid)
};
if !still_running { if !still_running {
break; break;
@ -292,24 +448,43 @@ impl HostAdapter for LinuxHost {
} }
} }
StopMode::Force => { StopMode::Force => {
// Force kill via snap cgroup or command name // Force kill via snap/flatpak cgroup or command name
if let Some(ref info) = session_info { if let Some(ref info) = session_info {
if let Some(ref snap) = info.snap_name { if let Some(ref snap) = info.snap_name {
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 {
let _ =
kill_steam_game_processes(app_id, nix::sys::signal::Signal::SIGKILL);
if let Ok(mut map) = self.steam_sessions.lock() {
map.entry(pid).and_modify(|entry| entry.seen_game = true);
}
info!(
steam_app_id = app_id,
"Sent SIGKILL to Steam game processes"
);
} else if let Some(ref app_id) = info.flatpak_app_id {
kill_flatpak_cgroup(app_id, nix::sys::signal::Signal::SIGKILL);
info!(flatpak = %app_id, "Sent SIGKILL via flatpak cgroup");
} 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"); info!(command = %info.command_name, "Sent SIGKILL via command name");
} }
} }
// Also force kill via process handle // 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();
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) {
let _ = p.kill(); let _ = p.kill();
} }
} }
} }
}
// Clean up the session info tracking // Clean up the session info tracking
self.session_info.lock().unwrap().remove(&session_id); self.session_info.lock().unwrap().remove(&session_id);

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");
} }
@ -91,6 +94,127 @@ pub fn kill_snap_cgroup(snap_name: &str, _signal: Signal) -> bool {
stopped_any stopped_any
} }
/// Kill all processes in a Flatpak app's cgroup using systemd
/// Flatpak apps create scopes at: app-flatpak-<app_id>-<number>.scope
/// For example: app-flatpak-org.prismlauncher.PrismLauncher-12345.scope
/// Similar to snap apps, we use systemctl --user to manage the scopes.
pub fn kill_flatpak_cgroup(app_id: &str, _signal: Signal) -> bool {
let uid = nix::unistd::getuid().as_raw();
let base_path = format!(
"/sys/fs/cgroup/user.slice/user-{}.slice/user@{}.service/app.slice",
uid, uid
);
// Flatpak uses a different naming pattern than snap
// The app_id dots are preserved: app-flatpak-org.example.App-<number>.scope
let pattern = format!("app-flatpak-{}-", app_id);
let base = std::path::Path::new(&base_path);
if !base.exists() {
debug!(path = %base_path, "Flatpak cgroup base path doesn't exist");
return false;
}
let mut stopped_any = false;
if let Ok(entries) = std::fs::read_dir(base) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with(&pattern) && name_str.ends_with(".scope") {
let scope_name = name_str.to_string();
// Always use SIGKILL for flatpak apps to prevent self-restart behavior
// Using systemctl kill --signal=KILL sends SIGKILL to all processes in scope
let result = Command::new("systemctl")
.args(["--user", "kill", "--signal=KILL", &scope_name])
.output();
match result {
Ok(output) => {
if output.status.success() {
info!(scope = %scope_name, "Killed flatpak scope via systemctl SIGKILL");
stopped_any = true;
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(scope = %scope_name, stderr = %stderr, "systemctl kill command failed");
}
}
Err(e) => {
warn!(scope = %scope_name, error = %e, "Failed to run systemctl");
}
}
}
}
}
if stopped_any {
info!(
app_id = app_id,
"Killed flatpak scope(s) via systemctl SIGKILL"
);
} else {
debug!(app_id = app_id, "No flatpak scope found to kill");
}
stopped_any
}
/// Find Steam game process IDs by Steam App ID (from environment variables)
pub fn find_steam_game_pids(app_id: u32) -> Vec<i32> {
let mut pids = Vec::new();
let target = app_id.to_string();
let keys = ["SteamAppId", "SteamAppID", "STEAM_APP_ID"];
if let Ok(entries) = std::fs::read_dir("/proc") {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if let Ok(pid) = name_str.parse::<i32>() {
let env_path = format!("/proc/{}/environ", pid);
let Ok(env_bytes) = std::fs::read(&env_path) else {
continue;
};
for var in env_bytes.split(|b| *b == 0) {
if var.is_empty() {
continue;
}
let Ok(var_str) = std::str::from_utf8(var) else {
continue;
};
for key in &keys {
let prefix = format!("{}=", key);
if var_str
.strip_prefix(&prefix)
.is_some_and(|val| val == target)
{
pids.push(pid);
}
}
}
}
}
}
pids
}
/// Kill Steam game processes by Steam App ID
pub fn kill_steam_game_processes(app_id: u32, signal: Signal) -> bool {
let pids = find_steam_game_pids(app_id);
if pids.is_empty() {
return false;
}
for pid in pids {
let _ = signal::kill(Pid::from_raw(pid), signal);
}
true
}
/// Kill processes by command name using pkill /// Kill processes by command name using pkill
pub fn kill_by_command(command_name: &str, signal: Signal) -> bool { pub fn kill_by_command(command_name: &str, signal: Signal) -> bool {
let signal_name = match signal { let signal_name = match signal {
@ -108,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
} }
} }
@ -158,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(" ");
@ -347,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
@ -390,7 +526,8 @@ impl ManagedProcess {
let fields: Vec<&str> = after_comm.split_whitespace().collect(); let fields: Vec<&str> = after_comm.split_whitespace().collect();
if fields.len() >= 2 if fields.len() >= 2
&& let Ok(ppid) = fields[1].parse::<i32>() && let Ok(ppid) = fields[1].parse::<i32>()
&& ppid == parent_pid { && ppid == parent_pid
{
descendants.push(pid); descendants.push(pid);
to_check.push(pid); to_check.push(pid);
} }

View file

@ -148,7 +148,8 @@ 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;
} }
} }
@ -185,7 +186,8 @@ 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]
@ -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,14 +35,16 @@ impl BatteryStatus {
// Check for battery // Check for battery
if name_str.starts_with("BAT") if name_str.starts_with("BAT")
&& let Some((percent, charging)) = read_battery_info(&path) { && let Some((percent, charging)) = read_battery_info(&path)
{
status.percent = Some(percent); status.percent = Some(percent);
status.charging = charging; status.charging = charging;
} }
// Check for AC adapter // Check for AC adapter
if (name_str.starts_with("AC") || name_str.contains("ADP")) if (name_str.starts_with("AC") || name_str.contains("ADP"))
&& let Some(online) = read_ac_status(&path) { && let Some(online) = read_ac_status(&path)
{
status.ac_connected = online; status.ac_connected = online;
} }
} }

View file

@ -43,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,7 +218,8 @@ impl SharedState {
entry_name, entry_name,
.. ..
} = state } = state
&& sid == session_id { && sid == session_id
{
*state = SessionState::Warning { *state = SessionState::Warning {
session_id: session_id.clone(), session_id: session_id.clone(),
entry_id: entry_id.clone(), entry_id: entry_id.clone(),

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,7 +75,18 @@ 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)
std::fs::set_permissions(&self.socket_path, std::fs::Permissions::from_mode(0o660))?; if let Err(err) =
std::fs::set_permissions(&self.socket_path, std::fs::Permissions::from_mode(0o660))
{
if err.kind() == std::io::ErrorKind::PermissionDenied {
warn!(
path = %self.socket_path.display(),
"Permission denied setting socket permissions; continuing with defaults"
);
} else {
return Err(err.into());
}
}
info!(path = %self.socket_path.display(), "IPC server listening"); info!(path = %self.socket_path.display(), "IPC server listening");
@ -178,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;
@ -328,7 +340,18 @@ mod tests {
let socket_path = dir.path().join("test.sock"); let socket_path = dir.path().join("test.sock");
let mut server = IpcServer::new(&socket_path); let mut server = IpcServer::new(&socket_path);
server.start().await.unwrap(); if let Err(err) = server.start().await {
if let IpcError::Io(ref io_err) = err
&& io_err.kind() == std::io::ErrorKind::PermissionDenied
{
eprintln!(
"Skipping IPC server start test due to permission error: {}",
io_err
);
return;
}
panic!("IPC server start failed: {err}");
}
assert!(socket_path.exists()); assert!(socket_path.exists());
} }

View file

@ -24,7 +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 = { workspace = true } gilrs = "0.11"
[features] [features]
default = [] default = []

View file

@ -2,17 +2,15 @@
use gtk4::glib; use gtk4::glib;
use gtk4::prelude::*; use gtk4::prelude::*;
use std::cell::RefCell;
use std::path::PathBuf; use std::path::PathBuf;
use std::rc::Rc;
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;
use crate::input::{key_to_nav_command, GamepadHandler};
use crate::state::{LauncherState, SharedState}; use crate::state::{LauncherState, SharedState};
/// CSS styling for the launcher /// CSS styling for the launcher
@ -44,11 +42,11 @@ window {
border-color: #4a90d9; border-color: #4a90d9;
} }
.launcher-tile.selected { .launcher-tile:focus,
.launcher-tile:focus-visible {
background: #1f3460; background: #1f3460;
background-color: #1f3460; background-color: #1f3460;
border-color: #4a90d9; border-color: #ffd166;
box-shadow: 0 0 0 3px rgba(74, 144, 217, 0.4);
} }
.launcher-tile:active { .launcher-tile:active {
@ -166,36 +164,6 @@ impl LauncherApp {
window.set_child(Some(&stack)); window.set_child(Some(&stack));
// Set up keyboard input handling
let grid_for_key = grid.downgrade();
let key_controller = gtk4::EventControllerKey::new();
key_controller.connect_key_pressed(move |_, keyval, _keycode, _state| {
if let Some(cmd) = key_to_nav_command(keyval)
&& let Some(grid) = grid_for_key.upgrade()
{
grid.handle_nav_command(cmd);
return glib::Propagation::Stop;
}
glib::Propagation::Proceed
});
window.add_controller(key_controller);
// Set up gamepad input handling in a background thread
let grid_for_gamepad = grid.downgrade();
let gamepad_handler = Rc::new(RefCell::new(GamepadHandler::new()));
// Poll gamepad at 60Hz using glib timeout
glib::timeout_add_local(std::time::Duration::from_millis(16), move || {
if let Some(handler) = gamepad_handler.borrow().as_ref() {
while let Some(cmd) = handler.try_recv() {
if let Some(grid) = grid_for_gamepad.upgrade() {
grid.handle_nav_command(cmd);
}
}
}
glib::ControlFlow::Continue
});
// Create shared state // Create shared state
let state = SharedState::new(); let state = SharedState::new();
let state_receiver = state.subscribe(); let state_receiver = state.subscribe();
@ -208,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();
@ -336,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;
@ -379,10 +356,10 @@ impl LauncherApp {
stack.set_visible_child_name("loading"); stack.set_visible_child_name("loading");
} }
LauncherState::Idle { entries } => { LauncherState::Idle { entries } => {
if let Some(ref 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.ensure_selection(); grid.grab_focus();
} }
if let Some(ref win) = window { if let Some(ref win) = window {
win.set_visible(true); win.set_visible(true);
@ -422,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);
@ -499,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
.send(Command::Launch {
entry_id: entry_id.clone(), entry_id: entry_id.clone(),
}).await.map_err(Into::into) })
.await
.map_err(Into::into)
} }
#[allow(dead_code)] #[allow(dead_code)]
pub async fn stop_current(&self) -> Result<Response> { pub async fn stop_current(&self) -> Result<Response> {
let mut client = IpcClient::connect(&self.socket_path).await?; let mut client = IpcClient::connect(&self.socket_path).await?;
client.send(Command::StopCurrent { client
.send(Command::StopCurrent {
mode: shepherd_api::StopMode::Graceful, mode: shepherd_api::StopMode::Graceful,
}).await.map_err(Into::into) })
.await
.map_err(Into::into)
} }
pub async fn get_state(&self) -> Result<Response> { pub async fn get_state(&self) -> Result<Response> {
@ -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)
} }
} }
@ -252,5 +265,6 @@ fn reason_to_message(reason: &ReasonCode) -> &'static str {
ReasonCode::SessionActive { .. } => "Another session is active", ReasonCode::SessionActive { .. } => "Another session is active",
ReasonCode::UnsupportedKind { .. } => "Entry type not supported", ReasonCode::UnsupportedKind { .. } => "Entry type not supported",
ReasonCode::Disabled { .. } => "Entry disabled", ReasonCode::Disabled { .. } => "Entry disabled",
ReasonCode::InternetUnavailable { .. } => "Internet connection unavailable",
} }
} }

View file

@ -5,10 +5,9 @@ use gtk4::prelude::*;
use gtk4::subclass::prelude::*; use gtk4::subclass::prelude::*;
use shepherd_api::EntryView; use shepherd_api::EntryView;
use shepherd_util::EntryId; use shepherd_util::EntryId;
use std::cell::{Cell, RefCell}; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use crate::input::NavCommand;
use crate::tile::LauncherTile; use crate::tile::LauncherTile;
mod imp { mod imp {
@ -18,22 +17,16 @@ mod imp {
pub struct LauncherGrid { pub struct LauncherGrid {
pub flow_box: gtk4::FlowBox, pub flow_box: gtk4::FlowBox,
pub scrolled: gtk4::ScrolledWindow,
pub tiles: RefCell<Vec<LauncherTile>>, pub tiles: RefCell<Vec<LauncherTile>>,
pub on_launch: LaunchCallback, pub on_launch: LaunchCallback,
pub selected_index: Cell<Option<usize>>,
pub columns: Cell<u32>,
} }
impl Default for LauncherGrid { impl Default for LauncherGrid {
fn default() -> Self { fn default() -> Self {
Self { Self {
flow_box: gtk4::FlowBox::new(), flow_box: gtk4::FlowBox::new(),
scrolled: gtk4::ScrolledWindow::new(),
tiles: RefCell::new(Vec::new()), tiles: RefCell::new(Vec::new()),
on_launch: Rc::new(RefCell::new(None)), on_launch: Rc::new(RefCell::new(None)),
selected_index: Cell::new(None),
columns: Cell::new(6), // Will be updated based on actual layout
} }
} }
} }
@ -55,13 +48,11 @@ mod imp {
obj.set_valign(gtk4::Align::Fill); obj.set_valign(gtk4::Align::Fill);
obj.set_hexpand(true); obj.set_hexpand(true);
obj.set_vexpand(true); obj.set_vexpand(true);
obj.set_focusable(true);
obj.set_focus_on_click(true);
// 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::Single); self.flow_box
self.flow_box.set_activate_on_single_click(false); .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);
@ -70,17 +61,17 @@ 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.add_css_class("launcher-grid");
self.flow_box.set_focusable(true); self.flow_box.set_focusable(true);
self.flow_box.add_css_class("launcher-grid");
// Wrap in a scrolled window // Wrap in a scrolled window
self.scrolled let scrolled = gtk4::ScrolledWindow::new();
.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic); scrolled.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic);
self.scrolled.set_child(Some(&self.flow_box)); scrolled.set_child(Some(&self.flow_box));
self.scrolled.set_hexpand(true); scrolled.set_hexpand(true);
self.scrolled.set_vexpand(true); scrolled.set_vexpand(true);
obj.append(&self.scrolled); obj.append(&scrolled);
} }
} }
@ -113,7 +104,6 @@ impl LauncherGrid {
imp.flow_box.remove(&child); imp.flow_box.remove(&child);
} }
imp.tiles.borrow_mut().clear(); imp.tiles.borrow_mut().clear();
imp.selected_index.set(None);
// Create tiles for enabled entries // Create tiles for enabled entries
for entry in entries { for entry in entries {
@ -129,7 +119,8 @@ 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);
} }
}); });
@ -138,10 +129,7 @@ impl LauncherGrid {
imp.tiles.borrow_mut().push(tile); imp.tiles.borrow_mut().push(tile);
} }
// Select first tile if we have any self.select_first();
if !imp.tiles.borrow().is_empty() {
self.select_index(0);
}
} }
/// Enable or disable all tiles /// Enable or disable all tiles
@ -151,146 +139,113 @@ impl LauncherGrid {
} }
} }
/// Handle a navigation command pub fn select_first(&self) {
pub fn handle_nav_command(&self, command: NavCommand) {
let imp = self.imp(); let imp = self.imp();
let tiles = imp.tiles.borrow(); if let Some(child) = imp.flow_box.child_at_index(0) {
let count = tiles.len();
if count == 0 {
return;
}
let current = imp.selected_index.get().unwrap_or(0);
let columns = self.get_columns_count();
match command {
NavCommand::Up => {
if current >= columns {
self.select_index(current - columns);
}
}
NavCommand::Down => {
let next = current + columns;
if next < count {
self.select_index(next);
}
}
NavCommand::Left => {
if current > 0 {
self.select_index(current - 1);
}
}
NavCommand::Right => {
if current + 1 < count {
self.select_index(current + 1);
}
}
NavCommand::Activate => {
drop(tiles);
self.activate_selected();
}
}
}
/// Select a tile by index
fn select_index(&self, index: usize) {
let imp = self.imp();
let tiles = imp.tiles.borrow();
if index >= tiles.len() {
return;
}
// Remove selected class from previous tile
if let Some(prev_idx) = imp.selected_index.get()
&& let Some(prev_tile) = tiles.get(prev_idx)
{
prev_tile.remove_css_class("selected");
}
// Add selected class to new tile
if let Some(tile) = tiles.get(index) {
tile.add_css_class("selected");
tile.grab_focus();
// Scroll to make sure the tile is visible
if let Some(child) = imp.flow_box.child_at_index(index as i32) {
imp.flow_box.select_child(&child); imp.flow_box.select_child(&child);
child.grab_focus();
// Ensure the tile is scrolled into view
let adj = imp.scrolled.vadjustment();
let (_, y) = tile.translate_coordinates(&imp.scrolled, 0.0, 0.0).unwrap_or((0.0, 0.0));
let tile_height = tile.height() as f64;
let view_height = imp.scrolled.height() as f64;
if y < 0.0 {
adj.set_value(adj.value() + y - 24.0);
} else if y + tile_height > view_height {
adj.set_value(adj.value() + (y + tile_height - view_height) + 24.0);
}
} }
} }
imp.selected_index.set(Some(index)); pub fn move_selection(&self, dx: i32, dy: i32) {
}
/// Activate the currently selected tile
fn activate_selected(&self) {
let imp = self.imp(); let imp = self.imp();
if imp.tiles.borrow().is_empty() {
return;
}
if let Some(index) = imp.selected_index.get() { let current_child = imp
let tiles = imp.tiles.borrow(); .flow_box
if let Some(tile) = tiles.get(index) .selected_children()
&& tile.is_sensitive() .first()
&& let Some(entry_id) = tile.entry_id() .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() && let Some(callback) = imp.on_launch.borrow().as_ref()
{ {
callback(entry_id); callback(entry_id);
} }
} }
} }
/// Get the current column count based on layout
fn get_columns_count(&self) -> usize {
let imp = self.imp();
let tiles = imp.tiles.borrow();
if tiles.is_empty() {
return 1;
}
// Try to determine columns from actual layout
// by checking how many tiles are on the first row (same y position)
if tiles.len() >= 2 {
let first_y = tiles[0].allocation().y();
let mut columns = 1;
for tile in tiles.iter().skip(1) {
if tile.allocation().y() == first_y {
columns += 1;
} else {
break;
}
}
imp.columns.set(columns as u32);
return columns;
}
imp.columns.get() as usize
}
/// Get the flow box for focus management
pub fn flow_box(&self) -> &gtk4::FlowBox {
&self.imp().flow_box
}
/// Ensure a tile is selected (for when grid becomes visible)
pub fn ensure_selection(&self) {
let imp = self.imp();
if imp.selected_index.get().is_none() && !imp.tiles.borrow().is_empty() {
self.select_index(0);
}
}
} }
impl Default for LauncherGrid { impl Default for LauncherGrid {

View file

@ -1,210 +0,0 @@
//! Input handling for keyboard and gamepad navigation
use gilrs::{Axis, Button, Event as GilrsEvent, EventType, Gilrs};
use gtk4::gdk;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use tracing::{debug, trace, warn};
/// Navigation commands that can be triggered by keyboard or gamepad
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavCommand {
/// Move selection up
Up,
/// Move selection down
Down,
/// Move selection left
Left,
/// Move selection right
Right,
/// Activate/launch the selected item
Activate,
}
/// Threshold for analog stick to register as a direction
const AXIS_THRESHOLD: f32 = 0.5;
/// Deadzone for analog stick (ignore small movements)
const AXIS_DEADZONE: f32 = 0.2;
/// Delay before repeating analog stick navigation (ms)
const ANALOG_REPEAT_DELAY_MS: u64 = 200;
/// Maps a GTK key to a navigation command
pub fn key_to_nav_command(keyval: gdk::Key) -> Option<NavCommand> {
match keyval {
gdk::Key::Up | gdk::Key::KP_Up | gdk::Key::w | gdk::Key::W => Some(NavCommand::Up),
gdk::Key::Down | gdk::Key::KP_Down | gdk::Key::s | gdk::Key::S => Some(NavCommand::Down),
gdk::Key::Left | gdk::Key::KP_Left | gdk::Key::a | gdk::Key::A => Some(NavCommand::Left),
gdk::Key::Right | gdk::Key::KP_Right | gdk::Key::d | gdk::Key::D => Some(NavCommand::Right),
gdk::Key::Return | gdk::Key::KP_Enter | gdk::Key::space => Some(NavCommand::Activate),
_ => None,
}
}
/// Handles gamepad input in a background thread and sends navigation commands
pub struct GamepadHandler {
command_rx: mpsc::Receiver<NavCommand>,
_thread_handle: thread::JoinHandle<()>,
}
impl GamepadHandler {
/// Create a new gamepad handler that polls for input
pub fn new() -> Option<Self> {
let (tx, rx) = mpsc::channel();
let handle = thread::Builder::new()
.name("gamepad-input".into())
.spawn(move || {
Self::gamepad_loop(tx);
})
.ok()?;
Some(Self {
command_rx: rx,
_thread_handle: handle,
})
}
/// Try to receive any pending navigation commands (non-blocking)
pub fn try_recv(&self) -> Option<NavCommand> {
self.command_rx.try_recv().ok()
}
/// The main gamepad polling loop
fn gamepad_loop(tx: mpsc::Sender<NavCommand>) {
let gilrs = match Gilrs::new() {
Ok(g) => g,
Err(e) => {
warn!(error = %e, "Failed to initialize gamepad support");
return;
}
};
debug!("Gamepad handler initialized");
// Log connected gamepads
for (_id, gamepad) in gilrs.gamepads() {
debug!(
name = gamepad.name(),
"Found gamepad"
);
}
let mut gilrs = gilrs;
// Track analog stick state for repeat navigation
let mut last_analog_nav: Option<(NavCommand, std::time::Instant)> = None;
let mut axis_x: f32 = 0.0;
let mut axis_y: f32 = 0.0;
loop {
// Process all pending events
while let Some(GilrsEvent { event, .. }) = gilrs.next_event() {
match event {
EventType::ButtonPressed(button, _) => {
if let Some(cmd) = Self::button_to_nav_command(button) {
trace!(button = ?button, command = ?cmd, "Gamepad button pressed");
let _ = tx.send(cmd);
}
}
EventType::AxisChanged(axis, value, _) => {
match axis {
Axis::LeftStickX | Axis::RightStickX => {
axis_x = value;
}
Axis::LeftStickY | Axis::RightStickY => {
axis_y = value;
}
_ => {}
}
}
_ => {}
}
}
// Handle analog stick navigation with repeat
let analog_cmd = Self::axis_to_nav_command(axis_x, axis_y);
match (analog_cmd, last_analog_nav.as_ref()) {
(Some(cmd), None) => {
// New direction - send immediately
trace!(command = ?cmd, "Analog stick navigation");
let _ = tx.send(cmd);
last_analog_nav = Some((cmd, std::time::Instant::now()));
}
(Some(cmd), Some((last_cmd, last_time))) => {
if cmd != *last_cmd {
// Direction changed - send immediately
trace!(command = ?cmd, "Analog stick direction changed");
let _ = tx.send(cmd);
last_analog_nav = Some((cmd, std::time::Instant::now()));
} else if last_time.elapsed() >= Duration::from_millis(ANALOG_REPEAT_DELAY_MS) {
// Same direction held - repeat
trace!(command = ?cmd, "Analog stick repeat");
let _ = tx.send(cmd);
last_analog_nav = Some((cmd, std::time::Instant::now()));
}
}
(None, _) => {
// Stick returned to center
last_analog_nav = None;
}
}
// Sleep briefly to avoid busy-waiting
thread::sleep(Duration::from_millis(16)); // ~60 Hz
}
}
/// Maps a gamepad button to a navigation command
fn button_to_nav_command(button: Button) -> Option<NavCommand> {
match button {
// D-pad
Button::DPadUp => Some(NavCommand::Up),
Button::DPadDown => Some(NavCommand::Down),
Button::DPadLeft => Some(NavCommand::Left),
Button::DPadRight => Some(NavCommand::Right),
// Action buttons - A, B, and Start all activate
Button::South => Some(NavCommand::Activate), // A on Xbox/Switch, X on PlayStation
Button::East => Some(NavCommand::Activate), // B on Xbox, Circle on PlayStation
Button::Start => Some(NavCommand::Activate),
_ => None,
}
}
/// Maps analog stick axes to a navigation command
fn axis_to_nav_command(x: f32, y: f32) -> Option<NavCommand> {
// Apply deadzone
let x = if x.abs() < AXIS_DEADZONE { 0.0 } else { x };
let y = if y.abs() < AXIS_DEADZONE { 0.0 } else { y };
// Determine primary direction (prioritize the axis with larger magnitude)
if x.abs() > y.abs() {
// Horizontal movement
if x > AXIS_THRESHOLD {
Some(NavCommand::Right)
} else if x < -AXIS_THRESHOLD {
Some(NavCommand::Left)
} else {
None
}
} else {
// Vertical movement (note: Y axis is typically inverted on gamepads)
if y > AXIS_THRESHOLD {
Some(NavCommand::Down)
} else if y < -AXIS_THRESHOLD {
Some(NavCommand::Up)
} else {
None
}
}
}
}
impl Default for GamepadHandler {
fn default() -> Self {
Self::new().expect("Failed to create gamepad handler")
}
}

View file

@ -6,12 +6,13 @@
mod app; mod app;
mod client; mod client;
mod grid; mod grid;
mod input;
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;
@ -28,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<()> {
@ -36,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();
@ -46,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

@ -79,6 +79,8 @@ impl LauncherTile {
let fallback_icon = match entry.kind_tag { let fallback_icon = match entry.kind_tag {
shepherd_api::EntryKindTag::Process => "application-x-executable", shepherd_api::EntryKindTag::Process => "application-x-executable",
shepherd_api::EntryKindTag::Snap => "application-x-executable", shepherd_api::EntryKindTag::Snap => "application-x-executable",
shepherd_api::EntryKindTag::Steam => "application-x-executable",
shepherd_api::EntryKindTag::Flatpak => "application-x-executable",
shepherd_api::EntryKindTag::Vm => "computer", shepherd_api::EntryKindTag::Vm => "computer",
shepherd_api::EntryKindTag::Media => "video-x-generic", shepherd_api::EntryKindTag::Media => "video-x-generic",
shepherd_api::EntryKindTag::Custom => "applications-other", shepherd_api::EntryKindTag::Custom => "applications-other",
@ -141,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,7 +111,10 @@ pub fn default_log_dir() -> PathBuf {
/// Get the parent directory of the socket (for creating it) /// Get the parent directory of the socket (for creating it)
pub fn socket_dir() -> PathBuf { pub fn socket_dir() -> PathBuf {
let socket_path = socket_path_without_env(); let socket_path = socket_path_without_env();
socket_path.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| { socket_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| {
// Should never happen with our paths, but just in case // Should never happen with our paths, but just in case
PathBuf::from("/tmp").join(APP_DIR) PathBuf::from("/tmp").join(APP_DIR)
}) })

View file

@ -42,7 +42,10 @@ impl RateLimiter {
pub fn check(&mut self, client_id: &ClientId) -> bool { pub fn check(&mut self, client_id: &ClientId) -> bool {
let now = Instant::now(); let now = Instant::now();
let bucket = self.clients.entry(client_id.clone()).or_insert(ClientBucket { let bucket = self
.clients
.entry(client_id.clone())
.or_insert(ClientBucket {
tokens: self.max_tokens, tokens: self.max_tokens,
last_refill: now, last_refill: now,
}); });
@ -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);
@ -534,15 +535,24 @@ mod tests {
// 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

@ -0,0 +1,100 @@
//! Internet connectivity monitoring for shepherdd.
use shepherd_config::{InternetCheckScheme, InternetCheckTarget, Policy};
use shepherd_core::CoreEngine;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpStream;
use tokio::sync::Mutex;
use tokio::time;
use tracing::{debug, warn};
pub struct InternetMonitor {
targets: Vec<InternetCheckTarget>,
interval: Duration,
timeout: Duration,
}
impl InternetMonitor {
pub fn from_policy(policy: &Policy) -> Option<Self> {
let mut targets = Vec::new();
if let Some(check) = policy.service.internet.check.clone() {
targets.push(check);
}
for entry in &policy.entries {
if entry.internet.required
&& let Some(check) = entry.internet.check.clone()
&& !targets.contains(&check)
{
targets.push(check);
}
}
if targets.is_empty() {
return None;
}
Some(Self {
targets,
interval: policy.service.internet.interval,
timeout: policy.service.internet.timeout,
})
}
pub async fn run(self, engine: Arc<Mutex<CoreEngine>>) {
// Initial check
self.check_all(&engine).await;
let mut interval = time::interval(self.interval);
loop {
interval.tick().await;
self.check_all(&engine).await;
}
}
async fn check_all(&self, engine: &Arc<Mutex<CoreEngine>>) {
for target in &self.targets {
let available = check_target(target, self.timeout).await;
let changed = {
let mut eng = engine.lock().await;
eng.set_internet_status(target.clone(), available)
};
if changed {
debug!(
check = %target.original,
available,
"Internet connectivity status changed"
);
}
}
}
}
async fn check_target(target: &InternetCheckTarget, timeout: Duration) -> bool {
match target.scheme {
InternetCheckScheme::Tcp | InternetCheckScheme::Http | InternetCheckScheme::Https => {
let connect = TcpStream::connect((target.host.as_str(), target.port));
match time::timeout(timeout, connect).await {
Ok(Ok(stream)) => {
drop(stream);
true
}
Ok(Err(err)) => {
debug!(
check = %target.original,
error = %err,
"Internet check failed"
);
false
}
Err(_) => {
warn!(check = %target.original, "Internet check timed out");
false
}
}
}
}
}

View file

@ -12,24 +12,26 @@
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;
mod internet;
/// shepherdd - Policy enforcement service for child-focused computing /// shepherdd - Policy enforcement service for child-focused computing
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "shepherdd")] #[command(name = "shepherdd")]
@ -60,6 +62,7 @@ struct Service {
ipc: Arc<IpcServer>, ipc: Arc<IpcServer>,
store: Arc<dyn Store>, store: Arc<dyn Store>,
rate_limiter: RateLimiter, rate_limiter: RateLimiter,
internet_monitor: Option<internet::InternetMonitor>,
} }
impl Service { impl Service {
@ -118,6 +121,9 @@ impl Service {
// Initialize core engine // Initialize core engine
let engine = CoreEngine::new(policy, store.clone(), host.capabilities().clone()); let engine = CoreEngine::new(policy, store.clone(), host.capabilities().clone());
// Initialize internet connectivity monitor (if configured)
let internet_monitor = internet::InternetMonitor::from_policy(engine.policy());
// Initialize IPC server // Initialize IPC server
let mut ipc = IpcServer::new(&socket_path); let mut ipc = IpcServer::new(&socket_path);
ipc.start().await?; ipc.start().await?;
@ -134,6 +140,7 @@ impl Service {
ipc: Arc::new(ipc), ipc: Arc::new(ipc),
store, store,
rate_limiter, rate_limiter,
internet_monitor,
}) })
} }
@ -156,6 +163,14 @@ impl Service {
let volume = self.volume.clone(); let volume = self.volume.clone();
let store = self.store.clone(); let store = self.store.clone();
// Start internet connectivity monitoring (if configured)
if let Some(monitor) = self.internet_monitor {
let engine_ref = engine.clone();
tokio::spawn(async move {
monitor.run(engine_ref).await;
});
}
// Spawn IPC accept task // Spawn IPC accept task
let ipc_accept = ipc_ref.clone(); let ipc_accept = ipc_ref.clone();
tokio::spawn(async move { tokio::spawn(async move {
@ -165,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);
@ -231,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
&& let Err(e) = host
.stop(
handle,
HostStopMode::Graceful {
timeout: Duration::from_secs(5), timeout: Duration::from_secs(5),
}).await { },
)
.await
{
warn!(error = %e, "Failed to stop session gracefully"); warn!(error = %e, "Failed to stop session gracefully");
} }
} }
@ -286,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
@ -390,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,
@ -457,8 +479,16 @@ 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,
host,
volume,
ipc,
store,
&client_id,
request.request_id,
request.command,
)
.await; .await;
let _ = ipc.send_response(&client_id, response).await; let _ = ipc.send_response(&client_id, response).await;
@ -472,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;
@ -532,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 {
@ -562,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) => {
@ -582,12 +601,14 @@ impl Service {
deadline, deadline,
} = event } = event
{ {
ipc.broadcast_event(Event::new(EventPayload::SessionStarted { ipc.broadcast_event(Event::new(
EventPayload::SessionStarted {
session_id: session_id.clone(), session_id: session_id.clone(),
entry_id, entry_id,
label, label,
deadline, deadline,
})); },
));
Response::success( Response::success(
request_id, request_id,
@ -599,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",
),
) )
} }
} }
@ -613,16 +637,20 @@ impl Service {
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(
@ -651,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,
@ -704,7 +730,8 @@ impl Service {
Command::ReloadConfig => { Command::ReloadConfig => {
// Check permission // Check permission
if let Some(info) = ipc.get_client_info(client_id).await if let Some(info) = ipc.get_client_info(client_id).await
&& !info.role.can_reload_config() { && !info.role.can_reload_config()
{
return Response::error( return Response::error(
request_id, request_id,
ErrorInfo::new(ErrorCode::PermissionDenied, "Admin role required"), ErrorInfo::new(ErrorCode::PermissionDenied, "Admin role required"),
@ -718,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)
@ -746,7 +771,8 @@ impl Service {
Command::ExtendCurrent { by } => { Command::ExtendCurrent { by } => {
// Check permission // Check permission
if let Some(info) = ipc.get_client_info(client_id).await if let Some(info) = ipc.get_client_info(client_id).await
&& !info.role.can_extend() { && !info.role.can_extend()
{
return Response::error( return Response::error(
request_id, request_id,
ErrorInfo::new(ErrorCode::PermissionDenied, "Admin role required"), ErrorInfo::new(ErrorCode::PermissionDenied, "Admin role required"),
@ -755,12 +781,18 @@ impl Service {
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",
),
), ),
} }
} }
@ -902,7 +934,8 @@ 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);
} }
@ -925,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,8 +15,7 @@ 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,
@ -50,8 +49,8 @@ fn make_test_policy() -> Policy {
volume: None, volume: None,
disabled: false, disabled: false,
disabled_reason: None, 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(),
@ -90,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]
@ -149,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);
@ -187,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");
} }
@ -290,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);
} }
@ -315,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,21 @@
# Internet Connection Gating
Issue: <https://github.com/aarmea/shepherd-launcher/issues/9>
Summary:
- Added internet connectivity configuration in the shepherdd config schema and policy, with global checks and per-entry requirements.
- Implemented a connectivity monitor in shepherdd and enforced internet-required gating in the core engine.
- Added a new ReasonCode for internet-unavailable, updated launcher UI message mapping, and refreshed docs/examples.
Key files:
- crates/shepherd-config/src/schema.rs
- crates/shepherd-config/src/policy.rs
- crates/shepherd-config/src/internet.rs
- crates/shepherd-config/src/validation.rs
- crates/shepherd-core/src/engine.rs
- crates/shepherdd/src/internet.rs
- crates/shepherdd/src/main.rs
- crates/shepherd-api/src/types.rs
- crates/shepherd-launcher-ui/src/client.rs
- config.example.toml
- crates/shepherd-config/README.md

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
@ -27,8 +28,5 @@ libgirepository1.0-dev
# Layer shell for HUD overlay # Layer shell for HUD overlay
libgtk4-layer-shell-dev libgtk4-layer-shell-dev
# Gamepad/joystick support (for launcher input)
libudev-dev
# Required for rustup # Required for rustup
curl curl

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
@ -127,9 +127,10 @@ EOF
install_config() { install_config() {
local user="${1:-}" local user="${1:-}"
local source_config="${2:-}" local source_config="${2:-}"
local force="${3:-false}"
if [[ -z "$user" ]]; then if [[ -z "$user" ]]; then
die "Usage: shepherd install config --user USER [--source CONFIG]" die "Usage: shepherd install config --user USER [--source CONFIG] [--force]"
fi fi
validate_user "$user" validate_user "$user"
@ -161,7 +162,15 @@ install_config() {
# Check if config already exists # Check if config already exists
if maybe_sudo test -f "$dst_config"; then if maybe_sudo test -f "$dst_config"; then
warn "Config file already exists at $dst_config, skipping" if [[ "$force" == "true" ]]; then
warn "Overwriting existing config at $dst_config"
maybe_sudo cp "$source_config" "$dst_config"
maybe_sudo chown "$user:$user" "$dst_config"
maybe_sudo chmod 0644 "$dst_config"
success "Overwrote user configuration for $user"
else
warn "Config file already exists at $dst_config, skipping (use --force to overwrite)"
fi
else else
# Copy config file # Copy config file
maybe_sudo cp "$source_config" "$dst_config" maybe_sudo cp "$source_config" "$dst_config"
@ -175,9 +184,10 @@ install_config() {
install_all() { install_all() {
local user="${1:-}" local user="${1:-}"
local prefix="${2:-$DEFAULT_PREFIX}" local prefix="${2:-$DEFAULT_PREFIX}"
local force="${3:-false}"
if [[ -z "$user" ]]; then if [[ -z "$user" ]]; then
die "Usage: shepherd install all --user USER [--prefix PREFIX]" die "Usage: shepherd install all --user USER [--prefix PREFIX] [--force]"
fi fi
require_root require_root
@ -188,7 +198,7 @@ install_all() {
install_bins "$prefix" install_bins "$prefix"
install_sway_config "$prefix" install_sway_config "$prefix"
install_desktop_entry "$prefix" install_desktop_entry "$prefix"
install_config "$user" install_config "$user" "" "$force"
success "Installation complete!" success "Installation complete!"
info "" info ""
@ -206,6 +216,7 @@ install_main() {
local user="" local user=""
local prefix="$DEFAULT_PREFIX" local prefix="$DEFAULT_PREFIX"
local source_config="" local source_config=""
local force="false"
# Parse remaining arguments # Parse remaining arguments
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@ -222,6 +233,10 @@ install_main() {
source_config="$2" source_config="$2"
shift 2 shift 2
;; ;;
--force|-f)
force="true"
shift
;;
*) *)
die "Unknown option: $1" die "Unknown option: $1"
;; ;;
@ -233,7 +248,7 @@ install_main() {
install_bins "$prefix" install_bins "$prefix"
;; ;;
config) config)
install_config "$user" "$source_config" install_config "$user" "$source_config" "$force"
;; ;;
sway-config) sway-config)
install_sway_config "$prefix" install_sway_config "$prefix"
@ -242,7 +257,7 @@ install_main() {
install_desktop_entry "$prefix" install_desktop_entry "$prefix"
;; ;;
all) all)
install_all "$user" "$prefix" install_all "$user" "$prefix" "$force"
;; ;;
""|help|-h|--help) ""|help|-h|--help)
cat <<EOF cat <<EOF
@ -259,6 +274,7 @@ Options:
--user USER Target user for config deployment (required for config/all) --user USER Target user for config deployment (required for config/all)
--prefix PREFIX Installation prefix (default: $DEFAULT_PREFIX) --prefix PREFIX Installation prefix (default: $DEFAULT_PREFIX)
--source CONFIG Source config file (default: config.example.toml) --source CONFIG Source config file (default: config.example.toml)
--force, -f Overwrite existing configuration files
Environment: Environment:
DESTDIR Installation root for packaging (default: empty) DESTDIR Installation root for packaging (default: empty)
@ -266,6 +282,7 @@ Environment:
Examples: Examples:
shepherd install bins --prefix /usr/local shepherd install bins --prefix /usr/local
shepherd install config --user kiosk shepherd install config --user kiosk
shepherd install config --user kiosk --force
shepherd install all --user kiosk --prefix /usr shepherd install all --user kiosk --prefix /usr
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