62 Commits
Author SHA1 Message Date
sankalp 8bfb0dda39 fix: include LICENSE file in release tarball 2026-05-29 17:33:55 +05:45
sankalp ae32ce9c8d fix: always upload tarball to release regardless of ref_type 2026-05-29 17:21:18 +05:45
sankalp 11c3cec99d fix: force-overwrite files with stash instead of merge 2026-05-29 17:13:00 +05:45
sankalp f69b74ab73 fix: stash changes before checking out master in AUR action 2026-05-29 17:06:37 +05:45
sankalp bdf1312dd1 fix: upgrade AUR action to v4.1.3, checkout master before push 2026-05-29 16:59:42 +05:45
sankalp 2544f44309 fix: add contents: write permission for release upload 2026-05-29 16:47:30 +05:45
sankalp 34a57007d1 refactor(src/commands): Cleanup command printing and simplify GPU environment handling
Enhancements:
- Remove unnecessary multiline formatting in GPU, status, and temperature commands
- Consolidate zone type checks into a single condition
- Simplify environment variable tuple definitions
2026-05-29 16:38:24 +05:45
sankalp 3684c5b071 chore(root): bump version to 0.3.5Updated version bump and project metadata enrichment.
New Features
- None

Enhancements
- Updated edition to 2024
- Added license and repository fields
- Refined dependency versions

Bug Fixes
- None
2026-05-29 16:35:31 +05:45
sankalp 8faccc117a feat(src): add temperature, fan, power commands handling
- New Features
  - Added `temperature` subcommand to display system temperatures.
  - Added `fan` subcommand to display fan speeds.
  - Added `power` subcommand under GPU commands to show GPU power state and consumption.
- Enhancements
  - Integrated new subcommands into the command dispatcher.
- Bug Fixes
  - None
2026-05-29 16:35:26 +05:45
sankalp 92806e2843 feat(src/commands): add fan speed monitoring and enhance GPU temperature command
Refactor fan monitoring and improve GPU temperature reporting for better hardware visibility.

- New Features:
  - Add `fan` command to enumerate and display fan speeds via hwmon and thermal cooling devices.
  - Expand temperature detection across hwmon, thermal zones, and NVIDIA GPU sensors.
- Enhancements:
  - Strengthen GPU temperature output with power and clock information.
  - Improve error messages and logging consistency.
- Bug Fixes:
  - Correct error message wording.
  - Gracefully handle missing sensor files.
2026-05-29 16:35:21 +05:45
sankalp bdc95801c3 fix: reorder workflow to create tarball and update sha256 before AUR push 2026-05-29 16:14:29 +05:45
sankalp 2efa7a64f1 feat: auto-create and upload release tarball in workflow 2026-05-29 16:12:09 +05:45
sankalp c8561bf4ff docs(root):update README with new installation methods and project manifest 2026-05-29 16:01:16 +05:45
sankalp f202103a06 fix: exclude unsupported dsa key type from ssh_keyscan 2026-05-29 15:43:46 +05:45
sankalp e0a589d441 fix: update AUR push action inputs for v2.5.0 API 2026-05-29 15:37:41 +05:45
sankalp 151f5ad303 fix: run makepkg as non-root user in docker container 2026-05-29 15:31:51 +05:45
sankalp 92298ddce8 fix: add authors field to Cargo.toml (required by cargo-aur) 2026-05-29 15:25:28 +05:45
sankalp c223fea5f4 fix: replace removed archlinux/setup-pacman action with docker for .SRCINFO 2026-05-29 15:19:22 +05:45
sankalp 80899602e5 fix: make crates.io publish non-blocking, add workflow_dispatch, fix AUR action 2026-05-29 15:12:50 +05:45
sankalp 21defdb70a fix(.github/workflows): correctsetup-pacman action version
- Bug Fixes: Fixed CI workflow by using the correct archlinux/setup-pacman@v1 action instead of archlinux/actions/setup-pacman@v1
2026-05-28 20:36:01 +05:45
sankalp 90965f13b9 feat(root): bump version to 0.3.4New Features
- Upgrade lapctl to 0.3.4, adding new hardware management commands.
- Added clap and ctrlc dependencies to enhance CLI functionality.

Enhancements
- Updated Cargo.toml to edition 2024 and aligned dependency versions.
- Refined project description and repository link.

Bug Fixes
- No bug fixes included in this change.
2026-05-28 20:30:42 +05:45
sankalp 30a89fe2fe chore(root): bump version to 0.3.4 and add repository field
New Features
-

Enhancements
-

Bug Fixes
-
2026-05-28 20:29:37 +05:45
sankalp c7c06f09b8 fix(.github/workflows): correct package name in release-smoke.yml
- Bug Fixes: Update pkgname from lapctl to lapctl-bin to match AUR package naming
2026-05-28 20:24:55 +05:45
sankalp e4c3646fe0 chore(.github/workflows): add release smoke test workflowAdds CI pipeline that runs on tag and release events, checks formatting, lints, builds, tests, verifies binary execution, uploads artifact, and publishes to crates.io and AUR.
- New Features: - Implements automated release smoke testing pipeline.
- Enhancements: - Adds formatting linting and binary verification steps.
- Bug Fixes: -
2026-05-26 10:40:28 +05:45
sankalp 9e5b618f19 chore(root): add project metadata to Cargo.toml
- New Features: Added description, license, and repository fields to clarify project identity.
- Enhancements: Improved Cargo.toml metadata completeness.  - Bug Fixes: None
2026-05-26 10:36:25 +05:45
sankalp 59dabcf6f4 feat(.opencode/plans):add release smoke test workflow and update Cargo.toml metadata 2026-05-26 10:33:40 +05:45
sankalp c03abaadc5 feat(src/commands): Add Ideapad detection and conditional platform profile handling
Enables proper handling of Ideapad laptops by skipping `set_platform_profile` when it is decoupled from fan control, improving compatibility and user feedback.

New Features
- Added `is_ideapad()` helper function.

Enhancements
- Conditional execution of `set_platform_profile` based on Ideapad detection.
- Added informative messages when platform profile is skipped on Ideapad.

Bug Fixes
- None.
2026-05-25 19:02:19 +05:45
sankalp 972618a8e3 fix(src/hardware): improveerror handling for GPU process killing
New Features
- Added explicit error handling and logging for `fuser` command failures.

Enhancements
- Refined process termination logic with richer diagnostics and graceful degradation when tools are unavailable.

Bug Fixes
- Prevented silent failures when stopping `nvidia-persistenced.service` by logging errors instead of ignoring them.
2026-05-25 14:50:17 +05:45
sankalp 74aa1ff262 fix(src): handle tokio runtime creation errors gracefullyBug Fixes:
- Added proper error handling for tokio runtime creation, exiting with a clear error message instead of panicking.
2026-05-25 14:50:06 +05:45
sankalp 0219f1c8ce refactor(src/utils): improve error handling and messaging in system utilitiesNew Features
- None

Enhancements- Added detailed error reporting with exit codes for privilege escalation, initramfs rebuild, service management, and session termination
- Implemented graceful handling of permission‑setting failures with proper logging
- Enhanced error messages for systemctl and loginctl commands, providing clearer diagnostics

Bug Fixes- Fixed incomplete error propagation in status matching, now reports actual exit codes or underlying errors
2026-05-25 14:50:00 +05:45
sankalp 191afd2f12 feat(src/commands): enhance error handling, logging, and robustness in GPU command implementations
- **New Features**: Added detailed error logging for signal handling, directory creation, cache file operations, and service management; improved cleanup with explicit error propagation.
- **Enhancements**: Refactored Drop implementations to handle failures gracefully; upgraded status handling for systemctl commands with precise exit code reporting; strengthened directory and file removal with debug logging.
- **Bug Fixes**: Fixed missing error propagation in Ctrl+C handler; corrected service enable/disable status reporting; ensured proper removal of cache files and directories with appropriate logging of failures.
2026-05-25 14:49:48 +05:45
sankalp 02f8ff4c33 docs(root): Clarify Wayland auto‑detection and add safety features documentation
- New Features: Add `--no-reboot` flag usage example for GPU switching
- Enhancements: Document Wayland auto‑detection and new safety mechanisms (concurrency lock, interrupt‑safe restart, atomic writes)
- Bug Fixes: Minor phrasing corrections and typo fixes in README sections
2026-05-25 13:38:26 +05:45
sankalp 75c3a39597 feat(src/daemon): add battery limit validation to set_battery_limit
The change ensures that only valid battery percentages (1‑100) are processed, preventing out‑of‑range values from reaching the underlying command.

- New Features
  - Validation of battery limit percentage added.
- Enhancements  - Better error reporting for invalid arguments.
- Bug Fixes
  - Prevents processing of invalid battery limit values.
2026-05-25 13:36:40 +05:45
sankalp f5f73a4990 fix(src/hardware): improvePCI address parsing and error handling
- Improves robustness of NVIDIA GPU PCI bus detection.
- Adds validation for unexpected PCI format strings.
- Prevents panic by handling missing command output and malformed IDs gracefully.
2026-05-25 13:36:34 +05:45
sankalp c018aeda57 chore(root): Update dependencies and add new crates
Add several new crates (block2, cfg_aliases, ctrlc, dispatch2, fs2, nix, objc2, objc2-encode, winapi and their sub‑crates) to support new functionality and improve compatibility. Bump versions of core crates (libc, futures-core, etc.) to latest releases.

- New Features:
  - Introduce `block2`, `cfg_aliases`, `ctrlc`, `dispatch2`, `fs2`, `nix`, `objc2`, `objc2-encode`, and `winapi` (including target‑specific crates) to enable additional system interaction and async capabilities.
- Enhancements:
  - Upgrade `libc`, `futures-core`, `futures-util`, `tokio`, `clap`, `serde`, and other dependencies to latest stable versions for performance and security improvements.
- Bug Fixes:
  - Updated checksums and dependency resolution to reflect newer releases, reducing potential runtime issues.
2026-05-25 13:36:29 +05:45
sankalp 73e1edfd97 fix(src/utils): add warning for unknowndistribution when rebuilding initramfs
Log a warning when the initramfs rebuild is skipped because the distribution is unknown, ensuring the user is informed.

New Features
- None

Enhancements
- Added explicit warning for unknown distributions to prevent silent skips
- Improved logging clarity in rebuild_initramfs

Bug Fixes
- Fixed missing warning path that could hide build issues on unsupported distros
2026-05-25 13:36:23 +05:45
sankalp 4e46604cfd feat(src/commands): implement atomic interrupt handling, display‑manager guard, lock file for operation serialization, safe cache writes, and improve daemon enable messaging
The changes add robust interruption support, exclusive‑lock enforcement, and safer file handling to the GPU switching commands, while clarifying installation output.

- New Features  - Atomic `INTERRUPTED` flag with Ctrl‑C signal handler
  - `DmGuard` struct to manage display‑manager start/stop lifecycle
  - `LockGuard` with exclusive lock file (`/var/lock/lapctl.lock`) to serialize GPU switches
  - Safe temporary‑file write + rename for cache updates
- Enhancements
  - Unified `prepare_no_reboot` returns `DmGuard` instead of `Option<String>`
  - Consistent early‑exit handling with lock acquisition checks
  - Improved debug/info logging for DM actions
  - Restructured `finalize_no_reboot` to return a guard value
  - Clarified installation success/failure messages for `lapctld`
- Bug Fixes
  - Prevented race conditions on concurrent GPU switches by locking before operation
  - Ensured lock is released on early return paths
  - Fixed incomplete `finalize_no_reboot` handling
  - Corrected daemon enable detection and user feedback logic
2026-05-25 13:36:16 +05:45
sankalp b6c0ca0b9b feat(src): add Clone derive to GpuCommandsThe GpuCommands enum now derives Clone, enabling it to be cloned and used in contexts such as message passing or state storage.
New Features:
- Enables cloning of GpuCommands variants for greater flexibility.

Enhancements:
- Improves CLI ergonomics by allowing command enum to be transmitted across async boundaries.
2026-05-25 13:27:23 +05:45
sankalp 94aed29728 feat(src/commands): add Wayland detection and Xorg modesetting support
Enable automatic handling of NVIDIA commands in Wayland sessions and generate proper Xorg config snippets.

- New Features
  - Adds `is_wayland_session()` that detects a Wayland environment via `WAYLAND_DISPLAY` or `XDG_SESSION_TYPE`.
  - Adds `xorg_modesetting(bus_id)` that builds a full Xorg configuration snippet for the modesetting driver.

- Enhancements
  - `switch_nvidia()` now automatically treats NVIDIA commands as Wayland‑aware when a Wayland session is detected.
  - Refactored command matching in `execute()` to preserve original fields while injecting Wayland awareness.
  - Fixed daemon invocation to pass a reference to the (potentially transformed) command.

- Bug Fixes
  - Corrected argument passing in `try_call_daemon` and `execute_local` to avoid move errors.
  - Prevented duplicate command handling when a Wayland session is detected.
2026-05-25 13:27:17 +05:45
sankalp d12d0a0916 bumped to 0.3.3 2026-05-08 22:14:36 +05:45
sankalp 30dadec637 fixed clippy:derivable-imps warning 2026-05-08 22:12:31 +05:45
sankalp dba3995608 docs(root): Remove sleep inhibitordocumentation and inhibit daemon CLI examples
Clean up obsolete documentation and CLI usage examples from README.
2026-05-08 22:08:03 +05:45
sankalp c8a688f44f refactor(tests): drop inhibit test file 2026-05-08 22:07:55 +05:45
sankalp 7de76d0365 refactor(src/daemon): drop set_system_inhibition method and simplify Default impl
Simplify daemon interface by removing obsolete system inhibition logic.

