diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a227ae9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + rebase-strategy: "disabled" + diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..76b4ede --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,99 @@ +permissions: + contents: read +on: + push: + branches: [main] + pull_request: +name: Check +jobs: + fmt: + runs-on: ubuntu-latest + name: stable / fmt + steps: + - uses: actions/checkout@v3 + with: + submodules: true + - name: Install stable + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: cargo fmt --check + run: cargo fmt --check + clippy: + runs-on: ubuntu-latest + name: ${{ matrix.toolchain }} / clippy + permissions: + contents: read + checks: write + strategy: + fail-fast: false + matrix: + toolchain: [stable, beta] + steps: + - uses: actions/checkout@v3 + with: + submodules: true + - name: Install ${{ matrix.toolchain }} + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.toolchain }} + components: clippy + - name: cargo clippy + uses: actions-rs/clippy-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + doc: + runs-on: ubuntu-latest + name: nightly / doc + steps: + - uses: actions/checkout@v3 + with: + submodules: true + - name: Install nightly + uses: dtolnay/rust-toolchain@nightly + - name: cargo doc + run: cargo doc --no-deps --all-features + env: + RUSTDOCFLAGS: --cfg docsrs + + hack: + runs-on: ubuntu-latest + name: ubuntu / stable / features + steps: + - uses: actions/checkout@v3 + with: + submodules: true + - name: Install stable + uses: dtolnay/rust-toolchain@stable + - name: cargo install cargo-hack + uses: taiki-e/install-action@cargo-hack + - name: cargo hack + run: cargo hack --feature-powerset check --lib --tests + + dependency-audit: + runs-on: ubuntu-latest + name: Dependency audit + steps: + - uses: actions/checkout@v3 + - uses: rustsec/audit-check@v1.4.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true + + cargo-deny: + name: cargo-deny + runs-on: ubuntu-latest + strategy: + matrix: + checks: + # - advisories + - bans licenses sources + + # Prevent sudden announcement of a new advisory from failing ci: + continue-on-error: ${{ matrix.checks == 'advisories' }} + + steps: + - uses: actions/checkout@v3 + - uses: EmbarkStudios/cargo-deny-action@v1 + with: + command: check ${{ matrix.checks }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..50add33 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,30 @@ +name: Release + +permissions: + pull-requests: write + contents: write + +on: + push: + branches: + - main + +jobs: + release-plz: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_PLZ_TOKEN }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Run release-plz + uses: MarcoIeni/release-plz-action@v0.5 + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/scheduled.yml b/.github/workflows/scheduled.yml new file mode 100644 index 0000000..44f2875 --- /dev/null +++ b/.github/workflows/scheduled.yml @@ -0,0 +1,48 @@ +permissions: + contents: read +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "7 7 * * *" +name: Rolling + +jobs: + nightly: + runs-on: ubuntu-latest + name: ubuntu / nightly + steps: + - uses: actions/checkout@v3 + with: + submodules: true + - name: Install nightly + uses: dtolnay/rust-toolchain@nightly + - name: cargo generate-lockfile + if: hashFiles('Cargo.lock') == '' + run: cargo generate-lockfile + - name: cargo test --locked + run: cargo test --locked --all-features --all-targets + + update: + runs-on: ubuntu-latest + name: ubuntu / beta / updated + # There's no point running this if no Cargo.lock was checked in in the + # first place, since we'd just redo what happened in the regular test job. + # Unfortunately, hashFiles only works in if on steps, so we reepeat it. + # if: hashFiles('Cargo.lock') != '' + steps: + - uses: actions/checkout@v3 + with: + submodules: true + - name: Install beta + if: hashFiles('Cargo.lock') != '' + uses: dtolnay/rust-toolchain@beta + - name: cargo update + if: hashFiles('Cargo.lock') != '' + run: cargo update + - name: cargo test + if: hashFiles('Cargo.lock') != '' + run: cargo test --locked --all-features --all-targets + env: + RUSTFLAGS: -D deprecated diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..45624d5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,151 @@ +permissions: + contents: read +on: + push: + branches: [main] + pull_request: +name: Test +jobs: + required: + runs-on: ubuntu-latest + name: ubuntu / ${{ matrix.toolchain }} + strategy: + matrix: + toolchain: [stable, beta] + steps: + - uses: actions/checkout@v3 + with: + submodules: true + - name: Install ${{ matrix.toolchain }} + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.toolchain }} + - name: cargo generate-lockfile + if: hashFiles('Cargo.lock') == '' + run: cargo generate-lockfile + - name: Restore cached target/ + id: target-cache-restore + uses: actions/cache/restore@v3 + with: + path: | + target + /home/runner/.cargo + key: ${{ matrix.toolchain }}-target + - name: cargo test --locked + run: cargo test --locked --all-features --all-targets + - name: Save cached target/ + id: target-cache-save + uses: actions/cache/save@v3 + with: + path: | + target + /home/runner/.cargo + key: ${{ steps.target-cache-restore.outputs.cache-primary-key }} + + proptest: + runs-on: ubuntu-latest + name: ubuntu / stable + needs: required + steps: + - uses: actions/checkout@v3 + with: + submodules: true + - name: Install stable + uses: dtolnay/rust-toolchain@stable + - name: Restore cached target/ + uses: actions/cache/restore@v3 + with: + path: | + target + /home/runner/.cargo + key: stable-target + - name: cargo test --test proptest --locked -- --ignored + run: cargo test --locked --test proptest -- --ignored + + # minimal: + # runs-on: ubuntu-latest + # name: ubuntu / stable / minimal-versions + # steps: + # - uses: actions/checkout@v3 + # with: + # submodules: true + # - name: Install stable + # uses: dtolnay/rust-toolchain@stable + # - name: Install nightly for -Zminimal-versions + # uses: dtolnay/rust-toolchain@nightly + # - name: rustup default stable + # run: rustup default stable + # - name: cargo update -Zminimal-versions + # run: cargo +nightly update -Zminimal-versions + # - name: cargo test + # run: cargo test --locked --all-features --all-targets + # + os-check: + runs-on: ${{ matrix.os }} + name: ${{ matrix.os }} / stable + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + steps: + - run: echo "VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT" | Out-File -FilePath $env:GITHUB_ENV -Append + if: runner.os == 'Windows' + - run: vcpkg install openssl:x64-windows-static-md + if: runner.os == 'Windows' + - uses: actions/checkout@v3 + with: + submodules: true + - name: Install stable + uses: dtolnay/rust-toolchain@stable + - name: cargo generate-lockfile + if: hashFiles('Cargo.lock') == '' + run: cargo generate-lockfile + - name: cargo test + run: cargo test --locked --all-features --all-targets + + coverage: + runs-on: ubuntu-latest + name: ubuntu / stable / coverage + steps: + - uses: actions/checkout@v3 + with: + submodules: true + - name: Install stable + uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + - name: cargo install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + - name: cargo generate-lockfile + if: hashFiles('Cargo.lock') == '' + run: cargo generate-lockfile + - name: Restore cached target/ + id: target-cache-restore + uses: actions/cache/restore@v3 + with: + path: | + target + /home/runner/.cargo + key: coverage-target + - name: cargo llvm-cov clean + run: cargo llvm-cov clean --workspace + - name: cargo llvm-cov + run: cargo llvm-cov --locked --all-features --no-report --release + - name: cargo llvm-cov proptest + run: cargo llvm-cov --locked --all-features --no-report --release --test proptest -- --ignored + - name: Save cached target/ + id: target-cache-save + uses: actions/cache/save@v3 + with: + path: | + target + /home/runner/.cargo + key: ${{ steps.target-cache-restore.outputs.cache-primary-key }} + - name: cargo llvm-cov report + run: cargo llvm-cov report --release --lcov --output-path lcov.info + - name: Upload to codecov.io + uses: codecov/codecov-action@v3 + with: + fail_ci_if_error: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 955d6bf..4857a76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,19 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -196,6 +209,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_more" +version = "0.99.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "gimli" version = "0.29.0" @@ -216,10 +240,11 @@ checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "hyprfocus" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "clap", + "hyprland", "regex", "serde", "serde_json", @@ -229,6 +254,35 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "hyprland" +version = "0.4.0-alpha.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d627cd06fb3389f2554b7a4bb21db8c0bfca8863e6e653702cc4c6dbf20d8276" +dependencies = [ + "ahash", + "derive_more", + "hyprland-macros", + "num-traits", + "once_cell", + "paste", + "regex", + "serde", + "serde_json", + "serde_repr", + "tokio", +] + +[[package]] +name = "hyprland-macros" +version = "0.4.0-alpha.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd8ce4c182ce77e485918f49262425ee51a2746fe97f14084869aeff2fbc38e" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -321,6 +375,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.36.2" @@ -365,6 +428,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pin-project-lite" version = "0.2.14" @@ -498,6 +567,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -743,6 +823,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -843,3 +929,23 @@ name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 2697a64..58fcb97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,11 @@ [package] name = "hyprfocus" -version = "0.1.0" +version = "1.0.0" edition = "2021" +license = "MIT OR Apache-2.0" +description = "Open or focus your apps instantly" +repository = "https://github.com/liamwh/HyprFocus" +authors = ["Liam Woodleigh-Hardinge", "liam.woodleigh@gmail.com"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -15,6 +19,7 @@ tokio = { version = "1", features = ["full"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json"] } tracing-appender = "0.2.3" +hyprland = "0.4.0-alpha.2" [lints.rust] diff --git a/README.md b/README.md new file mode 100644 index 0000000..83d2de7 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# HyprFocus + +HyprFocus is a CLI utility for opening an application or focusing the most recent client (window) of that open application. This is useful for opening applications that are not in the foreground, or for focusing the most recently opened window of an application. + +## Usage + +To use the CLI directly, run the following: + +```sh +hyprfocus --client "Notion" --launcher "notion-app" +``` + +To bind a key to execute HyprFocus, modify your Hyprland configuration file similar to the following example: + +```conf +bind = CONTROL, B, exec, hyprfocus --client "Brave-browser" --launcher "brave" +``` + +## Installation + +Run the following command to install HyprFocus: + +```sh +cargo install hyprfocus +``` diff --git a/justfile b/justfile index 998670d..e14ef0b 100644 --- a/justfile +++ b/justfile @@ -9,10 +9,10 @@ default: @just --list --justfile {{justfile()}} test-notion: - cargo run -- --client "Notion" --launcher "notion-app" + RUST_LOG="debug" cargo run -- --client "Notion" --launcher "notion-app" test-brave: - cargo run -- --client "Brave-browser" --launcher "brave" + RUST_LOG="debug" cargo run -- --client "Brave-browser" --launcher "brave" # run various auditing tools to assure we are legal and safe audit: diff --git a/src/focus.rs b/src/focus.rs index 133cf60..bc1a755 100644 --- a/src/focus.rs +++ b/src/focus.rs @@ -1,40 +1,47 @@ -use anyhow::{Context, Result}; +use anyhow::Result; -use serde_json::Value; +use hyprland::{ + data::Clients, + dispatch::{Dispatch, DispatchType, WindowIdentifier}, + shared::{Address, HyprData}, +}; use tracing::instrument; #[instrument(ret, err)] -pub fn focus_window(window_to_focus: &Value, active_address: &str) -> Result<()> { - let window_address = window_to_focus["address"] - .as_str() - .context("Window address not found")?; - if window_address != active_address { - focus_window_by_address(window_address)?; +pub fn focus_window(window_address_to_focus: &T, active_address: &T) -> Result<()> +where + T: ToString + std::fmt::Debug + PartialEq + std::fmt::Display, +{ + if window_address_to_focus != active_address { + focus_window_by_address(Address::new(window_address_to_focus))?; } Ok(()) } #[instrument(level = "trace", ret, err)] -pub fn focus_window_by_address(window_address: &str) -> anyhow::Result<()> { - crate::execute_hyprctl(&["dispatch", &format!("focuswindow address:{window_address}")]) +pub fn focus_window_by_address(window_address: T) -> Result<()> +where + T: ToString + std::fmt::Debug, +{ + let window_address = Address::new(window_address); + Ok(Dispatch::call(DispatchType::FocusWindow( + WindowIdentifier::Address(window_address), + ))?) } #[instrument(level = "trace", ret, err)] -pub fn focus_most_recent_window(windows: &[Value], active_address: &str) -> Result<()> { - tracing::info!(?windows); - if let Some(most_recent_window_different_to_active_window) = windows +pub fn focus_most_recent_window(active_address: Address) -> Result<()> { + let clients = Clients::get()?; + if let Some(most_recent_window_different_to_active_window) = clients .iter() - .filter(|window| window["address"].as_str() != Some(active_address)) - .min_by_key(|window| { - window["focusHistoryID"] - .as_i64() - .expect("expected focusHistoryID to be an i64") - }) + .filter(|client| client.address != active_address) + .min_by_key(|client| client.focus_history_id) { - let window_address = most_recent_window_different_to_active_window["address"] - .as_str() - .expect("expected to be able to convert the most recent window address to a string"); - focus_window_by_address(window_address)?; + focus_window_by_address( + most_recent_window_different_to_active_window + .address + .clone(), + )?; } Ok(()) } diff --git a/src/main.rs b/src/main.rs index 44cdcf2..1256752 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,5 @@ -use anyhow::{Context, Result}; +use anyhow::Result; -use serde_json::Value; -use std::process::Command; use tracing::instrument; mod app_cmds; @@ -10,7 +8,11 @@ mod io; mod observability; use focus::{focus_most_recent_window, focus_window}; -use io::{execute_hyprctl, launch_application}; +use hyprland::{ + data::{Client, Clients}, + shared::{HyprData, HyprDataActiveOptional}, +}; +use io::launch_application; #[tokio::main] async fn main() -> Result<()> { @@ -28,23 +30,15 @@ async fn main() -> Result<()> { Ok(()) } -#[instrument(ret, err)] -fn get_hyprland_clients_json() -> Result { - let clients_json = Command::new("hyprctl") - .args(["clients", "-j"]) - .output() - .context("Failed to get client list from hyprctl")? - .stdout; - - serde_json::from_slice(&clients_json).context("Failed to parse clients JSON") -} - #[instrument(ret, err)] fn focus_or_launch_app(client_name: &str, launcher_command: &str) -> Result<()> { - let clients: Value = get_hyprland_clients_json()?; - let open_windows_for_this_client: Vec = filter_clients_by_name(&clients, client_name); + let clients: Clients = Clients::get()?; + let active_windows_for_client_name = clients + .iter() + .filter(|client| client.class == client_name) + .collect::>(); - if open_windows_for_this_client.is_empty() { + if active_windows_for_client_name.is_empty() { tracing::debug!("No windows open for client '{client_name}'"); if launcher_command.is_empty() { tracing::warn!("No launcher command provided"); @@ -53,44 +47,21 @@ fn focus_or_launch_app(client_name: &str, launcher_command: &str) -> Result<()> return launch_application(launcher_command); } - focus_application(&open_windows_for_this_client) + focus_application(active_windows_for_client_name) } #[instrument(ret, err)] -fn focus_application(open_windows_for_this_client: &[Value]) -> anyhow::Result<()> { - let active_window = get_active_window_json()?; - let active_address = active_window["address"] - .as_str() - .expect("expected to be able to determine the active window address"); +fn focus_application(open_windows_for_this_client: Vec<&Client>) -> anyhow::Result<()> { + let active_client = + Client::get_active()?.expect("expected to be able to get the active client"); match open_windows_for_this_client.len() { - 1 => focus_window(&open_windows_for_this_client[0], active_address)?, - _ => focus_most_recent_window(open_windows_for_this_client, active_address)?, + 1 => focus_window( + &open_windows_for_this_client[0].address, + &active_client.address, + )?, + _ => focus_most_recent_window(active_client.address)?, } Ok(()) } - -#[instrument(level = "debug", ret, err)] -fn get_active_window_json() -> Result { - let active_window_address = Command::new("hyprctl") - .args(["activewindow", "-j"]) - .output() - .context("Failed to get active window from hyprctl")? - .stdout; - let active_window: Value = serde_json::from_slice(&active_window_address) - .context("Failed to parse active window JSON")?; - Ok(active_window) -} - -#[instrument(level = "trace", ret)] -fn filter_clients_by_name(clients: &Value, client_name: &str) -> Vec { - let open_windows_for_this_client: Vec = clients - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter(|client| client["class"] == client_name) - .cloned() - .collect(); - open_windows_for_this_client -}