diff --git a/scripts/audiomenu/.envrc b/scripts/audiomenu/.envrc deleted file mode 100644 index 729d54f..0000000 --- a/scripts/audiomenu/.envrc +++ /dev/null @@ -1,2 +0,0 @@ -source_up -source .venv/bin/activate diff --git a/scripts/audiomenu/.gitignore b/scripts/audiomenu/.gitignore index 1d17dae..ea8c4bf 100644 --- a/scripts/audiomenu/.gitignore +++ b/scripts/audiomenu/.gitignore @@ -1 +1 @@ -.venv +/target diff --git a/scripts/audiomenu/.python-version b/scripts/audiomenu/.python-version deleted file mode 100644 index e4fba21..0000000 --- a/scripts/audiomenu/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 diff --git a/scripts/audiomenu/Cargo.lock b/scripts/audiomenu/Cargo.lock new file mode 100644 index 0000000..c65db92 --- /dev/null +++ b/scripts/audiomenu/Cargo.lock @@ -0,0 +1,553 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys", +] + +[[package]] +name = "audiomenu" +version = "0.1.0" +dependencies = [ + "clap", + "duct", + "miette", + "serde", + "serde_json", +] + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.5.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "duct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6ce170a0e8454fa0f9b0e5ca38a6ba17ed76a50916839d217eb5357e05cdfde" +dependencies = [ + "libc", + "os_pipe", + "shared_child", + "shared_thread", +] + +[[package]] +name = "errno" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +dependencies = [ + "adler2", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "os_pipe" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ffd2b0a5634335b135d5728d84c5e0fd726954b87111f7506a61c502280d982" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "owo-colors" +version = "4.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26995317201fa17f3656c36716aed4a7c81743a9634ac4c99c0eeda495db0cec" + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustix" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "shared_child" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e297bd52991bbe0686c086957bee142f13df85d1e79b0b21630a99d374ae9dc" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "shared_thread" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a6f98357c6bb0ebace19b22220e5543801d9de90ffe77f8abb27c056bac064" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "terminal_size" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" +dependencies = [ + "rustix", + "windows-sys", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.0", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/scripts/audiomenu/Cargo.toml b/scripts/audiomenu/Cargo.toml new file mode 100644 index 0000000..b4e0aed --- /dev/null +++ b/scripts/audiomenu/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "audiomenu" +description = "fuzzel script to select the default audio device for pipewire+wireplumber" +version = "0.1.0" +edition = "2021" + +[dependencies] +clap = { version = "4.5.23", features = ["derive", "env"] } +duct = "1.0.0" +miette = { version = "7.4.0", features = ["fancy"] } +serde = { version = "1.0.215", features = ["derive"] } +serde_json = "1.0.133" + +[profile.release] +lto = true +opt-level = 's' +panic = "abort" +strip = true diff --git a/scripts/audiomenu/README.md b/scripts/audiomenu/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/audiomenu/audiomenu.py b/scripts/audiomenu/audiomenu.py deleted file mode 100644 index dbf158d..0000000 --- a/scripts/audiomenu/audiomenu.py +++ /dev/null @@ -1,236 +0,0 @@ -# pyright: strict, reportAny=false -from dataclasses import dataclass -import json -import subprocess -from typing import Self -import typing -import click - - -def expect[T](typ: type[T], value: object) -> T: - if not isinstance(value, typ): - raise ValueError( - f"expected value to be of type {typ} but was of type {type(value)}" - ) - return value - - -@dataclass(slots=True) -class PWNodeProps: - object_id: int - node_description: str - node_name: str - media_class: str - - @classmethod - def from_json(cls, data: dict[str, object]) -> Self: - return cls( - object_id=expect(int, data["object.id"]), - node_description=expect(str, data.get("node.description", "(unknown)")), - node_name=expect(str, data["node.name"]), - media_class=expect(str, data.get("media.class", "(unknown)")), - ) - - -@dataclass(slots=True) -class PWNodeInfo: - props: PWNodeProps - - @classmethod - def from_json(cls, data: dict[str, object]) -> Self: - props = typing.cast(dict[str, object], expect(dict, data["props"])) - return cls(PWNodeProps.from_json(props)) - - -@dataclass(slots=True) -class PWNode: - node_type: str - info: PWNodeInfo | None - - @classmethod - def from_json(cls, data: dict[str, object]) -> Self: - info = data.get("info", None) - if info is not None: - info = PWNodeInfo.from_json( - typing.cast(dict[str, object], expect(dict, info)) - ) - return cls(node_type=expect(str, data["type"]), info=info) - - -@dataclass(slots=True) -class AudioDevice: - id: int - name: str - volume: float - muted: bool - default: bool - - @staticmethod - def get_volume(id: int | str) -> tuple[float, bool]: - wpctl_output = subprocess.run( - ["wpctl", "get-volume", str(id)], - encoding="UTF-8", - check=True, - capture_output=True, - ) - match wpctl_output.stdout.strip().split(sep=" "): - case ["Volume:", value]: - return (float(value), False) - case ["Volume:", value, "[MUTED]"]: - return (float(value), True) - case _: - raise ValueError(f"Unexpected wpctl output: {wpctl_output.stdout}") - - @classmethod - def from_pw_node(cls, node: PWNode, default: str) -> Self: - if node.info is None: - raise ValueError(f"Node is not a valid audio device {node}") - - id = node.info.props.object_id - volume, muted = cls.get_volume(id) - - return cls( - id=id, - name=node.info.props.node_description, - volume=volume, - muted=muted, - default=node.info.props.node_name == default, - ) - - def menu_item(self) -> str: - id = f"id={self.id:<3}" - - if self.default: - id = f"[{id}]" - else: - id = f" {id} " - - if self.muted: - return f"{id} {self.volume:>4.0%} [MUTED] {self.name}" - else: - return f"{id} {self.volume:>4.0%} {self.name}" - - -def get_nodes(data: list[dict[str, object]]) -> list[PWNode]: - def is_audio_node(node: object) -> bool: - if not isinstance(node, dict): - return False - - node = typing.cast(dict[str, object], node) - if node["type"] != "PipeWire:Interface:Node": - return False - info = node.get("info", None) - if info is None or not isinstance(info, dict): - return False - info = typing.cast(dict[str, object], info) - props = info.get("props", None) - if props is None or not isinstance(props, dict): - return False - props = typing.cast(dict[str, object], props) - if (media_class := props.get("media.class", None)) is not None: - return isinstance(media_class, str) and media_class.startswith("Audio") - return False - - return [ - PWNode.from_json(typing.cast(dict[str, object], expect(dict, node))) - for node in data - if is_audio_node(node) - ] - - -def pw_dump() -> list[dict[str, object]]: - dump_output = subprocess.run( - ["pw-dump"], encoding="UTF-8", check=True, capture_output=True - ) - data = json.loads(dump_output.stdout) - return typing.cast(list[dict[str, object]], expect(list, data)) - - -def get_defaults_metadata(data: list[dict[str, object]]) -> list[dict[str, object]]: - return typing.cast( - list[dict[str, object]], - expect( - list, - next( - node - for node in data - if node["type"] == "PipeWire:Interface:Metadata" - and expect(dict, node["props"])["metadata.name"] == "default" - )["metadata"], - ), - ) - - -def get_sinks() -> list[AudioDevice]: - data = pw_dump() - default = next( - typing.cast(dict[str, str], expect(dict, data["value"]))["name"] - for data in get_defaults_metadata(data) - if data["key"] == "default.audio.sink" - ) - return [ - AudioDevice.from_pw_node(node, default) - for node in get_nodes(data) - if node.info is not None and node.info.props.media_class == "Audio/Sink" - ] - - -def get_sources() -> list[AudioDevice]: - data = pw_dump() - default = next( - typing.cast(dict[str, str], expect(dict, data["value"]))["name"] - for data in get_defaults_metadata(data) - if data["key"] == "default.audio.source" - ) - return [ - AudioDevice.from_pw_node(node, default) - for node in get_nodes(data) - if node.info is not None and node.info.props.media_class == "Audio/Source" - ] - - -@click.group(name="audiomenu") -def main() -> None: - pass - - -def select(options: list[str], prompt:str) -> int | None: - menu_output = subprocess.run( - ["fuzzel", "--dmenu", f"--prompt={prompt}"], - input="\n".join(options), - encoding="UTF-8", - capture_output=True, - ) - if menu_output.returncode == 2: - return None - menu_output.check_returncode() - selected = menu_output.stdout.rstrip() - return options.index(selected) - - -@main.command() -def select_sink() -> None: - devices = get_sinks() - selected = select([device.menu_item() for device in devices], prompt="Select Sink>") - if selected is None: - click.echo("No sink selected") - return - - device = devices[selected] - _ = subprocess.run(["wpctl", "set-default", str(device.id)], check=True) - - -@main.command() -def select_source() -> None: - devices = get_sources() - selected = select([device.menu_item() for device in devices], prompt="Select Source>") - if selected is None: - click.echo("No source selected") - return - - device = devices[selected] - _ = subprocess.run(["wpctl", "set-default", str(device.id)], check=True) - - -if __name__ == "__main__": - main() diff --git a/scripts/audiomenu/package.nix b/scripts/audiomenu/package.nix index a40d0da..ceb495b 100644 --- a/scripts/audiomenu/package.nix +++ b/scripts/audiomenu/package.nix @@ -1,6 +1,22 @@ -{ writers, python3Packages }: -writers.writePython3 "audiomenu" { - libraries = [ python3Packages.click ]; - # line too long, but I like my code well documented - flakeIgnore = [ "E501" ]; -} ./jpassmenu.py +{ + lib, + rustPlatform, + cleanRustSrc, +}: +let + cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); + inherit (cargoToml.package) name version description; + pname = name; + src = cleanRustSrc ./.; +in +rustPlatform.buildRustPackage { + inherit pname version src; + cargoLock.lockFile = ./Cargo.lock; + useNextest = true; + meta = { + inherit description; + license = lib.licenses.mit; + homepage = "https://github.com/jalil-salame/configuration.nix"; + mainProgram = name; + }; +} diff --git a/scripts/audiomenu/pyproject.toml b/scripts/audiomenu/pyproject.toml deleted file mode 100644 index 6d992a3..0000000 --- a/scripts/audiomenu/pyproject.toml +++ /dev/null @@ -1,9 +0,0 @@ -[project] -name = "audiomenu" -version = "0.1.0" -description = "fuzzel script to select the default audio device for pipewire+wireplumber" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "click>=8.1.7", -] diff --git a/scripts/audiomenu/src/main.rs b/scripts/audiomenu/src/main.rs new file mode 100644 index 0000000..a542fd4 --- /dev/null +++ b/scripts/audiomenu/src/main.rs @@ -0,0 +1,231 @@ +use std::{ + fmt::{Display, Write as _}, + io::{Read, Write as _}, + process::{Command, Stdio}, +}; + +use clap::Parser; +use duct::cmd; +use miette::{bail, Context, IntoDiagnostic, Result}; +use serde::Deserialize; + +fn main() -> Result<()> { + miette::set_panic_hook(); + Opts::parse().run() +} + +/// fuzzel script to select the default audio device for pipewire+wireplumber +#[derive(Debug, Parser)] +struct Opts { + #[clap(subcommand)] + cmd: Cmd, +} + +impl Opts { + fn run(self) -> Result<()> { + self.cmd.run() + } +} + +#[derive(Debug, clap::Subcommand)] +enum Cmd { + /// Select audio sink (speakers/headphones) + SelectSink, + /// Select audio source (microphone) + SelectSource, +} + +impl Cmd { + fn run(self) -> Result<()> { + let id = match self { + Cmd::SelectSink => { + let devices = get_sinks().wrap_err("failed to get sinks")?; + let selected = select( + devices.iter().map(|dev| dev.name.as_ref()), + Some("Select input>"), + ) + .wrap_err("failed to select a default sink")?; + if selected.is_empty() { + eprintln!("did not select a device"); + return Ok(()); + } + let Some(dev) = devices.into_iter().find(|dev| dev.name == selected) else { + bail!("couldn't find a device matching `{selected}`"); + }; + dev.id + } + Cmd::SelectSource => { + let devices = get_sources().wrap_err("failed to get sinks")?; + let selected = select( + devices.iter().map(|dev| dev.name.as_ref()), + Some("Select output>"), + ) + .wrap_err("failed to select a default source")?; + if selected.is_empty() { + eprintln!("did not select a device"); + return Ok(()); + } + let Some(dev) = devices.into_iter().find(|dev| dev.name == selected) else { + bail!("couldn't find a device matching `{selected}`"); + }; + dev.id + } + }; + cmd!("wpctl", "set-default", id.to_string()) + .run() + .map(drop) + .into_diagnostic() + .wrap_err("failed to set default input") + } +} + +#[derive(Debug, Deserialize)] +struct PWNode { + #[serde(rename = "type")] + node_type: Box, + #[serde(default)] + info: PWNodeInfo, + // json ignores the rest of the fields by default +} + +#[derive(Debug, Deserialize, Default)] +struct PWNodeInfo { + props: PWNodeProps, + // json ignores the rest of the fields by default +} + +#[derive(Debug, Deserialize, Default)] +struct PWNodeProps { + #[serde(rename = "object.id")] + object_id: u32, + #[serde(rename = "node.description", default)] + node_description: Box, + #[serde(rename = "media.class", default)] + media_class: Box, + // json ignores the rest of the fields by default +} + +struct AudioDevice { + id: u32, + name: Box, + _side: S, +} + +/// Output (e.g. speakers) +struct AudioSink; + +/// Input (e.g. microphone) +struct AudioSource; + +fn get_sinks() -> Result>> { + get_devices() +} + +fn get_sources() -> Result>> { + get_devices() +} + +fn get_devices() -> Result>> +where + AudioDevice: TryFrom, +{ + Ok(get_nodes()? + .into_iter() + .filter_map(|node| AudioDevice::::try_from(node).ok()) + .collect()) +} + +impl TryFrom for AudioDevice { + type Error = miette::Report; + + fn try_from(value: PWNode) -> std::result::Result { + if value.node_type.as_ref() != "PipeWire:Interface:Node" { + bail!( + "invalid type: `{}`, expected `PipeWire:Interface:Node`", + value.node_type + ) + } + let class = value.info.props.media_class; + match class.as_ref() { + "Audio/Source" => Ok(Self { + id: value.info.props.object_id, + name: value.info.props.node_description, + _side: AudioSource, + }), + _ => bail!("invalid media.class: `{class}`, expected `Audio/Source`"), + } + } +} + +impl TryFrom for AudioDevice { + type Error = miette::Report; + + fn try_from(value: PWNode) -> std::result::Result { + if value.node_type.as_ref() != "PipeWire:Interface:Node" { + bail!( + "invalid type: `{}`, expected `PipeWire:Interface:Node`", + value.node_type + ) + } + let class = value.info.props.media_class; + match class.as_ref() { + "Audio/Sink" => Ok(Self { + id: value.info.props.object_id, + name: value.info.props.node_description, + _side: AudioSink, + }), + _ => bail!("invalid media.class: `{class}`, expected `Audio/Sink`"), + } + } +} + +fn get_nodes() -> Result> { + let dump = cmd!("pw-dump") + .read() + .into_diagnostic() + .wrap_err("failed to get devices with pw-dump")?; + serde_json::from_str(&dump) + .into_diagnostic() + .wrap_err("failed to parse pw-dump output") +} + +fn select(options: It, prompt: Option<&str>) -> Result> +where + T: Display, + It: IntoIterator, +{ + let append_line = |mut s: String, it| { + writeln!(s, "{it}").unwrap(); + s + }; + let options = options.into_iter().fold(String::new(), append_line); + let mut menu = Command::new("fuzzel"); + menu.arg("--dmenu"); + if let Some(prompt) = prompt { + menu.arg(format!("--prompt={prompt}")); + } + Ok(pipe_to_stdin_and_return_stdout(&mut menu, options)? + .trim() + .into()) +} + +fn pipe_to_stdin_and_return_stdout(cmd: &mut Command, data: impl Display) -> Result { + let mut child = cmd + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .into_diagnostic() + .wrap_err_with(|| format!("failed to run {cmd:?}"))?; + let mut stdin = child.stdin.take().expect("stdin not piped"); + write!(stdin, "{data}") + .into_diagnostic() + .wrap_err("failed to send data to process' stdin")?; + drop(stdin); + let mut stdout = child.stdout.take().expect("stdout not piped"); + let mut buf = String::new(); + stdout + .read_to_string(&mut buf) + .into_diagnostic() + .wrap_err("failed to retrieve output from process")?; + Ok(buf) +} diff --git a/scripts/audiomenu/uv.lock b/scripts/audiomenu/uv.lock deleted file mode 100644 index 1876983..0000000 --- a/scripts/audiomenu/uv.lock +++ /dev/null @@ -1,34 +0,0 @@ -version = 1 -requires-python = ">=3.12" - -[[package]] -name = "audiomenu" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "click" }, -] - -[package.metadata] -requires-dist = [{ name = "click", specifier = ">=8.1.7" }] - -[[package]] -name = "click" -version = "8.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/0f/62ca20172d4f87d93cf89665fbaedcd560ac48b465bd1d92bfc7ea6b0a41/click-8.2.0.tar.gz", hash = "sha256:f5452aeddd9988eefa20f90f05ab66f17fce1ee2a36907fd30b05bbb5953814d", size = 235857 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/58/1f37bf81e3c689cc74ffa42102fa8915b59085f54a6e4a80bc6265c0f6bf/click-8.2.0-py3-none-any.whl", hash = "sha256:6b303f0b2aa85f1cb4e5303078fadcbcd4e476f114fab9b5007005711839325c", size = 102156 }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, -] diff --git a/scripts/default.nix b/scripts/default.nix index 5cde95c..0152c68 100644 --- a/scripts/default.nix +++ b/scripts/default.nix @@ -1,7 +1,38 @@ +{ lib, ... }: let + # Clean the package source leaving only the relevant rust files + cleanRustSrc = + pname: src: + lib.cleanSourceWith { + inherit src; + name = "${pname}-source"; + # Adapted from + # no need to pull in crane for just this + filter = + orig_path: type: + let + path_str = toString orig_path; + base = baseNameOf path_str; + parentDir = baseNameOf (dirOf path_str); + matchesSuffix = lib.any (suffix: lib.hasSuffix suffix base) [ + # Rust sources + ".rs" + # TOML files are often used to configure cargo based tools (e.g. .cargo/config.toml) + ".toml" + ]; + isCargoLock = base == "Cargo.lock"; + # .cargo/config.toml is captured above + isOldStyleCargoConfig = parentDir == ".cargo" && base == "config"; + in + type == "directory" || matchesSuffix || isCargoLock || isOldStyleCargoConfig; + }; + # callPackage but for my rust Packages + callRustPackage = + pkgs: pname: nixSrc: + pkgs.callPackage nixSrc { cleanRustSrc = cleanRustSrc pname; }; packages = pkgs: { jpassmenu = pkgs.callPackage ./jpassmenu/package.nix { }; - audiomenu = pkgs.callPackage ./audiomenu/package.nix { }; + audiomenu = callRustPackage pkgs "audiomenu" ./audiomenu/package.nix; }; in { diff --git a/scripts/jpassmenu/.envrc b/scripts/jpassmenu/.envrc index 729d54f..217b7db 100644 --- a/scripts/jpassmenu/.envrc +++ b/scripts/jpassmenu/.envrc @@ -1,2 +1,2 @@ source_up -source .venv/bin/activate +layout python3 diff --git a/scripts/jpassmenu/jpassmenu.py b/scripts/jpassmenu/jpassmenu.py index f446412..223c3aa 100644 --- a/scripts/jpassmenu/jpassmenu.py +++ b/scripts/jpassmenu/jpassmenu.py @@ -4,20 +4,6 @@ import subprocess import click -def select(options: list[str]) -> int | None: - menu_output = subprocess.run( - ["fuzzel", "--dmenu"], - input="\n".join(options), - encoding="UTF-8", - capture_output=True, - ) - if menu_output.returncode == 2: - return None - menu_output.check_returncode() - selected = menu_output.stdout.rstrip() - return options.index(selected) - - @click.command( "jpassmenu", context_settings={"show_default": True, "max_content_width": 120} ) @@ -66,11 +52,19 @@ def main( if not secrets: click.secho(f"No valid entries found in {store_dir}", err=True, fg="red") - selected = select(secrets) - if selected is None: + paths = "\n".join(secrets) + + menu_output = subprocess.run( + [menu_bin, *menu_args], + input=paths, + encoding="UTF-8", + check=True, + capture_output=True, + ) + selected = menu_output.stdout + if not selected: click.echo("No secret selected") return - selected = secrets[selected] # If PASSWORD_STORE_DIR and --store-dir disagree, set PASSWORD_STORE_DIR to --store-dir env_store = (