New Features
-
Enhancements
- Cleaned up dead code path.
-
Bug Fixes
- Prevented potential file descriptor leak by removing inhibition handling.
2026-05-08 22:07:53 +05:45
sankalp 055fdcd500 refactor(src/utils): remove systemd-inhibit handling from rebuild_initramfsSimplify command construction by dropping unused inhibition logic.
- New Features:
- Enhancements: Simplified command construction in rebuild_initramfs.
- Bug Fixes: Resolved potential error when systemd-inhibit is unavailable.
2026-05-08 22:07:49 +05:45
sankalp 934aeda6aa refactor(src): remove inhibit command
New Features
-

Enhancements
- Dropped inhibit subcommand and related modules.
- Cleaned up command handling logic in main.rs.

Bug Fixes- No bug fixes introduced.
2026-05-08 22:07:37 +05:45
sankalp 8947bd643a feat(commands):simplify battery limit messaging for Ideapad laptopsThe command now detects Ideapad hardware and provides clearer messages about Conservation Mode, removing outdated warnings. 2026-03-23 18:30:37 +05:45
sankalp 5ac2aed516 chore(root): bump lapctl version to 0.3.2
Updated Cargo.lock and Cargo.toml to version 0.3.2 of the `lapctl` package.

New Features
- None

Enhancements
- Updated dependency version to 0.3.2Bug Fixes
- None
2026-03-23 17:28:36 +05:45
sankalp 4020f65e28 bumped to 0.3.2 2026-03-23 17:10:37 +05:45
sankalp d52b3992e3 feat(src/commands):add Ideapad conservation mode fallback and improve charge limit messaging 2026-03-23 17:08:36 +05:45
sankalp 01d2e148a0 refactor(src/daemon): replace execute calls with execute_local for dbus commands
Why: Adopt local execution path to streamline command handling and reduce RPC overhead.

New Features
-

Enhancements
- Replace `gpu::execute` with `gpu::execute_local` in integrated, hybrid, and nvidia command invocations.
- Replace `crate::commands::power`, `battery`, `cooling`, and `touchpad` executions with `execute_local`.
- Remove the unused `LAPCTL_DAEMON_INTERNAL` environment variable setting.

Bug Fixes
- None
2026-03-23 16:16:31 +05:45
sankalp 0dc00ec5c9 refactor(src/commands): Refactor battery and touchpad command execution
Refactored command flow to separate daemon invocation, added status handling, and improved error handling.

- New Features: Added `execute_local` helper and `BatteryCommands::Status` handling.
- Enhancements: Simplified daemon checks, removed redundant condition, improved readability and error messages.
- Bug Fixes: Fixed missed daemon return path for status command and ensured proper touchpad inhibition logic.
2026-03-23 16:16:25 +05:45
sankalp 319ca54300 bumped to 0.3.1 2026-03-23 15:40:19 +05:45
sankalp f12134a9c8 feat(src/daemon): set LAPCTL_DAEMON_INTERNAL env var internally
Set an internal environment variable when the daemon starts to allow components to detect internal mode and prepare for future runtime configuration.

- New Features
  - Introduce `LAPCTL_DAEMON_INTERNAL` set to "1" inside the daemon.
- Enhancements
  - Enable downstream components to detect internal daemon execution.
2026-03-23 15:39:08 +05:45
sankalp 86ac9b556c chore(root): remove PKGBUILD and update .gitignore
Removed legacy packaging script and cleaned .gitignore entries.

New Features
-

Enhancements
-

Bug Fixes
-
2026-03-23 15:39:04 +05:45
sankalp c564259a19 refactor(src/commands): add timeouthandling and internal daemon guard
Adds 2‑second timeouts around D‑Bus interactions and guards against internal daemon self‑calls; refactors command handling to use a consistent async timeout pattern and improves error reporting.

New Features
- Wrap D‑Bus calls with a 2‑second timeout to prevent hangs
- Early‑exit when $LAPCTL_DAEMON_INTERNAL is set to skip self‑calls- Apply consistent timeout usage across all commands

Enhancements
- Unified error handling using the `matches!` macro
- Reduced race conditions in connection establishment

Bug Fixes
- Prevent silent failures when a connection timeout occurs
2026-03-23 15:38:57 +05:45
sankalp 46cbcb5b4b update 2026-03-23 15:28:18 +05:45
sankalp 899d4c831f docs(root): Simplify Arch Linux installation instructions
Remove outdated Arch AUR install recommendation and clean up documentation wording

New Features
-

Enhancements
- Updated `yay -S` command to install `lapctl` instead of `lapctl-git`
- Removed redundant dependency and udev rules note
- Adjusted emphasis formatting in `install-rules` description

Bug Fixes
- none
2026-03-22 18:05:36 +05:45
sankalp 3590f693d3 Merge pull request #3 from S4NKALP/dev 2026-03-22 17:55:25 +05:45
sankalp e98346fad5 fix test 2026-03-22 17:53:36 +05:45
sankalp b0fdc0d58e fuck it forgot to commit all those changes 2026-03-22 15:05:45 +05:45
sankalp c20a5d3a06 docs(root): update readme 2026-03-20 09:51:03 +05:45
24 changed files with 3055 additions and 463 deletions

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
name: Release Smoke Test
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+'
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: 'Version to publish (e.g. 0.3.4)'
required: true
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
jobs:
smoke-test:
name: Smoke Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Check formatting
run: cargo fmt --all -- --check
- name: Run Clippy Lints
run: cargo clippy -- -D warnings
- name: Build release
run: cargo build --release --verbose
- name: Run tests
run: cargo test --verbose
- name: Smoke test — binary runs
run: ./target/release/lapctl --version
- name: Upload release artifact
uses: actions/upload-artifact@v4
with:
name: lapctl-${{ github.ref_name }}
path: ./target/release/lapctl
- name: Publish to crates.io
continue-on-error: true
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: cargo publish
publish-aur:
name: Publish to AUR
needs: smoke-test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Get version
id: version
run: echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
env:
VERSION: ${{ github.event.inputs.version || github.ref_name }}
- name: Download release binary
uses: actions/download-artifact@v4
with:
name: lapctl-${{ github.ref_name }}
path: .
- name: Create tarball with binary
run: |
tar czf "lapctl-${{ steps.version.outputs.version }}-x86_64.tar.gz" \
lapctl LICENSE
- name: Generate PKGBUILD with cargo-aur
run: |
cargo install cargo-aur
mkdir -p aur-pkg
cargo aur --output aur-pkg
- name: Update PKGBUILD checksums
run: |
SHA=$(sha256sum "lapctl-${{ steps.version.outputs.version }}-x86_64.tar.gz" | cut -d' ' -f1)
sed -i "s/sha256sums=([^)]*)/sha256sums=('$SHA')/" aur-pkg/PKGBUILD
- name: Generate .SRCINFO
run: |
docker run --rm -v "$(pwd)/aur-pkg:/build" archlinux:latest \
bash -c "
useradd -m builder
chown -R builder:builder /build
cd /build && su builder -c 'makepkg --printsrcinfo > .SRCINFO'
"
- name: Push to AUR
uses: KSXGitHub/github-actions-deploy-aur@v4.1.3
with:
pkgname: lapctl-bin
pkgbuild: aur-pkg/PKGBUILD
assets: aur-pkg/.SRCINFO
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
ssh_keyscan_types: rsa,ecdsa,ed25519
commit_username: ${{ github.actor }}
commit_email: ${{ github.actor }}@users.noreply.github.com
allow_empty_commits: true
force_push: true
post_process: |
git add -A && git stash && git checkout master && git checkout stash -- . && git stash drop
- name: Upload tarball to release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="v${{ steps.version.outputs.version }}"
gh release create "$TAG" --title "$TAG" --notes "" 2>/dev/null || true
gh release upload "$TAG" \
"lapctl-${{ steps.version.outputs.version }}-x86_64.tar.gz" --clobber
+1 -3
View File
@@ -23,6 +23,4 @@ target
# Added by cargo
/target
./lapctl.py
lapctl.py
/target
Generated
+1132 -4
View File
File diff suppressed because it is too large. Load diff
+10 -1
View File
@@ -1,7 +1,11 @@
[package]
name = "lapctl"
version = "0.2.0"
version = "0.3.5"
edition = "2024"
authors = ["S4NKALP"]
description = "CLI tool for managing laptop hardware features on Linux"
license = "MIT"
repository = "https://github.com/S4NKALP/lapctl"
[dependencies]
clap = { version = "4.5", features = ["derive"] }
@@ -12,3 +16,8 @@ env_logger = "0.11"
regex = "1.10"
wayland-client = "0.31"
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
zbus = { version = "5.5.0", features = ["tokio"] }
tokio = { version = "1.21", features = ["full"] }
futures-util = "0.3"
ctrlc = "3.4"
fs2 = "0.4"
+42 -22
View File
@@ -12,21 +12,18 @@
### Why lapctl?
Modern Linux laptops often have great hardware that goes underutilized or requires heavy, bloated background services to manage. **lapctl** changes that.
Built with performance and simplicity in mind, it talks directly to your system's hardware interfaces (`sysfs`, `acpi`, `udev`). No background daemons (unless you want them), no heavy RAM usage just pure control from your terminal.
Built for speed and simplicity, it talks directly to your system's hardware interfaces (`sysfs`, `acpi`, `udev`). A background daemon (**lapctld**) handles all privileged operations via a secure D-Bus interface, allowing you to control your laptop completely **without sudo**.
---
### Key Features
- **Graphics Switching**: Effortlessly toggle between Integrated, NVIDIA, and Hybrid modes. Optimize for battery life on the go or raw performance at your desk.
- **Graphics Switching**: Effortlessly toggle between Integrated, NVIDIA, and Hybrid modes. Wayland is auto detected; no `--wayland` flag needed. Optimize for battery life on the go or raw performance at your desk.
- **Battery Health**: Modern batteries hate being at 100% all the time. Set custom charge limits (like 80%) to significantly extend your battery's lifespan.
- **Power Tuning**: Switch through performance profiles or set hard CPU power (TDP) limits in Watts to keep things cool or let them loose.
- **Intelligent Cooling**: Force your fans into Performance, Balanced, or Quiet modes (supporting ASUS and Lenovo laptops).
- **Display Refresh Rate**: Easily query available refresh rates and change your active display's Hz on-the-fly (100% native Rust Wayland implementation using `zwlr_output_manager_v1` for wlroots compositors like Sway and Hyprland).
- **Display Refresh Rate**: Easily query available refresh rates and change your active display's Hz on the fly (100% native Rust Wayland implementation using `zwlr_output_manager_v1` for wlroots compositors like Sway and Hyprland).
- **Touchpad Toggle**: Quickly enable or disable your touchpad from the terminal when using an external mouse.
- **Sleep Inhibitor**: Running a long compile or a critical download? Use the inhibitor to stop your laptop from falling asleep mid task.
- **Instant Status**: Get a bird's eye view of your hardware state, battery health, and current limits with one simple command.
---
@@ -35,23 +32,38 @@ Built with performance and simplicity in mind, it talks directly to your system'
**lapctl** is built with Rust. Ensure you have the [Rust toolchain](https://rustup.rs/) installed.
#### Option 1: Using `just` (Recommended)
If you have [just](https://github.com/casey/just) installed:
#### Option 1: Arch Linux (AUR)
```bash
just install
yay -S lapctl-bin
sudo lapctl install-rules
```
#### Option 2: Using `cargo`
#### Option 2: crates.io
```bash
cargo install --path .
cargo install lapctl
sudo lapctl install-rules
```
#### Option 3: From source (using `just`)
```bash
git clone https://github.com/S4NKALP/lapctl.git
cd lapctl
just install
sudo lapctl install-rules
```
_The `install-rules` command automatically sets up the D-Bus policy and starts the `lapctld` background service._
#### Requirements
- **systemd**: For sleep inhibitor (`systemd-inhibit`)
- **GPU Switching (Optional)**: `xrandr` and `nvidia-settings` are strictly required **ONLY** when using the `lapctl gpu` command on X11 (to route proprietary NVIDIA Optimus drivers).
- **GPU Switching (Optional)**: `xrandr` and `nvidia-settings` are strictly required **ONLY** when using the `lapctl gpu` command on X11 (to route proprietary NVIDIA Optimus drivers). Wayland is auto detected so no extra flags are needed.
- **Wayland Display**: Built entirely natively using `wayland-client` and `wayland-protocols-wlr` (no `wlr-randr` required!)
#### Limitations
- **GNOME / KDE Plasma (Wayland)**: The display refresh rate feature relies heavily on the `zwlr_output_manager_v1` protocol. This protocol is exclusive to wlroots-based compositors (like Sway and Hyprland). GNOME and KDE use their own disparate internal display protocols, meaning this feature will **not work** out-of-the-box on those Desktop Environments.
---
@@ -59,11 +71,17 @@ cargo install --path .
### Quick Start Guide
```bash
# Install D-Bus policy & start daemon (Required for rootless control)
sudo lapctl install-rules
# From now on, NO sudo is required!
# Manage your GPU
lapctl gpu integrated # Max battery
lapctl gpu hybrid # Best of both worlds
lapctl gpu nvidia # High performance
lapctl gpu run steam # Run 'steam' on dGPU directly while in Hybrid mode
lapctl gpu integrated # Max battery
lapctl gpu hybrid # Best of both worlds
lapctl gpu nvidia # High performance
lapctl gpu nvidia --no-reboot # Switch without rebooting (Ctrl+C safe)
lapctl gpu run steam # Run 'steam' on dGPU directly while in Hybrid mode
# Prolong battery life
lapctl battery limit 80
@@ -86,10 +104,6 @@ lapctl touchpad enable
lapctl display rates
lapctl display set-rate 144
# Keep it awake
lapctl inhibit --daemon # Run in background
lapctl inhibit -- why "Critical update" ./long-task.sh
# Check everything
lapctl status
```
@@ -106,13 +120,19 @@ lapctl
├── src/
│ ├── main.rs # The entry point
│ ├── cli.rs # Command definition & parsing
│ ├── commands/ # Feature logic (GPU, Battery, etc.)
│ ├── daemon/ # lapctld (D-Bus interface & logic)
│ ├── commands/ # CLI feature logic & proxies
│ ├── hardware/ # Hardware specific drivers (NVIDIA, etc.)
│ └── utils/ # System helpers
│
└── tests/ # Robust integration & unit tests
```
**Safety features:**
- **Concurrency lock** -- Exclusive file lock (`/var/lock/lapctl.lock`) prevents concurrent GPU switch commands from corrupting state
- **Interrupt safety** -- If `--no-reboot` GPU switching is interrupted (Ctrl+C, crash), the display manager is automatically restarted via a RAII guard
- **Atomic writes** -- Cache file is written atomically (temp file + rename) to prevent partial/corrupt state on crash
---
### Development & Connection
+20 -16
View File
@@ -44,6 +44,10 @@ pub enum Commands {
},
/// Hardware status
Status,
/// Show system temperatures
Temperature,
/// Show fan speeds
Fan,
/// Install udev rules for rootless operation
InstallRules,
/// Touchpad management
@@ -51,28 +55,20 @@ pub enum Commands {
#[command(subcommand)]
command: TouchpadCommands,
},
/// Inhibit system sleep/suspend
Inhibit {
/// The command to run while inhibiting (optional)
command: Vec<String>,
/// Why the system is being inhibited
#[arg(long, default_value = "lapctl inhibiting sleep")]
why: String,
/// Who is inhibiting the system
#[arg(long, default_value = "lapctl")]
who: String,
/// Run the inhibitor in the background (daemon mode)
#[arg(long)]
daemon: bool,
},
/// Start the lapctl background daemon (requires root)
Daemon,
}
#[derive(Subcommand, Debug)]
#[derive(Subcommand, Debug, Clone)]
pub enum GpuCommands {
/// Query the current graphics mode
Query,
/// Switch to integrated graphics
Integrated,
Integrated {
/// Switch without rebooting (experimental)
#[arg(long)]
no_reboot: bool,
},
/// Switch to hybrid graphics
Hybrid {
/// Setup PCI-Express Runtime D3 (RTD3) Power Management on Hybrid mode (0, 1, 2, 3)
@@ -81,6 +77,9 @@ pub enum GpuCommands {
/// Use nvidia-current instead of nvidia for kernel modules
#[arg(long)]
use_nvidia_current: bool,
/// Switch without rebooting (experimental)
#[arg(long)]
no_reboot: bool,
},
/// Switch to NVIDIA graphics
Nvidia {
@@ -99,6 +98,9 @@ pub enum GpuCommands {
/// Set up Nvidia mode for Wayland (skips Xorg configuration)
#[arg(long)]
wayland: bool,
/// Switch without rebooting (experimental)
#[arg(long)]
no_reboot: bool,
},
/// Revert changes made by lapctl
Reset,
@@ -116,6 +118,8 @@ pub enum GpuCommands {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
command: Vec<String>,
},
/// Show GPU power state and consumption
Power,
}
#[derive(Subcommand, Debug)]
+127 -61
View File
@@ -2,6 +2,56 @@ use crate::cli::BatteryCommands;
use log::{debug, error, info};
use std::fs;
use std::path::Path;
use zbus::Connection;
use zbus::proxy;
#[proxy(
interface = "org.lapctl1",
default_service = "org.lapctl",
default_path = "/org/lapctl"
)]
trait Lapctl {
async fn set_battery_limit(&self, percent: u32) -> zbus::Result<()>;
}
fn is_ideapad() -> bool {
Path::new("/sys/bus/platform/drivers/ideapad_acpi").exists()
}
fn try_call_daemon(command: &BatteryCommands) -> bool {
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(_) => return false,
};
rt.block_on(async {
let connection =
match tokio::time::timeout(std::time::Duration::from_secs(2), Connection::system())
.await
{
Ok(Ok(conn)) => conn,
_ => return false,
};
let proxy = match LapctlProxy::new(&connection).await {
Ok(p) => p,
Err(_) => return false,
};
let res = match command {
BatteryCommands::Limit { percent } => {
tokio::time::timeout(
std::time::Duration::from_secs(2),
proxy.set_battery_limit(*percent as u32),
)
.await
}
_ => return false,
};
matches!(res, Ok(Ok(_)))
})
}
pub fn execute(command: &BatteryCommands) {
match command {
@@ -11,9 +61,51 @@ pub fn execute(command: &BatteryCommands) {
std::process::exit(1);
}
println!("Setting battery charge limit to {}%", percent);
if is_ideapad() {
if *percent < 100 {
println!("Setting battery charge limit (Lenovo Conservation Mode ~60%)...");
} else {
println!("Disabling battery charge limit (charging to 100%)...");
}
} else {
println!("Setting battery charge limit to {}%...", percent);
}
}
BatteryCommands::Status => {}
}
if matches!(command, BatteryCommands::Limit { .. }) && try_call_daemon(command) {
return;
}
execute_local(command);
}
pub fn execute_local(command: &BatteryCommands) {
match command {
BatteryCommands::Limit { percent } => {
let mut success_count = 0;
// Ideapad fallback - search for conservation_mode in ideapad_acpi driver directories
if let Ok(drivers) = fs::read_dir("/sys/bus/platform/drivers/ideapad_acpi") {
for entry in drivers.flatten() {
let conservation_path = entry.path().join("conservation_mode");
if conservation_path.exists() {
let val = if *percent < 100 { "1" } else { "0" };
match fs::write(&conservation_path, val) {
Ok(_) => {
success_count += 1;
}
Err(e) => {
debug!("Failed to write to {}: {}", conservation_path.display(), e)
}
}
}
}
}
// Only try standard thresholds if we haven't already succeeded with Ideapad fallback
// or if we want to ensure all bases are covered. For now, let's do both but prioritize the message.
let sys_class_power = Path::new("/sys/class/power_supply");
if let Ok(entries) = fs::read_dir(sys_class_power) {
@@ -24,6 +116,10 @@ pub fn execute(command: &BatteryCommands) {
if name_str.starts_with("BAT") {
let threshold_path = entry.path().join("charge_control_end_threshold");
if threshold_path.exists() {
println!(
"Setting battery charge limit for {} to {}%",
name_str, percent
);
match fs::write(&threshold_path, percent.to_string()) {
Ok(_) => {
info!("Successfully set limit for {}", name_str);
@@ -33,49 +129,6 @@ pub fn execute(command: &BatteryCommands) {
debug!("Failed to write to {}: {}", threshold_path.display(), e)
}
}
} else {
// Ideapad fallback
let mut ideapad_found = false;
if let Ok(ideapad_entries) =
fs::read_dir("/sys/bus/platform/drivers/ideapad_acpi")
{
for ideapad_entry in ideapad_entries.flatten() {
let ideapad_path = ideapad_entry.path();
let conservation_path = ideapad_path.join("conservation_mode");
if conservation_path.exists() {
ideapad_found = true;
let val = if *percent < 100 { "1" } else { "0" };
match fs::write(&conservation_path, val) {
Ok(_) => {
if *percent < 100 {
println!(
"WARNING: Your laptop (Lenovo Ideapad) does NOT support custom charge limits (like {}%).",
percent
);
println!(
"Instead, lapctl has enabled Lenovo's built-in 'Conservation Mode' via the Ideapad ACPI."
);
println!(
"This mode is hard-coded into your laptop's firmware to stop charging at ~60%."
);
} else {
println!(
"Disabled Conservation Mode (charging to 100%)."
);
}
success_count += 1;
}
Err(e) => debug!(
"Failed to write to conservation_mode: {}",
e
),
}
}
}
}
if !ideapad_found {
debug!("limit not supported for {} (missing thresholds)", name_str);
}
}
}
}
@@ -90,7 +143,6 @@ pub fn execute(command: &BatteryCommands) {
}
}
BatteryCommands::Status => {
// Re-use logic from `lapctl status` to show battery-specific details:
let sys_class_power = Path::new("/sys/class/power_supply");
if let Ok(entries) = fs::read_dir(sys_class_power) {
for entry in entries.flatten() {
@@ -107,25 +159,39 @@ pub fn execute(command: &BatteryCommands) {
println!(" Capacity: {}%", capacity.trim());
println!(" Status: {}", status.trim());
if let Ok(limit) =
fs::read_to_string(bat_path.join("charge_control_end_threshold"))
// Check for Ideapad conservation mode first using robust detection
let mut limit_shown = false;
if let Ok(drivers) = fs::read_dir("/sys/bus/platform/drivers/ideapad_acpi")
{
println!(" Charge Limit: {}%", limit.trim());
} else {
// Ideapad fallback display
if let Ok(ideapad_entries) =
fs::read_dir("/sys/bus/platform/drivers/ideapad_acpi")
for entry in drivers.flatten() {
let conservation_path = entry.path().join("conservation_mode");
if conservation_path.exists()
&& let Ok(mode) = fs::read_to_string(&conservation_path)
{
if mode.trim() == "1" {
println!(" Charge Limit: Conservation Mode (~60%)");
limit_shown = true;
} else if mode.trim() == "0" {
// On Ideapads 0 means 100% or off.
}
}
}
}
if !limit_shown {
if let Ok(limit) =
fs::read_to_string(bat_path.join("charge_control_end_threshold"))
{
for ideapad_entry in ideapad_entries.flatten() {
let conservation_path =
ideapad_entry.path().join("conservation_mode");
if conservation_path.exists()
&& let Ok(mode) = fs::read_to_string(&conservation_path)
{
if mode.trim() == "1" {
println!(" Charge Limit: Conservation Mode (~60%)");
} else {
println!(" Charge Limit: {}%", limit.trim());
} else {
// Re-check if it's an ideapad and we haven't shown a limit (likely 100%)
if let Ok(drivers) =
fs::read_dir("/sys/bus/platform/drivers/ideapad_acpi")
{
for entry in drivers.flatten() {
if entry.path().join("conservation_mode").exists() {
println!(" Charge Limit: 100%");
break;
}
}
}
+74
View File
@@ -2,6 +2,65 @@ use crate::cli::CoolingCommands;
use log::{debug, info};
use std::fs;
use std::path::Path;
use zbus::Connection;
use zbus::proxy;
#[proxy(
interface = "org.lapctl1",
default_service = "org.lapctl",
default_path = "/org/lapctl"
)]
trait Lapctl {
async fn set_cooling_profile(&self, profile: String) -> zbus::Result<()>;
}
fn try_call_daemon(command: &CoolingCommands) -> bool {
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(_) => return false,
};
rt.block_on(async {
let connection =
match tokio::time::timeout(std::time::Duration::from_secs(2), Connection::system())
.await
{
Ok(Ok(conn)) => conn,
_ => return false,
};
let proxy = match LapctlProxy::new(&connection).await {
Ok(p) => p,
Err(_) => return false,
};
let res = match command {
CoolingCommands::Performance => {
tokio::time::timeout(
std::time::Duration::from_secs(2),
proxy.set_cooling_profile("performance".to_string()),
)
.await
}
CoolingCommands::Balanced => {
tokio::time::timeout(
std::time::Duration::from_secs(2),
proxy.set_cooling_profile("balanced".to_string()),
)
.await
}
CoolingCommands::Quiet => {
tokio::time::timeout(
std::time::Duration::from_secs(2),
proxy.set_cooling_profile("quiet".to_string()),
)
.await
}
};
matches!(res, Ok(Ok(_)))
})
}
fn set_ideapad_fan_mode(mode: &str) -> bool {
let mut success = false;
@@ -38,6 +97,21 @@ fn set_asus_throttle_policy(mode: &str) -> bool {
}
pub fn execute(command: &CoolingCommands) {
let desc = match command {
CoolingCommands::Performance => "Performance",
CoolingCommands::Balanced => "Balanced",
CoolingCommands::Quiet => "Quiet",
};
println!("Setting thermal/cooling profile to {}", desc);
if try_call_daemon(command) {
return;
}
execute_local(command);
}
pub fn execute_local(command: &CoolingCommands) {
let (ideapad_mode, asus_mode, desc) = match command {
CoolingCommands::Performance => ("1", "1", "Performance"),
CoolingCommands::Balanced => ("0", "0", "Balanced"),
+100
View File
@@ -0,0 +1,100 @@
use std::fs;
use std::path::Path;
pub fn execute() {
println!("--- Fan Speed ---");
let mut found = false;
// Read fan speeds from hwmon
let hwmon_dir = Path::new("/sys/class/hwmon");
if hwmon_dir.exists()
&& let Ok(entries) = fs::read_dir(hwmon_dir)
{
for entry in entries.flatten() {
let path = entry.path();
let name_path = path.join("name");
let Ok(name) = fs::read_to_string(&name_path) else {
continue;
};
let hwmon_name = name.trim().to_string();
let mut idx = 1;
loop {
let input_path = path.join(format!("fan{}_input", idx));
if !input_path.exists() {
break;
}
if let Ok(raw) = fs::read_to_string(&input_path) {
let rpm: u64 = raw.trim().parse().unwrap_or(0);
let label = path.join(format!("fan{}_label", idx));
let label_str = fs::read_to_string(&label)
.map(|l| l.trim().to_string())
.unwrap_or_else(|_| format!("Fan {}", idx));
let display_name = match hwmon_name.as_str() {
"acpi_fan" => "CPU Fan",
other => other,
};
if rpm == 0 {
println!(" {} ({}): Stopped", display_name, label_str);
} else {
println!(" {} ({}): {} RPM", display_name, label_str, rpm);
}
found = true;
}
idx += 1;
}
}
}
// Show fan policy from thermal cooling devices
let thermal_dir = Path::new("/sys/class/thermal");
if thermal_dir.exists()
&& let Ok(entries) = fs::read_dir(thermal_dir)
{
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("cooling_device") {
continue;
}
let type_path = entry.path().join("type");
let state_path = entry.path().join("cur_state");
let max_state_path = entry.path().join("max_state");
if let (Ok(type_content), Ok(state), Ok(max_state)) = (
fs::read_to_string(&type_path),
fs::read_to_string(&state_path),
fs::read_to_string(&max_state_path),
) {
let zone_type = type_content.trim();
let cur_state: u64 = state.trim().parse().unwrap_or(0);
let max: u64 = max_state.trim().parse().unwrap_or(0);
if zone_type == "Fan" {
let policy = if cur_state == 0 {
"Off"
} else if cur_state >= max {
"Full Speed"
} else {
"Variable"
};
println!(
" {} ({}): Level {}/{} ({})",
name_str, zone_type, cur_state, max, policy
);
found = true;
}
}
}
}
if !found {
println!(" No fan sensors found.");
}
}
+570 -70
View File
@@ -1,13 +1,171 @@
use crate::cli::GpuCommands;
use crate::hardware::gpu::{
get_amd_igpu_name, get_current_mode, get_igpu_vendor, get_nvidia_gpu_pci_bus,
get_amd_igpu_name, get_current_mode, get_igpu_vendor, get_nvidia_gpu_pci_addr,
get_nvidia_gpu_pci_bus, kill_gpu_processes, remove_gpu, rescan_pci, unbind_gpu,
};
use crate::utils::system::{assert_root, create_file, get_display_manager, rebuild_initramfs};
use crate::utils::system::{
assert_root, create_file, get_active_graphical_sessions, get_display_manager,
is_service_active, manage_service, rebuild_initramfs, terminate_session,
};
use fs2::FileExt;
use log::{error, info};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use zbus::Connection;
use zbus::proxy;
static INTERRUPTED: AtomicBool = AtomicBool::new(false);
fn setup_signal_handler() {
if let Err(e) = ctrlc::set_handler(|| {
INTERRUPTED.store(true, Ordering::SeqCst);
}) {
log::debug!("Could not set Ctrl+C handler (may already be set): {}", e);
}
}
struct DmGuard {
name: Option<String>,
}
impl Drop for DmGuard {
fn drop(&mut self) {
if let Some(ref name) = self.name {
info!("Restarting display manager: {}", name);
if let Err(e) = manage_service(name, "start") {
error!("Failed to restart display manager '{}': {}", name, e);
}
}
}
}
const LOCK_PATH: &str = "/var/lock/lapctl.lock";
struct LockGuard;
impl LockGuard {
fn acquire() -> Result<Self, String> {
let file = fs::File::create(LOCK_PATH)
.map_err(|e| format!("Failed to create lock file: {}", e))?;
file.try_lock_exclusive()
.map_err(|e| format!("Another GPU switch operation is already in progress: {}", e))?;
info!("Acquired exclusive lock");
Ok(LockGuard)
}
}
impl Drop for LockGuard {
fn drop(&mut self) {
if let Err(e) = fs::remove_file(LOCK_PATH) {
log::debug!("Failed to remove lock file: {}", e);
}
info!("Released lock");
}
}
#[proxy(
interface = "org.lapctl1",
default_service = "org.lapctl",
default_path = "/org/lapctl"
)]
trait Lapctl {
async fn switch_gpu_integrated(&self, no_reboot: bool) -> zbus::Result<()>;
async fn switch_gpu_hybrid(
&self,
rtd3: i32,
use_nvidia_current: bool,
no_reboot: bool,
) -> zbus::Result<()>;
async fn switch_gpu_nvidia(
&self,
dm: String,
force_comp: bool,
coolbits: i32,
use_nvidia_current: bool,
wayland: bool,
no_reboot: bool,
) -> zbus::Result<()>;
}
fn try_call_daemon(cmd: &GpuCommands) -> bool {
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(_) => return false,
};
rt.block_on(async {
let connection =
match tokio::time::timeout(std::time::Duration::from_secs(2), Connection::system())
.await
{
Ok(Ok(conn)) => conn,
_ => return false,
};
let proxy = match LapctlProxy::new(&connection).await {
Ok(p) => p,
Err(_) => return false,
};
let res = match cmd {
GpuCommands::Integrated { no_reboot } => {
tokio::time::timeout(
std::time::Duration::from_secs(5),
proxy.switch_gpu_integrated(*no_reboot),
)
.await
}
GpuCommands::Hybrid {
rtd3,
use_nvidia_current,
no_reboot,
} => {
let rtd3_val = rtd3.map(|v| v as i32).unwrap_or(-1);
tokio::time::timeout(
std::time::Duration::from_secs(5),
proxy.switch_gpu_hybrid(rtd3_val, *use_nvidia_current, *no_reboot),
)
.await
}
GpuCommands::Nvidia {
dm,
force_comp,
coolbits,
use_nvidia_current,
wayland,
no_reboot,
} => {
let dm_val = dm.clone().unwrap_or_default();
let coolbits_val = coolbits.map(|v| v as i32).unwrap_or(-1);
tokio::time::timeout(
std::time::Duration::from_secs(5),
proxy.switch_gpu_nvidia(
dm_val,
*force_comp,
coolbits_val,
*use_nvidia_current,
*wayland,
*no_reboot,
),
)
.await
}
_ => return false,
};
matches!(res, Ok(Ok(_)))
})
}
fn is_wayland_session() -> bool {
std::env::var("WAYLAND_DISPLAY").is_ok()
|| std::env::var("XDG_SESSION_TYPE")
.map(|v| v == "wayland")
.unwrap_or(false)
}
const CACHE_FILE_PATH: &str = "/var/cache/lapctl/cache.json";
const BLACKLIST_PATH: &str = "/etc/modprobe.d/blacklist-nvidia.conf";
@@ -189,6 +347,42 @@ EndSection
)
}
pub fn xorg_modesetting(bus_id: &str) -> String {
format!(
r#"# Automatically generated by lapctl
Section "ServerLayout"
Identifier "layout"
Screen 0 "nvidia"
Inactive "modesetting"
EndSection
Section "Device"
Identifier "nvidia"
Driver "nvidia"
BusID "{}"
EndSection
Section "Screen"
Identifier "nvidia"
Device "nvidia"
Option "AllowEmptyInitialConfiguration"
EndSection
Section "Device"
Identifier "modesetting"
Driver "modesetting"
EndSection
Section "Screen"
Identifier "modesetting"
Device "modesetting"
EndSection
"#,
bus_id
)
}
pub fn generate_xrandr_script(igpu_vendor: Option<&String>) -> String {
let source_name = match igpu_vendor.map(|s| s.as_str()) {
Some("intel") => "modesetting".to_string(),
@@ -258,21 +452,33 @@ fn create_cache_obj(bus_id: String) -> Cache {
}
fn create_cache_file() {
if get_current_mode() != "hybrid" {
error!("--cache-create requires that the system be in the hybrid Optimus mode");
let pci_addr = get_nvidia_gpu_pci_addr();
if pci_addr.is_none() {
log::debug!("NVIDIA GPU not found, skipping cache creation");
return;
}
let bus_id = get_nvidia_gpu_pci_bus();
let cache = create_cache_obj(bus_id);
if let Some(parent) = Path::new(CACHE_FILE_PATH).parent() {
let _ = fs::create_dir_all(parent);
if let Some(parent) = Path::new(CACHE_FILE_PATH).parent()
&& let Err(e) = fs::create_dir_all(parent)
{
log::debug!("Failed to create cache directory: {}", e);
}
if let Ok(json) = serde_json::to_string_pretty(&cache)
&& fs::write(CACHE_FILE_PATH, json).is_ok()
{
log::debug!("Created file {}", CACHE_FILE_PATH);
if let Ok(json) = serde_json::to_string_pretty(&cache) {
let tmp_path = format!("{}.tmp", CACHE_FILE_PATH);
if fs::write(&tmp_path, &json).is_ok() {
if fs::rename(&tmp_path, CACHE_FILE_PATH).is_ok() {
log::debug!("Created/Updated cache file {}", CACHE_FILE_PATH);
} else {
if let Err(e) = fs::remove_file(&tmp_path) {
log::debug!("Failed to remove stale temp cache file: {}", e);
}
log::debug!("Failed to rename temp cache file");
}
}
}
}
@@ -290,10 +496,92 @@ fn read_cache_file() -> Result<Cache, String> {
}
}
fn switch_integrated() {
fn prepare_no_reboot(dm_flag: Option<String>) -> Result<DmGuard, String> {
setup_signal_handler();
let dm_name = dm_flag.or_else(get_display_manager);
if let Some(ref name) = dm_name
&& is_service_active(name)
{
info!("Stopping display manager: {}", name);
manage_service(name, "stop")?;
return Ok(DmGuard {
name: Some(name.clone()),
});
}
// If no active DM found, check for graphical sessions
let sessions = get_active_graphical_sessions();
if !sessions.is_empty() {
println!("No active display manager found, but graphical sessions are running.");
println!("Terminating graphical sessions for no-reboot GPU switching...");
for session_id in sessions {
if let Err(e) = terminate_session(&session_id) {
error!("Failed to terminate session {}: {}", session_id, e);
}
}
// Give some time for sessions to terminate
std::thread::sleep(std::time::Duration::from_secs(2));
}
Ok(DmGuard { name: None })
}
fn switch_integrated(no_reboot: bool) {
assert_root();
let _lock = match LockGuard::acquire() {
Ok(l) => l,
Err(e) => {
error!("{}", e);
return;
}
};
println!("Switching to integrated mode");
if !Path::new(CACHE_FILE_PATH).exists() {
create_cache_file();
}
cleanup();
create_file(BLACKLIST_PATH, BLACKLIST_CONTENT, false);
create_file(UDEV_INTEGRATED_PATH, UDEV_INTEGRATED, false);
let _guard = if no_reboot {
let guard = match prepare_no_reboot(None) {
Ok(g) => g,
Err(e) => {
error!("{}", e);
return;
}
};
if let Err(e) = kill_gpu_processes() {
error!("Failed to kill GPU processes: {}", e);
}
// Try to unload modules
let modules = ["nvidia_uvm", "nvidia_modeset", "nvidia_drm", "nvidia"];
for module in modules {
if let Err(e) = Command::new("modprobe").args(["-r", module]).status() {
log::debug!("Failed to run modprobe -r {}: {}", module, e);
}
}
if let Some(pci_addr) = get_nvidia_gpu_pci_addr() {
if let Err(e) = unbind_gpu(&pci_addr) {
error!("Failed to unbind GPU: {}", e);
}
if let Err(e) = remove_gpu(&pci_addr) {
error!("Failed to remove GPU: {}", e);
}
}
guard
} else {
DmGuard { name: None }
};
let is_debug = log::log_enabled!(log::Level::Debug);
let mut dis_cmd = Command::new("systemctl");
dis_cmd.args(["disable", "nvidia-persistenced.service"]);
@@ -303,37 +591,65 @@ fn switch_integrated() {
}
match dis_cmd.status() {
Ok(s) if s.success() => println!("Successfully disabled nvidia-persistenced.service"),
_ => error!("An error ocurred while disabling service"),
Ok(s) => error!(
"nvidia-persistenced.service disable failed with exit code: {}",
s
),
Err(e) => error!("Failed to run systemctl: {}", e),
}
if get_current_mode() == "hybrid" && !Path::new(CACHE_FILE_PATH).exists() {
create_cache_file();
if no_reboot {
println!("Operation completed successfully");
} else {
rebuild_initramfs();
println!("Operation completed successfully");
println!("Please reboot your computer for changes to take effect!");
}
cleanup();
create_file(BLACKLIST_PATH, BLACKLIST_CONTENT, false);
create_file(UDEV_INTEGRATED_PATH, UDEV_INTEGRATED, false);
rebuild_initramfs();
println!("Operation completed successfully");
println!("Please reboot your computer for changes to take effect!");
}
fn switch_hybrid(rtd3: Option<u8>, use_nvidia_current: bool) {
fn switch_hybrid(rtd3: Option<u8>, use_nvidia_current: bool, no_reboot: bool) {
assert_root();
let _lock = match LockGuard::acquire() {
Ok(l) => l,
Err(e) => {
error!("{}", e);
return;
}
};
println!("Switching to hybrid mode");
println!(
"Enable PCI-Express Runtime D3 (RTD3) Power Management: {}",
rtd3.is_some()
);
if get_current_mode() == "hybrid" && !Path::new(CACHE_FILE_PATH).exists() {
if no_reboot && let Err(e) = rescan_pci() {
error!("Failed to rescan PCI bus: {}", e);
}
if !Path::new(CACHE_FILE_PATH).exists() {
create_cache_file();
}
cleanup();
let _guard = if no_reboot {
let guard = match prepare_no_reboot(None) {
Ok(g) => g,
Err(e) => {
error!("{}", e);
return;
}
};
if let Err(e) = kill_gpu_processes() {
error!("Failed to kill GPU processes: {}", e);
}
guard
} else {
DmGuard { name: None }
};
// persistenced enable
let is_debug = log::log_enabled!(log::Level::Debug);
let mut enable_cmd = Command::new("systemctl");
@@ -344,7 +660,11 @@ fn switch_hybrid(rtd3: Option<u8>, use_nvidia_current: bool) {
}
match enable_cmd.status() {
Ok(s) if s.success() => println!("Successfully enabled nvidia-persistenced.service"),
_ => error!("An error ocurred while enabling service"),
Ok(s) => error!(
"nvidia-persistenced.service enable failed with exit code: {}",
s
),
Err(e) => error!("Failed to run systemctl: {}", e),
}
if let Some(val) = rtd3 {
@@ -370,9 +690,13 @@ fn switch_hybrid(rtd3: Option<u8>, use_nvidia_current: bool) {
create_file(MODESET_PATH, &modeset_content, false);
}
rebuild_initramfs();
println!("Operation completed successfully");
println!("Please reboot your computer for changes to take effect!");
if no_reboot {
println!("Operation completed successfully");
} else {
rebuild_initramfs();
println!("Operation completed successfully");
println!("Please reboot your computer for changes to take effect!");
}
}
fn switch_nvidia(
@@ -381,26 +705,28 @@ fn switch_nvidia(
coolbits: Option<u32>,
use_nvidia_current: bool,
wayland: bool,
no_reboot: bool,
) {
assert_root();
let _lock = match LockGuard::acquire() {
Ok(l) => l,
Err(e) => {
error!("{}", e);
return;
}
};
let wayland = wayland || is_wayland_session();
println!("Switching to nvidia mode");
println!("Enable ForceCompositionPipeline: {}", force_comp);
println!("Enable Coolbits: {}", coolbits.is_some());
// persistenced enable
let is_debug = log::log_enabled!(log::Level::Debug);
let mut enable_cmd = Command::new("systemctl");
enable_cmd.args(["enable", "nvidia-persistenced.service"]);
if !is_debug {
enable_cmd.stdout(std::process::Stdio::null());
enable_cmd.stderr(std::process::Stdio::null());
}
match enable_cmd.status() {
Ok(s) if s.success() => println!("Successfully enabled nvidia-persistenced.service"),
_ => error!("An error ocurred while enabling service"),
if no_reboot && let Err(e) = rescan_pci() {
error!("Failed to rescan PCI bus: {}", e);
}
if get_current_mode() == "hybrid" && !Path::new(CACHE_FILE_PATH).exists() {
if !Path::new(CACHE_FILE_PATH).exists() {
create_cache_file();
}
@@ -440,6 +766,8 @@ fn switch_nvidia(
} else if vendor == "amd" {
create_file(XORG_PATH, &xorg_amd(&pci_bus), false);
}
} else {
create_file(XORG_PATH, &xorg_modesetting(&pci_bus), false);
}
let mut extra_xorg = EXTRA_XORG_CONTENT.to_string();
@@ -454,7 +782,7 @@ fn switch_nvidia(
create_file(EXTRA_XORG_PATH, &extra_xorg, false);
}
let display_manager = dm.or_else(get_display_manager);
let display_manager = dm.clone().or_else(get_display_manager);
if let Some(cdm) = display_manager {
if cdm == "sddm" {
@@ -480,49 +808,209 @@ fn switch_nvidia(
}
}
rebuild_initramfs();
println!("Operation completed successfully");
println!("Please reboot your computer for changes to take effect!");
let _guard = if no_reboot {
let guard = match prepare_no_reboot(dm.clone()) {
Ok(g) => g,
Err(e) => {
error!("{}", e);
return;
}
};
if let Err(e) = kill_gpu_processes() {
error!("Failed to kill GPU processes: {}", e);
}
guard
} else {
DmGuard { name: None }
};
// persistenced enable
let is_debug = log::log_enabled!(log::Level::Debug);
let mut enable_cmd = Command::new("systemctl");
enable_cmd.args(["enable", "nvidia-persistenced.service"]);
if !is_debug {
enable_cmd.stdout(std::process::Stdio::null());
enable_cmd.stderr(std::process::Stdio::null());
}
match enable_cmd.status() {
Ok(s) if s.success() => println!("Successfully enabled nvidia-persistenced.service"),
Ok(s) => error!(
"nvidia-persistenced.service enable failed with exit code: {}",
s
),
Err(e) => error!("Failed to run systemctl: {}", e),
}
if no_reboot {
println!("Operation completed successfully");
} else {
rebuild_initramfs();
println!("Operation completed successfully");
println!("Please reboot your computer for changes to take effect!");
}
}
fn run_on_dgpu(command: &[String]) {
if command.is_empty() {
error!("No command provided to run.");
error!("No command specified.");
return;
}
if get_current_mode() != "hybrid" {
log::warn!(
"Running on dGPU is typically only effective in Hybrid mode. \
Your current mode is '{}'. The application may fail to start or \
not use the NVIDIA GPU.",
get_current_mode()
);
error!("GPU run is only supported in Hybrid mode.");
return;
}
log::info!("Launching '{}' on the discrete GPU...", command.join(" "));
let env_vars = vec![
("__NV_PRIME_RENDER_OFFLOAD", "1".to_string()),
("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string()),
("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string()),
];
let mut child = Command::new(&command[0]);
let mut cmd = Command::new(&command[0]);
if command.len() > 1 {
child.args(&command[1..]);
cmd.args(&command[1..]);
}
// Set PRIME offload variables natively
child.env("__NV_PRIME_RENDER_OFFLOAD", "1");
child.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia");
child.env("__VK_LAYER_NV_optimus", "NVIDIA_only");
for (key, value) in &env_vars {
cmd.env(key, value);
}
match child.status() {
info!("Running '{}' on discrete GPU", command.join(" "));
match cmd.status() {
Ok(status) => {
log::info!("Application exited with: {}", status);
if !status.success() {
error!("Command exited with status: {}", status);
}
}
Err(e) => {
error!("Failed to launch application: {}", e);
error!("Failed to execute command: {}", e);
}
}
}
fn show_gpu_power_state() {
println!("--- GPU Power State ---");
// Read PCI power state from sysfs
let pci_base = Path::new("/sys/bus/pci/devices");
if let Ok(entries) = fs::read_dir(pci_base) {
for entry in entries.flatten() {
let vendor_path = entry.path().join("vendor");
let class_path = entry.path().join("class");
let Ok(vendor) = fs::read_to_string(&vendor_path) else {
continue;
};
let Ok(class) = fs::read_to_string(&class_path) else {
continue;
};
// NVIDIA vendor = 0x10de, VGA class starts with 0x0300
if vendor.trim() == "0x10de" && class.trim().starts_with("0x0300") {
let pci_addr = entry.file_name().to_string_lossy().to_string();
println!(" Device: NVIDIA ({})", pci_addr);
// Power state
let runtime_status = entry.path().join("power/runtime_status");
if let Ok(status) = fs::read_to_string(&runtime_status) {
println!(" Runtime Status: {}", status.trim());
}
// Power control
let power_control = entry.path().join("power/control");
if let Ok(control) = fs::read_to_string(&power_control) {
println!(" Power Control: {}", control.trim());
}
// Current power state (D0 = fully on, D3 = off)
let current_state = entry.path().join("power_state");
if let Ok(state) = fs::read_to_string(&current_state) {
println!(" PCI Power State: {}", state.trim());
}
// Max power state
let max_state = entry.path().join("max_power_state");
if let Ok(state) = fs::read_to_string(&max_state) {
println!(" Max Power State: {}", state.trim());
}
}
}
}
// NVIDIA power info via nvidia-smi
match std::process::Command::new("nvidia-smi")
.args([
"--query-gpu=power.draw,power.limit,power.default_limit,power.max_limit,clocks.current.graphics,clocks.max.graphics",
"--format=csv,noheader",
])
.output()
{
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let parts: Vec<&str> = line.split(", ").collect();
if parts.len() >= 5 {
println!();
println!(" Power Draw: {}", parts[0].trim());
println!(" Power Limit: {}", parts[1].trim());
println!(" Default Limit: {}", parts[2].trim());
println!(" Max Limit: {}", parts[3].trim());
println!(" Current Clock: {}", parts[4].trim());
if parts.len() > 5 {
println!(" Max Clock: {}", parts[5].trim());
}
}
}
}
_ => {
log::debug!("nvidia-smi not available or GPU not powered on");
println!(" (nvidia-smi not available - GPU may be powered off)");
}
}
}
pub fn execute(cmd: &GpuCommands) {
let cmd = match cmd {
GpuCommands::Nvidia {
wayland: false,
dm,
force_comp,
coolbits,
use_nvidia_current,
no_reboot,
} if is_wayland_session() => {
println!("Auto-detected Wayland session");
GpuCommands::Nvidia {
wayland: true,
dm: dm.clone(),
force_comp: *force_comp,
coolbits: *coolbits,
use_nvidia_current: *use_nvidia_current,
no_reboot: *no_reboot,
}
}
_ => cmd.clone(),
};
match &cmd {
GpuCommands::Integrated { .. } => println!("Switching to Integrated GPU mode..."),
GpuCommands::Hybrid { .. } => println!("Switching to Hybrid GPU mode..."),
GpuCommands::Nvidia { .. } => println!("Switching to NVIDIA-only GPU mode..."),
_ => {}
}
if try_call_daemon(&cmd) {
return;
}
execute_local(&cmd);
}
pub fn execute_local(cmd: &GpuCommands) {
match cmd {
GpuCommands::Query => {
let mode = get_current_mode();
@@ -535,9 +1023,13 @@ pub fn execute(cmd: &GpuCommands) {
GpuCommands::CacheDelete => {
assert_root();
if Path::new(CACHE_FILE_PATH).exists() {
let _ = fs::remove_file(CACHE_FILE_PATH);
if let Some(parent) = Path::new(CACHE_FILE_PATH).parent() {
let _ = fs::remove_dir(parent);
if let Err(e) = fs::remove_file(CACHE_FILE_PATH) {
error!("Failed to remove cache file: {}", e);
}
if let Some(parent) = Path::new(CACHE_FILE_PATH).parent()
&& let Err(e) = fs::remove_dir(parent)
{
log::debug!("Failed to remove cache directory: {}", e);
}
}
}
@@ -557,9 +1049,13 @@ pub fn execute(cmd: &GpuCommands) {
cleanup();
if Path::new(CACHE_FILE_PATH).exists() {
let _ = fs::remove_file(CACHE_FILE_PATH);
if let Some(parent) = Path::new(CACHE_FILE_PATH).parent() {
let _ = fs::remove_dir(parent);
if let Err(e) = fs::remove_file(CACHE_FILE_PATH) {
error!("Failed to remove cache file: {}", e);
}
if let Some(parent) = Path::new(CACHE_FILE_PATH).parent()
&& let Err(e) = fs::remove_dir(parent)
{
log::debug!("Failed to remove cache directory: {}", e);
}
}
rebuild_initramfs();
@@ -570,24 +1066,28 @@ pub fn execute(cmd: &GpuCommands) {
create_file(SDDM_XSETUP_PATH, SDDM_XSETUP_CONTENT, true);
println!("Operation completed successfully");
}
GpuCommands::Integrated => switch_integrated(),
GpuCommands::Integrated { no_reboot } => switch_integrated(*no_reboot),
GpuCommands::Hybrid {
rtd3,
use_nvidia_current,
} => switch_hybrid(*rtd3, *use_nvidia_current),
no_reboot,
} => switch_hybrid(*rtd3, *use_nvidia_current, *no_reboot),
GpuCommands::Nvidia {
dm,
force_comp,
coolbits,
use_nvidia_current,
wayland,
no_reboot,
} => switch_nvidia(
dm.clone(),
*force_comp,
*coolbits,
*use_nvidia_current,
*wayland,
*no_reboot,
),
GpuCommands::Run { command } => run_on_dgpu(command),
GpuCommands::Power => show_gpu_power_state(),
}
}
-105
View File
@@ -1,105 +0,0 @@
use log::{error, info};
use std::process::Command;
pub fn execute(command_args: &[String], why: &str, who: &str, daemon: bool) {
let args = build_args(command_args, why, who);
if command_args.is_empty() {
if !daemon {
info!("No command provided. Inhibiting sleep indefinitely. Press Ctrl+C to stop.");
}
} else {
info!(
"Running command while inhibiting sleep: {}",
command_args.join(" ")
);
}
if daemon {
match Command::new("systemd-inhibit").args(&args).spawn() {
Ok(child) => {
info!("Inhibitor started in background with PID: {}", child.id());
info!("To stop it, use: pkill -P {} systemd-inhibit", child.id());
// In background mode, we just let the child run and exit lapctl
}
Err(e) => {
error!("Failed to spawn systemd-inhibit: {}", e);
}
}
} else {
match Command::new("systemd-inhibit").args(&args).status() {
Ok(status) => {
if !status.success() {
error!("systemd-inhibit exited with status: {}", status);
}
}
Err(e) => {
error!("Failed to execute systemd-inhibit: {}", e);
eprintln!(
"Error: make sure 'systemd-inhibit' is installed and available in your PATH."
);
}
}
}
}
pub(crate) fn build_args(command_args: &[String], why: &str, who: &str) -> Vec<String> {
let mut args = vec![
"--why".to_string(),
why.to_string(),
"--who".to_string(),
who.to_string(),
"--what".to_string(),
"sleep:idle".to_string(),
];
if command_args.is_empty() {
args.push("sleep".to_string());
args.push("infinity".to_string());
} else {
args.extend(command_args.iter().cloned());
}
args
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_args_no_command() {
let args = build_args(&[], "testing", "lapctl");
assert_eq!(
args,
vec![
"--why",
"testing",
"--who",
"lapctl",
"--what",
"sleep:idle",
"sleep",
"infinity"
]
);
}
#[test]
fn test_build_args_with_command() {
let command_args = vec!["ls".to_string(), "-l".to_string()];
let args = build_args(&command_args, "testing", "lapctl");
assert_eq!(
args,
vec![
"--why",
"testing",
"--who",
"lapctl",
"--what",
"sleep:idle",
"ls",
"-l"
]
);
}
}
+131 -51
View File
@@ -1,75 +1,155 @@
use crate::utils::system::{assert_root, create_file};
use log::info;
use log::{error, info};
use std::process::Command;
const LAPCTL_UDEV_RULES: &str = r#"# /etc/udev/rules.d/99-lapctl.rules
# Automatically generated by lapctl
# This file grants read/write permissions to all local users
# for the SysFS endpoints required by lapctl's power, cooling, and battery commands.
// No longer needed: all privileged operations are now handled by the lapctld daemon via D-Bus.
const LAPCTL_UDEV_RULES: &str = "# lapctl udev rules (placeholder)\n";
# ACPI Platform Profiles & CPU Scaling
ACTION=="add", SUBSYSTEM=="firmware", RUN+="/bin/sh -c 'chmod a+w /sys/firmware/acpi/platform_profile 2>/dev/null'"
ACTION=="add", SUBSYSTEM=="cpu", RUN+="/bin/sh -c 'chmod a+w /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor 2>/dev/null'"
ACTION=="add", SUBSYSTEM=="cpu", RUN+="/bin/sh -c 'chmod a+w /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference 2>/dev/null'"
const POLKIT_POLICY: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<policyconfig>
<vendor>lapctl</vendor>
<vendor_url>https://github.com/S4NKALP/lapctl</vendor_url>
<action id="org.lapctl.execute">
<description>Elevated execution for lapctl</description>
<message>Authentication is required to perform privileged lapctl operations</message>
<defaults>
<allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive>
<allow_active>yes</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">{EXE_PATH}</annotate>
<annotate key="org.freedesktop.policykit.exec.allow_gui">false</annotate>
</action>
</policyconfig>
"#;
# Battery Charging Limits
ACTION=="add", SUBSYSTEM=="power_supply", RUN+="/bin/sh -c 'chmod a+w /sys/class/power_supply/BAT*/charge_control_end_threshold 2>/dev/null'"
const POLKIT_RULES: &str = r#"/* /etc/polkit-1/rules.d/10-lapctl.rules
* Automatically generated by lapctl
* This rule allows users in the 'wheel' or 'sudo' groups to
* perform privileged lapctl operations via pkexec without a password.
*/
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.policykit.exec" &&
action.lookup("program").indexOf("lapctl") !== -1 &&
(subject.isInGroup("wheel") || subject.isInGroup("sudo"))) {
return polkit.Result.YES;
}
});
"#;
# Lenovo Ideapad Thermal and Conservation Mode
ACTION=="add", SUBSYSTEM=="platform", RUN+="/bin/sh -c 'chmod a+w /sys/bus/platform/drivers/ideapad_acpi/VPC*/fan_mode 2>/dev/null'"
ACTION=="add", SUBSYSTEM=="platform", RUN+="/bin/sh -c 'chmod a+w /sys/bus/platform/drivers/ideapad_acpi/VPC*/conservation_mode 2>/dev/null'"
const DBUS_POLICY: &str = r#"<!DOCTYPE busconfig PUBLIC
"-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
<policy user="root">
<allow own="org.lapctl"/>
<allow send_destination="org.lapctl"/>
<allow receive_sender="org.lapctl"/>
</policy>
<policy group="wheel">
<allow send_destination="org.lapctl"/>
<allow receive_sender="org.lapctl"/>
</policy>
<policy group="sudo">
<allow send_destination="org.lapctl"/>
<allow receive_sender="org.lapctl"/>
</policy>
</busconfig>
"#;
# ASUS WMI Thermal Policy
ACTION=="add", SUBSYSTEM=="platform", RUN+="/bin/sh -c 'chmod a+w /sys/devices/platform/asus-nb-wmi/throttle_thermal_policy 2>/dev/null'"
const SYSTEMD_SERVICE: &str = r#"[Unit]
Description=lapctl hardware management daemon
After=dbus.service
# Intel RAPL TDP Control
ACTION=="add", SUBSYSTEM=="powercap", RUN+="/bin/sh -c 'chmod a+w /sys/class/powercap/intel-rapl:0/constraint_*_power_limit_uw 2>/dev/null'"
[Service]
Type=simple
ExecStart={EXE_PATH} daemon
Restart=on-failure
# AMD HWMon Power Capping
ACTION=="add", SUBSYSTEM=="hwmon", RUN+="/bin/sh -c 'find /sys/class/hwmon -name \"power1_cap\" -exec chmod a+w {} + 2>/dev/null'"
# Input Device Inhibition (Touchpad)
ACTION=="add", SUBSYSTEM=="input", RUN+="/bin/sh -c 'find /sys/class/input -name \"inhibited\" -exec chmod a+w {} + 2>/dev/null'"
[Install]
WantedBy=multi-user.target
"#;
pub fn execute() {
assert_root();
println!("Installing lapctl udev rules for rootless hardware configuration...");
let current_exe = match std::env::current_exe() {
Ok(exe) => exe,
Err(e) => {
error!("Failed to get current executable path: {}", e);
std::process::exit(1);
}
};
let exe_path = match current_exe.to_str() {
Some(p) => p,
None => {
error!("Executable path contains invalid UTF-8");
std::process::exit(1);
}
};
println!("Found lapctl at: {}", exe_path);
println!("Installing lapctl configuration with dynamic paths...");
let dbus_policy_path = "/etc/dbus-1/system.d/org.lapctl.conf";
create_file(dbus_policy_path, DBUS_POLICY, false);
let systemd_content = SYSTEMD_SERVICE.replace("{EXE_PATH}", exe_path);
let service_path = "/etc/systemd/system/lapctld.service";
create_file(service_path, &systemd_content, false);
// Also install the udev rules file (even if empty now) to avoid errors if it was previously there
let rules_path = "/etc/udev/rules.d/99-lapctl.rules";
create_file(rules_path, LAPCTL_UDEV_RULES, false);
println!("Successfully wrote rules to {}", rules_path);
println!("Loading rules and applying immediate session permissions...");
// Polkit fallbacks for rootless execution without the daemon
let polkit_content = POLKIT_POLICY.replace("{EXE_PATH}", exe_path);
let polkit_policy_path = "/usr/share/polkit-1/actions/org.lapctl.policy";
create_file(polkit_policy_path, &polkit_content, false);
let _ = Command::new("udevadm")
.args(["control", "--reload-rules"])
.status();
let polkit_rules_path = "/etc/polkit-1/rules.d/10-lapctl.rules";
create_file(polkit_rules_path, POLKIT_RULES, false);
let _ = Command::new("udevadm").args(["trigger"]).status();
println!("Successfully installed configuration files.");
println!("Activating background daemon (lapctld)...");
// The udevadm trigger often fails to catch already-initialized ACPI nodes without a reboot,
// so we manually apply the permissions for the current session right now.
let commands = vec![
"chmod a+w /sys/firmware/acpi/platform_profile 2>/dev/null",
"chmod a+w /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor 2>/dev/null",
"chmod a+w /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference 2>/dev/null",
"chmod a+w /sys/class/power_supply/BAT*/charge_control_end_threshold 2>/dev/null",
"chmod a+w /sys/bus/platform/drivers/ideapad_acpi/VPC*/fan_mode 2>/dev/null",
"chmod a+w /sys/bus/platform/drivers/ideapad_acpi/VPC*/conservation_mode 2>/dev/null",
"chmod a+w /sys/devices/platform/asus-nb-wmi/throttle_thermal_policy 2>/dev/null",
"chmod a+w /sys/class/powercap/intel-rapl:0/constraint_*_power_limit_uw 2>/dev/null",
"find /sys/class/hwmon -name \"power1_cap\" -exec chmod a+w {} + 2>/dev/null",
"find /sys/class/input -name \"inhibited\" -exec chmod a+w {} + 2>/dev/null",
];
for cmd in commands {
let _ = Command::new("sh").arg("-c").arg(cmd).status();
// Reload systemd to recognize the new service
if let Err(e) = Command::new("systemctl").arg("daemon-reload").status() {
error!("Failed to run systemctl daemon-reload: {}", e);
println!("You may need to run 'systemctl daemon-reload' manually.");
}
info!("Udev reload and immediate ACL application complete.");
println!(
"Installation successful! You can now use lapctl power, cooling, and battery commands without sudo."
);
// Enable and start the daemon
let status = Command::new("systemctl")
.args(["enable", "--now", "lapctld"])
.status();
let daemon_ok = match status {
Ok(s) if s.success() => {
info!("lapctld daemon enabled and started.");
true
}
Ok(s) => {
log::error!(
"systemctl enable --now lapctld failed with exit code: {}",
s
);
false
}
Err(e) => {
log::error!("Failed to run systemctl: {}", e);
false
}
};
if daemon_ok {
println!("Installation successful! The lapctld daemon is now running.");
} else {
println!("Installation completed (but daemon may not be running).");
println!("Try starting it manually with: systemctl start lapctld");
}
println!("You can now use all lapctl commands from any location.");
}
+2 -1
View File
@@ -1,9 +1,10 @@
pub mod battery;
pub mod cooling;
pub mod display;
pub mod fan;
pub mod gpu;
pub mod inhibit;
pub mod install_rules;
pub mod power;
pub mod status;
pub mod temperature;
pub mod touchpad;
+109 -5
View File
@@ -2,6 +2,73 @@ use crate::cli::PowerCommands;
use log::{error, info};
use std::fs;
use std::path::Path;
use zbus::Connection;
use zbus::proxy;
#[proxy(
interface = "org.lapctl1",
default_service = "org.lapctl",
default_path = "/org/lapctl"
)]
trait Lapctl {
async fn set_power_profile(&self, profile: String) -> zbus::Result<()>;
async fn set_tdp_limit(&self, watts: u32) -> zbus::Result<()>;
}
fn try_call_daemon(command: &PowerCommands) -> bool {
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(_) => return false,
};
rt.block_on(async {
let connection =
match tokio::time::timeout(std::time::Duration::from_secs(2), Connection::system())
.await
{
Ok(Ok(conn)) => conn,
_ => return false,
};
let proxy = match LapctlProxy::new(&connection).await {
Ok(p) => p,
Err(_) => return false,
};
let res = match command {
PowerCommands::Performance => {
tokio::time::timeout(
std::time::Duration::from_secs(2),
proxy.set_power_profile("performance".to_string()),
)
.await
}
PowerCommands::Balanced => {
tokio::time::timeout(
std::time::Duration::from_secs(2),
proxy.set_power_profile("balanced".to_string()),
)
.await
}
PowerCommands::BatterySave => {
tokio::time::timeout(
std::time::Duration::from_secs(2),
proxy.set_power_profile("battery-save".to_string()),
)
.await
}
PowerCommands::LimitTdp { watts } => {
tokio::time::timeout(
std::time::Duration::from_secs(2),
proxy.set_tdp_limit(*watts),
)
.await
}
};
matches!(res, Ok(Ok(_)))
})
}
fn set_cpu_governor(governor: &str) {
let cpu_dir = Path::new("/sys/devices/system/cpu");
@@ -142,6 +209,10 @@ fn set_amd_hwmon_limit(watts: u32) -> bool {
}
success
}
fn is_ideapad() -> bool {
Path::new("/sys/bus/platform/drivers/ideapad_acpi").exists()
}
fn set_platform_profile(profile: &str) {
let platform_profile_path = Path::new("/sys/firmware/acpi/platform_profile");
if platform_profile_path.exists() {
@@ -155,26 +226,59 @@ fn set_platform_profile(profile: &str) {
}
pub fn execute(command: &PowerCommands) {
match command {
PowerCommands::Performance => println!("Setting power profile to Performance"),
PowerCommands::Balanced => println!("Setting power profile to Balanced"),
PowerCommands::BatterySave => println!("Setting power profile to Battery Saver"),
PowerCommands::LimitTdp { watts } => {
println!("Setting CPU Package TDP Limit to {} Watts...", watts)
}
}
if try_call_daemon(command) {
return;
}
execute_local(command);
}
pub fn execute_local(command: &PowerCommands) {
match command {
PowerCommands::Performance => {
println!("Setting power profile to Performance");
set_platform_profile("performance");
if !is_ideapad() {
set_platform_profile("performance");
} else {
println!(
"Skipping platform_profile (decoupled from fan control on Ideapad). Use 'lapctl cooling' for fan speed."
);
}
set_cpu_governor("performance");
set_energy_performance_preference("performance");
println!("Operation completed successfully.");
}
PowerCommands::Balanced => {
println!("Setting power profile to Balanced");
set_platform_profile("balanced");
// Schedutil is the modern balanced default. Fallback isn't critical since cpufreq
// usually rejects invalid profiles, and standard Intel/AMD systems use EPP now anyway.
if !is_ideapad() {
set_platform_profile("balanced");
} else {
println!(
"Skipping platform_profile (decoupled from fan control on Ideapad). Use 'lapctl cooling' for fan speed."
);
}
set_cpu_governor("schedutil");
set_energy_performance_preference("balance_performance");
println!("Operation completed successfully.");
}
PowerCommands::BatterySave => {
println!("Setting power profile to Battery Saver");
set_platform_profile("low-power");
if !is_ideapad() {
set_platform_profile("low-power");
} else {
println!(
"Skipping platform_profile (decoupled from fan control on Ideapad). Use 'lapctl cooling' for fan speed."
);
}
set_cpu_governor("powersave");
set_energy_performance_preference("power");
println!("Operation completed successfully.");
+32
View File
@@ -181,6 +181,38 @@ pub fn execute() {
}
}
// Temperature
if let Ok(temp_output) = fs::read_to_string("/sys/class/hwmon/hwmon5/temp1_input")
&& let Ok(millideg) = temp_output.trim().parse::<i64>()
{
println!("CPU Temp: {:.1}°C", millideg as f64 / 1000.0);
}
// Fan Speed
if let Ok(fan_output) = fs::read_to_string("/sys/class/hwmon/hwmon1/fan1_input")
&& let Ok(rpm) = fan_output.trim().parse::<u64>()
{
println!("Fan Speed: {} RPM", rpm);
}
// GPU Power State
if let Ok(output) = std::process::Command::new("nvidia-smi")
.args([
"--query-gpu=power.draw,temperature.gpu",
"--format=csv,noheader",
])
.output()
&& output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(line) = stdout.lines().next() {
let parts: Vec<&str> = line.split(", ").collect();
if parts.len() >= 2 {
println!("GPU: {} @ {}°C (NVIDIA)", parts[0].trim(), parts[1].trim());
}
}
}
// Display Status
let displays = display::get_active_display_info();
if displays.is_empty() {
+120
View File
@@ -0,0 +1,120 @@
use log::debug;
use std::fs;
use std::path::Path;
pub fn execute() {
println!("--- Temperature ---");
let mut found = false;
// Read all hwmon temperature sensors
let hwmon_dir = Path::new("/sys/class/hwmon");
if hwmon_dir.exists()
&& let Ok(entries) = fs::read_dir(hwmon_dir)
{
for entry in entries.flatten() {
let path = entry.path();
let name_path = path.join("name");
let Ok(name) = fs::read_to_string(&name_path) else {
continue;
};
let hwmon_name = name.trim().to_string();
let mut idx = 1;
loop {
let input_path = path.join(format!("temp{}_input", idx));
if !input_path.exists() {
break;
}
if let Ok(raw) = fs::read_to_string(&input_path) {
let millideg: i64 = raw.trim().parse().unwrap_or(0);
let celsius = millideg as f64 / 1000.0;
let label = path.join(format!("temp{}_label", idx));
let label_str = fs::read_to_string(&label)
.map(|l| l.trim().to_string())
.unwrap_or_else(|_| format!("Sensor {}", idx));
let display_name = match hwmon_name.as_str() {
"k10temp" => "CPU",
"acpitz" => "ACPI Thermal Zone",
"nvme" => "NVMe SSD",
"mt7921_phy0" => "WiFi",
other => other,
};
println!(" {} ({}): {:.1}°C", display_name, label_str, celsius);
found = true;
}
idx += 1;
}
}
}
// NVIDIA GPU temperature via nvidia-smi
if let Ok(output) = std::process::Command::new("nvidia-smi")
.args([
"--query-gpu=temperature.gpu,power.draw,clocks.current.graphics",
"--format=csv,noheader",
])
.output()
&& output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let parts: Vec<&str> = line.split(", ").collect();
if let Some(temp) = parts.first()
&& let Ok(temp_c) = temp.trim().parse::<f64>()
{
let info = if parts.len() >= 3 {
format!(" [{} @ {}]", parts[1].trim(), parts[2].trim())
} else {
String::new()
};
println!(" GPU (NVIDIA): {:.0}°C{}", temp_c, info);
found = true;
}
}
} else {
debug!("nvidia-smi not available, skipping GPU temperature");
}
// Read thermal zone temperatures
let thermal_dir = Path::new("/sys/class/thermal");
if thermal_dir.exists()
&& let Ok(entries) = fs::read_dir(thermal_dir)
{
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("thermal_zone") {
continue;
}
let type_path = entry.path().join("type");
let temp_path = entry.path().join("temp");
if let (Ok(type_content), Ok(temp_content)) = (
fs::read_to_string(&type_path),
fs::read_to_string(&temp_path),
) {
let zone_type = type_content.trim();
let millideg: i64 = temp_content.trim().parse().unwrap_or(0);
let celsius = millideg as f64 / 1000.0;
// Skip if we already showed this from hwmon
if zone_type == "x86_pkg_temp" || zone_type == "k10temp" || zone_type == "acpitz" {
continue;
}
println!(" {} ({}): {:.1}°C", name_str, zone_type, celsius);
found = true;
}
}
}
if !found {
println!(" No temperature sensors found.");
}
}
+102 -37
View File
@@ -1,54 +1,119 @@
use crate::cli::TouchpadCommands;
use log::{error, info};
use log::{debug, error, info};
use std::fs;
use std::path::Path;
use zbus::Connection;
use zbus::proxy;
#[proxy(
interface = "org.lapctl1",
default_service = "org.lapctl",
default_path = "/org/lapctl"
)]
trait Lapctl {
async fn set_touchpad_inhibition(&self, inhibited: bool) -> zbus::Result<()>;
}
fn try_call_daemon(command: &TouchpadCommands) -> bool {
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(_) => return false,
};
rt.block_on(async {
let connection =
match tokio::time::timeout(std::time::Duration::from_secs(2), Connection::system())
.await
{
Ok(Ok(conn)) => conn,
_ => return false,
};
let proxy = match LapctlProxy::new(&connection).await {
Ok(p) => p,
Err(_) => return false,
};
let res = match command {
TouchpadCommands::Enable => {
tokio::time::timeout(
std::time::Duration::from_secs(2),
proxy.set_touchpad_inhibition(false),
)
.await
}
TouchpadCommands::Disable => {
tokio::time::timeout(
std::time::Duration::from_secs(2),
proxy.set_touchpad_inhibition(true),
)
.await
}
};
matches!(res, Ok(Ok(_)))
})
}
pub fn execute(command: &TouchpadCommands) {
let sys_class_input = Path::new("/sys/class/input");
if !sys_class_input.exists() {
match command {
TouchpadCommands::Enable => println!("Enabling touchpad..."),
TouchpadCommands::Disable => println!("Disabling touchpad..."),
}
if try_call_daemon(command) {
return;
}
execute_local(command);
}
pub fn execute_local(command: &TouchpadCommands) {
match command {
TouchpadCommands::Enable => set_touchpad_inhibition(false),
TouchpadCommands::Disable => set_touchpad_inhibition(true),
}
}
fn set_touchpad_inhibition(inhibited: bool) {
let dev_input = Path::new("/sys/class/input");
if !dev_input.exists() {
error!("Could not find /sys/class/input. Is this a Linux system?");
return;
}
let mut found_touchpads = Vec::new();
if let Ok(entries) = fs::read_dir(sys_class_input) {
if let Ok(entries) = fs::read_dir(dev_input) {
let mut found = false;
for entry in entries.flatten() {
let path = entry.path();
let name_path = path.join("name");
let inhibited_path = path.join("inhibited");
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_path.exists()
&& inhibited_path.exists()
&& let Ok(name) = fs::read_to_string(&name_path)
&& name.to_lowercase().contains("touchpad")
{
found_touchpads.push((name.trim().to_string(), inhibited_path));
}
}
}
if found_touchpads.is_empty() {
error!("No touchpad devices found with 'inhibited' support in /sys/class/input.");
return;
}
match command {
TouchpadCommands::Enable => {
for (name, path) in found_touchpads {
match fs::write(&path, "0") {
Ok(_) => info!("Enabled touchpad: {}", name),
Err(e) => error!("Failed to enable touchpad {}: {}", name, e),
if name_str.starts_with("input") {
let name_file = entry.path().join("name");
if let Ok(content) = fs::read_to_string(name_file) {
let content_lower = content.to_lowercase();
if content_lower.contains("touchpad") || content_lower.contains("trackpad") {
found = true;
let inhibited_file = entry.path().join("inhibited");
if inhibited_file.exists() {
let val = if inhibited { "1" } else { "0" };
match fs::write(&inhibited_file, val) {
Ok(_) => info!("Set inhibit={} for {}", inhibited, name_str),
Err(e) => {
debug!("Failed to write to {}: {}", inhibited_file.display(), e)
}
}
}
}
}
}
}
TouchpadCommands::Disable => {
for (name, path) in found_touchpads {
match fs::write(&path, "1") {
Ok(_) => info!("Disabled touchpad: {}", name),
Err(e) => error!("Failed to disable touchpad {}: {}", name, e),
}
}
if found {
println!("Operation completed successfully.");
} else {
error!("No touchpad device found in /sys/class/input.");
}
} else {
error!("Failed to read directory /sys/class/input.");
}
}
+115
View File
@@ -0,0 +1,115 @@
use crate::cli::GpuCommands;
use crate::commands::gpu;
use zbus::interface;
#[derive(Default)]
pub struct LapctlInterface {}
#[interface(name = "org.lapctl1")]
impl LapctlInterface {
pub async fn switch_gpu_integrated(&self, no_reboot: bool) -> zbus::fdo::Result<()> {
gpu::execute_local(&GpuCommands::Integrated { no_reboot });
Ok(())
}
pub async fn switch_gpu_hybrid(
&self,
rtd3: i32,
use_nvidia_current: bool,
no_reboot: bool,
) -> zbus::fdo::Result<()> {
let rtd3_opt = if rtd3 < 0 { None } else { Some(rtd3 as u8) };
gpu::execute_local(&GpuCommands::Hybrid {
rtd3: rtd3_opt,
use_nvidia_current,
no_reboot,
});
Ok(())
}
pub async fn switch_gpu_nvidia(
&self,
dm: String,
force_comp: bool,
coolbits: i32,
use_nvidia_current: bool,
wayland: bool,
no_reboot: bool,
) -> zbus::fdo::Result<()> {
let dm_opt = if dm.is_empty() { None } else { Some(dm) };
let coolbits_opt = if coolbits < 0 {
None
} else {
Some(coolbits as u32)
};
gpu::execute_local(&GpuCommands::Nvidia {
dm: dm_opt,
force_comp,
coolbits: coolbits_opt,
use_nvidia_current,
wayland,
no_reboot,
});
Ok(())
}
pub async fn set_battery_limit(&self, percent: u32) -> zbus::fdo::Result<()> {
if percent == 0 || percent > 100 {
return Err(zbus::fdo::Error::InvalidArgs(format!(
"Invalid battery limit: {}. Must be between 1 and 100.",
percent
)));
}
crate::commands::battery::execute_local(&crate::cli::BatteryCommands::Limit {
percent: percent as u8,
});
Ok(())
}
pub async fn set_power_profile(&self, profile: String) -> zbus::fdo::Result<()> {
let cmd = match profile.as_str() {
"performance" => crate::cli::PowerCommands::Performance,
"balanced" => crate::cli::PowerCommands::Balanced,
"battery-save" => crate::cli::PowerCommands::BatterySave,
_ => {
return Err(zbus::fdo::Error::InvalidArgs(format!(
"Invalid power profile: {}",
profile
)));
}
};
crate::commands::power::execute_local(&cmd);
Ok(())
}
pub async fn set_tdp_limit(&self, watts: u32) -> zbus::fdo::Result<()> {
crate::commands::power::execute_local(&crate::cli::PowerCommands::LimitTdp { watts });
Ok(())
}
pub async fn set_cooling_profile(&self, profile: String) -> zbus::fdo::Result<()> {
let cmd = match profile.as_str() {
"performance" => crate::cli::CoolingCommands::Performance,
"balanced" => crate::cli::CoolingCommands::Balanced,
"quiet" => crate::cli::CoolingCommands::Quiet,
_ => {
return Err(zbus::fdo::Error::InvalidArgs(format!(
"Invalid cooling profile: {}",
profile
)));
}
};
crate::commands::cooling::execute_local(&cmd);
Ok(())
}
pub async fn set_touchpad_inhibition(&self, inhibited: bool) -> zbus::fdo::Result<()> {
let cmd = if inhibited {
crate::cli::TouchpadCommands::Disable
} else {
crate::cli::TouchpadCommands::Enable
};
crate::commands::touchpad::execute_local(&cmd);
Ok(())
}
}
+19
View File
@@ -0,0 +1,19 @@
use log::info;
use std::error::Error;
use zbus::connection::Builder;
mod dbus_iface;
pub async fn run() -> Result<(), Box<dyn Error>> {
info!("Starting lapctld daemon...");
let _connection = Builder::system()?
.name("org.lapctl")?
.serve_at("/org/lapctl", dbus_iface::LapctlInterface::default())?
.build()
.await?;
info!("Daemon is running on the system bus.");
loop {
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
}
}
+90 -10
View File
@@ -21,30 +21,42 @@ pub fn get_current_mode() -> String {
}
pub fn get_nvidia_gpu_pci_bus() -> String {
let output = Command::new("lspci")
.output()
.expect("Failed to execute lspci");
let output = match Command::new("lspci").output() {
Ok(o) => o,
Err(e) => {
error!("Failed to execute lspci: {}", e);
std::process::exit(1);
}
};
let lspci_output = String::from_utf8_lossy(&output.stdout);
for line in lspci_output.lines() {
if line.contains("NVIDIA")
&& (line.contains("VGA compatible controller") || line.contains("3D controller"))
{
let pci_bus_id_raw = line.split_whitespace().next().unwrap();
let pci_bus_id_raw = match line.split_whitespace().next() {
Some(id) => id,
None => continue,
};
let pci_bus_id = pci_bus_id_raw.replace("0000:", "");
info!("Found Nvidia GPU at {}", pci_bus_id);
let parts: Vec<&str> = pci_bus_id.split(':').collect();
let bus = parts[0];
if parts.len() < 2 {
warn!("Unexpected PCI bus ID format: {}", pci_bus_id);
continue;
}
let device_fun: Vec<&str> = parts[1].split('.').collect();
let device = device_fun[0];
let function = device_fun[1];
if device_fun.len() < 2 {
warn!("Unexpected PCI device.function format: {}", parts[1]);
continue;
}
return format!(
"PCI:{}:{}:{}",
u32::from_str_radix(bus, 16).unwrap_or(0),
u32::from_str_radix(device, 16).unwrap_or(0),
u32::from_str_radix(function, 16).unwrap_or(0)
u32::from_str_radix(parts[0], 16).unwrap_or(0),
u32::from_str_radix(device_fun[0], 16).unwrap_or(0),
u32::from_str_radix(device_fun[1], 16).unwrap_or(0)
);
}
}
@@ -54,6 +66,74 @@ pub fn get_nvidia_gpu_pci_bus() -> String {
std::process::exit(1);
}
pub fn get_nvidia_gpu_pci_addr() -> Option<String> {
let output = Command::new("lspci").output().ok()?;
let lspci_output = String::from_utf8_lossy(&output.stdout);
for line in lspci_output.lines() {
if line.contains("NVIDIA")
&& (line.contains("VGA compatible controller") || line.contains("3D controller"))
{
let pci_addr = line.split_whitespace().next()?;
if pci_addr.contains(':') {
if pci_addr.chars().filter(|&c| c == ':').count() == 1 {
return Some(format!("0000:{}", pci_addr));
}
return Some(pci_addr.to_string());
}
}
}
None
}
pub fn unbind_gpu(pci_addr: &str) -> Result<(), String> {
let driver_path = Path::new("/sys/bus/pci/devices")
.join(pci_addr)
.join("driver");
if driver_path.exists() {
let unbind_path = driver_path.join("unbind");
info!("Unbinding GPU at {} via {:?}", pci_addr, unbind_path);
std::fs::write(unbind_path, pci_addr).map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn remove_gpu(pci_addr: &str) -> Result<(), String> {
let remove_path = Path::new("/sys/bus/pci/devices")
.join(pci_addr)
.join("remove");
if remove_path.exists() {
info!("Removing GPU at {} via {:?}", pci_addr, remove_path);
std::fs::write(remove_path, "1").map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn rescan_pci() -> Result<(), String> {
let rescan_path = Path::new("/sys/bus/pci/rescan");
info!("Rescanning PCI bus via {:?}", rescan_path);
std::fs::write(rescan_path, "1").map_err(|e| e.to_string())?;
Ok(())
}
pub fn kill_gpu_processes() -> Result<(), String> {
info!("Killing processes using NVIDIA GPU...");
// Try using fuser first as it is more likely to be available and easy to use
if let Err(e) = Command::new("fuser").args(["-k", "/dev/nvidia*"]).output() {
log::warn!("fuser failed (may not be installed): {}", e);
}
// Also kill specific nvidia services if they are running
if let Err(e) = Command::new("systemctl")
.args(["stop", "nvidia-persistenced.service"])
.output()
{
log::debug!("Failed to stop nvidia-persistenced: {}", e);
}
Ok(())
}
pub fn get_igpu_vendor() -> Option<String> {
let output = Command::new("lspci").output().ok()?;
let lspci_output = String::from_utf8_lossy(&output.stdout);
+1
View File
@@ -1,4 +1,5 @@
pub mod cli;
pub mod commands;
pub mod daemon;
pub mod hardware;
pub mod utils;
+17 -6
View File
@@ -28,13 +28,24 @@ fn main() {
Commands::Cooling { command } => commands::cooling::execute(command),
Commands::Display { command } => commands::display::execute(command),
Commands::Status => commands::status::execute(),
Commands::Temperature => commands::temperature::execute(),
Commands::Fan => commands::fan::execute(),
Commands::InstallRules => commands::install_rules::execute(),
Commands::Touchpad { command } => commands::touchpad::execute(command),
Commands::Inhibit {
command,
why,
who,
daemon,
} => commands::inhibit::execute(command, why, who, *daemon),
Commands::Daemon => {
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
eprintln!("Failed to create tokio runtime: {}", e);
std::process::exit(1);
}
};
rt.block_on(async {
if let Err(e) = lapctl::daemon::run().await {
eprintln!("Daemon error: {}", e);
std::process::exit(1);
}
});
}
}
}
+131 -28
View File
@@ -9,8 +9,29 @@ pub fn assert_root() {
if let Ok(output) = output {
let is_root = String::from_utf8_lossy(&output.stdout).trim() == "0";
if !is_root {
error!("This operation requires root privileges");
std::process::exit(1);
// Try to re-exec with pkexec
if let Ok(exe) = std::env::current_exe() {
let args: Vec<String> = std::env::args().skip(1).collect();
info!("Not running as root. Attempting to elevate privileges via pkexec...");
let status = Command::new("pkexec").arg(exe).args(&args).status();
match status {
Ok(s) if s.success() => std::process::exit(0),
Ok(s) => {
error!("Privilege escalation failed with exit code: {}", s);
std::process::exit(1);
}
Err(e) => {
error!("Privilege escalation failed: {}", e);
std::process::exit(1);
}
}
} else {
error!(
"This operation requires root privileges and could not determine executable path."
);
std::process::exit(1);
}
}
}
}
@@ -32,8 +53,11 @@ pub fn create_file(path: &str, content: &str, executable: bool) {
if executable && let Ok(mut perms) = fs::metadata(path_obj).map(|m| m.permissions()) {
perms.set_mode(perms.mode() | 0o111);
let _ = fs::set_permissions(path_obj, perms);
info!("Added execution privilege to file {}", path);
if let Err(e) = fs::set_permissions(path_obj, perms) {
error!("Failed to set executable permission on '{}': {}", path, e);
} else {
info!("Added execution privilege to file {}", path);
}
}
}
Err(e) => {
@@ -43,19 +67,74 @@ pub fn create_file(path: &str, content: &str, executable: bool) {
}
pub fn get_display_manager() -> Option<String> {
if let Ok(content) = fs::read_to_string("/etc/systemd/system/display-manager.service") {
let re = regex::Regex::new(r"ExecStart=(.+)\n").unwrap();
if let Some(cap) = re.captures(&content) {
let path = cap.get(1).unwrap().as_str();
let dm = Path::new(path).file_name()?.to_string_lossy().to_string();
info!("Found {} Display Manager", dm);
return Some(dm);
}
let output = Command::new("systemctl")
.args(["show", "-p", "FragmentPath", "display-manager.service"])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(line) = stdout.lines().next()
&& let Some(path_str) = line.strip_prefix("FragmentPath=")
&& !path_str.is_empty()
&& path_str != "/dev/null"
{
let dm = Path::new(path_str)
.file_name()?
.to_string_lossy()
.to_string();
info!("Found {} Display Manager", dm);
return Some(dm);
}
warn!("Display Manager detection is not available");
None
}
pub fn is_service_active(name: &str) -> bool {
let output = Command::new("systemctl").args(["is-active", name]).output();
if let Ok(output) = output {
return String::from_utf8_lossy(&output.stdout).trim() == "active";
}
false
}
pub fn get_active_graphical_sessions() -> Vec<String> {
let mut sessions = Vec::new();
let output = Command::new("loginctl")
.args(["list-sessions", "--no-legend"])
.output();
if let Ok(output) = output {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if let Some(id) = parts.first() {
let show_output = Command::new("loginctl")
.args(["show-session", id, "-p", "Type", "-p", "State"])
.output();
if let Ok(show_output) = show_output {
let show_stdout = String::from_utf8_lossy(&show_output.stdout);
let mut is_graphical = false;
let mut is_active = false;
for show_line in show_stdout.lines() {
if let Some(t) = show_line.strip_prefix("Type=") {
if t == "x11" || t == "wayland" {
is_graphical = true;
}
} else if let Some(s) = show_line.strip_prefix("State=")
&& (s == "active" || s == "online")
{
is_active = true;
}
}
if is_graphical && is_active {
sessions.push(id.to_string());
}
}
}
}
}
sessions
}
pub fn rebuild_initramfs() {
let mut command: Vec<&str> = Vec::new();
@@ -76,20 +155,6 @@ pub fn rebuild_initramfs() {
command = vec!["mkinitcpio", "-P"];
}
if let Ok(which) = Command::new("which").arg("systemd-inhibit").output()
&& which.status.success()
{
let mut new_cmd = vec![
"systemd-inhibit",
"--who=lapctl",
"--why",
"Rebuilding initramfs",
"--",
];
new_cmd.extend(command.iter());
command = new_cmd;
}
if !command.is_empty() {
println!("Rebuilding the initramfs...");
let is_debug = log::log_enabled!(log::Level::Debug);
@@ -106,9 +171,47 @@ pub fn rebuild_initramfs() {
Ok(status) if status.success() => {
println!("Successfully rebuilt the initramfs!");
}
_ => {
error!("An error occurred while rebuilding the initramfs");
Ok(status) => {
error!("initramfs rebuild failed with exit code: {}", status);
}
Err(e) => {
error!("Failed to run initramfs rebuild command: {}", e);
}
}
} else {
log::warn!(
"Unknown distribution: initramfs was not rebuilt. You may need to rebuild it manually before rebooting."
);
}
}
pub fn manage_service(name: &str, action: &str) -> Result<(), String> {
info!("Performing {} on service {}", action, name);
let mut cmd = Command::new("systemctl");
cmd.args([action, name]);
match cmd.status() {
Ok(s) if s.success() => Ok(()),
Ok(s) => Err(format!(
"systemctl {} {} failed with exit code: {}",
action, name, s
)),
Err(e) => Err(format!(
"Failed to run systemctl {} {}: {}",
action, name, e
)),
}
}
pub fn terminate_session(id: &str) -> Result<(), String> {
info!("Terminating session {}", id);
let mut cmd = Command::new("loginctl");
cmd.args(["terminate-session", id]);
match cmd.status() {
Ok(s) if s.success() => Ok(()),
Ok(s) => Err(format!(
"loginctl terminate-session {} failed with exit code: {}",
id, s
)),
Err(e) => Err(format!("Failed to run loginctl: {}", e)),
}
}
-43
View File
@@ -1,43 +0,0 @@
use clap::Parser;
use lapctl::cli::{Cli, Commands};
#[test]
fn test_inhibit_cli_parsing() {
let args = vec!["lapctl", "inhibit", "sleep", "10", "--why", "testing"];
let cli = Cli::parse_from(args);
if let Commands::Inhibit {
command,
why,
who,
daemon,
} = cli.command
{
assert_eq!(command, vec!["sleep".to_string(), "10".to_string()]);
assert_eq!(why, "testing");
assert_eq!(who, "lapctl"); // default value
assert!(!daemon);
} else {
panic!("Command should be Inhibit");
}
}
#[test]
fn test_inhibit_daemon_parsing() {
let args = vec!["lapctl", "inhibit", "--daemon"];
let cli = Cli::parse_from(args);
if let Commands::Inhibit {
command,
why,
who,
daemon,
} = cli.command
{
assert!(command.is_empty());
assert_eq!(why, "lapctl inhibiting sleep"); // default value
assert!(daemon);
} else {
panic!("Command should be Inhibit");
}
}